From 13456985d70642eb13d9d0dd34cf7f215b300ed9 Mon Sep 17 00:00:00 2001 From: reehals Date: Mon, 2 Feb 2026 14:56:56 -0800 Subject: [PATCH 01/14] add single member to rsvp list --- .../_actions/tito/createRsvpInvitation.ts | 141 +++++++ app/(api)/_actions/tito/createRsvpList.ts | 80 ++++ app/(api)/_actions/tito/getReleases.ts | 68 ++++ app/(api)/_actions/tito/getRsvpLists.ts | 69 ++++ .../TitoRsvpManager.module.scss | 181 +++++++++ .../TitoRsvpManager/TitoRsvpManager.tsx | 365 ++++++++++++++++++ app/(pages)/admin/page.tsx | 4 + app/(pages)/admin/tito-rsvp/page.module.scss | 4 + app/(pages)/admin/tito-rsvp/page.tsx | 11 + 9 files changed, 923 insertions(+) create mode 100644 app/(api)/_actions/tito/createRsvpInvitation.ts create mode 100644 app/(api)/_actions/tito/createRsvpList.ts create mode 100644 app/(api)/_actions/tito/getReleases.ts create mode 100644 app/(api)/_actions/tito/getRsvpLists.ts create mode 100644 app/(pages)/admin/_components/TitoRsvpManager/TitoRsvpManager.module.scss create mode 100644 app/(pages)/admin/_components/TitoRsvpManager/TitoRsvpManager.tsx create mode 100644 app/(pages)/admin/tito-rsvp/page.module.scss create mode 100644 app/(pages)/admin/tito-rsvp/page.tsx diff --git a/app/(api)/_actions/tito/createRsvpInvitation.ts b/app/(api)/_actions/tito/createRsvpInvitation.ts new file mode 100644 index 00000000..6562deff --- /dev/null +++ b/app/(api)/_actions/tito/createRsvpInvitation.ts @@ -0,0 +1,141 @@ +'use server'; + +const TITO_API_TOKEN = process.env.TITO_API_TOKEN; +const TITO_ACCOUNT_SLUG = process.env.TITO_ACCOUNT_SLUG; +const TITO_EVENT_SLUG = process.env.TITO_EVENT_SLUG; + +interface InvitationData { + firstName: string; + lastName: string; + email: string; + rsvpListSlug: string; + releaseIds: string; + discountCode?: string; +} + +interface ReleaseInvitation { + id: string; + slug: string; + email: string; + first_name: string; + last_name: string; + created_at: string; +} + +interface Response { + ok: boolean; + body: ReleaseInvitation | null; + error: string | null; +} + +export default async function createRsvpInvitation( + data: InvitationData +): Promise { + try { + if (!TITO_API_TOKEN || !TITO_ACCOUNT_SLUG || !TITO_EVENT_SLUG) { + const error = 'Missing Tito API configuration in environment variables'; + console.error('[Tito API] createRsvpInvitation:', error); + throw new Error(error); + } + + if (!data.email || data.email.trim() === '') { + const error = 'Email is required'; + console.error('[Tito API] createRsvpInvitation:', error); + throw new Error(error); + } + + if (!data.rsvpListSlug) { + const error = 'RSVP list slug is required'; + console.error('[Tito API] createRsvpInvitation:', error); + throw new Error(error); + } + + if (!data.releaseIds || data.releaseIds.trim() === '') { + const error = 'Release IDs are required'; + console.error('[Tito API] createRsvpInvitation:', error); + throw new Error(error); + } + + // Parse release IDs from comma-separated string to array of numbers + const releaseIdsArray = data.releaseIds + .split(',') + .map((id) => parseInt(id.trim(), 10)) + .filter((id) => !isNaN(id)); + + if (releaseIdsArray.length === 0) { + const error = 'Invalid release IDs format. Use comma-separated numbers.'; + console.error('[Tito API] createRsvpInvitation:', error); + throw new Error(error); + } + + const url = `https://api.tito.io/v3/${TITO_ACCOUNT_SLUG}/${TITO_EVENT_SLUG}/rsvp_lists/${data.rsvpListSlug}/release_invitations`; + + // Build the request body according to the API documentation + const requestBody: { + email: string; + release_ids: number[]; + first_name?: string; + last_name?: string; + discount_code?: string; + } = { + email: data.email.trim(), + release_ids: releaseIdsArray, + }; + + if (data.firstName && data.firstName.trim()) { + requestBody.first_name = data.firstName.trim(); + } + + if (data.lastName && data.lastName.trim()) { + requestBody.last_name = data.lastName.trim(); + } + + if (data.discountCode && data.discountCode.trim()) { + requestBody.discount_code = data.discountCode.trim(); + } + + console.log('[Tito API] Creating release invitation for:', data.email); + console.log('[Tito API] Request URL:', url); + console.log('[Tito API] Request body:', requestBody); + + const response = await fetch(url, { + method: 'POST', + headers: { + Authorization: `Token token=${TITO_API_TOKEN}`, + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + release_invitation: requestBody, + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + const errorMsg = `Tito API error: ${response.status} - ${errorText}`; + console.error('[Tito API] createRsvpInvitation failed:', errorMsg); + console.error('[Tito API] Request URL:', url); + console.error('[Tito API] Request body:', requestBody); + throw new Error(errorMsg); + } + + const responseData = await response.json(); + const invitation = responseData.release_invitation; + + console.log('[Tito API] Successfully created invitation:', invitation); + + return { + ok: true, + body: invitation, + error: null, + }; + } catch (e) { + const error = e as Error; + console.error('[Tito API] createRsvpInvitation exception:', error); + return { + ok: false, + body: null, + error: error.message, + }; + } +} diff --git a/app/(api)/_actions/tito/createRsvpList.ts b/app/(api)/_actions/tito/createRsvpList.ts new file mode 100644 index 00000000..15c87e79 --- /dev/null +++ b/app/(api)/_actions/tito/createRsvpList.ts @@ -0,0 +1,80 @@ +'use server'; + +const TITO_API_TOKEN = process.env.TITO_API_TOKEN; +const TITO_ACCOUNT_SLUG = process.env.TITO_ACCOUNT_SLUG; +const TITO_EVENT_SLUG = process.env.TITO_EVENT_SLUG; + +interface RsvpList { + id: string; + slug: string; + title: string; +} + +interface Response { + ok: boolean; + body: RsvpList | null; + error: string | null; +} + +export default async function createRsvpList(title: string): Promise { + try { + if (!TITO_API_TOKEN || !TITO_ACCOUNT_SLUG || !TITO_EVENT_SLUG) { + const error = 'Missing Tito API configuration in environment variables'; + console.error('[Tito API] createRsvpList:', error); + throw new Error(error); + } + + if (!title || title.trim() === '') { + const error = 'RSVP list title is required'; + console.error('[Tito API] createRsvpList:', error); + throw new Error(error); + } + + const url = `https://api.tito.io/v3/${TITO_ACCOUNT_SLUG}/${TITO_EVENT_SLUG}/rsvp_lists`; + + console.log('[Tito API] Creating RSVP list:', title); + console.log('[Tito API] Request URL:', url); + + const response = await fetch(url, { + method: 'POST', + headers: { + Authorization: `Token token=${TITO_API_TOKEN}`, + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + rsvp_list: { + title: title.trim(), + }, + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + const errorMsg = `Tito API error: ${response.status} - ${errorText}`; + console.error('[Tito API] createRsvpList failed:', errorMsg); + console.error('[Tito API] Request URL:', url); + console.error('[Tito API] Request body:', { title: title.trim() }); + throw new Error(errorMsg); + } + + const data = await response.json(); + const rsvpList = data.rsvp_list; + + console.log('[Tito API] Successfully created RSVP list:', rsvpList); + + return { + ok: true, + body: rsvpList, + error: null, + }; + } catch (e) { + const error = e as Error; + console.error('[Tito API] createRsvpList exception:', error); + return { + ok: false, + body: null, + error: error.message, + }; + } +} diff --git a/app/(api)/_actions/tito/getReleases.ts b/app/(api)/_actions/tito/getReleases.ts new file mode 100644 index 00000000..b0aa765c --- /dev/null +++ b/app/(api)/_actions/tito/getReleases.ts @@ -0,0 +1,68 @@ +'use server'; + +const TITO_API_TOKEN = process.env.TITO_API_TOKEN; +const TITO_ACCOUNT_SLUG = process.env.TITO_ACCOUNT_SLUG; +const TITO_EVENT_SLUG = process.env.TITO_EVENT_SLUG; + +interface Release { + id: string; + slug: string; + title: string; + quantity?: number; +} + +interface Response { + ok: boolean; + body: Release[] | null; + error: string | null; +} + +export default async function getReleases(): Promise { + try { + if (!TITO_API_TOKEN || !TITO_ACCOUNT_SLUG || !TITO_EVENT_SLUG) { + const error = 'Missing Tito API configuration in environment variables'; + console.error('[Tito API] getReleases:', error); + throw new Error(error); + } + + const url = `https://api.tito.io/v3/${TITO_ACCOUNT_SLUG}/${TITO_EVENT_SLUG}/releases`; + + console.log('[Tito API] Fetching releases from:', url); + + const response = await fetch(url, { + method: 'GET', + headers: { + Authorization: `Token token=${TITO_API_TOKEN}`, + Accept: 'application/json', + }, + }); + + if (!response.ok) { + const errorText = await response.text(); + const errorMsg = `Tito API error: ${response.status} - ${errorText}`; + console.error('[Tito API] getReleases failed:', errorMsg); + console.error('[Tito API] Request URL:', url); + throw new Error(errorMsg); + } + + const data = await response.json(); + const releases = data.releases || []; + + console.log('[Tito API] Successfully fetched', releases.length, 'releases'); + console.log('[Tito API] Full releases response:', JSON.stringify(data, null, 2)); + + return { + ok: true, + body: releases, + error: null, + }; + } catch (e) { + const error = e as Error; + console.error('[Tito API] getReleases exception:', error); + return { + ok: false, + body: null, + error: error.message, + }; + } +} diff --git a/app/(api)/_actions/tito/getRsvpLists.ts b/app/(api)/_actions/tito/getRsvpLists.ts new file mode 100644 index 00000000..5db2b0aa --- /dev/null +++ b/app/(api)/_actions/tito/getRsvpLists.ts @@ -0,0 +1,69 @@ +'use server'; + +const TITO_API_TOKEN = process.env.TITO_API_TOKEN; +const TITO_ACCOUNT_SLUG = process.env.TITO_ACCOUNT_SLUG; +const TITO_EVENT_SLUG = process.env.TITO_EVENT_SLUG; + +interface RsvpList { + id: string; + slug: string; + title: string; + release_ids?: number[]; + question_ids?: number[]; + activity_ids?: number[]; +} + +interface Response { + ok: boolean; + body: RsvpList[] | null; + error: string | null; +} + +export default async function getRsvpLists(): Promise { + try { + if (!TITO_API_TOKEN || !TITO_ACCOUNT_SLUG || !TITO_EVENT_SLUG) { + const error = 'Missing Tito API configuration in environment variables'; + console.error('[Tito API] getRsvpLists:', error); + throw new Error(error); + } + + const url = `https://api.tito.io/v3/${TITO_ACCOUNT_SLUG}/${TITO_EVENT_SLUG}/rsvp_lists`; + + console.log('[Tito API] Fetching RSVP lists from:', url); + + const response = await fetch(url, { + method: 'GET', + headers: { + Authorization: `Token token=${TITO_API_TOKEN}`, + Accept: 'application/json', + }, + }); + + if (!response.ok) { + const errorText = await response.text(); + const errorMsg = `Tito API error: ${response.status} - ${errorText}`; + console.error('[Tito API] getRsvpLists failed:', errorMsg); + console.error('[Tito API] Request URL:', url); + throw new Error(errorMsg); + } + + const data = await response.json(); + const rsvpLists = data.rsvp_lists || []; + + console.log('[Tito API] Successfully fetched', rsvpLists.length, 'RSVP lists'); + + return { + ok: true, + body: rsvpLists, + error: null, + }; + } catch (e) { + const error = e as Error; + console.error('[Tito API] getRsvpLists exception:', error); + return { + ok: false, + body: null, + error: error.message, + }; + } +} diff --git a/app/(pages)/admin/_components/TitoRsvpManager/TitoRsvpManager.module.scss b/app/(pages)/admin/_components/TitoRsvpManager/TitoRsvpManager.module.scss new file mode 100644 index 00000000..7d51f5ec --- /dev/null +++ b/app/(pages)/admin/_components/TitoRsvpManager/TitoRsvpManager.module.scss @@ -0,0 +1,181 @@ +.container { + max-width: 800px; + margin: 0 auto; + padding: 2rem; +} + +.container :global(h2) { + margin-bottom: 2rem; + color: #333; +} + +.container :global(h3) { + margin-bottom: 1rem; + color: #555; +} + +.section { + background: #f9f9f9; + border-radius: 8px; + padding: 1.5rem; + margin-bottom: 2rem; + border: 1px solid #e0e0e0; +} + +.sectionHeader { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; +} + +.formGroup { + margin-bottom: 1.5rem; +} + +.formGroup :global(label) { + display: block; + margin-bottom: 0.5rem; + font-weight: 500; + color: #333; +} + +.formGroup :global(input), +.formGroup :global(select) { + width: 100%; + padding: 0.75rem; + border: 1px solid #ccc; + border-radius: 4px; + font-size: 1rem; + transition: border-color 0.2s; +} + +.formGroup :global(input:focus), +.formGroup :global(select:focus) { + outline: none; + border-color: #007bff; +} + +.formGroup :global(input:disabled), +.formGroup :global(select:disabled) { + background-color: #f5f5f5; + cursor: not-allowed; +} + +.createListForm, +.invitationForm { + background: white; + padding: 1.5rem; + border-radius: 6px; + margin-bottom: 1rem; + border: 1px solid #e0e0e0; +} + +.button { + background-color: #007bff; + color: white; + padding: 0.75rem 1.5rem; + border: none; + border-radius: 4px; + font-size: 1rem; + cursor: pointer; + transition: background-color 0.2s; +} + +.button:hover:not(:disabled) { + background-color: #0056b3; +} + +.button:disabled { + background-color: #6c757d; + cursor: not-allowed; + opacity: 0.6; +} + +.secondaryButton { + composes: button; + background-color: #6c757d; +} + +.secondaryButton:hover:not(:disabled) { + background-color: #545b62; +} + +.refreshButton { + composes: button; + margin-top: 0.5rem; + background-color: #28a745; +} + +.refreshButton:hover:not(:disabled) { + background-color: #218838; +} + +.error { + background-color: #f8d7da; + color: #721c24; + padding: 1rem; + border-radius: 4px; + margin-top: 1rem; + border: 1px solid #f5c6cb; +} + +.success { + background-color: #d4edda; + color: #155724; + padding: 1rem; + border-radius: 4px; + margin-top: 1rem; + border: 1px solid #c3e6cb; +} + +.releasesInfo { + margin-bottom: 1.5rem; +} + +.releasesList { + background: white; + padding: 1rem; + border-radius: 6px; + margin-top: 1rem; + border: 1px solid #e0e0e0; +} + +.releasesList :global(h4) { + margin-bottom: 0.5rem; + color: #333; +} + +.releasesList :global(ul) { + list-style: none; + padding: 0; + margin: 1rem 0; +} + +.releasesList :global(li) { + padding: 0.5rem; + border-bottom: 1px solid #f0f0f0; +} + +.releasesList :global(li:last-child) { + border-bottom: none; +} + +.helpText { + font-size: 0.875rem; + color: #666; + font-style: italic; + margin-top: 0.5rem; +} + +.autoFillButton { + composes: button; + margin-top: 0.5rem; + background-color: #17a2b8; + font-size: 0.875rem; + padding: 0.5rem 1rem; +} + +.autoFillButton:hover:not(:disabled) { + background-color: #138496; +} diff --git a/app/(pages)/admin/_components/TitoRsvpManager/TitoRsvpManager.tsx b/app/(pages)/admin/_components/TitoRsvpManager/TitoRsvpManager.tsx new file mode 100644 index 00000000..1832aa51 --- /dev/null +++ b/app/(pages)/admin/_components/TitoRsvpManager/TitoRsvpManager.tsx @@ -0,0 +1,365 @@ +'use client'; + +import { FormEvent, useEffect, useState } from 'react'; +import getRsvpLists from '@actions/tito/getRsvpLists'; +import createRsvpList from '@actions/tito/createRsvpList'; +import createRsvpInvitation from '@actions/tito/createRsvpInvitation'; +import getReleases from '@actions/tito/getReleases'; +import styles from './TitoRsvpManager.module.scss'; + +interface RsvpList { + id: string; + slug: string; + title: string; +} + +interface Release { + id: string; + slug: string; + title: string; + quantity?: number; +} + +export default function TitoRsvpManager() { + const [rsvpLists, setRsvpLists] = useState([]); + const [selectedListId, setSelectedListId] = useState(''); + const [selectedListSlug, setSelectedListSlug] = useState(''); + const [releases, setReleases] = useState([]); + const [showReleases, setShowReleases] = useState(false); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [success, setSuccess] = useState(''); + const [showCreateList, setShowCreateList] = useState(false); + const [newListTitle, setNewListTitle] = useState(''); + + useEffect(() => { + loadRsvpLists(); + loadReleases(); + }, []); + + const loadReleases = async () => { + try { + const response = await getReleases(); + if (response.ok && response.body) { + setReleases(response.body); + console.log('Loaded releases:', response.body); + } else { + console.error('Failed to load releases:', response.error); + setError( + `Could not load releases: ${response.error}. Check server logs for details.` + ); + } + } catch (e) { + console.error('Failed to load releases:', e); + } + }; + + const loadRsvpLists = async () => { + setLoading(true); + setError(''); + try { + const response = await getRsvpLists(); + if (response.ok && response.body) { + setRsvpLists(response.body); + if (response.body.length > 0 && !selectedListId) { + setSelectedListId(response.body[0].id); + setSelectedListSlug(response.body[0].slug); + } + } else { + setError(response.error || 'Failed to load RSVP lists'); + } + } catch (e) { + setError('An unexpected error occurred while loading RSVP lists'); + } finally { + setLoading(false); + } + }; + + const handleCreateList = async (e: FormEvent) => { + e.preventDefault(); + if (!newListTitle.trim()) { + setError('Please enter a list title'); + return; + } + + setLoading(true); + setError(''); + setSuccess(''); + + try { + const response = await createRsvpList(newListTitle); + if (response.ok && response.body) { + setSuccess(`RSVP list "${response.body.title}" created successfully!`); + setNewListTitle(''); + setShowCreateList(false); + await loadRsvpLists(); + setSelectedListId(response.body.id); + setSelectedListSlug(response.body.slug); + } else { + setError(response.error || 'Failed to create RSVP list'); + } + } catch (e) { + setError('An unexpected error occurred while creating the RSVP list'); + } finally { + setLoading(false); + } + }; + + const handleSendInvitation = async (e: FormEvent) => { + e.preventDefault(); + + if (!selectedListSlug) { + setError('Please select an RSVP list'); + return; + } + + setLoading(true); + setError(''); + setSuccess(''); + + const formData = new FormData(e.currentTarget); + const firstName = formData.get('firstName') as string; + const lastName = formData.get('lastName') as string; + const email = formData.get('email') as string; + const releaseIds = formData.get('releaseIds') as string; + const discountCode = formData.get('discountCode') as string; + + try { + const response = await createRsvpInvitation({ + firstName, + lastName, + email, + rsvpListSlug: selectedListSlug, + releaseIds, + discountCode: discountCode || undefined, + }); + + if (response.ok && response.body) { + setSuccess( + `Invitation sent successfully to ${response.body.email} (${response.body.first_name} ${response.body.last_name})` + ); + (e.target as HTMLFormElement).reset(); + } else { + setError(response.error || 'Failed to send invitation'); + } + } catch (e) { + setError('An unexpected error occurred while sending the invitation'); + } finally { + setLoading(false); + } + }; + + return ( +
+

Tito RSVP Management

+ +
+
+

RSVP Lists

+ +
+ + {showCreateList && ( +
+
+ + setNewListTitle(e.target.value)} + placeholder="e.g., VIP Attendees" + required + disabled={loading} + /> +
+ +
+ )} + +
+ + + +
+
+ +
+

Send Release Invitation

+ +
+ + + {showReleases && ( +
+

Available Releases (Ticket Types)

+ {releases.length === 0 ? ( +
+

+ No releases found via API. Your "test ticket" should appear + here. +

+

+ Check the server console logs for API errors. Make sure your + TITO_EVENT_SLUG matches your event (currently using + "hackdavis-2026-test"). +

+

+ Workaround: Click on your "test ticket" in + Tito, check the URL for its ID, and manually enter it below. +

+
+ ) : ( +
    + {releases.map((release) => ( +
  • + ID: {release.id} - {release.title} ( + {release.slug}) + {release.quantity && ` - Qty: ${release.quantity}`} +
  • + ))} +
+ )} + {releases.length > 0 && ( +

+ Copy the ID numbers above and paste them below (comma-separated + if multiple) +

+ )} +
+ )} +
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + {releases.length > 0 && ( + + )} +
+ +
+ + +
+ + +
+
+ + {error &&
{error}
} + {success &&
{success}
} +
+ ); +} diff --git a/app/(pages)/admin/page.tsx b/app/(pages)/admin/page.tsx index c7a1f1b5..78243c39 100644 --- a/app/(pages)/admin/page.tsx +++ b/app/(pages)/admin/page.tsx @@ -25,6 +25,10 @@ const action_links = [ href: '/admin/invite-link', body: 'Invite Judges', }, + { + href: '/admin/tito-rsvp', + body: 'Tito RSVP Management', + }, { href: '/admin/randomize-projects', body: 'Randomize Projects', diff --git a/app/(pages)/admin/tito-rsvp/page.module.scss b/app/(pages)/admin/tito-rsvp/page.module.scss new file mode 100644 index 00000000..3a1cf2d8 --- /dev/null +++ b/app/(pages)/admin/tito-rsvp/page.module.scss @@ -0,0 +1,4 @@ +.container { + padding: 2rem; + min-height: 100vh; +} diff --git a/app/(pages)/admin/tito-rsvp/page.tsx b/app/(pages)/admin/tito-rsvp/page.tsx new file mode 100644 index 00000000..48d8ca75 --- /dev/null +++ b/app/(pages)/admin/tito-rsvp/page.tsx @@ -0,0 +1,11 @@ +'use client'; + +import TitoRsvpManager from '../_components/TitoRsvpManager/TitoRsvpManager'; + +export default function TitoRsvpPage() { + return ( +
+ +
+ ); +} From 101a62d242b06a97075cf40efa9d43238d803ca4 Mon Sep 17 00:00:00 2001 From: reehals Date: Mon, 2 Feb 2026 15:10:03 -0800 Subject: [PATCH 02/14] Show invite URL --- .../_actions/tito/createRsvpInvitation.ts | 9 ++++ .../TitoRsvpManager.module.scss | 48 +++++++++++++++++ .../TitoRsvpManager/TitoRsvpManager.tsx | 51 ++++++++++++++++++- 3 files changed, 106 insertions(+), 2 deletions(-) diff --git a/app/(api)/_actions/tito/createRsvpInvitation.ts b/app/(api)/_actions/tito/createRsvpInvitation.ts index 6562deff..0920046d 100644 --- a/app/(api)/_actions/tito/createRsvpInvitation.ts +++ b/app/(api)/_actions/tito/createRsvpInvitation.ts @@ -19,6 +19,8 @@ interface ReleaseInvitation { email: string; first_name: string; last_name: string; + url?: string; + unique_url?: string; created_at: string; } @@ -123,6 +125,13 @@ export default async function createRsvpInvitation( const invitation = responseData.release_invitation; console.log('[Tito API] Successfully created invitation:', invitation); + console.log('[Tito API] Full response data:', JSON.stringify(responseData, null, 2)); + if (invitation.unique_url) { + console.log('[Tito API] Unique invitation URL:', invitation.unique_url); + } + if (invitation.url) { + console.log('[Tito API] Invitation URL:', invitation.url); + } return { ok: true, diff --git a/app/(pages)/admin/_components/TitoRsvpManager/TitoRsvpManager.module.scss b/app/(pages)/admin/_components/TitoRsvpManager/TitoRsvpManager.module.scss index 7d51f5ec..2cd54b6b 100644 --- a/app/(pages)/admin/_components/TitoRsvpManager/TitoRsvpManager.module.scss +++ b/app/(pages)/admin/_components/TitoRsvpManager/TitoRsvpManager.module.scss @@ -179,3 +179,51 @@ .autoFillButton:hover:not(:disabled) { background-color: #138496; } + +.invitationUrlContainer { + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid #c3e6cb; +} + +.invitationUrlContainer :global(p) { + margin-bottom: 0.5rem; + color: #155724; +} + +.urlBox { + display: flex; + gap: 0.5rem; + margin-bottom: 0.5rem; +} + +.urlInput { + flex: 1; + padding: 0.5rem; + border: 1px solid #c3e6cb; + border-radius: 4px; + font-family: monospace; + font-size: 0.875rem; + background-color: #f8f9fa; +} + +.copyButton { + composes: button; + padding: 0.5rem 1rem; + background-color: #28a745; +} + +.copyButton:hover:not(:disabled) { + background-color: #218838; +} + +.urlLink { + display: inline-block; + color: #155724; + text-decoration: underline; + font-size: 0.875rem; +} + +.urlLink:hover { + color: #0c3d1a; +} diff --git a/app/(pages)/admin/_components/TitoRsvpManager/TitoRsvpManager.tsx b/app/(pages)/admin/_components/TitoRsvpManager/TitoRsvpManager.tsx index 1832aa51..ecbcda21 100644 --- a/app/(pages)/admin/_components/TitoRsvpManager/TitoRsvpManager.tsx +++ b/app/(pages)/admin/_components/TitoRsvpManager/TitoRsvpManager.tsx @@ -29,6 +29,7 @@ export default function TitoRsvpManager() { const [loading, setLoading] = useState(false); const [error, setError] = useState(''); const [success, setSuccess] = useState(''); + const [invitationUrl, setInvitationUrl] = useState(''); const [showCreateList, setShowCreateList] = useState(false); const [newListTitle, setNewListTitle] = useState(''); @@ -85,6 +86,7 @@ export default function TitoRsvpManager() { setLoading(true); setError(''); setSuccess(''); + setInvitationUrl(''); try { const response = await createRsvpList(newListTitle); @@ -135,9 +137,17 @@ export default function TitoRsvpManager() { }); if (response.ok && response.body) { + const fullName = [response.body.first_name, response.body.last_name] + .filter(Boolean) + .join(' '); setSuccess( - `Invitation sent successfully to ${response.body.email} (${response.body.first_name} ${response.body.last_name})` + `Invitation sent successfully to ${response.body.email}${fullName ? ` (${fullName})` : ''}` ); + // Use unique_url if available, fallback to url + const inviteUrl = response.body.unique_url || response.body.url; + if (inviteUrl) { + setInvitationUrl(inviteUrl); + } (e.target as HTMLFormElement).reset(); } else { setError(response.error || 'Failed to send invitation'); @@ -359,7 +369,44 @@ export default function TitoRsvpManager() { {error &&
{error}
} - {success &&
{success}
} + {success && ( +
+

{success}

+ {invitationUrl && ( +
+

+ Invitation URL: +

+
+ + +
+ + Open invitation link + +
+ )} +
+ )} ); } From 75415e604a255ce90da740268a83c44399563897 Mon Sep 17 00:00:00 2001 From: reehals Date: Thu, 5 Feb 2026 21:47:04 -0800 Subject: [PATCH 03/14] lint fixes --- .../_actions/tito/createRsvpInvitation.ts | 5 ++++- app/(api)/_actions/tito/getReleases.ts | 5 ++++- app/(api)/_actions/tito/getRsvpLists.ts | 6 +++++- .../TitoRsvpManager/TitoRsvpManager.tsx | 19 +++++++++++-------- 4 files changed, 24 insertions(+), 11 deletions(-) diff --git a/app/(api)/_actions/tito/createRsvpInvitation.ts b/app/(api)/_actions/tito/createRsvpInvitation.ts index 0920046d..35b4eab0 100644 --- a/app/(api)/_actions/tito/createRsvpInvitation.ts +++ b/app/(api)/_actions/tito/createRsvpInvitation.ts @@ -125,7 +125,10 @@ export default async function createRsvpInvitation( const invitation = responseData.release_invitation; console.log('[Tito API] Successfully created invitation:', invitation); - console.log('[Tito API] Full response data:', JSON.stringify(responseData, null, 2)); + console.log( + '[Tito API] Full response data:', + JSON.stringify(responseData, null, 2) + ); if (invitation.unique_url) { console.log('[Tito API] Unique invitation URL:', invitation.unique_url); } diff --git a/app/(api)/_actions/tito/getReleases.ts b/app/(api)/_actions/tito/getReleases.ts index b0aa765c..f988d825 100644 --- a/app/(api)/_actions/tito/getReleases.ts +++ b/app/(api)/_actions/tito/getReleases.ts @@ -49,7 +49,10 @@ export default async function getReleases(): Promise { const releases = data.releases || []; console.log('[Tito API] Successfully fetched', releases.length, 'releases'); - console.log('[Tito API] Full releases response:', JSON.stringify(data, null, 2)); + console.log( + '[Tito API] Full releases response:', + JSON.stringify(data, null, 2) + ); return { ok: true, diff --git a/app/(api)/_actions/tito/getRsvpLists.ts b/app/(api)/_actions/tito/getRsvpLists.ts index 5db2b0aa..bc87351e 100644 --- a/app/(api)/_actions/tito/getRsvpLists.ts +++ b/app/(api)/_actions/tito/getRsvpLists.ts @@ -50,7 +50,11 @@ export default async function getRsvpLists(): Promise { const data = await response.json(); const rsvpLists = data.rsvp_lists || []; - console.log('[Tito API] Successfully fetched', rsvpLists.length, 'RSVP lists'); + console.log( + '[Tito API] Successfully fetched', + rsvpLists.length, + 'RSVP lists' + ); return { ok: true, diff --git a/app/(pages)/admin/_components/TitoRsvpManager/TitoRsvpManager.tsx b/app/(pages)/admin/_components/TitoRsvpManager/TitoRsvpManager.tsx index ecbcda21..bfb652c6 100644 --- a/app/(pages)/admin/_components/TitoRsvpManager/TitoRsvpManager.tsx +++ b/app/(pages)/admin/_components/TitoRsvpManager/TitoRsvpManager.tsx @@ -36,7 +36,7 @@ export default function TitoRsvpManager() { useEffect(() => { loadRsvpLists(); loadReleases(); - }, []); + }); const loadReleases = async () => { try { @@ -141,7 +141,9 @@ export default function TitoRsvpManager() { .filter(Boolean) .join(' '); setSuccess( - `Invitation sent successfully to ${response.body.email}${fullName ? ` (${fullName})` : ''}` + `Invitation sent successfully to ${response.body.email}${ + fullName ? ` (${fullName})` : '' + }` ); // Use unique_url if available, fallback to url const inviteUrl = response.body.unique_url || response.body.url; @@ -248,8 +250,8 @@ export default function TitoRsvpManager() { {releases.length === 0 ? (

- No releases found via API. Your "test ticket" should appear - here. + No releases found via API. Your "test ticket" + should appear here.

Check the server console logs for API errors. Make sure your @@ -257,8 +259,9 @@ export default function TitoRsvpManager() { "hackdavis-2026-test").

- Workaround: Click on your "test ticket" in - Tito, check the URL for its ID, and manually enter it below. + Workaround: Click on your "test + ticket" in Tito, check the URL for its ID, and manually + enter it below.

) : ( @@ -274,8 +277,8 @@ export default function TitoRsvpManager() { )} {releases.length > 0 && (

- Copy the ID numbers above and paste them below (comma-separated - if multiple) + Copy the ID numbers above and paste them below + (comma-separated if multiple)

)} From 8b95d8719521d87fd22517b8010750e74af85f45 Mon Sep 17 00:00:00 2001 From: reehals Date: Fri, 6 Feb 2026 00:44:06 -0800 Subject: [PATCH 04/14] Add emails for hackers Add accepted, waitlist, waitlist accept, waitlist reject templates --- .../emailFormats/2026AcceptedTemplate.ts | 150 ++++++++ .../2026WaitlistAcceptedTemplate.ts | 148 +++++++ .../2026WaitlistRejectedTemplate.ts | 105 +++++ .../emailFormats/2026WaitlistedTemplate.ts | 106 +++++ app/(api)/_actions/emails/sendHackerEmail.ts | 161 ++++++++ .../HackerEmailSender/HackerEmailSender.tsx | 363 ++++++++++++++++++ .../TitoRsvpManager.module.scss | 229 ----------- .../TitoRsvpManager/TitoRsvpManager.tsx | 284 ++++++++------ app/(pages)/admin/hacker-emails/page.tsx | 11 + app/(pages)/admin/page.tsx | 4 + app/(pages)/admin/tito-rsvp/page.module.scss | 4 - public/email/2025_email_header.png | Bin 0 -> 199982 bytes 12 files changed, 1208 insertions(+), 357 deletions(-) create mode 100644 app/(api)/_actions/emails/emailFormats/2026AcceptedTemplate.ts create mode 100644 app/(api)/_actions/emails/emailFormats/2026WaitlistAcceptedTemplate.ts create mode 100644 app/(api)/_actions/emails/emailFormats/2026WaitlistRejectedTemplate.ts create mode 100644 app/(api)/_actions/emails/emailFormats/2026WaitlistedTemplate.ts create mode 100644 app/(api)/_actions/emails/sendHackerEmail.ts create mode 100644 app/(pages)/admin/_components/HackerEmailSender/HackerEmailSender.tsx delete mode 100644 app/(pages)/admin/_components/TitoRsvpManager/TitoRsvpManager.module.scss create mode 100644 app/(pages)/admin/hacker-emails/page.tsx delete mode 100644 app/(pages)/admin/tito-rsvp/page.module.scss create mode 100644 public/email/2025_email_header.png diff --git a/app/(api)/_actions/emails/emailFormats/2026AcceptedTemplate.ts b/app/(api)/_actions/emails/emailFormats/2026AcceptedTemplate.ts new file mode 100644 index 00000000..fe34c6f4 --- /dev/null +++ b/app/(api)/_actions/emails/emailFormats/2026AcceptedTemplate.ts @@ -0,0 +1,150 @@ +export default function acceptedTemplate( + fname: string, + titoTicket: string, + hubInvite: string +) { + return ` + + + + + Congratulations from HackDavis! + + + +
+ + HackDavis 2025 Ticket + + +

Congratulations from HackDavis! πŸŽ‰

+ + +
+

Hi ${fname},

+ +

We are thrilled to welcome you as a hacker at HackDavis 2025! We can't wait to see all the amazing projects and ideas that you'll bring to the table. HackDavis 2025 will take place on April 19th - 20th, 2025 completely in-person at the University Credit Union Center @ UC Davis. β€οΈπŸ’»πŸŒ±

+ +

Please read this email carefully and complete all tasks for a smooth experience.

+ +

Here's what we need from you:

+ +
    +
  • Claim your ticket by April 10th, 11:59 PM PDT here: ${titoTicket} +
      +
    • You MUST claim a ticket to attend the event β€” so check in on your friends as well and ensure that you all have tickets to HackDavis!
    • +
    +
  • +
  • Create an account on the HackDavis Hub for all your hacking needs here: ${hubInvite} +
      +
    • As the hackathon approaches, we will update the Hub with exciting information like prizes, workshops, starter kit, demo tips, and more!
    • +
    • After registering, if you have trouble logging into the Hub, please clear your browser cookies and try again. If the issue persists, please shoot us an email at hello@hackdavis.io.
    • +
    +
  • +
  • Join our Discord at https://discord.gg/wc6QQEc. This is where all communication will take place during the hackathon! +
      +
    • To gain access to all day-of-the-event channels, you must have the Hacker role. To obtain it, please follow the instructions in #read-me-first.
    • +
    +
  • +
+ +

After claiming your ticket using the link above, you will receive a unique QR Code which you will use to check-in at the venue on Saturday April 19th. If you are looking for teammates, keep a look out for our team mixers in April. You can also utilize the #find-a-team channel on Discord or attend our day-of-the-event team mixer!

+ +

Be sure to follow our Instagram at @hackdavis to stay updated! We will publish more information on the Hub and send out logistical details closer to the event.

+ +

If you have any questions, concerns, or comments, please reach out to hello@hackdavis.io.

+ +

See ya soon! ✨

+ +

The HackDavis Team

+
+ + +
+ + + HackDavis 2025 Ticket +
+ +`; +} diff --git a/app/(api)/_actions/emails/emailFormats/2026WaitlistAcceptedTemplate.ts b/app/(api)/_actions/emails/emailFormats/2026WaitlistAcceptedTemplate.ts new file mode 100644 index 00000000..b3891bde --- /dev/null +++ b/app/(api)/_actions/emails/emailFormats/2026WaitlistAcceptedTemplate.ts @@ -0,0 +1,148 @@ +export default function waitlistAcceptedTemplate( + fname: string, + titoTicket: string, + hubInvite: string +) { + return ` + + + + + RSVP To HackDavis 2025 Today! + + + +
+ + HackDavis 2025 Ticket + + +

RSVP To HackDavis 2025 Today! ⛱️

+ + +
+

Hi ${fname},

+ +

Congratulationsβ€”you're off the waitlist! We are thrilled to welcome you as a hacker at HackDavis 2025! We can't wait to see all the amazing projects and ideas that you'll bring to the table. HackDavis 2025 will take place on April 19th - 20th, 2025 completely in-person at the University Credit Union Center @ UC Davis. β€οΈπŸ’»πŸŒ±

+ +

Please read this email carefully and complete all tasks for a smooth experience. Here's what we need from you:

+ +
    +
  • Claim your ticket by April 12th, 11:59 PM PDT here: ${titoTicket} +
      +
    • πŸ”” You MUST claim a ticket to attend the event β€” so check in on your friends as well and ensure that you all have tickets to HackDavis!
    • +
    +
  • +
  • Create an account on the HackDavis Hub for all your hacking needs here: ${hubInvite} +
      +
    • As the hackathon approaches, we will update the Hub with exciting information like prizes, workshops, starter kit, demo tips, and more!
    • +
    • After registering, if you have trouble logging into the Hub, please clear your browser cookies and try again. If the issue persists, please shoot us an email at hello@hackdavis.io.
    • +
    +
  • +
  • Join our Discord at https://discord.gg/wc6QQEc. This is where all communication will take place during the hackathon! +
      +
    • To gain access to all day-of-the-event channels, you must have the Hacker role. To obtain it, please follow the instructions in #read-me-first.
    • +
    +
  • +
+ +

After claiming your ticket using the link above, you will receive a unique QR Code which you will use to check-in at the venue on Saturday April 19th. If you are looking for teammates, you can utilize the #find-a-team channel on Discord or attend our day-of-the-event team mixer!

+ +

Be sure to follow our Instagram at @hackdavis to stay updated! We will publish more information and send out logistical details closer to the event.

+ +

If you have any questions, concerns, or comments, please reach out to hello@hackdavis.io.

+ +

See ya soon! ✨

+ +

The HackDavis Team

+
+ + +
+ + + HackDavis 2025 Ticket +
+ +`; +} diff --git a/app/(api)/_actions/emails/emailFormats/2026WaitlistRejectedTemplate.ts b/app/(api)/_actions/emails/emailFormats/2026WaitlistRejectedTemplate.ts new file mode 100644 index 00000000..b87dd5a5 --- /dev/null +++ b/app/(api)/_actions/emails/emailFormats/2026WaitlistRejectedTemplate.ts @@ -0,0 +1,105 @@ +export default function waitlistRejectedTemplate(fname: string) { + return ` + + + + + Update from HackDavis + + + +
+ + HackDavis 2025 Ticket + + +

Update from HackDavis

+ + +
+

Hi ${fname},

+ +

Thank you so much for your interest in HackDavis 2025, we truly appreciate your enthusiasm and patience throughout this process.

+ +

Unfortunately, due to overwhelming interest and limited capacity, we're no longer able to accommodate hackers currently on the waitlist. We know this is disappointing, and we're just as bummed as you are.

+ +

If you have any questions, feel free to reach out to us at hello@hackdavis.io. You're part of what makes our community so special, and we hope to see you at HackDavis 2026! πŸ’™

+ +

Warmly,

+ +

The HackDavis Team

+
+ + +
+ + + HackDavis 2025 Ticket +
+ +`; +} diff --git a/app/(api)/_actions/emails/emailFormats/2026WaitlistedTemplate.ts b/app/(api)/_actions/emails/emailFormats/2026WaitlistedTemplate.ts new file mode 100644 index 00000000..401a6b61 --- /dev/null +++ b/app/(api)/_actions/emails/emailFormats/2026WaitlistedTemplate.ts @@ -0,0 +1,106 @@ +export default function waitlistedTemplate(fname: string) { + return ` + + + + + HackDavis 2025 Application Update + + + +
+ + HackDavis 2025 Ticket + + +

Update from HackDavis 🌊

+ + +
+

Hi ${fname},

+ +

Thank you for applying to HackDavis 2025! Unfortunately, due to a high volume of applications, you have been waitlisted for HackDavis 2025.

+ +

We are unable to offer you a spot at the moment, but there is still a possibility of spots opening up later. Just hang in there, we will get back to you about a change in your status as soon as possible.

+ +

If you have any questions, concerns, or comments, please reach out to hello@hackdavis.io.

+ +

Warmly,
The HackDavis Team

+
+ + +
+ + + HackDavis 2025 Ticket +
+ +`; +} diff --git a/app/(api)/_actions/emails/sendHackerEmail.ts b/app/(api)/_actions/emails/sendHackerEmail.ts new file mode 100644 index 00000000..58aac927 --- /dev/null +++ b/app/(api)/_actions/emails/sendHackerEmail.ts @@ -0,0 +1,161 @@ +'use server'; + +import createRsvpInvitation from '@actions/tito/createRsvpInvitation'; +import GenerateInvite from '@datalib/invite/generateInvite'; +import nodemailer from 'nodemailer'; +import acceptedTemplate from './emailFormats/2026AcceptedTemplate'; +import waitlistedTemplate from './emailFormats/2026WaitlistedTemplate'; +import waitlistAcceptedTemplate from './emailFormats/2026WaitlistAcceptedTemplate'; +import waitlistRejectedTemplate from './emailFormats/2026WaitlistRejectedTemplate'; + +const senderEmail = process.env.SENDER_EMAIL; +const password = process.env.SENDER_PWD; + +interface HackerEmailOptions { + firstName: string; + lastName: string; + email: string; + emailType: + | '2026AcceptedTemplate' + | '2026WaitlistedTemplate' + | '2026WaitlistAcceptedTemplate' + | '2026WaitlistRejectedTemplate'; + rsvpListSlug?: string; + releaseIds?: string; +} + +interface Response { + ok: boolean; + titoUrl?: string; + hubUrl?: string; + error: string | null; +} + +export default async function sendHackerEmail( + options: HackerEmailOptions +): Promise { + try { + console.log( + `[Hacker Email] Sending ${options.emailType} to ${options.email}` + ); + + if (!senderEmail || !password) { + throw new Error('Email configuration missing'); + } + + let titoUrl: string | undefined; + let hubUrl: string | undefined; + let htmlContent: string; + + // For accepted hackers, generate both Hub invite and Tito invite + if ( + options.emailType === '2026AcceptedTemplate' || + options.emailType === '2026WaitlistAcceptedTemplate' + ) { + if (!options.rsvpListSlug || !options.releaseIds) { + throw new Error( + 'RSVP list slug and release IDs are required for accepted hackers' + ); + } + + // 1. Create Tito invitation + const titoResponse = await createRsvpInvitation({ + firstName: options.firstName, + lastName: options.lastName, + email: options.email, + rsvpListSlug: options.rsvpListSlug, + releaseIds: options.releaseIds, + }); + + if (!titoResponse.ok || !titoResponse.body?.unique_url) { + throw new Error( + titoResponse.error || 'Failed to create Tito invitation' + ); + } + + titoUrl = titoResponse.body.unique_url; + + // 2. Generate Hub invite + const hubInviteResponse = await GenerateInvite( + { + email: options.email, + name: `${options.firstName} ${options.lastName}`, + role: 'hacker', + }, + 'invite' + ); + + if (!hubInviteResponse.ok || !hubInviteResponse.body) { + throw new Error( + hubInviteResponse.error || 'Failed to generate Hub invite' + ); + } + + hubUrl = hubInviteResponse.body; + + // Generate HTML content based on template type + if (options.emailType === '2026AcceptedTemplate') { + htmlContent = acceptedTemplate(options.firstName, titoUrl, hubUrl); + } else { + htmlContent = waitlistAcceptedTemplate( + options.firstName, + titoUrl, + hubUrl + ); + } + } else if (options.emailType === '2026WaitlistedTemplate') { + htmlContent = waitlistedTemplate(options.firstName); + } else if (options.emailType === '2026WaitlistRejectedTemplate') { + htmlContent = waitlistRejectedTemplate(options.firstName); + } else { + throw new Error(`Unknown email type: ${options.emailType}`); + } + + // 3. Send email + const transporter = nodemailer.createTransport({ + service: 'gmail', + auth: { + user: senderEmail, + pass: password, + }, + }); + + await transporter.sendMail({ + from: senderEmail, + to: options.email, + subject: getEmailSubject(options.emailType), + html: htmlContent, + }); + + console.log(`[Hacker Email] βœ“ Successfully sent to ${options.email}`); + + return { + ok: true, + titoUrl, + hubUrl, + error: null, + }; + } catch (e) { + const error = e as Error; + console.error(`[Hacker Email] βœ— Failed:`, error); + return { + ok: false, + error: error.message, + }; + } +} + +function getEmailSubject(emailType: string): string { + switch (emailType) { + case '2026AcceptedTemplate': + return "Congratulations! You're Accepted to HackDavis 2025"; + case '2026WaitlistedTemplate': + return 'HackDavis 2025 Application Update - Waitlisted'; + case '2026WaitlistAcceptedTemplate': + return "You're Off the Waitlist! Welcome to HackDavis 2025"; + case '2026WaitlistRejectedTemplate': + return 'HackDavis 2025 Application Update'; + default: + return 'HackDavis 2025 Update'; + } +} diff --git a/app/(pages)/admin/_components/HackerEmailSender/HackerEmailSender.tsx b/app/(pages)/admin/_components/HackerEmailSender/HackerEmailSender.tsx new file mode 100644 index 00000000..36025c38 --- /dev/null +++ b/app/(pages)/admin/_components/HackerEmailSender/HackerEmailSender.tsx @@ -0,0 +1,363 @@ +'use client'; + +import { FormEvent, useState, useEffect } from 'react'; +import getRsvpLists from '@actions/tito/getRsvpLists'; +import getReleases from '@actions/tito/getReleases'; +import sendHackerEmail from '@actions/emails/sendHackerEmail'; + +interface RsvpList { + id: string; + slug: string; + title: string; +} + +interface Release { + id: string; + slug: string; + title: string; +} + +const EMAIL_TYPES = [ + { + value: '2026_accepted_template', + label: 'Accepted (with Hub + Tito invite)', + }, + { value: '2026_waitlisted_template', label: 'Waitlisted' }, + { value: '2026_waitlist_accepted_template', label: 'Waitlist Accepted' }, + { value: '2026_waitlist_rejected_template', label: 'Waitlist Rejected' }, +]; + +export default function HackerEmailSender() { + const [rsvpLists, setRsvpLists] = useState([]); + const [releases, setReleases] = useState([]); + const [selectedListSlug, setSelectedListSlug] = useState(''); + const [selectedEmailType, setSelectedEmailType] = useState( + '2026_accepted_template' + ); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [success, setSuccess] = useState(''); + const [titoUrl, setTitoUrl] = useState(''); + const [hubUrl, setHubUrl] = useState(''); + const [selectedReleases, setSelectedReleases] = useState([]); + + useEffect(() => { + loadData(); + }, []); + + const loadData = async () => { + const [rsvpRes, relRes] = await Promise.all([ + getRsvpLists(), + getReleases(), + ]); + + if (rsvpRes.ok && rsvpRes.body) { + setRsvpLists(rsvpRes.body); + if (rsvpRes.body.length > 0) { + setSelectedListSlug(rsvpRes.body[0].slug); + } + } + + if (relRes.ok && relRes.body) { + setReleases(relRes.body); + } + }; + + const handleSendEmail = async (e: FormEvent) => { + e.preventDefault(); + + if (needsInvites && selectedReleases.length === 0) { + setError('Please select at least one release'); + return; + } + + setLoading(true); + setError(''); + setSuccess(''); + setTitoUrl(''); + setHubUrl(''); + + const formData = new FormData(e.currentTarget); + const firstName = formData.get('firstName') as string; + const lastName = formData.get('lastName') as string; + const email = formData.get('email') as string; + const releaseIds = selectedReleases.join(','); + + try { + const response = await sendHackerEmail({ + firstName, + lastName, + email, + emailType: selectedEmailType as any, + rsvpListSlug: + selectedEmailType === '2026_accepted_template' + ? selectedListSlug + : undefined, + releaseIds: + selectedEmailType === '2026_accepted_template' + ? releaseIds + : undefined, + }); + + if (response.ok) { + setSuccess(`Email sent successfully to ${email}!`); + if (response.titoUrl) setTitoUrl(response.titoUrl); + if (response.hubUrl) setHubUrl(response.hubUrl); + (e.target as HTMLFormElement).reset(); + } else { + setError(response.error || 'Failed to send email'); + } + } catch (err) { + setError('An unexpected error occurred'); + } finally { + setLoading(false); + } + }; + + const needsInvites = selectedEmailType === '2026_accepted_template'; + + return ( +
+

+ Send Hacker Email +

+ +
+
+ + + {needsInvites && ( +

+ ⚠️ This will generate both a Tito ticket invite and a HackDavis + Hub invite +

+ )} +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + {needsInvites && ( + <> +
+ + +
+ +
+ + {releases.length === 0 ? ( +
+ No releases found. Check server logs for API errors. +
+ ) : ( +
+ {releases.map((release) => ( + + ))} + +
+ )} +
+ + )} + + +
+ + {success && ( +
+

{success}

+ {titoUrl && ( +
+ Tito URL: +
+ + +
+
+ )} + {hubUrl && ( +
+ Hub Invite URL: +
+ + +
+
+ )} +
+ )} + + {error && ( +
+ {error} +
+ )} +
+ ); +} diff --git a/app/(pages)/admin/_components/TitoRsvpManager/TitoRsvpManager.module.scss b/app/(pages)/admin/_components/TitoRsvpManager/TitoRsvpManager.module.scss deleted file mode 100644 index 2cd54b6b..00000000 --- a/app/(pages)/admin/_components/TitoRsvpManager/TitoRsvpManager.module.scss +++ /dev/null @@ -1,229 +0,0 @@ -.container { - max-width: 800px; - margin: 0 auto; - padding: 2rem; -} - -.container :global(h2) { - margin-bottom: 2rem; - color: #333; -} - -.container :global(h3) { - margin-bottom: 1rem; - color: #555; -} - -.section { - background: #f9f9f9; - border-radius: 8px; - padding: 1.5rem; - margin-bottom: 2rem; - border: 1px solid #e0e0e0; -} - -.sectionHeader { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 1rem; -} - -.formGroup { - margin-bottom: 1.5rem; -} - -.formGroup :global(label) { - display: block; - margin-bottom: 0.5rem; - font-weight: 500; - color: #333; -} - -.formGroup :global(input), -.formGroup :global(select) { - width: 100%; - padding: 0.75rem; - border: 1px solid #ccc; - border-radius: 4px; - font-size: 1rem; - transition: border-color 0.2s; -} - -.formGroup :global(input:focus), -.formGroup :global(select:focus) { - outline: none; - border-color: #007bff; -} - -.formGroup :global(input:disabled), -.formGroup :global(select:disabled) { - background-color: #f5f5f5; - cursor: not-allowed; -} - -.createListForm, -.invitationForm { - background: white; - padding: 1.5rem; - border-radius: 6px; - margin-bottom: 1rem; - border: 1px solid #e0e0e0; -} - -.button { - background-color: #007bff; - color: white; - padding: 0.75rem 1.5rem; - border: none; - border-radius: 4px; - font-size: 1rem; - cursor: pointer; - transition: background-color 0.2s; -} - -.button:hover:not(:disabled) { - background-color: #0056b3; -} - -.button:disabled { - background-color: #6c757d; - cursor: not-allowed; - opacity: 0.6; -} - -.secondaryButton { - composes: button; - background-color: #6c757d; -} - -.secondaryButton:hover:not(:disabled) { - background-color: #545b62; -} - -.refreshButton { - composes: button; - margin-top: 0.5rem; - background-color: #28a745; -} - -.refreshButton:hover:not(:disabled) { - background-color: #218838; -} - -.error { - background-color: #f8d7da; - color: #721c24; - padding: 1rem; - border-radius: 4px; - margin-top: 1rem; - border: 1px solid #f5c6cb; -} - -.success { - background-color: #d4edda; - color: #155724; - padding: 1rem; - border-radius: 4px; - margin-top: 1rem; - border: 1px solid #c3e6cb; -} - -.releasesInfo { - margin-bottom: 1.5rem; -} - -.releasesList { - background: white; - padding: 1rem; - border-radius: 6px; - margin-top: 1rem; - border: 1px solid #e0e0e0; -} - -.releasesList :global(h4) { - margin-bottom: 0.5rem; - color: #333; -} - -.releasesList :global(ul) { - list-style: none; - padding: 0; - margin: 1rem 0; -} - -.releasesList :global(li) { - padding: 0.5rem; - border-bottom: 1px solid #f0f0f0; -} - -.releasesList :global(li:last-child) { - border-bottom: none; -} - -.helpText { - font-size: 0.875rem; - color: #666; - font-style: italic; - margin-top: 0.5rem; -} - -.autoFillButton { - composes: button; - margin-top: 0.5rem; - background-color: #17a2b8; - font-size: 0.875rem; - padding: 0.5rem 1rem; -} - -.autoFillButton:hover:not(:disabled) { - background-color: #138496; -} - -.invitationUrlContainer { - margin-top: 1rem; - padding-top: 1rem; - border-top: 1px solid #c3e6cb; -} - -.invitationUrlContainer :global(p) { - margin-bottom: 0.5rem; - color: #155724; -} - -.urlBox { - display: flex; - gap: 0.5rem; - margin-bottom: 0.5rem; -} - -.urlInput { - flex: 1; - padding: 0.5rem; - border: 1px solid #c3e6cb; - border-radius: 4px; - font-family: monospace; - font-size: 0.875rem; - background-color: #f8f9fa; -} - -.copyButton { - composes: button; - padding: 0.5rem 1rem; - background-color: #28a745; -} - -.copyButton:hover:not(:disabled) { - background-color: #218838; -} - -.urlLink { - display: inline-block; - color: #155724; - text-decoration: underline; - font-size: 0.875rem; -} - -.urlLink:hover { - color: #0c3d1a; -} diff --git a/app/(pages)/admin/_components/TitoRsvpManager/TitoRsvpManager.tsx b/app/(pages)/admin/_components/TitoRsvpManager/TitoRsvpManager.tsx index bfb652c6..9f2557da 100644 --- a/app/(pages)/admin/_components/TitoRsvpManager/TitoRsvpManager.tsx +++ b/app/(pages)/admin/_components/TitoRsvpManager/TitoRsvpManager.tsx @@ -1,11 +1,10 @@ 'use client'; -import { FormEvent, useEffect, useState } from 'react'; +import { FormEvent, useCallback, useEffect, useState } from 'react'; import getRsvpLists from '@actions/tito/getRsvpLists'; import createRsvpList from '@actions/tito/createRsvpList'; import createRsvpInvitation from '@actions/tito/createRsvpInvitation'; import getReleases from '@actions/tito/getReleases'; -import styles from './TitoRsvpManager.module.scss'; interface RsvpList { id: string; @@ -25,20 +24,15 @@ export default function TitoRsvpManager() { const [selectedListId, setSelectedListId] = useState(''); const [selectedListSlug, setSelectedListSlug] = useState(''); const [releases, setReleases] = useState([]); - const [showReleases, setShowReleases] = useState(false); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); const [success, setSuccess] = useState(''); const [invitationUrl, setInvitationUrl] = useState(''); const [showCreateList, setShowCreateList] = useState(false); const [newListTitle, setNewListTitle] = useState(''); + const [selectedReleases, setSelectedReleases] = useState([]); - useEffect(() => { - loadRsvpLists(); - loadReleases(); - }); - - const loadReleases = async () => { + const loadReleases = useCallback(async () => { try { const response = await getReleases(); if (response.ok && response.body) { @@ -53,9 +47,9 @@ export default function TitoRsvpManager() { } catch (e) { console.error('Failed to load releases:', e); } - }; + }, []); - const loadRsvpLists = async () => { + const loadRsvpLists = useCallback(async () => { setLoading(true); setError(''); try { @@ -74,7 +68,12 @@ export default function TitoRsvpManager() { } finally { setLoading(false); } - }; + }, [selectedListId]); + + useEffect(() => { + loadRsvpLists(); + loadReleases(); + }, [loadRsvpLists, loadReleases]); const handleCreateList = async (e: FormEvent) => { e.preventDefault(); @@ -115,6 +114,11 @@ export default function TitoRsvpManager() { return; } + if (selectedReleases.length === 0) { + setError('Please select at least one release'); + return; + } + setLoading(true); setError(''); setSuccess(''); @@ -123,8 +127,8 @@ export default function TitoRsvpManager() { const firstName = formData.get('firstName') as string; const lastName = formData.get('lastName') as string; const email = formData.get('email') as string; - const releaseIds = formData.get('releaseIds') as string; const discountCode = formData.get('discountCode') as string; + const releaseIds = selectedReleases.join(','); try { const response = await createRsvpInvitation({ @@ -145,7 +149,6 @@ export default function TitoRsvpManager() { fullName ? ` (${fullName})` : '' }` ); - // Use unique_url if available, fallback to url const inviteUrl = response.body.unique_url || response.body.url; if (inviteUrl) { setInvitationUrl(inviteUrl); @@ -162,16 +165,18 @@ export default function TitoRsvpManager() { }; return ( -
-

Tito RSVP Management

+
+

+ Tito RSVP Management +

-
-
-

RSVP Lists

+
+
+

RSVP Lists

{showCreateList && ( -
-
- + +
+
- )} -
- +
+
-
- +
+
-
- +
+
-
-