Merge pull request #6 from Ryderjj89/dev
Event conclusion message, calendar invites, & more attendee management
This commit is contained in:
13
README.md
13
README.md
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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())))}
|
||||||
|
|||||||
@@ -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 }}
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in New Issue
Block a user