diff --git a/scripts/process-batch-rsvp.ts b/scripts/process-batch-rsvp.ts new file mode 100644 index 00000000..a4b4f254 --- /dev/null +++ b/scripts/process-batch-rsvp.ts @@ -0,0 +1,401 @@ +/** + * Batch RSVP processor script + * + * Processes pending batch_rsvp_jobs by: + * 1. Loading the Luma embed page via Playwright + * 2. Solving the Cloudflare Turnstile challenge + * 3. Extracting the token + * 4. Calling submitLumaRegistration with the token + * 5. Updating job status in Supabase + * + * Usage: + * npx tsx scripts/process-batch-rsvp.ts [options] + * + * Options: + * --limit Max jobs to process (default: 50) + * --dry-run Process but don't submit or update status + * + * Env vars: + * NEXT_PUBLIC_SUPABASE_URL Supabase project URL + * SUPABASE_SERVICE_ROLE_KEY Supabase service role key + */ + +import { createClient, SupabaseClient } from '@supabase/supabase-js'; + +// --------------------------------------------------------------------------- +// CLI arg parsing +// --------------------------------------------------------------------------- + +interface CliArgs { + limit: number; + dryRun: boolean; +} + +function parseArgs(): CliArgs { + const args = process.argv.slice(2); + const result: CliArgs = { + limit: 50, + dryRun: false, + }; + + for (let i = 0; i < args.length; i++) { + switch (args[i]) { + case '--limit': + result.limit = Math.max(1, parseInt(args[++i] || '50', 10)); + break; + case '--dry-run': + result.dryRun = true; + break; + default: + console.warn(`Unknown argument: ${args[i]}`); + } + } + + return result; +} + +// --------------------------------------------------------------------------- +// Supabase setup +// --------------------------------------------------------------------------- + +function createSupabaseClient(): SupabaseClient { + const url = process.env.NEXT_PUBLIC_SUPABASE_URL; + const key = process.env.SUPABASE_SERVICE_ROLE_KEY; + + if (!url || !key) { + console.error('Missing NEXT_PUBLIC_SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY'); + process.exit(1); + } + + return createClient(url, key); +} + +// --------------------------------------------------------------------------- +// Rate limiting +// --------------------------------------------------------------------------- + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +// --------------------------------------------------------------------------- +// Form-fill submission via Playwright +// --------------------------------------------------------------------------- + +interface SubmissionResult { + success: boolean; + error?: string; +} + +/** + * Submit a Luma registration by filling the embed form via Playwright. + * + * Luma uses invisible Cloudflare Turnstile which injects a token on form submit. + * Instead of trying to extract the token, we let Luma's own JS handle it by + * filling and submitting the form natively, then intercepting the API response. + */ +async function submitViaPlaywright( + lumaSlug: string, + profile: Record, + customAnswers: Record, +): Promise { + const { chromium } = await import('@playwright/test'); + + // Use system Chrome in headed mode — Turnstile blocks headless browsers. + // NOTE: Turnstile may still block Playwright-driven Chrome. If this doesn't + // work, consider using a CAPTCHA-solving service or a persistent browser + // session where the user solves Turnstile once. + const browser = await chromium.launch({ + headless: false, + channel: 'chrome', + args: ['--disable-blink-features=AutomationControlled'], + }); + const page = await browser.newPage(); + + try { + const embedUrl = `https://lu.ma/embed/event/${lumaSlug}/simple`; + await page.goto(embedUrl, { waitUntil: 'networkidle', timeout: 30000 }); + + // Click "Register" / "Request to Join" / "RSVP" button to open the form + const registerBtn = page.locator( + 'button:has-text("Register"), button:has-text("Request to Join"), button:has-text("RSVP")' + ); + const btnCount = await registerBtn.count(); + if (btnCount === 0) { + return { success: false, error: 'No register button found on embed page' }; + } + console.log(` Found ${btnCount} register button(s), clicking first...`); + await registerBtn.first().click(); + await page.waitForTimeout(2000); + + // Fill standard fields + const nameInput = page.locator('input[placeholder="Your Name"], input[name="name"]').first(); + if (await nameInput.count() > 0) { + const nameVal = `${profile.firstName || ''} ${profile.lastName || ''}`.trim(); + await nameInput.fill(nameVal); + console.log(` Filled name: ${nameVal}`); + } + + const emailInput = page.locator('input[placeholder*="email"], input[type="email"], input[name="email"]').first(); + if (await emailInput.count() > 0) { + await emailInput.fill(profile.email || ''); + console.log(` Filled email: ${profile.email}`); + } + + // Fill custom fields by scanning the form DOM in order. + // Luma renders labels as div/span elements followed by the input/select. + // We walk through each label-like element, match it to a custom answer by + // question position, and fill the corresponding field. + // + // customAnswers is { questionId: value } ordered by form position. + const answerEntries = Object.entries(customAnswers); + + // Luma renders labels with the class names we've seen. + // The form fields appear in order: each label div is followed by an input/select. + // Strategy: find all visible select + input fields (except name/email) and fill sequentially. + + // 1. Fill all native onSelect(event.id)} + className="mt-0.5 accent-orange-500" + /> +
+

