Merge pull request #3 from Ryderjj89/dev
Email notifications & unique submission links for editing
This commit is contained in:
@@ -36,11 +36,9 @@ RUN mkdir -p /app/uploads/wallpapers && \
|
||||
chmod 755 /app/uploads && \
|
||||
chmod 644 /app/database.sqlite
|
||||
|
||||
# Copy package files and install dependencies
|
||||
COPY package*.json ./
|
||||
COPY backend/package*.json ./backend/
|
||||
# Copy backend package files and install dependencies
|
||||
COPY backend/package*.json ./
|
||||
RUN npm install --production
|
||||
RUN cd backend && npm install --production
|
||||
|
||||
# Copy built files from builder stage
|
||||
COPY --from=builder /app/backend/dist ./dist
|
||||
|
||||
22
README.md
22
README.md
@@ -2,7 +2,7 @@
|
||||
|
||||
A modern event RSVP management system with customizable backgrounds and item coordination.
|
||||
|
||||
This project was created completely by the [Cursor AI Code Editor](https://www.cursor.com/)!
|
||||
This project was created completely by the [Cursor AI Code Editor](https://www.cursor.com/) & Visual Studio Code with [Cline](https://cline.bot/)!
|
||||
|
||||
## Features
|
||||
|
||||
@@ -12,6 +12,8 @@ This project was created completely by the [Cursor AI Code Editor](https://www.c
|
||||
- Upload custom wallpapers to personalize event pages
|
||||
- Track RSVPs and guest counts
|
||||
- Comprehensive admin interface for event management
|
||||
- Email notifications for submitted RSVPs
|
||||
- Individual submission links so users can edit their submissions
|
||||
|
||||
- Item Coordination
|
||||
- Create and manage lists of needed items for events
|
||||
@@ -77,6 +79,20 @@ These variables below are all for the email notifications. If you want to be abl
|
||||
| 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 |
|
||||
|
||||
#### 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.
|
||||
|
||||
| Variable | Description |
|
||||
| ------------- | ------------- |
|
||||
| EMAIL_HOST | Your email provider's host name |
|
||||
| EMAIL_PORT | Your email provider's SMTP port |
|
||||
| EMAIL_USER | Login username for your email provider |
|
||||
| EMAIL_PASS | Login password for your email provider |
|
||||
| EMAIL_FROM_NAME | Name 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 |
|
||||
|
||||
#### Docker Compose
|
||||
|
||||
1. Clone the repository.
|
||||
@@ -134,6 +150,10 @@ docker run -d --name rsvp-manager \
|
||||
npm start
|
||||
```
|
||||
|
||||
## Email Notifications (Currently in dev branch!)
|
||||
|
||||
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.
|
||||
|
||||
## Authentication with Authentik
|
||||
|
||||
This application is compatible with Authentik. Make sure to create a Proxy Provider for Forward auth (single application). To protect the admin & create routes, add the following configuration to your Nginx Proxy Manager config in the Advanced section. For other web server applications, see the Setup area in Authentik on the Provider page for this app and setup the routes accordingly.
|
||||
|
||||
@@ -14,13 +14,17 @@
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.3.1",
|
||||
"sqlite3": "^5.1.6",
|
||||
"sqlite": "^4.1.2"
|
||||
"sqlite": "^4.1.2",
|
||||
"nodemailer": "^6.9.8",
|
||||
"multer": "^1.4.5-lts.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.17",
|
||||
"@types/node": "^20.4.5",
|
||||
"@types/cors": "^2.8.13",
|
||||
"@types/sqlite3": "^3.1.8",
|
||||
"@types/nodemailer": "^6.4.12",
|
||||
"@types/multer": "^1.4.7",
|
||||
"typescript": "^5.1.6",
|
||||
"nodemon": "^3.0.1",
|
||||
"ts-node": "^10.9.1"
|
||||
|
||||
119
backend/src/email.ts
Normal file
119
backend/src/email.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import nodemailer from 'nodemailer';
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: process.env.EMAIL_HOST,
|
||||
port: Number(process.env.EMAIL_PORT),
|
||||
secure: process.env.EMAIL_SECURE === 'true', // true for 465, false for other ports
|
||||
auth: {
|
||||
user: process.env.EMAIL_USER,
|
||||
pass: process.env.EMAIL_PASS,
|
||||
}
|
||||
});
|
||||
|
||||
export interface RSVPEmailData {
|
||||
eventTitle: string;
|
||||
eventSlug: string;
|
||||
name: string;
|
||||
attending: string;
|
||||
bringingGuests: string;
|
||||
guestCount: number;
|
||||
guestNames: string[];
|
||||
itemsBringing: string[];
|
||||
otherItems: string;
|
||||
to: string;
|
||||
}
|
||||
|
||||
export async function sendRSVPEmail(data: RSVPEmailData) {
|
||||
const {
|
||||
eventTitle,
|
||||
eventSlug,
|
||||
name,
|
||||
attending,
|
||||
bringingGuests,
|
||||
guestCount,
|
||||
guestNames,
|
||||
itemsBringing,
|
||||
otherItems,
|
||||
to,
|
||||
} = data;
|
||||
|
||||
// Capitalize attending and bringingGuests values
|
||||
function capitalizeYesNo(value: string) {
|
||||
if (typeof value !== 'string') return value;
|
||||
return value.toLowerCase() === 'yes' ? 'Yes' : value.toLowerCase() === 'no' ? 'No' : value;
|
||||
}
|
||||
|
||||
const subject = `RSVP Confirmation for ${eventTitle}`;
|
||||
const guestList = guestNames.length ? guestNames.join(', ') : 'None';
|
||||
const itemsList = itemsBringing.length ? itemsBringing.join(', ') : 'None';
|
||||
const otherItemsList = otherItems ? otherItems : 'None';
|
||||
const attendingDisplay = capitalizeYesNo(attending);
|
||||
const bringingGuestsDisplay = capitalizeYesNo(bringingGuests);
|
||||
|
||||
// Assume the frontend is served at the same host
|
||||
const baseUrl = process.env.FRONTEND_BASE_URL || '';
|
||||
const manageRsvpsUrl = `${baseUrl}/admin/events/${eventSlug}`;
|
||||
const viewRsvpsUrl = `${baseUrl}/view/events/${eventSlug}`;
|
||||
|
||||
const html = `
|
||||
<h2>RSVP Confirmation</h2>
|
||||
<p><strong>Event:</strong> ${eventTitle}</p>
|
||||
<p><strong>Name:</strong> ${name}</p>
|
||||
<p><strong>Attending:</strong> ${attendingDisplay}</p>
|
||||
<p><strong>Bringing Guests:</strong> ${bringingGuestsDisplay} (${guestCount})</p>
|
||||
<p><strong>Guest Names:</strong> ${guestList}</p>
|
||||
<p><strong>Items Claimed:</strong> ${itemsList}</p>
|
||||
<p><strong>Other Items:</strong> ${otherItemsList}</p>
|
||||
<p><a href="${manageRsvpsUrl}">Manage RSVPs for this event</a></p>
|
||||
<p><a href="${viewRsvpsUrl}">View all RSVPs for this event</a></p>
|
||||
`;
|
||||
|
||||
await transporter.sendMail({
|
||||
from: {
|
||||
name: process.env.EMAIL_FROM_NAME || '',
|
||||
address: process.env.EMAIL_FROM_ADDRESS || process.env.EMAIL_USER || '',
|
||||
},
|
||||
to,
|
||||
subject,
|
||||
html,
|
||||
});
|
||||
}
|
||||
|
||||
export interface RSVPEditLinkEmailData {
|
||||
eventTitle: string;
|
||||
eventSlug: string;
|
||||
name: string;
|
||||
to: string;
|
||||
editLink: string;
|
||||
}
|
||||
|
||||
export async function sendRSVPEditLinkEmail(data: RSVPEditLinkEmailData) {
|
||||
const {
|
||||
eventTitle,
|
||||
eventSlug,
|
||||
name,
|
||||
to,
|
||||
editLink,
|
||||
} = data;
|
||||
|
||||
const subject = `Confirming your RSVP for ${eventTitle}`; // Update the subject line
|
||||
|
||||
const html = `
|
||||
<p>Hello ${name},</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><a href="${editLink}">${editLink}</a></p>
|
||||
<p>Please save this email if you think you might need to edit your submission later.</p>
|
||||
<p>Thank you!</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,9 +6,21 @@ import dotenv from 'dotenv';
|
||||
import path from 'path';
|
||||
import multer from 'multer';
|
||||
import fs from 'fs';
|
||||
import { sendRSVPEmail, sendRSVPEditLinkEmail } from './email'; // Import the new email function
|
||||
|
||||
dotenv.config();
|
||||
|
||||
// Function to generate a random alphanumeric string
|
||||
function generateAlphanumericId(length: number): string {
|
||||
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
let result = '';
|
||||
const charactersLength = characters.length;
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += characters.charAt(Math.floor(Math.random() * charactersLength));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const app = express();
|
||||
const port = process.env.PORT || 3000;
|
||||
|
||||
@@ -151,7 +163,17 @@ app.get('/api/events/:slug', async (req: Request, res: Response) => {
|
||||
|
||||
app.post('/api/events', upload.single('wallpaper'), async (req: MulterRequest, res: Response) => {
|
||||
try {
|
||||
const { title, description, date, location, needed_items, rsvp_cutoff_date } = req.body;
|
||||
const {
|
||||
title,
|
||||
description,
|
||||
date,
|
||||
location,
|
||||
needed_items,
|
||||
rsvp_cutoff_date,
|
||||
max_guests_per_rsvp,
|
||||
email_notifications_enabled,
|
||||
email_recipients
|
||||
} = req.body;
|
||||
const wallpaperPath = req.file ? `${req.file.filename}` : null;
|
||||
|
||||
// Generate a slug from the title
|
||||
@@ -169,9 +191,15 @@ app.post('/api/events', upload.single('wallpaper'), async (req: MulterRequest, r
|
||||
console.error('Error parsing needed_items:', e);
|
||||
}
|
||||
|
||||
// Parse max_guests_per_rsvp to ensure it's a number
|
||||
const maxGuests = parseInt(max_guests_per_rsvp as string) || 0;
|
||||
|
||||
// Parse email_notifications_enabled to ensure it's a boolean
|
||||
const emailNotificationsEnabled = email_notifications_enabled === 'true' || email_notifications_enabled === true;
|
||||
|
||||
const result = await db.run(
|
||||
'INSERT INTO events (title, description, date, location, slug, needed_items, wallpaper, rsvp_cutoff_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[title, description, date, location, slug, JSON.stringify(parsedNeededItems), wallpaperPath, rsvp_cutoff_date]
|
||||
'INSERT INTO events (title, description, date, location, slug, needed_items, wallpaper, rsvp_cutoff_date, max_guests_per_rsvp, email_notifications_enabled, email_recipients) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[title, description, date, location, slug, JSON.stringify(parsedNeededItems), wallpaperPath, rsvp_cutoff_date, maxGuests, emailNotificationsEnabled ? 1 : 0, email_recipients || '']
|
||||
);
|
||||
|
||||
res.status(201).json({
|
||||
@@ -179,7 +207,10 @@ app.post('/api/events', upload.single('wallpaper'), async (req: MulterRequest, r
|
||||
slug,
|
||||
wallpaper: wallpaperPath ? `/uploads/wallpapers/${wallpaperPath}` : null,
|
||||
needed_items: parsedNeededItems,
|
||||
rsvp_cutoff_date
|
||||
rsvp_cutoff_date,
|
||||
max_guests_per_rsvp: maxGuests,
|
||||
email_notifications_enabled: emailNotificationsEnabled,
|
||||
email_recipients: email_recipients || ''
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creating event:', error);
|
||||
@@ -255,15 +286,31 @@ app.get('/api/events/:slug/rsvps', async (req: Request, res: Response) => {
|
||||
app.post('/api/events/:slug/rsvp', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { slug } = 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_email_confirmation, // New field for email opt-in
|
||||
email_address // New field for recipient email
|
||||
} = req.body;
|
||||
|
||||
const eventRows = await db.all('SELECT id FROM events WHERE slug = ?', [slug]);
|
||||
// Get the event with email notification settings
|
||||
const eventRows = await db.all('SELECT id, title, slug, email_notifications_enabled, email_recipients FROM events WHERE slug = ?', [slug]);
|
||||
|
||||
if (eventRows.length === 0) {
|
||||
return res.status(404).json({ error: 'Event not found' });
|
||||
}
|
||||
|
||||
const eventId = eventRows[0].id;
|
||||
const event = eventRows[0];
|
||||
const eventId = event.id;
|
||||
const eventTitle = event.title;
|
||||
const eventSlug = event.slug;
|
||||
const emailNotificationsEnabled = event.email_notifications_enabled;
|
||||
const eventEmailRecipients = event.email_recipients;
|
||||
|
||||
// Parse items_bringing if it's a string
|
||||
let parsedItemsBringing: string[] = [];
|
||||
@@ -289,12 +336,91 @@ app.post('/api/events/:slug/rsvp', async (req: Request, res: Response) => {
|
||||
console.error('Error parsing guest_names:', e);
|
||||
}
|
||||
|
||||
// Generate a unique edit ID
|
||||
let editId = '';
|
||||
let isUnique = false;
|
||||
while (!isUnique) {
|
||||
editId = generateAlphanumericId(16);
|
||||
const existingRsvp = await db.get('SELECT id FROM rsvps WHERE edit_id = ?', [editId]);
|
||||
if (!existingRsvp) {
|
||||
isUnique = true;
|
||||
}
|
||||
}
|
||||
|
||||
const result = await db.run(
|
||||
'INSERT INTO rsvps (event_id, name, attending, bringing_guests, guest_count, guest_names, items_bringing, other_items) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[eventId, name, attending, bringing_guests, guest_count, JSON.stringify(parsedGuestNames), JSON.stringify(parsedItemsBringing), other_items || '']
|
||||
'INSERT INTO rsvps (event_id, name, attending, bringing_guests, guest_count, guest_names, items_bringing, other_items, edit_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[eventId, name, attending, bringing_guests, guest_count, JSON.stringify(parsedGuestNames), JSON.stringify(parsedItemsBringing), other_items || '', editId]
|
||||
);
|
||||
|
||||
// Return the complete RSVP data including the parsed arrays
|
||||
// Send email notifications to event recipients if enabled for this event
|
||||
if (emailNotificationsEnabled) {
|
||||
// Get recipients from event settings
|
||||
let recipients: string[] = [];
|
||||
|
||||
// Use the event's email recipients
|
||||
if (eventEmailRecipients) {
|
||||
recipients = eventEmailRecipients.split(',').map((addr: string) => addr.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
// If no recipients are set for the event, use the sender email as a fallback
|
||||
if (recipients.length === 0 && process.env.EMAIL_USER) {
|
||||
recipients = [process.env.EMAIL_USER];
|
||||
}
|
||||
|
||||
if (recipients.length > 0) {
|
||||
try {
|
||||
for (const to of recipients) {
|
||||
await sendRSVPEmail({
|
||||
eventTitle,
|
||||
eventSlug,
|
||||
name,
|
||||
attending,
|
||||
bringingGuests: bringing_guests,
|
||||
guestCount: guest_count,
|
||||
guestNames: parsedGuestNames,
|
||||
itemsBringing: parsedItemsBringing,
|
||||
otherItems: other_items || '',
|
||||
to,
|
||||
});
|
||||
}
|
||||
} catch (emailErr) {
|
||||
console.error('Error sending RSVP email to event recipients:', emailErr);
|
||||
}
|
||||
} else {
|
||||
console.warn('No event email recipients set. Skipping RSVP email notification to event recipients.');
|
||||
}
|
||||
} else {
|
||||
console.log('Email notifications disabled for this event. Skipping RSVP email notification to event recipients.');
|
||||
}
|
||||
|
||||
// Send email confirmation with edit link to the submitter if requested
|
||||
const sendEmailConfirmationBool = send_email_confirmation === 'true' || send_email_confirmation === true;
|
||||
const submitterEmail = email_address?.trim();
|
||||
|
||||
if (sendEmailConfirmationBool && submitterEmail && process.env.EMAIL_USER) {
|
||||
try {
|
||||
const editLink = `${process.env.FRONTEND_BASE_URL}/events/${eventSlug}/rsvp/edit/${editId}`;
|
||||
await sendRSVPEditLinkEmail({
|
||||
eventTitle,
|
||||
eventSlug,
|
||||
name,
|
||||
to: submitterEmail,
|
||||
editLink,
|
||||
});
|
||||
console.log(`Sent RSVP edit link email to ${submitterEmail}`);
|
||||
} catch (emailErr) {
|
||||
console.error('Error sending RSVP edit link email:', emailErr);
|
||||
}
|
||||
} else if (sendEmailConfirmationBool && !submitterEmail) {
|
||||
console.warn('Email confirmation requested but no email address provided. Skipping edit link email.');
|
||||
} 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.');
|
||||
} else {
|
||||
console.log('Email confirmation not requested. Skipping edit link email.');
|
||||
}
|
||||
|
||||
|
||||
// Return the complete RSVP data including the parsed arrays and edit_id
|
||||
res.status(201).json({
|
||||
id: result.lastID,
|
||||
event_id: eventId,
|
||||
@@ -305,6 +431,7 @@ app.post('/api/events/:slug/rsvp', async (req: Request, res: Response) => {
|
||||
guest_names: parsedGuestNames,
|
||||
items_bringing: parsedItemsBringing,
|
||||
other_items: other_items || '',
|
||||
edit_id: editId,
|
||||
created_at: new Date().toISOString()
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -313,6 +440,34 @@ app.post('/api/events/:slug/rsvp', async (req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Get RSVP by edit ID
|
||||
app.get('/api/rsvps/edit/:editId', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { editId } = req.params;
|
||||
const rsvp = await db.get('SELECT * FROM rsvps WHERE edit_id = ?', [editId]);
|
||||
|
||||
if (!rsvp) {
|
||||
return res.status(404).json({ error: 'RSVP not found' });
|
||||
}
|
||||
|
||||
// Parse arrays for response
|
||||
try {
|
||||
rsvp.items_bringing = rsvp.items_bringing ? JSON.parse(rsvp.items_bringing) : [];
|
||||
rsvp.guest_names = rsvp.guest_names ? JSON.parse(rsvp.guest_names) : [];
|
||||
} catch (e) {
|
||||
console.error('Error parsing arrays in response:', e);
|
||||
rsvp.items_bringing = [];
|
||||
rsvp.guest_names = [];
|
||||
}
|
||||
|
||||
res.json(rsvp);
|
||||
} catch (error) {
|
||||
console.error('Error fetching RSVP by edit ID:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
app.delete('/api/events/:slug/rsvps/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { slug, id } = req.params;
|
||||
@@ -333,6 +488,83 @@ app.delete('/api/events/:slug/rsvps/:id', async (req: Request, res: Response) =>
|
||||
}
|
||||
});
|
||||
|
||||
// Update RSVP by edit ID
|
||||
app.put('/api/rsvps/edit/:editId', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { editId } = req.params;
|
||||
const { name, attending, bringing_guests, guest_count, guest_names, items_bringing, other_items } = req.body;
|
||||
|
||||
// Find the RSVP by edit_id
|
||||
const rsvp = await db.get('SELECT id, event_id FROM rsvps WHERE edit_id = ?', [editId]);
|
||||
|
||||
if (!rsvp) {
|
||||
return res.status(404).json({ error: 'RSVP not found' });
|
||||
}
|
||||
|
||||
const rsvpId = rsvp.id;
|
||||
const eventId = rsvp.event_id;
|
||||
|
||||
// Parse items_bringing if it's a string
|
||||
let parsedItemsBringing: string[] = [];
|
||||
try {
|
||||
if (typeof items_bringing === 'string') {
|
||||
parsedItemsBringing = JSON.parse(items_bringing);
|
||||
} else if (Array.isArray(items_bringing)) {
|
||||
parsedItemsBringing = items_bringing;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error parsing items_bringing:', e);
|
||||
}
|
||||
|
||||
// Parse guest_names if it's a string
|
||||
let parsedGuestNames: string[] = [];
|
||||
try {
|
||||
if (typeof guest_names === 'string' && guest_names.includes('[')) {
|
||||
// If it's a JSON string array
|
||||
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)) {
|
||||
// If it's already an array
|
||||
parsedGuestNames = guest_names.filter(name => name && name.trim());
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error parsing guest_names:', e);
|
||||
parsedGuestNames = [];
|
||||
}
|
||||
|
||||
// Update the RSVP
|
||||
await db.run(
|
||||
'UPDATE rsvps SET name = ?, attending = ?, bringing_guests = ?, guest_count = ?, guest_names = ?, items_bringing = ?, other_items = ? WHERE id = ?',
|
||||
[name, attending, bringing_guests, guest_count, JSON.stringify(parsedGuestNames), JSON.stringify(parsedItemsBringing), other_items || '', rsvpId]
|
||||
);
|
||||
|
||||
// Get the updated RSVP to verify and return
|
||||
const updatedRsvp = await db.get('SELECT * FROM rsvps WHERE id = ?', [rsvpId]);
|
||||
|
||||
if (!updatedRsvp) {
|
||||
return res.status(404).json({ error: 'RSVP not found after update' });
|
||||
}
|
||||
|
||||
// Parse arrays for response
|
||||
try {
|
||||
updatedRsvp.items_bringing = updatedRsvp.items_bringing ? JSON.parse(updatedRsvp.items_bringing) : [];
|
||||
updatedRsvp.guest_names = updatedRsvp.guest_names ? JSON.parse(updatedRsvp.guest_names) : [];
|
||||
} catch (e) {
|
||||
console.error('Error parsing arrays in response:', e);
|
||||
updatedRsvp.items_bringing = [];
|
||||
updatedRsvp.guest_names = [];
|
||||
}
|
||||
|
||||
res.json(updatedRsvp);
|
||||
} catch (error) {
|
||||
console.error('Error updating RSVP by edit ID:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Update RSVP
|
||||
app.put('/api/events/:slug/rsvps/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
@@ -412,7 +644,17 @@ app.put('/api/events/:slug/rsvps/:id', async (req: Request, res: Response) => {
|
||||
app.put('/api/events/:slug', upload.single('wallpaper'), async (req: MulterRequest, res: Response) => {
|
||||
try {
|
||||
const { slug } = req.params;
|
||||
const { title, description, date, location, needed_items, rsvp_cutoff_date } = req.body;
|
||||
const {
|
||||
title,
|
||||
description,
|
||||
date,
|
||||
location,
|
||||
needed_items,
|
||||
rsvp_cutoff_date,
|
||||
max_guests_per_rsvp,
|
||||
email_notifications_enabled,
|
||||
email_recipients
|
||||
} = req.body;
|
||||
|
||||
// Verify the event exists
|
||||
const eventRows = await db.all('SELECT * FROM events WHERE slug = ?', [slug]);
|
||||
@@ -433,6 +675,21 @@ app.put('/api/events/:slug', upload.single('wallpaper'), async (req: MulterReque
|
||||
console.error('Error parsing needed_items:', e);
|
||||
}
|
||||
|
||||
// Parse max_guests_per_rsvp to ensure it's a number
|
||||
const maxGuests = max_guests_per_rsvp !== undefined ?
|
||||
(parseInt(max_guests_per_rsvp as string) || 0) :
|
||||
eventRows[0].max_guests_per_rsvp || 0;
|
||||
|
||||
// Parse email_notifications_enabled to ensure it's a boolean
|
||||
const emailNotificationsEnabled = email_notifications_enabled !== undefined ?
|
||||
(email_notifications_enabled === 'true' || email_notifications_enabled === true) :
|
||||
eventRows[0].email_notifications_enabled;
|
||||
|
||||
// Get email recipients
|
||||
const emailRecipients = email_recipients !== undefined ?
|
||||
email_recipients :
|
||||
eventRows[0].email_recipients || '';
|
||||
|
||||
// Handle wallpaper update
|
||||
let wallpaperPath = eventRows[0].wallpaper;
|
||||
if (req.file) {
|
||||
@@ -450,7 +707,7 @@ app.put('/api/events/:slug', upload.single('wallpaper'), async (req: MulterReque
|
||||
|
||||
// Update the event
|
||||
await db.run(
|
||||
'UPDATE events SET title = ?, description = ?, date = ?, location = ?, needed_items = ?, rsvp_cutoff_date = ?, wallpaper = ? WHERE slug = ?',
|
||||
'UPDATE events SET title = ?, description = ?, date = ?, location = ?, needed_items = ?, rsvp_cutoff_date = ?, wallpaper = ?, max_guests_per_rsvp = ?, email_notifications_enabled = ?, email_recipients = ? WHERE slug = ?',
|
||||
[
|
||||
title ?? eventRows[0].title,
|
||||
description === undefined ? eventRows[0].description : description,
|
||||
@@ -459,6 +716,9 @@ app.put('/api/events/:slug', upload.single('wallpaper'), async (req: MulterReque
|
||||
JSON.stringify(parsedNeededItems),
|
||||
rsvp_cutoff_date !== undefined ? rsvp_cutoff_date : eventRows[0].rsvp_cutoff_date,
|
||||
wallpaperPath,
|
||||
maxGuests,
|
||||
emailNotificationsEnabled ? 1 : 0,
|
||||
emailRecipients,
|
||||
slug
|
||||
]
|
||||
);
|
||||
@@ -507,6 +767,9 @@ async function initializeDatabase() {
|
||||
needed_items TEXT,
|
||||
wallpaper TEXT,
|
||||
rsvp_cutoff_date TEXT,
|
||||
max_guests_per_rsvp INTEGER DEFAULT 0,
|
||||
email_notifications_enabled BOOLEAN DEFAULT 0,
|
||||
email_recipients TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
@@ -523,6 +786,7 @@ async function initializeDatabase() {
|
||||
guest_names TEXT,
|
||||
items_bringing TEXT,
|
||||
other_items TEXT,
|
||||
edit_id TEXT UNIQUE, -- Add a column for the unique edit ID
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (event_id) REFERENCES events(id) ON DELETE CASCADE
|
||||
)
|
||||
|
||||
@@ -11,6 +11,14 @@ services:
|
||||
- uploads:/app/uploads
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- EMAIL_HOST=smtp.example.com
|
||||
- EMAIL_PORT=587
|
||||
- EMAIL_USER=your@email.com
|
||||
- EMAIL_PASS=yourpassword
|
||||
- EMAIL_FROM_NAME=RSVP Manager
|
||||
- EMAIL_FROM_ADDRESS=your@email.com
|
||||
- EMAIL_SECURE=false
|
||||
- FRONTEND_BASE_URL=https://your-frontend-domain.com
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
|
||||
@@ -9,6 +9,14 @@ services:
|
||||
- uploads:/app/uploads
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- EMAIL_HOST=smtp.example.com
|
||||
- EMAIL_PORT=587
|
||||
- EMAIL_USER=your@email.com
|
||||
- EMAIL_PASS=yourpassword
|
||||
- EMAIL_FROM_NAME=RSVP Manager
|
||||
- EMAIL_FROM_ADDRESS=your@email.com
|
||||
- EMAIL_SECURE=false
|
||||
- FRONTEND_BASE_URL=https://your-frontend-domain.com
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
|
||||
@@ -8,6 +8,7 @@ import EventForm from './components/EventForm';
|
||||
import RSVPForm from './components/RSVPForm';
|
||||
import EventAdmin from './components/EventAdmin';
|
||||
import EventView from './components/EventView';
|
||||
import RSVPEditForm from './components/RSVPEditForm'; // Import the new component
|
||||
import './App.css';
|
||||
|
||||
const darkTheme = createTheme({
|
||||
@@ -71,6 +72,7 @@ const App: React.FC = () => {
|
||||
<Route path="/rsvp/events/:slug" element={<RSVPForm />} />
|
||||
<Route path="/admin/events/:slug" element={<EventAdmin />} />
|
||||
<Route path="/view/events/:slug" element={<EventView />} />
|
||||
<Route path="/events/:slug/rsvp/edit/:editId" element={<RSVPEditForm />} /> {/* Add the new route */}
|
||||
</Routes>
|
||||
</Container>
|
||||
</Box>
|
||||
|
||||
@@ -60,6 +60,9 @@ interface Event {
|
||||
needed_items?: string[] | string;
|
||||
wallpaper?: string;
|
||||
rsvp_cutoff_date?: string;
|
||||
max_guests_per_rsvp?: number;
|
||||
email_notifications_enabled?: boolean;
|
||||
email_recipients?: string;
|
||||
}
|
||||
|
||||
interface EditFormData {
|
||||
@@ -105,7 +108,9 @@ const EventAdmin: React.FC = () => {
|
||||
location: '',
|
||||
date: '',
|
||||
rsvp_cutoff_date: '',
|
||||
wallpaper: null as File | null
|
||||
wallpaper: null as File | null,
|
||||
email_notifications_enabled: false,
|
||||
email_recipients: ''
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -567,7 +572,9 @@ const EventAdmin: React.FC = () => {
|
||||
location: event.location,
|
||||
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) : '',
|
||||
wallpaper: null
|
||||
wallpaper: null,
|
||||
email_notifications_enabled: event.email_notifications_enabled || false,
|
||||
email_recipients: event.email_recipients || ''
|
||||
});
|
||||
setUpdateInfoDialogOpen(true);
|
||||
};
|
||||
@@ -584,6 +591,8 @@ const EventAdmin: React.FC = () => {
|
||||
formData.append('rsvp_cutoff_date', updateForm.rsvp_cutoff_date);
|
||||
formData.append('title', event.title); // Keep existing title
|
||||
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_recipients', updateForm.email_recipients);
|
||||
|
||||
// Append wallpaper if a new one was selected
|
||||
if (updateForm.wallpaper) {
|
||||
@@ -602,7 +611,9 @@ const EventAdmin: React.FC = () => {
|
||||
location: updateForm.location,
|
||||
date: updateForm.date,
|
||||
rsvp_cutoff_date: updateForm.rsvp_cutoff_date,
|
||||
wallpaper: response.data.wallpaper || prev.wallpaper
|
||||
wallpaper: response.data.wallpaper || prev.wallpaper,
|
||||
email_notifications_enabled: updateForm.email_notifications_enabled,
|
||||
email_recipients: updateForm.email_recipients
|
||||
} : null);
|
||||
|
||||
setUpdateInfoDialogOpen(false);
|
||||
@@ -850,6 +861,7 @@ const EventAdmin: React.FC = () => {
|
||||
<Chip
|
||||
key={index}
|
||||
label={item}
|
||||
color="success" // Change color to success
|
||||
sx={{ m: 0.5 }}
|
||||
/>
|
||||
)) :
|
||||
@@ -858,6 +870,7 @@ const EventAdmin: React.FC = () => {
|
||||
<Chip
|
||||
key={index}
|
||||
label={item}
|
||||
color="success" // Change color to success
|
||||
sx={{ m: 0.5 }}
|
||||
/>
|
||||
)) :
|
||||
@@ -1125,6 +1138,40 @@ const EventAdmin: React.FC = () => {
|
||||
shrink: true,
|
||||
}}
|
||||
/>
|
||||
<Box sx={{ mt: 1, mb: 2 }}>
|
||||
<Typography variant="subtitle1" gutterBottom>
|
||||
Email Notifications
|
||||
</Typography>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={updateForm.email_notifications_enabled}
|
||||
onChange={(e) => setUpdateForm(prev => ({
|
||||
...prev,
|
||||
email_notifications_enabled: e.target.checked
|
||||
}))}
|
||||
/>
|
||||
}
|
||||
label="Enable Email Notifications"
|
||||
/>
|
||||
|
||||
{updateForm.email_notifications_enabled && (
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Email Recipients (comma separated)"
|
||||
value={updateForm.email_recipients}
|
||||
onChange={(e) => setUpdateForm(prev => ({
|
||||
...prev,
|
||||
email_recipients: e.target.value
|
||||
}))}
|
||||
variant="outlined"
|
||||
placeholder="email1@example.com, email2@example.com"
|
||||
helperText="Enter email addresses separated by commas"
|
||||
sx={{ mt: 2 }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<Typography variant="subtitle1" gutterBottom>
|
||||
Wallpaper
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
Chip,
|
||||
IconButton,
|
||||
styled,
|
||||
Checkbox,
|
||||
FormControlLabel,
|
||||
} from '@mui/material';
|
||||
import WallpaperIcon from '@mui/icons-material/Wallpaper';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
@@ -43,6 +45,9 @@ interface FormData {
|
||||
location: string;
|
||||
needed_items: string[];
|
||||
rsvp_cutoff_date: string;
|
||||
max_guests_per_rsvp: number;
|
||||
email_notifications_enabled: boolean;
|
||||
email_recipients: string;
|
||||
}
|
||||
|
||||
const EventForm: React.FC = () => {
|
||||
@@ -54,6 +59,9 @@ const EventForm: React.FC = () => {
|
||||
location: '',
|
||||
needed_items: [],
|
||||
rsvp_cutoff_date: '',
|
||||
max_guests_per_rsvp: 0,
|
||||
email_notifications_enabled: false,
|
||||
email_recipients: '',
|
||||
});
|
||||
const [wallpaper, setWallpaper] = useState<File | null>(null);
|
||||
const [currentItem, setCurrentItem] = useState('');
|
||||
@@ -198,6 +206,24 @@ const EventForm: React.FC = () => {
|
||||
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"
|
||||
@@ -312,6 +338,52 @@ const EventForm: React.FC = () => {
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mt: 4, mb: 3, borderTop: '1px solid rgba(255, 255, 255, 0.12)', pt: 3 }}>
|
||||
<Typography variant="h6" sx={{ mb: 2, color: 'rgba(255, 255, 255, 0.9)' }}>
|
||||
Email Notifications
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={formData.email_notifications_enabled}
|
||||
onChange={(e) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
email_notifications_enabled: e.target.checked,
|
||||
}));
|
||||
}}
|
||||
sx={{
|
||||
color: 'rgba(255, 255, 255, 0.7)',
|
||||
'&.Mui-checked': {
|
||||
color: '#90caf9',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label="Enable Email Notifications"
|
||||
sx={{
|
||||
color: 'rgba(255, 255, 255, 0.9)',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{formData.email_notifications_enabled && (
|
||||
<DarkTextField
|
||||
fullWidth
|
||||
label="Email Recipients (comma separated)"
|
||||
name="email_recipients"
|
||||
value={formData.email_recipients}
|
||||
onChange={handleChange}
|
||||
variant="outlined"
|
||||
placeholder="email1@example.com, email2@example.com"
|
||||
helperText="Enter email addresses separated by commas"
|
||||
sx={{ mt: 2 }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
Container,
|
||||
Chip,
|
||||
Stack,
|
||||
Switch,
|
||||
FormControlLabel,
|
||||
} from '@mui/material';
|
||||
import AdminPanelSettingsIcon from '@mui/icons-material/AdminPanelSettings';
|
||||
import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||
@@ -30,6 +32,7 @@ interface Event {
|
||||
const EventList: React.FC = () => {
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const navigate = useNavigate();
|
||||
const [hideClosed, setHideClosed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchEvents();
|
||||
@@ -84,81 +87,95 @@ const EventList: React.FC = () => {
|
||||
>
|
||||
Create Event
|
||||
</Button>
|
||||
<Box sx={{ mt: 2, mb: 4, display: 'flex', justifyContent: 'center' }}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={hideClosed}
|
||||
onChange={(_, checked) => setHideClosed(checked)}
|
||||
color="primary"
|
||||
/>
|
||||
}
|
||||
label="Hide Closed Events"
|
||||
/>
|
||||
</Box>
|
||||
</Container>
|
||||
|
||||
<Container maxWidth="lg">
|
||||
<Grid container spacing={4}>
|
||||
{events.map((event) => (
|
||||
<Grid item xs={12} sm={6} md={6} key={event.id}>
|
||||
<Card
|
||||
sx={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
opacity: isEventOpen(event) ? 1 : 0.7,
|
||||
'& .MuiCardContent-root': {
|
||||
padding: 3
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CardContent sx={{ flexGrow: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||
<Typography variant="h5" component="h2" sx={{ flexGrow: 1 }}>
|
||||
{event.title}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={isEventOpen(event) ? "Open" : "Closed"}
|
||||
color={isEventOpen(event) ? "success" : "error"}
|
||||
size="small"
|
||||
/>
|
||||
</Box>
|
||||
{event.description && (
|
||||
{events
|
||||
.filter(event => !hideClosed || isEventOpen(event))
|
||||
.map((event) => (
|
||||
<Grid item xs={12} sm={6} md={6} key={event.id}>
|
||||
<Card
|
||||
sx={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
opacity: isEventOpen(event) ? 1 : 0.7,
|
||||
'& .MuiCardContent-root': {
|
||||
padding: 3
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CardContent sx={{ flexGrow: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||
<Typography variant="h5" component="h2" sx={{ flexGrow: 1 }}>
|
||||
{event.title}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={isEventOpen(event) ? "Open" : "Closed"}
|
||||
color={isEventOpen(event) ? "success" : "error"}
|
||||
size="small"
|
||||
/>
|
||||
</Box>
|
||||
{event.description && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
<strong>Info:</strong> {event.description}
|
||||
</Typography>
|
||||
)}
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
<strong>Info:</strong> {event.description}
|
||||
<strong>Date:</strong> {new Date(event.date).toLocaleString()}
|
||||
</Typography>
|
||||
)}
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
<strong>Date:</strong> {new Date(event.date).toLocaleString()}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
<strong>Location:</strong> {event.location}
|
||||
</Typography>
|
||||
{event.rsvp_cutoff_date && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
<strong>RSVP cut-off date:</strong> {new Date(event.rsvp_cutoff_date).toLocaleString()}
|
||||
<strong>Location:</strong> {event.location}
|
||||
</Typography>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardActions sx={{ justifyContent: 'space-between', px: 3, pb: 2 }}>
|
||||
<Stack direction="row" spacing={1}>
|
||||
{isEventOpen(event) && (
|
||||
{event.rsvp_cutoff_date && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
<strong>RSVP cut-off date:</strong> {new Date(event.rsvp_cutoff_date).toLocaleString()}
|
||||
</Typography>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardActions sx={{ justifyContent: 'space-between', px: 3, pb: 2 }}>
|
||||
<Stack direction="row" spacing={1}>
|
||||
{isEventOpen(event) && (
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<HowToRegIcon />}
|
||||
onClick={() => navigate(`/rsvp/events/${event.slug}`)}
|
||||
>
|
||||
Submit RSVP
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<HowToRegIcon />}
|
||||
onClick={() => navigate(`/rsvp/events/${event.slug}`)}
|
||||
startIcon={<VisibilityIcon />}
|
||||
onClick={(e) => handleViewClick(event, e)}
|
||||
>
|
||||
Submit RSVP
|
||||
View RSVPs
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<VisibilityIcon />}
|
||||
onClick={(e) => handleViewClick(event, e)}
|
||||
startIcon={<AdminPanelSettingsIcon />}
|
||||
onClick={(e) => handleAdminClick(event, e)}
|
||||
>
|
||||
View RSVPs
|
||||
Manage
|
||||
</Button>
|
||||
</Stack>
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<AdminPanelSettingsIcon />}
|
||||
onClick={(e) => handleAdminClick(event, e)}
|
||||
>
|
||||
Manage
|
||||
</Button>
|
||||
</CardActions>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
</CardActions>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Container>
|
||||
</Box>
|
||||
|
||||
593
frontend/src/components/RSVPEditForm.tsx
Normal file
593
frontend/src/components/RSVPEditForm.tsx
Normal file
@@ -0,0 +1,593 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import axios from 'axios';
|
||||
import {
|
||||
Box,
|
||||
Paper,
|
||||
Typography,
|
||||
TextField,
|
||||
Button,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem,
|
||||
SelectChangeEvent,
|
||||
Container,
|
||||
Checkbox,
|
||||
ListItemText,
|
||||
OutlinedInput,
|
||||
Chip,
|
||||
FormControlLabel, // Import FormControlLabel
|
||||
} from '@mui/material';
|
||||
import { Event } from '../types';
|
||||
|
||||
interface RSVPFormData {
|
||||
name: string;
|
||||
attending: string;
|
||||
bringing_guests: string;
|
||||
guest_count: number;
|
||||
guest_names: string[];
|
||||
items_bringing: string[];
|
||||
other_items: string;
|
||||
}
|
||||
|
||||
const RSVPEditForm: React.FC = () => {
|
||||
const { slug, editId } = useParams<{ slug: string; editId: string }>();
|
||||
const [formData, setFormData] = useState<RSVPFormData>({
|
||||
name: '',
|
||||
attending: '',
|
||||
bringing_guests: '',
|
||||
guest_count: 1,
|
||||
guest_names: [],
|
||||
items_bringing: [],
|
||||
other_items: ''
|
||||
});
|
||||
const [neededItems, setNeededItems] = useState<string[]>([]);
|
||||
const [claimedItems, setClaimedItems] = useState<string[]>([]);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const [event, setEvent] = useState<Event | null>(null);
|
||||
const [rsvpId, setRsvpId] = useState<number | null>(null);
|
||||
const [isEventClosed, setIsEventClosed] = useState(false); // New state to track if event is closed
|
||||
|
||||
useEffect(() => {
|
||||
const fetchRsvpDetails = async () => {
|
||||
try {
|
||||
const [eventResponse, rsvpResponse, rsvpsResponse] = await Promise.all([
|
||||
axios.get<Event>(`/api/events/${slug}`),
|
||||
axios.get(`/api/rsvps/edit/${editId}`), // New endpoint to fetch by editId
|
||||
axios.get(`/api/events/${slug}/rsvps`) // To get all RSVPs for claimed items
|
||||
]);
|
||||
|
||||
if (!eventResponse.data || !rsvpResponse.data || !rsvpsResponse.data) {
|
||||
throw new Error('Failed to fetch data from server');
|
||||
}
|
||||
|
||||
// Check if event is closed for RSVPs
|
||||
if (eventResponse.data.rsvp_cutoff_date) {
|
||||
const cutoffDate = new Date(eventResponse.data.rsvp_cutoff_date);
|
||||
if (new Date() > cutoffDate) {
|
||||
setIsEventClosed(true); // Set state if closed
|
||||
}
|
||||
}
|
||||
|
||||
setEvent(eventResponse.data);
|
||||
setRsvpId(rsvpResponse.data.id);
|
||||
|
||||
// Pre-fill the form with existing RSVP data
|
||||
setFormData({
|
||||
name: rsvpResponse.data.name,
|
||||
attending: rsvpResponse.data.attending,
|
||||
bringing_guests: rsvpResponse.data.bringing_guests,
|
||||
guest_count: rsvpResponse.data.guest_count,
|
||||
guest_names: Array.isArray(rsvpResponse.data.guest_names) ? rsvpResponse.data.guest_names : (typeof rsvpResponse.data.guest_names === 'string' && rsvpResponse.data.guest_names ? JSON.parse(rsvpResponse.data.guest_names) : []),
|
||||
items_bringing: Array.isArray(rsvpResponse.data.items_bringing) ? rsvpResponse.data.items_bringing : (typeof rsvpResponse.data.items_bringing === 'string' && rsvpResponse.data.items_bringing ? JSON.parse(rsvpResponse.data.items_bringing) : []),
|
||||
other_items: rsvpResponse.data.other_items || ''
|
||||
});
|
||||
|
||||
// Process needed items (same logic as RSVPForm)
|
||||
let items: string[] = [];
|
||||
if (eventResponse.data.needed_items) {
|
||||
items = Array.isArray(eventResponse.data.needed_items)
|
||||
? eventResponse.data.needed_items
|
||||
: typeof eventResponse.data.needed_items === 'string'
|
||||
? JSON.parse(eventResponse.data.needed_items)
|
||||
: [];
|
||||
}
|
||||
|
||||
// Get all claimed items from existing RSVPs (excluding the current one)
|
||||
const claimed = new Set<string>();
|
||||
if (Array.isArray(rsvpsResponse.data)) {
|
||||
rsvpsResponse.data.forEach((rsvp: any) => {
|
||||
if (rsvp.id !== rsvpResponse.data.id) { // Exclude current RSVP's claimed items initially
|
||||
try {
|
||||
let rsvpItems: string[] = [];
|
||||
if (typeof rsvp.items_bringing === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(rsvp.items_bringing);
|
||||
rsvpItems = Array.isArray(parsed) ? parsed : [];
|
||||
} catch (e) {
|
||||
console.error('Error parsing items_bringing JSON:', e);
|
||||
}
|
||||
} else if (Array.isArray(rsvp.items_bringing)) {
|
||||
rsvpItems = rsvp.items_bringing;
|
||||
}
|
||||
|
||||
if (Array.isArray(rsvpItems)) {
|
||||
rsvpItems.forEach((item: string) => claimed.add(item));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error processing RSVP items:', e);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Filter out claimed items from available items, but include items the current RSVP already claimed
|
||||
const availableItems = items.filter(item => !claimed.has(item) || (Array.isArray(rsvpResponse.data.items_bringing) ? rsvpResponse.data.items_bringing.includes(item) : (typeof rsvpResponse.data.items_bringing === 'string' && rsvpResponse.data.items_bringing ? JSON.parse(rsvpResponse.data.items_bringing).includes(item) : false)));
|
||||
|
||||
setNeededItems(availableItems);
|
||||
setClaimedItems(Array.from(claimed)); // This will be claimed by others
|
||||
setLoading(false);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching RSVP details:', error);
|
||||
setError('Failed to load RSVP details. The link may be invalid or expired.');
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchRsvpDetails();
|
||||
}, [slug, editId]);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
const { name, value } = e.target;
|
||||
|
||||
if (name === 'attending') {
|
||||
if (value === 'no' || value === 'maybe') {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: value,
|
||||
bringing_guests: 'no',
|
||||
guest_count: 0,
|
||||
guest_names: [],
|
||||
items_bringing: [],
|
||||
other_items: ''
|
||||
}));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (name === 'bringing_guests') {
|
||||
if (value === 'no') {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: value,
|
||||
guest_count: 0,
|
||||
guest_names: []
|
||||
}));
|
||||
return;
|
||||
} else if (value === 'yes') {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: value,
|
||||
guest_count: 1,
|
||||
guest_names: ['']
|
||||
}));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (name === 'guest_count') {
|
||||
const count = parseInt(value) || 0;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: count,
|
||||
guest_names: Array(count).fill('').map((_, i) => prev.guest_names[i] || '')
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (name.startsWith('guest_name_')) {
|
||||
const index = parseInt(name.split('_')[2]);
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
guest_names: prev.guest_names.map((name, i) => i === index ? value : name)
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSelectChange = (e: SelectChangeEvent<string>) => {
|
||||
const { name, value } = e.target;
|
||||
|
||||
if (name === 'attending' && value !== 'yes') {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
attending: value as 'no' | 'maybe',
|
||||
bringing_guests: 'no',
|
||||
guest_count: 0,
|
||||
guest_names: [],
|
||||
items_bringing: [],
|
||||
other_items: ''
|
||||
}));
|
||||
} else if (name === 'bringing_guests') {
|
||||
setFormData(prev => {
|
||||
const maxGuests = event?.max_guests_per_rsvp;
|
||||
let initialGuestCount = 1;
|
||||
|
||||
if (maxGuests === 0 && value === 'yes') {
|
||||
return {
|
||||
...prev,
|
||||
bringing_guests: 'no',
|
||||
guest_count: 0,
|
||||
guest_names: []
|
||||
};
|
||||
}
|
||||
|
||||
if (maxGuests !== undefined && maxGuests !== -1 && maxGuests < initialGuestCount) {
|
||||
initialGuestCount = maxGuests;
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
bringing_guests: value as 'yes' | 'no',
|
||||
guest_count: value === 'yes' ? initialGuestCount : 0,
|
||||
guest_names: value === 'no' ? [] : Array(initialGuestCount).fill('')
|
||||
};
|
||||
});
|
||||
} else {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const handleItemsChange = (e: SelectChangeEvent<string[]>) => {
|
||||
const { value } = e.target;
|
||||
const itemsArray = Array.isArray(value) ? value : [];
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
items_bringing: itemsArray
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Prevent submission if the event is closed
|
||||
if (isEventClosed) {
|
||||
setError('Event registration is closed. Changes are not allowed.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
if (!formData.name.trim() || !formData.attending) {
|
||||
setError('Please fill in all required fields');
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (formData.attending === 'yes' && !formData.bringing_guests) {
|
||||
setError('Please indicate if you are bringing guests');
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (formData.bringing_guests === 'yes' &&
|
||||
(formData.guest_count < 1 ||
|
||||
formData.guest_names.some(name => !name.trim()))) {
|
||||
setError('Please provide names for all guests');
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const splitOtherItems = formData.other_items
|
||||
.split(/\r?\n|,/)
|
||||
.map(s => s.trim())
|
||||
.filter(Boolean)
|
||||
.join(', ');
|
||||
const submissionData = {
|
||||
...formData,
|
||||
items_bringing: formData.items_bringing,
|
||||
other_items: splitOtherItems
|
||||
};
|
||||
|
||||
// Use the new PUT endpoint for updating by editId
|
||||
await axios.put(`/api/rsvps/edit/${editId}`, submissionData);
|
||||
|
||||
setSuccess(true);
|
||||
} catch (err) {
|
||||
console.error('Error updating RSVP:', err);
|
||||
setError('Failed to update RSVP. Please try again.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Container maxWidth="sm">
|
||||
<Typography>Loading...</Typography>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !event) {
|
||||
return (
|
||||
<Container maxWidth="sm">
|
||||
<Typography color="error">{error || 'Event or RSVP not found'}</Typography>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
minHeight: '100vh',
|
||||
width: '100%',
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundImage: event?.wallpaper ? `linear-gradient(rgba(0, 0, 0, 0.5), rgba(0, 0, 0, 0.5)), url(${event.wallpaper})` : 'none',
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundAttachment: 'fixed',
|
||||
backgroundColor: event?.wallpaper ? '#000' : 'rgb(25, 28, 34)',
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ py: 4 }}>
|
||||
<Container maxWidth="sm">
|
||||
<Paper elevation={3} sx={{ p: 4, mt: 4, textAlign: 'center' }}>
|
||||
<Typography variant="h4" component="h2" gutterBottom color="primary">
|
||||
Success!
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={{ mb: 3 }}>
|
||||
Your RSVP has been updated successfully.
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={() => navigate(`/view/events/${slug}`)}
|
||||
>
|
||||
View All RSVPs
|
||||
</Button>
|
||||
</Paper>
|
||||
</Container>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
minHeight: '100vh',
|
||||
width: '100%',
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundImage: event?.wallpaper ? `linear-gradient(rgba(0, 0, 0, 0.5), rgba(0, 0, 0, 0.5)), url(${event.wallpaper})` : 'none',
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundAttachment: 'fixed',
|
||||
backgroundColor: event?.wallpaper ? '#000' : 'rgb(25, 28, 34)',
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ py: 4 }}>
|
||||
<Container maxWidth="sm">
|
||||
<Paper elevation={3} sx={{ p: 4, mt: 4 }}>
|
||||
<Typography variant="h4" component="h2" gutterBottom color="primary" align="center">
|
||||
Edit Your RSVP
|
||||
</Typography>
|
||||
|
||||
{event?.rsvp_cutoff_date && (
|
||||
<Typography variant="body2" color="text.secondary" align="center" sx={{ mb: 2 }}>
|
||||
<strong>Note:</strong> Submissions cannot be edited after {new Date(event.rsvp_cutoff_date).toLocaleString()}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{isEventClosed && (
|
||||
<Typography color="error" align="center" sx={{ mb: 2 }}>
|
||||
Event registration is closed. Changes are not allowed. Please contact the event organizer for assistance.
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Typography color="error" align="center" sx={{ mb: 2 }}>
|
||||
{error}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<Box component="form" onSubmit={handleSubmit} sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<TextField
|
||||
label="Name"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
required
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
disabled={isEventClosed} // Disable if event is closed
|
||||
/>
|
||||
|
||||
<FormControl fullWidth required disabled={isEventClosed}> {/* Disable if event is closed */}
|
||||
<InputLabel>Are you attending?</InputLabel>
|
||||
<Select
|
||||
name="attending"
|
||||
value={formData.attending}
|
||||
onChange={handleSelectChange}
|
||||
label="Are you attending?"
|
||||
required
|
||||
>
|
||||
<MenuItem value="">Select an option</MenuItem>
|
||||
<MenuItem value="yes">Yes</MenuItem>
|
||||
<MenuItem value="no">No</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{formData.attending === 'yes' && (
|
||||
<>
|
||||
<FormControl fullWidth required disabled={isEventClosed}> {/* Disable if event is closed */}
|
||||
<InputLabel>Are you bringing any guests?</InputLabel>
|
||||
<Select
|
||||
name="bringing_guests"
|
||||
value={formData.bringing_guests}
|
||||
onChange={handleSelectChange}
|
||||
label="Are you bringing any guests?"
|
||||
required
|
||||
>
|
||||
<MenuItem value="">Select an option</MenuItem>
|
||||
<MenuItem value="yes">Yes</MenuItem>
|
||||
<MenuItem value="no">No</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{formData.bringing_guests === 'yes' && (
|
||||
<>
|
||||
<TextField
|
||||
label="Number of Guests"
|
||||
name="guest_count"
|
||||
type="number"
|
||||
value={formData.guest_count}
|
||||
onChange={(e) => {
|
||||
const value = parseInt(e.target.value);
|
||||
if (isNaN(value)) return;
|
||||
|
||||
const maxGuests = event?.max_guests_per_rsvp;
|
||||
let newCount = value;
|
||||
|
||||
if (maxGuests !== undefined && maxGuests !== -1 && value > maxGuests) {
|
||||
newCount = maxGuests;
|
||||
}
|
||||
|
||||
if (newCount < 1) newCount = 1;
|
||||
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
guest_count: newCount,
|
||||
guest_names: Array(newCount).fill('').map((_, i) => prev.guest_names[i] || '')
|
||||
}));
|
||||
}}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
required
|
||||
inputProps={{
|
||||
min: 1,
|
||||
max: event?.max_guests_per_rsvp === -1 ? undefined : event?.max_guests_per_rsvp
|
||||
}}
|
||||
error={formData.guest_count < 1}
|
||||
helperText={
|
||||
formData.guest_count < 1
|
||||
? "Number of guests must be at least 1"
|
||||
: event?.max_guests_per_rsvp === 0
|
||||
? "No additional guests allowed for this event"
|
||||
: event?.max_guests_per_rsvp === -1
|
||||
? "No limit on number of guests"
|
||||
: `Maximum ${event?.max_guests_per_rsvp} additional guests allowed`
|
||||
}
|
||||
disabled={isEventClosed} // Disable if event is closed
|
||||
/>
|
||||
|
||||
{Array.from({ length: formData.guest_count }).map((_, index) => (
|
||||
<TextField
|
||||
key={index}
|
||||
fullWidth
|
||||
label={`Guest ${index + 1} Name`}
|
||||
name={`guest_name_${index}`}
|
||||
value={formData.guest_names[index] || ''}
|
||||
onChange={handleChange}
|
||||
required
|
||||
disabled={isEventClosed} // Disable if event is closed
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{neededItems.length > 0 && (
|
||||
<FormControl fullWidth disabled={isEventClosed}> {/* Disable if event is closed */}
|
||||
<InputLabel>What items are you bringing?</InputLabel>
|
||||
<Select
|
||||
multiple
|
||||
name="items_bringing"
|
||||
value={formData.items_bringing}
|
||||
onChange={handleItemsChange}
|
||||
input={<OutlinedInput label="What items are you bringing?" />}
|
||||
renderValue={(selected) => (
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||
{selected.map((value) => (
|
||||
<Chip key={value} label={value} />
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
>
|
||||
{neededItems.map((item) => (
|
||||
<MenuItem key={item} value={item}>
|
||||
<Checkbox checked={formData.items_bringing.includes(item)} />
|
||||
<ListItemText primary={item} />
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
|
||||
<TextField
|
||||
label="Any other item(s)?"
|
||||
name="other_items"
|
||||
value={formData.other_items}
|
||||
onChange={handleChange}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
multiline
|
||||
rows={2}
|
||||
placeholder="Enter any additional items you'd like to bring"
|
||||
disabled={isEventClosed} // Disable if event is closed
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!isEventClosed && ( // Hide submit button if event is closed
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
size="large"
|
||||
disabled={isSubmitting ||
|
||||
!formData.name.trim() ||
|
||||
!formData.attending ||
|
||||
(formData.attending === 'yes' && !formData.bringing_guests) ||
|
||||
(formData.bringing_guests === 'yes' && (formData.guest_count < 1 || formData.guest_names.some(name => !name.trim())))}
|
||||
sx={{ mt: 2 }}
|
||||
>
|
||||
{isSubmitting ? 'Saving Changes...' : 'Save Changes'}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Paper>
|
||||
</Container>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default RSVPEditForm;
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
ListItemText,
|
||||
OutlinedInput,
|
||||
Chip,
|
||||
FormControlLabel, // Import FormControlLabel
|
||||
} from '@mui/material';
|
||||
import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||
import { Event } from '../types';
|
||||
@@ -29,6 +30,8 @@ interface RSVPFormData {
|
||||
guest_names: string[];
|
||||
items_bringing: 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 = () => {
|
||||
@@ -40,7 +43,9 @@ const RSVPForm: React.FC = () => {
|
||||
guest_count: 1,
|
||||
guest_names: [],
|
||||
items_bringing: [],
|
||||
other_items: ''
|
||||
other_items: '',
|
||||
send_email_confirmation: false, // Initialize to false
|
||||
email_address: '' // Initialize to empty
|
||||
});
|
||||
const [neededItems, setNeededItems] = useState<string[]>([]);
|
||||
const [claimedItems, setClaimedItems] = useState<string[]>([]);
|
||||
@@ -184,6 +189,14 @@ const RSVPForm: React.FC = () => {
|
||||
}));
|
||||
};
|
||||
|
||||
const handleCheckboxChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, checked } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: checked
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSelectChange = (e: SelectChangeEvent<string>) => {
|
||||
const { name, value } = e.target;
|
||||
|
||||
@@ -196,18 +209,40 @@ const RSVPForm: React.FC = () => {
|
||||
guest_count: 0,
|
||||
guest_names: [],
|
||||
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') {
|
||||
// When bringing guests is changed
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
bringing_guests: value as 'yes' | 'no',
|
||||
// If changing to 'yes', set guest count to 1 and initialize one empty name field
|
||||
guest_count: value === 'yes' ? 1 : 0,
|
||||
// Clear guest names if changing to 'no', otherwise initialize with empty string
|
||||
guest_names: value === 'no' ? [] : ['']
|
||||
}));
|
||||
// When bringing guests is changed
|
||||
setFormData(prev => {
|
||||
const maxGuests = event?.max_guests_per_rsvp;
|
||||
let initialGuestCount = 1;
|
||||
|
||||
// If max_guests_per_rsvp is 0, don't allow guests
|
||||
if (maxGuests === 0 && value === 'yes') {
|
||||
return {
|
||||
...prev,
|
||||
bringing_guests: 'no',
|
||||
guest_count: 0,
|
||||
guest_names: []
|
||||
};
|
||||
}
|
||||
|
||||
// If max_guests_per_rsvp is set and not -1 (unlimited), limit initial count
|
||||
if (maxGuests !== undefined && maxGuests !== -1 && maxGuests < initialGuestCount) {
|
||||
initialGuestCount = maxGuests;
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
bringing_guests: value as 'yes' | 'no',
|
||||
// If changing to 'yes', set guest count to appropriate value
|
||||
guest_count: value === 'yes' ? initialGuestCount : 0,
|
||||
// Clear guest names if changing to 'no', otherwise initialize with empty strings
|
||||
guest_names: value === 'no' ? [] : Array(initialGuestCount).fill('')
|
||||
};
|
||||
});
|
||||
} else {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
@@ -260,7 +295,9 @@ const RSVPForm: React.FC = () => {
|
||||
const submissionData = {
|
||||
...formData,
|
||||
items_bringing: formData.items_bringing,
|
||||
other_items: splitOtherItems
|
||||
other_items: splitOtherItems,
|
||||
send_email_confirmation: formData.send_email_confirmation,
|
||||
email_address: formData.email_address.trim()
|
||||
};
|
||||
const response = await axios.post(`/api/events/${slug}/rsvp`, submissionData);
|
||||
|
||||
@@ -270,6 +307,14 @@ const RSVPForm: React.FC = () => {
|
||||
axios.get(`/api/events/${slug}/rsvps`)
|
||||
]);
|
||||
|
||||
// Optionally display success message with edit link if email was sent
|
||||
if (formData.send_email_confirmation && formData.email_address.trim()) {
|
||||
// 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
|
||||
let items: string[] = [];
|
||||
if (eventResponse.data.needed_items) {
|
||||
@@ -465,19 +510,51 @@ const RSVPForm: React.FC = () => {
|
||||
|
||||
{formData.bringing_guests === 'yes' && (
|
||||
<>
|
||||
<TextField
|
||||
label="Number of Guests"
|
||||
name="guest_count"
|
||||
type="number"
|
||||
value={formData.guest_count}
|
||||
onChange={handleChange}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
required
|
||||
inputProps={{ min: 1 }}
|
||||
error={formData.guest_count < 1}
|
||||
helperText={formData.guest_count < 1 ? "Number of guests must be at least 1" : ""}
|
||||
/>
|
||||
<TextField
|
||||
label="Number of Guests"
|
||||
name="guest_count"
|
||||
type="number"
|
||||
value={formData.guest_count}
|
||||
onChange={(e) => {
|
||||
const value = parseInt(e.target.value);
|
||||
if (isNaN(value)) return;
|
||||
|
||||
// Check if there's a maximum guest limit
|
||||
const maxGuests = event?.max_guests_per_rsvp;
|
||||
let newCount = value;
|
||||
|
||||
// If max_guests_per_rsvp is set and not -1 (unlimited), enforce the limit
|
||||
if (maxGuests !== undefined && maxGuests !== -1 && value > maxGuests) {
|
||||
newCount = maxGuests;
|
||||
}
|
||||
|
||||
// Ensure count is at least 1
|
||||
if (newCount < 1) newCount = 1;
|
||||
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
guest_count: newCount,
|
||||
guest_names: Array(newCount).fill('').map((_, i) => prev.guest_names[i] || '')
|
||||
}));
|
||||
}}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
required
|
||||
inputProps={{
|
||||
min: 1,
|
||||
max: event?.max_guests_per_rsvp === -1 ? undefined : event?.max_guests_per_rsvp
|
||||
}}
|
||||
error={formData.guest_count < 1}
|
||||
helperText={
|
||||
formData.guest_count < 1
|
||||
? "Number of guests must be at least 1"
|
||||
: event?.max_guests_per_rsvp === 0
|
||||
? "No additional guests allowed for this event"
|
||||
: event?.max_guests_per_rsvp === -1
|
||||
? "No limit on number of guests"
|
||||
: `Maximum ${event?.max_guests_per_rsvp} additional guests allowed`
|
||||
}
|
||||
/>
|
||||
|
||||
{Array.from({ length: formData.guest_count }).map((_, index) => (
|
||||
<TextField
|
||||
@@ -534,6 +611,37 @@ 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
|
||||
type="submit"
|
||||
variant="contained"
|
||||
@@ -543,7 +651,9 @@ const RSVPForm: React.FC = () => {
|
||||
!formData.name.trim() ||
|
||||
!formData.attending ||
|
||||
(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 }}
|
||||
>
|
||||
{isSubmitting ? 'Submitting...' : 'Submit RSVP'}
|
||||
|
||||
@@ -9,6 +9,9 @@ export interface Event {
|
||||
needed_items: string[];
|
||||
wallpaper?: string;
|
||||
rsvp_cutoff_date?: string;
|
||||
max_guests_per_rsvp?: number;
|
||||
email_notifications_enabled?: boolean;
|
||||
email_recipients?: string;
|
||||
}
|
||||
|
||||
export interface Rsvp {
|
||||
|
||||
Reference in New Issue
Block a user