Merge pull request #6 from Ryderjj89/dev

Event conclusion message, calendar invites, & more attendee management
This commit is contained in:
Joshua Ryder
2025-06-03 17:22:01 -04:00
committed by GitHub
10 changed files with 1039 additions and 384 deletions

View File

@@ -14,6 +14,9 @@ This project was created completely by the [Cursor AI Code Editor](https://www.c
- Comprehensive admin interface for event management - Comprehensive admin interface for event management
- Email notifications for submitted RSVPs - Email notifications for submitted RSVPs
- Individual submission links so users can edit their submissions - Individual submission links so users can edit their submissions
- ICS calendar event so users can add your event to their calendar
- Customizable thank you/event conclusion message that can be automatically sent to your guests at 8 AM the following day
- This time is based on your local time zone that you specify with the TZ environment variable
- Item Coordination - Item Coordination
- Create and manage lists of needed items for events - Create and manage lists of needed items for events
@@ -25,8 +28,10 @@ This project was created completely by the [Cursor AI Code Editor](https://www.c
- Guest Management - Guest Management
- Track attendance status (yes/no) - Track attendance status (yes/no)
- Support for bringing additional guests - Support for bringing additional guests
- Keep track of guest names - Keep track of guest names and email addresses
- View all RSVPs and items being brought - View all RSVPs and items being brought
- Edit any part of a user's submission
- Re-send confirmation emails or just copy the unique submission link to send to the user in a message
- Modern, Responsive UI - Modern, Responsive UI
- Clean, intuitive interface - Clean, intuitive interface
@@ -67,7 +72,7 @@ There are 2 branches, latest & dev.
#### Environment Variables #### Environment Variables
These variables below are all for the email notifications. If you want to be able to send email notifications, each of these needs to be provided and filled out. These variables below are all for the email notifications. If you want to be able to send email notifications correctly, each of these needs to be provided and filled out.
| Variable | Description | | Variable | Description |
| ------------- | ------------- | | ------------- | ------------- |
@@ -78,6 +83,7 @@ These variables below are all for the email notifications. If you want to be abl
| EMAIL_FROM_NAME | Name displayed in the "from" on email notifications | | EMAIL_FROM_NAME | Name displayed in the "from" on email notifications |
| EMAIL_FROM_ADDRESS | Email displayed in the "from" on email notifications | | EMAIL_FROM_ADDRESS | Email displayed in the "from" on email notifications |
| FRONTEND_BASE_URL | The main URL for your instance. This will be used in the links that are sent in the email notificiations, eg. https://rsvp.example.com | | FRONTEND_BASE_URL | The main URL for your instance. This will be used in the links that are sent in the email notificiations, eg. https://rsvp.example.com |
| TZ | Your [time zone](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) to use for scheduling the event conclusion/thank you messages |
#### Docker Compose #### Docker Compose
@@ -105,6 +111,7 @@ docker run -d --name rsvp-manager \
-e EMAIL_FROM_ADDRESS=name@example.com \ -e EMAIL_FROM_ADDRESS=name@example.com \
-e EMAIL_SECURE=true or false \ -e EMAIL_SECURE=true or false \
-e FRONTEND_BASE_URL=https://rsvp.example.com \ -e FRONTEND_BASE_URL=https://rsvp.example.com \
-e TZ=<CHANGE THIS!>
--restart unless-stopped \ --restart unless-stopped \
ryderjj89/rsvp-manager:<CHANGE THIS TAG!> ryderjj89/rsvp-manager:<CHANGE THIS TAG!>
``` ```
@@ -140,7 +147,7 @@ docker run -d --name rsvp-manager \
By setting up the environment variables in the `docker-compose.yml`, you can have notifications sent to the recipients of your choice when someone submits an RSVP to an event. The notification will include the details of their submission and links to view or manage the RSVPs for that event. By setting up the environment variables in the `docker-compose.yml`, you can have notifications sent to the recipients of your choice when someone submits an RSVP to an event. The notification will include the details of their submission and links to view or manage the RSVPs for that event.
Users will also have the option to get an email confirmation of their submission that will include a unique link to view/edit their submission! Email notifications will also be sent to users when they complete their submission that will serve as a confirmation of their RSVP. It will include a link to view/edit their own submission. It will also include a link to a downloadable ICS file that users can use to add the event to their calendar!
## Authentication with Authentik ## Authentication with Authentik

View File

@@ -16,9 +16,11 @@
"sqlite3": "^5.1.6", "sqlite3": "^5.1.6",
"sqlite": "^4.1.2", "sqlite": "^4.1.2",
"nodemailer": "^6.9.8", "nodemailer": "^6.9.8",
"multer": "^1.4.5-lts.1" "multer": "^1.4.5-lts.1",
"node-cron": "^3.0.3"
}, },
"devDependencies": { "devDependencies": {
"@types/node-cron": "^3.0.11",
"@types/express": "^4.17.17", "@types/express": "^4.17.17",
"@types/node": "^20.4.5", "@types/node": "^20.4.5",
"@types/cors": "^2.8.13", "@types/cors": "^2.8.13",

View File

@@ -1,5 +1,90 @@
import nodemailer from 'nodemailer'; import nodemailer from 'nodemailer';
// Function to generate ICS calendar content
export function generateICSContent(eventData: {
title: string;
description: string;
location: string;
date: string; // ISO date string
slug: string;
rsvp_cutoff_date?: string; // Optional RSVP cutoff date
}): string {
const { title, description, location, date, slug, rsvp_cutoff_date } = eventData;
// Convert date to ICS format (YYYYMMDDTHHMMSSZ)
const eventDate = new Date(date);
const startDate = eventDate.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
// Set end time to 4 hours after start time (default duration)
const endDate = new Date(eventDate.getTime() + 4 * 60 * 60 * 1000);
const endDateFormatted = endDate.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
// Generate unique ID for the event
const uid = `${slug}-${Date.now()}@rsvp-manager`;
// Current timestamp for DTSTAMP
const now = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
// Build description with RSVP cutoff note
let fullDescription = description || '';
if (rsvp_cutoff_date) {
const cutoffDate = new Date(rsvp_cutoff_date);
const formattedCutoff = cutoffDate.toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
timeZoneName: 'short'
});
const rsvpNote = `\n\nNote: The RSVP cut-off for this event is ${formattedCutoff}. Make sure you get your reservation in before then!`;
fullDescription += rsvpNote;
}
// Clean description for ICS format (remove HTML, escape special chars)
const cleanDescription = fullDescription
.replace(/<[^>]*>/g, '') // Remove HTML tags
.replace(/,/g, '\\,') // Escape commas
.replace(/;/g, '\\;') // Escape semicolons
.replace(/\n/g, '\\n'); // Escape newlines (do this last to avoid double escaping)
// Clean location for ICS format
const cleanLocation = location
.replace(/,/g, '\\,')
.replace(/;/g, '\\;');
// Clean title for ICS format
const cleanTitle = title
.replace(/,/g, '\\,')
.replace(/;/g, '\\;')
.replace(/\\/g, '\\\\');
const icsContent = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//RSVP Manager//Event Calendar//EN',
'CALSCALE:GREGORIAN',
'METHOD:PUBLISH',
'BEGIN:VEVENT',
`UID:${uid}`,
`DTSTAMP:${now}`,
`DTSTART:${startDate}`,
`DTEND:${endDateFormatted}`,
`SUMMARY:${cleanTitle}`,
`DESCRIPTION:${cleanDescription}`,
`LOCATION:${cleanLocation}`,
'STATUS:CONFIRMED',
'TRANSP:OPAQUE',
'END:VEVENT',
'END:VCALENDAR'
].join('\r\n');
return icsContent;
}
const transporter = nodemailer.createTransport({ const transporter = nodemailer.createTransport({
host: process.env.EMAIL_HOST, host: process.env.EMAIL_HOST,
port: Number(process.env.EMAIL_PORT), port: Number(process.env.EMAIL_PORT),
@@ -98,11 +183,21 @@ export async function sendRSVPEditLinkEmail(data: RSVPEditLinkEmailData) {
const subject = `Confirming your RSVP for ${eventTitle}`; // Update the subject line const subject = `Confirming your RSVP for ${eventTitle}`; // Update the subject line
// Generate calendar download link
const baseUrl = process.env.FRONTEND_BASE_URL || '';
const calendarLink = `${baseUrl}/api/events/${eventSlug}/calendar.ics`;
const html = ` const html = `
<p>Hello ${name},</p> <p>Hello ${name},</p>
<p>You have successfully RSVP'd for the event "${eventTitle}".</p> <p>You have successfully RSVP'd for the event "${eventTitle}".</p>
<p>You can edit your RSVP at any time by clicking the link below:</p> <p>You can edit your RSVP at any time by clicking the link below:</p>
<p><a href="${editLink}">${editLink}</a></p> <p><a href="${editLink}">${editLink}</a></p>
<p style="margin: 20px 0;">
<a href="${calendarLink}"
style="color: #0066cc; padding: 12px 24px; text-decoration: none; border: 2px solid #0066cc; border-radius: 4px; display: inline-block; font-weight: bold;">
📅 Add to Calendar!
</a>
</p>
<p>Please save this email if you think you might need to edit your submission later.</p> <p>Please save this email if you think you might need to edit your submission later.</p>
<p>Thank you!</p> <p>Thank you!</p>
`; `;
@@ -117,3 +212,36 @@ export async function sendRSVPEditLinkEmail(data: RSVPEditLinkEmailData) {
html, html,
}); });
} }
export interface EventConclusionEmailData {
eventTitle: string;
attendeeName: string;
message: string;
to: string;
}
export async function sendEventConclusionEmail(data: EventConclusionEmailData) {
const {
eventTitle,
attendeeName,
message,
to,
} = data;
const subject = `Thank you for attending ${eventTitle}!`; // Subject for the conclusion email
const html = `
<p>Hello ${attendeeName},</p>
<p>${message}</p>
`;
await transporter.sendMail({
from: {
name: process.env.EMAIL_FROM_NAME || '',
address: process.env.EMAIL_FROM_ADDRESS || process.env.EMAIL_USER || '',
},
to,
subject,
html,
});
}

View File

