Merge pull request 'ESP implimentation & UI changes' (#2) from esp-implimentation into main

Reviewed-on: nathanb/sasa-membership#2
This commit is contained in:
2026-05-08 15:50:45 -04:00
32 changed files with 7480 additions and 2740 deletions
+2 -1
View File
@@ -1,5 +1,5 @@
from fastapi import APIRouter 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() 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(email_templates.router, prefix="/email-templates", tags=["email-templates"])
api_router.include_router(events.router, prefix="/events", tags=["events"]) 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(feature_flags.router, prefix="/feature-flags", tags=["feature-flags"])
api_router.include_router(esp.router, prefix="/esp", tags=["esp-rfid"])
+8 -7
View File
@@ -6,6 +6,7 @@ from typing import List
import uuid import uuid
from ...core.database import get_db 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 ...core.security import verify_password, get_password_hash, create_access_token
from ...models.models import User, UserRole, PasswordResetToken from ...models.models import User, UserRole, PasswordResetToken
from ...schemas import ( from ...schemas import (
@@ -85,7 +86,7 @@ async def login(
) )
# Update last login # Update last login
user.last_login = datetime.utcnow() user.last_login = utc_now()
db.commit() db.commit()
# Create access token # Create access token
@@ -120,7 +121,7 @@ async def login_json(
) )
# Update last login # Update last login
user.last_login = datetime.utcnow() user.last_login = utc_now()
db.commit() db.commit()
# Create access token # Create access token
@@ -149,12 +150,12 @@ async def forgot_password(
db.query(PasswordResetToken).filter( db.query(PasswordResetToken).filter(
PasswordResetToken.user_id == user.id, PasswordResetToken.user_id == user.id,
PasswordResetToken.used == False, PasswordResetToken.used == False,
PasswordResetToken.expires_at > datetime.utcnow() PasswordResetToken.expires_at > utc_now()
).update({"used": True}) ).update({"used": True})
# Generate new reset token # Generate new reset token
reset_token = str(uuid.uuid4()) 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 # Create password reset token
db_token = PasswordResetToken( db_token = PasswordResetToken(
@@ -192,7 +193,7 @@ async def reset_password(
reset_token = db.query(PasswordResetToken).filter( reset_token = db.query(PasswordResetToken).filter(
PasswordResetToken.token == request.token, PasswordResetToken.token == request.token,
PasswordResetToken.used == False, PasswordResetToken.used == False,
PasswordResetToken.expires_at > datetime.utcnow() PasswordResetToken.expires_at > utc_now()
).first() ).first()
if not reset_token: if not reset_token:
@@ -212,7 +213,7 @@ async def reset_password(
# Update password # Update password
hashed_password = get_password_hash(request.new_password) hashed_password = get_password_hash(request.new_password)
user.hashed_password = hashed_password user.hashed_password = hashed_password
user.updated_at = datetime.utcnow() user.updated_at = utc_now()
# Mark token as used # Mark token as used
reset_token.used = True reset_token.used = True
@@ -239,7 +240,7 @@ async def change_password(
# Update password # Update password
hashed_password = get_password_hash(request.new_password) hashed_password = get_password_hash(request.new_password)
current_user.hashed_password = hashed_password current_user.hashed_password = hashed_password
current_user.updated_at = datetime.utcnow() current_user.updated_at = utc_now()
db.commit() db.commit()
+3 -2
View File
@@ -6,6 +6,7 @@ from ...api.dependencies import get_admin_user
from ...models.models import User from ...models.models import User
from typing import Dict, Any, List from typing import Dict, Any, List
from ...core.database import get_db from ...core.database import get_db
from ...core.datetime import to_zulu_iso
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
router = APIRouter() router = APIRouter()
@@ -95,7 +96,7 @@ async def get_bounce_list(
"email": bounce.email, "email": bounce.email,
"bounce_type": bounce.bounce_type.value, "bounce_type": bounce.bounce_type.value,
"bounce_reason": bounce.bounce_reason, "bounce_reason": bounce.bounce_reason,
"bounce_date": bounce.bounce_date.isoformat(), "bounce_date": to_zulu_iso(bounce.bounce_date),
"is_active": bounce.is_active, "is_active": bounce.is_active,
"smtp2go_message_id": bounce.smtp2go_message_id "smtp2go_message_id": bounce.smtp2go_message_id
} }
@@ -132,7 +133,7 @@ async def get_bounce_history(
"id": bounce.id, "id": bounce.id,
"bounce_type": bounce.bounce_type.value, "bounce_type": bounce.bounce_type.value,
"bounce_reason": bounce.bounce_reason, "bounce_reason": bounce.bounce_reason,
"bounce_date": bounce.bounce_date.isoformat(), "bounce_date": to_zulu_iso(bounce.bounce_date),
"is_active": bounce.is_active, "is_active": bounce.is_active,
"smtp2go_message_id": bounce.smtp2go_message_id "smtp2go_message_id": bounce.smtp2go_message_id
} }
+17 -9
View File
@@ -1,9 +1,9 @@
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from typing import List from typing import List
from datetime import datetime
from ...core.database import get_db from ...core.database import get_db
from ...core.datetime import utc_now
from ...models.models import Event, EventRSVP, User, EventStatus from ...models.models import Event, EventRSVP, User, EventStatus
from ...schemas import ( from ...schemas import (
EventCreate, EventUpdate, EventResponse, EventRSVPResponse, EventRSVPUpdate, MessageResponse EventCreate, EventUpdate, EventResponse, EventRSVPResponse, EventRSVPUpdate, MessageResponse
@@ -13,6 +13,10 @@ from ...api.dependencies import get_current_active_user, get_admin_user
router = APIRouter() router = APIRouter()
def _utc_time_string(value) -> str:
return value.strftime("%H:%M")
@router.get("/", response_model=List[EventResponse]) @router.get("/", response_model=List[EventResponse])
async def get_events( async def get_events(
current_user: User = Depends(get_current_active_user), current_user: User = Depends(get_current_active_user),
@@ -34,9 +38,9 @@ async def get_upcoming_events(
db: Session = Depends(get_db) db: Session = Depends(get_db)
): ):
"""Get upcoming events""" """Get upcoming events"""
now = datetime.now() now = utc_now()
events = db.query(Event).filter( events = db.query(Event).filter(
Event.event_date >= now.date(), Event.event_date >= now,
Event.status == EventStatus.PUBLISHED Event.status == EventStatus.PUBLISHED
).order_by(Event.event_date).all() ).order_by(Event.event_date).all()
return events return events
@@ -50,7 +54,7 @@ async def create_event(
): ):
"""Create a new event (admin only)""" """Create a new event (admin only)"""
# Validate event date is in the future # Validate event date is in the future
if event_data.event_date < datetime.now(): if event_data.event_date < utc_now():
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="Event date must be in the future" detail="Event date must be in the future"
@@ -60,7 +64,7 @@ async def create_event(
title=event_data.title, title=event_data.title,
description=event_data.description, description=event_data.description,
event_date=event_data.event_date, 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, location=event_data.location,
max_attendees=event_data.max_attendees, max_attendees=event_data.max_attendees,
status=EventStatus.DRAFT, status=EventStatus.DRAFT,
@@ -89,10 +93,14 @@ async def update_event(
) )
# Update fields # 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) setattr(event, field, value)
event.updated_at = datetime.now() event.updated_at = utc_now()
db.commit() db.commit()
db.refresh(event) db.refresh(event)
return event return event
@@ -167,7 +175,7 @@ async def create_or_update_rsvp(
existing_rsvp.status = rsvp_data.status existing_rsvp.status = rsvp_data.status
if rsvp_data.notes is not None: if rsvp_data.notes is not None:
existing_rsvp.notes = rsvp_data.notes existing_rsvp.notes = rsvp_data.notes
existing_rsvp.updated_at = datetime.now() existing_rsvp.updated_at = utc_now()
db.commit() db.commit()
db.refresh(existing_rsvp) db.refresh(existing_rsvp)
return existing_rsvp return existing_rsvp
@@ -204,4 +212,4 @@ async def get_my_rsvps(
): ):
"""Get current user's RSVPs""" """Get current user's RSVPs"""
rsvps = db.query(EventRSVP).filter(EventRSVP.user_id == current_user.id).all() rsvps = db.query(EventRSVP).filter(EventRSVP.user_id == current_user.id).all()
return rsvps return rsvps
+6 -5
View File
@@ -5,6 +5,7 @@ from datetime import datetime, timedelta
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
from ...core.database import get_db 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 ...models.models import Payment, PaymentStatus, PaymentMethod, User, Membership, MembershipStatus, MembershipTier
from ...schemas import ( from ...schemas import (
PaymentCreate, PaymentUpdate, PaymentResponse, MessageResponse, PaymentCreate, PaymentUpdate, PaymentResponse, MessageResponse,
@@ -121,7 +122,7 @@ async def update_payment(
# If marking as completed, set payment_date if not already set # If marking as completed, set payment_date if not already set
if update_data.get("status") == PaymentStatus.COMPLETED and not payment.payment_date: 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(): for field, value in update_data.items():
setattr(payment, field, value) setattr(payment, field, value)
@@ -182,7 +183,7 @@ async def process_square_payment(
) )
# Create a reference ID for tracking # 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 # Process payment with Square
square_result = await square_service.create_payment( 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 # Payment succeeded - create membership and payment records in a transaction
try: try:
# Calculate membership dates # Calculate membership dates
start_date = datetime.utcnow().date() start_date = utc_now().date()
end_date = start_date + relativedelta(years=1) end_date = start_date + relativedelta(years=1)
# Create membership with ACTIVE status # Create membership with ACTIVE status
@@ -226,7 +227,7 @@ async def process_square_payment(
payment_method=PaymentMethod.SQUARE, payment_method=PaymentMethod.SQUARE,
status=PaymentStatus.COMPLETED, status=PaymentStatus.COMPLETED,
transaction_id=square_result.get('payment_id'), transaction_id=square_result.get('payment_id'),
payment_date=datetime.utcnow(), payment_date=utc_now(),
notes=payment_request.note notes=payment_request.note
) )
db.add(payment) db.add(payment)
@@ -389,7 +390,7 @@ async def record_manual_payment(
payment_method=payment_data.payment_method, payment_method=payment_data.payment_method,
notes=payment_data.notes, notes=payment_data.notes,
status=PaymentStatus.COMPLETED, status=PaymentStatus.COMPLETED,
payment_date=datetime.utcnow() payment_date=utc_now()
) )
db.add(payment) db.add(payment)
+3 -2
View File
@@ -7,6 +7,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from ...core.database import get_db from ...core.database import get_db
from ...core.datetime import utc_now
from ...models.models import ProfileQuestion, User, UserProfileAnswer, UserRole, PasswordResetToken from ...models.models import ProfileQuestion, User, UserProfileAnswer, UserRole, PasswordResetToken
from ...schemas import ( from ...schemas import (
MessageResponse, MessageResponse,
@@ -691,11 +692,11 @@ async def send_user_password_reset(
db.query(PasswordResetToken).filter( db.query(PasswordResetToken).filter(
PasswordResetToken.user_id == user.id, PasswordResetToken.user_id == user.id,
PasswordResetToken.used == False, PasswordResetToken.used == False,
PasswordResetToken.expires_at > datetime.utcnow() PasswordResetToken.expires_at > utc_now()
).update({"used": True}) ).update({"used": True})
reset_token = str(uuid.uuid4()) reset_token = str(uuid.uuid4())
expires_at = datetime.utcnow() + timedelta(hours=1) expires_at = utc_now() + timedelta(hours=1)
db_token = PasswordResetToken( db_token = PasswordResetToken(
user_id=user.id, user_id=user.id,
+26 -2
View File
@@ -1,10 +1,14 @@
import hashlib
import hmac
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Optional, Union, Any from typing import Optional, Union, Any
from jose import JWTError, jwt from jose import JWTError, jwt
from passlib.context import CryptContext from passlib.context import CryptContext
from .config import settings from .config import settings
from .datetime import utc_now
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
MACHINE_TOKEN_PREFIX = "sha256$"
def create_access_token( def create_access_token(
@@ -12,9 +16,9 @@ def create_access_token(
) -> str: ) -> str:
"""Create JWT access token""" """Create JWT access token"""
if expires_delta: if expires_delta:
expire = datetime.utcnow() + expires_delta expire = utc_now() + expires_delta
else: else:
expire = datetime.utcnow() + timedelta( expire = utc_now() + timedelta(
minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES
) )
@@ -33,6 +37,26 @@ def get_password_hash(password: str) -> str:
return pwd_context.hash(password) 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]: def decode_token(token: str) -> Optional[str]:
"""Decode JWT token and return subject""" """Decode JWT token and return subject"""
try: try:
+36 -2
View File
@@ -1,13 +1,30 @@
import asyncio
import time
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi import Request
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from .core.config import settings from .core.config import settings
from .api.v1 import api_router 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 .core.init_db import init_default_data
from .services.attendance_service import close_stale_attendance_sessions
from sqlalchemy.orm import Session 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 @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
"""Handle startup and shutdown events""" """Handle startup and shutdown events"""
@@ -15,13 +32,20 @@ async def lifespan(app: FastAPI):
db: Session = next(get_db()) db: Session = next(get_db())
try: try:
init_default_data(db) init_default_data(db)
close_stale_attendance_sessions(db)
finally: finally:
db.close() db.close()
attendance_task = asyncio.create_task(close_stale_attendance_loop())
yield yield
# Shutdown (if needed) # Shutdown (if needed)
pass attendance_task.cancel()
try:
await attendance_task
except asyncio.CancelledError:
pass
app = FastAPI( app = FastAPI(
@@ -40,6 +64,16 @@ app.add_middleware(
allow_headers=["*"], 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 # Include API router
app.include_router(api_router, prefix=settings.API_V1_PREFIX) app.include_router(api_router, prefix=settings.API_V1_PREFIX)
+175 -33
View File
@@ -3,9 +3,9 @@ from sqlalchemy import (
Float, Text, ForeignKey, Date, UniqueConstraint Float, Text, ForeignKey, Date, UniqueConstraint
) )
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from datetime import datetime
import enum import enum
from ..core.database import Base from ..core.database import Base
from ..core.datetime import utc_now
class UserRole(str, enum.Enum): class UserRole(str, enum.Enum):
@@ -49,6 +49,37 @@ class RSVPStatus(str, enum.Enum):
MAYBE = "maybe" 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): class User(Base):
__tablename__ = "users" __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) 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) volunteer_level = Column(String(50), nullable=True)
is_active = Column(Boolean, default=True, nullable=False) is_active = Column(Boolean, default=True, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False) created_at = Column(DateTime, default=utc_now, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) updated_at = Column(DateTime, default=utc_now, onupdate=utc_now, nullable=False)
last_login = Column(DateTime, nullable=True) last_login = Column(DateTime, nullable=True)
# Relationships # Relationships
@@ -78,6 +109,8 @@ class User(Base):
cascade="all, delete-orphan", cascade="all, delete-orphan",
foreign_keys="UserProfileAnswer.user_id" foreign_keys="UserProfileAnswer.user_id"
) )
rfid_cards = relationship("RfidCard", back_populates="user")
attendance_sessions = relationship("AttendanceSession", back_populates="user")
class ProfileQuestion(Base): class ProfileQuestion(Base):
@@ -96,8 +129,8 @@ class ProfileQuestion(Base):
display_order = Column(Integer, default=0, nullable=False) display_order = Column(Integer, default=0, nullable=False)
depends_on_question_id = Column(Integer, ForeignKey("profile_questions.id"), nullable=True) depends_on_question_id = Column(Integer, ForeignKey("profile_questions.id"), nullable=True)
depends_on_value = Column(String(255), nullable=True) depends_on_value = Column(String(255), nullable=True)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False) created_at = Column(DateTime, default=utc_now, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, 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") depends_on_question = relationship("ProfileQuestion", remote_side=[id], backref="dependent_questions")
answers = relationship("UserProfileAnswer", back_populates="question", cascade="all, delete-orphan") 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) question_id = Column(Integer, ForeignKey("profile_questions.id"), nullable=False, index=True)
value_text = Column(Text, nullable=True) value_text = Column(Text, nullable=True)
updated_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=True) updated_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False) created_at = Column(DateTime, default=utc_now, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, 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") user = relationship("User", foreign_keys=[user_id], back_populates="profile_answers")
question = relationship("ProfileQuestion", back_populates="answers") question = relationship("ProfileQuestion", back_populates="answers")
@@ -131,8 +164,8 @@ class MembershipTier(Base):
annual_fee = Column(Float, nullable=False) annual_fee = Column(Float, nullable=False)
benefits = Column(Text, nullable=True) benefits = Column(Text, nullable=True)
is_active = Column(Boolean, default=True, nullable=False) is_active = Column(Boolean, default=True, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False) created_at = Column(DateTime, default=utc_now, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) updated_at = Column(DateTime, default=utc_now, onupdate=utc_now, nullable=False)
# Relationships # Relationships
memberships = relationship("Membership", back_populates="tier") memberships = relationship("Membership", back_populates="tier")
@@ -148,8 +181,8 @@ class Membership(Base):
start_date = Column(Date, nullable=False) start_date = Column(Date, nullable=False)
end_date = Column(Date, nullable=False) end_date = Column(Date, nullable=False)
auto_renew = Column(Boolean, default=False, nullable=False) auto_renew = Column(Boolean, default=False, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False) created_at = Column(DateTime, default=utc_now, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) updated_at = Column(DateTime, default=utc_now, onupdate=utc_now, nullable=False)
# Relationships # Relationships
user = relationship("User", back_populates="memberships") user = relationship("User", back_populates="memberships")
@@ -169,8 +202,8 @@ class Payment(Base):
transaction_id = Column(String(255), nullable=True) transaction_id = Column(String(255), nullable=True)
payment_date = Column(DateTime, nullable=True) payment_date = Column(DateTime, nullable=True)
notes = Column(Text, nullable=True) notes = Column(Text, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False) created_at = Column(DateTime, default=utc_now, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) updated_at = Column(DateTime, default=utc_now, onupdate=utc_now, nullable=False)
# Relationships # Relationships
user = relationship("User", back_populates="payments") user = relationship("User", back_populates="payments")
@@ -189,8 +222,8 @@ class Event(Base):
max_attendees = Column(Integer, nullable=True) 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) 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_by = Column(Integer, ForeignKey("users.id"), nullable=False)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False) created_at = Column(DateTime, default=utc_now, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) updated_at = Column(DateTime, default=utc_now, onupdate=utc_now, nullable=False)
# Relationships # Relationships
rsvps = relationship("EventRSVP", back_populates="event", cascade="all, delete-orphan") 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) 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) attended = Column(Boolean, default=False, nullable=False)
notes = Column(Text, nullable=True) notes = Column(Text, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False) created_at = Column(DateTime, default=utc_now, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) updated_at = Column(DateTime, default=utc_now, onupdate=utc_now, nullable=False)
# Relationships # Relationships
event = relationship("Event", back_populates="rsvps") event = relationship("Event", back_populates="rsvps")
user = relationship("User", back_populates="event_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): class VolunteerRole(Base):
__tablename__ = "volunteer_roles" __tablename__ = "volunteer_roles"
@@ -220,8 +362,8 @@ class VolunteerRole(Base):
name = Column(String(100), nullable=False) name = Column(String(100), nullable=False)
description = Column(Text, nullable=True) description = Column(Text, nullable=True)
is_active = Column(Boolean, default=True, nullable=False) is_active = Column(Boolean, default=True, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False) created_at = Column(DateTime, default=utc_now, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) updated_at = Column(DateTime, default=utc_now, onupdate=utc_now, nullable=False)
# Relationships # Relationships
assignments = relationship("VolunteerAssignment", back_populates="role", cascade="all, delete-orphan") assignments = relationship("VolunteerAssignment", back_populates="role", cascade="all, delete-orphan")
@@ -236,8 +378,8 @@ class VolunteerAssignment(Base):
assigned_date = Column(Date, nullable=False) assigned_date = Column(Date, nullable=False)
is_active = Column(Boolean, default=True, nullable=False) is_active = Column(Boolean, default=True, nullable=False)
notes = Column(Text, nullable=True) notes = Column(Text, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False) created_at = Column(DateTime, default=utc_now, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) updated_at = Column(DateTime, default=utc_now, onupdate=utc_now, nullable=False)
# Relationships # Relationships
user = relationship("User", back_populates="volunteer_assignments") user = relationship("User", back_populates="volunteer_assignments")
@@ -256,8 +398,8 @@ class VolunteerSchedule(Base):
location = Column(String(255), nullable=True) location = Column(String(255), nullable=True)
notes = Column(Text, nullable=True) notes = Column(Text, nullable=True)
completed = Column(Boolean, default=False, nullable=False) completed = Column(Boolean, default=False, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False) created_at = Column(DateTime, default=utc_now, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) updated_at = Column(DateTime, default=utc_now, onupdate=utc_now, nullable=False)
# Relationships # Relationships
assignment = relationship("VolunteerAssignment", back_populates="schedules") assignment = relationship("VolunteerAssignment", back_populates="schedules")
@@ -275,8 +417,8 @@ class Certificate(Base):
certificate_number = Column(String(100), nullable=True) certificate_number = Column(String(100), nullable=True)
file_path = Column(String(500), nullable=True) file_path = Column(String(500), nullable=True)
notes = Column(Text, nullable=True) notes = Column(Text, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False) created_at = Column(DateTime, default=utc_now, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) updated_at = Column(DateTime, default=utc_now, onupdate=utc_now, nullable=False)
# Relationships # Relationships
user = relationship("User", back_populates="certificates") user = relationship("User", back_populates="certificates")
@@ -294,8 +436,8 @@ class File(Base):
min_tier_id = Column(Integer, ForeignKey("membership_tiers.id"), nullable=True) min_tier_id = Column(Integer, ForeignKey("membership_tiers.id"), nullable=True)
description = Column(Text, nullable=True) description = Column(Text, nullable=True)
uploaded_by = Column(Integer, ForeignKey("users.id"), nullable=False) uploaded_by = Column(Integer, ForeignKey("users.id"), nullable=False)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False) created_at = Column(DateTime, default=utc_now, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) updated_at = Column(DateTime, default=utc_now, onupdate=utc_now, nullable=False)
class Notification(Base): class Notification(Base):
@@ -308,7 +450,7 @@ class Notification(Base):
email_sent = Column(Boolean, default=False, nullable=False) email_sent = Column(Boolean, default=False, nullable=False)
sent_at = Column(DateTime, nullable=True) sent_at = Column(DateTime, nullable=True)
error_message = Column(Text, 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): class PasswordResetToken(Base):
@@ -319,7 +461,7 @@ class PasswordResetToken(Base):
token = Column(String(255), unique=True, nullable=False, index=True) token = Column(String(255), unique=True, nullable=False, index=True)
expires_at = Column(DateTime, nullable=False) expires_at = Column(DateTime, nullable=False)
used = Column(Boolean, default=False, 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 # Relationships
user = relationship("User", backref="password_reset_tokens") user = relationship("User", backref="password_reset_tokens")
@@ -336,8 +478,8 @@ class EmailTemplate(Base):
text_body = Column(Text, nullable=True) text_body = Column(Text, nullable=True)
variables = Column(Text, nullable=True) # JSON string of available variables variables = Column(Text, nullable=True) # JSON string of available variables
is_active = Column(Boolean, default=True, nullable=False) is_active = Column(Boolean, default=True, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False) created_at = Column(DateTime, default=utc_now, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) updated_at = Column(DateTime, default=utc_now, onupdate=utc_now, nullable=False)
class BounceType(str, enum.Enum): class BounceType(str, enum.Enum):
@@ -357,5 +499,5 @@ class EmailBounce(Base):
smtp2go_message_id = Column(String(255), nullable=True, index=True) smtp2go_message_id = Column(String(255), nullable=True, index=True)
bounce_date = Column(DateTime, nullable=False) bounce_date = Column(DateTime, nullable=False)
is_active = Column(Boolean, default=True, nullable=False) is_active = Column(Boolean, default=True, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False) created_at = Column(DateTime, default=utc_now, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) updated_at = Column(DateTime, default=utc_now, onupdate=utc_now, nullable=False)
+46
View File
@@ -44,6 +44,29 @@ from .schemas import (
ProfileQuestionForUser, ProfileQuestionForUser,
ProfileAnswerUpdate, ProfileAnswerUpdate,
ProfileAnswersUpdateRequest, 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__ = [ __all__ = [
@@ -92,4 +115,27 @@ __all__ = [
"ProfileQuestionForUser", "ProfileQuestionForUser",
"ProfileAnswerUpdate", "ProfileAnswerUpdate",
"ProfileAnswersUpdateRequest", "ProfileAnswersUpdateRequest",
"EspReaderCreate",
"EspReaderUpdate",
"EspReaderResponse",
"EspReaderCreateResponse",
"EspReaderRegistrationRequest",
"EspReaderRegistrationResponse",
"EspReaderProvisioningResponse",
"RfidCardCreate",
"RfidCardUpdate",
"RfidCardResponse",
"RfidTapRequest",
"RfidTapResponse",
"RfidWriteJobCreate",
"RfidWriteJobCompleteRequest",
"RfidWriteJobResponse",
"EspTimeResponse",
"EspHeartbeatRequest",
"EspHeartbeatResponse",
"EspDashboardLoginResponse",
"RfidTapAdminResponse",
"AttendanceSessionResponse",
"StaleSessionCloseRequest",
"StaleSessionCloseResponse",
] ]
+283 -33
View File
@@ -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 typing import Optional, Literal, Any
from datetime import datetime, date 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 # User Schemas
class UserBase(BaseModel): class UserBase(UTCBaseModel):
email: EmailStr email: EmailStr
first_name: str = Field(..., min_length=1, max_length=100) first_name: str = Field(..., min_length=1, max_length=100)
last_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) password: str = Field(..., min_length=8)
class UserUpdate(BaseModel): class UserUpdate(UTCBaseModel):
email: Optional[EmailStr] = None email: Optional[EmailStr] = None
first_name: Optional[str] = Field(None, min_length=1, max_length=100) first_name: Optional[str] = Field(None, min_length=1, max_length=100)
last_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 # Authentication Schemas
class Token(BaseModel): class Token(UTCBaseModel):
access_token: str access_token: str
token_type: str = "bearer" token_type: str = "bearer"
class TokenData(BaseModel): class TokenData(UTCBaseModel):
user_id: Optional[int] = None user_id: Optional[int] = None
class LoginRequest(BaseModel): class LoginRequest(UTCBaseModel):
email: EmailStr email: EmailStr
password: str password: str
# Password Reset Schemas # Password Reset Schemas
class ForgotPasswordRequest(BaseModel): class ForgotPasswordRequest(UTCBaseModel):
email: EmailStr email: EmailStr
class ResetPasswordRequest(BaseModel): class ResetPasswordRequest(UTCBaseModel):
token: str = Field(..., min_length=1) token: str = Field(..., min_length=1)
new_password: str = Field(..., min_length=8) new_password: str = Field(..., min_length=8)
class ChangePasswordRequest(BaseModel): class ChangePasswordRequest(UTCBaseModel):
current_password: str = Field(..., min_length=1) current_password: str = Field(..., min_length=1)
new_password: str = Field(..., min_length=8) new_password: str = Field(..., min_length=8)
# Membership Tier Schemas # Membership Tier Schemas
class MembershipTierBase(BaseModel): class MembershipTierBase(UTCBaseModel):
name: str = Field(..., min_length=1, max_length=100) name: str = Field(..., min_length=1, max_length=100)
description: Optional[str] = None description: Optional[str] = None
annual_fee: float = Field(..., ge=0) annual_fee: float = Field(..., ge=0)
@@ -84,7 +116,7 @@ class MembershipTierCreate(MembershipTierBase):
pass pass
class MembershipTierUpdate(BaseModel): class MembershipTierUpdate(UTCBaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=100) name: Optional[str] = Field(None, min_length=1, max_length=100)
description: Optional[str] = None description: Optional[str] = None
annual_fee: Optional[float] = Field(None, ge=0) annual_fee: Optional[float] = Field(None, ge=0)
@@ -101,7 +133,7 @@ class MembershipTierResponse(MembershipTierBase):
# Membership Schemas # Membership Schemas
class MembershipBase(BaseModel): class MembershipBase(UTCBaseModel):
tier_id: int tier_id: int
auto_renew: bool = False auto_renew: bool = False
@@ -111,14 +143,14 @@ class MembershipCreate(MembershipBase):
end_date: date end_date: date
class MembershipUpdate(BaseModel): class MembershipUpdate(UTCBaseModel):
tier_id: Optional[int] = None tier_id: Optional[int] = None
status: Optional[MembershipStatus] = None status: Optional[MembershipStatus] = None
end_date: Optional[date] = None end_date: Optional[date] = None
auto_renew: Optional[bool] = None auto_renew: Optional[bool] = None
class MembershipResponse(BaseModel): class MembershipResponse(UTCBaseModel):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
id: int id: int
@@ -133,7 +165,7 @@ class MembershipResponse(BaseModel):
# Payment Schemas # Payment Schemas
class PaymentBase(BaseModel): class PaymentBase(UTCBaseModel):
amount: float = Field(..., gt=0) amount: float = Field(..., gt=0)
payment_method: PaymentMethod payment_method: PaymentMethod
notes: Optional[str] = None notes: Optional[str] = None
@@ -143,14 +175,14 @@ class PaymentCreate(PaymentBase):
membership_id: Optional[int] = None membership_id: Optional[int] = None
class PaymentUpdate(BaseModel): class PaymentUpdate(UTCBaseModel):
status: Optional[PaymentStatus] = None status: Optional[PaymentStatus] = None
transaction_id: Optional[str] = None transaction_id: Optional[str] = None
payment_date: Optional[datetime] = None payment_date: Optional[datetime] = None
notes: Optional[str] = None notes: Optional[str] = None
class PaymentResponse(BaseModel): class PaymentResponse(UTCBaseModel):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
id: int id: int
@@ -166,7 +198,7 @@ class PaymentResponse(BaseModel):
# Square Payment Schemas # Square Payment Schemas
class SquarePaymentRequest(BaseModel): class SquarePaymentRequest(UTCBaseModel):
"""Request schema for Square payment processing""" """Request schema for Square payment processing"""
source_id: str = Field(..., description="Payment source ID from Square Web Payments SDK") 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") 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") 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""" """Response schema for Square payment"""
success: bool success: bool
payment_id: Optional[str] = None payment_id: Optional[str] = None
@@ -189,7 +221,7 @@ class SquarePaymentResponse(BaseModel):
membership_id: Optional[int] = Field(None, description="Created membership ID") membership_id: Optional[int] = Field(None, description="Created membership ID")
class SquareRefundRequest(BaseModel): class SquareRefundRequest(UTCBaseModel):
"""Request schema for Square payment refund""" """Request schema for Square payment refund"""
payment_id: int = Field(..., description="Database payment ID") payment_id: int = Field(..., description="Database payment ID")
amount: Optional[float] = Field(None, gt=0, description="Amount to refund (None for full refund)") amount: Optional[float] = Field(None, gt=0, description="Amount to refund (None for full refund)")
@@ -197,13 +229,13 @@ class SquareRefundRequest(BaseModel):
# Message Response # Message Response
class MessageResponse(BaseModel): class MessageResponse(UTCBaseModel):
message: str message: str
detail: Optional[str] = None detail: Optional[str] = None
# Email Template Schemas # Email Template Schemas
class EmailTemplateBase(BaseModel): class EmailTemplateBase(UTCBaseModel):
template_key: str template_key: str
name: str name: str
subject: str subject: str
@@ -216,7 +248,7 @@ class EmailTemplateCreate(EmailTemplateBase):
pass pass
class EmailTemplateUpdate(BaseModel): class EmailTemplateUpdate(UTCBaseModel):
name: Optional[str] = None name: Optional[str] = None
subject: Optional[str] = None subject: Optional[str] = None
html_body: Optional[str] = None html_body: Optional[str] = None
@@ -235,7 +267,7 @@ class EmailTemplateResponse(EmailTemplateBase):
# Event Schemas # Event Schemas
class EventBase(BaseModel): class EventBase(UTCBaseModel):
title: str = Field(..., min_length=1, max_length=255) title: str = Field(..., min_length=1, max_length=255)
description: Optional[str] = None description: Optional[str] = None
event_date: datetime event_date: datetime
@@ -248,7 +280,7 @@ class EventCreate(EventBase):
pass pass
class EventUpdate(BaseModel): class EventUpdate(UTCBaseModel):
title: Optional[str] = Field(None, min_length=1, max_length=255) title: Optional[str] = Field(None, min_length=1, max_length=255)
description: Optional[str] = None description: Optional[str] = None
event_date: Optional[datetime] = None event_date: Optional[datetime] = None
@@ -269,7 +301,7 @@ class EventResponse(EventBase):
# Event RSVP Schemas # Event RSVP Schemas
class EventRSVPBase(BaseModel): class EventRSVPBase(UTCBaseModel):
status: str = Field(..., pattern="^(pending|attending|not_attending|maybe)$") status: str = Field(..., pattern="^(pending|attending|not_attending|maybe)$")
notes: Optional[str] = None notes: Optional[str] = None
@@ -293,12 +325,12 @@ class EventRSVPResponse(EventRSVPBase):
ProfileQuestionInputType = Literal["text", "number", "boolean", "date", "select"] ProfileQuestionInputType = Literal["text", "number", "boolean", "date", "select"]
class QuestionOption(BaseModel): class QuestionOption(UTCBaseModel):
label: str = Field(..., min_length=1, max_length=100) label: str = Field(..., min_length=1, max_length=100)
value: 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_]+$") key: str = Field(..., min_length=2, max_length=100, pattern=r"^[a-z0-9_]+$")
label: str = Field(..., min_length=2, max_length=255) label: str = Field(..., min_length=2, max_length=255)
help_text: Optional[str] = None help_text: Optional[str] = None
@@ -317,7 +349,7 @@ class ProfileQuestionCreate(ProfileQuestionBase):
pass pass
class ProfileQuestionUpdate(BaseModel): class ProfileQuestionUpdate(UTCBaseModel):
key: Optional[str] = Field(None, min_length=2, max_length=100, pattern=r"^[a-z0-9_]+$") 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) label: Optional[str] = Field(None, min_length=2, max_length=255)
help_text: Optional[str] = None help_text: Optional[str] = None
@@ -332,7 +364,7 @@ class ProfileQuestionUpdate(BaseModel):
depends_on_value: Optional[str] = Field(None, max_length=255) depends_on_value: Optional[str] = Field(None, max_length=255)
class ProfileQuestionResponse(BaseModel): class ProfileQuestionResponse(UTCBaseModel):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
id: int id: int
@@ -357,10 +389,228 @@ class ProfileQuestionForUser(ProfileQuestionResponse):
can_edit: bool = True can_edit: bool = True
class ProfileAnswerUpdate(BaseModel): class ProfileAnswerUpdate(UTCBaseModel):
question_id: int question_id: int
value: Optional[Any] = None value: Optional[Any] = None
class ProfileAnswersUpdateRequest(BaseModel): class ProfileAnswersUpdateRequest(UTCBaseModel):
answers: list[ProfileAnswerUpdate] 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
+12 -8
View File
@@ -1,6 +1,7 @@
from typing import List, Optional, Dict, Any from typing import List, Optional, Dict, Any
from datetime import datetime from datetime import datetime, timezone
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from ..core.datetime import to_utc_naive, utc_now
from ..models.models import EmailBounce, BounceType from ..models.models import EmailBounce, BounceType
from ..core.database import get_db from ..core.database import get_db
@@ -38,7 +39,9 @@ class BounceService:
db = next(get_db()) db = next(get_db())
if bounce_date is None: 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 # Check if bounce already exists for this email and type
existing_bounce = db.query(EmailBounce).filter( existing_bounce = db.query(EmailBounce).filter(
@@ -54,7 +57,7 @@ class BounceService:
if smtp2go_message_id: if smtp2go_message_id:
existing_bounce.smtp2go_message_id = smtp2go_message_id existing_bounce.smtp2go_message_id = smtp2go_message_id
existing_bounce.bounce_date = bounce_date existing_bounce.bounce_date = bounce_date
existing_bounce.updated_at = datetime.utcnow() existing_bounce.updated_at = utc_now()
db.commit() db.commit()
db.refresh(existing_bounce) db.refresh(existing_bounce)
return existing_bounce return existing_bounce
@@ -130,7 +133,7 @@ class BounceService:
bounce = db.query(EmailBounce).filter(EmailBounce.id == bounce_id).first() bounce = db.query(EmailBounce).filter(EmailBounce.id == bounce_id).first()
if bounce: if bounce:
bounce.is_active = False bounce.is_active = False
bounce.updated_at = datetime.utcnow() bounce.updated_at = utc_now()
db.commit() db.commit()
return True return True
return False return False
@@ -189,9 +192,10 @@ class BounceService:
try: try:
# SMTP2GO timestamps are typically Unix timestamps # SMTP2GO timestamps are typically Unix timestamps
if isinstance(timestamp, (int, float)): if isinstance(timestamp, (int, float)):
bounce_date = datetime.fromtimestamp(timestamp) bounce_date = datetime.fromtimestamp(timestamp, tz=timezone.utc)
elif isinstance(timestamp, str): elif isinstance(timestamp, str):
bounce_date = datetime.fromisoformat(timestamp.replace('Z', '+00:00')) bounce_date = datetime.fromisoformat(timestamp.replace('Z', '+00:00'))
bounce_date = to_utc_naive(bounce_date)
except (ValueError, TypeError): except (ValueError, TypeError):
pass pass
@@ -252,18 +256,18 @@ class BounceService:
db = next(get_db()) db = next(get_db())
from datetime import timedelta 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 # Only deactivate soft bounces, keep hard bounces and complaints active
result = db.query(EmailBounce).filter( result = db.query(EmailBounce).filter(
EmailBounce.bounce_type == BounceType.SOFT, EmailBounce.bounce_type == BounceType.SOFT,
EmailBounce.is_active == True, EmailBounce.is_active == True,
EmailBounce.bounce_date < cutoff_date EmailBounce.bounce_date < cutoff_date
).update({'is_active': False, 'updated_at': datetime.utcnow()}) ).update({'is_active': False, 'updated_at': utc_now()})
db.commit() db.commit()
return result return result
# Create a singleton instance # Create a singleton instance
bounce_service = BounceService() bounce_service = BounceService()
+2 -1
View File
@@ -2,6 +2,7 @@ import httpx
from typing import List, Optional, Dict, Any from typing import List, Optional, Dict, Any
from datetime import datetime from datetime import datetime
from ..core.database import get_db from ..core.database import get_db
from ..core.datetime import utc_now
from ..models.models import EmailTemplate from ..models.models import EmailTemplate
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from ..core.config import settings from ..core.config import settings
@@ -147,7 +148,7 @@ class EmailService:
"payment_amount": f"£{payment_amount:.2f}", "payment_amount": f"£{payment_amount:.2f}",
"payment_method": payment_method, "payment_method": payment_method,
"renewal_date": renewal_date, "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 "app_name": settings.APP_NAME
} }
return await self.send_templated_email("membership_activation", to_email, variables, db) return await self.send_templated_email("membership_activation", to_email, variables, db)
+3412 -256
View File
File diff suppressed because it is too large Load Diff
+31 -34
View File
@@ -1,6 +1,8 @@
import React from 'react'; import React from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { FeatureFlagProvider } from './contexts/FeatureFlagContext'; import { FeatureFlagProvider } from './contexts/FeatureFlagContext';
import { ToastProvider } from './contexts/ToastContext';
import { ConfirmProvider } from './contexts/ConfirmContext';
import Register from './pages/Register'; import Register from './pages/Register';
import Login from './pages/Login'; import Login from './pages/Login';
import ForgotPassword from './pages/ForgotPassword'; import ForgotPassword from './pages/ForgotPassword';
@@ -8,9 +10,10 @@ import ResetPassword from './pages/ResetPassword';
import Dashboard from './pages/Dashboard'; import Dashboard from './pages/Dashboard';
import PrivacyPolicy from './pages/PrivacyPolicy'; import PrivacyPolicy from './pages/PrivacyPolicy';
import TermsOfService from './pages/TermsOfService'; import TermsOfService from './pages/TermsOfService';
import AppFooter from './components/layout/AppFooter';
import CookieBanner from './components/layout/CookieBanner';
import './App.css'; import './App.css';
import { useState } from 'react'; import { useState } from 'react';
import { Link } from 'react-router-dom';
const App: React.FC = () => { const App: React.FC = () => {
const [cookieDismissed, setCookieDismissed] = useState( const [cookieDismissed, setCookieDismissed] = useState(
@@ -25,40 +28,34 @@ const App: React.FC = () => {
return ( return (
<FeatureFlagProvider> <FeatureFlagProvider>
<BrowserRouter> <BrowserRouter>
<div className="app-shell"> <ConfirmProvider>
<main className="app-main"> <ToastProvider>
<Routes> <div className="app-shell">
<Route path="/" element={<Navigate to="/login" />} /> <main className="app-main">
<Route path="/register" element={<Register />} /> <Routes>
<Route path="/login" element={<Login />} /> <Route path="/" element={<Navigate to="/login" />} />
<Route path="/forgot-password" element={<ForgotPassword />} /> <Route path="/register" element={<Register />} />
<Route path="/reset-password" element={<ResetPassword />} /> <Route path="/login" element={<Login />} />
<Route path="/dashboard" element={<Dashboard />} /> <Route path="/forgot-password" element={<ForgotPassword />} />
<Route path="/email-templates" element={<Navigate to="/dashboard" />} /> <Route path="/reset-password" element={<ResetPassword />} />
<Route path="/membership-tiers" element={<Navigate to="/dashboard" />} /> <Route path="/dashboard" element={<Dashboard />} />
<Route path="/bounce-management" element={<Navigate to="/dashboard" />} /> <Route path="/dashboard/:tab" element={<Dashboard />} />
<Route path="/privacy-policy" element={<PrivacyPolicy />} /> <Route path="/dashboard/admin/:section" element={<Dashboard />} />
<Route path="/terms-of-service" element={<TermsOfService />} /> <Route path="/email-templates" element={<Navigate to="/dashboard/admin/email" replace />} />
</Routes> <Route path="/membership-tiers" element={<Navigate to="/dashboard/admin/tiers" replace />} />
</main> <Route path="/bounce-management" element={<Navigate to="/dashboard/admin/bounces" replace />} />
<footer className="site-footer"> <Route path="/privacy-policy" element={<PrivacyPolicy />} />
<div> <Route path="/terms-of-service" element={<TermsOfService />} />
<Link to="/privacy-policy">Privacy Policy</Link> <Route path="*" element={<Navigate to="/login" replace />} />
<Link to="/terms-of-service">Terms of Service</Link> </Routes>
</main>
<AppFooter />
{!cookieDismissed && (
<CookieBanner onDismiss={dismissCookies} />
)}
</div> </div>
<div style={{ marginTop: '8px' }}>SASA Portal</div> </ToastProvider>
</footer> </ConfirmProvider>
{!cookieDismissed && (
<div className="cookie-banner">
<div>
We use cookies for session authentication, security, and basic site functionality.
</div>
<button className="btn btn-primary" style={{ padding: '6px 12px' }} onClick={dismissCookies}>
OK
</button>
</div>
)}
</div>
</BrowserRouter> </BrowserRouter>
</FeatureFlagProvider> </FeatureFlagProvider>
); );
@@ -6,11 +6,16 @@ import {
ProfileQuestionUpsertData, ProfileQuestionUpsertData,
userService userService
} from '../services/membershipService'; } from '../services/membershipService';
import { useConfirm } from '../contexts/ConfirmContext';
interface AdminProfileQuestionManagerProps { interface AdminProfileQuestionManagerProps {
onQuestionsChanged?: () => void; onQuestionsChanged?: () => void;
openEditorToken?: number;
searchTerm?: string;
} }
type QuestionSortKey = 'order' | 'label' | 'type' | 'key' | 'status';
const INPUT_TYPES: ProfileQuestionInputType[] = ['text', 'number', 'boolean', 'date', 'select']; const INPUT_TYPES: ProfileQuestionInputType[] = ['text', 'number', 'boolean', 'date', 'select'];
const optionsToText = (options: ProfileQuestionOption[] | null | undefined): string => { 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); .filter((option) => option.label.length > 0 && option.value.length > 0);
}; };
const AdminProfileQuestionManager: React.FC<AdminProfileQuestionManagerProps> = ({ onQuestionsChanged }) => { const AdminProfileQuestionManager: React.FC<AdminProfileQuestionManagerProps> = ({
onQuestionsChanged,
openEditorToken = 0,
searchTerm = ''
}) => {
const { confirm } = useConfirm();
const [questions, setQuestions] = useState<ProfileQuestion[]>([]); const [questions, setQuestions] = useState<ProfileQuestion[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [editingQuestionId, setEditingQuestionId] = useState<number | null>(null); const [editingQuestionId, setEditingQuestionId] = useState<number | null>(null);
const [listSearch, setListSearch] = useState(''); const [isEditorOpen, setIsEditorOpen] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const [sortKey, setSortKey] = useState<QuestionSortKey>('order');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
const pageSize = 10;
const emptyForm: ProfileQuestionUpsertData = { const emptyForm: ProfileQuestionUpsertData = {
key: '', key: '',
@@ -77,9 +91,19 @@ const AdminProfileQuestionManager: React.FC<AdminProfileQuestionManagerProps> =
loadQuestions(); loadQuestions();
}, []); }, []);
const dependencyCandidates = useMemo(() => { useEffect(() => {
return questions.filter((question) => question.id !== editingQuestionId); if (openEditorToken > 0) {
}, [questions, editingQuestionId]); setFormData(emptyForm);
setOptionsText('');
setEditingQuestionId(null);
setIsEditorOpen(true);
}
}, [openEditorToken]);
const dependencyCandidates = useMemo(
() => questions.filter((question) => question.id !== editingQuestionId),
[questions, editingQuestionId]
);
const selectedDependencyQuestion = useMemo(() => { const selectedDependencyQuestion = useMemo(() => {
if (!formData.depends_on_question_id) { if (!formData.depends_on_question_id) {
@@ -89,7 +113,7 @@ const AdminProfileQuestionManager: React.FC<AdminProfileQuestionManagerProps> =
}, [questions, formData.depends_on_question_id]); }, [questions, formData.depends_on_question_id]);
const filteredQuestions = useMemo(() => { const filteredQuestions = useMemo(() => {
const term = listSearch.trim().toLowerCase(); const term = searchTerm.trim().toLowerCase();
if (!term) { if (!term) {
return questions; return questions;
} }
@@ -97,7 +121,60 @@ const AdminProfileQuestionManager: React.FC<AdminProfileQuestionManagerProps> =
question.label.toLowerCase().includes(term) || question.label.toLowerCase().includes(term) ||
question.key.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 = () => { const resetForm = () => {
setFormData(emptyForm); setFormData(emptyForm);
@@ -105,6 +182,11 @@ const AdminProfileQuestionManager: React.FC<AdminProfileQuestionManagerProps> =
setEditingQuestionId(null); setEditingQuestionId(null);
}; };
const closeEditor = () => {
resetForm();
setIsEditorOpen(false);
};
const handleEdit = (question: ProfileQuestion) => { const handleEdit = (question: ProfileQuestion) => {
setEditingQuestionId(question.id); setEditingQuestionId(question.id);
setFormData({ setFormData({
@@ -122,6 +204,7 @@ const AdminProfileQuestionManager: React.FC<AdminProfileQuestionManagerProps> =
depends_on_value: question.depends_on_value depends_on_value: question.depends_on_value
}); });
setOptionsText(optionsToText(question.options)); setOptionsText(optionsToText(question.options));
setIsEditorOpen(true);
}; };
const handleSave = async () => { const handleSave = async () => {
@@ -146,7 +229,7 @@ const AdminProfileQuestionManager: React.FC<AdminProfileQuestionManagerProps> =
} }
await loadQuestions(); await loadQuestions();
resetForm(); closeEditor();
onQuestionsChanged?.(); onQuestionsChanged?.();
} catch (err: any) { } catch (err: any) {
setError(err.response?.data?.detail || err.message || 'Failed to save question'); setError(err.response?.data?.detail || err.message || 'Failed to save question');
@@ -156,7 +239,13 @@ const AdminProfileQuestionManager: React.FC<AdminProfileQuestionManagerProps> =
}; };
const handleDeactivate = async (questionId: number) => { 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; return;
} }
@@ -169,238 +258,275 @@ const AdminProfileQuestionManager: React.FC<AdminProfileQuestionManagerProps> =
} }
}; };
return ( const toggleSort = (nextKey: QuestionSortKey) => {
<div className="card" style={{ marginTop: '20px' }}> if (sortKey === nextKey) {
<h3 style={{ marginBottom: '10px' }}>Profile Questions (Admin)</h3> setSortDirection((prev) => (prev === 'asc' ? 'desc' : 'asc'));
<p style={{ marginBottom: '16px', color: '#555' }}> return;
Manage the set of profile questions users can answer. You can add follow-up questions with dependencies. }
</p> setSortKey(nextKey);
setSortDirection('asc');
};
const renderSortArrow = (active: boolean, direction: 'asc' | 'desc') => (
<span className={`admin-sort-arrow ${active ? 'active' : ''} ${direction}`}>
<svg viewBox="0 0 16 16" aria-hidden="true">
<path d="M4 10.5 8 6l4 4.5" />
</svg>
</span>
);
return (
<div>
{error && <div className="alert alert-error">{error}</div>} {error && <div className="alert alert-error">{error}</div>}
<div style={{ display: 'grid', gap: '10px', marginBottom: '20px' }}>
<input
type="text"
placeholder="Question key (e.g. pilot_license_type)"
value={formData.key}
onChange={(event) => setFormData((prev) => ({ ...prev, key: event.target.value }))}
style={{ padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
/>
<input
type="text"
placeholder="Question label"
value={formData.label}
onChange={(event) => setFormData((prev) => ({ ...prev, label: event.target.value }))}
style={{ padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
/>
<textarea
placeholder="Help text (optional)"
value={formData.help_text || ''}
onChange={(event) => setFormData((prev) => ({ ...prev, help_text: event.target.value }))}
rows={2}
style={{ padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
/>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: '10px' }}>
<select
value={formData.input_type}
onChange={(event) => setFormData((prev) => ({ ...prev, input_type: event.target.value as ProfileQuestionInputType }))}
style={{ padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
>
{INPUT_TYPES.map((type) => (
<option key={type} value={type}>{type}</option>
))}
</select>
<input
type="number"
placeholder="Display order"
value={formData.display_order ?? 0}
onChange={(event) => setFormData((prev) => ({ ...prev, display_order: Number(event.target.value) }))}
style={{ padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
/>
<input
type="text"
placeholder="Placeholder"
value={formData.placeholder || ''}
onChange={(event) => setFormData((prev) => ({ ...prev, placeholder: event.target.value }))}
style={{ padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
/>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: '10px' }}>
<select
value={formData.depends_on_question_id ?? ''}
onChange={(event) => {
const nextValue = event.target.value;
setFormData((prev) => ({
...prev,
depends_on_question_id: nextValue ? Number(nextValue) : null,
depends_on_value: null
}));
}}
style={{ padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
>
<option value="">No dependency</option>
{dependencyCandidates.map((question) => (
<option key={question.id} value={question.id}>{question.label}</option>
))}
</select>
{!selectedDependencyQuestion && (
<input
type="text"
placeholder="Choose a dependency question first"
value=""
disabled
style={{ padding: '8px', border: '1px solid #ddd', borderRadius: '4px', background: '#f5f7fa' }}
/>
)}
{selectedDependencyQuestion?.input_type === 'select' && (
<select
value={formData.depends_on_value || ''}
onChange={(event) => setFormData((prev) => ({ ...prev, depends_on_value: event.target.value || null }))}
style={{ padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
>
<option value="">Any answered value</option>
{selectedDependencyQuestion.options.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
)}
{selectedDependencyQuestion?.input_type === 'boolean' && (
<select
value={formData.depends_on_value || ''}
onChange={(event) => setFormData((prev) => ({ ...prev, depends_on_value: event.target.value || null }))}
style={{ padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
>
<option value="">Any answered value</option>
<option value="true">Yes</option>
<option value="false">No</option>
</select>
)}
{selectedDependencyQuestion && !['select', 'boolean'].includes(selectedDependencyQuestion.input_type) && (
<input
type={selectedDependencyQuestion.input_type === 'number' ? 'number' : selectedDependencyQuestion.input_type === 'date' ? 'date' : 'text'}
placeholder="Show when parent answer equals..."
value={formData.depends_on_value || ''}
onChange={(event) => setFormData((prev) => ({ ...prev, depends_on_value: event.target.value || null }))}
style={{ padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
/>
)}
</div>
{formData.input_type === 'select' && (
<textarea
value={optionsText}
onChange={(event) => setOptionsText(event.target.value)}
rows={4}
placeholder={'Options (one per line):\nNo|none\nPrivate Pilot|ppl'}
style={{ padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
/>
)}
<div style={{ display: 'flex', gap: '16px', flexWrap: 'wrap' }}>
<label>
<input
type="checkbox"
checked={Boolean(formData.is_required)}
onChange={(event) => setFormData((prev) => ({ ...prev, is_required: event.target.checked }))}
style={{ marginRight: '6px' }}
/>
Required
</label>
<label>
<input
type="checkbox"
checked={Boolean(formData.admin_only_edit)}
onChange={(event) => setFormData((prev) => ({ ...prev, admin_only_edit: event.target.checked }))}
style={{ marginRight: '6px' }}
/>
Admin-only edits
</label>
<label>
<input
type="checkbox"
checked={Boolean(formData.is_active)}
onChange={(event) => setFormData((prev) => ({ ...prev, is_active: event.target.checked }))}
style={{ marginRight: '6px' }}
/>
Active
</label>
</div>
<div style={{ display: 'flex', gap: '10px' }}>
<button className="btn btn-primary" onClick={handleSave} disabled={saving || !formData.key || !formData.label}>
{saving ? 'Saving...' : editingQuestionId ? 'Update Question' : 'Create Question'}
</button>
{editingQuestionId && (
<button className="btn btn-secondary" onClick={resetForm}>
Cancel Edit
</button>
)}
</div>
</div>
<h4 style={{ marginBottom: '10px' }}>Existing Questions</h4>
<input
type="text"
placeholder="Search by label or key..."
value={listSearch}
onChange={(event) => setListSearch(event.target.value)}
style={{ marginBottom: '10px', width: '100%', padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
/>
{loading ? ( {loading ? (
<p>Loading questions...</p> <p className="admin-empty">Loading questions...</p>
) : ( ) : (
<div className="table-container"> <div className="admin-table-shell">
<table style={{ width: '100%', borderCollapse: 'collapse' }}> <div className="admin-table-wrap">
<thead> <table className="admin-table admin-question-table">
<tr style={{ borderBottom: '2px solid #ddd' }}> <thead>
<th style={{ padding: '8px', textAlign: 'left' }}>Order</th> <tr>
<th style={{ padding: '8px', textAlign: 'left' }}>Label</th> <th>
<th style={{ padding: '8px', textAlign: 'left' }}>Type</th> <button type="button" className={sortKey === 'order' ? 'admin-table-sort active' : 'admin-table-sort'} onClick={() => toggleSort('order')}>
<th style={{ padding: '8px', textAlign: 'left' }}>Key</th> <span>Order</span>{renderSortArrow(sortKey === 'order', sortDirection)}
<th style={{ padding: '8px', textAlign: 'left' }}>Status</th>
<th style={{ padding: '8px', textAlign: 'left' }}>Actions</th>
</tr>
</thead>
<tbody>
{filteredQuestions.map((question) => (
<tr key={question.id} style={{ borderBottom: '1px solid #eee' }}>
<td style={{ padding: '8px' }}>{question.display_order}</td>
<td style={{ padding: '8px' }}>
{question.label}
{question.admin_only_edit && (
<span style={{ backgroundColor: '#eef2ff', color: '#3730a3', marginLeft: '8px', padding: '2px 7px', borderRadius: '999px', fontWeight: 600, fontSize: '12px' }}>
Admin Managed
</span>
)}
</td>
<td style={{ padding: '8px' }}>{question.input_type}</td>
<td style={{ padding: '8px' }}>{question.key}</td>
<td style={{ padding: '8px' }}>{question.is_active ? 'Active' : 'Inactive'}</td>
<td style={{ padding: '8px' }}>
<button className="btn btn-secondary" style={{ fontSize: '12px', padding: '4px 8px', marginRight: '6px' }} onClick={() => handleEdit(question)}>
Edit
</button> </button>
{question.is_active && ( </th>
<button className="btn btn-danger" style={{ fontSize: '12px', padding: '4px 8px' }} onClick={() => handleDeactivate(question.id)}> <th>
Deactivate <button type="button" className={sortKey === 'label' ? 'admin-table-sort active' : 'admin-table-sort'} onClick={() => toggleSort('label')}>
</button> <span>Label</span>{renderSortArrow(sortKey === 'label', sortDirection)}
)} </button>
</td> </th>
<th>
<button type="button" className={sortKey === 'type' ? 'admin-table-sort active' : 'admin-table-sort'} onClick={() => toggleSort('type')}>
<span>Type</span>{renderSortArrow(sortKey === 'type', sortDirection)}
</button>
</th>
<th>
<button type="button" className={sortKey === 'key' ? 'admin-table-sort active' : 'admin-table-sort'} onClick={() => toggleSort('key')}>
<span>Key</span>{renderSortArrow(sortKey === 'key', sortDirection)}
</button>
</th>
<th>
<button type="button" className={sortKey === 'status' ? 'admin-table-sort active' : 'admin-table-sort'} onClick={() => toggleSort('status')}>
<span>Status</span>{renderSortArrow(sortKey === 'status', sortDirection)}
</button>
</th>
<th>Actions</th>
</tr> </tr>
))} </thead>
</tbody> <tbody>
</table> {paginatedQuestions.map((question) => (
{filteredQuestions.length === 0 && ( <tr key={question.id}>
<p style={{ padding: '10px', color: '#666' }}>No questions match your search.</p> <td>{question.display_order}</td>
)} <td>
{question.label}
{question.admin_only_edit && <span className="admin-inline-badge">Admin Managed</span>}
</td>
<td>{question.input_type}</td>
<td>{question.key}</td>
<td>
<span className={`status-badge ${question.is_active ? 'status-active' : 'status-expired'}`}>
{question.is_active ? 'ACTIVE' : 'INACTIVE'}
</span>
</td>
<td>
<div className="table-button-row">
<button className="btn btn-secondary" onClick={() => handleEdit(question)}>
Edit
</button>
{question.is_active && (
<button className="btn btn-danger" onClick={() => handleDeactivate(question.id)}>
Deactivate
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
{filteredQuestions.length === 0 && (
<p className="admin-empty admin-table-empty">No questions match your search.</p>
)}
</div>
<div className="admin-pagination admin-table-footer">
<span>Page {currentPage} of {totalPages}</span>
<div className="admin-pager-controls">
<button className="admin-pager-button" disabled={currentPage <= 1} onClick={() => setCurrentPage((prev) => Math.max(1, prev - 1))} aria-label="Previous page">
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M10.5 3.5 6 8l4.5 4.5" /></svg>
</button>
<button className="admin-pager-button" disabled={currentPage >= totalPages} onClick={() => setCurrentPage((prev) => Math.min(totalPages, prev + 1))} aria-label="Next page">
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M5.5 3.5 10 8l-4.5 4.5" /></svg>
</button>
</div>
</div>
</div>
)}
{isEditorOpen && (
<div className="drawer-overlay" onClick={closeEditor}>
<aside className="user-drawer property-drawer admin-question-drawer" onClick={(event) => event.stopPropagation()}>
<div className="drawer-header">
<div className="drawer-header-main">
<span className="drawer-eyebrow">Profile Question</span>
<h3>{editingQuestionId ? 'Edit Question' : 'Create Question'}</h3>
</div>
<div className="drawer-header-actions">
<button className="drawer-close" onClick={closeEditor}>
×
</button>
</div>
</div>
<div className="drawer-body">
<div className="drawer-section">
<div className="admin-form-grid">
<input
className="admin-field"
type="text"
placeholder="Question key"
value={formData.key}
onChange={(event) => setFormData((prev) => ({ ...prev, key: event.target.value }))}
/>
<input
className="admin-field"
type="text"
placeholder="Question label"
value={formData.label}
onChange={(event) => setFormData((prev) => ({ ...prev, label: event.target.value }))}
/>
<textarea
className="admin-field admin-field-textarea"
placeholder="Help text"
value={formData.help_text || ''}
onChange={(event) => setFormData((prev) => ({ ...prev, help_text: event.target.value }))}
rows={2}
/>
<div className="admin-field-grid">
<select
className="admin-field"
value={formData.input_type}
onChange={(event) => setFormData((prev) => ({ ...prev, input_type: event.target.value as ProfileQuestionInputType }))}
>
{INPUT_TYPES.map((type) => (
<option key={type} value={type}>{type}</option>
))}
</select>
<input
className="admin-field"
type="number"
placeholder="Display order"
value={formData.display_order ?? 0}
onChange={(event) => setFormData((prev) => ({ ...prev, display_order: Number(event.target.value) }))}
/>
<input
className="admin-field"
type="text"
placeholder="Placeholder"
value={formData.placeholder || ''}
onChange={(event) => setFormData((prev) => ({ ...prev, placeholder: event.target.value }))}
/>
</div>
<div className="admin-field-grid">
<select
className="admin-field"
value={formData.depends_on_question_id ?? ''}
onChange={(event) => {
const nextValue = event.target.value;
setFormData((prev) => ({
...prev,
depends_on_question_id: nextValue ? Number(nextValue) : null,
depends_on_value: null
}));
}}
>
<option value="">No dependency</option>
{dependencyCandidates.map((question) => (
<option key={question.id} value={question.id}>{question.label}</option>
))}
</select>
{!selectedDependencyQuestion && (
<input
className="admin-field admin-field-disabled"
type="text"
placeholder="Choose a dependency question first"
value=""
disabled
/>
)}
{selectedDependencyQuestion?.input_type === 'select' && (
<select
className="admin-field"
value={formData.depends_on_value || ''}
onChange={(event) => setFormData((prev) => ({ ...prev, depends_on_value: event.target.value || null }))}
>
<option value="">Any answered value</option>
{selectedDependencyQuestion.options.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
)}
{selectedDependencyQuestion?.input_type === 'boolean' && (
<select
className="admin-field"
value={formData.depends_on_value || ''}
onChange={(event) => setFormData((prev) => ({ ...prev, depends_on_value: event.target.value || null }))}
>
<option value="">Any answered value</option>
<option value="true">Yes</option>
<option value="false">No</option>
</select>
)}
{selectedDependencyQuestion && !['select', 'boolean'].includes(selectedDependencyQuestion.input_type) && (
<input
className="admin-field"
type={selectedDependencyQuestion.input_type === 'number' ? 'number' : selectedDependencyQuestion.input_type === 'date' ? 'date' : 'text'}
placeholder="Show when parent answer equals..."
value={formData.depends_on_value || ''}
onChange={(event) => setFormData((prev) => ({ ...prev, depends_on_value: event.target.value || null }))}
/>
)}
</div>
{formData.input_type === 'select' && (
<textarea
className="admin-field admin-field-textarea"
value={optionsText}
onChange={(event) => setOptionsText(event.target.value)}
rows={4}
placeholder={'Options (one per line):\nNo|none\nPrivate Pilot|ppl'}
/>
)}
<div className="admin-switch-group admin-question-switches">
<label className="admin-switch-row"><input type="checkbox" checked={Boolean(formData.is_required)} onChange={(event) => setFormData((prev) => ({ ...prev, is_required: event.target.checked }))} />Required</label>
<label className="admin-switch-row"><input type="checkbox" checked={Boolean(formData.admin_only_edit)} onChange={(event) => setFormData((prev) => ({ ...prev, admin_only_edit: event.target.checked }))} />Admin-only edits</label>
<label className="admin-switch-row"><input type="checkbox" checked={Boolean(formData.is_active)} onChange={(event) => setFormData((prev) => ({ ...prev, is_active: event.target.checked }))} />Active</label>
</div>
<div className="admin-form-actions">
<button className="btn btn-primary" onClick={handleSave} disabled={saving || !formData.key || !formData.label}>
{saving ? 'Saving...' : editingQuestionId ? 'Update Question' : 'Create Question'}
</button>
<button className="btn btn-secondary" onClick={closeEditor}>
Cancel
</button>
</div>
</div>
</div>
</div>
</aside>
</div> </div>
)} )}
</div> </div>
+176 -224
View File
@@ -1,5 +1,8 @@
import React, { useState, useEffect } from 'react'; import React, { useEffect, useState } from 'react';
import axios from 'axios'; import axios from 'axios';
import { useToast } from '../contexts/ToastContext';
import { useConfirm } from '../contexts/ConfirmContext';
import { formatLondonDateTime, utcMillis } from '../utils/timezone';
interface BounceRecord { interface BounceRecord {
id: number; id: number;
@@ -22,12 +25,23 @@ interface BounceStats {
}; };
} }
const BounceManagement: React.FC = () => { interface BounceManagementProps {
cleanupToken?: number;
searchTerm?: string;
}
type BounceSortKey = 'email' | 'type' | 'reason' | 'date' | 'status';
const BounceManagement: React.FC<BounceManagementProps> = ({ cleanupToken = 0, searchTerm = '' }) => {
const toast = useToast();
const { confirm } = useConfirm();
const [bounces, setBounces] = useState<BounceRecord[]>([]); const [bounces, setBounces] = useState<BounceRecord[]>([]);
const [stats, setStats] = useState<BounceStats | null>(null); const [stats, setStats] = useState<BounceStats | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [searchEmail, setSearchEmail] = useState(''); const [currentPage, setCurrentPage] = useState(1);
const [filteredBounces, setFilteredBounces] = useState<BounceRecord[]>([]); const [sortKey, setSortKey] = useState<BounceSortKey>('date');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
const pageSize = 10;
useEffect(() => { useEffect(() => {
fetchBounces(); fetchBounces();
@@ -35,16 +49,10 @@ const BounceManagement: React.FC = () => {
}, []); }, []);
useEffect(() => { useEffect(() => {
if (searchEmail.trim() === '') { if (cleanupToken > 0) {
setFilteredBounces(bounces); void handleCleanupOldBounces();
} else {
setFilteredBounces(
bounces.filter(bounce =>
bounce.email.toLowerCase().includes(searchEmail.toLowerCase())
)
);
} }
}, [bounces, searchEmail]); }, [cleanupToken]);
const fetchBounces = async () => { const fetchBounces = async () => {
try { try {
@@ -73,264 +81,197 @@ const BounceManagement: React.FC = () => {
}; };
const handleDeactivateBounce = async (bounceId: number) => { const handleDeactivateBounce = async (bounceId: number) => {
if (!window.confirm('Are you sure you want to deactivate this bounce record?')) { const confirmed = await confirm({
return; title: 'Resolve bounce record',
} message: 'Are you sure you want to deactivate this bounce record?',
confirmLabel: 'Resolve',
tone: 'danger'
});
if (!confirmed) return;
try { try {
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
await axios.delete(`/api/v1/email/bounces/${bounceId}`, { await axios.delete(`/api/v1/email/bounces/${bounceId}`, {
headers: { Authorization: `Bearer ${token}` } headers: { Authorization: `Bearer ${token}` }
}); });
fetchBounces(); // Refresh the list fetchBounces();
fetchStats(); // Refresh stats fetchStats();
} catch (error) { } catch (error) {
console.error('Error deactivating bounce:', error); console.error('Error deactivating bounce:', error);
alert('Failed to deactivate bounce record'); toast.error('Failed to deactivate bounce record.');
} }
}; };
const handleCleanupOldBounces = async () => { const handleCleanupOldBounces = async () => {
if (!window.confirm('Are you sure you want to cleanup old soft bounces? This will deactivate soft bounces older than 365 days.')) { const confirmed = await confirm({
return; title: 'Cleanup old bounces',
} message: 'Are you sure you want to cleanup old soft bounces?',
confirmLabel: 'Cleanup',
tone: 'danger'
});
if (!confirmed) return;
try { try {
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
const response = await axios.post('/api/v1/email/bounces/cleanup', {}, { const response = await axios.post('/api/v1/email/bounces/cleanup', {}, {
headers: { Authorization: `Bearer ${token}` } headers: { Authorization: `Bearer ${token}` }
}); });
alert(response.data.message); toast.success(response.data.message);
fetchBounces(); // Refresh the list fetchBounces();
fetchStats(); // Refresh stats fetchStats();
} catch (error) { } catch (error) {
console.error('Error cleaning up bounces:', error); console.error('Error cleaning up bounces:', error);
alert('Failed to cleanup old bounces'); toast.error('Failed to cleanup old bounces.');
} }
}; };
const getBounceTypeColor = (type: string) => { const filteredBounces = bounces.filter((bounce) =>
switch (type) { searchTerm.trim() === '' ? true : bounce.email.toLowerCase().includes(searchTerm.toLowerCase())
case 'hard': return '#dc3545'; );
case 'soft': return '#ffc107';
case 'complaint': return '#fd7e14'; const sortedBounces = [...filteredBounces].sort((left, right) => {
case 'unsubscribe': return '#6c757d'; const compareValues = (a: string | number, b: string | number) => {
default: return '#6c757d'; if (typeof a === 'number' && typeof b === 'number') return a - b;
return String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: 'base' });
};
let result = 0;
switch (sortKey) {
case 'email':
result = compareValues(left.email, right.email);
break;
case 'type':
result = compareValues(left.bounce_type, right.bounce_type);
break;
case 'reason':
result = compareValues(left.bounce_reason || 'ZZZ', right.bounce_reason || 'ZZZ');
break;
case 'date':
result = compareValues(utcMillis(left.bounce_date), utcMillis(right.bounce_date));
break;
case 'status':
result = compareValues(left.is_active ? 0 : 1, right.is_active ? 0 : 1);
break;
} }
if (result === 0) {
result = compareValues(left.email, right.email);
}
return sortDirection === 'asc' ? result : -result;
});
const totalPages = Math.max(1, Math.ceil(filteredBounces.length / pageSize));
const paginatedBounces = sortedBounces.slice((currentPage - 1) * pageSize, currentPage * pageSize);
const formatDate = (dateString: string) => formatLondonDateTime(dateString);
useEffect(() => {
setCurrentPage(1);
}, [searchTerm]);
useEffect(() => {
if (currentPage > totalPages) {
setCurrentPage(totalPages);
}
}, [currentPage, totalPages]);
const toggleSort = (nextKey: BounceSortKey) => {
if (sortKey === nextKey) {
setSortDirection((prev) => (prev === 'asc' ? 'desc' : 'asc'));
return;
}
setSortKey(nextKey);
setSortDirection(nextKey === 'date' ? 'desc' : 'asc');
}; };
const formatDate = (dateString: string) => { const renderSortArrow = (active: boolean, direction: 'asc' | 'desc') => (
return new Date(dateString).toLocaleString(); <span className={`admin-sort-arrow ${active ? 'active' : ''} ${direction}`}>
}; <svg viewBox="0 0 16 16" aria-hidden="true">
<path d="M4 10.5 8 6l4 4.5" />
</svg>
</span>
);
if (loading) { if (loading) {
return ( return <p className="admin-empty">Loading bounce data...</p>;
<div style={{ textAlign: 'center', padding: '40px' }}>
<div>Loading bounce data...</div>
</div>
);
} }
return ( return (
<div> <div>
{/* Statistics Cards */}
{stats && ( {stats && (
<div style={{ <div className="admin-stat-grid">
display: 'grid', <div className="admin-stat-card"><span>Total Bounces</span><strong>{stats.total_bounces}</strong></div>
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', <div className="admin-stat-card attention"><span>Active Bounces</span><strong>{stats.active_bounces}</strong></div>
gap: '20px', <div className="admin-stat-card"><span>Hard Bounces</span><strong>{stats.bounce_types.hard}</strong></div>
marginBottom: '30px' <div className="admin-stat-card"><span>Soft Bounces</span><strong>{stats.bounce_types.soft}</strong></div>
}}>
<div style={{
backgroundColor: '#f8f9fa',
padding: '20px',
borderRadius: '8px',
border: '1px solid #dee2e6'
}}>
<h3 style={{ margin: '0 0 10px 0', color: '#495057' }}>Total Bounces</h3>
<div style={{ fontSize: '2rem', fontWeight: 'bold', color: '#dc3545' }}>
{stats.total_bounces}
</div>
</div>
<div style={{
backgroundColor: '#f8f9fa',
padding: '20px',
borderRadius: '8px',
border: '1px solid #dee2e6'
}}>
<h3 style={{ margin: '0 0 10px 0', color: '#495057' }}>Active Bounces</h3>
<div style={{ fontSize: '2rem', fontWeight: 'bold', color: '#ffc107' }}>
{stats.active_bounces}
</div>
</div>
<div style={{
backgroundColor: '#f8f9fa',
padding: '20px',
borderRadius: '8px',
border: '1px solid #dee2e6'
}}>
<h3 style={{ margin: '0 0 10px 0', color: '#495057' }}>Hard Bounces</h3>
<div style={{ fontSize: '2rem', fontWeight: 'bold', color: '#dc3545' }}>
{stats.bounce_types.hard}
</div>
</div>
<div style={{
backgroundColor: '#f8f9fa',
padding: '20px',
borderRadius: '8px',
border: '1px solid #dee2e6'
}}>
<h3 style={{ margin: '0 0 10px 0', color: '#495057' }}>Soft Bounces</h3>
<div style={{ fontSize: '2rem', fontWeight: 'bold', color: '#ffc107' }}>
{stats.bounce_types.soft}
</div>
</div>
</div> </div>
)} )}
{/* Controls */} <div className="admin-table-shell">
<div style={{ <div className="admin-table-wrap">
display: 'flex', <table className="admin-table">
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '20px',
gap: '20px'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
<label htmlFor="search" style={{ fontWeight: 'bold' }}>Search by Email:</label>
<input
id="search"
type="text"
value={searchEmail}
onChange={(e) => setSearchEmail(e.target.value)}
placeholder="Enter email address..."
style={{
padding: '8px 12px',
border: '1px solid #ced4da',
borderRadius: '4px',
minWidth: '250px'
}}
/>
</div>
<button
onClick={handleCleanupOldBounces}
style={{
padding: '8px 16px',
backgroundColor: '#6c757d',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer'
}}
>
Cleanup Old Bounces
</button>
</div>
{/* Bounce Records Table */}
<div style={{
backgroundColor: 'white',
borderRadius: '8px',
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
overflow: 'hidden'
}}>
<div style={{
padding: '20px',
borderBottom: '1px solid #dee2e6',
backgroundColor: '#f8f9fa'
}}>
<h2 style={{ margin: 0, color: '#495057' }}>Bounce Records</h2>
</div>
<div style={{ overflowX: 'auto' }}>
<table style={{
width: '100%',
borderCollapse: 'collapse'
}}>
<thead> <thead>
<tr style={{ backgroundColor: '#f8f9fa' }}> <tr>
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #dee2e6', fontWeight: 'bold' }}>Email</th> <th>
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #dee2e6', fontWeight: 'bold' }}>Type</th> <button type="button" className={sortKey === 'email' ? 'admin-table-sort active' : 'admin-table-sort'} onClick={() => toggleSort('email')}>
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #dee2e6', fontWeight: 'bold' }}>Reason</th> <span>Email</span>{renderSortArrow(sortKey === 'email', sortDirection)}
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #dee2e6', fontWeight: 'bold' }}>Date</th> </button>
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #dee2e6', fontWeight: 'bold' }}>Status</th> </th>
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #dee2e6', fontWeight: 'bold' }}>Actions</th> <th>
<button type="button" className={sortKey === 'type' ? 'admin-table-sort active' : 'admin-table-sort'} onClick={() => toggleSort('type')}>
<span>Type</span>{renderSortArrow(sortKey === 'type', sortDirection)}
</button>
</th>
<th>
<button type="button" className={sortKey === 'reason' ? 'admin-table-sort active' : 'admin-table-sort'} onClick={() => toggleSort('reason')}>
<span>Reason</span>{renderSortArrow(sortKey === 'reason', sortDirection)}
</button>
</th>
<th>
<button type="button" className={sortKey === 'date' ? 'admin-table-sort active' : 'admin-table-sort'} onClick={() => toggleSort('date')}>
<span>Date</span>{renderSortArrow(sortKey === 'date', sortDirection)}
</button>
</th>
<th>
<button type="button" className={sortKey === 'status' ? 'admin-table-sort active' : 'admin-table-sort'} onClick={() => toggleSort('status')}>
<span>Status</span>{renderSortArrow(sortKey === 'status', sortDirection)}
</button>
</th>
<th>Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{filteredBounces.length === 0 ? ( {paginatedBounces.length === 0 ? (
<tr> <tr>
<td colSpan={6} style={{ <td colSpan={6} className="admin-table-empty">
padding: '40px', {searchTerm ? 'No bounces found matching your search.' : 'No bounce records found.'}
textAlign: 'center',
color: '#6c757d',
fontStyle: 'italic'
}}>
{searchEmail ? 'No bounces found matching your search.' : 'No bounce records found.'}
</td> </td>
</tr> </tr>
) : ( ) : (
filteredBounces.map((bounce) => ( paginatedBounces.map((bounce) => (
<tr key={bounce.id} style={{ borderBottom: '1px solid #f1f3f4' }}> <tr key={bounce.id}>
<td style={{ padding: '12px' }}> <td>
<div style={{ fontWeight: '500' }}>{bounce.email}</div> <strong>{bounce.email}</strong>
{bounce.smtp2go_message_id && ( {bounce.smtp2go_message_id && <span className="muted-line">ID: {bounce.smtp2go_message_id}</span>}
<div style={{ fontSize: '0.8rem', color: '#6c757d' }}>
ID: {bounce.smtp2go_message_id}
</div>
)}
</td> </td>
<td style={{ padding: '12px' }}> <td>
<span style={{ <span className={`status-badge ${
backgroundColor: getBounceTypeColor(bounce.bounce_type), bounce.bounce_type === 'soft' ? 'status-pending' :
color: 'white', bounce.bounce_type === 'hard' ? 'status-expired' :
padding: '4px 8px', 'status-active'
borderRadius: '4px', }`}>
fontSize: '0.8rem', {bounce.bounce_type.toUpperCase()}
fontWeight: 'bold',
textTransform: 'uppercase'
}}>
{bounce.bounce_type}
</span> </span>
</td> </td>
<td style={{ padding: '12px', maxWidth: '300px' }}> <td>{bounce.bounce_reason || 'No reason provided'}</td>
<div style={{ <td>{formatDate(bounce.bounce_date)}</td>
overflow: 'hidden', <td>
textOverflow: 'ellipsis', <span className={`status-badge ${bounce.is_active ? 'status-expired' : 'status-active'}`}>
whiteSpace: 'nowrap' {bounce.is_active ? 'ACTIVE' : 'RESOLVED'}
}}>
{bounce.bounce_reason || 'No reason provided'}
</div>
</td>
<td style={{ padding: '12px' }}>
{formatDate(bounce.bounce_date)}
</td>
<td style={{ padding: '12px' }}>
<span style={{
color: bounce.is_active ? '#dc3545' : '#28a745',
fontWeight: 'bold'
}}>
{bounce.is_active ? 'Active' : 'Resolved'}
</span> </span>
</td> </td>
<td style={{ padding: '12px' }}> <td>
{bounce.is_active && ( {bounce.is_active && (
<button <button className="btn btn-primary" onClick={() => handleDeactivateBounce(bounce.id)}>
onClick={() => handleDeactivateBounce(bounce.id)}
style={{
padding: '6px 12px',
backgroundColor: '#28a745',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '0.8rem'
}}
>
Resolve Resolve
</button> </button>
)} )}
@@ -341,9 +282,20 @@ const BounceManagement: React.FC = () => {
</tbody> </tbody>
</table> </table>
</div> </div>
<div className="admin-pagination admin-table-footer">
<span>Page {currentPage} of {totalPages}</span>
<div className="admin-pager-controls">
<button className="admin-pager-button" disabled={currentPage <= 1} onClick={() => setCurrentPage((prev) => Math.max(1, prev - 1))} aria-label="Previous page">
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M10.5 3.5 6 8l4.5 4.5" /></svg>
</button>
<button className="admin-pager-button" disabled={currentPage >= totalPages} onClick={() => setCurrentPage((prev) => Math.min(totalPages, prev + 1))} aria-label="Next page">
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M5.5 3.5 10 8l-4.5 4.5" /></svg>
</button>
</div>
</div>
</div> </div>
</div> </div>
); );
}; };
export default BounceManagement; export default BounceManagement;
@@ -1,5 +1,6 @@
import React, { useState, useEffect } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import axios from 'axios'; import axios from 'axios';
import { useToast } from '../contexts/ToastContext';
interface EmailTemplate { interface EmailTemplate {
template_key: string; template_key: string;
@@ -7,22 +8,55 @@ interface EmailTemplate {
subject: string; subject: string;
html_body: string; html_body: string;
text_body: string; text_body: string;
variables: string; // This comes as JSON string from backend variables: string;
is_active: boolean; is_active: boolean;
} }
const EmailTemplateManagement: React.FC = () => { interface EmailTemplateManagementProps {
searchTerm?: string;
statusFilter?: 'all' | 'active' | 'inactive';
refreshToken?: number;
}
type TemplateSortKey = 'name' | 'key' | 'subject' | 'variables' | 'status';
const parseTemplateVariables = (variables: string): string[] => {
try {
const parsed = JSON.parse(variables);
return Array.isArray(parsed) ? parsed : [];
} catch {
return variables
.split(',')
.map((value) => value.trim())
.filter(Boolean);
}
};
const EmailTemplateManagement: React.FC<EmailTemplateManagementProps> = ({
searchTerm = '',
statusFilter = 'all',
refreshToken = 0
}) => {
const toast = useToast();
const [templates, setTemplates] = useState<EmailTemplate[]>([]); const [templates, setTemplates] = useState<EmailTemplate[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [editingTemplate, setEditingTemplate] = useState<EmailTemplate | null>(null); const [editingTemplate, setEditingTemplate] = useState<EmailTemplate | null>(null);
const [showEditForm, setShowEditForm] = useState(false); const [sortKey, setSortKey] = useState<TemplateSortKey>('name');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
useEffect(() => { useEffect(() => {
fetchTemplates(); void fetchTemplates();
}, []); }, []);
useEffect(() => {
if (refreshToken > 0) {
void fetchTemplates();
}
}, [refreshToken]);
const fetchTemplates = async () => { const fetchTemplates = async () => {
try { try {
setLoading(true);
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
const response = await axios.get('/api/v1/email-templates/', { const response = await axios.get('/api/v1/email-templates/', {
headers: { Authorization: `Bearer ${token}` } headers: { Authorization: `Bearer ${token}` }
@@ -30,153 +64,202 @@ const EmailTemplateManagement: React.FC = () => {
setTemplates(response.data); setTemplates(response.data);
} catch (error) { } catch (error) {
console.error('Error fetching email templates:', error); console.error('Error fetching email templates:', error);
toast.error('Failed to load email templates.');
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
const handleEditTemplate = (template: EmailTemplate) => {
setEditingTemplate(template);
setShowEditForm(true);
};
const handleSaveTemplate = async (updatedTemplate: EmailTemplate) => { const handleSaveTemplate = async (updatedTemplate: EmailTemplate) => {
try { try {
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
await axios.put(`/api/v1/email-templates/${updatedTemplate.template_key}`, updatedTemplate, { await axios.put(`/api/v1/email-templates/${updatedTemplate.template_key}`, updatedTemplate, {
headers: { Authorization: `Bearer ${token}` } headers: { Authorization: `Bearer ${token}` }
}); });
setShowEditForm(false);
setEditingTemplate(null); setEditingTemplate(null);
fetchTemplates(); // Refresh the list toast.success('Email template updated.');
void fetchTemplates();
} catch (error) { } catch (error) {
console.error('Error updating email template:', error); console.error('Error updating email template:', error);
toast.error('Failed to update email template.');
} }
}; };
const handleCancelEdit = () => { const filteredTemplates = useMemo(() => {
setShowEditForm(false); const normalizedSearch = searchTerm.trim().toLowerCase();
setEditingTemplate(null);
return templates.filter((template) => {
const matchesSearch = normalizedSearch === '' || [
template.name,
template.template_key,
template.subject
].some((value) => value.toLowerCase().includes(normalizedSearch));
const matchesStatus =
statusFilter === 'all' ||
(statusFilter === 'active' && template.is_active) ||
(statusFilter === 'inactive' && !template.is_active);
return matchesSearch && matchesStatus;
});
}, [searchTerm, statusFilter, templates]);
const sortedTemplates = 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' });
};
const sorted = [...filteredTemplates].sort((left, right) => {
let result = 0;
switch (sortKey) {
case 'name':
result = compareValues(left.name, right.name);
break;
case 'key':
result = compareValues(left.template_key, right.template_key);
break;
case 'subject':
result = compareValues(left.subject, right.subject);
break;
case 'variables':
result = compareValues(parseTemplateVariables(left.variables).length, parseTemplateVariables(right.variables).length);
break;
case 'status':
result = compareValues(left.is_active ? 0 : 1, right.is_active ? 0 : 1);
break;
}
if (result === 0) {
result = compareValues(left.name, right.name);
}
return sortDirection === 'asc' ? result : -result;
});
return sorted;
}, [filteredTemplates, sortDirection, sortKey]);
const toggleSort = (nextKey: TemplateSortKey) => {
if (sortKey === nextKey) {
setSortDirection((prev) => (prev === 'asc' ? 'desc' : 'asc'));
return;
}
setSortKey(nextKey);
setSortDirection('asc');
}; };
const renderSortArrow = (active: boolean, direction: 'asc' | 'desc') => (
<span className={`admin-sort-arrow ${active ? 'active' : ''} ${direction}`}>
<svg viewBox="0 0 16 16" aria-hidden="true">
<path d="M4 10.5 8 6l4 4.5" />
</svg>
</span>
);
if (loading) { if (loading) {
return <div style={{ padding: '20px', textAlign: 'center' }}>Loading email templates...</div>; return <p className="admin-empty">Loading email templates...</p>;
} }
return ( return (
<div style={{ maxWidth: '1200px', margin: '0 auto', padding: '20px' }}> <div>
<div style={{ marginBottom: '20px' }}> <div className="admin-table-shell">
<button <div className="admin-table-wrap">
onClick={fetchTemplates} <table className="admin-table">
style={{ <thead>
padding: '8px 16px', <tr>
backgroundColor: '#007bff', <th>
color: 'white', <button type="button" className={sortKey === 'name' ? 'admin-table-sort active' : 'admin-table-sort'} onClick={() => toggleSort('name')}>
border: 'none', <span>Template</span>{renderSortArrow(sortKey === 'name', sortDirection)}
borderRadius: '4px', </button>
cursor: 'pointer' </th>
}} <th>
> <button type="button" className={sortKey === 'key' ? 'admin-table-sort active' : 'admin-table-sort'} onClick={() => toggleSort('key')}>
Refresh Templates <span>Key</span>{renderSortArrow(sortKey === 'key', sortDirection)}
</button> </button>
</th>
<th>
<button type="button" className={sortKey === 'subject' ? 'admin-table-sort active' : 'admin-table-sort'} onClick={() => toggleSort('subject')}>
<span>Subject</span>{renderSortArrow(sortKey === 'subject', sortDirection)}
</button>
</th>
<th>
<button type="button" className={sortKey === 'variables' ? 'admin-table-sort active' : 'admin-table-sort'} onClick={() => toggleSort('variables')}>
<span>Variables</span>{renderSortArrow(sortKey === 'variables', sortDirection)}
</button>
</th>
<th>
<button type="button" className={sortKey === 'status' ? 'admin-table-sort active' : 'admin-table-sort'} onClick={() => toggleSort('status')}>
<span>Status</span>{renderSortArrow(sortKey === 'status', sortDirection)}
</button>
</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{sortedTemplates.length === 0 ? (
<tr>
<td className="admin-table-empty" colSpan={6}>No templates match the current filters.</td>
</tr>
) : (
sortedTemplates.map((template) => {
const variables = parseTemplateVariables(template.variables);
return (
<tr key={template.template_key} onClick={() => setEditingTemplate(template)}>
<td>
<strong>{template.name}</strong>
<div className="muted-line">{variables.length} variable{variables.length === 1 ? '' : 's'}</div>
</td>
<td>
<code>{template.template_key}</code>
</td>
<td>{template.subject}</td>
<td>
{variables.length > 0 ? (
<div className="admin-inline-list">
{variables.slice(0, 3).map((variable) => (
<span key={variable} className="admin-inline-chip">{variable}</span>
))}
{variables.length > 3 && <span className="muted-line">+{variables.length - 3} more</span>}
</div>
) : (
<span className="muted-line">None</span>
)}
</td>
<td>
<span className={`status-badge ${template.is_active ? 'status-active' : 'status-expired'}`}>
{template.is_active ? 'Active' : 'Inactive'}
</span>
</td>
<td>
<div className="table-button-row">
<button
className="btn btn-primary"
type="button"
onClick={(event) => {
event.stopPropagation();
setEditingTemplate(template);
}}
>
Edit
</button>
</div>
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
</div> </div>
<div style={{ display: 'grid', gap: '20px', gridTemplateColumns: 'repeat(auto-fill, minmax(400px, 1fr))' }}> {editingTemplate && (
{templates.map((template) => (
<div
key={template.template_key}
style={{
border: '1px solid #ddd',
borderRadius: '8px',
padding: '20px',
backgroundColor: 'white',
boxShadow: '0 2px 4px rgba(0,0,0,0.1)'
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '15px' }}>
<h3 style={{ margin: 0, color: '#333', fontSize: '18px' }}>{template.name}</h3>
<div>
<span style={{
padding: '4px 8px',
borderRadius: '4px',
fontSize: '12px',
fontWeight: 'bold',
backgroundColor: template.is_active ? '#d4edda' : '#f8d7da',
color: template.is_active ? '#155724' : '#721c24'
}}>
{template.is_active ? 'Active' : 'Inactive'}
</span>
<button
onClick={() => handleEditTemplate(template)}
style={{
marginLeft: '10px',
padding: '6px 12px',
backgroundColor: '#28a745',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '14px'
}}
>
Edit
</button>
</div>
</div>
<div style={{ marginBottom: '10px' }}>
<strong style={{ color: '#666' }}>Key:</strong> <code style={{ backgroundColor: '#f8f9fa', padding: '2px 4px', borderRadius: '3px' }}>{template.template_key}</code>
</div>
<div style={{ marginBottom: '10px' }}>
<strong style={{ color: '#666' }}>Subject:</strong> <span style={{ color: '#333' }}>{template.subject}</span>
</div>
<div style={{ marginBottom: '15px' }}>
<strong style={{ color: '#666' }}>Variables:</strong>
<div style={{ marginTop: '5px', fontFamily: 'monospace', fontSize: '14px', color: '#333' }}>
{(() => {
try {
const vars = JSON.parse(template.variables);
return Array.isArray(vars) ? vars.join(', ') : template.variables;
} catch {
return template.variables;
}
})()}
</div>
</div>
<div>
<strong style={{ color: '#666' }}>HTML Body Preview:</strong>
<div
style={{
marginTop: '8px',
padding: '12px',
backgroundColor: '#f8f9fa',
border: '1px solid #e9ecef',
borderRadius: '4px',
maxHeight: '200px',
overflow: 'auto',
fontSize: '13px',
lineHeight: '1.4',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
color: '#333'
}}
>
{template.html_body.substring(0, 300)}
{template.html_body.length > 300 ? '...' : ''}
</div>
</div>
</div>
))}
</div>
{showEditForm && editingTemplate && (
<EmailTemplateEditForm <EmailTemplateEditForm
template={editingTemplate} template={editingTemplate}
onSave={handleSaveTemplate} onSave={handleSaveTemplate}
onCancel={handleCancelEdit} onCancel={() => setEditingTemplate(null)}
/> />
)} )}
</div> </div>
@@ -190,6 +273,7 @@ interface EmailTemplateEditFormProps {
} }
const EmailTemplateEditForm: React.FC<EmailTemplateEditFormProps> = ({ template, onSave, onCancel }) => { const EmailTemplateEditForm: React.FC<EmailTemplateEditFormProps> = ({ template, onSave, onCancel }) => {
const [previewMode, setPreviewMode] = useState<'rendered' | 'html' | 'text'>('rendered');
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
name: template.name, name: template.name,
subject: template.subject, subject: template.subject,
@@ -206,198 +290,186 @@ const EmailTemplateEditForm: React.FC<EmailTemplateEditFormProps> = ({ template,
is_active: template.is_active is_active: template.is_active
}); });
const handleChange = (field: keyof EmailTemplate, value: any) => { const handleChange = (field: keyof typeof formData, value: any) => {
setFormData(prev => ({ ...prev, [field]: value })); setFormData((prev) => ({ ...prev, [field]: value }));
}; };
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
const dataToSave = { onSave({
template_key: template.template_key, template_key: template.template_key,
...formData, ...formData,
variables: JSON.stringify(formData.variables) variables: JSON.stringify(formData.variables)
}; });
onSave(dataToSave); };
}; return (
<div style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000
}}>
<div style={{
backgroundColor: 'white',
padding: '20px',
borderRadius: '8px',
width: '90%',
maxWidth: '800px',
maxHeight: '90vh',
overflow: 'auto'
}}>
<h3 style={{ marginTop: 0, marginBottom: '20px' }}>Edit Email Template: {template.name}</h3>
<form onSubmit={handleSubmit}> const previewDocument = useMemo(() => {
<div style={{ marginBottom: '15px' }}> return `
<label style={{ display: 'block', marginBottom: '5px', fontWeight: 'bold' }}> <!doctype html>
Template Key: <html lang="en">
</label> <head>
<input <meta charset="utf-8" />
type="text" <meta name="viewport" content="width=device-width, initial-scale=1" />
value={template.template_key} <style>
disabled body {
style={{ margin: 0;
width: '100%', padding: 24px;
padding: '8px', background: #ffffff;
border: '1px solid #ddd', color: #111111;
borderRadius: '4px', font-family: Arial, sans-serif;
backgroundColor: '#f5f5f5' }
}} </style>
/> </head>
<small style={{ color: '#666' }}>Template key cannot be changed</small> <body>
${formData.html_body}
</body>
</html>
`;
}, [formData.html_body]);
return (
<div className="drawer-overlay" onClick={onCancel}>
<aside className="user-drawer property-drawer" onClick={(event) => event.stopPropagation()}>
<div className="drawer-header">
<div className="drawer-header-main">
<span className="drawer-eyebrow">Template Editor</span>
<h3>Edit Email Template</h3>
<p>{template.name}</p>
</div>
<div className="drawer-header-actions">
<button className="drawer-close" type="button" onClick={onCancel}>×</button>
</div>
</div>
<div className="drawer-hero">
<div className="drawer-hero-grid">
<div className="drawer-hero-card">
<span className="drawer-hero-label">Key</span>
<span className="drawer-hero-value">{template.template_key}</span>
</div>
<div className="drawer-hero-card">
<span className="drawer-hero-label">Status</span>
<span className="drawer-hero-value">
<span className={`status-badge ${formData.is_active ? 'status-active' : 'status-expired'}`}>
{formData.is_active ? 'ACTIVE' : 'INACTIVE'}
</span>
</span>
</div>
</div>
</div>
<div className="drawer-body">
<div className="drawer-section">
<div className="drawer-section-header">
<div>
<h4>Template Content</h4>
</div>
</div>
<form onSubmit={handleSubmit}>
<div className="form-group">
<label>Template Key</label>
<input type="text" value={template.template_key} disabled className="admin-field admin-field-disabled" />
</div>
<div className="form-group">
<label>Name</label>
<input type="text" value={formData.name} onChange={(e) => handleChange('name', e.target.value)} className="admin-field" required />
</div>
<div className="form-group">
<label>Subject</label>
<input type="text" value={formData.subject} onChange={(e) => handleChange('subject', e.target.value)} className="admin-field" required />
</div>
<div className="form-group">
<label>Variables</label>
<input
type="text"
value={formData.variables.join(', ')}
onChange={(e) => handleChange('variables', e.target.value.split(',').map((v) => v.trim()).filter(Boolean))}
className="admin-field"
/>
</div>
<div className="form-group">
<label>HTML Body</label>
<textarea
value={formData.html_body}
onChange={(e) => handleChange('html_body', e.target.value)}
rows={15}
className="admin-field admin-field-textarea admin-code-textarea"
required
/>
</div>
<div className="form-group">
<label>Text Body</label>
<textarea
value={formData.text_body}
onChange={(e) => handleChange('text_body', e.target.value)}
rows={10}
className="admin-field admin-field-textarea admin-code-textarea"
required
/>
</div>
<label className="admin-switch-row" style={{ marginBottom: '20px' }}>
<input type="checkbox" checked={formData.is_active} onChange={(e) => handleChange('is_active', e.target.checked)} />
Active
</label>
<div className="table-button-row" style={{ justifyContent: 'flex-end' }}>
<button type="button" className="btn btn-secondary" onClick={onCancel}>Cancel</button>
<button type="submit" className="btn btn-primary">Save Changes</button>
</div>
</form>
</div> </div>
<div style={{ marginBottom: '15px' }}> <div className="drawer-section">
<label style={{ display: 'block', marginBottom: '5px', fontWeight: 'bold' }}> <div className="drawer-section-header">
Name: <div>
</label> <h4>Preview</h4>
<input </div>
type="text" </div>
value={formData.name}
onChange={(e) => handleChange('name', e.target.value)}
style={{
width: '100%',
padding: '8px',
border: '1px solid #ddd',
borderRadius: '4px'
}}
required
/>
</div>
<div style={{ marginBottom: '15px' }}> <div className="email-preview-tabs" role="tablist" aria-label="Email preview mode">
<label style={{ display: 'block', marginBottom: '5px', fontWeight: 'bold' }}> <button
Subject: type="button"
</label> className={previewMode === 'rendered' ? 'email-preview-tab active' : 'email-preview-tab'}
<input onClick={() => setPreviewMode('rendered')}
type="text" >
value={formData.subject} Rendered
onChange={(e) => handleChange('subject', e.target.value)} </button>
style={{ <button
width: '100%', type="button"
padding: '8px', className={previewMode === 'html' ? 'email-preview-tab active' : 'email-preview-tab'}
border: '1px solid #ddd', onClick={() => setPreviewMode('html')}
borderRadius: '4px' >
}} HTML
required </button>
/> <button
</div> type="button"
className={previewMode === 'text' ? 'email-preview-tab active' : 'email-preview-tab'}
onClick={() => setPreviewMode('text')}
>
Text
</button>
</div>
<div style={{ marginBottom: '15px' }}> {previewMode === 'rendered' && (
<label style={{ display: 'block', marginBottom: '5px', fontWeight: 'bold' }}> <div className="email-preview-frame-shell">
Variables (comma-separated): <iframe
</label> title={`${template.name} preview`}
<input className="email-preview-frame"
type="text" srcDoc={previewDocument}
value={formData.variables.join(', ')} />
onChange={(e) => handleChange('variables', e.target.value.split(',').map(v => v.trim()))} </div>
style={{ )}
width: '100%',
padding: '8px',
border: '1px solid #ddd',
borderRadius: '4px'
}}
/>
</div>
<div style={{ marginBottom: '15px' }}> {previewMode === 'html' && (
<label style={{ display: 'block', marginBottom: '5px', fontWeight: 'bold' }}> <pre className="email-preview-code">{formData.html_body}</pre>
HTML Body: )}
</label>
<textarea
value={formData.html_body}
onChange={(e) => handleChange('html_body', e.target.value)}
rows={15}
style={{
width: '100%',
padding: '8px',
border: '1px solid #ddd',
borderRadius: '4px',
fontFamily: 'monospace',
fontSize: '14px'
}}
required
/>
</div>
<div style={{ marginBottom: '15px' }}> {previewMode === 'text' && (
<label style={{ display: 'block', marginBottom: '5px', fontWeight: 'bold' }}> <pre className="email-preview-code">{formData.text_body}</pre>
Text Body: )}
</label>
<textarea
value={formData.text_body}
onChange={(e) => handleChange('text_body', e.target.value)}
rows={10}
style={{
width: '100%',
padding: '8px',
border: '1px solid #ddd',
borderRadius: '4px',
fontFamily: 'monospace',
fontSize: '14px'
}}
required
/>
</div> </div>
</div>
<div style={{ marginBottom: '20px' }}> </aside>
<label style={{ display: 'flex', alignItems: 'center' }}>
<input
type="checkbox"
checked={formData.is_active}
onChange={(e) => handleChange('is_active', e.target.checked)}
style={{ marginRight: '8px' }}
/>
Active
</label>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '10px' }}>
<button
type="button"
onClick={onCancel}
style={{
padding: '8px 16px',
backgroundColor: '#6c757d',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer'
}}
>
Cancel
</button>
<button
type="submit"
style={{
padding: '8px 16px',
backgroundColor: '#28a745',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer'
}}
>
Save Changes
</button>
</div>
</form>
</div>
</div> </div>
); );
}; };
+32 -35
View File
@@ -5,30 +5,26 @@ const FeatureFlagStatus: React.FC = () => {
const { flags, loading, error, reloadFlags } = useFeatureFlags(); const { flags, loading, error, reloadFlags } = useFeatureFlags();
if (loading) { if (loading) {
return <div style={{ fontSize: '14px', color: '#666' }}>Loading feature flags...</div>; return <div style={{ fontSize: '14px', color: '#8D96A3' }}>Loading feature flags...</div>;
} }
if (error) { if (error) {
return <div style={{ fontSize: '14px', color: '#d32f2f' }}>Error loading feature flags</div>; return <div style={{ fontSize: '14px', color: '#EE6368' }}>Error loading feature flags</div>;
} }
if (!flags) { if (!flags) {
return null; return null;
} }
const handleReload = async () => {
try {
await reloadFlags();
console.log('Feature flags reloaded');
} catch (error) {
console.error('Failed to reload feature flags:', error);
}
};
return ( return (
<div className="card" style={{ marginBottom: '20px' }}> <div className="admin-surface" style={{ marginBottom: '20px' }}>
<h4 style={{ marginBottom: '16px' }}>Feature Flags Status</h4> <div className="admin-surface-header">
<div>
<h4>Feature Flags Status</h4>
<p>Environment-driven switches for admin-controlled behavior.</p>
</div>
</div>
<div style={{ display: 'grid', gap: '8px', marginBottom: '16px' }}> <div style={{ display: 'grid', gap: '8px', marginBottom: '16px' }}>
{Object.entries(flags.flags).map(([name, value]) => ( {Object.entries(flags.flags).map(([name, value]) => (
<div <div
@@ -37,23 +33,28 @@ const FeatureFlagStatus: React.FC = () => {
display: 'flex', display: 'flex',
justifyContent: 'space-between', justifyContent: 'space-between',
alignItems: 'center', alignItems: 'center',
padding: '8px 12px', padding: '10px 12px',
backgroundColor: '#f5f5f5', background: 'rgba(16,18,22,0.72)',
borderRadius: '4px', borderTop: '1px solid rgba(64,71,80,0.55)',
fontSize: '14px' borderBottom: '1px solid rgba(34,38,44,0.96)',
borderLeft: '1px solid rgba(42,46,52,0.78)',
borderRight: '1px solid rgba(42,46,52,0.78)',
borderRadius: '3px',
fontSize: '12px'
}} }}
> >
<span style={{ fontWeight: '500' }}> <span style={{ fontWeight: 500, color: '#E6EBF2' }}>
{name.replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, l => l.toUpperCase())} {name.replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, (l) => l.toUpperCase())}
</span> </span>
<span <span
style={{ style={{
padding: '2px 8px', padding: '2px 8px',
borderRadius: '12px', borderRadius: '999px',
fontSize: '12px', fontSize: '11px',
fontWeight: '500', fontWeight: 500,
backgroundColor: value ? '#4CAF50' : '#f44336', background: value ? 'rgba(47,162,82,.13)' : 'rgba(92,31,33,.4)',
color: 'white' color: value ? '#2FA252' : '#EE6368',
border: `1px solid ${value ? 'rgba(47,162,82,.36)' : 'rgba(238,99,104,.42)'}`
}} }}
> >
{String(value)} {String(value)}
@@ -61,20 +62,16 @@ const FeatureFlagStatus: React.FC = () => {
</div> </div>
))} ))}
</div> </div>
<button <button className="btn btn-secondary" onClick={reloadFlags} style={{ fontSize: '12px', padding: '6px 12px' }}>
className="btn btn-secondary"
onClick={handleReload}
style={{ fontSize: '12px', padding: '6px 12px' }}
>
Reload Flags Reload Flags
</button> </button>
<p style={{ fontSize: '12px', color: '#666', marginTop: '12px', marginBottom: 0 }}> <p style={{ fontSize: '12px', color: '#8D96A3', marginTop: '12px', marginBottom: 0 }}>
Feature flags are loaded from environment variables. Changes require updating the .env file and reloading. Feature flags are loaded from environment variables. Changes require updating the environment and reloading.
</p> </p>
</div> </div>
); );
}; };
export default FeatureFlagStatus; export default FeatureFlagStatus;
+55 -70
View File
@@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react';
import { membershipService, paymentService, MembershipTier, MembershipCreateData, PaymentCreateData } from '../services/membershipService'; import { membershipService, paymentService, MembershipTier, MembershipCreateData, PaymentCreateData } from '../services/membershipService';
import { useFeatureFlags } from '../contexts/FeatureFlagContext'; import { useFeatureFlags } from '../contexts/FeatureFlagContext';
import SquarePaymentNew from './SquarePaymentNew'; import SquarePaymentNew from './SquarePaymentNew';
import { londonTodayDateInput } from '../utils/timezone';
interface MembershipSetupProps { interface MembershipSetupProps {
onMembershipCreated: () => void; onMembershipCreated: () => void;
@@ -85,8 +86,10 @@ const MembershipSetup: React.FC<MembershipSetupProps> = ({ onMembershipCreated,
setError(''); setError('');
try { try {
const startDate = new Date().toISOString().split('T')[0]; const startDate = londonTodayDateInput();
const endDate = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]; const endDateValue = new Date(`${startDate}T00:00:00Z`);
endDateValue.setUTCFullYear(endDateValue.getUTCFullYear() + 1);
const endDate = endDateValue.toISOString().split('T')[0];
const membershipData: MembershipCreateData = { const membershipData: MembershipCreateData = {
tier_id: selectedTier.id, tier_id: selectedTier.id,
@@ -112,47 +115,38 @@ const MembershipSetup: React.FC<MembershipSetupProps> = ({ onMembershipCreated,
if (step === 'select') { if (step === 'select') {
return ( return (
<div className="card"> <div className="card member-card member-membership-setup">
<h3 style={{ marginBottom: '16px' }}>Choose Your Membership</h3> <div className="member-card-header">
<div>
<p className="member-card-kicker">Membership Setup</p>
<h3>Choose Your Membership</h3>
</div>
</div>
{error && <div className="alert alert-error">{error}</div>} {error && <div className="alert alert-error">{error}</div>}
<div style={{ display: 'grid', gap: '16px' }}> <div className="membership-tier-grid">
{tiers.map(tier => ( {tiers.map(tier => (
<div <div
key={tier.id} key={tier.id}
style={{ className="membership-tier-card"
border: '1px solid #ddd',
borderRadius: '8px',
padding: '16px',
cursor: 'pointer',
transition: 'all 0.3s'
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = '#0066cc';
e.currentTarget.style.boxShadow = '0 2px 8px rgba(0, 102, 204, 0.1)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = '#ddd';
e.currentTarget.style.boxShadow = 'none';
}}
onClick={() => handleTierSelect(tier)} onClick={() => handleTierSelect(tier)}
> >
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px' }}> <div className="membership-tier-header">
<h4 style={{ margin: 0, color: '#0066cc' }}>{tier.name}</h4> <h4>{tier.name}</h4>
<span style={{ fontSize: '18px', fontWeight: 'bold', color: '#0066cc' }}> <span className="membership-tier-price">
£{tier.annual_fee.toFixed(2)}/year £{tier.annual_fee.toFixed(2)}/year
</span> </span>
</div> </div>
<p style={{ margin: '8px 0', color: '#666', fontSize: '14px' }}>{tier.description}</p> <p className="membership-tier-description">{tier.description}</p>
<div style={{ backgroundColor: '#f5f5f5', padding: '12px', borderRadius: '4px' }}> <div className="membership-tier-benefits">
<strong>Benefits:</strong> <strong>Benefits:</strong>
<p style={{ marginTop: '4px', fontSize: '14px' }}>{tier.benefits}</p> <p>{tier.benefits}</p>
</div> </div>
</div> </div>
))} ))}
</div> </div>
<div style={{ marginTop: '20px', textAlign: 'center' }}> <div className="membership-setup-actions">
<button <button
type="button" type="button"
className="btn btn-secondary" className="btn btn-secondary"
@@ -167,12 +161,17 @@ const MembershipSetup: React.FC<MembershipSetupProps> = ({ onMembershipCreated,
if (step === 'payment') { if (step === 'payment') {
return ( return (
<div className="card"> <div className="card member-card member-membership-setup">
<h3 style={{ marginBottom: '16px' }}>Complete Payment</h3> <div className="member-card-header">
<div>
<p className="member-card-kicker">Membership Setup</p>
<h3>Complete Payment</h3>
</div>
</div>
{error && <div className="alert alert-error">{error}</div>} {error && <div className="alert alert-error">{error}</div>}
{selectedTier && ( {selectedTier && (
<div style={{ marginBottom: '20px' }}> <div className="membership-summary-panel">
<h4>Selected Membership: {selectedTier.name}</h4> <h4>Selected Membership: {selectedTier.name}</h4>
<p><strong>Annual Fee:</strong> £{selectedTier.annual_fee.toFixed(2)}</p> <p><strong>Annual Fee:</strong> £{selectedTier.annual_fee.toFixed(2)}</p>
<p><strong>Benefits:</strong> {selectedTier.benefits}</p> <p><strong>Benefits:</strong> {selectedTier.benefits}</p>
@@ -180,25 +179,19 @@ const MembershipSetup: React.FC<MembershipSetupProps> = ({ onMembershipCreated,
)} )}
{!paymentMethod && ( {!paymentMethod && (
<div style={{ marginBottom: '20px' }}> <div className="membership-payment-stage">
<h4 style={{ marginBottom: '16px' }}>Choose Payment Method</h4> <h4 className="membership-payment-heading">Choose Payment Method</h4>
<div style={{ display: 'grid', gap: '12px' }}> <div className="membership-payment-options">
<button <button
className="btn btn-primary" className="btn btn-primary"
onClick={() => handlePaymentMethodSelect('square')} onClick={() => handlePaymentMethodSelect('square')}
disabled={loading} disabled={loading}
style={{ style={{ textAlign: 'left' }}
padding: '16px',
textAlign: 'left',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
}}
> >
<div> <div className="membership-payment-option-copy">
<strong>Credit/Debit Card</strong> <strong>Credit/Debit Card</strong>
<div style={{ fontSize: '14px', marginTop: '4px', opacity: 0.8 }}> <div>
Pay securely with Square Pay securely with Square
</div> </div>
</div> </div>
@@ -210,17 +203,11 @@ const MembershipSetup: React.FC<MembershipSetupProps> = ({ onMembershipCreated,
className="btn btn-secondary" className="btn btn-secondary"
onClick={() => handlePaymentMethodSelect('cash')} onClick={() => handlePaymentMethodSelect('cash')}
disabled={loading} disabled={loading}
style={{ style={{ textAlign: 'left' }}
padding: '16px',
textAlign: 'left',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
}}
> >
<div> <div className="membership-payment-option-copy">
<strong>Cash Payment</strong> <strong>Cash Payment</strong>
<div style={{ fontSize: '14px', marginTop: '4px', opacity: 0.8 }}> <div>
Pay in person or by check Pay in person or by check
</div> </div>
</div> </div>
@@ -229,7 +216,7 @@ const MembershipSetup: React.FC<MembershipSetupProps> = ({ onMembershipCreated,
)} )}
</div> </div>
<div style={{ marginTop: '20px', textAlign: 'center' }}> <div className="membership-setup-actions">
<button <button
type="button" type="button"
className="btn btn-secondary" className="btn btn-secondary"
@@ -250,7 +237,7 @@ const MembershipSetup: React.FC<MembershipSetupProps> = ({ onMembershipCreated,
onPaymentSuccess={handleSquarePaymentSuccess} onPaymentSuccess={handleSquarePaymentSuccess}
onPaymentError={handleSquarePaymentError} onPaymentError={handleSquarePaymentError}
/> />
<div style={{ marginTop: '20px', textAlign: 'center' }}> <div className="membership-setup-actions">
<button <button
type="button" type="button"
className="btn btn-secondary" className="btn btn-secondary"
@@ -268,26 +255,19 @@ const MembershipSetup: React.FC<MembershipSetupProps> = ({ onMembershipCreated,
{paymentMethod === 'cash' && createdMembershipId && ( {paymentMethod === 'cash' && createdMembershipId && (
<div> <div>
<div style={{ <div className="membership-cash-notice">
backgroundColor: '#fff3cd',
border: '1px solid #ffeaa7',
borderRadius: '4px',
padding: '16px',
marginBottom: '20px'
}}>
<strong>Cash Payment Selected</strong> <strong>Cash Payment Selected</strong>
<p style={{ marginTop: '8px', marginBottom: 0 }}> <p>
Your membership will be marked as pending until an administrator confirms payment receipt. Your membership will be marked as pending until an administrator confirms payment receipt.
</p> </p>
</div> </div>
<div style={{ textAlign: 'center' }}> <div className="membership-action-row">
<button <button
type="button" type="button"
className="btn btn-primary" className="btn btn-primary"
onClick={handleCashPayment} onClick={handleCashPayment}
disabled={loading} disabled={loading}
style={{ marginRight: '10px' }}
> >
{loading ? 'Processing...' : 'Confirm Cash Payment'} {loading ? 'Processing...' : 'Confirm Cash Payment'}
</button> </button>
@@ -314,13 +294,18 @@ const MembershipSetup: React.FC<MembershipSetupProps> = ({ onMembershipCreated,
const isCashPayment = paymentMethod === 'cash'; const isCashPayment = paymentMethod === 'cash';
return ( return (
<div className="card"> <div className="card member-card member-membership-setup">
<h3 style={{ marginBottom: '16px' }}> <div className="member-card-header">
<div>
<p className="member-card-kicker">Membership Setup</p>
<h3>
{isCashPayment ? 'Membership Application Submitted!' : 'Payment Successful!'} {isCashPayment ? 'Membership Application Submitted!' : 'Payment Successful!'}
</h3> </h3>
</div>
</div>
{selectedTier && ( {selectedTier && (
<div style={{ marginBottom: '20px' }}> <div className="membership-summary-panel">
<h4>Your Membership Details:</h4> <h4>Your Membership Details:</h4>
<p><strong>Tier:</strong> {selectedTier.name}</p> <p><strong>Tier:</strong> {selectedTier.name}</p>
<p><strong>Annual Fee:</strong> £{selectedTier.annual_fee.toFixed(2)}</p> <p><strong>Annual Fee:</strong> £{selectedTier.annual_fee.toFixed(2)}</p>
@@ -329,7 +314,7 @@ const MembershipSetup: React.FC<MembershipSetupProps> = ({ onMembershipCreated,
{isCashPayment ? 'Pending' : 'Active'} {isCashPayment ? 'Pending' : 'Active'}
</span> </span>
</p> </p>
<p style={{ fontSize: '14px', color: '#666', marginTop: '12px' }}> <p className="membership-confirm-copy">
{isCashPayment {isCashPayment
? 'Your membership application has been submitted. An administrator will review and activate your membership once payment is confirmed.' ? 'Your membership application has been submitted. An administrator will review and activate your membership once payment is confirmed.'
: 'Thank you for your payment! Your membership has been activated and is now live. You can start enjoying your membership benefits immediately.' : 'Thank you for your payment! Your membership has been activated and is now live. You can start enjoying your membership benefits immediately.'
@@ -338,7 +323,7 @@ const MembershipSetup: React.FC<MembershipSetupProps> = ({ onMembershipCreated,
</div> </div>
)} )}
<div style={{ textAlign: 'center' }}> <div className="membership-setup-actions">
<button <button
type="button" type="button"
className="btn btn-primary" className="btn btn-primary"
@@ -354,4 +339,4 @@ const MembershipSetup: React.FC<MembershipSetupProps> = ({ onMembershipCreated,
return null; return null;
}; };
export default MembershipSetup; export default MembershipSetup;
+24 -95
View File
@@ -1,6 +1,8 @@
import React, { useState, useRef, useEffect } from 'react'; import React, { useState, useRef, useEffect } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { authService, User } from '../services/membershipService'; import { authService, User } from '../services/membershipService';
import { useToast } from '../contexts/ToastContext';
import { formatLondonDate } from '../utils/timezone';
interface ProfileMenuProps { interface ProfileMenuProps {
userName: string; userName: string;
@@ -38,115 +40,55 @@ const ProfileMenu: React.FC<ProfileMenuProps> = ({ userName, user, onEditProfile
setIsOpen(false); setIsOpen(false);
}; };
const formatDate = (dateString: string) => { const formatDate = (dateString: string) => formatLondonDate(dateString);
return new Date(dateString).toLocaleDateString('en-GB', {
day: 'numeric',
month: 'long',
year: 'numeric'
});
};
const dropdownStyle: React.CSSProperties = {
position: 'absolute',
top: '100%',
right: 0,
background: 'white',
border: '1px solid #ddd',
borderRadius: '4px',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
minWidth: '280px',
maxWidth: '320px',
zIndex: 1000,
};
const menuItemStyle: React.CSSProperties = {
display: 'block',
width: '100%',
padding: '12px 16px',
background: 'none',
border: 'none',
textAlign: 'left',
cursor: 'pointer',
color: '#333',
fontSize: '14px',
};
return ( return (
<> <>
<div style={{ position: 'relative' }} ref={menuRef}> <div className="profile-menu" ref={menuRef}>
<button <button
className="profile-menu-trigger"
onClick={() => setIsOpen(!isOpen)} onClick={() => setIsOpen(!isOpen)}
style={{
background: 'none',
border: 'none',
color: 'white',
cursor: 'pointer',
fontSize: '16px',
display: 'flex',
alignItems: 'center',
gap: '8px'
}}
> >
<span>{userName}</span> <span>{userName}</span>
<span style={{ fontSize: '12px' }}></span> <span className="profile-menu-chevron"></span>
</button> </button>
{isOpen && ( {isOpen && (
<div style={dropdownStyle}> <div className="profile-menu-dropdown">
{/* Profile Details Section */}
{user && ( {user && (
<div style={{ <div className="profile-menu-summary">
padding: '16px', <div className="profile-menu-summary-head">
borderBottom: '1px solid #eee', <h4>Profile Details</h4>
backgroundColor: '#f9f9f9',
borderRadius: '4px 4px 0 0'
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}>
<h4 style={{ margin: 0, fontSize: '14px', fontWeight: 'bold', color: '#333' }}>Profile Details</h4>
{onEditProfile && ( {onEditProfile && (
<button <button
className="profile-menu-edit"
onClick={() => { onClick={() => {
onEditProfile(); onEditProfile();
setIsOpen(false); setIsOpen(false);
}} }}
style={{
background: '#0066cc',
color: 'white',
border: 'none',
padding: '4px 8px',
borderRadius: '3px',
cursor: 'pointer',
fontSize: '11px',
fontWeight: '500'
}}
> >
Edit Edit
</button> </button>
)} )}
</div> </div>
<div style={{ fontSize: '12px', color: '#555', lineHeight: '1.6' }}> <div className="profile-menu-details">
<p style={{ margin: '4px 0' }}><strong>Name:</strong> {user.first_name} {user.last_name}</p> <p><strong>Name:</strong> {user.first_name} {user.last_name}</p>
<p style={{ margin: '4px 0' }}><strong>Email:</strong> {user.email}</p> <p><strong>Email:</strong> {user.email}</p>
{user.phone && <p style={{ margin: '4px 0' }}><strong>Phone:</strong> {user.phone}</p>} {user.phone && <p><strong>Phone:</strong> {user.phone}</p>}
{user.address && <p style={{ margin: '4px 0' }}><strong>Address:</strong> {user.address}</p>} {user.address && <p><strong>Address:</strong> {user.address}</p>}
<p style={{ margin: '4px 0' }}><strong>Member since:</strong> {formatDate(user.created_at)}</p> <p><strong>Member since:</strong> {formatDate(user.created_at)}</p>
</div> </div>
</div> </div>
)} )}
{/* Menu Items */}
<button <button
style={{ className={`profile-menu-item ${user ? '' : 'first'}`}
...menuItemStyle,
borderRadius: user ? '0' : '4px 4px 0 0',
borderTop: user ? '1px solid #eee' : 'none'
}}
onClick={handleChangePassword} onClick={handleChangePassword}
> >
Change Password Change Password
</button> </button>
<button <button
style={{ ...menuItemStyle, borderRadius: '0 0 4px 4px', borderTop: '1px solid #eee' }} className="profile-menu-item last"
onClick={handleLogout} onClick={handleLogout}
> >
Log Out Log Out
@@ -167,6 +109,7 @@ interface ChangePasswordModalProps {
} }
const ChangePasswordModal: React.FC<ChangePasswordModalProps> = ({ onClose }) => { const ChangePasswordModal: React.FC<ChangePasswordModalProps> = ({ onClose }) => {
const toast = useToast();
const [currentPassword, setCurrentPassword] = useState(''); const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState(''); const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState('');
@@ -195,7 +138,7 @@ const ChangePasswordModal: React.FC<ChangePasswordModalProps> = ({ onClose }) =>
new_password: newPassword new_password: newPassword
}); });
alert('Password changed successfully!'); toast.success('Password changed successfully.');
onClose(); onClose();
} catch (error: any) { } catch (error: any) {
setError(error.response?.data?.detail || 'Failed to change password'); setError(error.response?.data?.detail || 'Failed to change password');
@@ -254,33 +197,19 @@ const ChangePasswordModal: React.FC<ChangePasswordModalProps> = ({ onClose }) =>
</div> </div>
)} )}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '10px', marginTop: '16px' }}> <div className="modal-button-row">
<button <button
className="btn btn-secondary"
type="button" type="button"
onClick={onClose} onClick={onClose}
disabled={loading} disabled={loading}
style={{
padding: '10px 20px',
backgroundColor: '#6c757d',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer'
}}
> >
Cancel Cancel
</button> </button>
<button <button
className="btn btn-primary"
type="submit" type="submit"
disabled={loading} disabled={loading}
style={{
padding: '10px 20px',
backgroundColor: '#28a745',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer'
}}
> >
{loading ? 'Changing...' : 'Change Password'} {loading ? 'Changing...' : 'Change Password'}
</button> </button>
@@ -14,22 +14,16 @@ interface ProfileQuestionsFormProps {
onSave: (answers: ProfileAnswerInput[]) => Promise<void>; onSave: (answers: ProfileAnswerInput[]) => Promise<void>;
saveLabel?: string; saveLabel?: string;
allowAdminManagedEdit?: boolean; allowAdminManagedEdit?: boolean;
surface?: 'member' | 'admin';
} }
const formatAnswerForDisplay = (question: ProfileQuestionForUser, value: ProfileQuestionAnswerValue): string => { const formatAnswerForDisplay = (question: ProfileQuestionForUser, value: ProfileQuestionAnswerValue): string => {
if (value === null || value === undefined || value === '') { if (value === null || value === undefined || value === '') return 'Not set';
return 'Not set'; if (question.input_type === 'boolean') return value === true || value === 'true' ? 'Yes' : 'No';
}
if (question.input_type === 'boolean') {
return value === true || value === 'true' ? 'Yes' : 'No';
}
if (question.input_type === 'select') { if (question.input_type === 'select') {
const matchingOption = question.options.find((option) => option.value === String(value)); const matchingOption = question.options.find((option) => option.value === String(value));
return matchingOption?.label || String(value); return matchingOption?.label || String(value);
} }
return String(value); return String(value);
}; };
@@ -39,7 +33,8 @@ const ProfileQuestionsForm: React.FC<ProfileQuestionsFormProps> = ({
questions, questions,
onSave, onSave,
saveLabel = 'Save Answers', saveLabel = 'Save Answers',
allowAdminManagedEdit = false allowAdminManagedEdit = false,
surface = 'admin'
}) => { }) => {
const initialAnswers = useMemo(() => { const initialAnswers = useMemo(() => {
const values: Record<number, ProfileQuestionAnswerValue> = {}; const values: Record<number, ProfileQuestionAnswerValue> = {};
@@ -55,7 +50,6 @@ const ProfileQuestionsForm: React.FC<ProfileQuestionsFormProps> = ({
const [successMessage, setSuccessMessage] = useState<string | null>(null); const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const pageSize = 10; const pageSize = 10;
useEffect(() => { useEffect(() => {
@@ -67,39 +61,31 @@ const ProfileQuestionsForm: React.FC<ProfileQuestionsFormProps> = ({
const visibleQuestions = useMemo(() => { const visibleQuestions = useMemo(() => {
const byId = new Map<number, ProfileQuestionForUser>(); const byId = new Map<number, ProfileQuestionForUser>();
questions.forEach((question) => byId.set(question.id, question)); questions.forEach((question) => byId.set(question.id, question));
return questions.filter((question) => isProfileQuestionVisible(question, byId, answers)); return questions.filter((question) => isProfileQuestionVisible(question, byId, answers));
}, [questions, answers]); }, [questions, answers]);
const filteredQuestions = useMemo(() => { const filteredQuestions = useMemo(() => {
const searchTerm = search.trim().toLowerCase(); const searchTerm = search.trim().toLowerCase();
return visibleQuestions return visibleQuestions.filter((question) => {
.filter((question) => { if (!searchTerm) return true;
if (!searchTerm) { return (
return true; question.label.toLowerCase().includes(searchTerm) ||
} question.key.toLowerCase().includes(searchTerm) ||
return ( (question.help_text || '').toLowerCase().includes(searchTerm)
question.label.toLowerCase().includes(searchTerm) || );
question.key.toLowerCase().includes(searchTerm) || });
(question.help_text || '').toLowerCase().includes(searchTerm)
);
});
}, [visibleQuestions, search]); }, [visibleQuestions, search]);
const paginatedQuestions = useMemo(() => {
const totalPages = Math.max(1, Math.ceil(filteredQuestions.length / pageSize));
const safePage = Math.min(page, totalPages);
const start = (safePage - 1) * pageSize;
return filteredQuestions.slice(start, start + pageSize);
}, [filteredQuestions, page]);
const totalPages = Math.max(1, Math.ceil(filteredQuestions.length / pageSize)); const totalPages = Math.max(1, Math.ceil(filteredQuestions.length / pageSize));
const paginatedQuestions = useMemo(() => {
const safePage = Math.min(page, totalPages);
const start = (safePage - 1) * pageSize;
return filteredQuestions.slice(start, start + pageSize);
}, [filteredQuestions, page, totalPages]);
const setAnswerValue = (questionId: number, value: ProfileQuestionAnswerValue) => { const setAnswerValue = (questionId: number, value: ProfileQuestionAnswerValue) => {
setAnswers((prev) => ({ setAnswers((prev) => ({ ...prev, [questionId]: value }));
...prev,
[questionId]: value
}));
setSuccessMessage(null); setSuccessMessage(null);
setError(null); setError(null);
}; };
@@ -117,11 +103,7 @@ const ProfileQuestionsForm: React.FC<ProfileQuestionsFormProps> = ({
const visibleQuestionIds = new Set(visibleQuestions.map((question) => question.id)); const visibleQuestionIds = new Set(visibleQuestions.map((question) => question.id));
const changedAnswers: ProfileAnswerInput[] = questions const changedAnswers: ProfileAnswerInput[] = questions
.filter((question) => canEditProfileQuestion(question, allowAdminManagedEdit) && visibleQuestionIds.has(question.id)) .filter((question) => canEditProfileQuestion(question, allowAdminManagedEdit) && visibleQuestionIds.has(question.id))
.filter((question) => { .filter((question) => answerToComparable(answers[question.id] ?? null) !== answerToComparable(initialAnswers[question.id] ?? null))
const current = answerToComparable(answers[question.id] ?? null);
const initial = answerToComparable(initialAnswers[question.id] ?? null);
return current !== initial;
})
.map((question) => ({ .map((question) => ({
question_id: question.id, question_id: question.id,
value: answers[question.id] ?? null value: answers[question.id] ?? null
@@ -141,27 +123,16 @@ const ProfileQuestionsForm: React.FC<ProfileQuestionsFormProps> = ({
const disabled = !canEditProfileQuestion(question, allowAdminManagedEdit) || saving; const disabled = !canEditProfileQuestion(question, allowAdminManagedEdit) || saving;
if (disabled && !saving) { if (disabled && !saving) {
return ( return <div className="profile-question-readonly">{formatAnswerForDisplay(question, value)}</div>;
<div className="profile-question-readonly">
{formatAnswerForDisplay(question, value)}
</div>
);
} }
if (question.input_type === 'boolean') { if (question.input_type === 'boolean') {
return ( return (
<select <select
value={value === null ? '' : String(value)} value={value === null ? '' : String(value)}
onChange={(event) => { onChange={(event) => setAnswerValue(question.id, event.target.value === '' ? null : event.target.value === 'true')}
const nextValue = event.target.value;
if (nextValue === '') {
setAnswerValue(question.id, null);
} else {
setAnswerValue(question.id, nextValue === 'true');
}
}}
disabled={disabled} disabled={disabled}
style={{ width: '100%', padding: '8px', borderRadius: '4px', border: '1px solid #ddd' }} className="profile-question-input"
> >
<option value="">Prefer not to say</option> <option value="">Prefer not to say</option>
<option value="true">Yes</option> <option value="true">Yes</option>
@@ -176,13 +147,11 @@ const ProfileQuestionsForm: React.FC<ProfileQuestionsFormProps> = ({
value={value === null ? '' : String(value)} value={value === null ? '' : String(value)}
onChange={(event) => setAnswerValue(question.id, event.target.value || null)} onChange={(event) => setAnswerValue(question.id, event.target.value || null)}
disabled={disabled} disabled={disabled}
style={{ width: '100%', padding: '8px', borderRadius: '4px', border: '1px solid #ddd' }} className="profile-question-input"
> >
<option value="">Select an option</option> <option value="">Select an option</option>
{question.options.map((option) => ( {question.options.map((option) => (
<option key={option.value} value={option.value}> <option key={option.value} value={option.value}>{option.label}</option>
{option.label}
</option>
))} ))}
</select> </select>
); );
@@ -195,7 +164,7 @@ const ProfileQuestionsForm: React.FC<ProfileQuestionsFormProps> = ({
value={value === null ? '' : String(value)} value={value === null ? '' : String(value)}
onChange={(event) => setAnswerValue(question.id, event.target.value || null)} onChange={(event) => setAnswerValue(question.id, event.target.value || null)}
disabled={disabled} disabled={disabled}
style={{ width: '100%', padding: '8px', borderRadius: '4px', border: '1px solid #ddd' }} className="profile-question-input"
/> />
); );
} }
@@ -208,7 +177,7 @@ const ProfileQuestionsForm: React.FC<ProfileQuestionsFormProps> = ({
onChange={(event) => setAnswerValue(question.id, event.target.value === '' ? null : Number(event.target.value))} onChange={(event) => setAnswerValue(question.id, event.target.value === '' ? null : Number(event.target.value))}
disabled={disabled} disabled={disabled}
placeholder={question.placeholder || ''} placeholder={question.placeholder || ''}
style={{ width: '100%', padding: '8px', borderRadius: '4px', border: '1px solid #ddd' }} className="profile-question-input"
/> />
); );
} }
@@ -220,60 +189,51 @@ const ProfileQuestionsForm: React.FC<ProfileQuestionsFormProps> = ({
onChange={(event) => setAnswerValue(question.id, event.target.value || null)} onChange={(event) => setAnswerValue(question.id, event.target.value || null)}
disabled={disabled} disabled={disabled}
placeholder={question.placeholder || ''} placeholder={question.placeholder || ''}
style={{ width: '100%', padding: '8px', borderRadius: '4px', border: '1px solid #ddd' }} className="profile-question-input"
/> />
); );
}; };
return ( return (
<div className="card" style={{ marginTop: '20px' }}> <div className={`card profile-questions-form ${surface === 'member' ? 'member-surface' : 'admin-surface'}`}>
<h3 style={{ marginBottom: '8px' }}>{title}</h3> <h3 className="profile-questions-title">{title}</h3>
{description && <p style={{ color: '#555', marginBottom: '16px' }}>{description}</p>} {description && <p className="profile-questions-description">{description}</p>}
<div style={{ display: 'grid', gap: '10px', marginBottom: '14px' }}> <div className="profile-questions-search">
<input <input
type="text" type="text"
placeholder="Search questions..." placeholder="Search questions..."
value={search} value={search}
onChange={(event) => setSearch(event.target.value)} onChange={(event) => setSearch(event.target.value)}
style={{ width: '100%', padding: '9px 10px', borderRadius: '6px', border: '1px solid #d5d9e0' }} className="profile-question-input"
/> />
</div> </div>
{error && ( {error && <div className="alert alert-error">{error}</div>}
<div className="alert alert-error"> {successMessage && <div className="alert alert-success">{successMessage}</div>}
{error}
</div>
)}
{successMessage && (
<div className="alert alert-success">
{successMessage}
</div>
)}
{filteredQuestions.length === 0 ? ( {filteredQuestions.length === 0 ? (
<p style={{ color: '#666' }}>No questions available.</p> <p className="profile-questions-empty">No questions available.</p>
) : ( ) : (
<div style={{ display: 'grid', gap: '16px' }}> <div className="profile-questions-list">
{paginatedQuestions.map((question) => ( {paginatedQuestions.map((question) => (
<div key={question.id} className="profile-question-row"> <div
key={question.id}
className={`profile-question-row ${surface === 'member' ? 'profile-question-row-member' : 'profile-question-row-admin'}`}
>
<div className="profile-question-meta"> <div className="profile-question-meta">
<label style={{ display: 'block', fontWeight: 600, marginBottom: '4px' }}> <label className="profile-question-label">
{question.label} {question.label}
{question.is_required && <span style={{ color: '#dc3545' }}> *</span>} {question.is_required && <span className="profile-question-required"> *</span>}
{question.admin_only_edit && ( {question.admin_only_edit && <span className="admin-inline-badge">Admin Managed</span>}
<span style={{ backgroundColor: '#eef2ff', color: '#3730a3', marginLeft: '8px', padding: '2px 7px', borderRadius: '999px', fontWeight: 600, fontSize: '12px' }}>
Admin Managed
</span>
)}
</label> </label>
{question.help_text && ( {question.help_text && (
<p style={{ marginBottom: '0', color: '#666', fontSize: '13px' }}>{question.help_text}</p> <p className="profile-question-help">{question.help_text}</p>
)} )}
</div> </div>
<div className="profile-question-answer">{renderField(question)}</div> <div className="profile-question-answer">{renderField(question)}</div>
{!canEditProfileQuestion(question, allowAdminManagedEdit) && ( {!canEditProfileQuestion(question, allowAdminManagedEdit) && (
<p style={{ marginTop: '6px', color: '#5b6472', fontSize: '12px', fontWeight: 600, gridColumn: '1 / -1' }}> <p className="profile-question-lock-note">
This field can only be changed by an admin. This field can only be changed by an admin.
</p> </p>
)} )}
@@ -283,22 +243,22 @@ const ProfileQuestionsForm: React.FC<ProfileQuestionsFormProps> = ({
)} )}
{filteredQuestions.length > pageSize && ( {filteredQuestions.length > pageSize && (
<div style={{ marginTop: '14px', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '10px' }}> <div className="profile-questions-pagination">
<span style={{ fontSize: '13px', color: '#525a66' }}> <span className="profile-questions-page-copy">
Page {Math.min(page, totalPages)} of {totalPages} ({filteredQuestions.length} questions) Page {Math.min(page, totalPages)} of {totalPages} ({filteredQuestions.length} questions)
</span> </span>
<div style={{ display: 'flex', gap: '8px' }}> <div className="profile-questions-pager-buttons">
<button className="btn btn-secondary" style={{ padding: '6px 12px', fontSize: '13px' }} disabled={page <= 1} onClick={() => setPage((prev) => Math.max(1, prev - 1))}> <button className="btn btn-secondary profile-questions-pager-button" disabled={page <= 1} onClick={() => setPage((prev) => Math.max(1, prev - 1))}>
Previous Previous
</button> </button>
<button className="btn btn-secondary" style={{ padding: '6px 12px', fontSize: '13px' }} disabled={page >= totalPages} onClick={() => setPage((prev) => Math.min(totalPages, prev + 1))}> <button className="btn btn-secondary profile-questions-pager-button" disabled={page >= totalPages} onClick={() => setPage((prev) => Math.min(totalPages, prev + 1))}>
Next Next
</button> </button>
</div> </div>
</div> </div>
)} )}
<div style={{ marginTop: '16px', display: 'flex', justifyContent: 'flex-end' }}> <div className="profile-questions-actions">
<button className="btn btn-primary" onClick={handleSave} disabled={saving}> <button className="btn btn-primary" onClick={handleSave} disabled={saving}>
{saving ? 'Saving...' : saveLabel} {saving ? 'Saving...' : saveLabel}
</button> </button>
+69 -87
View File
@@ -1,12 +1,17 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { membershipService, MembershipTier, MembershipTierCreateData, MembershipTierUpdateData } from '../services/membershipService'; import { membershipService, MembershipTier, MembershipTierCreateData, MembershipTierUpdateData } from '../services/membershipService';
import EmailTemplateManagement from './EmailTemplateManagement'; import EmailTemplateManagement from './EmailTemplateManagement';
import { useToast } from '../contexts/ToastContext';
import { useConfirm } from '../contexts/ConfirmContext';
import { formatLondonDate } from '../utils/timezone';
interface SuperAdminMenuProps { interface SuperAdminMenuProps {
onClose: () => void; onClose: () => void;
} }
const SuperAdminMenu: React.FC<SuperAdminMenuProps> = ({ onClose }) => { const SuperAdminMenu: React.FC<SuperAdminMenuProps> = ({ onClose }) => {
const toast = useToast();
const { confirm } = useConfirm();
const [activeTab, setActiveTab] = useState<'tiers' | 'users' | 'email' | 'system'>('tiers'); const [activeTab, setActiveTab] = useState<'tiers' | 'users' | 'email' | 'system'>('tiers');
const [tiers, setTiers] = useState<MembershipTier[]>([]); const [tiers, setTiers] = useState<MembershipTier[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -26,7 +31,7 @@ const SuperAdminMenu: React.FC<SuperAdminMenuProps> = ({ onClose }) => {
setTiers(tierData); setTiers(tierData);
} catch (error) { } catch (error) {
console.error('Failed to load tiers:', error); console.error('Failed to load tiers:', error);
alert('Failed to load membership tiers'); toast.error('Failed to load membership tiers.');
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -38,7 +43,7 @@ const SuperAdminMenu: React.FC<SuperAdminMenuProps> = ({ onClose }) => {
setShowCreateForm(false); setShowCreateForm(false);
loadTiers(); loadTiers();
} catch (error: any) { } catch (error: any) {
alert(error.response?.data?.detail || 'Failed to create tier'); toast.error(error.response?.data?.detail || 'Failed to create tier.');
} }
}; };
@@ -48,12 +53,18 @@ const SuperAdminMenu: React.FC<SuperAdminMenuProps> = ({ onClose }) => {
setEditingTier(null); setEditingTier(null);
loadTiers(); loadTiers();
} catch (error: any) { } catch (error: any) {
alert(error.response?.data?.detail || 'Failed to update tier'); toast.error(error.response?.data?.detail || 'Failed to update tier.');
} }
}; };
const handleDeleteTier = async (tierId: number) => { const handleDeleteTier = async (tierId: number) => {
if (!confirm('Are you sure you want to delete this membership tier? This action cannot be undone.')) { const confirmed = await confirm({
title: 'Delete membership tier',
message: 'Are you sure you want to delete this membership tier? This action cannot be undone.',
confirmLabel: 'Delete',
tone: 'danger'
});
if (!confirmed) {
return; return;
} }
@@ -61,7 +72,7 @@ const SuperAdminMenu: React.FC<SuperAdminMenuProps> = ({ onClose }) => {
await membershipService.deleteTier(tierId); await membershipService.deleteTier(tierId);
loadTiers(); loadTiers();
} catch (error: any) { } catch (error: any) {
alert(error.response?.data?.detail || 'Failed to delete tier'); toast.error(error.response?.data?.detail || 'Failed to delete tier.');
} }
}; };
@@ -166,98 +177,74 @@ interface TierManagementProps {
export const TierManagement: React.FC<TierManagementProps> = ({ export const TierManagement: React.FC<TierManagementProps> = ({
tiers, tiers,
loading, loading,
showCreateForm,
editingTier,
onCreateTier,
onUpdateTier,
onDeleteTier, onDeleteTier,
onShowCreateForm,
onHideCreateForm,
onEditTier, onEditTier,
onCancelEdit
}) => { }) => {
if (loading) { if (loading) {
return <div style={{ padding: '20px', textAlign: 'center' }} className="super-admin-loading">Loading tiers...</div>; return <div className="admin-empty">Loading tiers...</div>;
} }
return ( return (
<div> <div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}> <div className="admin-page-header">
<h4 style={{ margin: 0, color: '#333' }}>Membership Tiers Management</h4> <div>
<button <h3>Membership Tiers</h3>
onClick={onShowCreateForm} <p>Manage pricing, availability, and the copy members see when choosing a plan.</p>
className="btn btn-primary" </div>
style={{ fontSize: '14px', padding: '8px 16px' }}
>
Create New Tier
</button>
</div> </div>
{showCreateForm && ( <div className="admin-table-shell">
<TierForm <div className="admin-table-wrap">
onSubmit={onCreateTier} <table className="admin-table">
onCancel={onHideCreateForm}
title="Create New Membership Tier"
/>
)}
{editingTier && (
<TierForm
initialData={editingTier}
onSubmit={(data) => onUpdateTier(editingTier.id, data)}
onCancel={onCancelEdit}
title="Edit Membership Tier"
/>
)}
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', marginTop: '20px' }} className="super-admin-table">
<thead> <thead>
<tr style={{ borderBottom: '2px solid #ddd' }}> <tr>
<th style={{ padding: '12px', textAlign: 'left' }}>Name</th> <th>Name</th>
<th style={{ padding: '12px', textAlign: 'left' }}>Description</th> <th>Description</th>
<th style={{ padding: '12px', textAlign: 'left' }}>Annual Fee</th> <th>Annual Fee</th>
<th style={{ padding: '12px', textAlign: 'left' }}>Benefits</th> <th>Benefits</th>
<th style={{ padding: '12px', textAlign: 'left' }}>Status</th> <th>Status</th>
<th style={{ padding: '12px', textAlign: 'left' }}>Actions</th> <th></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{tiers.map(tier => ( {tiers.map(tier => (
<tr key={tier.id} style={{ borderBottom: '1px solid #eee' }}> <tr key={tier.id}>
<td style={{ padding: '12px', fontWeight: 'bold' }}>{tier.name}</td> <td>
<td style={{ padding: '12px', maxWidth: '200px' }}> <strong>{tier.name}</strong>
{tier.description || 'No description'} <span className="muted-line">Created {formatLondonDate(tier.created_at)}</span>
</td> </td>
<td style={{ padding: '12px' }}>£{tier.annual_fee.toFixed(2)}</td> <td>{tier.description || 'No description'}</td>
<td style={{ padding: '12px', maxWidth: '250px' }}> <td>£{tier.annual_fee.toFixed(2)}</td>
{tier.benefits || 'No benefits listed'} <td className="admin-tier-benefits-cell">{tier.benefits || 'No benefits listed'}</td>
</td> <td>
<td style={{ padding: '12px' }}>
<span className={`status-badge ${tier.is_active ? 'status-active' : 'status-expired'}`}> <span className={`status-badge ${tier.is_active ? 'status-active' : 'status-expired'}`}>
{tier.is_active ? 'ACTIVE' : 'INACTIVE'} {tier.is_active ? 'ACTIVE' : 'INACTIVE'}
</span> </span>
</td> </td>
<td style={{ padding: '12px' }}> <td>
<div className="table-button-row">
<button <button
type="button"
onClick={() => onEditTier(tier)} onClick={() => onEditTier(tier)}
className="action-btn" className="btn btn-secondary"
style={{ marginRight: '8px', color: 'white', backgroundColor: '#007bff', border: '1px solid #007bff' }}
> >
Edit Edit
</button> </button>
<button <button
type="button"
onClick={() => onDeleteTier(tier.id)} onClick={() => onDeleteTier(tier.id)}
className="action-btn action-btn-danger" className="btn btn-danger"
style={{ color: 'white', backgroundColor: '#dc3545', border: '1px solid #dc3545' }}
> >
Delete Delete
</button> </button>
</div>
</td> </td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
{tiers.length === 0 && <p className="admin-empty admin-table-empty">No membership tiers found.</p>}
</div>
</div> </div>
</div> </div>
); );
@@ -268,9 +255,10 @@ interface TierFormProps {
onSubmit: (data: MembershipTierCreateData | MembershipTierUpdateData) => void; onSubmit: (data: MembershipTierCreateData | MembershipTierUpdateData) => void;
onCancel: () => void; onCancel: () => void;
title: string; title: string;
variant?: 'inline' | 'rail' | 'drawer';
} }
const TierForm: React.FC<TierFormProps> = ({ initialData, onSubmit, onCancel, title }) => { export const TierForm: React.FC<TierFormProps> = ({ initialData, onSubmit, onCancel, title, variant = 'inline' }) => {
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
name: initialData?.name || '', name: initialData?.name || '',
description: initialData?.description || '', description: initialData?.description || '',
@@ -288,18 +276,21 @@ const TierForm: React.FC<TierFormProps> = ({ initialData, onSubmit, onCancel, ti
setFormData(prev => ({ ...prev, [field]: value })); setFormData(prev => ({ ...prev, [field]: value }));
}; };
const panelClassName =
variant === 'rail'
? 'admin-rail-form-panel'
: variant === 'drawer'
? 'admin-drawer-form-panel'
: 'admin-inline-form-panel';
const gridClassName = variant === 'inline' ? 'admin-inline-form-grid' : 'admin-rail-form-grid';
return ( return (
<div style={{ <div className={panelClassName}>
background: '#f8f9fa', <h4>{title}</h4>
padding: '20px',
borderRadius: '8px',
marginBottom: '20px',
border: '1px solid #dee2e6'
}}>
<h4 style={{ margin: '0 0 16px 0', color: '#333' }}>{title}</h4>
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px', marginBottom: '16px' }}> <div className={gridClassName}>
<div className="modal-form-group"> <div className="modal-form-group">
<label>Name *</label> <label>Name *</label>
<input <input
@@ -323,7 +314,7 @@ const TierForm: React.FC<TierFormProps> = ({ initialData, onSubmit, onCancel, ti
</div> </div>
</div> </div>
<div className="modal-form-group" style={{ marginBottom: '16px' }}> <div className="modal-form-group">
<label>Description</label> <label>Description</label>
<input <input
type="text" type="text"
@@ -333,28 +324,19 @@ const TierForm: React.FC<TierFormProps> = ({ initialData, onSubmit, onCancel, ti
/> />
</div> </div>
<div className="modal-form-group" style={{ marginBottom: '16px' }}> <div className="modal-form-group">
<label>Benefits</label> <label>Benefits</label>
<textarea <textarea
value={formData.benefits} value={formData.benefits}
onChange={(e) => handleChange('benefits', e.target.value)} onChange={(e) => handleChange('benefits', e.target.value)}
placeholder="List the benefits of this membership tier" placeholder="List the benefits of this membership tier"
rows={3} rows={3}
style={{ className="admin-inline-textarea"
width: '100%',
padding: '8px',
border: '1px solid #ddd',
borderRadius: '4px',
fontSize: '14px',
color: '#333',
backgroundColor: '#fff',
resize: 'vertical'
}}
/> />
</div> </div>
<div style={{ marginBottom: '16px' }}> <div className="admin-inline-toggle-row">
<label style={{ display: 'flex', alignItems: 'center', gap: '8px' }}> <label>
<input <input
type="checkbox" type="checkbox"
checked={formData.is_active} checked={formData.is_active}
@@ -364,7 +346,7 @@ const TierForm: React.FC<TierFormProps> = ({ initialData, onSubmit, onCancel, ti
</label> </label>
</div> </div>
<div style={{ display: 'flex', gap: '8px', justifyContent: 'flex-end' }}> <div className="modal-buttons">
<button <button
type="button" type="button"
onClick={onCancel} onClick={onCancel}
File diff suppressed because it is too large Load Diff
+58 -36
View File
@@ -26,48 +26,70 @@ const ForgotPassword: React.FC = () => {
}; };
return ( return (
<div className="auth-container"> <div className="auth-shell">
<div className="auth-card"> <header className="auth-topbar">
<h2>Forgot Password</h2> <div className="portal-brand">
<p style={{ textAlign: 'center', marginBottom: '24px', color: '#666' }}> <div className="portal-mark">S</div>
Enter your email address and we'll send you a link to reset your password. <div className="portal-brand-text">
</p> <h1>SASA Member Portal</h1>
<div className="portal-subtitle">Account recovery</div>
</div>
</div>
</header>
{error && <div className="alert alert-error">{error}</div>} <main className="auth-container">
{message && <div className="alert alert-success">{message}</div>} <section className="auth-welcome-card">
<div className="auth-kicker">Password Help</div>
<h2>Recover access quickly</h2>
<p>
Enter the email address tied to your account and we&apos;ll send a secure password reset link if that account exists.
</p>
</section>
<form onSubmit={handleSubmit}> <section className="auth-card">
<div className="form-group"> <div className="auth-card-head">
<label htmlFor="email">Email Address</label> <h2>Forgot Password</h2>
<input <span>Email reset link</span>
type="email"
id="email"
name="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
placeholder="Enter your email address"
/>
</div> </div>
<button <div className="auth-card-body">
type="submit" {error && <div className="alert alert-error">{error}</div>}
className="btn btn-primary" {message && <div className="alert alert-success">{message}</div>}
disabled={loading}
style={{ width: '100%', marginTop: '16px' }}
>
{loading ? 'Sending...' : 'Send Reset Link'}
</button>
</form>
<div className="form-footer"> <form onSubmit={handleSubmit}>
<Link to="/login" style={{ color: '#0066cc', textDecoration: 'none' }}> <div className="form-group">
Back to login <label htmlFor="email">Email Address</label>
</Link> <input
</div> type="email"
</div> id="email"
name="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="email"
required
placeholder="you@example.com"
/>
</div>
<button
type="submit"
className="btn btn-primary auth-submit"
disabled={loading}
>
{loading ? 'Sending...' : 'Send Reset Link'}
</button>
</form>
</div>
<div className="auth-footer">
<div>
<Link to="/login">Back to login</Link>
</div>
</div>
</section>
</main>
</div> </div>
); );
}; };
export default ForgotPassword; export default ForgotPassword;
+81 -76
View File
@@ -43,84 +43,89 @@ const Login: React.FC = () => {
}; };
return ( return (
<div className="auth-container" style={{ gap: '40px', padding: '20px' }}> <div className="auth-shell">
<div className="welcome-section" style={{ <header className="auth-topbar">
flex: '1', <div className="portal-brand">
maxWidth: '400px', <div className="portal-mark">S</div>
textAlign: 'center', <div className="portal-brand-text">
backgroundColor: 'rgba(255, 255, 255, 0.95)', <h1>SASA Member Portal</h1>
padding: '30px', <div className="portal-subtitle">Member access and admin control room</div>
borderRadius: '12px',
boxShadow: '0 4px 16px rgba(0, 0, 0, 0.1)'
}}>
<h1 style={{ color: '#333', marginBottom: '16px', fontSize: '2.2rem' }}>Welcome to SASA</h1>
<p style={{ fontSize: '1.1rem', color: '#666', lineHeight: '1.6', marginBottom: '20px' }}>
REPLACE WITH BOB WORDS: Swansea Airport Stakeholder's Association (SASA) is a community interest company run by volunteers, which holds the lease of Swansea Airport.
</p>
<p style={{ fontSize: '1rem', color: '#555', lineHeight: '1.5' }}>
Join our community of aviation enthusiasts and support the future of Swansea Airport.
</p>
</div>
<div className="auth-card" style={{ flex: '1', maxWidth: '400px' }}>
<h2>SASA Member Portal</h2>
<p style={{ textAlign: 'center', marginBottom: '24px', color: '#666' }}>
Log in to your membership account
</p>
{error && <div className="alert alert-error">{error}</div>}
<form onSubmit={handleSubmit}>
<div className="form-group">
<label htmlFor="email">Email Address</label>
<input
type="email"
id="email"
name="email"
value={formData.email}
onChange={handleChange}
required
/>
</div> </div>
<div className="form-group">
<label htmlFor="password">Password</label>
<input
type="password"
id="password"
name="password"
value={formData.password}
onChange={handleChange}
required
/>
</div>
<button
type="submit"
className="btn btn-primary"
disabled={loading}
style={{ width: '100%', marginTop: '16px' }}
>
{loading ? 'Logging in...' : 'Log In'}
</button>
</form>
<div className="form-footer">
<div style={{ marginBottom: '16px' }}>
<Link to="/forgot-password" style={{ color: '#0066cc', textDecoration: 'none' }}>
Forgot your password?
</Link>
</div>
<button
type="button"
className="btn btn-secondary"
onClick={() => navigate('/register')}
style={{ width: '100%' }}
>
Join SASA
</button>
</div> </div>
</div> </header>
<main className="auth-container">
<section className="auth-welcome-card">
<div className="auth-kicker">Community Access</div>
<h2>Welcome to SASA</h2>
<p>
Swansea Airport Stakeholder&apos;s Association manages member access, events, and operations from one shared platform.
</p>
<div className="auth-feature-list">
<div className="auth-feature-item">Manage your membership, payments, and events in one place</div>
<div className="auth-feature-item">Keep profile and contact details current without admin help</div>
<div className="auth-feature-item">Admin users can switch into a separate operations workspace after login</div>
</div>
</section>
<section className="auth-card">
<div className="auth-card-head">
<h2>Sign In</h2>
<span>Secure session</span>
</div>
<div className="auth-card-body">
{error && <div className="alert alert-error">{error}</div>}
<form onSubmit={handleSubmit}>
<div className="form-group">
<label htmlFor="email">Email Address</label>
<input
type="email"
id="email"
name="email"
value={formData.email}
onChange={handleChange}
required
/>
</div>
<div className="form-group">
<label htmlFor="password">Password</label>
<input
type="password"
id="password"
name="password"
value={formData.password}
onChange={handleChange}
required
/>
</div>
<button
type="submit"
className="btn btn-primary auth-submit"
disabled={loading}
>
{loading ? 'Signing In...' : 'Sign In'}
</button>
</form>
</div>
<div className="form-footer auth-footer">
<div>
<Link to="/forgot-password">Forgot your password?</Link>
</div>
<button
type="button"
className="btn btn-secondary auth-submit"
onClick={() => navigate('/register')}
>
Join SASA
</button>
</div>
</section>
</main>
</div> </div>
); );
}; };
+16 -6
View File
@@ -1,8 +1,12 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { membershipService, MembershipTier, MembershipTierCreateData, MembershipTierUpdateData } from '../services/membershipService'; import { membershipService, MembershipTier, MembershipTierCreateData, MembershipTierUpdateData } from '../services/membershipService';
import { useToast } from '../contexts/ToastContext';
import { useConfirm } from '../contexts/ConfirmContext';
const MembershipTiers: React.FC = () => { const MembershipTiers: React.FC = () => {
const toast = useToast();
const { confirm } = useConfirm();
const navigate = useNavigate(); const navigate = useNavigate();
const [tiers, setTiers] = useState<MembershipTier[]>([]); const [tiers, setTiers] = useState<MembershipTier[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -20,7 +24,7 @@ const MembershipTiers: React.FC = () => {
setTiers(tierData); setTiers(tierData);
} catch (error) { } catch (error) {
console.error('Failed to load tiers:', error); console.error('Failed to load tiers:', error);
alert('Failed to load membership tiers'); toast.error('Failed to load membership tiers.');
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -32,7 +36,7 @@ const MembershipTiers: React.FC = () => {
setShowCreateForm(false); setShowCreateForm(false);
loadTiers(); loadTiers();
} catch (error: any) { } catch (error: any) {
alert(error.response?.data?.detail || 'Failed to create tier'); toast.error(error.response?.data?.detail || 'Failed to create tier.');
} }
}; };
@@ -42,12 +46,18 @@ const MembershipTiers: React.FC = () => {
setEditingTier(null); setEditingTier(null);
loadTiers(); loadTiers();
} catch (error: any) { } catch (error: any) {
alert(error.response?.data?.detail || 'Failed to update tier'); toast.error(error.response?.data?.detail || 'Failed to update tier.');
} }
}; };
const handleDeleteTier = async (tierId: number) => { const handleDeleteTier = async (tierId: number) => {
if (!confirm('Are you sure you want to delete this membership tier? This action cannot be undone.')) { const confirmed = await confirm({
title: 'Delete membership tier',
message: 'Are you sure you want to delete this membership tier? This action cannot be undone.',
confirmLabel: 'Delete',
tone: 'danger'
});
if (!confirmed) {
return; return;
} }
@@ -55,7 +65,7 @@ const MembershipTiers: React.FC = () => {
await membershipService.deleteTier(tierId); await membershipService.deleteTier(tierId);
loadTiers(); loadTiers();
} catch (error: any) { } catch (error: any) {
alert(error.response?.data?.detail || 'Failed to delete tier'); toast.error(error.response?.data?.detail || 'Failed to delete tier.');
} }
}; };
@@ -393,4 +403,4 @@ const MembershipTierForm: React.FC<MembershipTierFormProps> = ({ tier, onSave, o
); );
}; };
export default MembershipTiers; export default MembershipTiers;
+157 -135
View File
@@ -1,5 +1,5 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import { authService, RegisterData } from '../services/membershipService'; import { authService, RegisterData } from '../services/membershipService';
const Register: React.FC = () => { const Register: React.FC = () => {
@@ -67,142 +67,164 @@ const Register: React.FC = () => {
}; };
return ( return (
<div className="auth-container"> <div className="auth-shell">
<div className="auth-card"> <header className="auth-topbar">
<h2>Create Your Account</h2> <div className="portal-brand">
<p style={{ textAlign: 'center', marginBottom: '24px', color: '#666' }}> <div className="portal-mark">S</div>
Join Swansea Airport Stakeholders Alliance <div className="portal-brand-text">
</p> <h1>SASA Member Portal</h1>
<div className="portal-subtitle">Membership registration and profile setup</div>
{error && <div className="alert alert-error">{error}</div>}
<form onSubmit={handleSubmit} style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '40px', maxWidth: '900px', margin: '0 auto' }}>
{/* Left Column - Personal Information */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
<div className="form-group">
<label htmlFor="first_name">First Name *</label>
<input
type="text"
id="first_name"
name="first_name"
value={formData.first_name}
onChange={handleChange}
required
/>
</div>
<div className="form-group">
<label htmlFor="last_name">Last Name *</label>
<input
type="text"
id="last_name"
name="last_name"
value={formData.last_name}
onChange={handleChange}
required
/>
</div>
<div className="form-group">
<label htmlFor="email">Email Address *</label>
<input
type="email"
id="email"
name="email"
value={formData.email}
onChange={handleChange}
required
/>
</div>
<div className="form-group">
<label htmlFor="password">Password *</label>
<input
type="password"
id="password"
name="password"
value={formData.password}
onChange={handlePasswordChange}
minLength={8}
required
/>
<small style={{ color: '#666', fontSize: '12px' }}>
Minimum 8 characters
</small>
</div>
<div className="form-group">
<label htmlFor="confirmPassword">Confirm Password *</label>
<input
type="password"
id="confirmPassword"
name="confirmPassword"
value={confirmPassword}
onChange={handlePasswordChange}
minLength={8}
required
style={{
borderColor: confirmPassword && !passwordsMatch ? '#dc3545' : confirmPassword && passwordsMatch ? '#28a745' : undefined
}}
/>
{confirmPassword && (
<small style={{
color: passwordsMatch ? '#28a745' : '#dc3545',
fontSize: '12px'
}}>
{passwordsMatch ? '✓ Passwords match' : '✗ Passwords do not match'}
</small>
)}
{!confirmPassword && (
<small style={{ color: '#666', fontSize: '12px' }}>
Re-enter your password
</small>
)}
</div>
</div> </div>
{/* Right Column - Contact Information */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
<div className="form-group">
<label htmlFor="phone">Phone (optional)</label>
<input
type="tel"
id="phone"
name="phone"
value={formData.phone}
onChange={handleChange}
/>
</div>
<div className="form-group">
<label htmlFor="address">Address (optional)</label>
<textarea
id="address"
name="address"
value={formData.address}
onChange={handleChange}
rows={3}
/>
</div>
</div>
{/* Submit Button - Full Width */}
<div style={{ gridColumn: '1 / -1', marginTop: '8px' }}>
<button
type="submit"
className="btn btn-primary"
disabled={loading}
style={{ width: '100%' }}
>
{loading ? 'Creating Account...' : 'Create Account & Sign In'}
</button>
</div>
</form>
<div className="form-footer">
Already have an account? <a href="/login">Log in</a>
</div> </div>
</div> </header>
<main className="auth-container auth-container-wide">
<section className="auth-welcome-card">
<div className="auth-kicker">New Membership</div>
<h2>Join the SASA community</h2>
<p>
Create your account to manage your membership, respond to events, and keep your contact details up to date.
</p>
<div className="auth-feature-list">
<div className="auth-feature-item">Straightforward onboarding with automatic sign-in</div>
<div className="auth-feature-item">Membership tiers, payments, and event RSVPs in one place</div>
<div className="auth-feature-item">A separate admin workspace for staff users after login</div>
</div>
</section>
<section className="auth-card auth-card-wide">
<div className="auth-card-head">
<h2>Create Account</h2>
<span>Step 1 of 1</span>
</div>
<div className="auth-card-body">
<p className="auth-card-copy">
Complete the essentials below. You can add or update the rest of your profile later from your dashboard.
</p>
{error && <div className="alert alert-error">{error}</div>}
<form onSubmit={handleSubmit} className="auth-form-grid">
<div className="form-group">
<label htmlFor="first_name">First Name *</label>
<input
type="text"
id="first_name"
name="first_name"
value={formData.first_name}
onChange={handleChange}
autoComplete="given-name"
required
/>
</div>
<div className="form-group">
<label htmlFor="last_name">Last Name *</label>
<input
type="text"
id="last_name"
name="last_name"
value={formData.last_name}
onChange={handleChange}
autoComplete="family-name"
required
/>
</div>
<div className="form-group">
<label htmlFor="email">Email Address *</label>
<input
type="email"
id="email"
name="email"
value={formData.email}
onChange={handleChange}
autoComplete="email"
required
/>
</div>
<div className="form-group">
<label htmlFor="phone">Phone</label>
<input
type="tel"
id="phone"
name="phone"
value={formData.phone}
onChange={handleChange}
autoComplete="tel"
/>
</div>
<div className="form-group">
<label htmlFor="password">Password *</label>
<input
type="password"
id="password"
name="password"
value={formData.password}
onChange={handlePasswordChange}
autoComplete="new-password"
minLength={8}
required
/>
<small className="form-hint">Minimum 8 characters.</small>
</div>
<div className="form-group">
<label htmlFor="confirmPassword">Confirm Password *</label>
<input
type="password"
id="confirmPassword"
name="confirmPassword"
value={confirmPassword}
onChange={handlePasswordChange}
autoComplete="new-password"
minLength={8}
className={confirmPassword ? (passwordsMatch ? 'field-success' : 'field-error') : ''}
required
/>
{confirmPassword ? (
<small className={passwordsMatch ? 'form-hint hint-success' : 'form-hint hint-error'}>
{passwordsMatch ? 'Passwords match.' : 'Passwords do not match.'}
</small>
) : (
<small className="form-hint">Re-enter your password to confirm it.</small>
)}
</div>
<div className="form-group form-group-full">
<label htmlFor="address">Address</label>
<textarea
id="address"
name="address"
value={formData.address}
onChange={handleChange}
rows={4}
autoComplete="street-address"
/>
</div>
<div className="form-group-full">
<button
type="submit"
className="btn btn-primary auth-submit"
disabled={loading}
>
{loading ? 'Creating Account...' : 'Create Account & Sign In'}
</button>
</div>
</form>
</div>
<div className="auth-footer">
<div>
Already have an account? <Link to="/login">Log in</Link>
</div>
</div>
</section>
</main>
</div> </div>
); );
}; };
+102 -57
View File
@@ -55,74 +55,119 @@ const ResetPassword: React.FC = () => {
if (!token) { if (!token) {
return ( return (
<div className="auth-container"> <div className="auth-shell">
<div className="auth-card"> <header className="auth-topbar">
<h2>Invalid Reset Link</h2> <div className="portal-brand">
<p style={{ textAlign: 'center', marginBottom: '24px', color: '#666' }}> <div className="portal-mark">S</div>
This password reset link is invalid or has expired. Please request a new password reset. <div className="portal-brand-text">
</p> <h1>SASA Member Portal</h1>
<button <div className="portal-subtitle">Account recovery</div>
onClick={() => navigate('/forgot-password')} </div>
className="btn btn-primary" </div>
style={{ width: '100%' }} </header>
>
Request New Reset Link <main className="auth-container">
</button> <section className="auth-welcome-card">
</div> <div className="auth-kicker">Link Expired</div>
<h2>This reset link cant be used</h2>
<p>
The link is missing or no longer valid. Request a fresh reset email and try again from the newest message.
</p>
</section>
<section className="auth-card">
<div className="auth-card-head">
<h2>Invalid Reset Link</h2>
<span>Request a new one</span>
</div>
<div className="auth-card-body">
<button
onClick={() => navigate('/forgot-password')}
className="btn btn-primary auth-submit"
>
Request New Reset Link
</button>
</div>
</section>
</main>
</div> </div>
); );
} }
return ( return (
<div className="auth-container"> <div className="auth-shell">
<div className="auth-card"> <header className="auth-topbar">
<h2>Reset Password</h2> <div className="portal-brand">
<p style={{ textAlign: 'center', marginBottom: '24px', color: '#666' }}> <div className="portal-mark">S</div>
Enter your new password below. Make sure it's at least 8 characters long. <div className="portal-brand-text">
</p> <h1>SASA Member Portal</h1>
<div className="portal-subtitle">Choose a new password</div>
</div>
</div>
</header>
{error && <div className="alert alert-error">{error}</div>} <main className="auth-container">
{message && <div className="alert alert-success">{message}</div>} <section className="auth-welcome-card">
<div className="auth-kicker">Secure Reset</div>
<h2>Set a fresh password</h2>
<p>
Use a password with at least 8 characters. After a successful reset, you&apos;ll be returned to the login screen.
</p>
</section>
<form onSubmit={handleSubmit}> <section className="auth-card">
<div className="form-group"> <div className="auth-card-head">
<label htmlFor="newPassword">New Password</label> <h2>Reset Password</h2>
<input <span>Secure update</span>
type="password"
id="newPassword"
name="newPassword"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
required
placeholder="Enter new password"
/>
</div> </div>
<div className="form-group"> <div className="auth-card-body">
<label htmlFor="confirmPassword">Confirm New Password</label> {error && <div className="alert alert-error">{error}</div>}
<input {message && <div className="alert alert-success">{message}</div>}
type="password"
id="confirmPassword"
name="confirmPassword"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
placeholder="Confirm new password"
/>
</div>
<button <form onSubmit={handleSubmit}>
type="submit" <div className="form-group">
className="btn btn-primary" <label htmlFor="newPassword">New Password</label>
disabled={loading} <input
style={{ width: '100%', marginTop: '16px' }} type="password"
> id="newPassword"
{loading ? 'Resetting...' : 'Reset Password'} name="newPassword"
</button> value={newPassword}
</form> onChange={(e) => setNewPassword(e.target.value)}
</div> autoComplete="new-password"
required
placeholder="Enter new password"
/>
</div>
<div className="form-group">
<label htmlFor="confirmPassword">Confirm New Password</label>
<input
type="password"
id="confirmPassword"
name="confirmPassword"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
autoComplete="new-password"
required
placeholder="Confirm new password"
/>
</div>
<button
type="submit"
className="btn btn-primary auth-submit"
disabled={loading}
>
{loading ? 'Resetting...' : 'Reset Password'}
</button>
</form>
</div>
</section>
</main>
</div> </div>
); );
}; };
export default ResetPassword; export default ResetPassword;
+207 -2
View File
@@ -1,4 +1,5 @@
import api from './api'; import api from './api';
import { ensureUtcIso } from '../utils/timezone';
export interface RegisterData { export interface RegisterData {
email: string; email: string;
@@ -220,6 +221,127 @@ export interface EventRSVPData {
notes?: string; notes?: string;
} }
export type EspReaderType = 'checkin_checkout';
export type EspReaderProvisioningStatus = 'pending' | 'approved' | 'provisioned' | 'rejected';
export type EspTapAction = 'check_in' | 'check_out' | 'denied' | 'unknown';
export type RfidWriteJobStatus = 'pending' | 'claimed' | 'completed' | 'failed' | 'cancelled';
export interface EspReader {
id: number;
device_id: string;
name: string;
location: string | null;
reader_type: EspReaderType;
provisioning_status: EspReaderProvisioningStatus;
notes: string | null;
is_active: boolean;
can_write_cards: boolean;
firmware_version: string | null;
last_seen_at: string | null;
approved_at: string | null;
provisioned_at: string | null;
created_at: string;
updated_at: string;
api_key?: string;
}
export interface EspReaderCreateData {
device_id: string;
name: string;
location?: string | null;
reader_type?: EspReaderType;
notes?: string | null;
is_active?: boolean;
can_write_cards?: boolean;
firmware_version?: string | null;
api_key?: string;
}
export interface EspReaderUpdateData {
name?: string;
location?: string | null;
reader_type?: EspReaderType;
notes?: string | null;
is_active?: boolean;
can_write_cards?: boolean;
rotate_api_key?: boolean;
}
export interface RfidCard {
id: number;
uid: string;
user_id: number | null;
label: string | null;
is_active: boolean;
created_at: string;
updated_at: string;
}
export interface RfidCardCreateData {
uid: string;
user_id?: number | null;
label?: string | null;
is_active?: boolean;
}
export interface RfidCardUpdateData {
user_id?: number | null;
label?: string | null;
is_active?: boolean;
}
export interface RfidTap {
id: number;
reader_id: number;
card_id: number | null;
user_id: number | null;
card_uid: string;
action: EspTapAction;
accepted: boolean;
message: string | null;
tapped_at: string;
created_at: string;
}
export interface AttendanceSession {
id: number;
user_id: number;
reader_id: number;
check_in_tap_id: number;
check_out_tap_id: number | null;
checked_in_at: string;
checked_out_at: string | null;
checkout_source: string | null;
system_flag_reason: string | null;
duration_seconds: number | null;
is_open: boolean;
created_at: string;
updated_at: string;
}
export interface RfidWriteJob {
id: number;
reader_id: number;
user_id: number;
card_id: number | null;
label: string;
status: RfidWriteJobStatus;
requested_by_user_id: number;
card_uid: string | null;
write_payload: string | null;
claimed_at: string | null;
completed_at: string | null;
error_message: string | null;
created_at: string;
updated_at: string;
}
export interface RfidWriteJobCreateData {
reader_id: number;
user_id: number;
label: string;
}
export const authService = { export const authService = {
async register(data: RegisterData) { async register(data: RegisterData) {
const response = await api.post('/auth/register', data); const response = await api.post('/auth/register', data);
@@ -409,12 +531,18 @@ export const eventService = {
}, },
async createEvent(data: EventCreateData): Promise<Event> { async createEvent(data: EventCreateData): Promise<Event> {
const response = await api.post('/events/', data); const response = await api.post('/events/', {
...data,
event_date: ensureUtcIso(data.event_date)
});
return response.data; return response.data;
}, },
async updateEvent(eventId: number, data: EventUpdateData): Promise<Event> { async updateEvent(eventId: number, data: EventUpdateData): Promise<Event> {
const response = await api.put(`/events/${eventId}`, data); const response = await api.put(`/events/${eventId}`, {
...data,
event_date: data.event_date ? ensureUtcIso(data.event_date) : undefined
});
return response.data; return response.data;
}, },
@@ -438,3 +566,80 @@ export const eventService = {
return response.data; return response.data;
} }
}; };
export const espService = {
async getReaders(includeInactive: boolean = true): Promise<EspReader[]> {
const response = await api.get(`/esp/admin/readers?include_inactive=${includeInactive}`);
return response.data;
},
async createReader(data: EspReaderCreateData): Promise<EspReader> {
const response = await api.post('/esp/admin/readers', data);
return response.data;
},
async updateReader(readerId: number, data: EspReaderUpdateData): Promise<EspReader> {
const response = await api.put(`/esp/admin/readers/${readerId}`, data);
return response.data;
},
async approveReader(readerId: number): Promise<EspReader> {
const response = await api.post(`/esp/admin/readers/${readerId}/approve`);
return response.data;
},
async rejectReader(readerId: number): Promise<EspReader> {
const response = await api.post(`/esp/admin/readers/${readerId}/reject`);
return response.data;
},
async deleteReader(readerId: number): Promise<{ message: string }> {
const response = await api.delete(`/esp/admin/readers/${readerId}`);
return response.data;
},
async getCards(includeInactive: boolean = true): Promise<RfidCard[]> {
const response = await api.get(`/esp/admin/cards?include_inactive=${includeInactive}`);
return response.data;
},
async createCard(data: RfidCardCreateData): Promise<RfidCard> {
const response = await api.post('/esp/admin/cards', data);
return response.data;
},
async updateCard(cardId: number, data: RfidCardUpdateData): Promise<RfidCard> {
const response = await api.put(`/esp/admin/cards/${cardId}`, data);
return response.data;
},
async getTaps(limit: number = 100): Promise<RfidTap[]> {
const response = await api.get(`/esp/admin/taps?limit=${limit}`);
return response.data;
},
async getAttendance(openOnly: boolean = false, limit: number = 100): Promise<AttendanceSession[]> {
const response = await api.get(`/esp/admin/attendance?open_only=${openOnly}&limit=${limit}`);
return response.data;
},
async closeStaleSessions(checkoutHour: number = 17): Promise<{ closed_count: number }> {
const response = await api.post('/esp/admin/attendance/close-stale', { checkout_hour: checkoutHour });
return response.data;
},
async getWriteJobs(limit: number = 100): Promise<RfidWriteJob[]> {
const response = await api.get(`/esp/admin/write-jobs?limit=${limit}`);
return response.data;
},
async queueWriteJob(data: RfidWriteJobCreateData): Promise<RfidWriteJob> {
const response = await api.post('/esp/admin/write-jobs', data);
return response.data;
},
async cancelWriteJob(jobId: number): Promise<RfidWriteJob> {
const response = await api.post(`/esp/admin/write-jobs/${jobId}/cancel`);
return response.data;
}
};
+5 -1
View File
@@ -28,11 +28,15 @@ export const canEditProfileQuestion = (
question: EditableProfileQuestion, question: EditableProfileQuestion,
allowAdminManagedEdit = false allowAdminManagedEdit = false
): boolean => { ): boolean => {
if (allowAdminManagedEdit) {
return true;
}
if (!question.can_edit) { if (!question.can_edit) {
return false; return false;
} }
if (question.admin_only_edit && !allowAdminManagedEdit) { if (question.admin_only_edit) {
return false; return false;
} }
+9 -3
View File
@@ -1,6 +1,14 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
const hmrConfig = process.env.VITE_HMR_CLIENT_PORT || process.env.VITE_HMR_PROTOCOL || process.env.VITE_HMR_HOST
? {
clientPort: process.env.VITE_HMR_CLIENT_PORT ? Number(process.env.VITE_HMR_CLIENT_PORT) : undefined,
protocol: process.env.VITE_HMR_PROTOCOL,
host: process.env.VITE_HMR_HOST
}
: undefined
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
server: { server: {
@@ -11,9 +19,7 @@ export default defineConfig({
watch: { watch: {
usePolling: true usePolling: true
}, },
hmr: { hmr: hmrConfig,
clientPort: 8050
},
proxy: { proxy: {
'/api': { '/api': {
target: 'http://backend:8000', target: 'http://backend:8000',