feat: Add needed items feature to events and RSVPs - Add needed_items field to events table - Update event creation form with needed items input - Add multi-select for needed items in RSVP form - Update event details to display needed items - Update admin interface to handle new items_bringing format

This commit is contained in:
Your Name
2025-04-29 19:01:36 -04:00
parent a7a0f27a4b
commit 00fea07bee
6 changed files with 256 additions and 163 deletions

View File

@@ -54,13 +54,13 @@ app.get('/api/events/:slug', async (req: Request, res: Response) => {
app.post('/api/events', async (req: Request, res: Response) => {
try {
const { title, description, date, location } = req.body;
const { title, description, date, location, needed_items } = req.body;
// Generate a slug from the title
const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-');
const result = await db.run(
'INSERT INTO events (title, description, date, location, slug) VALUES (?, ?, ?, ?, ?)',
[title, description, date, location, slug]
'INSERT INTO events (title, description, date, location, slug, needed_items) VALUES (?, ?, ?, ?, ?, ?)',
[title, description, date, location, slug, JSON.stringify(needed_items || [])]
);
res.status(201).json({ ...result, slug });
} catch (error) {
@@ -102,7 +102,7 @@ app.post('/api/events/:slug/rsvp', async (req: Request, res: Response) => {
const eventId = eventRows[0].id;
const result = await db.run(
'INSERT INTO rsvps (event_id, name, attending, bringing_guests, guest_count, guest_names, items_bringing) VALUES (?, ?, ?, ?, ?, ?, ?)',
[eventId, name, attending, bringing_guests, guest_count, guest_names, items_bringing]
[eventId, name, attending, bringing_guests, guest_count, guest_names, JSON.stringify(items_bringing || [])]
);
res.status(201).json(result);
} catch (error) {
@@ -147,7 +147,7 @@ app.put('/api/events/:slug/rsvps/:id', async (req: Request, res: Response) => {
const eventId = eventRows[0].id;
await db.run(
'UPDATE rsvps SET name = ?, attending = ?, bringing_guests = ?, guest_count = ?, guest_names = ?, items_bringing = ? WHERE id = ? AND event_id = ?',
[name, attending, bringing_guests, guest_count, guest_names, items_bringing, id, eventId]
[name, attending, bringing_guests, guest_count, guest_names, JSON.stringify(items_bringing || []), id, eventId]
);
res.status(200).json({ message: 'RSVP updated successfully' });
} catch (error) {
@@ -167,6 +167,7 @@ async function initializeDatabase() {
date TEXT NOT NULL,
location TEXT,
slug TEXT NOT NULL UNIQUE,
needed_items TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
`);

View File

@@ -37,7 +37,7 @@ interface RSVP {
bringing_guests: string;
guest_count: number;
guest_names: string;
items_bringing: string;
items_bringing: string[];
}
interface Event {
@@ -206,7 +206,7 @@ const EventAdmin: React.FC = () => {
}
</TableCell>
<TableCell>
{rsvp.items_bringing.split('\n').map(item => item.trim()).filter(Boolean).join(', ')}
{rsvp.items_bringing.join(', ')}
</TableCell>
<TableCell>
<IconButton

View File

@@ -2,7 +2,19 @@ import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import axios from 'axios';
import { Event, Rsvp } from '../types';
import { Button, Container, Typography, Box, Paper, List, ListItem, ListItemText, CircularProgress, Alert } from '@mui/material';
import {
Button,
Container,
Typography,
Box,
Paper,
List,
ListItem,
ListItemText,
CircularProgress,
Alert,
Chip
} from '@mui/material';
const EventDetails: React.FC = () => {
const { slug } = useParams<{ slug: string }>();
@@ -31,6 +43,21 @@ const EventDetails: React.FC = () => {
fetchEventDetails();
}, [slug]);
const formatAttendingStatus = (attending: string) => {
return attending.charAt(0).toUpperCase() + attending.slice(1);
};
const getStatusColor = (attending: string): "success" | "error" | "warning" => {
switch (attending.toLowerCase()) {
case 'yes':
return 'success';
case 'no':
return 'error';
default:
return 'warning';
}
};
if (loading) {
return (
<Box
@@ -65,151 +92,119 @@ const EventDetails: React.FC = () => {
);
}
const formatStatus = (status: string) => {
switch (status.toLowerCase()) {
case 'attending':
return 'Yes';
case 'not_attending':
return 'No';
case 'maybe':
return 'Maybe';
default:
return status.charAt(0).toUpperCase() + status.slice(1);
}
};
return (
<Box
sx={{
minHeight: '100vh',
backgroundImage: 'url(https://www.rydertech.us/backgrounds/space1.jpg)',
backgroundSize: 'cover',
backgroundPosition: 'center',
py: 4,
}}
>
<Container maxWidth="md">
<Paper
elevation={3}
<Container maxWidth="md">
<Paper sx={{ p: 4, mb: 4 }}>
<Typography variant="h4" component="h1" gutterBottom>
{event.title}
</Typography>
<Typography variant="body1" paragraph>
{event.description}
</Typography>
<Box sx={{ mb: 3 }}>
<Typography variant="subtitle1" component="div" gutterBottom>
<strong>Date:</strong> {new Date(event.date).toLocaleDateString()}
</Typography>
<Typography variant="subtitle1" component="div">
<strong>Location:</strong> {event.location}
</Typography>
{event.needed_items && event.needed_items.length > 0 && (
<Box sx={{ mt: 2 }}>
<Typography variant="subtitle1" component="div" gutterBottom>
<strong>Needed Items:</strong>
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{event.needed_items.map((item, index) => (
<Chip key={index} label={item} color="primary" />
))}
</Box>
</Box>
)}
</Box>
<Button
variant="contained"
color="primary"
onClick={() => navigate(`/events/${event.slug}/rsvp`)}
>
RSVP to Event
</Button>
</Paper>
<Paper sx={{ p: 4 }}>
<Typography
variant="h5"
gutterBottom
sx={{
p: 4,
my: 4,
borderRadius: 2,
backdropFilter: 'blur(10px)',
backgroundColor: 'rgba(255, 255, 255, 0.9)',
maxWidth: '800px',
mx: 'auto'
fontWeight: 500,
color: 'primary.main',
mb: 2,
borderBottom: '2px solid',
borderColor: 'primary.main',
pb: 1
}}
>
<Typography
variant="h3"
gutterBottom
sx={{
fontWeight: 600,
color: 'primary.main',
mb: 3
}}
>
{event.title}
</Typography>
<Box sx={{ mb: 3 }}>
<Typography
variant="h6"
gutterBottom
sx={{
color: 'text.secondary',
display: 'flex',
alignItems: 'center',
gap: 1
}}
>
{event.date} at {event.location}
</Typography>
</Box>
<Typography
variant="body1"
paragraph
sx={{
fontSize: '1.1rem',
lineHeight: 1.6,
color: 'text.primary',
mb: 4
}}
>
{event.description}
</Typography>
<Typography
variant="h5"
gutterBottom
sx={{
fontWeight: 500,
color: 'primary.main',
mb: 2,
borderBottom: '2px solid',
borderColor: 'primary.main',
pb: 1
}}
>
RSVPs
</Typography>
<List sx={{
bgcolor: 'rgba(255, 255, 255, 0.7)',
borderRadius: 1,
boxShadow: '0 2px 4px rgba(0,0,0,0.05)',
maxWidth: '600px',
mx: 'auto'
}}>
{rsvps.map((rsvp) => (
<ListItem
key={rsvp.id}
sx={{
borderBottom: '1px solid',
borderColor: 'divider',
'&:last-child': {
borderBottom: 'none'
}
}}
>
<ListItemText
primary={
<Typography variant="subtitle1" sx={{ fontWeight: 500 }}>
{rsvp.name}
</Typography>
}
secondary={
<Typography variant="body2" color="text.secondary">
{rsvp.email} - {formatStatus(rsvp.status)}
</Typography>
}
/>
</ListItem>
))}
</List>
<Box mt={4} sx={{ display: 'flex', justifyContent: 'flex-end' }}>
<Button
variant="contained"
color="primary"
onClick={() => navigate('/')}
RSVPs
</Typography>
<List sx={{
bgcolor: 'rgba(255, 255, 255, 0.1)',
borderRadius: 1,
boxShadow: '0 2px 4px rgba(0,0,0,0.05)'
}}>
{rsvps.map((rsvp) => (
<ListItem
key={rsvp.id}
sx={{
px: 4,
py: 1,
borderRadius: 2,
textTransform: 'none',
fontSize: '1rem',
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
'&:hover': {
boxShadow: '0 4px 8px rgba(0,0,0,0.2)'
borderBottom: '1px solid',
borderColor: 'divider',
'&:last-child': {
borderBottom: 'none'
}
}}
>
Back to Events
</Button>
</Box>
</Paper>
</Container>
</Box>
<ListItemText
primary={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 500 }}>
{rsvp.name}
</Typography>
<Chip
label={formatAttendingStatus(rsvp.attending)}
color={getStatusColor(rsvp.attending)}
size="small"
/>
</Box>
}
secondary={
<Box sx={{ mt: 1 }}>
{rsvp.bringing_guests === 'yes' && (
<Typography variant="body2" color="text.secondary" paragraph>
Bringing {rsvp.guest_count} guest{rsvp.guest_count !== 1 ? 's' : ''}: {rsvp.guest_names}
</Typography>
)}
{rsvp.items_bringing && (
<Typography variant="body2" color="text.secondary">
<strong>Items:</strong> {rsvp.items_bringing}
</Typography>
)}
</Box>
}
/>
</ListItem>
))}
{rsvps.length === 0 && (
<ListItem>
<ListItemText
primary={
<Typography variant="body1" color="text.secondary" align="center">
No RSVPs yet
</Typography>
}
/>
</ListItem>
)}
</List>
</Paper>
</Container>
);
};

View File

@@ -7,6 +7,7 @@ import {
Typography,
Container,
Paper,
Chip,
} from '@mui/material';
import axios from 'axios';
@@ -17,7 +18,9 @@ const EventForm: React.FC = () => {
description: '',
date: '',
location: '',
needed_items: [] as string[],
});
const [currentItem, setCurrentItem] = useState('');
const [error, setError] = useState<string | null>(null);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -28,6 +31,27 @@ const EventForm: React.FC = () => {
}));
};
const handleItemChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setCurrentItem(e.target.value);
};
const handleAddItem = () => {
if (currentItem.trim()) {
setFormData((prev) => ({
...prev,
needed_items: [...prev.needed_items, currentItem.trim()],
}));
setCurrentItem('');
}
};
const handleRemoveItem = (index: number) => {
setFormData((prev) => ({
...prev,
needed_items: prev.needed_items.filter((_, i) => i !== index),
}));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
@@ -95,6 +119,36 @@ const EventForm: React.FC = () => {
variant="outlined"
required
/>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Typography variant="subtitle1">Needed Items</Typography>
<Box sx={{ display: 'flex', gap: 1 }}>
<TextField
fullWidth
label="Add Item"
value={currentItem}
onChange={handleItemChange}
variant="outlined"
size="small"
/>
<Button
variant="contained"
onClick={handleAddItem}
disabled={!currentItem.trim()}
>
Add
</Button>
</Box>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{formData.needed_items.map((item, index) => (
<Chip
key={index}
label={item}
onDelete={() => handleRemoveItem(index)}
color="primary"
/>
))}
</Box>
</Box>
<Box sx={{ mt: 3, display: 'flex', gap: 2 }}>
<Button
variant="contained"

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import axios from 'axios';
import {
@@ -13,6 +13,9 @@ import {
MenuItem,
SelectChangeEvent,
Container,
Checkbox,
ListItemText,
OutlinedInput,
} from '@mui/material';
interface RSVPFormData {
@@ -21,7 +24,7 @@ interface RSVPFormData {
bringing_guests: string;
guest_count: number;
guest_names: string;
items_bringing: string;
items_bringing: string[];
}
const RSVPForm: React.FC = () => {
@@ -32,13 +35,26 @@ const RSVPForm: React.FC = () => {
bringing_guests: '',
guest_count: 0,
guest_names: '',
items_bringing: ''
items_bringing: []
});
const [neededItems, setNeededItems] = useState<string[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const navigate = useNavigate();
useEffect(() => {
const fetchEventDetails = async () => {
try {
const response = await axios.get(`/api/events/${slug}`);
setNeededItems(response.data.needed_items || []);
} catch (error) {
setError('Failed to load event details');
}
};
fetchEventDetails();
}, [slug]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const { name, value } = e.target;
setFormData(prev => ({
@@ -55,6 +71,14 @@ const RSVPForm: React.FC = () => {
}));
};
const handleItemsChange = (e: SelectChangeEvent<string[]>) => {
const { value } = e.target;
setFormData(prev => ({
...prev,
items_bringing: typeof value === 'string' ? value.split(',') : value,
}));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
@@ -172,17 +196,34 @@ const RSVPForm: React.FC = () => {
</>
)}
<TextField
label="What items are you bringing?"
name="items_bringing"
value={formData.items_bringing}
onChange={handleChange}
multiline
rows={3}
fullWidth
variant="outlined"
placeholder="List any items you plan to bring to the event"
/>
{neededItems.length > 0 && (
<FormControl fullWidth>
<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) => (
<Typography key={value} variant="body2">
{value}
</Typography>
))}
</Box>
)}
>
{neededItems.map((item) => (
<MenuItem key={item} value={item}>
<Checkbox checked={formData.items_bringing.indexOf(item) > -1} />
<ListItemText primary={item} />
</MenuItem>
))}
</Select>
</FormControl>
)}
</>
)}

View File

@@ -6,15 +6,17 @@ export interface Event {
location: string;
slug: string;
created_at: string;
updated_at: string;
needed_items: string[];
}
export interface Rsvp {
id: number;
event_id: number;
name: string;
email: string;
status: 'attending' | 'not_attending' | 'maybe';
attending: string;
bringing_guests: string;
guest_count: number;
guest_names: string;
items_bringing: string[];
created_at: string;
updated_at: string;
}