Merge pull request #3 from Ryderjj89/dev

Email notifications & unique submission links for editing
This commit is contained in:
Joshua Ryder
2025-05-16 21:50:20 -04:00
committed by GitHub
14 changed files with 1379 additions and 114 deletions

View File

@@ -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

View File

@@ -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.

View File

@@ -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
View 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,
});
}

View File

@@ -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
)
@@ -543,4 +807,4 @@ app.get('*', (req: Request, res: Response) => {
app.listen(port, async () => {
console.log(`Server running on port ${port}`);
await connectToDatabase();
});
});

View File

@@ -11,8 +11,16 @@ 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:
data:
uploads:
uploads:

View File

@@ -9,8 +9,16 @@ 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:
data:
uploads:
uploads:

View File

@@ -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>
@@ -79,4 +81,4 @@ const App: React.FC = () => {
);
};
export default App;
export default App;

View File

@@ -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
@@ -1181,4 +1228,4 @@ const EventAdmin: React.FC = () => {
);
};
export default EventAdmin;
export default EventAdmin;

View File

@@ -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"
@@ -333,4 +405,4 @@ const EventForm: React.FC = () => {
);
};
export default EventForm;
export default EventForm;

View File

@@ -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>

View 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;

View File

@@ -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'}
@@ -556,4 +666,4 @@ const RSVPForm: React.FC = () => {
);
};
export default RSVPForm;
export default RSVPForm;

View File

@@ -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 {
@@ -22,4 +25,4 @@ export interface Rsvp {
items_bringing: string[] | string;
other_items?: string;
created_at: string;
}
}