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:
@@ -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
|
||||
)
|
||||
`);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user