Compare commits

..

21 Commits

Author SHA1 Message Date
Dianaka123
d4f0a05707 POL-127: Add organizations table and championship ownership
- Create organizations table with Alembic migration (3-phase: create table, migrate data, drop old column)
- Add org_id FK on championships linking to organizations
- Refactor all schemas into one-class-per-file packages (auth, championship, organization, participant, registration, user)
- Update CRUD layer with selectinload for organization relationships
- Update frontend types and components to use nested organization object
- Remove phantom Championship fields (subtitle, venue, accent_color) from frontend

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 22:09:10 +03:00
Dianaka123
96e02bf64a POL-126: Fix critical backend bugs — Championship model/DB mismatch, broken imports
- Restore judges/categories TEXT columns to Championship model (were in DB but missing from model)
- Remove phantom columns not in DB: org_id, subtitle, venue, accent_color
- Remove broken relationships to unmigrated tables (Organization, Discipline, Style, Fee, Rule, Judge)
- Remove broken instagram_service import from lifespan (file doesn't exist)
- Add http://localhost:3000 to default CORS origins (web frontend)

Model files for unmigrated tables kept on disk for future migration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 22:33:24 +03:00
Dianaka123
0716f09e3f Add unrelated-fix rule to Linear workflow in CLAUDE.md
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 14:09:05 +03:00
Dianaka123
7e123f1a31 Add Judge model for championships
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 14:05:05 +03:00
Dianaka123
b5fa1fe746 POL-125: Add showPassword state to login form hook
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 14:05:03 +03:00
Dianaka123
95836f441d POL-125: Fix profile label — restore "Instagram" text
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 14:02:54 +03:00
Dianaka123
6fbd0326fa POL-125: Frontend visual upgrade — dark luxury pole dance theme
- New dark theme with rose/purple/gold accent palette
- Premium typography: Cormorant Garamond (display) + Outfit (body)
- Glassmorphism cards, gradient mesh backgrounds, glow effects
- CSS split into theme.css, utilities.css, animations.css
- Staggered fade-in animations on list pages
- Redesigned all pages: auth, championships, registrations, profile, admin
- Lucide icons replace emoji throughout
- Responsive mobile nav with hamburger menu

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 13:56:45 +03:00
Dianaka123
cf4104069e Refactor: move tokenStorage into lib/api/
It only serves the API client — belongs with the HTTP layer.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-26 21:07:15 +03:00
Dianaka123
c948179b5b Refactor: reorganize components into domain subfolders
shared/    → StatusBadge
layout/    → Navbar
championships/ → ChampionshipCard
registrations/ → RegistrationCard, RegistrationTimeline
admin/     → UserCard

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-26 20:58:35 +03:00
Dianaka123
0767b87c1e Refactor: merge related hooks (8 files → 5)
- useChampionship + useChampionships → useChampionships
- useMyRegistrations + useRegisterForChampionship → useRegistrations
- useLoginForm + useRegisterForm → useAuthForms

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-26 20:47:14 +03:00
Dianaka123
5b7260de84 Refactor: move useAuth from store/ into hooks/
store/ folder removed — single file doesn't justify its own folder.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-26 20:41:49 +03:00
Dianaka123
9fcd7c1d63 POL-124: Migrate frontend from React Native to Next.js web app
- Replace mobile/ (Expo) with web/ (Next.js 16 + Tailwind + shadcn/ui)
- Pages: login, register, pending, championships, championship detail, registrations, profile, admin
- Logic/view separated: hooks/ for data, components/ for UI, pages compose both
- Types in src/types/ (one interface per file)
- Auth: Zustand store + localStorage tokens + cookie presence flag for proxy
- API layer: axios client with JWT auto-refresh

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-26 20:35:22 +03:00
Dianaka123
390c338b32 POL-10: Add rules table
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-26 15:21:01 +03:00
Dianaka123
17277836eb POL-9: Add fees table
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-26 15:20:44 +03:00
Dianaka123
e1e9de2bce POL-8: Add styles table
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-26 15:20:34 +03:00
Dianaka123
d4a0daebb2 POL-7: Add disciplines table
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-26 15:19:45 +03:00
Dianaka123
6528e89b69 POL-6: Expand championships table
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-26 15:19:35 +03:00
Dianaka123
d96d5560cf POL-5: Add organizations table
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-26 15:19:23 +03:00
Dianaka123
4c1870ebb4 Add root CLAUDE.md and rename spec CLAUDE.md
Created project-level CLAUDE.md with current architecture, quick start,
gotchas, and conventions. Renamed dancechamp-claude-code/CLAUDE.md to
SPEC-CLAUDE.md to distinguish target spec from current project context.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 23:22:08 +03:00
Dianaka123
789d2bf0a6 Full app rebuild: FastAPI backend + React Native mobile with auth, championships, admin
Backend (FastAPI + SQLAlchemy + SQLite):
- JWT auth with access/refresh tokens, bcrypt password hashing
- User model with member/organizer/admin roles, auto-approve members
- Championship, Registration, ParticipantList, Notification models
- Alembic async migrations, seed data with test users
- Registration endpoint returns tokens for members, pending for organizers
- /registrations/my returns championship title/date/location via eager loading
- Admin endpoints: list users, approve/reject organizers

Mobile (React Native + Expo + TypeScript):
- Zustand auth store, Axios client with token refresh interceptor
- Role-based registration (Member vs Organizer) with contextual form labels
- Tab navigation with Ionicons, safe area headers, admin tab for admin role
- Championships list with status badges, detail screen with registration progress
- My Registrations with championship title, progress bar, and tap-to-navigate
- Admin panel with pending/all filter, approve/reject with confirmation
- Profile screen with role badge, Ionicons info rows, sign out
- Password visibility toggle (Ionicons), keyboard flow hints (returnKeyType)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 22:46:50 +03:00
Dianaka123
9eb68695e9 Clear project — starting fresh from spec
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 14:36:47 +03:00
200 changed files with 19920 additions and 12471 deletions

View File

@@ -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\")"
]
}
}

