Compare commits
21 Commits
6fe452d4dc
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4f0a05707 | ||
|
|
96e02bf64a | ||
|
|
0716f09e3f | ||
|
|
7e123f1a31 | ||
|
|
b5fa1fe746 | ||
|
|
95836f441d | ||
|
|
6fbd0326fa | ||
|
|
cf4104069e | ||
|
|
c948179b5b | ||
|
|
0767b87c1e | ||
|
|
5b7260de84 | ||
|
|
9fcd7c1d63 | ||
|
|
390c338b32 | ||
|
|
17277836eb | ||
|
|
e1e9de2bce | ||
|
|
d4a0daebb2 | ||
|
|
6528e89b69 | ||
|
|
d96d5560cf | ||
|
|
4c1870ebb4 | ||
|
|
789d2bf0a6 | ||
|
|
9eb68695e9 |
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(curl:*)",
|
||||
"Bash(netstat:*)",
|
||||
"Bash(findstr:*)",
|
||||
"Bash(taskkill:*)",
|
||||
"Bash(cmd.exe /c \"taskkill /PID 35364 /F\")",
|
||||
"Bash(cmd.exe /c \"taskkill /PID 35364 /F 2>&1\")",
|
||||
"Bash(echo:*)",
|
||||
"Bash(cmd.exe /c \"ipconfig\")"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(npx expo:*)",
|
||||
"Bash(pip install:*)",
|
||||
"Bash(python -m pytest:*)",
|
||||
"Bash(python:*)",
|
||||
"Bash(/d/PoleDanceApp/backend/.venv/Scripts/pip install:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
29
.env.example
29
.env.example
@@ -1,14 +1,19 @@
|
||||
# PostgreSQL
|
||||
POSTGRES_PASSWORD=changeme
|
||||
# Database
|
||||
DATABASE_URL=postgresql+asyncpg://pole:pole@localhost:5432/poledance
|
||||
|
||||
# JWT — generate with: python -c "import secrets; print(secrets.token_hex(32))"
|
||||
SECRET_KEY=changeme
|
||||
# JWT
|
||||
SECRET_KEY=change-me-to-a-random-32-char-string
|
||||
ALGORITHM=HS256
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=15
|
||||
REFRESH_TOKEN_EXPIRE_DAYS=7
|
||||
|
||||
# Instagram Graph API
|
||||
# 1. Convert your Instagram account to Business/Creator and link it to a Facebook Page
|
||||
# 2. Create a Facebook App at developers.facebook.com
|
||||
# 3. Add Instagram Graph API product; grant instagram_basic + pages_read_engagement
|
||||
# 4. Generate a long-lived User Access Token (valid 60 days; auto-refreshed by the app)
|
||||
INSTAGRAM_USER_ID=123456789
|
||||
INSTAGRAM_ACCESS_TOKEN=EAAxxxxxx...
|
||||
INSTAGRAM_POLL_INTERVAL=1800 # seconds between Instagram polls (default 30 min)
|
||||
# Instagram (optional — needed for Phase 6)
|
||||
INSTAGRAM_USER_ID=
|
||||
INSTAGRAM_ACCESS_TOKEN=
|
||||
INSTAGRAM_POLL_INTERVAL=1800
|
||||
|
||||
# Expo Push
|
||||
EXPO_ACCESS_TOKEN=
|
||||
|
||||
# CORS
|
||||
CORS_ORIGINS=http://localhost:8081,exp://
|
||||
|
||||
47
.gitignore
vendored
47
.gitignore
vendored
@@ -1,47 +1,24 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.Python
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
.venv/
|
||||
venv/
|
||||
backend/.venv/
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
|
||||
# Alembic
|
||||
backend/alembic/versions/__pycache__/
|
||||
|
||||
# Environment
|
||||
# Env
|
||||
.env
|
||||
*.env.local
|
||||
backend/.env
|
||||
|
||||
# Node / Expo
|
||||
node_modules/
|
||||
.expo/
|
||||
dist/
|
||||
web-build/
|
||||
*.jks
|
||||
*.p8
|
||||
*.p12
|
||||
*.key
|
||||
*.mobileprovision
|
||||
*.orig.*
|
||||
npm-debug.*
|
||||
yarn-debug.*
|
||||
yarn-error.*
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
# Logs
|
||||
server.log
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Claude
|
||||
.claude/
|
||||
|
||||
91
CLAUDE.md
Normal file
91
CLAUDE.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# CLAUDE.md — Pole Dance Championships App
|
||||
|
||||
## Target Vision
|
||||
See `dancechamp-claude-code/` for full spec (3-app Supabase platform).
|
||||
Current implementation is a simplified MVP working toward that goal.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Backend
|
||||
cd backend && .venv/Scripts/python -m uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||
|
||||
# Web frontend
|
||||
cd web && npm run dev # runs on http://localhost:3000
|
||||
|
||||
# Seed test data
|
||||
cd backend && .venv/Scripts/python seed.py
|
||||
```
|
||||
|
||||
## Current Architecture
|
||||
|
||||
- **Backend**: FastAPI + SQLAlchemy 2 (async) + aiosqlite (SQLite dev) + Alembic + bcrypt
|
||||
- **Web**: Next.js 15 (App Router) + Tailwind CSS + shadcn/ui + TanStack Query + Zustand
|
||||
- **Auth**: JWT access (15min) + refresh token rotation (7 days, SHA-256 hashed in DB)
|
||||
|
||||
## Project Layout
|
||||
|
||||
```
|
||||
backend/app/ FastAPI app (models, schemas, routers, crud, services)
|
||||
backend/alembic/ Async migrations
|
||||
backend/.venv/ Virtual env (use .venv/Scripts/python)
|
||||
web/src/app/ Next.js App Router pages
|
||||
web/src/lib/api/ Axios client + API modules (auth, championships, users)
|
||||
web/src/store/ Zustand auth store
|
||||
web/src/components/ Shared UI components
|
||||
web/src/middleware.ts Route protection
|
||||
dancechamp-claude-code/ Target spec: SPEC.md, PLAN.md, DATABASE.md, DESIGN-SYSTEM.md, SCREENS.md
|
||||
```
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **passlib + bcrypt 5.x incompatible** — use `bcrypt` directly (NOT passlib)
|
||||
- **Circular import**: auth_service.py must NOT import crud_user
|
||||
- **SQLAlchemy UUID**: Use `sqlalchemy.Uuid(as_uuid=True)` not `postgresql.UUID`
|
||||
- **Stale uvicorn**: Kill with `powershell.exe -NoProfile -Command "Get-Process python | Stop-Process -Force"`
|
||||
- **Web API URL**: set `NEXT_PUBLIC_API_URL` in `web/.env.local` (default: http://localhost:8000/api/v1)
|
||||
|
||||
## Test Credentials (run seed.py)
|
||||
|
||||
```
|
||||
admin@pole.dev / Admin1234 (admin, approved)
|
||||
organizer@pole.dev / Org1234 (organizer, approved)
|
||||
member@pole.dev / Member1234 (member, approved)
|
||||
```
|
||||
|
||||
## API Routes
|
||||
|
||||
- Auth: POST /api/v1/auth/{register,login,refresh,logout,me}
|
||||
- Championships: GET/POST/PATCH/DELETE /api/v1/championships
|
||||
- Registrations: POST/GET/PATCH/DELETE /api/v1/registrations
|
||||
- Users (admin): GET /api/v1/users, PATCH /api/v1/users/{id}/{approve,reject}
|
||||
- Health: GET /internal/health | Swagger: GET /docs
|
||||
|
||||
## Code Conventions
|
||||
|
||||
- Backend: Pydantic v2 schemas, async SQLAlchemy, `from_attributes = True`
|
||||
- Web: Functional components, TanStack Query for server state, Zustand for auth, shadcn/ui components
|
||||
- Registration: members auto-approved, organizers require admin approval
|
||||
|
||||
## Environment
|
||||
|
||||
- Windows 10, Python 3.12, Node.js
|
||||
- Docker NOT installed, PostgreSQL NOT installed (use SQLite for dev)
|
||||
- venv always in backend/.venv
|
||||
|
||||
## Linear Workflow (MANDATORY)
|
||||
|
||||
**Every action** (coding, migrations, docs, planning, bug fixes) MUST have a Linear issue.
|
||||
|
||||
Before starting ANY task:
|
||||
1. Check if a matching Linear issue exists in the "Pole dance app" workspace
|
||||
2. If not — create one (title, description, priority)
|
||||
3. Set status to **In Progress** before writing any code
|
||||
|
||||
After completing:
|
||||
- Set status to **Done**
|
||||
- Make a git commit with message: `POL-N: <task title>`
|
||||
- Report the issue number to the user (e.g. "Done — POL-42")
|
||||
|
||||
**Unrelated fixes during a task:**
|
||||
If you discover and fix something unrelated to the current issue (e.g. a bug spotted while working on a feature), create a **separate Linear issue** describing what was fixed, commit it separately with that issue's `POL-N` prefix. Never mix unrelated changes into another issue's commit.
|
||||
348
LINEAR-ROADMAP.md
Normal file
348
LINEAR-ROADMAP.md
Normal file
@@ -0,0 +1,348 @@
|
||||
# DanceChamp — Linear Roadmap
|
||||
|
||||
> Development plan for the Pole Dance Championships app.
|
||||
> Tech: FastAPI + SQLAlchemy (async) + Expo (React Native) + PostgreSQL.
|
||||
> Copy each section as a **Project** in Linear, each checkbox as an **Issue**.
|
||||
|
||||
---
|
||||
|
||||
## Status Legend
|
||||
|
||||
- ✅ = Already implemented (current MVP)
|
||||
- 🔴 = Blocker (must have)
|
||||
- 🟡 = Important
|
||||
- 🟢 = Nice to have
|
||||
|
||||
---
|
||||
|
||||
## Project 1: Database & Models Expansion
|
||||
|
||||
> Expand the database schema from 4 tables to the full 12+ table schema required by the spec.
|
||||
|
||||
- ✅ `users` table (id, email, password, full_name, phone, role, status)
|
||||
- ✅ `refresh_tokens` table (JWT refresh rotation)
|
||||
- ✅ `championships` table (basic: title, description, location, event_date, status)
|
||||
- ✅ `registrations` table (basic: user_id, championship_id, category, level, status)
|
||||
- [ ] 🔴 Add `organizations` table — id, user_id (FK), name, instagram, email, city, logo_url, verified, status, block_reason
|
||||
- [ ] 🔴 Expand `championships` — add subtitle, reg_start, reg_end, venue, accent_color, source (manual/instagram), instagram_media_id, image_url, raw_caption_text
|
||||
- [ ] 🔴 Add `disciplines` table — id, championship_id (FK), name, levels (JSON array)
|
||||
- [ ] 🔴 Add `styles` table — id, championship_id (FK), name
|
||||
- [ ] 🔴 Add `fees` table — id, championship_id (FK), video_selection, solo, duet, group, refund_note
|
||||
- [ ] 🔴 Add `rules` table — id, championship_id (FK), section (general/costume/scoring/penalty), name, value
|
||||
- [ ] 🔴 Add `judges` table — id, championship_id (FK), name, instagram, bio, photo_url
|
||||
- [ ] 🔴 Expand `registrations` — add discipline_id, style, participation_type (solo/duet/group), current_step (1-10), video_url, receipt_url, insurance_url, passed (null/true/false)
|
||||
- [ ] 🔴 Add `participant_lists` table — id, championship_id (FK), published_by, is_published, published_at, notes
|
||||
- [ ] 🔴 Add `notifications` table — id, user_id (FK), championship_id, type, title, message, read, created_at
|
||||
- [ ] 🟡 Add `activity_logs` table — id, actor_id, action, target_type, target_id, details (JSON), created_at
|
||||
- [ ] 🟡 Add `sync_state` table — key, value, updated_at (for Instagram sync tracking)
|
||||
- [ ] 🔴 Create Alembic migration for all new tables
|
||||
- [ ] 🔴 Update Pydantic schemas for all new/expanded models
|
||||
- [ ] 🟡 Add seed data for new tables (disciplines, fees, rules, judges)
|
||||
|
||||
---
|
||||
|
||||
## Project 2: Organization System
|
||||
|
||||
> Organizations are separate entities from users. An organizer user creates/manages an organization.
|
||||
|
||||
- [ ] 🔴 Organization CRUD API — POST /api/v1/organizations, GET, PATCH
|
||||
- [ ] 🔴 Organization approval flow — pending → admin approves → active
|
||||
- [ ] 🔴 Organization verification system — verified orgs auto-approve championships
|
||||
- [ ] 🔴 Link championships to organizations (not directly to users)
|
||||
- [ ] 🔴 `get_org_owner` dependency — checks user owns the organization
|
||||
- [ ] 🟡 Organization profile with logo upload
|
||||
- [ ] 🟡 Mobile: Organization setup screen (name, instagram, city)
|
||||
- [ ] 🟡 Mobile: Organization dashboard (list own championships)
|
||||
|
||||
---
|
||||
|
||||
## Project 3: Championship Configuration (Org Side)
|
||||
|
||||
> Organizers configure championships through tabbed interface with 5 sections.
|
||||
|
||||
- ✅ Basic championship CRUD (title, description, location, date)
|
||||
- [ ] 🔴 Quick Create — minimal 3-field form (name, date, location) → creates draft
|
||||
- [ ] 🔴 Championship setup progress tracking — 5 sections (info, categories, fees, rules, judges)
|
||||
- [ ] 🔴 Categories API — CRUD for disciplines + levels per championship
|
||||
- [ ] 🔴 Styles API — CRUD for styles per championship
|
||||
- [ ] 🔴 Fees API — set video_selection, solo, duet, group fees
|
||||
- [ ] 🔴 Rules API — add/remove general rules, costume rules, scoring criteria, penalties
|
||||
- [ ] 🔴 Judges API — CRUD for judge profiles per championship
|
||||
- [ ] 🔴 "Go Live" endpoint — validates all sections done, sets status
|
||||
- [ ] 🔴 Registration period — reg_start, reg_end dates; auto-close when expired
|
||||
- [ ] 🔴 Mobile: Championship config tabs (Overview, Categories, Fees, Rules, Judges)
|
||||
- [ ] 🔴 Mobile: Tag editor component (for rules, levels, styles)
|
||||
- [ ] 🔴 Mobile: "Mark as Done" per section + progress checklist
|
||||
- [ ] 🟡 Mobile: Inline editing for championship info fields
|
||||
- [ ] 🟡 Championship status guard — prevent edits on live championships
|
||||
|
||||
---
|
||||
|
||||
## Project 4: Enhanced Member Experience
|
||||
|
||||
> Rich championship browsing with 5-tab detail view, search, and filtering.
|
||||
|
||||
- ✅ Championship list screen
|
||||
- ✅ Basic championship detail screen
|
||||
- [ ] 🔴 Championship Detail — 5 tabs: Overview, Categories, Rules, Fees, Judges
|
||||
- [ ] 🔴 Overview tab — event info, registration period, member count, "Register" button
|
||||
- [ ] 🔴 Categories tab — show disciplines with levels and styles
|
||||
- [ ] 🔴 Rules tab — general rules, costume rules, scoring criteria, penalties
|
||||
- [ ] 🔴 Fees tab — video selection + championship fees breakdown
|
||||
- [ ] 🔴 Judges tab — judge profiles with photo, instagram, bio
|
||||
- [ ] 🔴 Search championships — full-text search by name/org
|
||||
- [ ] 🔴 Filter championships — by discipline, location, status (open/upcoming/past)
|
||||
- [ ] 🟡 Sort championships — by date, by popularity
|
||||
- [ ] 🟡 Home screen dashboard — active registrations with progress, upcoming championships
|
||||
- [ ] 🟡 Pull-to-refresh on all list screens
|
||||
- [ ] 🟢 Championship image/poster display
|
||||
|
||||
---
|
||||
|
||||
## Project 5: 10-Step Registration Progress Tracker
|
||||
|
||||
> Members track their championship registration through 10 steps with uploads and auto-updates.
|
||||
|
||||
- ✅ Basic registration (submit application)
|
||||
- ✅ Registration status display (submitted/accepted/rejected)
|
||||
- [ ] 🔴 10-step progress model — current_step field on registration
|
||||
- [ ] 🔴 Step 1: Review rules — auto-mark when user opens Rules tab
|
||||
- [ ] 🔴 Step 2: Select category — saved from registration form (discipline + level + style)
|
||||
- [ ] 🔴 Step 3: Record video — manual toggle
|
||||
- [ ] 🔴 Step 4: Submit video form — manual toggle or external link
|
||||
- [ ] 🔴 Step 5: Pay video fee — upload receipt screenshot
|
||||
- [ ] 🔴 Step 6: Wait for results — pending state until org decides
|
||||
- [ ] 🔴 Step 7: Results — auto-updates when org passes/fails video
|
||||
- [ ] 🔴 Step 8: Pay championship fee — upload receipt (only if passed)
|
||||
- [ ] 🔴 Step 9: Submit "About Me" — manual toggle or external link
|
||||
- [ ] 🔴 Step 10: Confirm insurance — upload insurance document
|
||||
- [ ] 🔴 Mobile: Vertical step list with icons, status (locked/available/done/failed)
|
||||
- [ ] 🔴 Mobile: Progress bar (X/10 completed)
|
||||
- [ ] 🔴 Mobile: Step detail expansion with action area
|
||||
- [ ] 🟡 Mobile: "My Championships" with Active/Past tabs and progress bars
|
||||
|
||||
---
|
||||
|
||||
## Project 6: File Upload System
|
||||
|
||||
> Receipt photos, insurance documents, judge photos, org logos.
|
||||
|
||||
- [ ] 🔴 File upload API endpoint — POST /api/v1/uploads
|
||||
- [ ] 🔴 Local file storage (dev) with configurable S3/Supabase Storage (prod)
|
||||
- [ ] 🔴 Receipt photo upload — camera/gallery picker → upload → pending confirmation
|
||||
- [ ] 🔴 Insurance document upload — same flow
|
||||
- [ ] 🔴 Serve uploaded files — GET /api/v1/uploads/{filename}
|
||||
- [ ] 🟡 Judge profile photo upload
|
||||
- [ ] 🟡 Organization logo upload
|
||||
- [ ] 🟡 Image compression before upload (mobile side)
|
||||
- [ ] 🟡 File type validation (images only for receipts, PDF/images for insurance)
|
||||
|
||||
---
|
||||
|
||||
## Project 7: Organizer — Member Management
|
||||
|
||||
> Organizers review members, pass/fail videos, confirm payments, publish results.
|
||||
|
||||
- [ ] 🔴 Members list per championship — GET /api/v1/championships/{id}/members
|
||||
- [ ] 🔴 Member detail — registration info, video link, receipt, progress
|
||||
- [ ] 🔴 Edit member's level/style (with notification trigger)
|
||||
- [ ] 🔴 Video review — Pass/Fail buttons per member
|
||||
- [ ] 🔴 Payment confirmation — Confirm receipt button
|
||||
- [ ] 🔴 Results tab — pending/passed/failed counts, "Publish Results" button
|
||||
- [ ] 🔴 Mobile: Member list with search + filter chips (All, Receipts, Videos, Passed)
|
||||
- [ ] 🔴 Mobile: Member detail screen with action buttons
|
||||
- [ ] 🔴 Mobile: Results screen with Pass/Fail workflow
|
||||
- [ ] 🟡 Filter members by status (pending receipt, pending video, passed, failed)
|
||||
- [ ] 🟡 Bulk actions — pass/fail multiple members at once
|
||||
|
||||
---
|
||||
|
||||
## Project 8: Notification System
|
||||
|
||||
> In-app notifications + push notifications via Expo Push Service.
|
||||
|
||||
- [ ] 🔴 Notification model + CRUD API
|
||||
- [ ] 🔴 In-app notification feed — GET /api/v1/notifications
|
||||
- [ ] 🔴 Mark notification as read — PATCH /api/v1/notifications/{id}/read
|
||||
- [ ] 🔴 Mark all as read — POST /api/v1/notifications/read-all
|
||||
- [ ] 🔴 Notification triggers:
|
||||
- Video passed/failed → member notification
|
||||
- Payment confirmed → member notification
|
||||
- Level/style changed → member notification
|
||||
- Results published → all registrants notified
|
||||
- User approved → welcome notification
|
||||
- Championship goes live → all platform notification
|
||||
- [ ] 🔴 Expo Push Token registration — PATCH /api/v1/users/me/push-token
|
||||
- [ ] 🔴 Expo Push Service integration — send push on notification create
|
||||
- [ ] 🔴 Mobile: Bell icon with unread count badge
|
||||
- [ ] 🔴 Mobile: Notification feed screen (cards with icon, type, message, timestamp)
|
||||
- [ ] 🟡 Push notification preferences (toggles for each type)
|
||||
- [ ] 🟡 Deadline reminders — auto-send 7d, 3d, 1d before registration closes
|
||||
- [ ] 🟢 Email notifications (secondary channel)
|
||||
|
||||
---
|
||||
|
||||
## Project 9: Instagram Integration
|
||||
|
||||
> Automatically sync championship announcements from organizer's Instagram Business account.
|
||||
|
||||
- [ ] 🔴 Instagram Graph API client — fetch recent posts from Business/Creator account
|
||||
- [ ] 🔴 Caption parser — extract title (first line), location, date from caption text
|
||||
- [ ] 🔴 Date parser — support Russian ("15 марта 2025") and English ("March 15 2025") + numeric (15.03.2025)
|
||||
- [ ] 🔴 Location parser — detect "Место:", "Адрес:", "Location:", "Venue:", "Зал:"
|
||||
- [ ] 🔴 Championship upsert — use instagram_media_id as dedup key, create as draft
|
||||
- [ ] 🔴 APScheduler polling — run every 30 min via FastAPI lifespan event
|
||||
- [ ] 🔴 Sync state tracking — store last sync timestamp, only process new posts
|
||||
- [ ] 🔴 Long-lived token management — store in .env, auto-refresh before 60-day expiry
|
||||
- [ ] 🔴 Token refresh scheduler — weekly job to call Graph API token exchange
|
||||
- [ ] 🟡 Image sync — save media_url as championship image
|
||||
- [ ] 🟡 Manual import button — "Import from Instagram" in org dashboard
|
||||
- [ ] 🟡 Error handling — failed parsing saves raw caption for manual review
|
||||
- [ ] 🟢 Multiple Instagram accounts support (one per org)
|
||||
|
||||
---
|
||||
|
||||
## Project 10: Enhanced Admin Panel
|
||||
|
||||
> Full admin capabilities: org management, championship moderation, user management.
|
||||
|
||||
- ✅ Basic admin panel (approve/reject pending users)
|
||||
- [ ] 🔴 Admin dashboard — stats (active orgs, live champs, total users)
|
||||
- [ ] 🔴 "Needs Attention" section — pending orgs + pending championships
|
||||
- [ ] 🔴 Organization management — list, detail, approve/reject/block/unblock/verify
|
||||
- [ ] 🔴 Championship moderation — list, detail, approve/reject (from unverified orgs), suspend/reinstate
|
||||
- [ ] 🔴 Enhanced user management — warn, block/unblock, view activity
|
||||
- [ ] 🔴 Activity log — recent actions across the platform
|
||||
- [ ] 🟡 Mobile: Admin dashboard with stat cards
|
||||
- [ ] 🟡 Mobile: Organization list + detail screens
|
||||
- [ ] 🟡 Mobile: Championship moderation screens
|
||||
- [ ] 🟢 Web admin panel (React + Vite, same API)
|
||||
|
||||
---
|
||||
|
||||
## Project 11: Auth & Security Hardening
|
||||
|
||||
> Production-ready auth, rate limiting, CORS, input validation.
|
||||
|
||||
- ✅ JWT access + refresh token rotation
|
||||
- ✅ bcrypt password hashing
|
||||
- ✅ Role-based route protection (dependency chain)
|
||||
- [ ] 🔴 Rate limiting on auth endpoints (slowapi)
|
||||
- [ ] 🔴 CORS configuration (whitelist mobile app + admin panel origins)
|
||||
- [ ] 🔴 Input validation — Pydantic strict mode, length limits, email format
|
||||
- [ ] 🔴 Password strength requirements (min 8 chars, mixed case, number)
|
||||
- [ ] 🟡 Account lockout after N failed login attempts
|
||||
- [ ] 🟡 HTTPS enforcement in production
|
||||
- [ ] 🟡 Audit logging — log all admin actions
|
||||
- [ ] 🟢 Google OAuth login (alternative to email/password)
|
||||
- [ ] 🟢 Two-factor authentication
|
||||
|
||||
---
|
||||
|
||||
## Project 12: PostgreSQL & Production Infrastructure
|
||||
|
||||
> Migrate from SQLite to PostgreSQL, containerize with Docker.
|
||||
|
||||
- [ ] 🔴 PostgreSQL support in database.py (async via asyncpg)
|
||||
- [ ] 🔴 docker-compose.yml — PostgreSQL 16 + FastAPI containers
|
||||
- [ ] 🔴 Dockerfile for backend
|
||||
- [ ] 🔴 Environment config — .env.example with all required variables
|
||||
- [ ] 🔴 Alembic migration compatibility — ensure all migrations work on PostgreSQL
|
||||
- [ ] 🔴 Production ASGI server — gunicorn + uvicorn workers
|
||||
- [ ] 🟡 Health check endpoint improvements
|
||||
- [ ] 🟡 Structured logging (JSON format for production)
|
||||
- [ ] 🟡 Database connection pooling
|
||||
- [ ] 🟢 CI/CD pipeline (GitHub Actions — lint, test, build)
|
||||
- [ ] 🟢 Deployment to cloud (VPS / Railway / Fly.io)
|
||||
|
||||
---
|
||||
|
||||
## Project 13: Testing
|
||||
|
||||
> Comprehensive test coverage for backend and mobile.
|
||||
|
||||
- [ ] 🔴 pytest setup with async fixtures (httpx + AsyncClient)
|
||||
- [ ] 🔴 Auth tests — register, login, refresh, logout, me, role checks
|
||||
- [ ] 🔴 Championship tests — CRUD, status transitions, permission checks
|
||||
- [ ] 🔴 Registration tests — submit, duplicate guard, closed registration guard
|
||||
- [ ] 🔴 Admin tests — approve/reject users, org management
|
||||
- [ ] 🔴 Instagram parser tests — various caption formats, edge cases
|
||||
- [ ] 🔴 Notification tests — trigger conditions, push delivery
|
||||
- [ ] 🟡 Integration tests — full flows (register → apply → review → publish)
|
||||
- [ ] 🟡 Mobile: TypeScript strict mode + path aliases (@api/*, @store/*, @screens/*)
|
||||
- [ ] 🟢 E2E tests (Detox or Maestro for mobile)
|
||||
- [ ] 🟢 Load testing (locust or k6)
|
||||
|
||||
---
|
||||
|
||||
## Project 14: Design System & UI Polish
|
||||
|
||||
> Consistent dark luxury theme across all screens, smooth animations.
|
||||
|
||||
- [ ] 🟡 Design tokens — colors, typography, spacing constants file
|
||||
- [ ] 🟡 Typography — Playfair Display (headings) + DM Sans (body) + JetBrains Mono (badges)
|
||||
- [ ] 🟡 Component library — Button, Card, Badge, TabBar, TagEditor, LoadingOverlay
|
||||
- [ ] 🟡 Dark luxury theme — dark backgrounds (#08070D), pink/purple accents
|
||||
- [ ] 🟡 Loading skeletons on all list screens
|
||||
- [ ] 🟡 Empty states — custom illustrations per screen
|
||||
- [ ] 🟡 Error states — friendly messages + retry buttons
|
||||
- [ ] 🟡 Animations — tab transitions, card press, progress bar fill
|
||||
- [ ] 🟢 Haptic feedback on key actions
|
||||
- [ ] 🟢 Swipe gestures on member cards (pass/fail)
|
||||
- [ ] 🟢 Onboarding walkthrough for first-time users
|
||||
|
||||
---
|
||||
|
||||
## Project 15: Internationalization (Post-MVP)
|
||||
|
||||
> Russian + English language support.
|
||||
|
||||
- [ ] 🟢 i18n setup (react-i18next or similar)
|
||||
- [ ] 🟢 Extract all strings to translation files
|
||||
- [ ] 🟢 Russian translations
|
||||
- [ ] 🟢 Language switcher in profile settings
|
||||
- [ ] 🟢 Date/time formatting per locale
|
||||
|
||||
---
|
||||
|
||||
## Recommended Priority Order
|
||||
|
||||
If you need to ship fast, work in this order:
|
||||
|
||||
| Priority | Project | Why |
|
||||
|:---:|---|---|
|
||||
| 1 | Project 1: Database Expansion | Foundation for everything else |
|
||||
| 2 | Project 2: Organization System | Core business entity |
|
||||
| 3 | Project 3: Championship Config | Orgs need to create proper championships |
|
||||
| 4 | Project 4: Enhanced Member UX | Members need to see championship details |
|
||||
| 5 | Project 5: 10-Step Progress | Core differentiator — step-by-step registration |
|
||||
| 6 | Project 6: File Uploads | Required for receipts + insurance steps |
|
||||
| 7 | Project 7: Member Management | Orgs need to review and manage members |
|
||||
| 8 | Project 8: Notifications | Users need to know what's happening |
|
||||
| 9 | Project 9: Instagram Sync | Automate championship creation |
|
||||
| 10 | Project 10: Enhanced Admin | Full platform control |
|
||||
| 11 | Project 11: Security | Production hardening |
|
||||
| 12 | Project 12: PostgreSQL + Docker | Production deployment |
|
||||
| 13 | Project 13: Testing | Quality assurance |
|
||||
| 14 | Project 14: Design System | Visual polish |
|
||||
| 15 | Project 15: i18n | Post-launch |
|
||||
|
||||
---
|
||||
|
||||
## Current State Summary
|
||||
|
||||
### What's Built (MVP)
|
||||
- FastAPI backend with SQLite (async)
|
||||
- JWT auth with refresh token rotation
|
||||
- 4 tables: users, refresh_tokens, championships, registrations
|
||||
- Member auto-approve, organizer requires admin approval
|
||||
- Championship list + basic detail
|
||||
- Registration form + "My Registrations" with championship info
|
||||
- Admin panel (approve/reject pending users)
|
||||
- Profile screen with role badges
|
||||
- Tab navigation with Ionicons
|
||||
|
||||
### What's Next (Sprint 1 recommended)
|
||||
1. Database expansion (Project 1)
|
||||
2. Organization system (Project 2)
|
||||
3. Championship configuration tabs (Project 3)
|
||||
159
MANUAL_TESTS.md
Normal file
159
MANUAL_TESTS.md
Normal file
@@ -0,0 +1,159 @@
|
||||
# Manual Test Tracker — Pole Championships App
|
||||
|
||||
Mark each item with ✅ PASS / ❌ FAIL / ⏭ SKIP
|
||||
|
||||
---
|
||||
|
||||
## Setup
|
||||
|
||||
| # | Action | Expected | Result |
|
||||
|---|--------|----------|--------|
|
||||
| S1 | Backend running at `http://localhost:8000` | `{"status":"ok"}` at `/internal/health` | |
|
||||
| S2 | Expo app loaded on phone | Login screen visible | |
|
||||
| S3 | Seed data present | Run `backend/.venv/Scripts/python seed.py` — no errors | |
|
||||
|
||||
---
|
||||
|
||||
## 1. Registration (new account)
|
||||
|
||||
| # | Action | Expected | Result | Notes |
|
||||
|---|--------|----------|--------|-------|
|
||||
| 1.1 | Tap "Don't have an account? Register" | Register screen opens | | |
|
||||
| 1.2 | Submit form with all fields empty | Error: "fill in all required fields" | | |
|
||||
| 1.3 | Fill: Name=`Test User`, Email=`test@test.com`, Password=`Test1234` → Submit | "Application Submitted" screen shown | | |
|
||||
| 1.4 | Tap "Go to Sign In" | Login screen shown | | |
|
||||
| 1.5 | Try to register same email again | Error: "Email already registered" | | |
|
||||
|
||||
---
|
||||
|
||||
## 2. Login — Pending account (cannot access app)
|
||||
|
||||
| # | Action | Expected | Result | Notes |
|
||||
|---|--------|----------|--------|-------|
|
||||
| 2.1 | Login with `pending@pole.dev` / `Pending1234` | Login succeeds but stays on auth screen (status=pending) | | |
|
||||
| 2.2 | Login with wrong password `pending@pole.dev` / `wrongpass` | Error: "Invalid credentials" | | |
|
||||
| 2.3 | Login with non-existent email | Error: "Invalid credentials" | | |
|
||||
|
||||
---
|
||||
|
||||
## 3. Login — Approved member
|
||||
|
||||
| # | Action | Expected | Result | Notes |
|
||||
|---|--------|----------|--------|-------|
|
||||
| 3.1 | Login with `member@pole.dev` / `Member1234` | App opens, Championships tab visible | | |
|
||||
| 3.2 | Championships list loads | 2 championships shown (Spring Open 2026, Summer Championship 2026) | | |
|
||||
| 3.3 | Spring Open 2026 shows green OPEN badge | Green badge visible | | |
|
||||
| 3.4 | Summer Championship 2026 shows grey DRAFT badge | Grey badge visible | | |
|
||||
| 3.5 | Spring Open shows entry fee 50 BYN | "💰 Entry fee: 50 BYN" visible | | |
|
||||
| 3.6 | Spring Open shows location | "📍 Cultural Center, Minsk" visible | | |
|
||||
| 3.7 | Spring Open shows image | Banner image loads | | |
|
||||
|
||||
---
|
||||
|
||||
## 4. Championship Detail
|
||||
|
||||
| # | Action | Expected | Result | Notes |
|
||||
|---|--------|----------|--------|-------|
|
||||
| 4.1 | Tap "Spring Open 2026" | Detail screen opens | | |
|
||||
| 4.2 | Detail shows title, location, date | All fields visible | | |
|
||||
| 4.3 | Categories shown: Novice, Amateur, Professional | 3 purple tags visible | | |
|
||||
| 4.4 | Judges section shows 2 judges | Oksana Ivanova + Marta Sokolova | | |
|
||||
| 4.5 | "Open Registration Form ↗" button visible | Tapping opens link in browser | | |
|
||||
| 4.6 | "Register for Championship" button visible | Button at bottom | | |
|
||||
| 4.7 | Tap "Summer Championship 2026" | Detail opens, no Register button (status=draft) | | |
|
||||
|
||||
---
|
||||
|
||||
## 5. Registration Flow
|
||||
|
||||
| # | Action | Expected | Result | Notes |
|
||||
|---|--------|----------|--------|-------|
|
||||
| 5.1 | On Spring Open detail, tap "Register for Championship" | Confirmation alert appears | | |
|
||||
| 5.2 | Tap Cancel in alert | Alert dismisses, no registration created | | |
|
||||
| 5.3 | Tap Register → confirm | Success alert shown | | |
|
||||
| 5.4 | After registering: progress steps appear | "Application submitted" step shown as green | | |
|
||||
| 5.5 | "Register for Championship" button disappears | No duplicate registration possible | | |
|
||||
| 5.6 | Go back, tap Spring Open again | Progress steps still visible | | |
|
||||
| 5.7 | Try registering for same championship again (via API or re-tap) | Error: "Already registered" | | |
|
||||
|
||||
---
|
||||
|
||||
## 6. My Registrations Tab
|
||||
|
||||
| # | Action | Expected | Result | Notes |
|
||||
|---|--------|----------|--------|-------|
|
||||
| 6.1 | Tap "My Registrations" tab | Screen opens | | |
|
||||
| 6.2 | Registration for Spring Open visible | Shows reg ID + SUBMITTED badge (yellow) | | |
|
||||
| 6.3 | Pull to refresh | List refreshes | | |
|
||||
| 6.4 | Login as `pending@pole.dev` | My Registrations shows empty | | |
|
||||
|
||||
---
|
||||
|
||||
## 7. Profile Tab
|
||||
|
||||
| # | Action | Expected | Result | Notes |
|
||||
|---|--------|----------|--------|-------|
|
||||
| 7.1 | Tap Profile tab | Profile screen opens | | |
|
||||
| 7.2 | Shows full name, email, role=member, status=approved | All correct | | |
|
||||
| 7.3 | Avatar shows first letter of name | "A" for Anna Petrova | | |
|
||||
| 7.4 | Tap "Sign Out" | Confirmation alert appears | | |
|
||||
| 7.5 | Confirm sign out | Redirected to Login screen | | |
|
||||
| 7.6 | After logout, reopen app | Login screen shown (not auto-logged in) | | |
|
||||
|
||||
---
|
||||
|
||||
## 8. Token Persistence (session restore)
|
||||
|
||||
| # | Action | Expected | Result | Notes |
|
||||
|---|--------|----------|--------|-------|
|
||||
| 8.1 | Login as member, close Expo Go completely | — | | |
|
||||
| 8.2 | Reopen Expo Go, scan QR | App opens directly on Championships (no re-login) | | |
|
||||
| 8.3 | Restart backend server while logged in | App still works after backend restarts | | |
|
||||
|
||||
---
|
||||
|
||||
## 9. Admin user (via API — no admin UI yet)
|
||||
|
||||
| # | Action | Expected | Result | Notes |
|
||||
|---|--------|----------|--------|-------|
|
||||
| 9.1 | Login as `admin@pole.dev` / `Admin1234` | App opens (role=admin, status=approved) | | |
|
||||
| 9.2 | Championships visible | Same list as member | | |
|
||||
| 9.3 | Approve pending user via API:<br>`curl -X PATCH http://localhost:8000/api/v1/users/<user_id>/approve -H "Authorization: Bearer <token>"` | Returns `status: approved` | | |
|
||||
|
||||
---
|
||||
|
||||
## 10. Edge Cases & Error Handling
|
||||
|
||||
| # | Action | Expected | Result | Notes |
|
||||
|---|--------|----------|--------|-------|
|
||||
| 10.1 | Stop backend server, open app | Championships shows "Failed to load championships" | | |
|
||||
| 10.2 | Stop backend, try login | Login shows error message (not crash) | | |
|
||||
| 10.3 | Pull to refresh on Championships | Spinner appears, list reloads | | |
|
||||
| 10.4 | Very slow network (throttle in phone settings) | Loading spinners show while waiting | | |
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Section | Total | Pass | Fail | Skip |
|
||||
|---------|-------|------|------|------|
|
||||
| 1. Registration | 5 | | | |
|
||||
| 2. Login — Pending | 3 | | | |
|
||||
| 3. Login — Member | 7 | | | |
|
||||
| 4. Championship Detail | 7 | | | |
|
||||
| 5. Registration Flow | 7 | | | |
|
||||
| 6. My Registrations | 4 | | | |
|
||||
| 7. Profile | 6 | | | |
|
||||
| 8. Session Restore | 3 | | | |
|
||||
| 9. Admin | 3 | | | |
|
||||
| 10. Edge Cases | 4 | | | |
|
||||
| **Total** | **49** | | | |
|
||||
|
||||
---
|
||||
|
||||
## Known Limitations (not bugs)
|
||||
|
||||
- No admin UI — user approval done via API only
|
||||
- Organizer role has no extra UI in the mobile app yet
|
||||
- Push notifications not implemented
|
||||
- Instagram sync not implemented (requires credentials)
|
||||
@@ -7,4 +7,4 @@ RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[alembic]
|
||||
script_location = alembic
|
||||
prepend_sys_path = .
|
||||
sqlalchemy.url = driver://user:pass@localhost/dbname
|
||||
sqlalchemy.url = postgresql+asyncpg://pole:pole@localhost:5432/poledance
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
@@ -2,14 +2,17 @@ import asyncio
|
||||
from logging.config import fileConfig
|
||||
|
||||
from alembic import context
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
from sqlalchemy import pool
|
||||
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||
|
||||
from app.config import settings
|
||||
from app.database import Base
|
||||
import app.models # noqa: F401 — registers all models with Base.metadata
|
||||
|
||||
# Import all models so they are registered on Base.metadata
|
||||
import app.models # noqa: F401
|
||||
|
||||
config = context.config
|
||||
config.set_main_option("sqlalchemy.url", settings.database_url)
|
||||
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL)
|
||||
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
@@ -35,14 +38,22 @@ def do_run_migrations(connection):
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
async def run_migrations_online() -> None:
|
||||
connectable = create_async_engine(settings.database_url)
|
||||
async def run_async_migrations() -> None:
|
||||
connectable = async_engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
async with connectable.connect() as connection:
|
||||
await connection.run_sync(do_run_migrations)
|
||||
await connectable.dispose()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
asyncio.run(run_async_migrations())
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
asyncio.run(run_migrations_online())
|
||||
run_migrations_online()
|
||||
|
||||
@@ -1,195 +0,0 @@
|
||||
"""initial schema
|
||||
|
||||
Revision ID: 0001
|
||||
Revises:
|
||||
Create Date: 2026-02-22
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0001"
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"users",
|
||||
sa.Column("id", sa.Uuid(), nullable=False),
|
||||
sa.Column("email", sa.String(255), nullable=False),
|
||||
sa.Column("hashed_password", sa.String(255), nullable=False),
|
||||
sa.Column("full_name", sa.String(255), nullable=False),
|
||||
sa.Column("phone", sa.String(50), nullable=True),
|
||||
sa.Column("role", sa.String(20), nullable=False, server_default="member"),
|
||||
sa.Column("status", sa.String(20), nullable=False, server_default="pending"),
|
||||
sa.Column("expo_push_token", sa.String(512), nullable=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("email"),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"refresh_tokens",
|
||||
sa.Column("id", sa.Uuid(), nullable=False),
|
||||
sa.Column("user_id", sa.Uuid(), nullable=False),
|
||||
sa.Column("token_hash", sa.String(255), nullable=False),
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("revoked", sa.Boolean(), nullable=False, server_default="false"),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("token_hash"),
|
||||
)
|
||||
op.create_index("idx_refresh_tokens_user_id", "refresh_tokens", ["user_id"])
|
||||
|
||||
op.create_table(
|
||||
"championships",
|
||||
sa.Column("id", sa.Uuid(), nullable=False),
|
||||
sa.Column("title", sa.String(500), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("location", sa.String(500), nullable=True),
|
||||
sa.Column("event_date", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("registration_open_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("registration_close_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("status", sa.String(30), nullable=False, server_default="draft"),
|
||||
sa.Column("source", sa.String(20), nullable=False, server_default="manual"),
|
||||
sa.Column("instagram_media_id", sa.String(100), nullable=True),
|
||||
sa.Column("image_url", sa.String(1000), nullable=True),
|
||||
sa.Column("raw_caption_text", sa.Text(), nullable=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("instagram_media_id"),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"registrations",
|
||||
sa.Column("id", sa.Uuid(), nullable=False),
|
||||
sa.Column("championship_id", sa.Uuid(), nullable=False),
|
||||
sa.Column("user_id", sa.Uuid(), nullable=False),
|
||||
sa.Column("category", sa.String(255), nullable=True),
|
||||
sa.Column("level", sa.String(255), nullable=True),
|
||||
sa.Column("notes", sa.Text(), nullable=True),
|
||||
sa.Column("status", sa.String(20), nullable=False, server_default="submitted"),
|
||||
sa.Column(
|
||||
"submitted_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("decided_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["championship_id"], ["championships.id"], ondelete="CASCADE"
|
||||
),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint(
|
||||
"championship_id", "user_id", name="uq_registration_champ_user"
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"idx_registrations_championship_id", "registrations", ["championship_id"]
|
||||
)
|
||||
op.create_index("idx_registrations_user_id", "registrations", ["user_id"])
|
||||
|
||||
op.create_table(
|
||||
"participant_lists",
|
||||
sa.Column("id", sa.Uuid(), nullable=False),
|
||||
sa.Column("championship_id", sa.Uuid(), nullable=False),
|
||||
sa.Column("published_by", sa.Uuid(), nullable=False),
|
||||
sa.Column("is_published", sa.Boolean(), nullable=False, server_default="false"),
|
||||
sa.Column("published_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("notes", sa.Text(), nullable=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["championship_id"], ["championships.id"], ondelete="CASCADE"
|
||||
),
|
||||
sa.ForeignKeyConstraint(["published_by"], ["users.id"]),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("championship_id"),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"notification_log",
|
||||
sa.Column("id", sa.Uuid(), nullable=False),
|
||||
sa.Column("user_id", sa.Uuid(), nullable=False),
|
||||
sa.Column("registration_id", sa.Uuid(), nullable=True),
|
||||
sa.Column("type", sa.String(50), nullable=False),
|
||||
sa.Column("title", sa.String(255), nullable=False),
|
||||
sa.Column("body", sa.Text(), nullable=False),
|
||||
sa.Column(
|
||||
"sent_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("delivery_status", sa.String(30), server_default="pending"),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(
|
||||
["registration_id"], ["registrations.id"], ondelete="SET NULL"
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("idx_notification_log_user_id", "notification_log", ["user_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("notification_log")
|
||||
op.drop_table("participant_lists")
|
||||
op.drop_table("registrations")
|
||||
op.drop_table("championships")
|
||||
op.drop_table("refresh_tokens")
|
||||
op.drop_table("users")
|
||||
@@ -0,0 +1,32 @@
|
||||
"""add organizer fields to users
|
||||
|
||||
Revision ID: 43d947192af5
|
||||
Revises: 657f22c8aa55
|
||||
Create Date: 2026-02-25 21:18:04.707870
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '43d947192af5'
|
||||
down_revision: Union[str, None] = '657f22c8aa55'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('users', sa.Column('organization_name', sa.String(length=255), nullable=True))
|
||||
op.add_column('users', sa.Column('instagram_handle', sa.String(length=100), nullable=True))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column('users', 'instagram_handle')
|
||||
op.drop_column('users', 'organization_name')
|
||||
# ### end Alembic commands ###
|
||||
125
backend/alembic/versions/657f22c8aa55_initial_schema.py
Normal file
125
backend/alembic/versions/657f22c8aa55_initial_schema.py
Normal file
@@ -0,0 +1,125 @@
|
||||
"""initial schema
|
||||
|
||||
Revision ID: 657f22c8aa55
|
||||
Revises:
|
||||
Create Date: 2026-02-25 00:23:12.480733
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '657f22c8aa55'
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('championships',
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('title', sa.String(length=255), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('location', sa.String(length=500), nullable=True),
|
||||
sa.Column('event_date', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('registration_open_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('registration_close_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('form_url', sa.String(length=2048), nullable=True),
|
||||
sa.Column('entry_fee', sa.Float(), nullable=True),
|
||||
sa.Column('video_max_duration', sa.Integer(), nullable=True),
|
||||
sa.Column('judges', sa.Text(), nullable=True),
|
||||
sa.Column('categories', sa.Text(), nullable=True),
|
||||
sa.Column('status', sa.String(length=20), nullable=False),
|
||||
sa.Column('source', sa.String(length=20), nullable=False),
|
||||
sa.Column('instagram_media_id', sa.String(length=255), nullable=True),
|
||||
sa.Column('image_url', sa.String(length=2048), nullable=True),
|
||||
sa.Column('raw_caption_text', sa.Text(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('instagram_media_id')
|
||||
)
|
||||
op.create_table('users',
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('email', sa.String(length=255), nullable=False),
|
||||
sa.Column('hashed_password', sa.String(length=255), nullable=False),
|
||||
sa.Column('full_name', sa.String(length=255), nullable=False),
|
||||
sa.Column('phone', sa.String(length=50), nullable=True),
|
||||
sa.Column('role', sa.String(length=20), nullable=False),
|
||||
sa.Column('status', sa.String(length=20), nullable=False),
|
||||
sa.Column('expo_push_token', sa.String(length=255), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
|
||||
op.create_table('participant_lists',
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('championship_id', sa.Uuid(), nullable=False),
|
||||
sa.Column('published_by', sa.Uuid(), nullable=True),
|
||||
sa.Column('is_published', sa.Boolean(), nullable=False),
|
||||
sa.Column('published_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('notes', sa.Text(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['championship_id'], ['championships.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['published_by'], ['users.id'], ondelete='SET NULL'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('championship_id')
|
||||
)
|
||||
op.create_table('refresh_tokens',
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('user_id', sa.Uuid(), nullable=False),
|
||||
sa.Column('token_hash', sa.String(length=64), nullable=False),
|
||||
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('revoked', sa.Boolean(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_refresh_tokens_token_hash'), 'refresh_tokens', ['token_hash'], unique=False)
|
||||
op.create_table('registrations',
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('championship_id', sa.Uuid(), nullable=False),
|
||||
sa.Column('user_id', sa.Uuid(), nullable=False),
|
||||
sa.Column('category', sa.String(length=255), nullable=True),
|
||||
sa.Column('level', sa.String(length=100), nullable=True),
|
||||
sa.Column('notes', sa.Text(), nullable=True),
|
||||
sa.Column('status', sa.String(length=30), nullable=False),
|
||||
sa.Column('video_url', sa.String(length=2048), nullable=True),
|
||||
sa.Column('submitted_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||
sa.Column('decided_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(['championship_id'], ['championships.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('championship_id', 'user_id', name='uq_registration_champ_user')
|
||||
)
|
||||
op.create_table('notification_log',
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('user_id', sa.Uuid(), nullable=False),
|
||||
sa.Column('registration_id', sa.Uuid(), nullable=True),
|
||||
sa.Column('type', sa.String(length=50), nullable=False),
|
||||
sa.Column('title', sa.String(length=255), nullable=False),
|
||||
sa.Column('body', sa.Text(), nullable=False),
|
||||
sa.Column('sent_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||
sa.Column('delivery_status', sa.String(length=20), nullable=False),
|
||||
sa.ForeignKeyConstraint(['registration_id'], ['registrations.id'], ondelete='SET NULL'),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_table('notification_log')
|
||||
op.drop_table('registrations')
|
||||
op.drop_index(op.f('ix_refresh_tokens_token_hash'), table_name='refresh_tokens')
|
||||
op.drop_table('refresh_tokens')
|
||||
op.drop_table('participant_lists')
|
||||
op.drop_index(op.f('ix_users_email'), table_name='users')
|
||||
op.drop_table('users')
|
||||
op.drop_table('championships')
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,86 @@
|
||||
"""add organizations table and championship ownership
|
||||
|
||||
Revision ID: a1b2c3d4e5f6
|
||||
Revises: 43d947192af5
|
||||
Create Date: 2026-03-01 18:00:00.000000
|
||||
|
||||
"""
|
||||
import uuid
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'a1b2c3d4e5f6'
|
||||
down_revision: Union[str, None] = '43d947192af5'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Phase 1: Create organizations table
|
||||
op.create_table('organizations',
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('user_id', sa.Uuid(), nullable=False),
|
||||
sa.Column('name', sa.String(length=255), nullable=False),
|
||||
sa.Column('instagram', sa.String(length=100), nullable=True),
|
||||
sa.Column('email', sa.String(length=255), nullable=True),
|
||||
sa.Column('city', sa.String(length=100), nullable=True),
|
||||
sa.Column('logo_url', sa.String(length=500), nullable=True),
|
||||
sa.Column('verified', sa.Boolean(), nullable=False),
|
||||
sa.Column('status', sa.String(length=20), nullable=False),
|
||||
sa.Column('block_reason', sa.String(length=500), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('user_id'),
|
||||
)
|
||||
|
||||
# Phase 1b: Add org_id to championships
|
||||
with op.batch_alter_table('championships') as batch_op:
|
||||
batch_op.add_column(sa.Column('org_id', sa.Uuid(), nullable=True))
|
||||
batch_op.create_foreign_key('fk_championships_org_id', 'organizations', ['org_id'], ['id'], ondelete='SET NULL')
|
||||
|
||||
# Phase 2: Migrate existing organizer data to organizations table
|
||||
conn = op.get_bind()
|
||||
rows = conn.execute(
|
||||
sa.text("SELECT id, organization_name FROM users WHERE role = 'organizer' AND organization_name IS NOT NULL")
|
||||
).fetchall()
|
||||
for row in rows:
|
||||
org_id = str(uuid.uuid4())
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"INSERT INTO organizations (id, user_id, name, verified, status, created_at, updated_at) "
|
||||
"VALUES (:id, :user_id, :name, 0, 'active', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)"
|
||||
),
|
||||
{"id": org_id, "user_id": str(row[0]), "name": row[1]}
|
||||
)
|
||||
|
||||
# Phase 3: Remove organization_name from users (keep instagram_handle)
|
||||
with op.batch_alter_table('users') as batch_op:
|
||||
batch_op.drop_column('organization_name')
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Re-add organization_name to users
|
||||
with op.batch_alter_table('users') as batch_op:
|
||||
batch_op.add_column(sa.Column('organization_name', sa.String(length=255), nullable=True))
|
||||
|
||||
# Migrate data back from organizations to users
|
||||
conn = op.get_bind()
|
||||
orgs = conn.execute(sa.text("SELECT user_id, name FROM organizations")).fetchall()
|
||||
for org in orgs:
|
||||
conn.execute(
|
||||
sa.text("UPDATE users SET organization_name = :name WHERE id = :user_id"),
|
||||
{"name": org[1], "user_id": str(org[0])}
|
||||
)
|
||||
|
||||
# Remove org_id from championships
|
||||
with op.batch_alter_table('championships') as batch_op:
|
||||
batch_op.drop_constraint('fk_championships_org_id', type_='foreignkey')
|
||||
batch_op.drop_column('org_id')
|
||||
|
||||
op.drop_table('organizations')
|
||||
@@ -2,21 +2,27 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
||||
|
||||
# Database
|
||||
database_url: str = "postgresql+asyncpg://poledance:poledance@localhost:5432/poledance"
|
||||
# Default: SQLite for local dev. Set DATABASE_URL=postgresql+asyncpg://... for production.
|
||||
DATABASE_URL: str = "sqlite+aiosqlite:///./poledance.db"
|
||||
|
||||
# JWT
|
||||
secret_key: str = "dev-secret-key-change-in-production"
|
||||
algorithm: str = "HS256"
|
||||
access_token_expire_minutes: int = 15
|
||||
refresh_token_expire_days: int = 7
|
||||
SECRET_KEY: str = "dev-secret-change-in-production"
|
||||
ALGORITHM: str = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 15
|
||||
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
|
||||
|
||||
# Instagram Graph API
|
||||
instagram_user_id: str = ""
|
||||
instagram_access_token: str = ""
|
||||
instagram_poll_interval: int = 1800 # seconds
|
||||
INSTAGRAM_USER_ID: str = ""
|
||||
INSTAGRAM_ACCESS_TOKEN: str = ""
|
||||
INSTAGRAM_POLL_INTERVAL: int = 1800
|
||||
|
||||
EXPO_ACCESS_TOKEN: str = ""
|
||||
|
||||
CORS_ORIGINS: str = "http://localhost:3000,http://localhost:8081,exp://"
|
||||
|
||||
@property
|
||||
def cors_origins_list(self) -> list[str]:
|
||||
return [o.strip() for o in self.CORS_ORIGINS.split(",") if o.strip()]
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
from app.crud import crud_user, crud_organization, crud_championship, crud_registration, crud_participant
|
||||
|
||||
__all__ = ["crud_user", "crud_organization", "crud_championship", "crud_registration", "crud_participant"]
|
||||
|
||||
@@ -1,29 +1,24 @@
|
||||
import json
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models.championship import Championship
|
||||
from app.schemas.championship import ChampionshipCreate, ChampionshipUpdate
|
||||
|
||||
|
||||
def _uuid(v: str | uuid.UUID) -> uuid.UUID:
|
||||
return v if isinstance(v, uuid.UUID) else uuid.UUID(str(v))
|
||||
def _serialize(value) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
return json.dumps(value)
|
||||
|
||||
|
||||
async def get(db: AsyncSession, championship_id: str | uuid.UUID) -> Championship | None:
|
||||
async def get(db: AsyncSession, champ_id: str | uuid.UUID) -> Championship | None:
|
||||
cid = champ_id if isinstance(champ_id, uuid.UUID) else uuid.UUID(str(champ_id))
|
||||
result = await db.execute(
|
||||
select(Championship).where(Championship.id == _uuid(championship_id))
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_by_instagram_id(
|
||||
db: AsyncSession, instagram_media_id: str
|
||||
) -> Championship | None:
|
||||
result = await db.execute(
|
||||
select(Championship).where(
|
||||
Championship.instagram_media_id == instagram_media_id
|
||||
)
|
||||
select(Championship).where(Championship.id == cid).options(selectinload(Championship.organization))
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
@@ -32,28 +27,37 @@ async def list_all(
|
||||
db: AsyncSession,
|
||||
status: str | None = None,
|
||||
skip: int = 0,
|
||||
limit: int = 20,
|
||||
limit: int = 50,
|
||||
) -> list[Championship]:
|
||||
q = select(Championship)
|
||||
q = select(Championship).order_by(Championship.event_date.asc()).options(selectinload(Championship.organization))
|
||||
if status:
|
||||
q = q.where(Championship.status == status)
|
||||
q = q.order_by(Championship.event_date.asc().nullslast()).offset(skip).limit(limit)
|
||||
q = q.offset(skip).limit(limit)
|
||||
result = await db.execute(q)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def create(db: AsyncSession, **kwargs) -> Championship:
|
||||
champ = Championship(**kwargs)
|
||||
async def create(db: AsyncSession, data: ChampionshipCreate, org_id: uuid.UUID | None = None) -> Championship:
|
||||
payload = data.model_dump(exclude={"judges", "categories"})
|
||||
payload["judges"] = _serialize(data.judges)
|
||||
payload["categories"] = _serialize(data.categories)
|
||||
if org_id:
|
||||
payload["org_id"] = org_id
|
||||
champ = Championship(**payload)
|
||||
db.add(champ)
|
||||
await db.commit()
|
||||
await db.refresh(champ)
|
||||
await db.refresh(champ, attribute_names=["organization"])
|
||||
return champ
|
||||
|
||||
|
||||
async def update(db: AsyncSession, champ: Championship, **kwargs) -> Championship:
|
||||
for key, value in kwargs.items():
|
||||
if value is not None:
|
||||
setattr(champ, key, value)
|
||||
async def update(db: AsyncSession, champ: Championship, data: ChampionshipUpdate) -> Championship:
|
||||
raw = data.model_dump(exclude_none=True, exclude={"judges", "categories"})
|
||||
for field, value in raw.items():
|
||||
setattr(champ, field, value)
|
||||
if data.judges is not None:
|
||||
champ.judges = _serialize(data.judges)
|
||||
if data.categories is not None:
|
||||
champ.categories = _serialize(data.categories)
|
||||
await db.commit()
|
||||
await db.refresh(champ)
|
||||
return champ
|
||||
|
||||
11
backend/app/crud/crud_organization.py
Normal file
11
backend/app/crud/crud_organization.py
Normal file
@@ -0,0 +1,11 @@
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.organization import Organization
|
||||
|
||||
|
||||
async def get_by_user_id(db: AsyncSession, user_id: uuid.UUID) -> Organization | None:
|
||||
result = await db.execute(select(Organization).where(Organization.user_id == user_id))
|
||||
return result.scalar_one_or_none()
|
||||
@@ -1,62 +1,34 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.participant_list import ParticipantList
|
||||
from app.models.participant import ParticipantList
|
||||
|
||||
|
||||
def _uuid(v: str | uuid.UUID) -> uuid.UUID:
|
||||
return v if isinstance(v, uuid.UUID) else uuid.UUID(str(v))
|
||||
|
||||
|
||||
async def get_by_championship(
|
||||
db: AsyncSession, championship_id: str | uuid.UUID
|
||||
) -> ParticipantList | None:
|
||||
result = await db.execute(
|
||||
select(ParticipantList).where(
|
||||
ParticipantList.championship_id == _uuid(championship_id)
|
||||
)
|
||||
)
|
||||
async def get_for_championship(db: AsyncSession, championship_id: str | uuid.UUID) -> ParticipantList | None:
|
||||
cid = championship_id if isinstance(championship_id, uuid.UUID) else uuid.UUID(str(championship_id))
|
||||
result = await db.execute(select(ParticipantList).where(ParticipantList.championship_id == cid))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def upsert(
|
||||
db: AsyncSession,
|
||||
championship_id: uuid.UUID,
|
||||
published_by: uuid.UUID,
|
||||
notes: str | None,
|
||||
) -> ParticipantList:
|
||||
existing = await get_by_championship(db, championship_id)
|
||||
async def create_or_get(db: AsyncSession, championship_id: uuid.UUID, published_by: uuid.UUID) -> ParticipantList:
|
||||
existing = await get_for_championship(db, championship_id)
|
||||
if existing:
|
||||
existing.notes = notes
|
||||
existing.published_by = published_by
|
||||
await db.commit()
|
||||
await db.refresh(existing)
|
||||
return existing
|
||||
pl = ParticipantList(
|
||||
championship_id=championship_id,
|
||||
published_by=published_by,
|
||||
notes=notes,
|
||||
)
|
||||
pl = ParticipantList(championship_id=championship_id, published_by=published_by)
|
||||
db.add(pl)
|
||||
await db.commit()
|
||||
await db.refresh(pl)
|
||||
return pl
|
||||
|
||||
|
||||
async def publish(db: AsyncSession, pl: ParticipantList) -> ParticipantList:
|
||||
async def publish(db: AsyncSession, pl: ParticipantList, notes: str | None = None) -> ParticipantList:
|
||||
pl.is_published = True
|
||||
pl.published_at = datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
await db.refresh(pl)
|
||||
return pl
|
||||
|
||||
|
||||
async def unpublish(db: AsyncSession, pl: ParticipantList) -> ParticipantList:
|
||||
pl.is_published = False
|
||||
pl.published_at = None
|
||||
pl.published_at = datetime.now(UTC)
|
||||
if notes is not None:
|
||||
pl.notes = notes
|
||||
await db.commit()
|
||||
await db.refresh(pl)
|
||||
return pl
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.refresh_token import RefreshToken
|
||||
|
||||
|
||||
async def create(
|
||||
db: AsyncSession,
|
||||
user_id: uuid.UUID,
|
||||
token_hash: str,
|
||||
expires_at: datetime,
|
||||
) -> RefreshToken:
|
||||
rt = RefreshToken(user_id=user_id, token_hash=token_hash, expires_at=expires_at)
|
||||
db.add(rt)
|
||||
await db.commit()
|
||||
await db.refresh(rt)
|
||||
return rt
|
||||
|
||||
|
||||
async def get_by_hash(db: AsyncSession, token_hash: str) -> RefreshToken | None:
|
||||
result = await db.execute(
|
||||
select(RefreshToken).where(RefreshToken.token_hash == token_hash)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def revoke(db: AsyncSession, rt: RefreshToken) -> None:
|
||||
rt.revoked = True
|
||||
await db.commit()
|
||||
|
||||
|
||||
def is_valid(rt: RefreshToken) -> bool:
|
||||
if rt.revoked:
|
||||
return False
|
||||
now = datetime.now(timezone.utc)
|
||||
expires = rt.expires_at
|
||||
# SQLite returns naive datetimes; normalise to UTC-aware for comparison
|
||||
if expires.tzinfo is None:
|
||||
expires = expires.replace(tzinfo=timezone.utc)
|
||||
return expires > now
|
||||
@@ -1,69 +1,68 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models.registration import Registration
|
||||
from app.schemas.registration import RegistrationCreate, RegistrationUpdate
|
||||
|
||||
|
||||
def _uuid(v: str | uuid.UUID) -> uuid.UUID:
|
||||
return v if isinstance(v, uuid.UUID) else uuid.UUID(str(v))
|
||||
|
||||
|
||||
async def get(db: AsyncSession, registration_id: str | uuid.UUID) -> Registration | None:
|
||||
async def get(db: AsyncSession, reg_id: str | uuid.UUID) -> Registration | None:
|
||||
rid = reg_id if isinstance(reg_id, uuid.UUID) else uuid.UUID(str(reg_id))
|
||||
result = await db.execute(
|
||||
select(Registration).where(Registration.id == _uuid(registration_id))
|
||||
select(Registration).where(Registration.id == rid).options(selectinload(Registration.user))
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_by_champ_and_user(
|
||||
db: AsyncSession, championship_id: str | uuid.UUID, user_id: str | uuid.UUID
|
||||
async def get_by_user_and_championship(
|
||||
db: AsyncSession, user_id: uuid.UUID, championship_id: uuid.UUID
|
||||
) -> Registration | None:
|
||||
result = await db.execute(
|
||||
select(Registration).where(
|
||||
Registration.championship_id == _uuid(championship_id),
|
||||
Registration.user_id == _uuid(user_id),
|
||||
Registration.user_id == user_id,
|
||||
Registration.championship_id == championship_id,
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def list_by_user(db: AsyncSession, user_id: str | uuid.UUID) -> list[Registration]:
|
||||
result = await db.execute(
|
||||
select(Registration)
|
||||
.where(Registration.user_id == _uuid(user_id))
|
||||
.order_by(Registration.submitted_at.desc())
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def list_by_championship(
|
||||
db: AsyncSession, championship_id: str | uuid.UUID
|
||||
async def list_for_championship(
|
||||
db: AsyncSession, championship_id: str | uuid.UUID, skip: int = 0, limit: int = 100
|
||||
) -> list[Registration]:
|
||||
cid = championship_id if isinstance(championship_id, uuid.UUID) else uuid.UUID(str(championship_id))
|
||||
result = await db.execute(
|
||||
select(Registration)
|
||||
.where(Registration.championship_id == _uuid(championship_id))
|
||||
.order_by(Registration.submitted_at.asc())
|
||||
.where(Registration.championship_id == cid)
|
||||
.options(selectinload(Registration.user))
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def create(
|
||||
db: AsyncSession,
|
||||
championship_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
category: str | None,
|
||||
level: str | None,
|
||||
notes: str | None,
|
||||
) -> Registration:
|
||||
async def list_for_user(db: AsyncSession, user_id: uuid.UUID, skip: int = 0, limit: int = 50) -> list[Registration]:
|
||||
result = await db.execute(
|
||||
select(Registration)
|
||||
.where(Registration.user_id == user_id)
|
||||
.options(selectinload(Registration.championship))
|
||||
.order_by(Registration.submitted_at.desc())
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def create(db: AsyncSession, user_id: uuid.UUID, data: RegistrationCreate) -> Registration:
|
||||
reg = Registration(
|
||||
championship_id=championship_id,
|
||||
championship_id=data.championship_id,
|
||||
user_id=user_id,
|
||||
category=category,
|
||||
level=level,
|
||||
notes=notes,
|
||||
category=data.category,
|
||||
level=data.level,
|
||||
notes=data.notes,
|
||||
status="submitted",
|
||||
)
|
||||
db.add(reg)
|
||||
await db.commit()
|
||||
@@ -71,11 +70,12 @@ async def create(
|
||||
return reg
|
||||
|
||||
|
||||
async def update_status(
|
||||
db: AsyncSession, reg: Registration, status: str
|
||||
) -> Registration:
|
||||
reg.status = status
|
||||
reg.decided_at = datetime.now(timezone.utc)
|
||||
async def update(db: AsyncSession, reg: Registration, data: RegistrationUpdate) -> Registration:
|
||||
raw = data.model_dump(exclude_none=True)
|
||||
for field, value in raw.items():
|
||||
setattr(reg, field, value)
|
||||
if "status" in raw and raw["status"] in ("accepted", "rejected", "waitlisted"):
|
||||
reg.decided_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
await db.refresh(reg)
|
||||
return reg
|
||||
|
||||
@@ -2,60 +2,70 @@ import uuid
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models.organization import Organization
|
||||
from app.models.user import User
|
||||
from app.schemas.user import UserRegister, UserUpdate
|
||||
from app.services.auth_service import hash_password
|
||||
|
||||
|
||||
async def get_by_id(db: AsyncSession, user_id: str | uuid.UUID) -> User | None:
|
||||
uid = user_id if isinstance(user_id, uuid.UUID) else uuid.UUID(str(user_id))
|
||||
result = await db.execute(
|
||||
select(User).where(User.id == uid).options(selectinload(User.organization))
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_by_email(db: AsyncSession, email: str) -> User | None:
|
||||
result = await db.execute(select(User).where(User.email == email))
|
||||
result = await db.execute(
|
||||
select(User).where(User.email == email.lower()).options(selectinload(User.organization))
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get(db: AsyncSession, user_id: str | uuid.UUID) -> User | None:
|
||||
uid = uuid.UUID(str(user_id)) if not isinstance(user_id, uuid.UUID) else user_id
|
||||
result = await db.execute(select(User).where(User.id == uid))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def create(
|
||||
db: AsyncSession,
|
||||
email: str,
|
||||
password: str,
|
||||
full_name: str,
|
||||
phone: str | None = None,
|
||||
role: str = "member",
|
||||
status: str = "pending",
|
||||
) -> User:
|
||||
async def create(db: AsyncSession, data: UserRegister) -> User:
|
||||
user = User(
|
||||
email=email,
|
||||
hashed_password=hash_password(password),
|
||||
full_name=full_name,
|
||||
phone=phone,
|
||||
role=role,
|
||||
status=status,
|
||||
email=data.email.lower(),
|
||||
hashed_password=hash_password(data.password),
|
||||
full_name=data.full_name,
|
||||
phone=data.phone,
|
||||
instagram_handle=data.instagram_handle,
|
||||
role=data.requested_role,
|
||||
# Members are auto-approved; organizers require admin review
|
||||
status="approved" if data.requested_role == "member" else "pending",
|
||||
)
|
||||
db.add(user)
|
||||
await db.flush() # get user.id for the FK
|
||||
|
||||
# Create Organization row for organizer registrations
|
||||
if data.requested_role == "organizer" and data.organization_name:
|
||||
org = Organization(
|
||||
user_id=user.id,
|
||||
name=data.organization_name,
|
||||
status="pending",
|
||||
verified=False,
|
||||
)
|
||||
db.add(org)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
await db.refresh(user, attribute_names=["organization"])
|
||||
return user
|
||||
|
||||
|
||||
async def list_all(
|
||||
db: AsyncSession,
|
||||
status: str | None = None,
|
||||
role: str | None = None,
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
) -> list[User]:
|
||||
q = select(User)
|
||||
if status:
|
||||
q = q.where(User.status == status)
|
||||
if role:
|
||||
q = q.where(User.role == role)
|
||||
q = q.offset(skip).limit(limit)
|
||||
result = await db.execute(q)
|
||||
return list(result.scalars().all())
|
||||
async def update(db: AsyncSession, user: User, data: UserUpdate) -> User:
|
||||
user_fields = data.model_dump(exclude_none=True, exclude={"organization_name"})
|
||||
for field, value in user_fields.items():
|
||||
setattr(user, field, value)
|
||||
|
||||
# Route org field updates to Organization table
|
||||
if data.organization_name is not None and user.organization:
|
||||
user.organization.name = data.organization_name
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user, attribute_names=["organization"])
|
||||
return user
|
||||
|
||||
|
||||
async def set_status(db: AsyncSession, user: User, status: str) -> User:
|
||||
@@ -65,8 +75,8 @@ async def set_status(db: AsyncSession, user: User, status: str) -> User:
|
||||
return user
|
||||
|
||||
|
||||
async def set_push_token(db: AsyncSession, user: User, token: str) -> User:
|
||||
user.expo_push_token = token
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
return user
|
||||
async def list_all(db: AsyncSession, skip: int = 0, limit: int = 100) -> list[User]:
|
||||
result = await db.execute(
|
||||
select(User).options(selectinload(User.organization)).offset(skip).limit(limit)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
@@ -1,47 +1,17 @@
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from app.config import settings
|
||||
|
||||
_connect_args = {"check_same_thread": False} if settings.DATABASE_URL.startswith("sqlite") else {}
|
||||
engine = create_async_engine(settings.DATABASE_URL, echo=False, connect_args=_connect_args)
|
||||
AsyncSessionLocal = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
def _make_engine():
|
||||
return create_async_engine(settings.database_url, echo=False)
|
||||
|
||||
|
||||
def _make_session_factory(engine):
|
||||
return async_sessionmaker(engine, expire_on_commit=False)
|
||||
|
||||
|
||||
# Lazily initialised on first use so tests can patch settings before import
|
||||
_engine = None
|
||||
_session_factory = None
|
||||
|
||||
|
||||
def get_engine():
|
||||
global _engine
|
||||
if _engine is None:
|
||||
_engine = _make_engine()
|
||||
return _engine
|
||||
|
||||
|
||||
def get_session_factory():
|
||||
global _session_factory
|
||||
if _session_factory is None:
|
||||
_session_factory = _make_session_factory(get_engine())
|
||||
return _session_factory
|
||||
|
||||
|
||||
# Alias kept for Alembic and bot usage
|
||||
AsyncSessionLocal = None # populated on first call to get_session_factory()
|
||||
|
||||
|
||||
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
factory = get_session_factory()
|
||||
async with factory() as session:
|
||||
async def get_db() -> AsyncSession:
|
||||
async with AsyncSessionLocal() as session:
|
||||
yield session
|
||||
|
||||
@@ -1,53 +1,42 @@
|
||||
import jwt
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.crud import crud_user
|
||||
from app.database import AsyncSession, get_db
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from app.services.auth_service import decode_access_token
|
||||
from app.crud import crud_user
|
||||
|
||||
bearer_scheme = HTTPBearer()
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme),
|
||||
token: str = Depends(oauth2_scheme),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> User:
|
||||
try:
|
||||
payload = decode_access_token(credentials.credentials)
|
||||
except jwt.InvalidTokenError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired token"
|
||||
)
|
||||
user = await crud_user.get(db, payload["sub"])
|
||||
if not user:
|
||||
payload = decode_access_token(token)
|
||||
if payload is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired token")
|
||||
|
||||
user = await crud_user.get_by_id(db, payload["sub"])
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
|
||||
return user
|
||||
|
||||
|
||||
async def get_approved_user(user: User = Depends(get_current_user)) -> User:
|
||||
if user.status != "approved":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Account pending approval",
|
||||
)
|
||||
return user
|
||||
async def get_approved_user(current_user: User = Depends(get_current_user)) -> User:
|
||||
if current_user.status != "approved":
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Account not yet approved")
|
||||
return current_user
|
||||
|
||||
|
||||
async def get_organizer(user: User = Depends(get_approved_user)) -> User:
|
||||
if user.role not in ("organizer", "admin"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Organizer access required",
|
||||
)
|
||||
return user
|
||||
async def get_organizer(current_user: User = Depends(get_approved_user)) -> User:
|
||||
if current_user.role not in ("organizer", "admin"):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Organizer access required")
|
||||
return current_user
|
||||
|
||||
|
||||
async def get_admin(user: User = Depends(get_approved_user)) -> User:
|
||||
if user.role != "admin":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Admin access required",
|
||||
)
|
||||
return user
|
||||
async def get_admin(current_user: User = Depends(get_approved_user)) -> User:
|
||||
if current_user.role != "admin":
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
|
||||
return current_user
|
||||
|
||||
@@ -1,56 +1,27 @@
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.config import settings
|
||||
from app.routers import auth, championships, participant_lists, registrations, users
|
||||
from app.services.instagram_service import poll_instagram, refresh_instagram_token
|
||||
from app.routers import auth, championships, registrations, participant_lists, users
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
scheduler = AsyncIOScheduler()
|
||||
scheduler.add_job(
|
||||
poll_instagram,
|
||||
"interval",
|
||||
seconds=settings.instagram_poll_interval,
|
||||
id="instagram_poll",
|
||||
)
|
||||
scheduler.add_job(
|
||||
refresh_instagram_token,
|
||||
"interval",
|
||||
weeks=1,
|
||||
id="instagram_token_refresh",
|
||||
)
|
||||
scheduler.start()
|
||||
yield
|
||||
scheduler.shutdown()
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="Pole Dance Championships API",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
app = FastAPI(title="Pole Dance Championships API", version="1.0.0")
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"], # tighten in Phase 7
|
||||
allow_origins=settings.cors_origins_list,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
PREFIX = "/api/v1"
|
||||
app.include_router(auth.router, prefix=PREFIX)
|
||||
app.include_router(users.router, prefix=PREFIX)
|
||||
app.include_router(championships.router, prefix=PREFIX)
|
||||
app.include_router(registrations.router, prefix=PREFIX)
|
||||
app.include_router(participant_lists.router, prefix=PREFIX)
|
||||
app.include_router(auth.router, prefix="/api/v1/auth", tags=["auth"])
|
||||
app.include_router(users.router, prefix="/api/v1/users", tags=["users"])
|
||||
app.include_router(championships.router, prefix="/api/v1/championships", tags=["championships"])
|
||||
app.include_router(registrations.router, prefix="/api/v1/registrations", tags=["registrations"])
|
||||
app.include_router(participant_lists.router, prefix="/api/v1", tags=["participant-lists"])
|
||||
|
||||
|
||||
@app.get("/internal/health", tags=["internal"])
|
||||
@app.get("/internal/health", tags=["health"])
|
||||
async def health():
|
||||
return {"status": "ok"}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
from app.models.user import User
|
||||
from app.models.refresh_token import RefreshToken
|
||||
from app.models.user import User, RefreshToken
|
||||
from app.models.organization import Organization
|
||||
from app.models.championship import Championship
|
||||
from app.models.registration import Registration
|
||||
from app.models.participant_list import ParticipantList
|
||||
from app.models.notification_log import NotificationLog
|
||||
from app.models.participant import ParticipantList
|
||||
from app.models.notification import NotificationLog
|
||||
|
||||
__all__ = [
|
||||
"User",
|
||||
"RefreshToken",
|
||||
"Organization",
|
||||
"Championship",
|
||||
"Registration",
|
||||
"ParticipantList",
|
||||
|
||||
@@ -1,41 +1,43 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, String, Text
|
||||
from sqlalchemy import DateTime, Float, ForeignKey, Integer, String, Text, Uuid, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class Championship(Base):
|
||||
__tablename__ = "championships"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||
title: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
org_id: Mapped[uuid.UUID | None] = mapped_column(Uuid(as_uuid=True), ForeignKey("organizations.id", ondelete="SET NULL"))
|
||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
location: Mapped[str | None] = mapped_column(String(500))
|
||||
event_date: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
registration_open_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
registration_close_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
status: Mapped[str] = mapped_column(String(30), nullable=False, default="draft")
|
||||
# 'draft' | 'open' | 'closed' | 'completed'
|
||||
|
||||
form_url: Mapped[str | None] = mapped_column(String(2048))
|
||||
entry_fee: Mapped[float | None] = mapped_column(Float)
|
||||
video_max_duration: Mapped[int | None] = mapped_column(Integer) # seconds
|
||||
judges: Mapped[str | None] = mapped_column(Text) # JSON string: [{name, bio, instagram}]
|
||||
categories: Mapped[str | None] = mapped_column(Text) # JSON string: ["cat1", "cat2"]
|
||||
|
||||
# Status: 'draft' | 'open' | 'closed' | 'completed'
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False, default="draft")
|
||||
# Source: 'manual' | 'instagram'
|
||||
source: Mapped[str] = mapped_column(String(20), nullable=False, default="manual")
|
||||
# 'manual' | 'instagram'
|
||||
instagram_media_id: Mapped[str | None] = mapped_column(String(100), unique=True)
|
||||
image_url: Mapped[str | None] = mapped_column(String(1000))
|
||||
instagram_media_id: Mapped[str | None] = mapped_column(String(255), unique=True)
|
||||
image_url: Mapped[str | None] = mapped_column(String(2048))
|
||||
raw_caption_text: Mapped[str | None] = mapped_column(Text)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=_now, onupdate=_now
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
registrations: Mapped[list["Registration"]] = relationship(
|
||||
back_populates="championship", cascade="all, delete-orphan"
|
||||
)
|
||||
participant_list: Mapped["ParticipantList | None"] = relationship(
|
||||
back_populates="championship", cascade="all, delete-orphan"
|
||||
)
|
||||
organization: Mapped["Organization | None"] = relationship(back_populates="championships") # type: ignore[name-defined]
|
||||
registrations: Mapped[list["Registration"]] = relationship(back_populates="championship", cascade="all, delete-orphan") # type: ignore[name-defined]
|
||||
participant_list: Mapped["ParticipantList | None"] = relationship(back_populates="championship", uselist=False, cascade="all, delete-orphan") # type: ignore[name-defined]
|
||||
|
||||
20
backend/app/models/discipline.py
Normal file
20
backend/app/models/discipline.py
Normal file
@@ -0,0 +1,20 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, JSON, String, Uuid, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class Discipline(Base):
|
||||
__tablename__ = "disciplines"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
championship_id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), ForeignKey("championships.id", ondelete="CASCADE"), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
levels: Mapped[list | None] = mapped_column(JSON) # e.g. ["Amateur", "Pro", "Open"]
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
championship: Mapped["Championship"] = relationship(back_populates="disciplines") # type: ignore[name-defined]
|
||||
25
backend/app/models/fee.py
Normal file
25
backend/app/models/fee.py
Normal file
@@ -0,0 +1,25 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Numeric, Text, Uuid, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class Fee(Base):
|
||||
__tablename__ = "fees"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
championship_id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), ForeignKey("championships.id", ondelete="CASCADE"), nullable=False, unique=True)
|
||||
video_selection: Mapped[Decimal | None] = mapped_column(Numeric(10, 2)) # video review/selection fee
|
||||
solo: Mapped[Decimal | None] = mapped_column(Numeric(10, 2))
|
||||
duet: Mapped[Decimal | None] = mapped_column(Numeric(10, 2))
|
||||
group: Mapped[Decimal | None] = mapped_column(Numeric(10, 2))
|
||||
refund_note: Mapped[str | None] = mapped_column(Text)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
championship: Mapped["Championship"] = relationship(back_populates="fees") # type: ignore[name-defined]
|
||||
22
backend/app/models/judge.py
Normal file
22
backend/app/models/judge.py
Normal file
@@ -0,0 +1,22 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, Uuid, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class Judge(Base):
|
||||
__tablename__ = "judges"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
championship_id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), ForeignKey("championships.id", ondelete="CASCADE"), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
instagram: Mapped[str | None] = mapped_column(String(255))
|
||||
bio: Mapped[str | None] = mapped_column(Text)
|
||||
photo_url: Mapped[str | None] = mapped_column(String(2048))
|
||||
sort_order: Mapped[int] = mapped_column(Integer, default=0)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
championship: Mapped["Championship"] = relationship(back_populates="judges_list") # type: ignore[name-defined]
|
||||
27
backend/app/models/notification.py
Normal file
27
backend/app/models/notification.py
Normal file
@@ -0,0 +1,27 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, String, Text, Uuid, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class NotificationLog(Base):
|
||||
__tablename__ = "notification_log"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
Uuid(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
registration_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
Uuid(as_uuid=True), ForeignKey("registrations.id", ondelete="SET NULL")
|
||||
)
|
||||
type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
body: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
sent_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
delivery_status: Mapped[str] = mapped_column(String(20), default="pending")
|
||||
|
||||
user: Mapped["User"] = relationship(back_populates="notification_logs") # type: ignore[name-defined]
|
||||
registration: Mapped["Registration | None"] = relationship(back_populates="notification_logs") # type: ignore[name-defined]
|
||||
@@ -1,34 +0,0 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Index, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class NotificationLog(Base):
|
||||
__tablename__ = "notification_log"
|
||||
__table_args__ = (Index("idx_notification_log_user_id", "user_id"),)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
registration_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
ForeignKey("registrations.id", ondelete="SET NULL")
|
||||
)
|
||||
type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
body: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
sent_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
delivery_status: Mapped[str] = mapped_column(String(30), default="pending")
|
||||
|
||||
user: Mapped["User"] = relationship(back_populates="notification_logs")
|
||||
registration: Mapped["Registration | None"] = relationship(
|
||||
back_populates="notification_logs"
|
||||
)
|
||||
30
backend/app/models/organization.py
Normal file
30
backend/app/models/organization.py
Normal file
@@ -0,0 +1,30 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, String, Uuid, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class Organization(Base):
|
||||
__tablename__ = "organizations"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), unique=True)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
instagram: Mapped[str | None] = mapped_column(String(100))
|
||||
email: Mapped[str | None] = mapped_column(String(255))
|
||||
city: Mapped[str | None] = mapped_column(String(100))
|
||||
logo_url: Mapped[str | None] = mapped_column(String(500))
|
||||
verified: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
# 'pending' | 'active' | 'rejected' | 'blocked'
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False, default="pending")
|
||||
block_reason: Mapped[str | None] = mapped_column(String(500))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
user: Mapped["User"] = relationship(back_populates="organization") # type: ignore[name-defined]
|
||||
championships: Mapped[list["Championship"]] = relationship(back_populates="organization") # type: ignore[name-defined]
|
||||
25
backend/app/models/participant.py
Normal file
25
backend/app/models/participant.py
Normal file
@@ -0,0 +1,25 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Text, Uuid, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class ParticipantList(Base):
|
||||
__tablename__ = "participant_lists"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
championship_id: Mapped[uuid.UUID] = mapped_column(
|
||||
Uuid(as_uuid=True), ForeignKey("championships.id", ondelete="CASCADE"), nullable=False, unique=True
|
||||
)
|
||||
published_by: Mapped[uuid.UUID | None] = mapped_column(
|
||||
Uuid(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL")
|
||||
)
|
||||
is_published: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
notes: Mapped[str | None] = mapped_column(Text)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
championship: Mapped["Championship"] = relationship(back_populates="participant_list") # type: ignore[name-defined]
|
||||
@@ -1,33 +0,0 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class ParticipantList(Base):
|
||||
__tablename__ = "participant_lists"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||
championship_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("championships.id", ondelete="CASCADE"), unique=True, nullable=False
|
||||
)
|
||||
published_by: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("users.id"), nullable=False
|
||||
)
|
||||
is_published: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
notes: Mapped[str | None] = mapped_column(Text)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=_now, onupdate=_now
|
||||
)
|
||||
|
||||
championship: Mapped["Championship"] = relationship(back_populates="participant_list")
|
||||
organizer: Mapped["User"] = relationship()
|
||||
@@ -1,27 +0,0 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class RefreshToken(Base):
|
||||
__tablename__ = "refresh_tokens"
|
||||
__table_args__ = (Index("idx_refresh_tokens_user_id", "user_id"),)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
token_hash: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
revoked: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
|
||||
user: Mapped["User"] = relationship(back_populates="refresh_tokens")
|
||||
@@ -1,45 +1,36 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Index, String, Text, UniqueConstraint
|
||||
from sqlalchemy import DateTime, ForeignKey, String, Text, UniqueConstraint, Uuid, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class Registration(Base):
|
||||
__tablename__ = "registrations"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("championship_id", "user_id", name="uq_registration_champ_user"),
|
||||
Index("idx_registrations_championship_id", "championship_id"),
|
||||
Index("idx_registrations_user_id", "user_id"),
|
||||
)
|
||||
__table_args__ = (UniqueConstraint("championship_id", "user_id", name="uq_registration_champ_user"),)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
championship_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("championships.id", ondelete="CASCADE"), nullable=False
|
||||
Uuid(as_uuid=True), ForeignKey("championships.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||
Uuid(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
category: Mapped[str | None] = mapped_column(String(255))
|
||||
level: Mapped[str | None] = mapped_column(String(255))
|
||||
level: Mapped[str | None] = mapped_column(String(100))
|
||||
notes: Mapped[str | None] = mapped_column(Text)
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False, default="submitted")
|
||||
# 'submitted' | 'accepted' | 'rejected' | 'waitlisted'
|
||||
submitted_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
decided_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=_now, onupdate=_now
|
||||
)
|
||||
|
||||
championship: Mapped["Championship"] = relationship(back_populates="registrations")
|
||||
user: Mapped["User"] = relationship(back_populates="registrations")
|
||||
notification_logs: Mapped[list["NotificationLog"]] = relationship(
|
||||
back_populates="registration"
|
||||
)
|
||||
# Multi-stage status:
|
||||
# 'submitted' → 'form_submitted' → 'payment_pending' → 'payment_confirmed' →
|
||||
# 'video_submitted' → 'accepted' | 'rejected' | 'waitlisted'
|
||||
status: Mapped[str] = mapped_column(String(30), nullable=False, default="submitted")
|
||||
video_url: Mapped[str | None] = mapped_column(String(2048))
|
||||
|
||||
submitted_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
decided_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
championship: Mapped["Championship"] = relationship(back_populates="registrations") # type: ignore[name-defined]
|
||||
user: Mapped["User"] = relationship(back_populates="registrations") # type: ignore[name-defined]
|
||||
notification_logs: Mapped[list["NotificationLog"]] = relationship(back_populates="registration") # type: ignore[name-defined]
|
||||
|
||||
22
backend/app/models/rule.py
Normal file
22
backend/app/models/rule.py
Normal file
@@ -0,0 +1,22 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, String, Text, Uuid, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class Rule(Base):
|
||||
__tablename__ = "rules"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
championship_id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), ForeignKey("championships.id", ondelete="CASCADE"), nullable=False)
|
||||
# section: 'general' | 'costume' | 'scoring' | 'penalty'
|
||||
section: Mapped[str] = mapped_column(String(20), nullable=False, default="general")
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
value: Mapped[str | None] = mapped_column(Text)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
championship: Mapped["Championship"] = relationship(back_populates="rules") # type: ignore[name-defined]
|
||||
19
backend/app/models/style.py
Normal file
19
backend/app/models/style.py
Normal file
@@ -0,0 +1,19 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, String, Uuid, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class Style(Base):
|
||||
__tablename__ = "styles"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
championship_id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), ForeignKey("championships.id", ondelete="CASCADE"), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False) # e.g. "Exotic", "Sport", "Art"
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
championship: Mapped["Championship"] = relationship(back_populates="styles") # type: ignore[name-defined]
|
||||
@@ -1,40 +1,43 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, String
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, String, Uuid, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
|
||||
hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
full_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
phone: Mapped[str | None] = mapped_column(String(50))
|
||||
instagram_handle: Mapped[str | None] = mapped_column(String(100))
|
||||
role: Mapped[str] = mapped_column(String(20), nullable=False, default="member")
|
||||
# 'member' | 'organizer' | 'admin'
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False, default="pending")
|
||||
# 'pending' | 'approved' | 'rejected'
|
||||
expo_push_token: Mapped[str | None] = mapped_column(String(512))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False, default="pending")
|
||||
expo_push_token: Mapped[str | None] = mapped_column(String(255))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=_now, onupdate=_now
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
refresh_tokens: Mapped[list["RefreshToken"]] = relationship(
|
||||
back_populates="user", cascade="all, delete-orphan"
|
||||
)
|
||||
registrations: Mapped[list["Registration"]] = relationship(
|
||||
back_populates="user", cascade="all, delete-orphan"
|
||||
)
|
||||
notification_logs: Mapped[list["NotificationLog"]] = relationship(
|
||||
back_populates="user", cascade="all, delete-orphan"
|
||||
)
|
||||
refresh_tokens: Mapped[list["RefreshToken"]] = relationship(back_populates="user", cascade="all, delete-orphan")
|
||||
registrations: Mapped[list["Registration"]] = relationship(back_populates="user") # type: ignore[name-defined]
|
||||
notification_logs: Mapped[list["NotificationLog"]] = relationship(back_populates="user") # type: ignore[name-defined]
|
||||
organization: Mapped["Organization | None"] = relationship(back_populates="user", uselist=False) # type: ignore[name-defined]
|
||||
|
||||
|
||||
class RefreshToken(Base):
|
||||
__tablename__ = "refresh_tokens"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"))
|
||||
token_hash: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
revoked: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
user: Mapped["User"] = relationship(back_populates="refresh_tokens")
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
from app.routers import auth, championships, registrations, participant_lists, users
|
||||
|
||||
__all__ = ["auth", "championships", "registrations", "participant_lists", "users"]
|
||||
|
||||
@@ -1,77 +1,79 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.crud import crud_refresh_token, crud_user
|
||||
from app.crud import crud_user
|
||||
from app.database import get_db
|
||||
from app.dependencies import get_current_user
|
||||
from app.models.user import User
|
||||
from app.schemas.auth import LoginRequest, RefreshRequest, RegisterRequest, TokenResponse, UserOut
|
||||
from app.schemas.auth import LogoutRequest, RefreshRequest, RegisterResponse, TokenPair, TokenRefreshed
|
||||
from app.schemas.user import UserLogin, UserOut, UserRegister, UserUpdate
|
||||
from app.services.auth_service import (
|
||||
create_access_token,
|
||||
create_refresh_token,
|
||||
hash_token,
|
||||
revoke_refresh_token,
|
||||
rotate_refresh_token,
|
||||
verify_password,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/register", response_model=TokenResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def register(body: RegisterRequest, db: AsyncSession = Depends(get_db)):
|
||||
if await crud_user.get_by_email(db, body.email):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT, detail="Email already registered"
|
||||
@router.post("/register", response_model=RegisterResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def register(data: UserRegister, db: AsyncSession = Depends(get_db)):
|
||||
if await crud_user.get_by_email(db, data.email):
|
||||
raise HTTPException(status_code=400, detail="Email already registered")
|
||||
user = await crud_user.create(db, data)
|
||||
# Members are auto-approved — issue tokens immediately so they can log in right away
|
||||
if user.role == "member":
|
||||
access_token = create_access_token(user.id)
|
||||
refresh_token = await create_refresh_token(db, user.id)
|
||||
return RegisterResponse(
|
||||
user=UserOut.model_validate(user),
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
)
|
||||
user = await crud_user.create(
|
||||
db,
|
||||
email=body.email,
|
||||
password=body.password,
|
||||
full_name=body.full_name,
|
||||
phone=body.phone,
|
||||
# Organizers must wait for admin approval
|
||||
return RegisterResponse(user=UserOut.model_validate(user))
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenPair)
|
||||
async def login(data: UserLogin, db: AsyncSession = Depends(get_db)):
|
||||
user = await crud_user.get_by_email(db, data.email)
|
||||
if not user or not verify_password(data.password, user.hashed_password):
|
||||
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||
access_token = create_access_token(user.id)
|
||||
refresh_token = await create_refresh_token(db, user.id)
|
||||
return TokenPair(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
user=UserOut.model_validate(user),
|
||||
)
|
||||
return await _issue_tokens(db, user)
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
async def login(body: LoginRequest, db: AsyncSession = Depends(get_db)):
|
||||
user = await crud_user.get_by_email(db, body.email)
|
||||
if not user or not verify_password(body.password, user.hashed_password):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials"
|
||||
)
|
||||
return await _issue_tokens(db, user)
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=TokenResponse)
|
||||
async def refresh(body: RefreshRequest, db: AsyncSession = Depends(get_db)):
|
||||
hashed = hash_token(body.refresh_token)
|
||||
rt = await crud_refresh_token.get_by_hash(db, hashed)
|
||||
if not rt or not crud_refresh_token.is_valid(rt):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired refresh token"
|
||||
)
|
||||
await crud_refresh_token.revoke(db, rt)
|
||||
user = await crud_user.get(db, rt.user_id)
|
||||
if not user:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
||||
return await _issue_tokens(db, user)
|
||||
@router.post("/refresh", response_model=TokenRefreshed)
|
||||
async def refresh(data: RefreshRequest, db: AsyncSession = Depends(get_db)):
|
||||
result = await rotate_refresh_token(db, data.refresh_token)
|
||||
if result is None:
|
||||
raise HTTPException(status_code=401, detail="Invalid or expired refresh token")
|
||||
new_refresh, user_id = result
|
||||
access_token = create_access_token(user_id)
|
||||
return TokenRefreshed(access_token=access_token, refresh_token=new_refresh)
|
||||
|
||||
|
||||
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def logout(body: RefreshRequest, db: AsyncSession = Depends(get_db)):
|
||||
hashed = hash_token(body.refresh_token)
|
||||
rt = await crud_refresh_token.get_by_hash(db, hashed)
|
||||
if rt:
|
||||
await crud_refresh_token.revoke(db, rt)
|
||||
async def logout(data: LogoutRequest, db: AsyncSession = Depends(get_db)):
|
||||
await revoke_refresh_token(db, data.refresh_token)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserOut)
|
||||
async def me(user: User = Depends(get_current_user)):
|
||||
return user
|
||||
async def me(current_user: User = Depends(get_current_user)):
|
||||
return current_user
|
||||
|
||||
|
||||
async def _issue_tokens(db: AsyncSession, user: User) -> TokenResponse:
|
||||
access = create_access_token(str(user.id), user.role, user.status)
|
||||
raw_rt, hashed_rt, expires_at = create_refresh_token()
|
||||
await crud_refresh_token.create(db, user.id, hashed_rt, expires_at)
|
||||
return TokenResponse(access_token=access, refresh_token=raw_rt)
|
||||
@router.patch("/me", response_model=UserOut)
|
||||
async def update_me(
|
||||
data: UserUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return await crud_user.update(db, current_user, data)
|
||||
|
||||
@@ -1,70 +1,70 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.crud import crud_championship
|
||||
from app.database import get_db
|
||||
from app.dependencies import get_admin, get_approved_user, get_organizer
|
||||
from app.dependencies import get_approved_user, get_organizer
|
||||
from app.models.user import User
|
||||
from app.schemas.championship import ChampionshipCreate, ChampionshipOut, ChampionshipUpdate
|
||||
|
||||
router = APIRouter(prefix="/championships", tags=["championships"])
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=list[ChampionshipOut])
|
||||
async def list_championships(
|
||||
status: str | None = None,
|
||||
status: str | None = Query(None),
|
||||
skip: int = 0,
|
||||
limit: int = 20,
|
||||
limit: int = 50,
|
||||
_user: User = Depends(get_approved_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return await crud_championship.list_all(db, status=status, skip=skip, limit=limit)
|
||||
|
||||
|
||||
@router.get("/{championship_id}", response_model=ChampionshipOut)
|
||||
@router.get("/{champ_id}", response_model=ChampionshipOut)
|
||||
async def get_championship(
|
||||
championship_id: str,
|
||||
champ_id: uuid.UUID,
|
||||
_user: User = Depends(get_approved_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
champ = await crud_championship.get(db, championship_id)
|
||||
champ = await crud_championship.get(db, champ_id)
|
||||
if not champ:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||
raise HTTPException(status_code=404, detail="Championship not found")
|
||||
return champ
|
||||
|
||||
|
||||
@router.post("", response_model=ChampionshipOut, status_code=status.HTTP_201_CREATED)
|
||||
async def create_championship(
|
||||
body: ChampionshipCreate,
|
||||
_organizer: User = Depends(get_organizer),
|
||||
data: ChampionshipCreate,
|
||||
user: User = Depends(get_organizer),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return await crud_championship.create(db, **body.model_dump(), source="manual")
|
||||
org_id = user.organization.id if user.organization else None
|
||||
return await crud_championship.create(db, data, org_id=org_id)
|
||||
|
||||
|
||||
@router.patch("/{championship_id}", response_model=ChampionshipOut)
|
||||
@router.patch("/{champ_id}", response_model=ChampionshipOut)
|
||||
async def update_championship(
|
||||
championship_id: str,
|
||||
body: ChampionshipUpdate,
|
||||
_organizer: User = Depends(get_organizer),
|
||||
champ_id: uuid.UUID,
|
||||
data: ChampionshipUpdate,
|
||||
_user: User = Depends(get_organizer),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
champ = await crud_championship.get(db, championship_id)
|
||||
champ = await crud_championship.get(db, champ_id)
|
||||
if not champ:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||
updates = {k: v for k, v in body.model_dump().items() if v is not None}
|
||||
return await crud_championship.update(db, champ, **updates)
|
||||
raise HTTPException(status_code=404, detail="Championship not found")
|
||||
return await crud_championship.update(db, champ, data)
|
||||
|
||||
|
||||
@router.delete("/{championship_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
@router.delete("/{champ_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_championship(
|
||||
championship_id: str,
|
||||
_admin: User = Depends(get_admin),
|
||||
champ_id: uuid.UUID,
|
||||
_user: User = Depends(get_organizer),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
champ = await crud_championship.get(db, championship_id)
|
||||
champ = await crud_championship.get(db, champ_id)
|
||||
if not champ:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||
raise HTTPException(status_code=404, detail="Championship not found")
|
||||
await crud_championship.delete(db, champ)
|
||||
|
||||
@@ -3,73 +3,53 @@ import uuid
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.crud import crud_championship, crud_participant
|
||||
from app.crud import crud_participant, crud_registration
|
||||
from app.database import get_db
|
||||
from app.dependencies import get_approved_user, get_organizer
|
||||
from app.models.user import User
|
||||
from app.schemas.participant_list import ParticipantListOut, ParticipantListUpsert
|
||||
from app.services import participant_service
|
||||
from app.schemas.participant import ParticipantListOut, ParticipantListPublish
|
||||
from app.schemas.registration import RegistrationWithUser
|
||||
|
||||
router = APIRouter(prefix="/championships", tags=["participant-lists"])
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/{championship_id}/participant-list", response_model=ParticipantListOut)
|
||||
@router.get("/championships/{champ_id}/participant-list", response_model=ParticipantListOut | None)
|
||||
async def get_participant_list(
|
||||
championship_id: str,
|
||||
champ_id: uuid.UUID,
|
||||
_user: User = Depends(get_approved_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
pl = await crud_participant.get_by_championship(db, championship_id)
|
||||
if not pl or not pl.is_published:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Participant list not published yet",
|
||||
)
|
||||
return await crud_participant.get_for_championship(db, champ_id)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/championships/{champ_id}/participant-list/publish",
|
||||
response_model=ParticipantListOut,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def publish_participant_list(
|
||||
champ_id: uuid.UUID,
|
||||
data: ParticipantListPublish,
|
||||
current_user: User = Depends(get_organizer),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
pl = await crud_participant.create_or_get(db, champ_id, current_user.id)
|
||||
if pl.is_published:
|
||||
raise HTTPException(status_code=409, detail="Participant list already published")
|
||||
pl = await crud_participant.publish(db, pl, data.notes)
|
||||
|
||||
# TODO: send push notifications to accepted participants
|
||||
return pl
|
||||
|
||||
|
||||
@router.put("/{championship_id}/participant-list", response_model=ParticipantListOut)
|
||||
async def upsert_participant_list(
|
||||
championship_id: str,
|
||||
body: ParticipantListUpsert,
|
||||
organizer: User = Depends(get_organizer),
|
||||
@router.get(
|
||||
"/championships/{champ_id}/participant-list/registrations",
|
||||
response_model=list[RegistrationWithUser],
|
||||
)
|
||||
async def list_accepted_registrations(
|
||||
champ_id: uuid.UUID,
|
||||
_user: User = Depends(get_approved_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
champ = await crud_championship.get(db, championship_id)
|
||||
if not champ:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||
return await crud_participant.upsert(
|
||||
db,
|
||||
championship_id=uuid.UUID(championship_id),
|
||||
published_by=organizer.id,
|
||||
notes=body.notes,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{championship_id}/participant-list/publish", response_model=ParticipantListOut)
|
||||
async def publish_participant_list(
|
||||
championship_id: str,
|
||||
organizer: User = Depends(get_organizer),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
champ = await crud_championship.get(db, championship_id)
|
||||
if not champ:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||
try:
|
||||
return await participant_service.publish_participant_list(
|
||||
db, uuid.UUID(championship_id), organizer
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/{championship_id}/participant-list/unpublish", response_model=ParticipantListOut)
|
||||
async def unpublish_participant_list(
|
||||
championship_id: str,
|
||||
_organizer: User = Depends(get_organizer),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
pl = await crud_participant.get_by_championship(db, championship_id)
|
||||
if not pl:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||
return await crud_participant.unpublish(db, pl)
|
||||
regs = await crud_registration.list_for_championship(db, champ_id)
|
||||
return [r for r in regs if r.status == "accepted"]
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -8,116 +7,102 @@ from app.crud import crud_championship, crud_registration
|
||||
from app.database import get_db
|
||||
from app.dependencies import get_approved_user, get_organizer
|
||||
from app.models.user import User
|
||||
from app.schemas.registration import RegistrationCreate, RegistrationOut, RegistrationStatusUpdate
|
||||
from app.schemas.registration import (
|
||||
RegistrationCreate,
|
||||
RegistrationListItem,
|
||||
RegistrationOut,
|
||||
RegistrationUpdate,
|
||||
RegistrationWithUser,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/registrations", tags=["registrations"])
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("", response_model=RegistrationOut, status_code=status.HTTP_201_CREATED)
|
||||
async def submit_registration(
|
||||
body: RegistrationCreate,
|
||||
async def register_for_championship(
|
||||
data: RegistrationCreate,
|
||||
current_user: User = Depends(get_approved_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
champ = await crud_championship.get(db, body.championship_id)
|
||||
champ = await crud_championship.get(db, data.championship_id)
|
||||
if not champ:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Championship not found")
|
||||
raise HTTPException(status_code=404, detail="Championship not found")
|
||||
if champ.status != "open":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail="Registration is not open"
|
||||
)
|
||||
if champ.registration_close_at and champ.registration_close_at < datetime.now(timezone.utc):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail="Registration deadline has passed"
|
||||
)
|
||||
existing = await crud_registration.get_by_champ_and_user(
|
||||
db, body.championship_id, current_user.id
|
||||
)
|
||||
raise HTTPException(status_code=400, detail="Registration is not open for this championship")
|
||||
|
||||
existing = await crud_registration.get_by_user_and_championship(db, current_user.id, data.championship_id)
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="You have already registered for this championship",
|
||||
)
|
||||
return await crud_registration.create(
|
||||
db,
|
||||
championship_id=uuid.UUID(body.championship_id),
|
||||
user_id=current_user.id,
|
||||
category=body.category,
|
||||
level=body.level,
|
||||
notes=body.notes,
|
||||
)
|
||||
raise HTTPException(status_code=409, detail="Already registered for this championship")
|
||||
|
||||
return await crud_registration.create(db, current_user.id, data)
|
||||
|
||||
|
||||
@router.get("/my", response_model=list[RegistrationOut])
|
||||
@router.get("/my", response_model=list[RegistrationListItem])
|
||||
async def my_registrations(
|
||||
current_user: User = Depends(get_approved_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return await crud_registration.list_by_user(db, current_user.id)
|
||||
return await crud_registration.list_for_user(db, current_user.id)
|
||||
|
||||
|
||||
@router.get("/{registration_id}", response_model=RegistrationOut)
|
||||
@router.get("/{reg_id}", response_model=RegistrationOut)
|
||||
async def get_registration(
|
||||
registration_id: str,
|
||||
reg_id: uuid.UUID,
|
||||
current_user: User = Depends(get_approved_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
reg = await crud_registration.get(db, registration_id)
|
||||
reg = await crud_registration.get(db, reg_id)
|
||||
if not reg:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||
if str(reg.user_id) != str(current_user.id) and current_user.role not in (
|
||||
"organizer",
|
||||
"admin",
|
||||
):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||
raise HTTPException(status_code=404, detail="Registration not found")
|
||||
if reg.user_id != current_user.id and current_user.role not in ("organizer", "admin"):
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
return reg
|
||||
|
||||
|
||||
@router.get("/championship/{championship_id}", response_model=list[RegistrationOut])
|
||||
async def championship_registrations(
|
||||
championship_id: str,
|
||||
_organizer: User = Depends(get_organizer),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
champ = await crud_championship.get(db, championship_id)
|
||||
if not champ:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||
return await crud_registration.list_by_championship(db, championship_id)
|
||||
|
||||
|
||||
@router.patch("/{registration_id}/status", response_model=RegistrationOut)
|
||||
async def update_registration_status(
|
||||
registration_id: str,
|
||||
body: RegistrationStatusUpdate,
|
||||
_organizer: User = Depends(get_organizer),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
allowed = {"accepted", "rejected", "waitlisted"}
|
||||
if body.status not in allowed:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Status must be one of: {', '.join(allowed)}",
|
||||
)
|
||||
reg = await crud_registration.get(db, registration_id)
|
||||
if not reg:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||
return await crud_registration.update_status(db, reg, body.status)
|
||||
|
||||
|
||||
@router.delete("/{registration_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def withdraw_registration(
|
||||
registration_id: str,
|
||||
@router.patch("/{reg_id}", response_model=RegistrationOut)
|
||||
async def update_registration(
|
||||
reg_id: uuid.UUID,
|
||||
data: RegistrationUpdate,
|
||||
current_user: User = Depends(get_approved_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
reg = await crud_registration.get(db, registration_id)
|
||||
reg = await crud_registration.get(db, reg_id)
|
||||
if not reg:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||
if str(reg.user_id) != str(current_user.id):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||
if reg.status != "submitted":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Only submitted registrations can be withdrawn",
|
||||
)
|
||||
raise HTTPException(status_code=404, detail="Registration not found")
|
||||
|
||||
# Members can only update their own registration (video_url, notes)
|
||||
if current_user.role == "member":
|
||||
if reg.user_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
allowed_fields = {"video_url", "notes"}
|
||||
update_data = data.model_dump(exclude_none=True)
|
||||
if not set(update_data.keys()).issubset(allowed_fields):
|
||||
raise HTTPException(status_code=403, detail="Members can only update video_url and notes")
|
||||
|
||||
return await crud_registration.update(db, reg, data)
|
||||
|
||||
|
||||
@router.delete("/{reg_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def cancel_registration(
|
||||
reg_id: uuid.UUID,
|
||||
current_user: User = Depends(get_approved_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
reg = await crud_registration.get(db, reg_id)
|
||||
if not reg:
|
||||
raise HTTPException(status_code=404, detail="Registration not found")
|
||||
if reg.user_id != current_user.id and current_user.role not in ("organizer", "admin"):
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
await crud_registration.delete(db, reg)
|
||||
|
||||
|
||||
# Organizer: list all registrations for a championship
|
||||
@router.get("/championship/{champ_id}", response_model=list[RegistrationWithUser])
|
||||
async def list_registrations_for_championship(
|
||||
champ_id: uuid.UUID,
|
||||
_user: User = Depends(get_organizer),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
):
|
||||
return await crud_registration.list_for_championship(db, champ_id, skip=skip, limit=limit)
|
||||
|
||||
@@ -1,101 +1,46 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.crud import crud_user
|
||||
from app.database import get_db
|
||||
from app.dependencies import get_admin, get_current_user
|
||||
from app.dependencies import get_admin
|
||||
from app.models.user import User
|
||||
from app.schemas.user import PushTokenUpdate, UserCreate, UserOut
|
||||
from app.services import notification_service
|
||||
from app.schemas.user import UserOut
|
||||
|
||||
router = APIRouter(prefix="/users", tags=["users"])
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=list[UserOut])
|
||||
async def list_users(
|
||||
status: str | None = None,
|
||||
role: str | None = None,
|
||||
_admin: User = Depends(get_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
_admin: User = Depends(get_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
limit: int = 100,
|
||||
):
|
||||
return await crud_user.list_all(db, status=status, role=role, skip=skip, limit=limit)
|
||||
|
||||
|
||||
@router.get("/{user_id}", response_model=UserOut)
|
||||
async def get_user(
|
||||
user_id: str,
|
||||
_admin: User = Depends(get_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
user = await crud_user.get(db, user_id)
|
||||
if not user:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||
return user
|
||||
|
||||
|
||||
@router.post("", response_model=UserOut, status_code=status.HTTP_201_CREATED)
|
||||
async def create_user(
|
||||
body: UserCreate,
|
||||
_admin: User = Depends(get_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
if await crud_user.get_by_email(db, body.email):
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Email already registered")
|
||||
return await crud_user.create(
|
||||
db,
|
||||
email=body.email,
|
||||
password=body.password,
|
||||
full_name=body.full_name,
|
||||
phone=body.phone,
|
||||
role=body.role,
|
||||
status="approved",
|
||||
)
|
||||
return await crud_user.list_all(db, skip=skip, limit=limit)
|
||||
|
||||
|
||||
@router.patch("/{user_id}/approve", response_model=UserOut)
|
||||
async def approve_user(
|
||||
user_id: str,
|
||||
admin: User = Depends(get_admin),
|
||||
user_id: uuid.UUID,
|
||||
_admin: User = Depends(get_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
user = await crud_user.get(db, user_id)
|
||||
user = await crud_user.get_by_id(db, user_id)
|
||||
if not user:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||
user = await crud_user.set_status(db, user, "approved")
|
||||
await notification_service.send_push_notification(
|
||||
db=db,
|
||||
user=user,
|
||||
title="Welcome!",
|
||||
body="Your account has been approved. You can now access the app.",
|
||||
notif_type="account_approved",
|
||||
)
|
||||
return user
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
return await crud_user.set_status(db, user, "approved")
|
||||
|
||||
|
||||
@router.patch("/{user_id}/reject", response_model=UserOut)
|
||||
async def reject_user(
|
||||
user_id: str,
|
||||
user_id: uuid.UUID,
|
||||
_admin: User = Depends(get_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
user = await crud_user.get(db, user_id)
|
||||
user = await crud_user.get_by_id(db, user_id)
|
||||
if not user:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
return await crud_user.set_status(db, user, "rejected")
|
||||
|
||||
|
||||
@router.patch("/{user_id}/push-token", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def update_push_token(
|
||||
user_id: str,
|
||||
body: PushTokenUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
if str(current_user.id) != user_id and current_user.role != "admin":
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||
user = await crud_user.get(db, user_id)
|
||||
if not user:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||
await crud_user.set_push_token(db, user, body.expo_push_token)
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import uuid
|
||||
|
||||
from pydantic import BaseModel, EmailStr
|
||||
|
||||
|
||||
class RegisterRequest(BaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
full_name: str
|
||||
phone: str | None = None
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str = "bearer"
|
||||
|
||||
|
||||
class RefreshRequest(BaseModel):
|
||||
refresh_token: str
|
||||
|
||||
|
||||
class UserOut(BaseModel):
|
||||
id: uuid.UUID
|
||||
email: str
|
||||
full_name: str
|
||||
phone: str | None
|
||||
role: str
|
||||
status: str
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
7
backend/app/schemas/auth/__init__.py
Normal file
7
backend/app/schemas/auth/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from app.schemas.auth.token_pair import TokenPair
|
||||
from app.schemas.auth.refresh_request import RefreshRequest
|
||||
from app.schemas.auth.token_refreshed import TokenRefreshed
|
||||
from app.schemas.auth.logout_request import LogoutRequest
|
||||
from app.schemas.auth.register_response import RegisterResponse
|
||||
|
||||
__all__ = ["TokenPair", "RefreshRequest", "TokenRefreshed", "LogoutRequest", "RegisterResponse"]
|
||||
5
backend/app/schemas/auth/logout_request.py
Normal file
5
backend/app/schemas/auth/logout_request.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class LogoutRequest(BaseModel):
|
||||
refresh_token: str
|
||||
5
backend/app/schemas/auth/refresh_request.py
Normal file
5
backend/app/schemas/auth/refresh_request.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class RefreshRequest(BaseModel):
|
||||
refresh_token: str
|
||||
15
backend/app/schemas/auth/register_response.py
Normal file
15
backend/app/schemas/auth/register_response.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.schemas.user import UserOut
|
||||
|
||||
|
||||
class RegisterResponse(BaseModel):
|
||||
"""
|
||||
Returned after registration.
|
||||
Members get tokens immediately (auto-approved).
|
||||
Organizers only get the user object (pending approval).
|
||||
"""
|
||||
user: UserOut
|
||||
access_token: str | None = None
|
||||
refresh_token: str | None = None
|
||||
token_type: str = "bearer"
|
||||
10
backend/app/schemas/auth/token_pair.py
Normal file
10
backend/app/schemas/auth/token_pair.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.schemas.user import UserOut
|
||||
|
||||
|
||||
class TokenPair(BaseModel):
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str = "bearer"
|
||||
user: UserOut
|
||||
7
backend/app/schemas/auth/token_refreshed.py
Normal file
7
backend/app/schemas/auth/token_refreshed.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class TokenRefreshed(BaseModel):
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str = "bearer"
|
||||
@@ -1,43 +0,0 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class ChampionshipCreate(BaseModel):
|
||||
title: str
|
||||
description: str | None = None
|
||||
location: str | None = None
|
||||
event_date: datetime | None = None
|
||||
registration_open_at: datetime | None = None
|
||||
registration_close_at: datetime | None = None
|
||||
status: str = "draft"
|
||||
image_url: str | None = None
|
||||
|
||||
|
||||
class ChampionshipUpdate(BaseModel):
|
||||
title: str | None = None
|
||||
description: str | None = None
|
||||
location: str | None = None
|
||||
event_date: datetime | None = None
|
||||
registration_open_at: datetime | None = None
|
||||
registration_close_at: datetime | None = None
|
||||
status: str | None = None
|
||||
image_url: str | None = None
|
||||
|
||||
|
||||
class ChampionshipOut(BaseModel):
|
||||
id: uuid.UUID
|
||||
title: str
|
||||
description: str | None
|
||||
location: str | None
|
||||
event_date: datetime | None
|
||||
registration_open_at: datetime | None
|
||||
registration_close_at: datetime | None
|
||||
status: str
|
||||
source: str
|
||||
image_url: str | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
5
backend/app/schemas/championship/__init__.py
Normal file
5
backend/app/schemas/championship/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from app.schemas.championship.create import ChampionshipCreate
|
||||
from app.schemas.championship.update import ChampionshipUpdate
|
||||
from app.schemas.championship.out import ChampionshipOut
|
||||
|
||||
__all__ = ["ChampionshipCreate", "ChampionshipUpdate", "ChampionshipOut"]
|
||||
19
backend/app/schemas/championship/create.py
Normal file
19
backend/app/schemas/championship/create.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class ChampionshipCreate(BaseModel):
|
||||
title: str
|
||||
description: str | None = None
|
||||
location: str | None = None
|
||||
event_date: datetime | None = None
|
||||
registration_open_at: datetime | None = None
|
||||
registration_close_at: datetime | None = None
|
||||
form_url: str | None = None
|
||||
entry_fee: float | None = None
|
||||
video_max_duration: int | None = None
|
||||
judges: list[dict] | None = None # [{name, bio, instagram}]
|
||||
categories: list[str] | None = None
|
||||
status: str = "draft"
|
||||
image_url: str | None = None
|
||||
45
backend/app/schemas/championship/out.py
Normal file
45
backend/app/schemas/championship/out.py
Normal file
@@ -0,0 +1,45 @@
|
||||
import json
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, model_validator
|
||||
|
||||
from app.schemas.organization import OrganizationBrief
|
||||
|
||||
|
||||
class ChampionshipOut(BaseModel):
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
id: uuid.UUID
|
||||
org_id: uuid.UUID | None = None
|
||||
title: str
|
||||
description: str | None
|
||||
location: str | None
|
||||
event_date: datetime | None
|
||||
registration_open_at: datetime | None
|
||||
registration_close_at: datetime | None
|
||||
form_url: str | None
|
||||
entry_fee: float | None
|
||||
video_max_duration: int | None
|
||||
judges: list[dict] | None
|
||||
categories: list[str] | None
|
||||
status: str
|
||||
source: str
|
||||
instagram_media_id: str | None
|
||||
image_url: str | None
|
||||
organization: OrganizationBrief | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def parse_json_fields(cls, v):
|
||||
# judges and categories are stored as JSON strings in the DB
|
||||
if hasattr(v, "__dict__"):
|
||||
raw_j = getattr(v, "judges", None)
|
||||
raw_c = getattr(v, "categories", None)
|
||||
if isinstance(raw_j, str):
|
||||
v.__dict__["judges"] = json.loads(raw_j)
|
||||
if isinstance(raw_c, str):
|
||||
v.__dict__["categories"] = json.loads(raw_c)
|
||||
return v
|
||||
19
backend/app/schemas/championship/update.py
Normal file
19
backend/app/schemas/championship/update.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class ChampionshipUpdate(BaseModel):
|
||||
title: str | None = None
|
||||
description: str | None = None
|
||||
location: str | None = None
|
||||
event_date: datetime | None = None
|
||||
registration_open_at: datetime | None = None
|
||||
registration_close_at: datetime | None = None
|
||||
form_url: str | None = None
|
||||
entry_fee: float | None = None
|
||||
video_max_duration: int | None = None
|
||||
judges: list[dict] | None = None
|
||||
categories: list[str] | None = None
|
||||
status: str | None = None
|
||||
image_url: str | None = None
|
||||
4
backend/app/schemas/organization/__init__.py
Normal file
4
backend/app/schemas/organization/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from app.schemas.organization.out import OrganizationOut
|
||||
from app.schemas.organization.brief import OrganizationBrief
|
||||
|
||||
__all__ = ["OrganizationOut", "OrganizationBrief"]
|
||||
13
backend/app/schemas/organization/brief.py
Normal file
13
backend/app/schemas/organization/brief.py
Normal file
@@ -0,0 +1,13 @@
|
||||
import uuid
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class OrganizationBrief(BaseModel):
|
||||
"""Minimal org info for embedding in ChampionshipOut."""
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
id: uuid.UUID
|
||||
name: str
|
||||
instagram: str | None
|
||||
logo_url: str | None
|
||||
16
backend/app/schemas/organization/out.py
Normal file
16
backend/app/schemas/organization/out.py
Normal file
@@ -0,0 +1,16 @@
|
||||
import uuid
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class OrganizationOut(BaseModel):
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
id: uuid.UUID
|
||||
name: str
|
||||
instagram: str | None
|
||||
email: str | None
|
||||
city: str | None
|
||||
logo_url: str | None
|
||||
verified: bool
|
||||
status: str
|
||||
4
backend/app/schemas/participant/__init__.py
Normal file
4
backend/app/schemas/participant/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from app.schemas.participant.out import ParticipantListOut
|
||||
from app.schemas.participant.publish import ParticipantListPublish
|
||||
|
||||
__all__ = ["ParticipantListOut", "ParticipantListPublish"]
|
||||
@@ -4,16 +4,12 @@ from datetime import datetime
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class ParticipantListUpsert(BaseModel):
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class ParticipantListOut(BaseModel):
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
id: uuid.UUID
|
||||
championship_id: uuid.UUID
|
||||
published_by: uuid.UUID
|
||||
is_published: bool
|
||||
published_at: datetime | None
|
||||
notes: str | None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
created_at: datetime
|
||||
5
backend/app/schemas/participant/publish.py
Normal file
5
backend/app/schemas/participant/publish.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class ParticipantListPublish(BaseModel):
|
||||
notes: str | None = None
|
||||
13
backend/app/schemas/registration/__init__.py
Normal file
13
backend/app/schemas/registration/__init__.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from app.schemas.registration.create import RegistrationCreate
|
||||
from app.schemas.registration.update import RegistrationUpdate
|
||||
from app.schemas.registration.out import RegistrationOut
|
||||
from app.schemas.registration.list_item import RegistrationListItem
|
||||
from app.schemas.registration.with_user import RegistrationWithUser
|
||||
|
||||
__all__ = [
|
||||
"RegistrationCreate",
|
||||
"RegistrationUpdate",
|
||||
"RegistrationOut",
|
||||
"RegistrationListItem",
|
||||
"RegistrationWithUser",
|
||||
]
|
||||
10
backend/app/schemas/registration/create.py
Normal file
10
backend/app/schemas/registration/create.py
Normal file
@@ -0,0 +1,10 @@
|
||||
import uuid
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class RegistrationCreate(BaseModel):
|
||||
championship_id: uuid.UUID
|
||||
category: str | None = None
|
||||
level: str | None = None
|
||||
notes: str | None = None
|
||||
22
backend/app/schemas/registration/list_item.py
Normal file
22
backend/app/schemas/registration/list_item.py
Normal file
@@ -0,0 +1,22 @@
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import model_validator
|
||||
|
||||
from app.schemas.registration.out import RegistrationOut
|
||||
|
||||
|
||||
class RegistrationListItem(RegistrationOut):
|
||||
championship_title: str | None = None
|
||||
championship_event_date: datetime | None = None
|
||||
championship_location: str | None = None
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def extract_championship(cls, data: Any) -> Any:
|
||||
if hasattr(data, "championship") and data.championship:
|
||||
champ = data.championship
|
||||
data.__dict__["championship_title"] = champ.title
|
||||
data.__dict__["championship_event_date"] = champ.event_date
|
||||
data.__dict__["championship_location"] = champ.location
|
||||
return data
|
||||
@@ -4,18 +4,9 @@ from datetime import datetime
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class RegistrationCreate(BaseModel):
|
||||
championship_id: uuid.UUID
|
||||
category: str | None = None
|
||||
level: str | None = None
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class RegistrationStatusUpdate(BaseModel):
|
||||
status: str # 'accepted' | 'rejected' | 'waitlisted'
|
||||
|
||||
|
||||
class RegistrationOut(BaseModel):
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
id: uuid.UUID
|
||||
championship_id: uuid.UUID
|
||||
user_id: uuid.UUID
|
||||
@@ -23,7 +14,6 @@ class RegistrationOut(BaseModel):
|
||||
level: str | None
|
||||
notes: str | None
|
||||
status: str
|
||||
video_url: str | None
|
||||
submitted_at: datetime
|
||||
decided_at: datetime | None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
9
backend/app/schemas/registration/update.py
Normal file
9
backend/app/schemas/registration/update.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class RegistrationUpdate(BaseModel):
|
||||
status: str | None = None
|
||||
video_url: str | None = None
|
||||
category: str | None = None
|
||||
level: str | None = None
|
||||
notes: str | None = None
|
||||
6
backend/app/schemas/registration/with_user.py
Normal file
6
backend/app/schemas/registration/with_user.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from app.schemas.registration.out import RegistrationOut
|
||||
from app.schemas.user import UserOut
|
||||
|
||||
|
||||
class RegistrationWithUser(RegistrationOut):
|
||||
user: UserOut
|
||||
@@ -1,26 +0,0 @@
|
||||
import uuid
|
||||
|
||||
from pydantic import BaseModel, EmailStr
|
||||
|
||||
|
||||
class UserCreate(BaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
full_name: str
|
||||
phone: str | None = None
|
||||
role: str = "member"
|
||||
|
||||
|
||||
class UserOut(BaseModel):
|
||||
id: uuid.UUID
|
||||
email: str
|
||||
full_name: str
|
||||
phone: str | None
|
||||
role: str
|
||||
status: str
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class PushTokenUpdate(BaseModel):
|
||||
expo_push_token: str
|
||||
6
backend/app/schemas/user/__init__.py
Normal file
6
backend/app/schemas/user/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from app.schemas.user.register import UserRegister
|
||||
from app.schemas.user.login import UserLogin
|
||||
from app.schemas.user.out import UserOut
|
||||
from app.schemas.user.update import UserUpdate
|
||||
|
||||
__all__ = ["UserRegister", "UserLogin", "UserOut", "UserUpdate"]
|
||||
6
backend/app/schemas/user/login.py
Normal file
6
backend/app/schemas/user/login.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from pydantic import BaseModel, EmailStr
|
||||
|
||||
|
||||
class UserLogin(BaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
21
backend/app/schemas/user/out.py
Normal file
21
backend/app/schemas/user/out.py
Normal file
@@ -0,0 +1,21 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.schemas.organization import OrganizationOut
|
||||
|
||||
|
||||
class UserOut(BaseModel):
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
id: uuid.UUID
|
||||
email: str
|
||||
full_name: str
|
||||
phone: str | None
|
||||
role: str
|
||||
status: str
|
||||
instagram_handle: str | None
|
||||
organization: OrganizationOut | None = None
|
||||
expo_push_token: str | None
|
||||
created_at: datetime
|
||||
22
backend/app/schemas/user/register.py
Normal file
22
backend/app/schemas/user/register.py
Normal file
@@ -0,0 +1,22 @@
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, EmailStr, field_validator
|
||||
|
||||
|
||||
class UserRegister(BaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
full_name: str
|
||||
phone: str | None = None
|
||||
# Role requested at registration: 'member' or 'organizer'
|
||||
requested_role: Literal["member", "organizer"] = "member"
|
||||
# Organizer-only fields
|
||||
organization_name: str | None = None
|
||||
instagram_handle: str | None = None
|
||||
|
||||
@field_validator("organization_name")
|
||||
@classmethod
|
||||
def org_name_required_for_organizer(cls, v, info):
|
||||
if info.data.get("requested_role") == "organizer" and not v:
|
||||
raise ValueError("Organization name is required for organizer registration")
|
||||
return v
|
||||
10
backend/app/schemas/user/update.py
Normal file
10
backend/app/schemas/user/update.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
full_name: str | None = None
|
||||
phone: str | None = None
|
||||
instagram_handle: str | None = None
|
||||
expo_push_token: str | None = None
|
||||
# Org fields — routed to Organization table in CRUD
|
||||
organization_name: str | None = None
|
||||
@@ -1,54 +1,81 @@
|
||||
import hashlib
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import jwt
|
||||
from passlib.context import CryptContext
|
||||
import bcrypt
|
||||
from jose import JWTError, jwt
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
from app.models.user import RefreshToken
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return pwd_context.hash(password)
|
||||
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
|
||||
|
||||
|
||||
def verify_password(plain: str, hashed: str) -> bool:
|
||||
return pwd_context.verify(plain, hashed)
|
||||
return bcrypt.checkpw(plain.encode(), hashed.encode())
|
||||
|
||||
|
||||
def create_access_token(user_id: str, role: str, status: str) -> str:
|
||||
expire = datetime.now(timezone.utc) + timedelta(
|
||||
minutes=settings.access_token_expire_minutes
|
||||
)
|
||||
payload = {
|
||||
"sub": user_id,
|
||||
"role": role,
|
||||
"status": status,
|
||||
"exp": expire,
|
||||
"type": "access",
|
||||
}
|
||||
return jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm)
|
||||
def create_access_token(user_id: uuid.UUID) -> str:
|
||||
expire = datetime.now(UTC) + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
return jwt.encode({"sub": str(user_id), "exp": expire}, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
||||
|
||||
|
||||
def create_refresh_token() -> tuple[str, str, datetime]:
|
||||
"""Returns (raw_token, hashed_token, expires_at)."""
|
||||
def decode_access_token(token: str) -> dict | None:
|
||||
try:
|
||||
return jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
|
||||
except JWTError:
|
||||
return None
|
||||
|
||||
|
||||
def _hash_token(token: str) -> str:
|
||||
return hashlib.sha256(token.encode()).hexdigest()
|
||||
|
||||
|
||||
async def create_refresh_token(db: AsyncSession, user_id: uuid.UUID) -> str:
|
||||
raw = str(uuid.uuid4())
|
||||
hashed = hashlib.sha256(raw.encode()).hexdigest()
|
||||
expires_at = datetime.now(timezone.utc) + timedelta(
|
||||
days=settings.refresh_token_expire_days
|
||||
expires_at = datetime.now(UTC) + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
|
||||
record = RefreshToken(user_id=user_id, token_hash=_hash_token(raw), expires_at=expires_at)
|
||||
db.add(record)
|
||||
await db.commit()
|
||||
return raw
|
||||
|
||||
|
||||
async def rotate_refresh_token(db: AsyncSession, raw_token: str) -> tuple[str, uuid.UUID] | None:
|
||||
"""Validate old token, revoke it, issue a new one. Returns (new_raw, user_id) or None."""
|
||||
from sqlalchemy import select
|
||||
|
||||
token_hash = _hash_token(raw_token)
|
||||
result = await db.execute(
|
||||
select(RefreshToken).where(
|
||||
RefreshToken.token_hash == token_hash,
|
||||
RefreshToken.revoked.is_(False),
|
||||
RefreshToken.expires_at > datetime.now(UTC),
|
||||
)
|
||||
return raw, hashed, expires_at
|
||||
)
|
||||
record = result.scalar_one_or_none()
|
||||
if record is None:
|
||||
return None
|
||||
|
||||
record.revoked = True
|
||||
await db.flush()
|
||||
|
||||
new_raw = str(uuid.uuid4())
|
||||
expires_at = datetime.now(UTC) + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
|
||||
new_record = RefreshToken(user_id=record.user_id, token_hash=_hash_token(new_raw), expires_at=expires_at)
|
||||
db.add(new_record)
|
||||
await db.commit()
|
||||
return new_raw, record.user_id
|
||||
|
||||
|
||||
def decode_access_token(token: str) -> dict:
|
||||
"""Raises jwt.InvalidTokenError on failure."""
|
||||
payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
|
||||
if payload.get("type") != "access":
|
||||
raise jwt.InvalidTokenError("Not an access token")
|
||||
return payload
|
||||
async def revoke_refresh_token(db: AsyncSession, raw_token: str) -> None:
|
||||
from sqlalchemy import select
|
||||
|
||||
|
||||
def hash_token(raw: str) -> str:
|
||||
return hashlib.sha256(raw.encode()).hexdigest()
|
||||
token_hash = _hash_token(raw_token)
|
||||
result = await db.execute(select(RefreshToken).where(RefreshToken.token_hash == token_hash))
|
||||
record = result.scalar_one_or_none()
|
||||
if record:
|
||||
record.revoked = True
|
||||
await db.commit()
|
||||
|
||||
@@ -1,226 +0,0 @@
|
||||
"""
|
||||
Instagram Graph API polling service.
|
||||
|
||||
Setup requirements:
|
||||
1. Convert organizer's Instagram to Business/Creator account and link to a Facebook Page.
|
||||
2. Create a Facebook App at developers.facebook.com.
|
||||
3. Add Instagram Graph API product with permissions: instagram_basic, pages_read_engagement.
|
||||
4. Generate a long-lived User Access Token (valid 60 days) and set INSTAGRAM_ACCESS_TOKEN in .env.
|
||||
5. Find your Instagram numeric user ID and set INSTAGRAM_USER_ID in .env.
|
||||
|
||||
The scheduler runs every INSTAGRAM_POLL_INTERVAL seconds (default: 1800 = 30 min).
|
||||
Token is refreshed weekly to prevent expiry.
|
||||
"""
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database import get_session_factory
|
||||
from app.models.championship import Championship
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
GRAPH_BASE = "https://graph.facebook.com/v21.0"
|
||||
|
||||
# Russian month names → month number
|
||||
RU_MONTHS = {
|
||||
"января": 1, "февраля": 2, "марта": 3, "апреля": 4,
|
||||
"мая": 5, "июня": 6, "июля": 7, "августа": 8,
|
||||
"сентября": 9, "октября": 10, "ноября": 11, "декабря": 12,
|
||||
}
|
||||
|
||||
LOCATION_PREFIXES = ["место:", "адрес:", "location:", "venue:", "зал:", "address:"]
|
||||
|
||||
DATE_PATTERNS = [
|
||||
# 15 марта 2025
|
||||
(
|
||||
r"\b(\d{1,2})\s+("
|
||||
+ "|".join(RU_MONTHS.keys())
|
||||
+ r")\s+(\d{4})\b",
|
||||
"ru",
|
||||
),
|
||||
# 15.03.2025
|
||||
(r"\b(\d{1,2})\.(\d{2})\.(\d{4})\b", "dot"),
|
||||
# March 15 2025 or March 15, 2025
|
||||
(
|
||||
r"\b(January|February|March|April|May|June|July|August|September|October|November|December)"
|
||||
r"\s+(\d{1,2}),?\s+(\d{4})\b",
|
||||
"en",
|
||||
),
|
||||
]
|
||||
|
||||
EN_MONTHS = {
|
||||
"january": 1, "february": 2, "march": 3, "april": 4,
|
||||
"may": 5, "june": 6, "july": 7, "august": 8,
|
||||
"september": 9, "october": 10, "november": 11, "december": 12,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParsedChampionship:
|
||||
title: str
|
||||
description: Optional[str]
|
||||
location: Optional[str]
|
||||
event_date: Optional[datetime]
|
||||
raw_caption_text: str
|
||||
image_url: Optional[str]
|
||||
|
||||
|
||||
def parse_caption(text: str, image_url: str | None = None) -> ParsedChampionship:
|
||||
lines = [line.strip() for line in text.strip().splitlines() if line.strip()]
|
||||
title = lines[0] if lines else "Untitled Championship"
|
||||
description = "\n".join(lines[1:]) if len(lines) > 1 else None
|
||||
|
||||
location = None
|
||||
for line in lines:
|
||||
lower = line.lower()
|
||||
for prefix in LOCATION_PREFIXES:
|
||||
if lower.startswith(prefix):
|
||||
location = line[len(prefix):].strip()
|
||||
break
|
||||
|
||||
event_date = _extract_date(text)
|
||||
|
||||
return ParsedChampionship(
|
||||
title=title,
|
||||
description=description,
|
||||
location=location,
|
||||
event_date=event_date,
|
||||
raw_caption_text=text,
|
||||
image_url=image_url,
|
||||
)
|
||||
|
||||
|
||||
def _extract_date(text: str) -> Optional[datetime]:
|
||||
for pattern, fmt in DATE_PATTERNS:
|
||||
m = re.search(pattern, text, re.IGNORECASE)
|
||||
if not m:
|
||||
continue
|
||||
try:
|
||||
if fmt == "ru":
|
||||
day, month_name, year = int(m.group(1)), m.group(2).lower(), int(m.group(3))
|
||||
month = RU_MONTHS.get(month_name)
|
||||
if month:
|
||||
return datetime(year, month, day, tzinfo=timezone.utc)
|
||||
elif fmt == "dot":
|
||||
day, month, year = int(m.group(1)), int(m.group(2)), int(m.group(3))
|
||||
return datetime(year, month, day, tzinfo=timezone.utc)
|
||||
elif fmt == "en":
|
||||
month_name, day, year = m.group(1).lower(), int(m.group(2)), int(m.group(3))
|
||||
month = EN_MONTHS.get(month_name)
|
||||
if month:
|
||||
return datetime(year, month, day, tzinfo=timezone.utc)
|
||||
except ValueError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
async def _upsert_championship(
|
||||
session: AsyncSession,
|
||||
instagram_media_id: str,
|
||||
parsed: ParsedChampionship,
|
||||
) -> Championship:
|
||||
result = await session.execute(
|
||||
select(Championship).where(
|
||||
Championship.instagram_media_id == instagram_media_id
|
||||
)
|
||||
)
|
||||
champ = result.scalar_one_or_none()
|
||||
|
||||
if champ:
|
||||
champ.title = parsed.title
|
||||
champ.description = parsed.description
|
||||
champ.location = parsed.location
|
||||
champ.event_date = parsed.event_date
|
||||
champ.raw_caption_text = parsed.raw_caption_text
|
||||
champ.image_url = parsed.image_url
|
||||
else:
|
||||
champ = Championship(
|
||||
title=parsed.title,
|
||||
description=parsed.description,
|
||||
location=parsed.location,
|
||||
event_date=parsed.event_date,
|
||||
status="draft",
|
||||
source="instagram",
|
||||
instagram_media_id=instagram_media_id,
|
||||
raw_caption_text=parsed.raw_caption_text,
|
||||
image_url=parsed.image_url,
|
||||
)
|
||||
session.add(champ)
|
||||
|
||||
await session.commit()
|
||||
return champ
|
||||
|
||||
|
||||
async def poll_instagram() -> None:
|
||||
"""Fetch recent posts from the monitored Instagram account and sync championships."""
|
||||
if not settings.instagram_user_id or not settings.instagram_access_token:
|
||||
logger.warning("Instagram credentials not configured — skipping poll")
|
||||
return
|
||||
|
||||
url = (
|
||||
f"{GRAPH_BASE}/{settings.instagram_user_id}/media"
|
||||
f"?fields=id,caption,media_url,timestamp"
|
||||
f"&access_token={settings.instagram_access_token}"
|
||||
)
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
response = await client.get(url)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
except Exception as exc:
|
||||
logger.error("Instagram API request failed: %s", exc)
|
||||
return
|
||||
|
||||
posts = data.get("data", [])
|
||||
logger.info("Instagram poll: fetched %d posts", len(posts))
|
||||
|
||||
async with get_session_factory()() as session:
|
||||
for post in posts:
|
||||
media_id = post.get("id")
|
||||
caption = post.get("caption", "")
|
||||
image_url = post.get("media_url")
|
||||
|
||||
if not caption:
|
||||
continue
|
||||
|
||||
try:
|
||||
parsed = parse_caption(caption, image_url)
|
||||
await _upsert_championship(session, media_id, parsed)
|
||||
logger.info("Synced championship from Instagram post %s: %s", media_id, parsed.title)
|
||||
except Exception as exc:
|
||||
logger.error("Failed to sync Instagram post %s: %s", media_id, exc)
|
||||
|
||||
|
||||
async def refresh_instagram_token() -> None:
|
||||
"""Refresh the long-lived Instagram token before it expires (run weekly)."""
|
||||
if not settings.instagram_access_token:
|
||||
return
|
||||
|
||||
url = (
|
||||
f"{GRAPH_BASE}/oauth/access_token"
|
||||
f"?grant_type=ig_refresh_token"
|
||||
f"&access_token={settings.instagram_access_token}"
|
||||
)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.get(url)
|
||||
response.raise_for_status()
|
||||
new_token = response.json().get("access_token")
|
||||
if new_token:
|
||||
# In a production setup, persist the new token to the DB or secrets manager.
|
||||
# For now, log it so it can be manually updated in .env.
|
||||
logger.warning(
|
||||
"Instagram token refreshed. Update INSTAGRAM_ACCESS_TOKEN in .env:\n%s",
|
||||
new_token,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error("Failed to refresh Instagram token: %s", exc)
|
||||
@@ -1,44 +0,0 @@
|
||||
import httpx
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.notification_log import NotificationLog
|
||||
from app.models.user import User
|
||||
|
||||
EXPO_PUSH_URL = "https://exp.host/--/api/v2/push/send"
|
||||
|
||||
|
||||
async def send_push_notification(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
title: str,
|
||||
body: str,
|
||||
notif_type: str,
|
||||
registration_id: str | None = None,
|
||||
) -> None:
|
||||
delivery_status = "skipped"
|
||||
|
||||
if user.expo_push_token:
|
||||
payload = {
|
||||
"to": user.expo_push_token,
|
||||
"title": title,
|
||||
"body": body,
|
||||
"data": {"type": notif_type, "registration_id": registration_id},
|
||||
"sound": "default",
|
||||
}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.post(EXPO_PUSH_URL, json=payload)
|
||||
delivery_status = "sent" if response.status_code == 200 else "failed"
|
||||
except Exception:
|
||||
delivery_status = "failed"
|
||||
|
||||
log = NotificationLog(
|
||||
user_id=user.id,
|
||||
registration_id=registration_id,
|
||||
type=notif_type,
|
||||
title=title,
|
||||
body=body,
|
||||
delivery_status=delivery_status,
|
||||
)
|
||||
db.add(log)
|
||||
await db.commit()
|
||||
@@ -1,49 +0,0 @@
|
||||
import uuid
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.crud import crud_championship, crud_participant, crud_registration, crud_user
|
||||
from app.models.participant_list import ParticipantList
|
||||
from app.models.user import User
|
||||
from app.services import notification_service
|
||||
|
||||
|
||||
async def publish_participant_list(
|
||||
db: AsyncSession,
|
||||
championship_id: uuid.UUID,
|
||||
organizer: User,
|
||||
) -> ParticipantList:
|
||||
pl = await crud_participant.get_by_championship(db, championship_id)
|
||||
if not pl:
|
||||
raise ValueError("Participant list not found — create it first")
|
||||
|
||||
pl = await crud_participant.publish(db, pl)
|
||||
|
||||
championship = await crud_championship.get(db, championship_id)
|
||||
registrations = await crud_registration.list_by_championship(db, championship_id)
|
||||
|
||||
for reg in registrations:
|
||||
user = await crud_user.get(db, reg.user_id)
|
||||
if not user:
|
||||
continue
|
||||
|
||||
if reg.status == "accepted":
|
||||
title = "Congratulations!"
|
||||
body = f"You've been accepted to {championship.title}!"
|
||||
elif reg.status == "rejected":
|
||||
title = "Application Update"
|
||||
body = f"Unfortunately, your application to {championship.title} was not accepted this time."
|
||||
else:
|
||||
title = "Application Update"
|
||||
body = f"You are on the waitlist for {championship.title}."
|
||||
|
||||
await notification_service.send_push_notification(
|
||||
db=db,
|
||||
user=user,
|
||||
title=title,
|
||||
body=body,
|
||||
notif_type=reg.status,
|
||||
registration_id=str(reg.id),
|
||||
)
|
||||
|
||||
return pl
|
||||
@@ -1,2 +0,0 @@
|
||||
[pytest]
|
||||
asyncio_mode = auto
|
||||
@@ -1,18 +1,18 @@
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.32.1
|
||||
sqlalchemy==2.0.36
|
||||
asyncpg==0.30.0
|
||||
sqlalchemy[asyncio]==2.0.36
|
||||
alembic==1.14.0
|
||||
aiosqlite==0.20.0
|
||||
# asyncpg==0.30.0 # uncomment for PostgreSQL production use
|
||||
pydantic==2.10.3
|
||||
pydantic-settings==2.7.0
|
||||
pydantic[email]==2.10.3
|
||||
passlib[bcrypt]==1.7.4
|
||||
bcrypt==4.0.1
|
||||
PyJWT==2.10.1
|
||||
python-jose[cryptography]==3.3.0
|
||||
bcrypt==4.2.1
|
||||
pydantic[email]
|
||||
python-multipart==0.0.20
|
||||
httpx==0.28.1
|
||||
apscheduler==3.11.0
|
||||
apscheduler==3.10.4
|
||||
slowapi==0.1.9
|
||||
pytest==8.3.4
|
||||
pytest-asyncio==0.25.2
|
||||
pytest-asyncio==0.24.0
|
||||
pytest-httpx==0.35.0
|
||||
aiosqlite==0.20.0
|
||||
|
||||
159
backend/seed.py
Normal file
159
backend/seed.py
Normal file
@@ -0,0 +1,159 @@
|
||||
"""Seed script — creates test users, organization, and championships.
|
||||
Run from backend/: .venv/Scripts/python seed.py
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from app.database import AsyncSessionLocal
|
||||
from app.models.championship import Championship
|
||||
from app.models.organization import Organization
|
||||
from app.models.user import User
|
||||
from app.services.auth_service import hash_password
|
||||
from sqlalchemy import select
|
||||
|
||||
|
||||
async def seed():
|
||||
async with AsyncSessionLocal() as db:
|
||||
# ── Users ──────────────────────────────────────────────────────────────
|
||||
users_data = [
|
||||
{
|
||||
"email": "admin@pole.dev",
|
||||
"full_name": "Diana Admin",
|
||||
"password": "Admin1234",
|
||||
"role": "admin",
|
||||
"status": "approved",
|
||||
},
|
||||
{
|
||||
"email": "organizer@pole.dev",
|
||||
"full_name": "Ekaterina Organizer",
|
||||
"password": "Org1234",
|
||||
"role": "organizer",
|
||||
"status": "approved",
|
||||
"instagram_handle": "@ekaterina_pole",
|
||||
},
|
||||
{
|
||||
"email": "member@pole.dev",
|
||||
"full_name": "Anna Petrova",
|
||||
"password": "Member1234",
|
||||
"role": "member",
|
||||
"status": "approved",
|
||||
"instagram_handle": "@anna_petrova",
|
||||
},
|
||||
{
|
||||
"email": "pending@pole.dev",
|
||||
"full_name": "New Applicant",
|
||||
"password": "Pending1234",
|
||||
"role": "member",
|
||||
"status": "pending",
|
||||
},
|
||||
]
|
||||
|
||||
created_users = {}
|
||||
for ud in users_data:
|
||||
result = await db.execute(select(User).where(User.email == ud["email"]))
|
||||
user = result.scalar_one_or_none()
|
||||
if user is None:
|
||||
user = User(
|
||||
email=ud["email"],
|
||||
hashed_password=hash_password(ud["password"]),
|
||||
full_name=ud["full_name"],
|
||||
role=ud["role"],
|
||||
status=ud["status"],
|
||||
instagram_handle=ud.get("instagram_handle"),
|
||||
)
|
||||
db.add(user)
|
||||
print(f" Created user: {ud['email']}")
|
||||
else:
|
||||
user.role = ud["role"]
|
||||
user.status = ud["status"]
|
||||
user.hashed_password = hash_password(ud["password"])
|
||||
print(f" Updated user: {ud['email']}")
|
||||
created_users[ud["email"]] = user
|
||||
|
||||
await db.flush()
|
||||
|
||||
# ── Organization ──────────────────────────────────────────────────────
|
||||
organizer = created_users["organizer@pole.dev"]
|
||||
result = await db.execute(select(Organization).where(Organization.user_id == organizer.id))
|
||||
org = result.scalar_one_or_none()
|
||||
if org is None:
|
||||
org = Organization(
|
||||
user_id=organizer.id,
|
||||
name="Pole Studio Minsk",
|
||||
instagram="@polestudio_minsk",
|
||||
email="organizer@pole.dev",
|
||||
city="Minsk",
|
||||
verified=True,
|
||||
status="active",
|
||||
)
|
||||
db.add(org)
|
||||
print(f" Created organization: {org.name}")
|
||||
else:
|
||||
print(f" Organization already exists: {org.name}")
|
||||
|
||||
await db.flush()
|
||||
|
||||
# ── Championships ──────────────────────────────────────────────────────
|
||||
championships_data = [
|
||||
{
|
||||
"title": "Spring Open 2026",
|
||||
"description": "Annual spring pole dance championship. All levels welcome.",
|
||||
"location": "Cultural Center, Minsk",
|
||||
"event_date": datetime(2026, 4, 15, 10, 0, tzinfo=UTC),
|
||||
"registration_open_at": datetime(2026, 3, 1, 0, 0, tzinfo=UTC),
|
||||
"registration_close_at": datetime(2026, 4, 1, 0, 0, tzinfo=UTC),
|
||||
"form_url": "https://forms.example.com/spring2026",
|
||||
"entry_fee": 50.0,
|
||||
"video_max_duration": 180,
|
||||
"judges": json.dumps([
|
||||
{"name": "Oksana Ivanova", "bio": "Champion 2023", "instagram": "@oksana_pole"},
|
||||
{"name": "Marta Sokolova", "bio": "Certified judge", "instagram": "@marta_pole"},
|
||||
]),
|
||||
"categories": json.dumps(["Novice", "Amateur", "Professional"]),
|
||||
"status": "open",
|
||||
"source": "manual",
|
||||
"image_url": "https://images.unsplash.com/photo-1524594152303-9fd13543fe6e?w=800",
|
||||
},
|
||||
{
|
||||
"title": "Summer Championship 2026",
|
||||
"description": "The biggest pole dance event of the summer.",
|
||||
"location": "Sports Palace, Minsk",
|
||||
"event_date": datetime(2026, 7, 20, 9, 0, tzinfo=UTC),
|
||||
"registration_open_at": datetime(2026, 6, 1, 0, 0, tzinfo=UTC),
|
||||
"registration_close_at": datetime(2026, 7, 5, 0, 0, tzinfo=UTC),
|
||||
"entry_fee": 75.0,
|
||||
"video_max_duration": 240,
|
||||
"judges": json.dumps([
|
||||
{"name": "Elena Kozlova", "bio": "World finalist", "instagram": "@elena_wpc"},
|
||||
]),
|
||||
"categories": json.dumps(["Junior", "Senior", "Masters"]),
|
||||
"status": "draft",
|
||||
"source": "manual",
|
||||
},
|
||||
]
|
||||
|
||||
for cd in championships_data:
|
||||
result = await db.execute(
|
||||
select(Championship).where(Championship.title == cd["title"])
|
||||
)
|
||||
champ = result.scalar_one_or_none()
|
||||
if champ is None:
|
||||
champ = Championship(org_id=org.id, **cd)
|
||||
db.add(champ)
|
||||
print(f" Created championship: {cd['title']}")
|
||||
else:
|
||||
champ.org_id = org.id
|
||||
print(f" Updated championship: {cd['title']}")
|
||||
|
||||
await db.commit()
|
||||
print("\nSeed complete!")
|
||||
print("\n=== TEST CREDENTIALS ===")
|
||||
print("Admin: admin@pole.dev / Admin1234")
|
||||
print("Organizer: organizer@pole.dev / Org1234")
|
||||
print("Member: member@pole.dev / Member1234")
|
||||
print("Pending: pending@pole.dev / Pending1234")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(seed())
|
||||
@@ -1,55 +0,0 @@
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
# Override DATABASE_URL before any app code is imported so the lazy engine
|
||||
# initialises with SQLite (no asyncpg required in the test environment).
|
||||
os.environ["DATABASE_URL"] = "sqlite+aiosqlite:///:memory:"
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from httpx import AsyncClient, ASGITransport
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
|
||||
|
||||
from app.database import Base, get_db
|
||||
from app.main import app
|
||||
|
||||
TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:"
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def event_loop():
|
||||
loop = asyncio.new_event_loop()
|
||||
yield loop
|
||||
loop.close()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="session")
|
||||
async def db_engine():
|
||||
engine = create_async_engine(TEST_DATABASE_URL, echo=False)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
yield engine
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def db_session(db_engine):
|
||||
factory = async_sessionmaker(db_engine, expire_on_commit=False)
|
||||
async with factory() as session:
|
||||
yield session
|
||||
await session.rollback()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def client(db_session):
|
||||
async def override_get_db():
|
||||
yield db_session
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=app), base_url="http://test"
|
||||
) as ac:
|
||||
yield ac
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
@@ -1,89 +0,0 @@
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_and_login(client):
|
||||
# Register
|
||||
res = await client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={
|
||||
"email": "test@example.com",
|
||||
"password": "secret123",
|
||||
"full_name": "Test User",
|
||||
},
|
||||
)
|
||||
assert res.status_code == 201
|
||||
tokens = res.json()
|
||||
assert "access_token" in tokens
|
||||
assert "refresh_token" in tokens
|
||||
|
||||
# Duplicate registration should fail
|
||||
res2 = await client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={
|
||||
"email": "test@example.com",
|
||||
"password": "secret123",
|
||||
"full_name": "Test User",
|
||||
},
|
||||
)
|
||||
assert res2.status_code == 409
|
||||
|
||||
# Login with correct credentials
|
||||
res3 = await client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"email": "test@example.com", "password": "secret123"},
|
||||
)
|
||||
assert res3.status_code == 200
|
||||
|
||||
# Login with wrong password
|
||||
res4 = await client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"email": "test@example.com", "password": "wrong"},
|
||||
)
|
||||
assert res4.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_me_requires_auth(client):
|
||||
res = await client.get("/api/v1/auth/me")
|
||||
assert res.status_code in (401, 403) # missing Authorization header
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pending_user_cannot_access_championships(client):
|
||||
await client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": "pending@example.com", "password": "pw", "full_name": "Pending"},
|
||||
)
|
||||
login = await client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"email": "pending@example.com", "password": "pw"},
|
||||
)
|
||||
token = login.json()["access_token"]
|
||||
res = await client.get(
|
||||
"/api/v1/championships",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert res.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_token_refresh(client):
|
||||
await client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": "refresh@example.com", "password": "pw", "full_name": "Refresh"},
|
||||
)
|
||||
login = await client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"email": "refresh@example.com", "password": "pw"},
|
||||
)
|
||||
refresh_token = login.json()["refresh_token"]
|
||||
|
||||
res = await client.post("/api/v1/auth/refresh", json={"refresh_token": refresh_token})
|
||||
assert res.status_code == 200
|
||||
new_tokens = res.json()
|
||||
assert "access_token" in new_tokens
|
||||
|
||||
# Old refresh token should now be revoked
|
||||
res2 = await client.post("/api/v1/auth/refresh", json={"refresh_token": refresh_token})
|
||||
assert res2.status_code == 401
|
||||
@@ -1,46 +0,0 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from app.services.instagram_service import parse_caption
|
||||
|
||||
|
||||
def test_parse_basic_russian_post():
|
||||
text = """Открытый Чемпионат по Pole Dance
|
||||
Место: Москва, ул. Арбат, 10
|
||||
Дата: 15 марта 2026
|
||||
Регистрация открыта!"""
|
||||
result = parse_caption(text)
|
||||
assert result.title == "Открытый Чемпионат по Pole Dance"
|
||||
assert result.location == "Москва, ул. Арбат, 10"
|
||||
assert result.event_date == datetime(2026, 3, 15, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def test_parse_dot_date_format():
|
||||
text = "Summer Cup\nLocation: Saint Petersburg\n15.07.2026"
|
||||
result = parse_caption(text)
|
||||
assert result.event_date == datetime(2026, 7, 15, tzinfo=timezone.utc)
|
||||
assert result.location == "Saint Petersburg"
|
||||
|
||||
|
||||
def test_parse_english_date():
|
||||
text = "Winter Championship\nVenue: Moscow Arena\nJanuary 20, 2027"
|
||||
result = parse_caption(text)
|
||||
assert result.event_date == datetime(2027, 1, 20, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def test_parse_no_date_returns_none():
|
||||
text = "Some announcement\nNo date here"
|
||||
result = parse_caption(text)
|
||||
assert result.event_date is None
|
||||
assert result.title == "Some announcement"
|
||||
|
||||
|
||||
def test_parse_with_image_url():
|
||||
text = "Spring Cup"
|
||||
result = parse_caption(text, image_url="https://example.com/img.jpg")
|
||||
assert result.image_url == "https://example.com/img.jpg"
|
||||
|
||||
|
||||
def test_parse_empty_caption():
|
||||
result = parse_caption("")
|
||||
assert result.title == "Untitled Championship"
|
||||
assert result.description is None
|
||||
176
dancechamp-claude-code/SPEC-CLAUDE.md
Normal file
176
dancechamp-claude-code/SPEC-CLAUDE.md
Normal file
@@ -0,0 +1,176 @@
|
||||
# CLAUDE.md — DanceChamp
|
||||
|
||||
## What is this project?
|
||||
|
||||
DanceChamp is a mobile platform for **pole dance championships**. Three apps, one database:
|
||||
|
||||
- **Member App** (React Native / Expo) — Dancers discover championships, register, track their 10-step progress
|
||||
- **Org App** (React Native / Expo) — Championship organizers create events, manage members, review videos, confirm payments
|
||||
- **Admin Panel** (React + Vite, web) — Platform admin approves orgs, reviews championships from unverified orgs, manages users
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
/
|
||||
├── CLAUDE.md ← You are here
|
||||
├── apps/
|
||||
│ ├── mobile/ ← Expo app (Member + Org views, switched by role)
|
||||
│ │ ├── src/
|
||||
│ │ │ ├── screens/
|
||||
│ │ │ │ ├── member/ ← Home, MyChamps, Search, Profile, ChampDetail, Progress
|
||||
│ │ │ │ ├── org/ ← Dashboard, ChampDetail (tabbed), MemberDetail, Settings
|
||||
│ │ │ │ └── auth/ ← SignIn, SignUp, Onboarding
|
||||
│ │ │ ├── components/ ← Shared UI components
|
||||
│ │ │ ├── navigation/ ← Tab + Stack navigators
|
||||
│ │ │ ├── store/ ← Zustand stores
|
||||
│ │ │ ├── lib/ ← Supabase client, helpers
|
||||
│ │ │ └── theme/ ← Colors, fonts, spacing
|
||||
│ │ └── app.json
|
||||
│ └── admin/ ← Vite React app
|
||||
│ └── src/
|
||||
│ ├── pages/ ← Dashboard, Orgs, Champs, Users
|
||||
│ ├── components/
|
||||
│ └── lib/
|
||||
├── packages/
|
||||
│ └── shared/ ← Shared types, constants, validation
|
||||
│ ├── types.ts ← TypeScript interfaces (User, Championship, etc.)
|
||||
│ └── constants.ts ← Status enums, role enums
|
||||
├── supabase/
|
||||
│ ├── migrations/ ← SQL migration files
|
||||
│ └── seed.sql ← Demo data
|
||||
└── docs/
|
||||
├── SPEC.md ← Full technical specification
|
||||
├── PLAN.md ← Phase-by-phase dev plan with checkboxes
|
||||
├── DATABASE.md ← Complete database schema + RLS policies
|
||||
├── DESIGN-SYSTEM.md ← Colors, fonts, components, patterns
|
||||
└── SCREENS.md ← Screen-by-screen reference for all 3 apps
|
||||
```
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Layer | Choice | Notes |
|
||||
|---|---|---|
|
||||
| Mobile | React Native (Expo) | `npx create-expo-app` with TypeScript |
|
||||
| Admin | React + Vite | Separate web app |
|
||||
| Language | TypeScript | Everywhere |
|
||||
| Navigation | React Navigation | Bottom tabs + stack |
|
||||
| State | Zustand | Lightweight stores |
|
||||
| Backend | Supabase | Auth, Postgres DB, Storage, Realtime, Edge Functions |
|
||||
| Push | Expo Notifications | Via Supabase Edge Function triggers |
|
||||
|
||||
## Key Architecture Decisions
|
||||
|
||||
### 1. One mobile app, two views
|
||||
Member and Org use the **same Expo app**. After login, the app checks `user.role` and shows the appropriate navigation:
|
||||
- `role === "member"` → Member tabs (Home, My Champs, Search, Profile)
|
||||
- `role === "organization"` → Org tabs (Dashboard, Settings)
|
||||
|
||||
### 2. Everything is scoped per-championship
|
||||
Members, results, categories, rules, fees, judges — all belong to a specific championship. There is no "global members list" for an org. Each championship is self-contained.
|
||||
|
||||
### 3. Configurable tabs (Org)
|
||||
Orgs don't fill a giant wizard. They quick-create a championship (name + date + location), then configure each section (Categories, Fees, Rules, Judges) at their own pace. Each section has a "✓ Mark as Done" button. Championship can only go live when all sections are done.
|
||||
|
||||
### 4. Approval flow
|
||||
- **Verified orgs** → "Go Live" sets status to `live` immediately (auto-approved)
|
||||
- **Unverified orgs** → "Go Live" sets status to `pending_approval` → admin must approve
|
||||
|
||||
### 5. Registration dates (not deadline)
|
||||
Championships have: `event_date`, `reg_start`, `reg_end`. Registration close date must be before event date. No single "deadline" field.
|
||||
|
||||
### 6. Judges = People, Scoring = Rules
|
||||
The "Judges" tab shows judge profiles (name, instagram, bio). Scoring criteria and penalties live in the "Rules" tab.
|
||||
|
||||
## Conventions
|
||||
|
||||
### Code Style
|
||||
- Functional components only, no class components
|
||||
- Use hooks: `useState`, `useEffect`, custom hooks for data fetching
|
||||
- Zustand for global state (auth, current user, championships cache)
|
||||
- Local state for UI-only state (modals, form inputs, filters)
|
||||
- TypeScript strict mode
|
||||
|
||||
### Naming
|
||||
- Files: `kebab-case.ts` / `kebab-case.tsx`
|
||||
- Components: `PascalCase`
|
||||
- Hooks: `useCamelCase`
|
||||
- Zustand stores: `use[Name]Store`
|
||||
- DB tables: `snake_case`
|
||||
- DB columns: `snake_case`
|
||||
|
||||
### Supabase Patterns
|
||||
```typescript
|
||||
// Client init
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY)
|
||||
|
||||
// Fetching
|
||||
const { data, error } = await supabase
|
||||
.from('championships')
|
||||
.select('*, disciplines(*), fees(*)')
|
||||
.eq('status', 'live')
|
||||
|
||||
// Realtime subscription
|
||||
supabase.channel('registrations')
|
||||
.on('postgres_changes', { event: '*', schema: 'public', table: 'registrations' }, handler)
|
||||
.subscribe()
|
||||
```
|
||||
|
||||
### Navigation Pattern
|
||||
```typescript
|
||||
// Member
|
||||
const MemberTabs = () => (
|
||||
<Tab.Navigator>
|
||||
<Tab.Screen name="Home" component={HomeStack} />
|
||||
<Tab.Screen name="MyChamps" component={MyChampsStack} />
|
||||
<Tab.Screen name="Search" component={SearchStack} />
|
||||
<Tab.Screen name="Profile" component={ProfileStack} />
|
||||
</Tab.Navigator>
|
||||
)
|
||||
|
||||
// Org
|
||||
const OrgTabs = () => (
|
||||
<Tab.Navigator>
|
||||
<Tab.Screen name="Dashboard" component={DashboardStack} />
|
||||
<Tab.Screen name="Settings" component={SettingsStack} />
|
||||
</Tab.Navigator>
|
||||
)
|
||||
```
|
||||
|
||||
## Important Docs
|
||||
|
||||
Before coding any feature, read the relevant doc:
|
||||
|
||||
| Doc | When to read |
|
||||
|---|---|
|
||||
| `docs/SPEC.md` | Full feature spec — read first for any new feature |
|
||||
| `docs/PLAN.md` | Dev plan with phases — check what's next |
|
||||
| `docs/DATABASE.md` | Schema — read before any DB work |
|
||||
| `docs/DESIGN-SYSTEM.md` | UI — read before any screen work |
|
||||
| `docs/SCREENS.md` | Screen details — read before building specific screens |
|
||||
|
||||
## Quick Commands
|
||||
|
||||
```bash
|
||||
# Start mobile app
|
||||
cd apps/mobile && npx expo start
|
||||
|
||||
# Start admin panel
|
||||
cd apps/admin && npm run dev
|
||||
|
||||
# Supabase local dev
|
||||
npx supabase start
|
||||
npx supabase db reset # Reset + re-seed
|
||||
|
||||
# Generate types from Supabase
|
||||
npx supabase gen types typescript --local > packages/shared/database.types.ts
|
||||
```
|
||||
|
||||
## Current Status
|
||||
|
||||
Prototypes completed (JSX files in `/prototypes`):
|
||||
- `dance-champ-mvp.jsx` — Member app prototype
|
||||
- `dance-champ-org.jsx` — Org app prototype
|
||||
- `dance-champ-admin.jsx` — Admin panel prototype
|
||||
|
||||
These are reference implementations showing the exact UI, data structure, and flows. Use them as visual guides — don't copy the code directly (they're single-file React prototypes, not production React Native).
|
||||
357
dancechamp-claude-code/docs/DATABASE.md
Normal file
357
dancechamp-claude-code/docs/DATABASE.md
Normal file
@@ -0,0 +1,357 @@
|
||||
# DanceChamp — Database Schema
|
||||
|
||||
## Overview
|
||||
|
||||
Backend: **Supabase** (PostgreSQL + Auth + Storage + Realtime)
|
||||
|
||||
All tables use `uuid` primary keys generated by `gen_random_uuid()`.
|
||||
All tables have `created_at` and `updated_at` timestamps.
|
||||
|
||||
---
|
||||
|
||||
## Tables
|
||||
|
||||
### users
|
||||
Extended from Supabase Auth. This is a `public.users` table that mirrors `auth.users` via trigger.
|
||||
|
||||
```sql
|
||||
create table public.users (
|
||||
id uuid primary key references auth.users(id) on delete cascade,
|
||||
email text not null,
|
||||
name text not null,
|
||||
role text not null check (role in ('admin', 'organization', 'member')),
|
||||
city text,
|
||||
instagram_handle text,
|
||||
experience_years integer,
|
||||
disciplines text[] default '{}', -- ['Pole Exotic', 'Pole Art']
|
||||
auth_provider text default 'email', -- 'email' | 'google' | 'instagram'
|
||||
avatar_url text,
|
||||
status text not null default 'active' check (status in ('active', 'warned', 'blocked')),
|
||||
warn_reason text,
|
||||
block_reason text,
|
||||
created_at timestamptz default now(),
|
||||
updated_at timestamptz default now()
|
||||
);
|
||||
```
|
||||
|
||||
### organizations
|
||||
One-to-one with a user (role = 'organization').
|
||||
|
||||
```sql
|
||||
create table public.organizations (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
user_id uuid not null references public.users(id) on delete cascade,
|
||||
name text not null,
|
||||
instagram_handle text,
|
||||
email text,
|
||||
city text,
|
||||
logo_url text,
|
||||
verified boolean not null default false,
|
||||
status text not null default 'pending' check (status in ('active', 'pending', 'blocked')),
|
||||
block_reason text,
|
||||
created_at timestamptz default now(),
|
||||
updated_at timestamptz default now()
|
||||
);
|
||||
```
|
||||
|
||||
### championships
|
||||
Belongs to an organization. Core entity.
|
||||
|
||||
```sql
|
||||
create table public.championships (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
org_id uuid not null references public.organizations(id) on delete cascade,
|
||||
name text not null,
|
||||
subtitle text,
|
||||
event_date text, -- "May 30, 2026" or ISO date
|
||||
reg_start text, -- registration opens
|
||||
reg_end text, -- registration closes (must be before event_date)
|
||||
location text, -- "Minsk, Belarus"
|
||||
venue text, -- "Prime Hall"
|
||||
accent_color text default '#D4145A',
|
||||
image_emoji text default '💃',
|
||||
status text not null default 'draft' check (status in ('draft', 'pending_approval', 'live', 'completed', 'blocked')),
|
||||
-- configurable sections progress
|
||||
config_info boolean not null default false,
|
||||
config_categories boolean not null default false,
|
||||
config_fees boolean not null default false,
|
||||
config_rules boolean not null default false,
|
||||
config_judges boolean not null default false,
|
||||
-- links
|
||||
form_url text, -- Google Forms URL
|
||||
rules_doc_url text, -- Rules document URL
|
||||
created_at timestamptz default now(),
|
||||
updated_at timestamptz default now()
|
||||
);
|
||||
```
|
||||
|
||||
### disciplines
|
||||
Championship has many disciplines. Each discipline has levels.
|
||||
|
||||
```sql
|
||||
create table public.disciplines (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
championship_id uuid not null references public.championships(id) on delete cascade,
|
||||
name text not null, -- "Exotic Pole Dance"
|
||||
levels text[] default '{}', -- ['Beginners', 'Amateur', 'Semi-Pro', 'Profi', 'Elite']
|
||||
sort_order integer default 0,
|
||||
created_at timestamptz default now()
|
||||
);
|
||||
```
|
||||
|
||||
### styles
|
||||
Championship-level styles (not per-discipline).
|
||||
|
||||
```sql
|
||||
create table public.styles (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
championship_id uuid not null references public.championships(id) on delete cascade,
|
||||
name text not null, -- "Classic", "Flow", "Theater"
|
||||
sort_order integer default 0,
|
||||
created_at timestamptz default now()
|
||||
);
|
||||
```
|
||||
|
||||
### fees
|
||||
One-to-one with championship.
|
||||
|
||||
```sql
|
||||
create table public.fees (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
championship_id uuid not null unique references public.championships(id) on delete cascade,
|
||||
video_selection text, -- "50 BYN / 1,500 RUB"
|
||||
solo text, -- "280 BYN / 7,500 RUB"
|
||||
duet text, -- "210 BYN / 5,800 RUB pp"
|
||||
"group" text, -- "190 BYN / 4,500 RUB pp"
|
||||
refund_note text,
|
||||
created_at timestamptz default now(),
|
||||
updated_at timestamptz default now()
|
||||
);
|
||||
```
|
||||
|
||||
### rules
|
||||
Championship has many rules across sections.
|
||||
|
||||
```sql
|
||||
create table public.rules (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
championship_id uuid not null references public.championships(id) on delete cascade,
|
||||
section text not null check (section in ('general', 'costume', 'scoring', 'penalty')),
|
||||
name text not null, -- rule text or criterion name
|
||||
value text, -- for scoring: "10" (max), for penalty: "-2" or "DQ"
|
||||
sort_order integer default 0,
|
||||
created_at timestamptz default now()
|
||||
);
|
||||
```
|
||||
|
||||
### judges
|
||||
Championship has many judges.
|
||||
|
||||
```sql
|
||||
create table public.judges (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
championship_id uuid not null references public.championships(id) on delete cascade,
|
||||
name text not null,
|
||||
instagram text,
|
||||
bio text,
|
||||
photo_url text,
|
||||
sort_order integer default 0,
|
||||
created_at timestamptz default now()
|
||||
);
|
||||
```
|
||||
|
||||
### registrations
|
||||
Links a member to a championship. Tracks the 10-step progress.
|
||||
|
||||
```sql
|
||||
create table public.registrations (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
user_id uuid not null references public.users(id) on delete cascade,
|
||||
championship_id uuid not null references public.championships(id) on delete cascade,
|
||||
discipline_id uuid references public.disciplines(id),
|
||||
level text, -- "Semi-Pro"
|
||||
style text, -- "Classic"
|
||||
participation_type text default 'solo' check (participation_type in ('solo', 'duet', 'group')),
|
||||
-- Progress steps (step 1–10)
|
||||
step_rules_reviewed boolean default false,
|
||||
step_category_selected boolean default false,
|
||||
step_video_recorded boolean default false,
|
||||
step_form_submitted boolean default false,
|
||||
step_video_fee_paid boolean default false, -- confirmed by org
|
||||
step_video_fee_receipt_url text, -- uploaded receipt
|
||||
step_results text check (step_results in ('pending', 'passed', 'failed')),
|
||||
step_champ_fee_paid boolean default false,
|
||||
step_champ_fee_receipt_url text,
|
||||
step_about_me_submitted boolean default false,
|
||||
step_insurance_confirmed boolean default false,
|
||||
step_insurance_doc_url text,
|
||||
-- Video
|
||||
video_url text,
|
||||
-- Meta
|
||||
current_step integer default 1,
|
||||
created_at timestamptz default now(),
|
||||
updated_at timestamptz default now(),
|
||||
unique(user_id, championship_id)
|
||||
);
|
||||
```
|
||||
|
||||
### notifications
|
||||
Push to member's in-app feed.
|
||||
|
||||
```sql
|
||||
create table public.notifications (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
user_id uuid not null references public.users(id) on delete cascade,
|
||||
championship_id uuid references public.championships(id) on delete set null,
|
||||
type text not null check (type in (
|
||||
'category_changed', 'payment_confirmed', 'results',
|
||||
'deadline_reminder', 'registration_confirmed', 'announcement',
|
||||
'champ_approved', 'champ_rejected', 'org_approved', 'org_rejected'
|
||||
)),
|
||||
title text not null,
|
||||
message text not null,
|
||||
read boolean not null default false,
|
||||
created_at timestamptz default now()
|
||||
);
|
||||
```
|
||||
|
||||
### activity_logs
|
||||
Admin audit trail.
|
||||
|
||||
```sql
|
||||
create table public.activity_logs (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
actor_id uuid references public.users(id) on delete set null,
|
||||
action text not null, -- "org_approved", "user_blocked", "champ_auto_approved"
|
||||
target_type text not null, -- "organization", "championship", "user"
|
||||
target_id uuid,
|
||||
target_name text, -- denormalized for display
|
||||
details jsonb, -- extra context
|
||||
created_at timestamptz default now()
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Relationships Diagram
|
||||
|
||||
```
|
||||
users (1) ──── (1) organizations
|
||||
│
|
||||
│ has many
|
||||
▼
|
||||
championships
|
||||
┌────┼────┬────┬────┐
|
||||
│ │ │ │ │
|
||||
disciplines styles fees rules judges
|
||||
│
|
||||
registrations ─┘
|
||||
(user + championship)
|
||||
│
|
||||
notifications
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Row Level Security (RLS)
|
||||
|
||||
Enable RLS on all tables.
|
||||
|
||||
### users
|
||||
```sql
|
||||
-- Members can read/update their own row
|
||||
create policy "Users can read own" on users for select using (auth.uid() = id);
|
||||
create policy "Users can update own" on users for update using (auth.uid() = id);
|
||||
|
||||
-- Org admins can read members registered to their championships
|
||||
create policy "Orgs can read their members" on users for select using (
|
||||
id in (
|
||||
select r.user_id from registrations r
|
||||
join championships c on r.championship_id = c.id
|
||||
join organizations o on c.org_id = o.id
|
||||
where o.user_id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
-- Admin can read/update all
|
||||
create policy "Admin full access" on users for all using (
|
||||
exists (select 1 from users where id = auth.uid() and role = 'admin')
|
||||
);
|
||||
```
|
||||
|
||||
### championships
|
||||
```sql
|
||||
-- Anyone can read live championships
|
||||
create policy "Public read live" on championships for select using (status = 'live');
|
||||
|
||||
-- Org can CRUD their own
|
||||
create policy "Org manages own" on championships for all using (
|
||||
org_id in (select id from organizations where user_id = auth.uid())
|
||||
);
|
||||
|
||||
-- Admin full access
|
||||
create policy "Admin full access" on championships for all using (
|
||||
exists (select 1 from users where id = auth.uid() and role = 'admin')
|
||||
);
|
||||
```
|
||||
|
||||
### registrations
|
||||
```sql
|
||||
-- Members can read/create their own
|
||||
create policy "Member own registrations" on registrations for select using (user_id = auth.uid());
|
||||
create policy "Member can register" on registrations for insert with check (user_id = auth.uid());
|
||||
|
||||
-- Org can read/update registrations for their championships
|
||||
create policy "Org manages registrations" on registrations for all using (
|
||||
championship_id in (
|
||||
select c.id from championships c
|
||||
join organizations o on c.org_id = o.id
|
||||
where o.user_id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
-- Admin full access
|
||||
create policy "Admin full access" on registrations for all using (
|
||||
exists (select 1 from users where id = auth.uid() and role = 'admin')
|
||||
);
|
||||
```
|
||||
|
||||
### notifications
|
||||
```sql
|
||||
-- Users can read their own notifications
|
||||
create policy "Read own" on notifications for select using (user_id = auth.uid());
|
||||
-- Users can mark their own as read
|
||||
create policy "Update own" on notifications for update using (user_id = auth.uid());
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Storage Buckets
|
||||
|
||||
```
|
||||
receipts/ -- Payment receipt screenshots
|
||||
{user_id}/{registration_id}/receipt.jpg
|
||||
|
||||
insurance/ -- Insurance documents
|
||||
{user_id}/{registration_id}/insurance.pdf
|
||||
|
||||
judge-photos/ -- Judge profile photos
|
||||
{championship_id}/{judge_id}.jpg
|
||||
|
||||
org-logos/ -- Organization logos
|
||||
{org_id}/logo.jpg
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Seed Data
|
||||
|
||||
For development, seed with:
|
||||
- 1 admin user
|
||||
- 2 organizations (1 verified, 1 unverified/pending)
|
||||
- 2 championships for verified org (1 live, 1 draft)
|
||||
- 1 championship for unverified org (pending_approval)
|
||||
- 7 member users with registrations at various progress stages
|
||||
- Sample notifications, activity logs
|
||||
|
||||
This matches the prototype demo data.
|
||||
258
dancechamp-claude-code/docs/DESIGN-SYSTEM.md
Normal file
258
dancechamp-claude-code/docs/DESIGN-SYSTEM.md
Normal file
@@ -0,0 +1,258 @@
|
||||
# DanceChamp — Design System
|
||||
|
||||
## Theme: Dark Luxury
|
||||
|
||||
The app has a premium dark aesthetic. Think high-end dance competition branding — elegant, minimal, confident.
|
||||
|
||||
---
|
||||
|
||||
## Colors
|
||||
|
||||
### Core Palette
|
||||
```
|
||||
Background: #08070D (near-black with slight purple)
|
||||
Card: #12111A (elevated surface)
|
||||
Card Hover: #1A1926 (pressed/active state)
|
||||
Border: #1F1E2E (subtle separator)
|
||||
Text Primary: #F2F0FA (off-white)
|
||||
Text Dim: #5E5C72 (labels, placeholders)
|
||||
Text Mid: #8F8DA6 (secondary info)
|
||||
```
|
||||
|
||||
### Accent Colors
|
||||
```
|
||||
Pink (Primary): #D4145A ← Member app + Org app default
|
||||
Purple: #7C3AED ← Secondary accent (styles, alt champ branding)
|
||||
Indigo: #6366F1 ← Admin panel accent
|
||||
```
|
||||
|
||||
### Semantic Colors
|
||||
```
|
||||
Green: #10B981 (success, passed, active, confirmed)
|
||||
Yellow: #F59E0B (warning, pending, draft)
|
||||
Red: #EF4444 (error, failed, blocked, danger)
|
||||
Blue: #60A5FA (info, links, video)
|
||||
Orange: #F97316 (warned status, awaiting review)
|
||||
```
|
||||
|
||||
### Transparent Variants
|
||||
Each semantic color has a 10% opacity background:
|
||||
```
|
||||
Green Soft: rgba(16,185,129,0.10)
|
||||
Yellow Soft: rgba(245,158,11,0.10)
|
||||
Red Soft: rgba(239,68,68,0.10)
|
||||
Blue Soft: rgba(96,165,250,0.10)
|
||||
Purple Soft: rgba(139,92,246,0.10)
|
||||
```
|
||||
|
||||
For accent overlays use 15% opacity: `${color}15`
|
||||
For accent borders use 30% opacity: `${color}30`
|
||||
|
||||
### Per-Championship Branding
|
||||
Each championship can have its own accent color:
|
||||
- Zero Gravity: `#D4145A` (pink)
|
||||
- Pole Star: `#7C3AED` (purple)
|
||||
|
||||
This color is used for the championship's tab highlights, buttons, and member tags.
|
||||
|
||||
---
|
||||
|
||||
## Typography
|
||||
|
||||
### Font Stack
|
||||
```
|
||||
Display: 'Playfair Display', Georgia, serif ← Headings, numbers, titles
|
||||
Body: 'DM Sans', 'Segoe UI', sans-serif ← Body text, labels, buttons
|
||||
Mono: 'JetBrains Mono', monospace ← Badges, timestamps, codes, small labels
|
||||
```
|
||||
|
||||
### Sizes & Usage
|
||||
```
|
||||
Screen title: Playfair Display, 20px, 700 weight
|
||||
Section title: Playfair Display, 14px, 700 weight, Text Mid color
|
||||
Card title: DM Sans, 14-16px, 600 weight
|
||||
Body text: DM Sans, 12-13px, 400 weight
|
||||
Small label: JetBrains Mono, 9-10px, 500 weight, uppercase, letter-spacing 0.3-0.5
|
||||
Badge: JetBrains Mono, 8px, 700 weight, uppercase, letter-spacing 0.8
|
||||
Stat number: Playfair Display, 16-20px, 700 weight
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Components
|
||||
|
||||
### Card
|
||||
```
|
||||
Background: #12111A
|
||||
Border: 1px solid #1F1E2E
|
||||
Border Radius: 14px
|
||||
Padding: 16px
|
||||
```
|
||||
|
||||
### Status Badge
|
||||
Small pill with semantic color + soft background.
|
||||
```
|
||||
Font: JetBrains Mono, 8px, 700 weight, uppercase
|
||||
Padding: 3px 8px
|
||||
Border Radius: 4px
|
||||
```
|
||||
|
||||
Status mappings:
|
||||
| Status | Label | Color | Background |
|
||||
|---|---|---|---|
|
||||
| active / live | ACTIVE / LIVE | Green | Green Soft |
|
||||
| pending | PENDING | Yellow | Yellow Soft |
|
||||
| pending_approval | AWAITING REVIEW | Orange | Orange Soft |
|
||||
| draft | DRAFT | Dim | Dim 15% |
|
||||
| blocked | BLOCKED | Red | Red Soft |
|
||||
| warned | WARNED | Orange | Orange Soft |
|
||||
| passed | PASSED | Green | Green Soft |
|
||||
| failed | FAILED | Red | Red Soft |
|
||||
|
||||
### Tab Bar (in-screen tabs, not bottom nav)
|
||||
```
|
||||
Container: horizontal scroll, no scrollbar, gap 3px
|
||||
Tab: JetBrains Mono, 9px, 600 weight
|
||||
Active: accent color text, accent 15% bg, accent 30% border
|
||||
Inactive: Dim color text, transparent bg
|
||||
Border Radius: 16px (pill shape)
|
||||
Padding: 5px 10px
|
||||
```
|
||||
|
||||
Configurable tabs have a status dot (6px circle):
|
||||
- Green dot = section configured ✓
|
||||
- Yellow dot = section pending
|
||||
|
||||
### Input Field
|
||||
```
|
||||
Background: #08070D (same as page bg)
|
||||
Border: 1px solid #1F1E2E
|
||||
Border Radius: 10px
|
||||
Padding: 10px 12px
|
||||
Font: DM Sans, 13px
|
||||
Label: JetBrains Mono, 9px, uppercase, Dim color, 6px margin bottom
|
||||
```
|
||||
|
||||
### Action Button
|
||||
Two variants:
|
||||
- **Filled**: solid background, white text (for primary actions)
|
||||
- **Outline**: transparent bg, colored border 30%, colored text (for secondary/danger)
|
||||
|
||||
```
|
||||
Padding: 8px 14px
|
||||
Border Radius: 8px
|
||||
Font: DM Sans, 11px, 700 weight
|
||||
```
|
||||
|
||||
### Tag Editor
|
||||
For lists of editable items (rules, levels, styles):
|
||||
```
|
||||
Tag: DM Sans 11px, colored bg 10%, colored border 25%, 4px 10px padding, 16px radius
|
||||
Remove (×): 10px, Dim color
|
||||
Add input: same as Input Field but smaller (8px 12px, 12px font)
|
||||
Add button: colored bg, white "+" text, 8px 14px
|
||||
```
|
||||
|
||||
### Header
|
||||
```
|
||||
Padding: 14px 20px 6px
|
||||
Title: Playfair Display, 20px, 700
|
||||
Subtitle: DM Sans, 11px, Dim color
|
||||
Back button: 32×32px, Card bg, Border, 8px radius, "←" centered
|
||||
```
|
||||
|
||||
### Bottom Navigation
|
||||
```
|
||||
Border top: 1px solid #1F1E2E
|
||||
Padding: 10px 0 8px
|
||||
Items: flex, space-around
|
||||
Icon: 18px emoji
|
||||
Label: JetBrains Mono, 8px, letter-spacing 0.3
|
||||
Active: opacity 1
|
||||
Inactive: opacity 0.35
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Patterns
|
||||
|
||||
### Progress/Setup Checklist
|
||||
For configurable tabs on org side:
|
||||
```
|
||||
Each row:
|
||||
[Checkbox 22×22] [Label capitalize] [Configure › or ✓]
|
||||
|
||||
Checkbox: 6px radius, 2px border
|
||||
Done: Green border, Green Soft bg, "✓" inside
|
||||
Pending: Yellow border, transparent bg
|
||||
|
||||
Label done: Dim color, line-through
|
||||
Label pending: Text Primary, clickable → navigates to tab
|
||||
```
|
||||
|
||||
### Readiness Bar (dashboard cards)
|
||||
```
|
||||
Track: 4px height, Border color bg, 2px radius
|
||||
Fill: accent color, width = (done/total * 100)%
|
||||
Below: list of section names with ✓ (green) or ○ (yellow)
|
||||
```
|
||||
|
||||
### Member Card
|
||||
```
|
||||
Container: Card style, 12px padding
|
||||
Name: DM Sans 13px, 600 weight
|
||||
Instagram: JetBrains Mono 10px, accent color
|
||||
Tags: DM Sans 9px, Mid color, Mid 10% bg, 2px 7px padding, 10px radius
|
||||
Status badge: top-right corner
|
||||
```
|
||||
|
||||
### Stat Box
|
||||
```
|
||||
Container: Card style, 10px 6px padding, centered
|
||||
Number: Playfair Display, 16-20px, 700 weight, semantic color
|
||||
Label: JetBrains Mono, 7px, uppercase, Dim color
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phone Frame (for prototypes)
|
||||
|
||||
```
|
||||
Width: 375px
|
||||
Height: 740px
|
||||
Border Radius: 36px
|
||||
Border: 1.5px solid #1F1E2E
|
||||
Shadow: 0 0 80px rgba(accent, 0.06), 0 20px 40px rgba(0,0,0,0.5)
|
||||
|
||||
Status bar: 8px 24px padding
|
||||
Time: JetBrains Mono 11px, Dim
|
||||
Notch: 100×28px black, 14px radius
|
||||
Indicators: "●●●" JetBrains Mono 11px, Dim
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## React Native Adaptation
|
||||
|
||||
The prototypes use inline styles. For React Native:
|
||||
|
||||
| Prototype | React Native |
|
||||
|---|---|
|
||||
| `div` | `View` |
|
||||
| `span`, `p`, `h1` | `Text` |
|
||||
| `input` | `TextInput` |
|
||||
| `onClick` | `onPress` (via `Pressable` or `TouchableOpacity`) |
|
||||
| `overflow: auto` | `ScrollView` or `FlatList` |
|
||||
| `cursor: pointer` | Not needed |
|
||||
| `border: 1px solid` | `borderWidth: 1, borderColor:` |
|
||||
| `fontFamily: 'DM Sans'` | Loaded via `expo-font` |
|
||||
| `gap` | Use `marginBottom` on children (gap not fully supported) |
|
||||
| `overflowX: auto` with scrollbar hidden | `ScrollView horizontal showsHorizontalScrollIndicator={false}` |
|
||||
|
||||
### Fonts Loading (Expo)
|
||||
```typescript
|
||||
import { useFonts } from 'expo-font';
|
||||
import { PlayfairDisplay_700Bold } from '@expo-google-fonts/playfair-display';
|
||||
import { DMSans_400Regular, DMSans_500Medium, DMSans_600SemiBold } from '@expo-google-fonts/dm-sans';
|
||||
import { JetBrainsMono_400Regular, JetBrainsMono_500Medium, JetBrainsMono_700Bold } from '@expo-google-fonts/jetbrains-mono';
|
||||
```
|
||||
316
dancechamp-claude-code/docs/PLAN.md
Normal file
316
dancechamp-claude-code/docs/PLAN.md
Normal file
@@ -0,0 +1,316 @@
|
||||
# DanceChamp — Vibe Coding Plan
|
||||
|
||||
## How to use this plan
|
||||
- Work phase by phase, top to bottom
|
||||
- Check off tasks as you go: `[ ]` → `[x]`
|
||||
- Each phase has a **"Done when"** — don't move on until it's met
|
||||
- 🔴 = blocker (must do), 🟡 = important, 🟢 = nice to have for MVP
|
||||
- Estimated time is for vibe coding with AI (Claude Code / Cursor)
|
||||
|
||||
---
|
||||
|
||||
## Phase 0: Project Setup
|
||||
**Time: ~1 hour**
|
||||
|
||||
- [ ] 🔴 Init Expo project (React Native): `npx create-expo-app DanceChamp --template blank-typescript`
|
||||
- [ ] 🔴 Init Web admin panel: `npm create vite@latest admin-panel -- --template react-ts`
|
||||
- [ ] 🔴 Setup Supabase project (or Firebase): create account, new project
|
||||
- [ ] 🔴 Setup database tables (see Phase 1)
|
||||
- [ ] 🔴 Install core deps: `react-navigation`, `zustand`, `supabase-js`
|
||||
- [ ] 🟡 Setup Git repo + `.gitignore`
|
||||
- [ ] 🟡 Create `/apps/mobile`, `/apps/admin`, `/packages/shared` monorepo structure
|
||||
- [ ] 🟢 Add ESLint + Prettier
|
||||
|
||||
**Done when:** Both apps run locally, Supabase dashboard is accessible
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Database & Auth
|
||||
**Time: ~2-3 hours**
|
||||
|
||||
### 1.1 Database Tables
|
||||
- [ ] 🔴 `users` — id, email, name, role (admin | organization | member), city, instagram_handle, experience_years, disciplines[], auth_provider, status, created_at
|
||||
- [ ] 🔴 `organizations` — id, user_id (FK), name, instagram_handle, email, city, logo_url, verified (bool), status (active | pending | blocked), block_reason, created_at
|
||||
- [ ] 🔴 `championships` — id, org_id (FK), name, subtitle, event_date, reg_start, reg_end, location, venue, status (draft | pending_approval | live | completed | blocked), accent_color, created_at
|
||||
- [ ] 🔴 `disciplines` — id, championship_id (FK), name, levels[], styles[]
|
||||
- [ ] 🔴 `fees` — id, championship_id (FK), video_selection, solo, duet, group, refund_note
|
||||
- [ ] 🔴 `rules` — id, championship_id (FK), section (general | costume | scoring | penalty), text, value (for penalties)
|
||||
- [ ] 🔴 `judges` — id, championship_id (FK), name, instagram, bio, photo_url
|
||||
- [ ] 🔴 `registrations` — id, user_id (FK), championship_id (FK), discipline_id, level, style, type (solo | duet | group), current_step, video_url, fee_paid, receipt_uploaded, insurance_uploaded, passed (null | true | false), created_at
|
||||
- [ ] 🔴 `notifications` — id, user_id (FK), championship_id, type, title, message, read (bool), created_at
|
||||
- [ ] 🟡 `activity_logs` — id, actor_id, action, target_type, target_id, details, created_at
|
||||
|
||||
### 1.2 Auth
|
||||
- [ ] 🔴 Supabase Auth: enable Email + Google OAuth
|
||||
- [ ] 🔴 Role-based access: Row Level Security (RLS) policies
|
||||
- Members see only their own registrations
|
||||
- Orgs see only their own championships & members
|
||||
- Admin sees everything
|
||||
- [ ] 🔴 Sign up / Sign in screens (mobile)
|
||||
- [ ] 🔴 Admin login (web panel)
|
||||
- [ ] 🟡 Instagram OAuth (for member profiles)
|
||||
- [ ] 🟡 Onboarding flow: name → city → discipline → experience → done
|
||||
|
||||
**Done when:** Can sign up as member, org, and admin. RLS blocks cross-access.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Member App — Core Screens
|
||||
**Time: ~4-5 hours**
|
||||
|
||||
### 2.1 Navigation
|
||||
- [ ] 🔴 Bottom tab nav: Home, My Champs, Search, Profile
|
||||
- [ ] 🔴 Stack navigation: screens → detail → sub-screens
|
||||
|
||||
### 2.2 Home Screen
|
||||
- [ ] 🔴 "Upcoming championships" feed — cards with name, date, location, status badge
|
||||
- [ ] 🔴 "My active registrations" section with progress bars
|
||||
- [ ] 🟡 Bell icon → notifications feed
|
||||
- [ ] 🟡 Deadline urgency banners ("Registration closes in 3 days!")
|
||||
|
||||
### 2.3 Championship Detail
|
||||
- [ ] 🔴 Header: name, dates, location, venue, registration period
|
||||
- [ ] 🔴 Tab: Overview (info + registration funnel)
|
||||
- [ ] 🔴 Tab: Categories (disciplines, levels, styles + eligibility)
|
||||
- [ ] 🔴 Tab: Rules (general, costume, scoring criteria, penalties)
|
||||
- [ ] 🔴 Tab: Fees (video selection + championship fees)
|
||||
- [ ] 🔴 Tab: Judges (judge profiles with photo, instagram, bio)
|
||||
- [ ] 🔴 "Register" button → starts onboarding
|
||||
|
||||
### 2.4 Search & Discover
|
||||
- [ ] 🔴 Search by championship name
|
||||
- [ ] 🔴 Filter by: discipline, location, status (open/upcoming/past)
|
||||
- [ ] 🟡 Sort by: date, popularity
|
||||
|
||||
### 2.5 Profile
|
||||
- [ ] 🔴 View/edit: name, city, instagram, disciplines, experience
|
||||
- [ ] 🔴 "My Championships" list (past + active)
|
||||
- [ ] 🟢 Competition history
|
||||
|
||||
**Done when:** Can browse championships, view full details across all tabs, search/filter, see profile.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Member App — Registration & Progress Tracker
|
||||
**Time: ~4-5 hours**
|
||||
|
||||
### 3.1 Registration Flow
|
||||
- [ ] 🔴 Choose discipline → level → style → solo/duet/group
|
||||
- [ ] 🔴 Create `registration` record in DB
|
||||
- [ ] 🔴 Show 10-step progress checklist
|
||||
|
||||
### 3.2 Progress Steps (per championship)
|
||||
- [ ] 🔴 Step 1: Review rules — mark done when user opens Rules tab
|
||||
- [ ] 🔴 Step 2: Select category — saved from registration
|
||||
- [ ] 🔴 Step 3: Record video — manual toggle ("I've recorded my video")
|
||||
- [ ] 🔴 Step 4: Submit video form — manual toggle or link to Google Form
|
||||
- [ ] 🔴 Step 5: Pay video fee — upload receipt screenshot
|
||||
- [ ] 🔴 Step 6: Wait for results — shows "pending" until org decides
|
||||
- [ ] 🔴 Step 7: Results — auto-updates when org passes/fails
|
||||
- [ ] 🔴 Step 8: Pay championship fee — upload receipt (only if passed)
|
||||
- [ ] 🔴 Step 9: Submit "About Me" — manual toggle or link
|
||||
- [ ] 🔴 Step 10: Confirm insurance — upload document
|
||||
|
||||
### 3.3 Receipt & Document Upload
|
||||
- [ ] 🔴 Camera / gallery picker for receipt photos
|
||||
- [ ] 🔴 Upload to Supabase Storage
|
||||
- [ ] 🔴 Show upload status (pending org confirmation)
|
||||
|
||||
### 3.4 Notifications
|
||||
- [ ] 🔴 In-app notification feed (bell icon + unread count)
|
||||
- [ ] 🔴 Notification types: category changed, payment confirmed, results, deadline reminder, announcement
|
||||
- [ ] 🟡 Push notifications via Expo Notifications
|
||||
- [ ] 🟢 Notification preferences (toggle on/off)
|
||||
|
||||
**Done when:** Can register for a championship, track all 10 steps, upload receipts, receive notifications.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Org App — Dashboard & Championship Management
|
||||
**Time: ~5-6 hours**
|
||||
|
||||
### 4.1 Org Dashboard
|
||||
- [ ] 🔴 Championship cards: name, dates, status badge, member count, progress bar (if draft)
|
||||
- [ ] 🔴 "+" button → Quick Create (name, date, location → creates draft)
|
||||
- [ ] 🔴 Tap card → championship detail
|
||||
|
||||
### 4.2 Championship Detail (tabbed, configurable)
|
||||
- [ ] 🔴 Overview tab: setup progress checklist, event info (editable), stats (if live)
|
||||
- [ ] 🔴 Categories tab: add/remove levels, add/remove styles → "Mark as Done"
|
||||
- [ ] 🔴 Fees tab: video selection + solo/duet/group fees → "Mark as Done"
|
||||
- [ ] 🔴 Rules tab: general rules + costume rules + scoring criteria + penalties → "Mark as Done"
|
||||
- [ ] 🔴 Judges tab: add judge profiles (name, instagram, bio) → "Mark as Done"
|
||||
- [ ] 🔴 "Go Live" button — appears when all sections are done
|
||||
- [ ] 🔴 If org is verified → status = `live` (auto-approved)
|
||||
- [ ] 🔴 If org is unverified → status = `pending_approval` (needs admin)
|
||||
|
||||
### 4.3 Members Tab (only for live championships)
|
||||
- [ ] 🔴 Member list with search + filters (All, Receipts, Videos, Passed)
|
||||
- [ ] 🔴 Member card: name, instagram, level, style, status badge, progress
|
||||
- [ ] 🔴 Tap member → member detail
|
||||
|
||||
### 4.4 Member Detail
|
||||
- [ ] 🔴 Profile info, registration details
|
||||
- [ ] 🔴 Edit level (picker + "member will be notified" warning)
|
||||
- [ ] 🔴 Edit style (picker + notification)
|
||||
- [ ] 🔴 Video section: view link + Pass/Fail buttons
|
||||
- [ ] 🔴 Payment section: view receipt + Confirm button
|
||||
- [ ] 🔴 "Send Notification" button
|
||||
|
||||
### 4.5 Results Tab
|
||||
- [ ] 🔴 Pending review list with Pass/Fail buttons per member
|
||||
- [ ] 🔴 Decided list (passed/failed)
|
||||
- [ ] 🔴 "Publish Results" button → notifies all members
|
||||
|
||||
### 4.6 Org Settings
|
||||
- [ ] 🔴 Edit org profile (name, instagram)
|
||||
- [ ] 🔴 Notification preferences (toggles)
|
||||
- [ ] 🟡 Connected accounts (Instagram, Gmail, Telegram)
|
||||
|
||||
**Done when:** Org can create championship, configure all tabs, go live, manage members, pass/fail videos, publish results.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Admin Panel (Web)
|
||||
**Time: ~3-4 hours**
|
||||
|
||||
### 5.1 Dashboard
|
||||
- [ ] 🔴 Platform stats: orgs count, live champs, total users
|
||||
- [ ] 🔴 "Needs Attention" section: pending orgs, pending champs (from unverified orgs)
|
||||
- [ ] 🔴 Platform health: revenue, blocked users
|
||||
- [ ] 🔴 Recent activity log
|
||||
|
||||
### 5.2 Organizations Management
|
||||
- [ ] 🔴 List with search + filters (Active, Pending, Blocked)
|
||||
- [ ] 🔴 Org detail: profile, championships list, approval policy
|
||||
- [ ] 🔴 Actions: Approve / Reject, Block / Unblock, Verify, Delete
|
||||
|
||||
### 5.3 Championships Management
|
||||
- [ ] 🔴 List with search + filters (Live, Awaiting Review, Draft, Blocked)
|
||||
- [ ] 🔴 Champ detail: stats, approval policy indicator
|
||||
- [ ] 🔴 Actions: Approve / Reject (for unverified orgs), Suspend, Reinstate, Delete
|
||||
|
||||
### 5.4 Users Management
|
||||
- [ ] 🔴 List with search + filters (Active, Warned, Blocked, Org Admins)
|
||||
- [ ] 🔴 User detail: profile, role, championships joined
|
||||
- [ ] 🔴 Actions: Warn, Block / Unblock, Delete
|
||||
|
||||
**Done when:** Admin can approve/reject orgs, review championships from unverified orgs, manage users.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Connecting It All
|
||||
**Time: ~3-4 hours**
|
||||
|
||||
### 6.1 Real-Time Sync
|
||||
- [ ] 🔴 Supabase Realtime: members see status updates instantly (pass/fail, payment confirmed)
|
||||
- [ ] 🔴 Org dashboard updates when new member registers
|
||||
- [ ] 🟡 Admin panel live counters
|
||||
|
||||
### 6.2 Notification Triggers
|
||||
- [ ] 🔴 Org passes/fails video → member gets notification
|
||||
- [ ] 🔴 Org confirms receipt → member gets notification
|
||||
- [ ] 🔴 Org changes member's level/style → member gets notification
|
||||
- [ ] 🔴 Org publishes results → all members get notification
|
||||
- [ ] 🟡 Auto deadline reminders (cron job: 7 days, 3 days, 1 day before)
|
||||
|
||||
### 6.3 Approval Flow
|
||||
- [ ] 🔴 Unverified org clicks "Go Live" → status = pending_approval
|
||||
- [ ] 🔴 Admin sees it in "Needs Attention"
|
||||
- [ ] 🔴 Admin approves → status = live, org gets notification
|
||||
- [ ] 🔴 Admin rejects → status = blocked, org gets notification with reason
|
||||
|
||||
### 6.4 File Uploads
|
||||
- [ ] 🔴 Receipt photo upload → Supabase Storage → org sees thumbnail in member detail
|
||||
- [ ] 🔴 Insurance doc upload → same flow
|
||||
- [ ] 🟢 Judge profile photos
|
||||
|
||||
**Done when:** All three apps talk to the same DB. Actions in one app reflect in others in real time.
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: Polish & UX
|
||||
**Time: ~2-3 hours**
|
||||
|
||||
- [ ] 🟡 Loading states (skeletons, spinners)
|
||||
- [ ] 🟡 Empty states ("No championships yet", "No members match")
|
||||
- [ ] 🟡 Error handling (network errors, failed uploads, toast messages)
|
||||
- [ ] 🟡 Pull-to-refresh on lists
|
||||
- [ ] 🟡 Haptic feedback on key actions (pass/fail, payment confirm)
|
||||
- [ ] 🟡 Dark theme consistency across all screens
|
||||
- [ ] 🟡 Animations: tab transitions, card press, progress bar fill
|
||||
- [ ] 🟢 Onboarding walkthrough (first-time user tips)
|
||||
- [ ] 🟢 Swipe gestures on member cards (swipe right = pass, left = fail)
|
||||
|
||||
**Done when:** App feels smooth and professional. No raw errors shown to users.
|
||||
|
||||
---
|
||||
|
||||
## Phase 8: Integrations (Post-MVP)
|
||||
**Time: varies**
|
||||
|
||||
### 8.1 Instagram Parsing
|
||||
- [ ] 🟢 Apify Instagram scraper setup
|
||||
- [ ] 🟢 Claude API: extract structured data from post captions
|
||||
- [ ] 🟢 OCR (Google Vision): parse results from photo posts
|
||||
- [ ] 🟢 "Import from Instagram" button in org's championship creation
|
||||
|
||||
### 8.2 Gmail Integration
|
||||
- [ ] 🟢 Google OAuth for members
|
||||
- [ ] 🟢 Detect Google Forms confirmation emails → auto-mark steps
|
||||
|
||||
### 8.3 Payments
|
||||
- [ ] 🟢 In-app payment tracking
|
||||
- [ ] 🟢 Replace receipt uploads with direct payment
|
||||
|
||||
### 8.4 Multi-Language
|
||||
- [ ] 🟢 i18n setup (Russian + English)
|
||||
|
||||
---
|
||||
|
||||
## Phase 9: Testing & Launch
|
||||
**Time: ~2-3 hours**
|
||||
|
||||
- [ ] 🔴 Test full flow: member registers → org reviews → admin monitors
|
||||
- [ ] 🔴 Test approval flow: unverified org → pending → admin approves → live
|
||||
- [ ] 🔴 Test notifications: level change, payment confirm, results
|
||||
- [ ] 🔴 Test on real device (iOS + Android via Expo Go)
|
||||
- [ ] 🟡 Test edge cases: empty championships, blocked orgs, duplicate registrations
|
||||
- [ ] 🟡 Performance check: list with 50+ members, 10+ championships
|
||||
- [ ] 🟡 Expo build: `eas build` for iOS/Android
|
||||
- [ ] 🟢 TestFlight / Google Play internal testing
|
||||
- [ ] 🟢 Admin panel deploy (Vercel / Netlify)
|
||||
|
||||
**Done when:** All three roles can complete their full flow without bugs.
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference: What Goes Where
|
||||
|
||||
| Feature | Member App | Org App | Admin Panel |
|
||||
|---|:---:|:---:|:---:|
|
||||
| Browse championships | ✅ | — | ✅ (view all) |
|
||||
| Register for championship | ✅ | — | — |
|
||||
| Progress tracker | ✅ | — | — |
|
||||
| Create/edit championship | — | ✅ | ✅ (edit/delete) |
|
||||
| Review members | — | ✅ | ✅ (view) |
|
||||
| Pass/Fail videos | — | ✅ | — |
|
||||
| Confirm payments | — | ✅ | — |
|
||||
| Approve orgs & champs | — | — | ✅ |
|
||||
| Block/warn users | — | — | ✅ |
|
||||
| Notifications | ✅ (receive) | ✅ (send) | — |
|
||||
|
||||
---
|
||||
|
||||
## Priority Order (if short on time)
|
||||
|
||||
If you need to ship fast, do these phases in order and stop when you have enough:
|
||||
|
||||
1. **Phase 0 + 1** — Foundation (can't skip)
|
||||
2. **Phase 2** — Member app core (users need to see something)
|
||||
3. **Phase 4** — Org app (orgs need to create championships)
|
||||
4. **Phase 3** — Registration flow (connects member ↔ org)
|
||||
5. **Phase 6** — Wire it together
|
||||
6. **Phase 5** — Admin panel (can manage via Supabase dashboard temporarily)
|
||||
7. **Phase 7** — Polish
|
||||
8. **Phase 8** — Integrations (post-launch)
|
||||
371
dancechamp-claude-code/docs/SCREENS.md
Normal file
371
dancechamp-claude-code/docs/SCREENS.md
Normal file
@@ -0,0 +1,371 @@
|
||||
# DanceChamp — Screen Reference
|
||||
|
||||
## Member App Screens
|
||||
|
||||
### Navigation: Bottom Tabs
|
||||
`Home` | `My Champs` | `Search` | `Profile`
|
||||
|
||||
---
|
||||
|
||||
### M1: Home
|
||||
**Purpose:** Dashboard for the dancer
|
||||
|
||||
**Elements:**
|
||||
- Header: "DanceChamp" title + bell icon (🔔) with unread count badge
|
||||
- "Your Active" section: cards for championships they're registered in, showing progress bar (e.g. "Step 5/10")
|
||||
- "Upcoming Championships" section: browseable cards for live championships
|
||||
- Each card: championship name, org name, dates, location, status badge, accent color bar
|
||||
|
||||
**Data:** `championships` (status = 'live') + `registrations` (for current user)
|
||||
|
||||
**Navigation:**
|
||||
- Tap card → M5 (Championship Detail)
|
||||
- Tap bell → M7 (Notifications)
|
||||
|
||||
---
|
||||
|
||||
### M2: My Champs
|
||||
**Purpose:** All championships user is registered for
|
||||
|
||||
**Elements:**
|
||||
- Tabs: Active | Past
|
||||
- Championship cards with progress indicator
|
||||
- Status per card: "Step 3/10 — Pay video fee" or "✅ Registered" or "❌ Failed"
|
||||
|
||||
**Data:** `registrations` joined with `championships`
|
||||
|
||||
**Navigation:**
|
||||
- Tap card → M6 (Progress Tracker)
|
||||
|
||||
---
|
||||
|
||||
### M3: Search
|
||||
**Purpose:** Discover championships
|
||||
|
||||
**Elements:**
|
||||
- Search bar (text input)
|
||||
- Filter chips: All, Pole Exotic, Pole Art, Registration Open, Upcoming
|
||||
- Championship result cards
|
||||
|
||||
**Data:** `championships` + `organizations` (for org name)
|
||||
|
||||
**Navigation:**
|
||||
- Tap card → M5 (Championship Detail)
|
||||
|
||||
---
|
||||
|
||||
### M4: Profile
|
||||
**Purpose:** User profile and settings
|
||||
|
||||
**Elements:**
|
||||
- Avatar, name, instagram handle, city
|
||||
- Dance profile: disciplines, experience years
|
||||
- "My Championships" summary (X active, Y completed)
|
||||
- Settings list: Edit Profile, Notification Preferences, Connected Accounts, Help, Log Out
|
||||
|
||||
**Data:** `users` (current user)
|
||||
|
||||
---
|
||||
|
||||
### M5: Championship Detail
|
||||
**Purpose:** Full championship info — 5 tabs
|
||||
|
||||
**Header:** Championship name, org name, back button
|
||||
**Tabs:** Overview | Categories | Rules | Fees | Judges
|
||||
|
||||
#### Tab: Overview
|
||||
- Event info: date, location, venue, registration period (start → end)
|
||||
- Stats: members registered, spots left (if limited)
|
||||
- "Register" button (if registration open and user not registered)
|
||||
- If already registered: shows current progress step
|
||||
|
||||
#### Tab: Categories
|
||||
- Disciplines list, each with levels
|
||||
- Styles list
|
||||
- If registered: user's selected level/style highlighted
|
||||
|
||||
#### Tab: Rules
|
||||
- General rules (expandable list)
|
||||
- Costume rules
|
||||
- Scoring criteria: name + max score (0–10)
|
||||
- Penalties: name + deduction / DQ
|
||||
|
||||
#### Tab: Fees
|
||||
- Video selection fee
|
||||
- Championship fees: solo, duet, group
|
||||
- Refund note
|
||||
|
||||
#### Tab: Judges
|
||||
- Judge profile cards: photo/emoji, name, instagram link, bio
|
||||
|
||||
**Data:** Full championship with all related tables
|
||||
|
||||
**Navigation:**
|
||||
- "Register" → M6 (starts registration flow, then shows Progress Tracker)
|
||||
|
||||
---
|
||||
|
||||
### M6: Progress Tracker
|
||||
**Purpose:** 10-step registration checklist for a specific championship
|
||||
|
||||
**Header:** Championship name, back button
|
||||
|
||||
**Elements:**
|
||||
- Vertical step list (1–10)
|
||||
- Each step: number, icon, title, status (locked/available/done/failed)
|
||||
- Current step expanded with action area:
|
||||
- Step 3 "Record Video": toggle "I've recorded my video"
|
||||
- Step 5 "Pay Video Fee": upload receipt button, status after upload
|
||||
- Step 7 "Results": shows PASS ✅ / FAIL ❌ / ⏳ Pending
|
||||
- Step 10 "Insurance": upload document button
|
||||
- Progress bar at top: X/10 completed
|
||||
|
||||
**Data:** `registrations` (single record for this user + championship)
|
||||
|
||||
**Actions:** Update step fields in `registrations` table
|
||||
|
||||
---
|
||||
|
||||
### M7: Notifications
|
||||
**Purpose:** In-app notification feed
|
||||
|
||||
**Header:** "Notifications" + "Read all" button
|
||||
|
||||
**Elements:**
|
||||
- Notification cards: icon, type badge, championship name, message, time ago
|
||||
- Unread: colored left border + accent background tint + dot indicator
|
||||
- Tap: marks as read
|
||||
|
||||
**Types:** 🔄 Category Changed, ✅ Payment Confirmed, 🏆 Results, ⏰ Deadline, 📋 Registered, 📢 Announcement
|
||||
|
||||
**Data:** `notifications` (for current user, ordered by created_at desc)
|
||||
|
||||
---
|
||||
|
||||
## Org App Screens
|
||||
|
||||
### Navigation: Bottom Tabs
|
||||
`Dashboard` | `Settings`
|
||||
|
||||
---
|
||||
|
||||
### O1: Dashboard
|
||||
**Purpose:** Overview of all org's championships
|
||||
|
||||
**Elements:**
|
||||
- Header: "Dashboard" + org name + org logo
|
||||
- "New Championship" card (accent gradient, "+" icon)
|
||||
- Championship cards: name, dates, location, status badge (LIVE / SETUP 3/5 / AWAITING REVIEW)
|
||||
- For drafts: readiness bar + section checklist (info ✓, categories ○, fees ○, etc.)
|
||||
- For live: mini stats (Members, Passed, Pending)
|
||||
|
||||
**Data:** `championships` (where org_id = current org) + `registrations` (counts)
|
||||
|
||||
**Navigation:**
|
||||
- Tap "New Championship" → O2 (Quick Create)
|
||||
- Tap championship card → O3 (Championship Detail)
|
||||
|
||||
---
|
||||
|
||||
### O2: Quick Create
|
||||
**Purpose:** Minimal form to create a draft championship
|
||||
|
||||
**Elements:**
|
||||
- Header: "New Championship" + back button
|
||||
- 3 inputs: Championship Name, Event Date, Location
|
||||
- Info card: "Your championship will be created as a draft. Configure details later."
|
||||
- "✨ Create Draft" button (disabled until name filled)
|
||||
|
||||
**Action:** Creates championship with status = 'draft', navigates to O3
|
||||
|
||||
---
|
||||
|
||||
### O3: Championship Detail (Tabbed)
|
||||
**Purpose:** Configure and manage a championship
|
||||
|
||||
**Header:** Championship name, subtitle, back button, "🚀 Go Live" (if all config done)
|
||||
|
||||
**Tabs (with config status dots):**
|
||||
`Overview` | `Categories` (🟢/🟡) | `Fees` (🟢/🟡) | `Rules` (🟢/🟡) | `Judges` (🟢/🟡)
|
||||
|
||||
For live championships, add: | `Members (N)` | `Results`
|
||||
|
||||
#### Tab: Overview
|
||||
- **If draft:** Setup Progress checklist (5 items with checkmarks, tap incomplete → jumps to tab)
|
||||
- **If all done:** "🚀 Open Registration" button (or "Go Live")
|
||||
- Event Info card: inline edit (✎ Edit / ✕ Close toggle)
|
||||
- Editable fields: name, subtitle, event date, location, venue
|
||||
- Registration period: Opens (date) + Closes (date), side by side
|
||||
- Warning: "⚠️ Registration close date must be before event date"
|
||||
- **If live:** Stats boxes (Members, Passed, Failed, Pending) + "⚡ Needs Action" (receipts to review, videos to review)
|
||||
|
||||
#### Tab: Categories
|
||||
- Levels: tag editor (add/remove levels)
|
||||
- Styles: tag editor (add/remove styles)
|
||||
- "✓ Mark Categories as Done" button (shown when at least 1 level + 1 style)
|
||||
|
||||
#### Tab: Fees
|
||||
- Video Selection Fee input
|
||||
- Championship Fees: Solo, Duet (pp), Group (pp) inputs
|
||||
- "✓ Mark Fees as Done" button (shown when video fee filled)
|
||||
|
||||
#### Tab: Rules
|
||||
- General Rules: tag editor
|
||||
- Costume Rules: tag editor
|
||||
- Scoring Criteria (0–10): list + tag editor to add new
|
||||
- Penalties: list with colored values (-2 yellow, DQ red) + tag editor
|
||||
- "✓ Mark Rules as Done" button (shown when at least 1 rule)
|
||||
|
||||
#### Tab: Judges
|
||||
- Judge profile cards: emoji avatar, name, instagram, bio, × to remove
|
||||
- "Add Judge" form: name, instagram, bio inputs + "+ Add Judge" button
|
||||
- "✓ Mark Judges as Done" button (shown when at least 1 judge)
|
||||
|
||||
#### Tab: Members (live only)
|
||||
- Search bar
|
||||
- Filter chips: All (N), 📸 Receipts (N), 🎬 Videos (N), ✅ Passed (N)
|
||||
- Member cards: name, instagram, level, style, city tags, status badge
|
||||
- Tap member → O4 (Member Detail)
|
||||
|
||||
#### Tab: Results (live only)
|
||||
- Stat boxes: Pending, Passed, Failed
|
||||
- Pending review cards: member name/level + "🎥 View" + Pass/Fail buttons
|
||||
- Decided list: member name + badge
|
||||
- "📢 Publish Results" button
|
||||
|
||||
---
|
||||
|
||||
### O4: Member Detail
|
||||
**Purpose:** View/edit a single member's registration
|
||||
|
||||
**Header:** Member name, championship + instagram, back button
|
||||
|
||||
**Elements:**
|
||||
- Profile card: avatar, name, instagram, city, status badge
|
||||
- Registration section:
|
||||
- Discipline (read-only)
|
||||
- Type: solo/duet/group (read-only)
|
||||
- **Level:** value + "✎ Edit" button → expands picker with ⚠️ "Member will be notified"
|
||||
- **Style:** value + "✎ Edit" button → expands picker with ⚠️ warning
|
||||
- Video section: link display + Pass/Fail buttons (if pending) or status badge
|
||||
- Payment section: fee amount, receipt status, "📸 Confirm" button (if receipt uploaded)
|
||||
- "🔔 Send Notification" button
|
||||
|
||||
**Actions:** Update `registrations` fields + create `notifications` record
|
||||
|
||||
---
|
||||
|
||||
### O5: Org Settings
|
||||
**Purpose:** Organization profile and preferences
|
||||
|
||||
**Elements:**
|
||||
- Org profile: logo, name, instagram (editable inline when "Edit Organization Profile" tapped)
|
||||
- Menu items:
|
||||
- ✎ Edit Organization Profile → inline form (name + instagram) replaces menu
|
||||
- 🔔 Notification Preferences → sub-screen with toggle switches
|
||||
- 🔗 Connected Accounts → sub-screen (Instagram ✅, Gmail ✅, Telegram ❌)
|
||||
- ❓ Help & Support
|
||||
- 🚪 Log Out (red text)
|
||||
|
||||
---
|
||||
|
||||
## Admin Panel Screens (Web)
|
||||
|
||||
### Navigation: Bottom Tabs (mobile-style for prototype, sidebar for production)
|
||||
`Overview` | `Orgs` | `Champs` | `Users`
|
||||
|
||||
---
|
||||
|
||||
### A1: Overview (Dashboard)
|
||||
**Purpose:** Platform health at a glance
|
||||
|
||||
**Elements:**
|
||||
- Header: "Admin Panel" + platform name + version
|
||||
- Stat boxes: Active Orgs, Live Champs, Total Users
|
||||
- "⚡ Needs Attention" card (yellow tint):
|
||||
- 🏢 Organizations awaiting approval (count) → tap goes to A2
|
||||
- 🏆 Champs awaiting approval from unverified orgs (count) → tap goes to A4
|
||||
- Platform Health table: total revenue, active/total orgs, blocked users, avg members/champ
|
||||
- Recent Activity log: action + target (colored by type) + date + actor
|
||||
|
||||
---
|
||||
|
||||
### A2: Organizations List
|
||||
**Purpose:** All organizations on the platform
|
||||
|
||||
**Elements:**
|
||||
- Search bar
|
||||
- Filter chips: All (N), ✅ Active (N), ⏳ Pending (N), 🚫 Blocked (N)
|
||||
- Org cards: logo, name, instagram, status badge, city, champs count, members count
|
||||
- Tap → A3 (Org Detail)
|
||||
|
||||
---
|
||||
|
||||
### A3: Organization Detail
|
||||
**Purpose:** Review and manage a single org
|
||||
|
||||
**Elements:**
|
||||
- Profile card: logo, name, instagram, email, city, status badge
|
||||
- Details table: Joined, Championships, Total members, Verified (✅/❌)
|
||||
- Approval Policy card:
|
||||
- Verified: "🛡️ Verified — Auto-approve events" (green tint)
|
||||
- Unverified: "⏳ Unverified — Events need manual approval" (yellow tint)
|
||||
- Championships list (belonging to this org)
|
||||
- Block reason card (if blocked, red tint)
|
||||
- Actions:
|
||||
- Pending: [Approve ✅] [Reject ❌]
|
||||
- Active: [Block 🚫] + [Verify 🛡️] (if not verified)
|
||||
- Blocked: [Unblock ✅]
|
||||
- Always: [Delete 🗑️]
|
||||
|
||||
---
|
||||
|
||||
### A4: Championships List
|
||||
**Purpose:** All championships across all orgs
|
||||
|
||||
**Elements:**
|
||||
- Search bar
|
||||
- Filter chips: All (N), 🟢 Live (N), ⏳ Awaiting (N), 📝 Draft (N), 🚫 Blocked (N)
|
||||
- Champ cards: name, "by Org Name 🛡️" (shield if verified), status badge, dates, location, members count
|
||||
- Tap → A5 (Champ Detail)
|
||||
|
||||
---
|
||||
|
||||
### A5: Championship Detail
|
||||
**Purpose:** Review and manage a single championship
|
||||
|
||||
**Elements:**
|
||||
- Profile card: trophy emoji, name, org name, dates, location, status badge
|
||||
- Stats table: Members, Passed, Pending, Revenue
|
||||
- Approval Policy card: explains why auto-approved or needs review
|
||||
- Actions:
|
||||
- Pending approval: [Approve ✅] [Reject ❌]
|
||||
- Live: [Suspend ⏸️]
|
||||
- Blocked: [Reinstate ✅]
|
||||
- Always: [Delete 🗑️]
|
||||
|
||||
---
|
||||
|
||||
### A6: Users List
|
||||
**Purpose:** All platform users
|
||||
|
||||
**Elements:**
|
||||
- Search bar (name, @handle, email)
|
||||
- Filter chips: All (N), ✅ Active (N), ⚠️ Warned (N), 🚫 Blocked (N), 🏢 Org Admins (N)
|
||||
- User cards: avatar (👤 or 🏢 for org admins), name, instagram, city, status badge
|
||||
- Tap → A7 (User Detail)
|
||||
|
||||
---
|
||||
|
||||
### A7: User Detail
|
||||
**Purpose:** Review and manage a single user
|
||||
|
||||
**Elements:**
|
||||
- Profile card: avatar, name, instagram, email, status badge
|
||||
- Info table: City, Joined, Championships joined, Role (Member or Org Admin + org name)
|
||||
- Warning/Block reason card (if applicable, orange or red tint)
|
||||
- Actions:
|
||||
- Active: [Warn ⚠️] [Block 🚫]
|
||||
- Warned: [Remove Warning ✅] [Block 🚫]
|
||||
- Blocked: [Unblock ✅]
|
||||
- Always: [Delete 🗑️]
|
||||
267
dancechamp-claude-code/docs/SPEC.md
Normal file
267
dancechamp-claude-code/docs/SPEC.md
Normal file
@@ -0,0 +1,267 @@
|
||||
# DanceChamp — Technical Specification
|
||||
|
||||
## 1. Overview
|
||||
|
||||
A mobile platform for pole dance championships. Dancers discover events and track registration. Organizers create and manage championships. Platform admin oversees everything.
|
||||
|
||||
### The Problem
|
||||
Championship info is scattered across Instagram posts, Telegram chats, and Google Docs. Dancers miss deadlines, lose track of multi-step registration, and can't verify submissions went through. Organizers manually manage everything via spreadsheets and DMs.
|
||||
|
||||
### The Solution
|
||||
One app with three roles:
|
||||
- **Members** browse championships, register, track 10-step progress
|
||||
- **Organizations** create championships, configure rules/fees/categories, review videos, confirm payments
|
||||
- **Admin** approves organizations, reviews championships from unverified orgs, manages users
|
||||
|
||||
### Real-World Reference: "Zero Gravity"
|
||||
International Pole Exotic Championship, Minsk, Belarus. Two disciplines (Exotic Pole Dance + Pole Art), 6+ levels per discipline, two-stage payment (video selection fee + championship fee), video selection with pass/fail, detailed judging criteria (6 categories, 0–10), strict costume/equipment rules, insurance required.
|
||||
|
||||
---
|
||||
|
||||
## 2. Roles & Permissions
|
||||
|
||||
### Member (Dancer)
|
||||
**Access:** Mobile app (member view)
|
||||
- Browse & search championships
|
||||
- View full details (rules, fees, categories, judges)
|
||||
- Register for championships
|
||||
- Track 10-step progress per championship
|
||||
- Upload receipts & documents
|
||||
- Receive notifications (results, deadline reminders, announcements)
|
||||
- View past participation history
|
||||
|
||||
**Cannot:** Create/edit championships, see other members' data, access org/admin features
|
||||
|
||||
### Organization
|
||||
**Access:** Mobile app (org view)
|
||||
- Create championships (quick create → configure tabs)
|
||||
- Manage disciplines, levels, styles, fees, rules, scoring, judges
|
||||
- View & manage registered members per championship
|
||||
- Review videos (pass/fail)
|
||||
- Confirm receipt payments
|
||||
- Edit member's level/style (triggers notification)
|
||||
- Publish results
|
||||
- Send announcements
|
||||
|
||||
**Cannot:** See other orgs' data, access admin features
|
||||
|
||||
### Admin
|
||||
**Access:** Web admin panel
|
||||
- View all orgs, championships, users
|
||||
- Approve/reject pending organizations
|
||||
- Approve/reject championships from unverified orgs
|
||||
- Block/unblock orgs and users
|
||||
- Warn users
|
||||
- Verify organizations (changes approval policy)
|
||||
- Delete any entity
|
||||
- View platform stats and activity logs
|
||||
|
||||
---
|
||||
|
||||
## 3. Championship Lifecycle
|
||||
|
||||
```
|
||||
[Org: Quick Create]
|
||||
name + date + location → status: DRAFT
|
||||
│
|
||||
▼
|
||||
[Org: Configure Tabs]
|
||||
Categories ✓ → Fees ✓ → Rules ✓ → Judges ✓
|
||||
(any order, mark each as done)
|
||||
│
|
||||
▼
|
||||
[Org: "Go Live"]
|
||||
│
|
||||
├── Org is VERIFIED (🛡️)
|
||||
│ → status: LIVE (auto-approved)
|
||||
│ → visible to members immediately
|
||||
│
|
||||
└── Org is UNVERIFIED
|
||||
→ status: PENDING_APPROVAL
|
||||
→ admin sees in "Needs Attention"
|
||||
→ admin approves → LIVE
|
||||
→ admin rejects → BLOCKED
|
||||
│
|
||||
▼
|
||||
[LIVE — Registration Open]
|
||||
reg_start ≤ today ≤ reg_end
|
||||
Members can register
|
||||
│
|
||||
▼
|
||||
[Registration Closed]
|
||||
today > reg_end
|
||||
No new registrations
|
||||
│
|
||||
▼
|
||||
[Event Day]
|
||||
event_date
|
||||
│
|
||||
▼
|
||||
[COMPLETED]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Championship Data Structure
|
||||
|
||||
Each championship contains:
|
||||
|
||||
### Event Info
|
||||
- Name, subtitle
|
||||
- Event date (when it happens)
|
||||
- Registration period: start date → end date (must be before event date)
|
||||
- Location (city, country)
|
||||
- Venue name
|
||||
- Accent color (for branding)
|
||||
|
||||
### Categories (configurable tab)
|
||||
- Disciplines: e.g. "Exotic Pole Dance", "Pole Art"
|
||||
- Levels per discipline: e.g. "Beginners", "Amateur", "Semi-Pro", "Profi", "Elite", "Duets & Groups"
|
||||
- Styles: e.g. "Classic", "Flow", "Theater"
|
||||
|
||||
### Fees (configurable tab)
|
||||
- Video selection fee (e.g. "50 BYN / 1,500 RUB")
|
||||
- Championship fees by type:
|
||||
- Solo (e.g. "280 BYN / 7,500 RUB")
|
||||
- Duet per person (e.g. "210 BYN / 5,800 RUB pp")
|
||||
- Group per person (e.g. "190 BYN / 4,500 RUB pp")
|
||||
- Refund note (typically non-refundable)
|
||||
|
||||
### Rules (configurable tab)
|
||||
- General rules (list of text items)
|
||||
- Costume rules (list of text items)
|
||||
- Scoring criteria: name + max score (e.g. "Artistry: 0–10", "Technique: 0–10")
|
||||
- Penalties: name + value (e.g. "Fall: -2", "Exposure: DQ")
|
||||
|
||||
### Judges (configurable tab)
|
||||
- Judge profiles: name, Instagram handle, bio/description
|
||||
- These are people, not scoring criteria
|
||||
|
||||
### Members (only when LIVE)
|
||||
- Registered members scoped to this championship
|
||||
- Each with: discipline, level, style, type (solo/duet/group), progress steps, video URL, payment status, pass/fail result
|
||||
|
||||
---
|
||||
|
||||
## 5. Member Registration Flow (10 Steps)
|
||||
|
||||
```
|
||||
Step 1: 📋 Review Rules → Auto (tracked when user opens Rules tab)
|
||||
Step 2: 🎯 Select Category → Auto (saved from registration picker)
|
||||
Step 3: 🎬 Record Video → Manual toggle ("I've recorded my video")
|
||||
Step 4: 📝 Submit Video Form → Manual / link to Google Form
|
||||
Step 5: 💳 Pay Video Fee → Upload receipt screenshot → Org confirms
|
||||
Step 6: ⏳ Wait for Results → Pending until org decides
|
||||
Step 7: 🏆 Results → Auto-updates on pass/fail
|
||||
├── FAIL → Flow ends
|
||||
└── PASS → Continue ▼
|
||||
Step 8: 💰 Pay Championship Fee → Upload receipt → Org confirms
|
||||
Step 9: 📄 Submit "About Me" → Manual / link to form
|
||||
Step 10: 🛡️ Confirm Insurance → Upload document → Org confirms
|
||||
└── ✅ REGISTERED!
|
||||
```
|
||||
|
||||
### Detection Methods
|
||||
| Step | Method |
|
||||
|---|---|
|
||||
| Review rules | Auto — tracked on tab open |
|
||||
| Select category | Auto — saved from picker |
|
||||
| Record video | Manual toggle |
|
||||
| Submit video form | Manual or Gmail auto-detect (future) |
|
||||
| Pay video fee | Receipt upload → org confirms |
|
||||
| Results | Auto — org pass/fail updates member |
|
||||
| Pay championship fee | Receipt upload → org confirms |
|
||||
| About Me form | Manual or Gmail auto-detect (future) |
|
||||
| Insurance | Document upload → org confirms |
|
||||
|
||||
---
|
||||
|
||||
## 6. Notifications
|
||||
|
||||
### Types
|
||||
| Type | Trigger | Direction |
|
||||
|---|---|---|
|
||||
| 🔄 Category Changed | Org changes member's level/style | Org → Member |
|
||||
| ✅ Payment Confirmed | Org confirms uploaded receipt | Org → Member |
|
||||
| 🏆 Results | Org passes/fails video selection | Org → Member |
|
||||
| ⏰ Deadline Reminder | Auto (7d, 3d, 1d before reg_end) | System → Member |
|
||||
| 📋 Registration Confirmed | All 10 steps complete | System → Member |
|
||||
| 📢 Announcement | Org sends broadcast | Org → All Members |
|
||||
|
||||
### Delivery
|
||||
- In-app: Bell icon with unread count, notification feed
|
||||
- Push: Expo Notifications for critical updates
|
||||
- Email: Future enhancement
|
||||
|
||||
---
|
||||
|
||||
## 7. Org App — Configurable Tabs
|
||||
|
||||
When org creates a championship, it starts as DRAFT with 5 configurable sections:
|
||||
|
||||
| Section | Tab | What to configure | Mark as Done when |
|
||||
|---|---|---|---|
|
||||
| Info | Overview | Name, dates, location, venue, reg period | Has date + location |
|
||||
| Categories | Categories | Levels + styles | At least 1 level + 1 style |
|
||||
| Fees | Fees | Video fee + championship fees | Video fee filled |
|
||||
| Rules | Rules | General rules, costume rules, scoring criteria, penalties | At least 1 rule |
|
||||
| Judges | Judges | Judge profiles (name, instagram, bio) | At least 1 judge |
|
||||
|
||||
Setup progress shown on Overview tab as checklist. Each section shows green dot (done) or yellow dot (pending) in tab bar. "Go Live" button appears when all 5 sections are ✓.
|
||||
|
||||
---
|
||||
|
||||
## 8. Admin — Approval Flow
|
||||
|
||||
### Organization Approval
|
||||
- New org registers → status: `pending`
|
||||
- Admin reviews → Approve (status: `active`) or Reject (status: `blocked`)
|
||||
- Admin can also **verify** an active org (🛡️ badge)
|
||||
|
||||
### Championship Approval
|
||||
- Depends on org's verification status:
|
||||
- **Verified org** → Go Live = instant `live`
|
||||
- **Unverified org** → Go Live = `pending_approval` → admin reviews
|
||||
|
||||
### Admin Actions
|
||||
| Entity | Actions |
|
||||
|---|---|
|
||||
| Organization | Approve, Reject, Block, Unblock, Verify, Delete |
|
||||
| Championship | Approve, Reject, Suspend, Reinstate, Delete |
|
||||
| User | Warn, Block, Unblock, Delete |
|
||||
|
||||
---
|
||||
|
||||
## 9. Org Settings
|
||||
|
||||
- **Edit Organization Profile**: name, instagram (inline edit form)
|
||||
- **Notification Preferences**: toggles for push, email, registration alerts, payment alerts, deadline reminders
|
||||
- **Connected Accounts**: Instagram (connected/not), Gmail, Telegram
|
||||
- Help & Support
|
||||
- Log Out
|
||||
|
||||
---
|
||||
|
||||
## 10. Search & Discovery (Member)
|
||||
|
||||
Members can find championships by:
|
||||
- Text search (name, org name)
|
||||
- Filters: discipline, location, status (Registration Open / Upcoming / Past)
|
||||
- Sort: by date, by popularity
|
||||
|
||||
Championship cards show: name, org, dates, location, status badge, member count.
|
||||
|
||||
---
|
||||
|
||||
## 11. Future Features (Out of MVP Scope)
|
||||
|
||||
- Instagram parsing: auto-import championship data from org's posts
|
||||
- Gmail integration: auto-detect Google Forms confirmations
|
||||
- OCR results detection: parse results from Instagram photo posts
|
||||
- In-app payments: replace receipt uploads
|
||||
- In-app forms: replace Google Forms links
|
||||
- Telegram monitoring: detect results from Telegram chats
|
||||
- Category recommendation engine
|
||||
- Calendar sync (export to phone calendar)
|
||||
- Social features (see which friends are registered)
|
||||
- Multi-language (Russian + English)
|
||||
517
dancechamp-claude-code/prototypes/admin-panel.jsx
Normal file
517
dancechamp-claude-code/prototypes/admin-panel.jsx
Normal file
@@ -0,0 +1,517 @@
|
||||
import { useState } from "react";
|
||||
|
||||
/* ── Platform Data ── */
|
||||
const PLATFORM = { name: "DanceChamp", version: "1.0 MVP", totalRevenue: "12,450 BYN" };
|
||||
|
||||
const ORGS_DATA = [
|
||||
{ id: "o1", name: "Zero Gravity Team", instagram: "@zerogravity_pole", logo: "💃", status: "active", joined: "Jan 15, 2026", champsCount: 2, membersCount: 24, city: "Minsk", email: "team@zerogravity.by", verified: true },
|
||||
{ id: "o2", name: "Pole Universe", instagram: "@pole_universe", logo: "🌌", status: "active", joined: "Feb 2, 2026", champsCount: 1, membersCount: 12, city: "Moscow", email: "info@poleuniverse.ru", verified: true },
|
||||
{ id: "o3", name: "Sky Pole Studio", instagram: "@sky_pole", logo: "☁️", status: "pending", joined: "Feb 20, 2026", champsCount: 0, membersCount: 0, city: "St. Petersburg", email: "hello@skypole.ru", verified: false },
|
||||
{ id: "o4", name: "Dance Flames", instagram: "@dance_flames", logo: "🔥", status: "blocked", joined: "Dec 10, 2025", champsCount: 1, membersCount: 5, city: "Kyiv", email: "admin@danceflames.ua", verified: false, blockReason: "Fake organization — no real events" },
|
||||
];
|
||||
|
||||
const CHAMPS_DATA = [
|
||||
{ id: "c1", orgId: "o1", orgName: "Zero Gravity Team", name: "Zero Gravity", dates: "May 30, 2026", location: "Minsk", status: "live", members: 24, passed: 8, pending: 8, revenue: "4,200 BYN", orgVerified: true },
|
||||
{ id: "c2", orgId: "o1", orgName: "Zero Gravity Team", name: "Pole Star", dates: "Jul 12, 2026", location: "Moscow", status: "draft", members: 1, passed: 0, pending: 0, revenue: "0", orgVerified: true },
|
||||
{ id: "c3", orgId: "o2", orgName: "Pole Universe", name: "Galactic Pole", dates: "Sep 15, 2026", location: "Moscow", status: "live", members: 12, passed: 0, pending: 12, revenue: "1,800 BYN", orgVerified: true },
|
||||
{ id: "c4", orgId: "o3", orgName: "Sky Pole Studio", name: "Sky Open", dates: "Oct 5, 2026", location: "St. Petersburg", status: "pending_approval", members: 0, passed: 0, pending: 0, revenue: "0", orgVerified: false },
|
||||
{ id: "c5", orgId: "o4", orgName: "Dance Flames", name: "Fire Cup", dates: "Mar 1, 2026", location: "Kyiv", status: "blocked", members: 5, passed: 0, pending: 0, revenue: "250 BYN", orgVerified: false },
|
||||
];
|
||||
|
||||
const USERS_DATA = [
|
||||
{ id: "u1", name: "Alex Petrova", instagram: "@alex_pole", email: "alex@mail.ru", city: "Moscow", joined: "Jan 20, 2026", champsJoined: 2, status: "active", role: "member" },
|
||||
{ id: "u2", name: "Maria Ivanova", instagram: "@maria_exotic", email: "maria@gmail.com", city: "Minsk", joined: "Jan 22, 2026", champsJoined: 1, status: "active", role: "member" },
|
||||
{ id: "u3", name: "Elena Kozlova", instagram: "@elena.pole", email: "elena@ya.ru", city: "St. Petersburg", joined: "Feb 1, 2026", champsJoined: 1, status: "active", role: "member" },
|
||||
{ id: "u4", name: "Daria Sokolova", instagram: "@daria_art", email: "daria@mail.ru", city: "Kyiv", joined: "Feb 5, 2026", champsJoined: 1, status: "active", role: "member" },
|
||||
{ id: "u5", name: "Anna Belova", instagram: "@anna.b_pole", email: "anna@gmail.com", city: "Minsk", joined: "Feb 10, 2026", champsJoined: 1, status: "active", role: "member" },
|
||||
{ id: "u6", name: "Olga Morozova", instagram: "@olga_exotic", email: "olga@mail.ru", city: "Moscow", joined: "Feb 12, 2026", champsJoined: 3, status: "warned", role: "member", warnReason: "Disputed payment — under review" },
|
||||
{ id: "u7", name: "Ivan Petrov", instagram: "@ivan_admin", email: "ivan@zerogravity.by", city: "Minsk", joined: "Jan 10, 2026", champsJoined: 0, status: "active", role: "org_admin", org: "Zero Gravity Team" },
|
||||
{ id: "u8", name: "Spam Bot", instagram: "@totally_real", email: "spam@fake.com", city: "Unknown", joined: "Feb 22, 2026", champsJoined: 0, status: "blocked", role: "member", blockReason: "Spam account" },
|
||||
];
|
||||
|
||||
const LOGS_DATA = [
|
||||
{ id: "l1", action: "Org approved & verified", target: "Pole Universe", by: "Admin", date: "Feb 2, 2026", type: "org" },
|
||||
{ id: "l2", action: "User blocked", target: "Spam Bot", by: "Admin", date: "Feb 22, 2026", type: "user" },
|
||||
{ id: "l3", action: "Org blocked", target: "Dance Flames", by: "Admin", date: "Feb 23, 2026", type: "org" },
|
||||
{ id: "l4", action: "Champ auto-approved (verified org)", target: "Zero Gravity", by: "System", date: "Feb 1, 2026", type: "champ" },
|
||||
{ id: "l5", action: "User warned", target: "Olga Morozova", by: "Admin", date: "Feb 20, 2026", type: "user" },
|
||||
{ id: "l6", action: "New org registered (pending)", target: "Sky Pole Studio", by: "System", date: "Feb 20, 2026", type: "org" },
|
||||
{ id: "l7", action: "Champ submitted for review", target: "Sky Open", by: "Sky Pole Studio", date: "Feb 21, 2026", type: "champ" },
|
||||
];
|
||||
|
||||
/* ── Theme (admin = darker, more neutral accent) ── */
|
||||
const c = { bg: "#07060C", card: "#111019", cardH: "#18172290", brd: "#1D1C2B", text: "#F2F0FA", dim: "#5E5C72", mid: "#8F8DA6", accent: "#6366F1", accentS: "rgba(99,102,241,0.10)", green: "#10B981", greenS: "rgba(16,185,129,0.10)", yellow: "#F59E0B", yellowS: "rgba(245,158,11,0.10)", purple: "#8B5CF6", purpleS: "rgba(139,92,246,0.10)", blue: "#60A5FA", blueS: "rgba(96,165,250,0.10)", red: "#EF4444", redS: "rgba(239,68,68,0.10)", orange: "#F97316", orangeS: "rgba(249,115,22,0.10)" };
|
||||
const f = { d: "'Playfair Display',Georgia,serif", b: "'DM Sans','Segoe UI',sans-serif", m: "'JetBrains Mono',monospace" };
|
||||
|
||||
/* ── Shared UI ── */
|
||||
const Cd = ({ children, style: s }) => <div style={{ background: c.card, border: `1px solid ${c.brd}`, borderRadius: 14, padding: 16, ...s }}>{children}</div>;
|
||||
const ST = ({ children, right }) => <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", margin: "0 0 10px" }}><h3 style={{ fontFamily: f.d, fontSize: 14, fontWeight: 700, color: c.mid, margin: 0 }}>{children}</h3>{right}</div>;
|
||||
const Bg = ({ label, color, bg }) => <span style={{ fontFamily: f.m, fontSize: 8, fontWeight: 700, letterSpacing: 0.8, color, background: bg, padding: "3px 8px", borderRadius: 4 }}>{label}</span>;
|
||||
|
||||
const statusConfig = {
|
||||
active: { l: "ACTIVE", c: c.green, b: c.greenS }, live: { l: "LIVE", c: c.green, b: c.greenS },
|
||||
pending: { l: "PENDING", c: c.yellow, b: c.yellowS }, pending_approval: { l: "AWAITING REVIEW", c: c.orange, b: c.orangeS },
|
||||
draft: { l: "DRAFT", c: c.dim, b: `${c.dim}15` },
|
||||
blocked: { l: "BLOCKED", c: c.red, b: c.redS }, warned: { l: "WARNED", c: c.orange, b: c.orangeS },
|
||||
};
|
||||
|
||||
function Hdr({ title, subtitle, onBack, right }) {
|
||||
return <div style={{ padding: "14px 20px 6px", display: "flex", alignItems: "center", gap: 12 }}>
|
||||
{onBack && <div onClick={onBack} style={{ width: 32, height: 32, borderRadius: 8, background: c.card, border: `1px solid ${c.brd}`, display: "flex", alignItems: "center", justifyContent: "center", cursor: "pointer", fontSize: 15, color: c.text }}>←</div>}
|
||||
<div style={{ flex: 1, minWidth: 0 }}><h1 style={{ fontFamily: f.d, fontSize: 20, fontWeight: 700, color: c.text, margin: 0 }}>{title}</h1>{subtitle && <p style={{ fontFamily: f.b, fontSize: 11, color: c.dim, margin: "2px 0 0" }}>{subtitle}</p>}</div>
|
||||
{right}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function Nav({ active, onChange }) {
|
||||
return <div style={{ display: "flex", justifyContent: "space-around", padding: "10px 0 8px", borderTop: `1px solid ${c.brd}`, background: c.bg, flexShrink: 0 }}>
|
||||
{[{ id: "dash", i: "📊", l: "Overview" }, { id: "orgs", i: "🏢", l: "Orgs" }, { id: "champs", i: "🏆", l: "Champs" }, { id: "users", i: "👥", l: "Users" }].map(x =>
|
||||
<div key={x.id} onClick={() => onChange(x.id)} style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 2, cursor: "pointer", opacity: active === x.id ? 1 : 0.35 }}><span style={{ fontSize: 18 }}>{x.i}</span><span style={{ fontFamily: f.m, fontSize: 8, color: c.text, letterSpacing: 0.3 }}>{x.l}</span></div>
|
||||
)}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function SearchBar({ value, onChange, placeholder }) {
|
||||
return <div style={{ background: c.card, border: `1px solid ${c.brd}`, borderRadius: 12, padding: "10px 14px", display: "flex", alignItems: "center", gap: 10 }}>
|
||||
<span style={{ fontSize: 14, opacity: 0.4 }}>🔍</span>
|
||||
<input type="text" placeholder={placeholder || "Search..."} value={value} onChange={e => onChange(e.target.value)} style={{ background: "transparent", border: "none", outline: "none", color: c.text, fontFamily: f.b, fontSize: 13, width: "100%" }} />
|
||||
</div>;
|
||||
}
|
||||
|
||||
function FilterChips({ filters, active, onChange, accent }) {
|
||||
return <div style={{ display: "flex", gap: 4, overflowX: "auto", scrollbarWidth: "none" }}>
|
||||
{filters.map(fi => <div key={fi.id} onClick={() => onChange(fi.id)} style={{ fontFamily: f.m, fontSize: 9, fontWeight: 600, whiteSpace: "nowrap", color: active === fi.id ? accent || c.accent : c.dim, background: active === fi.id ? `${accent || c.accent}15` : "transparent", border: `1px solid ${active === fi.id ? `${accent || c.accent}30` : "transparent"}`, padding: "5px 10px", borderRadius: 16, cursor: "pointer" }}>{fi.l}{fi.n !== undefined ? ` (${fi.n})` : ""}</div>)}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function ActionBtn({ label, color, onClick, icon, filled }) {
|
||||
return <div onClick={onClick} style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 5, padding: "8px 14px", borderRadius: 8, background: filled ? color : `${color}15`, border: `1px solid ${filled ? color : `${color}30`}`, cursor: "pointer" }}>
|
||||
{icon && <span style={{ fontSize: 12 }}>{icon}</span>}
|
||||
<span style={{ fontFamily: f.b, fontSize: 11, fontWeight: 700, color: filled ? "#fff" : color }}>{label}</span>
|
||||
</div>;
|
||||
}
|
||||
|
||||
/* ── Dashboard ── */
|
||||
function Dashboard({ orgs, champs, users, onNav }) {
|
||||
const pendingOrgs = orgs.filter(o => o.status === "pending").length;
|
||||
const pendingChamps = champs.filter(c2 => c2.status === "pending_approval").length;
|
||||
const blockedUsers = users.filter(u => u.status === "blocked").length;
|
||||
|
||||
return <div>
|
||||
<Hdr title="Admin Panel" subtitle={`${PLATFORM.name} · ${PLATFORM.version}`} right={
|
||||
<div style={{ width: 36, height: 36, borderRadius: 10, background: `linear-gradient(135deg,${c.accent}25,${c.accent}10)`, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 14, fontFamily: f.m, fontWeight: 700, color: c.accent }}>⚡</div>
|
||||
} />
|
||||
<div style={{ padding: "6px 16px 16px", display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
|
||||
{/* Platform stats */}
|
||||
<div style={{ display: "flex", gap: 6 }}>
|
||||
{[{ n: orgs.filter(o => o.status === "active").length, l: "Orgs", co: c.accent, go: "orgs" },
|
||||
{ n: champs.filter(c2 => c2.status === "live").length, l: "Live Champs", co: c.green, go: "champs" },
|
||||
{ n: users.length, l: "Users", co: c.blue, go: "users" },
|
||||
].map(s => <div key={s.l} onClick={() => onNav(s.go)} style={{ flex: 1, background: c.card, border: `1px solid ${c.brd}`, borderRadius: 12, padding: "10px 6px", textAlign: "center", cursor: "pointer" }}>
|
||||
<p style={{ fontFamily: f.d, fontSize: 20, fontWeight: 700, color: s.co, margin: 0 }}>{s.n}</p>
|
||||
<p style={{ fontFamily: f.m, fontSize: 7, color: c.dim, margin: "2px 0 0", textTransform: "uppercase" }}>{s.l}</p>
|
||||
</div>)}
|
||||
</div>
|
||||
|
||||
{/* Needs attention */}
|
||||
{(pendingOrgs > 0 || pendingChamps > 0) && <Cd style={{ background: `${c.yellow}06`, border: `1px solid ${c.yellow}20` }}>
|
||||
<ST right={<Bg label="ACTION" color={c.yellow} bg={c.yellowS} />}>⚡ Needs Attention</ST>
|
||||
{pendingOrgs > 0 && <div onClick={() => onNav("orgs")} style={{ display: "flex", alignItems: "center", gap: 10, padding: "10px 0", borderBottom: `1px solid ${c.brd}`, cursor: "pointer" }}>
|
||||
<span style={{ fontSize: 16 }}>🏢</span>
|
||||
<span style={{ fontFamily: f.b, fontSize: 13, color: c.text, flex: 1 }}>Organizations awaiting approval</span>
|
||||
<span style={{ fontFamily: f.m, fontSize: 14, fontWeight: 700, color: c.yellow }}>{pendingOrgs}</span>
|
||||
<span style={{ color: c.dim }}>›</span>
|
||||
</div>}
|
||||
{pendingChamps > 0 && <div onClick={() => onNav("champs")} style={{ display: "flex", alignItems: "center", gap: 10, padding: "10px 0", cursor: "pointer" }}>
|
||||
<span style={{ fontSize: 16 }}>🏆</span>
|
||||
<span style={{ fontFamily: f.b, fontSize: 13, color: c.text, flex: 1 }}>Champs awaiting approval (unverified orgs)</span>
|
||||
<span style={{ fontFamily: f.m, fontSize: 14, fontWeight: 700, color: c.yellow }}>{pendingChamps}</span>
|
||||
<span style={{ color: c.dim }}>›</span>
|
||||
</div>}
|
||||
</Cd>}
|
||||
|
||||
{/* Quick stats */}
|
||||
<Cd>
|
||||
<ST>Platform Health</ST>
|
||||
{[{ l: "Total revenue", v: PLATFORM.totalRevenue, co: c.green },
|
||||
{ l: "Active orgs", v: `${orgs.filter(o => o.status === "active").length}/${orgs.length}`, co: c.accent },
|
||||
{ l: "Blocked users", v: `${blockedUsers}`, co: blockedUsers > 0 ? c.red : c.green },
|
||||
{ l: "Avg members/champ", v: Math.round(users.filter(u => u.role === "member").length / Math.max(champs.filter(c2 => c2.status === "live").length, 1)), co: c.blue },
|
||||
].map(s => <div key={s.l} style={{ display: "flex", justifyContent: "space-between", padding: "8px 0", borderBottom: `1px solid ${c.brd}` }}>
|
||||
<span style={{ fontFamily: f.b, fontSize: 12, color: c.mid }}>{s.l}</span>
|
||||
<span style={{ fontFamily: f.m, fontSize: 13, fontWeight: 700, color: s.co }}>{s.v}</span>
|
||||
</div>)}
|
||||
</Cd>
|
||||
|
||||
{/* Recent activity */}
|
||||
<Cd>
|
||||
<ST right={<span style={{ fontFamily: f.m, fontSize: 10, color: c.dim }}>{LOGS_DATA.length} entries</span>}>Recent Activity</ST>
|
||||
{LOGS_DATA.slice(0, 5).map(log => {
|
||||
const tc = { org: c.accent, user: c.blue, champ: c.green }[log.type] || c.dim;
|
||||
return <div key={log.id} style={{ display: "flex", alignItems: "center", gap: 10, padding: "7px 0", borderBottom: `1px solid ${c.brd}` }}>
|
||||
<div style={{ width: 6, height: 6, borderRadius: 3, background: tc, flexShrink: 0 }} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<p style={{ fontFamily: f.b, fontSize: 12, color: c.text, margin: 0 }}>{log.action}: <span style={{ color: tc }}>{log.target}</span></p>
|
||||
<p style={{ fontFamily: f.m, fontSize: 9, color: c.dim, margin: "2px 0 0" }}>{log.date} · {log.by}</p>
|
||||
</div>
|
||||
</div>;
|
||||
})}
|
||||
</Cd>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
/* ── Organizations ── */
|
||||
function OrgsList({ orgs, onOrgTap }) {
|
||||
const [search, setSearch] = useState("");
|
||||
const [filter, setFilter] = useState("all");
|
||||
|
||||
const filters = [
|
||||
{ id: "all", l: "All", n: orgs.length },
|
||||
{ id: "active", l: "✅ Active", n: orgs.filter(o => o.status === "active").length },
|
||||
{ id: "pending", l: "⏳ Pending", n: orgs.filter(o => o.status === "pending").length },
|
||||
{ id: "blocked", l: "🚫 Blocked", n: orgs.filter(o => o.status === "blocked").length },
|
||||
];
|
||||
|
||||
const filtered = orgs.filter(o => {
|
||||
const q = !search || o.name.toLowerCase().includes(search.toLowerCase()) || o.instagram.toLowerCase().includes(search.toLowerCase());
|
||||
if (!q) return false;
|
||||
if (filter === "active") return o.status === "active";
|
||||
if (filter === "pending") return o.status === "pending";
|
||||
if (filter === "blocked") return o.status === "blocked";
|
||||
return true;
|
||||
});
|
||||
|
||||
return <div>
|
||||
<Hdr title="Organizations" subtitle={`${orgs.length} registered`} />
|
||||
<div style={{ padding: "6px 16px 16px", display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
<SearchBar value={search} onChange={setSearch} placeholder="Search org name or @handle..." />
|
||||
<FilterChips filters={filters} active={filter} onChange={setFilter} />
|
||||
{filtered.map(o => {
|
||||
const st = statusConfig[o.status] || statusConfig.active;
|
||||
return <div key={o.id} onClick={() => onOrgTap(o)} style={{ background: c.card, border: `1px solid ${c.brd}`, borderRadius: 14, padding: 14, cursor: "pointer" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||
<div style={{ width: 40, height: 40, borderRadius: 10, background: `${c.accent}15`, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 18 }}>{o.logo}</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<p style={{ fontFamily: f.b, fontSize: 14, fontWeight: 600, color: c.text, margin: 0 }}>{o.name}</p>
|
||||
<Bg label={st.l} color={st.c} bg={st.b} />
|
||||
</div>
|
||||
<p style={{ fontFamily: f.m, fontSize: 10, color: c.accent, margin: "2px 0 0" }}>{o.instagram}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 12, marginTop: 8, paddingTop: 8, borderTop: `1px solid ${c.brd}` }}>
|
||||
<span style={{ fontFamily: f.b, fontSize: 10, color: c.mid }}>📍 {o.city}</span>
|
||||
<span style={{ fontFamily: f.b, fontSize: 10, color: c.mid }}>🏆 {o.champsCount} champs</span>
|
||||
<span style={{ fontFamily: f.b, fontSize: 10, color: c.mid }}>👥 {o.membersCount} members</span>
|
||||
</div>
|
||||
</div>;
|
||||
})}
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
/* ── Org Detail ── */
|
||||
function OrgDetail({ org: initial, onBack, champs }) {
|
||||
const [o, setO] = useState(initial);
|
||||
const st = statusConfig[o.status] || statusConfig.active;
|
||||
const orgChamps = champs.filter(c2 => c2.orgId === o.id);
|
||||
|
||||
return <div style={{ flex: 1, overflow: "auto" }}>
|
||||
<Hdr title={o.name} subtitle={o.instagram} onBack={onBack} />
|
||||
<div style={{ padding: "6px 16px 20px", display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
{/* Profile */}
|
||||
<Cd style={{ display: "flex", alignItems: "center", gap: 14, padding: "14px 16px" }}>
|
||||
<div style={{ width: 54, height: 54, borderRadius: 14, background: `${c.accent}15`, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 24 }}>{o.logo}</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<p style={{ fontFamily: f.b, fontSize: 16, fontWeight: 600, color: c.text, margin: 0 }}>{o.name}</p>
|
||||
<p style={{ fontFamily: f.m, fontSize: 11, color: c.accent, margin: "2px 0 0" }}>{o.instagram}</p>
|
||||
<p style={{ fontFamily: f.b, fontSize: 11, color: c.dim, margin: "2px 0 0" }}>📍 {o.city} · 📧 {o.email}</p>
|
||||
</div>
|
||||
<Bg label={st.l} color={st.c} bg={st.b} />
|
||||
</Cd>
|
||||
|
||||
{/* Info */}
|
||||
<Cd>
|
||||
<ST>Details</ST>
|
||||
{[{ l: "Joined", v: o.joined }, { l: "Championships", v: o.champsCount }, { l: "Total members", v: o.membersCount }, { l: "Verified", v: o.verified ? "✅ Yes" : "❌ No" }].map(r =>
|
||||
<div key={r.l} style={{ display: "flex", justifyContent: "space-between", padding: "7px 0", borderBottom: `1px solid ${c.brd}` }}>
|
||||
<span style={{ fontFamily: f.m, fontSize: 10, color: c.dim, textTransform: "uppercase" }}>{r.l}</span>
|
||||
<span style={{ fontFamily: f.b, fontSize: 12, color: c.text }}>{r.v}</span>
|
||||
</div>
|
||||
)}
|
||||
</Cd>
|
||||
|
||||
{/* Approval policy */}
|
||||
<Cd style={{ background: o.verified ? `${c.green}06` : `${c.yellow}06`, border: `1px solid ${o.verified ? `${c.green}20` : `${c.yellow}20`}` }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||
<span style={{ fontSize: 18 }}>{o.verified ? "🛡️" : "⏳"}</span>
|
||||
<div>
|
||||
<p style={{ fontFamily: f.b, fontSize: 12, fontWeight: 600, color: o.verified ? c.green : c.yellow, margin: 0 }}>{o.verified ? "Verified — Auto-approve events" : "Unverified — Events need manual approval"}</p>
|
||||
<p style={{ fontFamily: f.b, fontSize: 10, color: c.dim, margin: "2px 0 0" }}>{o.verified ? "Championships go live instantly when org clicks 'Go Live'" : "Admin must review & approve each championship before it becomes visible"}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Cd>
|
||||
|
||||
{/* Championships */}
|
||||
{orgChamps.length > 0 && <Cd>
|
||||
<ST right={<span style={{ fontFamily: f.m, fontSize: 10, color: c.dim }}>{orgChamps.length}</span>}>Championships</ST>
|
||||
{orgChamps.map(ch => {
|
||||
const cs = statusConfig[ch.status] || statusConfig.draft;
|
||||
return <div key={ch.id} style={{ display: "flex", justifyContent: "space-between", alignItems: "center", padding: "8px 0", borderBottom: `1px solid ${c.brd}` }}>
|
||||
<div><p style={{ fontFamily: f.b, fontSize: 12, color: c.text, margin: 0 }}>{ch.name}</p><p style={{ fontFamily: f.m, fontSize: 10, color: c.dim, margin: "2px 0 0" }}>{ch.dates} · {ch.location}</p></div>
|
||||
<Bg label={cs.l} color={cs.c} bg={cs.b} />
|
||||
</div>;
|
||||
})}
|
||||
</Cd>}
|
||||
|
||||
{/* Block reason */}
|
||||
{o.blockReason && <Cd style={{ background: `${c.red}06`, border: `1px solid ${c.red}20` }}>
|
||||
<p style={{ fontFamily: f.m, fontSize: 9, color: c.red, margin: "0 0 4px", textTransform: "uppercase", letterSpacing: 0.5 }}>Block Reason</p>
|
||||
<p style={{ fontFamily: f.b, fontSize: 12, color: c.text, margin: 0 }}>{o.blockReason}</p>
|
||||
</Cd>}
|
||||
|
||||
{/* Actions */}
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{o.status === "pending" && <div style={{ display: "flex", gap: 8 }}>
|
||||
<ActionBtn label="Approve" color={c.green} onClick={() => setO(p => ({ ...p, status: "active", verified: true }))} icon="✅" filled />
|
||||
<ActionBtn label="Reject" color={c.red} onClick={() => setO(p => ({ ...p, status: "blocked", blockReason: "Rejected by admin" }))} icon="❌" filled />
|
||||
</div>}
|
||||
{o.status === "active" && <ActionBtn label="Block Organization" color={c.red} onClick={() => setO(p => ({ ...p, status: "blocked", blockReason: "Blocked by admin" }))} icon="🚫" />}
|
||||
{o.status === "blocked" && <ActionBtn label="Unblock Organization" color={c.green} onClick={() => setO(p => ({ ...p, status: "active", blockReason: null }))} icon="✅" />}
|
||||
{!o.verified && o.status !== "blocked" && <ActionBtn label="Verify Organization" color={c.accent} onClick={() => setO(p => ({ ...p, verified: true }))} icon="🛡️" />}
|
||||
<ActionBtn label="Delete Organization" color={c.red} onClick={() => {}} icon="🗑️" />
|
||||
</div>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
/* ── Championships ── */
|
||||
function ChampsList({ champs, onChampTap }) {
|
||||
const [search, setSearch] = useState("");
|
||||
const [filter, setFilter] = useState("all");
|
||||
|
||||
const filters = [
|
||||
{ id: "all", l: "All", n: champs.length },
|
||||
{ id: "live", l: "🟢 Live", n: champs.filter(c2 => c2.status === "live").length },
|
||||
{ id: "pending_approval", l: "⏳ Awaiting", n: champs.filter(c2 => c2.status === "pending_approval").length },
|
||||
{ id: "draft", l: "📝 Draft", n: champs.filter(c2 => c2.status === "draft").length },
|
||||
{ id: "blocked", l: "🚫 Blocked", n: champs.filter(c2 => c2.status === "blocked").length },
|
||||
];
|
||||
|
||||
const filtered = champs.filter(ch => {
|
||||
const q = !search || ch.name.toLowerCase().includes(search.toLowerCase()) || ch.orgName.toLowerCase().includes(search.toLowerCase());
|
||||
if (!q) return false;
|
||||
if (filter !== "all") return ch.status === filter;
|
||||
return true;
|
||||
});
|
||||
|
||||
return <div>
|
||||
<Hdr title="Championships" subtitle={`${champs.length} total`} />
|
||||
<div style={{ padding: "6px 16px 16px", display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
<SearchBar value={search} onChange={setSearch} placeholder="Search championship or org..." />
|
||||
<FilterChips filters={filters} active={filter} onChange={setFilter} />
|
||||
{filtered.map(ch => {
|
||||
const st = statusConfig[ch.status] || statusConfig.draft;
|
||||
return <div key={ch.id} onClick={() => onChampTap(ch)} style={{ background: c.card, border: `1px solid ${c.brd}`, borderRadius: 14, padding: 14, cursor: "pointer" }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: 4 }}>
|
||||
<div><p style={{ fontFamily: f.b, fontSize: 14, fontWeight: 600, color: c.text, margin: 0 }}>{ch.name}</p><p style={{ fontFamily: f.m, fontSize: 10, color: c.accent, margin: "2px 0 0" }}>by {ch.orgName} {ch.orgVerified ? "🛡️" : ""}</p></div>
|
||||
<Bg label={st.l} color={st.c} bg={st.b} />
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 12, marginTop: 6 }}>
|
||||
<span style={{ fontFamily: f.b, fontSize: 10, color: c.mid }}>📅 {ch.dates}</span>
|
||||
<span style={{ fontFamily: f.b, fontSize: 10, color: c.mid }}>📍 {ch.location}</span>
|
||||
<span style={{ fontFamily: f.b, fontSize: 10, color: c.mid }}>👥 {ch.members}</span>
|
||||
</div>
|
||||
</div>;
|
||||
})}
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
/* ── Championship Detail ── */
|
||||
function ChampDetail({ ch: initial, onBack }) {
|
||||
const [ch, setCh] = useState(initial);
|
||||
const st = statusConfig[ch.status] || statusConfig.draft;
|
||||
|
||||
return <div style={{ flex: 1, overflow: "auto" }}>
|
||||
<Hdr title={ch.name} subtitle={`by ${ch.orgName}`} onBack={onBack} />
|
||||
<div style={{ padding: "6px 16px 20px", display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
<Cd style={{ display: "flex", alignItems: "center", gap: 14, padding: "14px 16px" }}>
|
||||
<div style={{ width: 50, height: 50, borderRadius: 14, background: `${c.accent}15`, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 22 }}>🏆</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<p style={{ fontFamily: f.b, fontSize: 16, fontWeight: 600, color: c.text, margin: 0 }}>{ch.name}</p>
|
||||
<p style={{ fontFamily: f.m, fontSize: 11, color: c.accent, margin: "2px 0 0" }}>{ch.orgName}</p>
|
||||
<p style={{ fontFamily: f.b, fontSize: 11, color: c.dim, margin: "2px 0 0" }}>📅 {ch.dates} · 📍 {ch.location}</p>
|
||||
</div>
|
||||
<Bg label={st.l} color={st.c} bg={st.b} />
|
||||
</Cd>
|
||||
|
||||
<Cd>
|
||||
<ST>Stats</ST>
|
||||
{[{ l: "Members", v: ch.members }, { l: "Passed", v: ch.passed }, { l: "Pending", v: ch.pending }, { l: "Revenue", v: ch.revenue }].map(r =>
|
||||
<div key={r.l} style={{ display: "flex", justifyContent: "space-between", padding: "7px 0", borderBottom: `1px solid ${c.brd}` }}>
|
||||
<span style={{ fontFamily: f.m, fontSize: 10, color: c.dim, textTransform: "uppercase" }}>{r.l}</span>
|
||||
<span style={{ fontFamily: f.b, fontSize: 12, color: c.text }}>{r.v}</span>
|
||||
</div>
|
||||
)}
|
||||
</Cd>
|
||||
|
||||
{/* Approval info */}
|
||||
{ch.orgVerified !== undefined && <Cd>
|
||||
<ST>Approval Policy</ST>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, padding: "8px 0" }}>
|
||||
<span style={{ fontSize: 16 }}>{ch.orgVerified ? "🛡️" : "⏳"}</span>
|
||||
<div style={{ flex: 1 }}>
|
||||
<p style={{ fontFamily: f.b, fontSize: 12, color: c.text, margin: 0 }}>{ch.orgVerified ? "Verified org — auto-approved" : "Unverified org — manual review required"}</p>
|
||||
<p style={{ fontFamily: f.b, fontSize: 10, color: c.dim, margin: "2px 0 0" }}>{ch.orgVerified ? "This org can go live without admin approval" : "Admin must approve before members can see this event"}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Cd>}
|
||||
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{ch.status === "pending_approval" && <div style={{ display: "flex", gap: 8 }}>
|
||||
<ActionBtn label="Approve" color={c.green} onClick={() => setCh(p => ({ ...p, status: "live" }))} icon="✅" filled />
|
||||
<ActionBtn label="Reject" color={c.red} onClick={() => setCh(p => ({ ...p, status: "blocked" }))} icon="❌" filled />
|
||||
</div>}
|
||||
{ch.status === "live" && <ActionBtn label="Suspend Event" color={c.red} onClick={() => setCh(p => ({ ...p, status: "blocked" }))} icon="⏸️" />}
|
||||
{ch.status === "blocked" && <ActionBtn label="Reinstate Event" color={c.green} onClick={() => setCh(p => ({ ...p, status: "live" }))} icon="✅" />}
|
||||
<ActionBtn label="Delete Championship" color={c.red} onClick={() => {}} icon="🗑️" />
|
||||
</div>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
/* ── Users ── */
|
||||
function UsersList({ users, onUserTap }) {
|
||||
const [search, setSearch] = useState("");
|
||||
const [filter, setFilter] = useState("all");
|
||||
|
||||
const filters = [
|
||||
{ id: "all", l: "All", n: users.length },
|
||||
{ id: "active", l: "✅ Active", n: users.filter(u => u.status === "active").length },
|
||||
{ id: "warned", l: "⚠️ Warned", n: users.filter(u => u.status === "warned").length },
|
||||
{ id: "blocked", l: "🚫 Blocked", n: users.filter(u => u.status === "blocked").length },
|
||||
{ id: "org_admin", l: "🏢 Org Admins", n: users.filter(u => u.role === "org_admin").length },
|
||||
];
|
||||
|
||||
const filtered = users.filter(u => {
|
||||
const q = !search || u.name.toLowerCase().includes(search.toLowerCase()) || u.instagram.toLowerCase().includes(search.toLowerCase()) || u.email.toLowerCase().includes(search.toLowerCase());
|
||||
if (!q) return false;
|
||||
if (filter === "active") return u.status === "active";
|
||||
if (filter === "warned") return u.status === "warned";
|
||||
if (filter === "blocked") return u.status === "blocked";
|
||||
if (filter === "org_admin") return u.role === "org_admin";
|
||||
return true;
|
||||
});
|
||||
|
||||
return <div>
|
||||
<Hdr title="Users" subtitle={`${users.length} total`} />
|
||||
<div style={{ padding: "6px 16px 16px", display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
<SearchBar value={search} onChange={setSearch} placeholder="Search name, @handle, or email..." />
|
||||
<FilterChips filters={filters} active={filter} onChange={setFilter} />
|
||||
{filtered.map(u => {
|
||||
const st = statusConfig[u.status] || statusConfig.active;
|
||||
return <div key={u.id} onClick={() => onUserTap(u)} style={{ background: c.card, border: `1px solid ${c.brd}`, borderRadius: 14, padding: 12, cursor: "pointer" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||
<div style={{ width: 38, height: 38, borderRadius: 10, background: u.role === "org_admin" ? `${c.purple}15` : `${c.blue}15`, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 16 }}>{u.role === "org_admin" ? "🏢" : "👤"}</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<p style={{ fontFamily: f.b, fontSize: 13, fontWeight: 600, color: c.text, margin: 0 }}>{u.name}</p>
|
||||
<Bg label={st.l} color={st.c} bg={st.b} />
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 8, marginTop: 2 }}>
|
||||
<span style={{ fontFamily: f.m, fontSize: 10, color: c.accent }}>{u.instagram}</span>
|
||||
<span style={{ fontFamily: f.b, fontSize: 10, color: c.dim }}>{u.city}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>;
|
||||
})}
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
/* ── User Detail ── */
|
||||
function UserDetail({ user: initial, onBack }) {
|
||||
const [u, setU] = useState(initial);
|
||||
const st = statusConfig[u.status] || statusConfig.active;
|
||||
|
||||
return <div style={{ flex: 1, overflow: "auto" }}>
|
||||
<Hdr title={u.name} subtitle={u.instagram} onBack={onBack} />
|
||||
<div style={{ padding: "6px 16px 20px", display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
<Cd style={{ display: "flex", alignItems: "center", gap: 14, padding: "14px 16px" }}>
|
||||
<div style={{ width: 50, height: 50, borderRadius: 14, background: `${c.blue}15`, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 22 }}>👤</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<p style={{ fontFamily: f.b, fontSize: 16, fontWeight: 600, color: c.text, margin: 0 }}>{u.name}</p>
|
||||
<p style={{ fontFamily: f.m, fontSize: 11, color: c.accent, margin: "2px 0 0" }}>{u.instagram}</p>
|
||||
<p style={{ fontFamily: f.b, fontSize: 11, color: c.dim, margin: "2px 0 0" }}>📧 {u.email}</p>
|
||||
</div>
|
||||
<Bg label={st.l} color={st.c} bg={st.b} />
|
||||
</Cd>
|
||||
|
||||
<Cd>
|
||||
<ST>Info</ST>
|
||||
{[{ l: "City", v: u.city }, { l: "Joined", v: u.joined }, { l: "Championships", v: u.champsJoined },
|
||||
{ l: "Role", v: u.role === "org_admin" ? `Org Admin (${u.org})` : "Member" },
|
||||
].map(r => <div key={r.l} style={{ display: "flex", justifyContent: "space-between", padding: "7px 0", borderBottom: `1px solid ${c.brd}` }}>
|
||||
<span style={{ fontFamily: f.m, fontSize: 10, color: c.dim, textTransform: "uppercase" }}>{r.l}</span>
|
||||
<span style={{ fontFamily: f.b, fontSize: 12, color: c.text }}>{r.v}</span>
|
||||
</div>)}
|
||||
</Cd>
|
||||
|
||||
{(u.blockReason || u.warnReason) && <Cd style={{ background: `${u.status === "blocked" ? c.red : c.orange}06`, border: `1px solid ${u.status === "blocked" ? c.red : c.orange}20` }}>
|
||||
<p style={{ fontFamily: f.m, fontSize: 9, color: u.status === "blocked" ? c.red : c.orange, margin: "0 0 4px", textTransform: "uppercase", letterSpacing: 0.5 }}>{u.status === "blocked" ? "Block" : "Warning"} Reason</p>
|
||||
<p style={{ fontFamily: f.b, fontSize: 12, color: c.text, margin: 0 }}>{u.blockReason || u.warnReason}</p>
|
||||
</Cd>}
|
||||
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{u.status === "active" && <>
|
||||
<ActionBtn label="Warn User" color={c.orange} onClick={() => setU(p => ({ ...p, status: "warned", warnReason: "Warning issued by admin" }))} icon="⚠️" />
|
||||
<ActionBtn label="Block User" color={c.red} onClick={() => setU(p => ({ ...p, status: "blocked", blockReason: "Blocked by admin" }))} icon="🚫" />
|
||||
</>}
|
||||
{u.status === "warned" && <>
|
||||
<ActionBtn label="Remove Warning" color={c.green} onClick={() => setU(p => ({ ...p, status: "active", warnReason: null }))} icon="✅" />
|
||||
<ActionBtn label="Block User" color={c.red} onClick={() => setU(p => ({ ...p, status: "blocked", blockReason: "Blocked after warning" }))} icon="🚫" />
|
||||
</>}
|
||||
{u.status === "blocked" && <ActionBtn label="Unblock User" color={c.green} onClick={() => setU(p => ({ ...p, status: "active", blockReason: null }))} icon="✅" />}
|
||||
<ActionBtn label="Delete User" color={c.red} onClick={() => {}} icon="🗑️" />
|
||||
</div>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
/* ── App Shell ── */
|
||||
export default function AdminApp() {
|
||||
const [scr, setScr] = useState("dash");
|
||||
const [sel, setSel] = useState(null);
|
||||
|
||||
const go = (screen, data) => { setScr(screen); setSel(data || null); };
|
||||
|
||||
const render = () => {
|
||||
if (scr === "orgDetail" && sel) return <OrgDetail org={sel} onBack={() => go("orgs")} champs={CHAMPS_DATA} />;
|
||||
if (scr === "champDetail" && sel) return <ChampDetail ch={sel} onBack={() => go("champs")} />;
|
||||
if (scr === "userDetail" && sel) return <UserDetail user={sel} onBack={() => go("users")} />;
|
||||
if (scr === "orgs") return <OrgsList orgs={ORGS_DATA} onOrgTap={o => go("orgDetail", o)} />;
|
||||
if (scr === "champs") return <ChampsList champs={CHAMPS_DATA} onChampTap={ch => go("champDetail", ch)} />;
|
||||
if (scr === "users") return <UsersList users={USERS_DATA} onUserTap={u => go("userDetail", u)} />;
|
||||
return <Dashboard orgs={ORGS_DATA} champs={CHAMPS_DATA} users={USERS_DATA} onNav={setScr} />;
|
||||
};
|
||||
|
||||
const showNav = ["dash", "orgs", "champs", "users"].includes(scr);
|
||||
|
||||
return <div style={{ display: "flex", justifyContent: "center", alignItems: "center", minHeight: "100vh", background: "#020106", padding: 20, fontFamily: f.b }}>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;600;700&family=DM+Sans:wght@400;500;600&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet" />
|
||||
<style>{`*::-webkit-scrollbar{display:none}*{scrollbar-width:none}`}</style>
|
||||
<div style={{ width: 375, height: 740, background: c.bg, borderRadius: 36, overflow: "hidden", display: "flex", flexDirection: "column", border: `1.5px solid ${c.brd}`, boxShadow: `0 0 80px rgba(99,102,241,0.06),0 20px 40px rgba(0,0,0,0.5)` }}>
|
||||
<div style={{ padding: "8px 24px", display: "flex", justifyContent: "space-between", alignItems: "center", flexShrink: 0 }}>
|
||||
<span style={{ fontFamily: f.m, fontSize: 11, color: c.dim }}>9:41</span>
|
||||
<div style={{ width: 100, height: 28, background: "#000", borderRadius: 14 }} />
|
||||
<span style={{ fontFamily: f.m, fontSize: 11, color: c.dim }}>●●●</span>
|
||||
</div>
|
||||
<div style={{ flex: 1, overflow: "auto", minHeight: 0 }}>{render()}</div>
|
||||
{showNav && <Nav active={scr} onChange={setScr} />}
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
643
dancechamp-claude-code/prototypes/member-app.jsx
Normal file
643
dancechamp-claude-code/prototypes/member-app.jsx
Normal file
@@ -0,0 +1,643 @@
|
||||
import { useState } from "react";
|
||||
|
||||
/* ── Data ── */
|
||||
const CHAMPS = [
|
||||
{
|
||||
id: "1", name: "Zero Gravity", subtitle: "International Pole Exotic Championship",
|
||||
org: "Zero Gravity Team", dates: "May 30, 2026", location: "Minsk, Belarus",
|
||||
venue: "Prime Hall", address: "Pr. Pobeditelei, 65",
|
||||
disciplines: [
|
||||
{ name: "Exotic Pole Dance", performanceReq: "70% floor & mid-level, 30% upper level", categories: [
|
||||
{ name: "Beginners", duration: "2:00–3:00", eligibility: "Up to 2 yrs, no instructor/pro background", type: "solo" },
|
||||
{ name: "Amateur", duration: "2:30–3:00", eligibility: "2–4 yrs, no instructor/pro background", type: "solo" },
|
||||
{ name: "Semi-Pro", duration: "2:50–3:20", eligibility: "3+ yrs, instructor OR pro OR prizes in Amateur", type: "solo" },
|
||||
{ name: "Profi", duration: "3:00–3:30", eligibility: "4+ yrs, instructor OR pro OR prizes in Semi-Pro", type: "solo" },
|
||||
{ name: "Elite", duration: "3:00–4:00", eligibility: "3+ prizes in Profi OR widely known", type: "solo" },
|
||||
{ name: "Duets & Groups", duration: "3:00–4:20", eligibility: "Open to all levels", type: "group" },
|
||||
]},
|
||||
{ name: "Pole Art", performanceReq: "60% floor & mid-level, 40% upper level", categories: [
|
||||
{ name: "Amateur", duration: "2:30–3:00", eligibility: "Up to 2 yrs, no instructor/pro background", type: "solo" },
|
||||
{ name: "Semi-Pro", duration: "2:50–3:20", eligibility: "3+ yrs, instructor OR pro OR prizes in Amateur", type: "solo" },
|
||||
{ name: "Profi", duration: "3:00–3:30", eligibility: "4+ yrs, instructor OR pro OR prizes in Semi-Pro", type: "solo" },
|
||||
]},
|
||||
],
|
||||
fees: { videoSelection: "50 BYN / 1,500 RUB", championship: { solo: "280 BYN / 7,500 RUB", duet: "210 BYN / 5,800 RUB pp", group: "190 BYN / 4,500 RUB pp" }, refundNote: "Non-refundable. All fees are charitable contributions." },
|
||||
videoReqs: { minDuration: "1:30", editing: "No editing or splicing", maxAge: "Less than 1 year old", note: "Must reflect your level" },
|
||||
judging: [
|
||||
{ name: "Image", max: 10, desc: "Costume, hair, makeup, originality" },
|
||||
{ name: "Artistry", max: 10, desc: "Charisma, stage presence, emotion" },
|
||||
{ name: "Choreography", max: 10, desc: "Body control, complexity, originality" },
|
||||
{ name: "Musicality", max: 10, desc: "Timing, feeling, accent play" },
|
||||
{ name: "Technique", max: 10, desc: "Clean execution, transitions, tricks" },
|
||||
{ name: "Overall", max: 10, desc: "General impression" },
|
||||
{ name: "Synchronicity", max: 10, desc: "Duets only" },
|
||||
],
|
||||
penalties: [
|
||||
{ name: "Missed element", points: -2 }, { name: "Fall", points: -2 },
|
||||
{ name: "Leaving stage", consequence: "DQ" }, { name: "Exposure", consequence: "DQ" },
|
||||
{ name: "Substance influence", consequence: "DQ" }, { name: "No special shoes", consequence: "DQ" },
|
||||
],
|
||||
venueSpecs: { poles: "2 (Static & Spinning)", poleHeight: "3.5 m", poleDiameter: "42 mm", stageSize: "6m × 14m" },
|
||||
costumeRules: ["Neat and well-fitted", "No advertising", "No spikes/sharp objects", "No thongs/sheer/pasties", "Specialized shoes for Exotic", "Creativity is scored"],
|
||||
generalRules: ["Must be 18+", "No medical contraindications", "Valid life & health insurance", "No lotions/bronzers 24h before", "Grip aids allowed (no wax/rosin)", "Judges' decision is final", "Organizers may change your category"],
|
||||
prizes: ["1st–3rd in each category", "Nominations per block", "Medals, diplomas, sponsor prizes", "All get participation diplomas", "1st Elite → judge next champ"],
|
||||
resultsChannels: ["Email", "Instagram", "Telegram"],
|
||||
applicationDeadline: "August 22, 2026",
|
||||
formUrl: "https://docs.google.com/forms/d/e/1FAIpQLSfLaNg5Sf2QMAI6anpMrnLu-2qYfT3tdwh0dsynQFn8xMhi2g/viewform",
|
||||
status: "registration_open", accent: "#D4145A", image: "💃",
|
||||
},
|
||||
{
|
||||
id: "2", name: "Pole Star", subtitle: "National Pole Championship",
|
||||
org: "Pole Star Events", dates: "Jul 12–13, 2026", location: "Moscow, Russia", venue: "Crystal Hall",
|
||||
disciplines: [{ name: "Exotic Pole Dance", categories: [
|
||||
{ name: "Amateur", duration: "2:30–3:00", eligibility: "2–4 years", type: "solo" },
|
||||
{ name: "Profi", duration: "3:00–3:30", eligibility: "4+ years", type: "solo" },
|
||||
]}],
|
||||
fees: { videoSelection: "2,000 RUB", championship: { solo: "8,000 RUB" } },
|
||||
videoReqs: { minDuration: "1:00", editing: "No editing", maxAge: "6 months" },
|
||||
status: "upcoming", applicationDeadline: "Jun 1, 2026", accent: "#7C3AED", image: "⭐",
|
||||
},
|
||||
];
|
||||
|
||||
const STEPS = [
|
||||
{ id: "s1", label: "Review rules & eligibility", icon: "📋", detect: "auto", detectLabel: "Auto: tracked in app" },
|
||||
{ id: "s2", label: "Select category", icon: "🏷️", detect: "auto", detectLabel: "Auto: saved in app" },
|
||||
{ id: "s3", label: "Record video (min 1:30)", icon: "🎬", detect: "manual", detectLabel: "You confirm" },
|
||||
{ id: "s4", label: "Submit video selection form", icon: "📤", detect: "email", detectLabel: "Auto: Gmail confirmation" },
|
||||
{ id: "s5", label: "Pay video selection fee", icon: "💳", warn: true, detect: "receipt", detectLabel: "Upload receipt → Org confirms" },
|
||||
{ id: "s6", label: "Results (auto-detected)", icon: "🤖", detect: "auto", detectLabel: "Auto: Instagram OCR" },
|
||||
{ id: "s7", label: "Pay championship fee", icon: "💰", warn: true, detect: "receipt", detectLabel: "Upload receipt → Org confirms" },
|
||||
{ id: "s8", label: 'Fill "About Me" form', icon: "👤", detect: "email", detectLabel: "Auto: Gmail confirmation" },
|
||||
{ id: "s9", label: "Confirm insurance", icon: "🛡️", detect: "receipt", detectLabel: "Upload doc → Org confirms" },
|
||||
{ id: "s10", label: "Submit music & performance", icon: "🎶", detect: "email", detectLabel: "Auto: Gmail confirmation" },
|
||||
];
|
||||
|
||||
const USER = { name: "Alex", city: "Moscow", disciplines: ["Pole Exotic", "Pole Art"], experienceYears: 3, isInstructor: false, instagram: "@alex_pole" };
|
||||
|
||||
const NOTIFICATIONS = [
|
||||
{ id: "n1", type: "category_change", champ: "Zero Gravity", from: "Amateur", to: "Semi-Pro", field: "Level", date: "Feb 24, 2026", read: false, message: "Your level was changed from Amateur to Semi-Pro by the organizer." },
|
||||
{ id: "n2", type: "payment_confirmed", champ: "Zero Gravity", date: "Feb 23, 2026", read: false, message: "Your video selection fee payment has been confirmed." },
|
||||
{ id: "n3", type: "result", champ: "Zero Gravity", date: "Feb 22, 2026", read: true, message: "Video selection results are out! You passed! 🎉" },
|
||||
{ id: "n4", type: "deadline", champ: "Zero Gravity", date: "Feb 20, 2026", read: true, message: "Reminder: registration deadline is Aug 22, 2026." },
|
||||
];
|
||||
|
||||
const MY_REGISTRATIONS = [
|
||||
{ champId: "1", discipline: "Exotic Pole Dance", category: "Semi-Pro", status: "in_progress", currentStep: 4, stepsCompleted: 3, nextAction: "Submit video selection form", deadline: "Aug 22, 2026" },
|
||||
{ champId: "2", discipline: "Exotic Pole Dance", category: "Profi", status: "planned", currentStep: 1, stepsCompleted: 0, nextAction: "Review rules & eligibility", deadline: "Jun 1, 2026" },
|
||||
];
|
||||
|
||||
/* ── Theme ── */
|
||||
const c = { bg: "#08070D", card: "#12111A", cardH: "#1A1926", brd: "#1F1E2E", text: "#F2F0FA", dim: "#5E5C72", mid: "#8F8DA6", accent: "#D4145A", accentS: "rgba(212,20,90,0.10)", green: "#10B981", greenS: "rgba(16,185,129,0.10)", yellow: "#F59E0B", yellowS: "rgba(245,158,11,0.10)", purple: "#8B5CF6" };
|
||||
const f = { d: "'Playfair Display',Georgia,serif", b: "'DM Sans','Segoe UI',sans-serif", m: "'JetBrains Mono',monospace" };
|
||||
|
||||
/* ── Shared ── */
|
||||
const Badge = ({ status }) => { const m = { registration_open: { l: "REG OPEN", c: c.green, b: c.greenS }, upcoming: { l: "UPCOMING", c: c.yellow, b: c.yellowS } }; const s = m[status] || m.upcoming; return <span style={{ fontFamily: f.m, fontSize: 9, fontWeight: 700, letterSpacing: 1.2, color: s.c, background: s.b, padding: "3px 8px", borderRadius: 4 }}>{s.l}</span>; };
|
||||
const Chip = ({ text, color = c.mid, bg = c.card, border = c.brd }) => <span style={{ fontFamily: f.b, fontSize: 11, fontWeight: 600, color, background: bg, border: `1px solid ${border}`, padding: "4px 10px", borderRadius: 16, whiteSpace: "nowrap" }}>{text}</span>;
|
||||
const Info = ({ icon, text }) => <span style={{ fontFamily: f.b, fontSize: 12, color: c.mid, display: "flex", alignItems: "center", gap: 5 }}><span style={{ fontSize: 13 }}>{icon}</span> {text}</span>;
|
||||
const ST = ({ children, right }) => <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", margin: "0 0 10px" }}><h3 style={{ fontFamily: f.d, fontSize: 14, fontWeight: 700, color: c.mid, margin: 0 }}>{children}</h3>{right && <span style={{ fontFamily: f.m, fontSize: 10, color: c.dim }}>{right}</span>}</div>;
|
||||
const Cd = ({ children, style: s }) => <div style={{ background: c.card, border: `1px solid ${c.brd}`, borderRadius: 14, padding: 16, ...s }}>{children}</div>;
|
||||
|
||||
function Tabs({ tabs, active, onChange, accent: ac }) {
|
||||
return <div style={{ display: "flex", gap: 4, overflowX: "auto", paddingBottom: 2, marginBottom: 14, scrollbarWidth: "none", msOverflowStyle: "none" }}>
|
||||
{tabs.map(t => <div key={t} onClick={() => onChange(t)} style={{ fontFamily: f.m, fontSize: 10, fontWeight: 600, letterSpacing: 0.4, color: active === t ? ac || c.accent : c.dim, background: active === t ? `${ac || c.accent}15` : "transparent", border: `1px solid ${active === t ? `${ac || c.accent}30` : "transparent"}`, padding: "5px 12px", borderRadius: 16, cursor: "pointer", whiteSpace: "nowrap" }}>{t}</div>)}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function Nav({ active, onChange }) {
|
||||
return <div style={{ display: "flex", justifyContent: "space-around", padding: "10px 0 8px", borderTop: `1px solid ${c.brd}`, background: c.bg, flexShrink: 0 }}>
|
||||
{[{ id: "home", i: "🏠", l: "Home" }, { id: "my", i: "🎯", l: "My Champs" }, { id: "search", i: "🔍", l: "Search" }, { id: "profile", i: "👤", l: "Profile" }].map(x =>
|
||||
<div key={x.id} onClick={() => onChange(x.id)} style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 2, cursor: "pointer", opacity: active === x.id ? 1 : 0.35 }}><span style={{ fontSize: 18 }}>{x.i}</span><span style={{ fontFamily: f.m, fontSize: 8, color: c.text, letterSpacing: 0.3 }}>{x.l}</span></div>
|
||||
)}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function Hdr({ title, subtitle, onBack, right }) {
|
||||
return <div style={{ padding: "14px 20px 6px", display: "flex", alignItems: "center", gap: 12 }}>
|
||||
{onBack && <div onClick={onBack} style={{ width: 32, height: 32, borderRadius: 8, background: c.card, border: `1px solid ${c.brd}`, display: "flex", alignItems: "center", justifyContent: "center", cursor: "pointer", fontSize: 15, color: c.text }}>←</div>}
|
||||
<div style={{ flex: 1 }}><h1 style={{ fontFamily: f.d, fontSize: 20, fontWeight: 700, color: c.text, margin: 0 }}>{title}</h1>{subtitle && <p style={{ fontFamily: f.b, fontSize: 11, color: c.dim, margin: "2px 0 0" }}>{subtitle}</p>}</div>
|
||||
{right}
|
||||
</div>;
|
||||
}
|
||||
|
||||
/* ── Home ── */
|
||||
function Home({ onTap, onNotifications }) {
|
||||
const unread = NOTIFICATIONS.filter(n => !n.read).length;
|
||||
return <div>
|
||||
<Hdr title="Dance Hub" subtitle={`Hey ${USER.name} 👋`} right={
|
||||
<div onClick={onNotifications} style={{ position: "relative", width: 36, height: 36, borderRadius: 10, background: c.card, border: `1px solid ${c.brd}`, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 18, cursor: "pointer" }}>
|
||||
🔔
|
||||
{unread > 0 && <div style={{ position: "absolute", top: -4, right: -4, width: 18, height: 18, borderRadius: 9, background: c.accent, display: "flex", alignItems: "center", justifyContent: "center" }}>
|
||||
<span style={{ fontFamily: f.m, fontSize: 9, fontWeight: 700, color: "#fff" }}>{unread}</span>
|
||||
</div>}
|
||||
</div>
|
||||
} />
|
||||
<div style={{ padding: "6px 16px 16px", display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
<div style={{ background: `linear-gradient(135deg,${c.accent}15,${c.accent}05)`, border: `1px solid ${c.accent}25`, borderRadius: 14, padding: "12px 16px" }}>
|
||||
<p style={{ fontFamily: f.b, fontSize: 12, color: c.accent, margin: 0, fontWeight: 600 }}>🔔 Zero Gravity — Deadline: Aug 22!</p>
|
||||
</div>
|
||||
<ST right={`${CHAMPS.length} events`}>Championships</ST>
|
||||
{CHAMPS.map(ch => <ChampCard key={ch.id} ch={ch} onTap={onTap} />)}
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function ChampCard({ ch, onTap }) {
|
||||
const [h, setH] = useState(false);
|
||||
return <div onClick={() => onTap(ch)} onMouseEnter={() => setH(true)} onMouseLeave={() => setH(false)} style={{ background: h ? c.cardH : c.card, border: `1px solid ${c.brd}`, borderRadius: 16, padding: 16, cursor: "pointer", transition: "all 0.2s", transform: h ? "translateY(-2px)" : "none", boxShadow: h ? "0 8px 24px rgba(0,0,0,0.3)" : "none" }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: 10 }}>
|
||||
<div style={{ width: 46, height: 46, borderRadius: 12, background: `linear-gradient(135deg,${ch.accent}20,${ch.accent}40)`, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 22 }}>{ch.image}</div>
|
||||
<Badge status={ch.status} />
|
||||
</div>
|
||||
<h3 style={{ fontFamily: f.d, fontSize: 16, fontWeight: 700, color: c.text, margin: "0 0 2px" }}>{ch.name}</h3>
|
||||
<p style={{ fontFamily: f.b, fontSize: 11, color: c.dim, margin: "0 0 10px" }}>{ch.subtitle}</p>
|
||||
<div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}><Info icon="📅" text={ch.dates} /><Info icon="📍" text={ch.location} /></div>
|
||||
{ch.disciplines && <div style={{ display: "flex", gap: 6, marginTop: 10, flexWrap: "wrap" }}>{ch.disciplines.map(d => <Chip key={d.name} text={d.name} color={ch.accent} bg={`${ch.accent}10`} border={`${ch.accent}25`} />)}</div>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
/* ── Championship Detail ── */
|
||||
function Detail({ ch, onBack, onProgress }) {
|
||||
const [tab, setTab] = useState("Info");
|
||||
const tabs = ["Info", "Categories", "Fees", "Judging", "Rules"];
|
||||
const completedCount = 3; // mock
|
||||
|
||||
return <div style={{ flex: 1, overflow: "auto" }}>
|
||||
<Hdr title={ch.name} subtitle={ch.subtitle} onBack={onBack} />
|
||||
<div style={{ padding: "6px 16px 20px" }}>
|
||||
|
||||
{/* Hero */}
|
||||
<div style={{ background: `linear-gradient(135deg,${ch.accent}15,${ch.accent}05)`, border: `1px solid ${ch.accent}25`, borderRadius: 16, padding: 16, marginBottom: 14 }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 12 }}>
|
||||
<span style={{ fontSize: 32 }}>{ch.image}</span><Badge status={ch.status} />
|
||||
</div>
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: "8px 18px" }}>
|
||||
<Info icon="📅" text={ch.dates} />
|
||||
<Info icon="📍" text={`${ch.venue ? ch.venue + ", " : ""}${ch.location}`} />
|
||||
{ch.applicationDeadline && <Info icon="⏰" text={`Deadline: ${ch.applicationDeadline}`} />}
|
||||
{ch.resultsChannels && <Info icon="📢" text={`Results: ${ch.resultsChannels.join(", ")}`} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Register + Progress buttons */}
|
||||
<div style={{ display: "flex", gap: 8, marginBottom: 14 }}>
|
||||
<div onClick={() => onProgress(ch)} style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center", gap: 6, padding: "11px 12px", borderRadius: 12, background: c.card, border: `1px solid ${c.brd}`, cursor: "pointer" }}>
|
||||
<span style={{ fontSize: 14 }}>📋</span>
|
||||
<span style={{ fontFamily: f.b, fontSize: 12, fontWeight: 600, color: c.text }}>Progress</span>
|
||||
<span style={{ fontFamily: f.m, fontSize: 10, color: c.dim }}>{completedCount}/{STEPS.length}</span>
|
||||
</div>
|
||||
{ch.formUrl && ch.status === "registration_open" && <a href={ch.formUrl} target="_blank" rel="noopener noreferrer" style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center", gap: 6, padding: "11px 12px", borderRadius: 12, background: ch.accent, fontFamily: f.b, fontSize: 12, fontWeight: 700, color: "#fff", textDecoration: "none", cursor: "pointer" }}>
|
||||
✍️ Register
|
||||
</a>}
|
||||
</div>
|
||||
|
||||
<Tabs tabs={tabs} active={tab} onChange={setTab} accent={ch.accent} />
|
||||
|
||||
{/* Info */}
|
||||
{tab === "Info" && <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
{ch.disciplines && <Cd><ST>Disciplines</ST>{ch.disciplines.map(d => <div key={d.name} style={{ marginBottom: 10 }}>
|
||||
<p style={{ fontFamily: f.b, fontSize: 13, fontWeight: 600, color: c.text, margin: "0 0 4px" }}>{d.name}</p>
|
||||
{d.performanceReq && <p style={{ fontFamily: f.b, fontSize: 11, color: c.dim, margin: 0 }}>{d.performanceReq}</p>}
|
||||
<div style={{ display: "flex", gap: 6, flexWrap: "wrap", marginTop: 6 }}>{d.categories.map(cat => <Chip key={cat.name} text={`${cat.name}${cat.type === "group" ? " 👥" : ""}`} />)}</div>
|
||||
</div>)}</Cd>}
|
||||
{ch.videoReqs && <Cd><ST>Video Requirements</ST><div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
<Info icon="⏱" text={`Min duration: ${ch.videoReqs.minDuration}`} />
|
||||
<Info icon="🚫" text={ch.videoReqs.editing} />
|
||||
<Info icon="📅" text={ch.videoReqs.maxAge} />
|
||||
<Info icon="📊" text={ch.videoReqs.note} />
|
||||
</div></Cd>}
|
||||
{ch.prizes && <Cd><ST>Prizes</ST>{ch.prizes.map((p, i) => <p key={i} style={{ fontFamily: f.b, fontSize: 12, color: c.mid, margin: "0 0 4px" }}>🏆 {p}</p>)}</Cd>}
|
||||
</div>}
|
||||
|
||||
{/* Categories */}
|
||||
{tab === "Categories" && ch.disciplines && <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
{ch.disciplines.map(d => <Cd key={d.name}><ST>{d.name}</ST>{d.categories.map(cat => {
|
||||
const match = (USER.experienceYears >= 2 && USER.experienceYears <= 4 && !USER.isInstructor && cat.name === "Amateur") || (USER.experienceYears >= 3 && cat.name === "Semi-Pro") || cat.name === "Duets & Groups";
|
||||
return <div key={cat.name} style={{ padding: "10px 0", borderBottom: `1px solid ${c.brd}` }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<span style={{ fontFamily: f.b, fontSize: 13, fontWeight: 600, color: c.text }}>{cat.name}</span>
|
||||
<div style={{ display: "flex", gap: 6, alignItems: "center" }}>
|
||||
<span style={{ fontFamily: f.m, fontSize: 10, color: c.dim }}>{cat.duration}</span>
|
||||
{match && <span style={{ fontFamily: f.m, fontSize: 9, fontWeight: 700, color: c.green, background: c.greenS, padding: "2px 7px", borderRadius: 4 }}>MATCH</span>}
|
||||
</div>
|
||||
</div>
|
||||
<p style={{ fontFamily: f.b, fontSize: 11, color: c.dim, margin: "4px 0 0" }}>{cat.eligibility}</p>
|
||||
</div>;
|
||||
})}</Cd>)}
|
||||
<Cd style={{ background: `${c.yellow}08`, border: `1px solid ${c.yellow}20` }}><p style={{ fontFamily: f.b, fontSize: 11, color: c.yellow, margin: 0 }}>⚠️ Organizers may change your category if level doesn't match</p></Cd>
|
||||
</div>}
|
||||
|
||||
{/* Fees */}
|
||||
{tab === "Fees" && ch.fees && <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
<Cd><ST>Stage 1: Video Selection</ST>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 6 }}><span style={{ fontFamily: f.b, fontSize: 13, color: c.text }}>Fee</span><span style={{ fontFamily: f.m, fontSize: 13, fontWeight: 700, color: ch.accent }}>{ch.fees.videoSelection}</span></div>
|
||||
<p style={{ fontFamily: f.b, fontSize: 11, color: c.yellow, margin: 0 }}>⚠️ Non-refundable even if you don't pass</p>
|
||||
</Cd>
|
||||
<Cd><ST>Stage 2: Championship (after passing)</ST>
|
||||
{Object.entries(ch.fees.championship).map(([t, a]) => <div key={t} style={{ display: "flex", justifyContent: "space-between", padding: "6px 0", borderBottom: `1px solid ${c.brd}` }}><span style={{ fontFamily: f.b, fontSize: 13, color: c.text, textTransform: "capitalize" }}>{t}</span><span style={{ fontFamily: f.m, fontSize: 13, fontWeight: 700, color: ch.accent }}>{a}</span></div>)}
|
||||
{ch.fees.refundNote && <p style={{ fontFamily: f.b, fontSize: 11, color: c.yellow, margin: "8px 0 0" }}>⚠️ {ch.fees.refundNote}</p>}
|
||||
</Cd>
|
||||
</div>}
|
||||
|
||||
{/* Judging */}
|
||||
{tab === "Judging" && ch.judging && <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
<Cd><ST>Scoring (0–10 each)</ST>{ch.judging.map(j => <div key={j.name} style={{ padding: "8px 0", borderBottom: `1px solid ${c.brd}` }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between" }}><span style={{ fontFamily: f.b, fontSize: 13, fontWeight: 600, color: c.text }}>{j.name}</span><span style={{ fontFamily: f.m, fontSize: 11, color: c.purple }}>0–{j.max}</span></div>
|
||||
<p style={{ fontFamily: f.b, fontSize: 11, color: c.dim, margin: "2px 0 0" }}>{j.desc}</p>
|
||||
</div>)}</Cd>
|
||||
{ch.penalties && <Cd><ST>Penalties</ST>{ch.penalties.map((p, i) => <div key={i} style={{ display: "flex", justifyContent: "space-between", alignItems: "center", padding: "6px 0", borderBottom: `1px solid ${c.brd}` }}>
|
||||
<span style={{ fontFamily: f.b, fontSize: 12, color: c.text }}>{p.name}</span>
|
||||
<span style={{ fontFamily: f.m, fontSize: 10, fontWeight: 700, color: p.consequence ? "#EF4444" : c.yellow, background: p.consequence ? "rgba(239,68,68,0.1)" : c.yellowS, padding: "2px 8px", borderRadius: 4 }}>{p.consequence || `${p.points}`}</span>
|
||||
</div>)}</Cd>}
|
||||
</div>}
|
||||
|
||||
{/* Rules + Venue */}
|
||||
{tab === "Rules" && <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
{ch.generalRules && <Cd><ST>General</ST>{ch.generalRules.map((r, i) => <p key={i} style={{ fontFamily: f.b, fontSize: 12, color: c.mid, margin: "0 0 6px" }}>• {r}</p>)}</Cd>}
|
||||
{ch.costumeRules && <Cd><ST>Costume & Shoes</ST>{ch.costumeRules.map((r, i) => <p key={i} style={{ fontFamily: f.b, fontSize: 12, color: c.mid, margin: "0 0 6px" }}>• {r}</p>)}</Cd>}
|
||||
{ch.venueSpecs && <Cd><ST>Stage & Equipment</ST>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10 }}>{Object.entries(ch.venueSpecs).map(([k, v]) => <div key={k} style={{ background: c.bg, borderRadius: 10, padding: 12 }}><p style={{ fontFamily: f.m, fontSize: 9, color: c.dim, margin: "0 0 4px", letterSpacing: 0.5, textTransform: "uppercase" }}>{k.replace(/([A-Z])/g, " $1")}</p><p style={{ fontFamily: f.b, fontSize: 13, fontWeight: 600, color: c.text, margin: 0 }}>{v}</p></div>)}</div>
|
||||
</Cd>}
|
||||
</div>}
|
||||
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
/* ── Progress Screen (separate full view) ── */
|
||||
function Progress({ ch, onBack }) {
|
||||
const [done, setDone] = useState({ s1: true, s2: true, s3: true });
|
||||
const [uploads, setUploads] = useState({});
|
||||
const [orgConfirmed, setOrgConfirmed] = useState({});
|
||||
const cnt = Object.values(done).filter(Boolean).length;
|
||||
const pct = (cnt / STEPS.length) * 100;
|
||||
|
||||
const detectColors = { auto: { c: c.green, bg: c.greenS, label: "AUTO" }, email: { c: "#60A5FA", bg: "rgba(96,165,250,0.10)", label: "GMAIL" }, receipt: { c: c.yellow, bg: c.yellowS, label: "UPLOAD" }, manual: { c: c.mid, bg: `${c.mid}15`, label: "MANUAL" } };
|
||||
|
||||
const handleUpload = (stepId) => {
|
||||
setUploads(p => ({ ...p, [stepId]: true }));
|
||||
};
|
||||
|
||||
return <div style={{ flex: 1, overflow: "auto" }}>
|
||||
<Hdr title="Progress" subtitle={ch.name} onBack={onBack} />
|
||||
<div style={{ padding: "6px 16px 20px", display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
|
||||
{/* Summary */}
|
||||
<div style={{ background: `linear-gradient(135deg,${ch.accent}15,${ch.accent}05)`, border: `1px solid ${ch.accent}25`, borderRadius: 16, padding: 16 }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 10 }}>
|
||||
<span style={{ fontFamily: f.d, fontSize: 32, fontWeight: 700, color: ch.accent }}>{Math.round(pct)}%</span>
|
||||
<span style={{ fontFamily: f.m, fontSize: 11, color: c.dim }}>{cnt} of {STEPS.length} steps</span>
|
||||
</div>
|
||||
<div style={{ height: 6, background: `${ch.accent}20`, borderRadius: 3, overflow: "hidden" }}>
|
||||
<div style={{ height: "100%", width: `${pct}%`, background: `linear-gradient(90deg,${ch.accent},${ch.accent}BB)`, borderRadius: 3, transition: "width 0.3s" }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>
|
||||
{Object.entries(detectColors).map(([k, v]) =>
|
||||
<span key={k} style={{ fontFamily: f.m, fontSize: 8, fontWeight: 700, color: v.c, background: v.bg, padding: "3px 8px", borderRadius: 4, letterSpacing: 0.5 }}>{v.label}</span>
|
||||
)}
|
||||
<span style={{ fontFamily: f.m, fontSize: 8, fontWeight: 700, color: c.purple, background: `${c.purple}15`, padding: "3px 8px", borderRadius: 4, letterSpacing: 0.5 }}>ORG ✓</span>
|
||||
</div>
|
||||
|
||||
{/* Steps */}
|
||||
<Cd style={{ padding: "4px 10px" }}>
|
||||
{STEPS.map((s, i) => {
|
||||
const d = done[s.id];
|
||||
const isN = !d && cnt === i;
|
||||
const uploaded = uploads[s.id];
|
||||
const confirmed = orgConfirmed[s.id];
|
||||
const dc = detectColors[s.detect];
|
||||
|
||||
return <div key={s.id} style={{ padding: "10px 4px", borderBottom: i < STEPS.length - 1 ? `1px solid ${c.brd}` : "none" }}>
|
||||
{/* Main row */}
|
||||
<div onClick={() => { if (s.detect === "manual" || s.detect === "auto") setDone(p => ({ ...p, [s.id]: !p[s.id] })); }} style={{ display: "flex", alignItems: "center", gap: 10, cursor: s.detect === "manual" || s.detect === "auto" ? "pointer" : "default", background: isN ? `${ch.accent}06` : "transparent", borderRadius: 8, padding: "2px 0" }}>
|
||||
<div style={{
|
||||
width: 26, height: 26, borderRadius: 8, flexShrink: 0,
|
||||
border: `2px solid ${d ? c.green : isN ? ch.accent : c.brd}`,
|
||||
background: d ? c.greenS : "transparent",
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
fontFamily: f.m, fontSize: 11, fontWeight: 700,
|
||||
color: d ? c.green : isN ? ch.accent : c.dim,
|
||||
}}>{d ? "✓" : i + 1}</div>
|
||||
<span style={{ fontSize: 15 }}>{s.icon}</span>
|
||||
<div style={{ flex: 1 }}>
|
||||
<span style={{ fontFamily: f.b, fontSize: 12, color: d ? c.dim : isN ? c.text : c.mid, textDecoration: d ? "line-through" : "none", fontWeight: isN ? 600 : 400 }}>{s.label}</span>
|
||||
</div>
|
||||
{s.warn && !d && <span style={{ fontSize: 10 }}>⚠️</span>}
|
||||
{isN && <span style={{ fontFamily: f.m, fontSize: 8, color: ch.accent, background: c.accentS, padding: "2px 8px", borderRadius: 4, fontWeight: 700 }}>NEXT</span>}
|
||||
</div>
|
||||
|
||||
{/* Detection method + action */}
|
||||
{!d && <div style={{ marginLeft: 36, marginTop: 6, display: "flex", alignItems: "center", gap: 6, flexWrap: "wrap" }}>
|
||||
<span style={{ fontFamily: f.m, fontSize: 8, fontWeight: 700, color: dc.c, background: dc.bg, padding: "2px 7px", borderRadius: 4, letterSpacing: 0.3 }}>{dc.label}</span>
|
||||
<span style={{ fontFamily: f.b, fontSize: 10, color: c.dim }}>{s.detectLabel}</span>
|
||||
</div>}
|
||||
|
||||
{/* Upload action for receipt steps */}
|
||||
{!d && isN && s.detect === "receipt" && <div style={{ marginLeft: 36, marginTop: 8 }}>
|
||||
{!uploaded ? (
|
||||
<div onClick={() => handleUpload(s.id)} style={{ display: "inline-flex", alignItems: "center", gap: 6, padding: "7px 14px", borderRadius: 8, background: `${c.yellow}15`, border: `1px solid ${c.yellow}30`, cursor: "pointer" }}>
|
||||
<span style={{ fontSize: 13 }}>📸</span>
|
||||
<span style={{ fontFamily: f.b, fontSize: 11, fontWeight: 600, color: c.yellow }}>Upload receipt</span>
|
||||
</div>
|
||||
) : !confirmed ? (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<span style={{ fontFamily: f.m, fontSize: 9, fontWeight: 700, color: c.green, background: c.greenS, padding: "2px 8px", borderRadius: 4 }}>📸 Uploaded</span>
|
||||
<span style={{ fontFamily: f.b, fontSize: 10, color: c.dim }}>Waiting for org to confirm...</span>
|
||||
{/* Demo: simulate org confirm */}
|
||||
<span onClick={() => { setOrgConfirmed(p => ({ ...p, [s.id]: true })); setDone(p => ({ ...p, [s.id]: true })); }} style={{ fontFamily: f.m, fontSize: 8, fontWeight: 700, color: c.purple, background: `${c.purple}15`, padding: "2px 8px", borderRadius: 4, cursor: "pointer" }}>Demo: Org ✓</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>}
|
||||
|
||||
{/* Email detection indicator */}
|
||||
{!d && isN && s.detect === "email" && <div style={{ marginLeft: 36, marginTop: 8, display: "flex", alignItems: "center", gap: 6 }}>
|
||||
<span style={{ fontSize: 12 }}>📧</span>
|
||||
<span style={{ fontFamily: f.b, fontSize: 10, color: c.dim }}>Monitoring Gmail for confirmation...</span>
|
||||
{/* Demo: simulate detection */}
|
||||
<span onClick={() => setDone(p => ({ ...p, [s.id]: true }))} style={{ fontFamily: f.m, fontSize: 8, fontWeight: 700, color: "#60A5FA", background: "rgba(96,165,250,0.10)", padding: "2px 8px", borderRadius: 4, cursor: "pointer" }}>Demo: Detected</span>
|
||||
</div>}
|
||||
|
||||
{/* Auto completed indicator */}
|
||||
{d && s.detect === "auto" && <div style={{ marginLeft: 36, marginTop: 4 }}>
|
||||
<span style={{ fontFamily: f.m, fontSize: 9, color: c.green }}>✓ Auto-detected</span>
|
||||
</div>}
|
||||
{d && s.detect === "email" && <div style={{ marginLeft: 36, marginTop: 4 }}>
|
||||
<span style={{ fontFamily: f.m, fontSize: 9, color: "#60A5FA" }}>✓ Gmail confirmation received</span>
|
||||
</div>}
|
||||
{d && s.detect === "receipt" && <div style={{ marginLeft: 36, marginTop: 4 }}>
|
||||
<span style={{ fontFamily: f.m, fontSize: 9, color: c.purple }}>✓ Receipt uploaded · Org confirmed</span>
|
||||
</div>}
|
||||
</div>;
|
||||
})}
|
||||
</Cd>
|
||||
|
||||
{/* Auto-detection monitoring */}
|
||||
<Cd style={{ background: `${c.purple}08`, border: `1px solid ${c.purple}20` }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 12 }}>
|
||||
<span style={{ fontSize: 20 }}>🤖</span>
|
||||
<div>
|
||||
<p style={{ fontFamily: f.b, fontSize: 14, fontWeight: 600, color: c.text, margin: 0 }}>Auto-Detection Active</p>
|
||||
<p style={{ fontFamily: f.b, fontSize: 11, color: c.dim, margin: "2px 0 0" }}>Monitoring multiple channels</p>
|
||||
</div>
|
||||
</div>
|
||||
{[
|
||||
{ ch: "Instagram", icon: "📸", desc: "Results photo OCR", status: "Monitoring" },
|
||||
{ ch: "Gmail", icon: "📧", desc: "Form confirmations & results", status: "Connected" },
|
||||
{ ch: "Telegram", icon: "💬", desc: "Championship chat", status: "Monitoring" },
|
||||
].map(x => <div key={x.ch} style={{ display: "flex", alignItems: "center", gap: 10, padding: "10px 12px", background: c.card, borderRadius: 10, marginBottom: 6 }}>
|
||||
<span style={{ fontSize: 16 }}>{x.icon}</span>
|
||||
<div style={{ flex: 1 }}>
|
||||
<p style={{ fontFamily: f.b, fontSize: 12, fontWeight: 500, color: c.text, margin: 0 }}>{x.ch}</p>
|
||||
<p style={{ fontFamily: f.b, fontSize: 10, color: c.dim, margin: "2px 0 0" }}>{x.desc}</p>
|
||||
</div>
|
||||
<span style={{ fontFamily: f.m, fontSize: 9, fontWeight: 700, color: x.status === "Connected" ? c.green : c.yellow, background: x.status === "Connected" ? c.greenS : c.yellowS, padding: "3px 8px", borderRadius: 4 }}>{x.status}</span>
|
||||
</div>)}
|
||||
</Cd>
|
||||
|
||||
{/* Register */}
|
||||
{ch.formUrl && ch.status === "registration_open" && <a href={ch.formUrl} target="_blank" rel="noopener noreferrer" style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 6, padding: "14px", borderRadius: 12, background: ch.accent, fontFamily: f.b, fontSize: 14, fontWeight: 700, color: "#fff", textDecoration: "none" }}>
|
||||
✍️ Register Now
|
||||
</a>}
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
/* ── My Championships ── */
|
||||
function MyChamps({ onTap, onProgress }) {
|
||||
const active = MY_REGISTRATIONS.filter(r => r.status === "in_progress");
|
||||
const planned = MY_REGISTRATIONS.filter(r => r.status === "planned");
|
||||
const completed = MY_REGISTRATIONS.filter(r => r.status === "completed");
|
||||
|
||||
const RegCard = ({ reg }) => {
|
||||
const ch = CHAMPS.find(c2 => c2.id === reg.champId);
|
||||
if (!ch) return null;
|
||||
const pct = (reg.stepsCompleted / STEPS.length) * 100;
|
||||
const statusMap = { in_progress: { label: "IN PROGRESS", color: c.green, bg: c.greenS }, planned: { label: "PLANNED", color: c.yellow, bg: c.yellowS }, completed: { label: "COMPLETED", color: c.purple, bg: `${c.purple}15` } };
|
||||
const st = statusMap[reg.status];
|
||||
|
||||
return <Cd style={{ padding: 0, overflow: "hidden" }}>
|
||||
{/* Color accent bar */}
|
||||
<div style={{ height: 3, background: `linear-gradient(90deg,${ch.accent},${ch.accent}88)` }} />
|
||||
<div style={{ padding: 14 }}>
|
||||
{/* Header */}
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: 10 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||
<div style={{ width: 40, height: 40, borderRadius: 10, background: `linear-gradient(135deg,${ch.accent}20,${ch.accent}40)`, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 18 }}>{ch.image}</div>
|
||||
<div>
|
||||
<h3 style={{ fontFamily: f.d, fontSize: 15, fontWeight: 700, color: c.text, margin: 0 }}>{ch.name}</h3>
|
||||
<p style={{ fontFamily: f.b, fontSize: 11, color: c.dim, margin: "1px 0 0" }}>{ch.dates} · {ch.location}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span style={{ fontFamily: f.m, fontSize: 8, fontWeight: 700, letterSpacing: 0.8, color: st.color, background: st.bg, padding: "3px 8px", borderRadius: 4 }}>{st.label}</span>
|
||||
</div>
|
||||
|
||||
{/* Category */}
|
||||
<div style={{ display: "flex", gap: 6, marginBottom: 12 }}>
|
||||
<Chip text={reg.discipline} color={ch.accent} bg={`${ch.accent}10`} border={`${ch.accent}25`} />
|
||||
<Chip text={reg.category} />
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div style={{ marginBottom: 10 }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 4 }}>
|
||||
<span style={{ fontFamily: f.m, fontSize: 10, color: c.dim }}>{reg.stepsCompleted}/{STEPS.length} steps</span>
|
||||
<span style={{ fontFamily: f.m, fontSize: 10, color: ch.accent }}>{Math.round(pct)}%</span>
|
||||
</div>
|
||||
<div style={{ height: 4, background: c.brd, borderRadius: 2, overflow: "hidden" }}>
|
||||
<div style={{ height: "100%", width: `${pct}%`, background: ch.accent, borderRadius: 2, transition: "width 0.3s" }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Next action */}
|
||||
{reg.nextAction && <div style={{ background: `${ch.accent}08`, border: `1px solid ${ch.accent}15`, borderRadius: 10, padding: "10px 12px", marginBottom: 10 }}>
|
||||
<p style={{ fontFamily: f.m, fontSize: 9, color: c.dim, margin: "0 0 3px", letterSpacing: 0.5, textTransform: "uppercase" }}>Next step</p>
|
||||
<p style={{ fontFamily: f.b, fontSize: 12, fontWeight: 600, color: c.text, margin: 0 }}>{STEPS[reg.currentStep - 1]?.icon} {reg.nextAction}</p>
|
||||
</div>}
|
||||
|
||||
{/* Deadline */}
|
||||
{reg.deadline && <div style={{ display: "flex", alignItems: "center", gap: 6, marginBottom: 12 }}>
|
||||
<span style={{ fontSize: 12 }}>⏰</span>
|
||||
<span style={{ fontFamily: f.b, fontSize: 11, color: c.yellow }}>Deadline: {reg.deadline}</span>
|
||||
</div>}
|
||||
|
||||
{/* Actions */}
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<div onClick={() => onTap(ch)} style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center", gap: 5, padding: "9px", borderRadius: 10, background: c.bg, border: `1px solid ${c.brd}`, cursor: "pointer" }}>
|
||||
<span style={{ fontSize: 12 }}>ℹ️</span>
|
||||
<span style={{ fontFamily: f.b, fontSize: 11, fontWeight: 600, color: c.mid }}>Details</span>
|
||||
</div>
|
||||
<div onClick={() => onProgress(ch)} style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center", gap: 5, padding: "9px", borderRadius: 10, background: ch.accent, cursor: "pointer" }}>
|
||||
<span style={{ fontSize: 12 }}>📋</span>
|
||||
<span style={{ fontFamily: f.b, fontSize: 11, fontWeight: 700, color: "#fff" }}>Progress</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Cd>;
|
||||
};
|
||||
|
||||
return <div>
|
||||
<Hdr title="My Championships" subtitle={`${MY_REGISTRATIONS.length} registrations`} />
|
||||
<div style={{ padding: "6px 16px 16px", display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
|
||||
{active.length > 0 && <>
|
||||
<ST right={`${active.length}`}>Active</ST>
|
||||
{active.map(r => <RegCard key={r.champId} reg={r} />)}
|
||||
</>}
|
||||
|
||||
{planned.length > 0 && <>
|
||||
<ST right={`${planned.length}`}>Planned</ST>
|
||||
{planned.map(r => <RegCard key={r.champId} reg={r} />)}
|
||||
</>}
|
||||
|
||||
{completed.length > 0 && <>
|
||||
<ST right={`${completed.length}`}>Completed</ST>
|
||||
{completed.map(r => <RegCard key={r.champId} reg={r} />)}
|
||||
</>}
|
||||
|
||||
{MY_REGISTRATIONS.length === 0 && <div style={{ textAlign: "center", padding: "60px 20px" }}>
|
||||
<span style={{ fontSize: 40 }}>🔍</span>
|
||||
<p style={{ fontFamily: f.b, fontSize: 14, color: c.mid, margin: "12px 0 4px" }}>No championships yet</p>
|
||||
<p style={{ fontFamily: f.b, fontSize: 12, color: c.dim }}>Browse championships and start your journey!</p>
|
||||
</div>}
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
/* ── Notifications ── */
|
||||
function Notifications({ onBack }) {
|
||||
const [notifs, setNotifs] = useState(NOTIFICATIONS);
|
||||
const markRead = (id) => setNotifs(p => p.map(n => n.id === id ? { ...n, read: true } : n));
|
||||
const markAllRead = () => setNotifs(p => p.map(n => ({ ...n, read: true })));
|
||||
const unread = notifs.filter(n => !n.read).length;
|
||||
|
||||
const typeConfig = {
|
||||
category_change: { icon: "🔄", color: c.yellow, label: "Category Changed" },
|
||||
payment_confirmed: { icon: "✅", color: c.green, label: "Payment Confirmed" },
|
||||
result: { icon: "🏆", color: c.accent, label: "Results" },
|
||||
deadline: { icon: "⏰", color: c.yellow, label: "Deadline Reminder" },
|
||||
style_change: { icon: "🔄", color: c.purple, label: "Style Changed" },
|
||||
registration_confirmed: { icon: "📋", color: c.green, label: "Registration" },
|
||||
announcement: { icon: "📢", color: c.blue, label: "Announcement" },
|
||||
};
|
||||
|
||||
return <div>
|
||||
<Hdr title="Notifications" subtitle={unread > 0 ? `${unread} unread` : "All caught up ✓"} onBack={onBack} right={
|
||||
unread > 0 ? <div onClick={markAllRead} style={{ fontFamily: f.b, fontSize: 11, color: c.accent, cursor: "pointer", padding: "4px 8px" }}>Read all</div> : null
|
||||
} />
|
||||
<div style={{ padding: "6px 16px 16px", display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
{notifs.length === 0 && <div style={{ textAlign: "center", padding: "60px 20px" }}>
|
||||
<span style={{ fontSize: 40 }}>🔕</span>
|
||||
<p style={{ fontFamily: f.b, fontSize: 14, color: c.mid, margin: "12px 0 4px" }}>No notifications</p>
|
||||
<p style={{ fontFamily: f.b, fontSize: 12, color: c.dim }}>You'll see updates from championships here</p>
|
||||
</div>}
|
||||
{notifs.map(n => {
|
||||
const tc = typeConfig[n.type] || typeConfig.announcement;
|
||||
return <div key={n.id} onClick={() => markRead(n.id)} style={{
|
||||
display: "flex", gap: 12, padding: "12px 14px", borderRadius: 12, cursor: "pointer",
|
||||
background: n.read ? c.card : `${tc.color}08`,
|
||||
border: `1px solid ${n.read ? c.brd : `${tc.color}20`}`,
|
||||
}}>
|
||||
<div style={{ width: 36, height: 36, borderRadius: 10, background: `${tc.color}15`, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 16, flexShrink: 0 }}>{tc.icon}</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 3 }}>
|
||||
<span style={{ fontFamily: f.m, fontSize: 9, fontWeight: 700, color: tc.color, letterSpacing: 0.5 }}>{tc.label}</span>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
||||
<span style={{ fontFamily: f.m, fontSize: 9, color: c.dim }}>{n.date}</span>
|
||||
{!n.read && <div style={{ width: 7, height: 7, borderRadius: 4, background: c.accent }} />}
|
||||
</div>
|
||||
</div>
|
||||
<p style={{ fontFamily: f.b, fontSize: 12, color: n.read ? c.mid : c.text, margin: 0, lineHeight: 1.4 }}>{n.message}</p>
|
||||
<p style={{ fontFamily: f.m, fontSize: 10, color: c.dim, margin: "4px 0 0" }}>{n.champ}</p>
|
||||
</div>
|
||||
</div>;
|
||||
})}
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
/* ── Search ── */
|
||||
function Search({ onTap }) {
|
||||
const [q, setQ] = useState("");
|
||||
const [fl, setFl] = useState("all");
|
||||
const fs = [{ id: "all", l: "All" }, { id: "registration_open", l: "Open" }, { id: "upcoming", l: "Upcoming" }];
|
||||
const res = CHAMPS.filter(ch => (!q || ch.name.toLowerCase().includes(q.toLowerCase()) || ch.location.toLowerCase().includes(q.toLowerCase())) && (fl === "all" || ch.status === fl));
|
||||
return <div>
|
||||
<Hdr title="Discover" subtitle="Find your next championship" />
|
||||
<div style={{ padding: "4px 16px 16px" }}>
|
||||
<div style={{ background: c.card, border: `1px solid ${c.brd}`, borderRadius: 12, padding: "10px 14px", display: "flex", alignItems: "center", gap: 10, marginBottom: 12 }}>
|
||||
<span style={{ fontSize: 15, opacity: 0.4 }}>🔍</span>
|
||||
<input type="text" placeholder="Search..." value={q} onChange={e => setQ(e.target.value)} style={{ background: "transparent", border: "none", outline: "none", color: c.text, fontFamily: f.b, fontSize: 13, width: "100%" }} />
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 6, marginBottom: 14 }}>{fs.map(x => <div key={x.id} onClick={() => setFl(x.id)} style={{ fontFamily: f.m, fontSize: 10, fontWeight: 600, color: fl === x.id ? c.accent : c.dim, background: fl === x.id ? c.accentS : c.card, border: `1px solid ${fl === x.id ? `${c.accent}30` : c.brd}`, padding: "5px 12px", borderRadius: 16, cursor: "pointer" }}>{x.l}</div>)}</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>{res.length ? res.map(ch => <ChampCard key={ch.id} ch={ch} onTap={onTap} />) : <div style={{ textAlign: "center", padding: 40 }}><span style={{ fontSize: 28 }}>🤷</span><p style={{ fontFamily: f.b, fontSize: 13, color: c.dim, marginTop: 8 }}>No results</p></div>}</div>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
/* ── Profile ── */
|
||||
function Profile() {
|
||||
return <div>
|
||||
<Hdr title="Profile" />
|
||||
<div style={{ padding: "6px 20px 20px" }}>
|
||||
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", marginBottom: 24 }}>
|
||||
<div style={{ width: 68, height: 68, borderRadius: 18, background: `linear-gradient(135deg,${c.accent}25,${c.accent}10)`, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 30, marginBottom: 10, border: `2px solid ${c.accent}35` }}>💃</div>
|
||||
<h2 style={{ fontFamily: f.d, fontSize: 19, fontWeight: 700, color: c.text, margin: "0 0 2px" }}>{USER.name}</h2>
|
||||
<p style={{ fontFamily: f.m, fontSize: 11, color: c.accent, margin: 0 }}>{USER.instagram}</p>
|
||||
</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8, marginBottom: 20 }}>
|
||||
{[{ i: "📍", l: "City", v: USER.city }, { i: "💃", l: "Disciplines", v: USER.disciplines.join(", ") }, { i: "📅", l: "Experience", v: `${USER.experienceYears} years` }, { i: "🎓", l: "Instructor", v: USER.isInstructor ? "Yes" : "No" }].map(r =>
|
||||
<Cd key={r.l} style={{ padding: "11px 14px", display: "flex", alignItems: "center", gap: 12 }}><span style={{ fontSize: 17 }}>{r.i}</span><div><p style={{ fontFamily: f.m, fontSize: 9, color: c.dim, margin: 0, letterSpacing: 0.5, textTransform: "uppercase" }}>{r.l}</p><p style={{ fontFamily: f.b, fontSize: 13, color: c.text, margin: "2px 0 0" }}>{r.v}</p></div></Cd>
|
||||
)}
|
||||
</div>
|
||||
<ST>Eligible Categories</ST>
|
||||
<Cd style={{ marginBottom: 20 }}>{["Amateur (Exotic)", "Semi-Pro (Exotic)", "Duets & Groups", "Amateur (Pole Art)", "Semi-Pro (Pole Art)"].map(cat =>
|
||||
<div key={cat} style={{ display: "flex", alignItems: "center", gap: 8, padding: "7px 0", borderBottom: `1px solid ${c.brd}` }}><span style={{ fontFamily: f.m, fontSize: 9, fontWeight: 700, color: c.green, background: c.greenS, padding: "2px 7px", borderRadius: 4 }}>✓</span><span style={{ fontFamily: f.b, fontSize: 12, color: c.text }}>{cat}</span></div>
|
||||
)}</Cd>
|
||||
<ST>Stats</ST>
|
||||
<div style={{ display: "flex", gap: 8, marginBottom: 20 }}>{[{ n: "2", l: "Champs", co: c.accent }, { n: "1", l: "Passed", co: c.green }, { n: "1", l: "Pending", co: c.yellow }].map(s =>
|
||||
<div key={s.l} style={{ flex: 1, background: c.card, border: `1px solid ${c.brd}`, borderRadius: 12, padding: "14px 8px", textAlign: "center" }}><p style={{ fontFamily: f.d, fontSize: 24, fontWeight: 700, color: s.co, margin: 0 }}>{s.n}</p><p style={{ fontFamily: f.m, fontSize: 9, color: c.dim, margin: "4px 0 0", textTransform: "uppercase" }}>{s.l}</p></div>
|
||||
)}</div>
|
||||
<div style={{ background: c.card, border: `1px solid ${c.brd}`, borderRadius: 12, overflow: "hidden" }}>{["Edit Profile", "Competition History", "Notifications", "Settings", "Log Out"].map((x, i, a) =>
|
||||
<div key={x} style={{ padding: "13px 16px", fontFamily: f.b, fontSize: 13, color: x === "Log Out" ? "#EF4444" : c.text, borderBottom: i < a.length - 1 ? `1px solid ${c.brd}` : "none", cursor: "pointer", display: "flex", justifyContent: "space-between" }}>{x}<span style={{ color: c.dim }}>›</span></div>
|
||||
)}</div>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
/* ── App Shell ── */
|
||||
export default function App() {
|
||||
const [scr, setScr] = useState("home");
|
||||
const [sel, setSel] = useState(null);
|
||||
const [prev, setPrev] = useState("home");
|
||||
|
||||
const go = (s, ch) => { setPrev(scr); setScr(s); if (ch) setSel(ch); };
|
||||
const goBack = () => { setScr(prev || "home"); setSel(null); };
|
||||
|
||||
const render = () => {
|
||||
if (scr === "progress" && sel) return <Progress ch={sel} onBack={() => go("detail")} />;
|
||||
if (scr === "detail" && sel) return <Detail ch={sel} onBack={goBack} onProgress={ch => go("progress", ch)} />;
|
||||
if (scr === "notifications") return <Notifications onBack={() => go("home")} />;
|
||||
if (scr === "my") return <MyChamps onTap={ch => go("detail", ch)} onProgress={ch => go("progress", ch)} />;
|
||||
if (scr === "search") return <Search onTap={ch => go("detail", ch)} />;
|
||||
if (scr === "profile") return <Profile />;
|
||||
return <Home onTap={ch => go("detail", ch)} onNotifications={() => go("notifications")} />;
|
||||
};
|
||||
|
||||
const showNav = scr === "home" || scr === "search" || scr === "profile" || scr === "my";
|
||||
|
||||
return <div style={{ display: "flex", justifyContent: "center", alignItems: "center", minHeight: "100vh", background: "#030206", padding: 20, fontFamily: f.b }}>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;600;700&family=DM+Sans:wght@400;500;600&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet" />
|
||||
<style>{`*::-webkit-scrollbar{display:none}*{scrollbar-width:none}`}</style>
|
||||
<div style={{ width: 375, height: 740, background: c.bg, borderRadius: 36, overflow: "hidden", display: "flex", flexDirection: "column", border: `1.5px solid ${c.brd}`, boxShadow: `0 0 80px rgba(212,20,90,0.06),0 20px 40px rgba(0,0,0,0.5)` }}>
|
||||
<div style={{ padding: "8px 24px", display: "flex", justifyContent: "space-between", alignItems: "center", flexShrink: 0 }}>
|
||||
<span style={{ fontFamily: f.m, fontSize: 11, color: c.dim }}>9:41</span>
|
||||
<div style={{ width: 100, height: 28, background: "#000", borderRadius: 14 }} />
|
||||
<span style={{ fontFamily: f.m, fontSize: 11, color: c.dim }}>●●●</span>
|
||||
</div>
|
||||
<div style={{ flex: 1, overflow: "auto", minHeight: 0 }}>{render()}</div>
|
||||
{showNav && <Nav active={scr} onChange={s => { setScr(s); setSel(null); }} />}
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
683
dancechamp-claude-code/prototypes/org-app.jsx
Normal file
683
dancechamp-claude-code/prototypes/org-app.jsx
Normal file
@@ -0,0 +1,683 @@
|
||||
import { useState } from "react";
|
||||
|
||||
/* ── Data ── */
|
||||
const ORG = { name: "Zero Gravity Team", instagram: "@zerogravity_pole", logo: "💃" };
|
||||
|
||||
const makeCh = (overrides) => ({
|
||||
id: "", name: "", subtitle: "", eventDate: "", regStart: "", regEnd: "", location: "", venue: "", accent: "#D4145A", image: "💃", status: "draft",
|
||||
disciplines: [], styles: [], fees: null, judging: [], penalties: [], judges: [], rules: [], costumeRules: [], members: [],
|
||||
formUrl: "", rulesUrl: "",
|
||||
configured: { info: false, categories: false, fees: false, rules: false, judging: false },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const INITIAL_CHAMPS = [
|
||||
makeCh({
|
||||
id: "ch1", name: "Zero Gravity", subtitle: "International Pole Exotic Championship",
|
||||
eventDate: "May 30, 2026", regStart: "Feb 1, 2026", regEnd: "Apr 22, 2026", location: "Minsk, Belarus", venue: "Prime Hall", status: "registration_open", accent: "#D4145A", image: "💃",
|
||||
disciplines: [
|
||||
{ name: "Exotic Pole Dance", levels: ["Beginners", "Amateur", "Semi-Pro", "Profi", "Elite", "Duets & Groups"] },
|
||||
{ name: "Pole Art", levels: ["Amateur", "Semi-Pro", "Profi"] },
|
||||
],
|
||||
styles: ["Classic", "Flow", "Theater"],
|
||||
fees: { videoSelection: "50 BYN / 1,500 RUB", solo: "280 BYN / 7,500 RUB", duet: "210 BYN / 5,800 RUB pp", group: "190 BYN / 4,500 RUB pp" },
|
||||
judging: [{ name: "Image", max: 10 }, { name: "Artistry", max: 10 }, { name: "Choreography", max: 10 }, { name: "Musicality", max: 10 }, { name: "Technique", max: 10 }, { name: "Overall", max: 10 }],
|
||||
penalties: [{ name: "Missed element", val: "-2" }, { name: "Fall", val: "-2" }, { name: "Leaving stage", val: "DQ" }, { name: "Exposure", val: "DQ" }],
|
||||
judges: [
|
||||
{ id: "j1", name: "Anastasia Skukhtorova", instagram: "@skukhtorova", bio: "World Pole Art Champion. International judge with 10+ years experience." },
|
||||
{ id: "j2", name: "Marion Crampe", instagram: "@marioncrampe", bio: "Pole Art legend, multiple international championship winner and judge." },
|
||||
{ id: "j3", name: "Dmitry Politov", instagram: "@dmitry_politov", bio: "World Pole Sports Champion. Certified IPSF judge." },
|
||||
],
|
||||
rules: ["Must be 18+", "Valid life & health insurance", "No lotions/bronzers 24h before", "Grip aids allowed (no wax/rosin)", "Judges' decision is final"],
|
||||
costumeRules: ["Neat and well-fitted", "No advertising", "No spikes/sharp objects", "Specialized shoes for Exotic"],
|
||||
configured: { info: true, categories: true, fees: true, rules: true, judging: true },
|
||||
members: [
|
||||
{ id: "m1", name: "Alex Petrova", instagram: "@alex_pole", level: "Semi-Pro", style: "Classic", discipline: "Exotic Pole Dance", type: "solo", stepsCompleted: 3, videoUrl: "https://youtube.com/...", feePaid: false, receiptUploaded: true, insuranceUploaded: false, passed: null, city: "Moscow" },
|
||||
{ id: "m2", name: "Maria Ivanova", instagram: "@maria_exotic", level: "Amateur", style: "Flow", discipline: "Exotic Pole Dance", type: "solo", stepsCompleted: 5, videoUrl: "https://youtube.com/...", feePaid: true, receiptUploaded: true, insuranceUploaded: true, passed: true, city: "Minsk" },
|
||||
{ id: "m3", name: "Elena Kozlova", instagram: "@elena.pole", level: "Profi", style: "Theater", discipline: "Exotic Pole Dance", type: "solo", stepsCompleted: 5, videoUrl: "https://youtube.com/...", feePaid: true, receiptUploaded: true, insuranceUploaded: false, passed: true, city: "St. Petersburg" },
|
||||
{ id: "m4", name: "Daria Sokolova", instagram: "@daria_art", level: "Amateur", style: "Classic", discipline: "Pole Art", type: "solo", stepsCompleted: 4, videoUrl: "https://youtube.com/...", feePaid: false, receiptUploaded: true, insuranceUploaded: false, passed: null, city: "Kyiv" },
|
||||
{ id: "m5", name: "Anna Belova", instagram: "@anna.b_pole", level: "Beginners", style: "Flow", discipline: "Exotic Pole Dance", type: "solo", stepsCompleted: 2, videoUrl: null, feePaid: false, receiptUploaded: false, insuranceUploaded: false, passed: null, city: "Minsk" },
|
||||
{ id: "m6", name: "Olga Morozova", instagram: "@olga_exotic", level: "Elite", style: "Classic", discipline: "Exotic Pole Dance", type: "solo", stepsCompleted: 5, videoUrl: "https://youtube.com/...", feePaid: true, receiptUploaded: true, insuranceUploaded: true, passed: false, city: "Moscow" },
|
||||
{ id: "m7", name: "Katya & Nina", instagram: "@katya_nina", level: "Semi-Pro", style: "Theater", discipline: "Exotic Pole Dance", type: "duet", stepsCompleted: 5, videoUrl: "https://youtube.com/...", feePaid: true, receiptUploaded: true, insuranceUploaded: false, passed: null, city: "Kazan" },
|
||||
],
|
||||
}),
|
||||
makeCh({
|
||||
id: "ch2", name: "Pole Star", subtitle: "National Pole Championship",
|
||||
eventDate: "Jul 12–13, 2026", regStart: "", regEnd: "", location: "Moscow, Russia", venue: "Crystal Hall", status: "draft", accent: "#7C3AED", image: "⭐",
|
||||
configured: { info: true, categories: false, fees: false, rules: false, judging: false },
|
||||
members: [],
|
||||
}),
|
||||
];
|
||||
|
||||
/* ── Theme ── */
|
||||
const c = { bg: "#08070D", card: "#12111A", cardH: "#1A1926", brd: "#1F1E2E", text: "#F2F0FA", dim: "#5E5C72", mid: "#8F8DA6", accent: "#D4145A", accentS: "rgba(212,20,90,0.10)", green: "#10B981", greenS: "rgba(16,185,129,0.10)", yellow: "#F59E0B", yellowS: "rgba(245,158,11,0.10)", purple: "#8B5CF6", purpleS: "rgba(139,92,246,0.10)", blue: "#60A5FA", blueS: "rgba(96,165,250,0.10)", red: "#EF4444", redS: "rgba(239,68,68,0.10)" };
|
||||
const f = { d: "'Playfair Display',Georgia,serif", b: "'DM Sans','Segoe UI',sans-serif", m: "'JetBrains Mono',monospace" };
|
||||
|
||||
/* ── Shared UI ── */
|
||||
const Cd = ({ children, style: s }) => <div style={{ background: c.card, border: `1px solid ${c.brd}`, borderRadius: 14, padding: 16, ...s }}>{children}</div>;
|
||||
const ST = ({ children, right }) => <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", margin: "0 0 10px" }}><h3 style={{ fontFamily: f.d, fontSize: 14, fontWeight: 700, color: c.mid, margin: 0 }}>{children}</h3>{right}</div>;
|
||||
const Bg = ({ label, color, bg }) => <span style={{ fontFamily: f.m, fontSize: 8, fontWeight: 700, letterSpacing: 0.8, color, background: bg, padding: "3px 8px", borderRadius: 4 }}>{label}</span>;
|
||||
|
||||
function Hdr({ title, subtitle, onBack, right }) {
|
||||
return <div style={{ padding: "14px 20px 6px", display: "flex", alignItems: "center", gap: 12 }}>
|
||||
{onBack && <div onClick={onBack} style={{ width: 32, height: 32, borderRadius: 8, background: c.card, border: `1px solid ${c.brd}`, display: "flex", alignItems: "center", justifyContent: "center", cursor: "pointer", fontSize: 15, color: c.text }}>←</div>}
|
||||
<div style={{ flex: 1, minWidth: 0 }}><h1 style={{ fontFamily: f.d, fontSize: 20, fontWeight: 700, color: c.text, margin: 0 }}>{title}</h1>{subtitle && <p style={{ fontFamily: f.b, fontSize: 11, color: c.dim, margin: "2px 0 0", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{subtitle}</p>}</div>
|
||||
{right}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function Tabs({ tabs, active, onChange, accent: ac }) {
|
||||
return <div style={{ display: "flex", gap: 3, overflowX: "auto", paddingBottom: 2, marginBottom: 14, scrollbarWidth: "none" }}>
|
||||
{tabs.map(t => <div key={t.id} onClick={() => onChange(t.id)} style={{ fontFamily: f.m, fontSize: 9, fontWeight: 600, letterSpacing: 0.3, display: "flex", alignItems: "center", gap: 4, color: active === t.id ? ac || c.accent : c.dim, background: active === t.id ? `${ac || c.accent}15` : "transparent", border: `1px solid ${active === t.id ? `${ac || c.accent}30` : "transparent"}`, padding: "5px 10px", borderRadius: 16, cursor: "pointer", whiteSpace: "nowrap" }}>
|
||||
{t.done !== undefined && <span style={{ width: 6, height: 6, borderRadius: 3, background: t.done ? c.green : c.yellow, flexShrink: 0 }} />}
|
||||
{t.label}
|
||||
</div>)}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function Nav({ active, onChange }) {
|
||||
return <div style={{ display: "flex", justifyContent: "space-around", padding: "10px 0 8px", borderTop: `1px solid ${c.brd}`, background: c.bg, flexShrink: 0 }}>
|
||||
{[{ id: "dash", i: "📊", l: "Dashboard" }, { id: "orgSettings", i: "⚙️", l: "Settings" }].map(x =>
|
||||
<div key={x.id} onClick={() => onChange(x.id)} style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 2, cursor: "pointer", opacity: active === x.id ? 1 : 0.35 }}><span style={{ fontSize: 18 }}>{x.i}</span><span style={{ fontFamily: f.m, fontSize: 8, color: c.text, letterSpacing: 0.3 }}>{x.l}</span></div>
|
||||
)}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function Input({ label, value, onChange, placeholder }) {
|
||||
return <div style={{ marginBottom: 12 }}>
|
||||
<p style={{ fontFamily: f.m, fontSize: 9, color: c.dim, margin: "0 0 6px", letterSpacing: 0.5, textTransform: "uppercase" }}>{label}</p>
|
||||
<input type="text" value={value || ""} onChange={e => onChange(e.target.value)} placeholder={placeholder} style={{ width: "100%", padding: "10px 12px", borderRadius: 10, background: c.bg, border: `1px solid ${c.brd}`, color: c.text, fontFamily: f.b, fontSize: 13, outline: "none", boxSizing: "border-box" }} />
|
||||
</div>;
|
||||
}
|
||||
|
||||
function TagEditor({ items, onAdd, onRemove, color, placeholder, addLabel }) {
|
||||
const [val, setVal] = useState("");
|
||||
const submit = () => { if (val.trim()) { onAdd(val.trim()); setVal(""); } };
|
||||
return <div>
|
||||
<div style={{ display: "flex", gap: 6, flexWrap: "wrap", marginBottom: 8 }}>
|
||||
{items.map((item, i) => <div key={item} style={{ display: "flex", alignItems: "center", gap: 4, padding: "4px 10px", borderRadius: 16, background: `${color}10`, border: `1px solid ${color}25` }}>
|
||||
<span style={{ fontFamily: f.b, fontSize: 11, color: c.text }}>{item}</span>
|
||||
<span onClick={() => onRemove(i)} style={{ fontSize: 10, color: c.dim, cursor: "pointer", lineHeight: 1 }}>×</span>
|
||||
</div>)}
|
||||
{items.length === 0 && <span style={{ fontFamily: f.b, fontSize: 11, color: c.dim, fontStyle: "italic" }}>None added yet</span>}
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 6 }}>
|
||||
<input value={val} onChange={e => setVal(e.target.value)} placeholder={placeholder} onKeyDown={e => e.key === "Enter" && submit()} style={{ flex: 1, padding: "8px 12px", borderRadius: 8, background: c.bg, border: `1px solid ${c.brd}`, color: c.text, fontFamily: f.b, fontSize: 12, outline: "none" }} />
|
||||
<div onClick={submit} style={{ padding: "8px 14px", borderRadius: 8, background: color, color: "#fff", fontFamily: f.b, fontSize: 12, fontWeight: 700, cursor: "pointer" }}>+</div>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
/* ── Dashboard ── */
|
||||
function Dashboard({ champs, org, onChampTap, onCreateChamp }) {
|
||||
return <div>
|
||||
<Hdr title="Dashboard" subtitle={org.name} right={
|
||||
<div style={{ width: 36, height: 36, borderRadius: 10, background: `linear-gradient(135deg,${c.accent}25,${c.accent}10)`, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 18 }}>{org.logo}</div>
|
||||
} />
|
||||
<div style={{ padding: "6px 16px 16px", display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
<div onClick={onCreateChamp} style={{ display: "flex", alignItems: "center", gap: 12, padding: "14px 16px", borderRadius: 14, background: `linear-gradient(135deg,${c.accent}15,${c.accent}08)`, border: `1px solid ${c.accent}30`, cursor: "pointer" }}>
|
||||
<div style={{ width: 42, height: 42, borderRadius: 12, background: c.accent, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 22, color: "#fff", fontWeight: 700, flexShrink: 0 }}>+</div>
|
||||
<div style={{ flex: 1 }}><p style={{ fontFamily: f.b, fontSize: 14, fontWeight: 700, color: c.text, margin: 0 }}>New Championship</p><p style={{ fontFamily: f.b, fontSize: 11, color: c.dim, margin: "2px 0 0" }}>Quick create — configure later</p></div>
|
||||
<span style={{ color: c.accent, fontSize: 16 }}>›</span>
|
||||
</div>
|
||||
|
||||
<ST right={<span style={{ fontFamily: f.m, fontSize: 10, color: c.dim }}>{champs.length} events</span>}>Your Championships</ST>
|
||||
|
||||
{champs.map(ch => {
|
||||
const cfg = ch.configured;
|
||||
const done = Object.values(cfg).filter(Boolean).length;
|
||||
const total = Object.keys(cfg).length;
|
||||
const ready = done === total;
|
||||
const stMap = { registration_open: { l: "LIVE", c: c.green, b: c.greenS }, draft: { l: `SETUP ${done}/${total}`, c: c.yellow, b: c.yellowS } };
|
||||
const st = stMap[ch.status] || stMap.draft;
|
||||
return <div key={ch.id} onClick={() => onChampTap(ch)} style={{ background: c.card, border: `1px solid ${c.brd}`, borderRadius: 14, overflow: "hidden", cursor: "pointer" }}>
|
||||
<div style={{ height: 3, background: `linear-gradient(90deg,${ch.accent},${ch.accent}88)` }} />
|
||||
<div style={{ padding: "12px 14px" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 6 }}>
|
||||
<div style={{ width: 38, height: 38, borderRadius: 10, background: `linear-gradient(135deg,${ch.accent}20,${ch.accent}40)`, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 17, flexShrink: 0 }}>{ch.image}</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<h3 style={{ fontFamily: f.d, fontSize: 15, fontWeight: 700, color: c.text, margin: 0 }}>{ch.name}</h3>
|
||||
<Bg label={st.l} color={st.c} bg={st.b} />
|
||||
</div>
|
||||
<p style={{ fontFamily: f.b, fontSize: 11, color: c.dim, margin: "2px 0 0" }}>{ch.eventDate} · {ch.location}</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* Readiness bar */}
|
||||
{!ready && <div style={{ marginTop: 6 }}>
|
||||
<div style={{ height: 4, background: c.brd, borderRadius: 2, overflow: "hidden" }}>
|
||||
<div style={{ height: "100%", width: `${(done / total) * 100}%`, background: ch.accent, borderRadius: 2 }} />
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 6, marginTop: 6, flexWrap: "wrap" }}>
|
||||
{Object.entries(cfg).map(([k, v]) => <span key={k} style={{ fontFamily: f.m, fontSize: 8, color: v ? c.green : c.yellow, letterSpacing: 0.3 }}>{v ? "✓" : "○"} {k}</span>)}
|
||||
</div>
|
||||
</div>}
|
||||
{/* Stats for live champs */}
|
||||
{ch.status === "registration_open" && <div style={{ display: "flex", gap: 10, paddingTop: 8, marginTop: 6, borderTop: `1px solid ${c.brd}` }}>
|
||||
{[{ n: ch.members.length, l: "Members", co: c.mid }, { n: ch.members.filter(m => m.passed === true).length, l: "Passed", co: c.green }, { n: ch.members.filter(m => m.videoUrl && m.passed === null).length, l: "Pending", co: c.yellow }].map(s =>
|
||||
<div key={s.l} style={{ flex: 1, textAlign: "center" }}><p style={{ fontFamily: f.d, fontSize: 16, fontWeight: 700, color: s.co, margin: 0 }}>{s.n}</p><p style={{ fontFamily: f.m, fontSize: 7, color: c.dim, margin: "2px 0 0", textTransform: "uppercase" }}>{s.l}</p></div>
|
||||
)}
|
||||
</div>}
|
||||
</div>
|
||||
</div>;
|
||||
})}
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
/* ── Championship Detail (configurable tabs) ── */
|
||||
function ChampDetail({ ch: initial, onBack, onMemberTap, onUpdate }) {
|
||||
const [ch, setCh] = useState(initial);
|
||||
const [tab, setTab] = useState("Overview");
|
||||
const [members, setMembers] = useState(ch.members);
|
||||
const [memFilter, setMemFilter] = useState("all");
|
||||
const [memSearch, setMemSearch] = useState("");
|
||||
const [editing, setEditing] = useState(null);
|
||||
const [newJudge, setNewJudge] = useState({ name: "", instagram: "", bio: "" });
|
||||
|
||||
const upd = (key, val) => setCh(p => ({ ...p, [key]: val }));
|
||||
const markDone = (section) => setCh(p => ({ ...p, configured: { ...p.configured, [section]: true } }));
|
||||
const allDone = Object.values(ch.configured).every(Boolean);
|
||||
|
||||
const stats = {
|
||||
total: members.length, videoSent: members.filter(m => m.videoUrl).length,
|
||||
passed: members.filter(m => m.passed === true).length, failed: members.filter(m => m.passed === false).length,
|
||||
pending: members.filter(m => m.videoUrl && m.passed === null).length, feePaid: members.filter(m => m.feePaid).length,
|
||||
receipts: members.filter(m => m.receiptUploaded && !m.feePaid).length,
|
||||
};
|
||||
|
||||
const decide = (id, pass) => setMembers(p => p.map(m => m.id === id ? { ...m, passed: pass } : m));
|
||||
|
||||
const tabDefs = [
|
||||
{ id: "Overview", label: "Overview" },
|
||||
{ id: "Categories", label: "Categories", done: ch.configured.categories },
|
||||
{ id: "Fees", label: "Fees", done: ch.configured.fees },
|
||||
{ id: "Rules", label: "Rules", done: ch.configured.rules },
|
||||
{ id: "Judges", label: "Judges", done: ch.configured.judging },
|
||||
...(ch.status === "registration_open" ? [{ id: "Members", label: `Members (${members.length})` }, { id: "Results", label: "Results" }] : []),
|
||||
];
|
||||
|
||||
const memFilters = [
|
||||
{ id: "all", l: "All", n: members.length }, { id: "receipts", l: "📸 Receipts", n: stats.receipts },
|
||||
{ id: "videos", l: "🎬 Videos", n: stats.pending }, { id: "passed", l: "✅ Passed", n: stats.passed },
|
||||
];
|
||||
const filteredMem = members.filter(m => {
|
||||
const q = !memSearch || m.name.toLowerCase().includes(memSearch.toLowerCase()) || m.instagram.toLowerCase().includes(memSearch.toLowerCase());
|
||||
if (!q) return false;
|
||||
if (memFilter === "receipts") return m.receiptUploaded && !m.feePaid;
|
||||
if (memFilter === "videos") return m.videoUrl && m.passed === null;
|
||||
if (memFilter === "passed") return m.passed === true;
|
||||
return true;
|
||||
});
|
||||
|
||||
return <div style={{ flex: 1, overflow: "auto" }}>
|
||||
<Hdr title={ch.name} subtitle={ch.subtitle || ch.location} onBack={onBack} right={
|
||||
ch.status === "draft" && allDone ? <div onClick={() => setCh(p => ({ ...p, status: "registration_open" }))} style={{ fontFamily: f.b, fontSize: 10, fontWeight: 700, color: "#fff", background: c.green, padding: "6px 12px", borderRadius: 8, cursor: "pointer" }}>🚀 Go Live</div> : null
|
||||
} />
|
||||
<div style={{ padding: "4px 16px 20px" }}>
|
||||
<Tabs tabs={tabDefs} active={tab} onChange={setTab} accent={ch.accent} />
|
||||
|
||||
{/* ═══ OVERVIEW ═══ */}
|
||||
{tab === "Overview" && <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
{/* Setup progress */}
|
||||
{ch.status === "draft" && <Cd style={{ background: `${c.yellow}06`, border: `1px solid ${c.yellow}20` }}>
|
||||
<ST right={<span style={{ fontFamily: f.m, fontSize: 10, color: c.yellow }}>{Object.values(ch.configured).filter(Boolean).length}/{Object.keys(ch.configured).length}</span>}>⚙️ Setup Progress</ST>
|
||||
{Object.entries(ch.configured).map(([section, done]) => {
|
||||
const tabMap = { info: "Overview", categories: "Categories", fees: "Fees", rules: "Rules", judging: "Judges" };
|
||||
return <div key={section} onClick={() => !done && setTab(tabMap[section] || section)} style={{ display: "flex", alignItems: "center", gap: 10, padding: "8px 0", borderBottom: `1px solid ${c.brd}`, cursor: done ? "default" : "pointer" }}>
|
||||
<div style={{ width: 22, height: 22, borderRadius: 6, border: `2px solid ${done ? c.green : c.yellow}`, background: done ? c.greenS : "transparent", display: "flex", alignItems: "center", justifyContent: "center", fontFamily: f.m, fontSize: 10, fontWeight: 700, color: done ? c.green : c.yellow }}>{done ? "✓" : ""}</div>
|
||||
<span style={{ fontFamily: f.b, fontSize: 12, color: done ? c.dim : c.text, textTransform: "capitalize", textDecoration: done ? "line-through" : "none", flex: 1 }}>{section === "judging" ? "judges" : section}</span>
|
||||
{!done && <span style={{ fontFamily: f.b, fontSize: 10, color: ch.accent }}>Configure ›</span>}
|
||||
</div>;
|
||||
})}
|
||||
{allDone && <div onClick={() => setCh(p => ({ ...p, status: "registration_open" }))} style={{ marginTop: 10, padding: "12px", borderRadius: 10, background: c.green, textAlign: "center", cursor: "pointer" }}>
|
||||
<span style={{ fontFamily: f.b, fontSize: 13, fontWeight: 700, color: "#fff" }}>🚀 Open Registration</span>
|
||||
</div>}
|
||||
</Cd>}
|
||||
|
||||
{/* Info (always editable) */}
|
||||
<Cd>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 10 }}>
|
||||
<h3 style={{ fontFamily: f.d, fontSize: 14, fontWeight: 700, color: c.mid, margin: 0 }}>Event Info</h3>
|
||||
<div onClick={() => setEditing(editing === "info" ? null : "info")} style={{ fontFamily: f.b, fontSize: 10, fontWeight: 600, color: editing === "info" ? c.dim : "#fff", background: editing === "info" ? "transparent" : ch.accent, border: `1px solid ${editing === "info" ? c.brd : ch.accent}`, padding: "3px 10px", borderRadius: 6, cursor: "pointer" }}>{editing === "info" ? "✕ Close" : "✎ Edit"}</div>
|
||||
</div>
|
||||
{editing === "info" ? <>
|
||||
<Input label="Name" value={ch.name} onChange={v => upd("name", v)} placeholder="Championship name" />
|
||||
<Input label="Subtitle" value={ch.subtitle} onChange={v => upd("subtitle", v)} placeholder="Subtitle" />
|
||||
<Input label="Event Date" value={ch.eventDate} onChange={v => upd("eventDate", v)} placeholder="e.g. May 30, 2026" />
|
||||
<Input label="Location" value={ch.location} onChange={v => upd("location", v)} placeholder="City, Country" />
|
||||
<Input label="Venue" value={ch.venue} onChange={v => upd("venue", v)} placeholder="Venue name" />
|
||||
<div style={{ height: 1, background: c.brd, margin: "4px 0 8px" }} />
|
||||
<p style={{ fontFamily: f.m, fontSize: 9, color: c.dim, margin: "0 0 6px", letterSpacing: 0.5 }}>REGISTRATION PERIOD</p>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<div style={{ flex: 1 }}><Input label="Opens" value={ch.regStart} onChange={v => upd("regStart", v)} placeholder="e.g. Feb 1, 2026" /></div>
|
||||
<div style={{ flex: 1 }}><Input label="Closes" value={ch.regEnd} onChange={v => upd("regEnd", v)} placeholder="e.g. Apr 22, 2026" /></div>
|
||||
</div>
|
||||
<p style={{ fontFamily: f.m, fontSize: 9, color: c.yellow, margin: "-6px 0 8px" }}>⚠️ Registration close date must be before event date</p>
|
||||
<div onClick={() => { markDone("info"); setEditing(null); }} style={{ padding: "10px", borderRadius: 8, background: c.green, textAlign: "center", cursor: "pointer" }}><span style={{ fontFamily: f.b, fontSize: 12, fontWeight: 700, color: "#fff" }}>✓ Save</span></div>
|
||||
</> : <div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: "6px 16px" }}>
|
||||
<span style={{ fontFamily: f.b, fontSize: 11, color: c.mid }}>📅 {ch.eventDate || "—"}</span>
|
||||
<span style={{ fontFamily: f.b, fontSize: 11, color: c.mid }}>📍 {ch.venue ? `${ch.venue}, ` : ""}{ch.location || "—"}</span>
|
||||
</div>
|
||||
{(ch.regStart || ch.regEnd) && <div style={{ display: "flex", alignItems: "center", gap: 6, padding: "6px 10px", borderRadius: 8, background: `${c.green}08`, border: `1px solid ${c.green}15` }}>
|
||||
<span style={{ fontFamily: f.m, fontSize: 10, color: c.green }}>📋 Registration:</span>
|
||||
<span style={{ fontFamily: f.b, fontSize: 11, color: c.text }}>{ch.regStart || "?"} → {ch.regEnd || "?"}</span>
|
||||
</div>}
|
||||
</div>}
|
||||
</Cd>
|
||||
|
||||
{/* Stats (only for live) */}
|
||||
{ch.status === "registration_open" && <>
|
||||
<div style={{ display: "flex", gap: 6 }}>
|
||||
{[{ n: stats.total, l: "Members", co: c.mid }, { n: stats.passed, l: "Passed", co: c.green }, { n: stats.failed, l: "Failed", co: c.red }, { n: stats.pending, l: "Pending", co: c.yellow }].map(s =>
|
||||
<div key={s.l} style={{ flex: 1, background: c.card, border: `1px solid ${c.brd}`, borderRadius: 12, padding: "10px 6px", textAlign: "center" }}>
|
||||
<p style={{ fontFamily: f.d, fontSize: 20, fontWeight: 700, color: s.co, margin: 0 }}>{s.n}</p>
|
||||
<p style={{ fontFamily: f.m, fontSize: 7, color: c.dim, margin: "2px 0 0", textTransform: "uppercase" }}>{s.l}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Cd>
|
||||
<ST right={<span style={{ fontFamily: f.m, fontSize: 10, color: c.dim }}>tap to view</span>}>⚡ Needs Action</ST>
|
||||
{[
|
||||
{ l: "Receipts to review", n: stats.receipts, icon: "📸", co: c.yellow, go: "Members" },
|
||||
{ l: "Videos to review", n: stats.pending, icon: "🎬", co: c.blue, go: "Results" },
|
||||
].map(a => <div key={a.l} onClick={() => { if (a.go === "Members") setMemFilter("receipts"); setTab(a.go); }} style={{ display: "flex", alignItems: "center", gap: 10, padding: "10px 0", borderBottom: `1px solid ${c.brd}`, cursor: "pointer" }}>
|
||||
<span style={{ fontSize: 16 }}>{a.icon}</span>
|
||||
<span style={{ fontFamily: f.b, fontSize: 13, color: c.text, flex: 1 }}>{a.l}</span>
|
||||
<span style={{ fontFamily: f.m, fontSize: 14, fontWeight: 700, color: a.co }}>{a.n}</span>
|
||||
<span style={{ color: c.dim }}>›</span>
|
||||
</div>)}
|
||||
</Cd>
|
||||
</>}
|
||||
</div>}
|
||||
|
||||
{/* ═══ CATEGORIES ═══ */}
|
||||
{tab === "Categories" && <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
<Cd>
|
||||
<ST right={ch.configured.categories ? <Bg label="✓ DONE" color={c.green} bg={c.greenS} /> : null}>Levels</ST>
|
||||
<TagEditor items={ch.disciplines.flatMap(d => d.levels).filter((v, i, a) => a.indexOf(v) === i)} color={ch.accent} placeholder="Add level (e.g. Amateur)"
|
||||
onAdd={v => { const d = ch.disciplines.length ? [...ch.disciplines] : [{ name: "Exotic Pole Dance", levels: [] }]; d[0] = { ...d[0], levels: [...d[0].levels, v] }; upd("disciplines", d); }}
|
||||
onRemove={i => { const all = ch.disciplines.flatMap(d => d.levels).filter((v, idx, a) => a.indexOf(v) === idx); const rm = all[i]; const d = ch.disciplines.map(d2 => ({ ...d2, levels: d2.levels.filter(l => l !== rm) })); upd("disciplines", d); }} />
|
||||
</Cd>
|
||||
<Cd>
|
||||
<ST>Styles</ST>
|
||||
<TagEditor items={ch.styles} color={c.purple} placeholder="Add style (e.g. Classic)"
|
||||
onAdd={v => upd("styles", [...ch.styles, v])} onRemove={i => upd("styles", ch.styles.filter((_, j) => j !== i))} />
|
||||
</Cd>
|
||||
{!ch.configured.categories && (ch.disciplines.some(d => d.levels.length > 0) && ch.styles.length > 0) && <div onClick={() => markDone("categories")} style={{ padding: "12px", borderRadius: 10, background: c.green, textAlign: "center", cursor: "pointer" }}>
|
||||
<span style={{ fontFamily: f.b, fontSize: 13, fontWeight: 700, color: "#fff" }}>✓ Mark Categories as Done</span>
|
||||
</div>}
|
||||
</div>}
|
||||
|
||||
{/* ═══ FEES ═══ */}
|
||||
{tab === "Fees" && <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
<Cd>
|
||||
<ST right={ch.configured.fees ? <Bg label="✓ DONE" color={c.green} bg={c.greenS} /> : null}>Video Selection Fee</ST>
|
||||
<Input label="Fee amount" value={ch.fees?.videoSelection || ""} onChange={v => upd("fees", { ...ch.fees, videoSelection: v })} placeholder="e.g. 50 BYN / 1,500 RUB" />
|
||||
</Cd>
|
||||
<Cd>
|
||||
<ST>Championship Fees</ST>
|
||||
<Input label="Solo" value={ch.fees?.solo || ""} onChange={v => upd("fees", { ...ch.fees, solo: v })} placeholder="e.g. 280 BYN" />
|
||||
<Input label="Duet (per person)" value={ch.fees?.duet || ""} onChange={v => upd("fees", { ...ch.fees, duet: v })} placeholder="e.g. 210 BYN" />
|
||||
<Input label="Group (per person)" value={ch.fees?.group || ""} onChange={v => upd("fees", { ...ch.fees, group: v })} placeholder="e.g. 190 BYN" />
|
||||
</Cd>
|
||||
{!ch.configured.fees && ch.fees?.videoSelection && <div onClick={() => markDone("fees")} style={{ padding: "12px", borderRadius: 10, background: c.green, textAlign: "center", cursor: "pointer" }}>
|
||||
<span style={{ fontFamily: f.b, fontSize: 13, fontWeight: 700, color: "#fff" }}>✓ Mark Fees as Done</span>
|
||||
</div>}
|
||||
</div>}
|
||||
|
||||
{/* ═══ RULES ═══ */}
|
||||
{tab === "Rules" && <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
<Cd>
|
||||
<ST right={ch.configured.rules ? <Bg label="✓ DONE" color={c.green} bg={c.greenS} /> : null}>General Rules</ST>
|
||||
<TagEditor items={ch.rules} color={c.blue} placeholder="Add rule" onAdd={v => upd("rules", [...ch.rules, v])} onRemove={i => upd("rules", ch.rules.filter((_, j) => j !== i))} />
|
||||
</Cd>
|
||||
<Cd>
|
||||
<ST>Costume Rules</ST>
|
||||
<TagEditor items={ch.costumeRules} color={c.yellow} placeholder="Add costume rule" onAdd={v => upd("costumeRules", [...ch.costumeRules, v])} onRemove={i => upd("costumeRules", ch.costumeRules.filter((_, j) => j !== i))} />
|
||||
</Cd>
|
||||
<Cd>
|
||||
<ST>Scoring Criteria (0–10)</ST>
|
||||
{ch.judging.map((j, i) => <div key={i} style={{ display: "flex", alignItems: "center", gap: 8, padding: "6px 0", borderBottom: `1px solid ${c.brd}` }}>
|
||||
<span style={{ fontFamily: f.b, fontSize: 12, color: c.text, flex: 1 }}>{j.name}</span>
|
||||
<span style={{ fontFamily: f.m, fontSize: 11, color: c.purple }}>0–{j.max}</span>
|
||||
<span onClick={() => upd("judging", ch.judging.filter((_, k) => k !== i))} style={{ fontSize: 10, color: c.dim, cursor: "pointer" }}>×</span>
|
||||
</div>)}
|
||||
<TagEditor items={[]} color={c.purple} placeholder="Add criterion (e.g. Artistry)"
|
||||
onAdd={v => upd("judging", [...ch.judging, { name: v, max: 10 }])} onRemove={() => {}} />
|
||||
</Cd>
|
||||
<Cd>
|
||||
<ST>Penalties</ST>
|
||||
{ch.penalties.map((p, i) => <div key={i} style={{ display: "flex", alignItems: "center", gap: 8, padding: "6px 0", borderBottom: `1px solid ${c.brd}` }}>
|
||||
<span style={{ fontFamily: f.b, fontSize: 12, color: c.text, flex: 1 }}>{p.name}</span>
|
||||
<span style={{ fontFamily: f.m, fontSize: 10, fontWeight: 700, color: p.val === "DQ" ? c.red : c.yellow, background: p.val === "DQ" ? c.redS : c.yellowS, padding: "2px 8px", borderRadius: 4 }}>{p.val}</span>
|
||||
<span onClick={() => upd("penalties", ch.penalties.filter((_, k) => k !== i))} style={{ fontSize: 10, color: c.dim, cursor: "pointer" }}>×</span>
|
||||
</div>)}
|
||||
<TagEditor items={[]} color={c.red} placeholder="Add penalty (e.g. Fall: -2)"
|
||||
onAdd={v => { const [name, val] = v.includes(":") ? v.split(":").map(s => s.trim()) : [v, "-2"]; upd("penalties", [...ch.penalties, { name, val }]); }} onRemove={() => {}} />
|
||||
</Cd>
|
||||
{!ch.configured.rules && ch.rules.length > 0 && <div onClick={() => markDone("rules")} style={{ padding: "12px", borderRadius: 10, background: c.green, textAlign: "center", cursor: "pointer" }}>
|
||||
<span style={{ fontFamily: f.b, fontSize: 13, fontWeight: 700, color: "#fff" }}>✓ Mark Rules as Done</span>
|
||||
</div>}
|
||||
</div>}
|
||||
|
||||
{/* ═══ JUDGES ═══ */}
|
||||
{tab === "Judges" && <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
<ST right={ch.configured.judging ? <Bg label="✓ DONE" color={c.green} bg={c.greenS} /> : <span style={{ fontFamily: f.m, fontSize: 10, color: c.dim }}>{ch.judges.length} judges</span>}>Jury Panel</ST>
|
||||
{ch.judges.map((j, i) => <Cd key={j.id || i} style={{ padding: 14 }}>
|
||||
<div style={{ display: "flex", gap: 12, alignItems: "flex-start" }}>
|
||||
<div style={{ width: 44, height: 44, borderRadius: 12, background: `linear-gradient(135deg,${c.purple}20,${c.purple}40)`, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 20, flexShrink: 0 }}>👩⚖️</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<p style={{ fontFamily: f.b, fontSize: 14, fontWeight: 600, color: c.text, margin: 0 }}>{j.name}</p>
|
||||
<span onClick={() => upd("judges", ch.judges.filter((_, k) => k !== i))} style={{ fontSize: 10, color: c.dim, cursor: "pointer", padding: "4px" }}>×</span>
|
||||
</div>
|
||||
<p style={{ fontFamily: f.m, fontSize: 11, color: c.purple, margin: "2px 0 4px" }}>{j.instagram}</p>
|
||||
<p style={{ fontFamily: f.b, fontSize: 11, color: c.mid, margin: 0, lineHeight: 1.4 }}>{j.bio}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Cd>)}
|
||||
|
||||
{/* Add judge form */}
|
||||
<Cd style={{ background: `${c.purple}06`, border: `1px solid ${c.purple}20` }}>
|
||||
<ST>Add Judge</ST>
|
||||
<Input label="Name" value={newJudge.name} onChange={v => setNewJudge(p => ({ ...p, name: v }))} placeholder="e.g. Anastasia Skukhtorova" />
|
||||
<Input label="Instagram" value={newJudge.instagram} onChange={v => setNewJudge(p => ({ ...p, instagram: v }))} placeholder="e.g. @skukhtorova" />
|
||||
<Input label="Bio / Description" value={newJudge.bio} onChange={v => setNewJudge(p => ({ ...p, bio: v }))} placeholder="Experience, titles, achievements..." />
|
||||
<div onClick={() => { if (newJudge.name) { upd("judges", [...ch.judges, { ...newJudge, id: `j${Date.now()}` }]); setNewJudge({ name: "", instagram: "", bio: "" }); } }} style={{ padding: "10px", borderRadius: 8, background: newJudge.name ? c.purple : c.brd, textAlign: "center", cursor: newJudge.name ? "pointer" : "default", opacity: newJudge.name ? 1 : 0.5 }}>
|
||||
<span style={{ fontFamily: f.b, fontSize: 12, fontWeight: 700, color: "#fff" }}>+ Add Judge</span>
|
||||
</div>
|
||||
</Cd>
|
||||
|
||||
{!ch.configured.judging && ch.judges.length > 0 && <div onClick={() => markDone("judging")} style={{ padding: "12px", borderRadius: 10, background: c.green, textAlign: "center", cursor: "pointer" }}>
|
||||
<span style={{ fontFamily: f.b, fontSize: 13, fontWeight: 700, color: "#fff" }}>✓ Mark Judges as Done</span>
|
||||
</div>}
|
||||
</div>}
|
||||
|
||||
{/* ═══ MEMBERS ═══ */}
|
||||
{tab === "Members" && <div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
<div style={{ background: c.card, border: `1px solid ${c.brd}`, borderRadius: 12, padding: "10px 14px", display: "flex", alignItems: "center", gap: 10 }}>
|
||||
<span style={{ fontSize: 14, opacity: 0.4 }}>🔍</span>
|
||||
<input type="text" placeholder="Search..." value={memSearch} onChange={e => setMemSearch(e.target.value)} style={{ background: "transparent", border: "none", outline: "none", color: c.text, fontFamily: f.b, fontSize: 13, width: "100%" }} />
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 4, overflowX: "auto", scrollbarWidth: "none" }}>
|
||||
{memFilters.map(fi => <div key={fi.id} onClick={() => setMemFilter(fi.id)} style={{ fontFamily: f.m, fontSize: 9, fontWeight: 600, whiteSpace: "nowrap", color: memFilter === fi.id ? ch.accent : c.dim, background: memFilter === fi.id ? `${ch.accent}15` : "transparent", border: `1px solid ${memFilter === fi.id ? `${ch.accent}30` : "transparent"}`, padding: "5px 10px", borderRadius: 16, cursor: "pointer" }}>{fi.l} ({fi.n})</div>)}
|
||||
</div>
|
||||
{filteredMem.map(m => <div key={m.id} onClick={() => onMemberTap(m, ch)} style={{ background: c.card, border: `1px solid ${c.brd}`, borderRadius: 14, padding: 12, cursor: "pointer" }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: 4 }}>
|
||||
<div><p style={{ fontFamily: f.b, fontSize: 13, fontWeight: 600, color: c.text, margin: 0 }}>{m.name}</p><p style={{ fontFamily: f.m, fontSize: 10, color: ch.accent, margin: "1px 0 0" }}>{m.instagram}</p></div>
|
||||
<Bg label={m.passed === true ? "PASSED" : m.passed === false ? "FAILED" : "PENDING"} color={m.passed === true ? c.green : m.passed === false ? c.red : c.yellow} bg={m.passed === true ? c.greenS : m.passed === false ? c.redS : c.yellowS} />
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 5, flexWrap: "wrap" }}>{[m.level, m.style, m.city].map(t => <span key={t} style={{ fontFamily: f.b, fontSize: 9, color: c.mid, background: `${c.mid}10`, padding: "2px 7px", borderRadius: 10 }}>{t}</span>)}</div>
|
||||
</div>)}
|
||||
{filteredMem.length === 0 && <div style={{ textAlign: "center", padding: 30 }}><span style={{ fontSize: 28 }}>🤷</span><p style={{ fontFamily: f.b, fontSize: 13, color: c.dim, marginTop: 8 }}>No members match</p></div>}
|
||||
</div>}
|
||||
|
||||
{/* ═══ RESULTS ═══ */}
|
||||
{tab === "Results" && <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
<div style={{ display: "flex", gap: 6 }}>
|
||||
{[{ n: stats.pending, l: "Pending", co: c.yellow }, { n: stats.passed, l: "Passed", co: c.green }, { n: stats.failed, l: "Failed", co: c.red }].map(s =>
|
||||
<div key={s.l} style={{ flex: 1, background: c.card, border: `1px solid ${c.brd}`, borderRadius: 12, padding: "10px 6px", textAlign: "center" }}>
|
||||
<p style={{ fontFamily: f.d, fontSize: 20, fontWeight: 700, color: s.co, margin: 0 }}>{s.n}</p>
|
||||
<p style={{ fontFamily: f.m, fontSize: 7, color: c.dim, margin: "2px 0 0", textTransform: "uppercase" }}>{s.l}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{members.filter(m => m.videoUrl && m.passed === null).map(m => <Cd key={m.id} style={{ padding: 12 }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 8 }}>
|
||||
<div><p style={{ fontFamily: f.b, fontSize: 13, fontWeight: 600, color: c.text, margin: 0 }}>{m.name}</p><p style={{ fontFamily: f.m, fontSize: 10, color: c.dim, margin: "2px 0 0" }}>{m.level} · {m.style}</p></div>
|
||||
<span style={{ fontFamily: f.b, fontSize: 10, color: c.blue, background: c.blueS, padding: "3px 8px", borderRadius: 8, cursor: "pointer" }}>🎥 View</span>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<div onClick={() => decide(m.id, true)} style={{ flex: 1, padding: "10px", borderRadius: 10, background: `${c.green}15`, border: `1px solid ${c.green}30`, cursor: "pointer", textAlign: "center" }}><span style={{ fontFamily: f.b, fontSize: 12, fontWeight: 700, color: c.green }}>✅ Pass</span></div>
|
||||
<div onClick={() => decide(m.id, false)} style={{ flex: 1, padding: "10px", borderRadius: 10, background: `${c.red}15`, border: `1px solid ${c.red}30`, cursor: "pointer", textAlign: "center" }}><span style={{ fontFamily: f.b, fontSize: 12, fontWeight: 700, color: c.red }}>❌ Fail</span></div>
|
||||
</div>
|
||||
</Cd>)}
|
||||
{members.filter(m => m.passed !== null).length > 0 && <>
|
||||
<ST>Decided</ST>
|
||||
{members.filter(m => m.passed !== null).map(m => <div key={m.id} style={{ display: "flex", alignItems: "center", gap: 10, padding: "10px 12px", background: c.card, border: `1px solid ${c.brd}`, borderRadius: 12 }}>
|
||||
<div style={{ flex: 1 }}><p style={{ fontFamily: f.b, fontSize: 13, color: c.text, margin: 0 }}>{m.name}</p><p style={{ fontFamily: f.m, fontSize: 10, color: c.dim, margin: "2px 0 0" }}>{m.level}</p></div>
|
||||
<Bg label={m.passed ? "PASSED" : "FAILED"} color={m.passed ? c.green : c.red} bg={m.passed ? c.greenS : c.redS} />
|
||||
</div>)}
|
||||
</>}
|
||||
<div style={{ padding: "14px", borderRadius: 12, background: ch.accent, textAlign: "center", cursor: "pointer" }}><span style={{ fontFamily: f.b, fontSize: 14, fontWeight: 700, color: "#fff" }}>📢 Publish Results</span></div>
|
||||
</div>}
|
||||
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
/* ── Member Detail ── */
|
||||
function MemberDetail({ member, champ, onBack }) {
|
||||
const [m, setM] = useState(member);
|
||||
const [showLvl, setShowLvl] = useState(false);
|
||||
const [showSty, setShowSty] = useState(false);
|
||||
const levels = champ.disciplines.flatMap(d => d.levels).filter((v, i, a) => a.indexOf(v) === i);
|
||||
|
||||
return <div style={{ flex: 1, overflow: "auto" }}>
|
||||
<Hdr title={m.name} subtitle={`${champ.name} · ${m.instagram}`} onBack={onBack} />
|
||||
<div style={{ padding: "6px 16px 20px", display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
<Cd style={{ display: "flex", alignItems: "center", gap: 14, padding: "14px 16px" }}>
|
||||
<div style={{ width: 50, height: 50, borderRadius: 14, background: `linear-gradient(135deg,${champ.accent}20,${champ.accent}40)`, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 22, flexShrink: 0 }}>👤</div>
|
||||
<div style={{ flex: 1 }}><p style={{ fontFamily: f.b, fontSize: 16, fontWeight: 600, color: c.text, margin: 0 }}>{m.name}</p><p style={{ fontFamily: f.m, fontSize: 11, color: champ.accent, margin: "2px 0 0" }}>{m.instagram}</p><p style={{ fontFamily: f.b, fontSize: 11, color: c.dim, margin: "2px 0 0" }}>📍 {m.city}</p></div>
|
||||
<Bg label={m.passed === true ? "PASSED" : m.passed === false ? "FAILED" : "PENDING"} color={m.passed === true ? c.green : m.passed === false ? c.red : c.yellow} bg={m.passed === true ? c.greenS : m.passed === false ? c.redS : c.yellowS} />
|
||||
</Cd>
|
||||
|
||||
<Cd>
|
||||
<ST>Registration</ST>
|
||||
{[{ l: "Discipline", v: m.discipline }, { l: "Type", v: m.type }].map(r => <div key={r.l} style={{ display: "flex", justifyContent: "space-between", padding: "7px 0", borderBottom: `1px solid ${c.brd}` }}><span style={{ fontFamily: f.m, fontSize: 10, color: c.dim, textTransform: "uppercase" }}>{r.l}</span><span style={{ fontFamily: f.b, fontSize: 12, color: c.text }}>{r.v}</span></div>)}
|
||||
|
||||
{/* Level */}
|
||||
<div style={{ padding: "7px 0", borderBottom: `1px solid ${c.brd}` }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<span style={{ fontFamily: f.m, fontSize: 10, color: c.dim, textTransform: "uppercase" }}>Level</span>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<span style={{ fontFamily: f.b, fontSize: 12, fontWeight: 600, color: c.text }}>{m.level}</span>
|
||||
<div onClick={() => { setShowLvl(!showLvl); setShowSty(false); }} style={{ fontFamily: f.b, fontSize: 10, fontWeight: 600, color: showLvl ? c.dim : "#fff", background: showLvl ? "transparent" : champ.accent, border: `1px solid ${showLvl ? c.brd : champ.accent}`, padding: "3px 10px", borderRadius: 6, cursor: "pointer" }}>{showLvl ? "✕" : "✎ Edit"}</div>
|
||||
</div>
|
||||
</div>
|
||||
{showLvl && <div style={{ marginTop: 8, padding: 8, background: c.bg, borderRadius: 8 }}>
|
||||
<p style={{ fontFamily: f.m, fontSize: 9, color: c.yellow, margin: "0 0 6px" }}>⚠️ Member will be notified</p>
|
||||
{levels.map(l => <div key={l} onClick={() => { setM(p => ({ ...p, level: l })); setShowLvl(false); }} style={{ padding: "8px 10px", borderRadius: 6, cursor: "pointer", marginBottom: 3, background: l === m.level ? `${champ.accent}15` : "transparent", border: `1px solid ${l === m.level ? `${champ.accent}30` : c.brd}` }}>
|
||||
<span style={{ fontFamily: f.b, fontSize: 12, color: l === m.level ? champ.accent : c.text }}>{l}</span>
|
||||
</div>)}
|
||||
</div>}
|
||||
</div>
|
||||
|
||||
{/* Style */}
|
||||
<div style={{ padding: "7px 0" }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<span style={{ fontFamily: f.m, fontSize: 10, color: c.dim, textTransform: "uppercase" }}>Style</span>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<span style={{ fontFamily: f.b, fontSize: 12, fontWeight: 600, color: c.text }}>{m.style}</span>
|
||||
<div onClick={() => { setShowSty(!showSty); setShowLvl(false); }} style={{ fontFamily: f.b, fontSize: 10, fontWeight: 600, color: showSty ? c.dim : "#fff", background: showSty ? "transparent" : c.purple, border: `1px solid ${showSty ? c.brd : c.purple}`, padding: "3px 10px", borderRadius: 6, cursor: "pointer" }}>{showSty ? "✕" : "✎ Edit"}</div>
|
||||
</div>
|
||||
</div>
|
||||
{showSty && <div style={{ marginTop: 8, padding: 8, background: c.bg, borderRadius: 8 }}>
|
||||
<p style={{ fontFamily: f.m, fontSize: 9, color: c.yellow, margin: "0 0 6px" }}>⚠️ Member will be notified</p>
|
||||
{champ.styles.map(s => <div key={s} onClick={() => { setM(p => ({ ...p, style: s })); setShowSty(false); }} style={{ padding: "8px 10px", borderRadius: 6, cursor: "pointer", marginBottom: 3, background: s === m.style ? `${c.purple}15` : "transparent", border: `1px solid ${s === m.style ? `${c.purple}30` : c.brd}` }}>
|
||||
<span style={{ fontFamily: f.b, fontSize: 12, color: s === m.style ? c.purple : c.text }}>{s}</span>
|
||||
</div>)}
|
||||
</div>}
|
||||
</div>
|
||||
</Cd>
|
||||
|
||||
{/* Video */}
|
||||
<Cd>
|
||||
<ST>🎬 Video</ST>
|
||||
{m.videoUrl ? <>
|
||||
<div style={{ background: c.bg, borderRadius: 8, padding: 10, marginBottom: 8, display: "flex", alignItems: "center", gap: 8 }}><span style={{ fontSize: 18 }}>🎥</span><p style={{ fontFamily: f.m, fontSize: 10, color: c.blue, margin: 0, flex: 1, overflow: "hidden", textOverflow: "ellipsis" }}>{m.videoUrl}</p></div>
|
||||
{m.passed === null ? <div style={{ display: "flex", gap: 8 }}>
|
||||
<div onClick={() => setM(p => ({ ...p, passed: true }))} style={{ flex: 1, padding: "10px", borderRadius: 10, background: c.green, cursor: "pointer", textAlign: "center" }}><span style={{ fontFamily: f.b, fontSize: 12, fontWeight: 700, color: "#fff" }}>✅ Pass</span></div>
|
||||
<div onClick={() => setM(p => ({ ...p, passed: false }))} style={{ flex: 1, padding: "10px", borderRadius: 10, background: c.red, cursor: "pointer", textAlign: "center" }}><span style={{ fontFamily: f.b, fontSize: 12, fontWeight: 700, color: "#fff" }}>❌ Fail</span></div>
|
||||
</div> : <Bg label={m.passed ? "PASSED" : "FAILED"} color={m.passed ? c.green : c.red} bg={m.passed ? c.greenS : c.redS} />}
|
||||
</> : <p style={{ fontFamily: f.b, fontSize: 12, color: c.dim, margin: 0 }}>No video yet</p>}
|
||||
</Cd>
|
||||
|
||||
{/* Payment */}
|
||||
<Cd>
|
||||
<ST>💳 Payment</ST>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<div><p style={{ fontFamily: f.b, fontSize: 12, color: c.text, margin: 0 }}>Video fee</p><p style={{ fontFamily: f.m, fontSize: 10, color: c.dim, margin: "2px 0 0" }}>{champ.fees?.videoSelection || "—"}</p></div>
|
||||
{m.receiptUploaded && !m.feePaid ? <div onClick={() => setM(p => ({ ...p, feePaid: true }))} style={{ padding: "6px 12px", borderRadius: 8, background: `${c.green}15`, border: `1px solid ${c.green}30`, cursor: "pointer" }}><span style={{ fontFamily: f.b, fontSize: 11, fontWeight: 600, color: c.green }}>📸 Confirm</span></div>
|
||||
: <Bg label={m.feePaid ? "CONFIRMED" : "PENDING"} color={m.feePaid ? c.green : c.yellow} bg={m.feePaid ? c.greenS : c.yellowS} />}
|
||||
</div>
|
||||
</Cd>
|
||||
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 6, padding: "12px", borderRadius: 12, background: c.card, border: `1px solid ${c.brd}`, cursor: "pointer" }}><span style={{ fontSize: 14 }}>🔔</span><span style={{ fontFamily: f.b, fontSize: 12, fontWeight: 600, color: c.text }}>Send Notification</span></div>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
/* ── Quick Create ── */
|
||||
function QuickCreate({ onBack, onDone }) {
|
||||
const [name, setName] = useState("");
|
||||
const [eventDate, setEventDate] = useState("");
|
||||
const [location, setLocation] = useState("");
|
||||
|
||||
return <div style={{ flex: 1, overflow: "auto" }}>
|
||||
<Hdr title="New Championship" subtitle="Quick create — configure details later" onBack={onBack} />
|
||||
<div style={{ padding: "6px 16px 20px", display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
<Cd>
|
||||
<Input label="Championship Name" value={name} onChange={setName} placeholder="e.g. Zero Gravity" />
|
||||
<Input label="Event Date" value={eventDate} onChange={setEventDate} placeholder="e.g. May 30, 2026" />
|
||||
<Input label="Location" value={location} onChange={setLocation} placeholder="e.g. Minsk, Belarus" />
|
||||
</Cd>
|
||||
|
||||
<Cd style={{ background: `${c.blue}06`, border: `1px solid ${c.blue}20` }}>
|
||||
<p style={{ fontFamily: f.b, fontSize: 12, color: c.blue, margin: "0 0 6px" }}>💡 What happens next?</p>
|
||||
<p style={{ fontFamily: f.b, fontSize: 11, color: c.mid, margin: 0, lineHeight: 1.6 }}>Your championship will be created as a draft. Configure categories, fees, rules, and judging at your own pace. Once everything is set, hit "Go Live" to open registration.</p>
|
||||
</Cd>
|
||||
|
||||
<div onClick={() => name && onDone(makeCh({ id: `ch${Date.now()}`, name, eventDate, location, status: "draft", configured: { info: !!eventDate && !!location, categories: false, fees: false, rules: false, judging: false } }))} style={{ padding: "14px", borderRadius: 12, background: name ? c.accent : c.brd, textAlign: "center", cursor: name ? "pointer" : "default", opacity: name ? 1 : 0.5 }}>
|
||||
<span style={{ fontFamily: f.b, fontSize: 14, fontWeight: 700, color: "#fff" }}>✨ Create Draft</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
/* ── Org Settings ── */
|
||||
function OrgSettings({ org, onUpdateOrg }) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [name, setName] = useState(org.name);
|
||||
const [instagram, setInstagram] = useState(org.instagram);
|
||||
const [subScreen, setSubScreen] = useState(null);
|
||||
|
||||
if (subScreen === "notifications") return <div>
|
||||
<Hdr title="Notifications" subtitle="Notification preferences" onBack={() => setSubScreen(null)} />
|
||||
<div style={{ padding: "6px 16px 20px", display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
{[{ l: "Push notifications", d: "Get notified on new registrations", on: true },
|
||||
{ l: "Email notifications", d: "Receive email for payments & uploads", on: true },
|
||||
{ l: "Registration alerts", d: "When a new member registers", on: true },
|
||||
{ l: "Payment alerts", d: "When a receipt is uploaded", on: true },
|
||||
{ l: "Deadline reminders", d: "Auto-remind members before deadlines", on: false },
|
||||
].map(n => <ToggleRow key={n.l} label={n.l} desc={n.d} defaultOn={n.on} />)}
|
||||
</div>
|
||||
</div>;
|
||||
|
||||
if (subScreen === "accounts") return <div>
|
||||
<Hdr title="Connected Accounts" subtitle="Integrations" onBack={() => setSubScreen(null)} />
|
||||
<div style={{ padding: "6px 16px 20px", display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
{[{ name: "Instagram", handle: org.instagram, icon: "📸", connected: true, color: c.purple },
|
||||
{ name: "Gmail", handle: "zerogravity@gmail.com", icon: "📧", connected: true, color: c.red },
|
||||
{ name: "Telegram", handle: "@zerogravity_bot", icon: "💬", connected: false, color: c.blue },
|
||||
].map(a => <Cd key={a.name} style={{ display: "flex", alignItems: "center", gap: 12, padding: 14 }}>
|
||||
<div style={{ width: 38, height: 38, borderRadius: 10, background: `${a.color}15`, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 17 }}>{a.icon}</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<p style={{ fontFamily: f.b, fontSize: 13, fontWeight: 600, color: c.text, margin: 0 }}>{a.name}</p>
|
||||
<p style={{ fontFamily: f.m, fontSize: 10, color: a.connected ? a.color : c.dim, margin: "2px 0 0" }}>{a.connected ? a.handle : "Not connected"}</p>
|
||||
</div>
|
||||
<Bg label={a.connected ? "CONNECTED" : "CONNECT"} color={a.connected ? c.green : c.accent} bg={a.connected ? c.greenS : c.accentS} />
|
||||
</Cd>)}
|
||||
</div>
|
||||
</div>;
|
||||
|
||||
return <div>
|
||||
<Hdr title="Settings" subtitle="Organization profile" />
|
||||
<div style={{ padding: "6px 20px 20px" }}>
|
||||
{/* Profile header */}
|
||||
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", marginBottom: 20 }}>
|
||||
<div style={{ width: 68, height: 68, borderRadius: 18, background: `linear-gradient(135deg,${c.accent}25,${c.accent}10)`, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 30, marginBottom: 10, border: `2px solid ${c.accent}35` }}>{org.logo}</div>
|
||||
{editing ? <div style={{ width: "100%", display: "flex", flexDirection: "column", gap: 8, marginTop: 4 }}>
|
||||
<Input label="Organization Name" value={name} onChange={setName} placeholder="Your org name" />
|
||||
<Input label="Instagram" value={instagram} onChange={setInstagram} placeholder="@handle" />
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<div onClick={() => setEditing(false)} style={{ flex: 1, padding: "10px", borderRadius: 8, background: c.card, border: `1px solid ${c.brd}`, textAlign: "center", cursor: "pointer" }}><span style={{ fontFamily: f.b, fontSize: 12, color: c.dim }}>Cancel</span></div>
|
||||
<div onClick={() => { onUpdateOrg({ name, instagram }); setEditing(false); }} style={{ flex: 1, padding: "10px", borderRadius: 8, background: c.green, textAlign: "center", cursor: "pointer" }}><span style={{ fontFamily: f.b, fontSize: 12, fontWeight: 700, color: "#fff" }}>✓ Save</span></div>
|
||||
</div>
|
||||
</div> : <>
|
||||
<h2 style={{ fontFamily: f.d, fontSize: 19, fontWeight: 700, color: c.text, margin: "0 0 2px" }}>{org.name}</h2>
|
||||
<p style={{ fontFamily: f.m, fontSize: 11, color: c.accent, margin: 0 }}>{org.instagram}</p>
|
||||
</>}
|
||||
</div>
|
||||
|
||||
{/* Menu items — hide when editing */}
|
||||
{!editing && <div style={{ background: c.card, border: `1px solid ${c.brd}`, borderRadius: 12, overflow: "hidden" }}>
|
||||
{[
|
||||
{ label: "Edit Organization Profile", action: () => setEditing(true), icon: "✎" },
|
||||
{ label: "Notification Preferences", action: () => setSubScreen("notifications"), icon: "🔔" },
|
||||
{ label: "Connected Accounts", action: () => setSubScreen("accounts"), icon: "🔗" },
|
||||
{ label: "Help & Support", action: () => {}, icon: "❓" },
|
||||
{ label: "Log Out", action: () => {}, icon: "🚪", danger: true },
|
||||
].map((x, i, a) =>
|
||||
<div key={x.label} onClick={x.action} style={{ padding: "13px 16px", fontFamily: f.b, fontSize: 13, color: x.danger ? c.red : c.text, borderBottom: i < a.length - 1 ? `1px solid ${c.brd}` : "none", cursor: "pointer", display: "flex", alignItems: "center", gap: 10 }}>
|
||||
<span style={{ fontSize: 14, opacity: 0.6 }}>{x.icon}</span>
|
||||
<span style={{ flex: 1 }}>{x.label}</span>
|
||||
<span style={{ color: c.dim, fontSize: 12 }}>›</span>
|
||||
</div>
|
||||
)}
|
||||
</div>}
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
/* Toggle row helper */
|
||||
function ToggleRow({ label, desc, defaultOn }) {
|
||||
const [on, setOn] = useState(defaultOn);
|
||||
return <Cd style={{ display: "flex", alignItems: "center", gap: 12, padding: 14 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<p style={{ fontFamily: f.b, fontSize: 13, color: c.text, margin: 0 }}>{label}</p>
|
||||
{desc && <p style={{ fontFamily: f.b, fontSize: 10, color: c.dim, margin: "2px 0 0" }}>{desc}</p>}
|
||||
</div>
|
||||
<div onClick={() => setOn(!on)} style={{ width: 42, height: 24, borderRadius: 12, background: on ? c.green : c.brd, padding: 2, cursor: "pointer", transition: "background 0.2s" }}>
|
||||
<div style={{ width: 20, height: 20, borderRadius: 10, background: "#fff", transform: on ? "translateX(18px)" : "translateX(0)", transition: "transform 0.2s" }} />
|
||||
</div>
|
||||
</Cd>;
|
||||
}
|
||||
|
||||
/* ── App Shell ── */
|
||||
export default function OrgApp() {
|
||||
const [champs, setChamps] = useState(INITIAL_CHAMPS);
|
||||
const [org, setOrg] = useState(ORG);
|
||||
const [scr, setScr] = useState("dash");
|
||||
const [selChamp, setSelChamp] = useState(null);
|
||||
const [selMember, setSelMember] = useState(null);
|
||||
|
||||
const addChamp = ch => { setChamps(p => [...p, ch]); setSelChamp(ch); setScr("champ"); };
|
||||
const updateOrg = updates => setOrg(p => ({ ...p, ...updates }));
|
||||
|
||||
const render = () => {
|
||||
if (scr === "create") return <QuickCreate onBack={() => setScr("dash")} onDone={addChamp} />;
|
||||
if (scr === "champ" && selChamp) return <ChampDetail ch={selChamp} onBack={() => { setScr("dash"); setSelChamp(null); }} onMemberTap={(m, ch) => { setSelMember({ m, ch }); setScr("member"); }} />;
|
||||
if (scr === "member" && selMember) return <MemberDetail member={selMember.m} champ={selMember.ch} onBack={() => setScr("champ")} />;
|
||||
if (scr === "orgSettings") return <OrgSettings org={org} onUpdateOrg={updateOrg} />;
|
||||
return <Dashboard champs={champs} org={org} onChampTap={ch => { setSelChamp(ch); setScr("champ"); }} onCreateChamp={() => setScr("create")} />;
|
||||
};
|
||||
|
||||
const showNav = scr === "dash" || scr === "orgSettings";
|
||||
|
||||
return <div style={{ display: "flex", justifyContent: "center", alignItems: "center", minHeight: "100vh", background: "#030206", padding: 20, fontFamily: f.b }}>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;600;700&family=DM+Sans:wght@400;500;600&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet" />
|
||||
<style>{`*::-webkit-scrollbar{display:none}*{scrollbar-width:none}`}</style>
|
||||
<div style={{ width: 375, height: 740, background: c.bg, borderRadius: 36, overflow: "hidden", display: "flex", flexDirection: "column", border: `1.5px solid ${c.brd}`, boxShadow: `0 0 80px rgba(212,20,90,0.06),0 20px 40px rgba(0,0,0,0.5)` }}>
|
||||
<div style={{ padding: "8px 24px", display: "flex", justifyContent: "space-between", alignItems: "center", flexShrink: 0 }}>
|
||||
<span style={{ fontFamily: f.m, fontSize: 11, color: c.dim }}>9:41</span>
|
||||
<div style={{ width: 100, height: 28, background: "#000", borderRadius: 14 }} />
|
||||
<span style={{ fontFamily: f.m, fontSize: 11, color: c.dim }}>●●●</span>
|
||||
</div>
|
||||
<div style={{ flex: 1, overflow: "auto", minHeight: 0 }}>{render()}</div>
|
||||
{showNav && <Nav active={scr} onChange={s => { setScr(s); setSelChamp(null); setSelMember(null); }} />}
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
@@ -1,41 +1,36 @@
|
||||
version: "3.9"
|
||||
|
||||
services:
|
||||
postgres:
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_USER: pole
|
||||
POSTGRES_PASSWORD: pole
|
||||
POSTGRES_DB: poledance
|
||||
POSTGRES_USER: poledance
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U poledance"]
|
||||
test: ["CMD-SHELL", "pg_isready -U pole -d poledance"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
backend:
|
||||
api:
|
||||
build: ./backend
|
||||
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||
environment:
|
||||
DATABASE_URL: postgresql+asyncpg://poledance:${POSTGRES_PASSWORD}@postgres:5432/poledance
|
||||
SECRET_KEY: ${SECRET_KEY}
|
||||
ALGORITHM: HS256
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: 15
|
||||
REFRESH_TOKEN_EXPIRE_DAYS: 7
|
||||
INSTAGRAM_USER_ID: ${INSTAGRAM_USER_ID:-}
|
||||
INSTAGRAM_ACCESS_TOKEN: ${INSTAGRAM_ACCESS_TOKEN:-}
|
||||
INSTAGRAM_POLL_INTERVAL: ${INSTAGRAM_POLL_INTERVAL:-1800}
|
||||
ports:
|
||||
- "8000:8000"
|
||||
environment:
|
||||
DATABASE_URL: postgresql+asyncpg://pole:pole@db:5432/poledance
|
||||
env_file:
|
||||
- .env
|
||||
depends_on:
|
||||
postgres:
|
||||
db:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- ./backend:/app
|
||||
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
pgdata:
|
||||
|
||||
41
mobile/.gitignore
vendored
41
mobile/.gitignore
vendored
@@ -1,41 +0,0 @@
|
||||
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
# Expo
|
||||
.expo/
|
||||
dist/
|
||||
web-build/
|
||||
expo-env.d.ts
|
||||
|
||||
# Native
|
||||
.kotlin/
|
||||
*.orig.*
|
||||
*.jks
|
||||
*.p8
|
||||
*.p12
|
||||
*.key
|
||||
*.mobileprovision
|
||||
|
||||
# Metro
|
||||
.metro-health-check*
|
||||
|
||||
# debug
|
||||
npm-debug.*
|
||||
yarn-debug.*
|
||||
yarn-error.*
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
|
||||
# generated native folders
|
||||
/ios
|
||||
/android
|
||||
@@ -1,49 +0,0 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { NavigationContainer } from '@react-navigation/native';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import * as Notifications from 'expo-notifications';
|
||||
import { RootNavigator } from './src/navigation/RootNavigator';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: 1,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export default function App() {
|
||||
const notificationListener = useRef<Notifications.EventSubscription>();
|
||||
const responseListener = useRef<Notifications.EventSubscription>();
|
||||
|
||||
useEffect(() => {
|
||||
// Handle notifications received while app is in foreground
|
||||
notificationListener.current = Notifications.addNotificationReceivedListener(
|
||||
(notification) => {
|
||||
console.log('Notification received:', notification);
|
||||
},
|
||||
);
|
||||
|
||||
// Handle notification tap
|
||||
responseListener.current = Notifications.addNotificationResponseReceivedListener(
|
||||
(response) => {
|
||||
console.log('Notification tapped:', response);
|
||||
// TODO: navigate to relevant screen based on response.notification.request.content.data
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
notificationListener.current?.remove();
|
||||
responseListener.current?.remove();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<NavigationContainer>
|
||||
<RootNavigator />
|
||||
</NavigationContainer>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "Pole Dance Championships",
|
||||
"slug": "poledance-championships",
|
||||
"version": "1.0.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "light",
|
||||
"newArchEnabled": false,
|
||||
"splash": {
|
||||
"image": "./assets/splash-icon.png",
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#6C3FC5"
|
||||
},
|
||||
"ios": {
|
||||
"supportsTablet": true,
|
||||
"bundleIdentifier": "com.yourorg.poledance",
|
||||
"infoPlist": {
|
||||
"UIBackgroundModes": ["remote-notification"]
|
||||
}
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/adaptive-icon.png",
|
||||
"backgroundColor": "#6C3FC5"
|
||||
},
|
||||
"package": "com.yourorg.poledance",
|
||||
"edgeToEdgeEnabled": true,
|
||||
"predictiveBackGestureEnabled": false
|
||||
},
|
||||
"web": {
|
||||
"favicon": "./assets/favicon.png"
|
||||
},
|
||||
"plugins": [
|
||||
[
|
||||
"expo-notifications",
|
||||
{
|
||||
"icon": "./assets/icon.png",
|
||||
"color": "#6C3FC5",
|
||||
"defaultChannel": "default"
|
||||
}
|
||||
]
|
||||
],
|
||||
"extra": {
|
||||
"eas": {
|
||||
"projectId": "YOUR_EAS_PROJECT_ID"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 17 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user