From 9a10ee9d28836e6a47b326b31c4de25fd10c3dc7 Mon Sep 17 00:00:00 2001 From: snackman Date: Mon, 4 May 2026 19:30:02 -0400 Subject: [PATCH 1/4] feat: batch RSVP wizard + Luma form scanner (sausage-24472) Add a multi-step batch RSVP wizard that lets users RSVP to multiple Luma events in their itinerary at once. Includes: - Luma form field scanner (API + CLI script) - 4-step wizard modal: select events, profile info, custom fields, review - Batch job API with status polling - Supabase migrations for form cache, custom answers, and job tracking - Extended profile with phone, website, first_name, last_name fields - RsvpButton now supports pending/submitting/failed states - Playwright-based batch processor script for Turnstile token extraction Co-Authored-By: Claude Opus 4.6 --- scripts/process-batch-rsvp.ts | 302 +++++++ scripts/scan-luma-forms.ts | 262 ++++++ src/app/api/batch-rsvp/route.ts | 162 ++++ src/app/api/luma/scan-form/route.ts | 38 + src/components/BatchRsvpModal.tsx | 745 ++++++++++++++++++ src/components/EventApp.tsx | 37 +- src/components/RsvpButton.tsx | 41 +- src/hooks/useBatchRsvp.ts | 358 +++++++++ src/hooks/useProfile.ts | 10 +- src/hooks/useRsvp.ts | 2 +- src/lib/api-validation.ts | 28 + src/lib/luma-form-scanner.ts | 128 +++ src/lib/luma-registration.ts | 92 +++ src/lib/types.ts | 54 ++ .../20260504_batch_rsvp_profile.sql | 5 + .../migrations/20260504_batch_rsvp_tables.sql | 88 +++ 16 files changed, 2345 insertions(+), 7 deletions(-) create mode 100644 scripts/process-batch-rsvp.ts create mode 100644 scripts/scan-luma-forms.ts create mode 100644 src/app/api/batch-rsvp/route.ts create mode 100644 src/app/api/luma/scan-form/route.ts create mode 100644 src/components/BatchRsvpModal.tsx create mode 100644 src/hooks/useBatchRsvp.ts create mode 100644 src/lib/luma-form-scanner.ts create mode 100644 src/lib/luma-registration.ts create mode 100644 supabase/migrations/20260504_batch_rsvp_profile.sql create mode 100644 supabase/migrations/20260504_batch_rsvp_tables.sql diff --git a/scripts/process-batch-rsvp.ts b/scripts/process-batch-rsvp.ts new file mode 100644 index 00000000..3a251a30 --- /dev/null +++ b/scripts/process-batch-rsvp.ts @@ -0,0 +1,302 @@ +/** + * 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'; +import { submitLumaRegistration } from '../src/lib/luma-registration'; + +// --------------------------------------------------------------------------- +// 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)); +} + +// --------------------------------------------------------------------------- +// Turnstile token extraction via Playwright +// --------------------------------------------------------------------------- + +async function extractTurnstileToken(lumaSlug: string): Promise { + // Dynamic import so Playwright is only required when actually running + const { chromium } = await import('@playwright/test'); + + const browser = await chromium.launch({ headless: true }); + const page = await browser.newPage(); + + try { + const embedUrl = `https://lu.ma/embed/event/${lumaSlug}/simple`; + await page.goto(embedUrl, { waitUntil: 'networkidle', timeout: 30000 }); + + // Wait for the Turnstile iframe to appear and be solved + // The token is stored in a hidden input or as a response from the Turnstile widget + await page.waitForTimeout(5000); // Allow time for Turnstile to solve + + // Try to find the Turnstile response token + const token = await page.evaluate(() => { + // Turnstile typically stores its response in a specific element + const turnstileInput = document.querySelector( + 'input[name="cf-turnstile-response"]' + ); + if (turnstileInput?.value) return turnstileInput.value; + + // Try the global turnstile object + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const w = window as any; + if (w.turnstile) { + const widgets = w.turnstile.getResponse?.(); + if (widgets) return widgets; + } + + return null; + }); + + if (!token) { + throw new Error('Could not extract Turnstile token'); + } + + return token; + } finally { + await browser.close(); + } +} + +// --------------------------------------------------------------------------- +// Process a single job +// --------------------------------------------------------------------------- + +async function processJob( + supabase: SupabaseClient, + job: { + id: number; + luma_slug: string; + event_name: string | null; + event_api_id: string; + profile_snapshot: Record; + custom_answers: Record; + }, + dryRun: boolean, +): Promise<'success' | 'failed'> { + const profile = job.profile_snapshot; + + console.log(` Extracting Turnstile token for ${job.luma_slug}...`); + + if (dryRun) { + console.log(' [DRY RUN] Would submit registration'); + return 'success'; + } + + try { + // Mark as submitting + await supabase + .from('batch_rsvp_jobs') + .update({ status: 'submitting', updated_at: new Date().toISOString() }) + .eq('id', job.id); + + // Extract Turnstile token + const turnstileToken = await extractTurnstileToken(job.luma_slug); + + // Build registration answers from custom_answers + const registrationAnswers = Object.entries(job.custom_answers).map( + ([questionId, answer]) => ({ question_id: questionId, answer }) + ); + + // Submit registration + const result = await submitLumaRegistration({ + name: `${profile.firstName} ${profile.lastName}`, + first_name: profile.firstName, + last_name: profile.lastName, + email: profile.email, + event_api_id: job.event_api_id, + registration_answers: registrationAnswers, + phone_number: profile.phone || undefined, + turnstile_token: turnstileToken, + }); + + if (result.success) { + await supabase + .from('batch_rsvp_jobs') + .update({ + status: 'success', + updated_at: new Date().toISOString(), + }) + .eq('id', job.id); + + // Also create an RSVP record so the UI shows confirmed status + await supabase + .from('rsvps') + .upsert({ + user_id: (await supabase.from('batch_rsvp_jobs').select('user_id').eq('id', job.id).single()).data?.user_id, + event_id: (await supabase.from('batch_rsvp_jobs').select('event_id').eq('id', job.id).single()).data?.event_id, + status: 'confirmed', + method: 'batch', + }, { onConflict: 'user_id,event_id' }); + + return 'success'; + } else { + await supabase + .from('batch_rsvp_jobs') + .update({ + status: 'failed', + error_message: result.error || 'Unknown error', + updated_at: new Date().toISOString(), + }) + .eq('id', job.id); + + return 'failed'; + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + await supabase + .from('batch_rsvp_jobs') + .update({ + status: 'failed', + error_message: message, + updated_at: new Date().toISOString(), + }) + .eq('id', job.id); + + return 'failed'; + } +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +async function main() { + const args = parseArgs(); + + console.log('=== Batch RSVP Processor ==='); + console.log(` Limit: ${args.limit}`); + console.log(` Dry run: ${args.dryRun}`); + console.log(''); + + const supabase = createSupabaseClient(); + + // Fetch pending jobs + const { data: jobs, error } = await supabase + .from('batch_rsvp_jobs') + .select('id, luma_slug, event_name, event_api_id, profile_snapshot, custom_answers') + .eq('status', 'pending') + .order('created_at', { ascending: true }) + .limit(args.limit); + + if (error) { + console.error('Failed to fetch pending jobs:', error.message); + process.exit(1); + } + + if (!jobs || jobs.length === 0) { + console.log('No pending jobs to process.'); + return; + } + + console.log(`Found ${jobs.length} pending job(s)`); + console.log(''); + + let totalSuccess = 0; + let totalFailed = 0; + + for (let i = 0; i < jobs.length; i++) { + const job = jobs[i]; + const progress = `[${i + 1}/${jobs.length}]`; + + console.log(`${progress} ${job.event_name || job.luma_slug}`); + + const status = await processJob(supabase, job, args.dryRun); + + if (status === 'success') { + totalSuccess++; + console.log(' Result: SUCCESS'); + } else { + totalFailed++; + console.log(' Result: FAILED'); + } + + // 2s delay between submissions + if (i < jobs.length - 1) { + await delay(2000); + } + } + + console.log(''); + console.log('=== Summary ==='); + console.log(` Processed: ${jobs.length}`); + console.log(` Success: ${totalSuccess}`); + console.log(` Failed: ${totalFailed}`); + if (args.dryRun) { + console.log(' (Dry run -- no submissions made)'); + } +} + +main().catch((err) => { + console.error('Fatal error:', err); + process.exit(1); +}); diff --git a/scripts/scan-luma-forms.ts b/scripts/scan-luma-forms.ts new file mode 100644 index 00000000..6929671f --- /dev/null +++ b/scripts/scan-luma-forms.ts @@ -0,0 +1,262 @@ +/** + * Luma form field scanning script + * + * Fetches events from Google Sheets, filters to Luma URLs, + * scans each event's registration form fields via the Luma API, + * and upserts the results into the luma_form_fields table. + * + * Usage: + * npx tsx scripts/scan-luma-forms.ts [options] + * + * Options: + * --conference Only scan events from the named conference + * --force Re-scan slugs already in the cache + * --dry-run Scan but don't write to Supabase + * + * Env vars: + * NEXT_PUBLIC_SUPABASE_URL Supabase project URL + * SUPABASE_SERVICE_ROLE_KEY Supabase service role key + */ + +import { createClient, SupabaseClient } from '@supabase/supabase-js'; +import { FALLBACK_TABS } from '../src/lib/conferences'; +import { getActiveConferences, conferenceToTab } from '../src/lib/conferences'; +import { fetchEvents } from '../src/lib/fetch-events'; +import type { TabConfig } from '../src/lib/conferences'; +import type { ConferenceConfig, ETHDenverEvent } from '../src/lib/types'; +import { isLumaUrl, getLumaSlug } from '../src/lib/luma'; +import { scanLumaFormFields } from '../src/lib/luma-form-scanner'; + +// --------------------------------------------------------------------------- +// CLI arg parsing +// --------------------------------------------------------------------------- + +interface CliArgs { + conference: string | null; + force: boolean; + dryRun: boolean; +} + +function parseArgs(): CliArgs { + const args = process.argv.slice(2); + const result: CliArgs = { + conference: null, + force: false, + dryRun: false, + }; + + for (let i = 0; i < args.length; i++) { + switch (args[i]) { + case '--conference': + result.conference = args[++i] || null; + break; + case '--force': + result.force = true; + 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); +} + +// --------------------------------------------------------------------------- +// Fetch conference tabs +// --------------------------------------------------------------------------- + +async function fetchConferenceTabs(supabase: SupabaseClient): Promise { + try { + const { data, error } = await supabase + .from('admin_config') + .select('value') + .eq('key', 'conferences') + .single(); + + if (error || !data?.value) { + console.log('No conferences config in Supabase, using FALLBACK_TABS'); + return FALLBACK_TABS; + } + + const allConfs = data.value as ConferenceConfig[]; + const active = getActiveConferences(allConfs); + const tabs = active.map(conferenceToTab); + return tabs.length > 0 ? tabs : FALLBACK_TABS; + } catch (err) { + console.log('Failed to fetch from Supabase:', err); + return FALLBACK_TABS; + } +} + +// --------------------------------------------------------------------------- +// Rate limiting +// --------------------------------------------------------------------------- + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +async function main() { + const args = parseArgs(); + + console.log('=== Luma Form Scanner ==='); + console.log(` Conference filter: ${args.conference || 'all'}`); + console.log(` Force re-scan: ${args.force}`); + console.log(` Dry run: ${args.dryRun}`); + console.log(''); + + const supabase = createSupabaseClient(); + + // Fetch tabs + const allTabs = await fetchConferenceTabs(supabase); + let tabs = allTabs; + + if (args.conference) { + tabs = allTabs.filter( + (t) => + t.name.toLowerCase().includes(args.conference!.toLowerCase()) || + t.slug.toLowerCase() === args.conference!.toLowerCase() + ); + if (tabs.length === 0) { + console.error(`No conference matching "${args.conference}". Available: ${allTabs.map((t) => t.name).join(', ')}`); + process.exit(1); + } + } + + console.log(`Using ${tabs.length} conference tab(s): ${tabs.map((t) => t.name).join(', ')}`); + + // Fetch events + console.log('Fetching events from Google Sheets...'); + const allEvents = await fetchEvents(undefined, tabs); + console.log(` Total events: ${allEvents.length}`); + + // Filter to Luma events with valid slugs + const lumaEvents: { event: ETHDenverEvent; slug: string }[] = []; + for (const event of allEvents) { + if (!event.link || !isLumaUrl(event.link)) continue; + const slug = getLumaSlug(event.link); + if (slug) lumaEvents.push({ event, slug }); + } + console.log(` Luma events: ${lumaEvents.length}`); + + // Deduplicate by slug + const uniqueSlugs = new Map(); + for (const { event, slug } of lumaEvents) { + if (!uniqueSlugs.has(slug)) uniqueSlugs.set(slug, event); + } + console.log(` Unique slugs: ${uniqueSlugs.size}`); + + // Check existing scans (skip unless --force) + let slugsToScan = Array.from(uniqueSlugs.keys()); + + if (!args.force) { + const { data: existing } = await supabase + .from('luma_form_fields') + .select('luma_slug') + .in('luma_slug', slugsToScan); + + const scannedSlugs = new Set((existing || []).map((r: { luma_slug: string }) => r.luma_slug)); + slugsToScan = slugsToScan.filter((s) => !scannedSlugs.has(s)); + console.log(` Already scanned: ${scannedSlugs.size}`); + } + + console.log(` To scan: ${slugsToScan.length}`); + console.log(''); + + if (slugsToScan.length === 0) { + console.log('Nothing to scan. Use --force to re-scan.'); + return; + } + + let totalScanned = 0; + let totalWithQuestions = 0; + let totalErrors = 0; + + for (let i = 0; i < slugsToScan.length; i++) { + const slug = slugsToScan[i]; + const event = uniqueSlugs.get(slug)!; + const progress = `[${i + 1}/${slugsToScan.length}]`; + + console.log(`${progress} ${event.name}`); + console.log(` Slug: ${slug}`); + + try { + const result = await scanLumaFormFields(slug); + totalScanned++; + + if (result.questions.length > 0) { + totalWithQuestions++; + console.log(` Found ${result.questions.length} registration question(s):`); + for (const q of result.questions) { + console.log(` - ${q.label} (${q.question_type}${q.is_required ? ', required' : ''})`); + } + } else { + console.log(' No custom registration questions'); + } + + // Upsert to Supabase + if (!args.dryRun) { + const { error } = await supabase + .from('luma_form_fields') + .upsert({ + luma_slug: slug, + event_api_id: result.eventApiId, + event_name: result.eventName, + name_requirement: result.nameRequirement, + questions: result.questions, + scanned_at: new Date().toISOString(), + }, { onConflict: 'luma_slug' }); + + if (error) { + console.error(` Supabase upsert error: ${error.message}`); + } + } + } catch (err) { + totalErrors++; + const message = err instanceof Error ? err.message : String(err); + console.log(` Error: ${message}`); + } + + // Rate limit: 500ms between requests + if (i < slugsToScan.length - 1) { + await delay(500); + } + } + + console.log(''); + console.log('=== Summary ==='); + console.log(` Total scanned: ${totalScanned}`); + console.log(` With questions: ${totalWithQuestions}`); + console.log(` Errors: ${totalErrors}`); + if (args.dryRun) { + console.log(' (Dry run -- no data written to Supabase)'); + } +} + +main().catch((err) => { + console.error('Fatal error:', err); + process.exit(1); +}); diff --git a/src/app/api/batch-rsvp/route.ts b/src/app/api/batch-rsvp/route.ts new file mode 100644 index 00000000..a80e1c90 --- /dev/null +++ b/src/app/api/batch-rsvp/route.ts @@ -0,0 +1,162 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { createClient } from '@supabase/supabase-js'; +import { parseBody, BatchRsvpSubmitSchema } from '@/lib/api-validation'; + +function getSupabaseAdmin() { + const url = process.env.NEXT_PUBLIC_SUPABASE_URL; + const key = process.env.SUPABASE_SERVICE_ROLE_KEY; + if (!url || !key) { + throw new Error('Missing Supabase env vars'); + } + return createClient(url, key); +} + +/** + * POST /api/batch-rsvp — Create batch RSVP jobs from wizard data. + * + * The jobs are stored with status='pending' and must be processed + * by the CLI script (scripts/process-batch-rsvp.ts) which uses + * Playwright to solve the Turnstile captcha. + */ +export async function POST(request: NextRequest) { + try { + const { data, error } = await parseBody(request, BatchRsvpSubmitSchema); + if (error) return error; + + // Get the user from the Authorization header (Supabase anon key auth) + const authHeader = request.headers.get('authorization'); + if (!authHeader?.startsWith('Bearer ')) { + return NextResponse.json( + { error: 'Authentication required' }, + { status: 401 } + ); + } + + const supabaseAdmin = getSupabaseAdmin(); + + // Verify the JWT to get the user ID + const token = authHeader.slice(7); + const { data: userData, error: authError } = await supabaseAdmin.auth.getUser(token); + if (authError || !userData?.user) { + return NextResponse.json( + { error: 'Invalid or expired token' }, + { status: 401 } + ); + } + + const userId = userData.user.id; + + // Build the profile snapshot + const profileSnapshot: Record = { + email: data.profile.email, + firstName: data.profile.firstName, + lastName: data.profile.lastName, + }; + if (data.profile.company) profileSnapshot.company = data.profile.company; + if (data.profile.jobTitle) profileSnapshot.jobTitle = data.profile.jobTitle; + if (data.profile.phone) profileSnapshot.phone = data.profile.phone; + if (data.profile.telegram) profileSnapshot.telegram = data.profile.telegram; + if (data.profile.xHandle) profileSnapshot.xHandle = data.profile.xHandle; + if (data.profile.linkedin) profileSnapshot.linkedin = data.profile.linkedin; + if (data.profile.website) profileSnapshot.website = data.profile.website; + + // Insert batch jobs + const rows = data.events.map((event) => ({ + user_id: userId, + event_id: event.eventId, + luma_slug: event.lumaSlug, + event_name: event.eventName, + event_api_id: event.eventApiId, + status: 'pending', + profile_snapshot: profileSnapshot, + custom_answers: event.customAnswers || {}, + })); + + const { data: insertedJobs, error: insertError } = await supabaseAdmin + .from('batch_rsvp_jobs') + .insert(rows) + .select('id, event_id, event_name, status'); + + if (insertError) { + console.error('Batch RSVP insert error:', insertError); + return NextResponse.json( + { error: 'Failed to create batch RSVP jobs' }, + { status: 500 } + ); + } + + return NextResponse.json({ + jobs: insertedJobs, + message: `Created ${insertedJobs.length} RSVP job(s). They will be processed shortly.`, + }); + } catch (err) { + console.error('Batch RSVP error:', err); + return NextResponse.json( + { error: 'Failed to create batch RSVP jobs' }, + { status: 500 } + ); + } +} + +/** + * GET /api/batch-rsvp?ids=1,2,3 — Poll job statuses. + */ +export async function GET(request: NextRequest) { + try { + const idsParam = request.nextUrl.searchParams.get('ids'); + if (!idsParam) { + return NextResponse.json( + { error: 'Missing ids parameter' }, + { status: 400 } + ); + } + + const ids = idsParam.split(',').map(Number).filter(Boolean); + if (ids.length === 0 || ids.length > 50) { + return NextResponse.json( + { error: 'Invalid ids (1-50 required)' }, + { status: 400 } + ); + } + + const authHeader = request.headers.get('authorization'); + if (!authHeader?.startsWith('Bearer ')) { + return NextResponse.json( + { error: 'Authentication required' }, + { status: 401 } + ); + } + + const supabaseAdmin = getSupabaseAdmin(); + + const token = authHeader.slice(7); + const { data: userData, error: authError } = await supabaseAdmin.auth.getUser(token); + if (authError || !userData?.user) { + return NextResponse.json( + { error: 'Invalid or expired token' }, + { status: 401 } + ); + } + + const { data: jobs, error: queryError } = await supabaseAdmin + .from('batch_rsvp_jobs') + .select('id, event_id, event_name, status, error_message, updated_at') + .eq('user_id', userData.user.id) + .in('id', ids); + + if (queryError) { + return NextResponse.json( + { error: 'Failed to fetch job statuses' }, + { status: 500 } + ); + } + + return NextResponse.json({ jobs }); + } catch (err) { + console.error('Batch RSVP GET error:', err); + return NextResponse.json( + { error: 'Failed to fetch job statuses' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/luma/scan-form/route.ts b/src/app/api/luma/scan-form/route.ts new file mode 100644 index 00000000..435a5e9b --- /dev/null +++ b/src/app/api/luma/scan-form/route.ts @@ -0,0 +1,38 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { parseBody, ScanFormFieldsSchema } from '@/lib/api-validation'; +import { scanLumaFormFields, type ScannedFormResult } from '@/lib/luma-form-scanner'; + +interface ScanResult { + slug: string; + result?: ScannedFormResult; + error?: string; +} + +export async function POST(request: NextRequest) { + try { + const { data, error } = await parseBody(request, ScanFormFieldsSchema); + if (error) return error; + + const { slugs } = data; + + const results: ScanResult[] = await Promise.all( + slugs.map(async (slug): Promise => { + try { + const result = await scanLumaFormFields(slug); + return { slug, result }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { slug, error: message }; + } + }) + ); + + return NextResponse.json({ results }); + } catch (err) { + console.error('Luma scan-form error:', err); + return NextResponse.json( + { error: 'Failed to scan form fields' }, + { status: 500 } + ); + } +} diff --git a/src/components/BatchRsvpModal.tsx b/src/components/BatchRsvpModal.tsx new file mode 100644 index 00000000..78a67022 --- /dev/null +++ b/src/components/BatchRsvpModal.tsx @@ -0,0 +1,745 @@ +'use client'; + +import { useEffect, useCallback, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { + X, CheckCircle, Clock, Loader2, AlertTriangle, + ChevronDown, ChevronRight, Mail, User, Building2, + Briefcase, Phone, Globe, Send, Linkedin, +} from 'lucide-react'; +import { useBatchRsvp, type BatchProfileData } from '@/hooks/useBatchRsvp'; +import { useProfile } from '@/hooks/useProfile'; +import { isLumaUrl } from '@/lib/luma'; +import type { ETHDenverEvent } from '@/lib/types'; + +// --------------------------------------------------------------------------- +// Props +// --------------------------------------------------------------------------- + +interface BatchRsvpModalProps { + isOpen: boolean; + onClose: () => void; + events: ETHDenverEvent[]; + itinerary: Set; + confirmedIds: Set; +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +export function BatchRsvpModal({ + isOpen, + onClose, + events, + itinerary, + confirmedIds, +}: BatchRsvpModalProps) { + const { + step, + selectedEventIds, + scannedEvents, + scanning, + profileData, + customAnswers, + jobStatuses, + selectEvent, + selectAll, + deselectAll, + setProfileField, + setAnswer, + scanSelectedEvents, + nextStep, + prevStep, + submit, + reset, + stopPolling, + } = useBatchRsvp(); + + const { profile, updateProfile } = useProfile(); + + // Luma events in itinerary that aren't already RSVP'd + const eligibleEvents = events.filter( + (e) => itinerary.has(e.id) && isLumaUrl(e.link) && !confirmedIds.has(e.id) + ); + + // Pre-fill profile data from existing profile + useEffect(() => { + if (profile && isOpen) { + setProfileField('email', profile.email || ''); + setProfileField('firstName', profile.first_name || ''); + setProfileField('lastName', profile.last_name || ''); + setProfileField('company', profile.company || ''); + setProfileField('jobTitle', profile.job_title || ''); + setProfileField('phone', profile.phone || ''); + setProfileField('telegram', profile.telegram_handle || ''); + setProfileField('xHandle', profile.x_handle || ''); + setProfileField('linkedin', profile.linkedin_url || ''); + setProfileField('website', profile.website || ''); + } + }, [profile, isOpen, setProfileField]); + + // Close on Escape key + useEffect(() => { + if (!isOpen) return; + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') handleClose(); + }; + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isOpen]); + + // Lock body scroll + useEffect(() => { + if (!isOpen) return; + document.body.style.overflow = 'hidden'; + return () => { + document.body.style.overflow = ''; + }; + }, [isOpen]); + + const handleClose = useCallback(() => { + stopPolling(); + reset(); + onClose(); + }, [stopPolling, reset, onClose]); + + // Trigger scan when moving from select -> profile + const handleNextFromSelect = useCallback(async () => { + await scanSelectedEvents(eligibleEvents); + nextStep(); + }, [scanSelectedEvents, eligibleEvents, nextStep]); + + // Save profile field on blur + const handleProfileBlur = useCallback( + (field: keyof BatchProfileData) => { + const fieldMap: Partial> = { + firstName: 'first_name', + lastName: 'last_name', + company: 'company', + jobTitle: 'job_title', + phone: 'phone', + telegram: 'telegram_handle', + xHandle: 'x_handle', + linkedin: 'linkedin_url', + website: 'website', + }; + + const dbField = fieldMap[field]; + if (dbField && profileData[field]) { + updateProfile({ [dbField]: profileData[field] } as Record); + } + }, + [profileData, updateProfile] + ); + + if (!isOpen) return null; + + const stepTitles: Record = { + select: 'Select Events', + profile: 'Your Info', + custom: 'Just a few more details', + review: 'Review & Submit', + submitting: 'Submitting...', + results: 'Results', + }; + + return createPortal( +
+
+ +
e.stopPropagation()} + > + {/* Header */} +
+
+