+ {event.name} +

+

+ {event.date} {event.startTime && `- ${event.startTime}`} +

+

+ {event.organizer} +

+
+ + ))} + + ); +} + +// --------------------------------------------------------------------------- +// Step 2: Profile Info +// --------------------------------------------------------------------------- + +function ProfileStep({ + data, + onChange, + onBlur, + scanning, +}: { + data: BatchProfileData; + onChange: (field: keyof BatchProfileData, value: string) => void; + onBlur: (field: keyof BatchProfileData) => void; + scanning: boolean; +}) { + const fields: { key: keyof BatchProfileData; label: string; icon: React.ReactNode; required?: boolean; type?: string }[] = [ + { key: 'email', label: 'Email', icon: , required: true, type: 'email' }, + { key: 'firstName', label: 'First Name', icon: , required: true }, + { key: 'lastName', label: 'Last Name', icon: , required: true }, + { key: 'company', label: 'Company', icon: }, + { key: 'jobTitle', label: 'Job Title', icon: }, + { key: 'telegram', label: 'Telegram', icon: }, + { key: 'xHandle', label: 'X Handle', icon: X }, + { key: 'linkedin', label: 'LinkedIn', icon: }, + { key: 'phone', label: 'Phone', icon: , type: 'tel' }, + { key: 'website', label: 'Website', icon: , type: 'url' }, + ]; + + return ( +
+ {scanning && ( +
+ + Scanning event registration forms... +
+ )} + +

+ Saved for future events +

+ + {fields.map(({ key, label, icon, required, type }) => ( +
+ {icon} + onChange(key, e.target.value)} + onBlur={() => onBlur(key)} + placeholder={`${label}${required ? ' *' : ''}`} + className="flex-1 bg-[var(--theme-bg-tertiary)] border border-[var(--theme-border-primary)] rounded-lg px-3 py-2 text-sm text-[var(--theme-text-primary)] placeholder:text-[var(--theme-text-muted)] focus:outline-none focus:border-orange-500/50" + /> +
+ ))} +
+ ); +} + +// --------------------------------------------------------------------------- +// Step 3: Custom Fields +// --------------------------------------------------------------------------- + +function CustomFieldsStep({ + selectedEventIds, + scannedEvents, + customAnswers, + onSetAnswer, +}: { + selectedEventIds: Set; + scannedEvents: Map; + customAnswers: Map>; + onSetAnswer: (eventId: string, questionId: string, answer: string) => void; +}) { + const [collapsed, setCollapsed] = useState>(new Set()); + + const toggleCollapse = useCallback((eventId: string) => { + setCollapsed((prev) => { + const next = new Set(prev); + if (next.has(eventId)) next.delete(eventId); + else next.add(eventId); + return next; + }); + }, []); + + const eventsWithQuestions = Array.from(selectedEventIds) + .map((id) => scannedEvents.get(id)) + .filter((s): s is NonNullable => !!s?.formResult?.questions?.length); + + if (eventsWithQuestions.length === 0) { + return ( +
+ +

+ No custom fields needed! +

+
+ ); + } + + return ( +
+ {eventsWithQuestions.map((scanned) => { + const isCollapsed = collapsed.has(scanned.event.id); + const eventAnswers = customAnswers.get(scanned.event.id); + + return ( +
+ + + {!isCollapsed && ( +
+ {scanned.formResult!.questions.map((q) => ( + onSetAnswer(scanned.event.id, q.id, val)} + /> + ))} +
+ )} +
+ ); + })} +
+ ); +} + +function CustomFieldInput({ + question, + value, + onChange, +}: { + question: { id: string; label: string; question_type: string; is_required: boolean; options?: string[]; terms_content?: string }; + value: string; + onChange: (val: string) => void; +}) { + const label = `${question.label}${question.is_required ? ' *' : ''}`; + + if (question.question_type === 'dropdown' && question.options) { + return ( +
+ + +
+ ); + } + + if (question.question_type === 'multi-select' && question.options) { + const selected = new Set(value ? value.split(',') : []); + return ( +
+ +
+ {question.options.map((opt) => ( + + ))} +
+
+ ); + } + + if (question.question_type === 'terms') { + return ( + + ); + } + + // Default: text input + return ( +
+ + onChange(e.target.value)} + placeholder={question.label} + className="w-full bg-[var(--theme-bg-tertiary)] border border-[var(--theme-border-primary)] rounded-lg px-3 py-2 text-sm text-[var(--theme-text-primary)] placeholder:text-[var(--theme-text-muted)] focus:outline-none focus:border-orange-500/50" + /> +
+ ); +} + +// --------------------------------------------------------------------------- +// Step 4: Review & Submit +// --------------------------------------------------------------------------- + +function ReviewStep({ + selectedEventIds, + scannedEvents, + profileData, + submitting, +}: { + selectedEventIds: Set; + scannedEvents: Map; + profileData: BatchProfileData; + submitting: boolean; +}) { + return ( +
+ {/* Profile summary */} +
+

