diff --git a/app/main.py b/app/main.py index cdb593c..4cde5ed 100644 --- a/app/main.py +++ b/app/main.py @@ -1,7 +1,7 @@ import os import secrets from datetime import datetime, timedelta, date -from typing import Optional, Dict +from typing import Optional, Dict, List from decimal import Decimal from fastapi import FastAPI, Request, Depends, HTTPException, Form, status @@ -104,9 +104,6 @@ def index(request: Request, db: Session = Depends(get_db)): return templates.TemplateResponse(request, "not_found_tenant.html", context={}) current_user = get_current_user(request, db) - if not current_user: - return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER) - now = datetime.now() # Aktuelle offene Runde suchen @@ -119,6 +116,11 @@ def index(request: Request, db: Session = Depends(get_db)): if not event: event = db.query(Event).filter(Event.tenant_id == tenant.id).order_by(Event.id.desc()).first() + # Wenn nicht eingeloggt und Event erlaubt keine Gäste -> Redirect zu Login + if not current_user: + if not event or not event.allow_guests: + return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER) + # Fristprüfung & Countdown is_closed = True seconds_remaining = 0 @@ -136,7 +138,7 @@ def index(request: Request, db: Session = Depends(get_db)): # Bestehende Bestellung des Users in diesem Event existing_order = None user_items: Dict[int, int] = {} - if event: + if event and current_user: existing_order = db.query(Order).filter( Order.tenant_id == tenant.id, Order.event_id == event.id, @@ -175,10 +177,19 @@ def login_page(request: Request, db: Session = Depends(get_db), error: Optional[ if current_user: return RedirectResponse(url="/", status_code=status.HTTP_303_SEE_OTHER) + # Prüfen, ob aktives Event Gäste erlaubt + active_event = db.query(Event).filter( + Event.tenant_id == tenant.id, + Event.status == "open", + Event.deadline > datetime.now() + ).first() + allow_guests = (active_event.allow_guests if active_event else False) + return templates.TemplateResponse(request, "login.html", context={ "tenant": tenant, "error": error, "success": success, + "allow_guests": allow_guests, "current_user": None }) @@ -198,10 +209,10 @@ def login( return templates.TemplateResponse(request, "login.html", context={ "tenant": tenant, "error": "Ungültige E-Mail-Adresse oder falsches Passwort.", - "current_user": None + "current_user": None, + "allow_guests": False }, status_code=400) - # Session setzen request.session["user_id"] = user.id return RedirectResponse(url="/", status_code=status.HTTP_303_SEE_OTHER) @@ -251,9 +262,9 @@ def join_page(request: Request, token: str, db: Session = Depends(get_db)): error = None if not invitation: error = "Dieser Einladungslink ist ungültig oder gehört zu einem anderen Team." - elif invitation.used_at is not None: + elif not invitation.is_reusable and invitation.used_at is not None: error = "Dieser Einladungslink wurde bereits eingelöst. Bitte melde dich mit deinem Konto an." - elif invitation.expires_at < datetime.now(): + elif invitation.expires_at and invitation.expires_at < datetime.now(): error = "Dieser Einladungslink ist leider abgelaufen. Bitte frage deinen Admin nach einem neuen Link." return templates.TemplateResponse(request, "join.html", context={ @@ -281,8 +292,14 @@ def process_join( Invitation.tenant_id == tenant.id ).first() - if not invitation or invitation.used_at is not None or invitation.expires_at < datetime.now(): - raise HTTPException(status_code=400, detail="Ungültiger oder abgelaufener Einladungslink") + if not invitation: + raise HTTPException(status_code=400, detail="Ungültiger Einladungslink") + + if not invitation.is_reusable and invitation.used_at is not None: + raise HTTPException(status_code=400, detail="Einladungslink wurde bereits verwendet") + + if invitation.expires_at and invitation.expires_at < datetime.now(): + raise HTTPException(status_code=400, detail="Einladungslink ist abgelaufen") clean_email = email.strip().lower() existing_user = db.query(User).filter( @@ -299,7 +316,6 @@ def process_join( "current_user": None }, status_code=400) - # Neuen Benutzer anlegen username = clean_email.split("@")[0] new_user = User( tenant_id=tenant.id, @@ -312,12 +328,13 @@ def process_join( ) db.add(new_user) - # Einladung als verwendet markieren - invitation.used_at = datetime.now() + # Nur Einmal-Links als verwendet markieren; wiederverwendbare Links bleiben offen! + if not invitation.is_reusable: + invitation.used_at = datetime.now() + db.commit() db.refresh(new_user) - # Direkt einloggen request.session["user_id"] = new_user.id return RedirectResponse(url="/", status_code=status.HTTP_303_SEE_OTHER) @@ -328,12 +345,13 @@ def process_join( async def place_order( request: Request, event_id: int = Form(...), + guest_first_name: Optional[str] = Form(None), + guest_last_name: Optional[str] = Form(None), + guest_email: Optional[str] = Form(None), db: Session = Depends(get_db) ): tenant = resolve_tenant(request, db) current_user = get_current_user(request, db) - if not current_user: - return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER) event = db.query(Event).filter(Event.id == event_id, Event.tenant_id == tenant.id).first() if not event or event.status != "open": @@ -342,6 +360,33 @@ async def place_order( if event.deadline < datetime.now(): raise HTTPException(status_code=400, detail="Die Bestellfrist für diese Runde ist leider abgelaufen!") + # Falls nicht eingeloggt -> Gast-Bestellung prüfen + if not current_user: + if not event.allow_guests: + return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER) + + if not guest_first_name or not guest_first_name.strip(): + raise HTTPException(status_code=400, detail="Bitte gib als Gast deinen Vornamen an!") + + fname = guest_first_name.strip() + lname = guest_last_name.strip() if guest_last_name else "(Gast)" + clean_email = (guest_email.strip().lower() if guest_email and guest_email.strip() else f"gast_{secrets.token_hex(4)}@{tenant.slug}.local") + + guest_user = User( + tenant_id=tenant.id, + email=clean_email, + username=f"gast_{secrets.token_hex(4)}", + first_name=fname, + last_name=lname, + role="guest", + password_hash="" + ) + db.add(guest_user) + db.commit() + db.refresh(guest_user) + current_user = guest_user + request.session["user_id"] = guest_user.id + form_data = await request.form() order_items = [] total_price = Decimal("0.00") @@ -468,12 +513,17 @@ def admin_dashboard( # Teammitglieder & Einladungen laden team_members = db.query(User).filter(User.tenant_id == tenant.id).order_by(User.role.asc(), User.first_name.asc()).all() + + # Offene Einladungen (entweder unbegrenzt oder expires_at in der Zukunft) + now = datetime.now() pending_invitations = db.query(Invitation).filter( Invitation.tenant_id == tenant.id, - Invitation.used_at == None, - Invitation.expires_at > datetime.now() + (Invitation.expires_at == None) | (Invitation.expires_at > now) ).order_by(Invitation.created_at.desc()).all() + # Produkte des Mandanten laden + products = db.query(Product).filter(Product.tenant_id == tenant.id).order_by(Product.sort_order.asc(), Product.id.asc()).all() + return templates.TemplateResponse(request, "admin.html", context={ "tenant": tenant, "current_user": current_user, @@ -485,119 +535,90 @@ def admin_dashboard( "total_paid": total_paid, "team_members": team_members, "pending_invitations": pending_invitations, + "products": products, "active_tab": tab, - "now": datetime.now() + "now": now }) -@app.post("/admin/events/new") -def admin_create_event( +# --- ADMIN USER MANAGEMENT --- + +@app.post("/admin/users/{user_id}/reset-password") +def admin_reset_password( + user_id: int, request: Request, - title: str = Form(...), - event_date: str = Form(...), - deadline_date: str = Form(...), - deadline_time: str = Form(...), + new_password: str = Form(...), db: Session = Depends(get_db) ): tenant = resolve_tenant(request, db) require_admin(request, db) - try: - ev_date = datetime.strptime(event_date.strip(), "%Y-%m-%d").date() - dl_str = f"{deadline_date.strip()} {deadline_time.strip()}" - deadline_dt = datetime.strptime(dl_str, "%Y-%m-%d %H:%M") - except ValueError as e: - raise HTTPException(status_code=400, detail=f"Ungültiges Datumsformat: {e}") + user = db.query(User).filter(User.id == user_id, User.tenant_id == tenant.id).first() + if not user: + raise HTTPException(status_code=404, detail="Benutzer nicht gefunden") - new_event = Event( - tenant_id=tenant.id, - title=title.strip(), - event_date=ev_date, - deadline=deadline_dt, - status="open" - ) - db.add(new_event) - db.commit() - db.refresh(new_event) + if not new_password or len(new_password.strip()) < 4: + raise HTTPException(status_code=400, detail="Passwort muss mindestens 4 Zeichen lang sein") - return RedirectResponse(url=f"/admin?event_id={new_event.id}&tab=event", status_code=status.HTTP_303_SEE_OTHER) - - -@app.post("/admin/events/{event_id}/edit") -def admin_edit_event( - event_id: int, - request: Request, - title: str = Form(...), - event_date: str = Form(...), - deadline_date: str = Form(...), - deadline_time: str = Form(...), - status_val: str = Form(...), - db: Session = Depends(get_db) -): - tenant = resolve_tenant(request, db) - require_admin(request, db) - - event = db.query(Event).filter(Event.id == event_id, Event.tenant_id == tenant.id).first() - if not event: - raise HTTPException(status_code=404, detail="Event nicht gefunden") - - try: - ev_date = datetime.strptime(event_date.strip(), "%Y-%m-%d").date() - dl_str = f"{deadline_date.strip()} {deadline_time.strip()}" - deadline_dt = datetime.strptime(dl_str, "%Y-%m-%d %H:%M") - except ValueError as e: - raise HTTPException(status_code=400, detail=f"Ungültiges Datumsformat: {e}") - - event.title = title.strip() - event.event_date = ev_date - event.deadline = deadline_dt - event.status = status_val + user.password_hash = hash_password(new_password.strip()) db.commit() - return RedirectResponse(url=f"/admin?event_id={event.id}&tab=event", status_code=status.HTTP_303_SEE_OTHER) + return RedirectResponse(url="/admin?tab=team", status_code=status.HTTP_303_SEE_OTHER) -@app.post("/admin/events/{event_id}/toggle-status") -def admin_toggle_event_status( - event_id: int, +@app.post("/admin/users/{user_id}/delete") +def admin_delete_user( + user_id: int, request: Request, db: Session = Depends(get_db) ): tenant = resolve_tenant(request, db) - require_admin(request, db) + admin = require_admin(request, db) - event = db.query(Event).filter(Event.id == event_id, Event.tenant_id == tenant.id).first() - if not event: - raise HTTPException(status_code=404, detail="Event nicht gefunden") + if admin.id == user_id: + raise HTTPException(status_code=400, detail="Du kannst dein eigenes Admin-Konto nicht löschen") - event.status = "closed" if event.status == "open" else "open" - db.commit() + user = db.query(User).filter(User.id == user_id, User.tenant_id == tenant.id).first() + if user: + db.delete(user) + db.commit() - return RedirectResponse(url=f"/admin?event_id={event.id}", status_code=status.HTTP_303_SEE_OTHER) + return RedirectResponse(url="/admin?tab=team", status_code=status.HTTP_303_SEE_OTHER) +# --- ADMIN INVITATIONS --- + @app.post("/admin/invitations/create") def admin_create_invitation( request: Request, email: Optional[str] = Form(None), role: str = Form("user"), - valid_days: int = Form(14), + valid_days: str = Form("14"), db: Session = Depends(get_db) ): tenant = resolve_tenant(request, db) admin = require_admin(request, db) token = secrets.token_urlsafe(20) - expires_at = datetime.now() + timedelta(days=valid_days) clean_email = email.strip().lower() if email and email.strip() else None + # Unbegrenzt prüfen + if valid_days in ["0", "unlimited", "forever"]: + expires_at = None + is_reusable = True + else: + days = int(valid_days) + expires_at = datetime.now() + timedelta(days=days) + is_reusable = False + inv = Invitation( tenant_id=tenant.id, token=token, email=clean_email, role=role, created_by=admin.id, - expires_at=expires_at + expires_at=expires_at, + is_reusable=is_reusable ) db.add(inv) db.commit() @@ -622,6 +643,238 @@ def admin_revoke_invitation( return RedirectResponse(url="/admin?tab=team", status_code=status.HTTP_303_SEE_OTHER) +# --- ADMIN EVENT MANAGEMENT --- + +@app.post("/admin/events/new") +def admin_create_event( + request: Request, + title: str = Form(...), + event_date: str = Form(...), + deadline_date: str = Form(...), + deadline_time: str = Form(...), + sponsor_name: Optional[str] = Form(None), + allow_guests: bool = Form(True), + db: Session = Depends(get_db) +): + tenant = resolve_tenant(request, db) + require_admin(request, db) + + try: + ev_date = datetime.strptime(event_date.strip(), "%Y-%m-%d").date() + dl_str = f"{deadline_date.strip()} {deadline_time.strip()}" + deadline_dt = datetime.strptime(dl_str, "%Y-%m-%d %H:%M") + except ValueError as e: + raise HTTPException(status_code=400, detail=f"Ungültiges Datumsformat: {e}") + + new_event = Event( + tenant_id=tenant.id, + title=title.strip(), + event_date=ev_date, + deadline=deadline_dt, + status="open", + sponsor_name=sponsor_name.strip() if sponsor_name and sponsor_name.strip() else None, + allow_guests=allow_guests + ) + db.add(new_event) + db.commit() + db.refresh(new_event) + + return RedirectResponse(url=f"/admin?event_id={new_event.id}&tab=event", status_code=status.HTTP_303_SEE_OTHER) + + +@app.post("/admin/events/{event_id}/edit") +def admin_edit_event( + event_id: int, + request: Request, + title: str = Form(...), + event_date: str = Form(...), + deadline_date: str = Form(...), + deadline_time: str = Form(...), + status_val: str = Form(...), + sponsor_name: Optional[str] = Form(None), + allow_guests: bool = Form(False), + db: Session = Depends(get_db) +): + tenant = resolve_tenant(request, db) + require_admin(request, db) + + event = db.query(Event).filter(Event.id == event_id, Event.tenant_id == tenant.id).first() + if not event: + raise HTTPException(status_code=404, detail="Event nicht gefunden") + + try: + ev_date = datetime.strptime(event_date.strip(), "%Y-%m-%d").date() + dl_str = f"{deadline_date.strip()} {deadline_time.strip()}" + deadline_dt = datetime.strptime(dl_str, "%Y-%m-%d %H:%M") + except ValueError as e: + raise HTTPException(status_code=400, detail=f"Ungültiges Datumsformat: {e}") + + event.title = title.strip() + event.event_date = ev_date + event.deadline = deadline_dt + event.status = status_val + event.sponsor_name = sponsor_name.strip() if sponsor_name and sponsor_name.strip() else None + event.allow_guests = allow_guests + db.commit() + + return RedirectResponse(url=f"/admin?event_id={event.id}&tab=event", status_code=status.HTTP_303_SEE_OTHER) + + +@app.post("/admin/events/{event_id}/toggle-status") +def admin_toggle_event_status( + event_id: int, + request: Request, + db: Session = Depends(get_db) +): + tenant = resolve_tenant(request, db) + require_admin(request, db) + + event = db.query(Event).filter(Event.id == event_id, Event.tenant_id == tenant.id).first() + if not event: + raise HTTPException(status_code=404, detail="Event nicht gefunden") + + event.status = "closed" if event.status == "open" else "open" + db.commit() + + return RedirectResponse(url=f"/admin?event_id={event.id}", status_code=status.HTTP_303_SEE_OTHER) + + +# --- ADMIN PRODUCT CATALOG MANAGEMENT --- + +@app.post("/admin/products/new") +def admin_create_product( + request: Request, + name: str = Form(...), + description: str = Form(""), + price: str = Form(...), + unit: str = Form("Stück"), + sort_order: int = Form(0), + db: Session = Depends(get_db) +): + tenant = resolve_tenant(request, db) + require_admin(request, db) + + try: + clean_price = Decimal(price.strip().replace(",", ".")) + except Exception: + raise HTTPException(status_code=400, detail="Ungültiger Preis") + + product = Product( + tenant_id=tenant.id, + name=name.strip(), + description=description.strip(), + price=clean_price, + unit=unit.strip(), + sort_order=sort_order, + is_active=True + ) + db.add(product) + db.commit() + + return RedirectResponse(url="/admin?tab=products", status_code=status.HTTP_303_SEE_OTHER) + + +@app.post("/admin/products/{product_id}/edit") +def admin_edit_product( + product_id: int, + request: Request, + name: str = Form(...), + description: str = Form(""), + price: str = Form(...), + unit: str = Form("Stück"), + sort_order: int = Form(0), + db: Session = Depends(get_db) +): + tenant = resolve_tenant(request, db) + require_admin(request, db) + + prod = db.query(Product).filter(Product.id == product_id, Product.tenant_id == tenant.id).first() + if not prod: + raise HTTPException(status_code=404, detail="Produkt nicht gefunden") + + try: + clean_price = Decimal(price.strip().replace(",", ".")) + except Exception: + raise HTTPException(status_code=400, detail="Ungültiger Preis") + + prod.name = name.strip() + prod.description = description.strip() + prod.price = clean_price + prod.unit = unit.strip() + prod.sort_order = sort_order + db.commit() + + return RedirectResponse(url="/admin?tab=products", status_code=status.HTTP_303_SEE_OTHER) + + +@app.post("/admin/products/{product_id}/toggle") +def admin_toggle_product( + product_id: int, + request: Request, + db: Session = Depends(get_db) +): + tenant = resolve_tenant(request, db) + require_admin(request, db) + + prod = db.query(Product).filter(Product.id == product_id, Product.tenant_id == tenant.id).first() + if prod: + prod.is_active = not prod.is_active + db.commit() + + return RedirectResponse(url="/admin?tab=products", status_code=status.HTTP_303_SEE_OTHER) + + +# --- ONBOARDING / SETUP WIZARD (/setup) --- + +@app.get("/setup", response_class=HTMLResponse) +def setup_page(request: Request, db: Session = Depends(get_db)): + tenant = resolve_tenant(request, db) + if not tenant: + raise HTTPException(status_code=404, detail="Mandant nicht gefunden") + + current_user = get_current_user(request, db) + prod_count = db.query(Product).filter(Product.tenant_id == tenant.id).count() + user_count = db.query(User).filter(User.tenant_id == tenant.id).count() + + return templates.TemplateResponse(request, "setup.html", context={ + "tenant": tenant, + "current_user": current_user, + "prod_count": prod_count, + "user_count": user_count + }) + + +@app.post("/setup/seed-products") +def seed_standard_products(request: Request, db: Session = Depends(get_db)): + tenant = resolve_tenant(request, db) + require_admin(request, db) + + standard_items = [ + {"name": "Original Münchner Weißwürste", "desc": "Frisch vom Metzger, traditionell hergestellt", "price": Decimal("1.40"), "unit": "Stück", "order": 1}, + {"name": "Frische Laugenbreze", "desc": "Knusprig gebacken, ofenfrisch vom Bäcker", "price": Decimal("1.10"), "unit": "Stück", "order": 2}, + {"name": "Händlmaier Hausmachersenf", "desc": "Original bayerischer süßer Hausmachersenf", "price": Decimal("0.40"), "unit": "Portion", "order": 3}, + {"name": "Leberkassemmel (warm)", "desc": "Feiner bayerischer Leberkäse in der Kaisersemmel", "price": Decimal("2.80"), "unit": "Semmel", "order": 4}, + {"name": "Wiener Würstchen", "desc": "Knackige Wiener Würstchen (Paar)", "price": Decimal("2.60"), "unit": "Paar", "order": 5}, + {"name": "Alkoholfreies Weißbier (0,5l)", "desc": "Erfrischendes bayerisches Hefe-Weißbier alkoholfrei", "price": Decimal("1.80"), "unit": "Flasche", "order": 6}, + ] + + for item in standard_items: + exists = db.query(Product).filter(Product.tenant_id == tenant.id, Product.name == item["name"]).first() + if not exists: + db.add(Product( + tenant_id=tenant.id, + name=item["name"], + description=item["desc"], + price=item["price"], + unit=item["unit"], + sort_order=item["order"], + is_active=True + )) + db.commit() + + return RedirectResponse(url="/admin?tab=products", status_code=status.HTTP_303_SEE_OTHER) + + @app.post("/admin/toggle-paid/{order_id}") def toggle_paid(order_id: int, request: Request, db: Session = Depends(get_db)): current_user = get_current_user(request, db) diff --git a/app/models.py b/app/models.py index 1b33e58..99934f0 100644 --- a/app/models.py +++ b/app/models.py @@ -30,12 +30,12 @@ class User(Base): password_hash = Column(String(255), default="") first_name = Column(String(100), nullable=False) last_name = Column(String(100), nullable=False) - role = Column(String(20), default="user") # 'admin', 'user' + role = Column(String(20), default="user") # 'admin', 'user', 'guest' is_active = Column(Boolean, default=True) created_at = Column(DateTime, default=func.now()) tenant = relationship("Tenant", back_populates="users") - orders = relationship("Order", back_populates="user") + orders = relationship("Order", back_populates="user", cascade="all, delete-orphan") class Invitation(Base): @@ -47,8 +47,9 @@ class Invitation(Base): email = Column(String(191), nullable=True) role = Column(String(20), default="user", nullable=False) created_by = Column(Integer, nullable=True) - expires_at = Column(DateTime, nullable=False) + expires_at = Column(DateTime, nullable=True) used_at = Column(DateTime, nullable=True) + is_reusable = Column(Boolean, default=False, nullable=False) created_at = Column(DateTime, default=func.now()) tenant = relationship("Tenant", back_populates="invitations") @@ -78,6 +79,8 @@ class Event(Base): event_date = Column(Date, nullable=False) deadline = Column(DateTime, nullable=False) status = Column(String(20), default="open") # 'open', 'closed', 'completed' + allow_guests = Column(Boolean, default=True, nullable=False) + sponsor_name = Column(String(150), nullable=True) created_at = Column(DateTime, default=func.now()) tenant = relationship("Tenant", back_populates="events") diff --git a/app/static/style.css b/app/static/style.css index 69ed2a8..5b98945 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -234,6 +234,18 @@ body { margin-bottom: 12px; } +.sponsor-badge-banner { + display: inline-block; + background: rgba(251, 191, 36, 0.15); + border: 1px solid rgba(251, 191, 36, 0.4); + color: var(--accent-gold); + padding: 6px 14px; + border-radius: 8px; + font-size: 0.9rem; + font-weight: 600; + margin-bottom: 12px; +} + .banner-stats { color: var(--text-muted); font-size: 0.85rem; @@ -380,6 +392,14 @@ body { margin-bottom: 16px; } +.guest-order-box { + background: rgba(11, 15, 25, 0.6); + border: 1px solid rgba(251, 191, 36, 0.3); + border-radius: var(--radius-md); + padding: 16px; + margin-bottom: 18px; +} + .input-group { margin-bottom: 16px; } @@ -407,12 +427,38 @@ body { border-color: var(--accent); } +.input-group-checkbox { + margin: 14px 0; + display: flex; + align-items: center; +} + +.input-group-checkbox label { + display: flex; + align-items: center; + gap: 8px; + font-size: 0.9rem; + cursor: pointer; +} + +.input-group-checkbox input { + width: 18px; + height: 18px; + cursor: pointer; +} + .form-row-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } +.form-row-3 { + display: grid; + grid-template-columns: 1.5fr 1fr 1fr; + gap: 12px; +} + .readonly-input { background: rgba(255, 255, 255, 0.05) !important; color: var(--text-muted) !important; @@ -677,6 +723,27 @@ body { font-size: 0.9rem; } +.sponsor-highlight-card { + display: flex; + align-items: center; + gap: 16px; + background: rgba(251, 191, 36, 0.12); + border: 1px solid rgba(251, 191, 36, 0.35); + border-radius: var(--radius-md); + padding: 18px 22px; + margin-bottom: 24px; +} + +.sponsor-icon { + font-size: 2.5rem; +} + +.sponsor-highlight-card h3 { + color: var(--accent-gold); + font-size: 1.15rem; + margin-bottom: 4px; +} + .stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); @@ -758,6 +825,11 @@ body { font-size: 0.8rem; } +.row-inactive { + opacity: 0.5; + background: rgba(0, 0, 0, 0.2); +} + .mini-items-list { list-style: none; font-size: 0.85rem; @@ -805,6 +877,11 @@ body { color: var(--accent-gold); } +.badge-role.guest { + background: rgba(168, 85, 247, 0.15); + color: #c084fc; +} + /* Invitations List */ .invitations-list { display: flex; @@ -884,6 +961,60 @@ body { background: rgba(255, 255, 255, 0.15); } +/* Modal */ +.modal-backdrop { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background: rgba(0, 0, 0, 0.75); + backdrop-filter: blur(6px); + display: flex; + justify-content: center; + align-items: center; + z-index: 100; +} + +.modal-card { + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: var(--radius-lg); + padding: 28px; + width: 90%; + max-width: 500px; + box-shadow: 0 20px 40px rgba(0, 0, 0, 0.5); +} + +/* Setup Wizard Steps */ +.setup-step-card { + display: flex; + gap: 16px; + background: rgba(11, 15, 25, 0.6); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + padding: 20px; + margin-bottom: 18px; +} + +.step-num { + width: 36px; + height: 36px; + border-radius: 50%; + background: var(--accent); + color: #0b0f19; + font-weight: 800; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.step-content h3 { + font-size: 1.05rem; + margin-bottom: 6px; +} + /* Footer */ .footer { border-top: 1px solid var(--border-color); @@ -951,6 +1082,13 @@ body { margin-bottom: 6px; } + .print-sponsor-box { + margin-top: 8px; + padding: 6px; + border: 1px dashed #000; + font-size: 10pt; + } + .print-section h3 { font-size: 13pt; margin-bottom: 8px; diff --git a/app/templates/admin.html b/app/templates/admin.html index 591e651..8feaa8d 100644 --- a/app/templates/admin.html +++ b/app/templates/admin.html @@ -3,7 +3,7 @@ {% block content %}
- +