+ Batch RSVP +

+

+ {stepTitles[step] || ''} +

+
+ +
+ + {/* Step content */} +
+ {step === 'select' && ( + selectAll(eligibleEvents.map((e) => e.id))} + onDeselectAll={deselectAll} + /> + )} + {step === 'profile' && ( + + )} + {step === 'custom' && ( + + )} + {(step === 'review' || step === 'submitting') && ( + + )} + {step === 'results' && ( + + )} +
+ + {/* Footer buttons */} +
+ {step === 'select' && ( + <> + + + + )} + {(step === 'profile' || step === 'custom') && ( + <> + + + + )} + {step === 'review' && ( + <> + + + + )} + {step === 'submitting' && ( +
+ + Creating RSVP jobs... +
+ )} + {step === 'results' && ( + + )} +
+
+
, + document.body + ); +} + +// --------------------------------------------------------------------------- +// Step 1: Select Events +// --------------------------------------------------------------------------- + +function SelectStep({ + events, + selectedIds, + onSelect, + onSelectAll, + onDeselectAll, +}: { + events: ETHDenverEvent[]; + selectedIds: Set; + onSelect: (id: string) => void; + onSelectAll: () => void; + onDeselectAll: () => void; +}) { + if (events.length === 0) { + return ( +
+

+ No Luma events in your itinerary that need RSVPs. +

+

+ Add some Luma events to your itinerary first. +

+
+ ); + } + + return ( +
+
+ + {selectedIds.size} of {events.length} selected + +
+ + | + +
+
+ + {events.map((event) => ( + + ))} +
+ ); +} + +// --------------------------------------------------------------------------- +// 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..57d5b01c 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 (