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:

+ + + +

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/2026MentorInviteTemplate.ts b/app/(api)/_actions/emails/emailFormats/2026MentorInviteTemplate.ts new file mode 100644 index 00000000..666db312 --- /dev/null +++ b/app/(api)/_actions/emails/emailFormats/2026MentorInviteTemplate.ts @@ -0,0 +1,140 @@ +export default function mentorInviteTemplate(fname: string, titoUrl: string) { + return ` + + + + + Mentor Invitation - HackDavis 2025 + + + +
+ + HackDavis 2025 Ticket + + +

Congratulations from HackDavis! 🎉

+ + +
+

Hi ${fname},

+ +

We are thrilled to welcome you as a mentor at HackDavis 2025! We can't wait to see all the guidance and support you'll bring to our hackers. 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:

+ + + +

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.

+ +

Be sure to follow our Instagram at @hackdavis to stay updated! We will 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:

+ + + +

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/emailFormats/html/2026_accepted_template.html b/app/(api)/_actions/emails/emailFormats/html/2026_accepted_template.html new file mode 100644 index 00000000..ac4c7a96 --- /dev/null +++ b/app/(api)/_actions/emails/emailFormats/html/2026_accepted_template.html @@ -0,0 +1,210 @@ + + + + + + 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:

+ + + +

+ 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/html/2026_waitlist_accepted_template.html b/app/(api)/_actions/emails/emailFormats/html/2026_waitlist_accepted_template.html new file mode 100644 index 00000000..5885242f --- /dev/null +++ b/app/(api)/_actions/emails/emailFormats/html/2026_waitlist_accepted_template.html @@ -0,0 +1,209 @@ + + + + + + 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: +

+ + + +

+ 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/sendBulkMentorInvites.ts b/app/(api)/_actions/emails/sendBulkMentorInvites.ts new file mode 100644 index 00000000..9fb40c3a --- /dev/null +++ b/app/(api)/_actions/emails/sendBulkMentorInvites.ts @@ -0,0 +1,137 @@ +'use server'; + +import createRsvpInvitation from '@actions/tito/createRsvpInvitation'; +import mentorInviteTemplate from './emailFormats/2026MentorInviteTemplate'; +import { DEFAULT_SENDER, transporter } from './transporter'; +import { + BulkInviteOptions, + BulkInviteResponse, + InviteResult, +} from '@typeDefs/emails'; + +const CONCURRENCY = 10; + +async function processMentor( + mentor: BulkInviteOptions['mentors'][number], + options: BulkInviteOptions +): Promise { + const mentorStartTime = Date.now(); + console.log(`[Bulk Mentor Invites] Processing mentor: ${mentor.email}`); + + // 1. Create Tito invitation + const titoStartTime = Date.now(); + const titoResponse = await createRsvpInvitation({ + firstName: mentor.firstName, + lastName: mentor.lastName, + email: mentor.email, + rsvpListSlug: options.rsvpListSlug, + releaseIds: options.releaseIds, + }); + console.log( + `[Bulk Mentor Invites] Tito API call took ${ + Date.now() - titoStartTime + }ms for ${mentor.email}` + ); + + if (!titoResponse.ok || !titoResponse.body?.unique_url) { + throw new Error(titoResponse.error || 'Failed to create Tito invitation'); + } + + const titoUrl = titoResponse.body.unique_url; + + // 2. Send email with Tito URL + if (!DEFAULT_SENDER) { + throw new Error('Email configuration missing'); + } + + const htmlContent = mentorInviteTemplate(mentor.firstName, titoUrl); + + const emailStartTime = Date.now(); + await transporter.sendMail({ + from: DEFAULT_SENDER, + to: mentor.email, + subject: 'Welcome to HackDavis 2025 - Mentor Invitation', + html: htmlContent, + }); + console.log( + `[Bulk Mentor Invites] Nodemailer sendMail took ${ + Date.now() - emailStartTime + }ms for ${mentor.email}` + ); + + console.log( + `[Bulk Mentor Invites] ✓ Success: ${mentor.email} (total: ${ + Date.now() - mentorStartTime + }ms)` + ); + return { email: mentor.email, success: true, titoUrl }; +} + +export default async function sendBulkMentorInvites( + options: BulkInviteOptions +): Promise { + const results: InviteResult[] = []; + let successCount = 0; + let failureCount = 0; + + const totalStartTime = Date.now(); + const totalBatches = Math.ceil(options.mentors.length / CONCURRENCY); + console.log( + `[Bulk Mentor Invites] Starting bulk send for ${options.mentors.length} mentors (concurrency: ${CONCURRENCY}, ${totalBatches} batches)` + ); + + // Process mentors in concurrent batches + for (let i = 0; i < options.mentors.length; i += CONCURRENCY) { + const batch = options.mentors.slice(i, i + CONCURRENCY); + const batchNum = Math.floor(i / CONCURRENCY) + 1; + const batchStartTime = Date.now(); + console.log( + `[Bulk Mentor Invites] Processing batch ${batchNum}/${totalBatches} (${batch.length} mentors)` + ); + + const batchResults = await Promise.allSettled( + batch.map((mentor) => processMentor(mentor, options)) + ); + + for (let j = 0; j < batchResults.length; j++) { + const result = batchResults[j]; + if (result.status === 'fulfilled') { + results.push(result.value); + successCount++; + } else { + const email = batch[j].email; + console.error( + `[Bulk Mentor Invites] ✗ Failed: ${email}`, + result.reason?.message + ); + results.push({ + email, + success: false, + error: result.reason?.message || 'Unknown error', + }); + failureCount++; + } + } + + console.log( + `[Bulk Mentor Invites] Batch ${batchNum}/${totalBatches} completed in ${ + Date.now() - batchStartTime + }ms` + ); + } + + const totalTime = Date.now() - totalStartTime; + console.log( + `[Bulk Mentor Invites] Complete: ${successCount} success, ${failureCount} failed in ${( + totalTime / 1000 + ).toFixed(1)}s` + ); + + return { + ok: failureCount === 0, + results, + successCount, + failureCount, + error: failureCount > 0 ? `${failureCount} invites failed` : null, + }; +} diff --git a/app/(api)/_actions/emails/sendHackerEmail.ts b/app/(api)/_actions/emails/sendHackerEmail.ts new file mode 100644 index 00000000..cc293db7 --- /dev/null +++ b/app/(api)/_actions/emails/sendHackerEmail.ts @@ -0,0 +1,156 @@ +'use server'; + +import createRsvpInvitation from '@actions/tito/createRsvpInvitation'; +import GenerateInvite from '@datalib/invite/generateInvite'; +import acceptedTemplate from './emailFormats/2026AcceptedTemplate'; +import waitlistedTemplate from './emailFormats/2026WaitlistedTemplate'; +import waitlistAcceptedTemplate from './emailFormats/2026WaitlistAcceptedTemplate'; +import waitlistRejectedTemplate from './emailFormats/2026WaitlistRejectedTemplate'; +import { DEFAULT_SENDER, transporter } from './transporter'; +import { + EmailType, + HackerEmailOptions, + HackerEmailResponse, +} from '@typeDefs/emails'; + +export default async function sendHackerEmail( + options: HackerEmailOptions +): Promise { + try { + console.log( + `[Hacker Email] Sending ${options.emailType} to ${options.email}` + ); + + if (!DEFAULT_SENDER) { + 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 titoStartTime = Date.now(); + const titoResponse = await createRsvpInvitation({ + firstName: options.firstName, + lastName: options.lastName, + email: options.email, + rsvpListSlug: options.rsvpListSlug, + releaseIds: options.releaseIds, + }); + const titoEndTime = Date.now(); + console.log( + `[Hacker Email] Tito API call took ${ + titoEndTime - titoStartTime + }ms for ${options.email}` + ); + + 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 hubStartTime = Date.now(); + const hubInviteResponse = await GenerateInvite( + { + email: options.email, + name: `${options.firstName} ${options.lastName}`, + role: 'hacker', + }, + 'invite' + ); + const hubEndTime = Date.now(); + console.log( + `[Hacker Email] Hub invite generation took ${ + hubEndTime - hubStartTime + }ms for ${options.email}` + ); + + 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 emailStartTime = Date.now(); + await transporter.sendMail({ + from: DEFAULT_SENDER, + to: options.email, + subject: getEmailSubject(options.emailType), + html: htmlContent, + }); + const emailEndTime = Date.now(); + console.log( + `[Hacker Email] Nodemailer sendMail took ${ + emailEndTime - emailStartTime + }ms for ${options.email}` + ); + + 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: EmailType): 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/(api)/_actions/emails/sendSingleMentorInvite.ts b/app/(api)/_actions/emails/sendSingleMentorInvite.ts new file mode 100644 index 00000000..f84619e2 --- /dev/null +++ b/app/(api)/_actions/emails/sendSingleMentorInvite.ts @@ -0,0 +1,74 @@ +'use server'; + +import createRsvpInvitation from '@actions/tito/createRsvpInvitation'; +import mentorInviteTemplate from './emailFormats/2026MentorInviteTemplate'; +import { DEFAULT_SENDER, transporter } from './transporter'; +import { SingleInviteResponse, SingleMentorOptions } from '@typeDefs/emails'; + +export default async function sendSingleMentorInvite( + options: SingleMentorOptions +): Promise { + try { + console.log(`[Single Mentor Invite] Sending invite to ${options.email}`); + + // 1. Create Tito invitation + const titoStartTime = Date.now(); + const titoResponse = await createRsvpInvitation({ + firstName: options.firstName, + lastName: options.lastName, + email: options.email, + rsvpListSlug: options.rsvpListSlug, + releaseIds: options.releaseIds, + }); + const titoEndTime = Date.now(); + console.log( + `[Single Mentor Invite] Tito API call took ${ + titoEndTime - titoStartTime + }ms for ${options.email}` + ); + + if (!titoResponse.ok || !titoResponse.body?.unique_url) { + throw new Error(titoResponse.error || 'Failed to create Tito invitation'); + } + + const titoUrl = titoResponse.body.unique_url; + + // 2. Send email with Tito URL + if (!DEFAULT_SENDER) { + throw new Error('Email configuration missing'); + } + + const htmlContent = mentorInviteTemplate(options.firstName, titoUrl); + + const emailStartTime = Date.now(); + await transporter.sendMail({ + from: DEFAULT_SENDER, + to: options.email, + subject: 'Welcome to HackDavis 2025 - Mentor Invitation', + html: htmlContent, + }); + const emailEndTime = Date.now(); + console.log( + `[Single Mentor Invite] Nodemailer sendMail took ${ + emailEndTime - emailStartTime + }ms for ${options.email}` + ); + + console.log( + `[Single Mentor Invite] ✓ Successfully sent to ${options.email}` + ); + + return { + ok: true, + titoUrl, + error: null, + }; + } catch (e) { + const error = e as Error; + console.error(`[Single Mentor Invite] ✗ Failed:`, error); + return { + ok: false, + error: error.message, + }; + } +} diff --git a/app/(api)/_actions/emails/transporter.ts b/app/(api)/_actions/emails/transporter.ts new file mode 100644 index 00000000..f6c18bc6 --- /dev/null +++ b/app/(api)/_actions/emails/transporter.ts @@ -0,0 +1,25 @@ +import nodemailer from 'nodemailer'; + +const SENDER_EMAIL = process.env.SENDER_EMAIL; +const SENDER_PWD = process.env.SENDER_PWD; + +// Validate environment variables +if (!SENDER_EMAIL || !SENDER_PWD) { + const missingVars: string[] = []; + if (!SENDER_EMAIL) missingVars.push('SENDER_EMAIL'); + if (!SENDER_PWD) missingVars.push('SENDER_PWD'); + console.error('Missing Environment Variable(s): ' + missingVars.join(', ')); +} + +// Create transporter with Gmail SMTP +export const transporter = nodemailer.createTransport({ + service: 'gmail', + pool: true, // to reuse connections for multiple emails + maxConnections: 10, // allow more concurrent SMTP connections (default is 5) + auth: { + user: SENDER_EMAIL, + pass: SENDER_PWD, + }, +}); + +export const DEFAULT_SENDER = SENDER_EMAIL; diff --git a/app/(api)/_actions/tito/createRsvpInvitation.ts b/app/(api)/_actions/tito/createRsvpInvitation.ts new file mode 100644 index 00000000..a8b061ea --- /dev/null +++ b/app/(api)/_actions/tito/createRsvpInvitation.ts @@ -0,0 +1,139 @@ +'use server'; + +import { + ReleaseInvitation, + ReleaseInvitationRequest, + TitoResponse, +} from '@typeDefs/tito'; + +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; + +export default async function createRsvpInvitation( + data: ReleaseInvitationRequest +): 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 fetchStartTime = Date.now(); + 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, + }), + }); + const fetchEndTime = Date.now(); + console.log( + `[Tito API] HTTP POST request took ${ + fetchEndTime - fetchStartTime + }ms for ${data.email}` + ); + + 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 for', + invitation.email + ); + 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, + 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..f8222bd0 --- /dev/null +++ b/app/(api)/_actions/tito/createRsvpList.ts @@ -0,0 +1,77 @@ +'use server'; + +import { RsvpList, TitoResponse } from '@typeDefs/tito'; + +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; + +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 fetchStartTime = Date.now(); + 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(), + }, + }), + }); + const fetchEndTime = Date.now(); + console.log( + `[Tito API] HTTP POST create RSVP list request took ${fetchEndTime - fetchStartTime}ms` + ); + + 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..5567c829 --- /dev/null +++ b/app/(api)/_actions/tito/getReleases.ts @@ -0,0 +1,60 @@ +'use server'; +import { Release, TitoResponse } from '@typeDefs/tito'; + +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; + +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`; + + const fetchStartTime = Date.now(); + const response = await fetch(url, { + method: 'GET', + headers: { + Authorization: `Token token=${TITO_API_TOKEN}`, + Accept: 'application/json', + }, + }); + const fetchEndTime = Date.now(); + console.log( + `[Tito API] HTTP GET releases request took ${ + fetchEndTime - fetchStartTime + }ms` + ); + + 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'); + + 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..7470af71 --- /dev/null +++ b/app/(api)/_actions/tito/getRsvpLists.ts @@ -0,0 +1,69 @@ +'use server'; + +import { RsvpList, TitoResponse } from '@typeDefs/tito'; + +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; + +export default async function getRsvpLists(): Promise< + TitoResponse +> { + 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 fetchStartTime = Date.now(); + const response = await fetch(url, { + method: 'GET', + headers: { + Authorization: `Token token=${TITO_API_TOKEN}`, + Accept: 'application/json', + }, + }); + const fetchEndTime = Date.now(); + console.log( + `[Tito API] HTTP GET RSVP lists request took ${ + fetchEndTime - fetchStartTime + }ms` + ); + + 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/HackerEmailSender/HackerEmailSender.tsx b/app/(pages)/admin/_components/HackerEmailSender/HackerEmailSender.tsx new file mode 100644 index 00000000..bdb869df --- /dev/null +++ b/app/(pages)/admin/_components/HackerEmailSender/HackerEmailSender.tsx @@ -0,0 +1,354 @@ +'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'; +import { RsvpList, Release } from '@typeDefs/tito'; +import { EmailType } from '@typeDefs/emails'; + +const EMAIL_TYPES: { value: EmailType; label: string }[] = [ + { + value: '2026AcceptedTemplate', + label: 'Accepted (with Hub + Tito invite)', + }, + { value: '2026WaitlistedTemplate', label: 'Waitlisted' }, + { + value: '2026WaitlistAcceptedTemplate', + label: 'Waitlist Accepted (with Hub + Tito invite)', + }, + { value: '2026WaitlistRejectedTemplate', label: 'Waitlist Rejected' }, +]; + +export default function HackerEmailSender() { + const [rsvpLists, setRsvpLists] = useState([]); + const [releases, setReleases] = useState([]); + const [selectedListSlug, setSelectedListSlug] = useState(''); + const [selectedEmailType, setSelectedEmailType] = useState( + '2026AcceptedTemplate' + ); + 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, + rsvpListSlug: + selectedEmailType === '2026AcceptedTemplate' + ? selectedListSlug + : undefined, + releaseIds: + selectedEmailType === '2026AcceptedTemplate' ? 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 === '2026AcceptedTemplate'; + + 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/MentorBulkInvite/MentorBulkInvite.tsx b/app/(pages)/admin/_components/MentorBulkInvite/MentorBulkInvite.tsx new file mode 100644 index 00000000..d74a60ca --- /dev/null +++ b/app/(pages)/admin/_components/MentorBulkInvite/MentorBulkInvite.tsx @@ -0,0 +1,448 @@ +'use client'; + +import { FormEvent, useState, useEffect, useRef } from 'react'; +import { RsvpList, Release } from '@typeDefs/tito'; +import { MentorData } from '@typeDefs/emails'; +import getRsvpLists from '@actions/tito/getRsvpLists'; +import getReleases from '@actions/tito/getReleases'; +import sendBulkMentorInvites from '@actions/emails/sendBulkMentorInvites'; +import sendSingleMentorInvite from '@actions/emails/sendSingleMentorInvite'; + +export default function MentorBulkInvite() { + const [rsvpLists, setRsvpLists] = useState([]); + const [releases, setReleases] = useState([]); + const [selectedListSlug, setSelectedListSlug] = useState(''); + const [mentors, setMentors] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [success, setSuccess] = useState(''); + const [results, setResults] = useState([]); + const [selectedReleases, setSelectedReleases] = useState([]); + const [inviteMode, setInviteMode] = useState<'single' | 'bulk'>('bulk'); + const [titoUrl, setTitoUrl] = useState(''); + const fileInputRef = useRef(null); + + 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 handleFileUpload = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + const reader = new FileReader(); + reader.onload = (event) => { + try { + const text = event.target?.result as string; + const lines = text.split('\n').filter((line) => line.trim()); + + // Skip header row + const dataLines = lines.slice(1); + + const parsed: MentorData[] = dataLines + .map((line) => { + const [firstName, lastName, email] = line + .split(',') + .map((s) => s.trim()); + return { firstName, lastName, email }; + }) + .filter((m) => m.email && m.firstName && m.lastName); + + setMentors(parsed); + setError(''); + setSuccess(`Loaded ${parsed.length} mentors from CSV`); + } catch (err) { + setError('Failed to parse CSV file'); + } + }; + reader.readAsText(file); + }; + + const handleClearCsv = () => { + setMentors([]); + setResults([]); + setError(''); + setSuccess(''); + setTitoUrl(''); + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + }; + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + + if (!selectedListSlug) { + setError('Please select an RSVP list'); + return; + } + + if (selectedReleases.length === 0) { + setError('Please select at least one release'); + return; + } + + setLoading(true); + setError(''); + setSuccess(''); + setResults([]); + setTitoUrl(''); + + const releaseIds = selectedReleases.join(','); + + try { + if (inviteMode === 'single') { + // Single invite mode + 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 response = await sendSingleMentorInvite({ + firstName, + lastName, + email, + rsvpListSlug: selectedListSlug, + releaseIds, + }); + + if (response.ok) { + setSuccess(`Invitation sent successfully to ${email}!`); + if (response.titoUrl) { + setTitoUrl(response.titoUrl); + } + (e.target as HTMLFormElement).reset(); + } else { + setError(response.error || 'Failed to send invitation'); + } + } else { + // Bulk invite mode + if (mentors.length === 0) { + setError('Please upload a CSV file first'); + setLoading(false); + return; + } + + const response = await sendBulkMentorInvites({ + mentors, + rsvpListSlug: selectedListSlug, + releaseIds, + }); + + setResults(response.results); + + if (response.ok) { + setSuccess( + `Successfully sent ${response.successCount} mentor invitations!` + ); + } else { + setError( + `Sent ${response.successCount} invitations, but ${response.failureCount} failed` + ); + } + } + } catch (err) { + setError('An unexpected error occurred'); + } finally { + setLoading(false); + } + }; + + return ( +
+

+ Send Mentor Invitations +

+ +
+
+ + +
+ + {inviteMode === 'single' ? ( + <> +
+ + +
+ +
+ + +
+ +
+ + +
+ + ) : ( +
+ +

+ CSV format: FirstName, LastName, Email (with header row) +

+ + {mentors.length > 0 && ( +
+

+ ✓ Loaded {mentors.length} mentors +

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

{success}

+ {inviteMode === 'single' && titoUrl && ( +
+ Tito URL: +
+ + +
+
+ )} +
+ )} + + {error && ( +
+ {error} +
+ )} + + {results.length > 0 && inviteMode === 'bulk' && ( +
+

Results

+
+ {results.map((result, idx) => ( +
+ {result.email}:{' '} + {result.success ? '✓ Sent' : `✗ ${result.error}`} + {result.titoUrl && ( +
+ {result.titoUrl} +
+ )} +
+ ))} +
+
+ )} +
+ ); +} diff --git a/app/(pages)/admin/_components/TitoRsvpManager/TitoRsvpManager.tsx b/app/(pages)/admin/_components/TitoRsvpManager/TitoRsvpManager.tsx new file mode 100644 index 00000000..08b7c311 --- /dev/null +++ b/app/(pages)/admin/_components/TitoRsvpManager/TitoRsvpManager.tsx @@ -0,0 +1,439 @@ +'use client'; + +import { FormEvent, useCallback, useEffect, useState } from 'react'; +import { RsvpList, Release } from '@typeDefs/tito'; +import getRsvpLists from '@actions/tito/getRsvpLists'; +import createRsvpList from '@actions/tito/createRsvpList'; +import createRsvpInvitation from '@actions/tito/createRsvpInvitation'; +import getReleases from '@actions/tito/getReleases'; + +export default function TitoRsvpManager() { + const [rsvpLists, setRsvpLists] = useState([]); + const [selectedListId, setSelectedListId] = useState(''); + const [selectedListSlug, setSelectedListSlug] = useState(''); + const [releases, setReleases] = useState([]); + 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([]); + + const loadReleases = useCallback(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 = useCallback(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); + } + }, [selectedListId]); + + useEffect(() => { + loadRsvpLists(); + loadReleases(); + }, [loadRsvpLists, loadReleases]); + + const handleCreateList = async (e: FormEvent) => { + e.preventDefault(); + if (!newListTitle.trim()) { + setError('Please enter a list title'); + return; + } + + setLoading(true); + setError(''); + setSuccess(''); + setInvitationUrl(''); + + 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; + } + + if (selectedReleases.length === 0) { + setError('Please select at least one release'); + 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 discountCode = formData.get('discountCode') as string; + const releaseIds = selectedReleases.join(','); + + try { + const response = await createRsvpInvitation({ + firstName, + lastName, + email, + rsvpListSlug: selectedListSlug, + releaseIds, + discountCode: discountCode || undefined, + }); + + 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}${ + fullName ? ` (${fullName})` : '' + }` + ); + 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'); + } + } 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} + className="w-full p-3 border border-gray-300 rounded-lg focus:outline-none focus:border-blue-500 disabled:bg-gray-100" + /> +
+ +
+ )} + +
+ + + +
+
+ +
+

+ Send Release Invitation +

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

{success}

+ {invitationUrl && ( +
+

+ Invitation URL: +

+
+ + +
+ + Open invitation link + +
+ )} +
+ )} +
+ ); +} diff --git a/app/(pages)/admin/hacker-emails/page.tsx b/app/(pages)/admin/hacker-emails/page.tsx new file mode 100644 index 00000000..e691ab49 --- /dev/null +++ b/app/(pages)/admin/hacker-emails/page.tsx @@ -0,0 +1,11 @@ +'use client'; + +import HackerEmailSender from '../_components/HackerEmailSender/HackerEmailSender'; + +export default function HackerEmailsPage() { + return ( +
+ +
+ ); +} diff --git a/app/(pages)/admin/mentor-invites/page.tsx b/app/(pages)/admin/mentor-invites/page.tsx new file mode 100644 index 00000000..abdd5e18 --- /dev/null +++ b/app/(pages)/admin/mentor-invites/page.tsx @@ -0,0 +1,11 @@ +'use client'; + +import MentorBulkInvite from '../_components/MentorBulkInvite/MentorBulkInvite'; + +export default function MentorInvitesPage() { + return ( +
+ +
+ ); +} diff --git a/app/(pages)/admin/page.tsx b/app/(pages)/admin/page.tsx index c7a1f1b5..92b6d396 100644 --- a/app/(pages)/admin/page.tsx +++ b/app/(pages)/admin/page.tsx @@ -25,6 +25,18 @@ const action_links = [ href: '/admin/invite-link', body: 'Invite Judges', }, + { + href: '/admin/tito-rsvp', + body: 'Tito RSVP Management', + }, + { + href: '/admin/mentor-invites', + body: 'Send Mentor Invites (CSV)', + }, + { + href: '/admin/hacker-emails', + body: 'Send Hacker Emails', + }, { href: '/admin/randomize-projects', body: 'Randomize Projects', 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 ( +
+ +
+ ); +} diff --git a/app/_types/emails.ts b/app/_types/emails.ts new file mode 100644 index 00000000..9733aac7 --- /dev/null +++ b/app/_types/emails.ts @@ -0,0 +1,62 @@ +export interface MentorData { + firstName: string; + lastName: string; + email: string; +} + +export interface BulkInviteOptions { + mentors: MentorData[]; + rsvpListSlug: string; + releaseIds: string; +} + +export interface InviteResult { + email: string; + success: boolean; + titoUrl?: string; + error?: string; +} + +export interface BulkInviteResponse { + ok: boolean; + results: InviteResult[]; + successCount: number; + failureCount: number; + error: string | null; +} + +export interface SingleMentorOptions { + firstName: string; + lastName: string; + email: string; + rsvpListSlug: string; + releaseIds: string; +} + +export interface SingleInviteResponse { + ok: boolean; + titoUrl?: string; + error: string | null; +} + +export type EmailType = + | '2026AcceptedTemplate' + | '2026WaitlistedTemplate' + | '2026WaitlistAcceptedTemplate' + | '2026WaitlistRejectedTemplate'; + +export interface HackerEmailOptions { + firstName: string; + lastName: string; + email: string; + emailType: EmailType; + rsvpListSlug?: string; + releaseIds?: string; +} + +export interface HackerEmailResponse { + ok: boolean; + titoUrl?: string; + hubUrl?: string; + error: string | null; +} diff --git a/app/_types/tito.ts b/app/_types/tito.ts new file mode 100644 index 00000000..a80a2d5b --- /dev/null +++ b/app/_types/tito.ts @@ -0,0 +1,41 @@ +export interface RsvpList { + id: string; + slug: string; + title: string; + release_ids?: number[]; + question_ids?: number[]; + activity_ids?: number[]; +} + +export interface Release { + id: string; + slug: string; + title: string; + quantity?: number; +} + +export interface ReleaseInvitation { + id: string; + slug: string; + email: string; + first_name: string; + last_name: string; + url?: string; + unique_url?: string; + created_at: string; +} + +export interface ReleaseInvitationRequest { + firstName: string; + lastName: string; + email: string; + rsvpListSlug: string; + releaseIds: string; + discountCode?: string; +} + +export interface TitoResponse { + ok: boolean; + body: T | null; + error: string | null; +} diff --git a/public/email/2025_email_header.png b/public/email/2025_email_header.png new file mode 100644 index 00000000..0c829d69 Binary files /dev/null and b/public/email/2025_email_header.png differ