Compare commits

..

2 Commits

Author SHA1 Message Date
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
102 changed files with 6545 additions and 3368 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/

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

@@ -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: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_championship, crud_registration, crud_participant
__all__ = ["crud_user", "crud_championship", "crud_registration", "crud_participant"]

View File

@@ -1,30 +1,22 @@
import json
import uuid
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
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:
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
)
)
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 == cid))
return result.scalar_one_or_none()
@@ -32,28 +24,35 @@ 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())
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) -> Championship:
payload = data.model_dump(exclude={"judges", "categories"})
payload["judges"] = _serialize(data.judges)
payload["categories"] = _serialize(data.categories)
champ = Championship(**payload)
db.add(champ)
await db.commit()
await db.refresh(champ)
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

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

@@ -4,36 +4,32 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
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_email(db: AsyncSession, email: str) -> User | None:
result = await db.execute(select(User).where(User.email == email))
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
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))
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 get_by_email(db: AsyncSession, email: str) -> User | None:
result = await db.execute(select(User).where(User.email == email.lower()))
return result.scalar_one_or_none()
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,
role=data.requested_role,
organization_name=data.organization_name,
instagram_handle=data.instagram_handle,
# Members are auto-approved; organizers require admin review
status="approved" if data.requested_role == "member" else "pending",
)
db.add(user)
await db.commit()
@@ -41,21 +37,12 @@ async def create(
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:
for field, value in data.model_dump(exclude_none=True).items():
setattr(user, field, value)
await db.commit()
await db.refresh(user)
return user
async def set_status(db: AsyncSession, user: User, status: str) -> User:
@@ -65,8 +52,6 @@ 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).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,41 @@
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()
# Start Instagram sync scheduler if configured
if settings.INSTAGRAM_USER_ID and settings.INSTAGRAM_ACCESS_TOKEN:
from app.services.instagram_service import start_scheduler
scheduler = start_scheduler()
yield
scheduler.shutdown()
else:
yield
app = FastAPI(
title="Pole Dance Championships API",
version="1.0.0",
lifespan=lifespan,
)
app = FastAPI(title="Pole Dance Championships API", version="1.0.0", lifespan=lifespan)
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,9 +1,8 @@
from app.models.user import User
from app.models.refresh_token import RefreshToken
from app.models.user import User, RefreshToken
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",

View File

@@ -1,41 +1,42 @@
import uuid
from datetime import datetime, timezone
from datetime import datetime
from sqlalchemy import DateTime, String, Text
from sqlalchemy import DateTime, Float, 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)
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'
# Extended fields
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: [str]
# 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"
)
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,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,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