Submitting as:

+

+ {profileData.firstName} {profileData.lastName} +

+

{profileData.email}

+ {profileData.company && ( +

{profileData.company}

+ )} +
+ + {/* Event list */} +
+

+ {selectedEventIds.size} event(s): +

+ {Array.from(selectedEventIds).map((eventId) => { + const scanned = scannedEvents.get(eventId); + if (!scanned) return null; + return ( +
+ {submitting ? ( + + ) : ( + + )} + + {scanned.event.name} + +
+ ); + })} +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Step 5: Results +// --------------------------------------------------------------------------- + +function ResultsStep({ jobStatuses }: { jobStatuses: { id: number; eventId: string; eventName: string; status: string; errorMessage?: string }[] }) { + const statusIcon = (status: string) => { + switch (status) { + case 'pending': + return ; + case 'submitting': + return ; + case 'success': + return ; + case 'failed': + return ; + default: + return ; + } + }; + + const successCount = jobStatuses.filter((j) => j.status === 'success').length; + const failedCount = jobStatuses.filter((j) => j.status === 'failed').length; + const pendingCount = jobStatuses.filter((j) => j.status === 'pending' || j.status === 'submitting').length; + + return ( +
+ {/* Summary */} +
+ {pendingCount > 0 ? ( + <> + + + Processing... {successCount} done, {pendingCount} remaining + + + ) : failedCount > 0 ? ( + <> + + + {successCount} succeeded, {failedCount} failed + + + ) : ( + <> + + + All {successCount} RSVPs submitted! + + + )} +
+ + {/* Job list */} +
+ {jobStatuses.map((job) => ( +
+ {statusIcon(job.status)} +
+

+ {job.eventName} +

+ {job.errorMessage && ( +

{job.errorMessage}

+ )} +
+
+ ))} +
+ + {pendingCount > 0 && ( +

+ Jobs are processed by a background worker. You can close this and check back later. +

+ )} +
+ ); +} diff --git a/src/components/EventApp.tsx b/src/components/EventApp.tsx index c14b2e1d..9d3e274a 100644 --- a/src/components/EventApp.tsx +++ b/src/components/EventApp.tsx @@ -46,6 +46,8 @@ import { useAuth } from '@/contexts/AuthContext'; import { useRsvp } from '@/hooks/useRsvp'; import { useProfile } from '@/hooks/useProfile'; import { RsvpOverlay } from './RsvpOverlay'; +import { BatchRsvpModal } from './BatchRsvpModal'; +import { isLumaUrl } from '@/lib/luma'; export function EventApp({ initialConference, initialEvents }: { initialConference?: string; initialEvents?: ETHDenverEvent[] }) { const { config } = useAdminConfig(); @@ -168,9 +170,20 @@ export function EventApp({ initialConference, initialEvents }: { initialConferen clearResult: clearCheckInResult, } = useEventCheckIn(); - const { getRsvpStatus, openRsvp, confirmRsvp, closeRsvp, activeRsvp } = useRsvp(); + const { getRsvpStatus, openRsvp, confirmRsvp, closeRsvp, activeRsvp, confirmedIds } = useRsvp(); const { profile } = useProfile(); + // Batch RSVP: count eligible Luma events in itinerary that aren't RSVP'd yet + const batchRsvpEligibleCount = useMemo(() => { + let count = 0; + for (const e of events) { + if (itinerary.has(e.id) && isLumaUrl(e.link) && !confirmedIds.has(e.id)) { + count++; + } + } + return count; + }, [events, itinerary, confirmedIds]); + const liveEventIds = useMemo(() => { const now = getConferenceNow(filters.conference); const nowMinutes = now.getHours() * 60 + now.getMinutes(); @@ -359,6 +372,7 @@ export function EventApp({ initialConference, initialEvents }: { initialConferen const [showFriends, setShowFriends] = useState(false); const [showSubmitEvent, setShowSubmitEvent] = useState(false); const [showSignIn, setShowSignIn] = useState(false); + const [showBatchRsvp, setShowBatchRsvp] = useState(false); // Friend code (?fc=) URL handler const { toast: friendCodeToast } = useFriendCode({ @@ -648,6 +662,20 @@ export function EventApp({ initialConference, initialEvents }: { initialConferen /> )} + {/* Batch RSVP FAB: visible when logged in with eligible Luma events */} + {authUser && batchRsvpEligibleCount > 0 && ( + + )} + )} + setShowBatchRsvp(false)} + events={events} + itinerary={itinerary} + confirmedIds={confirmedIds} + /> {friendCodeToast && (
void; } @@ -24,6 +24,43 @@ export function RsvpButton({ eventLink, status, onClick }: RsvpButtonProps) { ); } + if (status === 'pending') { + return ( +
+ +
+ ); + } + + if (status === 'submitting') { + return ( +
+ +
+ ); + } + + if (status === 'failed') { + return ( + + ); + } + return (