View File

@@ -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:*)"
]
}
}

View File

@@ -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
View File

@@ -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
View 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
View 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
View 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)

View File

@@ -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"]

View File

@@ -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

View File

@@ -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()

View File

@@ -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")

View File

@@ -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 ###

View 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 ###

View File

@@ -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')

View File

@@ -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()

View File

@@ -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"]

View File

@@ -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

View 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()

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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())

View File

@@ -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

View File

@@ -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

View File

@@ -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"}

View File

@@ -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",

View File

@@ -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]

View 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
View 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]

View 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]

View 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]

View File

@@ -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"
)

View 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]

View 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]

View File

@@ -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()

View File

@@ -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")

View File

@@ -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]

View 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]

View 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]

View File

@@ -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")

View File

@@ -0,0 +1,3 @@
from app.routers import auth, championships, registrations, participant_lists, users
__all__ = ["auth", "championships", "registrations", "participant_lists", "users"]

View File

@@ -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)

View File

@@ -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)

View File

@@ -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),
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.get(
"/championships/{champ_id}/participant-list/registrations",
response_model=list[RegistrationWithUser],
)
@router.post("/{championship_id}/participant-list/publish", response_model=ParticipantListOut)
async def publish_participant_list(
championship_id: str,
organizer: User = Depends(get_organizer),
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)
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"]

View File

@@ -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)

View File

@@ -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)

View File

@@ -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}

View 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"]

View File

@@ -0,0 +1,5 @@
from pydantic import BaseModel
class LogoutRequest(BaseModel):
refresh_token: str

View File

@@ -0,0 +1,5 @@
from pydantic import BaseModel
class RefreshRequest(BaseModel):
refresh_token: str

View 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"

View 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

View File

@@ -0,0 +1,7 @@
from pydantic import BaseModel
class TokenRefreshed(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"

View File

@@ -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}

View 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"]

View 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

View 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

View 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

View File

@@ -0,0 +1,4 @@
from app.schemas.organization.out import OrganizationOut
from app.schemas.organization.brief import OrganizationBrief
__all__ = ["OrganizationOut", "OrganizationBrief"]

View 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

View 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

View File

@@ -0,0 +1,4 @@
from app.schemas.participant.out import ParticipantListOut
from app.schemas.participant.publish import ParticipantListPublish
__all__ = ["ParticipantListOut", "ParticipantListPublish"]

View File

@@ -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

View File

@@ -0,0 +1,5 @@
from pydantic import BaseModel
class ParticipantListPublish(BaseModel):
notes: str | None = None

View 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",
]

View 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

View 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

View File

@@ -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}

View 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

View File

@@ -0,0 +1,6 @@
from app.schemas.registration.out import RegistrationOut
from app.schemas.user import UserOut
class RegistrationWithUser(RegistrationOut):
user: UserOut

View File

@@ -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

View 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"]

View File

@@ -0,0 +1,6 @@
from pydantic import BaseModel, EmailStr
class UserLogin(BaseModel):
email: EmailStr
password: str

View 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

View 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

View 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

View File

@@ -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()

View File

@@ -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)

View File

@@ -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()

View File

@@ -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

View File

@@ -1,2 +0,0 @@
[pytest]
asyncio_mode = auto

View File

@@ -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
View 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())

View File

@@ -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()

View File

@@ -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

View File

@@ -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

View 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).

View 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 110)
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.

View 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';
```

View 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)

View 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 (010)
- 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 (110)
- 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 (010): 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 🗑️]

View 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, 010), 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: 010", "Technique: 010")
- 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)

View 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>;
}

View 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:003:00", eligibility: "Up to 2 yrs, no instructor/pro background", type: "solo" },
{ name: "Amateur", duration: "2:303:00", eligibility: "24 yrs, no instructor/pro background", type: "solo" },
{ name: "Semi-Pro", duration: "2:503:20", eligibility: "3+ yrs, instructor OR pro OR prizes in Amateur", type: "solo" },
{ name: "Profi", duration: "3:003:30", eligibility: "4+ yrs, instructor OR pro OR prizes in Semi-Pro", type: "solo" },
{ name: "Elite", duration: "3:004:00", eligibility: "3+ prizes in Profi OR widely known", type: "solo" },
{ name: "Duets & Groups", duration: "3:004:20", eligibility: "Open to all levels", type: "group" },
]},
{ name: "Pole Art", performanceReq: "60% floor & mid-level, 40% upper level", categories: [
{ name: "Amateur", duration: "2:303:00", eligibility: "Up to 2 yrs, no instructor/pro background", type: "solo" },
{ name: "Semi-Pro", duration: "2:503:20", eligibility: "3+ yrs, instructor OR pro OR prizes in Amateur", type: "solo" },
{ name: "Profi", duration: "3:003: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: ["1st3rd 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 1213, 2026", location: "Moscow, Russia", venue: "Crystal Hall",
disciplines: [{ name: "Exotic Pole Dance", categories: [
{ name: "Amateur", duration: "2:303:00", eligibility: "24 years", type: "solo" },
{ name: "Profi", duration: "3:003: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 (010 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>;
}

View 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 1213, 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 (010)</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>;
}

View File

@@ -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
View File

@@ -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

View File

@@ -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>
);
}

View File

@@ -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