From 379c846250cacc9eff436abc8782b28e315464db Mon Sep 17 00:00:00 2001 From: Ryderjj89 Date: Fri, 16 May 2025 20:16:23 -0400 Subject: [PATCH] Implement RSVP editing via unique link --- backend/src/index.ts | 105 +++++ frontend/src/App.tsx | 4 +- frontend/src/components/RSVPEditForm.tsx | 558 +++++++++++++++++++++++ 3 files changed, 666 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/RSVPEditForm.tsx diff --git a/backend/src/index.ts b/backend/src/index.ts index 84e5ff1..96c2516 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -440,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; @@ -460,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 { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f7ba567..2aa3115 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 = () => { } /> } /> } /> + } /> {/* Add the new route */} @@ -79,4 +81,4 @@ const App: React.FC = () => { ); }; -export default App; \ No newline at end of file +export default App; diff --git a/frontend/src/components/RSVPEditForm.tsx b/frontend/src/components/RSVPEditForm.tsx new file mode 100644 index 0000000..87bd570 --- /dev/null +++ b/frontend/src/components/RSVPEditForm.tsx @@ -0,0 +1,558 @@ +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, +} 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({ + name: '', + attending: '', + bringing_guests: '', + guest_count: 1, + guest_names: [], + items_bringing: [], + other_items: '' + }); + const [neededItems, setNeededItems] = useState([]); + const [claimedItems, setClaimedItems] = useState([]); + const [isSubmitting, setIsSubmitting] = useState(false); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(false); + const navigate = useNavigate(); + const [event, setEvent] = useState(null); + const [rsvpId, setRsvpId] = useState(null); + + useEffect(() => { + const fetchRsvpDetails = async () => { + try { + const [eventResponse, rsvpResponse, rsvpsResponse] = await Promise.all([ + axios.get(`/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'); + } + + 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(); + 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) => { + 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) => { + 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) => { + const { value } = e.target; + const itemsArray = Array.isArray(value) ? value : []; + setFormData(prev => ({ + ...prev, + items_bringing: itemsArray + })); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + 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 ( + + Loading... + + ); + } + + if (error || !event) { + return ( + + {error || 'Event or RSVP not found'} + + ); + } + + if (success) { + return ( + + + + + + Success! + + + Your RSVP has been updated successfully. + + + + + + + ); + } + + return ( + + + + + + Edit Your RSVP + + + {error && ( + + {error} + + )} + + + + + + Are you attending? + + + + {formData.attending === 'yes' && ( + <> + + Are you bringing any guests? + + + + {formData.bringing_guests === 'yes' && ( + <> + { + 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` + } + /> + + {Array.from({ length: formData.guest_count }).map((_, index) => ( + + ))} + + )} + + {neededItems.length > 0 && ( + + What items are you bringing? + + + )} + + + + )} + + + + + + + + ); +}; + +export default RSVPEditForm;