diff --git a/.aiignore b/.aiignore new file mode 100644 index 0000000..cb989a0 --- /dev/null +++ b/.aiignore @@ -0,0 +1,23 @@ +# An .aiignore file follows the same syntax as a .gitignore file. +# .gitignore documentation: https://git-scm.com/docs/gitignore + +# you can ignore files +.DS_Store +*.log +*.tmp + +# or folders +dist/ +build/ +out/ +.idea/ + +/.aiignore +.aiignore + +/.env +/.env.* +.env +.env.* +**/.env +**/.env.* \ No newline at end of file diff --git a/.gitignore b/.gitignore index 60b54fb..3dd5fca 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ __pycache__/ .Python env/ venv/ +.venv/ ENV/ build/ develop-eggs/ @@ -23,6 +24,13 @@ wheels/ .installed.cfg *.egg +# Node +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + # Environment variables .env .env.local diff --git a/ESP_RFID_API.md b/ESP_RFID_API.md new file mode 100644 index 0000000..279d542 --- /dev/null +++ b/ESP_RFID_API.md @@ -0,0 +1,463 @@ +# ESP RFID Integration Contract + +The ESP32 reader is expected to host its own local setup/dashboard UI. The reader registers itself with the SASA server, waits for admin approval, receives an API key once, then uses that key for heartbeat, time sync, taps, dashboard login checks, and queued card-write jobs. + +All timestamps are UTC ISO-8601 unless otherwise noted. + +## 1. Reader Setup and Approval + +### Register from the ESP dashboard + +Unauthenticated endpoint. Use this when the reader first boots or when it is reset/reconfigured. + +```http +POST /api/v1/esp/device/register +Content-Type: application/json +``` + +Request: + +```json +{ + "device_id": "front-desk-01", + "name": "Front Desk Reader", + "location": "Reception", + "reader_type": "checkin_checkout", + "can_write_cards": true, + "firmware_version": "esp32-rfid-0.1.0", + "notes": "PN532 over I2C" +} +``` + +Response: + +```json +{ + "device_id": "front-desk-01", + "provisioning_status": "pending", + "registration_token": "one-time-registration-token", + "message": "Registration received. Approve this reader in the admin panel.", + "poll_interval_seconds": 5 +} +``` + +Firmware must store: + +- `device_id` +- `registration_token` + +### Admin approval + +In the web portal: + +- Dashboard -> Admin -> ESP RFID -> Setup +- Approve or reject pending readers. + +When approved, the server does not show the key in the admin panel. The reader gets it through the provisioning poll below. + +### Poll provisioning status + +Use this while waiting for approval. + +```http +GET /api/v1/esp/device/provisioning-status +X-ESP-Device-ID: front-desk-01 +X-ESP-Registration-Token: one-time-registration-token +``` + +Pending response: + +```json +{ + "device_id": "front-desk-01", + "provisioning_status": "pending", + "message": "Waiting for admin approval.", + "api_key": null, + "poll_interval_seconds": 5 +} +``` + +Approved response: + +```json +{ + "device_id": "front-desk-01", + "provisioning_status": "provisioned", + "message": "Reader approved. Store this API key; it will not be returned again.", + "api_key": "generated-api-key", + "poll_interval_seconds": 5 +} +``` + +Firmware must store `api_key` permanently. The server only returns it once. If it is lost, either rotate the key in admin and manually enter it on the ESP, or re-register the reader. + +Rejected response: + +```json +{ + "device_id": "front-desk-01", + "provisioning_status": "rejected", + "message": "Reader registration rejected.", + "api_key": null, + "poll_interval_seconds": 5 +} +``` + +## 2. Authenticated Reader Requests + +After provisioning, every reader endpoint uses: + +```http +X-ESP-Device-ID: front-desk-01 +X-ESP-API-Key: generated-api-key +Content-Type: application/json +``` + +Invalid, inactive, unapproved, or rejected readers receive `401`. + +## 3. Time Sync + +The backend returns the server clock in UTC/Zulu. Firmware should treat this as the source of truth and display it locally as needed. + +## 3. Local ESP Dashboard + +The firmware in `ESP/main.cpp` serves the local dashboard on port `80`. + +Access options: + +- If WiFi connects, open `http:///`. +- If WiFi does not connect, the reader starts a setup AP named `RFID-Setup-XXXXXX`; connect to it and open `http://192.168.4.1/`. + +Default local dashboard credentials are configured in `ESP/secrets.h`: + +- Username: `admin` +- Password: `admin` + +The dashboard shows: + +- WiFi status and RSSI +- Provisioning status +- Current operating mode +- Server-synced clock +- Last heartbeat result +- Current write-job state +- Last card UID/action/result + +The local UI also exposes controls for: + +- Saving server/WiFi/reader configuration +- Registering or re-registering the reader +- Manually syncing time +- Manually polling for queued write jobs +- Clearing provisioning +- Rebooting + +Local dashboard JSON endpoints: + +- `GET /api/status` +- `POST /api/config` +- `POST /api/register` +- `POST /api/sync-time` +- `POST /api/poll-job` +- `POST /api/clear-provisioning` +- `POST /api/cancel-local-job` +- `POST /api/reboot` + +These are local ESP endpoints, not SASA backend endpoints. + +## 4. Heartbeat + +The reader sends a heartbeat after provisioning so the server knows it is alive and so the ESP can show heartbeat status on its dashboard. + +```http +POST /api/v1/esp/device/heartbeat +X-ESP-Device-ID: front-desk-01 +X-ESP-API-Key: generated-api-key +Content-Type: application/json +``` + +Request: + +```json +{ + "mode": "idle", + "message": "Heartbeat OK", + "wifi_rssi": -54, + "free_heap": 184320, + "firmware_version": "esp32-rfid-0.2.0", + "active_write_job_id": null +} +``` + +Response: + +```json +{ + "ok": true, + "server_time_utc": "2026-05-05T10:15:30.123456", + "unix_ms": 1777976130123, + "heartbeat_interval_seconds": 10, + "time_poll_interval_seconds": 3, + "write_job_poll_interval_seconds": 3 +} +``` + +Firmware modes currently used: + +- `setup` +- `waiting approval` +- `idle` +- `syncing time` +- `heartbeat` +- `checking jobs` +- `ready to write` +- `writing card` +- `reporting tap` +- `error` + +The backend updates `last_seen_at` when heartbeat succeeds. + +## 5. Time Sync + +The reader should poll every 3 seconds. + +```http +GET /api/v1/esp/device/time +``` + +Response: + +```json +{ + "server_time_utc": "2026-05-05T10:15:30.123456", + "unix_ms": 1777976130123, + "poll_interval_seconds": 3 +} +``` + +Use `unix_ms` to correct the ESP local clock. Do not convert the value to a local timezone before applying it on the device. + +## 6. Check-In and Check-Out Taps + +```http +POST /api/v1/esp/device/taps +``` + +Request: + +```json +{ + "card_uid": "04A1B2C3D4", + "tapped_at": "2026-05-05T10:15:30.123456", + "reader_type": "checkin_checkout" +} +``` + +`tapped_at` is optional. If omitted, server time is used. If provided, it is normalized to UTC before persistence. + +Accepted check-in: + +```json +{ + "accepted": true, + "action": "check_in", + "message": "Checked in", + "server_time_utc": "2026-05-05T10:15:30.123456", + "tap_id": 123, + "session_id": 45, + "user_id": 7, + "user_name": "Jane Smith", + "checked_in_at": "2026-05-05T10:15:30.123456", + "checked_out_at": null, + "duration_seconds": null +} +``` + +Accepted check-out: + +```json +{ + "accepted": true, + "action": "check_out", + "message": "Checked out", + "server_time_utc": "2026-05-05T12:20:30.123456", + "tap_id": 124, + "session_id": 45, + "user_id": 7, + "user_name": "Jane Smith", + "checked_in_at": "2026-05-05T10:15:30.123456", + "checked_out_at": "2026-05-05T12:20:30.123456", + "duration_seconds": 7500 +} +``` + +Denied: + +```json +{ + "accepted": false, + "action": "denied", + "message": "Unknown RFID card", + "server_time_utc": "2026-05-05T10:15:30.123456", + "tap_id": 125, + "session_id": null, + "user_id": null, + "user_name": null +} +``` + +The server logs denied taps so unknown cards can still be audited. + +The stored tap, check-in, check-out, approval, heartbeat, and write-job timestamps are all UTC. The frontend renders those values in Europe/London, but the backend and ESP contract stay on UTC/Zulu. + +## 7. Queued RFID Card Writes + +Admins queue card writes in: + +- Dashboard -> Admin -> ESP RFID -> Setup -> Queue Card Write + +They select: + +- User +- Writing reader +- Card label + +The reader does not need an inbound network port. It polls for jobs. + +### Poll next write job + +Only readers with `can_write_cards=true` may use this endpoint. + +```http +GET /api/v1/esp/device/write-jobs/next +``` + +No job response: + +```json +null +``` + +Job response: + +```json +{ + "id": 20, + "reader_id": 3, + "user_id": 7, + "card_id": null, + "label": "Jane main card", + "status": "claimed", + "requested_by_user_id": 1, + "card_uid": null, + "write_payload": "{\"job_id\":20,\"user_id\":7,\"user_name\":\"Jane Smith\",\"label\":\"Jane main card\",\"issued_at\":\"2026-05-05T10:15:30.123456\"}", + "claimed_at": "2026-05-05T10:15:30.123456", + "completed_at": null, + "error_message": null, + "created_at": "2026-05-05T10:14:00.000000", + "updated_at": "2026-05-05T10:15:30.123456" +} +``` + +Firmware behavior: + +1. Poll for a job. +2. If a job is returned, enter `ready to write` mode and show the target user/label on the ESP dashboard. +3. On the next card tap, write `write_payload` to the card if supported by the chosen RFID card type. +4. Report success/failure. + +The included ESP firmware writes MIFARE Classic 1K/4K cards using the default key `FF FF FF FF FF FF`, starting at block 4 and skipping trailer blocks. Unsupported cards are reported as failed write jobs. + +### Complete write job + +```http +POST /api/v1/esp/device/write-jobs/20/complete +``` + +Success request: + +```json +{ + "success": true, + "card_uid": "04A1B2C3D4" +} +``` + +Failure request: + +```json +{ + "success": false, + "error_message": "Card write failed verification" +} +``` + +On success, the server creates or updates the RFID card record and assigns it to the selected user with the queued label. + +## 8. ESP Local Dashboard Login + +Use this when someone logs into the ESP-hosted dashboard. + +```http +POST /api/v1/esp/device/dashboard-login +``` + +Request: + +```json +{ + "email": "admin@example.com", + "password": "password" +} +``` + +Response: + +```json +{ + "valid": true, + "user_id": 1, + "role": "admin", + "user_name": "Admin User" +} +``` + +Only `admin` and `super_admin` users return `valid: true`. + +## 9. Admin API Summary + +These endpoints use the normal portal JWT. + +- `GET /api/v1/esp/admin/readers` +- `POST /api/v1/esp/admin/readers` manual reader/key creation fallback +- `PUT /api/v1/esp/admin/readers/{reader_id}` +- `POST /api/v1/esp/admin/readers/{reader_id}/approve` +- `POST /api/v1/esp/admin/readers/{reader_id}/reject` +- `DELETE /api/v1/esp/admin/readers/{reader_id}` +- `POST /api/v1/esp/device/heartbeat` +- `GET /api/v1/esp/admin/cards` +- `POST /api/v1/esp/admin/cards` +- `PUT /api/v1/esp/admin/cards/{card_id}` +- `GET /api/v1/esp/admin/write-jobs?limit=100` +- `POST /api/v1/esp/admin/write-jobs` +- `POST /api/v1/esp/admin/write-jobs/{job_id}/cancel` +- `GET /api/v1/esp/admin/taps?limit=100` +- `GET /api/v1/esp/admin/attendance?open_only=false&limit=100` +- `POST /api/v1/esp/admin/attendance/close-stale` + +Stale close request: + +```json +{ + "checkout_hour": 17 +} +``` + +The backend also runs stale closing automatically on startup and hourly. It closes sessions checked in before today's midnight, sets checkout to 17:00 on the check-in date, and marks `checkout_source` as `system`. + +## 10. Time Contract Summary + +- API responses use UTC/Zulu datetimes. +- ESP readers sync against `/api/v1/esp/device/time`. +- Reader-supplied timestamps are normalized to UTC before storage. +- The browser frontend displays these values in Europe/London for operators and members. diff --git a/INSTRUCTIONS.md b/INSTRUCTIONS.md index f955683..439911a 100644 --- a/INSTRUCTIONS.md +++ b/INSTRUCTIONS.md @@ -4,25 +4,36 @@ This project aims to develop a comprehensive membership management system for the Swansea Airport Stakeholders' Alliance. The system will handle member registration, payment processing, membership tracking, and administrative functions for a medium-sized alliance. +## Current Implementation Status + +The app now includes a FastAPI backend, React/Vite frontend, Docker Compose development gateway, Alembic migrations, Square payment integration, SMTP2GO email integration, ESP RFID provisioning/time-sync/tap tracking, event/RSVP endpoints, configurable profile questions, privacy/terms pages, feature flags, UTC backend timestamps with London-facing frontend formatting, and a fast test gate in `restart.sh`. + ## Core Features ### Public Member Features - **Self-Service Registration**: Members can sign up online and select their membership tier - **Payment Processing**: Integration with Square payment system for secure online payments, and a dummy payment system for initial testing - **Membership Portal**: Secure login to view membership status, payment history, and upcoming meetings -- **Renewal Reminders**: Automated email notifications for membership renewal deadlines +- **Profile Questions**: Members can answer configurable profile questions, including conditional and volunteering-related questions - **Event Management**: View upcoming events and RSVP to participate -- **Volunteering**: View assigned volunteer roles, schedule availability for roles, and access certificates/training records +- **Account Management**: Members can update profile details, change passwords, request password resets, and review privacy/terms pages +- **Renewal Reminders**: Planned automated email notifications for membership renewal deadlines +- **Volunteering**: Volunteer-related profile fields are implemented; richer role, schedule, and certificate screens are planned ### Administrative Features - **Member Database Management**: Query and modify member records - **Manual Payment Entry**: Record cash payments to activate memberships - **Membership Tier Management**: Configure different membership levels and associated fees -- **Meeting Management**: Post notices and updates about upcoming alliance meetings -- **Reporting**: Generate reports on membership statistics and payment status -- **Files**: A repositry for files which members can access based on their tier - such as meeting minutes and manuals. Admins can upload files to this area. +- **Profile Question Management**: Create, edit, deactivate, order, and configure dependencies for member profile questions +- **Meeting/Event Management**: Create, edit, and manage events, track RSVPs and attendance +- **Timezone Handling**: Persist timestamps in UTC, display member-facing times in Europe/London, and convert event input back to UTC before saving +- **ESP RFID Readers**: Reader registration, time sync, taps, attendance sessions, and queued card-writing jobs are implemented end to end +- **Email Management**: Edit database-backed email templates with escaped previews, send test emails, and monitor SMTP2GO bounces +- **Feature Flags**: View backend feature flags and reload them from the super-admin interface +- **Reporting**: Planned reports on membership statistics and payment status +- **Files**: Planned repository for member files based on tier, such as meeting minutes and manuals - **Event Management**: Create, edit, and manage events, track RSVPs and attendance -- **Volunteering**: Assign configurable volunteer roles to members (e.g., Fire, Radio, General), manage volunteer schedules, and record certificates/training. Note: A member may not necessarily be a volunteer, but all volunteers are members. +- **Volunteering**: Models exist for configurable volunteer roles, assignments, schedules, and certificates/training. Note: A member may not necessarily be a volunteer, but all volunteers are members. ## Technical Stack @@ -32,7 +43,8 @@ This project aims to develop a comprehensive membership management system for th - **Authentication**: JWT-based authentication system - **Payment Integration**: Square API for payment processing - **Email Service**: SMTP2GO API for automated reminders and notifications -- **Frontend**: Modern web interface (to be determined - potentially React/Vue.js) +- **Frontend**: React 18, TypeScript, Vite, and Tailwind CSS +- **Testing**: Vitest for frontend unit tests and pytest for backend unit tests ## Membership Tiers @@ -73,21 +85,41 @@ Each tier will have associated annual fees and benefits. - `memberships`: Membership records with tier and status - `payments`: Payment transactions - `tiers`: Membership tier definitions +- `profile_questions`: Configurable profile/onboarding questions +- `user_profile_answers`: Per-member profile answers - `events`: Event information and details - `event_rsvps`: Event registration and attendance tracking - `volunteer_roles`: Configurable volunteer role definitions (e.g., Fire, Radio, General) - `volunteer_assignments`: Member-to-role assignments - `volunteer_schedules`: Volunteer shift scheduling and availability - `certificates`: Training certificates and qualifications +- `email_templates`: Editable SMTP2GO email templates +- `email_bounces`: Bounce/complaint/unsubscribe tracking +- `password_reset_tokens`: One-time reset tokens - `notifications`: Email notification logs +## Testing and Restart Workflow + +`./restart.sh` rebuilds Docker images with cache, runs the fast frontend and backend unit tests, shuts down the current stack, and starts it again only if tests pass. + +```bash +./restart.sh +``` + +Individual test commands: + +```bash +docker compose run --rm frontend npm test +docker compose run --rm backend pytest -q +``` + ## Development Phases -1. **Phase 1**: Core API development (authentication, user management) -2. **Phase 2**: Payment integration and membership management -3. **Phase 3**: Admin interface development -4. **Phase 4**: Member portal, email system, event management, and volunteering features -5. **Phase 5**: Testing, deployment, and documentation +1. **Phase 1**: Core API development (authentication, user management) - implemented +2. **Phase 2**: Payment integration and membership management - implemented +3. **Phase 3**: Admin interface development - implemented for users, tiers, payments, emails, bounces, profile questions, and feature flags +4. **Phase 4**: Member portal, email system, event management, and volunteering features - partially implemented; richer volunteer screens and renewal reminders remain +5. **Phase 5**: Testing, deployment, and documentation - active; fast unit tests and documentation are in place ## Deployment Considerations @@ -152,4 +184,4 @@ Each tier will have associated annual fees and benefits. - Payment processing success rate - User engagement with portal - Administrative efficiency improvements -- System uptime and performance \ No newline at end of file +- System uptime and performance diff --git a/PROJECT_STRUCTURE.md b/PROJECT_STRUCTURE.md index 8523436..656db75 100644 --- a/PROJECT_STRUCTURE.md +++ b/PROJECT_STRUCTURE.md @@ -2,115 +2,158 @@ ``` membership/ -├── .env # Environment configuration (ready to use) -├── .env.example # Template for environment variables +├── .env # Local environment configuration +├── .env.example # Environment variable template ├── .gitignore # Git ignore rules -├── docker-compose.yml # Docker services configuration -├── INSTRUCTIONS.md # Original project requirements -├── README.md # Complete documentation -├── QUICKSTART.md # Quick start guide +├── docker-compose.yml # Backend, frontend, gateway, and prod frontend services +├── restart.sh # Build, run fast tests, and restart the app +├── INSTRUCTIONS.md # Product requirements and roadmap context +├── README.md # Full project documentation +├── QUICKSTART.md # Short operator/developer guide │ ├── backend/ # FastAPI application -│ ├── Dockerfile # Backend container configuration -│ ├── requirements.txt # Python dependencies +│ ├── Dockerfile +│ ├── requirements.txt +│ ├── alembic.ini +│ ├── alembic/ # Database migrations │ └── app/ -│ ├── __init__.py -│ ├── main.py # Application entry point -│ │ -│ ├── api/ # API endpoints -│ │ ├── __init__.py -│ │ ├── dependencies.py # Auth dependencies +│ ├── main.py # App, CORS, health check, router registration +│ ├── api/ +│ │ ├── dependencies.py # Auth dependencies │ │ └── v1/ -│ │ ├── __init__.py -│ │ ├── auth.py # Registration, login -│ │ ├── users.py # User management -│ │ ├── tiers.py # Membership tiers -│ │ ├── memberships.py # Membership management -│ │ └── payments.py # Payment processing -│ │ -│ ├── core/ # Core functionality -│ │ ├── __init__.py -│ │ ├── config.py # Configuration settings -│ │ ├── database.py # Database connection -│ │ └── security.py # Auth & password hashing -│ │ -│ ├── models/ # Database models -│ │ ├── __init__.py -│ │ └── models.py # SQLAlchemy models -│ │ -│ ├── schemas/ # Pydantic schemas -│ │ ├── __init__.py -│ │ └── schemas.py # Request/response schemas -│ │ -│ ├── services/ # Business logic (placeholder) -│ └── utils/ # Utilities (placeholder) +│ │ ├── auth.py # Register, login, password reset/change +│ │ ├── users.py # Users, profile questions, profile answers +│ │ ├── tiers.py # Membership tiers +│ │ ├── memberships.py +│ │ ├── payments.py # Manual, Square, refund, payment history +│ │ ├── email.py # SMTP2GO email tests and bounce webhooks +│ │ ├── email_templates.py +│ │ ├── events.py # Events and RSVPs +│ │ └── feature_flags.py +│ ├── core/ # Config, database, security, datetime helpers, default data +│ ├── models/ # SQLAlchemy models +│ ├── schemas/ # Pydantic schemas +│ ├── services/ # Email, bounce, Square, attendance, feature flags +│ └── tests/ # Fast backend pytest unit tests │ -├── database/ # Database initialization -│ └── init.sql # Default data & admin user +├── docker/ +│ └── gateway/ # Nginx dev gateway and self-signed TLS setup │ -└── frontend/ # Frontend (placeholder for future) +└── frontend/ # React/Vite frontend + ├── Dockerfile + ├── package.json + ├── vite.config.ts + └── src/ + ├── App.tsx # Routes, footer links, cookie notice + ├── components/ # Dashboard, admin, payment, email, profile UI + ├── contexts/ # Feature flag context/provider + ├── pages/ # Login, register, dashboard, policy pages + ├── services/ # API clients + └── utils/ # Shared frontend logic and Vitest tests ``` ## Key Files ### Configuration -- **`.env`** - Environment variables (database, API keys, etc.) -- **`docker-compose.yml`** - Services: MySQL + FastAPI backend +- **`.env`** - Runtime configuration for database, auth, Square, SMTP2GO, ports, and gateway TLS. +- **`docker-compose.yml`** - Services for FastAPI backend, Vite frontend, Nginx gateway, and production static frontend. +- **`restart.sh`** - Rebuilds images, runs frontend/backend unit tests, and restarts the stack only if tests pass. ### Backend Application -- **`backend/app/main.py`** - FastAPI app initialization, CORS, routes -- **`backend/app/core/config.py`** - Settings management -- **`backend/app/core/security.py`** - JWT tokens, password hashing -- **`backend/app/models/models.py`** - Database tables (User, Membership, Payment, etc.) -- **`backend/app/schemas/schemas.py`** - API request/response models +- **`backend/app/main.py`** - FastAPI app initialization, CORS, startup default-data seeding, routes, and health checks. +- **`backend/app/core/config.py`** - Settings management. +- **`backend/app/core/init_db.py`** - Default membership tiers, super admin, email templates, and profile questions. +- **`backend/app/core/security.py`** - JWT tokens and password hashing. +- **`backend/app/core/datetime.py`** - UTC helpers and Zulu serialization helpers. +- **`backend/app/models/models.py`** - Database tables. +- **`backend/app/schemas/schemas.py`** - API request/response models. +- **`backend/app/tests/test_profile_question_logic.py`** - Fast backend unit tests for profile answer validation. +- **`backend/app/tests/test_datetime_utc.py`** - UTC normalization and serialization tests. -### API Endpoints (v1) -- **`auth.py`** - Register, login -- **`users.py`** - User profile, admin user management -- **`tiers.py`** - Membership tier CRUD -- **`memberships.py`** - Membership management -- **`payments.py`** - Payment processing & history +### Frontend Application +- **`frontend/src/pages/Dashboard.tsx`** - Main member/admin dashboard. +- **`frontend/src/components/MembershipSetup.tsx`** - Membership tier selection and payment flow. +- **`frontend/src/components/SquarePayment.tsx`** - Square Web Payments SDK form. +- **`frontend/src/components/AdminProfileQuestionManager.tsx`** - Admin profile-question configuration. +- **`frontend/src/components/ProfileQuestionsForm.tsx`** - Member/admin answer form with dependency handling. +- **`frontend/src/components/EmailTemplateManagement.tsx`** - Email template editing. +- **`frontend/src/components/BounceManagement.tsx`** - SMTP2GO bounce management. +- **`frontend/src/components/EspReaderManagement.tsx`** - ESP reader, card, tap, and attendance admin UI. +- **`frontend/src/utils/profileQuestionLogic.test.ts`** - Fast frontend unit tests for profile-question visibility/editability. +- **`frontend/src/utils/timezone.ts`** - Europe/London display helpers and UTC conversion utilities. + +## API Endpoints + +- **`auth.py`** - Register, login, forgot password, reset password, change password. +- **`users.py`** - Current user profile, admin user CRUD, profile-question CRUD, member/admin profile answers, and role-guarded admin password reset emails. +- **`tiers.py`** - Membership tier CRUD. +- **`memberships.py`** - Member/admin membership management. +- **`payments.py`** - Payment history, manual payments, Square config/process/refund. +- **`events.py`** - Event CRUD, upcoming events, RSVP create/update, RSVP listing. +- **`email.py`** - SMTP2GO test emails, welcome email tests, bounce webhook, bounce stats, cleanup, deactivation. +- **`esp.py`** - ESP reader provisioning, time sync, tap capture, dashboard login, attendance, and queued write jobs. +- **`email_templates.py`** - Database-backed template listing, lookup, update, and default seeding. +- **`feature_flags.py`** - Public feature flag listing/lookup and super-admin-only reload. ## Database Models Fully implemented: -- **User** - Authentication, profile, roles (member/admin/super_admin) -- **MembershipTier** - Configurable tiers with fees and benefits -- **Membership** - User memberships with status tracking -- **Payment** - Payment records with multiple methods -- **Event** - Event management (model ready, endpoints TODO) -- **EventRSVP** - Event registration (model ready, endpoints TODO) -- **VolunteerRole** - Volunteer roles (model ready, endpoints TODO) -- **VolunteerAssignment** - Role assignments (model ready, endpoints TODO) -- **VolunteerSchedule** - Shift scheduling (model ready, endpoints TODO) -- **Certificate** - Training certificates (model ready, endpoints TODO) -- **File** - File repository (model ready, endpoints TODO) -- **Notification** - Email tracking (model ready, endpoints TODO) +- **User** - Authentication, profile, roles, volunteer level. +- **ProfileQuestion** - Configurable profile fields, options, dependencies, admin-only edit flags. +- **UserProfileAnswer** - Per-user answers with update attribution. +- **MembershipTier** - Configurable tiers with fees and benefits. +- **Membership** - User memberships with status, dates, and auto-renew flag. +- **Payment** - Payment records for Square, cash, check, and dummy methods. +- **Event** - Event management records. +- **EventRSVP** - RSVP and attendance records. +- **EmailTemplate** - Editable database-backed email templates. +- **EmailBounce** - SMTP2GO bounce, complaint, and unsubscribe tracking. +- **PasswordResetToken** - One-time password reset support. +- **EspReader** - Provisioned RFID readers with UTC heartbeat and time-sync data. +- **RfidTap** - UTC-normalized RFID tap records. +- **AttendanceSession** - Attendance sessions driven by RFID taps. +- **RfidCardWriteJob** - Queued RFID card write jobs. +- **VolunteerRole** - Volunteer role definitions. +- **VolunteerAssignment** - Member-to-role assignments. +- **VolunteerSchedule** - Volunteer shift schedules. +- **Certificate** - Training/certificate records. +- **File** - File repository metadata. +- **Notification** - Email notification logs. ## Quick Start ```bash # Start everything -docker-compose up -d +docker compose up -d # View logs -docker-compose logs -f +docker compose logs -f # Access API docs -# http://localhost:8000/docs +# http://localhost:8050/docs +``` + +## Tests + +```bash +# Run both fast test suites and restart only if they pass +./restart.sh + +# Run test suites individually +docker compose run --rm frontend npm test +docker compose run --rm backend pytest -q ``` ## Default Credentials **Admin**: admin@swanseaairport.org / admin123 -**Database**: Configured via environment variables (see .env file) +**Database**: Configured via environment variables in `.env`. -## What's Next +## Remaining Roadmap -1. Test the API endpoints -2. Add Square payment integration -3. Implement email notifications -4. Create event management endpoints -5. Add volunteer management endpoints -6. Build frontend interface +1. Expand authenticated API tests for member/admin workflows +2. Add member file repository endpoints and UI +3. Build richer volunteer assignment, schedule, and certificate screens +4. Add renewal reminder batch jobs +5. Add reporting and analytics diff --git a/QUICKSTART.md b/QUICKSTART.md index 46eb821..5bdde78 100644 --- a/QUICKSTART.md +++ b/QUICKSTART.md @@ -16,11 +16,27 @@ Wait until you see "Application startup complete", then press Ctrl+C. - API: http://localhost:8050/api/v1 - Docs: http://localhost:8050/docs +API datetimes are stored and returned in UTC/Zulu. The frontend shows member-facing times in Europe/London and converts event input back to UTC before saving. + Set `APP_PORT` in `.env` / `.env.example` to change `8050`. For Square payment form testing, use HTTPS at `https://localhost:8443`. Set `APP_TLS_PORT` in `.env` / `.env.example` to change `8443`. TLS certs are auto-generated by the gateway container on first start. +## Restart With Tests + +Use the restart helper when you want to rebuild, run the fast test suite, and restart only after tests pass: + +```bash +./restart.sh +``` + +It runs: +- `docker compose run --rm frontend npm test` +- `docker compose run --rm backend pytest -q` + +The current tests cover frontend profile-question visibility/editability rules and backend profile-question answer normalization/validation. They are designed to complete quickly. + ## Testing the API ### 1. Register a new user @@ -108,6 +124,32 @@ docker compose logs -f gateway 1. Login as admin 2. GET `/api/v1/users/` +### Manage profile questions (admin) +1. Login as admin or super admin +2. Open the dashboard Admin area +3. Create, edit, deactivate, and order configurable profile questions +4. Use dependencies to show questions only after a matching parent answer + +### Edit member profile answers +1. Members can update normal profile questions from the Questions dashboard tab +2. Admin-only answers, such as verified training fields, must be updated by an admin + +### Manage events and RSVPs +1. Admins can create and edit events from the dashboard +2. Members can view upcoming events and submit RSVP status +3. Admins can view RSVP lists and attendance data + +### ESP RFID readers +1. Readers register with `/api/v1/esp/device/register` +2. Readers sync clocks from `/api/v1/esp/device/time` +3. Tap, heartbeat, and write-job timestamps are UTC-normalized in the backend +4. Admins can review readers, taps, attendance, and card-write jobs from the ESP screens + +### Manage email templates and bounces +1. Super admins can edit database-backed email templates; previews are shown as escaped HTML text +2. SMTP2GO bounce webhooks are stored and visible in bounce management +3. Bounce cleanup and manual deactivation are available through the API/admin screens + ## Troubleshooting ### Check service status @@ -143,4 +185,6 @@ docker compose up -d 3. Create additional admin users 4. Configure membership tiers as needed 5. Test payment processing -6. Customize email templates (coming soon) +6. Customize email templates +7. Configure profile questions for onboarding and volunteer data +8. Use `./restart.sh` before deploying changes so frontend and backend unit tests run first diff --git a/README.md b/README.md index 0da27fb..f2ac1b3 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,34 @@ # Swansea Airport Stakeholders' Alliance Membership Management System -A comprehensive membership management system built with FastAPI, MySQL, and Docker. +A membership management system for Swansea Airport Stakeholders' Alliance, built with FastAPI, React, MySQL-compatible storage, Square payments, SMTP2GO email services, and Docker Compose. ## Features -- **User Management**: Registration, authentication, and profile management -- **Membership Tiers**: Configurable membership levels with different benefits and fees -- **Payment Processing**: Support for Square payments, cash, and check payments -- **Admin Dashboard**: Complete administrative control over members and payments -- **Event Management**: Create and manage events with RSVP tracking (coming soon) -- **Volunteer Management**: Role assignments, scheduling, and certificates (coming soon) -- **Email Notifications**: Automated notifications via SMTP2GO (coming soon) +- **Authentication and accounts**: Registration, JSON/form login, JWT sessions, password reset, password change, and role-based access for members, admins, and super admins. +- **Member portal**: Dashboard with membership status, payment history, membership setup, account settings, profile editing, configurable profile questions, cookie notice, privacy policy, and terms of service pages. +- **Admin operations**: User listing/editing, admin-triggered member password reset emails, membership tier CRUD, manual payment recording, Square refunds, email template editing with escaped previews, SMTP2GO bounce management, profile-question management, and super-admin feature-flag reloads. +- **Membership tiers**: Configurable Personal, Aircraft Owners, Corporate, and custom tiers with annual fees, descriptions, active/inactive state, and benefits. +- **Memberships and payments**: Membership lifecycle tracking, Square card payments, cash/check/manual payments, dummy test payments, payment history, transaction IDs, refund state, and payment-to-membership linking. +- **Events and RSVPs**: Event CRUD, upcoming event listing, member RSVP updates, RSVP status tracking, attendance fields, and admin RSVP visibility. +- **Time handling**: Backend timestamps are stored and returned as UTC/Zulu, the frontend renders member-facing dates and times in Europe/London, and event entry is converted back to UTC before save. +- **ESP RFID**: Reader provisioning, heartbeat, time sync, tap capture, queued card writes, and admin review of readers/cards/attendance. +- **Volunteer and profile data**: Volunteer flag/level support, configurable member profile questions, conditional questions, admin-only answers, seeded aviation/volunteering questions, and data models for volunteer roles, assignments, schedules, and certificates. +- **Email system**: SMTP2GO-backed email sending, default database templates, editable templates, welcome/password-reset/test emails, bounce webhooks, bounce stats, cleanup, and manual deactivation. +- **Feature flags**: Backend feature-flag service with frontend context and admin status/reload controls. +- **Testing**: Fast frontend Vitest unit tests and backend pytest unit tests wired into `restart.sh`. ## Tech Stack - **Backend**: FastAPI (Python 3.11) +- **Frontend**: React 18, TypeScript, Vite, Tailwind CSS - **Database**: MySQL 8.0 - **Authentication**: JWT tokens with OAuth2 - **Containerization**: Docker & Docker Compose - **ORM**: SQLAlchemy +- **Migrations**: Alembic +- **Payments**: Square Web Payments SDK and Square API +- **Email**: SMTP2GO +- **Tests**: Vitest and pytest ## Project Structure @@ -37,11 +47,17 @@ membership/ │ │ │ │ ├── users.py # User management │ │ │ │ ├── tiers.py # Membership tiers │ │ │ │ ├── memberships.py # Membership management -│ │ │ │ └── payments.py # Payment processing +│ │ │ │ ├── payments.py # Payment processing +│ │ │ │ ├── email.py # SMTP2GO email and bounces +│ │ │ │ ├── email_templates.py +│ │ │ │ ├── events.py # Events and RSVPs +│ │ │ │ ├── esp.py # ESP RFID provisioning, taps, attendance, write jobs +│ │ │ │ └── feature_flags.py │ │ │ └── dependencies.py # Auth dependencies │ │ ├── core/ │ │ │ ├── config.py # Configuration │ │ │ ├── database.py # Database setup +│ │ │ ├── datetime.py # UTC helpers and Zulu serialization helpers │ │ │ └── security.py # Security utilities │ │ ├── models/ │ │ │ └── models.py # Database models @@ -50,8 +66,13 @@ membership/ │ │ └── main.py # Application entry point │ ├── Dockerfile │ └── requirements.txt -├── database/ -│ └── init.sql # Legacy database initialization (deprecated - use Alembic migrations) +├── frontend/ +│ ├── src/ +│ │ ├── components/ # Dashboard, payment, admin, profile, ESP components +│ │ ├── contexts/ # Feature flag, toast, and confirm contexts +│ │ ├── pages/ # Login, register, dashboard, policy pages +│ │ ├── services/ # API clients +│ │ └── utils/ # Tested frontend logic and timezone helpers ├── docker-compose.yml ├── .env.example └── README.md @@ -95,6 +116,25 @@ membership/ - API Documentation: http://localhost:8050/docs - TLS certs are generated automatically by the gateway container on first start +## Restart and Test Gate + +`restart.sh` rebuilds images with cache, runs the fast frontend and backend unit tests, then restarts the stack only if tests pass: + +```bash +./restart.sh +``` + +The current fast test suite covers: +- frontend profile-question visibility and editability rules with Vitest +- backend profile-question option parsing, answer normalization/deserialization, select validation, and volunteer flag normalization with pytest + +You can also run them individually: + +```bash +docker compose run --rm frontend npm test +docker compose run --rm backend pytest -q +``` + ## Frontend Development vs Production ### Development Mode (Vite) @@ -191,6 +231,71 @@ docker compose --profile prod down - `PUT /api/v1/payments/{id}` - Update payment (admin) - `GET /api/v1/payments/` - List all payments (admin) - `POST /api/v1/payments/manual-payment` - Record manual payment (admin) +- `GET /api/v1/payments/config/square` - Get frontend Square config +- `POST /api/v1/payments/square/process` - Process Square card payment +- `POST /api/v1/payments/square/refund` - Refund Square payment (admin) + +### Profile Questions +- `GET /api/v1/users/me/profile-questions` - List active questions with current answers +- `PUT /api/v1/users/me/profile-answers` - Update editable answers +- `GET /api/v1/users/admin/profile-questions` - List all profile questions (admin) +- `POST /api/v1/users/admin/profile-questions` - Create profile question (admin) +- `PUT /api/v1/users/admin/profile-questions/{id}` - Update profile question (admin) +- `DELETE /api/v1/users/admin/profile-questions/{id}` - Deactivate profile question (admin) +- `GET /api/v1/users/admin/users/{id}/profile-answers` - View user answers (admin) +- `PUT /api/v1/users/admin/users/{id}/profile-answers` - Update user answers (admin) + +### Events +- `GET /api/v1/events/` - List events +- `GET /api/v1/events/upcoming` - List upcoming events +- `POST /api/v1/events/` - Create event (admin) +- `PUT /api/v1/events/{id}` - Update event (admin) +- `DELETE /api/v1/events/{id}` - Delete event (admin) +- `GET /api/v1/events/{id}/rsvps` - List RSVPs (admin) +- `POST /api/v1/events/{id}/rsvp` - Create or update current user's RSVP + +### Email and Feature Flags +- `POST /api/v1/email/test-email` - Send test email +- `POST /api/v1/email/test-welcome-email` - Send test welcome email +- `POST /api/v1/email/webhooks/smtp2go/bounce` - Receive SMTP2GO bounce webhook +- `GET /api/v1/email/bounces` - List bounces +- `GET /api/v1/email/bounces/stats` - Bounce statistics +- `GET /api/v1/email-templates/` - List templates +- `PUT /api/v1/email-templates/{template_key}` - Update template +- `GET /api/v1/feature-flags/flags` - List flags +- `POST /api/v1/feature-flags/flags/reload` - Reload flags (super admin) + +### ESP RFID +- `POST /api/v1/esp/device/register` - Reader registration and one-time token issuance +- `GET /api/v1/esp/device/provisioning-status` - Poll reader provisioning +- `GET /api/v1/esp/device/time` - UTC clock sync for ESP firmware +- `POST /api/v1/esp/device/heartbeat` - Reader heartbeat with UTC server time +- `POST /api/v1/esp/device/taps` - RFID tap capture with UTC-normalized timestamps +- `GET /api/v1/esp/device/write-jobs/next` - Poll queued card write job +- `POST /api/v1/esp/device/write-jobs/{job_id}/complete` - Complete a queued write job +- `POST /api/v1/esp/device/dashboard-login` - Validate ESP-hosted dashboard login +- `GET /api/v1/esp/admin/readers` - Admin reader list +- `POST /api/v1/esp/admin/readers` - Admin reader create/provision fallback +- `PUT /api/v1/esp/admin/readers/{reader_id}` - Admin reader update / key rotation +- `POST /api/v1/esp/admin/readers/{reader_id}/approve` - Approve a reader +- `POST /api/v1/esp/admin/readers/{reader_id}/reject` - Reject a reader +- `DELETE /api/v1/esp/admin/readers/{reader_id}` - Delete a reader +- `GET /api/v1/esp/admin/cards` - Admin RFID card list +- `POST /api/v1/esp/admin/cards` - Create RFID card +- `PUT /api/v1/esp/admin/cards/{card_id}` - Update RFID card +- `GET /api/v1/esp/admin/write-jobs` - Admin queued write jobs +- `POST /api/v1/esp/admin/write-jobs` - Queue a write job +- `POST /api/v1/esp/admin/write-jobs/{job_id}/cancel` - Cancel a queued write job +- `GET /api/v1/esp/admin/taps` - Admin tap history +- `GET /api/v1/esp/admin/attendance` - Admin attendance sessions +- `POST /api/v1/esp/admin/attendance/close-stale` - Close stale attendance sessions + +## Time Handling + +- Database timestamps are stored as UTC and serialized as Zulu (`...Z`) in API responses. +- Frontend display uses Europe/London for member-facing dates and times. +- Event creation/editing converts London-local input back to UTC before sending it to the backend. +- ESP devices sync their clocks from `/api/v1/esp/device/time` and persist tap times as UTC. ## Docker Compose Commands @@ -362,16 +467,13 @@ docker compose up -d docker compose logs -f ``` -## Next Steps +## Remaining Roadmap -- [ ] Implement Square payment integration -- [ ] Add email notification system -- [ ] Create event management endpoints -- [ ] Add volunteer management features -- [ ] Build frontend interface -- [ ] Add file upload/management -- [ ] Implement automated renewal reminders +- [ ] Add member file upload/repository endpoints and UI +- [ ] Add richer volunteer role, assignment, schedule, and certificate screens on top of the existing models +- [ ] Implement automated renewal reminder batch jobs - [ ] Add reporting and analytics +- [ ] Expand test coverage around authenticated API flows and payment/email service boundaries ## License diff --git a/SQUARE_CHECKLIST.md b/SQUARE_CHECKLIST.md index 24cd111..b598392 100644 --- a/SQUARE_CHECKLIST.md +++ b/SQUARE_CHECKLIST.md @@ -36,7 +36,7 @@ - [x] Created `SQUARE_PAYMENT_SETUP.md` - Comprehensive setup guide - [x] Created `SQUARE_IMPLEMENTATION.md` - Implementation details - [x] Created `SQUARE_QUICKSTART.md` - Quick start guide -- [x] Created `deploy-square.sh` - Deployment helper script +- [x] Updated `restart.sh` - Build, fast tests, and restart helper ### Code Quality - [x] No Python syntax errors @@ -64,13 +64,15 @@ Before deploying, complete these steps: - [ ] Set SQUARE_ENVIRONMENT=sandbox ### 3. Deployment -- [ ] Run `./deploy-square.sh` OR -- [ ] Run `docker-compose down` -- [ ] Run `docker-compose up -d --build` -- [ ] Verify containers are running: `docker-compose ps` +- [ ] Run `./restart.sh` OR +- [ ] Run `docker compose build` +- [ ] Run `docker compose run --rm frontend npm test` +- [ ] Run `docker compose run --rm backend pytest -q` +- [ ] Run `docker compose up -d` +- [ ] Verify containers are running: `docker compose ps` ### 4. Testing -- [ ] Access frontend at http://localhost:3000 +- [ ] Access frontend at http://localhost:8050 or HTTPS at https://localhost:8443 - [ ] Login/register a user - [ ] Navigate to membership setup - [ ] Select a membership tier @@ -104,7 +106,7 @@ After deployment, run these commands to verify: ```bash # Check backend is running -curl http://localhost:8000/api/v1/payments/config/square +curl http://localhost:8050/api/v1/payments/config/square # Expected output (with your actual IDs): # { @@ -114,10 +116,10 @@ curl http://localhost:8000/api/v1/payments/config/square # } # Check frontend is running -curl http://localhost:3000 +curl http://localhost:8050 # Check logs -docker-compose logs backend | grep -i square +docker compose logs backend | grep -i square ``` ## 📊 Testing Matrix @@ -135,13 +137,13 @@ docker-compose logs backend | grep -i square ```bash # Check Square SDK installed -docker-compose exec backend pip list | grep square +docker compose exec backend pip list | grep square # Check configuration loaded -docker-compose exec backend python -c "from app.core.config import settings; print(settings.SQUARE_ENVIRONMENT)" +docker compose exec backend python -c "from app.core.config import settings; print(settings.SQUARE_ENVIRONMENT)" # Check database has payments -docker-compose exec mysql mysql -u "${DATABASE_USER}" -p -e "SELECT * FROM ${DATABASE_NAME}.payments LIMIT 5;" +docker exec -it membership_mysql mysql -u "${DATABASE_USER}" -p -e "SELECT * FROM ${DATABASE_NAME}.payments LIMIT 5;" # Check frontend files ls -la frontend/src/components/SquarePayment.tsx @@ -151,7 +153,7 @@ ls -la frontend/src/components/SquarePayment.tsx | Issue | Solution | |-------|----------| -| "Module not found: squareup" | Rebuild backend: `docker-compose build backend` | +| "Module not found: squareup" | Rebuild backend: `docker compose build backend` | | "SQUARE_APPLICATION_ID not found" | Add to `.env` and restart containers | | Square SDK not loading | Check browser console, verify script tag in index.html | | Payment fails with 401 | Check SQUARE_ACCESS_TOKEN is correct | diff --git a/SQUARE_IMPLEMENTATION.md b/SQUARE_IMPLEMENTATION.md index e662158..ce317ac 100644 --- a/SQUARE_IMPLEMENTATION.md +++ b/SQUARE_IMPLEMENTATION.md @@ -193,6 +193,15 @@ The Square payment integration is complete, tested, and working in sandbox mode: - Users can retry failed payments - Cash payments still work with PENDING status for admin approval - All payment flows properly tested with Square sandbox test cards +- `restart.sh` now runs the fast Vitest and pytest suites before restarting the stack + +Fast verification commands: + +```bash +./restart.sh +docker compose run --rm frontend npm test +docker compose run --rm backend pytest -q +``` ## Summary diff --git a/SQUARE_PAYMENT_SETUP.md b/SQUARE_PAYMENT_SETUP.md index 6f2e250..de688bb 100644 --- a/SQUARE_PAYMENT_SETUP.md +++ b/SQUARE_PAYMENT_SETUP.md @@ -66,13 +66,14 @@ SQUARE_APPLICATION_ID=sq0idp-... # Your Production Application ID ### 5. Restart the Application -After updating the environment variables, restart your Docker containers: +After updating the environment variables, run the tested restart helper: ```bash -docker-compose down -docker-compose up -d --build +./restart.sh ``` +For a manual restart, run `docker compose build`, `docker compose run --rm frontend npm test`, `docker compose run --rm backend pytest -q`, and then `docker compose up -d`. + ## Testing with Sandbox Square provides test card numbers for sandbox testing: diff --git a/SQUARE_QUICKSTART.md b/SQUARE_QUICKSTART.md index 537988e..2398c7e 100644 --- a/SQUARE_QUICKSTART.md +++ b/SQUARE_QUICKSTART.md @@ -38,19 +38,21 @@ SQUARE_APPLICATION_ID=sandbox-sq0idb-...your-app-id... Run the deployment script: ```bash -./deploy-square.sh +./restart.sh ``` Or manually: ```bash -docker-compose down -docker-compose up -d --build +docker compose build +docker compose run --rm frontend npm test +docker compose run --rm backend pytest -q +docker compose up -d ``` ### Step 4: Test It Out! -1. Open http://localhost:3000 +1. Open http://localhost:8050 or https://localhost:8443 for HTTPS Square testing 2. Register/login 3. Go to "Setup Membership" 4. Select a tier @@ -78,7 +80,7 @@ docker-compose up -d --build - ✅ `.env.example` - UPDATED - ✅ `SQUARE_PAYMENT_SETUP.md` - NEW (detailed setup guide) - ✅ `SQUARE_IMPLEMENTATION.md` - NEW (implementation details) -- ✅ `deploy-square.sh` - NEW (deployment helper) +- ✅ `restart.sh` - build, fast tests, and restart helper ## 🔧 Key Features @@ -118,7 +120,7 @@ User → Select Tier → Choose Payment Method ### Backend won't start? ```bash -docker-compose logs backend +docker compose logs backend ``` Check for missing dependencies or configuration errors. @@ -156,7 +158,7 @@ When ready for production payments: 1. Check `SQUARE_PAYMENT_SETUP.md` for detailed instructions 2. Review Square's documentation -3. Check application logs: `docker-compose logs -f backend` +3. Check application logs: `docker compose logs -f backend` 4. Contact Square support for payment-specific issues --- diff --git a/backend/alembic/versions/2e8a0f9d4b31_add_volunteer_level_and_profile_questions.py b/backend/alembic/versions/2e8a0f9d4b31_add_volunteer_level_and_profile_questions.py new file mode 100644 index 0000000..d9d90a0 --- /dev/null +++ b/backend/alembic/versions/2e8a0f9d4b31_add_volunteer_level_and_profile_questions.py @@ -0,0 +1,77 @@ +"""Add volunteer level and dynamic profile questions + +Revision ID: 2e8a0f9d4b31 +Revises: b583fd2cf202 +Create Date: 2026-05-04 17:50:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '2e8a0f9d4b31' +down_revision: Union[str, None] = 'b583fd2cf202' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column('users', sa.Column('volunteer_level', sa.String(length=50), nullable=True)) + + op.create_table( + 'profile_questions', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('key', sa.String(length=100), nullable=False), + sa.Column('label', sa.String(length=255), nullable=False), + sa.Column('help_text', sa.Text(), nullable=True), + sa.Column('input_type', sa.String(length=30), nullable=False), + sa.Column('placeholder', sa.String(length=255), nullable=True), + sa.Column('options_json', sa.Text(), nullable=True), + sa.Column('is_required', sa.Boolean(), nullable=False), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('admin_only_edit', sa.Boolean(), nullable=False), + sa.Column('display_order', sa.Integer(), nullable=False), + sa.Column('depends_on_question_id', sa.Integer(), nullable=True), + sa.Column('depends_on_value', sa.String(length=255), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['depends_on_question_id'], ['profile_questions.id']), + sa.PrimaryKeyConstraint('id'), + ) + op.create_index(op.f('ix_profile_questions_id'), 'profile_questions', ['id'], unique=False) + op.create_index(op.f('ix_profile_questions_key'), 'profile_questions', ['key'], unique=True) + + op.create_table( + 'user_profile_answers', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('question_id', sa.Integer(), nullable=False), + sa.Column('value_text', sa.Text(), nullable=True), + sa.Column('updated_by_user_id', sa.Integer(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['question_id'], ['profile_questions.id']), + sa.ForeignKeyConstraint(['updated_by_user_id'], ['users.id']), + sa.ForeignKeyConstraint(['user_id'], ['users.id']), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('user_id', 'question_id', name='uq_user_profile_answer'), + ) + op.create_index(op.f('ix_user_profile_answers_id'), 'user_profile_answers', ['id'], unique=False) + op.create_index(op.f('ix_user_profile_answers_question_id'), 'user_profile_answers', ['question_id'], unique=False) + op.create_index(op.f('ix_user_profile_answers_user_id'), 'user_profile_answers', ['user_id'], unique=False) + + +def downgrade() -> None: + op.drop_index(op.f('ix_user_profile_answers_user_id'), table_name='user_profile_answers') + op.drop_index(op.f('ix_user_profile_answers_question_id'), table_name='user_profile_answers') + op.drop_index(op.f('ix_user_profile_answers_id'), table_name='user_profile_answers') + op.drop_table('user_profile_answers') + + op.drop_index(op.f('ix_profile_questions_key'), table_name='profile_questions') + op.drop_index(op.f('ix_profile_questions_id'), table_name='profile_questions') + op.drop_table('profile_questions') + + op.drop_column('users', 'volunteer_level') diff --git a/backend/alembic/versions/8d2b0c4a1f7e_add_esp_rfid_attendance.py b/backend/alembic/versions/8d2b0c4a1f7e_add_esp_rfid_attendance.py new file mode 100644 index 0000000..f56d599 --- /dev/null +++ b/backend/alembic/versions/8d2b0c4a1f7e_add_esp_rfid_attendance.py @@ -0,0 +1,130 @@ +"""Add ESP RFID attendance tables + +Revision ID: 8d2b0c4a1f7e +Revises: 2e8a0f9d4b31 +Create Date: 2026-05-05 11:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "8d2b0c4a1f7e" +down_revision: Union[str, None] = "2e8a0f9d4b31" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "esp_readers", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("device_id", sa.String(length=100), nullable=False), + sa.Column("name", sa.String(length=255), nullable=False), + sa.Column("location", sa.String(length=255), nullable=True), + sa.Column("reader_type", sa.Enum("checkin_checkout", name="espreadertype"), nullable=False), + sa.Column("api_key_hash", sa.String(length=255), nullable=False), + sa.Column("is_active", sa.Boolean(), nullable=False), + sa.Column("last_seen_at", sa.DateTime(), nullable=True), + sa.Column("notes", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_esp_readers_device_id"), "esp_readers", ["device_id"], unique=True) + op.create_index(op.f("ix_esp_readers_id"), "esp_readers", ["id"], unique=False) + + op.create_table( + "rfid_cards", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("uid", sa.String(length=100), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=True), + sa.Column("label", sa.String(length=255), nullable=True), + sa.Column("is_active", sa.Boolean(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(["user_id"], ["users.id"]), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_rfid_cards_id"), "rfid_cards", ["id"], unique=False) + op.create_index(op.f("ix_rfid_cards_uid"), "rfid_cards", ["uid"], unique=True) + op.create_index(op.f("ix_rfid_cards_user_id"), "rfid_cards", ["user_id"], unique=False) + + op.create_table( + "rfid_taps", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("reader_id", sa.Integer(), nullable=False), + sa.Column("card_id", sa.Integer(), nullable=True), + sa.Column("user_id", sa.Integer(), nullable=True), + sa.Column("card_uid", sa.String(length=100), nullable=False), + sa.Column("action", sa.Enum("check_in", "check_out", "denied", "unknown", name="esptapaction"), nullable=False), + sa.Column("accepted", sa.Boolean(), nullable=False), + sa.Column("message", sa.String(length=255), nullable=True), + sa.Column("raw_payload", sa.Text(), nullable=True), + sa.Column("tapped_at", sa.DateTime(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(["card_id"], ["rfid_cards.id"]), + sa.ForeignKeyConstraint(["reader_id"], ["esp_readers.id"]), + sa.ForeignKeyConstraint(["user_id"], ["users.id"]), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_rfid_taps_card_id"), "rfid_taps", ["card_id"], unique=False) + op.create_index(op.f("ix_rfid_taps_card_uid"), "rfid_taps", ["card_uid"], unique=False) + op.create_index(op.f("ix_rfid_taps_id"), "rfid_taps", ["id"], unique=False) + op.create_index(op.f("ix_rfid_taps_reader_id"), "rfid_taps", ["reader_id"], unique=False) + op.create_index(op.f("ix_rfid_taps_tapped_at"), "rfid_taps", ["tapped_at"], unique=False) + op.create_index(op.f("ix_rfid_taps_user_id"), "rfid_taps", ["user_id"], unique=False) + + op.create_table( + "attendance_sessions", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("reader_id", sa.Integer(), nullable=False), + sa.Column("check_in_tap_id", sa.Integer(), nullable=False), + sa.Column("check_out_tap_id", sa.Integer(), nullable=True), + sa.Column("checked_in_at", sa.DateTime(), nullable=False), + sa.Column("checked_out_at", sa.DateTime(), nullable=True), + sa.Column("checkout_source", sa.Enum("user", "system", name="attendancecheckoutsource"), nullable=True), + sa.Column("system_flag_reason", sa.String(length=255), nullable=True), + sa.Column("duration_seconds", sa.Integer(), nullable=True), + sa.Column("is_open", sa.Boolean(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(["check_in_tap_id"], ["rfid_taps.id"]), + sa.ForeignKeyConstraint(["check_out_tap_id"], ["rfid_taps.id"]), + sa.ForeignKeyConstraint(["reader_id"], ["esp_readers.id"]), + sa.ForeignKeyConstraint(["user_id"], ["users.id"]), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_attendance_sessions_checked_in_at"), "attendance_sessions", ["checked_in_at"], unique=False) + op.create_index(op.f("ix_attendance_sessions_checked_out_at"), "attendance_sessions", ["checked_out_at"], unique=False) + op.create_index(op.f("ix_attendance_sessions_id"), "attendance_sessions", ["id"], unique=False) + op.create_index(op.f("ix_attendance_sessions_is_open"), "attendance_sessions", ["is_open"], unique=False) + op.create_index(op.f("ix_attendance_sessions_reader_id"), "attendance_sessions", ["reader_id"], unique=False) + op.create_index(op.f("ix_attendance_sessions_user_id"), "attendance_sessions", ["user_id"], unique=False) + + +def downgrade() -> None: + op.drop_index(op.f("ix_attendance_sessions_user_id"), table_name="attendance_sessions") + op.drop_index(op.f("ix_attendance_sessions_reader_id"), table_name="attendance_sessions") + op.drop_index(op.f("ix_attendance_sessions_is_open"), table_name="attendance_sessions") + op.drop_index(op.f("ix_attendance_sessions_id"), table_name="attendance_sessions") + op.drop_index(op.f("ix_attendance_sessions_checked_out_at"), table_name="attendance_sessions") + op.drop_index(op.f("ix_attendance_sessions_checked_in_at"), table_name="attendance_sessions") + op.drop_table("attendance_sessions") + op.drop_index(op.f("ix_rfid_taps_user_id"), table_name="rfid_taps") + op.drop_index(op.f("ix_rfid_taps_tapped_at"), table_name="rfid_taps") + op.drop_index(op.f("ix_rfid_taps_reader_id"), table_name="rfid_taps") + op.drop_index(op.f("ix_rfid_taps_id"), table_name="rfid_taps") + op.drop_index(op.f("ix_rfid_taps_card_uid"), table_name="rfid_taps") + op.drop_index(op.f("ix_rfid_taps_card_id"), table_name="rfid_taps") + op.drop_table("rfid_taps") + op.drop_index(op.f("ix_rfid_cards_user_id"), table_name="rfid_cards") + op.drop_index(op.f("ix_rfid_cards_uid"), table_name="rfid_cards") + op.drop_index(op.f("ix_rfid_cards_id"), table_name="rfid_cards") + op.drop_table("rfid_cards") + op.drop_index(op.f("ix_esp_readers_id"), table_name="esp_readers") + op.drop_index(op.f("ix_esp_readers_device_id"), table_name="esp_readers") + op.drop_table("esp_readers") diff --git a/backend/alembic/versions/c4f1d2a9b8e6_add_esp_provisioning_and_write_jobs.py b/backend/alembic/versions/c4f1d2a9b8e6_add_esp_provisioning_and_write_jobs.py new file mode 100644 index 0000000..b07d7d8 --- /dev/null +++ b/backend/alembic/versions/c4f1d2a9b8e6_add_esp_provisioning_and_write_jobs.py @@ -0,0 +1,81 @@ +"""Add ESP provisioning and RFID write jobs + +Revision ID: c4f1d2a9b8e6 +Revises: 8d2b0c4a1f7e +Create Date: 2026-05-05 12:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "c4f1d2a9b8e6" +down_revision: Union[str, None] = "8d2b0c4a1f7e" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "esp_readers", + sa.Column( + "provisioning_status", + sa.Enum("pending", "approved", "provisioned", "rejected", name="espreaderprovisioningstatus"), + nullable=False, + server_default="provisioned", + ), + ) + op.add_column("esp_readers", sa.Column("registration_token_hash", sa.String(length=255), nullable=True)) + op.add_column("esp_readers", sa.Column("can_write_cards", sa.Boolean(), nullable=False, server_default=sa.false())) + op.add_column("esp_readers", sa.Column("firmware_version", sa.String(length=100), nullable=True)) + op.add_column("esp_readers", sa.Column("approved_at", sa.DateTime(), nullable=True)) + op.add_column("esp_readers", sa.Column("provisioned_at", sa.DateTime(), nullable=True)) + op.alter_column("esp_readers", "api_key_hash", existing_type=sa.String(length=255), nullable=True) + + op.create_table( + "rfid_card_write_jobs", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("reader_id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("card_id", sa.Integer(), nullable=True), + sa.Column("label", sa.String(length=255), nullable=False), + sa.Column("status", sa.Enum("pending", "claimed", "completed", "failed", "cancelled", name="rfidwritejobstatus"), nullable=False), + sa.Column("requested_by_user_id", sa.Integer(), nullable=False), + sa.Column("card_uid", sa.String(length=100), nullable=True), + sa.Column("write_payload", sa.Text(), nullable=True), + sa.Column("claimed_at", sa.DateTime(), nullable=True), + sa.Column("completed_at", sa.DateTime(), nullable=True), + sa.Column("error_message", sa.String(length=500), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(["card_id"], ["rfid_cards.id"]), + sa.ForeignKeyConstraint(["reader_id"], ["esp_readers.id"]), + sa.ForeignKeyConstraint(["requested_by_user_id"], ["users.id"]), + sa.ForeignKeyConstraint(["user_id"], ["users.id"]), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_rfid_card_write_jobs_card_id"), "rfid_card_write_jobs", ["card_id"], unique=False) + op.create_index(op.f("ix_rfid_card_write_jobs_card_uid"), "rfid_card_write_jobs", ["card_uid"], unique=False) + op.create_index(op.f("ix_rfid_card_write_jobs_id"), "rfid_card_write_jobs", ["id"], unique=False) + op.create_index(op.f("ix_rfid_card_write_jobs_reader_id"), "rfid_card_write_jobs", ["reader_id"], unique=False) + op.create_index(op.f("ix_rfid_card_write_jobs_status"), "rfid_card_write_jobs", ["status"], unique=False) + op.create_index(op.f("ix_rfid_card_write_jobs_user_id"), "rfid_card_write_jobs", ["user_id"], unique=False) + + +def downgrade() -> None: + op.drop_index(op.f("ix_rfid_card_write_jobs_user_id"), table_name="rfid_card_write_jobs") + op.drop_index(op.f("ix_rfid_card_write_jobs_status"), table_name="rfid_card_write_jobs") + op.drop_index(op.f("ix_rfid_card_write_jobs_reader_id"), table_name="rfid_card_write_jobs") + op.drop_index(op.f("ix_rfid_card_write_jobs_id"), table_name="rfid_card_write_jobs") + op.drop_index(op.f("ix_rfid_card_write_jobs_card_uid"), table_name="rfid_card_write_jobs") + op.drop_index(op.f("ix_rfid_card_write_jobs_card_id"), table_name="rfid_card_write_jobs") + op.drop_table("rfid_card_write_jobs") + op.alter_column("esp_readers", "api_key_hash", existing_type=sa.String(length=255), nullable=False) + op.drop_column("esp_readers", "provisioned_at") + op.drop_column("esp_readers", "approved_at") + op.drop_column("esp_readers", "firmware_version") + op.drop_column("esp_readers", "can_write_cards") + op.drop_column("esp_readers", "registration_token_hash") + op.drop_column("esp_readers", "provisioning_status") diff --git a/backend/alembic/versions/e7a9c2b1d4f0_add_pending_esp_api_key.py b/backend/alembic/versions/e7a9c2b1d4f0_add_pending_esp_api_key.py new file mode 100644 index 0000000..560f326 --- /dev/null +++ b/backend/alembic/versions/e7a9c2b1d4f0_add_pending_esp_api_key.py @@ -0,0 +1,25 @@ +"""Add pending ESP API key delivery + +Revision ID: e7a9c2b1d4f0 +Revises: c4f1d2a9b8e6 +Create Date: 2026-05-05 13:30:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "e7a9c2b1d4f0" +down_revision: Union[str, None] = "c4f1d2a9b8e6" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column("esp_readers", sa.Column("pending_api_key", sa.String(length=255), nullable=True)) + + +def downgrade() -> None: + op.drop_column("esp_readers", "pending_api_key") diff --git a/backend/app/api/v1/__init__.py b/backend/app/api/v1/__init__.py index 61e573b..a9f03f6 100644 --- a/backend/app/api/v1/__init__.py +++ b/backend/app/api/v1/__init__.py @@ -1,5 +1,5 @@ from fastapi import APIRouter -from . import auth, users, tiers, memberships, payments, email, email_templates, events, feature_flags +from . import auth, users, tiers, memberships, payments, email, email_templates, events, feature_flags, esp api_router = APIRouter() @@ -12,3 +12,4 @@ api_router.include_router(email.router, prefix="/email", tags=["email"]) api_router.include_router(email_templates.router, prefix="/email-templates", tags=["email-templates"]) api_router.include_router(events.router, prefix="/events", tags=["events"]) api_router.include_router(feature_flags.router, prefix="/feature-flags", tags=["feature-flags"]) +api_router.include_router(esp.router, prefix="/esp", tags=["esp-rfid"]) diff --git a/backend/app/api/v1/auth.py b/backend/app/api/v1/auth.py index 576e9b1..d756d73 100644 --- a/backend/app/api/v1/auth.py +++ b/backend/app/api/v1/auth.py @@ -6,6 +6,7 @@ from typing import List import uuid from ...core.database import get_db +from ...core.datetime import utc_now from ...core.security import verify_password, get_password_hash, create_access_token from ...models.models import User, UserRole, PasswordResetToken from ...schemas import ( @@ -85,7 +86,7 @@ async def login( ) # Update last login - user.last_login = datetime.utcnow() + user.last_login = utc_now() db.commit() # Create access token @@ -120,7 +121,7 @@ async def login_json( ) # Update last login - user.last_login = datetime.utcnow() + user.last_login = utc_now() db.commit() # Create access token @@ -149,12 +150,12 @@ async def forgot_password( db.query(PasswordResetToken).filter( PasswordResetToken.user_id == user.id, PasswordResetToken.used == False, - PasswordResetToken.expires_at > datetime.utcnow() + PasswordResetToken.expires_at > utc_now() ).update({"used": True}) # Generate new reset token reset_token = str(uuid.uuid4()) - expires_at = datetime.utcnow() + timedelta(hours=1) # Token expires in 1 hour + expires_at = utc_now() + timedelta(hours=1) # Token expires in 1 hour # Create password reset token db_token = PasswordResetToken( @@ -192,7 +193,7 @@ async def reset_password( reset_token = db.query(PasswordResetToken).filter( PasswordResetToken.token == request.token, PasswordResetToken.used == False, - PasswordResetToken.expires_at > datetime.utcnow() + PasswordResetToken.expires_at > utc_now() ).first() if not reset_token: @@ -212,7 +213,7 @@ async def reset_password( # Update password hashed_password = get_password_hash(request.new_password) user.hashed_password = hashed_password - user.updated_at = datetime.utcnow() + user.updated_at = utc_now() # Mark token as used reset_token.used = True @@ -239,7 +240,7 @@ async def change_password( # Update password hashed_password = get_password_hash(request.new_password) current_user.hashed_password = hashed_password - current_user.updated_at = datetime.utcnow() + current_user.updated_at = utc_now() db.commit() diff --git a/backend/app/api/v1/email.py b/backend/app/api/v1/email.py index 84938a1..2e418ce 100644 --- a/backend/app/api/v1/email.py +++ b/backend/app/api/v1/email.py @@ -6,6 +6,7 @@ from ...api.dependencies import get_admin_user from ...models.models import User from typing import Dict, Any, List from ...core.database import get_db +from ...core.datetime import to_zulu_iso from sqlalchemy.orm import Session router = APIRouter() @@ -95,7 +96,7 @@ async def get_bounce_list( "email": bounce.email, "bounce_type": bounce.bounce_type.value, "bounce_reason": bounce.bounce_reason, - "bounce_date": bounce.bounce_date.isoformat(), + "bounce_date": to_zulu_iso(bounce.bounce_date), "is_active": bounce.is_active, "smtp2go_message_id": bounce.smtp2go_message_id } @@ -132,7 +133,7 @@ async def get_bounce_history( "id": bounce.id, "bounce_type": bounce.bounce_type.value, "bounce_reason": bounce.bounce_reason, - "bounce_date": bounce.bounce_date.isoformat(), + "bounce_date": to_zulu_iso(bounce.bounce_date), "is_active": bounce.is_active, "smtp2go_message_id": bounce.smtp2go_message_id } diff --git a/backend/app/api/v1/esp.py b/backend/app/api/v1/esp.py new file mode 100644 index 0000000..ee237d5 --- /dev/null +++ b/backend/app/api/v1/esp.py @@ -0,0 +1,860 @@ +import json +import secrets +from datetime import datetime, timedelta +from typing import List, Optional + +from fastapi import APIRouter, Depends, Header, HTTPException, Query, status +from fastapi.responses import JSONResponse +from sqlalchemy.orm import Session + +from ...api.dependencies import get_admin_user +from ...core.database import get_db +from ...core.datetime import to_utc_naive, to_zulu_iso, unix_ms_utc, utc_now +from ...core.security import ( + get_machine_token_hash, + is_machine_token_hash, + verify_machine_token, + verify_password, +) +from ...models.models import ( + AttendanceCheckoutSource, + AttendanceSession, + EspReader, + EspReaderProvisioningStatus, + EspTapAction, + RfidCard, + RfidCardWriteJob, + RfidTap, + RfidWriteJobStatus, + User, + UserRole, +) +from ...schemas import ( + AttendanceSessionResponse, + EspDashboardLoginResponse, + EspHeartbeatRequest, + EspHeartbeatResponse, + EspReaderCreate, + EspReaderCreateResponse, + EspReaderProvisioningResponse, + EspReaderRegistrationRequest, + EspReaderRegistrationResponse, + EspReaderResponse, + EspReaderUpdate, + EspTimeResponse, + LoginRequest, + MessageResponse, + RfidCardCreate, + RfidCardResponse, + RfidCardUpdate, + RfidTapAdminResponse, + RfidTapRequest, + RfidTapResponse, + RfidWriteJobCompleteRequest, + RfidWriteJobCreate, + RfidWriteJobResponse, + StaleSessionCloseRequest, + StaleSessionCloseResponse, +) +from ...services.attendance_service import close_stale_attendance_sessions, duration_seconds + +router = APIRouter() +READER_LAST_SEEN_WRITE_INTERVAL = timedelta(seconds=30) + + +def _normalize_card_uid(uid: str) -> str: + return uid.strip().upper().replace(" ", "") + + +def _new_api_key() -> str: + return secrets.token_urlsafe(32) + + +def _new_registration_token() -> str: + return secrets.token_urlsafe(24) + + +def _provisioning_status_value(value: object) -> str: + return getattr(value, "value", value) + + +def _compact_tap_response(tap: RfidTap) -> JSONResponse: + return JSONResponse( + content={ + "ok": tap.accepted, + "a": getattr(tap.action, "value", tap.action), + "m": tap.message or "", + } + ) + + +async def get_current_reader( + x_esp_device_id: str = Header(..., alias="X-ESP-Device-ID"), + x_esp_api_key: str = Header(..., alias="X-ESP-API-Key"), + db: Session = Depends(get_db), +) -> EspReader: + reader = db.query(EspReader).filter(EspReader.device_id == x_esp_device_id).first() + provisioning_status = _provisioning_status_value(reader.provisioning_status) if reader else None + credentials_valid = bool( + reader + and reader.is_active + and reader.api_key_hash + and provisioning_status in [EspReaderProvisioningStatus.APPROVED.value, EspReaderProvisioningStatus.PROVISIONED.value] + and verify_machine_token(x_esp_api_key, reader.api_key_hash) + ) + if ( + not credentials_valid + ): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid ESP reader credentials", + ) + + now = utc_now() + should_persist_reader = False + if not is_machine_token_hash(reader.api_key_hash): + reader.api_key_hash = get_machine_token_hash(x_esp_api_key) + should_persist_reader = True + if ( + reader.last_seen_at is None + or now - reader.last_seen_at >= READER_LAST_SEEN_WRITE_INTERVAL + ): + reader.last_seen_at = now + should_persist_reader = True + if reader.pending_api_key: + reader.pending_api_key = None + should_persist_reader = True + if should_persist_reader: + db.commit() + return reader + + +def _get_reader_by_registration_token( + device_id: str, + registration_token: str, + db: Session, +) -> EspReader: + reader = db.query(EspReader).filter(EspReader.device_id == device_id).first() + credentials_valid = bool( + reader + and reader.registration_token_hash + and verify_machine_token(registration_token, reader.registration_token_hash) + ) + if not credentials_valid: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid reader registration token", + ) + if not is_machine_token_hash(reader.registration_token_hash): + reader.registration_token_hash = get_machine_token_hash(registration_token) + db.commit() + return reader + + +@router.post("/device/register", response_model=EspReaderRegistrationResponse, status_code=status.HTTP_202_ACCEPTED) +async def register_reader( + registration: EspReaderRegistrationRequest, + db: Session = Depends(get_db), +): + existing = db.query(EspReader).filter(EspReader.device_id == registration.device_id).first() + existing_status = _provisioning_status_value(existing.provisioning_status) if existing else None + allow_recovery = bool( + existing + and existing_status in [EspReaderProvisioningStatus.APPROVED.value, EspReaderProvisioningStatus.PROVISIONED.value] + and existing.last_seen_at is None + ) + if existing and existing_status in [EspReaderProvisioningStatus.APPROVED.value, EspReaderProvisioningStatus.PROVISIONED.value] and not allow_recovery: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Reader is already approved. Use its existing API key or rotate it from admin.", + ) + + registration_token = _new_registration_token() + now = utc_now() + if existing: + existing.name = registration.name + existing.location = registration.location + existing.reader_type = registration.reader_type + existing.can_write_cards = registration.can_write_cards + existing.firmware_version = registration.firmware_version + existing.notes = registration.notes + existing.registration_token_hash = get_machine_token_hash(registration_token) + existing.provisioning_status = ( + EspReaderProvisioningStatus.APPROVED + if allow_recovery + else EspReaderProvisioningStatus.PENDING + ) + existing.is_active = True + if not allow_recovery: + existing.pending_api_key = None + existing.updated_at = now + reader = existing + else: + reader = EspReader( + device_id=registration.device_id, + name=registration.name, + location=registration.location, + reader_type=registration.reader_type, + can_write_cards=registration.can_write_cards, + firmware_version=registration.firmware_version, + notes=registration.notes, + registration_token_hash=get_machine_token_hash(registration_token), + provisioning_status=EspReaderProvisioningStatus.PENDING, + is_active=True, + ) + db.add(reader) + + db.commit() + return { + "device_id": reader.device_id, + "provisioning_status": reader.provisioning_status, + "registration_token": registration_token, + "message": ( + "Reader recovery accepted. Poll provisioning to receive the API key again." + if allow_recovery + else "Registration received. Approve this reader in the admin panel." + ), + "poll_interval_seconds": 5, + } + + +@router.get("/device/provisioning-status", response_model=EspReaderProvisioningResponse) +async def get_provisioning_status( + x_esp_device_id: str = Header(..., alias="X-ESP-Device-ID"), + x_esp_registration_token: str = Header(..., alias="X-ESP-Registration-Token"), + db: Session = Depends(get_db), +): + reader = _get_reader_by_registration_token(x_esp_device_id, x_esp_registration_token, db) + provisioning_status = _provisioning_status_value(reader.provisioning_status) + if provisioning_status in [EspReaderProvisioningStatus.APPROVED.value, EspReaderProvisioningStatus.PROVISIONED.value]: + api_key = reader.pending_api_key or _new_api_key() + now = utc_now() + if not reader.pending_api_key: + reader.api_key_hash = get_machine_token_hash(api_key) + reader.pending_api_key = api_key + reader.provisioning_status = EspReaderProvisioningStatus.PROVISIONED + reader.provisioned_at = now + reader.updated_at = now + db.commit() + payload = { + "device_id": reader.device_id, + "provisioning_status": EspReaderProvisioningStatus.PROVISIONED.value, + "message": "Reader approved. Store this API key; it will not be returned again.", + "api_key": api_key, + "apiKey": api_key, + "poll_interval_seconds": 5, + } + print( + f"[ESP] provisioning-status device={reader.device_id} status=provisioned has_api_key={bool(api_key)}" + ) + return JSONResponse(content=payload) + + messages = { + EspReaderProvisioningStatus.PENDING.value: "Waiting for admin approval.", + EspReaderProvisioningStatus.APPROVED.value: "Reader approved. API key is already available or will be generated shortly.", + EspReaderProvisioningStatus.PROVISIONED.value: "Reader already provisioned. Use the stored API key.", + EspReaderProvisioningStatus.REJECTED.value: "Reader registration rejected.", + } + payload = { + "device_id": reader.device_id, + "provisioning_status": provisioning_status, + "message": messages.get(provisioning_status, "Waiting for admin approval."), + "api_key": None, + "apiKey": None, + "poll_interval_seconds": 5, + } + print( + f"[ESP] provisioning-status device={reader.device_id} status={provisioning_status} has_api_key=False" + ) + return JSONResponse(content=payload) + + +@router.get("/device/time", response_model=EspTimeResponse) +async def get_device_time( + reader: EspReader = Depends(get_current_reader), +): + now = utc_now() + return { + "server_time_utc": now, + "unix_ms": unix_ms_utc(now), + "poll_interval_seconds": 3, + } + + +@router.post("/device/heartbeat", response_model=EspHeartbeatResponse) +async def record_heartbeat( + heartbeat: EspHeartbeatRequest, + reader: EspReader = Depends(get_current_reader), + db: Session = Depends(get_db), +): + now = utc_now() + if heartbeat.firmware_version and heartbeat.firmware_version != reader.firmware_version: + reader.firmware_version = heartbeat.firmware_version + reader.last_seen_at = now + reader.updated_at = now + db.commit() + return { + "ok": True, + "server_time_utc": now, + "unix_ms": unix_ms_utc(now), + "heartbeat_interval_seconds": 10, + "time_poll_interval_seconds": 3, + "write_job_poll_interval_seconds": 3, + } + + +@router.post("/device/taps", response_model=RfidTapResponse) +async def record_tap( + tap_request: RfidTapRequest, + x_esp_compact_response: str | None = Header(None, alias="X-ESP-Compact-Response"), + reader: EspReader = Depends(get_current_reader), + db: Session = Depends(get_db), +): + compact_response = x_esp_compact_response == "1" + now = utc_now() + tapped_at = to_utc_naive(tap_request.tapped_at) or now + card_uid = _normalize_card_uid(tap_request.card_uid) + card = db.query(RfidCard).filter(RfidCard.uid == card_uid).first() + + tap = RfidTap( + reader_id=reader.id, + card_id=card.id if card else None, + user_id=card.user_id if card and card.is_active else None, + card_uid=card_uid, + action=EspTapAction.UNKNOWN, + accepted=False, + raw_payload=json.dumps(tap_request.model_dump(mode="json")), + tapped_at=tapped_at, + ) + db.add(tap) + db.flush() + + if not card: + tap.action = EspTapAction.DENIED + tap.message = "Unknown RFID card" + db.commit() + if compact_response: + return _compact_tap_response(tap) + return { + "accepted": False, + "action": tap.action, + "message": tap.message, + "server_time_utc": now, + "tap_id": tap.id, + } + + if not card.is_active or not card.user_id: + tap.action = EspTapAction.DENIED + tap.message = "RFID card is inactive or unassigned" + db.commit() + if compact_response: + return _compact_tap_response(tap) + return { + "accepted": False, + "action": tap.action, + "message": tap.message, + "server_time_utc": now, + "tap_id": tap.id, + } + + user = db.query(User).filter(User.id == card.user_id).first() + if not user or not user.is_active: + tap.action = EspTapAction.DENIED + tap.message = "User is inactive" + db.commit() + if compact_response: + return _compact_tap_response(tap) + return { + "accepted": False, + "action": tap.action, + "message": tap.message, + "server_time_utc": now, + "tap_id": tap.id, + "user_id": card.user_id, + } + + open_session = ( + db.query(AttendanceSession) + .filter( + AttendanceSession.user_id == user.id, + AttendanceSession.is_open == True, + ) + .order_by(AttendanceSession.checked_in_at.desc()) + .first() + ) + + user_name = f"{user.first_name} {user.last_name}" + if open_session: + tap.action = EspTapAction.CHECK_OUT + tap.accepted = True + tap.message = "Checked out" + open_session.check_out_tap_id = tap.id + open_session.checked_out_at = tapped_at + open_session.checkout_source = AttendanceCheckoutSource.USER + open_session.duration_seconds = duration_seconds(open_session.checked_in_at, tapped_at) + open_session.is_open = False + open_session.updated_at = now + db.commit() + if compact_response: + return _compact_tap_response(tap) + return { + "accepted": True, + "action": tap.action, + "message": tap.message, + "server_time_utc": now, + "tap_id": tap.id, + "session_id": open_session.id, + "user_id": user.id, + "user_name": user_name, + "checked_in_at": open_session.checked_in_at, + "checked_out_at": open_session.checked_out_at, + "duration_seconds": open_session.duration_seconds, + } + + tap.action = EspTapAction.CHECK_IN + tap.accepted = True + tap.message = "Checked in" + session = AttendanceSession( + user_id=user.id, + reader_id=reader.id, + check_in_tap_id=tap.id, + checked_in_at=tapped_at, + is_open=True, + ) + db.add(session) + db.commit() + if compact_response: + return _compact_tap_response(tap) + return { + "accepted": True, + "action": tap.action, + "message": tap.message, + "server_time_utc": now, + "tap_id": tap.id, + "session_id": session.id, + "user_id": user.id, + "user_name": user_name, + "checked_in_at": session.checked_in_at, + } + + +@router.post("/device/dashboard-login", response_model=EspDashboardLoginResponse) +async def validate_dashboard_login( + login_data: LoginRequest, + reader: EspReader = Depends(get_current_reader), + db: Session = Depends(get_db), +): + user = db.query(User).filter(User.email == login_data.email).first() + if ( + not user + or not user.is_active + or user.role not in [UserRole.ADMIN, UserRole.SUPER_ADMIN] + or not verify_password(login_data.password, user.hashed_password) + ): + return {"valid": False} + + return { + "valid": True, + "user_id": user.id, + "role": user.role, + "user_name": f"{user.first_name} {user.last_name}", + } + + +@router.get("/device/write-jobs/next", response_model=RfidWriteJobResponse | None) +async def get_next_write_job( + reader: EspReader = Depends(get_current_reader), + db: Session = Depends(get_db), +): + if not reader.can_write_cards: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Reader is not enabled for card writing") + + job = ( + db.query(RfidCardWriteJob) + .filter( + RfidCardWriteJob.reader_id == reader.id, + RfidCardWriteJob.status == RfidWriteJobStatus.PENDING, + ) + .order_by(RfidCardWriteJob.created_at.asc()) + .first() + ) + if not job: + return None + + user = db.query(User).filter(User.id == job.user_id).first() + job.status = RfidWriteJobStatus.CLAIMED + job.claimed_at = utc_now() + job.write_payload = json.dumps( + { + "job_id": job.id, + "user_id": job.user_id, + "user_name": f"{user.first_name} {user.last_name}" if user else None, + "label": job.label, + "issued_at": to_zulu_iso(job.claimed_at), + } + ) + db.commit() + db.refresh(job) + return job + + +@router.post("/device/write-jobs/{job_id}/complete", response_model=RfidWriteJobResponse) +async def complete_write_job( + job_id: int, + completion: RfidWriteJobCompleteRequest, + reader: EspReader = Depends(get_current_reader), + db: Session = Depends(get_db), +): + job = ( + db.query(RfidCardWriteJob) + .filter(RfidCardWriteJob.id == job_id, RfidCardWriteJob.reader_id == reader.id) + .first() + ) + if not job: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Write job not found") + if job.status not in [RfidWriteJobStatus.PENDING, RfidWriteJobStatus.CLAIMED]: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Write job is already closed") + + now = utc_now() + if not completion.success: + job.status = RfidWriteJobStatus.FAILED + job.error_message = completion.error_message or "Reader reported write failure" + job.completed_at = now + job.updated_at = now + db.commit() + db.refresh(job) + return job + + if not completion.card_uid: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="card_uid is required when success is true") + + card_uid = _normalize_card_uid(completion.card_uid) + card = db.query(RfidCard).filter(RfidCard.uid == card_uid).first() + if card: + card.user_id = job.user_id + card.label = job.label + card.is_active = True + card.updated_at = now + else: + card = RfidCard(uid=card_uid, user_id=job.user_id, label=job.label, is_active=True) + db.add(card) + db.flush() + + job.card_id = card.id + job.card_uid = card_uid + job.status = RfidWriteJobStatus.COMPLETED + job.completed_at = now + job.updated_at = now + db.commit() + db.refresh(job) + return job + + +@router.get("/admin/readers", response_model=List[EspReaderResponse]) +async def list_readers( + include_inactive: bool = Query(True), + admin_user: User = Depends(get_admin_user), + db: Session = Depends(get_db), +): + query = db.query(EspReader) + if not include_inactive: + query = query.filter(EspReader.is_active == True) + return query.order_by(EspReader.name.asc()).all() + + +@router.post("/admin/readers", response_model=EspReaderCreateResponse, status_code=status.HTTP_201_CREATED) +async def create_reader( + reader_data: EspReaderCreate, + admin_user: User = Depends(get_admin_user), + db: Session = Depends(get_db), +): + existing = db.query(EspReader).filter(EspReader.device_id == reader_data.device_id).first() + if existing: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Device ID already exists") + + api_key = reader_data.api_key or _new_api_key() + reader = EspReader( + device_id=reader_data.device_id, + name=reader_data.name, + location=reader_data.location, + reader_type=reader_data.reader_type, + provisioning_status=EspReaderProvisioningStatus.PROVISIONED, + api_key_hash=get_machine_token_hash(api_key), + is_active=reader_data.is_active, + can_write_cards=reader_data.can_write_cards, + firmware_version=reader_data.firmware_version, + approved_at=utc_now(), + provisioned_at=utc_now(), + notes=reader_data.notes, + ) + db.add(reader) + db.commit() + db.refresh(reader) + + return EspReaderCreateResponse( + **EspReaderResponse.model_validate(reader).model_dump(), + api_key=api_key, + ) + + +@router.post("/admin/readers/{reader_id}/approve", response_model=EspReaderResponse) +async def approve_reader( + reader_id: int, + admin_user: User = Depends(get_admin_user), + db: Session = Depends(get_db), +): + reader = db.query(EspReader).filter(EspReader.id == reader_id).first() + if not reader: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Reader not found") + if reader.provisioning_status == EspReaderProvisioningStatus.REJECTED: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Rejected reader must register again") + + now = utc_now() + reader.provisioning_status = EspReaderProvisioningStatus.APPROVED + reader.is_active = True + reader.approved_at = now + reader.updated_at = now + reader.api_key_hash = None + reader.pending_api_key = None + db.commit() + db.refresh(reader) + return reader + + +@router.post("/admin/readers/{reader_id}/reject", response_model=EspReaderResponse) +async def reject_reader( + reader_id: int, + admin_user: User = Depends(get_admin_user), + db: Session = Depends(get_db), +): + reader = db.query(EspReader).filter(EspReader.id == reader_id).first() + if not reader: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Reader not found") + reader.provisioning_status = EspReaderProvisioningStatus.REJECTED + reader.is_active = False + reader.pending_api_key = None + reader.updated_at = utc_now() + db.commit() + db.refresh(reader) + return reader + + +@router.put("/admin/readers/{reader_id}", response_model=EspReaderCreateResponse | EspReaderResponse) +async def update_reader( + reader_id: int, + reader_data: EspReaderUpdate, + admin_user: User = Depends(get_admin_user), + db: Session = Depends(get_db), +): + reader = db.query(EspReader).filter(EspReader.id == reader_id).first() + if not reader: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Reader not found") + + update_data = reader_data.model_dump(exclude_unset=True) + rotate_api_key = update_data.pop("rotate_api_key", False) + for field, value in update_data.items(): + setattr(reader, field, value) + + new_api_key: Optional[str] = None + if rotate_api_key: + new_api_key = _new_api_key() + reader.api_key_hash = get_machine_token_hash(new_api_key) + reader.pending_api_key = None + reader.provisioning_status = EspReaderProvisioningStatus.PROVISIONED + reader.provisioned_at = utc_now() + + reader.updated_at = utc_now() + db.commit() + db.refresh(reader) + + if new_api_key: + return EspReaderCreateResponse( + **EspReaderResponse.model_validate(reader).model_dump(), + api_key=new_api_key, + ) + return reader + + +@router.get("/admin/write-jobs", response_model=List[RfidWriteJobResponse]) +async def list_write_jobs( + limit: int = Query(100, ge=1, le=500), + admin_user: User = Depends(get_admin_user), + db: Session = Depends(get_db), +): + return db.query(RfidCardWriteJob).order_by(RfidCardWriteJob.created_at.desc()).limit(limit).all() + + +@router.post("/admin/write-jobs", response_model=RfidWriteJobResponse, status_code=status.HTTP_201_CREATED) +async def queue_write_job( + job_data: RfidWriteJobCreate, + admin_user: User = Depends(get_admin_user), + db: Session = Depends(get_db), +): + reader = db.query(EspReader).filter(EspReader.id == job_data.reader_id).first() + if not reader: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Reader does not exist") + provisioning_status = _provisioning_status_value(reader.provisioning_status) + if not reader.is_active or provisioning_status not in [EspReaderProvisioningStatus.APPROVED.value, EspReaderProvisioningStatus.PROVISIONED.value]: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Reader is not active and provisioned") + if not reader.can_write_cards: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Reader is not enabled for card writing") + + user = db.query(User).filter(User.id == job_data.user_id).first() + if not user: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="User does not exist") + + job = RfidCardWriteJob( + reader_id=job_data.reader_id, + user_id=job_data.user_id, + label=job_data.label, + status=RfidWriteJobStatus.PENDING, + requested_by_user_id=admin_user.id, + ) + db.add(job) + db.commit() + db.refresh(job) + return job + + +@router.post("/admin/write-jobs/{job_id}/cancel", response_model=RfidWriteJobResponse) +async def cancel_write_job( + job_id: int, + admin_user: User = Depends(get_admin_user), + db: Session = Depends(get_db), +): + job = db.query(RfidCardWriteJob).filter(RfidCardWriteJob.id == job_id).first() + if not job: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Write job not found") + if job.status not in [RfidWriteJobStatus.PENDING, RfidWriteJobStatus.CLAIMED]: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Only pending or claimed jobs can be cancelled") + job.status = RfidWriteJobStatus.CANCELLED + job.updated_at = utc_now() + db.commit() + db.refresh(job) + return job + + +@router.get("/admin/cards", response_model=List[RfidCardResponse]) +async def list_cards( + include_inactive: bool = Query(True), + admin_user: User = Depends(get_admin_user), + db: Session = Depends(get_db), +): + query = db.query(RfidCard) + if not include_inactive: + query = query.filter(RfidCard.is_active == True) + return query.order_by(RfidCard.uid.asc()).all() + + +@router.post("/admin/cards", response_model=RfidCardResponse, status_code=status.HTTP_201_CREATED) +async def create_card( + card_data: RfidCardCreate, + admin_user: User = Depends(get_admin_user), + db: Session = Depends(get_db), +): + uid = _normalize_card_uid(card_data.uid) + existing = db.query(RfidCard).filter(RfidCard.uid == uid).first() + if existing: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="RFID card already exists") + + if card_data.user_id: + user = db.query(User).filter(User.id == card_data.user_id).first() + if not user: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="User does not exist") + + card = RfidCard( + uid=uid, + user_id=card_data.user_id, + label=card_data.label, + is_active=card_data.is_active, + ) + db.add(card) + db.commit() + db.refresh(card) + return card + + +@router.put("/admin/cards/{card_id}", response_model=RfidCardResponse) +async def update_card( + card_id: int, + card_data: RfidCardUpdate, + admin_user: User = Depends(get_admin_user), + db: Session = Depends(get_db), +): + card = db.query(RfidCard).filter(RfidCard.id == card_id).first() + if not card: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="RFID card not found") + + update_data = card_data.model_dump(exclude_unset=True) + if update_data.get("user_id"): + user = db.query(User).filter(User.id == update_data["user_id"]).first() + if not user: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="User does not exist") + + for field, value in update_data.items(): + setattr(card, field, value) + card.updated_at = utc_now() + db.commit() + db.refresh(card) + return card + + +@router.get("/admin/taps", response_model=List[RfidTapAdminResponse]) +async def list_taps( + limit: int = Query(100, ge=1, le=500), + reader_id: Optional[int] = None, + user_id: Optional[int] = None, + admin_user: User = Depends(get_admin_user), + db: Session = Depends(get_db), +): + query = db.query(RfidTap) + if reader_id: + query = query.filter(RfidTap.reader_id == reader_id) + if user_id: + query = query.filter(RfidTap.user_id == user_id) + return query.order_by(RfidTap.tapped_at.desc()).limit(limit).all() + + +@router.get("/admin/attendance", response_model=List[AttendanceSessionResponse]) +async def list_attendance_sessions( + open_only: bool = False, + limit: int = Query(100, ge=1, le=500), + admin_user: User = Depends(get_admin_user), + db: Session = Depends(get_db), +): + query = db.query(AttendanceSession) + if open_only: + query = query.filter(AttendanceSession.is_open == True) + return query.order_by(AttendanceSession.checked_in_at.desc()).limit(limit).all() + + +@router.post("/admin/attendance/close-stale", response_model=StaleSessionCloseResponse) +async def close_stale_sessions( + request: StaleSessionCloseRequest, + admin_user: User = Depends(get_admin_user), + db: Session = Depends(get_db), +): + closed_count = close_stale_attendance_sessions( + db, + cutoff_date=request.cutoff_date, + checkout_hour=request.checkout_hour, + ) + return {"closed_count": closed_count} + + +@router.delete("/admin/readers/{reader_id}", response_model=MessageResponse) +async def delete_reader( + reader_id: int, + admin_user: User = Depends(get_admin_user), + db: Session = Depends(get_db), +): + reader = db.query(EspReader).filter(EspReader.id == reader_id).first() + if not reader: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Reader not found") + + db.query(AttendanceSession).filter(AttendanceSession.reader_id == reader.id).delete(synchronize_session=False) + db.query(RfidTap).filter(RfidTap.reader_id == reader.id).delete(synchronize_session=False) + db.query(RfidCardWriteJob).filter(RfidCardWriteJob.reader_id == reader.id).delete(synchronize_session=False) + db.delete(reader) + db.commit() + return {"message": "Reader deleted"} diff --git a/backend/app/api/v1/events.py b/backend/app/api/v1/events.py index c0d07d8..5239db2 100644 --- a/backend/app/api/v1/events.py +++ b/backend/app/api/v1/events.py @@ -1,9 +1,9 @@ from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.orm import Session from typing import List -from datetime import datetime from ...core.database import get_db +from ...core.datetime import utc_now from ...models.models import Event, EventRSVP, User, EventStatus from ...schemas import ( EventCreate, EventUpdate, EventResponse, EventRSVPResponse, EventRSVPUpdate, MessageResponse @@ -13,6 +13,10 @@ from ...api.dependencies import get_current_active_user, get_admin_user router = APIRouter() +def _utc_time_string(value) -> str: + return value.strftime("%H:%M") + + @router.get("/", response_model=List[EventResponse]) async def get_events( current_user: User = Depends(get_current_active_user), @@ -34,9 +38,9 @@ async def get_upcoming_events( db: Session = Depends(get_db) ): """Get upcoming events""" - now = datetime.now() + now = utc_now() events = db.query(Event).filter( - Event.event_date >= now.date(), + Event.event_date >= now, Event.status == EventStatus.PUBLISHED ).order_by(Event.event_date).all() return events @@ -50,7 +54,7 @@ async def create_event( ): """Create a new event (admin only)""" # Validate event date is in the future - if event_data.event_date < datetime.now(): + if event_data.event_date < utc_now(): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Event date must be in the future" @@ -60,7 +64,7 @@ async def create_event( title=event_data.title, description=event_data.description, event_date=event_data.event_date, - event_time=event_data.event_time, + event_time=_utc_time_string(event_data.event_date), location=event_data.location, max_attendees=event_data.max_attendees, status=EventStatus.DRAFT, @@ -89,10 +93,14 @@ async def update_event( ) # Update fields - for field, value in event_data.dict(exclude_unset=True).items(): + update_data = event_data.model_dump(exclude_unset=True) + if "event_date" in update_data: + update_data["event_time"] = _utc_time_string(update_data["event_date"]) + + for field, value in update_data.items(): setattr(event, field, value) - event.updated_at = datetime.now() + event.updated_at = utc_now() db.commit() db.refresh(event) return event @@ -167,7 +175,7 @@ async def create_or_update_rsvp( existing_rsvp.status = rsvp_data.status if rsvp_data.notes is not None: existing_rsvp.notes = rsvp_data.notes - existing_rsvp.updated_at = datetime.now() + existing_rsvp.updated_at = utc_now() db.commit() db.refresh(existing_rsvp) return existing_rsvp @@ -204,4 +212,4 @@ async def get_my_rsvps( ): """Get current user's RSVPs""" rsvps = db.query(EventRSVP).filter(EventRSVP.user_id == current_user.id).all() - return rsvps \ No newline at end of file + return rsvps diff --git a/backend/app/api/v1/feature_flags.py b/backend/app/api/v1/feature_flags.py index 696481e..1809316 100644 --- a/backend/app/api/v1/feature_flags.py +++ b/backend/app/api/v1/feature_flags.py @@ -1,7 +1,7 @@ from fastapi import APIRouter, Depends -from typing import Dict, Any from app.services.feature_flag_service import feature_flags from app.schemas.feature_flags import FeatureFlagsResponse, FeatureFlagResponse +from app.api.dependencies import get_super_admin_user router = APIRouter() @@ -38,10 +38,11 @@ async def get_feature_flag(flag_name: str) -> FeatureFlagResponse: @router.post("/flags/reload") -async def reload_feature_flags(): +async def reload_feature_flags( + current_user = Depends(get_super_admin_user), +): """ - Reload feature flags from environment variables - This could be protected with admin permissions in production + Reload feature flags from environment variables. """ feature_flags.reload_flags() - return {"message": "Feature flags reloaded successfully"} \ No newline at end of file + return {"message": "Feature flags reloaded successfully"} diff --git a/backend/app/api/v1/payments.py b/backend/app/api/v1/payments.py index 64f779f..e62d31d 100644 --- a/backend/app/api/v1/payments.py +++ b/backend/app/api/v1/payments.py @@ -5,6 +5,7 @@ from datetime import datetime, timedelta from dateutil.relativedelta import relativedelta from ...core.database import get_db +from ...core.datetime import unix_ms_utc, utc_now from ...models.models import Payment, PaymentStatus, PaymentMethod, User, Membership, MembershipStatus, MembershipTier from ...schemas import ( PaymentCreate, PaymentUpdate, PaymentResponse, MessageResponse, @@ -121,7 +122,7 @@ async def update_payment( # If marking as completed, set payment_date if not already set if update_data.get("status") == PaymentStatus.COMPLETED and not payment.payment_date: - update_data["payment_date"] = datetime.utcnow() + update_data["payment_date"] = utc_now() for field, value in update_data.items(): setattr(payment, field, value) @@ -182,7 +183,7 @@ async def process_square_payment( ) # Create a reference ID for tracking - reference_id = f"user_{current_user.id}_tier_{tier.id}_{datetime.utcnow().timestamp()}" + reference_id = f"user_{current_user.id}_tier_{tier.id}_{unix_ms_utc(utc_now())}" # Process payment with Square square_result = await square_service.create_payment( @@ -204,7 +205,7 @@ async def process_square_payment( # Payment succeeded - create membership and payment records in a transaction try: # Calculate membership dates - start_date = datetime.utcnow().date() + start_date = utc_now().date() end_date = start_date + relativedelta(years=1) # Create membership with ACTIVE status @@ -226,7 +227,7 @@ async def process_square_payment( payment_method=PaymentMethod.SQUARE, status=PaymentStatus.COMPLETED, transaction_id=square_result.get('payment_id'), - payment_date=datetime.utcnow(), + payment_date=utc_now(), notes=payment_request.note ) db.add(payment) @@ -389,7 +390,7 @@ async def record_manual_payment( payment_method=payment_data.payment_method, notes=payment_data.notes, status=PaymentStatus.COMPLETED, - payment_date=datetime.utcnow() + payment_date=utc_now() ) db.add(payment) diff --git a/backend/app/api/v1/users.py b/backend/app/api/v1/users.py index 5626eea..f843026 100644 --- a/backend/app/api/v1/users.py +++ b/backend/app/api/v1/users.py @@ -1,16 +1,189 @@ +import json +from datetime import date, datetime, timedelta +from typing import Any, List, Optional +import uuid + from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.orm import Session -from typing import List from ...core.database import get_db -from ...core.security import get_password_hash -from ...models.models import User -from ...schemas import UserResponse, UserUpdate, MessageResponse +from ...core.datetime import utc_now +from ...models.models import ProfileQuestion, User, UserProfileAnswer, UserRole, PasswordResetToken +from ...schemas import ( + MessageResponse, + ProfileAnswersUpdateRequest, + ProfileQuestionCreate, + ProfileQuestionForUser, + ProfileQuestionResponse, + ProfileQuestionUpdate, + UserResponse, + UserUpdate, +) from ...api.dependencies import get_current_active_user, get_admin_user +from ...services.email_service import email_service router = APIRouter() +def _parse_options(options_json: Optional[str]) -> list[dict[str, str]]: + if not options_json: + return [] + try: + parsed = json.loads(options_json) + except (TypeError, json.JSONDecodeError): + return [] + + if not isinstance(parsed, list): + return [] + + normalized: list[dict[str, str]] = [] + for item in parsed: + if not isinstance(item, dict): + continue + label = str(item.get("label", "")).strip() + value = str(item.get("value", "")).strip() + if label and value: + normalized.append({"label": label, "value": value}) + return normalized + + +def _serialize_options(options: Optional[list[Any]]) -> Optional[str]: + if not options: + return None + normalized = [] + for item in options: + data = item.model_dump() if hasattr(item, "model_dump") else item + normalized.append({"label": str(data["label"]), "value": str(data["value"])}) + return json.dumps(normalized) + + +def _normalize_answer_value(question: ProfileQuestion, value: Any) -> Optional[str]: + if value is None: + return None + + if isinstance(value, str) and value.strip() == "": + return None + + input_type = question.input_type + + if input_type == "boolean": + if isinstance(value, bool): + return "true" if value else "false" + + text = str(value).strip().lower() + if text in {"true", "1", "yes", "y"}: + return "true" + if text in {"false", "0", "no", "n"}: + return "false" + + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid boolean answer for question '{question.key}'" + ) + + if input_type == "number": + try: + number = float(value) + return str(int(number)) if number.is_integer() else str(number) + except (TypeError, ValueError): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid number answer for question '{question.key}'" + ) + + if input_type == "date": + if isinstance(value, datetime): + return value.date().isoformat() + if isinstance(value, date): + return value.isoformat() + + text = str(value).strip() + try: + parsed = datetime.strptime(text, "%Y-%m-%d") + return parsed.date().isoformat() + except ValueError: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid date answer for question '{question.key}'. Use YYYY-MM-DD" + ) + + if input_type == "select": + selected = str(value).strip() + option_values = {opt["value"] for opt in _parse_options(question.options_json)} + if selected not in option_values: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid selection for question '{question.key}'" + ) + return selected + + return str(value).strip() + + +def _deserialize_answer_value(question: ProfileQuestion, value_text: Optional[str]) -> Any: + if value_text is None: + return None + + if question.input_type == "boolean": + return value_text.lower() == "true" + + if question.input_type == "number": + try: + number = float(value_text) + return int(number) if number.is_integer() else number + except ValueError: + return value_text + + return value_text + + +def _validate_question_dependencies( + db: Session, + depends_on_question_id: Optional[int], + depends_on_value: Optional[str], + current_question_id: Optional[int] = None, +) -> None: + if depends_on_question_id is None: + if depends_on_value is not None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="depends_on_value requires depends_on_question_id" + ) + return + + dependent_question = db.query(ProfileQuestion).filter(ProfileQuestion.id == depends_on_question_id).first() + if not dependent_question: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="depends_on_question_id does not exist" + ) + + if current_question_id is not None and current_question_id == depends_on_question_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="A question cannot depend on itself" + ) + + +def _normalize_volunteer_level(value: Optional[str]) -> Optional[str]: + if value is None: + return None + + normalized = str(value).strip().lower() + if normalized == "": + return None + + if normalized in {"yes", "true", "1"}: + return "yes" + if normalized in {"no", "false", "0"}: + return "no" + + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Volunteer flag must be yes or no" + ) + + @router.get("/me", response_model=UserResponse) async def get_current_user_profile( current_user: User = Depends(get_current_active_user) @@ -27,25 +200,123 @@ async def update_current_user_profile( ): """Update current user's profile""" update_data = user_update.model_dump(exclude_unset=True) - - # Check email uniqueness if email is being updated - if 'email' in update_data and update_data['email'] != current_user.email: - existing_user = db.query(User).filter(User.email == update_data['email']).first() + + # Prevent privilege and volunteer-level edits through self-service profile endpoint. + update_data.pop("role", None) + update_data.pop("volunteer_level", None) + + if "email" in update_data and update_data["email"] != current_user.email: + existing_user = db.query(User).filter(User.email == update_data["email"]).first() if existing_user: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Email already registered" ) - + for field, value in update_data.items(): setattr(current_user, field, value) - + db.commit() db.refresh(current_user) - + return current_user +@router.get("/me/profile-questions", response_model=List[ProfileQuestionForUser]) +async def list_my_profile_questions( + current_user: User = Depends(get_current_active_user), + db: Session = Depends(get_db) +): + questions = db.query(ProfileQuestion).filter(ProfileQuestion.is_active == True).order_by(ProfileQuestion.display_order.asc(), ProfileQuestion.id.asc()).all() + + answers = db.query(UserProfileAnswer).filter(UserProfileAnswer.user_id == current_user.id).all() + answers_by_question = {answer.question_id: answer for answer in answers} + + response: list[ProfileQuestionForUser] = [] + for question in questions: + user_answer = answers_by_question.get(question.id) + can_edit = (not question.admin_only_edit) or (current_user.role in [UserRole.ADMIN, UserRole.SUPER_ADMIN]) + + response.append( + ProfileQuestionForUser( + id=question.id, + key=question.key, + label=question.label, + help_text=question.help_text, + input_type=question.input_type, + placeholder=question.placeholder, + options=_parse_options(question.options_json), + is_required=question.is_required, + is_active=question.is_active, + admin_only_edit=question.admin_only_edit, + display_order=question.display_order, + depends_on_question_id=question.depends_on_question_id, + depends_on_value=question.depends_on_value, + created_at=question.created_at, + updated_at=question.updated_at, + answer=_deserialize_answer_value(question, user_answer.value_text if user_answer else None), + can_edit=can_edit, + ) + ) + + return response + + +@router.put("/me/profile-answers", response_model=MessageResponse) +async def update_my_profile_answers( + payload: ProfileAnswersUpdateRequest, + current_user: User = Depends(get_current_active_user), + db: Session = Depends(get_db) +): + if not payload.answers: + return {"message": "No changes submitted"} + + question_ids = {item.question_id for item in payload.answers} + questions = db.query(ProfileQuestion).filter(ProfileQuestion.id.in_(question_ids), ProfileQuestion.is_active == True).all() + questions_by_id = {question.id: question for question in questions} + + missing_ids = [q_id for q_id in question_ids if q_id not in questions_by_id] + if missing_ids: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Questions not found: {missing_ids}" + ) + + for item in payload.answers: + question = questions_by_id[item.question_id] + if question.admin_only_edit: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Question '{question.label}' can only be changed by admins" + ) + + normalized_value = _normalize_answer_value(question, item.value) + + answer = db.query(UserProfileAnswer).filter( + UserProfileAnswer.user_id == current_user.id, + UserProfileAnswer.question_id == question.id + ).first() + + if normalized_value is None: + if answer: + db.delete(answer) + continue + + if answer: + answer.value_text = normalized_value + answer.updated_by_user_id = current_user.id + else: + db.add(UserProfileAnswer( + user_id=current_user.id, + question_id=question.id, + value_text=normalized_value, + updated_by_user_id=current_user.id, + )) + + db.commit() + return {"message": "Profile answers updated successfully"} + + @router.get("/", response_model=List[UserResponse]) async def list_users( skip: int = 0, @@ -58,6 +329,281 @@ async def list_users( return users +@router.get("/admin/profile-questions", response_model=List[ProfileQuestionResponse]) +async def list_profile_questions_admin( + include_inactive: bool = True, + current_user: User = Depends(get_admin_user), + db: Session = Depends(get_db) +): + query = db.query(ProfileQuestion) + if not include_inactive: + query = query.filter(ProfileQuestion.is_active == True) + + questions = query.order_by(ProfileQuestion.display_order.asc(), ProfileQuestion.id.asc()).all() + + return [ + ProfileQuestionResponse( + id=question.id, + key=question.key, + label=question.label, + help_text=question.help_text, + input_type=question.input_type, + placeholder=question.placeholder, + options=_parse_options(question.options_json), + is_required=question.is_required, + is_active=question.is_active, + admin_only_edit=question.admin_only_edit, + display_order=question.display_order, + depends_on_question_id=question.depends_on_question_id, + depends_on_value=question.depends_on_value, + created_at=question.created_at, + updated_at=question.updated_at, + ) + for question in questions + ] + + +@router.post("/admin/profile-questions", response_model=ProfileQuestionResponse) +async def create_profile_question_admin( + payload: ProfileQuestionCreate, + current_user: User = Depends(get_admin_user), + db: Session = Depends(get_db) +): + if payload.input_type == "select" and not payload.options: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Select questions require options" + ) + + _validate_question_dependencies(db, payload.depends_on_question_id, payload.depends_on_value) + + existing = db.query(ProfileQuestion).filter(ProfileQuestion.key == payload.key).first() + if existing: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Question key already exists" + ) + + question = ProfileQuestion( + key=payload.key, + label=payload.label, + help_text=payload.help_text, + input_type=payload.input_type, + placeholder=payload.placeholder, + options_json=_serialize_options(payload.options), + is_required=payload.is_required, + is_active=payload.is_active, + admin_only_edit=payload.admin_only_edit, + display_order=payload.display_order, + depends_on_question_id=payload.depends_on_question_id, + depends_on_value=payload.depends_on_value, + ) + + db.add(question) + db.commit() + db.refresh(question) + + return ProfileQuestionResponse( + id=question.id, + key=question.key, + label=question.label, + help_text=question.help_text, + input_type=question.input_type, + placeholder=question.placeholder, + options=_parse_options(question.options_json), + is_required=question.is_required, + is_active=question.is_active, + admin_only_edit=question.admin_only_edit, + display_order=question.display_order, + depends_on_question_id=question.depends_on_question_id, + depends_on_value=question.depends_on_value, + created_at=question.created_at, + updated_at=question.updated_at, + ) + + +@router.put("/admin/profile-questions/{question_id}", response_model=ProfileQuestionResponse) +async def update_profile_question_admin( + question_id: int, + payload: ProfileQuestionUpdate, + current_user: User = Depends(get_admin_user), + db: Session = Depends(get_db) +): + question = db.query(ProfileQuestion).filter(ProfileQuestion.id == question_id).first() + if not question: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Question not found" + ) + + update_data = payload.model_dump(exclude_unset=True) + + if "key" in update_data and update_data["key"] != question.key: + existing = db.query(ProfileQuestion).filter(ProfileQuestion.key == update_data["key"]).first() + if existing: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Question key already exists" + ) + + input_type = update_data.get("input_type", question.input_type) + options = update_data.get("options") + options_to_validate = options if options is not None else _parse_options(question.options_json) + if input_type == "select" and not options_to_validate: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Select questions require options" + ) + + depends_on_question_id = update_data.get("depends_on_question_id", question.depends_on_question_id) + depends_on_value = update_data.get("depends_on_value", question.depends_on_value) + _validate_question_dependencies(db, depends_on_question_id, depends_on_value, current_question_id=question.id) + + for field, value in update_data.items(): + if field == "options": + question.options_json = _serialize_options(value) + else: + setattr(question, field, value) + + db.commit() + db.refresh(question) + + return ProfileQuestionResponse( + id=question.id, + key=question.key, + label=question.label, + help_text=question.help_text, + input_type=question.input_type, + placeholder=question.placeholder, + options=_parse_options(question.options_json), + is_required=question.is_required, + is_active=question.is_active, + admin_only_edit=question.admin_only_edit, + display_order=question.display_order, + depends_on_question_id=question.depends_on_question_id, + depends_on_value=question.depends_on_value, + created_at=question.created_at, + updated_at=question.updated_at, + ) + + +@router.delete("/admin/profile-questions/{question_id}", response_model=MessageResponse) +async def deactivate_profile_question_admin( + question_id: int, + current_user: User = Depends(get_admin_user), + db: Session = Depends(get_db) +): + question = db.query(ProfileQuestion).filter(ProfileQuestion.id == question_id).first() + if not question: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Question not found" + ) + + question.is_active = False + db.commit() + + return {"message": "Question deactivated successfully"} + + +@router.get("/admin/users/{user_id}/profile-answers", response_model=List[ProfileQuestionForUser]) +async def get_user_profile_answers_admin( + user_id: int, + current_user: User = Depends(get_admin_user), + db: Session = Depends(get_db) +): + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found" + ) + + questions = db.query(ProfileQuestion).order_by(ProfileQuestion.display_order.asc(), ProfileQuestion.id.asc()).all() + answers = db.query(UserProfileAnswer).filter(UserProfileAnswer.user_id == user_id).all() + answers_by_question = {answer.question_id: answer for answer in answers} + + return [ + ProfileQuestionForUser( + id=question.id, + key=question.key, + label=question.label, + help_text=question.help_text, + input_type=question.input_type, + placeholder=question.placeholder, + options=_parse_options(question.options_json), + is_required=question.is_required, + is_active=question.is_active, + admin_only_edit=question.admin_only_edit, + display_order=question.display_order, + depends_on_question_id=question.depends_on_question_id, + depends_on_value=question.depends_on_value, + created_at=question.created_at, + updated_at=question.updated_at, + answer=_deserialize_answer_value(question, answers_by_question.get(question.id).value_text if answers_by_question.get(question.id) else None), + can_edit=True, + ) + for question in questions + ] + + +@router.put("/admin/users/{user_id}/profile-answers", response_model=MessageResponse) +async def update_user_profile_answers_admin( + user_id: int, + payload: ProfileAnswersUpdateRequest, + current_user: User = Depends(get_admin_user), + db: Session = Depends(get_db) +): + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found" + ) + + if not payload.answers: + return {"message": "No changes submitted"} + + question_ids = {item.question_id for item in payload.answers} + questions = db.query(ProfileQuestion).filter(ProfileQuestion.id.in_(question_ids)).all() + questions_by_id = {question.id: question for question in questions} + + missing_ids = [q_id for q_id in question_ids if q_id not in questions_by_id] + if missing_ids: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Questions not found: {missing_ids}" + ) + + for item in payload.answers: + question = questions_by_id[item.question_id] + normalized_value = _normalize_answer_value(question, item.value) + + answer = db.query(UserProfileAnswer).filter( + UserProfileAnswer.user_id == user_id, + UserProfileAnswer.question_id == question.id + ).first() + + if normalized_value is None: + if answer: + db.delete(answer) + continue + + if answer: + answer.value_text = normalized_value + answer.updated_by_user_id = current_user.id + else: + db.add(UserProfileAnswer( + user_id=user_id, + question_id=question.id, + value_text=normalized_value, + updated_by_user_id=current_user.id, + )) + + db.commit() + return {"message": "User profile answers updated successfully"} + + @router.get("/{user_id}", response_model=UserResponse) async def get_user( user_id: int, @@ -88,18 +634,97 @@ async def update_user( status_code=status.HTTP_404_NOT_FOUND, detail="User not found" ) - + update_data = user_update.model_dump(exclude_unset=True) - + + if "email" in update_data and update_data["email"] != user.email: + existing_user = db.query(User).filter(User.email == update_data["email"]).first() + if existing_user: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Email already registered" + ) + + if "role" in update_data and update_data["role"] == UserRole.SUPER_ADMIN and current_user.role != UserRole.SUPER_ADMIN: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Only super admins can assign super admin role" + ) + + if "volunteer_level" in update_data: + update_data["volunteer_level"] = _normalize_volunteer_level(update_data["volunteer_level"]) + for field, value in update_data.items(): setattr(user, field, value) - + db.commit() db.refresh(user) - + return user +@router.post("/{user_id}/send-password-reset", response_model=MessageResponse) +async def send_user_password_reset( + user_id: int, + current_user: User = Depends(get_admin_user), + db: Session = Depends(get_db) +): + """Send a one-time password reset link email for a user.""" + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found" + ) + + if user.role in [UserRole.ADMIN, UserRole.SUPER_ADMIN] and current_user.role != UserRole.SUPER_ADMIN: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Only super admins can send password reset emails for admin users" + ) + + if not user.is_active: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Cannot reset password for inactive user" + ) + + db.query(PasswordResetToken).filter( + PasswordResetToken.user_id == user.id, + PasswordResetToken.used == False, + PasswordResetToken.expires_at > utc_now() + ).update({"used": True}) + + reset_token = str(uuid.uuid4()) + expires_at = utc_now() + timedelta(hours=1) + + db_token = PasswordResetToken( + user_id=user.id, + token=reset_token, + expires_at=expires_at, + used=False + ) + + db.add(db_token) + db.commit() + + try: + await email_service.send_password_reset_email( + to_email=user.email, + first_name=user.first_name, + reset_token=reset_token, + db=db + ) + except Exception as exc: + print(f"Failed to send admin password reset email: {exc}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to send reset email" + ) + + return {"message": "One-time password reset email sent successfully"} + + @router.delete("/{user_id}", response_model=MessageResponse) async def delete_user( user_id: int, @@ -113,8 +738,8 @@ async def delete_user( status_code=status.HTTP_404_NOT_FOUND, detail="User not found" ) - + db.delete(user) db.commit() - + return {"message": "User deleted successfully"} diff --git a/backend/app/core/config.py b/backend/app/core/config.py index c57c722..8eac399 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -1,6 +1,11 @@ -from pydantic_settings import BaseSettings +from pathlib import Path from typing import List -import os + +from pydantic_settings import BaseSettings, SettingsConfigDict + + +PROJECT_ROOT = Path(__file__).resolve().parents[3] +BACKEND_ROOT = Path(__file__).resolve().parents[2] class Settings(BaseSettings): @@ -47,9 +52,11 @@ class Settings(BaseSettings): UPLOAD_DIR: str = "/app/uploads" MAX_UPLOAD_SIZE: int = 10485760 # 10MB - class Config: - env_file = ".env" - case_sensitive = True + model_config = SettingsConfigDict( + env_file=(PROJECT_ROOT / ".env", BACKEND_ROOT / ".env", ".env"), + case_sensitive=True, + extra="ignore", + ) settings = Settings() diff --git a/backend/app/core/database.py b/backend/app/core/database.py index 2090286..4571f78 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -1,6 +1,5 @@ from sqlalchemy import create_engine -from sqlalchemy.ext.declarative import declarative_base -from sqlalchemy.orm import sessionmaker +from sqlalchemy.orm import declarative_base, sessionmaker from .config import settings engine = create_engine( diff --git a/backend/app/core/datetime.py b/backend/app/core/datetime.py new file mode 100644 index 0000000..ea15fa5 --- /dev/null +++ b/backend/app/core/datetime.py @@ -0,0 +1,30 @@ +from datetime import datetime, timezone + + +def utc_now() -> datetime: + """Naive UTC datetime for existing SQLAlchemy DateTime columns.""" + return datetime.now(timezone.utc).replace(tzinfo=None) + + +def to_utc_naive(value: datetime | None) -> datetime | None: + if value is None: + return None + if value.tzinfo is None: + return value + return value.astimezone(timezone.utc).replace(tzinfo=None) + + +def to_utc_aware(value: datetime | None) -> datetime | None: + if value is None: + return None + if value.tzinfo is None: + return value.replace(tzinfo=timezone.utc) + return value.astimezone(timezone.utc) + + +def to_zulu_iso(value: datetime) -> str: + return to_utc_aware(value).isoformat().replace("+00:00", "Z") + + +def unix_ms_utc(value: datetime) -> int: + return int(to_utc_aware(value).timestamp() * 1000) diff --git a/backend/app/core/init_db.py b/backend/app/core/init_db.py index ac42ebc..7c9d7d3 100644 --- a/backend/app/core/init_db.py +++ b/backend/app/core/init_db.py @@ -1,5 +1,7 @@ from sqlalchemy.orm import Session -from ..models.models import MembershipTier, User, UserRole, EmailTemplate +import json + +from ..models.models import MembershipTier, User, UserRole, EmailTemplate, ProfileQuestion from .security import get_password_hash from datetime import datetime @@ -70,3 +72,99 @@ def init_default_data(db: Session): db.add_all(default_templates) db.commit() print(f"✓ Created {len(default_templates)} default email templates") + + # Seed default profile questions for onboarding and profile attributes + existing_questions = db.query(ProfileQuestion).count() + if existing_questions == 0: + print("Creating default profile questions...") + default_questions = [ + ProfileQuestion( + key="has_professional_license", + label="Do you hold a professional aviation-related license?", + help_text="Select your current license status.", + input_type="select", + options_json=json.dumps([ + {"label": "No", "value": "none"}, + {"label": "Student", "value": "student"}, + {"label": "Private Pilot", "value": "ppl"}, + {"label": "Commercial Pilot", "value": "cpl"}, + {"label": "ATPL", "value": "atpl"}, + {"label": "Instructor", "value": "instructor"}, + ]), + is_required=False, + is_active=True, + admin_only_edit=False, + display_order=10, + ), + ProfileQuestion( + key="license_number", + label="License number", + help_text="Optional: your current license number.", + input_type="text", + placeholder="e.g. UK.FCL.123456", + is_required=False, + is_active=True, + admin_only_edit=False, + display_order=20, + depends_on_value="ppl", + ), + ProfileQuestion( + key="can_support_events", + label="Can you support airport or membership events?", + help_text="Choose yes if you're open to helping with events.", + input_type="boolean", + is_required=False, + is_active=True, + admin_only_edit=False, + display_order=30, + ), + ProfileQuestion( + key="event_support_notes", + label="What support can you offer?", + help_text="Examples: stewarding, admin desk, setup/packdown, mentoring.", + input_type="text", + placeholder="Type details here", + is_required=False, + is_active=True, + admin_only_edit=False, + display_order=40, + depends_on_value="true", + ), + ProfileQuestion( + key="hours_available_monthly", + label="Approximate volunteer hours available each month", + help_text="Optional estimate in hours.", + input_type="number", + is_required=False, + is_active=True, + admin_only_edit=False, + display_order=50, + ), + ProfileQuestion( + key="medical_expiry_date", + label="Medical certificate expiry date", + help_text="Optional date in YYYY-MM-DD format.", + input_type="date", + is_required=False, + is_active=True, + admin_only_edit=False, + display_order=60, + ), + ProfileQuestion( + key="completed_training_x", + label="Completed Training X", + help_text="This is set by admins once verified.", + input_type="boolean", + is_required=False, + is_active=True, + admin_only_edit=True, + display_order=70, + ), + ] + db.add_all(default_questions) + db.commit() + question_by_key = {question.key: question for question in db.query(ProfileQuestion).all()} + question_by_key["license_number"].depends_on_question_id = question_by_key["has_professional_license"].id + question_by_key["event_support_notes"].depends_on_question_id = question_by_key["can_support_events"].id + db.commit() + print(f"✓ Created {len(default_questions)} default profile questions") diff --git a/backend/app/core/security.py b/backend/app/core/security.py index 6b0f28a..79112a7 100644 --- a/backend/app/core/security.py +++ b/backend/app/core/security.py @@ -1,10 +1,14 @@ +import hashlib +import hmac from datetime import datetime, timedelta from typing import Optional, Union, Any from jose import JWTError, jwt from passlib.context import CryptContext from .config import settings +from .datetime import utc_now pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") +MACHINE_TOKEN_PREFIX = "sha256$" def create_access_token( @@ -12,9 +16,9 @@ def create_access_token( ) -> str: """Create JWT access token""" if expires_delta: - expire = datetime.utcnow() + expires_delta + expire = utc_now() + expires_delta else: - expire = datetime.utcnow() + timedelta( + expire = utc_now() + timedelta( minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES ) @@ -33,6 +37,26 @@ def get_password_hash(password: str) -> str: return pwd_context.hash(password) +def get_machine_token_hash(token: str) -> str: + """Hash a machine token for fast constant-time verification.""" + digest = hashlib.sha256(token.encode("utf-8")).hexdigest() + return f"{MACHINE_TOKEN_PREFIX}{digest}" + + +def verify_machine_token(token: str, stored_hash: str) -> bool: + """Verify a machine token, supporting legacy bcrypt hashes during migration.""" + if not stored_hash: + return False + if stored_hash.startswith(MACHINE_TOKEN_PREFIX): + expected_hash = get_machine_token_hash(token) + return hmac.compare_digest(expected_hash, stored_hash) + return verify_password(token, stored_hash) + + +def is_machine_token_hash(stored_hash: str | None) -> bool: + return bool(stored_hash and stored_hash.startswith(MACHINE_TOKEN_PREFIX)) + + def decode_token(token: str) -> Optional[str]: """Decode JWT token and return subject""" try: diff --git a/backend/app/main.py b/backend/app/main.py index 41f9de4..8cbb5df 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,13 +1,30 @@ +import asyncio +import time from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware +from fastapi import Request from contextlib import asynccontextmanager from .core.config import settings from .api.v1 import api_router -from .core.database import get_db +from .core.database import SessionLocal, get_db from .core.init_db import init_default_data +from .services.attendance_service import close_stale_attendance_sessions from sqlalchemy.orm import Session +async def close_stale_attendance_loop(): + """Periodically close forgotten RFID check-ins after midnight.""" + while True: + await asyncio.sleep(3600) + db = SessionLocal() + try: + close_stale_attendance_sessions(db) + except Exception as exc: + print(f"Failed to close stale attendance sessions: {exc}") + finally: + db.close() + + @asynccontextmanager async def lifespan(app: FastAPI): """Handle startup and shutdown events""" @@ -15,13 +32,20 @@ async def lifespan(app: FastAPI): db: Session = next(get_db()) try: init_default_data(db) + close_stale_attendance_sessions(db) finally: db.close() + + attendance_task = asyncio.create_task(close_stale_attendance_loop()) yield # Shutdown (if needed) - pass + attendance_task.cancel() + try: + await attendance_task + except asyncio.CancelledError: + pass app = FastAPI( @@ -40,6 +64,16 @@ app.add_middleware( allow_headers=["*"], ) + +@app.middleware("http") +async def add_request_timing_headers(request: Request, call_next): + started_at = time.perf_counter() + response = await call_next(request) + elapsed_ms = (time.perf_counter() - started_at) * 1000 + response.headers["X-Process-Time-Ms"] = f"{elapsed_ms:.1f}" + response.headers["Server-Timing"] = f"app;dur={elapsed_ms:.1f}" + return response + # Include API router app.include_router(api_router, prefix=settings.API_V1_PREFIX) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 8189865..f823eee 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -15,6 +15,8 @@ from .models import ( VolunteerRole, VolunteerAssignment, VolunteerSchedule, + ProfileQuestion, + UserProfileAnswer, Certificate, File, Notification, @@ -36,6 +38,8 @@ __all__ = [ "VolunteerRole", "VolunteerAssignment", "VolunteerSchedule", + "ProfileQuestion", + "UserProfileAnswer", "Certificate", "File", "Notification", diff --git a/backend/app/models/models.py b/backend/app/models/models.py index d5160ed..b0d1930 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -1,11 +1,11 @@ from sqlalchemy import ( Column, Integer, String, DateTime, Boolean, Enum as SQLEnum, - Float, Text, ForeignKey, Date + Float, Text, ForeignKey, Date, UniqueConstraint ) from sqlalchemy.orm import relationship -from datetime import datetime import enum from ..core.database import Base +from ..core.datetime import utc_now class UserRole(str, enum.Enum): @@ -49,6 +49,37 @@ class RSVPStatus(str, enum.Enum): MAYBE = "maybe" +class EspReaderType(str, enum.Enum): + CHECKIN_CHECKOUT = "checkin_checkout" + + +class EspReaderProvisioningStatus(str, enum.Enum): + PENDING = "pending" + APPROVED = "approved" + PROVISIONED = "provisioned" + REJECTED = "rejected" + + +class EspTapAction(str, enum.Enum): + CHECK_IN = "check_in" + CHECK_OUT = "check_out" + DENIED = "denied" + UNKNOWN = "unknown" + + +class AttendanceCheckoutSource(str, enum.Enum): + USER = "user" + SYSTEM = "system" + + +class RfidWriteJobStatus(str, enum.Enum): + PENDING = "pending" + CLAIMED = "claimed" + COMPLETED = "completed" + FAILED = "failed" + CANCELLED = "cancelled" + + class User(Base): __tablename__ = "users" @@ -60,9 +91,10 @@ class User(Base): phone = Column(String(20), nullable=True) address = Column(Text, nullable=True) role = Column(SQLEnum(UserRole, values_callable=lambda x: [e.value for e in x]), default=UserRole.MEMBER, nullable=False) + volunteer_level = Column(String(50), nullable=True) is_active = Column(Boolean, default=True, nullable=False) - created_at = Column(DateTime, default=datetime.utcnow, nullable=False) - updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + created_at = Column(DateTime, default=utc_now, nullable=False) + updated_at = Column(DateTime, default=utc_now, onupdate=utc_now, nullable=False) last_login = Column(DateTime, nullable=True) # Relationships @@ -71,6 +103,56 @@ class User(Base): event_rsvps = relationship("EventRSVP", back_populates="user", cascade="all, delete-orphan") volunteer_assignments = relationship("VolunteerAssignment", back_populates="user", cascade="all, delete-orphan") certificates = relationship("Certificate", back_populates="user", cascade="all, delete-orphan") + profile_answers = relationship( + "UserProfileAnswer", + back_populates="user", + cascade="all, delete-orphan", + foreign_keys="UserProfileAnswer.user_id" + ) + rfid_cards = relationship("RfidCard", back_populates="user") + attendance_sessions = relationship("AttendanceSession", back_populates="user") + + +class ProfileQuestion(Base): + __tablename__ = "profile_questions" + + id = Column(Integer, primary_key=True, index=True) + key = Column(String(100), unique=True, nullable=False, index=True) + label = Column(String(255), nullable=False) + help_text = Column(Text, nullable=True) + input_type = Column(String(30), nullable=False) # text, number, boolean, date, select + placeholder = Column(String(255), nullable=True) + options_json = Column(Text, nullable=True) # JSON array: [{"label":"Yes","value":"true"}] + is_required = Column(Boolean, default=False, nullable=False) + is_active = Column(Boolean, default=True, nullable=False) + admin_only_edit = Column(Boolean, default=False, nullable=False) + display_order = Column(Integer, default=0, nullable=False) + depends_on_question_id = Column(Integer, ForeignKey("profile_questions.id"), nullable=True) + depends_on_value = Column(String(255), nullable=True) + created_at = Column(DateTime, default=utc_now, nullable=False) + updated_at = Column(DateTime, default=utc_now, onupdate=utc_now, nullable=False) + + depends_on_question = relationship("ProfileQuestion", remote_side=[id], backref="dependent_questions") + answers = relationship("UserProfileAnswer", back_populates="question", cascade="all, delete-orphan") + + +class UserProfileAnswer(Base): + __tablename__ = "user_profile_answers" + __table_args__ = ( + UniqueConstraint("user_id", "question_id", name="uq_user_profile_answer"), + ) + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True) + question_id = Column(Integer, ForeignKey("profile_questions.id"), nullable=False, index=True) + value_text = Column(Text, nullable=True) + updated_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=True) + created_at = Column(DateTime, default=utc_now, nullable=False) + updated_at = Column(DateTime, default=utc_now, onupdate=utc_now, nullable=False) + + user = relationship("User", foreign_keys=[user_id], back_populates="profile_answers") + question = relationship("ProfileQuestion", back_populates="answers") + updated_by_user = relationship("User", foreign_keys=[updated_by_user_id]) class MembershipTier(Base): @@ -82,8 +164,8 @@ class MembershipTier(Base): annual_fee = Column(Float, nullable=False) benefits = Column(Text, nullable=True) is_active = Column(Boolean, default=True, nullable=False) - created_at = Column(DateTime, default=datetime.utcnow, nullable=False) - updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + created_at = Column(DateTime, default=utc_now, nullable=False) + updated_at = Column(DateTime, default=utc_now, onupdate=utc_now, nullable=False) # Relationships memberships = relationship("Membership", back_populates="tier") @@ -99,8 +181,8 @@ class Membership(Base): start_date = Column(Date, nullable=False) end_date = Column(Date, nullable=False) auto_renew = Column(Boolean, default=False, nullable=False) - created_at = Column(DateTime, default=datetime.utcnow, nullable=False) - updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + created_at = Column(DateTime, default=utc_now, nullable=False) + updated_at = Column(DateTime, default=utc_now, onupdate=utc_now, nullable=False) # Relationships user = relationship("User", back_populates="memberships") @@ -120,8 +202,8 @@ class Payment(Base): transaction_id = Column(String(255), nullable=True) payment_date = Column(DateTime, nullable=True) notes = Column(Text, nullable=True) - created_at = Column(DateTime, default=datetime.utcnow, nullable=False) - updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + created_at = Column(DateTime, default=utc_now, nullable=False) + updated_at = Column(DateTime, default=utc_now, onupdate=utc_now, nullable=False) # Relationships user = relationship("User", back_populates="payments") @@ -140,8 +222,8 @@ class Event(Base): max_attendees = Column(Integer, nullable=True) status = Column(SQLEnum(EventStatus, values_callable=lambda x: [e.value for e in x]), default=EventStatus.DRAFT, nullable=False) created_by = Column(Integer, ForeignKey("users.id"), nullable=False) - created_at = Column(DateTime, default=datetime.utcnow, nullable=False) - updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + created_at = Column(DateTime, default=utc_now, nullable=False) + updated_at = Column(DateTime, default=utc_now, onupdate=utc_now, nullable=False) # Relationships rsvps = relationship("EventRSVP", back_populates="event", cascade="all, delete-orphan") @@ -156,14 +238,123 @@ class EventRSVP(Base): status = Column(SQLEnum(RSVPStatus, values_callable=lambda x: [e.value for e in x]), default=RSVPStatus.PENDING, nullable=False) attended = Column(Boolean, default=False, nullable=False) notes = Column(Text, nullable=True) - created_at = Column(DateTime, default=datetime.utcnow, nullable=False) - updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + created_at = Column(DateTime, default=utc_now, nullable=False) + updated_at = Column(DateTime, default=utc_now, onupdate=utc_now, nullable=False) # Relationships event = relationship("Event", back_populates="rsvps") user = relationship("User", back_populates="event_rsvps") +class EspReader(Base): + __tablename__ = "esp_readers" + + id = Column(Integer, primary_key=True, index=True) + device_id = Column(String(100), unique=True, index=True, nullable=False) + name = Column(String(255), nullable=False) + location = Column(String(255), nullable=True) + reader_type = Column(SQLEnum(EspReaderType, values_callable=lambda x: [e.value for e in x]), default=EspReaderType.CHECKIN_CHECKOUT, nullable=False) + provisioning_status = Column(SQLEnum(EspReaderProvisioningStatus, values_callable=lambda x: [e.value for e in x]), default=EspReaderProvisioningStatus.PENDING, nullable=False) + api_key_hash = Column(String(255), nullable=True) + pending_api_key = Column(String(255), nullable=True) + registration_token_hash = Column(String(255), nullable=True) + is_active = Column(Boolean, default=True, nullable=False) + can_write_cards = Column(Boolean, default=False, nullable=False) + firmware_version = Column(String(100), nullable=True) + last_seen_at = Column(DateTime, nullable=True) + approved_at = Column(DateTime, nullable=True) + provisioned_at = Column(DateTime, nullable=True) + notes = Column(Text, nullable=True) + created_at = Column(DateTime, default=utc_now, nullable=False) + updated_at = Column(DateTime, default=utc_now, onupdate=utc_now, nullable=False) + + taps = relationship("RfidTap", back_populates="reader") + attendance_sessions = relationship("AttendanceSession", back_populates="reader") + write_jobs = relationship("RfidCardWriteJob", back_populates="reader") + + +class RfidCard(Base): + __tablename__ = "rfid_cards" + + id = Column(Integer, primary_key=True, index=True) + uid = Column(String(100), unique=True, index=True, nullable=False) + user_id = Column(Integer, ForeignKey("users.id"), nullable=True, index=True) + label = Column(String(255), nullable=True) + is_active = Column(Boolean, default=True, nullable=False) + created_at = Column(DateTime, default=utc_now, nullable=False) + updated_at = Column(DateTime, default=utc_now, onupdate=utc_now, nullable=False) + + user = relationship("User", back_populates="rfid_cards") + taps = relationship("RfidTap", back_populates="card") + + +class RfidCardWriteJob(Base): + __tablename__ = "rfid_card_write_jobs" + + id = Column(Integer, primary_key=True, index=True) + reader_id = Column(Integer, ForeignKey("esp_readers.id"), nullable=False, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True) + card_id = Column(Integer, ForeignKey("rfid_cards.id"), nullable=True, index=True) + label = Column(String(255), nullable=False) + status = Column(SQLEnum(RfidWriteJobStatus, values_callable=lambda x: [e.value for e in x]), default=RfidWriteJobStatus.PENDING, nullable=False, index=True) + requested_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + card_uid = Column(String(100), nullable=True, index=True) + write_payload = Column(Text, nullable=True) + claimed_at = Column(DateTime, nullable=True) + completed_at = Column(DateTime, nullable=True) + error_message = Column(String(500), nullable=True) + created_at = Column(DateTime, default=utc_now, nullable=False) + updated_at = Column(DateTime, default=utc_now, onupdate=utc_now, nullable=False) + + reader = relationship("EspReader", back_populates="write_jobs") + user = relationship("User", foreign_keys=[user_id]) + requested_by_user = relationship("User", foreign_keys=[requested_by_user_id]) + card = relationship("RfidCard") + + +class RfidTap(Base): + __tablename__ = "rfid_taps" + + id = Column(Integer, primary_key=True, index=True) + reader_id = Column(Integer, ForeignKey("esp_readers.id"), nullable=False, index=True) + card_id = Column(Integer, ForeignKey("rfid_cards.id"), nullable=True, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=True, index=True) + card_uid = Column(String(100), nullable=False, index=True) + action = Column(SQLEnum(EspTapAction, values_callable=lambda x: [e.value for e in x]), default=EspTapAction.UNKNOWN, nullable=False) + accepted = Column(Boolean, default=False, nullable=False) + message = Column(String(255), nullable=True) + raw_payload = Column(Text, nullable=True) + tapped_at = Column(DateTime, default=utc_now, nullable=False, index=True) + created_at = Column(DateTime, default=utc_now, nullable=False) + + reader = relationship("EspReader", back_populates="taps") + card = relationship("RfidCard", back_populates="taps") + user = relationship("User") + + +class AttendanceSession(Base): + __tablename__ = "attendance_sessions" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True) + reader_id = Column(Integer, ForeignKey("esp_readers.id"), nullable=False, index=True) + check_in_tap_id = Column(Integer, ForeignKey("rfid_taps.id"), nullable=False) + check_out_tap_id = Column(Integer, ForeignKey("rfid_taps.id"), nullable=True) + checked_in_at = Column(DateTime, nullable=False, index=True) + checked_out_at = Column(DateTime, nullable=True, index=True) + checkout_source = Column(SQLEnum(AttendanceCheckoutSource, values_callable=lambda x: [e.value for e in x]), nullable=True) + system_flag_reason = Column(String(255), nullable=True) + duration_seconds = Column(Integer, nullable=True) + is_open = Column(Boolean, default=True, nullable=False, index=True) + created_at = Column(DateTime, default=utc_now, nullable=False) + updated_at = Column(DateTime, default=utc_now, onupdate=utc_now, nullable=False) + + user = relationship("User", back_populates="attendance_sessions") + reader = relationship("EspReader", back_populates="attendance_sessions") + check_in_tap = relationship("RfidTap", foreign_keys=[check_in_tap_id]) + check_out_tap = relationship("RfidTap", foreign_keys=[check_out_tap_id]) + + class VolunteerRole(Base): __tablename__ = "volunteer_roles" @@ -171,8 +362,8 @@ class VolunteerRole(Base): name = Column(String(100), nullable=False) description = Column(Text, nullable=True) is_active = Column(Boolean, default=True, nullable=False) - created_at = Column(DateTime, default=datetime.utcnow, nullable=False) - updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + created_at = Column(DateTime, default=utc_now, nullable=False) + updated_at = Column(DateTime, default=utc_now, onupdate=utc_now, nullable=False) # Relationships assignments = relationship("VolunteerAssignment", back_populates="role", cascade="all, delete-orphan") @@ -187,8 +378,8 @@ class VolunteerAssignment(Base): assigned_date = Column(Date, nullable=False) is_active = Column(Boolean, default=True, nullable=False) notes = Column(Text, nullable=True) - created_at = Column(DateTime, default=datetime.utcnow, nullable=False) - updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + created_at = Column(DateTime, default=utc_now, nullable=False) + updated_at = Column(DateTime, default=utc_now, onupdate=utc_now, nullable=False) # Relationships user = relationship("User", back_populates="volunteer_assignments") @@ -207,8 +398,8 @@ class VolunteerSchedule(Base): location = Column(String(255), nullable=True) notes = Column(Text, nullable=True) completed = Column(Boolean, default=False, nullable=False) - created_at = Column(DateTime, default=datetime.utcnow, nullable=False) - updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + created_at = Column(DateTime, default=utc_now, nullable=False) + updated_at = Column(DateTime, default=utc_now, onupdate=utc_now, nullable=False) # Relationships assignment = relationship("VolunteerAssignment", back_populates="schedules") @@ -226,8 +417,8 @@ class Certificate(Base): certificate_number = Column(String(100), nullable=True) file_path = Column(String(500), nullable=True) notes = Column(Text, nullable=True) - created_at = Column(DateTime, default=datetime.utcnow, nullable=False) - updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + created_at = Column(DateTime, default=utc_now, nullable=False) + updated_at = Column(DateTime, default=utc_now, onupdate=utc_now, nullable=False) # Relationships user = relationship("User", back_populates="certificates") @@ -245,8 +436,8 @@ class File(Base): min_tier_id = Column(Integer, ForeignKey("membership_tiers.id"), nullable=True) description = Column(Text, nullable=True) uploaded_by = Column(Integer, ForeignKey("users.id"), nullable=False) - created_at = Column(DateTime, default=datetime.utcnow, nullable=False) - updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + created_at = Column(DateTime, default=utc_now, nullable=False) + updated_at = Column(DateTime, default=utc_now, onupdate=utc_now, nullable=False) class Notification(Base): @@ -259,7 +450,7 @@ class Notification(Base): email_sent = Column(Boolean, default=False, nullable=False) sent_at = Column(DateTime, nullable=True) error_message = Column(Text, nullable=True) - created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + created_at = Column(DateTime, default=utc_now, nullable=False) class PasswordResetToken(Base): @@ -270,7 +461,7 @@ class PasswordResetToken(Base): token = Column(String(255), unique=True, nullable=False, index=True) expires_at = Column(DateTime, nullable=False) used = Column(Boolean, default=False, nullable=False) - created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + created_at = Column(DateTime, default=utc_now, nullable=False) # Relationships user = relationship("User", backref="password_reset_tokens") @@ -287,8 +478,8 @@ class EmailTemplate(Base): text_body = Column(Text, nullable=True) variables = Column(Text, nullable=True) # JSON string of available variables is_active = Column(Boolean, default=True, nullable=False) - created_at = Column(DateTime, default=datetime.utcnow, nullable=False) - updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + created_at = Column(DateTime, default=utc_now, nullable=False) + updated_at = Column(DateTime, default=utc_now, onupdate=utc_now, nullable=False) class BounceType(str, enum.Enum): @@ -308,5 +499,5 @@ class EmailBounce(Base): smtp2go_message_id = Column(String(255), nullable=True, index=True) bounce_date = Column(DateTime, nullable=False) is_active = Column(Boolean, default=True, nullable=False) - created_at = Column(DateTime, default=datetime.utcnow, nullable=False) - updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + created_at = Column(DateTime, default=utc_now, nullable=False) + updated_at = Column(DateTime, default=utc_now, onupdate=utc_now, nullable=False) diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index 97b5489..eff8186 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -37,6 +37,36 @@ from .schemas import ( EventRSVPBase, EventRSVPUpdate, EventRSVPResponse, + QuestionOption, + ProfileQuestionCreate, + ProfileQuestionUpdate, + ProfileQuestionResponse, + ProfileQuestionForUser, + ProfileAnswerUpdate, + ProfileAnswersUpdateRequest, + EspReaderCreate, + EspReaderUpdate, + EspReaderResponse, + EspReaderCreateResponse, + EspReaderRegistrationRequest, + EspReaderRegistrationResponse, + EspReaderProvisioningResponse, + RfidCardCreate, + RfidCardUpdate, + RfidCardResponse, + RfidTapRequest, + RfidTapResponse, + RfidWriteJobCreate, + RfidWriteJobCompleteRequest, + RfidWriteJobResponse, + EspTimeResponse, + EspHeartbeatRequest, + EspHeartbeatResponse, + EspDashboardLoginResponse, + RfidTapAdminResponse, + AttendanceSessionResponse, + StaleSessionCloseRequest, + StaleSessionCloseResponse, ) __all__ = [ @@ -78,4 +108,34 @@ __all__ = [ "EventRSVPBase", "EventRSVPUpdate", "EventRSVPResponse", + "QuestionOption", + "ProfileQuestionCreate", + "ProfileQuestionUpdate", + "ProfileQuestionResponse", + "ProfileQuestionForUser", + "ProfileAnswerUpdate", + "ProfileAnswersUpdateRequest", + "EspReaderCreate", + "EspReaderUpdate", + "EspReaderResponse", + "EspReaderCreateResponse", + "EspReaderRegistrationRequest", + "EspReaderRegistrationResponse", + "EspReaderProvisioningResponse", + "RfidCardCreate", + "RfidCardUpdate", + "RfidCardResponse", + "RfidTapRequest", + "RfidTapResponse", + "RfidWriteJobCreate", + "RfidWriteJobCompleteRequest", + "RfidWriteJobResponse", + "EspTimeResponse", + "EspHeartbeatRequest", + "EspHeartbeatResponse", + "EspDashboardLoginResponse", + "RfidTapAdminResponse", + "AttendanceSessionResponse", + "StaleSessionCloseRequest", + "StaleSessionCloseResponse", ] diff --git a/backend/app/schemas/schemas.py b/backend/app/schemas/schemas.py index a90dba5..2eacb95 100644 --- a/backend/app/schemas/schemas.py +++ b/backend/app/schemas/schemas.py @@ -1,11 +1,43 @@ -from pydantic import BaseModel, EmailStr, Field, ConfigDict -from typing import Optional +from pydantic import BaseModel, EmailStr, Field, ConfigDict, field_serializer, field_validator +from typing import Optional, Literal, Any from datetime import datetime, date -from ..models.models import UserRole, MembershipStatus, PaymentStatus, PaymentMethod +from ..core.datetime import to_utc_naive, to_zulu_iso +from ..models.models import ( + UserRole, + MembershipStatus, + PaymentStatus, + PaymentMethod, + EspReaderProvisioningStatus, + EspReaderType, + EspTapAction, + RfidWriteJobStatus, +) + + +class UTCBaseModel(BaseModel): + @field_validator("*", mode="before", check_fields=False) + @classmethod + def normalize_datetime_inputs(cls, value: Any) -> Any: + if isinstance(value, datetime): + return to_utc_naive(value) + return value + + @field_validator("*", mode="after", check_fields=False) + @classmethod + def normalize_parsed_datetimes(cls, value: Any) -> Any: + if isinstance(value, datetime): + return to_utc_naive(value) + return value + + @field_serializer("*", when_used="json", check_fields=False) + def serialize_datetime_outputs(self, value: Any) -> Any: + if isinstance(value, datetime): + return to_zulu_iso(value) + return value # User Schemas -class UserBase(BaseModel): +class UserBase(UTCBaseModel): email: EmailStr first_name: str = Field(..., min_length=1, max_length=100) last_name: str = Field(..., min_length=1, max_length=100) @@ -17,13 +49,14 @@ class UserCreate(UserBase): password: str = Field(..., min_length=8) -class UserUpdate(BaseModel): +class UserUpdate(UTCBaseModel): email: Optional[EmailStr] = None first_name: Optional[str] = Field(None, min_length=1, max_length=100) last_name: Optional[str] = Field(None, min_length=1, max_length=100) phone: Optional[str] = None address: Optional[str] = None role: Optional[UserRole] = None + volunteer_level: Optional[str] = Field(None, max_length=50) class UserResponse(UserBase): @@ -31,6 +64,7 @@ class UserResponse(UserBase): id: int role: UserRole + volunteer_level: Optional[str] = None is_active: bool created_at: datetime last_login: Optional[datetime] = None @@ -41,37 +75,37 @@ class UserInDB(UserResponse): # Authentication Schemas -class Token(BaseModel): +class Token(UTCBaseModel): access_token: str token_type: str = "bearer" -class TokenData(BaseModel): +class TokenData(UTCBaseModel): user_id: Optional[int] = None -class LoginRequest(BaseModel): +class LoginRequest(UTCBaseModel): email: EmailStr password: str # Password Reset Schemas -class ForgotPasswordRequest(BaseModel): +class ForgotPasswordRequest(UTCBaseModel): email: EmailStr -class ResetPasswordRequest(BaseModel): +class ResetPasswordRequest(UTCBaseModel): token: str = Field(..., min_length=1) new_password: str = Field(..., min_length=8) -class ChangePasswordRequest(BaseModel): +class ChangePasswordRequest(UTCBaseModel): current_password: str = Field(..., min_length=1) new_password: str = Field(..., min_length=8) # Membership Tier Schemas -class MembershipTierBase(BaseModel): +class MembershipTierBase(UTCBaseModel): name: str = Field(..., min_length=1, max_length=100) description: Optional[str] = None annual_fee: float = Field(..., ge=0) @@ -82,7 +116,7 @@ class MembershipTierCreate(MembershipTierBase): pass -class MembershipTierUpdate(BaseModel): +class MembershipTierUpdate(UTCBaseModel): name: Optional[str] = Field(None, min_length=1, max_length=100) description: Optional[str] = None annual_fee: Optional[float] = Field(None, ge=0) @@ -99,7 +133,7 @@ class MembershipTierResponse(MembershipTierBase): # Membership Schemas -class MembershipBase(BaseModel): +class MembershipBase(UTCBaseModel): tier_id: int auto_renew: bool = False @@ -109,14 +143,14 @@ class MembershipCreate(MembershipBase): end_date: date -class MembershipUpdate(BaseModel): +class MembershipUpdate(UTCBaseModel): tier_id: Optional[int] = None status: Optional[MembershipStatus] = None end_date: Optional[date] = None auto_renew: Optional[bool] = None -class MembershipResponse(BaseModel): +class MembershipResponse(UTCBaseModel): model_config = ConfigDict(from_attributes=True) id: int @@ -131,7 +165,7 @@ class MembershipResponse(BaseModel): # Payment Schemas -class PaymentBase(BaseModel): +class PaymentBase(UTCBaseModel): amount: float = Field(..., gt=0) payment_method: PaymentMethod notes: Optional[str] = None @@ -141,14 +175,14 @@ class PaymentCreate(PaymentBase): membership_id: Optional[int] = None -class PaymentUpdate(BaseModel): +class PaymentUpdate(UTCBaseModel): status: Optional[PaymentStatus] = None transaction_id: Optional[str] = None payment_date: Optional[datetime] = None notes: Optional[str] = None -class PaymentResponse(BaseModel): +class PaymentResponse(UTCBaseModel): model_config = ConfigDict(from_attributes=True) id: int @@ -164,7 +198,7 @@ class PaymentResponse(BaseModel): # Square Payment Schemas -class SquarePaymentRequest(BaseModel): +class SquarePaymentRequest(UTCBaseModel): """Request schema for Square payment processing""" source_id: str = Field(..., description="Payment source ID from Square Web Payments SDK") tier_id: int = Field(..., description="Membership tier ID to create membership for") @@ -174,7 +208,7 @@ class SquarePaymentRequest(BaseModel): billing_details: Optional[dict] = Field(None, description="Billing address and cardholder name for AVS") -class SquarePaymentResponse(BaseModel): +class SquarePaymentResponse(UTCBaseModel): """Response schema for Square payment""" success: bool payment_id: Optional[str] = None @@ -187,7 +221,7 @@ class SquarePaymentResponse(BaseModel): membership_id: Optional[int] = Field(None, description="Created membership ID") -class SquareRefundRequest(BaseModel): +class SquareRefundRequest(UTCBaseModel): """Request schema for Square payment refund""" payment_id: int = Field(..., description="Database payment ID") amount: Optional[float] = Field(None, gt=0, description="Amount to refund (None for full refund)") @@ -195,13 +229,13 @@ class SquareRefundRequest(BaseModel): # Message Response -class MessageResponse(BaseModel): +class MessageResponse(UTCBaseModel): message: str detail: Optional[str] = None # Email Template Schemas -class EmailTemplateBase(BaseModel): +class EmailTemplateBase(UTCBaseModel): template_key: str name: str subject: str @@ -214,7 +248,7 @@ class EmailTemplateCreate(EmailTemplateBase): pass -class EmailTemplateUpdate(BaseModel): +class EmailTemplateUpdate(UTCBaseModel): name: Optional[str] = None subject: Optional[str] = None html_body: Optional[str] = None @@ -233,7 +267,7 @@ class EmailTemplateResponse(EmailTemplateBase): # Event Schemas -class EventBase(BaseModel): +class EventBase(UTCBaseModel): title: str = Field(..., min_length=1, max_length=255) description: Optional[str] = None event_date: datetime @@ -246,7 +280,7 @@ class EventCreate(EventBase): pass -class EventUpdate(BaseModel): +class EventUpdate(UTCBaseModel): title: Optional[str] = Field(None, min_length=1, max_length=255) description: Optional[str] = None event_date: Optional[datetime] = None @@ -267,7 +301,7 @@ class EventResponse(EventBase): # Event RSVP Schemas -class EventRSVPBase(BaseModel): +class EventRSVPBase(UTCBaseModel): status: str = Field(..., pattern="^(pending|attending|not_attending|maybe)$") notes: Optional[str] = None @@ -285,3 +319,298 @@ class EventRSVPResponse(EventRSVPBase): attended: bool created_at: datetime updated_at: datetime + + +# Profile Question Schemas +ProfileQuestionInputType = Literal["text", "number", "boolean", "date", "select"] + + +class QuestionOption(UTCBaseModel): + label: str = Field(..., min_length=1, max_length=100) + value: str = Field(..., min_length=1, max_length=100) + + +class ProfileQuestionBase(UTCBaseModel): + key: str = Field(..., min_length=2, max_length=100, pattern=r"^[a-z0-9_]+$") + label: str = Field(..., min_length=2, max_length=255) + help_text: Optional[str] = None + input_type: ProfileQuestionInputType + placeholder: Optional[str] = Field(None, max_length=255) + options: Optional[list[QuestionOption]] = None + is_required: bool = False + is_active: bool = True + admin_only_edit: bool = False + display_order: int = 0 + depends_on_question_id: Optional[int] = None + depends_on_value: Optional[str] = Field(None, max_length=255) + + +class ProfileQuestionCreate(ProfileQuestionBase): + pass + + +class ProfileQuestionUpdate(UTCBaseModel): + key: Optional[str] = Field(None, min_length=2, max_length=100, pattern=r"^[a-z0-9_]+$") + label: Optional[str] = Field(None, min_length=2, max_length=255) + help_text: Optional[str] = None + input_type: Optional[ProfileQuestionInputType] = None + placeholder: Optional[str] = Field(None, max_length=255) + options: Optional[list[QuestionOption]] = None + is_required: Optional[bool] = None + is_active: Optional[bool] = None + admin_only_edit: Optional[bool] = None + display_order: Optional[int] = None + depends_on_question_id: Optional[int] = None + depends_on_value: Optional[str] = Field(None, max_length=255) + + +class ProfileQuestionResponse(UTCBaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + key: str + label: str + help_text: Optional[str] = None + input_type: ProfileQuestionInputType + placeholder: Optional[str] = None + options: list[QuestionOption] = [] + is_required: bool + is_active: bool + admin_only_edit: bool + display_order: int + depends_on_question_id: Optional[int] = None + depends_on_value: Optional[str] = None + created_at: datetime + updated_at: datetime + + +class ProfileQuestionForUser(ProfileQuestionResponse): + answer: Optional[Any] = None + can_edit: bool = True + + +class ProfileAnswerUpdate(UTCBaseModel): + question_id: int + value: Optional[Any] = None + + +class ProfileAnswersUpdateRequest(UTCBaseModel): + answers: list[ProfileAnswerUpdate] + + +# ESP RFID Reader Schemas +class EspReaderBase(UTCBaseModel): + device_id: str = Field(..., min_length=2, max_length=100, pattern=r"^[A-Za-z0-9_.:-]+$") + name: str = Field(..., min_length=1, max_length=255) + location: Optional[str] = Field(None, max_length=255) + reader_type: EspReaderType = EspReaderType.CHECKIN_CHECKOUT + notes: Optional[str] = None + is_active: bool = True + can_write_cards: bool = False + firmware_version: Optional[str] = Field(None, max_length=100) + + +class EspReaderCreate(EspReaderBase): + api_key: Optional[str] = Field(None, min_length=16, max_length=255) + + +class EspReaderUpdate(UTCBaseModel): + name: Optional[str] = Field(None, min_length=1, max_length=255) + location: Optional[str] = Field(None, max_length=255) + reader_type: Optional[EspReaderType] = None + notes: Optional[str] = None + is_active: Optional[bool] = None + can_write_cards: Optional[bool] = None + rotate_api_key: bool = False + + +class EspReaderResponse(EspReaderBase): + model_config = ConfigDict(from_attributes=True) + + id: int + provisioning_status: EspReaderProvisioningStatus + last_seen_at: Optional[datetime] = None + approved_at: Optional[datetime] = None + provisioned_at: Optional[datetime] = None + created_at: datetime + updated_at: datetime + + +class EspReaderCreateResponse(EspReaderResponse): + api_key: str + + +class EspReaderRegistrationRequest(UTCBaseModel): + device_id: str = Field(..., min_length=2, max_length=100, pattern=r"^[A-Za-z0-9_.:-]+$") + name: str = Field(..., min_length=1, max_length=255) + location: Optional[str] = Field(None, max_length=255) + reader_type: EspReaderType = EspReaderType.CHECKIN_CHECKOUT + can_write_cards: bool = False + firmware_version: Optional[str] = Field(None, max_length=100) + notes: Optional[str] = None + + +class EspReaderRegistrationResponse(UTCBaseModel): + device_id: str + provisioning_status: EspReaderProvisioningStatus + registration_token: str + message: str + poll_interval_seconds: int = 5 + + +class EspReaderProvisioningResponse(UTCBaseModel): + device_id: str + provisioning_status: EspReaderProvisioningStatus + message: str + api_key: Optional[str] = None + apiKey: Optional[str] = None + poll_interval_seconds: int = 5 + + +class RfidCardBase(UTCBaseModel): + uid: str = Field(..., min_length=2, max_length=100) + user_id: Optional[int] = None + label: Optional[str] = Field(None, max_length=255) + is_active: bool = True + + +class RfidCardCreate(RfidCardBase): + pass + + +class RfidCardUpdate(UTCBaseModel): + user_id: Optional[int] = None + label: Optional[str] = Field(None, max_length=255) + is_active: Optional[bool] = None + + +class RfidCardResponse(RfidCardBase): + model_config = ConfigDict(from_attributes=True) + + id: int + created_at: datetime + updated_at: datetime + + +class RfidTapRequest(UTCBaseModel): + card_uid: str = Field(..., min_length=2, max_length=100) + tapped_at: Optional[datetime] = None + reader_type: Optional[EspReaderType] = None + + +class RfidTapResponse(UTCBaseModel): + accepted: bool + action: EspTapAction + message: str + server_time_utc: datetime + tap_id: int + session_id: Optional[int] = None + user_id: Optional[int] = None + user_name: Optional[str] = None + checked_in_at: Optional[datetime] = None + checked_out_at: Optional[datetime] = None + duration_seconds: Optional[int] = None + + +class RfidWriteJobCreate(UTCBaseModel): + reader_id: int + user_id: int + label: str = Field(..., min_length=1, max_length=255) + + +class RfidWriteJobCompleteRequest(UTCBaseModel): + card_uid: Optional[str] = Field(None, min_length=2, max_length=100) + success: bool + error_message: Optional[str] = Field(None, max_length=500) + + +class RfidWriteJobResponse(UTCBaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + reader_id: int + user_id: int + card_id: Optional[int] = None + label: str + status: RfidWriteJobStatus + requested_by_user_id: int + card_uid: Optional[str] = None + write_payload: Optional[str] = None + claimed_at: Optional[datetime] = None + completed_at: Optional[datetime] = None + error_message: Optional[str] = None + created_at: datetime + updated_at: datetime + + +class EspTimeResponse(UTCBaseModel): + server_time_utc: datetime + unix_ms: int + poll_interval_seconds: int = 3 + + +class EspHeartbeatRequest(UTCBaseModel): + mode: str = Field(..., max_length=50) + message: Optional[str] = Field(None, max_length=255) + wifi_rssi: Optional[int] = None + free_heap: Optional[int] = None + firmware_version: Optional[str] = Field(None, max_length=100) + active_write_job_id: Optional[int] = None + + +class EspHeartbeatResponse(UTCBaseModel): + ok: bool + server_time_utc: datetime + unix_ms: int + heartbeat_interval_seconds: int = 10 + time_poll_interval_seconds: int = 3 + write_job_poll_interval_seconds: int = 3 + + +class EspDashboardLoginResponse(UTCBaseModel): + valid: bool + user_id: Optional[int] = None + role: Optional[UserRole] = None + user_name: Optional[str] = None + + +class RfidTapAdminResponse(UTCBaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + reader_id: int + card_id: Optional[int] = None + user_id: Optional[int] = None + card_uid: str + action: EspTapAction + accepted: bool + message: Optional[str] = None + tapped_at: datetime + created_at: datetime + + +class AttendanceSessionResponse(UTCBaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + user_id: int + reader_id: int + check_in_tap_id: int + check_out_tap_id: Optional[int] = None + checked_in_at: datetime + checked_out_at: Optional[datetime] = None + checkout_source: Optional[str] = None + system_flag_reason: Optional[str] = None + duration_seconds: Optional[int] = None + is_open: bool + created_at: datetime + updated_at: datetime + + +class StaleSessionCloseRequest(UTCBaseModel): + cutoff_date: Optional[date] = None + checkout_hour: int = Field(17, ge=0, le=23) + + +class StaleSessionCloseResponse(UTCBaseModel): + closed_count: int diff --git a/backend/app/services/attendance_service.py b/backend/app/services/attendance_service.py new file mode 100644 index 0000000..29cd748 --- /dev/null +++ b/backend/app/services/attendance_service.py @@ -0,0 +1,42 @@ +from datetime import date, datetime, time, timedelta + +from sqlalchemy.orm import Session + +from ..core.datetime import utc_now +from ..models.models import AttendanceCheckoutSource, AttendanceSession + + +def duration_seconds(start: datetime, end: datetime) -> int: + return max(0, int((end - start).total_seconds())) + + +def close_stale_attendance_sessions( + db: Session, + cutoff_date: date | None = None, + checkout_hour: int = 17, +) -> int: + cutoff = cutoff_date or date.today() + cutoff_at = datetime.combine(cutoff, time.min) + sessions = ( + db.query(AttendanceSession) + .filter( + AttendanceSession.is_open == True, + AttendanceSession.checked_in_at < cutoff_at, + ) + .all() + ) + + now = utc_now() + for session in sessions: + checkout_at = datetime.combine(session.checked_in_at.date(), time(hour=checkout_hour)) + if checkout_at < session.checked_in_at: + checkout_at = session.checked_in_at + timedelta(minutes=1) + session.checked_out_at = checkout_at + session.checkout_source = AttendanceCheckoutSource.SYSTEM + session.system_flag_reason = "User did not check out before midnight; checkout time was system-set." + session.duration_seconds = duration_seconds(session.checked_in_at, checkout_at) + session.is_open = False + session.updated_at = now + + db.commit() + return len(sessions) diff --git a/backend/app/services/bounce_service.py b/backend/app/services/bounce_service.py index f63dba5..1b90029 100644 --- a/backend/app/services/bounce_service.py +++ b/backend/app/services/bounce_service.py @@ -1,6 +1,7 @@ from typing import List, Optional, Dict, Any -from datetime import datetime +from datetime import datetime, timezone from sqlalchemy.orm import Session +from ..core.datetime import to_utc_naive, utc_now from ..models.models import EmailBounce, BounceType from ..core.database import get_db @@ -38,7 +39,9 @@ class BounceService: db = next(get_db()) if bounce_date is None: - bounce_date = datetime.utcnow() + bounce_date = utc_now() + else: + bounce_date = to_utc_naive(bounce_date) # Check if bounce already exists for this email and type existing_bounce = db.query(EmailBounce).filter( @@ -54,7 +57,7 @@ class BounceService: if smtp2go_message_id: existing_bounce.smtp2go_message_id = smtp2go_message_id existing_bounce.bounce_date = bounce_date - existing_bounce.updated_at = datetime.utcnow() + existing_bounce.updated_at = utc_now() db.commit() db.refresh(existing_bounce) return existing_bounce @@ -130,7 +133,7 @@ class BounceService: bounce = db.query(EmailBounce).filter(EmailBounce.id == bounce_id).first() if bounce: bounce.is_active = False - bounce.updated_at = datetime.utcnow() + bounce.updated_at = utc_now() db.commit() return True return False @@ -189,9 +192,10 @@ class BounceService: try: # SMTP2GO timestamps are typically Unix timestamps if isinstance(timestamp, (int, float)): - bounce_date = datetime.fromtimestamp(timestamp) + bounce_date = datetime.fromtimestamp(timestamp, tz=timezone.utc) elif isinstance(timestamp, str): bounce_date = datetime.fromisoformat(timestamp.replace('Z', '+00:00')) + bounce_date = to_utc_naive(bounce_date) except (ValueError, TypeError): pass @@ -252,18 +256,18 @@ class BounceService: db = next(get_db()) from datetime import timedelta - cutoff_date = datetime.utcnow() - timedelta(days=days_old) + cutoff_date = utc_now() - timedelta(days=days_old) # Only deactivate soft bounces, keep hard bounces and complaints active result = db.query(EmailBounce).filter( EmailBounce.bounce_type == BounceType.SOFT, EmailBounce.is_active == True, EmailBounce.bounce_date < cutoff_date - ).update({'is_active': False, 'updated_at': datetime.utcnow()}) + ).update({'is_active': False, 'updated_at': utc_now()}) db.commit() return result # Create a singleton instance -bounce_service = BounceService() \ No newline at end of file +bounce_service = BounceService() diff --git a/backend/app/services/email_service.py b/backend/app/services/email_service.py index 1907492..776e04a 100644 --- a/backend/app/services/email_service.py +++ b/backend/app/services/email_service.py @@ -2,6 +2,7 @@ import httpx from typing import List, Optional, Dict, Any from datetime import datetime from ..core.database import get_db +from ..core.datetime import utc_now from ..models.models import EmailTemplate from sqlalchemy.orm import Session from ..core.config import settings @@ -147,7 +148,7 @@ class EmailService: "payment_amount": f"£{payment_amount:.2f}", "payment_method": payment_method, "renewal_date": renewal_date, - "payment_date": datetime.now().strftime("%d %B %Y"), + "payment_date": utc_now().strftime("%d %B %Y"), "app_name": settings.APP_NAME } return await self.send_templated_email("membership_activation", to_email, variables, db) diff --git a/backend/app/tests/conftest.py b/backend/app/tests/conftest.py new file mode 100644 index 0000000..bfea3d2 --- /dev/null +++ b/backend/app/tests/conftest.py @@ -0,0 +1,7 @@ +import sys +from pathlib import Path + +APP_ROOT = Path(__file__).resolve().parents[2] + +if str(APP_ROOT) not in sys.path: + sys.path.insert(0, str(APP_ROOT)) diff --git a/backend/app/tests/test_datetime_utc.py b/backend/app/tests/test_datetime_utc.py new file mode 100644 index 0000000..d80eed3 --- /dev/null +++ b/backend/app/tests/test_datetime_utc.py @@ -0,0 +1,47 @@ +from datetime import datetime + +from app.core.datetime import unix_ms_utc, utc_now +from app.schemas import EventCreate, EventResponse, EspTimeResponse + + +def test_event_input_datetime_is_normalized_to_utc_naive() -> None: + event = EventCreate( + title="Evening briefing", + event_date="2026-06-01T19:30:00+01:00", + event_time=None, + ) + + assert event.event_date == datetime(2026, 6, 1, 18, 30) + assert event.event_date.tzinfo is None + + +def test_response_datetimes_serialize_as_zulu() -> None: + event = EventResponse( + id=1, + title="Evening briefing", + description=None, + event_date=datetime(2026, 6, 1, 18, 30), + event_time=None, + location=None, + max_attendees=None, + status="draft", + created_by=1, + created_at=datetime(2026, 5, 1, 10, 0), + updated_at=datetime(2026, 5, 1, 10, 0), + ) + + payload = event.model_dump_json() + + assert '"event_date":"2026-06-01T18:30:00Z"' in payload + assert '"created_at":"2026-05-01T10:00:00Z"' in payload + + +def test_esp_time_uses_same_utc_instant_for_iso_and_unix_ms() -> None: + now = utc_now() + response = EspTimeResponse( + server_time_utc=now, + unix_ms=unix_ms_utc(now), + ) + + assert '"server_time_utc":"' in response.model_dump_json() + assert response.unix_ms == unix_ms_utc(now) diff --git a/backend/app/tests/test_esp_provisioning.py b/backend/app/tests/test_esp_provisioning.py new file mode 100644 index 0000000..b01f90e --- /dev/null +++ b/backend/app/tests/test_esp_provisioning.py @@ -0,0 +1,186 @@ +import asyncio +import json +from types import SimpleNamespace + +from app.api.v1 import esp +from app.core.security import get_machine_token_hash, get_password_hash, verify_machine_token +from app.models.models import EspReaderProvisioningStatus +from app.schemas import EspReaderRegistrationRequest + + +class _FakeDb: + def __init__(self, reader=None) -> None: + self.reader = reader + + class _Query: + def __init__(self, reader) -> None: + self.reader = reader + + def filter(self, *args, **kwargs): + return self + + def first(self): + return self.reader + + def commit(self) -> None: + return None + + def add(self, _obj) -> None: + return None + + def refresh(self, _obj) -> None: + return None + + def query(self, _model): + return self._Query(self.reader) + + +def test_provisioning_status_returns_api_key_for_enum_status(monkeypatch) -> None: + reader = SimpleNamespace( + device_id="esp32-123456", + provisioning_status=EspReaderProvisioningStatus.APPROVED, + pending_api_key=None, + api_key_hash=None, + provisioned_at=None, + updated_at=None, + ) + + monkeypatch.setattr(esp, "_get_reader_by_registration_token", lambda *args, **kwargs: reader) + monkeypatch.setattr(esp, "_new_api_key", lambda: "generated-api-key") + + response = asyncio.run( + esp.get_provisioning_status( + x_esp_device_id="esp32-123456", + x_esp_registration_token="token", + db=_FakeDb(), + ) + ) + payload = json.loads(response.body) + + assert payload["provisioning_status"] == EspReaderProvisioningStatus.PROVISIONED.value + assert payload["api_key"] == "generated-api-key" + assert payload["apiKey"] == "generated-api-key" + + +def test_register_reader_allows_recovery_before_first_authenticated_call(monkeypatch) -> None: + reader = SimpleNamespace( + device_id="esp32-123456", + name="Old Reader", + location="Old Location", + reader_type="checkin_checkout", + can_write_cards=False, + firmware_version="old-fw", + notes="old", + registration_token_hash="old-hash", + provisioning_status=EspReaderProvisioningStatus.PROVISIONED, + is_active=True, + pending_api_key="pending-api-key", + last_seen_at=None, + updated_at=None, + ) + db = _FakeDb(reader) + + monkeypatch.setattr(esp, "_new_registration_token", lambda: "replacement-token") + monkeypatch.setattr(esp, "get_machine_token_hash", lambda value: f"hashed:{value}") + + response = asyncio.run( + esp.register_reader( + EspReaderRegistrationRequest( + device_id="esp32-123456", + name="Recovered Reader", + location="Front Desk", + reader_type="checkin_checkout", + can_write_cards=True, + firmware_version="new-fw", + notes="recovered", + ), + db=db, + ) + ) + + assert response["provisioning_status"] == EspReaderProvisioningStatus.APPROVED + assert response["registration_token"] == "replacement-token" + assert response["message"] == "Reader recovery accepted. Poll provisioning to receive the API key again." + assert reader.registration_token_hash == "hashed:replacement-token" + assert reader.pending_api_key == "pending-api-key" + assert reader.provisioning_status == EspReaderProvisioningStatus.APPROVED + + +def test_machine_token_hash_round_trip() -> None: + token = "esp-device-token" + stored_hash = get_machine_token_hash(token) + + assert verify_machine_token(token, stored_hash) is True + assert verify_machine_token("wrong-token", stored_hash) is False + + +def test_machine_token_verify_supports_legacy_bcrypt_hash() -> None: + token = "legacy-esp-token" + stored_hash = get_password_hash(token) + + assert verify_machine_token(token, stored_hash) is True + assert verify_machine_token("wrong-token", stored_hash) is False + + +def test_get_current_reader_migrates_legacy_bcrypt_api_key() -> None: + api_key = "legacy-api-key" + reader = SimpleNamespace( + device_id="esp32-123456", + provisioning_status=EspReaderProvisioningStatus.PROVISIONED, + is_active=True, + api_key_hash=get_password_hash(api_key), + pending_api_key=None, + last_seen_at=None, + ) + db = _FakeDb(reader) + + response_reader = asyncio.run( + esp.get_current_reader( + x_esp_device_id="esp32-123456", + x_esp_api_key=api_key, + db=db, + ) + ) + + assert response_reader is reader + assert reader.api_key_hash == get_machine_token_hash(api_key) + + +def test_compact_tap_response_uses_short_keys() -> None: + tap = SimpleNamespace( + accepted=True, + action=SimpleNamespace(value="check_in"), + message="Checked in", + ) + + response = esp._compact_tap_response(tap) + payload = json.loads(response.body) + + assert payload == {"ok": True, "a": "check_in", "m": "Checked in"} + + +def test_provisioning_status_returns_api_key_for_string_status(monkeypatch) -> None: + reader = SimpleNamespace( + device_id="esp32-123456", + provisioning_status="approved", + pending_api_key=None, + api_key_hash=None, + provisioned_at=None, + updated_at=None, + ) + + monkeypatch.setattr(esp, "_get_reader_by_registration_token", lambda *args, **kwargs: reader) + monkeypatch.setattr(esp, "_new_api_key", lambda: "generated-api-key") + + response = asyncio.run( + esp.get_provisioning_status( + x_esp_device_id="esp32-123456", + x_esp_registration_token="token", + db=_FakeDb(), + ) + ) + payload = json.loads(response.body) + + assert payload["provisioning_status"] == EspReaderProvisioningStatus.PROVISIONED.value + assert payload["api_key"] == "generated-api-key" + assert payload["apiKey"] == "generated-api-key" diff --git a/backend/app/tests/test_profile_question_logic.py b/backend/app/tests/test_profile_question_logic.py new file mode 100644 index 0000000..0649000 --- /dev/null +++ b/backend/app/tests/test_profile_question_logic.py @@ -0,0 +1,113 @@ +from datetime import date, datetime + +import pytest +from fastapi import HTTPException + +from app.api.v1.users import ( + _deserialize_answer_value, + _normalize_answer_value, + _normalize_volunteer_level, + _parse_options, + _serialize_options, +) +from app.models.models import ProfileQuestion +from app.schemas import QuestionOption + + +def make_question(input_type: str, options_json: str | None = None) -> ProfileQuestion: + return ProfileQuestion( + key=f"{input_type}_question", + label=f"{input_type.title()} Question", + input_type=input_type, + options_json=options_json, + ) + + +def test_option_parsing_and_serialization_filters_invalid_items() -> None: + assert _parse_options('[{"label":" Yes ","value":" yes "}, {"label":"","value":"no"}, "bad"]') == [ + {"label": "Yes", "value": "yes"} + ] + assert _parse_options("not-json") == [] + + serialized = _serialize_options([QuestionOption(label="Private Pilot", value="ppl")]) + + assert _parse_options(serialized) == [{"label": "Private Pilot", "value": "ppl"}] + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + (True, "true"), + ("yes", "true"), + ("0", "false"), + (False, "false"), + ], +) +def test_boolean_answers_are_normalized(value: object, expected: str) -> None: + assert _normalize_answer_value(make_question("boolean"), value) == expected + + +def test_invalid_boolean_answer_raises_400() -> None: + with pytest.raises(HTTPException) as exc: + _normalize_answer_value(make_question("boolean"), "maybe") + + assert exc.value.status_code == 400 + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + (3, "3"), + ("3.50", "3.5"), + (date(2026, 5, 4), "2026-05-04"), + (datetime(2026, 5, 4, 12, 30), "2026-05-04"), + ], +) +def test_number_and_date_answers_are_normalized(value: object, expected: str) -> None: + input_type = "date" if isinstance(value, (date, datetime)) else "number" + + assert _normalize_answer_value(make_question(input_type), value) == expected + + +def test_select_answers_must_match_configured_options() -> None: + question = make_question("select", '[{"label":"Private Pilot","value":"ppl"}]') + + assert _normalize_answer_value(question, "ppl") == "ppl" + with pytest.raises(HTTPException) as exc: + _normalize_answer_value(question, "cpl") + + assert exc.value.status_code == 400 + + +def test_empty_answers_clear_existing_values() -> None: + assert _normalize_answer_value(make_question("text"), "") is None + assert _normalize_answer_value(make_question("text"), None) is None + + +def test_answer_deserialization_restores_frontend_types() -> None: + assert _deserialize_answer_value(make_question("boolean"), "true") is True + assert _deserialize_answer_value(make_question("boolean"), "false") is False + assert _deserialize_answer_value(make_question("number"), "10") == 10 + assert _deserialize_answer_value(make_question("number"), "10.5") == 10.5 + assert _deserialize_answer_value(make_question("text"), "SASA") == "SASA" + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("yes", "yes"), + ("true", "yes"), + ("0", "no"), + ("", None), + (None, None), + ], +) +def test_volunteer_level_accepts_boolean_like_values(value: str | None, expected: str | None) -> None: + assert _normalize_volunteer_level(value) == expected + + +def test_invalid_volunteer_level_raises_400() -> None: + with pytest.raises(HTTPException) as exc: + _normalize_volunteer_level("sometimes") + + assert exc.value.status_code == 400 diff --git a/backend/requirements.txt b/backend/requirements.txt index f44c322..58271d7 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -6,7 +6,7 @@ pydantic-settings==2.6.1 python-multipart==0.0.6 # Database -sqlalchemy==2.0.23 +sqlalchemy==2.0.49 pymysql==1.1.0 cryptography==41.0.7 alembic==1.13.0 @@ -15,7 +15,7 @@ alembic==1.13.0 python-jose[cryptography]==3.3.0 passlib[bcrypt]==1.7.4 python-dotenv==1.0.0 -bcrypt==4.1.1 +bcrypt==4.0.1 # Payment Integration squareup==43.2.0.20251016 @@ -28,3 +28,6 @@ email-validator==2.1.0 aiofiles==23.2.1 Jinja2==3.1.2 python-dateutil==2.8.2 + +# Tests +pytest==8.3.4 diff --git a/docker-compose.yml b/docker-compose.yml index 1f01bb8..29fa815 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,26 +1,24 @@ services: - # mysql: - # image: mysql:8.0 - # container_name: membership_mysql - # restart: unless-stopped - # environment: - # MYSQL_ROOT_PASSWORD: ${DATABASE_PASSWORD:-secure_root_password_change_this} - # MYSQL_DATABASE: ${DATABASE_NAME:-membership_db} - # MYSQL_USER: ${DATABASE_USER:-membership_user} - # MYSQL_PASSWORD: ${DATABASE_PASSWORD:-secure_password_change_this} - # # No external port exposure - database only accessible on private network - # expose: - # - "3306" - # volumes: - # - mysql_data:/var/lib/mysql - # networks: - # - membership_private - # healthcheck: - # test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] - # start_period: 10s - # interval: 5s - # timeout: 5s - # retries: 10 + #mysql: + # image: mysql:8.0 + # container_name: membership_mysql + # restart: unless-stopped + # environment: + # MYSQL_ROOT_PASSWORD: ${DATABASE_PASSWORD:-secure_root_password_change_this} + # MYSQL_DATABASE: ${DATABASE_NAME:-membership_db} + # MYSQL_USER: ${DATABASE_USER:-membership_user} + # MYSQL_PASSWORD: ${DATABASE_PASSWORD:-secure_password_change_this} + # # No external port exposure - database only accessible on private network + # expose: + # - "3306" + # volumes: + # - mysql_data:/var/lib/mysql + # healthcheck: + # test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] + # start_period: 10s + # interval: 5s + # timeout: 5s + # retries: 10 backend: build: diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..e12dbf5 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,3411 @@ +{ + "name": "membership-frontend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "membership-frontend", + "version": "1.0.0", + "dependencies": { + "@types/react": "^18.2.42", + "@types/react-dom": "^18.2.17", + "autoprefixer": "^10.4.16", + "axios": "^1.6.2", + "postcss": "^8.4.32", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "^6.20.0", + "tailwindcss": "^3.4.0", + "typescript": "^5.3.2" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.2.0", + "vite": "^5.0.5", + "vitest": "^1.6.1" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz", + "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "dev": true, + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.2", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", + "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.3.tgz", + "integrity": "sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.3.tgz", + "integrity": "sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.3.tgz", + "integrity": "sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.3.tgz", + "integrity": "sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.3.tgz", + "integrity": "sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.3.tgz", + "integrity": "sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.3.tgz", + "integrity": "sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.3.tgz", + "integrity": "sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.3.tgz", + "integrity": "sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.3.tgz", + "integrity": "sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.3.tgz", + "integrity": "sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.3.tgz", + "integrity": "sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.3.tgz", + "integrity": "sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.3.tgz", + "integrity": "sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.3.tgz", + "integrity": "sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.3.tgz", + "integrity": "sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.3.tgz", + "integrity": "sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.3.tgz", + "integrity": "sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.3.tgz", + "integrity": "sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.3.tgz", + "integrity": "sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.3.tgz", + "integrity": "sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.3.tgz", + "integrity": "sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.3.tgz", + "integrity": "sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.3.tgz", + "integrity": "sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.3.tgz", + "integrity": "sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sinclair/typebox": { + "version": "0.27.10", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", + "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", + "dev": true + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==" + }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@vitest/expect": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.6.1.tgz", + "integrity": "sha512-jXL+9+ZNIJKruofqXuuTClf44eSpcHlgj3CiuNihUF3Ioujtmc0zIa3UJOW5RjDK1YLBJZnWBlPuqhYycLioog==", + "dev": true, + "dependencies": { + "@vitest/spy": "1.6.1", + "@vitest/utils": "1.6.1", + "chai": "^4.3.10" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.6.1.tgz", + "integrity": "sha512-3nSnYXkVkf3mXFfE7vVyPmi3Sazhb/2cfZGGs0JRzFsPFvAMBEcrweV1V1GsrstdXeKCTXlJbvnQwGWgEIHmOA==", + "dev": true, + "dependencies": { + "@vitest/utils": "1.6.1", + "p-limit": "^5.0.0", + "pathe": "^1.1.1" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.6.1.tgz", + "integrity": "sha512-WvidQuWAzU2p95u8GAKlRMqMyN1yOJkGHnx3M1PL9Raf7AQ1kwLKg04ADlCa3+OXUZE7BceOhVZiuWAbzCKcUQ==", + "dev": true, + "dependencies": { + "magic-string": "^0.30.5", + "pathe": "^1.1.1", + "pretty-format": "^29.7.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.6.1.tgz", + "integrity": "sha512-MGcMmpGkZebsMZhbQKkAf9CX5zGvjkBTqf8Zx3ApYWXr3wG+QvEu2eXWfnIIWYSJExIp4V9FCKDEeygzkYrXMw==", + "dev": true, + "dependencies": { + "tinyspy": "^2.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.6.1.tgz", + "integrity": "sha512-jOrrUvXM4Av9ZWiG1EajNto0u96kWAhJ1LmPmJhXXQx/32MecEKd10pOLYgS2BQx1TgkGhloPU1ArDW2vvaY6g==", + "dev": true, + "dependencies": { + "diff-sequences": "^29.6.3", + "estree-walker": "^3.0.3", + "loupe": "^2.3.7", + "pretty-format": "^29.7.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "dev": true, + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==" + }, + "node_modules/assertion-error": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", + "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", + "dev": true, + "engines": { + "node": "*" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, + "node_modules/autoprefixer": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz", + "integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "browserslist": "^4.28.2", + "caniuse-lite": "^1.0.30001787", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/axios": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz", + "integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.27", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.27.tgz", + "integrity": "sha512-zEs/ufmZoUd7WftKpKyXaT6RFxpQ5Qm9xytKRHvJfxFV9DFJkZph9RvJ1LcOUi0Z1ZVijMte65JbILeV+8QQEA==", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001792", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001792.tgz", + "integrity": "sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ] + }, + "node_modules/chai": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz", + "integrity": "sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==", + "dev": true, + "dependencies": { + "assertion-error": "^1.1.0", + "check-error": "^1.0.3", + "deep-eql": "^4.1.3", + "get-func-name": "^2.0.2", + "loupe": "^2.3.6", + "pathval": "^1.1.1", + "type-detect": "^4.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/check-error": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", + "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==", + "dev": true, + "dependencies": { + "get-func-name": "^2.0.2" + }, + "engines": { + "node": "*" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "engines": { + "node": ">= 6" + } + }, + "node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "dev": true + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz", + "integrity": "sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==", + "dev": true, + "dependencies": { + "type-detect": "^4.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==" + }, + "node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==" + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.352", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.352.tgz", + "integrity": "sha512-9wHk8x6dyuimoe18EdiDPWKExNdxYqo4fn4FwOVVper6RxT3cmpBwBkWWfSOCYJjQdIco/nPhJhNLmn4Ufg1Yg==" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "engines": { + "node": ">=6" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/execa": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", + "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^8.0.1", + "human-signals": "^5.0.0", + "is-stream": "^3.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^5.1.0", + "onetime": "^6.0.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^3.0.0" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-func-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", + "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", + "dev": true, + "engines": { + "node": "*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", + "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", + "dev": true, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/human-signals": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", + "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", + "dev": true, + "engines": { + "node": ">=16.17.0" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", + "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", + "dev": true, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==" + }, + "node_modules/local-pkg": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.5.1.tgz", + "integrity": "sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==", + "dev": true, + "dependencies": { + "mlly": "^1.7.3", + "pkg-types": "^1.2.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/loupe": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz", + "integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==", + "dev": true, + "dependencies": { + "get-func-name": "^2.0.1" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", + "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mlly": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", + "dev": true, + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, + "node_modules/mlly/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.38", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz", + "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-run-path": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", + "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", + "dev": true, + "dependencies": { + "path-key": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "engines": { + "node": ">= 6" + } + }, + "node_modules/onetime": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", + "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", + "dev": true, + "dependencies": { + "mimic-fn": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-limit": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-5.0.0.tgz", + "integrity": "sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==", + "dev": true, + "dependencies": { + "yocto-queue": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true + }, + "node_modules/pathval": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", + "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", + "dev": true, + "engines": { + "node": "*" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dev": true, + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/pkg-types/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true + }, + "node_modules/postcss": { + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==" + }, + "node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "engines": { + "node": ">=10" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz", + "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==", + "dependencies": { + "@remix-run/router": "1.23.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz", + "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==", + "dependencies": { + "@remix-run/router": "1.23.2", + "react-router": "6.30.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.3.tgz", + "integrity": "sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A==", + "dev": true, + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.3", + "@rollup/rollup-android-arm64": "4.60.3", + "@rollup/rollup-darwin-arm64": "4.60.3", + "@rollup/rollup-darwin-x64": "4.60.3", + "@rollup/rollup-freebsd-arm64": "4.60.3", + "@rollup/rollup-freebsd-x64": "4.60.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.3", + "@rollup/rollup-linux-arm-musleabihf": "4.60.3", + "@rollup/rollup-linux-arm64-gnu": "4.60.3", + "@rollup/rollup-linux-arm64-musl": "4.60.3", + "@rollup/rollup-linux-loong64-gnu": "4.60.3", + "@rollup/rollup-linux-loong64-musl": "4.60.3", + "@rollup/rollup-linux-ppc64-gnu": "4.60.3", + "@rollup/rollup-linux-ppc64-musl": "4.60.3", + "@rollup/rollup-linux-riscv64-gnu": "4.60.3", + "@rollup/rollup-linux-riscv64-musl": "4.60.3", + "@rollup/rollup-linux-s390x-gnu": "4.60.3", + "@rollup/rollup-linux-x64-gnu": "4.60.3", + "@rollup/rollup-linux-x64-musl": "4.60.3", + "@rollup/rollup-openbsd-x64": "4.60.3", + "@rollup/rollup-openharmony-arm64": "4.60.3", + "@rollup/rollup-win32-arm64-msvc": "4.60.3", + "@rollup/rollup-win32-ia32-msvc": "4.60.3", + "@rollup/rollup-win32-x64-gnu": "4.60.3", + "@rollup/rollup-win32-x64-msvc": "4.60.3", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true + }, + "node_modules/strip-final-newline": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", + "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-literal": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-2.1.1.tgz", + "integrity": "sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==", + "dev": true, + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tinypool": { + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.8.4.tgz", + "integrity": "sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ==", + "dev": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-2.2.1.tgz", + "integrity": "sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==", + "dev": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==" + }, + "node_modules/type-detect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", + "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ufo": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", + "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", + "dev": true + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-1.6.1.tgz", + "integrity": "sha512-YAXkfvGtuTzwWbDSACdJSg4A4DZiAqckWe90Zapc/sEX3XvHcw1NdurM/6od8J207tSDqNbSsgdCacBgvJKFuA==", + "dev": true, + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.4", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-1.6.1.tgz", + "integrity": "sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==", + "dev": true, + "dependencies": { + "@vitest/expect": "1.6.1", + "@vitest/runner": "1.6.1", + "@vitest/snapshot": "1.6.1", + "@vitest/spy": "1.6.1", + "@vitest/utils": "1.6.1", + "acorn-walk": "^8.3.2", + "chai": "^4.3.10", + "debug": "^4.3.4", + "execa": "^8.0.1", + "local-pkg": "^0.5.0", + "magic-string": "^0.30.5", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "std-env": "^3.5.0", + "strip-literal": "^2.0.0", + "tinybench": "^2.5.1", + "tinypool": "^0.8.3", + "vite": "^5.0.0", + "vite-node": "1.6.1", + "why-is-node-running": "^2.2.2" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "1.6.1", + "@vitest/ui": "1.6.1", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true + }, + "node_modules/yocto-queue": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", + "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", + "dev": true, + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json index 4aa6cbe..fbc8c07 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,10 +17,12 @@ "scripts": { "dev": "vite --host 0.0.0.0", "build": "tsc && vite build", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest run" }, "devDependencies": { "@vitejs/plugin-react": "^4.2.0", - "vite": "^5.0.5" + "vite": "^5.0.5", + "vitest": "^1.6.1" } } diff --git a/frontend/src/App.css b/frontend/src/App.css index 2454953..a0d816e 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -3,13 +3,35 @@ @tailwind utilities; :root { - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', - 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', - sans-serif; - line-height: 1.6; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Arial, sans-serif; + line-height: 1.45; font-weight: 400; - color: #213547; - background-color: #ffffff; + color: #e6ebf2; + background-color: #131416; + --ops-bg: #131416; + --ops-top: #282b2f; + --ops-surface: #181a1d; + --ops-surface-muted: #202328; + --ops-surface-strong: #111214; + --ops-border: rgba(70, 75, 83, 0.9); + --ops-border-soft: rgba(42, 45, 50, 0.92); + --ops-text: #e6ebf2; + --ops-muted: #8d96a3; + --ops-subtle: #c6ced8; + --ops-accent: #4797ff; + --ops-accent-dark: #006eff; + --ops-accent-soft: rgba(71, 151, 255, 0.12); + --ops-accent-mid: rgba(71, 151, 255, 0.2); + --ops-accent-wash: #11161d; + --ops-coral: #ee6368; + --ops-coral-soft: rgba(238, 99, 104, 0.12); + --ops-danger: #ee6368; + --ops-danger-soft: rgba(92, 31, 33, 0.4); + --ops-warning: #ffb84c; + --ops-warning-soft: rgba(255, 184, 76, 0.12); + --ops-success: #2fa252; + --ops-success-soft: rgba(47, 162, 82, 0.13); + --ops-radius: 8px; } * { @@ -20,56 +42,227 @@ body { min-height: 100vh; + background: var(--ops-bg); + color: var(--ops-text); + font-size: 12px; + overflow-x: hidden; +} + +#root { + min-height: 100vh; +} + +.app-shell { + min-height: 100vh; + display: flex; + flex-direction: column; +} + +.app-main { + flex: 1 0 auto; } .container { - max-width: 1200px; + max-width: 1380px; margin: 0 auto; - padding: 20px; + padding: 24px 20px 28px; +} + +.portal-topbar { + min-height: 44px; + display: grid; + grid-template-columns: 280px minmax(0, 1fr) auto; + gap: 12px; + align-items: center; + padding: 10px 12px; + background: var(--ops-top); + border-bottom: 1px solid var(--ops-border-soft); + position: sticky; + top: 0; + z-index: 40; +} + +.portal-topbar-admin { + grid-template-columns: 280px auto; + justify-content: space-between; + background: #282b2f; +} + +.portal-brand { + display: flex; + align-items: center; + gap: 9px; + min-width: 0; +} + +.portal-mark { + width: 22px; + height: 22px; + display: grid; + place-items: center; + border-radius: 6px; + background: var(--ops-accent-dark); + color: #fff; + font-weight: 600; + font-size: 12px; + letter-spacing: -0.03em; + flex: 0 0 auto; +} + +.portal-brand-text { + min-width: 0; +} + +.portal-brand-text h1 { + margin: 0; + font-size: 13px; + line-height: 1.1; + color: #f3f6fa; + font-weight: 520; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.portal-subtitle { + margin-top: 2px; + font-size: 11px; + color: #aab2bd; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.portal-nav { + display: flex; + align-items: center; + justify-content: center; + gap: 4px; + min-width: 0; + overflow-x: auto; + scrollbar-width: none; +} + +.portal-nav::-webkit-scrollbar { + display: none; +} + +.portal-tab, +.portal-switch-button, +.portal-exit-button { + min-height: 30px; + padding: 7px 12px; + border-radius: 6px; + font-size: 12px; + font-weight: 500; + white-space: nowrap; +} + +.portal-tab { + border: 1px solid transparent; + background: transparent; + color: #c7cdd6; +} + +.portal-tab:hover { + background: rgba(5, 37, 77, 0.75); + color: #fff; + border-color: transparent; +} + +.portal-tab.active { + background: #34383e; + color: #fff; + border-color: #34383e; +} + +.portal-switch-button, +.portal-exit-button { + border: 1px solid rgba(71, 151, 255, 0.72); + background: transparent; + color: var(--ops-accent); +} + +.portal-switch-button:hover, +.portal-exit-button:hover { + background: rgba(5, 37, 77, 0.75); + border-color: var(--ops-accent); + color: #fff; +} + +.portal-meta { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; +} + +.admin-brand-text .portal-subtitle { color: #8d96a3; } + +.portal-container { + padding-top: 18px; +} + +.portal-container-admin { + max-width: none; + width: 100%; + margin: 0; + padding: 0; } .card { - background: white; - border-radius: 8px; - padding: 24px; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + background: rgba(24, 27, 31, 0.96); + border-top: 1px solid rgba(70, 76, 86, 0.7); + border-bottom: 1px solid rgba(34, 38, 44, 0.96); + border-left: 1px solid rgba(42, 46, 52, 0.85); + border-right: 1px solid rgba(42, 46, 52, 0.85); + border-radius: 3px; + padding: 18px 18px 19px; margin-bottom: 20px; } .btn { - padding: 10px 20px; - border: none; - border-radius: 4px; - font-size: 16px; + padding: 9px 15px; + border: 1px solid rgba(71, 151, 255, 0.72); + border-radius: var(--ops-radius); + font-size: 12px; cursor: pointer; - transition: all 0.3s; + transition: all 0.2s; + font-weight: 500; } .btn-primary { - background-color: #0066cc; - color: white; + background: transparent; + color: var(--ops-accent); } .btn-primary:hover { - background-color: #0052a3; + background: rgba(5, 37, 77, 0.75); + color: #fff; } .btn-secondary { - background-color: #6c757d; - color: white; + background-color: var(--ops-surface-muted); + border-color: var(--ops-border); + color: var(--ops-text); } .btn-secondary:hover { - background-color: #5a6268; + background-color: rgba(255, 255, 255, 0.05); + border-color: #5a626d; + color: #fff; } .btn-danger { - background-color: #dc3545; - color: white; + background-color: transparent; + border-color: rgba(238, 99, 104, 0.68); + color: #ffa2a6; } .btn-danger:hover { - background-color: #c82333; + background-color: rgba(92, 31, 33, 0.75); + border-color: var(--ops-danger); + color: #ffe2e4; } .form-group { @@ -80,118 +273,2117 @@ body { display: block; margin-bottom: 6px; font-weight: 500; + color: var(--ops-text); } .form-group input, -.form-group textarea { +.form-group textarea, +.form-group select { width: 100%; - padding: 10px; - border: 1px solid #ddd; - border-radius: 4px; - font-size: 16px; + padding: 8px 9px; + border: 1px solid var(--ops-border); + border-radius: var(--ops-radius); + font-size: 12px; + background: #111214; + color: var(--ops-text); } .form-group input:focus, -.form-group textarea:focus { +.form-group textarea:focus, +.form-group select:focus { outline: none; - border-color: #0066cc; + border-color: var(--ops-accent); + box-shadow: 0 0 0 3px rgba(11, 93, 125, 0.12); } .alert { padding: 12px 16px; - border-radius: 4px; + border-radius: 6px; margin-bottom: 16px; } .alert-success { - background-color: #d4edda; - color: #155724; - border: 1px solid #c3e6cb; + background-color: var(--ops-success-soft); + color: var(--ops-success); + border: 1px solid rgba(47, 162, 82, 0.36); } .alert-error { - background-color: #f8d7da; - color: #721c24; - border: 1px solid #f5c6cb; + background-color: var(--ops-coral-soft); + color: #ffd7df; + border: 1px solid rgba(238, 99, 104, 0.42); } .alert-warning { - background-color: #fff3cd; - color: #856404; - border: 1px solid #ffeaa7; + background-color: var(--ops-warning-soft); + color: var(--ops-warning); + border: 1px solid rgba(255, 184, 76, 0.28); } .navbar { - background-color: #0066cc; - color: white; + background: #111923; + color: var(--ops-text); padding: 16px 24px; display: flex; justify-content: space-between; align-items: center; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + border-bottom: 1px solid rgba(120, 160, 196, 0.18); } .navbar h1 { margin: 0; - font-size: 20px; + font-size: 19px; + color: #f3f8fc; + letter-spacing: 0.01em; } .navbar button { - background: rgba(255, 255, 255, 0.2); - color: white; - border: none; + background: rgba(14, 24, 37, 0.94); + color: var(--ops-text); + border: 1px solid var(--ops-border); padding: 8px 16px; - border-radius: 4px; + border-radius: var(--ops-radius); cursor: pointer; } .navbar button:hover { - background: rgba(255, 255, 255, 0.3); + background: var(--ops-accent-soft); + border-color: #b8d5e4; + color: var(--ops-accent); +} + +.auth-shell { + min-height: 100vh; + display: grid; + grid-template-rows: auto 1fr; + background: var(--ops-bg); +} + +.auth-topbar { + min-height: 44px; + display: flex; + align-items: center; + padding: 9px 12px; + background: var(--ops-top); + border-bottom: 1px solid var(--ops-border-soft); } .auth-container { - min-height: 100vh; - display: flex; - align-items: center; + min-height: calc(100vh - 44px); + display: grid; + grid-template-columns: minmax(280px, 420px) minmax(320px, 420px); justify-content: center; - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + align-content: center; + gap: 18px; + padding: 20px; +} + +.auth-welcome-card, +.auth-card { + border: 1px solid var(--ops-border-soft); + border-radius: var(--ops-radius); + background: transparent; + overflow: hidden; +} + +.auth-welcome-card { + padding: 26px; + display: grid; + gap: 14px; +} + +.auth-kicker { + font-size: 11px; + color: #aab2bd; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.auth-welcome-card h2 { + margin: 0; + font-size: 26px; + color: #f3f6fa; +} + +.auth-welcome-card p { + color: var(--ops-subtle); + font-size: 14px; + line-height: 1.6; } .auth-card { - background: white; - border-radius: 8px; - padding: 40px; - box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2); width: 100%; - max-width: 900px; + max-width: 420px; } -.auth-card h2 { - margin-bottom: 24px; - color: #213547; +.auth-card-head { + min-height: 40px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 0 12px; + border-bottom: 1px solid var(--ops-border-soft); +} + +.auth-card-head h2 { + margin: 0; + font-size: 12px; + font-weight: 500; + color: #f3f6fa; +} + +.auth-card-head span { + color: var(--ops-muted); + font-size: 11px; +} + +.auth-card form, +.auth-footer { + padding: 11px; +} + +.auth-submit { + width: 100%; + margin-top: 8px; +} + +.auth-footer { + display: grid; + gap: 12px; + border-top: 1px solid var(--ops-border-soft); text-align: center; + color: var(--ops-muted); } -.auth-card .form-footer { - margin-top: 16px; - text-align: center; - color: #666; -} - -.auth-card .form-footer a { - color: #0066cc; +.auth-footer a { + color: var(--ops-accent); text-decoration: none; } -.auth-card .form-footer a:hover { +.auth-footer a:hover { text-decoration: underline; } .dashboard-grid { display: grid; grid-template-columns: 1fr; - gap: 20px; - margin-top: 20px; + gap: 22px; + margin-top: 18px; +} + +.dashboard-tabs { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 12px; +} + +.navbar-main { + display: flex; + align-items: center; + gap: 14px; + flex-wrap: wrap; +} + +.profile-menu { + position: relative; +} + +.profile-menu-trigger { + background: var(--ops-surface-muted); + border: 1px solid rgba(120, 160, 196, 0.16); + color: #f3f8fc; + cursor: pointer; + font-size: 14px; + display: inline-flex; + align-items: center; + gap: 8px; + padding: 9px 12px; + border-radius: 999px; + font-weight: 700; +} + +.profile-menu-chevron { + font-size: 11px; + color: var(--ops-muted); +} + +.profile-menu-dropdown { + position: absolute; + top: calc(100% + 10px); + right: 0; + min-width: 320px; + max-width: 360px; + z-index: 1000; + background: var(--ops-surface); + border: 1px solid var(--ops-border); + border-radius: 8px; + box-shadow: none; + overflow: hidden; +} + +.profile-menu-summary { + padding: 18px; + border-bottom: 1px solid var(--ops-border-soft); + background: var(--ops-surface-muted); +} + +.profile-menu-summary-head { + display: flex; + justify-content: space-between; + align-items: center; + gap: 10px; + margin-bottom: 12px; +} + +.profile-menu-summary-head h4 { + margin: 0; + font-size: 13px; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--ops-muted); +} + +.profile-menu-edit { + border: 1px solid rgba(68, 166, 255, 0.24); + background: rgba(29, 143, 255, 0.16); + color: #d9ecff; + border-radius: 999px; + padding: 5px 10px; + font-size: 11px; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.04em; + cursor: pointer; +} + +.profile-menu-details { + font-size: 12px; + color: var(--ops-muted); + line-height: 1.65; +} + +.profile-menu-details p { + margin: 4px 0; +} + +.profile-menu-details strong { + color: var(--ops-text); +} + +.profile-menu-item { + display: block; + width: 100%; + padding: 14px 18px; + background: transparent; + border: none; + border-top: 1px solid var(--ops-border-soft); + text-align: left; + cursor: pointer; + color: var(--ops-text); + font-size: 14px; + font-weight: 600; +} + +.profile-menu-item.first { + border-top: none; +} + +.profile-menu-item:hover { background: rgba(255, 255, 255, 0.04); } + +.navbar-tab-strip { + display: flex; + gap: 6px; + flex-wrap: wrap; +} + +.navbar-tab-active, +.navbar-tab-inactive { + border: 1px solid var(--ops-border); + color: var(--ops-text); + border-radius: var(--ops-radius); + padding: 7px 12px; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.02em; + text-transform: uppercase; + cursor: pointer; +} + +.navbar-tab-active { + background: var(--ops-accent); + border-color: var(--ops-accent-dark); + color: white; +} + +.navbar-tab-inactive { + background: var(--ops-surface-muted); +} + +.navbar-tab-inactive:hover { + background: var(--ops-accent-soft); + border-color: #b8d5e4; + color: var(--ops-accent); +} + +.profile-question-row { + display: grid; + grid-template-columns: minmax(240px, 320px) 1fr; + gap: 14px; + align-items: start; + border: 1px solid var(--ops-border-soft); + border-radius: var(--ops-radius); + padding: 12px 12px 10px; + background: rgba(24, 27, 31, 0.92); +} + +.profile-question-meta { + min-width: 0; +} + +.profile-question-answer { + min-width: 0; +} + +.profile-question-readonly { + width: 100%; + min-height: 38px; + padding: 8px 10px; + border: 1px solid var(--ops-border); + border-radius: var(--ops-radius); + background: var(--ops-surface-muted); + color: var(--ops-muted); + font-weight: 600; +} + +.profile-question-row-admin { + grid-template-columns: 1fr; + gap: 8px; + padding: 14px 0; + border: 0; + border-top: 1px solid rgba(38, 44, 50, 0.88); + border-radius: 0; + background: transparent; +} + +.profile-question-row-admin:first-child { + padding-top: 0; + border-top: 0; +} + +.profile-question-row-admin .profile-question-meta { + display: grid; + gap: 6px; +} + +.profile-question-row-admin .profile-question-answer { + width: 100%; +} + +.profile-question-row-admin .profile-question-input, +.profile-question-row-admin .profile-question-readonly { + min-height: 42px; +} + +.site-footer { + margin-top: auto; + padding: 20px 14px; + background: var(--ops-top); + color: var(--ops-muted); + border-top: 1px solid var(--ops-border-soft); + text-align: center; + font-size: 12px; +} + +.site-footer a { + color: var(--ops-accent); + text-decoration: none; + margin: 0 8px; +} + +.site-footer a:hover { + text-decoration: underline; +} + +.cookie-banner { + position: fixed; + right: 16px; + bottom: 16px; + z-index: 2400; + width: min(360px, calc(100vw - 32px)); + background: var(--ops-surface); + color: var(--ops-text); + border-radius: var(--ops-radius); + border: 1px solid var(--ops-border); + border-left: 4px solid var(--ops-accent); + box-shadow: none; + padding: 12px 14px; + display: flex; + align-items: center; + gap: 12px; + justify-content: space-between; + font-size: 13px; + line-height: 1.45; +} + +.admin-workspace { + display: grid; + grid-template-columns: 58px 236px minmax(0, 1fr); + gap: 0; + align-items: start; + margin-top: 0; + min-height: calc(100vh - 44px); + background: transparent; +} + +.admin-workspace.single-page-area { + grid-template-columns: 58px minmax(0, 1fr); +} + +.admin-primary-rail { + position: sticky; + top: 54px; + min-height: calc(100vh - 64px); + padding: 14px 8px 12px; + background: rgba(17, 18, 20, 0.94); + border-right: 1px solid rgba(42, 45, 50, 0.92); + align-self: stretch; +} + +.admin-primary-nav { + display: grid; + gap: 4px; +} + +.admin-primary-link { + width: 100%; + height: 46px; + display: grid; + place-items: center; + border: 1px solid transparent; + background: transparent; + color: #aab2bd; + border-radius: 0; + cursor: pointer; + position: relative; + transition: background 0.14s ease, color 0.14s ease; +} + +.admin-primary-link::before { + content: ""; + position: absolute; + left: -8px; + top: 8px; + bottom: 8px; + width: 2px; + background: transparent; + transition: background 0.14s ease; +} + +.admin-primary-link:hover { + background: rgba(255, 255, 255, 0.03); + color: #eef3f8; +} + +.admin-primary-link:hover::before, +.admin-primary-link.active::before { + background: #4797ff; +} + +.admin-primary-link.active { + background: rgba(255, 255, 255, 0.04); + color: #f3f6fa; +} + +.admin-primary-icon { + width: 24px; + height: 24px; + display: grid; + place-items: center; +} + +.admin-primary-icon svg { + width: 19px; + height: 19px; +} + +.admin-primary-icon .icon-stroke { + fill: none; + stroke: currentColor; + stroke-width: 1.05; + stroke-linecap: round; + stroke-linejoin: round; +} + +.admin-primary-link:not(.active) .admin-primary-icon .icon-fill { + display: none; +} + +.admin-primary-link:hover .admin-primary-icon .icon-fill, +.admin-primary-link.active .admin-primary-icon .icon-fill { + fill: currentColor; + stroke: none; + opacity: 0.18; +} + +.admin-page-rail { + position: sticky; + top: 54px; + min-height: calc(100vh - 64px); + padding: 18px 0 14px; + background: rgba(19, 20, 22, 0.82); + border-right: 1px solid rgba(42, 45, 50, 0.92); + align-self: stretch; +} + +.admin-page-rail-title { + padding: 0 16px 12px; + margin: 0 0 6px; + color: #8d96a3; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.09em; + border-bottom: 1px solid rgba(42, 45, 50, 0.92); +} + +.admin-page-nav { + display: grid; + gap: 0; +} + +.admin-rail-tools { + margin-top: 14px; + padding: 16px 16px 0; + border-top: 1px solid rgba(42, 45, 50, 0.92); + display: grid; + gap: 14px; +} + +.admin-rail-search, +.admin-rail-group { + display: grid; + gap: 5px; +} + +.admin-rail-search label, +.admin-rail-group-title { + color: #8d96a3; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.09em; +} + +.admin-rail-search input, +.admin-rail-group select, +.admin-rail-search select { + width: 100%; + min-height: 36px; + padding: 8px 11px; + border: 1px solid rgba(42, 45, 50, 0.92); + border-radius: 0; + background: rgba(255, 255, 255, 0.02); + color: #eef3f8; + box-shadow: none; +} + +.admin-rail-search input:focus, +.admin-rail-group select:focus, +.admin-rail-search select:focus { + outline: none; + border-color: rgba(71, 151, 255, 0.42); + background: rgba(255, 255, 255, 0.03); + box-shadow: inset 0 0 0 1px rgba(71, 151, 255, 0.12); +} + +.admin-rail-meta { + display: grid; + gap: 4px; + padding: 10px 0 0; + border-top: 1px solid rgba(42, 45, 50, 0.92); + background: transparent; + color: #b5c0ce; + font-size: 11px; +} + +.admin-rail-action { + min-height: 36px; + padding: 8px 0; + border: 0; + border-top: 1px solid rgba(42, 45, 50, 0.92); + border-bottom: 1px solid rgba(42, 45, 50, 0.92); + background: transparent; + color: #c7cdd6; + text-align: left; + font-size: 12px; + font-weight: 500; + cursor: pointer; +} + +.admin-rail-action:hover:not(:disabled) { + background: transparent; + color: #fff; + border-color: rgba(71, 151, 255, 0.26); +} + +.admin-rail-action:first-child { + border-top: 1px solid rgba(42, 45, 50, 0.92); +} + +.admin-page-link { + width: 100%; + padding: 10px 16px; + border: 0; + border-left: 2px solid transparent; + border-radius: 0; + background: transparent; + color: #c7cdd6; + text-align: left; + font-size: 12px; + font-weight: 500; + cursor: pointer; + transition: background 0.14s ease, border-color 0.14s ease, color 0.14s ease; +} + +.admin-page-link:hover { + background: rgba(255, 255, 255, 0.03); + color: #fff; + border-left-color: rgba(71, 151, 255, 0.4); +} + +.admin-page-link.active { + background: rgba(255, 255, 255, 0.04); + border-left-color: #4797ff; + color: #f3f6fa; +} + +.admin-rail-action:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.admin-content { + min-width: 0; + padding: 18px 20px 28px; + background: transparent; + display: grid; + gap: 14px; + align-content: stretch; + min-height: calc(100vh - 44px); +} + +.admin-inline-badge { + display: inline-block; + margin-left: 8px; + padding: 4px 8px; + border-radius: 4px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + background: rgba(71, 151, 255, 0.12); + border: 0; + color: #86bcff; +} + +.admin-kv-list { + display: grid; + gap: 10px; + margin-bottom: 14px; +} + +.admin-surface, +.admin-panel { + background: transparent; + border: 0; + border-radius: 0; + padding: 0; +} + +.admin-surface-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 16px; + margin-bottom: 14px; +} + +.admin-surface-header h4, +.admin-surface-header h3 { + margin: 0 0 4px; +} + +.admin-surface-header p { + margin: 0; + color: #b5c0ce; + font-size: 12px; +} + +.admin-switch-row { + display: flex; + align-items: center; + gap: 10px; + color: #d7e0ec; + font-size: 12px; + font-weight: 400; + cursor: pointer; +} + +.admin-switch-row + .admin-switch-row { + margin-top: 10px; +} + +.admin-switch-group { + display: flex; + gap: 18px; + flex-wrap: wrap; +} + +.admin-kv-list strong, +.admin-kv-list span, +.admin-kv-list code { + display: block; +} + +.admin-kv-list strong { + margin-bottom: 3px; + color: #eef3f8; + font-size: 11px; + font-weight: 500; +} + +.admin-preview-block { + padding: 12px 13px; + border-top: 1px solid rgba(64, 71, 80, 0.55); + border-bottom: 1px solid rgba(34, 38, 44, 0.96); + background: rgba(16, 18, 22, 0.72); +} + +.admin-preview-block strong { + display: block; + margin-bottom: 8px; + color: #eef3f8; + font-size: 11px; + font-weight: 500; +} + +.admin-preview-block pre { + margin: 0; + white-space: pre-wrap; + word-break: break-word; + color: #eef3f8; + font-size: 12px; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +} + +.admin-page-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 14px; + margin-bottom: 14px; + padding: 0 2px; +} + +.admin-page-header h3 { + margin: 0 0 3px; + color: #f3f6fa; + font-size: 18px; + font-weight: 520; + letter-spacing: -0.02em; +} + +.admin-page-header p { + margin: 0; + color: #eef3f8; + font-size: 12px; +} + +.admin-stat-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 0; + margin-bottom: 18px; + border-top: 1px solid rgba(42, 45, 50, 0.92); + border-left: 1px solid rgba(42, 45, 50, 0.92); +} + +.admin-stat-card { + display: grid; + gap: 6px; + background: transparent; + border: 0; + border-right: 1px solid rgba(42, 45, 50, 0.92); + border-bottom: 1px solid rgba(42, 45, 50, 0.92); + border-radius: 0; + padding: 15px 16px; + text-align: left; + color: var(--ops-text); + box-shadow: none; +} + +button.admin-stat-card { + cursor: pointer; +} + +.admin-stat-card span { + color: #eef3f8; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.05em; + text-transform: uppercase; +} + +.admin-stat-card strong { + font-size: 28px; + line-height: 1; + color: #f3f6fa; +} + +.admin-stat-card.attention { + background: rgba(255, 184, 76, 0.04); +} + +.admin-panel-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + gap: 0 18px; + border-top: 1px solid rgba(42, 45, 50, 0.92); +} + +.admin-panel { + box-shadow: none; + padding-top: 16px; +} + +.admin-panel h4 { + margin: 0 0 12px; + color: #f3f6fa; + font-size: 14px; + font-weight: 520; +} + +.admin-queue-list { + display: grid; + gap: 0; +} + +.admin-queue-item { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: center; + border: 0; + border-top: 1px solid rgba(42, 45, 50, 0.92); + border-radius: 0; + padding: 12px 0; + background: transparent; +} + +.admin-queue-item span, +.muted-line { + display: block; + color: #b5c0ce; + font-size: 12px; + margin-top: 2px; +} + +.admin-queue-item:first-child { + border-top: 0; +} + +.admin-filter-bar { + display: grid; + grid-template-columns: minmax(240px, 1fr) repeat(3, minmax(150px, 180px)); + gap: 10px; + margin-bottom: 12px; + padding: 0; + border: 0; + border-radius: 0; + background: transparent; +} + +.admin-filter-bar input, +.admin-filter-bar select, +.drawer-control-grid select { + width: 100%; + padding: 8px 10px; + min-height: 34px; + border: 1px solid rgba(52, 57, 66, 0.96); + border-radius: 2px; + background: #111214; + color: var(--ops-text); +} + +.admin-filter-bar input:focus, +.admin-filter-bar select:focus, +.drawer-control-grid select:focus { + outline: none; + border-color: var(--ops-accent); + box-shadow: 0 0 0 3px rgba(11, 111, 143, 0.12); +} + +.admin-table-shell { + min-height: 0; + border: 0; + border-radius: 0; + background: transparent; + box-shadow: none; + overflow: visible; +} + +.admin-table-screen { + display: flex; + flex-direction: column; + min-height: calc(100vh - 92px); +} + +.admin-table-wrap { + flex: 1 1 auto; + overflow-x: auto; + overflow-y: auto; + border: 0; + border-radius: 0; + background: transparent; + box-shadow: none; + margin: 0 -16px; + padding: 0 16px; +} + +.admin-table { + width: 100%; + min-width: 820px; + border-collapse: collapse; + table-layout: auto; +} + +.admin-table th, +.admin-table td { + padding: 9px 12px; + border-bottom: 1px solid rgba(42, 45, 50, 0.92); + text-align: left; + vertical-align: middle; +} + +.admin-table th { + position: sticky; + top: 0; + z-index: 1; + color: #f3f6fa; + font-size: 12px; + font-weight: 520; + letter-spacing: 0; + text-transform: none; + background: transparent; + white-space: nowrap; + border-bottom: 1px solid rgba(42, 45, 50, 0.92); +} + +.admin-table-sort { + width: 100%; + display: inline-flex; + align-items: center; + justify-content: space-between; + gap: 10px; + border: 0; + background: transparent; + padding: 0; + color: inherit; + font: inherit; + text-transform: inherit; + letter-spacing: inherit; + cursor: pointer; +} + +.admin-table-sort:hover { + color: #f3f6fa; +} + +.admin-table-sort.active { + color: var(--ops-accent); +} + +.admin-sort-arrow { + width: 12px; + height: 12px; + color: rgba(149, 160, 175, 0.62); + flex: 0 0 auto; + transition: transform 0.14s ease, color 0.14s ease; +} + +.admin-sort-arrow svg { + width: 12px; + height: 12px; + fill: none; + stroke: currentColor; + stroke-width: 1.6; + stroke-linecap: round; + stroke-linejoin: round; +} + +.admin-sort-arrow.active { + color: var(--ops-accent); +} + +.admin-sort-arrow.asc { + transform: rotate(180deg); +} + +.admin-table td { + color: #f3f6fa; + font-size: 12px; +} + +.admin-table tbody tr { + cursor: pointer; + transition: background 0.12s ease; +} + +.admin-table tbody tr:hover { + background: rgba(255, 255, 255, 0.028); +} + +.admin-table tbody tr:last-child td { + border-bottom: 0; +} + +.admin-table-empty { + padding: 16px 14px; +} + +.admin-table td strong { + color: #f3f6fa; + font-weight: 520; +} + +.admin-table td .muted-line { + color: #eef3f8; +} + +.admin-table-footer { + margin-top: auto; + margin-left: -16px; + margin-right: -16px; + padding: 10px 16px; + border-top: 1px solid rgba(42, 45, 50, 0.92); + background: transparent; + position: sticky; + bottom: 0; + z-index: 2; +} + +.admin-table-intro { + padding: 16px 16px 0; +} + +.admin-table-intro h4, +.admin-table-intro h3 { + margin: 0 0 4px; +} + +.admin-table-intro p { + margin: 0; + color: #b5c0ce; + font-size: 12px; +} + +.admin-field, +.admin-question-builder .admin-field, +.admin-question-search, +.admin-question-builder textarea, +.admin-question-builder select, +.admin-question-builder input { + width: 100%; + padding: 8px 10px; + min-height: 34px; + border: 1px solid rgba(52, 57, 66, 0.96); + border-radius: 2px; + background: #111214; + color: var(--ops-text); +} + +.admin-field:focus, +.admin-question-builder .admin-field:focus, +.admin-question-builder textarea:focus, +.admin-question-builder select:focus, +.admin-question-builder input:focus { + outline: none; + border-color: var(--ops-accent); + box-shadow: 0 0 0 3px rgba(11, 111, 143, 0.12); +} + +.admin-field::placeholder, +.admin-question-builder .admin-field::placeholder, +.admin-question-builder textarea::placeholder, +.admin-question-builder input::placeholder { + color: #8d96a3; +} + +.admin-field-disabled { + background: #202328; + color: #8d96a3; +} + +.admin-field-textarea { + min-height: 84px; + resize: vertical; +} + +.admin-form-grid { + display: grid; + gap: 10px; +} + +.admin-field-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 10px; +} + +.admin-form-actions { + display: flex; + gap: 10px; +} + +.admin-question-builder { + margin-bottom: 0; +} + +.admin-question-switches { + padding-top: 4px; +} + +.admin-subsection-header { + margin-bottom: 10px; +} + +.admin-question-search { + margin-bottom: 10px; +} + +.admin-question-table { + min-width: 0; +} + +.admin-question-table td:nth-child(2) { + min-width: 240px; +} + +.admin-question-table td:nth-child(3), +.admin-question-table td:nth-child(4) { + color: #c6ced8; +} + +.admin-question-layout { + display: grid; + grid-template-columns: minmax(0, 1fr) 360px; + gap: 18px; + align-items: start; +} + +.admin-question-main { + min-width: 0; +} + +.admin-question-sidebar { + min-width: 0; +} + +.admin-question-sidebar .admin-surface { + position: sticky; + top: 16px; +} + +.admin-pager-controls { + display: flex; + gap: 6px; +} + +.admin-pager-button { + width: 30px; + height: 30px; + display: grid; + place-items: center; + border: 1px solid transparent; + background: transparent; + color: #c7cdd6; + border-radius: 2px; + cursor: pointer; +} + +.admin-pager-button:hover:not(:disabled) { + background: transparent; + color: #fff; + border-color: transparent; +} + +.admin-pager-button:disabled { + opacity: 0.35; + cursor: not-allowed; +} + +.admin-inline-list { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; +} + +.admin-inline-chip { + display: inline-flex; + align-items: center; + min-height: 22px; + padding: 0 8px; + border: 1px solid rgba(52, 57, 66, 0.96); + border-radius: 999px; + background: rgba(255, 255, 255, 0.03); + color: #d3dae4; + font-size: 11px; + line-height: 1; + white-space: nowrap; +} + +.admin-code-textarea { + font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; + font-size: 13px; +} + +.admin-date-stack { + display: grid; + gap: 2px; +} + +.admin-date-stack strong { + color: #f3f6fa; + font-weight: 520; +} + +.admin-date-stack span { + color: #b5c0ce; + font-size: 12px; +} + +.email-preview-tabs { + display: inline-flex; + gap: 6px; + margin-bottom: 12px; + padding: 3px; + border: 1px solid rgba(38, 44, 50, 0.88); + background: rgba(17, 18, 20, 0.9); +} + +.email-preview-tab { + min-height: 30px; + padding: 0 10px; + border: 0; + background: transparent; + color: #a9b6c6; + font-size: 12px; + cursor: pointer; +} + +.email-preview-tab.active { + background: rgba(255, 255, 255, 0.06); + color: #f3f7fb; +} + +.email-preview-frame-shell { + overflow: hidden; + border: 1px solid rgba(38, 44, 50, 0.88); + background: #ffffff; +} + +.email-preview-frame { + width: 100%; + min-height: 440px; + border: 0; + background: #ffffff; +} + +.email-preview-code { + margin: 0; + padding: 14px; + border: 1px solid rgba(38, 44, 50, 0.88); + background: rgba(17, 18, 20, 0.9); + color: #eef3f8; + white-space: pre-wrap; + word-break: break-word; + font-size: 12px; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +} + +.admin-pager-button svg { + width: 14px; + height: 14px; + fill: none; + stroke: currentColor; + stroke-width: 1.7; + stroke-linecap: round; + stroke-linejoin: round; +} + +.admin-table .btn, +.table-button-row .btn { + padding: 5px 9px; + font-size: 12px; +} + +.table-button-row { + display: flex; + gap: 6px; + flex-wrap: wrap; +} + +.admin-section-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + margin-bottom: 14px; +} + +.admin-section-header h3, +.admin-section-header h4 { + margin: 0 0 4px 0; +} + +.admin-section-header p { + margin: 0; + color: var(--ops-muted); +} + +.admin-header-actions { + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; + justify-content: flex-end; +} + +.admin-tier-benefits-cell { + min-width: 240px; + max-width: 320px; + color: #c6ced8; + white-space: pre-wrap; +} + +.admin-inline-form-panel { + margin-bottom: 16px; + padding: 18px 0 2px; + border-top: 1px solid rgba(42, 45, 50, 0.92); +} + +.admin-inline-form-panel h4 { + margin: 0 0 14px; + color: #f3f6fa; + font-size: 14px; + font-weight: 520; +} + +.admin-inline-form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.admin-rail-form-panel { + padding-top: 2px; + border-top: 1px solid rgba(42, 45, 50, 0.92); +} + +.admin-drawer-form-panel { + padding-top: 0; + border-top: 0; +} + +.admin-rail-form-panel h4 { + margin: 0 0 12px; + color: #f3f6fa; + font-size: 12px; + font-weight: 520; +} + +.admin-drawer-form-panel h4 { + margin: 0 0 14px; + color: #f3f6fa; + font-size: 14px; + font-weight: 520; +} + +.admin-rail-form-grid { + display: grid; + grid-template-columns: 1fr; + gap: 0; +} + +.admin-rail-form-panel .modal-form-group label { + margin-bottom: 5px; + color: #8d96a3; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.09em; +} + +.admin-drawer-form-panel .modal-form-group label { + margin-bottom: 4px; +} + +.admin-rail-form-panel .modal-form-group input, +.admin-rail-form-panel .admin-inline-textarea { + min-height: 36px; + padding: 8px 11px; + border: 1px solid rgba(42, 45, 50, 0.92); + border-radius: 0; + background: rgba(255, 255, 255, 0.02); + color: #eef3f8; + font-size: 12px; + box-shadow: none; +} + +.admin-drawer-form-panel .modal-form-group input, +.admin-drawer-form-panel .admin-inline-textarea { + width: 100%; + padding: 10px 12px; + border: 1px solid var(--ops-border); + border-radius: 10px; + font-size: 15px; + color: var(--ops-text); + background-color: rgba(9, 17, 27, 0.88); +} + +.admin-rail-form-panel .admin-inline-textarea { + min-height: 88px; +} + +.admin-rail-form-panel .modal-form-group input:focus, +.admin-rail-form-panel .admin-inline-textarea:focus { + border-color: rgba(71, 151, 255, 0.42); + background: rgba(255, 255, 255, 0.03); + box-shadow: inset 0 0 0 1px rgba(71, 151, 255, 0.12); +} + +.admin-drawer-form-panel .modal-form-group input:focus, +.admin-drawer-form-panel .admin-inline-textarea:focus { + outline: none; + border-color: var(--ops-accent); + box-shadow: 0 0 0 3px rgba(11, 93, 125, 0.12); +} + +.admin-rail-form-panel .modal-buttons { + justify-content: stretch; +} + +.admin-rail-form-panel .modal-buttons button { + flex: 1 1 0; +} + +.admin-inline-textarea { + width: 100%; + min-height: 88px; + padding: 10px 12px; + border: 1px solid var(--ops-border); + border-radius: 10px; + font-size: 15px; + color: var(--ops-text); + background-color: rgba(9, 17, 27, 0.88); + resize: vertical; +} + +.admin-inline-textarea:focus { + outline: none; + border-color: var(--ops-accent); + box-shadow: 0 0 0 3px rgba(11, 93, 125, 0.12); +} + +.admin-inline-toggle-row { + margin-bottom: 16px; +} + +.admin-inline-toggle-row label { + display: inline-flex; + align-items: center; + gap: 8px; + color: var(--ops-text); + font-size: 13px; + font-weight: 500; +} + +.esp-admin { + display: grid; + gap: 14px; + min-width: 0; +} + +.esp-live-meta { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + margin-top: 10px; +} + +.esp-live-pill { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 5px 10px; + border-radius: 6px; + border: 1px solid #464b53; + background: rgba(19, 20, 22, 0.48); + color: #b5bdc8; + font-size: 11px; + font-weight: 450; +} + +.esp-live-pill::before { + content: ""; + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--ops-success); + box-shadow: 0 0 0 0 rgba(23, 108, 72, 0.28); +} + +.esp-live-pill.is-refreshing::before { + animation: opsPulse 1s ease-out infinite; +} + +.esp-live-timestamp { + color: var(--ops-muted); + font-size: 11px; + font-weight: 400; +} + +.esp-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 16px; +} + +.esp-form-card h4 { + margin-bottom: 14px; +} + +.esp-summary-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 10px; + margin-bottom: 14px; +} + +.esp-summary-card { + border-top: 1px solid rgba(74, 82, 92, 0.58); + border-bottom: 1px solid rgba(33, 36, 42, 0.95); + border-left: 1px solid rgba(42, 46, 52, 0.78); + border-right: 1px solid rgba(42, 46, 52, 0.78); + background: linear-gradient(180deg, rgba(30, 34, 39, 0.94) 0%, rgba(19, 21, 25, 0.94) 100%); + border-radius: 3px; + padding: 14px; + min-height: 96px; +} + +.esp-summary-card strong, +.esp-summary-card span { + display: block; +} + +.esp-summary-card strong { + font-size: 24px; + color: #f3f6fa; +} + +.esp-summary-card span { + color: var(--ops-muted); + font-size: 11px; +} + +.esp-tab-strip { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin-bottom: 14px; +} + +.esp-tab { + border: 0; + background: transparent; + color: #c7cdd6; + border-radius: 2px; + padding: 8px 12px; + cursor: pointer; + font-weight: 450; + transition: background-color 0.18s ease, color 0.18s ease; +} + +.esp-tab:hover { + background: rgba(5, 37, 77, 0.75); + color: #fff; +} + +.esp-tab.active { + background: #34383e; + color: #fff; +} + +.esp-reader-list, +.esp-compact-list { + display: grid; + gap: 10px; +} + +.esp-reader-row, +.esp-compact-row { + display: flex; + justify-content: space-between; + gap: 14px; + align-items: center; + border-top: 1px solid rgba(64, 71, 80, 0.55); + border-bottom: 1px solid rgba(34, 38, 44, 0.96); + border-left: 1px solid rgba(42, 46, 52, 0.78); + border-right: 1px solid rgba(42, 46, 52, 0.78); + border-radius: 3px; + padding: 12px 13px; + background: rgba(18, 20, 24, 0.74); +} + +.esp-reader-row strong, +.esp-reader-row span, +.esp-compact-row strong, +.esp-compact-row span { + display: block; +} + +.esp-reader-row span, +.esp-compact-row span { + color: var(--ops-muted); + font-size: 11px; +} + +.esp-edit-modal { + max-width: 620px; + width: min(94vw, 620px); +} + +.esp-check-row { + display: flex; + align-items: center; + gap: 10px; + margin-top: 10px; + color: #d7e0ec; + font-size: 12px; +} + +@keyframes opsPulse { + 0% { + box-shadow: 0 0 0 0 rgba(23, 108, 72, 0.28); + } + 100% { + box-shadow: 0 0 0 10px rgba(23, 108, 72, 0); + } +} + +.admin-pagination { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + margin-top: 12px; + color: var(--ops-muted); + font-size: 11px; +} + +.admin-pagination div { + display: flex; + gap: 8px; +} + +.admin-empty { + color: var(--ops-subtle); + padding: 12px 0; +} + +.role-badge { + display: inline-block; + padding: 4px 8px; + border-radius: 4px; + font-size: 11px; + font-weight: 600; + text-transform: capitalize; +} + +.role-member { + background: var(--ops-success-soft); + color: var(--ops-success); +} + +.role-admin { + background: var(--ops-warning-soft); + color: var(--ops-warning); +} + +.role-super_admin { + background: var(--ops-coral-soft); + color: #ffd7df; +} + +.drawer-overlay { + position: fixed; + inset: 0; + z-index: 2600; + background: rgba(5, 8, 12, 0.58); + display: flex; + justify-content: flex-end; + backdrop-filter: blur(3px); +} + +.user-drawer { + width: min(430px, 100vw); + height: 100vh; + overflow-y: auto; + background: #161a1e; + border-left: 1px solid rgba(56, 62, 71, 0.95); + box-shadow: -18px 0 40px rgba(0, 0, 0, 0.26); + padding: 0 0 26px; + scrollbar-width: thin; + scrollbar-color: rgba(93, 100, 112, 0.75) transparent; +} + +.property-drawer { + display: flex; + flex-direction: column; +} + +.drawer-header { + position: sticky; + top: 0; + z-index: 5; + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 14px; + padding: 18px 20px 16px; + background: rgba(22, 26, 30, 0.96); + border-bottom: 1px solid rgba(54, 59, 67, 0.95); +} + +.drawer-header-main { + min-width: 0; +} + +.drawer-eyebrow { + display: inline-block; + margin-bottom: 8px; + color: #8ea0b8; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.16em; + text-transform: uppercase; +} + +.drawer-header h3 { + margin: 0 0 6px; + font-size: 24px; + line-height: 1.1; + color: #f8fbff; + letter-spacing: -0.03em; +} + +.drawer-header p { + margin: 0; + color: #c7d0dc; + font-size: 13px; + line-height: 1.45; +} + +.drawer-header-actions { + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; + justify-content: flex-end; +} + +.drawer-close { + width: 34px; + height: 34px; + border: 1px solid rgba(62, 68, 77, 0.92); + background: rgba(20, 23, 27, 0.96); + color: #dbe4ef; + font-size: 22px; + line-height: 1; + cursor: pointer; + transition: background 0.18s ease, border-color 0.18s ease, color 0.18s ease; +} + +.drawer-close:hover { + background: rgba(71, 151, 255, 0.12); + border-color: rgba(71, 151, 255, 0.38); + color: #fff; +} + +.drawer-hero { + padding: 14px 20px 0; +} + +.drawer-hero-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0; + border-top: 1px solid rgba(38, 44, 50, 0.88); + border-left: 1px solid rgba(38, 44, 50, 0.88); +} + +.drawer-hero-card { + min-width: 0; + display: grid; + gap: 5px; + padding: 12px 14px; + background: transparent; + border-right: 1px solid rgba(38, 44, 50, 0.88); + border-bottom: 1px solid rgba(38, 44, 50, 0.88); +} + +.drawer-hero-label { + color: #90a0b4; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.drawer-hero-value { + min-width: 0; + color: #f7fbff; + font-size: 13px; + font-weight: 600; + line-height: 1.35; + overflow: hidden; + text-overflow: ellipsis; +} + +.drawer-body { + display: grid; + gap: 0; + padding: 16px 20px 0; +} + +.drawer-section { + background: transparent; + border: 0; + border-top: 1px solid rgba(38, 44, 50, 0.88); + border-radius: 0; + padding: 18px 0; + margin-bottom: 0; +} + +.drawer-section:first-child { + border-top: 0; +} + +.drawer-section-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-bottom: 12px; +} + +.drawer-section h4 { + margin: 0; + color: #f3f7fb; + font-size: 14px; + font-weight: 700; + letter-spacing: 0.02em; +} + +.drawer-section-meta { + color: #95a4b8; + font-size: 11px; +} + +.drawer-data-list { + display: grid; + gap: 6px; +} + +.drawer-data-row { + display: grid; + grid-template-columns: 112px minmax(0, 1fr); + gap: 14px; + padding: 10px 0; + border-top: 1px solid rgba(38, 44, 50, 0.88); +} + +.drawer-data-row:first-child { + padding-top: 0; + border-top: 0; +} + +.drawer-data-label { + color: #8fa0b4; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.drawer-data-value { + min-width: 0; + color: #f3f8fe; + font-size: 13px; + line-height: 1.45; + word-break: break-word; +} + +.drawer-control-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 12px; + margin-bottom: 12px; +} + +.drawer-control-grid label { + display: grid; + gap: 5px; + font-weight: 700; + color: var(--ops-text); + font-size: 11px; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.drawer-toggle-group { + display: grid; + gap: 8px; +} + +.drawer-toggle-label { + font-weight: 700; + color: var(--ops-text); + font-size: 11px; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.drawer-toggle-switch { + display: inline-flex; + align-items: center; + gap: 10px; + width: fit-content; + padding: 0; + border: 0; + background: transparent; + color: #c7d0dc; + cursor: pointer; +} + +.drawer-toggle-track { + position: relative; + width: 42px; + height: 24px; + border-radius: 999px; + background: rgba(38, 44, 50, 0.96); + border: 1px solid rgba(62, 68, 77, 0.92); + transition: background-color 0.18s ease, border-color 0.18s ease; +} + +.drawer-toggle-thumb { + position: absolute; + top: 2px; + left: 2px; + width: 18px; + height: 18px; + border-radius: 999px; + background: #c7d0dc; + transition: transform 0.18s ease, background-color 0.18s ease; +} + +.drawer-toggle-copy { + font-size: 12px; + font-weight: 600; + color: #c7d0dc; +} + +.drawer-toggle-switch.active .drawer-toggle-track { + background: rgba(47, 162, 82, 0.16); + border-color: rgba(47, 162, 82, 0.42); +} + +.drawer-toggle-switch.active .drawer-toggle-thumb { + transform: translateX(18px); + background: #2fa252; +} + +.drawer-toggle-switch.active .drawer-toggle-copy { + color: #e6ebf2; +} + +.drawer-empty-copy { + color: #b2bdca; + font-size: 13px; +} + +.drawer-mini-table { + width: 100%; + border-collapse: collapse; + font-size: 12px; +} + +.drawer-mini-table th, +.drawer-mini-table td { + padding: 10px 0; + border-bottom: 1px solid rgba(45, 49, 56, 0.86); + text-align: left; + vertical-align: top; +} + +.drawer-mini-table th { + color: #90a0b4; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.drawer-mini-table td { + color: #f2f7fc; +} + +.drawer-mini-table tbody tr:last-child td { + border-bottom: 0; +} + +.drawer-rsvp-summary { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; + margin-bottom: 14px; +} + +.drawer-rsvp-stat { + display: grid; + gap: 4px; + padding: 11px 12px; + background: rgba(20, 23, 28, 0.78); + border-top: 1px solid rgba(70, 76, 86, 0.55); + border-bottom: 1px solid rgba(34, 38, 44, 0.96); +} + +.drawer-rsvp-stat strong { + color: #f8fbff; + font-size: 18px; + font-weight: 650; +} + +.drawer-rsvp-label { + color: #8fa0b4; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; } /* Desktop view: side-by-side layout */ @@ -213,7 +2405,7 @@ body { } .container { - padding: 10px; + padding: 10px 8px; } .card { @@ -231,7 +2423,7 @@ body { /* Make tables responsive */ table { width: 100%; - min-width: 600px; /* Ensure minimum width for readability */ + min-width: 520px; } .table-container { @@ -268,29 +2460,117 @@ body { gap: 16px !important; } } + + .dashboard-tabs { + gap: 6px; + } + + .navbar-main { + width: 100%; + justify-content: space-between; + } + + .navbar-tab-strip { + width: 100%; + } + + .profile-question-row { + grid-template-columns: 1fr; + } + + .cookie-banner { + right: 10px; + bottom: 10px; + width: min(360px, calc(100vw - 20px)); + flex-direction: column; + align-items: flex-start; + } + + .admin-workspace { + grid-template-columns: 1fr; + } + + .admin-question-layout { + grid-template-columns: 1fr; + } + + .admin-question-sidebar .admin-surface { + position: static; + } + + .admin-filter-bar { + grid-template-columns: 1fr; + } + + .admin-page-header, + .admin-queue-item, + .admin-pagination { + align-items: stretch; + flex-direction: column; + } + + .admin-inline-form-grid { + grid-template-columns: 1fr; + } + + .admin-header-actions { + justify-content: stretch; + } + + .admin-header-actions .btn { + width: 100%; + } + + .user-drawer { + width: 100vw; + } + + .drawer-header, + .drawer-hero, + .drawer-body { + padding-left: 16px; + padding-right: 16px; + } + + .drawer-hero-grid, + .drawer-rsvp-summary { + grid-template-columns: 1fr; + } + + .drawer-data-row { + grid-template-columns: 1fr; + gap: 4px; + } } .status-badge { display: inline-block; - padding: 4px 12px; - border-radius: 12px; - font-size: 14px; - font-weight: 500; + padding: 4px 8px; + border-radius: 4px; + border: 0; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.status-badge::before { + content: none; } .status-active { - background-color: #d4edda; - color: #155724; + background-color: var(--ops-success-soft); + color: var(--ops-success); } .status-pending { - background-color: #fff3cd; - color: #856404; + background-color: var(--ops-warning-soft); + color: var(--ops-warning); } .status-expired { - background-color: #f8d7da; - color: #721c24; + background-color: var(--ops-danger-soft); + color: var(--ops-danger); } /* Modal Styles */ @@ -300,7 +2580,7 @@ body { left: 0; right: 0; bottom: 0; - background: rgba(0, 0, 0, 0.5); + background: rgba(3, 8, 14, 0.5); display: flex; align-items: center; justify-content: center; @@ -308,17 +2588,18 @@ body { } .modal-content { - background: white; + background: var(--ops-surface); padding: 24px; - border-radius: 8px; + border-radius: var(--ops-radius); width: 100%; max-width: 400px; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + box-shadow: none; + border: 1px solid rgba(120, 160, 196, 0.18); } .modal-content h3 { margin: 0 0 20px 0; - color: #333; + color: var(--ops-text); font-size: 18px; font-weight: bold; } @@ -331,32 +2612,37 @@ body { display: block; margin-bottom: 4px; font-weight: bold; - color: #333; + color: var(--ops-text); font-size: 14px; } .modal-form-group input { width: 100%; - padding: 8px; - border: 1px solid #ddd; - border-radius: 4px; - font-size: 16px; - color: #333; - background-color: #fff; + padding: 10px 12px; + border: 1px solid var(--ops-border); + border-radius: 10px; + font-size: 15px; + color: var(--ops-text); + background-color: rgba(9, 17, 27, 0.88); } .modal-form-group input:focus { outline: none; - border-color: #0066cc; + border-color: var(--ops-accent); + box-shadow: 0 0 0 3px rgba(11, 93, 125, 0.12); } .modal-error { - color: #dc3545; + color: var(--ops-danger); + background: var(--ops-danger-soft); + padding: 10px 12px; + border-radius: 8px; margin-bottom: 16px; font-size: 14px; } -.modal-buttons { +.modal-buttons, +.modal-button-row { display: flex; gap: 8px; justify-content: flex-end; @@ -364,30 +2650,30 @@ body { .modal-btn-cancel { padding: 8px 16px; - border: 1px solid #ddd; - border-radius: 4px; - background: white; + border: 1px solid var(--ops-border); + border-radius: var(--ops-radius); + background: var(--ops-surface); cursor: pointer; - color: #333; + color: var(--ops-text); font-size: 14px; } .modal-btn-cancel:hover { - background: #f8f9fa; + background: var(--ops-surface-muted); } .modal-btn-primary { padding: 8px 16px; border: none; - border-radius: 4px; - background: #007bff; + border-radius: var(--ops-radius); + background: linear-gradient(180deg, #0d789b 0%, var(--ops-accent-dark) 100%); color: white; cursor: pointer; font-size: 14px; } .modal-btn-primary:hover:not(:disabled) { - background: #0056b3; + background: var(--ops-accent-dark); } .modal-btn-primary:disabled { @@ -397,27 +2683,29 @@ body { /* Tab Styles */ .tab-active { - padding: 10px 20px; - border: none; - background: #007bff; + padding: 9px 14px; + border: 1px solid var(--ops-accent-dark); + background: linear-gradient(180deg, #0d789b 0%, var(--ops-accent-dark) 100%); color: white; cursor: pointer; - border-bottom: 2px solid #007bff; - font-weight: bold; + border-radius: var(--ops-radius); + font-weight: 600; } .tab-inactive { - padding: 10px 20px; - border: none; - background: none; - color: #666; + padding: 9px 14px; + border: 1px solid var(--ops-border); + background: var(--ops-surface); + color: var(--ops-text); cursor: pointer; - border-bottom: 2px solid transparent; + border-radius: var(--ops-radius); + font-weight: 600; } .tab-inactive:hover { - color: #007bff; - border-bottom-color: #007bff; + color: var(--ops-accent); + border-color: #b8d5e4; + background: var(--ops-accent-soft); } /* Super Admin Panel Styles */ @@ -441,9 +2729,9 @@ body { /* Action buttons in tables */ .action-btn { padding: 6px 12px; - border: 1px solid #007bff; - border-radius: 4px; - background: #007bff; + border: 1px solid var(--ops-accent); + border-radius: var(--ops-radius); + background: linear-gradient(180deg, #0d789b 0%, var(--ops-accent-dark) 100%); color: white !important; cursor: pointer; font-size: 12px; @@ -454,35 +2742,34 @@ body { } .action-btn:hover { - background: #0056b3; - border-color: #0056b3; + background: var(--ops-accent-dark); + border-color: var(--ops-accent-dark); color: white !important; } .action-btn-danger { - border-color: #dc3545; - background: #dc3545; + border-color: var(--ops-coral); + background: var(--ops-coral); color: white !important; } .action-btn-danger:hover { - background: #c82333; - border-color: #c82333; + background: #9f2f3d; + border-color: #9f2f3d; color: white !important; } /* Events Container Styles */ .events-container { - display: flex; - flex-direction: column; + display: grid; gap: 16px; } .event-card { - border: 1px solid #ddd; - border-radius: 8px; - padding: 16px; - background-color: #f9f9f9; + border: 1px solid var(--ops-border-soft); + border-radius: 16px; + padding: 18px; + background: var(--ops-surface-muted); } .event-header { @@ -500,7 +2787,7 @@ body { .event-title { margin: 0 0 4px 0; - color: #0066cc; + color: #eef7ff; font-size: 18px; word-wrap: break-word; } @@ -508,13 +2795,13 @@ body { .event-datetime { margin: 0; font-size: 14px; - color: #666; + color: var(--ops-muted); } .event-location { margin: 4px 0 0 0; font-size: 14px; - color: #666; + color: var(--ops-muted); } .event-rsvp-buttons { @@ -526,12 +2813,12 @@ body { .rsvp-btn { font-size: 12px; padding: 8px 16px; - border: 2px solid #adb5bd; - border-radius: 4px; - background-color: transparent; - color: #6c757d; + border: 1px solid rgba(120, 160, 196, 0.18); + border-radius: 999px; + background-color: rgba(9, 17, 27, 0.72); + color: var(--ops-muted); cursor: pointer; - font-weight: normal; + font-weight: 700; transition: all 0.3s ease; white-space: nowrap; } @@ -556,24 +2843,21 @@ body { } .rsvp-btn-attending.active { - border: 3px solid #28a745; - background-color: #28a745; + border: 1px solid rgba(36, 192, 138, 0.28); + background-color: rgba(36, 192, 138, 0.18); color: white; - box-shadow: 0 4px 8px rgba(40, 167, 69, 0.3); } .rsvp-btn-maybe.active { - border: 3px solid #ffc107; - background-color: #ffc107; - color: #212529; - box-shadow: 0 4px 8px rgba(255, 193, 7, 0.3); + border: 1px solid rgba(227, 162, 63, 0.28); + background-color: rgba(227, 162, 63, 0.18); + color: #fff4d8; } .rsvp-btn-not-attending.active { - border: 3px solid #dc3545; - background-color: #dc3545; + border: 1px solid rgba(217, 75, 98, 0.28); + background-color: rgba(217, 75, 98, 0.18); color: white; - box-shadow: 0 4px 8px rgba(220, 53, 69, 0.3); } .event-description { @@ -590,25 +2874,197 @@ body { } .event-rsvp-status.attending { - background-color: #d4edda; - border: 1px solid #c3e6cb; - color: #155724; + background-color: rgba(36, 192, 138, 0.14); + border: 1px solid rgba(36, 192, 138, 0.22); + color: #bcf5df; } .event-rsvp-status.maybe { - background-color: #fff3cd; - border: 1px solid #ffeaa7; - color: #856404; + background-color: rgba(227, 162, 63, 0.14); + border: 1px solid rgba(227, 162, 63, 0.22); + color: #ffeabf; } .event-rsvp-status.not_attending { - background-color: #f8d7da; - border: 1px solid #f5c6cb; - color: #721c24; + background-color: rgba(217, 75, 98, 0.14); + border: 1px solid rgba(217, 75, 98, 0.22); + color: #ffd0d8; +} + +input, +textarea, +select { + font: inherit; +} + +input[type="checkbox"] { + appearance: none; + -webkit-appearance: none; + width: 34px; + height: 18px; + min-height: 18px; + flex: 0 0 34px; + margin: 0; + padding: 0; + border: 1px solid #41474f; + border-radius: 999px; + background: #252a30; + cursor: pointer; + position: relative; + transition: background 0.14s ease, border-color 0.14s ease; + vertical-align: middle; +} + +input[type="checkbox"]::after { + content: ""; + position: absolute; + top: 1px; + left: 1px; + width: 14px; + height: 14px; + border-radius: 50%; + background: #d7e0ec; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.32); + transition: transform 0.14s ease, background 0.14s ease; +} + +input[type="checkbox"]:checked { + background: var(--ops-accent-dark); + border-color: var(--ops-accent); +} + +input[type="checkbox"]:checked::after { + transform: translateX(16px); + background: #ffffff; +} + +input[type="checkbox"]:focus-visible { + outline: none; + box-shadow: 0 0 0 3px rgba(71, 151, 255, 0.18); +} + +input[type="checkbox"]:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +button, +a, +input, +textarea, +select { + transition: background-color 0.18s ease, border-color 0.18s ease, color 0.18s ease, box-shadow 0.18s ease; +} + +table { + border-collapse: collapse; +} + +th { + background: var(--ops-surface-muted); + color: var(--ops-muted); + font-size: 11px; + letter-spacing: 0.01em; +} + +td, +th { + border-bottom: 1px solid rgba(42, 45, 50, 0.72); +} + +.card h2, +.card h3, +.card h4, +.admin-panel h3, +.admin-panel h4, +.drawer-section h4 { + color: var(--ops-text); +} + +.card p, +.event-description { + color: var(--ops-text); +} + +.welcome-section, +.auth-card > div[style*="linear-gradient"], +.card div[style*="backgroundColor: '#f5f5f5'"], +.card div[style*="background-color: #f5f5f5"] { + background: var(--ops-surface-muted) !important; + border: 1px solid var(--ops-border-soft) !important; + border-radius: var(--ops-radius) !important; + box-shadow: none !important; +} + +.rsvp-btn { + border-radius: var(--ops-radius); +} + +.rsvp-btn.active { + transform: none; +} + +.rsvp-btn-attending.active, +.rsvp-btn-maybe.active, +.rsvp-btn-not-attending.active { + box-shadow: none; } /* Mobile responsive adjustments for events */ @media (max-width: 768px) { + .portal-topbar { + grid-template-columns: 1fr; + gap: 8px; + padding: 9px 10px; + position: relative; + } + + .portal-nav, + .portal-meta { + justify-content: flex-start; + flex-wrap: wrap; + } + + .portal-topbar-admin { + grid-template-columns: 1fr; + } + + .admin-workspace { + grid-template-columns: 1fr; + } + + .admin-primary-rail, + .admin-page-rail { + position: static; + min-height: auto; + padding: 10px; + border-right: 0; + border-bottom: 1px solid rgba(42, 45, 50, 0.92); + } + + .admin-primary-nav, + .admin-page-nav { + grid-template-columns: repeat(auto-fit, minmax(68px, 1fr)); + } + + .admin-primary-link { + height: 40px; + } + + .admin-page-link { + text-align: center; + } + + .auth-container { + grid-template-columns: 1fr; + align-content: start; + padding: 10px; + } + + .auth-card { + max-width: none; + } + .event-header { flex-direction: column; align-items: stretch; @@ -643,3 +3099,1368 @@ body { grid-template-columns: 1fr !important; } } + +/* Member experience */ +.auth-shell { + background: + radial-gradient(circle at top left, rgba(193, 214, 175, 0.4), transparent 28%), + radial-gradient(circle at top right, rgba(151, 186, 198, 0.28), transparent 24%), + linear-gradient(180deg, #f5f1e8 0%, #eef3ea 42%, #e8efe3 100%); + color: #1f2c22; +} + +.auth-topbar, +.member-topbar { + background: rgba(245, 241, 232, 0.86); + border-bottom: 1px solid rgba(83, 102, 84, 0.16); + backdrop-filter: blur(14px); +} + +.auth-shell .portal-mark, +.member-topbar .portal-mark { + background: linear-gradient(135deg, #314d3d 0%, #597861 100%); + box-shadow: 0 10px 24px rgba(49, 77, 61, 0.18); +} + +.member-experience { + min-height: calc(100vh - 44px); + background: + radial-gradient(circle at top left, rgba(197, 214, 196, 0.32), transparent 30%), + linear-gradient(180deg, #f8faf7 0%, #eef4ec 45%, #e7efe3 100%); + color: #203125; +} + +.auth-shell .portal-brand-text h1, +.member-topbar .portal-brand-text h1 { + color: #1d2b21; +} + +.auth-shell .portal-subtitle, +.member-topbar .portal-subtitle { + color: #5f6f62; +} + +.auth-container { + padding: 40px 24px; +} + +.auth-container-wide { + grid-template-columns: minmax(300px, 420px) minmax(380px, 620px); +} + +.auth-welcome-card, +.auth-card, +.member-card { + border: 1px solid rgba(89, 120, 98, 0.16); + border-radius: 24px; + background: rgba(255, 252, 246, 0.86); + box-shadow: 0 24px 60px rgba(44, 66, 47, 0.08); +} + +.auth-welcome-card { + padding: 32px; + gap: 18px; +} + +.auth-kicker, +.member-card-kicker, +.member-hero-kicker { + color: #4d6955; + letter-spacing: 0.12em; + font-weight: 700; +} + +.auth-welcome-card h2, +.member-hero-title { + font-family: Georgia, "Times New Roman", serif; + color: #203125; + line-height: 1.06; +} + +.auth-welcome-card h2 { + font-size: clamp(2.1rem, 3vw, 3rem); +} + +.auth-welcome-card p, +.auth-card-copy, +.member-hero-copy, +.member-muted-copy, +.membership-confirm-copy { + color: #405244; + font-size: 15px; + line-height: 1.65; +} + +.auth-feature-list { + display: grid; + gap: 10px; +} + +.auth-feature-item { + display: flex; + align-items: flex-start; + gap: 10px; + color: #314239; + line-height: 1.5; +} + +.auth-feature-item::before { + content: ""; + width: 9px; + height: 9px; + margin-top: 7px; + border-radius: 50%; + background: linear-gradient(135deg, #758d63 0%, #315846 100%); + flex: 0 0 auto; +} + +.auth-card { + max-width: none; +} + +.auth-card-wide { + max-width: 620px; +} + +.auth-card-head { + min-height: 72px; + padding: 0 24px; + border-bottom: 1px solid rgba(89, 120, 98, 0.14); +} + +.auth-card-head h2 { + font-family: Georgia, "Times New Roman", serif; + font-size: 28px; + color: #203125; +} + +.auth-card-head span { + color: #6a7f6f; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.auth-card-body { + padding: 24px; +} + +.auth-card form, +.auth-footer { + padding: 0; +} + +.auth-footer { + padding: 18px 24px 24px; + border-top: 1px solid rgba(89, 120, 98, 0.14); + color: #405244; +} + +.auth-footer a { + color: #2f5d49; + font-weight: 600; +} + +.auth-form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px 18px; +} + +.form-group-full { + grid-column: 1 / -1; +} + +.auth-shell .form-group label, +.member-experience .form-group label { + color: #2d4035; + font-size: 13px; + font-weight: 700; +} + +.auth-shell .form-group input, +.auth-shell .form-group textarea, +.auth-shell .form-group select, +.member-experience .form-group input, +.member-experience .form-group textarea, +.member-experience .form-group select { + min-height: 46px; + padding: 11px 14px; + border: 1px solid rgba(89, 120, 98, 0.24); + border-radius: 14px; + background: rgba(255, 255, 255, 0.82); + color: #203125; + font-size: 14px; +} + +.auth-shell .form-group textarea, +.member-experience .form-group textarea { + min-height: 108px; + resize: vertical; +} + +.auth-shell .form-group input::placeholder, +.auth-shell .form-group textarea::placeholder, +.member-experience .form-group input::placeholder, +.member-experience .form-group textarea::placeholder { + color: #819082; +} + +.auth-shell .form-group input:focus, +.auth-shell .form-group textarea:focus, +.auth-shell .form-group select:focus, +.member-experience .form-group input:focus, +.member-experience .form-group textarea:focus, +.member-experience .form-group select:focus { + border-color: #5f7f69; + box-shadow: 0 0 0 4px rgba(95, 127, 105, 0.14); +} + +.form-hint { + display: block; + margin-top: 6px; + color: #6c7f70; + font-size: 12px; +} + +.hint-success { + color: #2e7d4f; +} + +.hint-error { + color: #b44747; +} + +.field-success { + border-color: rgba(46, 125, 79, 0.5) !important; +} + +.field-error { + border-color: rgba(180, 71, 71, 0.56) !important; +} + +.auth-shell .btn, +.member-experience .btn, +.member-topbar .btn { + min-height: 44px; + padding: 10px 18px; + border-radius: 999px; + font-size: 13px; + font-weight: 700; +} + +.auth-shell .btn-primary, +.member-experience .btn-primary, +.member-topbar .btn-primary { + border-color: #315846; + background: linear-gradient(135deg, #315846 0%, #5a7a61 100%); + color: #fff; +} + +.auth-shell .btn-primary:hover, +.member-experience .btn-primary:hover, +.member-topbar .btn-primary:hover { + background: linear-gradient(135deg, #294738 0%, #4f6f57 100%); + color: #fff; +} + +.auth-shell .btn-secondary, +.member-experience .btn-secondary, +.member-topbar .btn-secondary { + border-color: rgba(89, 120, 98, 0.28); + background: rgba(255, 255, 255, 0.7); + color: #274635; +} + +.auth-shell .btn-secondary:hover, +.member-experience .btn-secondary:hover, +.member-topbar .btn-secondary:hover { + border-color: rgba(49, 88, 70, 0.36); + background: rgba(255, 255, 255, 0.96); + color: #203125; +} + +.auth-shell .alert, +.member-experience .alert { + border-radius: 16px; +} + +.auth-shell .alert-success, +.member-experience .alert-success { + background: rgba(69, 132, 88, 0.12); + color: #245538; + border-color: rgba(69, 132, 88, 0.18); +} + +.auth-shell .alert-error, +.member-experience .alert-error { + background: rgba(194, 78, 78, 0.1); + color: #8a2d2d; + border-color: rgba(194, 78, 78, 0.2); +} + +.member-experience { + padding-top: 28px; + padding-bottom: 36px; +} + +.member-topbar { + background: rgba(248, 250, 247, 0.92); + border-bottom: 1px solid rgba(89, 120, 98, 0.18); + backdrop-filter: blur(14px); +} + +.member-topbar .portal-tab { + border-color: rgba(89, 120, 98, 0.18); + color: #425547; + background: rgba(255, 255, 255, 0.66); +} + +.member-topbar .portal-tab:hover { + background: rgba(89, 120, 98, 0.08); + color: #203125; +} + +.member-topbar .portal-tab.active { + background: #315846; + border-color: #315846; + color: #fff; +} + +.member-topbar .portal-switch-button, +.member-topbar .portal-exit-button { + border-color: rgba(89, 120, 98, 0.26); + background: rgba(255, 255, 255, 0.72); + color: #274635; +} + +.member-topbar .portal-switch-button:hover, +.member-topbar .portal-exit-button:hover { + background: rgba(89, 120, 98, 0.08); + border-color: rgba(49, 88, 70, 0.36); + color: #203125; +} + +.member-loading-state { + min-height: 60vh; + display: grid; + place-items: center; + color: #314239; + font-size: 16px; +} + +.member-hero { + display: grid; + grid-template-columns: minmax(0, 1.6fr) minmax(280px, 1fr); + gap: 18px; + margin-bottom: 20px; + padding: 24px 28px; + border-radius: 28px; + background: + linear-gradient(135deg, rgba(255, 252, 246, 0.92) 0%, rgba(248, 245, 236, 0.88) 100%), + radial-gradient(circle at top right, rgba(143, 172, 140, 0.16), transparent 38%); + border: 1px solid rgba(89, 120, 98, 0.14); + box-shadow: 0 24px 60px rgba(44, 66, 47, 0.08); +} + +.member-hero-title { + margin: 4px 0 10px; + font-size: clamp(2rem, 2.6vw, 3rem); +} + +.member-stat-strip { + display: grid; + gap: 12px; +} + +.toast-viewport { + position: fixed; + right: 16px; + bottom: 16px; + z-index: 2800; + width: min(380px, calc(100vw - 32px)); + display: grid; + gap: 10px; +} + +.toast { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 12px; + align-items: start; + padding: 12px 14px; + border: 1px solid rgba(42, 46, 52, 0.9); + border-left: 4px solid #4797ff; + background: rgba(24, 27, 31, 0.98); + color: #e6ebf2; + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.26); +} + +.toast-success { + border-left-color: #2fa252; +} + +.toast-error { + border-left-color: #ee6368; +} + +.toast-info { + border-left-color: #4797ff; +} + +.toast-message { + font-size: 13px; + line-height: 1.45; + word-break: break-word; +} + +.toast-close { + width: 24px; + height: 24px; + border: 0; + background: transparent; + color: #b5c0ce; + font-size: 18px; + line-height: 1; + cursor: pointer; +} + +.toast-close:hover { + color: #fff; +} + +.confirm-dialog { + width: min(460px, calc(100vw - 32px)); +} + +.confirm-dialog-title { + margin: 0 0 10px; + color: #f3f6fa; +} + +.confirm-dialog-title.danger { + color: #ffa2a6; +} + +.confirm-dialog-message { + margin: 0 0 20px; + color: #c6ced8; + line-height: 1.5; +} + +.member-stat-chip { + padding: 16px 18px; + border-radius: 18px; + background: rgba(255, 255, 255, 0.62); + border: 1px solid rgba(89, 120, 98, 0.14); +} + +.member-stat-label { + display: block; + margin-bottom: 6px; + color: #4c5e50; + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.member-stat-value { + color: #203125; + font-size: 20px; +} + +.member-overview-grid { + grid-template-columns: minmax(320px, 420px) minmax(0, 1fr); + align-items: start; +} + +.member-card { + padding: 24px; + margin-bottom: 20px; +} + +.member-card-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 14px; + margin-bottom: 18px; +} + +.member-card h3, +.member-card h4, +.member-section-heading { + color: #203125; +} + +.member-tier-title { + margin-bottom: 16px; + color: #315846 !important; + font-size: 24px; +} + +.member-data-list { + display: grid; + gap: 10px; +} + +.member-data-row { + display: grid; + grid-template-columns: minmax(130px, 180px) 1fr; + gap: 12px; + color: #405244; +} + +.member-data-row strong { + color: #233227; +} + +.member-info-panel, +.membership-summary-panel, +.membership-tier-benefits { + margin-top: 18px; + padding: 16px 18px; + border-radius: 18px; + background: rgba(239, 245, 236, 0.92); + border: 1px solid rgba(89, 120, 98, 0.14); +} + +.member-info-panel p, +.membership-summary-panel p, +.membership-tier-benefits p { + margin-top: 8px; + color: #4c5e50 !important; +} + +.member-table { + width: 100%; +} + +.member-table th, +.member-table td { + padding: 14px 12px; + text-align: left; + border-bottom: 1px solid rgba(89, 120, 98, 0.14); +} + +.member-table th { + background: rgba(239, 245, 236, 0.9); + color: #405244; + font-size: 12px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.member-table td { + color: #2c3c30; +} + +.member-table-caps { + text-transform: capitalize; +} + +.member-experience .status-badge { + border-radius: 999px; + border-color: rgba(89, 120, 98, 0.14); + background: rgba(255, 255, 255, 0.76); +} + +.member-experience .profile-menu-trigger { + background: rgba(255, 255, 255, 0.78); + border-color: rgba(89, 120, 98, 0.22); + color: #203125; +} + +.member-experience .profile-menu-chevron { + color: #6a7f6f; +} + +.member-experience .profile-menu-dropdown { + background: rgba(255, 253, 248, 0.96); + border-color: rgba(89, 120, 98, 0.18); +} + +.member-experience .profile-menu-summary { + background: rgba(239, 245, 236, 0.94); + border-bottom-color: rgba(89, 120, 98, 0.14); +} + +.member-experience .profile-menu-summary h4 { + color: #516657; +} + +.member-experience .profile-menu-edit { + background: rgba(49, 88, 70, 0.08); + border-color: rgba(49, 88, 70, 0.18); + color: #274635; +} + +.member-experience .profile-menu-details { + color: #55685b; +} + +.member-experience .profile-menu-details strong { + color: #203125; +} + +.member-experience .profile-menu-item { + color: #203125; + border-top-color: rgba(89, 120, 98, 0.12); +} + +.member-experience .profile-menu-item:hover { + background: rgba(89, 120, 98, 0.08); +} + +.member-settings-card { + max-width: 980px; +} + +.member-settings-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 12px 16px; +} + +.member-settings-actions { + display: flex; + justify-content: flex-end; + margin-top: 12px; +} + +.member-settings-divider { + height: 1px; + margin: 26px 0 18px; + background: rgba(89, 120, 98, 0.14); +} + +.member-section-heading { + margin-bottom: 10px; +} + +.membership-tier-grid, +.membership-payment-options { + display: grid; + gap: 14px; +} + +.membership-tier-card { + padding: 18px; + border-radius: 20px; + border: 1px solid rgba(89, 120, 98, 0.16); + background: rgba(255, 255, 255, 0.75); + cursor: pointer; +} + +.membership-tier-card:hover { + border-color: rgba(49, 88, 70, 0.34); + box-shadow: 0 16px 30px rgba(49, 77, 61, 0.1); +} + +.membership-tier-header { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: flex-start; +} + +.membership-tier-header h4 { + margin: 0; + color: #274635 !important; +} + +.membership-tier-price { + color: #315846; + font-size: 18px; + font-weight: 800; +} + +.membership-tier-description { + margin-top: 10px; + color: #405244 !important; +} + +.membership-payment-stage, +.membership-action-row { + display: grid; + gap: 14px; +} + +.membership-payment-heading { + margin-bottom: 2px; +} + +.membership-payment-options .btn { + justify-content: space-between; + padding: 16px 18px; +} + +.membership-payment-option-copy { + display: grid; + gap: 4px; +} + +.membership-payment-option-copy div { + font-size: 14px; + opacity: 0.86; +} + +.membership-cash-notice { + margin-bottom: 20px; + padding: 16px 18px; + border-radius: 18px; + background: rgba(214, 177, 78, 0.12); + border: 1px solid rgba(214, 177, 78, 0.22); + color: #6f5620; +} + +.membership-cash-notice p { + margin-top: 8px; +} + +.membership-setup-actions { + display: flex; + justify-content: center; + margin-top: 20px; +} + +.events-container { + gap: 14px; +} + +.member-card .event-card { + border: 1px solid rgba(89, 120, 98, 0.14); + border-radius: 20px; + padding: 18px; + background: rgba(255, 255, 255, 0.72); +} + +.member-card .event-title { + color: #203125; +} + +.member-card .event-datetime, +.member-card .event-location, +.member-card .event-description { + color: #405244; +} + +.member-card .event-location::before { + content: "Location"; + display: inline-block; + margin-right: 8px; + color: #55685b; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.member-card .rsvp-btn { + border-color: rgba(89, 120, 98, 0.14); + background: rgba(248, 245, 236, 0.88); + color: #405244; +} + +.member-card .rsvp-btn:not(.active) { + opacity: 1; + filter: none; +} + +.member-card .rsvp-btn-attending.active { + background-color: rgba(69, 132, 88, 0.12); + border-color: rgba(69, 132, 88, 0.22); + color: #245538; +} + +.member-card .rsvp-btn-maybe.active { + background-color: rgba(214, 177, 78, 0.14); + border-color: rgba(214, 177, 78, 0.22); + color: #6f5620; +} + +.member-card .rsvp-btn-not-attending.active { + background-color: rgba(194, 78, 78, 0.12); + border-color: rgba(194, 78, 78, 0.2); + color: #8a2d2d; +} + +.member-card .event-rsvp-status.attending { + background-color: rgba(69, 132, 88, 0.12); + border-color: rgba(69, 132, 88, 0.18); + color: #245538; +} + +.member-card .event-rsvp-status.maybe { + background-color: rgba(214, 177, 78, 0.14); + border-color: rgba(214, 177, 78, 0.2); + color: #6f5620; +} + +.member-card .event-rsvp-status.not_attending { + background-color: rgba(194, 78, 78, 0.12); + border-color: rgba(194, 78, 78, 0.2); + color: #8a2d2d; +} + +.site-footer { + background: rgba(248, 250, 247, 0.94); + color: #5f6f62; + border-top: 1px solid rgba(89, 120, 98, 0.14); +} + +.site-footer-caption { + margin-top: 8px; +} + +.site-footer a { + color: #2f5d49; +} + +.cookie-banner { + background: rgba(255, 255, 255, 0.94); + color: #203125; + border-color: rgba(89, 120, 98, 0.18); + border-left-color: #315846; +} + +.cookie-banner-button { + padding: 6px 12px; +} + +.member-experience { + min-height: 100%; + background: + radial-gradient(circle at top left, rgba(141, 170, 144, 0.14), transparent 28%), + linear-gradient(180deg, rgba(252, 253, 250, 0.98) 0%, rgba(244, 248, 242, 0.98) 100%); + color: #203125; +} + +.portal-container-admin, +.admin-workspace { + background: var(--ops-bg); +} + +.member-experience .card, +.member-experience .profile-questions-form.member-surface { + background: rgba(255, 252, 247, 0.92); + border: 1px solid rgba(89, 120, 98, 0.14); + box-shadow: 0 18px 36px rgba(56, 82, 61, 0.08); +} + +.member-experience .form-group label, +.member-experience .profile-question-label, +.member-experience .profile-question-readonly { + color: #203125; +} + +.member-experience .form-group input, +.member-experience .form-group textarea, +.member-experience .form-group select, +.member-experience .profile-question-input { + background: rgba(255, 255, 255, 0.94); + color: #203125; + border: 1px solid rgba(89, 120, 98, 0.18); + border-radius: 10px; +} + +.member-experience .form-group input::placeholder, +.member-experience .form-group textarea::placeholder, +.member-experience .profile-question-input::placeholder { + color: #7a8a7c; +} + +.member-experience .alert-success { + background: rgba(69, 132, 88, 0.1); + color: #245538; + border-color: rgba(69, 132, 88, 0.18); +} + +.member-experience .alert-error { + background: rgba(194, 78, 78, 0.1); + color: #8a2d2d; + border-color: rgba(194, 78, 78, 0.18); +} + +.member-inline-action { + margin-top: 16px; +} + +.member-rsvp-state { + text-transform: capitalize; +} + +.profile-questions-form { + margin-top: 20px; +} + +.profile-questions-form.admin-surface { + padding: 0; + background: transparent; + border: 0; + margin-top: 0; +} + +.profile-questions-form.admin-surface .profile-questions-title { + margin-bottom: 6px; + padding: 0; +} + +.profile-questions-form.admin-surface .profile-questions-description, +.profile-questions-form.admin-surface .profile-questions-search, +.profile-questions-form.admin-surface .alert, +.profile-questions-form.admin-surface .profile-questions-pagination, +.profile-questions-form.admin-surface .profile-questions-actions { + margin-left: 0; + margin-right: 0; +} + +.profile-questions-form.admin-surface .profile-questions-search { + margin-bottom: 16px; +} + +.profile-questions-form.admin-surface .profile-question-input, +.profile-questions-form.admin-surface .profile-question-readonly { + background: #111214; + color: #e6ebf2; + border: 1px solid rgba(70, 75, 83, 0.9); +} + +.profile-questions-form.admin-surface .profile-question-input::placeholder { + color: #8d96a3; +} + +.profile-questions-form.admin-surface .profile-question-input:focus { + border-color: var(--ops-accent); + box-shadow: 0 0 0 3px rgba(71, 151, 255, 0.12); +} + +.profile-questions-form.admin-surface .profile-question-label, +.profile-questions-form.admin-surface .profile-questions-title { + color: #f3f6fa; +} + +.profile-questions-form.admin-surface .profile-question-help, +.profile-questions-form.admin-surface .profile-questions-description, +.profile-questions-form.admin-surface .profile-questions-empty, +.profile-questions-form.admin-surface .profile-questions-page-copy, +.profile-questions-form.admin-surface .profile-question-lock-note { + color: #8d96a3; +} + +.profile-questions-title { + margin-bottom: 8px; +} + +.profile-questions-description { + margin-bottom: 16px; +} + +.profile-questions-search, +.profile-questions-list { + display: grid; +} + +.profile-questions-search { + gap: 10px; + margin-bottom: 14px; +} + +.profile-questions-list { + gap: 16px; +} + +.profile-question-label { + display: block; + margin-bottom: 4px; + font-weight: 600; +} + +.profile-question-required { + color: #c24e4e; +} + +.profile-question-help, +.profile-questions-description, +.profile-questions-empty, +.profile-questions-page-copy { + color: #4c5e50; + font-size: 13px; +} + +.profile-question-help { + margin-bottom: 0; +} + +.profile-question-lock-note { + margin-top: 6px; + grid-column: 1 / -1; + color: #4c5e50; + font-size: 12px; + font-weight: 600; +} + +.profile-question-input { + width: 100%; + padding: 10px 12px; +} + +.profile-questions-pagination, +.profile-questions-pager-buttons, +.profile-questions-actions { + display: flex; + gap: 10px; +} + +.profile-questions-pagination { + margin-top: 14px; + align-items: center; + justify-content: space-between; +} + +.profile-questions-pager-buttons { + gap: 8px; +} + +.profile-questions-pager-button { + padding: 6px 12px; + font-size: 13px; +} + +.profile-questions-actions { + margin-top: 16px; + justify-content: flex-end; +} + +.member-hero { + border-radius: 18px; +} + +.member-stat-chip, +.member-info-panel, +.membership-summary-panel, +.membership-tier-benefits, +.membership-tier-card, +.member-card .event-card, +.cookie-banner, +.site-footer, +.member-experience .profile-menu-trigger, +.member-experience .profile-menu-dropdown { + border-radius: 12px; +} + +.member-card, +.profile-questions-form.member-surface { + border-radius: 14px; +} + +/* Dark mode reset for member/auth surfaces */ +.auth-shell { + background: + radial-gradient(circle at top left, rgba(33, 40, 46, 0.42), transparent 26%), + radial-gradient(circle at top right, rgba(22, 30, 38, 0.34), transparent 22%), + linear-gradient(180deg, #111417 0%, #14181c 48%, #171c21 100%); + color: var(--ops-text); +} + +.auth-topbar, +.member-topbar { + background: rgba(24, 27, 31, 0.9); + border-bottom: 1px solid rgba(47, 53, 60, 0.9); +} + +.auth-shell .portal-mark, +.member-topbar .portal-mark { + background: var(--ops-accent-dark); + box-shadow: none; +} + +.auth-shell .portal-brand-text h1, +.member-topbar .portal-brand-text h1 { + color: #f3f6fa; +} + +.auth-shell .portal-subtitle, +.member-topbar .portal-subtitle { + color: #aab2bd; +} + +.auth-welcome-card, +.auth-card, +.member-card, +.member-experience .card, +.member-experience .profile-questions-form.member-surface { + background: rgba(24, 27, 31, 0.96); + border-top: 1px solid rgba(70, 76, 86, 0.7); + border-bottom: 1px solid rgba(34, 38, 44, 0.96); + border-left: 1px solid rgba(42, 46, 52, 0.85); + border-right: 1px solid rgba(42, 46, 52, 0.85); + box-shadow: none; + color: var(--ops-text); +} + +.auth-kicker, +.member-card-kicker, +.member-hero-kicker, +.auth-card-head span, +.member-stat-label, +.profile-question-help, +.profile-questions-description, +.profile-questions-empty, +.profile-questions-page-copy, +.profile-question-lock-note, +.member-card .event-location::before { + color: #8d96a3; +} + +.auth-welcome-card h2, +.member-hero-title, +.auth-card-head h2, +.member-card h3, +.member-card h4, +.member-section-heading, +.member-tier-title, +.member-stat-value, +.member-card .event-title, +.member-experience .profile-question-label, +.member-experience .profile-question-readonly { + color: #f3f6fa !important; +} + +.auth-welcome-card p, +.auth-card-copy, +.member-hero-copy, +.member-muted-copy, +.membership-confirm-copy, +.auth-feature-item, +.auth-footer, +.member-data-row, +.membership-tier-description, +.member-card .event-datetime, +.member-card .event-location, +.member-card .event-description, +.member-experience .profile-menu-details, +.site-footer, +.cookie-banner { + color: #c6ced8 !important; +} + +.auth-footer a, +.site-footer a { + color: var(--ops-accent); +} + +.member-experience, +.portal-container-admin, +.admin-workspace { + background: var(--ops-bg); + color: var(--ops-text); +} + +.member-hero { + background: rgba(24, 27, 31, 0.96); + border: 1px solid rgba(42, 46, 52, 0.85); + box-shadow: none; +} + +.member-stat-chip, +.member-info-panel, +.membership-summary-panel, +.membership-tier-benefits, +.membership-tier-card, +.member-card .event-card { + background: rgba(24, 27, 31, 0.92); + border: 1px solid rgba(42, 46, 52, 0.85); + box-shadow: none; +} + +.member-table th { + background: rgba(24, 27, 31, 0.96); + color: #c6ced8; + border-bottom: 1px solid rgba(42, 46, 52, 0.85); +} + +.member-table td, +.member-data-row strong, +.member-info-panel p, +.membership-summary-panel p, +.membership-tier-benefits p { + color: #e6ebf2 !important; +} + +.auth-shell .form-group label, +.member-experience .form-group label { + color: #e6ebf2; +} + +.auth-shell .form-group input, +.auth-shell .form-group textarea, +.auth-shell .form-group select, +.member-experience .form-group input, +.member-experience .form-group textarea, +.member-experience .form-group select, +.member-experience .profile-question-input { + background: #111214; + color: #e6ebf2; + border: 1px solid rgba(70, 75, 83, 0.9); + box-shadow: none; +} + +.auth-shell .form-group input::placeholder, +.auth-shell .form-group textarea::placeholder, +.member-experience .form-group input::placeholder, +.member-experience .form-group textarea::placeholder, +.member-experience .profile-question-input::placeholder { + color: #8d96a3; +} + +.auth-shell .form-group input:focus, +.auth-shell .form-group textarea:focus, +.auth-shell .form-group select:focus, +.member-experience .form-group input:focus, +.member-experience .form-group textarea:focus, +.member-experience .form-group select:focus, +.member-experience .profile-question-input:focus { + border-color: var(--ops-accent); + box-shadow: 0 0 0 3px rgba(71, 151, 255, 0.12); +} + +.auth-shell .btn-primary, +.member-experience .btn-primary, +.member-topbar .btn-primary { + background: transparent; + color: var(--ops-accent); + border-color: rgba(71, 151, 255, 0.72); +} + +.auth-shell .btn-primary:hover, +.member-experience .btn-primary:hover, +.member-topbar .btn-primary:hover { + background: rgba(5, 37, 77, 0.75); + color: #fff; +} + +.auth-shell .btn-secondary, +.member-experience .btn-secondary, +.member-topbar .btn-secondary, +.member-card .rsvp-btn, +.member-experience .profile-menu-trigger { + background: rgba(24, 27, 31, 0.92); + color: #c7cdd6; + border-color: rgba(70, 75, 83, 0.9); +} + +.auth-shell .btn-secondary:hover, +.member-experience .btn-secondary:hover, +.member-topbar .btn-secondary:hover, +.member-topbar .portal-tab:hover, +.member-topbar .portal-switch-button:hover, +.member-topbar .portal-exit-button:hover, +.member-experience .profile-menu-item:hover { + background: rgba(5, 37, 77, 0.75); + color: #fff; +} + +.member-topbar .portal-tab { + border-color: transparent; + background: transparent; + color: #c7cdd6; +} + +.member-topbar .portal-tab.active { + background: #34383e; + border-color: #34383e; + color: #fff; +} + +.member-topbar .portal-switch-button, +.member-topbar .portal-exit-button { + background: transparent; + color: var(--ops-accent); + border-color: rgba(71, 151, 255, 0.72); +} + +.member-experience .status-badge, +.member-experience .profile-menu-dropdown, +.member-experience .profile-menu-summary, +.site-footer, +.cookie-banner { + background: rgba(24, 27, 31, 0.96); + border-color: rgba(42, 46, 52, 0.85); +} + +.member-experience .profile-menu-summary h4, +.member-experience .profile-menu-details strong, +.member-experience .profile-menu-item, +.member-experience .profile-menu-chevron { + color: #e6ebf2; +} + +.site-footer { + border-top: 1px solid rgba(42, 46, 52, 0.85); +} + +.cookie-banner { + border-left-color: var(--ops-accent); +} + +@media (max-width: 960px) { + .auth-container, + .auth-container-wide, + .member-hero, + .member-overview-grid { + grid-template-columns: 1fr; + } + + .auth-card-wide { + max-width: none; + } + + .auth-form-grid { + grid-template-columns: 1fr; + } + + .member-card-header, + .membership-tier-header, + .member-data-row { + grid-template-columns: 1fr; + flex-direction: column; + } + + .member-data-row { + gap: 4px; + } +} + +@media (max-width: 768px) { + .auth-container, + .member-experience { + padding: 18px 12px 28px; + } + + .auth-welcome-card, + .auth-card-body, + .member-card, + .member-hero { + padding: 20px; + } + + .auth-card-head { + padding: 0 20px; + } + + .member-settings-actions, + .membership-setup-actions, + .membership-action-row, + .modal-button-row { + justify-content: stretch; + } + + .member-settings-actions .btn, + .membership-setup-actions .btn, + .membership-action-row .btn { + width: 100%; + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6b7eb4b..74a83a2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,31 +1,61 @@ import React from 'react'; import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; import { FeatureFlagProvider } from './contexts/FeatureFlagContext'; +import { ToastProvider } from './contexts/ToastContext'; +import { ConfirmProvider } from './contexts/ConfirmContext'; import Register from './pages/Register'; import Login from './pages/Login'; import ForgotPassword from './pages/ForgotPassword'; import ResetPassword from './pages/ResetPassword'; import Dashboard from './pages/Dashboard'; -import EmailTemplates from './pages/EmailTemplates'; -import MembershipTiers from './pages/MembershipTiers'; -import BounceManagement from './pages/BounceManagement'; +import PrivacyPolicy from './pages/PrivacyPolicy'; +import TermsOfService from './pages/TermsOfService'; +import AppFooter from './components/layout/AppFooter'; +import CookieBanner from './components/layout/CookieBanner'; import './App.css'; +import { useState } from 'react'; const App: React.FC = () => { + const [cookieDismissed, setCookieDismissed] = useState( + () => localStorage.getItem('cookie_notice_dismissed') === 'true' + ); + + const dismissCookies = () => { + localStorage.setItem('cookie_notice_dismissed', 'true'); + setCookieDismissed(true); + }; + return ( - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - + + +
+
+ + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + +
+ + {!cookieDismissed && ( + + )} +
+
+
); diff --git a/frontend/src/components/AdminProfileQuestionManager.tsx b/frontend/src/components/AdminProfileQuestionManager.tsx new file mode 100644 index 0000000..8ca9c0b --- /dev/null +++ b/frontend/src/components/AdminProfileQuestionManager.tsx @@ -0,0 +1,536 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { + ProfileQuestion, + ProfileQuestionInputType, + ProfileQuestionOption, + ProfileQuestionUpsertData, + userService +} from '../services/membershipService'; +import { useConfirm } from '../contexts/ConfirmContext'; + +interface AdminProfileQuestionManagerProps { + onQuestionsChanged?: () => void; + openEditorToken?: number; + searchTerm?: string; +} + +type QuestionSortKey = 'order' | 'label' | 'type' | 'key' | 'status'; + +const INPUT_TYPES: ProfileQuestionInputType[] = ['text', 'number', 'boolean', 'date', 'select']; + +const optionsToText = (options: ProfileQuestionOption[] | null | undefined): string => { + if (!options || options.length === 0) { + return ''; + } + return options.map((option) => `${option.label}|${option.value}`).join('\n'); +}; + +const textToOptions = (value: string): ProfileQuestionOption[] => { + return value + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => { + const [labelPart, valuePart] = line.split('|'); + const label = (labelPart || '').trim(); + const optionValue = (valuePart || labelPart || '').trim(); + return { label, value: optionValue }; + }) + .filter((option) => option.label.length > 0 && option.value.length > 0); +}; + +const AdminProfileQuestionManager: React.FC = ({ + onQuestionsChanged, + openEditorToken = 0, + searchTerm = '' +}) => { + const { confirm } = useConfirm(); + const [questions, setQuestions] = useState([]); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [editingQuestionId, setEditingQuestionId] = useState(null); + const [isEditorOpen, setIsEditorOpen] = useState(false); + const [currentPage, setCurrentPage] = useState(1); + const [sortKey, setSortKey] = useState('order'); + const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc'); + const pageSize = 10; + + const emptyForm: ProfileQuestionUpsertData = { + key: '', + label: '', + help_text: '', + input_type: 'text', + placeholder: '', + options: null, + is_required: false, + is_active: true, + admin_only_edit: false, + display_order: 0, + depends_on_question_id: null, + depends_on_value: null + }; + + const [formData, setFormData] = useState(emptyForm); + const [optionsText, setOptionsText] = useState(''); + + const loadQuestions = async () => { + try { + setLoading(true); + const data = await userService.getAdminProfileQuestions(true); + setQuestions(data); + setError(null); + } catch (err: any) { + setError(err.response?.data?.detail || err.message || 'Failed to load questions'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + loadQuestions(); + }, []); + + useEffect(() => { + if (openEditorToken > 0) { + setFormData(emptyForm); + setOptionsText(''); + setEditingQuestionId(null); + setIsEditorOpen(true); + } + }, [openEditorToken]); + + const dependencyCandidates = useMemo( + () => questions.filter((question) => question.id !== editingQuestionId), + [questions, editingQuestionId] + ); + + const selectedDependencyQuestion = useMemo(() => { + if (!formData.depends_on_question_id) { + return null; + } + return questions.find((question) => question.id === formData.depends_on_question_id) || null; + }, [questions, formData.depends_on_question_id]); + + const filteredQuestions = useMemo(() => { + const term = searchTerm.trim().toLowerCase(); + if (!term) { + return questions; + } + return questions.filter((question) => + question.label.toLowerCase().includes(term) || + question.key.toLowerCase().includes(term) + ); + }, [questions, searchTerm]); + + const sortedQuestions = useMemo(() => { + const compareValues = (left: string | number, right: string | number) => { + if (typeof left === 'number' && typeof right === 'number') { + return left - right; + } + return String(left).localeCompare(String(right), undefined, { numeric: true, sensitivity: 'base' }); + }; + + return [...filteredQuestions].sort((left, right) => { + let result = 0; + + switch (sortKey) { + case 'order': + result = compareValues(left.display_order ?? 0, right.display_order ?? 0); + break; + case 'label': + result = compareValues(left.label, right.label); + break; + case 'type': + result = compareValues(left.input_type, right.input_type); + break; + case 'key': + result = compareValues(left.key, right.key); + break; + case 'status': + result = compareValues(left.is_active ? 0 : 1, right.is_active ? 0 : 1); + break; + } + + if (result === 0) { + result = compareValues(left.label, right.label); + } + + return sortDirection === 'asc' ? result : -result; + }); + }, [filteredQuestions, sortDirection, sortKey]); + + const totalPages = Math.max(1, Math.ceil(sortedQuestions.length / pageSize)); + const paginatedQuestions = useMemo( + () => sortedQuestions.slice((currentPage - 1) * pageSize, currentPage * pageSize), + [sortedQuestions, currentPage] + ); + + useEffect(() => { + setCurrentPage(1); + }, [searchTerm]); + + useEffect(() => { + if (currentPage > totalPages) { + setCurrentPage(totalPages); + } + }, [currentPage, totalPages]); + + const resetForm = () => { + setFormData(emptyForm); + setOptionsText(''); + setEditingQuestionId(null); + }; + + const closeEditor = () => { + resetForm(); + setIsEditorOpen(false); + }; + + const handleEdit = (question: ProfileQuestion) => { + setEditingQuestionId(question.id); + setFormData({ + key: question.key, + label: question.label, + help_text: question.help_text, + input_type: question.input_type, + placeholder: question.placeholder, + options: question.options, + is_required: question.is_required, + is_active: question.is_active, + admin_only_edit: question.admin_only_edit, + display_order: question.display_order, + depends_on_question_id: question.depends_on_question_id, + depends_on_value: question.depends_on_value + }); + setOptionsText(optionsToText(question.options)); + setIsEditorOpen(true); + }; + + const handleSave = async () => { + setSaving(true); + setError(null); + + try { + const payload: ProfileQuestionUpsertData = { + ...formData, + key: formData.key.trim(), + label: formData.label.trim(), + help_text: formData.help_text?.trim() || null, + placeholder: formData.placeholder?.trim() || null, + depends_on_value: formData.depends_on_value?.trim() || null, + options: formData.input_type === 'select' ? textToOptions(optionsText) : null, + }; + + if (editingQuestionId) { + await userService.updateAdminProfileQuestion(editingQuestionId, payload); + } else { + await userService.createAdminProfileQuestion(payload); + } + + await loadQuestions(); + closeEditor(); + onQuestionsChanged?.(); + } catch (err: any) { + setError(err.response?.data?.detail || err.message || 'Failed to save question'); + } finally { + setSaving(false); + } + }; + + const handleDeactivate = async (questionId: number) => { + const confirmed = await confirm({ + title: 'Deactivate question', + message: 'Deactivate this question? Existing answers are kept.', + confirmLabel: 'Deactivate', + tone: 'danger' + }); + if (!confirmed) { + return; + } + + try { + await userService.deactivateAdminProfileQuestion(questionId); + await loadQuestions(); + onQuestionsChanged?.(); + } catch (err: any) { + setError(err.response?.data?.detail || err.message || 'Failed to deactivate question'); + } + }; + + const toggleSort = (nextKey: QuestionSortKey) => { + if (sortKey === nextKey) { + setSortDirection((prev) => (prev === 'asc' ? 'desc' : 'asc')); + return; + } + setSortKey(nextKey); + setSortDirection('asc'); + }; + + const renderSortArrow = (active: boolean, direction: 'asc' | 'desc') => ( + + + + ); + + return ( +
+ {error &&
{error}
} + + {loading ? ( +

Loading questions...

+ ) : ( +
+
+ + + + + + + + + + + + + {paginatedQuestions.map((question) => ( + + + + + + + + + ))} + +
+ + + + + + + + + + Actions
{question.display_order} + {question.label} + {question.admin_only_edit && Admin Managed} + {question.input_type}{question.key} + + {question.is_active ? 'ACTIVE' : 'INACTIVE'} + + +
+ + {question.is_active && ( + + )} +
+
+ {filteredQuestions.length === 0 && ( +

No questions match your search.

+ )} +
+
+ Page {currentPage} of {totalPages} +
+ + +
+
+
+ )} + + {isEditorOpen && ( +
+