first commit
This commit is contained in:
commit
3d1d48fd49
31
.github/copilot-instructions.md
vendored
Normal file
31
.github/copilot-instructions.md
vendored
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
# Wedding Guest List App - Project Setup
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
Full-stack wedding guest list application with React + Vite frontend, FastAPI backend, and PostgreSQL database.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
- Manage guest information (names, phone numbers, email, RSVP status, meal preferences, plus-ones)
|
||||||
|
- Import contacts from Google account
|
||||||
|
- Filter and search guests
|
||||||
|
- User-friendly interface with modern design
|
||||||
|
|
||||||
|
## Setup Status
|
||||||
|
- [x] Create copilot-instructions.md file
|
||||||
|
- [x] Get project setup info
|
||||||
|
- [x] Create backend structure (FastAPI + SQLAlchemy + PostgreSQL)
|
||||||
|
- [x] Create frontend structure (React + Vite)
|
||||||
|
- [x] Add Google Contacts integration
|
||||||
|
- [x] Create documentation (README.md)
|
||||||
|
- [x] Install dependencies and test
|
||||||
|
|
||||||
|
## ✅ Project Complete!
|
||||||
|
|
||||||
|
All components have been created and dependencies installed.
|
||||||
|
|
||||||
|
### Next Steps:
|
||||||
|
1. Set up PostgreSQL database
|
||||||
|
2. Create .env file from .env.example
|
||||||
|
3. Configure Google OAuth (optional)
|
||||||
|
4. Run `start.bat` (Windows) or `./start.sh` (Linux/Mac)
|
||||||
|
|
||||||
|
See README.md for detailed instructions.
|
||||||
278
README.md
Normal file
278
README.md
Normal file
@ -0,0 +1,278 @@
|
|||||||
|
# 💒 Wedding Guest List App
|
||||||
|
|
||||||
|
A full-stack wedding guest list management application built with React + Vite frontend, FastAPI backend, and PostgreSQL database. Easily manage your wedding guests, import contacts from Google, and track RSVPs, meal preferences, and seating arrangements.
|
||||||
|
|
||||||
|
## ✨ Features
|
||||||
|
|
||||||
|
- **Guest Management**: Add, edit, and delete guest information
|
||||||
|
- **Contact Import**: Import contacts directly from your Google account
|
||||||
|
- **Search & Filter**: Find guests by name, email, phone, RSVP status, or meal preference
|
||||||
|
- **RSVP Tracking**: Track pending, accepted, and declined RSVPs
|
||||||
|
- **Meal Preferences**: Manage dietary requirements (vegetarian, vegan, gluten-free, kosher, halal)
|
||||||
|
- **Plus-Ones**: Track guests with plus-ones
|
||||||
|
- **Table Assignments**: Assign guests to tables
|
||||||
|
- **Modern UI**: User-friendly interface with a beautiful gradient design
|
||||||
|
- **Mobile Responsive**: Works seamlessly on all devices
|
||||||
|
|
||||||
|
## 🛠️ Tech Stack
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
- React 18
|
||||||
|
- Vite
|
||||||
|
- Axios for API calls
|
||||||
|
- CSS3 with modern styling
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
- FastAPI
|
||||||
|
- SQLAlchemy ORM
|
||||||
|
- PostgreSQL database
|
||||||
|
- Google People API integration
|
||||||
|
- Pydantic for data validation
|
||||||
|
|
||||||
|
## 📋 Prerequisites
|
||||||
|
|
||||||
|
- **Node.js** (v16 or higher)
|
||||||
|
- **Python** (v3.8 or higher)
|
||||||
|
- **PostgreSQL** (v12 or higher)
|
||||||
|
- **Google Cloud Console** account (for contact import feature)
|
||||||
|
|
||||||
|
## 🚀 Installation & Setup
|
||||||
|
|
||||||
|
### 1. Clone the Repository
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd c:\Users\dvirl\OneDrive\Desktop\gitea\invy
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Database Setup
|
||||||
|
|
||||||
|
Install PostgreSQL and create a database:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE DATABASE wedding_guests;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Backend Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
|
||||||
|
# Create virtual environment (optional but recommended)
|
||||||
|
python -m venv venv
|
||||||
|
|
||||||
|
# Activate virtual environment
|
||||||
|
# Windows:
|
||||||
|
venv\Scripts\activate
|
||||||
|
# Linux/Mac:
|
||||||
|
source venv/bin/activate
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
# Create .env file
|
||||||
|
copy .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Edit the `.env` file with your database credentials:
|
||||||
|
|
||||||
|
```env
|
||||||
|
DATABASE_URL=postgresql://postgres:your_password@localhost:5432/wedding_guests
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Frontend Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Google OAuth Setup (Optional - for contact import)
|
||||||
|
|
||||||
|
1. Go to [Google Cloud Console](https://console.cloud.google.com/)
|
||||||
|
2. Create a new project or select an existing one
|
||||||
|
3. Enable the **Google People API**
|
||||||
|
4. Create OAuth 2.0 credentials:
|
||||||
|
- Application type: Web application
|
||||||
|
- Authorized JavaScript origins: `http://localhost:5173`
|
||||||
|
- Authorized redirect URIs: `http://localhost:5173`
|
||||||
|
5. Copy the Client ID
|
||||||
|
6. Update `GOOGLE_CLIENT_ID` in `frontend/src/components/GoogleImport.jsx`
|
||||||
|
|
||||||
|
## 🎯 Running the Application
|
||||||
|
|
||||||
|
### Start the Backend Server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
uvicorn main:app --reload
|
||||||
|
```
|
||||||
|
|
||||||
|
The API will be available at: `http://localhost:8000`
|
||||||
|
|
||||||
|
API documentation: `http://localhost:8000/docs`
|
||||||
|
|
||||||
|
### Start the Frontend Development Server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
The application will be available at: `http://localhost:5173`
|
||||||
|
|
||||||
|
## 📖 API Documentation
|
||||||
|
|
||||||
|
Once the backend is running, visit `http://localhost:8000/docs` for interactive API documentation powered by Swagger UI.
|
||||||
|
|
||||||
|
### Main Endpoints
|
||||||
|
|
||||||
|
- `GET /guests/` - Get all guests
|
||||||
|
- `POST /guests/` - Create a new guest
|
||||||
|
- `GET /guests/{guest_id}` - Get a specific guest
|
||||||
|
- `PUT /guests/{guest_id}` - Update a guest
|
||||||
|
- `DELETE /guests/{guest_id}` - Delete a guest
|
||||||
|
- `GET /guests/search/` - Search and filter guests
|
||||||
|
- `POST /import/google` - Import contacts from Google
|
||||||
|
|
||||||
|
## 📱 Usage Guide
|
||||||
|
|
||||||
|
### Adding Guests Manually
|
||||||
|
|
||||||
|
1. Click the **"+ Add Guest"** button
|
||||||
|
2. Fill in the guest details:
|
||||||
|
- First and last name (required)
|
||||||
|
- Email and phone number (optional)
|
||||||
|
- RSVP status
|
||||||
|
- Meal preference
|
||||||
|
- Plus-one information
|
||||||
|
- Table number
|
||||||
|
- Notes
|
||||||
|
3. Click **"Add Guest"** to save
|
||||||
|
|
||||||
|
### Importing from Google Contacts
|
||||||
|
|
||||||
|
1. Click the **"Import from Google"** button
|
||||||
|
2. Sign in with your Google account
|
||||||
|
3. Grant permission to access your contacts
|
||||||
|
4. Contacts will be imported automatically
|
||||||
|
|
||||||
|
### Searching and Filtering
|
||||||
|
|
||||||
|
Use the search bar and filters to find guests:
|
||||||
|
- Search by name, email, or phone number
|
||||||
|
- Filter by RSVP status (pending, accepted, declined)
|
||||||
|
- Filter by meal preference
|
||||||
|
- Click **"Clear Filters"** to reset
|
||||||
|
|
||||||
|
### Editing and Deleting Guests
|
||||||
|
|
||||||
|
- Click **"Edit"** on any guest row to modify their information
|
||||||
|
- Click **"Delete"** to remove a guest (confirmation required)
|
||||||
|
|
||||||
|
## 🗂️ Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
wedding-guest-list/
|
||||||
|
├── backend/
|
||||||
|
│ ├── main.py # FastAPI application entry point
|
||||||
|
│ ├── models.py # SQLAlchemy database models
|
||||||
|
│ ├── schemas.py # Pydantic schemas for validation
|
||||||
|
│ ├── crud.py # Database operations
|
||||||
|
│ ├── database.py # Database configuration
|
||||||
|
│ ├── google_contacts.py # Google API integration
|
||||||
|
│ ├── requirements.txt # Python dependencies
|
||||||
|
│ └── .env.example # Environment variables template
|
||||||
|
│
|
||||||
|
├── frontend/
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── components/ # React components
|
||||||
|
│ │ │ ├── GuestList.jsx
|
||||||
|
│ │ │ ├── GuestForm.jsx
|
||||||
|
│ │ │ ├── SearchFilter.jsx
|
||||||
|
│ │ │ └── GoogleImport.jsx
|
||||||
|
│ │ ├── api/ # API client
|
||||||
|
│ │ │ └── api.js
|
||||||
|
│ │ ├── App.jsx # Main app component
|
||||||
|
│ │ └── main.jsx # Application entry point
|
||||||
|
│ ├── index.html
|
||||||
|
│ ├── package.json
|
||||||
|
│ └── vite.config.js
|
||||||
|
│
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔒 Security Notes
|
||||||
|
|
||||||
|
- Never commit your `.env` file or Google OAuth credentials
|
||||||
|
- Use environment variables for sensitive data
|
||||||
|
- In production, use HTTPS for all communications
|
||||||
|
- Implement proper authentication and authorization
|
||||||
|
- Validate all user inputs on both frontend and backend
|
||||||
|
|
||||||
|
## 🚀 Deployment
|
||||||
|
|
||||||
|
### Backend Deployment
|
||||||
|
|
||||||
|
Consider deploying to:
|
||||||
|
- **Heroku** (with PostgreSQL add-on)
|
||||||
|
- **Railway**
|
||||||
|
- **Render**
|
||||||
|
- **AWS EC2 + RDS**
|
||||||
|
|
||||||
|
### Frontend Deployment
|
||||||
|
|
||||||
|
Consider deploying to:
|
||||||
|
- **Vercel**
|
||||||
|
- **Netlify**
|
||||||
|
- **GitHub Pages**
|
||||||
|
|
||||||
|
Don't forget to update:
|
||||||
|
- API base URL in `frontend/src/api/api.js`
|
||||||
|
- CORS settings in `backend/main.py`
|
||||||
|
- Google OAuth redirect URIs
|
||||||
|
|
||||||
|
## 🤝 Contributing
|
||||||
|
|
||||||
|
Contributions are welcome! Feel free to:
|
||||||
|
- Report bugs
|
||||||
|
- Suggest new features
|
||||||
|
- Submit pull requests
|
||||||
|
|
||||||
|
## 📝 License
|
||||||
|
|
||||||
|
This project is open source and available under the MIT License.
|
||||||
|
|
||||||
|
## 💡 Tips
|
||||||
|
|
||||||
|
- Regularly backup your database
|
||||||
|
- Test the Google import feature with a test account first
|
||||||
|
- Use the table assignment feature to organize seating
|
||||||
|
- Export guest data before the wedding day
|
||||||
|
- Keep track of dietary restrictions for catering
|
||||||
|
|
||||||
|
## 🆘 Troubleshooting
|
||||||
|
|
||||||
|
### Database Connection Issues
|
||||||
|
- Verify PostgreSQL is running
|
||||||
|
- Check database credentials in `.env`
|
||||||
|
- Ensure the database exists
|
||||||
|
|
||||||
|
### Google Import Not Working
|
||||||
|
- Verify OAuth credentials are correct
|
||||||
|
- Check that Google People API is enabled
|
||||||
|
- Ensure redirect URI matches exactly
|
||||||
|
|
||||||
|
### Frontend Can't Connect to Backend
|
||||||
|
- Verify backend is running on port 8000
|
||||||
|
- Check CORS settings in `backend/main.py`
|
||||||
|
- Ensure API URL is correct in `frontend/src/api/api.js`
|
||||||
|
|
||||||
|
## 📞 Support
|
||||||
|
|
||||||
|
For questions or issues, please open an issue on the GitHub repository.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Made with ❤️ for your special day!
|
||||||
6
backend/.env.example
Normal file
6
backend/.env.example
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
# Database Configuration
|
||||||
|
DATABASE_URL=postgresql://wedding_admin:Aa123456@localhost:5432/wedding_guests
|
||||||
|
|
||||||
|
# Google OAuth (for contact import)
|
||||||
|
GOOGLE_CLIENT_ID=143092846986-v9s70im3ilpai78n89q38qpikernp2f8.apps.googleusercontent.com
|
||||||
|
GOOGLE_CLIENT_SECRET=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
|
||||||
34
backend/.gitignore
vendored
Normal file
34
backend/.gitignore
vendored
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
ENV/
|
||||||
|
.venv
|
||||||
|
|
||||||
|
# Database
|
||||||
|
*.db
|
||||||
|
*.sqlite3
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Node
|
||||||
|
node_modules/
|
||||||
110
backend/crud.py
Normal file
110
backend/crud.py
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy import or_
|
||||||
|
import models
|
||||||
|
import schemas
|
||||||
|
|
||||||
|
|
||||||
|
def get_guest(db: Session, guest_id: int):
|
||||||
|
return db.query(models.Guest).filter(models.Guest.id == guest_id).first()
|
||||||
|
|
||||||
|
|
||||||
|
def get_guests(db: Session, skip: int = 0, limit: int = 100):
|
||||||
|
return db.query(models.Guest).offset(skip).limit(limit).all()
|
||||||
|
|
||||||
|
|
||||||
|
def create_guest(db: Session, guest: schemas.GuestCreate):
|
||||||
|
db_guest = models.Guest(**guest.model_dump())
|
||||||
|
db.add(db_guest)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_guest)
|
||||||
|
return db_guest
|
||||||
|
|
||||||
|
|
||||||
|
def update_guest(db: Session, guest_id: int, guest: schemas.GuestUpdate):
|
||||||
|
db_guest = db.query(models.Guest).filter(models.Guest.id == guest_id).first()
|
||||||
|
if db_guest:
|
||||||
|
update_data = guest.model_dump(exclude_unset=True)
|
||||||
|
for field, value in update_data.items():
|
||||||
|
setattr(db_guest, field, value)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_guest)
|
||||||
|
return db_guest
|
||||||
|
|
||||||
|
|
||||||
|
def delete_guest(db: Session, guest_id: int):
|
||||||
|
db_guest = db.query(models.Guest).filter(models.Guest.id == guest_id).first()
|
||||||
|
if db_guest:
|
||||||
|
db.delete(db_guest)
|
||||||
|
db.commit()
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def search_guests(
|
||||||
|
db: Session,
|
||||||
|
query: str = "",
|
||||||
|
rsvp_status: str = None,
|
||||||
|
meal_preference: str = None,
|
||||||
|
owner: str = None
|
||||||
|
):
|
||||||
|
db_query = db.query(models.Guest)
|
||||||
|
|
||||||
|
# Search by name, email, or phone
|
||||||
|
if query:
|
||||||
|
search_pattern = f"%{query}%"
|
||||||
|
db_query = db_query.filter(
|
||||||
|
or_(
|
||||||
|
models.Guest.first_name.ilike(search_pattern),
|
||||||
|
models.Guest.last_name.ilike(search_pattern),
|
||||||
|
models.Guest.email.ilike(search_pattern),
|
||||||
|
models.Guest.phone_number.ilike(search_pattern)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Filter by RSVP status
|
||||||
|
if rsvp_status:
|
||||||
|
db_query = db_query.filter(models.Guest.rsvp_status == rsvp_status)
|
||||||
|
|
||||||
|
# Filter by meal preference
|
||||||
|
if meal_preference:
|
||||||
|
db_query = db_query.filter(models.Guest.meal_preference == meal_preference)
|
||||||
|
|
||||||
|
# Filter by owner
|
||||||
|
if owner:
|
||||||
|
db_query = db_query.filter(models.Guest.owner == owner)
|
||||||
|
|
||||||
|
return db_query.all()
|
||||||
|
|
||||||
|
|
||||||
|
def delete_guests_bulk(db: Session, guest_ids: list[int]):
|
||||||
|
"""Delete multiple guests by their IDs"""
|
||||||
|
deleted_count = db.query(models.Guest).filter(models.Guest.id.in_(guest_ids)).delete(synchronize_session=False)
|
||||||
|
db.commit()
|
||||||
|
return deleted_count
|
||||||
|
|
||||||
|
|
||||||
|
def delete_guests_by_owner(db: Session, owner: str):
|
||||||
|
"""Delete all guests by owner (for undo import)"""
|
||||||
|
# Delete guests where owner matches exactly or is in comma-separated list
|
||||||
|
deleted_count = db.query(models.Guest).filter(
|
||||||
|
or_(
|
||||||
|
models.Guest.owner == owner,
|
||||||
|
models.Guest.owner.like(f"{owner},%"),
|
||||||
|
models.Guest.owner.like(f"%,{owner},%"),
|
||||||
|
models.Guest.owner.like(f"%,{owner}")
|
||||||
|
)
|
||||||
|
).delete(synchronize_session=False)
|
||||||
|
db.commit()
|
||||||
|
return deleted_count
|
||||||
|
|
||||||
|
|
||||||
|
def get_unique_owners(db: Session):
|
||||||
|
"""Get list of unique owner emails"""
|
||||||
|
results = db.query(models.Guest.owner).distinct().filter(models.Guest.owner.isnot(None)).all()
|
||||||
|
owners = set()
|
||||||
|
for result in results:
|
||||||
|
if result[0]:
|
||||||
|
# Split comma-separated owners
|
||||||
|
for owner in result[0].split(','):
|
||||||
|
owners.add(owner.strip())
|
||||||
|
return sorted(list(owners))
|
||||||
23
backend/database.py
Normal file
23
backend/database.py
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Database URL - update with your PostgreSQL credentials
|
||||||
|
DATABASE_URL = os.getenv(
|
||||||
|
"DATABASE_URL",
|
||||||
|
"postgresql://wedding_admin:Aa123456@localhost:5432/wedding_guests"
|
||||||
|
)
|
||||||
|
|
||||||
|
engine = create_engine(DATABASE_URL)
|
||||||
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
|
|
||||||
|
Base = declarative_base()
|
||||||
|
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
89
backend/google_contacts.py
Normal file
89
backend/google_contacts.py
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
import httpx
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
import models
|
||||||
|
|
||||||
|
|
||||||
|
async def import_contacts_from_google(access_token: str, db: Session, owner: str = None) -> int:
|
||||||
|
"""
|
||||||
|
Import contacts from Google People API
|
||||||
|
|
||||||
|
Args:
|
||||||
|
access_token: OAuth 2.0 access token from Google
|
||||||
|
db: Database session
|
||||||
|
owner: Name of the person importing (e.g., 'me', 'fianc\u00e9')
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of contacts imported
|
||||||
|
"""
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {access_token}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Google People API endpoint
|
||||||
|
url = "https://people.googleapis.com/v1/people/me/connections"
|
||||||
|
params = {
|
||||||
|
"personFields": "names,phoneNumbers,emailAddresses",
|
||||||
|
"pageSize": 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
imported_count = 0
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
response = await client.get(url, headers=headers, params=params)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise Exception(f"Failed to fetch contacts: {response.text}")
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
connections = data.get("connections", [])
|
||||||
|
|
||||||
|
for connection in connections:
|
||||||
|
# Extract name
|
||||||
|
names = connection.get("names", [])
|
||||||
|
if not names:
|
||||||
|
continue
|
||||||
|
|
||||||
|
name = names[0]
|
||||||
|
first_name = name.get("givenName", "")
|
||||||
|
last_name = name.get("familyName", "")
|
||||||
|
|
||||||
|
if not first_name and not last_name:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Extract email
|
||||||
|
emails = connection.get("emailAddresses", [])
|
||||||
|
email = emails[0].get("value") if emails else None
|
||||||
|
|
||||||
|
# Extract phone number
|
||||||
|
phones = connection.get("phoneNumbers", [])
|
||||||
|
phone_number = phones[0].get("value") if phones else None
|
||||||
|
|
||||||
|
# Check if contact already exists by email OR phone number
|
||||||
|
existing = None
|
||||||
|
if email:
|
||||||
|
existing = db.query(models.Guest).filter(models.Guest.email == email).first()
|
||||||
|
if not existing and phone_number:
|
||||||
|
existing = db.query(models.Guest).filter(models.Guest.phone_number == phone_number).first()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
# Contact exists - merge owners
|
||||||
|
if existing.owner and owner not in existing.owner.split(","):
|
||||||
|
# Add current owner to existing owners
|
||||||
|
existing.owner = f"{existing.owner},{owner}"
|
||||||
|
db.add(existing)
|
||||||
|
else:
|
||||||
|
# Create new guest
|
||||||
|
guest = models.Guest(
|
||||||
|
first_name=first_name or "Unknown",
|
||||||
|
last_name=last_name or "",
|
||||||
|
email=email,
|
||||||
|
phone_number=phone_number,
|
||||||
|
rsvp_status="pending",
|
||||||
|
owner=owner
|
||||||
|
)
|
||||||
|
db.add(guest)
|
||||||
|
imported_count += 1
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return imported_count
|
||||||
193
backend/main.py
Normal file
193
backend/main.py
Normal file
@ -0,0 +1,193 @@
|
|||||||
|
from fastapi import FastAPI, Depends, HTTPException
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.responses import RedirectResponse, HTMLResponse
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
import uvicorn
|
||||||
|
from typing import List
|
||||||
|
import os
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
import httpx
|
||||||
|
import models
|
||||||
|
import schemas
|
||||||
|
import crud
|
||||||
|
from database import engine, get_db
|
||||||
|
|
||||||
|
# Load environment variables
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
# Create database tables
|
||||||
|
models.Base.metadata.create_all(bind=engine)
|
||||||
|
|
||||||
|
app = FastAPI(title="Wedding Guest List API")
|
||||||
|
|
||||||
|
# Configure CORS
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["http://localhost:5173"], # Vite default port
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
def read_root():
|
||||||
|
return {"message": "Wedding Guest List API"}
|
||||||
|
|
||||||
|
|
||||||
|
# Guest endpoints
|
||||||
|
@app.post("/guests/", response_model=schemas.Guest)
|
||||||
|
def create_guest(guest: schemas.GuestCreate, db: Session = Depends(get_db)):
|
||||||
|
return crud.create_guest(db=db, guest=guest)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/guests/", response_model=List[schemas.Guest])
|
||||||
|
def read_guests(skip: int = 0, limit: int = 1000, db: Session = Depends(get_db)):
|
||||||
|
guests = crud.get_guests(db, skip=skip, limit=limit)
|
||||||
|
return guests
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/guests/{guest_id}", response_model=schemas.Guest)
|
||||||
|
def read_guest(guest_id: int, db: Session = Depends(get_db)):
|
||||||
|
db_guest = crud.get_guest(db, guest_id=guest_id)
|
||||||
|
if db_guest is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Guest not found")
|
||||||
|
return db_guest
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/guests/{guest_id}", response_model=schemas.Guest)
|
||||||
|
def update_guest(guest_id: int, guest: schemas.GuestUpdate, db: Session = Depends(get_db)):
|
||||||
|
db_guest = crud.update_guest(db, guest_id=guest_id, guest=guest)
|
||||||
|
if db_guest is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Guest not found")
|
||||||
|
return db_guest
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/guests/{guest_id}")
|
||||||
|
def delete_guest(guest_id: int, db: Session = Depends(get_db)):
|
||||||
|
success = crud.delete_guest(db, guest_id=guest_id)
|
||||||
|
if not success:
|
||||||
|
raise HTTPException(status_code=404, detail="Guest not found")
|
||||||
|
return {"message": "Guest deleted successfully"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/guests/bulk-delete")
|
||||||
|
def delete_guests_bulk(guest_ids: List[int], db: Session = Depends(get_db)):
|
||||||
|
deleted_count = crud.delete_guests_bulk(db, guest_ids=guest_ids)
|
||||||
|
return {"message": f"Successfully deleted {deleted_count} guests"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/guests/undo-import/{owner}")
|
||||||
|
def undo_import(owner: str, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Delete all guests imported by a specific owner
|
||||||
|
"""
|
||||||
|
deleted_count = crud.delete_guests_by_owner(db, owner=owner)
|
||||||
|
return {"message": f"Successfully deleted {deleted_count} guests from {owner}"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/guests/owners/")
|
||||||
|
def get_owners(db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Get list of unique owners
|
||||||
|
"""
|
||||||
|
owners = crud.get_unique_owners(db)
|
||||||
|
return {"owners": owners}
|
||||||
|
|
||||||
|
|
||||||
|
# Search and filter endpoints
|
||||||
|
@app.get("/guests/search/", response_model=List[schemas.Guest])
|
||||||
|
def search_guests(
|
||||||
|
query: str = "",
|
||||||
|
rsvp_status: str = None,
|
||||||
|
meal_preference: str = None,
|
||||||
|
owner: str = None,
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
guests = crud.search_guests(
|
||||||
|
db, query=query, rsvp_status=rsvp_status, meal_preference=meal_preference, owner=owner
|
||||||
|
)
|
||||||
|
return guests
|
||||||
|
|
||||||
|
|
||||||
|
# Google OAuth configuration
|
||||||
|
GOOGLE_CLIENT_ID = os.getenv("GOOGLE_CLIENT_ID")
|
||||||
|
GOOGLE_CLIENT_SECRET = os.getenv("GOOGLE_CLIENT_SECRET")
|
||||||
|
GOOGLE_REDIRECT_URI = "http://localhost:8000/auth/google/callback"
|
||||||
|
|
||||||
|
# Google OAuth endpoints
|
||||||
|
@app.get("/auth/google")
|
||||||
|
async def google_auth():
|
||||||
|
"""
|
||||||
|
Initiate Google OAuth flow - redirects to Google
|
||||||
|
"""
|
||||||
|
auth_url = (
|
||||||
|
f"https://accounts.google.com/o/oauth2/v2/auth?"
|
||||||
|
f"client_id={GOOGLE_CLIENT_ID}&"
|
||||||
|
f"redirect_uri={GOOGLE_REDIRECT_URI}&"
|
||||||
|
f"response_type=code&"
|
||||||
|
f"scope=https://www.googleapis.com/auth/contacts.readonly%20https://www.googleapis.com/auth/userinfo.email&"
|
||||||
|
f"access_type=offline&"
|
||||||
|
f"prompt=consent"
|
||||||
|
)
|
||||||
|
return RedirectResponse(url=auth_url)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/auth/google/callback")
|
||||||
|
async def google_callback(code: str, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Handle Google OAuth callback and import contacts
|
||||||
|
Owner will be extracted from the user's email
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Exchange code for access token
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
token_response = await client.post(
|
||||||
|
"https://oauth2.googleapis.com/token",
|
||||||
|
data={
|
||||||
|
"code": code,
|
||||||
|
"client_id": GOOGLE_CLIENT_ID,
|
||||||
|
"client_secret": GOOGLE_CLIENT_SECRET,
|
||||||
|
"redirect_uri": GOOGLE_REDIRECT_URI,
|
||||||
|
"grant_type": "authorization_code",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if token_response.status_code != 200:
|
||||||
|
raise HTTPException(status_code=400, detail="Failed to get access token")
|
||||||
|
|
||||||
|
token_data = token_response.json()
|
||||||
|
access_token = token_data.get("access_token")
|
||||||
|
|
||||||
|
# Get user info to extract email
|
||||||
|
user_info_response = await client.get(
|
||||||
|
"https://www.googleapis.com/oauth2/v2/userinfo",
|
||||||
|
headers={"Authorization": f"Bearer {access_token}"}
|
||||||
|
)
|
||||||
|
|
||||||
|
if user_info_response.status_code != 200:
|
||||||
|
raise HTTPException(status_code=400, detail="Failed to get user info")
|
||||||
|
|
||||||
|
user_info = user_info_response.json()
|
||||||
|
user_email = user_info.get("email", "unknown")
|
||||||
|
# Use full email as owner
|
||||||
|
owner = user_email
|
||||||
|
|
||||||
|
# Import contacts
|
||||||
|
from google_contacts import import_contacts_from_google
|
||||||
|
imported_count = await import_contacts_from_google(access_token, db, owner)
|
||||||
|
|
||||||
|
# Redirect back to frontend with success message
|
||||||
|
return RedirectResponse(
|
||||||
|
url=f"http://localhost:5173?imported={imported_count}&owner={owner}",
|
||||||
|
status_code=302
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
# Redirect back with error
|
||||||
|
return RedirectResponse(
|
||||||
|
url=f"http://localhost:5173?error={str(e)}",
|
||||||
|
status_code=302
|
||||||
|
)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)
|
||||||
34
backend/models.py
Normal file
34
backend/models.py
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
from sqlalchemy import Boolean, Column, Integer, String, DateTime
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
from database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Guest(Base):
|
||||||
|
__tablename__ = "guests"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
first_name = Column(String, nullable=False)
|
||||||
|
last_name = Column(String, nullable=False)
|
||||||
|
email = Column(String, unique=True, index=True)
|
||||||
|
phone_number = Column(String)
|
||||||
|
|
||||||
|
# RSVP status: pending, accepted, declined
|
||||||
|
rsvp_status = Column(String, default="pending")
|
||||||
|
|
||||||
|
# Meal preferences
|
||||||
|
meal_preference = Column(String) # vegetarian, vegan, gluten-free, no-preference, etc.
|
||||||
|
|
||||||
|
# Plus one information
|
||||||
|
has_plus_one = Column(Boolean, default=False)
|
||||||
|
plus_one_name = Column(String, nullable=True)
|
||||||
|
|
||||||
|
# Owner tracking (who added this guest)
|
||||||
|
owner = Column(String, nullable=True) # e.g., 'me', 'fiancé', or specific name
|
||||||
|
|
||||||
|
# Additional notes
|
||||||
|
notes = Column(String, nullable=True)
|
||||||
|
table_number = Column(Integer, nullable=True)
|
||||||
|
|
||||||
|
# Timestamps
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||||
7
backend/requirements.txt
Normal file
7
backend/requirements.txt
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
fastapi>=0.104.1
|
||||||
|
uvicorn[standard]>=0.24.0
|
||||||
|
sqlalchemy>=2.0.23
|
||||||
|
psycopg2-binary>=2.9.9
|
||||||
|
pydantic[email]>=2.5.0
|
||||||
|
httpx>=0.25.2
|
||||||
|
python-dotenv>=1.0.0
|
||||||
105
backend/schema.sql
Normal file
105
backend/schema.sql
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
-- Wedding Guest List Database Schema
|
||||||
|
-- PostgreSQL Database Setup Script
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- STEP 1: Create Database User
|
||||||
|
-- ============================================
|
||||||
|
-- Create a dedicated user for the wedding guest list application
|
||||||
|
-- Change the password to something secure!
|
||||||
|
CREATE USER wedding_admin WITH PASSWORD 'your_secure_password_here';
|
||||||
|
|
||||||
|
-- Grant user the ability to create databases (optional)
|
||||||
|
ALTER USER wedding_admin CREATEDB;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- STEP 2: Create Database
|
||||||
|
-- ============================================
|
||||||
|
-- Create the wedding guests database
|
||||||
|
CREATE DATABASE wedding_guests
|
||||||
|
WITH
|
||||||
|
OWNER = wedding_admin
|
||||||
|
ENCODING = 'UTF8'
|
||||||
|
LC_COLLATE = 'en_US.UTF-8'
|
||||||
|
LC_CTYPE = 'en_US.UTF-8'
|
||||||
|
TEMPLATE = template0;
|
||||||
|
|
||||||
|
-- Connect to the new database
|
||||||
|
\c wedding_guests
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- STEP 3: Grant Schema Privileges
|
||||||
|
-- ============================================
|
||||||
|
-- Grant all privileges on the public schema to the user
|
||||||
|
GRANT ALL PRIVILEGES ON SCHEMA public TO wedding_admin;
|
||||||
|
|
||||||
|
-- Grant privileges on all current and future tables
|
||||||
|
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO wedding_admin;
|
||||||
|
GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO wedding_admin;
|
||||||
|
|
||||||
|
-- Ensure the user has privileges on future objects
|
||||||
|
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO wedding_admin;
|
||||||
|
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO wedding_admin;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- STEP 4: Create Tables
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- Guests table
|
||||||
|
CREATE TABLE IF NOT EXISTS guests (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
first_name VARCHAR(100) NOT NULL,
|
||||||
|
last_name VARCHAR(100) NOT NULL,
|
||||||
|
email VARCHAR(255) UNIQUE,
|
||||||
|
phone_number VARCHAR(50),
|
||||||
|
rsvp_status VARCHAR(20) DEFAULT 'pending' CHECK (rsvp_status IN ('pending', 'accepted', 'declined')),
|
||||||
|
meal_preference VARCHAR(50),
|
||||||
|
has_plus_one BOOLEAN DEFAULT FALSE,
|
||||||
|
plus_one_name VARCHAR(200),
|
||||||
|
owner VARCHAR(50),
|
||||||
|
notes TEXT,
|
||||||
|
table_number INTEGER,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Create indexes for better query performance
|
||||||
|
CREATE INDEX idx_guests_email ON guests(email);
|
||||||
|
CREATE INDEX idx_guests_rsvp_status ON guests(rsvp_status);
|
||||||
|
CREATE INDEX idx_guests_last_name ON guests(last_name);
|
||||||
|
CREATE INDEX idx_guests_owner ON guests(owner);
|
||||||
|
|
||||||
|
-- Create a trigger to automatically update the updated_at timestamp
|
||||||
|
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = CURRENT_TIMESTAMP;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ language 'plpgsql';
|
||||||
|
|
||||||
|
CREATE TRIGGER update_guests_updated_at
|
||||||
|
BEFORE UPDATE ON guests
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
|
||||||
|
-- Grant table ownership to wedding_admin
|
||||||
|
ALTER TABLE guests OWNER TO wedding_admin;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- STEP 5: Insert Sample Data (Optional)
|
||||||
|
-- ============================================
|
||||||
|
-- Uncomment the lines below to insert sample guests for testing
|
||||||
|
|
||||||
|
-- INSERT INTO guests (first_name, last_name, email, phone_number, rsvp_status, meal_preference, has_plus_one)
|
||||||
|
-- VALUES
|
||||||
|
-- ('John', 'Doe', 'john.doe@example.com', '+1-555-0101', 'accepted', 'vegetarian', TRUE),
|
||||||
|
-- ('Jane', 'Smith', 'jane.smith@example.com', '+1-555-0102', 'pending', 'vegan', FALSE),
|
||||||
|
-- ('Bob', 'Johnson', 'bob.j@example.com', '+1-555-0103', 'accepted', 'gluten-free', TRUE),
|
||||||
|
-- ('Alice', 'Williams', 'alice.w@example.com', '+1-555-0104', 'declined', NULL, FALSE);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Verification Query
|
||||||
|
-- ============================================
|
||||||
|
-- Run this to verify the setup
|
||||||
|
SELECT 'Database setup completed successfully!' AS status;
|
||||||
|
SELECT COUNT(*) AS total_guests FROM guests;
|
||||||
35
backend/schemas.py
Normal file
35
backend/schemas.py
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
from pydantic import BaseModel, EmailStr
|
||||||
|
from typing import Optional
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
class GuestBase(BaseModel):
|
||||||
|
first_name: str
|
||||||
|
last_name: str
|
||||||
|
email: Optional[EmailStr] = None
|
||||||
|
phone_number: Optional[str] = None
|
||||||
|
rsvp_status: str = "pending"
|
||||||
|
meal_preference: Optional[str] = None
|
||||||
|
has_plus_one: bool = False
|
||||||
|
plus_one_name: Optional[str] = None
|
||||||
|
owner: Optional[str] = None
|
||||||
|
notes: Optional[str] = None
|
||||||
|
table_number: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
class GuestCreate(GuestBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class GuestUpdate(GuestBase):
|
||||||
|
first_name: Optional[str] = None
|
||||||
|
last_name: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class Guest(GuestBase):
|
||||||
|
id: int
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
30
frontend/.gitignore
vendored
Normal file
30
frontend/.gitignore
vendored
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.production
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# Editor
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Vite
|
||||||
|
.vite/
|
||||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Wedding Guest List</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1946
frontend/package-lock.json
generated
Normal file
1946
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
frontend/package.json
Normal file
22
frontend/package.json
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "wedding-guest-list-frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"axios": "^1.6.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.2.43",
|
||||||
|
"@types/react-dom": "^18.2.17",
|
||||||
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
|
"vite": "^5.0.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
17
frontend/public/callback.html
Normal file
17
frontend/public/callback.html
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Google OAuth Callback</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<script>
|
||||||
|
// Send the token back to the parent window
|
||||||
|
if (window.opener) {
|
||||||
|
window.opener.postMessage(window.location.hash, window.location.origin);
|
||||||
|
window.close();
|
||||||
|
} else {
|
||||||
|
document.write('Authentication successful! You can close this window.');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
96
frontend/src/App.css
Normal file
96
frontend/src/App.css
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
.App {
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
text-align: center;
|
||||||
|
color: white;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
header h1 {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background: white;
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 30px;
|
||||||
|
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 15px;
|
||||||
|
margin-bottom: 25px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 12px 24px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 5px 15px rgba(102, 126, 234, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: #ef4444;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover {
|
||||||
|
background: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px;
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.container {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
header h1 {
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-bar {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
99
frontend/src/App.jsx
Normal file
99
frontend/src/App.jsx
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import GuestList from './components/GuestList'
|
||||||
|
import GuestForm from './components/GuestForm'
|
||||||
|
import SearchFilter from './components/SearchFilter'
|
||||||
|
import GoogleImport from './components/GoogleImport'
|
||||||
|
import { getGuests, searchGuests } from './api/api'
|
||||||
|
import './App.css'
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const [guests, setGuests] = useState([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [showForm, setShowForm] = useState(false)
|
||||||
|
const [editingGuest, setEditingGuest] = useState(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadGuests()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const loadGuests = async () => {
|
||||||
|
try {
|
||||||
|
const data = await getGuests()
|
||||||
|
setGuests(data)
|
||||||
|
setLoading(false)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading guests:', error)
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSearch = async (filters) => {
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
const data = await searchGuests(filters)
|
||||||
|
setGuests(data)
|
||||||
|
setLoading(false)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error searching guests:', error)
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAddGuest = () => {
|
||||||
|
setEditingGuest(null)
|
||||||
|
setShowForm(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEditGuest = (guest) => {
|
||||||
|
setEditingGuest(guest)
|
||||||
|
setShowForm(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFormClose = () => {
|
||||||
|
setShowForm(false)
|
||||||
|
setEditingGuest(null)
|
||||||
|
loadGuests()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleImportComplete = () => {
|
||||||
|
loadGuests()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="App">
|
||||||
|
<header>
|
||||||
|
<h1>💒 Wedding Guest List</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="container">
|
||||||
|
<div className="actions-bar">
|
||||||
|
<button className="btn btn-primary" onClick={handleAddGuest}>
|
||||||
|
+ Add Guest
|
||||||
|
</button>
|
||||||
|
<GoogleImport onImportComplete={handleImportComplete} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SearchFilter onSearch={handleSearch} />
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="loading">Loading guests...</div>
|
||||||
|
) : (
|
||||||
|
<GuestList
|
||||||
|
guests={guests}
|
||||||
|
onEdit={handleEditGuest}
|
||||||
|
onUpdate={loadGuests}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showForm && (
|
||||||
|
<GuestForm
|
||||||
|
guest={editingGuest}
|
||||||
|
onClose={handleFormClose}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App
|
||||||
71
frontend/src/api/api.js
Normal file
71
frontend/src/api/api.js
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
const API_BASE_URL = 'http://localhost:8000'
|
||||||
|
|
||||||
|
const api = axios.create({
|
||||||
|
baseURL: API_BASE_URL,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Guest API calls
|
||||||
|
export const getGuests = async () => {
|
||||||
|
const response = await api.get('/guests/')
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getGuest = async (id) => {
|
||||||
|
const response = await api.get(`/guests/${id}`)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createGuest = async (guest) => {
|
||||||
|
const response = await api.post('/guests/', guest)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const updateGuest = async (id, guest) => {
|
||||||
|
const response = await api.put(`/guests/${id}`, guest)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const deleteGuest = async (id) => {
|
||||||
|
const response = await api.delete(`/guests/${id}`)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const deleteGuestsBulk = async (guestIds) => {
|
||||||
|
const response = await api.post('/guests/bulk-delete', guestIds)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const undoImport = async (owner) => {
|
||||||
|
const response = await api.delete(`/guests/undo-import/${encodeURIComponent(owner)}`)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getOwners = async () => {
|
||||||
|
const response = await api.get('/guests/owners/')
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const searchGuests = async ({ query = '', rsvpStatus = '', mealPreference = '', owner = '' }) => {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (query) params.append('query', query)
|
||||||
|
if (rsvpStatus) params.append('rsvp_status', rsvpStatus)
|
||||||
|
if (mealPreference) params.append('meal_preference', mealPreference)
|
||||||
|
if (owner) params.append('owner', owner)
|
||||||
|
|
||||||
|
const response = await api.get(`/guests/search/?${params.toString()}`)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const importGoogleContacts = async (accessToken) => {
|
||||||
|
const response = await api.post('/import/google', null, {
|
||||||
|
params: { access_token: accessToken }
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export default api
|
||||||
28
frontend/src/components/GoogleImport.css
Normal file
28
frontend/src/components/GoogleImport.css
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
.btn-google {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
background: white;
|
||||||
|
color: #5f6368;
|
||||||
|
border: 1px solid #dadce0;
|
||||||
|
padding: 12px 24px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-google:hover:not(:disabled) {
|
||||||
|
background: #f8f9fa;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-google:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-google svg {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
69
frontend/src/components/GoogleImport.jsx
Normal file
69
frontend/src/components/GoogleImport.jsx
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import './GoogleImport.css'
|
||||||
|
|
||||||
|
function GoogleImport({ onImportComplete }) {
|
||||||
|
const [importing, setImporting] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Check if we got redirected back from Google OAuth
|
||||||
|
const urlParams = new URLSearchParams(window.location.search)
|
||||||
|
const imported = urlParams.get('imported')
|
||||||
|
const importOwner = urlParams.get('owner')
|
||||||
|
const error = urlParams.get('error')
|
||||||
|
|
||||||
|
if (imported) {
|
||||||
|
alert(`Successfully imported ${imported} contacts from ${importOwner}'s Google account!`)
|
||||||
|
onImportComplete()
|
||||||
|
// Clean up URL
|
||||||
|
window.history.replaceState({}, document.title, window.location.pathname)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
alert(`Failed to import contacts: ${error}`)
|
||||||
|
// Clean up URL
|
||||||
|
window.history.replaceState({}, document.title, window.location.pathname)
|
||||||
|
}
|
||||||
|
}, [onImportComplete])
|
||||||
|
|
||||||
|
const handleGoogleImport = () => {
|
||||||
|
setImporting(true)
|
||||||
|
// Redirect to backend OAuth endpoint (owner will be extracted from email)
|
||||||
|
window.location.href = 'http://localhost:8000/auth/google'
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className="btn btn-google"
|
||||||
|
onClick={handleGoogleImport}
|
||||||
|
disabled={importing}
|
||||||
|
>
|
||||||
|
{importing ? (
|
||||||
|
'⏳ Importing...'
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<svg viewBox="0 0 24 24" width="18" height="18">
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Import from Google
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default GoogleImport
|
||||||
133
frontend/src/components/GuestForm.css
Normal file
133
frontend/src/components/GuestForm.css
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background: white;
|
||||||
|
border-radius: 16px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 600px;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 20px 30px;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header h2 {
|
||||||
|
color: #1f2937;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 32px;
|
||||||
|
color: #6b7280;
|
||||||
|
cursor: pointer;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn:hover {
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
form {
|
||||||
|
padding: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: #374151;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input,
|
||||||
|
.form-group select,
|
||||||
|
.form-group textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input:focus,
|
||||||
|
.form-group select:focus,
|
||||||
|
.form-group textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #667eea;
|
||||||
|
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-group label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-group input[type="checkbox"] {
|
||||||
|
width: auto;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 30px;
|
||||||
|
padding-top: 20px;
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.form-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
max-height: 95vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
form {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
padding: 15px 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
192
frontend/src/components/GuestForm.jsx
Normal file
192
frontend/src/components/GuestForm.jsx
Normal file
@ -0,0 +1,192 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { createGuest, updateGuest } from '../api/api'
|
||||||
|
import './GuestForm.css'
|
||||||
|
|
||||||
|
function GuestForm({ guest, onClose }) {
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
first_name: '',
|
||||||
|
last_name: '',
|
||||||
|
email: '',
|
||||||
|
phone_number: '',
|
||||||
|
rsvp_status: 'pending',
|
||||||
|
meal_preference: '',
|
||||||
|
has_plus_one: false,
|
||||||
|
plus_one_name: '',
|
||||||
|
notes: '',
|
||||||
|
table_number: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (guest) {
|
||||||
|
setFormData(guest)
|
||||||
|
}
|
||||||
|
}, [guest])
|
||||||
|
|
||||||
|
const handleChange = (e) => {
|
||||||
|
const { name, value, type, checked } = e.target
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
[name]: type === 'checkbox' ? checked : value
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
try {
|
||||||
|
if (guest) {
|
||||||
|
await updateGuest(guest.id, formData)
|
||||||
|
} else {
|
||||||
|
await createGuest(formData)
|
||||||
|
}
|
||||||
|
onClose()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving guest:', error)
|
||||||
|
alert('Failed to save guest')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-overlay" onClick={onClose}>
|
||||||
|
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="modal-header">
|
||||||
|
<h2>{guest ? 'Edit Guest' : 'Add New Guest'}</h2>
|
||||||
|
<button className="close-btn" onClick={onClose}>×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="form-row">
|
||||||
|
<div className="form-group">
|
||||||
|
<label>First Name *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="first_name"
|
||||||
|
value={formData.first_name}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Last Name *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="last_name"
|
||||||
|
value={formData.last_name}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-row">
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Email</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
name="email"
|
||||||
|
value={formData.email}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Phone Number</label>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
name="phone_number"
|
||||||
|
value={formData.phone_number}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-row">
|
||||||
|
<div className="form-group">
|
||||||
|
<label>RSVP Status</label>
|
||||||
|
<select
|
||||||
|
name="rsvp_status"
|
||||||
|
value={formData.rsvp_status}
|
||||||
|
onChange={handleChange}
|
||||||
|
>
|
||||||
|
<option value="pending">Pending</option>
|
||||||
|
<option value="accepted">Accepted</option>
|
||||||
|
<option value="declined">Declined</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Meal Preference</label>
|
||||||
|
<select
|
||||||
|
name="meal_preference"
|
||||||
|
value={formData.meal_preference}
|
||||||
|
onChange={handleChange}
|
||||||
|
>
|
||||||
|
<option value="">No preference</option>
|
||||||
|
<option value="vegetarian">Vegetarian</option>
|
||||||
|
<option value="vegan">Vegan</option>
|
||||||
|
<option value="gluten-free">Gluten-free</option>
|
||||||
|
<option value="kosher">Kosher</option>
|
||||||
|
<option value="halal">Halal</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group checkbox-group">
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="has_plus_one"
|
||||||
|
checked={formData.has_plus_one}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
Has Plus One
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{formData.has_plus_one && (
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Plus One Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="plus_one_name"
|
||||||
|
value={formData.plus_one_name}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Table Number</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
name="table_number"
|
||||||
|
value={formData.table_number}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Notes</label>
|
||||||
|
<textarea
|
||||||
|
name="notes"
|
||||||
|
value={formData.notes}
|
||||||
|
onChange={handleChange}
|
||||||
|
rows="3"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-actions">
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" className="btn btn-primary">
|
||||||
|
{guest ? 'Update' : 'Add'} Guest
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default GuestForm
|
||||||
205
frontend/src/components/GuestList.css
Normal file
205
frontend/src/components/GuestList.css
Normal file
@ -0,0 +1,205 @@
|
|||||||
|
.guest-list {
|
||||||
|
margin-top: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
color: #1f2937;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 15px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-controls label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: #374151;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-controls select {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
background: white;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guest-list h2 {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
color: #1f2937;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-container {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
thead {
|
||||||
|
background: #f9fafb;
|
||||||
|
border-bottom: 2px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
padding: 12px;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #374151;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
padding: 12px;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
color: #4b5563;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr:hover {
|
||||||
|
background: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-success {
|
||||||
|
background: #d1fae5;
|
||||||
|
color: #065f46;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-danger {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #991b1b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-warning {
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-small {
|
||||||
|
padding: 6px 12px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-edit {
|
||||||
|
background: #dbeafe;
|
||||||
|
color: #1e40af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-edit:hover {
|
||||||
|
background: #bfdbfe;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-delete {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #991b1b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-delete:hover {
|
||||||
|
background: #fecaca;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-guests {
|
||||||
|
text-align: center;
|
||||||
|
padding: 60px 20px;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-guests p {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.owner-cell {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #6b7280;
|
||||||
|
max-width: 200px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
|
margin-top: 20px;
|
||||||
|
padding: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination button {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: white;
|
||||||
|
color: #374151;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination button:hover:not(:disabled) {
|
||||||
|
background: #f3f4f6;
|
||||||
|
border-color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination span {
|
||||||
|
color: #374151;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
table {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
th, td {
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-small {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
199
frontend/src/components/GuestList.jsx
Normal file
199
frontend/src/components/GuestList.jsx
Normal file
@ -0,0 +1,199 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { deleteGuest, deleteGuestsBulk } from '../api/api'
|
||||||
|
import './GuestList.css'
|
||||||
|
|
||||||
|
function GuestList({ guests, onEdit, onUpdate }) {
|
||||||
|
const [selectedGuests, setSelectedGuests] = useState([])
|
||||||
|
const [currentPage, setCurrentPage] = useState(1)
|
||||||
|
const [pageSize, setPageSize] = useState(100)
|
||||||
|
|
||||||
|
// Calculate pagination
|
||||||
|
const totalPages = pageSize === 'all' ? 1 : Math.ceil(guests.length / pageSize)
|
||||||
|
const startIndex = pageSize === 'all' ? 0 : (currentPage - 1) * pageSize
|
||||||
|
const endIndex = pageSize === 'all' ? guests.length : startIndex + pageSize
|
||||||
|
const paginatedGuests = guests.slice(startIndex, endIndex)
|
||||||
|
|
||||||
|
const handleSelectAll = (e) => {
|
||||||
|
if (e.target.checked) {
|
||||||
|
setSelectedGuests(paginatedGuests.map(g => g.id))
|
||||||
|
} else {
|
||||||
|
setSelectedGuests([])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSelectOne = (guestId) => {
|
||||||
|
if (selectedGuests.includes(guestId)) {
|
||||||
|
setSelectedGuests(selectedGuests.filter(id => id !== guestId))
|
||||||
|
} else {
|
||||||
|
setSelectedGuests([...selectedGuests, guestId])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBulkDelete = async () => {
|
||||||
|
if (selectedGuests.length === 0) return
|
||||||
|
|
||||||
|
if (window.confirm(`Are you sure you want to delete ${selectedGuests.length} guests?`)) {
|
||||||
|
try {
|
||||||
|
await deleteGuestsBulk(selectedGuests)
|
||||||
|
setSelectedGuests([])
|
||||||
|
onUpdate()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting guests:', error)
|
||||||
|
alert('Failed to delete guests')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (id) => {
|
||||||
|
if (window.confirm('Are you sure you want to delete this guest?')) {
|
||||||
|
try {
|
||||||
|
await deleteGuest(id)
|
||||||
|
onUpdate()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting guest:', error)
|
||||||
|
alert('Failed to delete guest')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getRsvpBadgeClass = (status) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'accepted':
|
||||||
|
return 'badge-success'
|
||||||
|
case 'declined':
|
||||||
|
return 'badge-danger'
|
||||||
|
default:
|
||||||
|
return 'badge-warning'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (guests.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="no-guests">
|
||||||
|
<p>No guests found. Add your first guest to get started!</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="guest-list">
|
||||||
|
<div className="list-header">
|
||||||
|
<h2>Guest List ({guests.length})</h2>
|
||||||
|
<div className="list-controls">
|
||||||
|
<label>
|
||||||
|
Show:
|
||||||
|
<select value={pageSize} onChange={(e) => {
|
||||||
|
const value = e.target.value === 'all' ? 'all' : parseInt(e.target.value)
|
||||||
|
setPageSize(value)
|
||||||
|
setCurrentPage(1)
|
||||||
|
}}>
|
||||||
|
<option value="25">25</option>
|
||||||
|
<option value="50">50</option>
|
||||||
|
<option value="100">100</option>
|
||||||
|
<option value="200">200</option>
|
||||||
|
<option value="all">All</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{selectedGuests.length > 0 && (
|
||||||
|
<button className="btn btn-danger" onClick={handleBulkDelete}>
|
||||||
|
Delete Selected ({selectedGuests.length})
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="table-container">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
onChange={handleSelectAll}
|
||||||
|
checked={paginatedGuests.length > 0 && selectedGuests.length === paginatedGuests.length}
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Phone</th>
|
||||||
|
<th>RSVP</th>
|
||||||
|
<th>Meal</th>
|
||||||
|
<th>Plus One</th>
|
||||||
|
<th>Table</th>
|
||||||
|
<th>Owner</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{paginatedGuests.map((guest) => (
|
||||||
|
<tr key={guest.id}>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedGuests.includes(guest.id)}
|
||||||
|
onChange={() => handleSelectOne(guest.id)}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<strong>{guest.first_name} {guest.last_name}</strong>
|
||||||
|
</td>
|
||||||
|
<td>{guest.email || '-'}</td>
|
||||||
|
<td>{guest.phone_number || '-'}</td>
|
||||||
|
<td>
|
||||||
|
<span className={`badge ${getRsvpBadgeClass(guest.rsvp_status)}`}>
|
||||||
|
{guest.rsvp_status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>{guest.meal_preference || '-'}</td>
|
||||||
|
<td>
|
||||||
|
{guest.has_plus_one ? (
|
||||||
|
<span>✓ {guest.plus_one_name || 'Yes'}</span>
|
||||||
|
) : (
|
||||||
|
'-'
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>{guest.table_number || '-'}</td>
|
||||||
|
<td className="owner-cell">{guest.owner || '-'}</td>
|
||||||
|
<td className="actions">
|
||||||
|
<button
|
||||||
|
className="btn-small btn-edit"
|
||||||
|
onClick={() => onEdit(guest)}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn-small btn-delete"
|
||||||
|
onClick={() => handleDelete(guest.id)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{pageSize !== 'all' && totalPages > 1 && (
|
||||||
|
<div className="pagination">
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
<span>
|
||||||
|
Page {currentPage} of {totalPages}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default GuestList
|
||||||
75
frontend/src/components/SearchFilter.css
Normal file
75
frontend/src/components/SearchFilter.css
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
.search-filter {
|
||||||
|
background: #f9fafb;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-bottom: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 2fr 1fr 1fr 1fr auto auto;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #667eea;
|
||||||
|
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-filter select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
background: white;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-filter select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #667eea;
|
||||||
|
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-reset {
|
||||||
|
padding: 12px 20px;
|
||||||
|
background: #374151;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-reset:hover {
|
||||||
|
background: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.filter-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-reset {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
132
frontend/src/components/SearchFilter.jsx
Normal file
132
frontend/src/components/SearchFilter.jsx
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { getOwners, undoImport } from '../api/api'
|
||||||
|
import './SearchFilter.css'
|
||||||
|
|
||||||
|
function SearchFilter({ onSearch }) {
|
||||||
|
const [filters, setFilters] = useState({
|
||||||
|
query: '',
|
||||||
|
rsvpStatus: '',
|
||||||
|
mealPreference: '',
|
||||||
|
owner: ''
|
||||||
|
})
|
||||||
|
const [owners, setOwners] = useState([])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadOwners()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const loadOwners = async () => {
|
||||||
|
try {
|
||||||
|
const data = await getOwners()
|
||||||
|
setOwners(data.owners || [])
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading owners:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleChange = (e) => {
|
||||||
|
const { name, value } = e.target
|
||||||
|
const newFilters = {
|
||||||
|
...filters,
|
||||||
|
[name]: value
|
||||||
|
}
|
||||||
|
setFilters(newFilters)
|
||||||
|
onSearch(newFilters)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
const resetFilters = {
|
||||||
|
query: '',
|
||||||
|
rsvpStatus: '',
|
||||||
|
mealPreference: '',
|
||||||
|
owner: ''
|
||||||
|
}
|
||||||
|
setFilters(resetFilters)
|
||||||
|
onSearch(resetFilters)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUndoImport = async () => {
|
||||||
|
if (!filters.owner) {
|
||||||
|
alert('Please select an owner to undo their import')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window.confirm(`Are you sure you want to delete all guests imported by ${filters.owner}?`)) {
|
||||||
|
try {
|
||||||
|
const result = await undoImport(filters.owner)
|
||||||
|
alert(result.message)
|
||||||
|
handleReset()
|
||||||
|
loadOwners()
|
||||||
|
onSearch({})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error undoing import:', error)
|
||||||
|
alert('Failed to undo import')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="search-filter">
|
||||||
|
<div className="filter-row">
|
||||||
|
<div className="search-box">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="query"
|
||||||
|
value={filters.query}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder="Search by name, email, or phone..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<select
|
||||||
|
name="rsvpStatus"
|
||||||
|
value={filters.rsvpStatus}
|
||||||
|
onChange={handleChange}
|
||||||
|
>
|
||||||
|
<option value="">All RSVP Status</option>
|
||||||
|
<option value="pending">Pending</option>
|
||||||
|
<option value="accepted">Accepted</option>
|
||||||
|
<option value="declined">Declined</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select
|
||||||
|
name="mealPreference"
|
||||||
|
value={filters.mealPreference}
|
||||||
|
onChange={handleChange}
|
||||||
|
>
|
||||||
|
<option value="">All Meals</option>
|
||||||
|
<option value="vegetarian">Vegetarian</option>
|
||||||
|
<option value="vegan">Vegan</option>
|
||||||
|
<option value="gluten-free">Gluten-free</option>
|
||||||
|
<option value="kosher">Kosher</option>
|
||||||
|
<option value="halal">Halal</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select
|
||||||
|
name="owner"
|
||||||
|
value={filters.owner}
|
||||||
|
onChange={handleChange}
|
||||||
|
>
|
||||||
|
<option value="">All Guests</option>
|
||||||
|
{owners.map(owner => (
|
||||||
|
<option key={owner} value={owner}>{owner}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{filters.owner && (
|
||||||
|
<button className="btn btn-secondary" onClick={handleUndoImport}>
|
||||||
|
Undo Import
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(filters.query || filters.rsvpStatus || filters.mealPreference || filters.owner) && (
|
||||||
|
<button className="btn-reset" onClick={handleReset}>
|
||||||
|
Clear Filters
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SearchFilter
|
||||||
19
frontend/src/index.css
Normal file
19
frontend/src/index.css
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||||
|
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||||
|
sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
10
frontend/src/main.jsx
Normal file
10
frontend/src/main.jsx
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import App from './App.jsx'
|
||||||
|
import './index.css'
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>,
|
||||||
|
)
|
||||||
10
frontend/vite.config.js
Normal file
10
frontend/vite.config.js
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
// https://vitejs.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 5173
|
||||||
|
}
|
||||||
|
})
|
||||||
25
start.bat
Normal file
25
start.bat
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
@echo off
|
||||||
|
echo Starting Wedding Guest List Application...
|
||||||
|
echo.
|
||||||
|
|
||||||
|
echo [1/3] Starting PostgreSQL (make sure it's running)
|
||||||
|
timeout /t 2 /nobreak > nul
|
||||||
|
|
||||||
|
echo [2/3] Starting Backend Server...
|
||||||
|
start "Backend API" cmd /k "cd /d %~dp0backend && uvicorn main:app --reload"
|
||||||
|
timeout /t 3 /nobreak > nul
|
||||||
|
|
||||||
|
echo [3/3] Starting Frontend...
|
||||||
|
start "Frontend Dev Server" cmd /k "cd /d %~dp0frontend && npm run dev"
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo ========================================
|
||||||
|
echo Application is starting!
|
||||||
|
echo ========================================
|
||||||
|
echo.
|
||||||
|
echo Backend API: http://localhost:8000
|
||||||
|
echo API Docs: http://localhost:8000/docs
|
||||||
|
echo Frontend: http://localhost:5173
|
||||||
|
echo.
|
||||||
|
echo Press any key to close this window...
|
||||||
|
pause > nul
|
||||||
35
start.sh
Normal file
35
start.sh
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
echo "Starting Wedding Guest List Application..."
|
||||||
|
echo
|
||||||
|
|
||||||
|
echo "[1/3] Starting PostgreSQL (make sure it's running)"
|
||||||
|
sleep 2
|
||||||
|
|
||||||
|
echo "[2/3] Starting Backend Server..."
|
||||||
|
cd backend
|
||||||
|
uvicorn main:app --reload &
|
||||||
|
BACKEND_PID=$!
|
||||||
|
cd ..
|
||||||
|
sleep 3
|
||||||
|
|
||||||
|
echo "[3/3] Starting Frontend..."
|
||||||
|
cd frontend
|
||||||
|
npm run dev &
|
||||||
|
FRONTEND_PID=$!
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "========================================"
|
||||||
|
echo "Application is running!"
|
||||||
|
echo "========================================"
|
||||||
|
echo
|
||||||
|
echo "Backend API: http://localhost:8000"
|
||||||
|
echo "API Docs: http://localhost:8000/docs"
|
||||||
|
echo "Frontend: http://localhost:5173"
|
||||||
|
echo
|
||||||
|
echo "Press Ctrl+C to stop all services"
|
||||||
|
|
||||||
|
# Wait for Ctrl+C
|
||||||
|
trap "kill $BACKEND_PID $FRONTEND_PID; exit" INT
|
||||||
|
wait
|
||||||
Loading…
x
Reference in New Issue
Block a user