@@ -1,40 +1,44 @@
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))
# Organizer-specific fields
organization_name: Mapped[str | None] = mapped_column(String(255))
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]
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,69 @@
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")
return await crud_championship.create(db, data)
@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 +1,36 @@
import uuid
from pydantic import BaseModel
from pydantic import BaseModel, EmailStr
from app.schemas.user import UserOut
class RegisterRequest(BaseModel):
email: EmailStr
password: str
full_name: str
phone: str | None = None
class LoginRequest(BaseModel):
email: EmailStr
password: str
class TokenResponse(BaseModel):
class TokenPair(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
user: UserOut
class RefreshRequest(BaseModel):
refresh_token: str
class UserOut(BaseModel):
id: uuid.UUID
email: str
full_name: str
phone: str | None
role: str
status: str
class TokenRefreshed(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
model_config = {"from_attributes": True}
class LogoutRequest(BaseModel):
refresh_token: str
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

@@ -1,7 +1,8 @@
import json
import uuid
from datetime import datetime
from pydantic import BaseModel
from pydantic import BaseModel, model_validator
class ChampionshipCreate(BaseModel):
@@ -11,6 +12,11 @@ class ChampionshipCreate(BaseModel):
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
@@ -22,11 +28,18 @@ class ChampionshipUpdate(BaseModel):
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
class ChampionshipOut(BaseModel):
model_config = {"from_attributes": True}
id: uuid.UUID
title: str
description: str | None
@@ -34,10 +47,27 @@ class ChampionshipOut(BaseModel):
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
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
@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

@@ -4,16 +4,16 @@ 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
created_at: datetime
model_config = {"from_attributes": True}
class ParticipantListPublish(BaseModel):
notes: str | None = None

View File

@@ -1,7 +1,10 @@
import uuid
from datetime import datetime
from typing import Any
from pydantic import BaseModel
from pydantic import BaseModel, model_validator
from app.schemas.user import UserOut
class RegistrationCreate(BaseModel):
@@ -11,11 +14,17 @@ class RegistrationCreate(BaseModel):
notes: str | None = None
class RegistrationStatusUpdate(BaseModel):
status: str # 'accepted' | 'rejected' | 'waitlisted'
class RegistrationUpdate(BaseModel):
status: str | None = None
video_url: str | None = None
category: str | None = None
level: str | None = None
notes: str | None = None
class RegistrationOut(BaseModel):
model_config = {"from_attributes": True}
id: uuid.UUID
championship_id: uuid.UUID
user_id: uuid.UUID
@@ -23,7 +32,26 @@ 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}
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
class RegistrationWithUser(RegistrationOut):
user: UserOut

View File

@@ -1,26 +1,52 @@
import uuid
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, EmailStr
from pydantic import BaseModel, EmailStr, field_validator
class UserCreate(BaseModel):
class UserRegister(BaseModel):
email: EmailStr
password: str
full_name: str
phone: str | None = None
role: str = "member"
# 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
class UserLogin(BaseModel):
email: EmailStr
password: str
class UserOut(BaseModel):
model_config = {"from_attributes": True}
id: uuid.UUID
email: str
full_name: str
phone: str | None
role: str
status: str
model_config = {"from_attributes": True}
organization_name: str | None
instagram_handle: str | None
expo_push_token: str | None
created_at: datetime
class PushTokenUpdate(BaseModel):
expo_push_token: str
class UserUpdate(BaseModel):
full_name: str | None = None
phone: str | None = None
organization_name: str | None = None
instagram_handle: str | None = None
expo_push_token: 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

134
backend/seed.py Normal file
View File

@@ -0,0 +1,134 @@
"""Seed script — creates test users and one championship.
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.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",
},
{
"email": "member@pole.dev",
"full_name": "Anna Petrova",
"password": "Member1234",
"role": "member",
"status": "approved",
},
{
"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"],
)
db.add(user)
print(f" Created user: {ud['email']}")
else:
# Update role/status if needed
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()
# ── 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(**cd)
db.add(champ)
print(f" Created championship: {cd['title']}")
else:
print(f" Championship already exists: {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:

View File

@@ -1,49 +1,19 @@
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
},
},
});
import { useEffect } from 'react';
import { StatusBar } from 'expo-status-bar';
import RootNavigator from './src/navigation';
import { useAuthStore } from './src/store/auth.store';
export default function App() {
const notificationListener = useRef<Notifications.EventSubscription>();
const responseListener = useRef<Notifications.EventSubscription>();
const initialize = useAuthStore((s) => s.initialize);
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();
};
initialize();
}, []);
return (
<QueryClientProvider client={queryClient}>
<NavigationContainer>
<>
<StatusBar style="dark" />
<RootNavigator />
</NavigationContainer>
</QueryClientProvider>
</>
);
}

View File

@@ -1,7 +1,7 @@
{
"expo": {
"name": "Pole Dance Championships",
"slug": "poledance-championships",
"name": "Pole Championships",
"slug": "pole-championships",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
@@ -10,41 +10,19 @@
"splash": {
"image": "./assets/splash-icon.png",
"resizeMode": "contain",
"backgroundColor": "#6C3FC5"
"backgroundColor": "#ffffff"
},
"ios": {
"supportsTablet": true,
"bundleIdentifier": "com.yourorg.poledance",
"infoPlist": {
"UIBackgroundModes": ["remote-notification"]
}
"supportsTablet": true
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#6C3FC5"
},
"package": "com.yourorg.poledance",
"edgeToEdgeEnabled": true,
"predictiveBackGestureEnabled": false
"backgroundColor": "#ffffff"
}
},
"web": {
"favicon": "./assets/favicon.png"
},
"plugins": [
[
"expo-notifications",
{
"icon": "./assets/icon.png",
"color": "#6C3FC5",
"defaultChannel": "default"
}
]
],
"extra": {
"eas": {
"projectId": "YOUR_EAS_PROJECT_ID"
}
}
}
}

595
mobile/package-lock.json generated
View File

@@ -15,16 +15,13 @@
"@tanstack/react-query": "^5.90.21",
"axios": "^1.13.5",
"expo": "~54.0.33",
"expo-notifications": "~0.32.16",
"expo-secure-store": "^15.0.8",
"expo-status-bar": "~3.0.9",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-hook-form": "^7.71.2",
"react-native": "0.81.5",
"react-native-safe-area-context": "^5.6.2",
"react-native-screens": "^4.16.0",
"react-native-web": "~0.21.0",
"react-native-safe-area-context": "^5.7.0",
"react-native-screens": "4.16.0",
"zod": "^4.3.6",
"zustand": "^5.0.11"
},
@@ -2053,9 +2050,9 @@
"integrity": "sha512-nDRbLmSrJar7abvUjp3smDwH8HcbZcoOEa5jVPUv9/9CajgmWw20JNRwTuBRzWIWIkEJDkz20GoNA+tSwUqk0Q=="
},
"node_modules/@expo/xcpretty": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/@expo/xcpretty/-/xcpretty-4.4.0.tgz",
"integrity": "sha512-o2qDlTqJ606h4xR36H2zWTywmZ3v3842K6TU8Ik2n1mfW0S580VHlt3eItVYdLYz+klaPp7CXqanja8eASZjRw==",
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/@expo/xcpretty/-/xcpretty-4.4.1.tgz",
"integrity": "sha512-KZNxZvnGCtiM2aYYZ6Wz0Ix5r47dAvpNLApFtZWnSoERzAdOMzVBOPysBoM0JlF6FKWZ8GPqgn6qt3dV/8Zlpg==",
"dependencies": {
"@babel/code-frame": "^7.20.0",
"chalk": "^4.1.0",
@@ -2169,11 +2166,6 @@
"react-hook-form": "^7.55.0"
}
},
"node_modules/@ide/backoff": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@ide/backoff/-/backoff-1.0.0.tgz",
"integrity": "sha512-F0YfUDjvT+Mtt/R4xdl2X0EYCHMMiJqNLdxHD++jDT5ydEFIyqbCHh51Qx2E211dgZprPKhV7sHmnXKpLuvc5g=="
},
"node_modules/@isaacs/fs-minipass": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
@@ -3174,18 +3166,6 @@
"resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz",
"integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA=="
},
"node_modules/assert": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/assert/-/assert-2.1.0.tgz",
"integrity": "sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw==",
"dependencies": {
"call-bind": "^1.0.2",
"is-nan": "^1.3.2",
"object-is": "^1.1.5",
"object.assign": "^4.1.4",
"util": "^0.12.5"
}
},
"node_modules/async-limiter": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.1.tgz",
@@ -3196,20 +3176,6 @@
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
},
"node_modules/available-typed-arrays": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
"integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==",
"dependencies": {
"possible-typed-array-names": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/axios": {
"version": "1.13.5",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz",
@@ -3459,11 +3425,6 @@
"@babel/core": "^7.0.0"
}
},
"node_modules/badgin": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/badgin/-/badgin-1.2.3.tgz",
"integrity": "sha512-NQGA7LcfCpSzIbGRbkgjgdWkjy7HI+Th5VLxTJfW5EeaAf3fnS+xWQaQOCYiny+q6QSvxqoSO04vCx+4u++EJw=="
},
"node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
@@ -3654,23 +3615,6 @@
"node": ">= 0.8"
}
},
"node_modules/call-bind": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
"integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==",
"dependencies": {
"call-bind-apply-helpers": "^1.0.0",
"es-define-property": "^1.0.0",
"get-intrinsic": "^1.2.4",
"set-function-length": "^1.2.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
@@ -3683,21 +3627,6 @@
"node": ">= 0.4"
}
},
"node_modules/call-bound": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/camelcase": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz",
@@ -3710,9 +3639,9 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001772",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001772.tgz",
"integrity": "sha512-mIwLZICj+ntVTw4BT2zfp+yu/AqV6GMKfJVJMx3MwPxs+uk/uj2GLl2dH8LQbjiLDX66amCga5nKFyDgRR43kg==",
"version": "1.0.30001774",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001774.tgz",
"integrity": "sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA==",
"funding": [
{
"type": "opencollective",
@@ -4016,14 +3945,6 @@
"url": "https://opencollective.com/core-js"
}
},
"node_modules/cross-fetch": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.2.0.tgz",
"integrity": "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==",
"dependencies": {
"node-fetch": "^2.7.0"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -4045,14 +3966,6 @@
"node": ">=8"
}
},
"node_modules/css-in-js-utils": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/css-in-js-utils/-/css-in-js-utils-3.1.0.tgz",
"integrity": "sha512-fJAcud6B3rRu+KHYk+Bwf+WFL2MDCJJ1XG9x137tJQ0xYxor7XziQtuGFbWNdqrvF4Tk26O3H73nfVqXt/fW1A==",
"dependencies": {
"hyphenate-style-name": "^1.0.3"
}
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -4110,22 +4023,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/define-data-property": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
"integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
"dependencies": {
"es-define-property": "^1.0.0",
"es-errors": "^1.3.0",
"gopd": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/define-lazy-prop": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz",
@@ -4134,22 +4031,6 @@
"node": ">=8"
}
},
"node_modules/define-properties": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz",
"integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==",
"dependencies": {
"define-data-property": "^1.0.1",
"has-property-descriptors": "^1.0.0",
"object-keys": "^1.1.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
@@ -4406,27 +4287,6 @@
}
}
},
"node_modules/expo-application": {
"version": "7.0.8",
"resolved": "https://registry.npmjs.org/expo-application/-/expo-application-7.0.8.tgz",
"integrity": "sha512-qFGyxk7VJbrNOQWBbE09XUuGuvkOgFS9QfToaK2FdagM2aQ+x3CvGV2DuVgl/l4ZxPgIf3b/MNh9xHpwSwn74Q==",
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-constants": {
"version": "18.0.13",
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.13.tgz",
"integrity": "sha512-FnZn12E1dRYKDHlAdIyNFhBurKTS3F9CrfrBDJI5m3D7U17KBHMQ6JEfYlSj7LG7t+Ulr+IKaj58L1k5gBwTcQ==",
"dependencies": {
"@expo/config": "~12.0.13",
"@expo/env": "~2.0.8"
},
"peerDependencies": {
"expo": "*",
"react-native": "*"
}
},
"node_modules/expo-modules-autolinking": {
"version": "3.0.24",
"resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.24.tgz",
@@ -4518,25 +4378,6 @@
"react-native": "*"
}
},
"node_modules/expo-notifications": {
"version": "0.32.16",
"resolved": "https://registry.npmjs.org/expo-notifications/-/expo-notifications-0.32.16.tgz",
"integrity": "sha512-QQD/UA6v7LgvwIJ+tS7tSvqJZkdp0nCSj9MxsDk/jU1GttYdK49/5L2LvE/4U0H7sNBz1NZAyhDZozg8xgBLXw==",
"dependencies": {
"@expo/image-utils": "^0.8.8",
"@ide/backoff": "^1.0.0",
"abort-controller": "^3.0.0",
"assert": "^2.0.0",
"badgin": "^1.1.5",
"expo-application": "~7.0.8",
"expo-constants": "~18.0.13"
},
"peerDependencies": {
"expo": "*",
"react": "*",
"react-native": "*"
}
},
"node_modules/expo-secure-store": {
"version": "15.0.8",
"resolved": "https://registry.npmjs.org/expo-secure-store/-/expo-secure-store-15.0.8.tgz",
@@ -4721,9 +4562,9 @@
}
},
"node_modules/expo/node_modules/@expo/vector-icons": {
"version": "15.0.3",
"resolved": "https://registry.npmjs.org/@expo/vector-icons/-/vector-icons-15.0.3.tgz",
"integrity": "sha512-SBUyYKphmlfUBqxSfDdJ3jAdEVSALS2VUPOUyqn48oZmb2TL/O7t7/PQm5v4NQujYEPLPMTLn9KVw6H7twwbTA==",
"version": "15.1.1",
"resolved": "https://registry.npmjs.org/@expo/vector-icons/-/vector-icons-15.1.1.tgz",
"integrity": "sha512-Iu2VkcoI5vygbtYngm7jb4ifxElNVXQYdDrYkT7UCEIiKLeWnQY0wf2ZhHZ+Wro6Sc5TaumpKUOqDRpLi5rkvw==",
"peerDependencies": {
"expo-font": ">=14.0.4",
"react": "*",
@@ -4845,6 +4686,19 @@
"react-native": "*"
}
},
"node_modules/expo/node_modules/expo-constants": {
"version": "18.0.13",
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.13.tgz",
"integrity": "sha512-FnZn12E1dRYKDHlAdIyNFhBurKTS3F9CrfrBDJI5m3D7U17KBHMQ6JEfYlSj7LG7t+Ulr+IKaj58L1k5gBwTcQ==",
"dependencies": {
"@expo/config": "~12.0.13",
"@expo/env": "~2.0.8"
},
"peerDependencies": {
"expo": "*",
"react-native": "*"
}
},
"node_modules/expo/node_modules/expo-file-system": {
"version": "19.0.21",
"resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.21.tgz",
@@ -4962,33 +4816,6 @@
"bser": "2.1.1"
}
},
"node_modules/fbjs": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/fbjs/-/fbjs-3.0.5.tgz",
"integrity": "sha512-ztsSx77JBtkuMrEypfhgc3cI0+0h+svqeie7xHbh1k/IKdcydnvadp/mUaGgjAOXQmQSxsqgaRhS3q9fy+1kxg==",
"dependencies": {
"cross-fetch": "^3.1.5",
"fbjs-css-vars": "^1.0.0",
"loose-envify": "^1.0.0",
"object-assign": "^4.1.0",
"promise": "^7.1.1",
"setimmediate": "^1.0.5",
"ua-parser-js": "^1.0.35"
}
},
"node_modules/fbjs-css-vars": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/fbjs-css-vars/-/fbjs-css-vars-1.0.2.tgz",
"integrity": "sha512-b2XGFAFdWZWg0phtAWLHCk836A1Xann+I+Dgd3Gk64MHKZO44FfoD1KxyvbSh0qZsIoXQGGlVztIY+oitJPpRQ=="
},
"node_modules/fbjs/node_modules/promise": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/promise/-/promise-7.3.1.tgz",
"integrity": "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==",
"dependencies": {
"asap": "~2.0.3"
}
},
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@@ -5079,20 +4906,6 @@
"resolved": "https://registry.npmjs.org/fontfaceobserver/-/fontfaceobserver-2.3.0.tgz",
"integrity": "sha512-6FPvD/IVyT4ZlNe7Wcn5Fb/4ChigpucKYSvD6a+0iMoLn2inpo711eyIcKjmDtE5XNcgAkSH9uN/nfAeZzHEfg=="
},
"node_modules/for-each": {
"version": "0.3.5",
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
"integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==",
"dependencies": {
"is-callable": "^1.2.7"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/form-data": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
@@ -5150,14 +4963,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/generator-function": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz",
"integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gensync": {
"version": "1.0.0-beta.2",
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
@@ -5290,17 +5095,6 @@
"node": ">=4"
}
},
"node_modules/has-property-descriptors": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
"integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
"dependencies": {
"es-define-property": "^1.0.0"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
@@ -5405,11 +5199,6 @@
"node": ">= 14"
}
},
"node_modules/hyphenate-style-name": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.1.0.tgz",
"integrity": "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw=="
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
@@ -5479,14 +5268,6 @@
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="
},
"node_modules/inline-style-prefixer": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/inline-style-prefixer/-/inline-style-prefixer-7.0.1.tgz",
"integrity": "sha512-lhYo5qNTQp3EvSSp3sRvXMbVQTLrvGV6DycRMJ5dm2BLMiJ30wpXKdDdgX+GmJZ5uQMucwRKHamXSst3Sj/Giw==",
"dependencies": {
"css-in-js-utils": "^3.1.0"
}
},
"node_modules/invariant": {
"version": "2.2.4",
"resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz",
@@ -5495,37 +5276,11 @@
"loose-envify": "^1.0.0"
}
},
"node_modules/is-arguments": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz",
"integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==",
"dependencies": {
"call-bound": "^1.0.2",
"has-tostringtag": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-arrayish": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz",
"integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA=="
},
"node_modules/is-callable": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz",
"integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-core-module": {
"version": "2.16.1",
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
@@ -5562,39 +5317,6 @@
"node": ">=8"
}
},
"node_modules/is-generator-function": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz",
"integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==",
"dependencies": {
"call-bound": "^1.0.4",
"generator-function": "^2.0.0",
"get-proto": "^1.0.1",
"has-tostringtag": "^1.0.2",
"safe-regex-test": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-nan": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/is-nan/-/is-nan-1.3.2.tgz",
"integrity": "sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w==",
"dependencies": {
"call-bind": "^1.0.0",
"define-properties": "^1.1.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
@@ -5603,37 +5325,6 @@
"node": ">=0.12.0"
}
},
"node_modules/is-regex": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
"integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==",
"dependencies": {
"call-bound": "^1.0.2",
"gopd": "^1.2.0",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-typed-array": {
"version": "1.1.15",
"resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz",
"integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==",
"dependencies": {
"which-typed-array": "^1.1.16"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-wsl": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
@@ -6954,25 +6645,6 @@
"resolved": "https://registry.npmjs.org/nested-error-stacks/-/nested-error-stacks-2.0.1.tgz",
"integrity": "sha512-SrQrok4CATudVzBS7coSz26QRSmlK9TzzoFbeKfcPBUFPjcQM9Rqvr/DlJkOrwI/0KcgvMub1n1g5Jt9EgRn4A=="
},
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/node-forge": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.3.tgz",
@@ -7037,48 +6709,6 @@
"node": ">=0.10.0"
}
},
"node_modules/object-is": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz",
"integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==",
"dependencies": {
"call-bind": "^1.0.7",
"define-properties": "^1.2.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/object-keys": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
"integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/object.assign": {
"version": "4.1.7",
"resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz",
"integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==",
"dependencies": {
"call-bind": "^1.0.8",
"call-bound": "^1.0.3",
"define-properties": "^1.2.1",
"es-object-atoms": "^1.0.0",
"has-symbols": "^1.1.0",
"object-keys": "^1.1.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/on-finished": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz",
@@ -7330,14 +6960,6 @@
"node": ">=4.0.0"
}
},
"node_modules/possible-typed-array-names": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
"integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/postcss": {
"version": "8.4.49",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz",
@@ -7365,11 +6987,6 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/postcss-value-parser": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="
},
"node_modules/pretty-bytes": {
"version": "5.6.0",
"resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz",
@@ -7526,17 +7143,6 @@
"ws": "^7"
}
},
"node_modules/react-dom": {
"version": "19.1.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
"dependencies": {
"scheduler": "^0.26.0"
},
"peerDependencies": {
"react": "^19.1.0"
}
},
"node_modules/react-freeze": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/react-freeze/-/react-freeze-1.0.4.tgz",
@@ -7634,9 +7240,9 @@
}
},
"node_modules/react-native-safe-area-context": {
"version": "5.6.2",
"resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.6.2.tgz",
"integrity": "sha512-4XGqMNj5qjUTYywJqpdWZ9IG8jgkS3h06sfVjfw5yZQZfWnRFXczi0GnYyFyCc2EBps/qFmoCH8fez//WumdVg==",
"version": "5.7.0",
"resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.7.0.tgz",
"integrity": "sha512-/9/MtQz8ODphjsLdZ+GZAIcC/RtoqW9EeShf7Uvnfgm/pzYrJ75y3PV/J1wuAV1T5Dye5ygq4EAW20RoBq0ABQ==",
"peerDependencies": {
"react": "*",
"react-native": "*"
@@ -7656,35 +7262,6 @@
"react-native": "*"
}
},
"node_modules/react-native-web": {
"version": "0.21.2",
"resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.21.2.tgz",
"integrity": "sha512-SO2t9/17zM4iEnFvlu2DA9jqNbzNhoUP+AItkoCOyFmDMOhUnBBznBDCYN92fGdfAkfQlWzPoez6+zLxFNsZEg==",
"dependencies": {
"@babel/runtime": "^7.18.6",
"@react-native/normalize-colors": "^0.74.1",
"fbjs": "^3.0.4",
"inline-style-prefixer": "^7.0.1",
"memoize-one": "^6.0.0",
"nullthrows": "^1.1.1",
"postcss-value-parser": "^4.2.0",
"styleq": "^0.1.3"
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
}
},
"node_modules/react-native-web/node_modules/@react-native/normalize-colors": {
"version": "0.74.89",
"resolved": "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.74.89.tgz",
"integrity": "sha512-qoMMXddVKVhZ8PA1AbUCk83trpd6N+1nF2A6k1i6LsQObyS92fELuk8kU/lQs6M7BsMHwqyLCpQJ1uFgNvIQXg=="
},
"node_modules/react-native-web/node_modules/memoize-one": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz",
"integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw=="
},
"node_modules/react-native/node_modules/@react-native/virtualized-lists": {
"version": "0.81.5",
"resolved": "https://registry.npmjs.org/@react-native/virtualized-lists/-/virtualized-lists-0.81.5.tgz",
@@ -8008,22 +7585,6 @@
}
]
},
"node_modules/safe-regex-test": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz",
"integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"is-regex": "^1.2.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/sax": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.4.4.tgz",
@@ -8141,27 +7702,6 @@
"node": ">= 0.8"
}
},
"node_modules/set-function-length": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
"integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==",
"dependencies": {
"define-data-property": "^1.1.4",
"es-errors": "^1.3.0",
"function-bind": "^1.1.2",
"get-intrinsic": "^1.2.4",
"gopd": "^1.0.1",
"has-property-descriptors": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
@@ -8391,11 +7931,6 @@
"resolved": "https://registry.npmjs.org/structured-headers/-/structured-headers-0.4.1.tgz",
"integrity": "sha512-0MP/Cxx5SzeeZ10p/bZI0S6MpgD+yxAhi1BOQ34jgnMXsCq3j1t6tQnZu+KdlL7dvJTLT3g9xN8tl10TqgFMcg=="
},
"node_modules/styleq": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/styleq/-/styleq-0.1.3.tgz",
"integrity": "sha512-3ZUifmCDCQanjeej1f6kyl/BeP/Vae5EYkQ9iJfUm/QwZvlgnZzyflqAsAWYURdtea8Vkvswu2GrC57h3qffcA=="
},
"node_modules/sucrase": {
"version": "3.35.1",
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
@@ -8694,11 +8229,6 @@
"node": ">=0.6"
}
},
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="
},
"node_modules/ts-interface-checker": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
@@ -8733,31 +8263,6 @@
"node": ">=14.17"
}
},
"node_modules/ua-parser-js": {
"version": "1.0.41",
"resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.41.tgz",
"integrity": "sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/ua-parser-js"
},
{
"type": "paypal",
"url": "https://paypal.me/faisalman"
},
{
"type": "github",
"url": "https://github.com/sponsors/faisalman"
}
],
"bin": {
"ua-parser-js": "script/cli.js"
},
"engines": {
"node": "*"
}
},
"node_modules/undici": {
"version": "6.23.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.23.0.tgz",
@@ -8871,18 +8376,6 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/util": {
"version": "0.12.5",
"resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz",
"integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==",
"dependencies": {
"inherits": "^2.0.3",
"is-arguments": "^1.0.4",
"is-generator-function": "^1.0.7",
"is-typed-array": "^1.1.3",
"which-typed-array": "^1.1.2"
}
},
"node_modules/utils-merge": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
@@ -8954,15 +8447,6 @@
"resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz",
"integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg=="
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/whatwg-url-without-unicode": {
"version": "8.0.0-3",
"resolved": "https://registry.npmjs.org/whatwg-url-without-unicode/-/whatwg-url-without-unicode-8.0.0-3.tgz",
@@ -8976,11 +8460,6 @@
"node": ">=10"
}
},
"node_modules/whatwg-url/node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -8995,26 +8474,6 @@
"node": ">= 8"
}
},
"node_modules/which-typed-array": {
"version": "1.1.20",
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz",
"integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==",
"dependencies": {
"available-typed-arrays": "^1.0.7",
"call-bind": "^1.0.8",
"call-bound": "^1.0.4",
"for-each": "^0.3.5",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-tostringtag": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/wonka": {
"version": "6.3.5",
"resolved": "https://registry.npmjs.org/wonka/-/wonka-6.3.5.tgz",

View File

@@ -16,16 +16,13 @@
"@tanstack/react-query": "^5.90.21",
"axios": "^1.13.5",
"expo": "~54.0.33",
"expo-notifications": "~0.32.16",
"expo-secure-store": "^15.0.8",
"expo-status-bar": "~3.0.9",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-hook-form": "^7.71.2",
"react-native": "0.81.5",
"react-native-safe-area-context": "^5.6.2",
"react-native-screens": "^4.16.0",
"react-native-web": "~0.21.0",
"react-native-safe-area-context": "^5.7.0",
"react-native-screens": "4.16.0",
"zod": "^4.3.6",
"zustand": "^5.0.11"
},

View File

@@ -1,20 +0,0 @@
import { LoginRequest, RegisterRequest, TokenResponse, User } from '../types/auth.types';
import { apiClient } from './client';
export const authApi = {
register: (data: RegisterRequest) =>
apiClient.post<TokenResponse>('/auth/register', data).then((r) => r.data),
login: (data: LoginRequest) =>
apiClient.post<TokenResponse>('/auth/login', data).then((r) => r.data),
refresh: (refreshToken: string) =>
apiClient
.post<TokenResponse>('/auth/refresh', { refresh_token: refreshToken })
.then((r) => r.data),
logout: (refreshToken: string) =>
apiClient.post('/auth/logout', { refresh_token: refreshToken }),
me: () => apiClient.get<User>('/auth/me').then((r) => r.data),
};

33
mobile/src/api/auth.ts Normal file
View File

@@ -0,0 +1,33 @@
import { apiClient } from './client';
import type { TokenPair, User } from '../types';
export const authApi = {
register: (data: {
email: string;
password: string;
full_name: string;
phone?: string;
requested_role: 'member' | 'organizer';
organization_name?: string;
instagram_handle?: string;
}) =>
apiClient
.post<{ user: User; access_token?: string; refresh_token?: string }>('/auth/register', data)
.then((r) => r.data),
login: (data: { email: string; password: string }) =>
apiClient.post<TokenPair>('/auth/login', data).then((r) => r.data),
refresh: (refresh_token: string) =>
apiClient
.post<{ access_token: string; refresh_token: string }>('/auth/refresh', { refresh_token })
.then((r) => r.data),
logout: (refresh_token: string) =>
apiClient.post('/auth/logout', { refresh_token }),
me: () => apiClient.get<User>('/auth/me').then((r) => r.data),
updateMe: (data: { full_name?: string; phone?: string; expo_push_token?: string }) =>
apiClient.patch<User>('/auth/me', data).then((r) => r.data),
};

View File

@@ -1,10 +0,0 @@
import { Championship } from '../types/championship.types';
import { apiClient } from './client';
export const championshipsApi = {
list: (params?: { status?: string; skip?: number; limit?: number }) =>
apiClient.get<Championship[]>('/championships', { params }).then((r) => r.data),
get: (id: string) =>
apiClient.get<Championship>(`/championships/${id}`).then((r) => r.data),
};

View File

@@ -0,0 +1,25 @@
import { apiClient } from './client';
import type { Championship, Registration } from '../types';
export const championshipsApi = {
list: (status?: string) =>
apiClient.get<Championship[]>('/championships', { params: status ? { status } : {} }).then((r) => r.data),
get: (id: string) =>
apiClient.get<Championship>(`/championships/${id}`).then((r) => r.data),
register: (data: { championship_id: string; category?: string; level?: string; notes?: string }) =>
apiClient.post<Registration>('/registrations', data).then((r) => r.data),
myRegistrations: () =>
apiClient.get<Registration[]>('/registrations/my').then((r) => r.data),
getRegistration: (id: string) =>
apiClient.get<Registration>(`/registrations/${id}`).then((r) => r.data),
updateRegistration: (id: string, data: { video_url?: string; notes?: string }) =>
apiClient.patch<Registration>(`/registrations/${id}`, data).then((r) => r.data),
cancelRegistration: (id: string) =>
apiClient.delete(`/registrations/${id}`),
};

View File

@@ -1,84 +1,73 @@
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios';
import axios from 'axios';
import { tokenStorage } from '../utils/tokenStorage';
const API_BASE_URL = process.env.EXPO_PUBLIC_API_URL ?? 'http://localhost:8000/api/v1';
// Replace with your machine's LAN IP when testing on a physical device
export const BASE_URL = 'http://192.168.2.56:8000/api/v1';
export const apiClient = axios.create({
baseURL: API_BASE_URL,
headers: { 'Content-Type': 'application/json' },
baseURL: BASE_URL,
timeout: 10000,
});
// Attach access token to every request
apiClient.interceptors.request.use(async (config) => {
const token = await tokenStorage.getAccessToken();
// Attach access token from in-memory cache (synchronous — no await needed)
apiClient.interceptors.request.use((config) => {
const token = tokenStorage.getAccessTokenSync();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// On 401, try refreshing once then retry
// Refresh token on 401
let isRefreshing = false;
let failedQueue: Array<{
resolve: (value: string) => void;
reject: (reason?: unknown) => void;
}> = [];
let queue: Array<{ resolve: (token: string) => void; reject: (err: unknown) => void }> = [];
function processQueue(error: unknown, token: string | null = null) {
failedQueue.forEach((p) => {
if (error) {
p.reject(error);
} else {
p.resolve(token!);
}
});
failedQueue = [];
queue.forEach((p) => (error ? p.reject(error) : p.resolve(token!)));
queue = [];
}
apiClient.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
const originalRequest = error.config as InternalAxiosRequestConfig & {
_retry?: boolean;
};
if (error.response?.status === 401 && !originalRequest._retry) {
(res) => res,
async (error) => {
const original = error.config;
if (error.response?.status === 401 && !original._retry) {
if (isRefreshing) {
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject });
}).then((token) => {
originalRequest.headers.Authorization = `Bearer ${token}`;
return apiClient(originalRequest);
queue.push({
resolve: (token) => {
original.headers.Authorization = `Bearer ${token}`;
resolve(apiClient(original));
},
reject,
});
});
}
originalRequest._retry = true;
original._retry = true;
isRefreshing = true;
try {
const refreshToken = await tokenStorage.getRefreshToken();
const refreshToken = tokenStorage.getRefreshTokenSync();
if (!refreshToken) throw new Error('No refresh token');
const { data } = await axios.post(`${API_BASE_URL}/auth/refresh`, {
const { data } = await axios.post(`${BASE_URL}/auth/refresh`, {
refresh_token: refreshToken,
});
await tokenStorage.saveTokens(data.access_token, data.refresh_token);
apiClient.defaults.headers.common.Authorization = `Bearer ${data.access_token}`;
processQueue(null, data.access_token);
originalRequest.headers.Authorization = `Bearer ${data.access_token}`;
return apiClient(originalRequest);
} catch (refreshError) {
processQueue(refreshError, null);
original.headers.Authorization = `Bearer ${data.access_token}`;
return apiClient(original);
} catch (err) {
processQueue(err, null);
await tokenStorage.clearTokens();
return Promise.reject(refreshError);
return Promise.reject(err);
} finally {
isRefreshing = false;
}
}
return Promise.reject(error);
},
}
);

View File

@@ -1,13 +0,0 @@
import { Registration, RegistrationCreate } from '../types/registration.types';
import { apiClient } from './client';
export const registrationsApi = {
submit: (data: RegistrationCreate) =>
apiClient.post<Registration>('/registrations', data).then((r) => r.data),
myRegistrations: () =>
apiClient.get<Registration[]>('/registrations/my').then((r) => r.data),
withdrawRegistration: (id: string) =>
apiClient.delete(`/registrations/${id}`),
};

View File

@@ -1,6 +0,0 @@
import { apiClient } from './client';
export const usersApi = {
updatePushToken: (userId: string, expoPushToken: string) =>
apiClient.patch(`/users/${userId}/push-token`, { expo_push_token: expoPushToken }),
};

12
mobile/src/api/users.ts Normal file
View File

@@ -0,0 +1,12 @@
import { apiClient } from './client';
import type { User } from '../types';
export const usersApi = {
list: () => apiClient.get<User[]>('/users').then((r) => r.data),
approve: (id: string) =>
apiClient.patch<User>(`/users/${id}/approve`).then((r) => r.data),
reject: (id: string) =>
apiClient.patch<User>(`/users/${id}/reject`).then((r) => r.data),
};

View File

@@ -1,31 +0,0 @@
import React from 'react';
import { StyleSheet, Text, View } from 'react-native';
const COLORS: Record<string, { bg: string; text: string }> = {
draft: { bg: '#F3F4F6', text: '#6B7280' },
open: { bg: '#D1FAE5', text: '#065F46' },
closed: { bg: '#FEE2E2', text: '#991B1B' },
completed: { bg: '#EDE9FE', text: '#5B21B6' },
submitted: { bg: '#FEF3C7', text: '#92400E' },
accepted: { bg: '#D1FAE5', text: '#065F46' },
rejected: { bg: '#FEE2E2', text: '#991B1B' },
waitlisted: { bg: '#DBEAFE', text: '#1E40AF' },
};
interface Props {
status: string;
}
export function StatusBadge({ status }: Props) {
const colors = COLORS[status] ?? { bg: '#F3F4F6', text: '#374151' };
return (
<View style={[styles.badge, { backgroundColor: colors.bg }]}>
<Text style={[styles.text, { color: colors.text }]}>{status.toUpperCase()}</Text>
</View>
);
}
const styles = StyleSheet.create({
badge: { borderRadius: 12, paddingHorizontal: 10, paddingVertical: 4, alignSelf: 'flex-start' },
text: { fontSize: 11, fontWeight: '700', letterSpacing: 0.5 },
});

View File

@@ -1,55 +0,0 @@
import { authApi } from '../api/auth.api';
import { LoginRequest, RegisterRequest } from '../types/auth.types';
import { tokenStorage } from '../utils/tokenStorage';
import { useAuthStore } from '../store/auth.store';
export function useAuth() {
const { user, isLoading, setUser, setLoading } = useAuthStore();
const initialize = async () => {
setLoading(true);
try {
const token = await tokenStorage.getAccessToken();
if (token) {
const me = await authApi.me();
setUser(me);
}
} catch {
await tokenStorage.clearTokens();
setUser(null);
} finally {
setLoading(false);
}
};
const login = async (data: LoginRequest) => {
const tokens = await authApi.login(data);
await tokenStorage.saveTokens(tokens.access_token, tokens.refresh_token);
const me = await authApi.me();
setUser(me);
return me;
};
const register = async (data: RegisterRequest) => {
const tokens = await authApi.register(data);
await tokenStorage.saveTokens(tokens.access_token, tokens.refresh_token);
const me = await authApi.me();
setUser(me);
return me;
};
const logout = async () => {
const refreshToken = await tokenStorage.getRefreshToken();
if (refreshToken) {
try {
await authApi.logout(refreshToken);
} catch {
// Ignore errors on logout
}
}
await tokenStorage.clearTokens();
setUser(null);
};
return { user, isLoading, initialize, login, register, logout };
}

View File

@@ -1,42 +0,0 @@
import * as Notifications from 'expo-notifications';
import { Platform } from 'react-native';
import { usersApi } from '../api/users.api';
import { useAuthStore } from '../store/auth.store';
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: false,
}),
});
export function usePushNotifications() {
const user = useAuthStore((s) => s.user);
const registerToken = async () => {
if (!user) return;
const { status: existingStatus } = await Notifications.getPermissionsAsync();
let finalStatus = existingStatus;
if (existingStatus !== 'granted') {
const { status } = await Notifications.requestPermissionsAsync();
finalStatus = status;
}
if (finalStatus !== 'granted') return;
if (Platform.OS === 'android') {
await Notifications.setNotificationChannelAsync('default', {
name: 'default',
importance: Notifications.AndroidImportance.MAX,
});
}
const tokenData = await Notifications.getExpoPushTokenAsync();
await usersApi.updatePushToken(user.id, tokenData.data);
};
return { registerToken };
}

View File

@@ -1,59 +0,0 @@
import React, { useEffect } from 'react';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { ChampionshipListScreen } from '../screens/championships/ChampionshipListScreen';
import { ChampionshipDetailScreen } from '../screens/championships/ChampionshipDetailScreen';
import { RegistrationFormScreen } from '../screens/registration/RegistrationFormScreen';
import { ProfileScreen } from '../screens/profile/ProfileScreen';
import { usePushNotifications } from '../hooks/usePushNotifications';
export type AppStackParamList = {
Championships: undefined;
ChampionshipDetail: { id: string };
RegistrationForm: { championshipId: string; championshipTitle: string };
Profile: undefined;
};
const Tab = createBottomTabNavigator();
const Stack = createNativeStackNavigator<AppStackParamList>();
function ChampionshipStack() {
return (
<Stack.Navigator>
<Stack.Screen
name="Championships"
component={ChampionshipListScreen}
options={{ title: 'Championships' }}
/>
<Stack.Screen
name="ChampionshipDetail"
component={ChampionshipDetailScreen}
options={{ title: 'Details' }}
/>
<Stack.Screen
name="RegistrationForm"
component={RegistrationFormScreen}
options={{ title: 'Register' }}
/>
</Stack.Navigator>
);
}
export function AppStack() {
const { registerToken } = usePushNotifications();
useEffect(() => {
registerToken();
}, []);
return (
<Tab.Navigator>
<Tab.Screen
name="ChampionshipTab"
component={ChampionshipStack}
options={{ headerShown: false, title: 'Schedule' }}
/>
<Tab.Screen name="ProfileTab" component={ProfileScreen} options={{ title: 'Profile' }} />
</Tab.Navigator>
);
}

View File

@@ -1,20 +0,0 @@
import React from 'react';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { LoginScreen } from '../screens/auth/LoginScreen';
import { RegisterScreen } from '../screens/auth/RegisterScreen';
export type AuthStackParamList = {
Login: undefined;
Register: undefined;
};
const Stack = createNativeStackNavigator<AuthStackParamList>();
export function AuthStack() {
return (
<Stack.Navigator screenOptions={{ headerShown: false }}>
<Stack.Screen name="Login" component={LoginScreen} />
<Stack.Screen name="Register" component={RegisterScreen} />
</Stack.Navigator>
);
}

View File

@@ -1,28 +0,0 @@
import React, { useEffect } from 'react';
import { ActivityIndicator, View } from 'react-native';
import { useAuth } from '../hooks/useAuth';
import { AuthStack } from './AuthStack';
import { AppStack } from './AppStack';
import { PendingApprovalScreen } from '../screens/auth/PendingApprovalScreen';
export function RootNavigator() {
const { user, isLoading, initialize } = useAuth();
useEffect(() => {
initialize();
}, []);
if (isLoading) {
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<ActivityIndicator size="large" />
</View>
);
}
if (!user) return <AuthStack />;
if (user.status === 'pending') return <PendingApprovalScreen />;
if (user.status === 'rejected') return <AuthStack />;
return <AppStack />;
}

View File

@@ -0,0 +1,114 @@
import { NavigationContainer } from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { ActivityIndicator, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useAuthStore } from '../store/auth.store';
// Screens
import LoginScreen from '../screens/auth/LoginScreen';
import RegisterScreen from '../screens/auth/RegisterScreen';
import PendingApprovalScreen from '../screens/auth/PendingApprovalScreen';
import ChampionshipsScreen from '../screens/championships/ChampionshipsScreen';
import ChampionshipDetailScreen from '../screens/championships/ChampionshipDetailScreen';
import MyRegistrationsScreen from '../screens/championships/MyRegistrationsScreen';
import ProfileScreen from '../screens/profile/ProfileScreen';
import AdminScreen from '../screens/admin/AdminScreen';
export type AuthStackParams = {
Login: undefined;
Register: undefined;
PendingApproval: undefined;
};
export type AppStackParams = {
Tabs: undefined;
ChampionshipDetail: { id: string };
};
export type TabParams = {
Championships: undefined;
MyRegistrations: undefined;
Admin: undefined;
Profile: undefined;
};
const AuthStack = createNativeStackNavigator<AuthStackParams>();
const AppStack = createNativeStackNavigator<AppStackParams>();
const Tab = createBottomTabNavigator<TabParams>();
function AppTabs({ isAdmin }: { isAdmin: boolean }) {
return (
<Tab.Navigator
screenOptions={({ route }) => ({
headerShown: true,
headerTitleStyle: { fontWeight: '700', fontSize: 18, color: '#1a1a2e' },
headerShadowVisible: false,
headerStyle: { backgroundColor: '#fff' },
tabBarActiveTintColor: '#7c3aed',
tabBarInactiveTintColor: '#9ca3af',
tabBarIcon: ({ focused, color, size }) => {
if (route.name === 'Championships') {
return <Ionicons name={focused ? 'trophy' : 'trophy-outline'} size={size} color={color} />;
}
if (route.name === 'MyRegistrations') {
return <Ionicons name={focused ? 'list' : 'list-outline'} size={size} color={color} />;
}
if (route.name === 'Admin') {
return <Ionicons name={focused ? 'shield' : 'shield-outline'} size={size} color={color} />;
}
if (route.name === 'Profile') {
return <Ionicons name={focused ? 'person' : 'person-outline'} size={size} color={color} />;
}
},
})}
>
<Tab.Screen name="Championships" component={ChampionshipsScreen} options={{ title: 'Championships' }} />
<Tab.Screen name="MyRegistrations" component={MyRegistrationsScreen} options={{ title: 'My Registrations' }} />
{isAdmin && <Tab.Screen name="Admin" component={AdminScreen} options={{ title: 'Admin' }} />}
<Tab.Screen name="Profile" component={ProfileScreen} />
</Tab.Navigator>
);
}
function AppNavigator({ isAdmin }: { isAdmin: boolean }) {
return (
<AppStack.Navigator>
<AppStack.Screen name="Tabs" options={{ headerShown: false }}>
{() => <AppTabs isAdmin={isAdmin} />}
</AppStack.Screen>
<AppStack.Screen name="ChampionshipDetail" component={ChampionshipDetailScreen} options={{ title: 'Details' }} />
</AppStack.Navigator>
);
}
function AuthNavigator() {
return (
<AuthStack.Navigator screenOptions={{ headerShown: false }}>
<AuthStack.Screen name="Login" component={LoginScreen} />
<AuthStack.Screen name="Register" component={RegisterScreen} />
<AuthStack.Screen name="PendingApproval" component={PendingApprovalScreen} />
</AuthStack.Navigator>
);
}
export default function RootNavigator() {
const { user, isInitialized } = useAuthStore();
if (!isInitialized) {
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<ActivityIndicator size="large" />
</View>
);
}
return (
<NavigationContainer>
{user?.status === 'approved'
? <AppNavigator isAdmin={user.role === 'admin'} />
: <AuthNavigator />}
</NavigationContainer>
);
}

View File

@@ -1,17 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { championshipsApi } from '../api/championships.api';
export function useChampionships(status?: string) {
return useQuery({
queryKey: ['championships', status],
queryFn: () => championshipsApi.list({ status }),
});
}
export function useChampionshipDetail(id: string) {
return useQuery({
queryKey: ['championship', id],
queryFn: () => championshipsApi.get(id),
enabled: !!id,
});
}

View File

@@ -1,30 +0,0 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { registrationsApi } from '../api/registrations.api';
import { RegistrationCreate } from '../types/registration.types';
export function useMyRegistrations() {
return useQuery({
queryKey: ['registrations', 'my'],
queryFn: registrationsApi.myRegistrations,
});
}
export function useSubmitRegistration() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: RegistrationCreate) => registrationsApi.submit(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['registrations', 'my'] });
},
});
}
export function useWithdrawRegistration() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => registrationsApi.withdrawRegistration(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['registrations', 'my'] });
},
});
}

View File

@@ -0,0 +1,290 @@
import { useEffect, useState, useCallback } from 'react';
import {
View,
Text,
FlatList,
TouchableOpacity,
StyleSheet,
Alert,
ActivityIndicator,
RefreshControl,
} from 'react-native';
import { usersApi } from '../../api/users';
import type { User } from '../../types';
const STATUS_COLOR: Record<string, string> = {
pending: '#f59e0b',
approved: '#16a34a',
rejected: '#dc2626',
};
const ROLE_LABEL: Record<string, string> = {
member: 'Member',
organizer: 'Organizer',
admin: 'Admin',
};
function UserCard({
user,
onApprove,
onReject,
acting,
}: {
user: User;
onApprove: () => void;
onReject: () => void;
acting: boolean;
}) {
return (
<View style={styles.card}>
<View style={styles.cardHeader}>
<View style={styles.avatar}>
<Text style={styles.avatarText}>{user.full_name.charAt(0).toUpperCase()}</Text>
</View>
<View style={styles.cardInfo}>
<Text style={styles.cardName}>{user.full_name}</Text>
<Text style={styles.cardEmail}>{user.email}</Text>
{user.organization_name && (
<Text style={styles.cardOrg}>{user.organization_name}</Text>
)}
{user.phone && <Text style={styles.cardDetail}>{user.phone}</Text>}
{user.instagram_handle && (
<Text style={styles.cardDetail}>{user.instagram_handle}</Text>
)}
</View>
<View style={[styles.statusDot, { backgroundColor: STATUS_COLOR[user.status] ?? '#9ca3af' }]} />
</View>
<View style={styles.meta}>
<Text style={styles.metaText}>Role: {ROLE_LABEL[user.role] ?? user.role}</Text>
<Text style={styles.metaText}>
Registered: {new Date(user.created_at).toLocaleDateString()}
</Text>
</View>
{user.status === 'pending' && (
<View style={styles.actions}>
<TouchableOpacity
style={[styles.btn, styles.approveBtn, acting && styles.btnDisabled]}
onPress={onApprove}
disabled={acting}
>
{acting ? (
<ActivityIndicator color="#fff" size="small" />
) : (
<Text style={styles.btnText}>Approve</Text>
)}
</TouchableOpacity>
<TouchableOpacity
style={[styles.btn, styles.rejectBtn, acting && styles.btnDisabled]}
onPress={onReject}
disabled={acting}
>
<Text style={[styles.btnText, styles.rejectText]}>Reject</Text>
</TouchableOpacity>
</View>
)}
{user.status !== 'pending' && (
<View style={styles.resolvedBanner}>
<Text style={[styles.resolvedText, { color: STATUS_COLOR[user.status] }]}>
{user.status === 'approved' ? '✓ Approved' : '✗ Rejected'}
</Text>
</View>
)}
</View>
);
}
export default function AdminScreen() {
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [actingId, setActingId] = useState<string | null>(null);
const [filter, setFilter] = useState<'pending' | 'all'>('pending');
const load = useCallback(async (silent = false) => {
if (!silent) setLoading(true);
try {
const data = await usersApi.list();
setUsers(data);
} catch {
Alert.alert('Error', 'Failed to load users');
} finally {
setLoading(false);
setRefreshing(false);
}
}, []);
useEffect(() => { load(); }, [load]);
const handleApprove = (user: User) => {
Alert.alert('Approve', `Approve "${user.full_name}" (${user.organization_name ?? user.email})?`, [
{ text: 'Cancel', style: 'cancel' },
{
text: 'Approve',
onPress: async () => {
setActingId(user.id);
try {
const updated = await usersApi.approve(user.id);
setUsers((prev) => prev.map((u) => (u.id === updated.id ? updated : u)));
} catch {
Alert.alert('Error', 'Failed to approve user');
} finally {
setActingId(null);
}
},
},
]);
};
const handleReject = (user: User) => {
Alert.alert('Reject', `Reject "${user.full_name}"? They will not be able to sign in.`, [
{ text: 'Cancel', style: 'cancel' },
{
text: 'Reject',
style: 'destructive',
onPress: async () => {
setActingId(user.id);
try {
const updated = await usersApi.reject(user.id);
setUsers((prev) => prev.map((u) => (u.id === updated.id ? updated : u)));
} catch {
Alert.alert('Error', 'Failed to reject user');
} finally {
setActingId(null);
}
},
},
]);
};
const displayed = filter === 'pending'
? users.filter((u) => u.status === 'pending')
: users;
const pendingCount = users.filter((u) => u.status === 'pending').length;
if (loading) {
return (
<View style={styles.center}>
<ActivityIndicator size="large" color="#7c3aed" />
</View>
);
}
return (
<FlatList
data={displayed}
keyExtractor={(item) => item.id}
contentContainerStyle={styles.list}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={() => { setRefreshing(true); load(true); }} />
}
ListHeaderComponent={
<View>
<View style={styles.filterRow}>
<TouchableOpacity
style={[styles.filterBtn, filter === 'pending' && styles.filterBtnActive]}
onPress={() => setFilter('pending')}
>
<Text style={[styles.filterText, filter === 'pending' && styles.filterTextActive]}>
Pending {pendingCount > 0 ? `(${pendingCount})` : ''}
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.filterBtn, filter === 'all' && styles.filterBtnActive]}
onPress={() => setFilter('all')}
>
<Text style={[styles.filterText, filter === 'all' && styles.filterTextActive]}>
All Users
</Text>
</TouchableOpacity>
</View>
</View>
}
ListEmptyComponent={
<View style={styles.center}>
<Text style={styles.empty}>
{filter === 'pending' ? 'No pending approvals' : 'No users found'}
</Text>
</View>
}
renderItem={({ item }) => (
<UserCard
user={item}
onApprove={() => handleApprove(item)}
onReject={() => handleReject(item)}
acting={actingId === item.id}
/>
)}
/>
);
}
const styles = StyleSheet.create({
list: { padding: 16, flexGrow: 1 },
heading: { fontSize: 24, fontWeight: '700', color: '#1a1a2e', marginBottom: 16 },
center: { flex: 1, justifyContent: 'center', alignItems: 'center', paddingTop: 60 },
empty: { color: '#9ca3af', fontSize: 15 },
filterRow: { flexDirection: 'row', gap: 8, marginBottom: 16 },
filterBtn: {
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 20,
borderWidth: 1.5,
borderColor: '#e5e7eb',
},
filterBtnActive: { borderColor: '#7c3aed', backgroundColor: '#f3f0ff' },
filterText: { fontSize: 13, fontWeight: '600', color: '#9ca3af' },
filterTextActive: { color: '#7c3aed' },
card: {
backgroundColor: '#fff',
borderRadius: 14,
marginBottom: 12,
padding: 16,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.07,
shadowRadius: 6,
elevation: 3,
},
cardHeader: { flexDirection: 'row', alignItems: 'flex-start' },
avatar: {
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: '#7c3aed',
justifyContent: 'center',
alignItems: 'center',
marginRight: 12,
},
avatarText: { color: '#fff', fontSize: 18, fontWeight: '700' },
cardInfo: { flex: 1 },
cardName: { fontSize: 15, fontWeight: '700', color: '#1a1a2e' },
cardEmail: { fontSize: 13, color: '#6b7280', marginTop: 1 },
cardOrg: { fontSize: 13, color: '#7c3aed', fontWeight: '600', marginTop: 3 },
cardDetail: { fontSize: 12, color: '#9ca3af', marginTop: 1 },
statusDot: { width: 10, height: 10, borderRadius: 5, marginTop: 4 },
meta: { flexDirection: 'row', justifyContent: 'space-between', marginTop: 12, marginBottom: 12 },
metaText: { fontSize: 12, color: '#9ca3af' },
actions: { flexDirection: 'row', gap: 8 },
btn: {
flex: 1,
paddingVertical: 10,
borderRadius: 8,
alignItems: 'center',
},
btnDisabled: { opacity: 0.5 },
approveBtn: { backgroundColor: '#16a34a' },
rejectBtn: { backgroundColor: '#fff', borderWidth: 1.5, borderColor: '#ef4444' },
btnText: { fontSize: 14, fontWeight: '600', color: '#fff' },
rejectText: { color: '#ef4444' },
resolvedBanner: { alignItems: 'center', paddingTop: 4 },
resolvedText: { fontSize: 13, fontWeight: '600' },
});

View File

@@ -1,104 +1,128 @@
import React, { useState } from 'react';
import { useRef, useState } from 'react';
import {
Alert,
KeyboardAvoidingView,
Platform,
StyleSheet,
View,
Text,
TextInput,
TouchableOpacity,
StyleSheet,
KeyboardAvoidingView,
Platform,
Alert,
ActivityIndicator,
} from 'react-native';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import { useAuth } from '../../hooks/useAuth';
import { AuthStackParamList } from '../../navigation/AuthStack';
import { Ionicons } from '@expo/vector-icons';
import { useAuthStore } from '../../store/auth.store';
import type { AuthStackParams } from '../../navigation';
type Props = NativeStackScreenProps<AuthStackParamList, 'Login'>;
type Props = NativeStackScreenProps<AuthStackParams, 'Login'>;
export function LoginScreen({ navigation }: Props) {
export default function LoginScreen({ navigation }: Props) {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const { login } = useAuth();
const [showPassword, setShowPassword] = useState(false);
const passwordRef = useRef<TextInput>(null);
const { login, isLoading } = useAuthStore();
const handleLogin = async () => {
if (!email || !password) {
Alert.alert('Error', 'Please enter your email and password');
if (!email.trim() || !password.trim()) {
Alert.alert('Error', 'Please enter email and password');
return;
}
setLoading(true);
try {
await login({ email, password });
await login(email.trim().toLowerCase(), password);
} catch (err: any) {
const msg = err?.code === 'ECONNABORTED'
? `Cannot reach server at ${process.env.EXPO_PUBLIC_API_URL}.\nMake sure your phone is on the same Wi-Fi and Windows Firewall allows port 8000.`
: err?.response?.status === 401
? 'Invalid email or password'
: `Error: ${err?.message ?? 'Unknown error'}`;
const msg = err?.response?.data?.detail ?? 'Login failed. Check your credentials.';
Alert.alert('Login failed', msg);
} finally {
setLoading(false);
}
};
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<Text style={styles.title}>Pole Dance Championships</Text>
<Text style={styles.subtitle}>Member Portal</Text>
<KeyboardAvoidingView style={styles.container} behavior={Platform.OS === 'ios' ? 'padding' : 'height'}>
<View style={styles.inner}>
<Text style={styles.title}>Pole Championships</Text>
<Text style={styles.subtitle}>Sign in to your account</Text>
<TextInput
style={styles.input}
placeholder="Email"
autoCapitalize="none"
keyboardType="email-address"
autoCapitalize="none"
autoComplete="email"
returnKeyType="next"
onSubmitEditing={() => passwordRef.current?.focus()}
value={email}
onChangeText={setEmail}
/>
<View style={styles.passwordRow}>
<TextInput
style={styles.input}
ref={passwordRef}
style={styles.passwordInput}
placeholder="Password"
secureTextEntry
secureTextEntry={!showPassword}
autoComplete="password"
returnKeyType="done"
onSubmitEditing={handleLogin}
value={password}
onChangeText={setPassword}
/>
<TouchableOpacity style={styles.eyeBtn} onPress={() => setShowPassword((v) => !v)}>
<Ionicons name={showPassword ? 'eye-off-outline' : 'eye-outline'} size={20} color="#6b7280" />
</TouchableOpacity>
</View>
<TouchableOpacity
style={[styles.button, loading && styles.buttonDisabled]}
onPress={handleLogin}
disabled={loading}
>
<Text style={styles.buttonText}>{loading ? 'Logging in...' : 'Log In'}</Text>
<TouchableOpacity style={styles.btn} onPress={handleLogin} disabled={isLoading}>
{isLoading ? <ActivityIndicator color="#fff" /> : <Text style={styles.btnText}>Sign In</Text>}
</TouchableOpacity>
<TouchableOpacity onPress={() => navigation.navigate('Register')}>
<Text style={styles.link}>Don&apos;t have an account? Register</Text>
<Text style={styles.link}>Don't have an account? Register</Text>
</TouchableOpacity>
</View>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
container: { flex: 1, justifyContent: 'center', padding: 24, backgroundColor: '#fff' },
title: { fontSize: 26, fontWeight: '700', textAlign: 'center', marginBottom: 4 },
subtitle: { fontSize: 14, color: '#666', textAlign: 'center', marginBottom: 40 },
container: { flex: 1, backgroundColor: '#fff' },
inner: { flex: 1, justifyContent: 'center', padding: 24 },
title: { fontSize: 28, fontWeight: '700', textAlign: 'center', marginBottom: 8, color: '#1a1a2e' },
subtitle: { fontSize: 15, textAlign: 'center', color: '#666', marginBottom: 32 },
input: {
borderWidth: 1,
borderColor: '#ddd',
borderRadius: 8,
borderRadius: 10,
padding: 14,
marginBottom: 14,
fontSize: 16,
backgroundColor: '#fafafa',
},
button: {
backgroundColor: '#6C3FC5',
borderRadius: 8,
padding: 16,
passwordRow: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 16,
borderWidth: 1,
borderColor: '#ddd',
borderRadius: 10,
backgroundColor: '#fafafa',
marginBottom: 14,
},
buttonDisabled: { opacity: 0.6 },
buttonText: { color: '#fff', fontWeight: '600', fontSize: 16 },
link: { textAlign: 'center', color: '#6C3FC5', fontSize: 14 },
passwordInput: {
flex: 1,
padding: 14,
fontSize: 16,
},
eyeBtn: {
paddingHorizontal: 14,
paddingVertical: 14,
},
btn: {
backgroundColor: '#7c3aed',
padding: 16,
borderRadius: 10,
alignItems: 'center',
marginTop: 8,
marginBottom: 20,
},
btnText: { color: '#fff', fontSize: 16, fontWeight: '600' },
link: { textAlign: 'center', color: '#7c3aed', fontSize: 14 },
});

View File

@@ -1,42 +1,36 @@
import React from 'react';
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { useAuth } from '../../hooks/useAuth';
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import type { AuthStackParams } from '../../navigation';
export function PendingApprovalScreen() {
const { logout } = useAuth();
type Props = NativeStackScreenProps<AuthStackParams, 'PendingApproval'>;
export default function PendingApprovalScreen({ navigation }: Props) {
return (
<View style={styles.container}>
<Text style={styles.emoji}></Text>
<Text style={styles.title}>Account Pending Approval</Text>
<Text style={styles.icon}></Text>
<Text style={styles.title}>Application Submitted</Text>
<Text style={styles.body}>
Your registration has been received. An organizer will review and approve your account.
You will receive a notification once approved.
Your registration has been received. An administrator will review and approve your account shortly.
{'\n\n'}
Once approved, you can sign in with your email and password.
</Text>
<TouchableOpacity style={styles.button} onPress={logout}>
<Text style={styles.buttonText}>Log Out</Text>
<TouchableOpacity style={styles.btn} onPress={() => navigation.navigate('Login')}>
<Text style={styles.btnText}>Go to Sign In</Text>
</TouchableOpacity>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 32,
backgroundColor: '#fff',
container: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 32, backgroundColor: '#fff' },
icon: { fontSize: 64, marginBottom: 20 },
title: { fontSize: 24, fontWeight: '700', color: '#1a1a2e', marginBottom: 16, textAlign: 'center' },
body: { fontSize: 15, color: '#555', lineHeight: 24, textAlign: 'center', marginBottom: 36 },
btn: {
backgroundColor: '#7c3aed',
paddingVertical: 14,
paddingHorizontal: 40,
borderRadius: 10,
},
emoji: { fontSize: 60, marginBottom: 24 },
title: { fontSize: 22, fontWeight: '700', textAlign: 'center', marginBottom: 16 },
body: { fontSize: 15, color: '#555', textAlign: 'center', lineHeight: 22, marginBottom: 40 },
button: {
borderWidth: 1,
borderColor: '#6C3FC5',
borderRadius: 8,
paddingVertical: 12,
paddingHorizontal: 32,
},
buttonText: { color: '#6C3FC5', fontWeight: '600' },
btnText: { color: '#fff', fontSize: 16, fontWeight: '600' },
});

View File

@@ -1,91 +1,198 @@
import React, { useState } from 'react';
import { useState } from 'react';
import {
Alert,
KeyboardAvoidingView,
Platform,
ScrollView,
StyleSheet,
View,
Text,
TextInput,
TouchableOpacity,
StyleSheet,
KeyboardAvoidingView,
Platform,
Alert,
ActivityIndicator,
ScrollView,
} from 'react-native';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import { useAuth } from '../../hooks/useAuth';
import { AuthStackParamList } from '../../navigation/AuthStack';
import { Ionicons } from '@expo/vector-icons';
import { useAuthStore } from '../../store/auth.store';
import type { AuthStackParams } from '../../navigation';
type Props = NativeStackScreenProps<AuthStackParamList, 'Register'>;
type Props = NativeStackScreenProps<AuthStackParams, 'Register'>;
type Role = 'member' | 'organizer';
export function RegisterScreen({ navigation }: Props) {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
export default function RegisterScreen({ navigation }: Props) {
const [role, setRole] = useState<Role>('member');
const [fullName, setFullName] = useState('');
const [email, setEmail] = useState('');
const [phone, setPhone] = useState('');
const [loading, setLoading] = useState(false);
const { register } = useAuth();
const [password, setPassword] = useState('');
const [orgName, setOrgName] = useState('');
const [instagram, setInstagram] = useState('');
const [showPassword, setShowPassword] = useState(false);
const { register, isLoading } = useAuthStore();
const handleRegister = async () => {
if (!email || !password || !fullName) {
Alert.alert('Error', 'Email, password and full name are required');
if (!fullName.trim() || !email.trim() || !password.trim()) {
Alert.alert('Error', 'Please fill in all required fields');
return;
}
if (role === 'organizer' && !orgName.trim()) {
Alert.alert('Error', 'Organization name is required for organizers');
return;
}
setLoading(true);
try {
await register({ email, password, full_name: fullName, phone: phone || undefined });
const autoLoggedIn = await register({
email: email.trim().toLowerCase(),
password,
full_name: fullName.trim(),
phone: phone.trim() || undefined,
requested_role: role,
organization_name: role === 'organizer' ? orgName.trim() : undefined,
instagram_handle: role === 'organizer' && instagram.trim() ? instagram.trim() : undefined,
});
if (!autoLoggedIn) {
// Organizer — navigate to pending screen
navigation.navigate('PendingApproval');
}
// Member — autoLoggedIn=true means the store already has user set,
// RootNavigator will switch to AppStack automatically
} catch (err: any) {
const msg = err?.response?.data?.detail ?? 'Registration failed';
Alert.alert('Error', msg);
} finally {
setLoading(false);
const detail = err?.response?.data?.detail;
const msg = Array.isArray(detail)
? detail.map((d: any) => d.msg).join('\n')
: detail ?? 'Registration failed';
Alert.alert('Registration failed', msg);
}
};
return (
<KeyboardAvoidingView
style={{ flex: 1 }}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<ScrollView contentContainerStyle={styles.container}>
<KeyboardAvoidingView style={styles.container} behavior={Platform.OS === 'ios' ? 'padding' : 'height'}>
<ScrollView contentContainerStyle={styles.inner} keyboardShouldPersistTaps="handled">
<Text style={styles.title}>Create Account</Text>
<Text style={styles.subtitle}>Who are you registering as?</Text>
{/* Role selector — large cards */}
<View style={styles.roleRow}>
<TouchableOpacity
style={[styles.roleCard, role === 'member' && styles.roleCardActive]}
onPress={() => setRole('member')}
activeOpacity={0.8}
>
<Text style={styles.roleEmoji}>🏅</Text>
<Text style={[styles.roleTitle, role === 'member' && styles.roleTitleActive]}>Member</Text>
<Text style={[styles.roleDesc, role === 'member' && styles.roleDescActive]}>
Compete in championships
</Text>
{role === 'member' && <View style={styles.roleCheck}><Text style={styles.roleCheckText}></Text></View>}
</TouchableOpacity>
<TouchableOpacity
style={[styles.roleCard, role === 'organizer' && styles.roleCardActive]}
onPress={() => setRole('organizer')}
activeOpacity={0.8}
>
<Text style={styles.roleEmoji}>🏆</Text>
<Text style={[styles.roleTitle, role === 'organizer' && styles.roleTitleActive]}>Organizer</Text>
<Text style={[styles.roleDesc, role === 'organizer' && styles.roleDescActive]}>
Create & manage events
</Text>
{role === 'organizer' && <View style={styles.roleCheck}><Text style={styles.roleCheckText}></Text></View>}
</TouchableOpacity>
</View>
{/* Info banner — organizer only */}
{role === 'organizer' && (
<View style={[styles.infoBanner, styles.infoBannerAmber]}>
<Text style={[styles.infoText, styles.infoTextAmber]}>
Organizer accounts require admin approval before you can sign in.
</Text>
</View>
)}
{/* Common fields */}
<Text style={styles.label}>{role === 'organizer' ? 'Contact Person *' : 'Full Name *'}</Text>
<TextInput
style={styles.input}
placeholder="Full Name"
placeholder={role === 'organizer' ? 'Your name (account manager)' : 'Anna Petrova'}
returnKeyType="next"
value={fullName}
onChangeText={setFullName}
/>
<Text style={styles.label}>Email *</Text>
<TextInput
style={styles.input}
placeholder="Email"
autoCapitalize="none"
placeholder="you@example.com"
keyboardType="email-address"
autoCapitalize="none"
autoComplete="email"
returnKeyType="next"
value={email}
onChangeText={setEmail}
/>
<Text style={styles.label}>{role === 'organizer' ? 'Contact Phone' : 'Phone'}</Text>
<TextInput
style={styles.input}
placeholder="Password"
secureTextEntry
value={password}
onChangeText={setPassword}
/>
<TextInput
style={styles.input}
placeholder="Phone (optional)"
placeholder="+375 29 000 0000 (optional)"
keyboardType="phone-pad"
returnKeyType="next"
value={phone}
onChangeText={setPhone}
/>
<TouchableOpacity
style={[styles.button, loading && styles.buttonDisabled]}
onPress={handleRegister}
disabled={loading}
>
<Text style={styles.buttonText}>{loading ? 'Registering...' : 'Register'}</Text>
<Text style={styles.label}>Password *</Text>
<View style={styles.passwordRow}>
<TextInput
style={styles.passwordInput}
placeholder="Min 6 characters"
secureTextEntry={!showPassword}
returnKeyType={role === 'member' ? 'done' : 'next'}
value={password}
onChangeText={setPassword}
/>
<TouchableOpacity style={styles.eyeBtn} onPress={() => setShowPassword((v) => !v)}>
<Ionicons name={showPassword ? 'eye-off-outline' : 'eye-outline'} size={20} color="#6b7280" />
</TouchableOpacity>
</View>
{/* Organizer-only fields */}
{role === 'organizer' && (
<>
<View style={styles.divider}>
<Text style={styles.dividerLabel}>Organization Details</Text>
</View>
<Text style={styles.label}>Organization Name *</Text>
<TextInput
style={styles.input}
placeholder="Pole Sport Federation"
value={orgName}
onChangeText={setOrgName}
/>
<Text style={styles.label}>Instagram Handle</Text>
<TextInput
style={styles.input}
placeholder="@your_org (optional)"
autoCapitalize="none"
value={instagram}
onChangeText={(v) => setInstagram(v.startsWith('@') ? v : v ? `@${v}` : '')}
/>
</>
)}
<TouchableOpacity style={styles.btn} onPress={handleRegister} disabled={isLoading}>
{isLoading ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={styles.btnText}>
{role === 'member' ? 'Register & Sign In' : 'Submit Application'}
</Text>
)}
</TouchableOpacity>
<TouchableOpacity onPress={() => navigation.goBack()}>
<Text style={styles.link}>Already have an account? Log In</Text>
<Text style={styles.link}>Already have an account? Sign In</Text>
</TouchableOpacity>
</ScrollView>
</KeyboardAvoidingView>
@@ -93,24 +200,98 @@ export function RegisterScreen({ navigation }: Props) {
}
const styles = StyleSheet.create({
container: { flexGrow: 1, justifyContent: 'center', padding: 24, backgroundColor: '#fff' },
title: { fontSize: 26, fontWeight: '700', textAlign: 'center', marginBottom: 32 },
container: { flex: 1, backgroundColor: '#fff' },
inner: { flexGrow: 1, padding: 24, paddingTop: 48 },
title: { fontSize: 26, fontWeight: '700', color: '#1a1a2e', marginBottom: 4, textAlign: 'center' },
subtitle: { fontSize: 14, color: '#6b7280', textAlign: 'center', marginBottom: 20 },
// Role cards
roleRow: { flexDirection: 'row', gap: 12, marginBottom: 16 },
roleCard: {
flex: 1,
padding: 16,
borderRadius: 14,
borderWidth: 2,
borderColor: '#e5e7eb',
alignItems: 'center',
backgroundColor: '#f9fafb',
position: 'relative',
},
roleCardActive: { borderColor: '#7c3aed', backgroundColor: '#f3f0ff' },
roleEmoji: { fontSize: 28, marginBottom: 8 },
roleTitle: { fontSize: 16, fontWeight: '700', color: '#9ca3af', marginBottom: 4 },
roleTitleActive: { color: '#7c3aed' },
roleDesc: { fontSize: 12, color: '#d1d5db', textAlign: 'center', lineHeight: 16 },
roleDescActive: { color: '#a78bfa' },
roleCheck: {
position: 'absolute',
top: 8,
right: 8,
width: 20,
height: 20,
borderRadius: 10,
backgroundColor: '#7c3aed',
justifyContent: 'center',
alignItems: 'center',
},
roleCheckText: { color: '#fff', fontSize: 11, fontWeight: '700' },
// Info banner
infoBanner: { borderRadius: 10, padding: 12, marginBottom: 20 },
infoBannerAmber: { backgroundColor: '#fef3c7' },
infoText: { fontSize: 13, lineHeight: 19 },
infoTextAmber: { color: '#92400e' },
// Form
label: { fontSize: 13, fontWeight: '600', color: '#374151', marginBottom: 5 },
input: {
borderWidth: 1,
borderColor: '#ddd',
borderRadius: 8,
padding: 14,
borderColor: '#e5e7eb',
borderRadius: 10,
padding: 13,
marginBottom: 14,
fontSize: 16,
fontSize: 15,
backgroundColor: '#fafafa',
},
button: {
backgroundColor: '#6C3FC5',
borderRadius: 8,
passwordRow: {
flexDirection: 'row',
alignItems: 'center',
borderWidth: 1,
borderColor: '#e5e7eb',
borderRadius: 10,
backgroundColor: '#fafafa',
marginBottom: 14,
},
passwordInput: {
flex: 1,
padding: 13,
fontSize: 15,
},
eyeBtn: {
paddingHorizontal: 13,
paddingVertical: 13,
},
divider: {
flexDirection: 'row',
alignItems: 'center',
marginVertical: 16,
},
dividerLabel: {
fontSize: 13,
fontWeight: '700',
color: '#7c3aed',
backgroundColor: '#fff',
paddingRight: 8,
},
btn: {
backgroundColor: '#7c3aed',
padding: 16,
borderRadius: 12,
alignItems: 'center',
marginBottom: 16,
marginTop: 4,
},
buttonDisabled: { opacity: 0.6 },
buttonText: { color: '#fff', fontWeight: '600', fontSize: 16 },
link: { textAlign: 'center', color: '#6C3FC5', fontSize: 14 },
btnText: { color: '#fff', fontSize: 16, fontWeight: '600' },
link: { textAlign: 'center', color: '#7c3aed', fontSize: 14 },
});

View File

@@ -1,118 +1,246 @@
import React from 'react';
import { useEffect, useState } from 'react';
import {
ActivityIndicator,
Image,
View,
Text,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View,
Alert,
ActivityIndicator,
Image,
Linking,
} from 'react-native';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import { useChampionshipDetail } from '../../queries/useChampionships';
import { StatusBadge } from '../../components/StatusBadge';
import { formatDate } from '../../utils/dateFormatters';
import { isRegistrationOpen } from '../../utils/dateFormatters';
import { AppStackParamList } from '../../navigation/AppStack';
import { championshipsApi } from '../../api/championships';
import type { Championship, Registration } from '../../types';
import type { AppStackParams } from '../../navigation';
type Props = NativeStackScreenProps<AppStackParamList, 'ChampionshipDetail'>;
type Props = NativeStackScreenProps<AppStackParams, 'ChampionshipDetail'>;
export function ChampionshipDetailScreen({ route, navigation }: Props) {
export default function ChampionshipDetailScreen({ route }: Props) {
const { id } = route.params;
const { data: champ, isLoading } = useChampionshipDetail(id);
const [champ, setChamp] = useState<Championship | null>(null);
const [myReg, setMyReg] = useState<Registration | null>(null);
const [loading, setLoading] = useState(true);
const [registering, setRegistering] = useState(false);
if (isLoading || !champ) {
useEffect(() => {
const load = async () => {
try {
const detail = await championshipsApi.get(id);
setChamp(detail);
try {
const regs = await championshipsApi.myRegistrations();
setMyReg(regs.find((r) => r.championship_id === id) ?? null);
} catch {
// myRegistrations failing shouldn't hide the championship
}
} finally {
setLoading(false);
}
};
load();
}, [id]);
const handleRegister = async () => {
if (!champ) return;
Alert.alert('Register', `Register for "${champ.title}"?`, [
{ text: 'Cancel', style: 'cancel' },
{
text: 'Register',
onPress: async () => {
setRegistering(true);
try {
const reg = await championshipsApi.register({ championship_id: id });
setMyReg(reg);
Alert.alert('Success', 'You are registered! Complete the next steps on the registration form.');
} catch (err: any) {
Alert.alert('Error', err?.response?.data?.detail ?? 'Registration failed');
} finally {
setRegistering(false);
}
},
},
]);
};
if (loading) {
return (
<View style={styles.center}>
<ActivityIndicator size="large" color="#6C3FC5" />
<ActivityIndicator size="large" color="#7c3aed" />
</View>
);
}
const canRegister = isRegistrationOpen(
champ.registration_open_at,
champ.registration_close_at,
champ.status,
if (!champ) {
return (
<View style={styles.center}>
<Text>Championship not found</Text>
</View>
);
}
const steps = [
{ key: 'submitted', label: 'Application submitted' },
{ key: 'form_submitted', label: 'Registration form submitted' },
{ key: 'payment_pending', label: 'Payment pending' },
{ key: 'payment_confirmed', label: 'Payment confirmed' },
{ key: 'video_submitted', label: 'Video submitted' },
{ key: 'accepted', label: 'Accepted' },
];
const currentStepIndex = myReg
? steps.findIndex((s) => s.key === myReg.status)
: -1;
return (
<ScrollView style={styles.container}>
{champ.image_url ? (
<Image source={{ uri: champ.image_url }} style={styles.image} />
) : (
<View style={styles.imagePlaceholder} />
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
{champ.image_url && (
<Image source={{ uri: champ.image_url }} style={styles.image} resizeMode="cover" />
)}
<View style={styles.body}>
<View style={styles.row}>
<StatusBadge status={champ.status} />
</View>
<Text style={styles.title}>{champ.title}</Text>
{champ.location && <Text style={styles.meta}>📍 {champ.location}</Text>}
{champ.event_date && (
<View style={styles.detail}>
<Text style={styles.detailLabel}>Date</Text>
<Text style={styles.detailValue}>{formatDate(champ.event_date)}</Text>
</View>
)}
{champ.location && (
<View style={styles.detail}>
<Text style={styles.detailLabel}>Location</Text>
<Text style={styles.detailValue}>{champ.location}</Text>
</View>
)}
{champ.registration_close_at && (
<View style={styles.detail}>
<Text style={styles.detailLabel}>Registration deadline</Text>
<Text style={styles.detailValue}>{formatDate(champ.registration_close_at)}</Text>
</View>
)}
{champ.description && (
<Text style={styles.description}>{champ.description}</Text>
)}
<TouchableOpacity
style={[styles.button, !canRegister && styles.buttonDisabled]}
disabled={!canRegister}
onPress={() =>
navigation.navigate('RegistrationForm', {
championshipId: champ.id,
championshipTitle: champ.title,
})
}
>
<Text style={styles.buttonText}>
{canRegister ? 'Register for this Championship' : 'Registration Closed'}
<Text style={styles.meta}>
📅 {new Date(champ.event_date).toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })}
</Text>
</TouchableOpacity>
)}
{champ.entry_fee != null && <Text style={styles.meta}>💰 Entry fee: {champ.entry_fee} BYN</Text>}
{champ.video_max_duration != null && <Text style={styles.meta}>🎥 Max video duration: {champ.video_max_duration}s</Text>}
{champ.description && <Text style={styles.description}>{champ.description}</Text>}
{/* Categories */}
{champ.categories && champ.categories.length > 0 && (
<View style={styles.section}>
<Text style={styles.sectionTitle}>Categories</Text>
<View style={styles.tags}>
{champ.categories.map((cat) => (
<View key={cat} style={styles.tag}>
<Text style={styles.tagText}>{cat}</Text>
</View>
))}
</View>
</View>
)}
{/* Judges */}
{champ.judges && champ.judges.length > 0 && (
<View style={styles.section}>
<Text style={styles.sectionTitle}>Judges</Text>
{champ.judges.map((j) => (
<View key={j.name} style={styles.judgeRow}>
<Text style={styles.judgeName}>{j.name}</Text>
{j.bio && <Text style={styles.judgeBio}>{j.bio}</Text>}
{j.instagram && <Text style={styles.judgeInsta}>{j.instagram}</Text>}
</View>
))}
</View>
)}
{/* Registration form link */}
{champ.form_url && (
<TouchableOpacity style={styles.formBtn} onPress={() => Linking.openURL(champ.form_url!)}>
<Text style={styles.formBtnText}>Open Registration Form </Text>
</TouchableOpacity>
)}
{/* My registration progress */}
{myReg && (
<View style={styles.section}>
<Text style={styles.sectionTitle}>My Registration Progress</Text>
{steps.map((step, i) => {
const done = i <= currentStepIndex;
const isRejected = myReg.status === 'rejected' || myReg.status === 'waitlisted';
return (
<View key={step.key} style={styles.step}>
<View style={[styles.stepDot, done && !isRejected && styles.stepDotDone]} />
<Text style={[styles.stepLabel, done && !isRejected && styles.stepLabelDone]}>
{step.label}
</Text>
</View>
);
})}
{(myReg.status === 'rejected' || myReg.status === 'waitlisted') && (
<Text style={styles.rejectedText}>
Status: {myReg.status === 'rejected' ? '❌ Rejected' : '⏳ Waitlisted'}
</Text>
)}
</View>
)}
{/* Register button / status */}
{!myReg && (
champ.status === 'open' ? (
<TouchableOpacity style={styles.registerBtn} onPress={handleRegister} disabled={registering}>
{registering ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={styles.registerBtnText}>Register for Championship</Text>
)}
</TouchableOpacity>
) : (
<View style={styles.closedBanner}>
<Text style={styles.closedText}>
{champ.status === 'draft' && '⏳ Registration is not open yet'}
{champ.status === 'closed' && '🔒 Registration is closed'}
{champ.status === 'completed' && '✅ This championship has ended'}
</Text>
</View>
)
)}
</ScrollView>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#fff' },
content: { paddingBottom: 40 },
center: { flex: 1, justifyContent: 'center', alignItems: 'center' },
image: { width: '100%', height: 220 },
imagePlaceholder: { width: '100%', height: 120, backgroundColor: '#E9D5FF' },
body: { padding: 20, gap: 12 },
row: { flexDirection: 'row' },
title: { fontSize: 22, fontWeight: '800', marginTop: 4 },
detail: { gap: 2 },
detailLabel: { fontSize: 12, color: '#888', fontWeight: '500', textTransform: 'uppercase' },
detailValue: { fontSize: 15, color: '#222' },
description: { fontSize: 15, color: '#444', lineHeight: 22, marginTop: 8 },
button: {
backgroundColor: '#6C3FC5',
title: { fontSize: 22, fontWeight: '700', color: '#1a1a2e', margin: 16, marginBottom: 8 },
meta: { fontSize: 14, color: '#555', marginHorizontal: 16, marginBottom: 4 },
description: { fontSize: 14, color: '#444', lineHeight: 22, margin: 16, marginTop: 12 },
section: { marginHorizontal: 16, marginTop: 20 },
sectionTitle: { fontSize: 17, fontWeight: '600', color: '#1a1a2e', marginBottom: 12 },
tags: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
tag: { backgroundColor: '#f3f0ff', paddingHorizontal: 12, paddingVertical: 6, borderRadius: 8 },
tagText: { color: '#7c3aed', fontSize: 13, fontWeight: '500' },
judgeRow: { marginBottom: 12, padding: 12, backgroundColor: '#f9fafb', borderRadius: 10 },
judgeName: { fontSize: 15, fontWeight: '600', color: '#1a1a2e' },
judgeBio: { fontSize: 13, color: '#555', marginTop: 2 },
judgeInsta: { fontSize: 13, color: '#7c3aed', marginTop: 2 },
formBtn: {
margin: 16,
padding: 14,
borderWidth: 2,
borderColor: '#7c3aed',
borderRadius: 10,
padding: 16,
alignItems: 'center',
marginTop: 16,
},
buttonDisabled: { backgroundColor: '#C4B5FD' },
buttonText: { color: '#fff', fontWeight: '700', fontSize: 16 },
formBtnText: { color: '#7c3aed', fontSize: 15, fontWeight: '600' },
step: { flexDirection: 'row', alignItems: 'center', marginBottom: 10 },
stepDot: { width: 14, height: 14, borderRadius: 7, backgroundColor: '#ddd', marginRight: 10 },
stepDotDone: { backgroundColor: '#16a34a' },
stepLabel: { fontSize: 14, color: '#9ca3af' },
stepLabelDone: { color: '#1a1a2e' },
rejectedText: { fontSize: 14, color: '#dc2626', marginTop: 8, fontWeight: '600' },
registerBtn: {
margin: 16,
backgroundColor: '#7c3aed',
padding: 16,
borderRadius: 10,
alignItems: 'center',
},
registerBtnText: { color: '#fff', fontSize: 16, fontWeight: '600' },
closedBanner: {
margin: 16,
padding: 14,
backgroundColor: '#f3f4f6',
borderRadius: 10,
alignItems: 'center',
},
closedText: { color: '#6b7280', fontSize: 14, fontWeight: '500' },
});

View File

@@ -1,91 +0,0 @@
import React from 'react';
import {
ActivityIndicator,
FlatList,
Image,
RefreshControl,
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import { useChampionships } from '../../queries/useChampionships';
import { StatusBadge } from '../../components/StatusBadge';
import { Championship } from '../../types/championship.types';
import { formatDate } from '../../utils/dateFormatters';
import { AppStackParamList } from '../../navigation/AppStack';
type Props = NativeStackScreenProps<AppStackParamList, 'Championships'>;
export function ChampionshipListScreen({ navigation }: Props) {
const { data, isLoading, refetch, isRefetching } = useChampionships();
if (isLoading) {
return (
<View style={styles.center}>
<ActivityIndicator size="large" color="#6C3FC5" />
</View>
);
}
const renderItem = ({ item }: { item: Championship }) => (
<TouchableOpacity
style={styles.card}
onPress={() => navigation.navigate('ChampionshipDetail', { id: item.id })}
>
{item.image_url ? (
<Image source={{ uri: item.image_url }} style={styles.image} />
) : (
<View style={styles.imagePlaceholder} />
)}
<View style={styles.cardBody}>
<Text style={styles.cardTitle} numberOfLines={2}>
{item.title}
</Text>
<Text style={styles.cardDate}>{formatDate(item.event_date)}</Text>
{item.location ? <Text style={styles.cardLocation}>{item.location}</Text> : null}
<StatusBadge status={item.status} />
</View>
</TouchableOpacity>
);
return (
<FlatList
data={data ?? []}
keyExtractor={(item) => item.id}
renderItem={renderItem}
contentContainerStyle={styles.list}
refreshControl={
<RefreshControl refreshing={isRefetching} onRefresh={refetch} tintColor="#6C3FC5" />
}
ListEmptyComponent={
<View style={styles.center}>
<Text style={styles.emptyText}>No championships scheduled yet</Text>
</View>
}
/>
);
}
const styles = StyleSheet.create({
center: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 32 },
list: { padding: 16, gap: 12 },
card: {
backgroundColor: '#fff',
borderRadius: 12,
overflow: 'hidden',
elevation: 2,
shadowColor: '#000',
shadowOpacity: 0.08,
shadowRadius: 4,
shadowOffset: { width: 0, height: 2 },
},
image: { width: '100%', height: 160 },
imagePlaceholder: { width: '100%', height: 100, backgroundColor: '#E9D5FF' },
cardBody: { padding: 14, gap: 6 },
cardTitle: { fontSize: 17, fontWeight: '700' },
cardDate: { fontSize: 13, color: '#6C3FC5', fontWeight: '500' },
cardLocation: { fontSize: 13, color: '#555' },
emptyText: { color: '#888', fontSize: 15 },
});

View File

@@ -0,0 +1,135 @@
import { useEffect, useState } from 'react';
import {
View,
Text,
FlatList,
TouchableOpacity,
StyleSheet,
RefreshControl,
ActivityIndicator,
Image,
} from 'react-native';
import { useNavigation } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { championshipsApi } from '../../api/championships';
import type { Championship } from '../../types';
import type { AppStackParams } from '../../navigation';
const STATUS_COLOR: Record<string, string> = {
open: '#16a34a',
draft: '#9ca3af',
closed: '#dc2626',
completed: '#2563eb',
};
function StatusBadge({ status }: { status: string }) {
return (
<View style={[styles.badge, { backgroundColor: STATUS_COLOR[status] ?? '#9ca3af' }]}>
<Text style={styles.badgeText}>{status.toUpperCase()}</Text>
</View>
);
}
function ChampionshipCard({ item, onPress }: { item: Championship; onPress: () => void }) {
return (
<TouchableOpacity style={styles.card} onPress={onPress} activeOpacity={0.85}>
{item.image_url && (
<Image source={{ uri: item.image_url }} style={styles.cardImage} resizeMode="cover" />
)}
<View style={styles.cardBody}>
<View style={styles.cardHeader}>
<Text style={styles.cardTitle} numberOfLines={2}>{item.title}</Text>
<StatusBadge status={item.status} />
</View>
{item.location && <Text style={styles.cardMeta}>📍 {item.location}</Text>}
{item.event_date && (
<Text style={styles.cardMeta}>
📅 {new Date(item.event_date).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' })}
</Text>
)}
{item.entry_fee != null && (
<Text style={styles.cardMeta}>💰 Entry fee: {item.entry_fee} BYN</Text>
)}
</View>
</TouchableOpacity>
);
}
export default function ChampionshipsScreen() {
const navigation = useNavigation<NativeStackNavigationProp<AppStackParams>>();
const [championships, setChampionships] = useState<Championship[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const load = async (silent = false) => {
if (!silent) setLoading(true);
setError(null);
try {
const data = await championshipsApi.list();
setChampionships(data);
} catch {
setError('Failed to load championships');
} finally {
setLoading(false);
setRefreshing(false);
}
};
useEffect(() => { load(); }, []);
if (loading) {
return (
<View style={styles.center}>
<ActivityIndicator size="large" color="#7c3aed" />
</View>
);
}
return (
<FlatList
data={championships}
keyExtractor={(item) => item.id}
contentContainerStyle={styles.list}
ListEmptyComponent={
<View style={styles.center}>
<Text style={styles.empty}>{error ?? 'No championships yet'}</Text>
</View>
}
renderItem={({ item }) => (
<ChampionshipCard
item={item}
onPress={() => navigation.navigate('ChampionshipDetail', { id: item.id })}
/>
)}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={() => { setRefreshing(true); load(true); }} />
}
/>
);
}
const styles = StyleSheet.create({
list: { padding: 16 },
heading: { fontSize: 24, fontWeight: '700', color: '#1a1a2e', marginBottom: 16 },
center: { flex: 1, justifyContent: 'center', alignItems: 'center', paddingTop: 60 },
empty: { color: '#9ca3af', fontSize: 15 },
card: {
backgroundColor: '#fff',
borderRadius: 14,
marginBottom: 14,
overflow: 'hidden',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.08,
shadowRadius: 6,
elevation: 3,
},
cardImage: { width: '100%', height: 160 },
cardBody: { padding: 14 },
cardHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 8 },
cardTitle: { flex: 1, fontSize: 17, fontWeight: '600', color: '#1a1a2e', marginRight: 8 },
badge: { paddingHorizontal: 8, paddingVertical: 3, borderRadius: 6 },
badgeText: { color: '#fff', fontSize: 11, fontWeight: '700' },
cardMeta: { fontSize: 13, color: '#555', marginTop: 4 },
});

View File

@@ -0,0 +1,189 @@
import { useEffect, useState } from 'react';
import {
View,
Text,
FlatList,
TouchableOpacity,
StyleSheet,
RefreshControl,
ActivityIndicator,
Alert,
} from 'react-native';
import { useNavigation } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { Ionicons } from '@expo/vector-icons';
import { championshipsApi } from '../../api/championships';
import type { Registration } from '../../types';
import type { AppStackParams } from '../../navigation';
const STATUS_CONFIG: Record<string, { color: string; icon: string; label: string }> = {
submitted: { color: '#f59e0b', icon: 'time-outline', label: 'Submitted' },
form_submitted: { color: '#3b82f6', icon: 'document-text-outline', label: 'Form Done' },
payment_pending: { color: '#f97316', icon: 'card-outline', label: 'Payment Pending' },
payment_confirmed: { color: '#8b5cf6', icon: 'checkmark-circle-outline', label: 'Paid' },
video_submitted: { color: '#06b6d4', icon: 'videocam-outline', label: 'Video Sent' },
accepted: { color: '#16a34a', icon: 'trophy-outline', label: 'Accepted' },
rejected: { color: '#dc2626', icon: 'close-circle-outline', label: 'Rejected' },
waitlisted: { color: '#9ca3af', icon: 'hourglass-outline', label: 'Waitlisted' },
};
const STEP_KEYS = ['submitted', 'form_submitted', 'payment_pending', 'payment_confirmed', 'video_submitted', 'accepted'];
function RegistrationCard({ item, onPress }: { item: Registration; onPress: () => void }) {
const config = STATUS_CONFIG[item.status] ?? { color: '#9ca3af', icon: 'help-outline', label: item.status };
const stepIndex = STEP_KEYS.indexOf(item.status);
const isFinal = item.status === 'rejected' || item.status === 'waitlisted';
return (
<TouchableOpacity style={styles.card} onPress={onPress} activeOpacity={0.85}>
<View style={styles.cardTop}>
<View style={styles.cardTitleArea}>
<Text style={styles.cardTitle} numberOfLines={2}>
{item.championship_title ?? 'Championship'}
</Text>
{item.championship_location && (
<Text style={styles.cardMeta}>
<Ionicons name="location-outline" size={12} color="#6b7280" /> {item.championship_location}
</Text>
)}
{item.championship_event_date && (
<Text style={styles.cardMeta}>
<Ionicons name="calendar-outline" size={12} color="#6b7280" />{' '}
{new Date(item.championship_event_date).toLocaleDateString('en-GB', {
day: 'numeric',
month: 'long',
year: 'numeric',
})}
</Text>
)}
</View>
<View style={[styles.statusBadge, { backgroundColor: config.color + '18' }]}>
<Ionicons name={config.icon as any} size={14} color={config.color} />
<Text style={[styles.statusText, { color: config.color }]}>{config.label}</Text>
</View>
</View>
{/* Progress bar */}
<View style={styles.progressRow}>
{STEP_KEYS.map((key, i) => {
const done = !isFinal && i <= stepIndex;
return <View key={key} style={[styles.progressDot, done && { backgroundColor: config.color }]} />;
})}
</View>
<View style={styles.cardBottom}>
<Text style={styles.dateText}>
Registered {new Date(item.submitted_at).toLocaleDateString()}
</Text>
<Ionicons name="chevron-forward" size={16} color="#9ca3af" />
</View>
</TouchableOpacity>
);
}
export default function MyRegistrationsScreen() {
const navigation = useNavigation<NativeStackNavigationProp<AppStackParams>>();
const [registrations, setRegistrations] = useState<Registration[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const load = async (silent = false) => {
if (!silent) setLoading(true);
try {
const data = await championshipsApi.myRegistrations();
setRegistrations(data);
} catch {
Alert.alert('Error', 'Failed to load registrations');
} finally {
setLoading(false);
setRefreshing(false);
}
};
useEffect(() => { load(); }, []);
if (loading) {
return (
<View style={styles.center}>
<ActivityIndicator size="large" color="#7c3aed" />
</View>
);
}
return (
<FlatList
data={registrations}
keyExtractor={(item) => item.id}
contentContainerStyle={styles.list}
ListEmptyComponent={
<View style={styles.emptyContainer}>
<Ionicons name="document-text-outline" size={48} color="#d1d5db" />
<Text style={styles.empty}>No registrations yet</Text>
<Text style={styles.emptySub}>Browse championships and register for events</Text>
</View>
}
renderItem={({ item }) => (
<RegistrationCard
item={item}
onPress={() => navigation.navigate('ChampionshipDetail', { id: item.championship_id })}
/>
)}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={() => { setRefreshing(true); load(true); }} />
}
/>
);
}
const styles = StyleSheet.create({
list: { padding: 16, flexGrow: 1 },
heading: { fontSize: 24, fontWeight: '700', color: '#1a1a2e', marginBottom: 16 },
center: { flex: 1, justifyContent: 'center', alignItems: 'center' },
emptyContainer: { flex: 1, justifyContent: 'center', alignItems: 'center', paddingTop: 80 },
empty: { color: '#6b7280', fontSize: 16, fontWeight: '600', marginTop: 12, marginBottom: 4 },
emptySub: { color: '#9ca3af', fontSize: 13 },
card: {
backgroundColor: '#fff',
borderRadius: 14,
padding: 16,
marginBottom: 12,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.07,
shadowRadius: 6,
elevation: 3,
},
cardTop: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-start' },
cardTitleArea: { flex: 1, marginRight: 10 },
cardTitle: { fontSize: 16, fontWeight: '700', color: '#1a1a2e', marginBottom: 4 },
cardMeta: { fontSize: 12, color: '#6b7280', marginTop: 2 },
statusBadge: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 8,
gap: 4,
},
statusText: { fontSize: 11, fontWeight: '700' },
progressRow: { flexDirection: 'row', gap: 4, marginTop: 14, marginBottom: 12 },
progressDot: {
flex: 1,
height: 4,
borderRadius: 2,
backgroundColor: '#e5e7eb',
},
cardBottom: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
borderTopWidth: 1,
borderTopColor: '#f3f4f6',
paddingTop: 10,
},
dateText: { fontSize: 12, color: '#9ca3af' },
});

View File

@@ -1,124 +1,149 @@
import React from 'react';
import {
ActivityIndicator,
Alert,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native';
import { useAuth } from '../../hooks/useAuth';
import { useMyRegistrations, useWithdrawRegistration } from '../../queries/useRegistrations';
import { useChampionshipDetail } from '../../queries/useChampionships';
import { StatusBadge } from '../../components/StatusBadge';
import { formatDate } from '../../utils/dateFormatters';
import { Registration } from '../../types/registration.types';
import { View, Text, StyleSheet, TouchableOpacity, Alert, ScrollView } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useAuthStore } from '../../store/auth.store';
function RegistrationItem({ reg }: { reg: Registration }) {
const { data: champ } = useChampionshipDetail(reg.championship_id);
const { mutate: withdraw } = useWithdrawRegistration();
const ROLE_CONFIG: Record<string, { color: string; bg: string; label: string }> = {
member: { color: '#16a34a', bg: '#f0fdf4', label: 'Member' },
organizer: { color: '#7c3aed', bg: '#f3f0ff', label: 'Organizer' },
admin: { color: '#dc2626', bg: '#fef2f2', label: 'Admin' },
};
const handleWithdraw = () => {
Alert.alert('Withdraw Registration', 'Are you sure?', [
export default function ProfileScreen() {
const { user, logout } = useAuthStore();
const handleLogout = () => {
Alert.alert('Sign Out', 'Are you sure you want to sign out?', [
{ text: 'Cancel', style: 'cancel' },
{
text: 'Withdraw',
style: 'destructive',
onPress: () => withdraw(reg.id),
},
{ text: 'Sign Out', style: 'destructive', onPress: logout },
]);
};
return (
<View style={styles.regCard}>
<Text style={styles.regTitle} numberOfLines={2}>
{champ?.title ?? 'Loading...'}
</Text>
{champ?.event_date && (
<Text style={styles.regDate}>{formatDate(champ.event_date)}</Text>
)}
<Text style={styles.regCategory}>
{reg.category} · {reg.level}
</Text>
<View style={styles.regFooter}>
<StatusBadge status={reg.status} />
{reg.status === 'submitted' && (
<TouchableOpacity onPress={handleWithdraw}>
<Text style={styles.withdrawText}>Withdraw</Text>
</TouchableOpacity>
)}
</View>
</View>
);
}
if (!user) return null;
export function ProfileScreen() {
const { user, logout } = useAuth();
const { data: registrations, isLoading } = useMyRegistrations();
const roleConfig = ROLE_CONFIG[user.role] ?? { color: '#6b7280', bg: '#f3f4f6', label: user.role };
return (
<ScrollView style={styles.container}>
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
{/* Avatar + Name */}
<View style={styles.header}>
<Text style={styles.name}>{user?.full_name}</Text>
<Text style={styles.email}>{user?.email}</Text>
<StatusBadge status={user?.role ?? 'member'} />
<View style={styles.avatar}>
<Text style={styles.avatarText}>{user.full_name.charAt(0).toUpperCase()}</Text>
</View>
<Text style={styles.name}>{user.full_name}</Text>
<Text style={styles.email}>{user.email}</Text>
<View style={[styles.roleBadge, { backgroundColor: roleConfig.bg }]}>
<Text style={[styles.roleText, { color: roleConfig.color }]}>{roleConfig.label}</Text>
</View>
</View>
<Text style={styles.sectionTitle}>My Registrations</Text>
{isLoading ? (
<ActivityIndicator color="#6C3FC5" style={{ marginTop: 24 }} />
) : registrations?.length === 0 ? (
<Text style={styles.emptyText}>No registrations yet</Text>
) : (
registrations?.map((reg) => <RegistrationItem key={reg.id} reg={reg} />)
{/* Info Card */}
<View style={styles.card}>
{user.phone && (
<Row icon="call-outline" label="Phone" value={user.phone} />
)}
{user.organization_name && (
<Row icon="business-outline" label="Organization" value={user.organization_name} />
)}
{user.instagram_handle && (
<Row icon="logo-instagram" label="Instagram" value={user.instagram_handle} />
)}
<Row
icon="calendar-outline"
label="Member since"
value={new Date(user.created_at).toLocaleDateString('en-GB', {
day: 'numeric',
month: 'long',
year: 'numeric',
})}
isLast
/>
</View>
<TouchableOpacity style={styles.logoutButton} onPress={logout}>
<Text style={styles.logoutText}>Log Out</Text>
{/* Sign Out */}
<TouchableOpacity style={styles.logoutBtn} onPress={handleLogout}>
<Ionicons name="log-out-outline" size={18} color="#ef4444" />
<Text style={styles.logoutText}>Sign Out</Text>
</TouchableOpacity>
</ScrollView>
);
}
function Row({
icon,
label,
value,
isLast,
}: {
icon: string;
label: string;
value: string;
isLast?: boolean;
}) {
return (
<View style={[styles.row, isLast && styles.rowLast]}>
<View style={styles.rowLeft}>
<Ionicons name={icon as any} size={16} color="#7c3aed" />
<Text style={styles.rowLabel}>{label}</Text>
</View>
<Text style={styles.rowValue}>{value}</Text>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#F9FAFB' },
header: {
backgroundColor: '#6C3FC5',
padding: 24,
paddingTop: 48,
gap: 6,
},
name: { fontSize: 22, fontWeight: '700', color: '#fff' },
email: { fontSize: 14, color: '#DDD6FE' },
sectionTitle: { fontSize: 16, fontWeight: '700', margin: 16, color: '#333' },
regCard: {
backgroundColor: '#fff',
marginHorizontal: 16,
marginBottom: 12,
borderRadius: 10,
padding: 16,
gap: 6,
elevation: 1,
shadowColor: '#000',
shadowOpacity: 0.05,
shadowRadius: 3,
shadowOffset: { width: 0, height: 1 },
},
regTitle: { fontSize: 15, fontWeight: '700' },
regDate: { fontSize: 13, color: '#6C3FC5' },
regCategory: { fontSize: 13, color: '#555' },
regFooter: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
withdrawText: { fontSize: 13, color: '#DC2626' },
emptyText: { textAlign: 'center', color: '#888', marginTop: 24 },
logoutButton: {
margin: 24,
borderWidth: 1,
borderColor: '#6C3FC5',
borderRadius: 8,
padding: 14,
container: { flex: 1, backgroundColor: '#fff' },
content: { padding: 24, paddingBottom: 40 },
header: { alignItems: 'center', marginBottom: 28 },
avatar: {
width: 80,
height: 80,
borderRadius: 40,
backgroundColor: '#7c3aed',
justifyContent: 'center',
alignItems: 'center',
marginBottom: 14,
},
logoutText: { color: '#6C3FC5', fontWeight: '600' },
avatarText: { color: '#fff', fontSize: 32, fontWeight: '700' },
name: { fontSize: 22, fontWeight: '700', color: '#1a1a2e', marginBottom: 4 },
email: { fontSize: 14, color: '#6b7280', marginBottom: 10 },
roleBadge: {
paddingHorizontal: 14,
paddingVertical: 5,
borderRadius: 20,
},
roleText: { fontSize: 13, fontWeight: '700' },
card: {
backgroundColor: '#f9fafb',
borderRadius: 14,
marginBottom: 28,
overflow: 'hidden',
},
row: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingVertical: 14,
paddingHorizontal: 16,
borderBottomWidth: 1,
borderBottomColor: '#f3f4f6',
},
rowLast: { borderBottomWidth: 0 },
rowLeft: { flexDirection: 'row', alignItems: 'center', gap: 8 },
rowLabel: { fontSize: 14, color: '#6b7280' },
rowValue: { fontSize: 14, color: '#1a1a2e', fontWeight: '500' },
logoutBtn: {
flexDirection: 'row',
gap: 8,
justifyContent: 'center',
alignItems: 'center',
borderWidth: 1.5,
borderColor: '#fecaca',
backgroundColor: '#fef2f2',
borderRadius: 12,
padding: 14,
},
logoutText: { color: '#ef4444', fontSize: 15, fontWeight: '600' },
});

View File

@@ -1,140 +0,0 @@
import React, { useState } from 'react';
import {
Alert,
KeyboardAvoidingView,
Platform,
ScrollView,
StyleSheet,
Text,
TextInput,
TouchableOpacity,
View,
} from 'react-native';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import { AppStackParamList } from '../../navigation/AppStack';
import { useSubmitRegistration } from '../../queries/useRegistrations';
type Props = NativeStackScreenProps<AppStackParamList, 'RegistrationForm'>;
const CATEGORIES = ['Pole Sport', 'Pole Art', 'Pole Exotic', 'Doubles', 'Kids'];
const LEVELS = ['Beginner', 'Amateur', 'Semi-Pro', 'Professional'];
export function RegistrationFormScreen({ route, navigation }: Props) {
const { championshipId, championshipTitle } = route.params;
const [category, setCategory] = useState('');
const [level, setLevel] = useState('');
const [notes, setNotes] = useState('');
const { mutate: submit, isPending } = useSubmitRegistration();
const handleSubmit = () => {
if (!category || !level) {
Alert.alert('Required', 'Please select a category and level');
return;
}
submit(
{ championship_id: championshipId, category, level, notes: notes || undefined },
{
onSuccess: () => {
Alert.alert('Submitted!', 'Your registration has been received. You will be notified when the participant list is published.', [
{ text: 'OK', onPress: () => navigation.goBack() },
]);
},
onError: (err: any) => {
const msg = err?.response?.data?.detail ?? 'Submission failed';
Alert.alert('Error', msg);
},
},
);
};
return (
<KeyboardAvoidingView
style={{ flex: 1 }}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<ScrollView contentContainerStyle={styles.container}>
<Text style={styles.champTitle}>{championshipTitle}</Text>
<Text style={styles.label}>Category *</Text>
<View style={styles.chips}>
{CATEGORIES.map((c) => (
<TouchableOpacity
key={c}
style={[styles.chip, category === c && styles.chipSelected]}
onPress={() => setCategory(c)}
>
<Text style={[styles.chipText, category === c && styles.chipTextSelected]}>{c}</Text>
</TouchableOpacity>
))}
</View>
<Text style={styles.label}>Level *</Text>
<View style={styles.chips}>
{LEVELS.map((l) => (
<TouchableOpacity
key={l}
style={[styles.chip, level === l && styles.chipSelected]}
onPress={() => setLevel(l)}
>
<Text style={[styles.chipText, level === l && styles.chipTextSelected]}>{l}</Text>
</TouchableOpacity>
))}
</View>
<Text style={styles.label}>Additional Notes</Text>
<TextInput
style={styles.textarea}
placeholder="Any additional information for the organizers..."
multiline
numberOfLines={4}
value={notes}
onChangeText={setNotes}
/>
<TouchableOpacity
style={[styles.button, isPending && styles.buttonDisabled]}
onPress={handleSubmit}
disabled={isPending}
>
<Text style={styles.buttonText}>{isPending ? 'Submitting...' : 'Submit Registration'}</Text>
</TouchableOpacity>
</ScrollView>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
container: { padding: 20, backgroundColor: '#fff', flexGrow: 1 },
champTitle: { fontSize: 18, fontWeight: '700', marginBottom: 24, color: '#333' },
label: { fontSize: 14, fontWeight: '600', color: '#444', marginBottom: 8, marginTop: 16 },
chips: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
chip: {
borderWidth: 1.5,
borderColor: '#C4B5FD',
borderRadius: 20,
paddingHorizontal: 14,
paddingVertical: 8,
},
chipSelected: { backgroundColor: '#6C3FC5', borderColor: '#6C3FC5' },
chipText: { fontSize: 13, color: '#6C3FC5' },
chipTextSelected: { color: '#fff', fontWeight: '600' },
textarea: {
borderWidth: 1,
borderColor: '#ddd',
borderRadius: 8,
padding: 12,
fontSize: 15,
textAlignVertical: 'top',
minHeight: 100,
},
button: {
backgroundColor: '#6C3FC5',
borderRadius: 10,
padding: 16,
alignItems: 'center',
marginTop: 32,
},
buttonDisabled: { opacity: 0.6 },
buttonText: { color: '#fff', fontWeight: '700', fontSize: 16 },
});

View File

@@ -1,16 +1,94 @@
import { create } from 'zustand';
import { User } from '../types/auth.types';
import { apiClient } from '../api/client';
import { authApi } from '../api/auth';
import { tokenStorage } from '../utils/tokenStorage';
import type { User } from '../types';
interface AuthState {
user: User | null;
isLoading: boolean;
setUser: (user: User | null) => void;
setLoading: (loading: boolean) => void;
isInitialized: boolean;
login: (email: string, password: string) => Promise<void>;
// Returns true if auto-logged in (member), false if pending approval (organizer)
register: (data: {
email: string;
password: string;
full_name: string;
phone?: string;
requested_role: 'member' | 'organizer';
organization_name?: string;
instagram_handle?: string;
}) => Promise<boolean>;
logout: () => Promise<void>;
initialize: () => Promise<void>;
}
export const useAuthStore = create<AuthState>((set) => ({
user: null,
isLoading: true,
setUser: (user) => set({ user }),
setLoading: (isLoading) => set({ isLoading }),
isLoading: false,
isInitialized: false,
initialize: async () => {
try {
await tokenStorage.loadFromStorage();
const token = tokenStorage.getAccessTokenSync();
if (token) {
apiClient.defaults.headers.common.Authorization = `Bearer ${token}`;
const user = await authApi.me();
set({ user, isInitialized: true });
} else {
set({ isInitialized: true });
}
} catch {
await tokenStorage.clearTokens();
set({ user: null, isInitialized: true });
}
},
login: async (email, password) => {
set({ isLoading: true });
try {
const data = await authApi.login({ email, password });
await tokenStorage.saveTokens(data.access_token, data.refresh_token);
apiClient.defaults.headers.common.Authorization = `Bearer ${data.access_token}`;
set({ user: data.user, isLoading: false });
} catch (err) {
set({ isLoading: false });
throw err;
}
},
register: async (data) => {
set({ isLoading: true });
try {
const res = await authApi.register(data);
if (res.access_token && res.refresh_token) {
// Member: auto-approved — save tokens and log in immediately
await tokenStorage.saveTokens(res.access_token, res.refresh_token);
apiClient.defaults.headers.common.Authorization = `Bearer ${res.access_token}`;
set({ user: res.user, isLoading: false });
return true;
}
// Organizer: pending admin approval
set({ isLoading: false });
return false;
} catch (err) {
set({ isLoading: false });
throw err;
}
},
logout: async () => {
const refresh = tokenStorage.getRefreshTokenSync();
if (refresh) {
try {
await authApi.logout(refresh);
} catch {
// ignore
}
}
await tokenStorage.clearTokens();
delete apiClient.defaults.headers.common.Authorization;
set({ user: null });
},
}));

View File

@@ -1,26 +0,0 @@
export interface User {
id: string;
email: string;
full_name: string;
phone: string | null;
role: 'member' | 'organizer' | 'admin';
status: 'pending' | 'approved' | 'rejected';
}
export interface TokenResponse {
access_token: string;
refresh_token: string;
token_type: string;
}
export interface LoginRequest {
email: string;
password: string;
}
export interface RegisterRequest {
email: string;
password: string;
full_name: string;
phone?: string;
}

View File

@@ -1,14 +0,0 @@
export interface Championship {
id: string;
title: string;
description: string | null;
location: string | null;
event_date: string | null;
registration_open_at: string | null;
registration_close_at: string | null;
status: 'draft' | 'open' | 'closed' | 'completed';
source: 'manual' | 'instagram';
image_url: string | null;
created_at: string;
updated_at: string;
}

63
mobile/src/types/index.ts Normal file
View File

@@ -0,0 +1,63 @@
export interface User {
id: string;
email: string;
full_name: string;
phone: string | null;
role: 'member' | 'organizer' | 'admin';
status: 'pending' | 'approved' | 'rejected';
organization_name: string | null;
instagram_handle: string | null;
expo_push_token: string | null;
created_at: string;
}
export interface TokenPair {
access_token: string;
refresh_token: string;
token_type: string;
user: User;
}
export interface Championship {
id: string;
title: string;
description: string | null;
location: string | null;
event_date: string | null;
registration_open_at: string | null;
registration_close_at: string | null;
form_url: string | null;
entry_fee: number | null;
video_max_duration: number | null;
judges: { name: string; bio: string; instagram: string }[] | null;
categories: string[] | null;
status: 'draft' | 'open' | 'closed' | 'completed';
source: string;
image_url: string | null;
created_at: string;
updated_at: string;
}
export interface Registration {
id: string;
championship_id: string;
user_id: string;
category: string | null;
level: string | null;
notes: string | null;
status:
| 'submitted'
| 'form_submitted'
| 'payment_pending'
| 'payment_confirmed'
| 'video_submitted'
| 'accepted'
| 'rejected'
| 'waitlisted';
video_url: string | null;
submitted_at: string;
decided_at: string | null;
championship_title: string | null;
championship_event_date: string | null;
championship_location: string | null;
}

View File

@@ -1,18 +0,0 @@
export interface Registration {
id: string;
championship_id: string;
user_id: string;
category: string | null;
level: string | null;
notes: string | null;
status: 'submitted' | 'accepted' | 'rejected' | 'waitlisted';
submitted_at: string;
decided_at: string | null;
}
export interface RegistrationCreate {
championship_id: string;
category?: string;
level?: string;
notes?: string;
}

View File

@@ -1,33 +0,0 @@
export function formatDate(iso: string | null): string {
if (!iso) return 'TBA';
const d = new Date(iso);
return d.toLocaleDateString('en-GB', {
day: 'numeric',
month: 'long',
year: 'numeric',
});
}
export function formatDateTime(iso: string | null): string {
if (!iso) return 'TBA';
const d = new Date(iso);
return d.toLocaleString('en-GB', {
day: 'numeric',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
export function isRegistrationOpen(
openAt: string | null,
closeAt: string | null,
status: string,
): boolean {
if (status !== 'open') return false;
const now = Date.now();
if (openAt && new Date(openAt).getTime() > now) return false;
if (closeAt && new Date(closeAt).getTime() < now) return false;
return true;
}

View File

@@ -1,19 +1,49 @@
import * as SecureStore from 'expo-secure-store';
const ACCESS_TOKEN_KEY = 'access_token';
const REFRESH_TOKEN_KEY = 'refresh_token';
const ACCESS_KEY = 'access_token';
const REFRESH_KEY = 'refresh_token';
// In-memory cache so synchronous reads work immediately after login
let _accessToken: string | null = null;
let _refreshToken: string | null = null;
export const tokenStorage = {
saveTokens: async (access: string, refresh: string): Promise<void> => {
await SecureStore.setItemAsync(ACCESS_TOKEN_KEY, access);
await SecureStore.setItemAsync(REFRESH_TOKEN_KEY, refresh);
async saveTokens(access: string, refresh: string): Promise<void> {
_accessToken = access;
_refreshToken = refresh;
await SecureStore.setItemAsync(ACCESS_KEY, access);
await SecureStore.setItemAsync(REFRESH_KEY, refresh);
},
getAccessToken: (): Promise<string | null> =>
SecureStore.getItemAsync(ACCESS_TOKEN_KEY),
getRefreshToken: (): Promise<string | null> =>
SecureStore.getItemAsync(REFRESH_TOKEN_KEY),
clearTokens: async (): Promise<void> => {
await SecureStore.deleteItemAsync(ACCESS_TOKEN_KEY);
await SecureStore.deleteItemAsync(REFRESH_TOKEN_KEY);
getAccessTokenSync(): string | null {
return _accessToken;
},
getRefreshTokenSync(): string | null {
return _refreshToken;
},
async getAccessToken(): Promise<string | null> {
if (_accessToken) return _accessToken;
_accessToken = await SecureStore.getItemAsync(ACCESS_KEY);
return _accessToken;
},
async getRefreshToken(): Promise<string | null> {
if (_refreshToken) return _refreshToken;
_refreshToken = await SecureStore.getItemAsync(REFRESH_KEY);
return _refreshToken;
},
async clearTokens(): Promise<void> {
_accessToken = null;
_refreshToken = null;
await SecureStore.deleteItemAsync(ACCESS_KEY);
await SecureStore.deleteItemAsync(REFRESH_KEY);
},
async loadFromStorage(): Promise<void> {
_accessToken = await SecureStore.getItemAsync(ACCESS_KEY);
_refreshToken = await SecureStore.getItemAsync(REFRESH_KEY);
},
};

View File

@@ -1,17 +1,6 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"baseUrl": ".",
"paths": {
"@api/*": ["src/api/*"],
"@store/*": ["src/store/*"],
"@screens/*": ["src/screens/*"],
"@components/*": ["src/components/*"],
"@hooks/*": ["src/hooks/*"],
"@queries/*": ["src/queries/*"],
"@types/*": ["src/types/*"],
"@utils/*": ["src/utils/*"]
}
"strict": true
}
}

Some files were not shown because too many files have changed in this diff Show More