diff --git a/backend/app/api/v1/__init__.py b/backend/app/api/v1/__init__.py index 61e573b..a9f03f6 100644 --- a/backend/app/api/v1/__init__.py +++ b/backend/app/api/v1/__init__.py @@ -1,5 +1,5 @@ from fastapi import APIRouter -from . import auth, users, tiers, memberships, payments, email, email_templates, events, feature_flags +from . import auth, users, tiers, memberships, payments, email, email_templates, events, feature_flags, esp api_router = APIRouter() @@ -12,3 +12,4 @@ api_router.include_router(email.router, prefix="/email", tags=["email"]) api_router.include_router(email_templates.router, prefix="/email-templates", tags=["email-templates"]) api_router.include_router(events.router, prefix="/events", tags=["events"]) api_router.include_router(feature_flags.router, prefix="/feature-flags", tags=["feature-flags"]) +api_router.include_router(esp.router, prefix="/esp", tags=["esp-rfid"]) diff --git a/backend/app/api/v1/auth.py b/backend/app/api/v1/auth.py index 576e9b1..d756d73 100644 --- a/backend/app/api/v1/auth.py +++ b/backend/app/api/v1/auth.py @@ -6,6 +6,7 @@ from typing import List import uuid from ...core.database import get_db +from ...core.datetime import utc_now from ...core.security import verify_password, get_password_hash, create_access_token from ...models.models import User, UserRole, PasswordResetToken from ...schemas import ( @@ -85,7 +86,7 @@ async def login( ) # Update last login - user.last_login = datetime.utcnow() + user.last_login = utc_now() db.commit() # Create access token @@ -120,7 +121,7 @@ async def login_json( ) # Update last login - user.last_login = datetime.utcnow() + user.last_login = utc_now() db.commit() # Create access token @@ -149,12 +150,12 @@ async def forgot_password( db.query(PasswordResetToken).filter( PasswordResetToken.user_id == user.id, PasswordResetToken.used == False, - PasswordResetToken.expires_at > datetime.utcnow() + PasswordResetToken.expires_at > utc_now() ).update({"used": True}) # Generate new reset token reset_token = str(uuid.uuid4()) - expires_at = datetime.utcnow() + timedelta(hours=1) # Token expires in 1 hour + expires_at = utc_now() + timedelta(hours=1) # Token expires in 1 hour # Create password reset token db_token = PasswordResetToken( @@ -192,7 +193,7 @@ async def reset_password( reset_token = db.query(PasswordResetToken).filter( PasswordResetToken.token == request.token, PasswordResetToken.used == False, - PasswordResetToken.expires_at > datetime.utcnow() + PasswordResetToken.expires_at > utc_now() ).first() if not reset_token: @@ -212,7 +213,7 @@ async def reset_password( # Update password hashed_password = get_password_hash(request.new_password) user.hashed_password = hashed_password - user.updated_at = datetime.utcnow() + user.updated_at = utc_now() # Mark token as used reset_token.used = True @@ -239,7 +240,7 @@ async def change_password( # Update password hashed_password = get_password_hash(request.new_password) current_user.hashed_password = hashed_password - current_user.updated_at = datetime.utcnow() + current_user.updated_at = utc_now() db.commit() diff --git a/backend/app/api/v1/email.py b/backend/app/api/v1/email.py index 84938a1..2e418ce 100644 --- a/backend/app/api/v1/email.py +++ b/backend/app/api/v1/email.py @@ -6,6 +6,7 @@ from ...api.dependencies import get_admin_user from ...models.models import User from typing import Dict, Any, List from ...core.database import get_db +from ...core.datetime import to_zulu_iso from sqlalchemy.orm import Session router = APIRouter() @@ -95,7 +96,7 @@ async def get_bounce_list( "email": bounce.email, "bounce_type": bounce.bounce_type.value, "bounce_reason": bounce.bounce_reason, - "bounce_date": bounce.bounce_date.isoformat(), + "bounce_date": to_zulu_iso(bounce.bounce_date), "is_active": bounce.is_active, "smtp2go_message_id": bounce.smtp2go_message_id } @@ -132,7 +133,7 @@ async def get_bounce_history( "id": bounce.id, "bounce_type": bounce.bounce_type.value, "bounce_reason": bounce.bounce_reason, - "bounce_date": bounce.bounce_date.isoformat(), + "bounce_date": to_zulu_iso(bounce.bounce_date), "is_active": bounce.is_active, "smtp2go_message_id": bounce.smtp2go_message_id } diff --git a/backend/app/api/v1/events.py b/backend/app/api/v1/events.py index c0d07d8..5239db2 100644 --- a/backend/app/api/v1/events.py +++ b/backend/app/api/v1/events.py @@ -1,9 +1,9 @@ from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.orm import Session from typing import List -from datetime import datetime from ...core.database import get_db +from ...core.datetime import utc_now from ...models.models import Event, EventRSVP, User, EventStatus from ...schemas import ( EventCreate, EventUpdate, EventResponse, EventRSVPResponse, EventRSVPUpdate, MessageResponse @@ -13,6 +13,10 @@ from ...api.dependencies import get_current_active_user, get_admin_user router = APIRouter() +def _utc_time_string(value) -> str: + return value.strftime("%H:%M") + + @router.get("/", response_model=List[EventResponse]) async def get_events( current_user: User = Depends(get_current_active_user), @@ -34,9 +38,9 @@ async def get_upcoming_events( db: Session = Depends(get_db) ): """Get upcoming events""" - now = datetime.now() + now = utc_now() events = db.query(Event).filter( - Event.event_date >= now.date(), + Event.event_date >= now, Event.status == EventStatus.PUBLISHED ).order_by(Event.event_date).all() return events @@ -50,7 +54,7 @@ async def create_event( ): """Create a new event (admin only)""" # Validate event date is in the future - if event_data.event_date < datetime.now(): + if event_data.event_date < utc_now(): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Event date must be in the future" @@ -60,7 +64,7 @@ async def create_event( title=event_data.title, description=event_data.description, event_date=event_data.event_date, - event_time=event_data.event_time, + event_time=_utc_time_string(event_data.event_date), location=event_data.location, max_attendees=event_data.max_attendees, status=EventStatus.DRAFT, @@ -89,10 +93,14 @@ async def update_event( ) # Update fields - for field, value in event_data.dict(exclude_unset=True).items(): + update_data = event_data.model_dump(exclude_unset=True) + if "event_date" in update_data: + update_data["event_time"] = _utc_time_string(update_data["event_date"]) + + for field, value in update_data.items(): setattr(event, field, value) - event.updated_at = datetime.now() + event.updated_at = utc_now() db.commit() db.refresh(event) return event @@ -167,7 +175,7 @@ async def create_or_update_rsvp( existing_rsvp.status = rsvp_data.status if rsvp_data.notes is not None: existing_rsvp.notes = rsvp_data.notes - existing_rsvp.updated_at = datetime.now() + existing_rsvp.updated_at = utc_now() db.commit() db.refresh(existing_rsvp) return existing_rsvp @@ -204,4 +212,4 @@ async def get_my_rsvps( ): """Get current user's RSVPs""" rsvps = db.query(EventRSVP).filter(EventRSVP.user_id == current_user.id).all() - return rsvps \ No newline at end of file + return rsvps diff --git a/backend/app/api/v1/payments.py b/backend/app/api/v1/payments.py index 64f779f..e62d31d 100644 --- a/backend/app/api/v1/payments.py +++ b/backend/app/api/v1/payments.py @@ -5,6 +5,7 @@ from datetime import datetime, timedelta from dateutil.relativedelta import relativedelta from ...core.database import get_db +from ...core.datetime import unix_ms_utc, utc_now from ...models.models import Payment, PaymentStatus, PaymentMethod, User, Membership, MembershipStatus, MembershipTier from ...schemas import ( PaymentCreate, PaymentUpdate, PaymentResponse, MessageResponse, @@ -121,7 +122,7 @@ async def update_payment( # If marking as completed, set payment_date if not already set if update_data.get("status") == PaymentStatus.COMPLETED and not payment.payment_date: - update_data["payment_date"] = datetime.utcnow() + update_data["payment_date"] = utc_now() for field, value in update_data.items(): setattr(payment, field, value) @@ -182,7 +183,7 @@ async def process_square_payment( ) # Create a reference ID for tracking - reference_id = f"user_{current_user.id}_tier_{tier.id}_{datetime.utcnow().timestamp()}" + reference_id = f"user_{current_user.id}_tier_{tier.id}_{unix_ms_utc(utc_now())}" # Process payment with Square square_result = await square_service.create_payment( @@ -204,7 +205,7 @@ async def process_square_payment( # Payment succeeded - create membership and payment records in a transaction try: # Calculate membership dates - start_date = datetime.utcnow().date() + start_date = utc_now().date() end_date = start_date + relativedelta(years=1) # Create membership with ACTIVE status @@ -226,7 +227,7 @@ async def process_square_payment( payment_method=PaymentMethod.SQUARE, status=PaymentStatus.COMPLETED, transaction_id=square_result.get('payment_id'), - payment_date=datetime.utcnow(), + payment_date=utc_now(), notes=payment_request.note ) db.add(payment) @@ -389,7 +390,7 @@ async def record_manual_payment( payment_method=payment_data.payment_method, notes=payment_data.notes, status=PaymentStatus.COMPLETED, - payment_date=datetime.utcnow() + payment_date=utc_now() ) db.add(payment) diff --git a/backend/app/api/v1/users.py b/backend/app/api/v1/users.py index f9c9da8..f843026 100644 --- a/backend/app/api/v1/users.py +++ b/backend/app/api/v1/users.py @@ -7,6 +7,7 @@ from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.orm import Session from ...core.database import get_db +from ...core.datetime import utc_now from ...models.models import ProfileQuestion, User, UserProfileAnswer, UserRole, PasswordResetToken from ...schemas import ( MessageResponse, @@ -691,11 +692,11 @@ async def send_user_password_reset( db.query(PasswordResetToken).filter( PasswordResetToken.user_id == user.id, PasswordResetToken.used == False, - PasswordResetToken.expires_at > datetime.utcnow() + PasswordResetToken.expires_at > utc_now() ).update({"used": True}) reset_token = str(uuid.uuid4()) - expires_at = datetime.utcnow() + timedelta(hours=1) + expires_at = utc_now() + timedelta(hours=1) db_token = PasswordResetToken( user_id=user.id, diff --git a/backend/app/core/security.py b/backend/app/core/security.py index 6b0f28a..79112a7 100644 --- a/backend/app/core/security.py +++ b/backend/app/core/security.py @@ -1,10 +1,14 @@ +import hashlib +import hmac from datetime import datetime, timedelta from typing import Optional, Union, Any from jose import JWTError, jwt from passlib.context import CryptContext from .config import settings +from .datetime import utc_now pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") +MACHINE_TOKEN_PREFIX = "sha256$" def create_access_token( @@ -12,9 +16,9 @@ def create_access_token( ) -> str: """Create JWT access token""" if expires_delta: - expire = datetime.utcnow() + expires_delta + expire = utc_now() + expires_delta else: - expire = datetime.utcnow() + timedelta( + expire = utc_now() + timedelta( minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES ) @@ -33,6 +37,26 @@ def get_password_hash(password: str) -> str: return pwd_context.hash(password) +def get_machine_token_hash(token: str) -> str: + """Hash a machine token for fast constant-time verification.""" + digest = hashlib.sha256(token.encode("utf-8")).hexdigest() + return f"{MACHINE_TOKEN_PREFIX}{digest}" + + +def verify_machine_token(token: str, stored_hash: str) -> bool: + """Verify a machine token, supporting legacy bcrypt hashes during migration.""" + if not stored_hash: + return False + if stored_hash.startswith(MACHINE_TOKEN_PREFIX): + expected_hash = get_machine_token_hash(token) + return hmac.compare_digest(expected_hash, stored_hash) + return verify_password(token, stored_hash) + + +def is_machine_token_hash(stored_hash: str | None) -> bool: + return bool(stored_hash and stored_hash.startswith(MACHINE_TOKEN_PREFIX)) + + def decode_token(token: str) -> Optional[str]: """Decode JWT token and return subject""" try: diff --git a/backend/app/main.py b/backend/app/main.py index 41f9de4..8cbb5df 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,13 +1,30 @@ +import asyncio +import time from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware +from fastapi import Request from contextlib import asynccontextmanager from .core.config import settings from .api.v1 import api_router -from .core.database import get_db +from .core.database import SessionLocal, get_db from .core.init_db import init_default_data +from .services.attendance_service import close_stale_attendance_sessions from sqlalchemy.orm import Session +async def close_stale_attendance_loop(): + """Periodically close forgotten RFID check-ins after midnight.""" + while True: + await asyncio.sleep(3600) + db = SessionLocal() + try: + close_stale_attendance_sessions(db) + except Exception as exc: + print(f"Failed to close stale attendance sessions: {exc}") + finally: + db.close() + + @asynccontextmanager async def lifespan(app: FastAPI): """Handle startup and shutdown events""" @@ -15,13 +32,20 @@ async def lifespan(app: FastAPI): db: Session = next(get_db()) try: init_default_data(db) + close_stale_attendance_sessions(db) finally: db.close() + + attendance_task = asyncio.create_task(close_stale_attendance_loop()) yield # Shutdown (if needed) - pass + attendance_task.cancel() + try: + await attendance_task + except asyncio.CancelledError: + pass app = FastAPI( @@ -40,6 +64,16 @@ app.add_middleware( allow_headers=["*"], ) + +@app.middleware("http") +async def add_request_timing_headers(request: Request, call_next): + started_at = time.perf_counter() + response = await call_next(request) + elapsed_ms = (time.perf_counter() - started_at) * 1000 + response.headers["X-Process-Time-Ms"] = f"{elapsed_ms:.1f}" + response.headers["Server-Timing"] = f"app;dur={elapsed_ms:.1f}" + return response + # Include API router app.include_router(api_router, prefix=settings.API_V1_PREFIX) diff --git a/backend/app/models/models.py b/backend/app/models/models.py index 6d89c9f..b0d1930 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -3,9 +3,9 @@ from sqlalchemy import ( Float, Text, ForeignKey, Date, UniqueConstraint ) from sqlalchemy.orm import relationship -from datetime import datetime import enum from ..core.database import Base +from ..core.datetime import utc_now class UserRole(str, enum.Enum): @@ -49,6 +49,37 @@ class RSVPStatus(str, enum.Enum): MAYBE = "maybe" +class EspReaderType(str, enum.Enum): + CHECKIN_CHECKOUT = "checkin_checkout" + + +class EspReaderProvisioningStatus(str, enum.Enum): + PENDING = "pending" + APPROVED = "approved" + PROVISIONED = "provisioned" + REJECTED = "rejected" + + +class EspTapAction(str, enum.Enum): + CHECK_IN = "check_in" + CHECK_OUT = "check_out" + DENIED = "denied" + UNKNOWN = "unknown" + + +class AttendanceCheckoutSource(str, enum.Enum): + USER = "user" + SYSTEM = "system" + + +class RfidWriteJobStatus(str, enum.Enum): + PENDING = "pending" + CLAIMED = "claimed" + COMPLETED = "completed" + FAILED = "failed" + CANCELLED = "cancelled" + + class User(Base): __tablename__ = "users" @@ -62,8 +93,8 @@ class User(Base): role = Column(SQLEnum(UserRole, values_callable=lambda x: [e.value for e in x]), default=UserRole.MEMBER, nullable=False) volunteer_level = Column(String(50), nullable=True) is_active = Column(Boolean, default=True, nullable=False) - created_at = Column(DateTime, default=datetime.utcnow, nullable=False) - updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + created_at = Column(DateTime, default=utc_now, nullable=False) + updated_at = Column(DateTime, default=utc_now, onupdate=utc_now, nullable=False) last_login = Column(DateTime, nullable=True) # Relationships @@ -78,6 +109,8 @@ class User(Base): cascade="all, delete-orphan", foreign_keys="UserProfileAnswer.user_id" ) + rfid_cards = relationship("RfidCard", back_populates="user") + attendance_sessions = relationship("AttendanceSession", back_populates="user") class ProfileQuestion(Base): @@ -96,8 +129,8 @@ class ProfileQuestion(Base): display_order = Column(Integer, default=0, nullable=False) depends_on_question_id = Column(Integer, ForeignKey("profile_questions.id"), nullable=True) depends_on_value = Column(String(255), nullable=True) - created_at = Column(DateTime, default=datetime.utcnow, nullable=False) - updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + created_at = Column(DateTime, default=utc_now, nullable=False) + updated_at = Column(DateTime, default=utc_now, onupdate=utc_now, nullable=False) depends_on_question = relationship("ProfileQuestion", remote_side=[id], backref="dependent_questions") answers = relationship("UserProfileAnswer", back_populates="question", cascade="all, delete-orphan") @@ -114,8 +147,8 @@ class UserProfileAnswer(Base): question_id = Column(Integer, ForeignKey("profile_questions.id"), nullable=False, index=True) value_text = Column(Text, nullable=True) updated_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=True) - created_at = Column(DateTime, default=datetime.utcnow, nullable=False) - updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + created_at = Column(DateTime, default=utc_now, nullable=False) + updated_at = Column(DateTime, default=utc_now, onupdate=utc_now, nullable=False) user = relationship("User", foreign_keys=[user_id], back_populates="profile_answers") question = relationship("ProfileQuestion", back_populates="answers") @@ -131,8 +164,8 @@ class MembershipTier(Base): annual_fee = Column(Float, nullable=False) benefits = Column(Text, nullable=True) is_active = Column(Boolean, default=True, nullable=False) - created_at = Column(DateTime, default=datetime.utcnow, nullable=False) - updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + created_at = Column(DateTime, default=utc_now, nullable=False) + updated_at = Column(DateTime, default=utc_now, onupdate=utc_now, nullable=False) # Relationships memberships = relationship("Membership", back_populates="tier") @@ -148,8 +181,8 @@ class Membership(Base): start_date = Column(Date, nullable=False) end_date = Column(Date, nullable=False) auto_renew = Column(Boolean, default=False, nullable=False) - created_at = Column(DateTime, default=datetime.utcnow, nullable=False) - updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + created_at = Column(DateTime, default=utc_now, nullable=False) + updated_at = Column(DateTime, default=utc_now, onupdate=utc_now, nullable=False) # Relationships user = relationship("User", back_populates="memberships") @@ -169,8 +202,8 @@ class Payment(Base): transaction_id = Column(String(255), nullable=True) payment_date = Column(DateTime, nullable=True) notes = Column(Text, nullable=True) - created_at = Column(DateTime, default=datetime.utcnow, nullable=False) - updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + created_at = Column(DateTime, default=utc_now, nullable=False) + updated_at = Column(DateTime, default=utc_now, onupdate=utc_now, nullable=False) # Relationships user = relationship("User", back_populates="payments") @@ -189,8 +222,8 @@ class Event(Base): max_attendees = Column(Integer, nullable=True) status = Column(SQLEnum(EventStatus, values_callable=lambda x: [e.value for e in x]), default=EventStatus.DRAFT, nullable=False) created_by = Column(Integer, ForeignKey("users.id"), nullable=False) - created_at = Column(DateTime, default=datetime.utcnow, nullable=False) - updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + created_at = Column(DateTime, default=utc_now, nullable=False) + updated_at = Column(DateTime, default=utc_now, onupdate=utc_now, nullable=False) # Relationships rsvps = relationship("EventRSVP", back_populates="event", cascade="all, delete-orphan") @@ -205,14 +238,123 @@ class EventRSVP(Base): status = Column(SQLEnum(RSVPStatus, values_callable=lambda x: [e.value for e in x]), default=RSVPStatus.PENDING, nullable=False) attended = Column(Boolean, default=False, nullable=False) notes = Column(Text, nullable=True) - created_at = Column(DateTime, default=datetime.utcnow, nullable=False) - updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + created_at = Column(DateTime, default=utc_now, nullable=False) + updated_at = Column(DateTime, default=utc_now, onupdate=utc_now, nullable=False) # Relationships event = relationship("Event", back_populates="rsvps") user = relationship("User", back_populates="event_rsvps") +class EspReader(Base): + __tablename__ = "esp_readers" + + id = Column(Integer, primary_key=True, index=True) + device_id = Column(String(100), unique=True, index=True, nullable=False) + name = Column(String(255), nullable=False) + location = Column(String(255), nullable=True) + reader_type = Column(SQLEnum(EspReaderType, values_callable=lambda x: [e.value for e in x]), default=EspReaderType.CHECKIN_CHECKOUT, nullable=False) + provisioning_status = Column(SQLEnum(EspReaderProvisioningStatus, values_callable=lambda x: [e.value for e in x]), default=EspReaderProvisioningStatus.PENDING, nullable=False) + api_key_hash = Column(String(255), nullable=True) + pending_api_key = Column(String(255), nullable=True) + registration_token_hash = Column(String(255), nullable=True) + is_active = Column(Boolean, default=True, nullable=False) + can_write_cards = Column(Boolean, default=False, nullable=False) + firmware_version = Column(String(100), nullable=True) + last_seen_at = Column(DateTime, nullable=True) + approved_at = Column(DateTime, nullable=True) + provisioned_at = Column(DateTime, nullable=True) + notes = Column(Text, nullable=True) + created_at = Column(DateTime, default=utc_now, nullable=False) + updated_at = Column(DateTime, default=utc_now, onupdate=utc_now, nullable=False) + + taps = relationship("RfidTap", back_populates="reader") + attendance_sessions = relationship("AttendanceSession", back_populates="reader") + write_jobs = relationship("RfidCardWriteJob", back_populates="reader") + + +class RfidCard(Base): + __tablename__ = "rfid_cards" + + id = Column(Integer, primary_key=True, index=True) + uid = Column(String(100), unique=True, index=True, nullable=False) + user_id = Column(Integer, ForeignKey("users.id"), nullable=True, index=True) + label = Column(String(255), nullable=True) + is_active = Column(Boolean, default=True, nullable=False) + created_at = Column(DateTime, default=utc_now, nullable=False) + updated_at = Column(DateTime, default=utc_now, onupdate=utc_now, nullable=False) + + user = relationship("User", back_populates="rfid_cards") + taps = relationship("RfidTap", back_populates="card") + + +class RfidCardWriteJob(Base): + __tablename__ = "rfid_card_write_jobs" + + id = Column(Integer, primary_key=True, index=True) + reader_id = Column(Integer, ForeignKey("esp_readers.id"), nullable=False, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True) + card_id = Column(Integer, ForeignKey("rfid_cards.id"), nullable=True, index=True) + label = Column(String(255), nullable=False) + status = Column(SQLEnum(RfidWriteJobStatus, values_callable=lambda x: [e.value for e in x]), default=RfidWriteJobStatus.PENDING, nullable=False, index=True) + requested_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + card_uid = Column(String(100), nullable=True, index=True) + write_payload = Column(Text, nullable=True) + claimed_at = Column(DateTime, nullable=True) + completed_at = Column(DateTime, nullable=True) + error_message = Column(String(500), nullable=True) + created_at = Column(DateTime, default=utc_now, nullable=False) + updated_at = Column(DateTime, default=utc_now, onupdate=utc_now, nullable=False) + + reader = relationship("EspReader", back_populates="write_jobs") + user = relationship("User", foreign_keys=[user_id]) + requested_by_user = relationship("User", foreign_keys=[requested_by_user_id]) + card = relationship("RfidCard") + + +class RfidTap(Base): + __tablename__ = "rfid_taps" + + id = Column(Integer, primary_key=True, index=True) + reader_id = Column(Integer, ForeignKey("esp_readers.id"), nullable=False, index=True) + card_id = Column(Integer, ForeignKey("rfid_cards.id"), nullable=True, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=True, index=True) + card_uid = Column(String(100), nullable=False, index=True) + action = Column(SQLEnum(EspTapAction, values_callable=lambda x: [e.value for e in x]), default=EspTapAction.UNKNOWN, nullable=False) + accepted = Column(Boolean, default=False, nullable=False) + message = Column(String(255), nullable=True) + raw_payload = Column(Text, nullable=True) + tapped_at = Column(DateTime, default=utc_now, nullable=False, index=True) + created_at = Column(DateTime, default=utc_now, nullable=False) + + reader = relationship("EspReader", back_populates="taps") + card = relationship("RfidCard", back_populates="taps") + user = relationship("User") + + +class AttendanceSession(Base): + __tablename__ = "attendance_sessions" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True) + reader_id = Column(Integer, ForeignKey("esp_readers.id"), nullable=False, index=True) + check_in_tap_id = Column(Integer, ForeignKey("rfid_taps.id"), nullable=False) + check_out_tap_id = Column(Integer, ForeignKey("rfid_taps.id"), nullable=True) + checked_in_at = Column(DateTime, nullable=False, index=True) + checked_out_at = Column(DateTime, nullable=True, index=True) + checkout_source = Column(SQLEnum(AttendanceCheckoutSource, values_callable=lambda x: [e.value for e in x]), nullable=True) + system_flag_reason = Column(String(255), nullable=True) + duration_seconds = Column(Integer, nullable=True) + is_open = Column(Boolean, default=True, nullable=False, index=True) + created_at = Column(DateTime, default=utc_now, nullable=False) + updated_at = Column(DateTime, default=utc_now, onupdate=utc_now, nullable=False) + + user = relationship("User", back_populates="attendance_sessions") + reader = relationship("EspReader", back_populates="attendance_sessions") + check_in_tap = relationship("RfidTap", foreign_keys=[check_in_tap_id]) + check_out_tap = relationship("RfidTap", foreign_keys=[check_out_tap_id]) + + class VolunteerRole(Base): __tablename__ = "volunteer_roles" @@ -220,8 +362,8 @@ class VolunteerRole(Base): name = Column(String(100), nullable=False) description = Column(Text, nullable=True) is_active = Column(Boolean, default=True, nullable=False) - created_at = Column(DateTime, default=datetime.utcnow, nullable=False) - updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + created_at = Column(DateTime, default=utc_now, nullable=False) + updated_at = Column(DateTime, default=utc_now, onupdate=utc_now, nullable=False) # Relationships assignments = relationship("VolunteerAssignment", back_populates="role", cascade="all, delete-orphan") @@ -236,8 +378,8 @@ class VolunteerAssignment(Base): assigned_date = Column(Date, nullable=False) is_active = Column(Boolean, default=True, nullable=False) notes = Column(Text, nullable=True) - created_at = Column(DateTime, default=datetime.utcnow, nullable=False) - updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + created_at = Column(DateTime, default=utc_now, nullable=False) + updated_at = Column(DateTime, default=utc_now, onupdate=utc_now, nullable=False) # Relationships user = relationship("User", back_populates="volunteer_assignments") @@ -256,8 +398,8 @@ class VolunteerSchedule(Base): location = Column(String(255), nullable=True) notes = Column(Text, nullable=True) completed = Column(Boolean, default=False, nullable=False) - created_at = Column(DateTime, default=datetime.utcnow, nullable=False) - updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + created_at = Column(DateTime, default=utc_now, nullable=False) + updated_at = Column(DateTime, default=utc_now, onupdate=utc_now, nullable=False) # Relationships assignment = relationship("VolunteerAssignment", back_populates="schedules") @@ -275,8 +417,8 @@ class Certificate(Base): certificate_number = Column(String(100), nullable=True) file_path = Column(String(500), nullable=True) notes = Column(Text, nullable=True) - created_at = Column(DateTime, default=datetime.utcnow, nullable=False) - updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + created_at = Column(DateTime, default=utc_now, nullable=False) + updated_at = Column(DateTime, default=utc_now, onupdate=utc_now, nullable=False) # Relationships user = relationship("User", back_populates="certificates") @@ -294,8 +436,8 @@ class File(Base): min_tier_id = Column(Integer, ForeignKey("membership_tiers.id"), nullable=True) description = Column(Text, nullable=True) uploaded_by = Column(Integer, ForeignKey("users.id"), nullable=False) - created_at = Column(DateTime, default=datetime.utcnow, nullable=False) - updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + created_at = Column(DateTime, default=utc_now, nullable=False) + updated_at = Column(DateTime, default=utc_now, onupdate=utc_now, nullable=False) class Notification(Base): @@ -308,7 +450,7 @@ class Notification(Base): email_sent = Column(Boolean, default=False, nullable=False) sent_at = Column(DateTime, nullable=True) error_message = Column(Text, nullable=True) - created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + created_at = Column(DateTime, default=utc_now, nullable=False) class PasswordResetToken(Base): @@ -319,7 +461,7 @@ class PasswordResetToken(Base): token = Column(String(255), unique=True, nullable=False, index=True) expires_at = Column(DateTime, nullable=False) used = Column(Boolean, default=False, nullable=False) - created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + created_at = Column(DateTime, default=utc_now, nullable=False) # Relationships user = relationship("User", backref="password_reset_tokens") @@ -336,8 +478,8 @@ class EmailTemplate(Base): text_body = Column(Text, nullable=True) variables = Column(Text, nullable=True) # JSON string of available variables is_active = Column(Boolean, default=True, nullable=False) - created_at = Column(DateTime, default=datetime.utcnow, nullable=False) - updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + created_at = Column(DateTime, default=utc_now, nullable=False) + updated_at = Column(DateTime, default=utc_now, onupdate=utc_now, nullable=False) class BounceType(str, enum.Enum): @@ -357,5 +499,5 @@ class EmailBounce(Base): smtp2go_message_id = Column(String(255), nullable=True, index=True) bounce_date = Column(DateTime, nullable=False) is_active = Column(Boolean, default=True, nullable=False) - created_at = Column(DateTime, default=datetime.utcnow, nullable=False) - updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + created_at = Column(DateTime, default=utc_now, nullable=False) + updated_at = Column(DateTime, default=utc_now, onupdate=utc_now, nullable=False) diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index 911e19a..eff8186 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -44,6 +44,29 @@ from .schemas import ( ProfileQuestionForUser, ProfileAnswerUpdate, ProfileAnswersUpdateRequest, + EspReaderCreate, + EspReaderUpdate, + EspReaderResponse, + EspReaderCreateResponse, + EspReaderRegistrationRequest, + EspReaderRegistrationResponse, + EspReaderProvisioningResponse, + RfidCardCreate, + RfidCardUpdate, + RfidCardResponse, + RfidTapRequest, + RfidTapResponse, + RfidWriteJobCreate, + RfidWriteJobCompleteRequest, + RfidWriteJobResponse, + EspTimeResponse, + EspHeartbeatRequest, + EspHeartbeatResponse, + EspDashboardLoginResponse, + RfidTapAdminResponse, + AttendanceSessionResponse, + StaleSessionCloseRequest, + StaleSessionCloseResponse, ) __all__ = [ @@ -92,4 +115,27 @@ __all__ = [ "ProfileQuestionForUser", "ProfileAnswerUpdate", "ProfileAnswersUpdateRequest", + "EspReaderCreate", + "EspReaderUpdate", + "EspReaderResponse", + "EspReaderCreateResponse", + "EspReaderRegistrationRequest", + "EspReaderRegistrationResponse", + "EspReaderProvisioningResponse", + "RfidCardCreate", + "RfidCardUpdate", + "RfidCardResponse", + "RfidTapRequest", + "RfidTapResponse", + "RfidWriteJobCreate", + "RfidWriteJobCompleteRequest", + "RfidWriteJobResponse", + "EspTimeResponse", + "EspHeartbeatRequest", + "EspHeartbeatResponse", + "EspDashboardLoginResponse", + "RfidTapAdminResponse", + "AttendanceSessionResponse", + "StaleSessionCloseRequest", + "StaleSessionCloseResponse", ] diff --git a/backend/app/schemas/schemas.py b/backend/app/schemas/schemas.py index 9fe5a39..2eacb95 100644 --- a/backend/app/schemas/schemas.py +++ b/backend/app/schemas/schemas.py @@ -1,11 +1,43 @@ -from pydantic import BaseModel, EmailStr, Field, ConfigDict +from pydantic import BaseModel, EmailStr, Field, ConfigDict, field_serializer, field_validator from typing import Optional, Literal, Any from datetime import datetime, date -from ..models.models import UserRole, MembershipStatus, PaymentStatus, PaymentMethod +from ..core.datetime import to_utc_naive, to_zulu_iso +from ..models.models import ( + UserRole, + MembershipStatus, + PaymentStatus, + PaymentMethod, + EspReaderProvisioningStatus, + EspReaderType, + EspTapAction, + RfidWriteJobStatus, +) + + +class UTCBaseModel(BaseModel): + @field_validator("*", mode="before", check_fields=False) + @classmethod + def normalize_datetime_inputs(cls, value: Any) -> Any: + if isinstance(value, datetime): + return to_utc_naive(value) + return value + + @field_validator("*", mode="after", check_fields=False) + @classmethod + def normalize_parsed_datetimes(cls, value: Any) -> Any: + if isinstance(value, datetime): + return to_utc_naive(value) + return value + + @field_serializer("*", when_used="json", check_fields=False) + def serialize_datetime_outputs(self, value: Any) -> Any: + if isinstance(value, datetime): + return to_zulu_iso(value) + return value # User Schemas -class UserBase(BaseModel): +class UserBase(UTCBaseModel): email: EmailStr first_name: str = Field(..., min_length=1, max_length=100) last_name: str = Field(..., min_length=1, max_length=100) @@ -17,7 +49,7 @@ class UserCreate(UserBase): password: str = Field(..., min_length=8) -class UserUpdate(BaseModel): +class UserUpdate(UTCBaseModel): email: Optional[EmailStr] = None first_name: Optional[str] = Field(None, min_length=1, max_length=100) last_name: Optional[str] = Field(None, min_length=1, max_length=100) @@ -43,37 +75,37 @@ class UserInDB(UserResponse): # Authentication Schemas -class Token(BaseModel): +class Token(UTCBaseModel): access_token: str token_type: str = "bearer" -class TokenData(BaseModel): +class TokenData(UTCBaseModel): user_id: Optional[int] = None -class LoginRequest(BaseModel): +class LoginRequest(UTCBaseModel): email: EmailStr password: str # Password Reset Schemas -class ForgotPasswordRequest(BaseModel): +class ForgotPasswordRequest(UTCBaseModel): email: EmailStr -class ResetPasswordRequest(BaseModel): +class ResetPasswordRequest(UTCBaseModel): token: str = Field(..., min_length=1) new_password: str = Field(..., min_length=8) -class ChangePasswordRequest(BaseModel): +class ChangePasswordRequest(UTCBaseModel): current_password: str = Field(..., min_length=1) new_password: str = Field(..., min_length=8) # Membership Tier Schemas -class MembershipTierBase(BaseModel): +class MembershipTierBase(UTCBaseModel): name: str = Field(..., min_length=1, max_length=100) description: Optional[str] = None annual_fee: float = Field(..., ge=0) @@ -84,7 +116,7 @@ class MembershipTierCreate(MembershipTierBase): pass -class MembershipTierUpdate(BaseModel): +class MembershipTierUpdate(UTCBaseModel): name: Optional[str] = Field(None, min_length=1, max_length=100) description: Optional[str] = None annual_fee: Optional[float] = Field(None, ge=0) @@ -101,7 +133,7 @@ class MembershipTierResponse(MembershipTierBase): # Membership Schemas -class MembershipBase(BaseModel): +class MembershipBase(UTCBaseModel): tier_id: int auto_renew: bool = False @@ -111,14 +143,14 @@ class MembershipCreate(MembershipBase): end_date: date -class MembershipUpdate(BaseModel): +class MembershipUpdate(UTCBaseModel): tier_id: Optional[int] = None status: Optional[MembershipStatus] = None end_date: Optional[date] = None auto_renew: Optional[bool] = None -class MembershipResponse(BaseModel): +class MembershipResponse(UTCBaseModel): model_config = ConfigDict(from_attributes=True) id: int @@ -133,7 +165,7 @@ class MembershipResponse(BaseModel): # Payment Schemas -class PaymentBase(BaseModel): +class PaymentBase(UTCBaseModel): amount: float = Field(..., gt=0) payment_method: PaymentMethod notes: Optional[str] = None @@ -143,14 +175,14 @@ class PaymentCreate(PaymentBase): membership_id: Optional[int] = None -class PaymentUpdate(BaseModel): +class PaymentUpdate(UTCBaseModel): status: Optional[PaymentStatus] = None transaction_id: Optional[str] = None payment_date: Optional[datetime] = None notes: Optional[str] = None -class PaymentResponse(BaseModel): +class PaymentResponse(UTCBaseModel): model_config = ConfigDict(from_attributes=True) id: int @@ -166,7 +198,7 @@ class PaymentResponse(BaseModel): # Square Payment Schemas -class SquarePaymentRequest(BaseModel): +class SquarePaymentRequest(UTCBaseModel): """Request schema for Square payment processing""" source_id: str = Field(..., description="Payment source ID from Square Web Payments SDK") tier_id: int = Field(..., description="Membership tier ID to create membership for") @@ -176,7 +208,7 @@ class SquarePaymentRequest(BaseModel): billing_details: Optional[dict] = Field(None, description="Billing address and cardholder name for AVS") -class SquarePaymentResponse(BaseModel): +class SquarePaymentResponse(UTCBaseModel): """Response schema for Square payment""" success: bool payment_id: Optional[str] = None @@ -189,7 +221,7 @@ class SquarePaymentResponse(BaseModel): membership_id: Optional[int] = Field(None, description="Created membership ID") -class SquareRefundRequest(BaseModel): +class SquareRefundRequest(UTCBaseModel): """Request schema for Square payment refund""" payment_id: int = Field(..., description="Database payment ID") amount: Optional[float] = Field(None, gt=0, description="Amount to refund (None for full refund)") @@ -197,13 +229,13 @@ class SquareRefundRequest(BaseModel): # Message Response -class MessageResponse(BaseModel): +class MessageResponse(UTCBaseModel): message: str detail: Optional[str] = None # Email Template Schemas -class EmailTemplateBase(BaseModel): +class EmailTemplateBase(UTCBaseModel): template_key: str name: str subject: str @@ -216,7 +248,7 @@ class EmailTemplateCreate(EmailTemplateBase): pass -class EmailTemplateUpdate(BaseModel): +class EmailTemplateUpdate(UTCBaseModel): name: Optional[str] = None subject: Optional[str] = None html_body: Optional[str] = None @@ -235,7 +267,7 @@ class EmailTemplateResponse(EmailTemplateBase): # Event Schemas -class EventBase(BaseModel): +class EventBase(UTCBaseModel): title: str = Field(..., min_length=1, max_length=255) description: Optional[str] = None event_date: datetime @@ -248,7 +280,7 @@ class EventCreate(EventBase): pass -class EventUpdate(BaseModel): +class EventUpdate(UTCBaseModel): title: Optional[str] = Field(None, min_length=1, max_length=255) description: Optional[str] = None event_date: Optional[datetime] = None @@ -269,7 +301,7 @@ class EventResponse(EventBase): # Event RSVP Schemas -class EventRSVPBase(BaseModel): +class EventRSVPBase(UTCBaseModel): status: str = Field(..., pattern="^(pending|attending|not_attending|maybe)$") notes: Optional[str] = None @@ -293,12 +325,12 @@ class EventRSVPResponse(EventRSVPBase): ProfileQuestionInputType = Literal["text", "number", "boolean", "date", "select"] -class QuestionOption(BaseModel): +class QuestionOption(UTCBaseModel): label: str = Field(..., min_length=1, max_length=100) value: str = Field(..., min_length=1, max_length=100) -class ProfileQuestionBase(BaseModel): +class ProfileQuestionBase(UTCBaseModel): key: str = Field(..., min_length=2, max_length=100, pattern=r"^[a-z0-9_]+$") label: str = Field(..., min_length=2, max_length=255) help_text: Optional[str] = None @@ -317,7 +349,7 @@ class ProfileQuestionCreate(ProfileQuestionBase): pass -class ProfileQuestionUpdate(BaseModel): +class ProfileQuestionUpdate(UTCBaseModel): key: Optional[str] = Field(None, min_length=2, max_length=100, pattern=r"^[a-z0-9_]+$") label: Optional[str] = Field(None, min_length=2, max_length=255) help_text: Optional[str] = None @@ -332,7 +364,7 @@ class ProfileQuestionUpdate(BaseModel): depends_on_value: Optional[str] = Field(None, max_length=255) -class ProfileQuestionResponse(BaseModel): +class ProfileQuestionResponse(UTCBaseModel): model_config = ConfigDict(from_attributes=True) id: int @@ -357,10 +389,228 @@ class ProfileQuestionForUser(ProfileQuestionResponse): can_edit: bool = True -class ProfileAnswerUpdate(BaseModel): +class ProfileAnswerUpdate(UTCBaseModel): question_id: int value: Optional[Any] = None -class ProfileAnswersUpdateRequest(BaseModel): +class ProfileAnswersUpdateRequest(UTCBaseModel): answers: list[ProfileAnswerUpdate] + + +# ESP RFID Reader Schemas +class EspReaderBase(UTCBaseModel): + device_id: str = Field(..., min_length=2, max_length=100, pattern=r"^[A-Za-z0-9_.:-]+$") + name: str = Field(..., min_length=1, max_length=255) + location: Optional[str] = Field(None, max_length=255) + reader_type: EspReaderType = EspReaderType.CHECKIN_CHECKOUT + notes: Optional[str] = None + is_active: bool = True + can_write_cards: bool = False + firmware_version: Optional[str] = Field(None, max_length=100) + + +class EspReaderCreate(EspReaderBase): + api_key: Optional[str] = Field(None, min_length=16, max_length=255) + + +class EspReaderUpdate(UTCBaseModel): + name: Optional[str] = Field(None, min_length=1, max_length=255) + location: Optional[str] = Field(None, max_length=255) + reader_type: Optional[EspReaderType] = None + notes: Optional[str] = None + is_active: Optional[bool] = None + can_write_cards: Optional[bool] = None + rotate_api_key: bool = False + + +class EspReaderResponse(EspReaderBase): + model_config = ConfigDict(from_attributes=True) + + id: int + provisioning_status: EspReaderProvisioningStatus + last_seen_at: Optional[datetime] = None + approved_at: Optional[datetime] = None + provisioned_at: Optional[datetime] = None + created_at: datetime + updated_at: datetime + + +class EspReaderCreateResponse(EspReaderResponse): + api_key: str + + +class EspReaderRegistrationRequest(UTCBaseModel): + device_id: str = Field(..., min_length=2, max_length=100, pattern=r"^[A-Za-z0-9_.:-]+$") + name: str = Field(..., min_length=1, max_length=255) + location: Optional[str] = Field(None, max_length=255) + reader_type: EspReaderType = EspReaderType.CHECKIN_CHECKOUT + can_write_cards: bool = False + firmware_version: Optional[str] = Field(None, max_length=100) + notes: Optional[str] = None + + +class EspReaderRegistrationResponse(UTCBaseModel): + device_id: str + provisioning_status: EspReaderProvisioningStatus + registration_token: str + message: str + poll_interval_seconds: int = 5 + + +class EspReaderProvisioningResponse(UTCBaseModel): + device_id: str + provisioning_status: EspReaderProvisioningStatus + message: str + api_key: Optional[str] = None + apiKey: Optional[str] = None + poll_interval_seconds: int = 5 + + +class RfidCardBase(UTCBaseModel): + uid: str = Field(..., min_length=2, max_length=100) + user_id: Optional[int] = None + label: Optional[str] = Field(None, max_length=255) + is_active: bool = True + + +class RfidCardCreate(RfidCardBase): + pass + + +class RfidCardUpdate(UTCBaseModel): + user_id: Optional[int] = None + label: Optional[str] = Field(None, max_length=255) + is_active: Optional[bool] = None + + +class RfidCardResponse(RfidCardBase): + model_config = ConfigDict(from_attributes=True) + + id: int + created_at: datetime + updated_at: datetime + + +class RfidTapRequest(UTCBaseModel): + card_uid: str = Field(..., min_length=2, max_length=100) + tapped_at: Optional[datetime] = None + reader_type: Optional[EspReaderType] = None + + +class RfidTapResponse(UTCBaseModel): + accepted: bool + action: EspTapAction + message: str + server_time_utc: datetime + tap_id: int + session_id: Optional[int] = None + user_id: Optional[int] = None + user_name: Optional[str] = None + checked_in_at: Optional[datetime] = None + checked_out_at: Optional[datetime] = None + duration_seconds: Optional[int] = None + + +class RfidWriteJobCreate(UTCBaseModel): + reader_id: int + user_id: int + label: str = Field(..., min_length=1, max_length=255) + + +class RfidWriteJobCompleteRequest(UTCBaseModel): + card_uid: Optional[str] = Field(None, min_length=2, max_length=100) + success: bool + error_message: Optional[str] = Field(None, max_length=500) + + +class RfidWriteJobResponse(UTCBaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + reader_id: int + user_id: int + card_id: Optional[int] = None + label: str + status: RfidWriteJobStatus + requested_by_user_id: int + card_uid: Optional[str] = None + write_payload: Optional[str] = None + claimed_at: Optional[datetime] = None + completed_at: Optional[datetime] = None + error_message: Optional[str] = None + created_at: datetime + updated_at: datetime + + +class EspTimeResponse(UTCBaseModel): + server_time_utc: datetime + unix_ms: int + poll_interval_seconds: int = 3 + + +class EspHeartbeatRequest(UTCBaseModel): + mode: str = Field(..., max_length=50) + message: Optional[str] = Field(None, max_length=255) + wifi_rssi: Optional[int] = None + free_heap: Optional[int] = None + firmware_version: Optional[str] = Field(None, max_length=100) + active_write_job_id: Optional[int] = None + + +class EspHeartbeatResponse(UTCBaseModel): + ok: bool + server_time_utc: datetime + unix_ms: int + heartbeat_interval_seconds: int = 10 + time_poll_interval_seconds: int = 3 + write_job_poll_interval_seconds: int = 3 + + +class EspDashboardLoginResponse(UTCBaseModel): + valid: bool + user_id: Optional[int] = None + role: Optional[UserRole] = None + user_name: Optional[str] = None + + +class RfidTapAdminResponse(UTCBaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + reader_id: int + card_id: Optional[int] = None + user_id: Optional[int] = None + card_uid: str + action: EspTapAction + accepted: bool + message: Optional[str] = None + tapped_at: datetime + created_at: datetime + + +class AttendanceSessionResponse(UTCBaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + user_id: int + reader_id: int + check_in_tap_id: int + check_out_tap_id: Optional[int] = None + checked_in_at: datetime + checked_out_at: Optional[datetime] = None + checkout_source: Optional[str] = None + system_flag_reason: Optional[str] = None + duration_seconds: Optional[int] = None + is_open: bool + created_at: datetime + updated_at: datetime + + +class StaleSessionCloseRequest(UTCBaseModel): + cutoff_date: Optional[date] = None + checkout_hour: int = Field(17, ge=0, le=23) + + +class StaleSessionCloseResponse(UTCBaseModel): + closed_count: int diff --git a/backend/app/services/bounce_service.py b/backend/app/services/bounce_service.py index f63dba5..1b90029 100644 --- a/backend/app/services/bounce_service.py +++ b/backend/app/services/bounce_service.py @@ -1,6 +1,7 @@ from typing import List, Optional, Dict, Any -from datetime import datetime +from datetime import datetime, timezone from sqlalchemy.orm import Session +from ..core.datetime import to_utc_naive, utc_now from ..models.models import EmailBounce, BounceType from ..core.database import get_db @@ -38,7 +39,9 @@ class BounceService: db = next(get_db()) if bounce_date is None: - bounce_date = datetime.utcnow() + bounce_date = utc_now() + else: + bounce_date = to_utc_naive(bounce_date) # Check if bounce already exists for this email and type existing_bounce = db.query(EmailBounce).filter( @@ -54,7 +57,7 @@ class BounceService: if smtp2go_message_id: existing_bounce.smtp2go_message_id = smtp2go_message_id existing_bounce.bounce_date = bounce_date - existing_bounce.updated_at = datetime.utcnow() + existing_bounce.updated_at = utc_now() db.commit() db.refresh(existing_bounce) return existing_bounce @@ -130,7 +133,7 @@ class BounceService: bounce = db.query(EmailBounce).filter(EmailBounce.id == bounce_id).first() if bounce: bounce.is_active = False - bounce.updated_at = datetime.utcnow() + bounce.updated_at = utc_now() db.commit() return True return False @@ -189,9 +192,10 @@ class BounceService: try: # SMTP2GO timestamps are typically Unix timestamps if isinstance(timestamp, (int, float)): - bounce_date = datetime.fromtimestamp(timestamp) + bounce_date = datetime.fromtimestamp(timestamp, tz=timezone.utc) elif isinstance(timestamp, str): bounce_date = datetime.fromisoformat(timestamp.replace('Z', '+00:00')) + bounce_date = to_utc_naive(bounce_date) except (ValueError, TypeError): pass @@ -252,18 +256,18 @@ class BounceService: db = next(get_db()) from datetime import timedelta - cutoff_date = datetime.utcnow() - timedelta(days=days_old) + cutoff_date = utc_now() - timedelta(days=days_old) # Only deactivate soft bounces, keep hard bounces and complaints active result = db.query(EmailBounce).filter( EmailBounce.bounce_type == BounceType.SOFT, EmailBounce.is_active == True, EmailBounce.bounce_date < cutoff_date - ).update({'is_active': False, 'updated_at': datetime.utcnow()}) + ).update({'is_active': False, 'updated_at': utc_now()}) db.commit() return result # Create a singleton instance -bounce_service = BounceService() \ No newline at end of file +bounce_service = BounceService() diff --git a/backend/app/services/email_service.py b/backend/app/services/email_service.py index 1907492..776e04a 100644 --- a/backend/app/services/email_service.py +++ b/backend/app/services/email_service.py @@ -2,6 +2,7 @@ import httpx from typing import List, Optional, Dict, Any from datetime import datetime from ..core.database import get_db +from ..core.datetime import utc_now from ..models.models import EmailTemplate from sqlalchemy.orm import Session from ..core.config import settings @@ -147,7 +148,7 @@ class EmailService: "payment_amount": f"£{payment_amount:.2f}", "payment_method": payment_method, "renewal_date": renewal_date, - "payment_date": datetime.now().strftime("%d %B %Y"), + "payment_date": utc_now().strftime("%d %B %Y"), "app_name": settings.APP_NAME } return await self.send_templated_email("membership_activation", to_email, variables, db) diff --git a/frontend/src/App.css b/frontend/src/App.css index 5f58f00..a0d816e 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -3,34 +3,35 @@ @tailwind utilities; :root { - font-family: "IBM Plex Sans", "Segoe UI", "Helvetica Neue", Arial, sans-serif; - line-height: 1.6; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Arial, sans-serif; + line-height: 1.45; font-weight: 400; - color: #263445; - background-color: #eef4f3; - --ops-bg: #eef4f3; - --ops-surface: #ffffff; - --ops-surface-muted: #f5f9fb; - --ops-surface-strong: #e3f0f2; - --ops-border: #c9d8df; - --ops-border-soft: #d7e4ea; - --ops-text: #263445; - --ops-muted: #5b6674; - --ops-subtle: #667386; - --ops-accent: #0b6f8f; - --ops-accent-dark: #084f67; - --ops-accent-soft: #e2f3f8; - --ops-accent-mid: #b7dfea; - --ops-accent-wash: #f0faf9; - --ops-coral: #c94f5c; - --ops-coral-soft: #fdebed; - --ops-danger: #b42336; - --ops-danger-soft: #fde8ec; - --ops-warning: #9a6a00; - --ops-warning-soft: #fff5da; - --ops-success: #176c48; - --ops-success-soft: #e4f6ed; - --ops-radius: 6px; + color: #e6ebf2; + background-color: #131416; + --ops-bg: #131416; + --ops-top: #282b2f; + --ops-surface: #181a1d; + --ops-surface-muted: #202328; + --ops-surface-strong: #111214; + --ops-border: rgba(70, 75, 83, 0.9); + --ops-border-soft: rgba(42, 45, 50, 0.92); + --ops-text: #e6ebf2; + --ops-muted: #8d96a3; + --ops-subtle: #c6ced8; + --ops-accent: #4797ff; + --ops-accent-dark: #006eff; + --ops-accent-soft: rgba(71, 151, 255, 0.12); + --ops-accent-mid: rgba(71, 151, 255, 0.2); + --ops-accent-wash: #11161d; + --ops-coral: #ee6368; + --ops-coral-soft: rgba(238, 99, 104, 0.12); + --ops-danger: #ee6368; + --ops-danger-soft: rgba(92, 31, 33, 0.4); + --ops-warning: #ffb84c; + --ops-warning-soft: rgba(255, 184, 76, 0.12); + --ops-success: #2fa252; + --ops-success-soft: rgba(47, 162, 82, 0.13); + --ops-radius: 8px; } * { @@ -41,9 +42,10 @@ body { min-height: 100vh; - background: - linear-gradient(180deg, rgba(226, 243, 248, 0.72) 0, rgba(238, 244, 243, 0.96) 240px, var(--ops-bg) 100%); + background: var(--ops-bg); color: var(--ops-text); + font-size: 12px; + overflow-x: hidden; } #root { @@ -61,59 +63,206 @@ body { } .container { - max-width: 1280px; + max-width: 1380px; margin: 0 auto; - padding: 20px 18px; + padding: 24px 20px 28px; +} + +.portal-topbar { + min-height: 44px; + display: grid; + grid-template-columns: 280px minmax(0, 1fr) auto; + gap: 12px; + align-items: center; + padding: 10px 12px; + background: var(--ops-top); + border-bottom: 1px solid var(--ops-border-soft); + position: sticky; + top: 0; + z-index: 40; +} + +.portal-topbar-admin { + grid-template-columns: 280px auto; + justify-content: space-between; + background: #282b2f; +} + +.portal-brand { + display: flex; + align-items: center; + gap: 9px; + min-width: 0; +} + +.portal-mark { + width: 22px; + height: 22px; + display: grid; + place-items: center; + border-radius: 6px; + background: var(--ops-accent-dark); + color: #fff; + font-weight: 600; + font-size: 12px; + letter-spacing: -0.03em; + flex: 0 0 auto; +} + +.portal-brand-text { + min-width: 0; +} + +.portal-brand-text h1 { + margin: 0; + font-size: 13px; + line-height: 1.1; + color: #f3f6fa; + font-weight: 520; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.portal-subtitle { + margin-top: 2px; + font-size: 11px; + color: #aab2bd; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.portal-nav { + display: flex; + align-items: center; + justify-content: center; + gap: 4px; + min-width: 0; + overflow-x: auto; + scrollbar-width: none; +} + +.portal-nav::-webkit-scrollbar { + display: none; +} + +.portal-tab, +.portal-switch-button, +.portal-exit-button { + min-height: 30px; + padding: 7px 12px; + border-radius: 6px; + font-size: 12px; + font-weight: 500; + white-space: nowrap; +} + +.portal-tab { + border: 1px solid transparent; + background: transparent; + color: #c7cdd6; +} + +.portal-tab:hover { + background: rgba(5, 37, 77, 0.75); + color: #fff; + border-color: transparent; +} + +.portal-tab.active { + background: #34383e; + color: #fff; + border-color: #34383e; +} + +.portal-switch-button, +.portal-exit-button { + border: 1px solid rgba(71, 151, 255, 0.72); + background: transparent; + color: var(--ops-accent); +} + +.portal-switch-button:hover, +.portal-exit-button:hover { + background: rgba(5, 37, 77, 0.75); + border-color: var(--ops-accent); + color: #fff; +} + +.portal-meta { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; +} + +.admin-brand-text .portal-subtitle { color: #8d96a3; } + +.portal-container { + padding-top: 18px; +} + +.portal-container-admin { + max-width: none; + width: 100%; + margin: 0; + padding: 0; } .card { - background: linear-gradient(180deg, var(--ops-surface) 0%, #fbfdfd 100%); - border-radius: var(--ops-radius); - padding: 22px; - box-shadow: none; - border: 1px solid var(--ops-border-soft); - border-top: 3px solid var(--ops-accent-mid); + background: rgba(24, 27, 31, 0.96); + border-top: 1px solid rgba(70, 76, 86, 0.7); + border-bottom: 1px solid rgba(34, 38, 44, 0.96); + border-left: 1px solid rgba(42, 46, 52, 0.85); + border-right: 1px solid rgba(42, 46, 52, 0.85); + border-radius: 3px; + padding: 18px 18px 19px; margin-bottom: 20px; } .btn { padding: 9px 15px; - border: 1px solid transparent; + border: 1px solid rgba(71, 151, 255, 0.72); border-radius: var(--ops-radius); - font-size: 15px; + font-size: 12px; cursor: pointer; transition: all 0.2s; - font-weight: 600; + font-weight: 500; } .btn-primary { - background: linear-gradient(180deg, #0d789b 0%, var(--ops-accent-dark) 100%); - color: white; + background: transparent; + color: var(--ops-accent); } .btn-primary:hover { - background-color: var(--ops-accent-dark); + background: rgba(5, 37, 77, 0.75); + color: #fff; } .btn-secondary { - background-color: var(--ops-surface); + background-color: var(--ops-surface-muted); border-color: var(--ops-border); color: var(--ops-text); } .btn-secondary:hover { - background-color: var(--ops-accent-soft); - border-color: var(--ops-accent); - color: var(--ops-accent); + background-color: rgba(255, 255, 255, 0.05); + border-color: #5a626d; + color: #fff; } .btn-danger { - background-color: var(--ops-danger); - color: white; + background-color: transparent; + border-color: rgba(238, 99, 104, 0.68); + color: #ffa2a6; } .btn-danger:hover { - background-color: #8f1d2d; + background-color: rgba(92, 31, 33, 0.75); + border-color: var(--ops-danger); + color: #ffe2e4; } .form-group { @@ -123,7 +272,7 @@ body { .form-group label { display: block; margin-bottom: 6px; - font-weight: 700; + font-weight: 500; color: var(--ops-text); } @@ -131,11 +280,11 @@ body { .form-group textarea, .form-group select { width: 100%; - padding: 10px; + padding: 8px 9px; border: 1px solid var(--ops-border); border-radius: var(--ops-radius); - font-size: 16px; - background: var(--ops-surface); + font-size: 12px; + background: #111214; color: var(--ops-text); } @@ -149,47 +298,47 @@ body { .alert { padding: 12px 16px; - border-radius: 4px; + border-radius: 6px; margin-bottom: 16px; } .alert-success { background-color: var(--ops-success-soft); color: var(--ops-success); - border: 1px solid #b9e6cc; + border: 1px solid rgba(47, 162, 82, 0.36); } .alert-error { background-color: var(--ops-coral-soft); - color: #8c2631; - border: 1px solid #f2c5ca; + color: #ffd7df; + border: 1px solid rgba(238, 99, 104, 0.42); } .alert-warning { background-color: var(--ops-warning-soft); color: var(--ops-warning); - border: 1px solid #efd080; + border: 1px solid rgba(255, 184, 76, 0.28); } .navbar { - background: linear-gradient(90deg, #f7fcfb 0%, #e8f5f7 56%, #fff8e8 100%); + background: #111923; color: var(--ops-text); - padding: 14px 20px; + padding: 16px 24px; display: flex; justify-content: space-between; align-items: center; - box-shadow: none; - border-bottom: 1px solid #bdd7df; + border-bottom: 1px solid rgba(120, 160, 196, 0.18); } .navbar h1 { margin: 0; - font-size: 20px; - color: var(--ops-accent-dark); + font-size: 19px; + color: #f3f8fc; + letter-spacing: 0.01em; } .navbar button { - background: var(--ops-surface-muted); + background: rgba(14, 24, 37, 0.94); color: var(--ops-text); border: 1px solid var(--ops-border); padding: 8px 16px; @@ -203,54 +352,124 @@ body { color: var(--ops-accent); } -.auth-container { +.auth-shell { min-height: 100vh; + display: grid; + grid-template-rows: auto 1fr; + background: var(--ops-bg); +} + +.auth-topbar { + min-height: 44px; display: flex; align-items: center; + padding: 9px 12px; + background: var(--ops-top); + border-bottom: 1px solid var(--ops-border-soft); +} + +.auth-container { + min-height: calc(100vh - 44px); + display: grid; + grid-template-columns: minmax(280px, 420px) minmax(320px, 420px); justify-content: center; - background: - linear-gradient(135deg, rgba(226, 243, 248, 0.92) 0%, rgba(255, 245, 218, 0.72) 100%), - var(--ops-bg); - padding: 24px; + align-content: center; + gap: 18px; + padding: 20px; +} + +.auth-welcome-card, +.auth-card { + border: 1px solid var(--ops-border-soft); + border-radius: var(--ops-radius); + background: transparent; + overflow: hidden; +} + +.auth-welcome-card { + padding: 26px; + display: grid; + gap: 14px; +} + +.auth-kicker { + font-size: 11px; + color: #aab2bd; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.auth-welcome-card h2 { + margin: 0; + font-size: 26px; + color: #f3f6fa; +} + +.auth-welcome-card p { + color: var(--ops-subtle); + font-size: 14px; + line-height: 1.6; } .auth-card { - background: linear-gradient(180deg, #ffffff 0%, #fbfdfd 100%); - border-radius: var(--ops-radius); - padding: 34px; - box-shadow: none; - border: 1px solid #bfd7df; - border-top: 4px solid var(--ops-accent); width: 100%; - max-width: 900px; + max-width: 420px; } -.auth-card h2 { - margin-bottom: 24px; - color: var(--ops-text); - text-align: center; +.auth-card-head { + min-height: 40px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 0 12px; + border-bottom: 1px solid var(--ops-border-soft); } -.auth-card .form-footer { - margin-top: 16px; +.auth-card-head h2 { + margin: 0; + font-size: 12px; + font-weight: 500; + color: #f3f6fa; +} + +.auth-card-head span { + color: var(--ops-muted); + font-size: 11px; +} + +.auth-card form, +.auth-footer { + padding: 11px; +} + +.auth-submit { + width: 100%; + margin-top: 8px; +} + +.auth-footer { + display: grid; + gap: 12px; + border-top: 1px solid var(--ops-border-soft); text-align: center; color: var(--ops-muted); } -.auth-card .form-footer a { +.auth-footer a { color: var(--ops-accent); text-decoration: none; } -.auth-card .form-footer a:hover { +.auth-footer a:hover { text-decoration: underline; } .dashboard-grid { display: grid; grid-template-columns: 1fr; - gap: 20px; - margin-top: 16px; + gap: 22px; + margin-top: 18px; } .dashboard-tabs { @@ -267,6 +486,112 @@ body { flex-wrap: wrap; } +.profile-menu { + position: relative; +} + +.profile-menu-trigger { + background: var(--ops-surface-muted); + border: 1px solid rgba(120, 160, 196, 0.16); + color: #f3f8fc; + cursor: pointer; + font-size: 14px; + display: inline-flex; + align-items: center; + gap: 8px; + padding: 9px 12px; + border-radius: 999px; + font-weight: 700; +} + +.profile-menu-chevron { + font-size: 11px; + color: var(--ops-muted); +} + +.profile-menu-dropdown { + position: absolute; + top: calc(100% + 10px); + right: 0; + min-width: 320px; + max-width: 360px; + z-index: 1000; + background: var(--ops-surface); + border: 1px solid var(--ops-border); + border-radius: 8px; + box-shadow: none; + overflow: hidden; +} + +.profile-menu-summary { + padding: 18px; + border-bottom: 1px solid var(--ops-border-soft); + background: var(--ops-surface-muted); +} + +.profile-menu-summary-head { + display: flex; + justify-content: space-between; + align-items: center; + gap: 10px; + margin-bottom: 12px; +} + +.profile-menu-summary-head h4 { + margin: 0; + font-size: 13px; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--ops-muted); +} + +.profile-menu-edit { + border: 1px solid rgba(68, 166, 255, 0.24); + background: rgba(29, 143, 255, 0.16); + color: #d9ecff; + border-radius: 999px; + padding: 5px 10px; + font-size: 11px; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.04em; + cursor: pointer; +} + +.profile-menu-details { + font-size: 12px; + color: var(--ops-muted); + line-height: 1.65; +} + +.profile-menu-details p { + margin: 4px 0; +} + +.profile-menu-details strong { + color: var(--ops-text); +} + +.profile-menu-item { + display: block; + width: 100%; + padding: 14px 18px; + background: transparent; + border: none; + border-top: 1px solid var(--ops-border-soft); + text-align: left; + cursor: pointer; + color: var(--ops-text); + font-size: 14px; + font-weight: 600; +} + +.profile-menu-item.first { + border-top: none; +} + +.profile-menu-item:hover { background: rgba(255, 255, 255, 0.04); } + .navbar-tab-strip { display: flex; gap: 6px; @@ -278,20 +603,22 @@ body { border: 1px solid var(--ops-border); color: var(--ops-text); border-radius: var(--ops-radius); - padding: 6px 12px; - font-size: 13px; - font-weight: 600; + padding: 7px 12px; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.02em; + text-transform: uppercase; cursor: pointer; } .navbar-tab-active { - background: linear-gradient(180deg, #0d789b 0%, var(--ops-accent-dark) 100%); + background: var(--ops-accent); border-color: var(--ops-accent-dark); color: white; } .navbar-tab-inactive { - background: rgba(255, 255, 255, 0.72); + background: var(--ops-surface-muted); } .navbar-tab-inactive:hover { @@ -308,7 +635,7 @@ body { border: 1px solid var(--ops-border-soft); border-radius: var(--ops-radius); padding: 12px 12px 10px; - background: linear-gradient(90deg, var(--ops-accent-wash) 0%, var(--ops-surface) 36%); + background: rgba(24, 27, 31, 0.92); } .profile-question-meta { @@ -323,21 +650,50 @@ body { width: 100%; min-height: 38px; padding: 8px 10px; - border: 1px solid #d5dde8; + border: 1px solid var(--ops-border); border-radius: var(--ops-radius); background: var(--ops-surface-muted); color: var(--ops-muted); font-weight: 600; } +.profile-question-row-admin { + grid-template-columns: 1fr; + gap: 8px; + padding: 14px 0; + border: 0; + border-top: 1px solid rgba(38, 44, 50, 0.88); + border-radius: 0; + background: transparent; +} + +.profile-question-row-admin:first-child { + padding-top: 0; + border-top: 0; +} + +.profile-question-row-admin .profile-question-meta { + display: grid; + gap: 6px; +} + +.profile-question-row-admin .profile-question-answer { + width: 100%; +} + +.profile-question-row-admin .profile-question-input, +.profile-question-row-admin .profile-question-readonly { + min-height: 42px; +} + .site-footer { margin-top: auto; padding: 20px 14px; - background: linear-gradient(90deg, #f7fcfb 0%, #eef7f8 62%, #fff8e8 100%); + background: var(--ops-top); color: var(--ops-muted); - border-top: 1px solid #bdd7df; + border-top: 1px solid var(--ops-border-soft); text-align: center; - font-size: 14px; + font-size: 12px; } .site-footer a { @@ -356,12 +712,12 @@ body { bottom: 16px; z-index: 2400; width: min(360px, calc(100vw - 32px)); - background: linear-gradient(180deg, #ffffff 0%, #f4fbfa 100%); + background: var(--ops-surface); color: var(--ops-text); border-radius: var(--ops-radius); - border: 1px solid #b9d8df; + border: 1px solid var(--ops-border); border-left: 4px solid var(--ops-accent); - box-shadow: 0 8px 18px rgba(38, 52, 69, 0.12); + box-shadow: none; padding: 12px 14px; display: flex; align-items: center; @@ -373,59 +729,353 @@ body { .admin-workspace { display: grid; - grid-template-columns: 230px minmax(0, 1fr); - gap: 18px; + grid-template-columns: 58px 236px minmax(0, 1fr); + gap: 0; align-items: start; - margin-top: 20px; + margin-top: 0; + min-height: calc(100vh - 44px); + background: transparent; } -.admin-sidebar { +.admin-workspace.single-page-area { + grid-template-columns: 58px minmax(0, 1fr); +} + +.admin-primary-rail { position: sticky; - top: 18px; + top: 54px; + min-height: calc(100vh - 64px); + padding: 14px 8px 12px; + background: rgba(17, 18, 20, 0.94); + border-right: 1px solid rgba(42, 45, 50, 0.92); + align-self: stretch; +} + +.admin-primary-nav { display: grid; - gap: 6px; - background: linear-gradient(180deg, #ffffff 0%, #f4fbfa 100%); - border: 1px solid #bdd7df; - border-radius: var(--ops-radius); - padding: 12px; + gap: 4px; +} + +.admin-primary-link { + width: 100%; + height: 46px; + display: grid; + place-items: center; + border: 1px solid transparent; + background: transparent; + color: #aab2bd; + border-radius: 0; + cursor: pointer; + position: relative; + transition: background 0.14s ease, color 0.14s ease; +} + +.admin-primary-link::before { + content: ""; + position: absolute; + left: -8px; + top: 8px; + bottom: 8px; + width: 2px; + background: transparent; + transition: background 0.14s ease; +} + +.admin-primary-link:hover { + background: rgba(255, 255, 255, 0.03); + color: #eef3f8; +} + +.admin-primary-link:hover::before, +.admin-primary-link.active::before { + background: #4797ff; +} + +.admin-primary-link.active { + background: rgba(255, 255, 255, 0.04); + color: #f3f6fa; +} + +.admin-primary-icon { + width: 24px; + height: 24px; + display: grid; + place-items: center; +} + +.admin-primary-icon svg { + width: 19px; + height: 19px; +} + +.admin-primary-icon .icon-stroke { + fill: none; + stroke: currentColor; + stroke-width: 1.05; + stroke-linecap: round; + stroke-linejoin: round; +} + +.admin-primary-link:not(.active) .admin-primary-icon .icon-fill { + display: none; +} + +.admin-primary-link:hover .admin-primary-icon .icon-fill, +.admin-primary-link.active .admin-primary-icon .icon-fill { + fill: currentColor; + stroke: none; + opacity: 0.18; +} + +.admin-page-rail { + position: sticky; + top: 54px; + min-height: calc(100vh - 64px); + padding: 18px 0 14px; + background: rgba(19, 20, 22, 0.82); + border-right: 1px solid rgba(42, 45, 50, 0.92); + align-self: stretch; +} + +.admin-page-rail-title { + padding: 0 16px 12px; + margin: 0 0 6px; + color: #8d96a3; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.09em; + border-bottom: 1px solid rgba(42, 45, 50, 0.92); +} + +.admin-page-nav { + display: grid; + gap: 0; +} + +.admin-rail-tools { + margin-top: 14px; + padding: 16px 16px 0; + border-top: 1px solid rgba(42, 45, 50, 0.92); + display: grid; + gap: 14px; +} + +.admin-rail-search, +.admin-rail-group { + display: grid; + gap: 5px; +} + +.admin-rail-search label, +.admin-rail-group-title { + color: #8d96a3; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.09em; +} + +.admin-rail-search input, +.admin-rail-group select, +.admin-rail-search select { + width: 100%; + min-height: 36px; + padding: 8px 11px; + border: 1px solid rgba(42, 45, 50, 0.92); + border-radius: 0; + background: rgba(255, 255, 255, 0.02); + color: #eef3f8; box-shadow: none; } -.admin-sidebar-title { - color: var(--ops-muted); - font-size: 12px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.04em; - padding: 4px 6px 10px; +.admin-rail-search input:focus, +.admin-rail-group select:focus, +.admin-rail-search select:focus { + outline: none; + border-color: rgba(71, 151, 255, 0.42); + background: rgba(255, 255, 255, 0.03); + box-shadow: inset 0 0 0 1px rgba(71, 151, 255, 0.12); } -.admin-sidebar-link { - width: 100%; - border: 1px solid transparent; +.admin-rail-meta { + display: grid; + gap: 4px; + padding: 10px 0 0; + border-top: 1px solid rgba(42, 45, 50, 0.92); background: transparent; - color: var(--ops-text); - border-radius: var(--ops-radius); - padding: 10px 11px; + color: #b5c0ce; + font-size: 11px; +} + +.admin-rail-action { + min-height: 36px; + padding: 8px 0; + border: 0; + border-top: 1px solid rgba(42, 45, 50, 0.92); + border-bottom: 1px solid rgba(42, 45, 50, 0.92); + background: transparent; + color: #c7cdd6; text-align: left; - font-weight: 700; + font-size: 12px; + font-weight: 500; cursor: pointer; } -.admin-sidebar-link:hover, -.admin-sidebar-link.active { - background: var(--ops-accent-soft); - border-color: #b8d5e4; - color: var(--ops-accent); +.admin-rail-action:hover:not(:disabled) { + background: transparent; + color: #fff; + border-color: rgba(71, 151, 255, 0.26); } -.admin-sidebar-link.active { - border-left: 4px solid var(--ops-accent); - padding-left: 8px; +.admin-rail-action:first-child { + border-top: 1px solid rgba(42, 45, 50, 0.92); +} + +.admin-page-link { + width: 100%; + padding: 10px 16px; + border: 0; + border-left: 2px solid transparent; + border-radius: 0; + background: transparent; + color: #c7cdd6; + text-align: left; + font-size: 12px; + font-weight: 500; + cursor: pointer; + transition: background 0.14s ease, border-color 0.14s ease, color 0.14s ease; +} + +.admin-page-link:hover { + background: rgba(255, 255, 255, 0.03); + color: #fff; + border-left-color: rgba(71, 151, 255, 0.4); +} + +.admin-page-link.active { + background: rgba(255, 255, 255, 0.04); + border-left-color: #4797ff; + color: #f3f6fa; +} + +.admin-rail-action:disabled { + opacity: 0.45; + cursor: not-allowed; } .admin-content { min-width: 0; + padding: 18px 20px 28px; + background: transparent; + display: grid; + gap: 14px; + align-content: stretch; + min-height: calc(100vh - 44px); +} + +.admin-inline-badge { + display: inline-block; + margin-left: 8px; + padding: 4px 8px; + border-radius: 4px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + background: rgba(71, 151, 255, 0.12); + border: 0; + color: #86bcff; +} + +.admin-kv-list { + display: grid; + gap: 10px; + margin-bottom: 14px; +} + +.admin-surface, +.admin-panel { + background: transparent; + border: 0; + border-radius: 0; + padding: 0; +} + +.admin-surface-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 16px; + margin-bottom: 14px; +} + +.admin-surface-header h4, +.admin-surface-header h3 { + margin: 0 0 4px; +} + +.admin-surface-header p { + margin: 0; + color: #b5c0ce; + font-size: 12px; +} + +.admin-switch-row { + display: flex; + align-items: center; + gap: 10px; + color: #d7e0ec; + font-size: 12px; + font-weight: 400; + cursor: pointer; +} + +.admin-switch-row + .admin-switch-row { + margin-top: 10px; +} + +.admin-switch-group { + display: flex; + gap: 18px; + flex-wrap: wrap; +} + +.admin-kv-list strong, +.admin-kv-list span, +.admin-kv-list code { + display: block; +} + +.admin-kv-list strong { + margin-bottom: 3px; + color: #eef3f8; + font-size: 11px; + font-weight: 500; +} + +.admin-preview-block { + padding: 12px 13px; + border-top: 1px solid rgba(64, 71, 80, 0.55); + border-bottom: 1px solid rgba(34, 38, 44, 0.96); + background: rgba(16, 18, 22, 0.72); +} + +.admin-preview-block strong { + display: block; + margin-bottom: 8px; + color: #eef3f8; + font-size: 11px; + font-weight: 500; +} + +.admin-preview-block pre { + margin: 0; + white-space: pre-wrap; + word-break: break-word; + color: #eef3f8; + font-size: 12px; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } .admin-page-header { @@ -433,34 +1083,42 @@ body { justify-content: space-between; align-items: flex-start; gap: 14px; - margin-bottom: 16px; - border-bottom: 1px solid #cfe1e5; - padding-bottom: 12px; + margin-bottom: 14px; + padding: 0 2px; } .admin-page-header h3 { - margin: 0 0 4px; + margin: 0 0 3px; + color: #f3f6fa; + font-size: 18px; + font-weight: 520; + letter-spacing: -0.02em; } .admin-page-header p { margin: 0; - color: var(--ops-muted); + color: #eef3f8; + font-size: 12px; } .admin-stat-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); - gap: 10px; + gap: 0; margin-bottom: 18px; + border-top: 1px solid rgba(42, 45, 50, 0.92); + border-left: 1px solid rgba(42, 45, 50, 0.92); } .admin-stat-card { display: grid; gap: 6px; - background: linear-gradient(180deg, #ffffff 0%, #f8fcfb 100%); - border: 1px solid var(--ops-border); - border-radius: var(--ops-radius); - padding: 14px; + background: transparent; + border: 0; + border-right: 1px solid rgba(42, 45, 50, 0.92); + border-bottom: 1px solid rgba(42, 45, 50, 0.92); + border-radius: 0; + padding: 15px 16px; text-align: left; color: var(--ops-text); box-shadow: none; @@ -471,44 +1129,45 @@ button.admin-stat-card { } .admin-stat-card span { - color: var(--ops-muted); - font-size: 13px; - font-weight: 700; + color: #eef3f8; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.05em; + text-transform: uppercase; } .admin-stat-card strong { - font-size: 26px; + font-size: 28px; line-height: 1; - color: var(--ops-accent-dark); + color: #f3f6fa; } .admin-stat-card.attention { - border-color: #d2aa4d; - background: var(--ops-warning-soft); + background: rgba(255, 184, 76, 0.04); } .admin-panel-grid { display: grid; - grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); - gap: 14px; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + gap: 0 18px; + border-top: 1px solid rgba(42, 45, 50, 0.92); } .admin-panel { - background: linear-gradient(180deg, #ffffff 0%, #fbfdfd 100%); - border: 1px solid var(--ops-border-soft); - border-top: 3px solid var(--ops-accent-mid); - border-radius: var(--ops-radius); - padding: 18px; box-shadow: none; + padding-top: 16px; } .admin-panel h4 { margin: 0 0 12px; + color: #f3f6fa; + font-size: 14px; + font-weight: 520; } .admin-queue-list { display: grid; - gap: 10px; + gap: 0; } .admin-queue-item { @@ -516,35 +1175,45 @@ button.admin-stat-card { justify-content: space-between; gap: 12px; align-items: center; - border: 1px solid var(--ops-border-soft); - border-radius: var(--ops-radius); - padding: 10px; - background: linear-gradient(90deg, #ffffff 0%, #f6fbfa 100%); + border: 0; + border-top: 1px solid rgba(42, 45, 50, 0.92); + border-radius: 0; + padding: 12px 0; + background: transparent; } .admin-queue-item span, .muted-line { display: block; - color: var(--ops-subtle); + color: #b5c0ce; font-size: 12px; margin-top: 2px; } +.admin-queue-item:first-child { + border-top: 0; +} + .admin-filter-bar { display: grid; grid-template-columns: minmax(240px, 1fr) repeat(3, minmax(150px, 180px)); gap: 10px; margin-bottom: 12px; + padding: 0; + border: 0; + border-radius: 0; + background: transparent; } .admin-filter-bar input, .admin-filter-bar select, .drawer-control-grid select { width: 100%; - padding: 9px 10px; - border: 1px solid var(--ops-border); - border-radius: var(--ops-radius); - background: var(--ops-surface); + padding: 8px 10px; + min-height: 34px; + border: 1px solid rgba(52, 57, 66, 0.96); + border-radius: 2px; + background: #111214; color: var(--ops-text); } @@ -556,41 +1225,403 @@ button.admin-stat-card { box-shadow: 0 0 0 3px rgba(11, 111, 143, 0.12); } +.admin-table-shell { + min-height: 0; + border: 0; + border-radius: 0; + background: transparent; + box-shadow: none; + overflow: visible; +} + +.admin-table-screen { + display: flex; + flex-direction: column; + min-height: calc(100vh - 92px); +} + .admin-table-wrap { + flex: 1 1 auto; overflow-x: auto; - border: 1px solid var(--ops-border); - border-radius: var(--ops-radius); - background: var(--ops-surface); + overflow-y: auto; + border: 0; + border-radius: 0; + background: transparent; + box-shadow: none; + margin: 0 -16px; + padding: 0 16px; } .admin-table { width: 100%; min-width: 820px; border-collapse: collapse; + table-layout: auto; } .admin-table th, .admin-table td { - padding: 10px 12px; - border-bottom: 1px solid #edf1f6; + padding: 9px 12px; + border-bottom: 1px solid rgba(42, 45, 50, 0.92); text-align: left; - vertical-align: top; + vertical-align: middle; } .admin-table th { - color: var(--ops-muted); + position: sticky; + top: 0; + z-index: 1; + color: #f3f6fa; + font-size: 12px; + font-weight: 520; + letter-spacing: 0; + text-transform: none; + background: transparent; + white-space: nowrap; + border-bottom: 1px solid rgba(42, 45, 50, 0.92); +} + +.admin-table-sort { + width: 100%; + display: inline-flex; + align-items: center; + justify-content: space-between; + gap: 10px; + border: 0; + background: transparent; + padding: 0; + color: inherit; + font: inherit; + text-transform: inherit; + letter-spacing: inherit; + cursor: pointer; +} + +.admin-table-sort:hover { + color: #f3f6fa; +} + +.admin-table-sort.active { + color: var(--ops-accent); +} + +.admin-sort-arrow { + width: 12px; + height: 12px; + color: rgba(149, 160, 175, 0.62); + flex: 0 0 auto; + transition: transform 0.14s ease, color 0.14s ease; +} + +.admin-sort-arrow svg { + width: 12px; + height: 12px; + fill: none; + stroke: currentColor; + stroke-width: 1.6; + stroke-linecap: round; + stroke-linejoin: round; +} + +.admin-sort-arrow.active { + color: var(--ops-accent); +} + +.admin-sort-arrow.asc { + transform: rotate(180deg); +} + +.admin-table td { + color: #f3f6fa; font-size: 12px; - text-transform: uppercase; - letter-spacing: 0.03em; - background: #e9f4f7; } .admin-table tbody tr { cursor: pointer; + transition: background 0.12s ease; } .admin-table tbody tr:hover { - background: var(--ops-accent-wash); + background: rgba(255, 255, 255, 0.028); +} + +.admin-table tbody tr:last-child td { + border-bottom: 0; +} + +.admin-table-empty { + padding: 16px 14px; +} + +.admin-table td strong { + color: #f3f6fa; + font-weight: 520; +} + +.admin-table td .muted-line { + color: #eef3f8; +} + +.admin-table-footer { + margin-top: auto; + margin-left: -16px; + margin-right: -16px; + padding: 10px 16px; + border-top: 1px solid rgba(42, 45, 50, 0.92); + background: transparent; + position: sticky; + bottom: 0; + z-index: 2; +} + +.admin-table-intro { + padding: 16px 16px 0; +} + +.admin-table-intro h4, +.admin-table-intro h3 { + margin: 0 0 4px; +} + +.admin-table-intro p { + margin: 0; + color: #b5c0ce; + font-size: 12px; +} + +.admin-field, +.admin-question-builder .admin-field, +.admin-question-search, +.admin-question-builder textarea, +.admin-question-builder select, +.admin-question-builder input { + width: 100%; + padding: 8px 10px; + min-height: 34px; + border: 1px solid rgba(52, 57, 66, 0.96); + border-radius: 2px; + background: #111214; + color: var(--ops-text); +} + +.admin-field:focus, +.admin-question-builder .admin-field:focus, +.admin-question-builder textarea:focus, +.admin-question-builder select:focus, +.admin-question-builder input:focus { + outline: none; + border-color: var(--ops-accent); + box-shadow: 0 0 0 3px rgba(11, 111, 143, 0.12); +} + +.admin-field::placeholder, +.admin-question-builder .admin-field::placeholder, +.admin-question-builder textarea::placeholder, +.admin-question-builder input::placeholder { + color: #8d96a3; +} + +.admin-field-disabled { + background: #202328; + color: #8d96a3; +} + +.admin-field-textarea { + min-height: 84px; + resize: vertical; +} + +.admin-form-grid { + display: grid; + gap: 10px; +} + +.admin-field-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 10px; +} + +.admin-form-actions { + display: flex; + gap: 10px; +} + +.admin-question-builder { + margin-bottom: 0; +} + +.admin-question-switches { + padding-top: 4px; +} + +.admin-subsection-header { + margin-bottom: 10px; +} + +.admin-question-search { + margin-bottom: 10px; +} + +.admin-question-table { + min-width: 0; +} + +.admin-question-table td:nth-child(2) { + min-width: 240px; +} + +.admin-question-table td:nth-child(3), +.admin-question-table td:nth-child(4) { + color: #c6ced8; +} + +.admin-question-layout { + display: grid; + grid-template-columns: minmax(0, 1fr) 360px; + gap: 18px; + align-items: start; +} + +.admin-question-main { + min-width: 0; +} + +.admin-question-sidebar { + min-width: 0; +} + +.admin-question-sidebar .admin-surface { + position: sticky; + top: 16px; +} + +.admin-pager-controls { + display: flex; + gap: 6px; +} + +.admin-pager-button { + width: 30px; + height: 30px; + display: grid; + place-items: center; + border: 1px solid transparent; + background: transparent; + color: #c7cdd6; + border-radius: 2px; + cursor: pointer; +} + +.admin-pager-button:hover:not(:disabled) { + background: transparent; + color: #fff; + border-color: transparent; +} + +.admin-pager-button:disabled { + opacity: 0.35; + cursor: not-allowed; +} + +.admin-inline-list { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; +} + +.admin-inline-chip { + display: inline-flex; + align-items: center; + min-height: 22px; + padding: 0 8px; + border: 1px solid rgba(52, 57, 66, 0.96); + border-radius: 999px; + background: rgba(255, 255, 255, 0.03); + color: #d3dae4; + font-size: 11px; + line-height: 1; + white-space: nowrap; +} + +.admin-code-textarea { + font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; + font-size: 13px; +} + +.admin-date-stack { + display: grid; + gap: 2px; +} + +.admin-date-stack strong { + color: #f3f6fa; + font-weight: 520; +} + +.admin-date-stack span { + color: #b5c0ce; + font-size: 12px; +} + +.email-preview-tabs { + display: inline-flex; + gap: 6px; + margin-bottom: 12px; + padding: 3px; + border: 1px solid rgba(38, 44, 50, 0.88); + background: rgba(17, 18, 20, 0.9); +} + +.email-preview-tab { + min-height: 30px; + padding: 0 10px; + border: 0; + background: transparent; + color: #a9b6c6; + font-size: 12px; + cursor: pointer; +} + +.email-preview-tab.active { + background: rgba(255, 255, 255, 0.06); + color: #f3f7fb; +} + +.email-preview-frame-shell { + overflow: hidden; + border: 1px solid rgba(38, 44, 50, 0.88); + background: #ffffff; +} + +.email-preview-frame { + width: 100%; + min-height: 440px; + border: 0; + background: #ffffff; +} + +.email-preview-code { + margin: 0; + padding: 14px; + border: 1px solid rgba(38, 44, 50, 0.88); + background: rgba(17, 18, 20, 0.9); + color: #eef3f8; + white-space: pre-wrap; + word-break: break-word; + font-size: 12px; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +} + +.admin-pager-button svg { + width: 14px; + height: 14px; + fill: none; + stroke: currentColor; + stroke-width: 1.7; + stroke-linecap: round; + stroke-linejoin: round; } .admin-table .btn, @@ -605,6 +1636,355 @@ button.admin-stat-card { flex-wrap: wrap; } +.admin-section-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + margin-bottom: 14px; +} + +.admin-section-header h3, +.admin-section-header h4 { + margin: 0 0 4px 0; +} + +.admin-section-header p { + margin: 0; + color: var(--ops-muted); +} + +.admin-header-actions { + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; + justify-content: flex-end; +} + +.admin-tier-benefits-cell { + min-width: 240px; + max-width: 320px; + color: #c6ced8; + white-space: pre-wrap; +} + +.admin-inline-form-panel { + margin-bottom: 16px; + padding: 18px 0 2px; + border-top: 1px solid rgba(42, 45, 50, 0.92); +} + +.admin-inline-form-panel h4 { + margin: 0 0 14px; + color: #f3f6fa; + font-size: 14px; + font-weight: 520; +} + +.admin-inline-form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.admin-rail-form-panel { + padding-top: 2px; + border-top: 1px solid rgba(42, 45, 50, 0.92); +} + +.admin-drawer-form-panel { + padding-top: 0; + border-top: 0; +} + +.admin-rail-form-panel h4 { + margin: 0 0 12px; + color: #f3f6fa; + font-size: 12px; + font-weight: 520; +} + +.admin-drawer-form-panel h4 { + margin: 0 0 14px; + color: #f3f6fa; + font-size: 14px; + font-weight: 520; +} + +.admin-rail-form-grid { + display: grid; + grid-template-columns: 1fr; + gap: 0; +} + +.admin-rail-form-panel .modal-form-group label { + margin-bottom: 5px; + color: #8d96a3; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.09em; +} + +.admin-drawer-form-panel .modal-form-group label { + margin-bottom: 4px; +} + +.admin-rail-form-panel .modal-form-group input, +.admin-rail-form-panel .admin-inline-textarea { + min-height: 36px; + padding: 8px 11px; + border: 1px solid rgba(42, 45, 50, 0.92); + border-radius: 0; + background: rgba(255, 255, 255, 0.02); + color: #eef3f8; + font-size: 12px; + box-shadow: none; +} + +.admin-drawer-form-panel .modal-form-group input, +.admin-drawer-form-panel .admin-inline-textarea { + width: 100%; + padding: 10px 12px; + border: 1px solid var(--ops-border); + border-radius: 10px; + font-size: 15px; + color: var(--ops-text); + background-color: rgba(9, 17, 27, 0.88); +} + +.admin-rail-form-panel .admin-inline-textarea { + min-height: 88px; +} + +.admin-rail-form-panel .modal-form-group input:focus, +.admin-rail-form-panel .admin-inline-textarea:focus { + border-color: rgba(71, 151, 255, 0.42); + background: rgba(255, 255, 255, 0.03); + box-shadow: inset 0 0 0 1px rgba(71, 151, 255, 0.12); +} + +.admin-drawer-form-panel .modal-form-group input:focus, +.admin-drawer-form-panel .admin-inline-textarea:focus { + outline: none; + border-color: var(--ops-accent); + box-shadow: 0 0 0 3px rgba(11, 93, 125, 0.12); +} + +.admin-rail-form-panel .modal-buttons { + justify-content: stretch; +} + +.admin-rail-form-panel .modal-buttons button { + flex: 1 1 0; +} + +.admin-inline-textarea { + width: 100%; + min-height: 88px; + padding: 10px 12px; + border: 1px solid var(--ops-border); + border-radius: 10px; + font-size: 15px; + color: var(--ops-text); + background-color: rgba(9, 17, 27, 0.88); + resize: vertical; +} + +.admin-inline-textarea:focus { + outline: none; + border-color: var(--ops-accent); + box-shadow: 0 0 0 3px rgba(11, 93, 125, 0.12); +} + +.admin-inline-toggle-row { + margin-bottom: 16px; +} + +.admin-inline-toggle-row label { + display: inline-flex; + align-items: center; + gap: 8px; + color: var(--ops-text); + font-size: 13px; + font-weight: 500; +} + +.esp-admin { + display: grid; + gap: 14px; + min-width: 0; +} + +.esp-live-meta { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + margin-top: 10px; +} + +.esp-live-pill { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 5px 10px; + border-radius: 6px; + border: 1px solid #464b53; + background: rgba(19, 20, 22, 0.48); + color: #b5bdc8; + font-size: 11px; + font-weight: 450; +} + +.esp-live-pill::before { + content: ""; + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--ops-success); + box-shadow: 0 0 0 0 rgba(23, 108, 72, 0.28); +} + +.esp-live-pill.is-refreshing::before { + animation: opsPulse 1s ease-out infinite; +} + +.esp-live-timestamp { + color: var(--ops-muted); + font-size: 11px; + font-weight: 400; +} + +.esp-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 16px; +} + +.esp-form-card h4 { + margin-bottom: 14px; +} + +.esp-summary-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 10px; + margin-bottom: 14px; +} + +.esp-summary-card { + border-top: 1px solid rgba(74, 82, 92, 0.58); + border-bottom: 1px solid rgba(33, 36, 42, 0.95); + border-left: 1px solid rgba(42, 46, 52, 0.78); + border-right: 1px solid rgba(42, 46, 52, 0.78); + background: linear-gradient(180deg, rgba(30, 34, 39, 0.94) 0%, rgba(19, 21, 25, 0.94) 100%); + border-radius: 3px; + padding: 14px; + min-height: 96px; +} + +.esp-summary-card strong, +.esp-summary-card span { + display: block; +} + +.esp-summary-card strong { + font-size: 24px; + color: #f3f6fa; +} + +.esp-summary-card span { + color: var(--ops-muted); + font-size: 11px; +} + +.esp-tab-strip { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin-bottom: 14px; +} + +.esp-tab { + border: 0; + background: transparent; + color: #c7cdd6; + border-radius: 2px; + padding: 8px 12px; + cursor: pointer; + font-weight: 450; + transition: background-color 0.18s ease, color 0.18s ease; +} + +.esp-tab:hover { + background: rgba(5, 37, 77, 0.75); + color: #fff; +} + +.esp-tab.active { + background: #34383e; + color: #fff; +} + +.esp-reader-list, +.esp-compact-list { + display: grid; + gap: 10px; +} + +.esp-reader-row, +.esp-compact-row { + display: flex; + justify-content: space-between; + gap: 14px; + align-items: center; + border-top: 1px solid rgba(64, 71, 80, 0.55); + border-bottom: 1px solid rgba(34, 38, 44, 0.96); + border-left: 1px solid rgba(42, 46, 52, 0.78); + border-right: 1px solid rgba(42, 46, 52, 0.78); + border-radius: 3px; + padding: 12px 13px; + background: rgba(18, 20, 24, 0.74); +} + +.esp-reader-row strong, +.esp-reader-row span, +.esp-compact-row strong, +.esp-compact-row span { + display: block; +} + +.esp-reader-row span, +.esp-compact-row span { + color: var(--ops-muted); + font-size: 11px; +} + +.esp-edit-modal { + max-width: 620px; + width: min(94vw, 620px); +} + +.esp-check-row { + display: flex; + align-items: center; + gap: 10px; + margin-top: 10px; + color: #d7e0ec; + font-size: 12px; +} + +@keyframes opsPulse { + 0% { + box-shadow: 0 0 0 0 rgba(23, 108, 72, 0.28); + } + 100% { + box-shadow: 0 0 0 10px rgba(23, 108, 72, 0); + } +} + .admin-pagination { display: flex; justify-content: space-between; @@ -612,7 +1992,7 @@ button.admin-stat-card { gap: 12px; margin-top: 12px; color: var(--ops-muted); - font-size: 13px; + font-size: 11px; } .admin-pagination div { @@ -629,8 +2009,8 @@ button.admin-stat-card { display: inline-block; padding: 4px 8px; border-radius: 4px; - font-size: 12px; - font-weight: 800; + font-size: 11px; + font-weight: 600; text-transform: capitalize; } @@ -646,43 +2026,76 @@ button.admin-stat-card { .role-super_admin { background: var(--ops-coral-soft); - color: #9f1f2d; + color: #ffd7df; } .drawer-overlay { position: fixed; inset: 0; z-index: 2600; - background: rgba(38, 52, 69, 0.32); + background: rgba(5, 8, 12, 0.58); display: flex; justify-content: flex-end; + backdrop-filter: blur(3px); } .user-drawer { - width: min(760px, 100vw); + width: min(430px, 100vw); height: 100vh; overflow-y: auto; - background: - linear-gradient(180deg, rgba(226, 243, 248, 0.72) 0, var(--ops-bg) 220px); - box-shadow: -12px 0 24px rgba(38, 52, 69, 0.16); - padding: 22px; + background: #161a1e; + border-left: 1px solid rgba(56, 62, 71, 0.95); + box-shadow: -18px 0 40px rgba(0, 0, 0, 0.26); + padding: 0 0 26px; + scrollbar-width: thin; + scrollbar-color: rgba(93, 100, 112, 0.75) transparent; +} + +.property-drawer { + display: flex; + flex-direction: column; } .drawer-header { + position: sticky; + top: 0; + z-index: 5; display: flex; justify-content: space-between; align-items: flex-start; - gap: 12px; - margin-bottom: 16px; + gap: 14px; + padding: 18px 20px 16px; + background: rgba(22, 26, 30, 0.96); + border-bottom: 1px solid rgba(54, 59, 67, 0.95); +} + +.drawer-header-main { + min-width: 0; +} + +.drawer-eyebrow { + display: inline-block; + margin-bottom: 8px; + color: #8ea0b8; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.16em; + text-transform: uppercase; } .drawer-header h3 { - margin: 0 0 4px; + margin: 0 0 6px; + font-size: 24px; + line-height: 1.1; + color: #f8fbff; + letter-spacing: -0.03em; } .drawer-header p { margin: 0; - color: var(--ops-muted); + color: #c7d0dc; + font-size: 13px; + line-height: 1.45; } .drawer-header-actions { @@ -694,25 +2107,135 @@ button.admin-stat-card { } .drawer-close { - border: none; - background: transparent; - color: var(--ops-muted); - font-size: 26px; + width: 34px; + height: 34px; + border: 1px solid rgba(62, 68, 77, 0.92); + background: rgba(20, 23, 27, 0.96); + color: #dbe4ef; + font-size: 22px; line-height: 1; cursor: pointer; + transition: background 0.18s ease, border-color 0.18s ease, color 0.18s ease; +} + +.drawer-close:hover { + background: rgba(71, 151, 255, 0.12); + border-color: rgba(71, 151, 255, 0.38); + color: #fff; +} + +.drawer-hero { + padding: 14px 20px 0; +} + +.drawer-hero-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0; + border-top: 1px solid rgba(38, 44, 50, 0.88); + border-left: 1px solid rgba(38, 44, 50, 0.88); +} + +.drawer-hero-card { + min-width: 0; + display: grid; + gap: 5px; + padding: 12px 14px; + background: transparent; + border-right: 1px solid rgba(38, 44, 50, 0.88); + border-bottom: 1px solid rgba(38, 44, 50, 0.88); +} + +.drawer-hero-label { + color: #90a0b4; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.drawer-hero-value { + min-width: 0; + color: #f7fbff; + font-size: 13px; + font-weight: 600; + line-height: 1.35; + overflow: hidden; + text-overflow: ellipsis; +} + +.drawer-body { + display: grid; + gap: 0; + padding: 16px 20px 0; } .drawer-section { - background: linear-gradient(180deg, #ffffff 0%, #fbfdfd 100%); - border: 1px solid var(--ops-border-soft); - border-top: 3px solid var(--ops-accent-mid); - border-radius: var(--ops-radius); - padding: 16px; - margin-bottom: 14px; + background: transparent; + border: 0; + border-top: 1px solid rgba(38, 44, 50, 0.88); + border-radius: 0; + padding: 18px 0; + margin-bottom: 0; +} + +.drawer-section:first-child { + border-top: 0; +} + +.drawer-section-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-bottom: 12px; } .drawer-section h4 { - margin: 0 0 12px; + margin: 0; + color: #f3f7fb; + font-size: 14px; + font-weight: 700; + letter-spacing: 0.02em; +} + +.drawer-section-meta { + color: #95a4b8; + font-size: 11px; +} + +.drawer-data-list { + display: grid; + gap: 6px; +} + +.drawer-data-row { + display: grid; + grid-template-columns: 112px minmax(0, 1fr); + gap: 14px; + padding: 10px 0; + border-top: 1px solid rgba(38, 44, 50, 0.88); +} + +.drawer-data-row:first-child { + padding-top: 0; + border-top: 0; +} + +.drawer-data-label { + color: #8fa0b4; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.drawer-data-value { + min-width: 0; + color: #f3f8fe; + font-size: 13px; + line-height: 1.45; + word-break: break-word; } .drawer-control-grid { @@ -727,6 +2250,140 @@ button.admin-stat-card { gap: 5px; font-weight: 700; color: var(--ops-text); + font-size: 11px; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.drawer-toggle-group { + display: grid; + gap: 8px; +} + +.drawer-toggle-label { + font-weight: 700; + color: var(--ops-text); + font-size: 11px; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.drawer-toggle-switch { + display: inline-flex; + align-items: center; + gap: 10px; + width: fit-content; + padding: 0; + border: 0; + background: transparent; + color: #c7d0dc; + cursor: pointer; +} + +.drawer-toggle-track { + position: relative; + width: 42px; + height: 24px; + border-radius: 999px; + background: rgba(38, 44, 50, 0.96); + border: 1px solid rgba(62, 68, 77, 0.92); + transition: background-color 0.18s ease, border-color 0.18s ease; +} + +.drawer-toggle-thumb { + position: absolute; + top: 2px; + left: 2px; + width: 18px; + height: 18px; + border-radius: 999px; + background: #c7d0dc; + transition: transform 0.18s ease, background-color 0.18s ease; +} + +.drawer-toggle-copy { + font-size: 12px; + font-weight: 600; + color: #c7d0dc; +} + +.drawer-toggle-switch.active .drawer-toggle-track { + background: rgba(47, 162, 82, 0.16); + border-color: rgba(47, 162, 82, 0.42); +} + +.drawer-toggle-switch.active .drawer-toggle-thumb { + transform: translateX(18px); + background: #2fa252; +} + +.drawer-toggle-switch.active .drawer-toggle-copy { + color: #e6ebf2; +} + +.drawer-empty-copy { + color: #b2bdca; + font-size: 13px; +} + +.drawer-mini-table { + width: 100%; + border-collapse: collapse; + font-size: 12px; +} + +.drawer-mini-table th, +.drawer-mini-table td { + padding: 10px 0; + border-bottom: 1px solid rgba(45, 49, 56, 0.86); + text-align: left; + vertical-align: top; +} + +.drawer-mini-table th { + color: #90a0b4; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.drawer-mini-table td { + color: #f2f7fc; +} + +.drawer-mini-table tbody tr:last-child td { + border-bottom: 0; +} + +.drawer-rsvp-summary { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; + margin-bottom: 14px; +} + +.drawer-rsvp-stat { + display: grid; + gap: 4px; + padding: 11px 12px; + background: rgba(20, 23, 28, 0.78); + border-top: 1px solid rgba(70, 76, 86, 0.55); + border-bottom: 1px solid rgba(34, 38, 44, 0.96); +} + +.drawer-rsvp-stat strong { + color: #f8fbff; + font-size: 18px; + font-weight: 650; +} + +.drawer-rsvp-label { + color: #8fa0b4; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; } /* Desktop view: side-by-side layout */ @@ -833,13 +2490,12 @@ button.admin-stat-card { grid-template-columns: 1fr; } - .admin-sidebar { - position: static; - grid-template-columns: repeat(2, minmax(0, 1fr)); + .admin-question-layout { + grid-template-columns: 1fr; } - .admin-sidebar-title { - grid-column: 1 / -1; + .admin-question-sidebar .admin-surface { + position: static; } .admin-filter-bar { @@ -853,9 +2509,37 @@ button.admin-stat-card { flex-direction: column; } + .admin-inline-form-grid { + grid-template-columns: 1fr; + } + + .admin-header-actions { + justify-content: stretch; + } + + .admin-header-actions .btn { + width: 100%; + } + .user-drawer { width: 100vw; - padding: 16px; + } + + .drawer-header, + .drawer-hero, + .drawer-body { + padding-left: 16px; + padding-right: 16px; + } + + .drawer-hero-grid, + .drawer-rsvp-summary { + grid-template-columns: 1fr; + } + + .drawer-data-row { + grid-template-columns: 1fr; + gap: 4px; } } @@ -863,9 +2547,15 @@ button.admin-stat-card { display: inline-block; padding: 4px 8px; border-radius: 4px; - font-size: 12px; - font-weight: 800; + border: 0; + font-size: 11px; + font-weight: 600; text-transform: uppercase; + letter-spacing: 0.06em; +} + +.status-badge::before { + content: none; } .status-active { @@ -890,7 +2580,7 @@ button.admin-stat-card { left: 0; right: 0; bottom: 0; - background: rgba(38, 52, 69, 0.32); + background: rgba(3, 8, 14, 0.5); display: flex; align-items: center; justify-content: center; @@ -898,14 +2588,13 @@ button.admin-stat-card { } .modal-content { - background: linear-gradient(180deg, #ffffff 0%, #fbfdfd 100%); + background: var(--ops-surface); padding: 24px; border-radius: var(--ops-radius); width: 100%; max-width: 400px; - box-shadow: 0 12px 24px rgba(38, 52, 69, 0.14); - border: 1px solid #bfd7df; - border-top: 4px solid var(--ops-accent); + box-shadow: none; + border: 1px solid rgba(120, 160, 196, 0.18); } .modal-content h3 { @@ -929,12 +2618,12 @@ button.admin-stat-card { .modal-form-group input { width: 100%; - padding: 8px; + padding: 10px 12px; border: 1px solid var(--ops-border); - border-radius: var(--ops-radius); - font-size: 16px; + border-radius: 10px; + font-size: 15px; color: var(--ops-text); - background-color: var(--ops-surface); + background-color: rgba(9, 17, 27, 0.88); } .modal-form-group input:focus { @@ -945,11 +2634,15 @@ button.admin-stat-card { .modal-error { color: var(--ops-danger); + background: var(--ops-danger-soft); + padding: 10px 12px; + border-radius: 8px; margin-bottom: 16px; font-size: 14px; } -.modal-buttons { +.modal-buttons, +.modal-button-row { display: flex; gap: 8px; justify-content: flex-end; @@ -1068,17 +2761,15 @@ button.admin-stat-card { /* Events Container Styles */ .events-container { - display: flex; - flex-direction: column; + display: grid; gap: 16px; } .event-card { border: 1px solid var(--ops-border-soft); - border-radius: var(--ops-radius); - padding: 16px; - background: linear-gradient(90deg, #ffffff 0%, #f4fbfa 100%); - border-left: 4px solid var(--ops-accent-mid); + border-radius: 16px; + padding: 18px; + background: var(--ops-surface-muted); } .event-header { @@ -1096,7 +2787,7 @@ button.admin-stat-card { .event-title { margin: 0 0 4px 0; - color: var(--ops-accent); + color: #eef7ff; font-size: 18px; word-wrap: break-word; } @@ -1122,12 +2813,12 @@ button.admin-stat-card { .rsvp-btn { font-size: 12px; padding: 8px 16px; - border: 2px solid #adb5bd; - border-radius: 4px; - background-color: transparent; - color: #6c757d; + border: 1px solid rgba(120, 160, 196, 0.18); + border-radius: 999px; + background-color: rgba(9, 17, 27, 0.72); + color: var(--ops-muted); cursor: pointer; - font-weight: normal; + font-weight: 700; transition: all 0.3s ease; white-space: nowrap; } @@ -1152,24 +2843,21 @@ button.admin-stat-card { } .rsvp-btn-attending.active { - border: 3px solid #28a745; - background-color: #28a745; + border: 1px solid rgba(36, 192, 138, 0.28); + background-color: rgba(36, 192, 138, 0.18); color: white; - box-shadow: 0 4px 8px rgba(40, 167, 69, 0.3); } .rsvp-btn-maybe.active { - border: 3px solid #ffc107; - background-color: #ffc107; - color: #212529; - box-shadow: 0 4px 8px rgba(255, 193, 7, 0.3); + border: 1px solid rgba(227, 162, 63, 0.28); + background-color: rgba(227, 162, 63, 0.18); + color: #fff4d8; } .rsvp-btn-not-attending.active { - border: 3px solid #dc3545; - background-color: #dc3545; + border: 1px solid rgba(217, 75, 98, 0.28); + background-color: rgba(217, 75, 98, 0.18); color: white; - box-shadow: 0 4px 8px rgba(220, 53, 69, 0.3); } .event-description { @@ -1186,21 +2874,21 @@ button.admin-stat-card { } .event-rsvp-status.attending { - background-color: #d4edda; - border: 1px solid #c3e6cb; - color: #155724; + background-color: rgba(36, 192, 138, 0.14); + border: 1px solid rgba(36, 192, 138, 0.22); + color: #bcf5df; } .event-rsvp-status.maybe { - background-color: #fff3cd; - border: 1px solid #ffeaa7; - color: #856404; + background-color: rgba(227, 162, 63, 0.14); + border: 1px solid rgba(227, 162, 63, 0.22); + color: #ffeabf; } .event-rsvp-status.not_attending { - background-color: #f8d7da; - border: 1px solid #f5c6cb; - color: #721c24; + background-color: rgba(217, 75, 98, 0.14); + border: 1px solid rgba(217, 75, 98, 0.22); + color: #ffd0d8; } input, @@ -1209,6 +2897,57 @@ select { font: inherit; } +input[type="checkbox"] { + appearance: none; + -webkit-appearance: none; + width: 34px; + height: 18px; + min-height: 18px; + flex: 0 0 34px; + margin: 0; + padding: 0; + border: 1px solid #41474f; + border-radius: 999px; + background: #252a30; + cursor: pointer; + position: relative; + transition: background 0.14s ease, border-color 0.14s ease; + vertical-align: middle; +} + +input[type="checkbox"]::after { + content: ""; + position: absolute; + top: 1px; + left: 1px; + width: 14px; + height: 14px; + border-radius: 50%; + background: #d7e0ec; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.32); + transition: transform 0.14s ease, background 0.14s ease; +} + +input[type="checkbox"]:checked { + background: var(--ops-accent-dark); + border-color: var(--ops-accent); +} + +input[type="checkbox"]:checked::after { + transform: translateX(16px); + background: #ffffff; +} + +input[type="checkbox"]:focus-visible { + outline: none; + box-shadow: 0 0 0 3px rgba(71, 151, 255, 0.18); +} + +input[type="checkbox"]:disabled { + opacity: 0.45; + cursor: not-allowed; +} + button, a, input, @@ -1224,14 +2963,13 @@ table { th { background: var(--ops-surface-muted); color: var(--ops-muted); - font-size: 12px; - text-transform: uppercase; - letter-spacing: 0.03em; + font-size: 11px; + letter-spacing: 0.01em; } td, th { - border-bottom: 1px solid #edf1f6; + border-bottom: 1px solid rgba(42, 45, 50, 0.72); } .card h2, @@ -1274,6 +3012,59 @@ th { /* Mobile responsive adjustments for events */ @media (max-width: 768px) { + .portal-topbar { + grid-template-columns: 1fr; + gap: 8px; + padding: 9px 10px; + position: relative; + } + + .portal-nav, + .portal-meta { + justify-content: flex-start; + flex-wrap: wrap; + } + + .portal-topbar-admin { + grid-template-columns: 1fr; + } + + .admin-workspace { + grid-template-columns: 1fr; + } + + .admin-primary-rail, + .admin-page-rail { + position: static; + min-height: auto; + padding: 10px; + border-right: 0; + border-bottom: 1px solid rgba(42, 45, 50, 0.92); + } + + .admin-primary-nav, + .admin-page-nav { + grid-template-columns: repeat(auto-fit, minmax(68px, 1fr)); + } + + .admin-primary-link { + height: 40px; + } + + .admin-page-link { + text-align: center; + } + + .auth-container { + grid-template-columns: 1fr; + align-content: start; + padding: 10px; + } + + .auth-card { + max-width: none; + } + .event-header { flex-direction: column; align-items: stretch; @@ -1308,3 +3099,1368 @@ th { grid-template-columns: 1fr !important; } } + +/* Member experience */ +.auth-shell { + background: + radial-gradient(circle at top left, rgba(193, 214, 175, 0.4), transparent 28%), + radial-gradient(circle at top right, rgba(151, 186, 198, 0.28), transparent 24%), + linear-gradient(180deg, #f5f1e8 0%, #eef3ea 42%, #e8efe3 100%); + color: #1f2c22; +} + +.auth-topbar, +.member-topbar { + background: rgba(245, 241, 232, 0.86); + border-bottom: 1px solid rgba(83, 102, 84, 0.16); + backdrop-filter: blur(14px); +} + +.auth-shell .portal-mark, +.member-topbar .portal-mark { + background: linear-gradient(135deg, #314d3d 0%, #597861 100%); + box-shadow: 0 10px 24px rgba(49, 77, 61, 0.18); +} + +.member-experience { + min-height: calc(100vh - 44px); + background: + radial-gradient(circle at top left, rgba(197, 214, 196, 0.32), transparent 30%), + linear-gradient(180deg, #f8faf7 0%, #eef4ec 45%, #e7efe3 100%); + color: #203125; +} + +.auth-shell .portal-brand-text h1, +.member-topbar .portal-brand-text h1 { + color: #1d2b21; +} + +.auth-shell .portal-subtitle, +.member-topbar .portal-subtitle { + color: #5f6f62; +} + +.auth-container { + padding: 40px 24px; +} + +.auth-container-wide { + grid-template-columns: minmax(300px, 420px) minmax(380px, 620px); +} + +.auth-welcome-card, +.auth-card, +.member-card { + border: 1px solid rgba(89, 120, 98, 0.16); + border-radius: 24px; + background: rgba(255, 252, 246, 0.86); + box-shadow: 0 24px 60px rgba(44, 66, 47, 0.08); +} + +.auth-welcome-card { + padding: 32px; + gap: 18px; +} + +.auth-kicker, +.member-card-kicker, +.member-hero-kicker { + color: #4d6955; + letter-spacing: 0.12em; + font-weight: 700; +} + +.auth-welcome-card h2, +.member-hero-title { + font-family: Georgia, "Times New Roman", serif; + color: #203125; + line-height: 1.06; +} + +.auth-welcome-card h2 { + font-size: clamp(2.1rem, 3vw, 3rem); +} + +.auth-welcome-card p, +.auth-card-copy, +.member-hero-copy, +.member-muted-copy, +.membership-confirm-copy { + color: #405244; + font-size: 15px; + line-height: 1.65; +} + +.auth-feature-list { + display: grid; + gap: 10px; +} + +.auth-feature-item { + display: flex; + align-items: flex-start; + gap: 10px; + color: #314239; + line-height: 1.5; +} + +.auth-feature-item::before { + content: ""; + width: 9px; + height: 9px; + margin-top: 7px; + border-radius: 50%; + background: linear-gradient(135deg, #758d63 0%, #315846 100%); + flex: 0 0 auto; +} + +.auth-card { + max-width: none; +} + +.auth-card-wide { + max-width: 620px; +} + +.auth-card-head { + min-height: 72px; + padding: 0 24px; + border-bottom: 1px solid rgba(89, 120, 98, 0.14); +} + +.auth-card-head h2 { + font-family: Georgia, "Times New Roman", serif; + font-size: 28px; + color: #203125; +} + +.auth-card-head span { + color: #6a7f6f; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.auth-card-body { + padding: 24px; +} + +.auth-card form, +.auth-footer { + padding: 0; +} + +.auth-footer { + padding: 18px 24px 24px; + border-top: 1px solid rgba(89, 120, 98, 0.14); + color: #405244; +} + +.auth-footer a { + color: #2f5d49; + font-weight: 600; +} + +.auth-form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px 18px; +} + +.form-group-full { + grid-column: 1 / -1; +} + +.auth-shell .form-group label, +.member-experience .form-group label { + color: #2d4035; + font-size: 13px; + font-weight: 700; +} + +.auth-shell .form-group input, +.auth-shell .form-group textarea, +.auth-shell .form-group select, +.member-experience .form-group input, +.member-experience .form-group textarea, +.member-experience .form-group select { + min-height: 46px; + padding: 11px 14px; + border: 1px solid rgba(89, 120, 98, 0.24); + border-radius: 14px; + background: rgba(255, 255, 255, 0.82); + color: #203125; + font-size: 14px; +} + +.auth-shell .form-group textarea, +.member-experience .form-group textarea { + min-height: 108px; + resize: vertical; +} + +.auth-shell .form-group input::placeholder, +.auth-shell .form-group textarea::placeholder, +.member-experience .form-group input::placeholder, +.member-experience .form-group textarea::placeholder { + color: #819082; +} + +.auth-shell .form-group input:focus, +.auth-shell .form-group textarea:focus, +.auth-shell .form-group select:focus, +.member-experience .form-group input:focus, +.member-experience .form-group textarea:focus, +.member-experience .form-group select:focus { + border-color: #5f7f69; + box-shadow: 0 0 0 4px rgba(95, 127, 105, 0.14); +} + +.form-hint { + display: block; + margin-top: 6px; + color: #6c7f70; + font-size: 12px; +} + +.hint-success { + color: #2e7d4f; +} + +.hint-error { + color: #b44747; +} + +.field-success { + border-color: rgba(46, 125, 79, 0.5) !important; +} + +.field-error { + border-color: rgba(180, 71, 71, 0.56) !important; +} + +.auth-shell .btn, +.member-experience .btn, +.member-topbar .btn { + min-height: 44px; + padding: 10px 18px; + border-radius: 999px; + font-size: 13px; + font-weight: 700; +} + +.auth-shell .btn-primary, +.member-experience .btn-primary, +.member-topbar .btn-primary { + border-color: #315846; + background: linear-gradient(135deg, #315846 0%, #5a7a61 100%); + color: #fff; +} + +.auth-shell .btn-primary:hover, +.member-experience .btn-primary:hover, +.member-topbar .btn-primary:hover { + background: linear-gradient(135deg, #294738 0%, #4f6f57 100%); + color: #fff; +} + +.auth-shell .btn-secondary, +.member-experience .btn-secondary, +.member-topbar .btn-secondary { + border-color: rgba(89, 120, 98, 0.28); + background: rgba(255, 255, 255, 0.7); + color: #274635; +} + +.auth-shell .btn-secondary:hover, +.member-experience .btn-secondary:hover, +.member-topbar .btn-secondary:hover { + border-color: rgba(49, 88, 70, 0.36); + background: rgba(255, 255, 255, 0.96); + color: #203125; +} + +.auth-shell .alert, +.member-experience .alert { + border-radius: 16px; +} + +.auth-shell .alert-success, +.member-experience .alert-success { + background: rgba(69, 132, 88, 0.12); + color: #245538; + border-color: rgba(69, 132, 88, 0.18); +} + +.auth-shell .alert-error, +.member-experience .alert-error { + background: rgba(194, 78, 78, 0.1); + color: #8a2d2d; + border-color: rgba(194, 78, 78, 0.2); +} + +.member-experience { + padding-top: 28px; + padding-bottom: 36px; +} + +.member-topbar { + background: rgba(248, 250, 247, 0.92); + border-bottom: 1px solid rgba(89, 120, 98, 0.18); + backdrop-filter: blur(14px); +} + +.member-topbar .portal-tab { + border-color: rgba(89, 120, 98, 0.18); + color: #425547; + background: rgba(255, 255, 255, 0.66); +} + +.member-topbar .portal-tab:hover { + background: rgba(89, 120, 98, 0.08); + color: #203125; +} + +.member-topbar .portal-tab.active { + background: #315846; + border-color: #315846; + color: #fff; +} + +.member-topbar .portal-switch-button, +.member-topbar .portal-exit-button { + border-color: rgba(89, 120, 98, 0.26); + background: rgba(255, 255, 255, 0.72); + color: #274635; +} + +.member-topbar .portal-switch-button:hover, +.member-topbar .portal-exit-button:hover { + background: rgba(89, 120, 98, 0.08); + border-color: rgba(49, 88, 70, 0.36); + color: #203125; +} + +.member-loading-state { + min-height: 60vh; + display: grid; + place-items: center; + color: #314239; + font-size: 16px; +} + +.member-hero { + display: grid; + grid-template-columns: minmax(0, 1.6fr) minmax(280px, 1fr); + gap: 18px; + margin-bottom: 20px; + padding: 24px 28px; + border-radius: 28px; + background: + linear-gradient(135deg, rgba(255, 252, 246, 0.92) 0%, rgba(248, 245, 236, 0.88) 100%), + radial-gradient(circle at top right, rgba(143, 172, 140, 0.16), transparent 38%); + border: 1px solid rgba(89, 120, 98, 0.14); + box-shadow: 0 24px 60px rgba(44, 66, 47, 0.08); +} + +.member-hero-title { + margin: 4px 0 10px; + font-size: clamp(2rem, 2.6vw, 3rem); +} + +.member-stat-strip { + display: grid; + gap: 12px; +} + +.toast-viewport { + position: fixed; + right: 16px; + bottom: 16px; + z-index: 2800; + width: min(380px, calc(100vw - 32px)); + display: grid; + gap: 10px; +} + +.toast { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 12px; + align-items: start; + padding: 12px 14px; + border: 1px solid rgba(42, 46, 52, 0.9); + border-left: 4px solid #4797ff; + background: rgba(24, 27, 31, 0.98); + color: #e6ebf2; + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.26); +} + +.toast-success { + border-left-color: #2fa252; +} + +.toast-error { + border-left-color: #ee6368; +} + +.toast-info { + border-left-color: #4797ff; +} + +.toast-message { + font-size: 13px; + line-height: 1.45; + word-break: break-word; +} + +.toast-close { + width: 24px; + height: 24px; + border: 0; + background: transparent; + color: #b5c0ce; + font-size: 18px; + line-height: 1; + cursor: pointer; +} + +.toast-close:hover { + color: #fff; +} + +.confirm-dialog { + width: min(460px, calc(100vw - 32px)); +} + +.confirm-dialog-title { + margin: 0 0 10px; + color: #f3f6fa; +} + +.confirm-dialog-title.danger { + color: #ffa2a6; +} + +.confirm-dialog-message { + margin: 0 0 20px; + color: #c6ced8; + line-height: 1.5; +} + +.member-stat-chip { + padding: 16px 18px; + border-radius: 18px; + background: rgba(255, 255, 255, 0.62); + border: 1px solid rgba(89, 120, 98, 0.14); +} + +.member-stat-label { + display: block; + margin-bottom: 6px; + color: #4c5e50; + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.member-stat-value { + color: #203125; + font-size: 20px; +} + +.member-overview-grid { + grid-template-columns: minmax(320px, 420px) minmax(0, 1fr); + align-items: start; +} + +.member-card { + padding: 24px; + margin-bottom: 20px; +} + +.member-card-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 14px; + margin-bottom: 18px; +} + +.member-card h3, +.member-card h4, +.member-section-heading { + color: #203125; +} + +.member-tier-title { + margin-bottom: 16px; + color: #315846 !important; + font-size: 24px; +} + +.member-data-list { + display: grid; + gap: 10px; +} + +.member-data-row { + display: grid; + grid-template-columns: minmax(130px, 180px) 1fr; + gap: 12px; + color: #405244; +} + +.member-data-row strong { + color: #233227; +} + +.member-info-panel, +.membership-summary-panel, +.membership-tier-benefits { + margin-top: 18px; + padding: 16px 18px; + border-radius: 18px; + background: rgba(239, 245, 236, 0.92); + border: 1px solid rgba(89, 120, 98, 0.14); +} + +.member-info-panel p, +.membership-summary-panel p, +.membership-tier-benefits p { + margin-top: 8px; + color: #4c5e50 !important; +} + +.member-table { + width: 100%; +} + +.member-table th, +.member-table td { + padding: 14px 12px; + text-align: left; + border-bottom: 1px solid rgba(89, 120, 98, 0.14); +} + +.member-table th { + background: rgba(239, 245, 236, 0.9); + color: #405244; + font-size: 12px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.member-table td { + color: #2c3c30; +} + +.member-table-caps { + text-transform: capitalize; +} + +.member-experience .status-badge { + border-radius: 999px; + border-color: rgba(89, 120, 98, 0.14); + background: rgba(255, 255, 255, 0.76); +} + +.member-experience .profile-menu-trigger { + background: rgba(255, 255, 255, 0.78); + border-color: rgba(89, 120, 98, 0.22); + color: #203125; +} + +.member-experience .profile-menu-chevron { + color: #6a7f6f; +} + +.member-experience .profile-menu-dropdown { + background: rgba(255, 253, 248, 0.96); + border-color: rgba(89, 120, 98, 0.18); +} + +.member-experience .profile-menu-summary { + background: rgba(239, 245, 236, 0.94); + border-bottom-color: rgba(89, 120, 98, 0.14); +} + +.member-experience .profile-menu-summary h4 { + color: #516657; +} + +.member-experience .profile-menu-edit { + background: rgba(49, 88, 70, 0.08); + border-color: rgba(49, 88, 70, 0.18); + color: #274635; +} + +.member-experience .profile-menu-details { + color: #55685b; +} + +.member-experience .profile-menu-details strong { + color: #203125; +} + +.member-experience .profile-menu-item { + color: #203125; + border-top-color: rgba(89, 120, 98, 0.12); +} + +.member-experience .profile-menu-item:hover { + background: rgba(89, 120, 98, 0.08); +} + +.member-settings-card { + max-width: 980px; +} + +.member-settings-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 12px 16px; +} + +.member-settings-actions { + display: flex; + justify-content: flex-end; + margin-top: 12px; +} + +.member-settings-divider { + height: 1px; + margin: 26px 0 18px; + background: rgba(89, 120, 98, 0.14); +} + +.member-section-heading { + margin-bottom: 10px; +} + +.membership-tier-grid, +.membership-payment-options { + display: grid; + gap: 14px; +} + +.membership-tier-card { + padding: 18px; + border-radius: 20px; + border: 1px solid rgba(89, 120, 98, 0.16); + background: rgba(255, 255, 255, 0.75); + cursor: pointer; +} + +.membership-tier-card:hover { + border-color: rgba(49, 88, 70, 0.34); + box-shadow: 0 16px 30px rgba(49, 77, 61, 0.1); +} + +.membership-tier-header { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: flex-start; +} + +.membership-tier-header h4 { + margin: 0; + color: #274635 !important; +} + +.membership-tier-price { + color: #315846; + font-size: 18px; + font-weight: 800; +} + +.membership-tier-description { + margin-top: 10px; + color: #405244 !important; +} + +.membership-payment-stage, +.membership-action-row { + display: grid; + gap: 14px; +} + +.membership-payment-heading { + margin-bottom: 2px; +} + +.membership-payment-options .btn { + justify-content: space-between; + padding: 16px 18px; +} + +.membership-payment-option-copy { + display: grid; + gap: 4px; +} + +.membership-payment-option-copy div { + font-size: 14px; + opacity: 0.86; +} + +.membership-cash-notice { + margin-bottom: 20px; + padding: 16px 18px; + border-radius: 18px; + background: rgba(214, 177, 78, 0.12); + border: 1px solid rgba(214, 177, 78, 0.22); + color: #6f5620; +} + +.membership-cash-notice p { + margin-top: 8px; +} + +.membership-setup-actions { + display: flex; + justify-content: center; + margin-top: 20px; +} + +.events-container { + gap: 14px; +} + +.member-card .event-card { + border: 1px solid rgba(89, 120, 98, 0.14); + border-radius: 20px; + padding: 18px; + background: rgba(255, 255, 255, 0.72); +} + +.member-card .event-title { + color: #203125; +} + +.member-card .event-datetime, +.member-card .event-location, +.member-card .event-description { + color: #405244; +} + +.member-card .event-location::before { + content: "Location"; + display: inline-block; + margin-right: 8px; + color: #55685b; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.member-card .rsvp-btn { + border-color: rgba(89, 120, 98, 0.14); + background: rgba(248, 245, 236, 0.88); + color: #405244; +} + +.member-card .rsvp-btn:not(.active) { + opacity: 1; + filter: none; +} + +.member-card .rsvp-btn-attending.active { + background-color: rgba(69, 132, 88, 0.12); + border-color: rgba(69, 132, 88, 0.22); + color: #245538; +} + +.member-card .rsvp-btn-maybe.active { + background-color: rgba(214, 177, 78, 0.14); + border-color: rgba(214, 177, 78, 0.22); + color: #6f5620; +} + +.member-card .rsvp-btn-not-attending.active { + background-color: rgba(194, 78, 78, 0.12); + border-color: rgba(194, 78, 78, 0.2); + color: #8a2d2d; +} + +.member-card .event-rsvp-status.attending { + background-color: rgba(69, 132, 88, 0.12); + border-color: rgba(69, 132, 88, 0.18); + color: #245538; +} + +.member-card .event-rsvp-status.maybe { + background-color: rgba(214, 177, 78, 0.14); + border-color: rgba(214, 177, 78, 0.2); + color: #6f5620; +} + +.member-card .event-rsvp-status.not_attending { + background-color: rgba(194, 78, 78, 0.12); + border-color: rgba(194, 78, 78, 0.2); + color: #8a2d2d; +} + +.site-footer { + background: rgba(248, 250, 247, 0.94); + color: #5f6f62; + border-top: 1px solid rgba(89, 120, 98, 0.14); +} + +.site-footer-caption { + margin-top: 8px; +} + +.site-footer a { + color: #2f5d49; +} + +.cookie-banner { + background: rgba(255, 255, 255, 0.94); + color: #203125; + border-color: rgba(89, 120, 98, 0.18); + border-left-color: #315846; +} + +.cookie-banner-button { + padding: 6px 12px; +} + +.member-experience { + min-height: 100%; + background: + radial-gradient(circle at top left, rgba(141, 170, 144, 0.14), transparent 28%), + linear-gradient(180deg, rgba(252, 253, 250, 0.98) 0%, rgba(244, 248, 242, 0.98) 100%); + color: #203125; +} + +.portal-container-admin, +.admin-workspace { + background: var(--ops-bg); +} + +.member-experience .card, +.member-experience .profile-questions-form.member-surface { + background: rgba(255, 252, 247, 0.92); + border: 1px solid rgba(89, 120, 98, 0.14); + box-shadow: 0 18px 36px rgba(56, 82, 61, 0.08); +} + +.member-experience .form-group label, +.member-experience .profile-question-label, +.member-experience .profile-question-readonly { + color: #203125; +} + +.member-experience .form-group input, +.member-experience .form-group textarea, +.member-experience .form-group select, +.member-experience .profile-question-input { + background: rgba(255, 255, 255, 0.94); + color: #203125; + border: 1px solid rgba(89, 120, 98, 0.18); + border-radius: 10px; +} + +.member-experience .form-group input::placeholder, +.member-experience .form-group textarea::placeholder, +.member-experience .profile-question-input::placeholder { + color: #7a8a7c; +} + +.member-experience .alert-success { + background: rgba(69, 132, 88, 0.1); + color: #245538; + border-color: rgba(69, 132, 88, 0.18); +} + +.member-experience .alert-error { + background: rgba(194, 78, 78, 0.1); + color: #8a2d2d; + border-color: rgba(194, 78, 78, 0.18); +} + +.member-inline-action { + margin-top: 16px; +} + +.member-rsvp-state { + text-transform: capitalize; +} + +.profile-questions-form { + margin-top: 20px; +} + +.profile-questions-form.admin-surface { + padding: 0; + background: transparent; + border: 0; + margin-top: 0; +} + +.profile-questions-form.admin-surface .profile-questions-title { + margin-bottom: 6px; + padding: 0; +} + +.profile-questions-form.admin-surface .profile-questions-description, +.profile-questions-form.admin-surface .profile-questions-search, +.profile-questions-form.admin-surface .alert, +.profile-questions-form.admin-surface .profile-questions-pagination, +.profile-questions-form.admin-surface .profile-questions-actions { + margin-left: 0; + margin-right: 0; +} + +.profile-questions-form.admin-surface .profile-questions-search { + margin-bottom: 16px; +} + +.profile-questions-form.admin-surface .profile-question-input, +.profile-questions-form.admin-surface .profile-question-readonly { + background: #111214; + color: #e6ebf2; + border: 1px solid rgba(70, 75, 83, 0.9); +} + +.profile-questions-form.admin-surface .profile-question-input::placeholder { + color: #8d96a3; +} + +.profile-questions-form.admin-surface .profile-question-input:focus { + border-color: var(--ops-accent); + box-shadow: 0 0 0 3px rgba(71, 151, 255, 0.12); +} + +.profile-questions-form.admin-surface .profile-question-label, +.profile-questions-form.admin-surface .profile-questions-title { + color: #f3f6fa; +} + +.profile-questions-form.admin-surface .profile-question-help, +.profile-questions-form.admin-surface .profile-questions-description, +.profile-questions-form.admin-surface .profile-questions-empty, +.profile-questions-form.admin-surface .profile-questions-page-copy, +.profile-questions-form.admin-surface .profile-question-lock-note { + color: #8d96a3; +} + +.profile-questions-title { + margin-bottom: 8px; +} + +.profile-questions-description { + margin-bottom: 16px; +} + +.profile-questions-search, +.profile-questions-list { + display: grid; +} + +.profile-questions-search { + gap: 10px; + margin-bottom: 14px; +} + +.profile-questions-list { + gap: 16px; +} + +.profile-question-label { + display: block; + margin-bottom: 4px; + font-weight: 600; +} + +.profile-question-required { + color: #c24e4e; +} + +.profile-question-help, +.profile-questions-description, +.profile-questions-empty, +.profile-questions-page-copy { + color: #4c5e50; + font-size: 13px; +} + +.profile-question-help { + margin-bottom: 0; +} + +.profile-question-lock-note { + margin-top: 6px; + grid-column: 1 / -1; + color: #4c5e50; + font-size: 12px; + font-weight: 600; +} + +.profile-question-input { + width: 100%; + padding: 10px 12px; +} + +.profile-questions-pagination, +.profile-questions-pager-buttons, +.profile-questions-actions { + display: flex; + gap: 10px; +} + +.profile-questions-pagination { + margin-top: 14px; + align-items: center; + justify-content: space-between; +} + +.profile-questions-pager-buttons { + gap: 8px; +} + +.profile-questions-pager-button { + padding: 6px 12px; + font-size: 13px; +} + +.profile-questions-actions { + margin-top: 16px; + justify-content: flex-end; +} + +.member-hero { + border-radius: 18px; +} + +.member-stat-chip, +.member-info-panel, +.membership-summary-panel, +.membership-tier-benefits, +.membership-tier-card, +.member-card .event-card, +.cookie-banner, +.site-footer, +.member-experience .profile-menu-trigger, +.member-experience .profile-menu-dropdown { + border-radius: 12px; +} + +.member-card, +.profile-questions-form.member-surface { + border-radius: 14px; +} + +/* Dark mode reset for member/auth surfaces */ +.auth-shell { + background: + radial-gradient(circle at top left, rgba(33, 40, 46, 0.42), transparent 26%), + radial-gradient(circle at top right, rgba(22, 30, 38, 0.34), transparent 22%), + linear-gradient(180deg, #111417 0%, #14181c 48%, #171c21 100%); + color: var(--ops-text); +} + +.auth-topbar, +.member-topbar { + background: rgba(24, 27, 31, 0.9); + border-bottom: 1px solid rgba(47, 53, 60, 0.9); +} + +.auth-shell .portal-mark, +.member-topbar .portal-mark { + background: var(--ops-accent-dark); + box-shadow: none; +} + +.auth-shell .portal-brand-text h1, +.member-topbar .portal-brand-text h1 { + color: #f3f6fa; +} + +.auth-shell .portal-subtitle, +.member-topbar .portal-subtitle { + color: #aab2bd; +} + +.auth-welcome-card, +.auth-card, +.member-card, +.member-experience .card, +.member-experience .profile-questions-form.member-surface { + background: rgba(24, 27, 31, 0.96); + border-top: 1px solid rgba(70, 76, 86, 0.7); + border-bottom: 1px solid rgba(34, 38, 44, 0.96); + border-left: 1px solid rgba(42, 46, 52, 0.85); + border-right: 1px solid rgba(42, 46, 52, 0.85); + box-shadow: none; + color: var(--ops-text); +} + +.auth-kicker, +.member-card-kicker, +.member-hero-kicker, +.auth-card-head span, +.member-stat-label, +.profile-question-help, +.profile-questions-description, +.profile-questions-empty, +.profile-questions-page-copy, +.profile-question-lock-note, +.member-card .event-location::before { + color: #8d96a3; +} + +.auth-welcome-card h2, +.member-hero-title, +.auth-card-head h2, +.member-card h3, +.member-card h4, +.member-section-heading, +.member-tier-title, +.member-stat-value, +.member-card .event-title, +.member-experience .profile-question-label, +.member-experience .profile-question-readonly { + color: #f3f6fa !important; +} + +.auth-welcome-card p, +.auth-card-copy, +.member-hero-copy, +.member-muted-copy, +.membership-confirm-copy, +.auth-feature-item, +.auth-footer, +.member-data-row, +.membership-tier-description, +.member-card .event-datetime, +.member-card .event-location, +.member-card .event-description, +.member-experience .profile-menu-details, +.site-footer, +.cookie-banner { + color: #c6ced8 !important; +} + +.auth-footer a, +.site-footer a { + color: var(--ops-accent); +} + +.member-experience, +.portal-container-admin, +.admin-workspace { + background: var(--ops-bg); + color: var(--ops-text); +} + +.member-hero { + background: rgba(24, 27, 31, 0.96); + border: 1px solid rgba(42, 46, 52, 0.85); + box-shadow: none; +} + +.member-stat-chip, +.member-info-panel, +.membership-summary-panel, +.membership-tier-benefits, +.membership-tier-card, +.member-card .event-card { + background: rgba(24, 27, 31, 0.92); + border: 1px solid rgba(42, 46, 52, 0.85); + box-shadow: none; +} + +.member-table th { + background: rgba(24, 27, 31, 0.96); + color: #c6ced8; + border-bottom: 1px solid rgba(42, 46, 52, 0.85); +} + +.member-table td, +.member-data-row strong, +.member-info-panel p, +.membership-summary-panel p, +.membership-tier-benefits p { + color: #e6ebf2 !important; +} + +.auth-shell .form-group label, +.member-experience .form-group label { + color: #e6ebf2; +} + +.auth-shell .form-group input, +.auth-shell .form-group textarea, +.auth-shell .form-group select, +.member-experience .form-group input, +.member-experience .form-group textarea, +.member-experience .form-group select, +.member-experience .profile-question-input { + background: #111214; + color: #e6ebf2; + border: 1px solid rgba(70, 75, 83, 0.9); + box-shadow: none; +} + +.auth-shell .form-group input::placeholder, +.auth-shell .form-group textarea::placeholder, +.member-experience .form-group input::placeholder, +.member-experience .form-group textarea::placeholder, +.member-experience .profile-question-input::placeholder { + color: #8d96a3; +} + +.auth-shell .form-group input:focus, +.auth-shell .form-group textarea:focus, +.auth-shell .form-group select:focus, +.member-experience .form-group input:focus, +.member-experience .form-group textarea:focus, +.member-experience .form-group select:focus, +.member-experience .profile-question-input:focus { + border-color: var(--ops-accent); + box-shadow: 0 0 0 3px rgba(71, 151, 255, 0.12); +} + +.auth-shell .btn-primary, +.member-experience .btn-primary, +.member-topbar .btn-primary { + background: transparent; + color: var(--ops-accent); + border-color: rgba(71, 151, 255, 0.72); +} + +.auth-shell .btn-primary:hover, +.member-experience .btn-primary:hover, +.member-topbar .btn-primary:hover { + background: rgba(5, 37, 77, 0.75); + color: #fff; +} + +.auth-shell .btn-secondary, +.member-experience .btn-secondary, +.member-topbar .btn-secondary, +.member-card .rsvp-btn, +.member-experience .profile-menu-trigger { + background: rgba(24, 27, 31, 0.92); + color: #c7cdd6; + border-color: rgba(70, 75, 83, 0.9); +} + +.auth-shell .btn-secondary:hover, +.member-experience .btn-secondary:hover, +.member-topbar .btn-secondary:hover, +.member-topbar .portal-tab:hover, +.member-topbar .portal-switch-button:hover, +.member-topbar .portal-exit-button:hover, +.member-experience .profile-menu-item:hover { + background: rgba(5, 37, 77, 0.75); + color: #fff; +} + +.member-topbar .portal-tab { + border-color: transparent; + background: transparent; + color: #c7cdd6; +} + +.member-topbar .portal-tab.active { + background: #34383e; + border-color: #34383e; + color: #fff; +} + +.member-topbar .portal-switch-button, +.member-topbar .portal-exit-button { + background: transparent; + color: var(--ops-accent); + border-color: rgba(71, 151, 255, 0.72); +} + +.member-experience .status-badge, +.member-experience .profile-menu-dropdown, +.member-experience .profile-menu-summary, +.site-footer, +.cookie-banner { + background: rgba(24, 27, 31, 0.96); + border-color: rgba(42, 46, 52, 0.85); +} + +.member-experience .profile-menu-summary h4, +.member-experience .profile-menu-details strong, +.member-experience .profile-menu-item, +.member-experience .profile-menu-chevron { + color: #e6ebf2; +} + +.site-footer { + border-top: 1px solid rgba(42, 46, 52, 0.85); +} + +.cookie-banner { + border-left-color: var(--ops-accent); +} + +@media (max-width: 960px) { + .auth-container, + .auth-container-wide, + .member-hero, + .member-overview-grid { + grid-template-columns: 1fr; + } + + .auth-card-wide { + max-width: none; + } + + .auth-form-grid { + grid-template-columns: 1fr; + } + + .member-card-header, + .membership-tier-header, + .member-data-row { + grid-template-columns: 1fr; + flex-direction: column; + } + + .member-data-row { + gap: 4px; + } +} + +@media (max-width: 768px) { + .auth-container, + .member-experience { + padding: 18px 12px 28px; + } + + .auth-welcome-card, + .auth-card-body, + .member-card, + .member-hero { + padding: 20px; + } + + .auth-card-head { + padding: 0 20px; + } + + .member-settings-actions, + .membership-setup-actions, + .membership-action-row, + .modal-button-row { + justify-content: stretch; + } + + .member-settings-actions .btn, + .membership-setup-actions .btn, + .membership-action-row .btn { + width: 100%; + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index dc145bd..74a83a2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,6 +1,8 @@ import React from 'react'; import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; import { FeatureFlagProvider } from './contexts/FeatureFlagContext'; +import { ToastProvider } from './contexts/ToastContext'; +import { ConfirmProvider } from './contexts/ConfirmContext'; import Register from './pages/Register'; import Login from './pages/Login'; import ForgotPassword from './pages/ForgotPassword'; @@ -8,9 +10,10 @@ import ResetPassword from './pages/ResetPassword'; import Dashboard from './pages/Dashboard'; import PrivacyPolicy from './pages/PrivacyPolicy'; import TermsOfService from './pages/TermsOfService'; +import AppFooter from './components/layout/AppFooter'; +import CookieBanner from './components/layout/CookieBanner'; import './App.css'; import { useState } from 'react'; -import { Link } from 'react-router-dom'; const App: React.FC = () => { const [cookieDismissed, setCookieDismissed] = useState( @@ -25,40 +28,34 @@ const App: React.FC = () => { return ( -
-
- - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - -
-
-
- Privacy Policy - Terms of Service + + +
+
+ + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + +
+ + {!cookieDismissed && ( + + )}
-
SASA Portal
-
- {!cookieDismissed && ( -
-
- We use cookies for session authentication, security, and basic site functionality. -
- -
- )} -
+ +
); diff --git a/frontend/src/components/AdminProfileQuestionManager.tsx b/frontend/src/components/AdminProfileQuestionManager.tsx index e98bb5b..8ca9c0b 100644 --- a/frontend/src/components/AdminProfileQuestionManager.tsx +++ b/frontend/src/components/AdminProfileQuestionManager.tsx @@ -6,11 +6,16 @@ import { ProfileQuestionUpsertData, userService } from '../services/membershipService'; +import { useConfirm } from '../contexts/ConfirmContext'; interface AdminProfileQuestionManagerProps { onQuestionsChanged?: () => void; + openEditorToken?: number; + searchTerm?: string; } +type QuestionSortKey = 'order' | 'label' | 'type' | 'key' | 'status'; + const INPUT_TYPES: ProfileQuestionInputType[] = ['text', 'number', 'boolean', 'date', 'select']; const optionsToText = (options: ProfileQuestionOption[] | null | undefined): string => { @@ -34,13 +39,22 @@ const textToOptions = (value: string): ProfileQuestionOption[] => { .filter((option) => option.label.length > 0 && option.value.length > 0); }; -const AdminProfileQuestionManager: React.FC = ({ onQuestionsChanged }) => { +const AdminProfileQuestionManager: React.FC = ({ + onQuestionsChanged, + openEditorToken = 0, + searchTerm = '' +}) => { + const { confirm } = useConfirm(); const [questions, setQuestions] = useState([]); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); const [editingQuestionId, setEditingQuestionId] = useState(null); - const [listSearch, setListSearch] = useState(''); + const [isEditorOpen, setIsEditorOpen] = useState(false); + const [currentPage, setCurrentPage] = useState(1); + const [sortKey, setSortKey] = useState('order'); + const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc'); + const pageSize = 10; const emptyForm: ProfileQuestionUpsertData = { key: '', @@ -77,9 +91,19 @@ const AdminProfileQuestionManager: React.FC = loadQuestions(); }, []); - const dependencyCandidates = useMemo(() => { - return questions.filter((question) => question.id !== editingQuestionId); - }, [questions, editingQuestionId]); + useEffect(() => { + if (openEditorToken > 0) { + setFormData(emptyForm); + setOptionsText(''); + setEditingQuestionId(null); + setIsEditorOpen(true); + } + }, [openEditorToken]); + + const dependencyCandidates = useMemo( + () => questions.filter((question) => question.id !== editingQuestionId), + [questions, editingQuestionId] + ); const selectedDependencyQuestion = useMemo(() => { if (!formData.depends_on_question_id) { @@ -89,7 +113,7 @@ const AdminProfileQuestionManager: React.FC = }, [questions, formData.depends_on_question_id]); const filteredQuestions = useMemo(() => { - const term = listSearch.trim().toLowerCase(); + const term = searchTerm.trim().toLowerCase(); if (!term) { return questions; } @@ -97,7 +121,60 @@ const AdminProfileQuestionManager: React.FC = question.label.toLowerCase().includes(term) || question.key.toLowerCase().includes(term) ); - }, [questions, listSearch]); + }, [questions, searchTerm]); + + const sortedQuestions = useMemo(() => { + const compareValues = (left: string | number, right: string | number) => { + if (typeof left === 'number' && typeof right === 'number') { + return left - right; + } + return String(left).localeCompare(String(right), undefined, { numeric: true, sensitivity: 'base' }); + }; + + return [...filteredQuestions].sort((left, right) => { + let result = 0; + + switch (sortKey) { + case 'order': + result = compareValues(left.display_order ?? 0, right.display_order ?? 0); + break; + case 'label': + result = compareValues(left.label, right.label); + break; + case 'type': + result = compareValues(left.input_type, right.input_type); + break; + case 'key': + result = compareValues(left.key, right.key); + break; + case 'status': + result = compareValues(left.is_active ? 0 : 1, right.is_active ? 0 : 1); + break; + } + + if (result === 0) { + result = compareValues(left.label, right.label); + } + + return sortDirection === 'asc' ? result : -result; + }); + }, [filteredQuestions, sortDirection, sortKey]); + + const totalPages = Math.max(1, Math.ceil(sortedQuestions.length / pageSize)); + const paginatedQuestions = useMemo( + () => sortedQuestions.slice((currentPage - 1) * pageSize, currentPage * pageSize), + [sortedQuestions, currentPage] + ); + + useEffect(() => { + setCurrentPage(1); + }, [searchTerm]); + + useEffect(() => { + if (currentPage > totalPages) { + setCurrentPage(totalPages); + } + }, [currentPage, totalPages]); const resetForm = () => { setFormData(emptyForm); @@ -105,6 +182,11 @@ const AdminProfileQuestionManager: React.FC = setEditingQuestionId(null); }; + const closeEditor = () => { + resetForm(); + setIsEditorOpen(false); + }; + const handleEdit = (question: ProfileQuestion) => { setEditingQuestionId(question.id); setFormData({ @@ -122,6 +204,7 @@ const AdminProfileQuestionManager: React.FC = depends_on_value: question.depends_on_value }); setOptionsText(optionsToText(question.options)); + setIsEditorOpen(true); }; const handleSave = async () => { @@ -146,7 +229,7 @@ const AdminProfileQuestionManager: React.FC = } await loadQuestions(); - resetForm(); + closeEditor(); onQuestionsChanged?.(); } catch (err: any) { setError(err.response?.data?.detail || err.message || 'Failed to save question'); @@ -156,7 +239,13 @@ const AdminProfileQuestionManager: React.FC = }; const handleDeactivate = async (questionId: number) => { - if (!window.confirm('Deactivate this question? Existing answers are kept.')) { + const confirmed = await confirm({ + title: 'Deactivate question', + message: 'Deactivate this question? Existing answers are kept.', + confirmLabel: 'Deactivate', + tone: 'danger' + }); + if (!confirmed) { return; } @@ -169,238 +258,275 @@ const AdminProfileQuestionManager: React.FC = } }; - return ( -
-

Profile Questions (Admin)

-

- Manage the set of profile questions users can answer. You can add follow-up questions with dependencies. -

+ const toggleSort = (nextKey: QuestionSortKey) => { + if (sortKey === nextKey) { + setSortDirection((prev) => (prev === 'asc' ? 'desc' : 'asc')); + return; + } + setSortKey(nextKey); + setSortDirection('asc'); + }; + const renderSortArrow = (active: boolean, direction: 'asc' | 'desc') => ( + + + + ); + + return ( +
{error &&
{error}
} -
- setFormData((prev) => ({ ...prev, key: event.target.value }))} - style={{ padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }} - /> - setFormData((prev) => ({ ...prev, label: event.target.value }))} - style={{ padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }} - /> -