@@ -6,7 +6,8 @@ import dotenv from 'dotenv';
import path from 'path'; import path from 'path';
import multer from 'multer'; import multer from 'multer';
import fs from 'fs'; import fs from 'fs';
import { sendRSVPEmail, sendRSVPEditLinkEmail } from './email'; // Import the new email function import { sendRSVPEmail, sendRSVPEditLinkEmail, sendEventConclusionEmail, generateICSContent } from './email'; // Import the new email function and ICS generator
import cron from 'node-cron'; // Import node-cron for scheduling
dotenv.config(); dotenv.config();
@@ -46,6 +47,10 @@ interface RSVP {
guest_count: number; guest_count: number;
guest_names: string | null; guest_names: string | null;
items_bringing: string | null; items_bringing: string | null;
other_items: string | null;
edit_id: string;
send_event_conclusion_email: boolean; // Added field for event conclusion email opt-in
attendee_email: string | null; // Added field for attendee email
created_at?: string; created_at?: string;
} }
@@ -108,21 +113,13 @@ app.get('/api/events', async (req: Request, res: Response) => {
try { try {
const rows = await db.all('SELECT * FROM events'); const rows = await db.all('SELECT * FROM events');
// Add the full path to wallpapers // Add the full path to wallpapers and parse JSON fields
const events = rows.map((event: { const events = rows.map((event: any) => ({
id: number;
title: string;
description: string;
date: string;
location: string;
slug: string;
needed_items: string | null;
wallpaper: string | null;
created_at: string;
}) => ({
...event, ...event,
wallpaper: event.wallpaper ? `/uploads/wallpapers/${event.wallpaper}` : null, wallpaper: event.wallpaper ? `/uploads/wallpapers/${event.wallpaper}` : null,
needed_items: event.needed_items ? JSON.parse(event.needed_items) : [] needed_items: event.needed_items ? JSON.parse(event.needed_items) : [],
email_notifications_enabled: Boolean(event.email_notifications_enabled),
event_conclusion_email_enabled: Boolean(event.event_conclusion_email_enabled),
})); }));
res.json(events); res.json(events);
@@ -140,13 +137,18 @@ app.get('/api/events/:slug', async (req: Request, res: Response) => {
return res.status(404).json({ error: 'Event not found' }); return res.status(404).json({ error: 'Event not found' });
} }
// Parse needed_items from JSON string to array // Parse needed_items from JSON string to array and boolean fields
const event = rows[0]; const event = rows[0];
console.log('Raw event_conclusion_message from DB:', event.event_conclusion_message); // Keep this line for now, it's helpful for debugging
try { try {
event.needed_items = event.needed_items ? JSON.parse(event.needed_items) : []; event.needed_items = event.needed_items ? JSON.parse(event.needed_items) : [];
event.email_notifications_enabled = Boolean(event.email_notifications_enabled);
event.event_conclusion_email_enabled = Boolean(event.event_conclusion_email_enabled);
} catch (e) { } catch (e) {
console.error('Error parsing needed_items:', e); console.error('Error parsing event JSON/boolean fields:', e);
event.needed_items = []; event.needed_items = [];
event.email_notifications_enabled = false;
event.event_conclusion_email_enabled = false;
} }
// Add the full path to the wallpaper // Add the full path to the wallpaper
@@ -154,7 +156,11 @@ app.get('/api/events/:slug', async (req: Request, res: Response) => {
event.wallpaper = `/uploads/wallpapers/${event.wallpaper}`; event.wallpaper = `/uploads/wallpapers/${event.wallpaper}`;
} }
// Explicitly ensure event_conclusion_message is a string or null before sending
event.event_conclusion_message = typeof event.event_conclusion_message === 'string' ? event.event_conclusion_message : null;
res.json(event); res.json(event);
} catch (error) { } catch (error) {
console.error('Error fetching event:', error); console.error('Error fetching event:', error);
res.status(500).json({ error: 'Internal server error' }); res.status(500).json({ error: 'Internal server error' });
@@ -172,7 +178,9 @@ app.post('/api/events', upload.single('wallpaper'), async (req: MulterRequest, r
rsvp_cutoff_date, rsvp_cutoff_date,
max_guests_per_rsvp, max_guests_per_rsvp,
email_notifications_enabled, email_notifications_enabled,
email_recipients email_recipients,
event_conclusion_email_enabled, // Receive new field
event_conclusion_message // Receive new field
} = req.body; } = req.body;
const wallpaperPath = req.file ? `${req.file.filename}` : null; const wallpaperPath = req.file ? `${req.file.filename}` : null;
@@ -194,12 +202,28 @@ app.post('/api/events', upload.single('wallpaper'), async (req: MulterRequest, r
// Parse max_guests_per_rsvp to ensure it's a number // Parse max_guests_per_rsvp to ensure it's a number
const maxGuests = parseInt(max_guests_per_rsvp as string) || 0; const maxGuests = parseInt(max_guests_per_rsvp as string) || 0;
// Parse email_notifications_enabled to ensure it's a boolean // Parse boolean fields
const emailNotificationsEnabled = email_notifications_enabled === 'true' || email_notifications_enabled === true; const emailNotificationsEnabled = email_notifications_enabled === 'true' || email_notifications_enabled === true;
const eventConclusionEmailEnabled = event_conclusion_email_enabled === 'true' || event_conclusion_email_enabled === true;
const result = await db.run( const result = await db.run(
'INSERT INTO events (title, description, date, location, slug, needed_items, wallpaper, rsvp_cutoff_date, max_guests_per_rsvp, email_notifications_enabled, email_recipients) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', 'INSERT INTO events (title, description, date, location, slug, needed_items, wallpaper, rsvp_cutoff_date, max_guests_per_rsvp, email_notifications_enabled, email_recipients, event_conclusion_email_enabled, event_conclusion_message) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[title, description, date, location, slug, JSON.stringify(parsedNeededItems), wallpaperPath, rsvp_cutoff_date, maxGuests, emailNotificationsEnabled ? 1 : 0, email_recipients || ''] [
title,
description,
date,
location,
slug,
JSON.stringify(parsedNeededItems),
wallpaperPath,
rsvp_cutoff_date,
maxGuests,
emailNotificationsEnabled ? 1 : 0,
email_recipients || '',
eventConclusionEmailEnabled ? 1 : 0, // Save new field
event_conclusion_message === undefined ? null : event_conclusion_message // Save new field
]
); );
res.status(201).json({ res.status(201).json({
@@ -210,7 +234,9 @@ app.post('/api/events', upload.single('wallpaper'), async (req: MulterRequest, r
rsvp_cutoff_date, rsvp_cutoff_date,
max_guests_per_rsvp: maxGuests, max_guests_per_rsvp: maxGuests,
email_notifications_enabled: emailNotificationsEnabled, email_notifications_enabled: emailNotificationsEnabled,
email_recipients: email_recipients || '' email_recipients: email_recipients || '',
event_conclusion_email_enabled: eventConclusionEmailEnabled, // Include in response
event_conclusion_message: event_conclusion_message || '' // Include in response
}); });
} catch (error) { } catch (error) {
console.error('Error creating event:', error); console.error('Error creating event:', error);
@@ -258,20 +284,22 @@ app.get('/api/events/:slug/rsvps', async (req: Request, res: Response) => {
const eventId = eventRows[0].id; const eventId = eventRows[0].id;
const rows = await db.all('SELECT * FROM rsvps WHERE event_id = ?', [eventId]); const rows = await db.all('SELECT * FROM rsvps WHERE event_id = ?', [eventId]);
// Parse JSON arrays in each RSVP // Parse JSON arrays and boolean fields in each RSVP
const parsedRows = rows.map((rsvp: RSVP) => { const parsedRows = rows.map((rsvp: RSVP) => {
try { try {
return { return {
...rsvp, ...rsvp,
items_bringing: rsvp.items_bringing ? JSON.parse(rsvp.items_bringing) : [], items_bringing: rsvp.items_bringing ? JSON.parse(rsvp.items_bringing) : [],
guest_names: rsvp.guest_names ? JSON.parse(rsvp.guest_names) : [] guest_names: rsvp.guest_names ? JSON.parse(rsvp.guest_names) : [],
send_event_conclusion_email: Boolean(rsvp.send_event_conclusion_email), // Parse new field
}; };
} catch (e) { } catch (e) {
console.error('Error parsing RSVP JSON fields:', e); console.error('Error parsing RSVP JSON/boolean fields:', e);
return { return {
...rsvp, ...rsvp,
items_bringing: [], items_bringing: [],
guest_names: [] guest_names: [],
send_event_conclusion_email: false, // Default value on error
}; };
} }
}); });
@@ -294,8 +322,9 @@ app.post('/api/events/:slug/rsvp', async (req: Request, res: Response) => {
guest_names, guest_names,
items_bringing, items_bringing,
other_items, other_items,
send_email_confirmation, // New field for email opt-in send_email_confirmation, // Existing field for RSVP confirmation email opt-in
email_address // New field for recipient email email_address, // Existing field for recipient email
send_event_conclusion_email // Receive new field for conclusion email opt-in
} = req.body; } = req.body;
// Get the event with email notification settings // Get the event with email notification settings
@@ -309,7 +338,7 @@ app.post('/api/events/:slug/rsvp', async (req: Request, res: Response) => {
const eventId = event.id; const eventId = event.id;
const eventTitle = event.title; const eventTitle = event.title;
const eventSlug = event.slug; const eventSlug = event.slug;
const emailNotificationsEnabled = event.email_notifications_enabled; const emailNotificationsEnabled = Boolean(event.email_notifications_enabled);
const eventEmailRecipients = event.email_recipients; const eventEmailRecipients = event.email_recipients;
// Parse items_bringing if it's a string // Parse items_bringing if it's a string
@@ -327,15 +356,26 @@ app.post('/api/events/:slug/rsvp', async (req: Request, res: Response) => {
// Parse guest_names if it's a string // Parse guest_names if it's a string
let parsedGuestNames: string[] = []; let parsedGuestNames: string[] = [];
try { try {
if (typeof guest_names === 'string') { if (typeof guest_names === 'string' && guest_names.includes('[')) {
// If it's a JSON string array
parsedGuestNames = JSON.parse(guest_names); parsedGuestNames = JSON.parse(guest_names);
} else if (typeof guest_names === 'string') {
// If it's a comma-separated string
parsedGuestNames = guest_names.split(',').map(name => name.trim()).filter(name => name);
} else if (Array.isArray(guest_names)) { } else if (Array.isArray(guest_names)) {
parsedGuestNames = guest_names; // If it's already an array
parsedGuestNames = guest_names.filter(name => name && name.trim());
} }
} catch (e) { } catch (e) {
console.error('Error parsing guest_names:', e); console.error('Error parsing guest_names:', e);
parsedGuestNames = [];
} }
// Parse new boolean field
const sendEventConclusionEmailBool = send_event_conclusion_email === 'true' || send_event_conclusion_email === true;
const attendeeEmail = email_address?.trim() || null; // Store attendee email
// Generate a unique edit ID // Generate a unique edit ID
let editId = ''; let editId = '';
let isUnique = false; let isUnique = false;
@@ -348,8 +388,20 @@ app.post('/api/events/:slug/rsvp', async (req: Request, res: Response) => {
} }
const result = await db.run( const result = await db.run(
'INSERT INTO rsvps (event_id, name, attending, bringing_guests, guest_count, guest_names, items_bringing, other_items, edit_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', 'INSERT INTO rsvps (event_id, name, attending, bringing_guests, guest_count, guest_names, items_bringing, other_items, edit_id, send_event_conclusion_email, attendee_email) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[eventId, name, attending, bringing_guests, guest_count, JSON.stringify(parsedGuestNames), JSON.stringify(parsedItemsBringing), other_items || '', editId] [
eventId,
name,
attending,
bringing_guests,
guest_count,
JSON.stringify(parsedGuestNames),
JSON.stringify(parsedItemsBringing),
other_items || '',
editId,
sendEventConclusionEmailBool ? 1 : 0, // Save new field
attendeeEmail // Save new field
]
); );
// Send email notifications to event recipients if enabled for this event // Send email notifications to event recipients if enabled for this event
@@ -395,23 +447,22 @@ app.post('/api/events/:slug/rsvp', async (req: Request, res: Response) => {
// Send email confirmation with edit link to the submitter if requested // Send email confirmation with edit link to the submitter if requested
const sendEmailConfirmationBool = send_email_confirmation === 'true' || send_email_confirmation === true; const sendEmailConfirmationBool = send_email_confirmation === 'true' || send_email_confirmation === true;
const submitterEmail = email_address?.trim();
if (sendEmailConfirmationBool && submitterEmail && process.env.EMAIL_USER) { if (sendEmailConfirmationBool && attendeeEmail && process.env.EMAIL_USER) {
try { try {
const editLink = `${process.env.FRONTEND_BASE_URL}/events/${eventSlug}/rsvp/edit/${editId}`; const editLink = `${process.env.FRONTEND_BASE_URL}/events/${eventSlug}/rsvp/edit/${editId}`;
await sendRSVPEditLinkEmail({ await sendRSVPEditLinkEmail({
eventTitle, eventTitle,
eventSlug, eventSlug,
name, name,
to: submitterEmail, to: attendeeEmail,
editLink, editLink,
}); });
console.log(`Sent RSVP edit link email to ${submitterEmail}`); console.log(`Sent RSVP edit link email to ${attendeeEmail}`);
} catch (emailErr) { } catch (emailErr) {
console.error('Error sending RSVP edit link email:', emailErr); console.error('Error sending RSVP edit link email:', emailErr);
} }
} else if (sendEmailConfirmationBool && !submitterEmail) { } else if (sendEmailConfirmationBool && !attendeeEmail) {
console.warn('Email confirmation requested but no email address provided. Skipping edit link email.'); console.warn('Email confirmation requested but no email address provided. Skipping edit link email.');
} else if (sendEmailConfirmationBool && !process.env.EMAIL_USER) { } else if (sendEmailConfirmationBool && !process.env.EMAIL_USER) {
console.warn('Email confirmation requested but EMAIL_USER environment variable is not set. Cannot send edit link email.'); console.warn('Email confirmation requested but EMAIL_USER environment variable is not set. Cannot send edit link email.');
@@ -432,6 +483,8 @@ app.post('/api/events/:slug/rsvp', async (req: Request, res: Response) => {
items_bringing: parsedItemsBringing, items_bringing: parsedItemsBringing,
other_items: other_items || '', other_items: other_items || '',
edit_id: editId, edit_id: editId,
send_event_conclusion_email: sendEventConclusionEmailBool, // Include in response
attendee_email: attendeeEmail, // Include in response
created_at: new Date().toISOString() created_at: new Date().toISOString()
}); });
} catch (error) { } catch (error) {
@@ -450,14 +503,16 @@ app.get('/api/rsvps/edit/:editId', async (req: Request, res: Response) => {
return res.status(404).json({ error: 'RSVP not found' }); return res.status(404).json({ error: 'RSVP not found' });
} }
// Parse arrays for response // Parse arrays and boolean fields for response
try { try {
rsvp.items_bringing = rsvp.items_bringing ? JSON.parse(rsvp.items_bringing) : []; rsvp.items_bringing = rsvp.items_bringing ? JSON.parse(rsvp.items_bringing) : [];
rsvp.guest_names = rsvp.guest_names ? JSON.parse(rsvp.guest_names) : []; rsvp.guest_names = rsvp.guest_names ? JSON.parse(rsvp.guest_names) : [];
rsvp.send_event_conclusion_email = Boolean(rsvp.send_event_conclusion_email); // Parse new field
} catch (e) { } catch (e) {
console.error('Error parsing arrays in response:', e); console.error('Error parsing RSVP JSON/boolean fields in response:', e);
rsvp.items_bringing = []; rsvp.items_bringing = [];
rsvp.guest_names = []; rsvp.guest_names = [];
rsvp.send_event_conclusion_email = false; // Default value on error
} }
res.json(rsvp); res.json(rsvp);
@@ -467,6 +522,48 @@ app.get('/api/rsvps/edit/:editId', async (req: Request, res: Response) => {
} }
}); });
// Resend RSVP edit link email
app.post('/api/rsvps/resend-email/:editId', async (req: Request, res: Response) => {
try {
const { editId } = req.params;
// Get RSVP and event details
const rsvp = await db.get(`
SELECT r.*, e.title, e.slug
FROM rsvps r
JOIN events e ON r.event_id = e.id
WHERE r.edit_id = ?
`, [editId]);
if (!rsvp) {
return res.status(404).json({ error: 'RSVP not found' });
}
if (!rsvp.attendee_email) {
return res.status(400).json({ error: 'No email address associated with this RSVP' });
}
if (!process.env.EMAIL_USER) {
return res.status(500).json({ error: 'Email service not configured' });
}
// Send the edit link email
const editLink = `${process.env.FRONTEND_BASE_URL}/events/${rsvp.slug}/rsvp/edit/${editId}`;
await sendRSVPEditLinkEmail({
eventTitle: rsvp.title,
eventSlug: rsvp.slug,
name: rsvp.name,
to: rsvp.attendee_email,
editLink,
});
res.json({ message: 'Email sent successfully' });
} catch (error) {
console.error('Error resending RSVP edit link email:', error);
res.status(500).json({ error: 'Failed to send email' });
}
});
app.delete('/api/events/:slug/rsvps/:id', async (req: Request, res: Response) => { app.delete('/api/events/:slug/rsvps/:id', async (req: Request, res: Response) => {
try { try {
@@ -492,10 +589,10 @@ app.delete('/api/events/:slug/rsvps/:id', async (req: Request, res: Response) =>
app.put('/api/rsvps/edit/:editId', async (req: Request, res: Response) => { app.put('/api/rsvps/edit/:editId', async (req: Request, res: Response) => {
try { try {
const { editId } = req.params; const { editId } = req.params;
const { name, attending, bringing_guests, guest_count, guest_names, items_bringing, other_items } = req.body; const { name, email_address, attending, bringing_guests, guest_count, guest_names, items_bringing, other_items, send_event_conclusion_email } = req.body; // Updated to use email_address
// Find the RSVP by edit_id // Find the RSVP by edit_id and get current email
const rsvp = await db.get('SELECT id, event_id FROM rsvps WHERE edit_id = ?', [editId]); const rsvp = await db.get('SELECT id, event_id, attendee_email, name FROM rsvps WHERE edit_id = ?', [editId]);
if (!rsvp) { if (!rsvp) {
return res.status(404).json({ error: 'RSVP not found' }); return res.status(404).json({ error: 'RSVP not found' });
@@ -503,6 +600,8 @@ app.put('/api/rsvps/edit/:editId', async (req: Request, res: Response) => {
const rsvpId = rsvp.id; const rsvpId = rsvp.id;
const eventId = rsvp.event_id; const eventId = rsvp.event_id;
const currentEmail = rsvp.attendee_email;
const newEmail = email_address?.trim() || null;
// Parse items_bringing if it's a string // Parse items_bringing if it's a string
let parsedItemsBringing: string[] = []; let parsedItemsBringing: string[] = [];
@@ -534,10 +633,51 @@ app.put('/api/rsvps/edit/:editId', async (req: Request, res: Response) => {
parsedGuestNames = []; parsedGuestNames = [];
} }
// Parse new boolean field
const sendEventConclusionEmailBool = send_event_conclusion_email !== undefined ?
(send_event_conclusion_email === 'true' || send_event_conclusion_email === true) :
Boolean(rsvp.send_event_conclusion_email); // Use existing value if not provided
const attendeeEmailToSave = newEmail;
// Check if email address changed and send new confirmation if needed
const emailChanged = currentEmail !== newEmail && newEmail && process.env.EMAIL_USER;
if (emailChanged) {
try {
// Get event details for the email
const event = await db.get('SELECT title, slug FROM events WHERE id = ?', [eventId]);
if (event) {
const editLink = `${process.env.FRONTEND_BASE_URL}/events/${event.slug}/rsvp/edit/${editId}`;
await sendRSVPEditLinkEmail({
eventTitle: event.title,
eventSlug: event.slug,
name: name ?? rsvp.name,
to: newEmail,
editLink,
});
console.log(`Sent new RSVP edit link email to updated address: ${newEmail}`);
}
} catch (emailErr) {
console.error('Error sending RSVP edit link email to new address:', emailErr);
}
}
// Update the RSVP // Update the RSVP
await db.run( await db.run(
'UPDATE rsvps SET name = ?, attending = ?, bringing_guests = ?, guest_count = ?, guest_names = ?, items_bringing = ?, other_items = ? WHERE id = ?', 'UPDATE rsvps SET name = ?, attending = ?, bringing_guests = ?, guest_count = ?, guest_names = ?, items_bringing = ?, other_items = ?, send_event_conclusion_email = ?, attendee_email = ? WHERE id = ?',
[name, attending, bringing_guests, guest_count, JSON.stringify(parsedGuestNames), JSON.stringify(parsedItemsBringing), other_items || '', rsvpId] [
name ?? rsvp.name,
attending ?? rsvp.attending,
bringing_guests ?? rsvp.bringing_guests,
guest_count !== undefined ? guest_count : rsvp.guest_count,
JSON.stringify(parsedGuestNames),
JSON.stringify(parsedItemsBringing),
other_items === undefined ? rsvp.other_items : other_items || '',
sendEventConclusionEmailBool ? 1 : 0, // Update new field
attendeeEmailToSave, // Update new field
rsvpId
]
); );
// Get the updated RSVP to verify and return // Get the updated RSVP to verify and return
@@ -547,14 +687,16 @@ app.put('/api/rsvps/edit/:editId', async (req: Request, res: Response) => {
return res.status(404).json({ error: 'RSVP not found after update' }); return res.status(404).json({ error: 'RSVP not found after update' });
} }
// Parse arrays for response // Parse arrays and boolean fields for response
try { try {
updatedRsvp.items_bringing = updatedRsvp.items_bringing ? JSON.parse(updatedRsvp.items_bringing) : []; updatedRsvp.items_bringing = updatedRsvp.items_bringing ? JSON.parse(updatedRsvp.items_bringing) : [];
updatedRsvp.guest_names = updatedRsvp.guest_names ? JSON.parse(updatedRsvp.guest_names) : []; updatedRsvp.guest_names = updatedRsvp.guest_names ? JSON.parse(updatedRsvp.guest_names) : [];
updatedRsvp.send_event_conclusion_email = Boolean(updatedRsvp.send_event_conclusion_email); // Parse new field
} catch (e) { } catch (e) {
console.error('Error parsing arrays in response:', e); console.error('Error parsing arrays in response:', e);
updatedRsvp.items_bringing = []; updatedRsvp.items_bringing = [];
updatedRsvp.guest_names = []; updatedRsvp.guest_names = [];
updatedRsvp.send_event_conclusion_email = false; // Default value on error
} }
res.json(updatedRsvp); res.json(updatedRsvp);
@@ -569,7 +711,7 @@ app.put('/api/rsvps/edit/:editId', async (req: Request, res: Response) => {
app.put('/api/events/:slug/rsvps/:id', async (req: Request, res: Response) => { app.put('/api/events/:slug/rsvps/:id', async (req: Request, res: Response) => {
try { try {
const { slug, id } = req.params; const { slug, id } = req.params;
const { name, attending, bringing_guests, guest_count, guest_names, items_bringing, other_items } = req.body; const { name, attending, bringing_guests, guest_count, guest_names, items_bringing, other_items, send_event_conclusion_email, attendee_email } = req.body; // Receive new fields
// Verify the RSVP belongs to the correct event // Verify the RSVP belongs to the correct event
const eventRows = await db.all('SELECT id FROM events WHERE slug = ?', [slug]); const eventRows = await db.all('SELECT id FROM events WHERE slug = ?', [slug]);
@@ -610,10 +752,36 @@ app.put('/api/events/:slug/rsvps/:id', async (req: Request, res: Response) => {
parsedGuestNames = []; parsedGuestNames = [];
} }
// Get existing RSVP to check current values
const existingRsvp = await db.get('SELECT send_event_conclusion_email, attendee_email FROM rsvps WHERE id = ? AND event_id = ?', [id, eventId]);
if (!existingRsvp) {
return res.status(404).json({ error: 'RSVP not found for this event' });
}
// Parse new boolean field
const sendEventConclusionEmailBool = send_event_conclusion_email !== undefined ?
(send_event_conclusion_email === 'true' || send_event_conclusion_email === true) :
Boolean(existingRsvp.send_event_conclusion_email); // Use existing value if not provided
const attendeeEmailToSave = attendee_email !== undefined ? attendee_email?.trim() || null : existingRsvp.attendee_email;
// Update the RSVP // Update the RSVP
await db.run( await db.run(
'UPDATE rsvps SET name = ?, attending = ?, bringing_guests = ?, guest_count = ?, guest_names = ?, items_bringing = ?, other_items = ? WHERE id = ? AND event_id = ?', 'UPDATE rsvps SET name = ?, attending = ?, bringing_guests = ?, guest_count = ?, guest_names = ?, items_bringing = ?, other_items = ?, send_event_conclusion_email = ?, attendee_email = ? WHERE id = ? AND event_id = ?',
[name, attending, bringing_guests, guest_count, JSON.stringify(parsedGuestNames), JSON.stringify(parsedItemsBringing), other_items || '', id, eventId] [
name ?? existingRsvp.name, // Use existing value if not provided
attending ?? existingRsvp.attending, // Use existing value if not provided
bringing_guests ?? existingRsvp.bringing_guests, // Use existing value if not provided
guest_count !== undefined ? guest_count : existingRsvp.guest_count, // Use existing value if not provided
JSON.stringify(parsedGuestNames),
JSON.stringify(parsedItemsBringing),
other_items === undefined ? existingRsvp.other_items : other_items || '',
sendEventConclusionEmailBool ? 1 : 0, // Update new field
attendeeEmailToSave, // Update new field
id,
eventId
]
); );
// Get the updated RSVP to verify and return // Get the updated RSVP to verify and return
@@ -623,14 +791,16 @@ app.put('/api/events/:slug/rsvps/:id', async (req: Request, res: Response) => {
return res.status(404).json({ error: 'RSVP not found after update' }); return res.status(404).json({ error: 'RSVP not found after update' });
} }
// Parse arrays for response // Parse arrays and boolean fields for response
try { try {
updatedRsvp.items_bringing = updatedRsvp.items_bringing ? JSON.parse(updatedRsvp.items_bringing) : []; updatedRsvp.items_bringing = updatedRsvp.items_bringing ? JSON.parse(updatedRsvp.items_bringing) : [];
updatedRsvp.guest_names = updatedRsvp.guest_names ? JSON.parse(updatedRsvp.guest_names) : []; updatedRsvp.guest_names = updatedRsvp.guest_names ? JSON.parse(updatedRsvp.guest_names) : [];
updatedRsvp.send_event_conclusion_email = Boolean(updatedRsvp.send_event_conclusion_email); // Parse new field
} catch (e) { } catch (e) {
console.error('Error parsing arrays in response:', e); console.error('Error parsing arrays in response:', e);
updatedRsvp.items_bringing = []; updatedRsvp.items_bringing = [];
updatedRsvp.guest_names = []; updatedRsvp.guest_names = [];
updatedRsvp.send_event_conclusion_email = false; // Default value on error
} }
res.json(updatedRsvp); res.json(updatedRsvp);
@@ -653,7 +823,9 @@ app.put('/api/events/:slug', upload.single('wallpaper'), async (req: MulterReque
rsvp_cutoff_date, rsvp_cutoff_date,
max_guests_per_rsvp, max_guests_per_rsvp,
email_notifications_enabled, email_notifications_enabled,
email_recipients email_recipients,
event_conclusion_email_enabled, // Receive new field
event_conclusion_message // Receive new field
} = req.body; } = req.body;
// Verify the event exists // Verify the event exists
@@ -680,16 +852,25 @@ app.put('/api/events/:slug', upload.single('wallpaper'), async (req: MulterReque
(parseInt(max_guests_per_rsvp as string) || 0) : (parseInt(max_guests_per_rsvp as string) || 0) :
eventRows[0].max_guests_per_rsvp || 0; eventRows[0].max_guests_per_rsvp || 0;
// Parse email_notifications_enabled to ensure it's a boolean // Parse boolean fields
const emailNotificationsEnabled = email_notifications_enabled !== undefined ? const emailNotificationsEnabled = email_notifications_enabled !== undefined ?
(email_notifications_enabled === 'true' || email_notifications_enabled === true) : (email_notifications_enabled === 'true' || email_notifications_enabled === true) :
eventRows[0].email_notifications_enabled; Boolean(eventRows[0].email_notifications_enabled);
// Get email recipients const eventConclusionEmailEnabled = event_conclusion_email_enabled !== undefined ?
(event_conclusion_email_enabled === 'true' || event_conclusion_email_enabled === true) :
Boolean(eventRows[0].event_conclusion_email_enabled); // Use existing value if not provided
// Get email recipients and conclusion message
const emailRecipients = email_recipients !== undefined ? const emailRecipients = email_recipients !== undefined ?
email_recipients : email_recipients :
eventRows[0].email_recipients || ''; eventRows[0].email_recipients || '';
const eventConclusionMessage = event_conclusion_message !== undefined ?
event_conclusion_message :
eventRows[0].event_conclusion_message || ''; // Use existing value if not provided
// Handle wallpaper update // Handle wallpaper update
let wallpaperPath = eventRows[0].wallpaper; let wallpaperPath = eventRows[0].wallpaper;
if (req.file) { if (req.file) {
@@ -707,7 +888,7 @@ app.put('/api/events/:slug', upload.single('wallpaper'), async (req: MulterReque
// Update the event // Update the event
await db.run( await db.run(
'UPDATE events SET title = ?, description = ?, date = ?, location = ?, needed_items = ?, rsvp_cutoff_date = ?, wallpaper = ?, max_guests_per_rsvp = ?, email_notifications_enabled = ?, email_recipients = ? WHERE slug = ?', 'UPDATE events SET title = ?, description = ?, date = ?, location = ?, needed_items = ?, rsvp_cutoff_date = ?, wallpaper = ?, max_guests_per_rsvp = ?, email_notifications_enabled = ?, email_recipients = ?, event_conclusion_email_enabled = ?, event_conclusion_message = ? WHERE slug = ?',
[ [
title ?? eventRows[0].title, title ?? eventRows[0].title,
description === undefined ? eventRows[0].description : description, description === undefined ? eventRows[0].description : description,
@@ -719,6 +900,8 @@ app.put('/api/events/:slug', upload.single('wallpaper'), async (req: MulterReque
maxGuests, maxGuests,
emailNotificationsEnabled ? 1 : 0, emailNotificationsEnabled ? 1 : 0,
emailRecipients, emailRecipients,
eventConclusionEmailEnabled ? 1 : 0, // Update new field
eventConclusionMessage, // Update new field
slug slug
] ]
); );
@@ -726,19 +909,23 @@ app.put('/api/events/:slug', upload.single('wallpaper'), async (req: MulterReque
// Get the updated event // Get the updated event
const updatedEvent = await db.get('SELECT * FROM events WHERE slug = ?', [slug]); const updatedEvent = await db.get('SELECT * FROM events WHERE slug = ?', [slug]);
// Add the full path to the wallpaper // Add the full path to the wallpaper and parse JSON/boolean fields
if (updatedEvent.wallpaper) { if (updatedEvent.wallpaper) {
updatedEvent.wallpaper = `/uploads/wallpapers/${updatedEvent.wallpaper}`; updatedEvent.wallpaper = `/uploads/wallpapers/${updatedEvent.wallpaper}`;
} }
// Parse needed_items for response
try { try {
updatedEvent.needed_items = updatedEvent.needed_items ? JSON.parse(updatedEvent.needed_items) : []; updatedEvent.needed_items = updatedEvent.needed_items ? JSON.parse(updatedEvent.needed_items) : [];
updatedEvent.email_notifications_enabled = Boolean(updatedEvent.email_notifications_enabled);
updatedEvent.event_conclusion_email_enabled = Boolean(updatedEvent.event_conclusion_email_enabled);
} catch (e) { } catch (e) {
console.error('Error parsing needed_items in response:', e); console.error('Error parsing updated event JSON/boolean fields:', e);
updatedEvent.needed_items = []; updatedEvent.needed_items = [];
updatedEvent.email_notifications_enabled = false;
updatedEvent.event_conclusion_email_enabled = false;
} }
res.json(updatedEvent); res.json(updatedEvent);
} catch (error) { } catch (error) {
console.error('Error updating event:', error); console.error('Error updating event:', error);
@@ -770,6 +957,8 @@ async function initializeDatabase() {
max_guests_per_rsvp INTEGER DEFAULT 0, max_guests_per_rsvp INTEGER DEFAULT 0,
email_notifications_enabled BOOLEAN DEFAULT 0, email_notifications_enabled BOOLEAN DEFAULT 0,
email_recipients TEXT, email_recipients TEXT,
event_conclusion_email_enabled BOOLEAN DEFAULT 0, -- Added event conclusion email toggle
event_conclusion_message TEXT, -- Added event conclusion message field
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) )
`); `);
@@ -787,6 +976,8 @@ async function initializeDatabase() {
items_bringing TEXT, items_bringing TEXT,
other_items TEXT, other_items TEXT,
edit_id TEXT UNIQUE, -- Add a column for the unique edit ID edit_id TEXT UNIQUE, -- Add a column for the unique edit ID
send_event_conclusion_email BOOLEAN DEFAULT 0, -- Added field for event conclusion email opt-in
attendee_email TEXT, -- Added field for attendee email
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (event_id) REFERENCES events(id) ON DELETE CASCADE FOREIGN KEY (event_id) REFERENCES events(id) ON DELETE CASCADE
) )
@@ -798,6 +989,96 @@ async function initializeDatabase() {
} }
} }
// Function to send event conclusion emails
async function sendConclusionEmails() {
console.log('Running scheduled task to send event conclusion emails...');
try {
// Calculate yesterday's date in the format stored in the database (assuming YYYY-MM-DD)
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
const yesterdayString = yesterday.toISOString().split('T')[0];
// Find events that ended yesterday and have conclusion emails enabled
const events = await db.all(
'SELECT id, title, event_conclusion_message FROM events WHERE date LIKE ? AND event_conclusion_email_enabled = 1',
[`${yesterdayString}%`] // Match any time on yesterday's date
);
for (const event of events) {
console.log(`Processing event "${event.title}" for conclusion email.`);
// Find RSVPs for this event where conclusion email is opted in and email is provided
const rsvps = await db.all(
'SELECT name, attendee_email FROM rsvps WHERE event_id = ? AND send_event_conclusion_email = 1 AND attendee_email IS NOT NULL AND attendee_email != ""',
[event.id]
);
if (rsvps.length > 0) {
console.log(`Found ${rsvps.length} attendees opted in for conclusion email for event "${event.title}".`);
for (const rsvp of rsvps) {
try {
await sendEventConclusionEmail({
eventTitle: event.title,
attendeeName: rsvp.name,
message: event.event_conclusion_message,
to: rsvp.attendee_email,
});
console.log(`Sent conclusion email to ${rsvp.attendee_email} for event "${event.title}".`);
} catch (emailErr) {
console.error(`Error sending conclusion email to ${rsvp.attendee_email} for event "${event.title}":`, emailErr);
}
}
} else {
console.log(`No attendees opted in for conclusion email for event "${event.title}".`);
}
}
console.log('Finished running scheduled task.');
} catch (error) {
console.error('Error in scheduled task to send event conclusion emails:', error);
}
}
// Schedule the task to run daily (e.g., at 8:00 AM)
const scheduledTask = cron.schedule('0 8 * * *', () => {
sendConclusionEmails();
}, {
scheduled: true,
timezone: process.env.TZ || 'UTC' // Use TZ environment variable, default to UTC
});
console.log(`Event conclusion email scheduled task scheduled for timezone ${process.env.TZ || 'UTC'} at 8:00 AM.`);
// ICS Calendar file endpoint
app.get('/api/events/:slug/calendar.ics', async (req: Request, res: Response) => {
try {
const { slug } = req.params;
const event = await db.get('SELECT * FROM events WHERE slug = ?', [slug]);
if (!event) {
return res.status(404).json({ error: 'Event not found' });
}
// Generate ICS content
const icsContent = generateICSContent({
title: event.title,
description: event.description || '',
location: event.location || '',
date: event.date,
slug: event.slug,
rsvp_cutoff_date: event.rsvp_cutoff_date
});
// Set appropriate headers for ICS file download
res.setHeader('Content-Type', 'text/calendar; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename="${event.slug}-calendar.ics"`);
res.send(icsContent);
} catch (error) {
console.error('Error generating ICS file:', error);
res.status(500).json({ error: 'Failed to generate calendar file' });
}
});
// Handle client-side routing // Handle client-side routing
app.get('*', (req: Request, res: Response) => { app.get('*', (req: Request, res: Response) => {
res.sendFile(path.join(__dirname, '../frontend/build/index.html')); res.sendFile(path.join(__dirname, '../frontend/build/index.html'));
@@ -807,4 +1088,6 @@ app.get('*', (req: Request, res: Response) => {
app.listen(port, async () => { app.listen(port, async () => {
console.log(`Server running on port ${port}`); console.log(`Server running on port ${port}`);
await connectToDatabase(); await connectToDatabase();
// Optionally run the task on startup for testing
// sendConclusionEmails();
}); });

View File

@@ -19,6 +19,7 @@ services:
- EMAIL_FROM_ADDRESS=your@email.com - EMAIL_FROM_ADDRESS=your@email.com
- EMAIL_SECURE=false - EMAIL_SECURE=false
- FRONTEND_BASE_URL=https://your-frontend-domain.com - FRONTEND_BASE_URL=https://your-frontend-domain.com
- TZ=America/New_York # Set your desired timezone here
restart: unless-stopped restart: unless-stopped
volumes: volumes:

View File

@@ -17,6 +17,7 @@ services:
- EMAIL_FROM_ADDRESS=your@email.com - EMAIL_FROM_ADDRESS=your@email.com
- EMAIL_SECURE=false - EMAIL_SECURE=false
- FRONTEND_BASE_URL=https://your-frontend-domain.com - FRONTEND_BASE_URL=https://your-frontend-domain.com
- TZ=America/New_York # Set your desired timezone here
restart: unless-stopped restart: unless-stopped
volumes: volumes:

View File

@@ -29,11 +29,15 @@ import {
ListItemText, ListItemText,
Checkbox, Checkbox,
Chip, Chip,
Snackbar,
Alert,
} from '@mui/material'; } from '@mui/material';
import DeleteIcon from '@mui/icons-material/Delete'; import DeleteIcon from '@mui/icons-material/Delete';
import EditIcon from '@mui/icons-material/Edit'; import EditIcon from '@mui/icons-material/Edit';
import AddIcon from '@mui/icons-material/Add'; import AddIcon from '@mui/icons-material/Add';
import WallpaperIcon from '@mui/icons-material/Wallpaper'; import WallpaperIcon from '@mui/icons-material/Wallpaper';
import EmailIcon from '@mui/icons-material/Email';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import axios from 'axios'; import axios from 'axios';
interface RSVP { interface RSVP {
@@ -45,6 +49,8 @@ interface RSVP {
guest_names: string[] | string; guest_names: string[] | string;
items_bringing: string[] | string; items_bringing: string[] | string;
other_items?: string; other_items?: string;
attendee_email?: string;
edit_id?: string;
event_id?: number; event_id?: number;
created_at?: string; created_at?: string;
updated_at?: string; updated_at?: string;
@@ -63,10 +69,13 @@ interface Event {
max_guests_per_rsvp?: number; max_guests_per_rsvp?: number;
email_notifications_enabled?: boolean; email_notifications_enabled?: boolean;
email_recipients?: string; email_recipients?: string;
event_conclusion_email_enabled?: boolean; // Added event conclusion email toggle
event_conclusion_message?: string; // Added event conclusion message field
} }
interface EditFormData { interface EditFormData {
name: string; name: string;
email_address: string;
attending: 'yes' | 'no' | 'maybe'; attending: 'yes' | 'no' | 'maybe';
bringing_guests: 'yes' | 'no'; bringing_guests: 'yes' | 'no';
guest_count: number; guest_count: number;
@@ -75,6 +84,19 @@ interface EditFormData {
other_items: string; other_items: string;
} }
interface UpdateFormData {
title: string;
description: string;
location: string;
date: string;
rsvp_cutoff_date: string;
wallpaper: File | null;
email_notifications_enabled: boolean;
email_recipients: string;
event_conclusion_email_enabled: boolean;
event_conclusion_message: string;
}
const EventAdmin: React.FC = () => { const EventAdmin: React.FC = () => {
const { slug } = useParams<{ slug: string }>(); const { slug } = useParams<{ slug: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -91,6 +113,7 @@ const EventAdmin: React.FC = () => {
const [rsvpToEdit, setRsvpToEdit] = useState<RSVP | null>(null); const [rsvpToEdit, setRsvpToEdit] = useState<RSVP | null>(null);
const [editForm, setEditForm] = useState<EditFormData>({ const [editForm, setEditForm] = useState<EditFormData>({
name: '', name: '',
email_address: '',
attending: 'yes', attending: 'yes',
bringing_guests: 'no', bringing_guests: 'no',
guest_count: 0, guest_count: 0,
@@ -103,15 +126,21 @@ const EventAdmin: React.FC = () => {
const [newItem, setNewItem] = useState(''); const [newItem, setNewItem] = useState('');
const [itemToDelete, setItemToDelete] = useState<string | null>(null); const [itemToDelete, setItemToDelete] = useState<string | null>(null);
const [updateInfoDialogOpen, setUpdateInfoDialogOpen] = useState(false); const [updateInfoDialogOpen, setUpdateInfoDialogOpen] = useState(false);
const [updateForm, setUpdateForm] = useState({ const [updateForm, setUpdateForm] = useState<UpdateFormData>({
title: '',
description: '', description: '',
location: '', location: '',
date: '', date: '',
rsvp_cutoff_date: '', rsvp_cutoff_date: '',
wallpaper: null as File | null, wallpaper: null,
email_notifications_enabled: false, email_notifications_enabled: false,
email_recipients: '' email_recipients: '',
event_conclusion_email_enabled: false,
event_conclusion_message: ''
}); });
const [snackbarOpen, setSnackbarOpen] = useState(false);
const [snackbarMessage, setSnackbarMessage] = useState('');
const [snackbarSeverity, setSnackbarSeverity] = useState<'success' | 'error'>('success');
useEffect(() => { useEffect(() => {
fetchEventAndRsvps(); fetchEventAndRsvps();
@@ -136,6 +165,7 @@ const EventAdmin: React.FC = () => {
} }
setEvent(eventResponse.data); setEvent(eventResponse.data);
console.log('Fetched event data:', eventResponse.data); // Add logging
// Process needed items // Process needed items
let items: string[] = []; let items: string[] = [];
@@ -243,6 +273,7 @@ const EventAdmin: React.FC = () => {
setRsvpToEdit(rsvp); setRsvpToEdit(rsvp);
setEditForm({ setEditForm({
name: rsvp.name, name: rsvp.name,
email_address: rsvp.attendee_email || '',
attending: rsvp.attending || 'yes', attending: rsvp.attending || 'yes',
bringing_guests: rsvp.bringing_guests || 'no', bringing_guests: rsvp.bringing_guests || 'no',
guest_count: typeof rsvp.guest_count === 'number' ? rsvp.guest_count : 0, guest_count: typeof rsvp.guest_count === 'number' ? rsvp.guest_count : 0,
@@ -356,6 +387,7 @@ const EventAdmin: React.FC = () => {
// Prepare submission data in the exact format the backend expects // Prepare submission data in the exact format the backend expects
const submissionData = { const submissionData = {
name: editForm.name, name: editForm.name,
attendee_email: editForm.email_address,
attending: editForm.attending, attending: editForm.attending,
bringing_guests: editForm.bringing_guests, bringing_guests: editForm.bringing_guests,
guest_count: editForm.bringing_guests === 'yes' ? Math.max(1, parseInt(editForm.guest_count.toString(), 10)) : 0, guest_count: editForm.bringing_guests === 'yes' ? Math.max(1, parseInt(editForm.guest_count.toString(), 10)) : 0,
@@ -394,6 +426,7 @@ const EventAdmin: React.FC = () => {
const updatedRsvp: RSVP = { const updatedRsvp: RSVP = {
...rsvpToEdit, ...rsvpToEdit,
...submissionData, ...submissionData,
attendee_email: editForm.email_address,
guest_names: filteredGuestNames, guest_names: filteredGuestNames,
items_bringing: editForm.items_bringing, items_bringing: editForm.items_bringing,
other_items: splitOtherItems other_items: splitOtherItems
@@ -568,13 +601,16 @@ const EventAdmin: React.FC = () => {
if (!event) return; if (!event) return;
setUpdateForm({ setUpdateForm({
title: event.title, // Include title
description: event.description, description: event.description,
location: event.location, location: event.location,
date: event.date.slice(0, 16), // Format date for datetime-local input date: event.date.slice(0, 16), // Format date for datetime-local input
rsvp_cutoff_date: event.rsvp_cutoff_date ? event.rsvp_cutoff_date.slice(0, 16) : '', rsvp_cutoff_date: event.rsvp_cutoff_date ? event.rsvp_cutoff_date.slice(0, 16) : '',
wallpaper: null, wallpaper: null,
email_notifications_enabled: event.email_notifications_enabled || false, email_notifications_enabled: event.email_notifications_enabled || false,
email_recipients: event.email_recipients || '' email_recipients: event.email_recipients || '',
event_conclusion_email_enabled: event.event_conclusion_email_enabled || false, // Include new field
event_conclusion_message: event.event_conclusion_message || '' // Handle null/undefined properly
}); });
setUpdateInfoDialogOpen(true); setUpdateInfoDialogOpen(true);
}; };
@@ -593,12 +629,16 @@ const EventAdmin: React.FC = () => {
formData.append('needed_items', JSON.stringify(event.needed_items)); // Keep existing needed items formData.append('needed_items', JSON.stringify(event.needed_items)); // Keep existing needed items
formData.append('email_notifications_enabled', updateForm.email_notifications_enabled.toString()); formData.append('email_notifications_enabled', updateForm.email_notifications_enabled.toString());
formData.append('email_recipients', updateForm.email_recipients); formData.append('email_recipients', updateForm.email_recipients);
formData.append('event_conclusion_email_enabled', updateForm.event_conclusion_email_enabled.toString()); // Append new field
formData.append('event_conclusion_message', String(updateForm.event_conclusion_message)); // Ensure it's a string
// Append wallpaper if a new one was selected // Append wallpaper if a new one was selected
if (updateForm.wallpaper) { if (updateForm.wallpaper) {
formData.append('wallpaper', updateForm.wallpaper); formData.append('wallpaper', updateForm.wallpaper);
} }
console.log('Submitting event update data:', Object.fromEntries(formData.entries())); // Add logging
const response = await axios.put(`/api/events/${slug}`, formData, { const response = await axios.put(`/api/events/${slug}`, formData, {
headers: { headers: {
'Content-Type': 'multipart/form-data', 'Content-Type': 'multipart/form-data',
@@ -632,6 +672,61 @@ const EventAdmin: React.FC = () => {
} }
}; };
const handleSendEmail = async (rsvp: RSVP) => {
if (!rsvp.attendee_email || !rsvp.edit_id || !event) {
setSnackbarMessage('Cannot send email: missing email address or edit ID');
setSnackbarSeverity('error');
setSnackbarOpen(true);
return;
}
try {
await axios.post(`/api/rsvps/resend-email/${rsvp.edit_id}`);
setSnackbarMessage(`Email sent successfully to ${rsvp.attendee_email}`);
setSnackbarSeverity('success');
setSnackbarOpen(true);
} catch (error) {
console.error('Error sending email:', error);
setSnackbarMessage('Failed to send email');
setSnackbarSeverity('error');
setSnackbarOpen(true);
}
};
const handleCopyLink = async (rsvp: RSVP) => {
if (!rsvp.edit_id || !event) {
setSnackbarMessage('Cannot copy link: missing edit ID');
setSnackbarSeverity('error');
setSnackbarOpen(true);
return;
}
const editLink = `${window.location.origin}/events/${event.slug}/rsvp/edit/${rsvp.edit_id}`;
try {
await navigator.clipboard.writeText(editLink);
setSnackbarMessage('Link copied to clipboard successfully');
setSnackbarSeverity('success');
setSnackbarOpen(true);
} catch (error) {
console.error('Error copying to clipboard:', error);
// Fallback for older browsers
const textArea = document.createElement('textarea');
textArea.value = editLink;
document.body.appendChild(textArea);
textArea.select();
document.execCommand('copy');
document.body.removeChild(textArea);
setSnackbarMessage('Link copied to clipboard successfully');
setSnackbarSeverity('success');
setSnackbarOpen(true);
}
};
const handleSnackbarClose = () => {
setSnackbarOpen(false);
};
if (loading) { if (loading) {
return ( return (
<Container maxWidth="lg"> <Container maxWidth="lg">
@@ -828,6 +923,7 @@ const EventAdmin: React.FC = () => {
<TableHead> <TableHead>
<TableRow> <TableRow>
<TableCell>Name</TableCell> <TableCell>Name</TableCell>
<TableCell>Email</TableCell>
<TableCell>Attending</TableCell> <TableCell>Attending</TableCell>
<TableCell>Guests</TableCell> <TableCell>Guests</TableCell>
<TableCell>Needed Items</TableCell> <TableCell>Needed Items</TableCell>
@@ -839,6 +935,7 @@ const EventAdmin: React.FC = () => {
{rsvps.map((rsvp: RSVP) => ( {rsvps.map((rsvp: RSVP) => (
<TableRow key={rsvp.id}> <TableRow key={rsvp.id}>
<TableCell>{rsvp.name || 'No name provided'}</TableCell> <TableCell>{rsvp.name || 'No name provided'}</TableCell>
<TableCell>{rsvp.attendee_email || 'No email provided'}</TableCell>
<TableCell> <TableCell>
{rsvp.attending ? {rsvp.attending ?
rsvp.attending.charAt(0).toUpperCase() + rsvp.attending.slice(1) : rsvp.attending.charAt(0).toUpperCase() + rsvp.attending.slice(1) :
@@ -891,9 +988,25 @@ const EventAdmin: React.FC = () => {
<IconButton <IconButton
color="error" color="error"
onClick={() => handleDeleteRsvp(rsvp)} onClick={() => handleDeleteRsvp(rsvp)}
sx={{ mr: 1 }}
> >
<DeleteIcon /> <DeleteIcon />
</IconButton> </IconButton>
<IconButton
color="info"
onClick={() => handleSendEmail(rsvp)}
sx={{ mr: 1 }}
disabled={!rsvp.attendee_email}
>
<EmailIcon />
</IconButton>
<IconButton
color="secondary"
onClick={() => handleCopyLink(rsvp)}
disabled={!rsvp.edit_id}
>
<ContentCopyIcon />
</IconButton>
</TableCell> </TableCell>
</TableRow> </TableRow>
))} ))}
@@ -934,6 +1047,13 @@ const EventAdmin: React.FC = () => {
onChange={handleTextInputChange} onChange={handleTextInputChange}
fullWidth fullWidth
/> />
<TextField
label="Email Address"
name="email_address"
value={editForm.email_address}
onChange={handleTextInputChange}
fullWidth
/>
<FormControl fullWidth> <FormControl fullWidth>
<InputLabel>Attending</InputLabel> <InputLabel>Attending</InputLabel>
<Select <Select
@@ -1033,6 +1153,8 @@ const EventAdmin: React.FC = () => {
<Dialog <Dialog
open={deleteEventDialogOpen} open={deleteEventDialogOpen}
onClose={() => setDeleteEventDialogOpen(false)} onClose={() => setDeleteEventDialogOpen(false)}
maxWidth="sm"
fullWidth
> >
<DialogTitle>Delete Event</DialogTitle> <DialogTitle>Delete Event</DialogTitle>
<DialogContent> <DialogContent>
@@ -1104,6 +1226,13 @@ const EventAdmin: React.FC = () => {
<DialogTitle>Update Event Information</DialogTitle> <DialogTitle>Update Event Information</DialogTitle>
<DialogContent> <DialogContent>
<Box sx={{ mt: 2, display: 'flex', flexDirection: 'column', gap: 2 }}> <Box sx={{ mt: 2, display: 'flex', flexDirection: 'column', gap: 2 }}>
{/* Event Title Field */}
<TextField
label="Title"
value={updateForm.title}
onChange={(e) => setUpdateForm(prev => ({ ...prev, title: e.target.value }))}
fullWidth
/>
<TextField <TextField
label="Description" label="Description"
value={updateForm.description} value={updateForm.description}
@@ -1172,6 +1301,42 @@ const EventAdmin: React.FC = () => {
)} )}
</Box> </Box>
{/* Event Conclusion Email Settings */}
<Box sx={{ mt: 1, mb: 2 }}>
<Typography variant="subtitle1" gutterBottom>
Event Conclusion Email
</Typography>
<FormControlLabel
control={
<Checkbox
checked={updateForm.event_conclusion_email_enabled}
onChange={(e) => setUpdateForm(prev => ({
...prev,
event_conclusion_email_enabled: e.target.checked
}))}
/>
}
label="Enable Event Conclusion Email"
/>
{updateForm.event_conclusion_email_enabled && (
<TextField
fullWidth
label="Event conclusion message"
value={updateForm.event_conclusion_message}
onChange={(e) => setUpdateForm(prev => ({
...prev,
event_conclusion_message: e.target.value
}))}
variant="outlined"
multiline
rows={4}
helperText="This message will be sent to attendees who opted for email notifications the day after the event."
sx={{ mt: 2 }}
/>
)}
</Box>
<Box sx={{ mt: 1 }}> <Box sx={{ mt: 1 }}>
<Typography variant="subtitle1" gutterBottom> <Typography variant="subtitle1" gutterBottom>
Wallpaper Wallpaper
@@ -1187,8 +1352,8 @@ const EventAdmin: React.FC = () => {
alt="Current wallpaper" alt="Current wallpaper"
sx={{ sx={{
width: '100%', width: '100%',
height: 120, maxHeight: 200, // Increased max height for better viewing
objectFit: 'cover', objectFit: 'contain', // Changed to contain to show the whole image
borderRadius: 1, borderRadius: 1,
}} }}
/> />
@@ -1222,6 +1387,16 @@ const EventAdmin: React.FC = () => {
<Button onClick={handleUpdateInfoSubmit} color="primary">Save Changes</Button> <Button onClick={handleUpdateInfoSubmit} color="primary">Save Changes</Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>
<Snackbar
open={snackbarOpen}
autoHideDuration={6000}
onClose={handleSnackbarClose}
>
<Alert onClose={handleSnackbarClose} severity={snackbarSeverity} sx={{ width: '100%' }}>
{snackbarMessage}
</Alert>
</Snackbar>
</Container> </Container>
</Box> </Box>
</Box> </Box>

View File

@@ -12,6 +12,7 @@ import {
styled, styled,
Checkbox, Checkbox,
FormControlLabel, FormControlLabel,
Divider, // Added Divider for visual separation
} from '@mui/material'; } from '@mui/material';
import WallpaperIcon from '@mui/icons-material/Wallpaper'; import WallpaperIcon from '@mui/icons-material/Wallpaper';
import DeleteIcon from '@mui/icons-material/Delete'; import DeleteIcon from '@mui/icons-material/Delete';
@@ -48,6 +49,8 @@ interface FormData {
max_guests_per_rsvp: number; max_guests_per_rsvp: number;
email_notifications_enabled: boolean; email_notifications_enabled: boolean;
email_recipients: string; email_recipients: string;
event_conclusion_message: string; // Renamed from thank_you_message
event_conclusion_email_enabled: boolean; // Added state for event conclusion email toggle
} }
const EventForm: React.FC = () => { const EventForm: React.FC = () => {
@@ -62,6 +65,8 @@ const EventForm: React.FC = () => {
max_guests_per_rsvp: 0, max_guests_per_rsvp: 0,
email_notifications_enabled: false, email_notifications_enabled: false,
email_recipients: '', email_recipients: '',
event_conclusion_message: '', // Renamed state
event_conclusion_email_enabled: false, // Initialize new state
}); });
const [wallpaper, setWallpaper] = useState<File | null>(null); const [wallpaper, setWallpaper] = useState<File | null>(null);
const [currentItem, setCurrentItem] = useState(''); const [currentItem, setCurrentItem] = useState('');
@@ -123,11 +128,14 @@ const EventForm: React.FC = () => {
} }
}); });
// Append wallpaper if selected // Append wallpaper if selected
if (wallpaper) { if (wallpaper) {
submitData.append('wallpaper', wallpaper); submitData.append('wallpaper', wallpaper);
} }
console.log('Submitting event creation data:', Object.fromEntries(submitData.entries())); // Add logging
await axios.post('/api/events', submitData, { await axios.post('/api/events', submitData, {
headers: { headers: {
'Content-Type': 'multipart/form-data', 'Content-Type': 'multipart/form-data',
@@ -161,189 +169,208 @@ const EventForm: React.FC = () => {
</Typography> </Typography>
)} )}
<Box component="form" onSubmit={handleSubmit} sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}> <Box component="form" onSubmit={handleSubmit} sx={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<DarkTextField {/* Part 1: Basic Event Details */}
fullWidth <Box>
label="Title" <Typography variant="h6" gutterBottom sx={{ color: 'rgba(255, 255, 255, 0.9)' }}>
name="title" Basic Event Details
value={formData.title}
onChange={handleChange}
variant="outlined"
required
/>
<DarkTextField
fullWidth
label="Description"
name="description"
value={formData.description}
onChange={handleChange}
variant="outlined"
multiline
rows={4}
/>
<DarkTextField
fullWidth
label="Date and Time"
name="date"
type="datetime-local"
value={formData.date}
onChange={handleChange}
variant="outlined"
required
InputLabelProps={{
shrink: true,
}}
/>
<DarkTextField
fullWidth
label="RSVP Cut-off Date"
name="rsvp_cutoff_date"
type="datetime-local"
value={formData.rsvp_cutoff_date}
onChange={handleChange}
variant="outlined"
InputLabelProps={{
shrink: true,
}}
/>
<DarkTextField
fullWidth
label="Maximum Additional Guests Per RSVP"
name="max_guests_per_rsvp"
type="number"
value={formData.max_guests_per_rsvp}
onChange={(e) => {
const value = parseInt(e.target.value);
setFormData((prev) => ({
...prev,
max_guests_per_rsvp: isNaN(value) ? 0 : value,
}));
}}
variant="outlined"
helperText="Set to 0 for no additional guests, -1 for unlimited"
inputProps={{ min: -1 }}
/>
<DarkTextField
fullWidth
label="Location"
name="location"
value={formData.location}
onChange={handleChange}
variant="outlined"
required
/>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Typography variant="subtitle1" gutterBottom sx={{ color: 'rgba(255, 255, 255, 0.7)' }}>
Event Wallpaper
</Typography> </Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Button
variant="outlined"
component="span"
startIcon={<WallpaperIcon />}
onClick={handleWallpaperClick}
sx={{
flexGrow: 1,
borderColor: '#64b5f6',
color: '#64b5f6',
'&:hover': {
borderColor: '#90caf9',
backgroundColor: 'rgba(100, 181, 246, 0.08)',
}
}}
>
{wallpaper ? 'Change Wallpaper' : 'Upload Wallpaper'}
</Button>
{wallpaper && (
<IconButton
color="error"
onClick={() => setWallpaper(null)}
size="small"
sx={{
color: '#f44336',
'&:hover': {
backgroundColor: 'rgba(244, 67, 54, 0.08)',
}
}}
>
<DeleteIcon />
</IconButton>
)}
</Box>
{wallpaper && (
<Typography variant="body2" sx={{ color: 'rgba(255, 255, 255, 0.6)', mt: 1 }}>
Selected: {wallpaper.name}
</Typography>
)}
<input
type="file"
ref={fileInputRef}
onChange={handleWallpaperChange}
accept="image/jpeg,image/png,image/gif"
style={{ display: 'none' }}
/>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Typography variant="subtitle1" sx={{ color: 'rgba(255, 255, 255, 0.7)' }}>
Needed Items
</Typography>
<Box sx={{ display: 'flex', gap: 1 }}>
<DarkTextField <DarkTextField
fullWidth fullWidth
label="Add Item" label="Title"
value={currentItem} name="title"
onChange={handleItemChange} value={formData.title}
onChange={handleChange}
variant="outlined" variant="outlined"
size="small" required
/> />
<Button <DarkTextField
variant="contained" fullWidth
onClick={handleAddItem} label="Description"
disabled={!currentItem.trim()} name="description"
startIcon={<AddIcon />} value={formData.description}
sx={{ onChange={handleChange}
bgcolor: '#90caf9', variant="outlined"
'&:hover': { multiline
bgcolor: '#42a5f5', rows={4}
}, />
'&.Mui-disabled': { <DarkTextField
bgcolor: 'rgba(144, 202, 249, 0.3)', fullWidth
} label="Location"
name="location"
value={formData.location}
onChange={handleChange}
variant="outlined"
required
/>
<DarkTextField
fullWidth
label="Date and Time"
name="date"
type="datetime-local"
value={formData.date}
onChange={handleChange}
variant="outlined"
required
InputLabelProps={{
shrink: true,
}} }}
> />
Add <DarkTextField
</Button> fullWidth
</Box> label="RSVP Cut-off Date"
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}> name="rsvp_cutoff_date"
{formData.needed_items.map((item, index) => ( type="datetime-local"
<Chip value={formData.rsvp_cutoff_date}
key={index} onChange={handleChange}
label={item} variant="outlined"
onDelete={() => handleRemoveItem(index)} InputLabelProps={{
sx={{ shrink: true,
bgcolor: 'rgba(144, 202, 249, 0.2)', }}
color: 'rgba(255, 255, 255, 0.9)', />
'& .MuiChip-deleteIcon': { <DarkTextField
color: 'rgba(255, 255, 255, 0.7)', fullWidth
label="Maximum Additional Guests Per RSVP"
name="max_guests_per_rsvp"
type="number"
value={formData.max_guests_per_rsvp}
onChange={(e) => {
const value = parseInt(e.target.value);
setFormData((prev) => ({
...prev,
max_guests_per_rsvp: isNaN(value) ? 0 : value,
}));
}}
variant="outlined"
helperText="Set to 0 for no additional guests, -1 for unlimited"
inputProps={{ min: -1 }}
/>
{/* Needed Items moved here */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Typography variant="subtitle1" sx={{ color: 'rgba(255, 255, 255, 0.7)' }}>
Needed Items
</Typography>
<Box sx={{ display: 'flex', gap: 1 }}>
<DarkTextField
fullWidth
label="Add Item"
value={currentItem}
onChange={handleItemChange}
variant="outlined"
size="small"
/>
<Button
variant="contained"
onClick={handleAddItem}
disabled={!currentItem.trim()}
startIcon={<AddIcon />}
sx={{
bgcolor: '#90caf9',
'&:hover': { '&:hover': {
color: 'rgba(255, 255, 255, 0.9)', bgcolor: '#42a5f5',
},
'&.Mui-disabled': {
bgcolor: 'rgba(144, 202, 249, 0.3)',
} }
} }}
}} >
/> Add
))} </Button>
</Box>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{formData.needed_items.map((item, index) => (
<Chip
key={index}
label={item}
onDelete={() => handleRemoveItem(index)}
sx={{
bgcolor: 'rgba(144, 202, 249, 0.2)',
color: 'rgba(255, 255, 255, 0.9)',
'& .MuiChip-deleteIcon': {
color: 'rgba(255, 255, 255, 0.7)',
'&:hover': {
color: 'rgba(255, 255, 255, 0.9)',
}
}
}}
/>
))}
</Box>
</Box>
</Box> </Box>
</Box> </Box>
<Box sx={{ mt: 4, mb: 3, borderTop: '1px solid rgba(255, 255, 255, 0.12)', pt: 3 }}> <Divider sx={{ borderColor: 'rgba(255, 255, 255, 0.12)' }} />
<Typography variant="h6" sx={{ mb: 2, color: 'rgba(255, 255, 255, 0.9)' }}>
Email Notifications
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center' }}> {/* Part 2: Customization */}
<Box>
<Typography variant="h6" gutterBottom sx={{ color: 'rgba(255, 255, 255, 0.9)' }}>
Customization
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Typography variant="subtitle1" gutterBottom sx={{ color: 'rgba(255, 255, 255, 0.7)' }}>
Event Wallpaper
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Button
variant="outlined"
component="span"
startIcon={<WallpaperIcon />}
onClick={handleWallpaperClick}
sx={{
flexGrow: 1,
borderColor: '#64b5f6',
color: '#64b5f6',
'&:hover': {
borderColor: '#90caf9',
backgroundColor: 'rgba(100, 181, 246, 0.08)',
}
}}
>
{wallpaper ? 'Change Wallpaper' : 'Upload Wallpaper'}
</Button>
{wallpaper && (
<IconButton
color="error"
onClick={() => setWallpaper(null)}
size="small"
sx={{
color: '#f44336',
'&:hover': {
backgroundColor: 'rgba(244, 67, 54, 0.08)',
}
}}
>
<DeleteIcon />
</IconButton>
)}
</Box>
{wallpaper && (
<Typography variant="body2" sx={{ color: 'rgba(255, 255, 255, 0.6)', mt: 1 }}>
Selected: {wallpaper.name}
</Typography>
)}
<input
type="file"
ref={fileInputRef}
onChange={handleWallpaperChange}
accept="image/jpeg,image/png,image/gif"
style={{ display: 'none' }}
/>
</Box>
</Box>
</Box>
<Divider sx={{ borderColor: 'rgba(255, 255, 255, 0.12)' }} />
{/* Part 3: Notifications and Messaging */}
<Box>
<Typography variant="h6" gutterBottom sx={{ color: 'rgba(255, 255, 255, 0.9)' }}>
Notifications and Messaging
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<FormControlLabel <FormControlLabel
control={ control={
<Checkbox <Checkbox
@@ -367,21 +394,61 @@ const EventForm: React.FC = () => {
color: 'rgba(255, 255, 255, 0.9)', color: 'rgba(255, 255, 255, 0.9)',
}} }}
/> />
</Box>
{formData.email_notifications_enabled && ( {formData.email_notifications_enabled && (
<DarkTextField <DarkTextField
fullWidth fullWidth
label="Email Recipients (comma separated)" label="Email Recipients (comma separated)"
name="email_recipients" name="email_recipients"
value={formData.email_recipients} value={formData.email_recipients}
onChange={handleChange} onChange={handleChange}
variant="outlined" variant="outlined"
placeholder="email1@example.com, email2@example.com" placeholder="email1@example.com, email2@example.com"
helperText="Enter email addresses separated by commas" helperText="Enter email addresses separated by commas"
sx={{ mt: 2 }} sx={{ mt: 2 }} // Added margin top for spacing
/>
)}
<FormControlLabel
control={
<Checkbox
checked={formData.event_conclusion_email_enabled}
onChange={(e) => {
setFormData((prev) => ({
...prev,
event_conclusion_email_enabled: e.target.checked,
}));
}}
sx={{
color: 'rgba(255, 255, 255, 0.7)',
'&.Mui-checked': {
color: '#90caf9',
},
}}
/>
}
label="Enable Event Conclusion Email"
sx={{
color: 'rgba(255, 255, 255, 0.9)',
mt: 2, // Added margin top for spacing
}}
/> />
)}
{formData.event_conclusion_email_enabled && (
<DarkTextField
fullWidth
label="Event conclusion message"
name="event_conclusion_message" // Corrected name prop
value={formData.event_conclusion_message} // Corrected value prop
onChange={handleChange}
variant="outlined"
multiline
rows={4}
helperText="This message will be sent to attendees who opted for email notifications the day after the event."
sx={{ mt: 2 }} // Added margin top for spacing
/>
)}
</Box>
</Box> </Box>
<Button <Button

View File

@@ -23,6 +23,7 @@ import { Event } from '../types';
interface RSVPFormData { interface RSVPFormData {
name: string; name: string;
email_address: string;
attending: string; attending: string;
bringing_guests: string; bringing_guests: string;
guest_count: number; guest_count: number;
@@ -35,6 +36,7 @@ const RSVPEditForm: React.FC = () => {
const { slug, editId } = useParams<{ slug: string; editId: string }>(); const { slug, editId } = useParams<{ slug: string; editId: string }>();
const [formData, setFormData] = useState<RSVPFormData>({ const [formData, setFormData] = useState<RSVPFormData>({
name: '', name: '',
email_address: '',
attending: '', attending: '',
bringing_guests: '', bringing_guests: '',
guest_count: 1, guest_count: 1,
@@ -80,6 +82,7 @@ const RSVPEditForm: React.FC = () => {
// Pre-fill the form with existing RSVP data // Pre-fill the form with existing RSVP data
setFormData({ setFormData({
name: rsvpResponse.data.name, name: rsvpResponse.data.name,
email_address: rsvpResponse.data.attendee_email || '',
attending: rsvpResponse.data.attending, attending: rsvpResponse.data.attending,
bringing_guests: rsvpResponse.data.bringing_guests, bringing_guests: rsvpResponse.data.bringing_guests,
guest_count: rsvpResponse.data.guest_count, guest_count: rsvpResponse.data.guest_count,
@@ -272,7 +275,7 @@ const RSVPEditForm: React.FC = () => {
setIsSubmitting(true); setIsSubmitting(true);
setError(null); setError(null);
if (!formData.name.trim() || !formData.attending) { if (!formData.name.trim() || !formData.email_address.trim() || !formData.attending) {
setError('Please fill in all required fields'); setError('Please fill in all required fields');
setIsSubmitting(false); setIsSubmitting(false);
return; return;
@@ -431,6 +434,19 @@ const RSVPEditForm: React.FC = () => {
disabled={isEventClosed} // Disable if event is closed disabled={isEventClosed} // Disable if event is closed
/> />
<TextField
label="Email Address"
name="email_address"
type="email"
value={formData.email_address}
onChange={handleChange}
required
fullWidth
variant="outlined"
disabled={isEventClosed} // Disable if event is closed
helperText="If you change your email, a new confirmation will be sent to the new address"
/>
<FormControl fullWidth required disabled={isEventClosed}> {/* Disable if event is closed */} <FormControl fullWidth required disabled={isEventClosed}> {/* Disable if event is closed */}
<InputLabel>Are you attending?</InputLabel> <InputLabel>Are you attending?</InputLabel>
<Select <Select
@@ -574,6 +590,7 @@ const RSVPEditForm: React.FC = () => {
size="large" size="large"
disabled={isSubmitting || disabled={isSubmitting ||
!formData.name.trim() || !formData.name.trim() ||
!formData.email_address.trim() ||
!formData.attending || !formData.attending ||
(formData.attending === 'yes' && !formData.bringing_guests) || (formData.attending === 'yes' && !formData.bringing_guests) ||
(formData.bringing_guests === 'yes' && (formData.guest_count < 1 || formData.guest_names.some(name => !name.trim())))} (formData.bringing_guests === 'yes' && (formData.guest_count < 1 || formData.guest_names.some(name => !name.trim())))}

View File

@@ -24,28 +24,26 @@ import { Event } from '../types';
interface RSVPFormData { interface RSVPFormData {
name: string; name: string;
email_address: string; // Required email field
attending: string; attending: string;
bringing_guests: string; bringing_guests: string;
guest_count: number; guest_count: number;
guest_names: string[]; guest_names: string[];
items_bringing: string[]; items_bringing: string[];
other_items: string; other_items: string;
send_email_confirmation: boolean; // New field for email opt-in
email_address: string; // New field for recipient email
} }
const RSVPForm: React.FC = () => { const RSVPForm: React.FC = () => {
const { slug } = useParams<{ slug: string }>(); const { slug } = useParams<{ slug: string }>();
const [formData, setFormData] = useState<RSVPFormData>({ const [formData, setFormData] = useState<RSVPFormData>({
name: '', name: '',
email_address: '', // Required email field
attending: '', attending: '',
bringing_guests: '', bringing_guests: '',
guest_count: 1, guest_count: 1,
guest_names: [], guest_names: [],
items_bringing: [], items_bringing: [],
other_items: '', other_items: ''
send_email_confirmation: false, // Initialize to false
email_address: '' // Initialize to empty
}); });
const [neededItems, setNeededItems] = useState<string[]>([]); const [neededItems, setNeededItems] = useState<string[]>([]);
const [claimedItems, setClaimedItems] = useState<string[]>([]); const [claimedItems, setClaimedItems] = useState<string[]>([]);
@@ -209,9 +207,7 @@ const RSVPForm: React.FC = () => {
guest_count: 0, guest_count: 0,
guest_names: [], guest_names: [],
items_bringing: [], // Clear items when not attending items_bringing: [], // Clear items when not attending
other_items: '', other_items: ''
send_email_confirmation: false, // Also reset email opt-in
email_address: '' // And email address
})); }));
} else if (name === 'bringing_guests') { } else if (name === 'bringing_guests') {
// When bringing guests is changed // When bringing guests is changed
@@ -266,7 +262,7 @@ const RSVPForm: React.FC = () => {
setError(null); setError(null);
// Validate required fields // Validate required fields
if (!formData.name.trim() || !formData.attending) { if (!formData.name.trim() || !formData.email_address.trim() || !formData.attending) {
setError('Please fill in all required fields'); setError('Please fill in all required fields');
setIsSubmitting(false); setIsSubmitting(false);
return; return;
@@ -296,8 +292,9 @@ const RSVPForm: React.FC = () => {
...formData, ...formData,
items_bringing: formData.items_bringing, items_bringing: formData.items_bringing,
other_items: splitOtherItems, other_items: splitOtherItems,
send_email_confirmation: formData.send_email_confirmation, send_email_confirmation: true, // Always send email confirmation now
email_address: formData.email_address.trim() email_address: formData.email_address.trim(),
send_event_conclusion_email: true, // Always send true for conclusion email
}; };
const response = await axios.post(`/api/events/${slug}/rsvp`, submissionData); const response = await axios.post(`/api/events/${slug}/rsvp`, submissionData);
@@ -307,13 +304,8 @@ const RSVPForm: React.FC = () => {
axios.get(`/api/events/${slug}/rsvps`) axios.get(`/api/events/${slug}/rsvps`)
]); ]);
// Optionally display success message with edit link if email was sent // Email confirmation is always sent now
if (formData.send_email_confirmation && formData.email_address.trim()) { setSuccess(true);
// The backend sends the email, we just need to confirm success here
setSuccess(true);
} else {
setSuccess(true); // Still show success even if email wasn't sent
}
// Process needed items // Process needed items
let items: string[] = []; let items: string[] = [];
@@ -476,6 +468,18 @@ const RSVPForm: React.FC = () => {
variant="outlined" variant="outlined"
/> />
<TextField
label="Email Address"
name="email_address"
type="email"
value={formData.email_address}
onChange={handleChange}
required
fullWidth
variant="outlined"
helperText="You will receive a confirmation email with an edit link"
/>
<FormControl fullWidth required> <FormControl fullWidth required>
<InputLabel>Are you attending?</InputLabel> <InputLabel>Are you attending?</InputLabel>
<Select <Select
@@ -611,36 +615,6 @@ const RSVPForm: React.FC = () => {
</> </>
)} )}
{/* Email Notification Section */}
<Box sx={{ mt: 3, borderTop: '1px solid rgba(0, 0, 0, 0.12)', pt: 3 }}>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
To receive a link to edit your submission later, please enable email notifications below.
</Typography>
<FormControlLabel
control={
<Checkbox
checked={formData.send_email_confirmation}
onChange={handleCheckboxChange}
name="send_email_confirmation"
/>
}
label="Send me an email confirmation with an edit link"
/>
{formData.send_email_confirmation && (
<TextField
fullWidth
label="Your Email Address"
name="email_address"
type="email"
value={formData.email_address}
onChange={handleChange}
variant="outlined"
required // Make email required if checkbox is checked
sx={{ mt: 2 }}
/>
)}
</Box>
<Button <Button
type="submit" type="submit"
@@ -649,10 +623,10 @@ const RSVPForm: React.FC = () => {
size="large" size="large"
disabled={isSubmitting || disabled={isSubmitting ||
!formData.name.trim() || !formData.name.trim() ||
!formData.email_address.trim() ||
!formData.attending || !formData.attending ||
(formData.attending === 'yes' && !formData.bringing_guests) || (formData.attending === 'yes' && !formData.bringing_guests) ||
(formData.bringing_guests === 'yes' && (formData.guest_count < 1 || formData.guest_names.some(name => !name.trim()))) || (formData.bringing_guests === 'yes' && (formData.guest_count < 1 || formData.guest_names.some(name => !name.trim())))
(formData.send_email_confirmation && !formData.email_address.trim()) // Disable if email confirmation is checked but email is empty
} }
sx={{ mt: 2 }} sx={{ mt: 2 }}
> >