first commit

This commit is contained in:
dvirlabs 2025-12-29 05:49:05 +02:00
commit 3d1d48fd49
33 changed files with 4371 additions and 0 deletions

31
.github/copilot-instructions.md vendored Normal file
View 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
View 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
View 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
View 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
View 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
View 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()

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

File diff suppressed because it is too large Load Diff

22
frontend/package.json Normal file
View 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"
}
}

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

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

View 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

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

View 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

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

View 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

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

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