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>
This commit is contained in:
Dianaka123
2026-02-25 22:46:50 +03:00
parent 9eb68695e9
commit 789d2bf0a6
81 changed files with 16283 additions and 310 deletions

19
.env.example Normal file
View File

@@ -0,0 +1,19 @@
# Database
DATABASE_URL=postgresql+asyncpg://pole:pole@localhost:5432/poledance
# JWT
SECRET_KEY=change-me-to-a-random-32-char-string
ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=15
REFRESH_TOKEN_EXPIRE_DAYS=7
# 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://

24
.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Python
__pycache__/
*.pyc
*.pyo
backend/.venv/
*.db
# Env
.env
backend/.env
# Logs
server.log
# IDE
.vscode/
.idea/
# 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)

10
backend/Dockerfile Normal file
View File

@@ -0,0 +1,10 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

38
backend/alembic.ini Normal file
View File

@@ -0,0 +1,38 @@
[alembic]
script_location = alembic
prepend_sys_path = .
sqlalchemy.url = postgresql+asyncpg://pole:pole@localhost:5432/poledance
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

59
backend/alembic/env.py Normal file
View File

@@ -0,0 +1,59 @@
import asyncio
from logging.config import fileConfig
from alembic import context
from sqlalchemy import pool
from sqlalchemy.ext.asyncio import async_engine_from_config
from app.config import settings
from app.database import Base
# 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)
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection):
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
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:
run_migrations_online()

View File

@@ -0,0 +1,26 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

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

0
backend/app/__init__.py Normal file
View File

28
backend/app/config.py Normal file
View File

@@ -0,0 +1,28 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
# Default: SQLite for local dev. Set DATABASE_URL=postgresql+asyncpg://... for production.
DATABASE_URL: str = "sqlite+aiosqlite:///./poledance.db"
SECRET_KEY: str = "dev-secret-change-in-production"
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 15
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
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

@@ -0,0 +1,63 @@
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 _serialize(value) -> str | None:
if value is None:
return None
return json.dumps(value)
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()
async def list_all(
db: AsyncSession,
status: str | None = None,
skip: int = 0,
limit: int = 50,
) -> list[Championship]:
q = select(Championship).order_by(Championship.event_date.asc())
if status:
q = q.where(Championship.status == status)
q = q.offset(skip).limit(limit)
result = await db.execute(q)
return list(result.scalars().all())
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, 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
async def delete(db: AsyncSession, champ: Championship) -> None:
await db.delete(champ)
await db.commit()

View File

@@ -0,0 +1,34 @@
import uuid
from datetime import UTC, datetime
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.participant import ParticipantList
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 create_or_get(db: AsyncSession, championship_id: uuid.UUID, published_by: uuid.UUID) -> ParticipantList:
existing = await get_for_championship(db, championship_id)
if existing:
return existing
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, notes: str | None = None) -> ParticipantList:
pl.is_published = True
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

@@ -0,0 +1,86 @@
import uuid
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
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 == rid).options(selectinload(Registration.user))
)
return result.scalar_one_or_none()
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.user_id == user_id,
Registration.championship_id == championship_id,
)
)
return result.scalar_one_or_none()
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 == cid)
.options(selectinload(Registration.user))
.offset(skip)
.limit(limit)
)
return list(result.scalars().all())
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=data.championship_id,
user_id=user_id,
category=data.category,
level=data.level,
notes=data.notes,
status="submitted",
)
db.add(reg)
await db.commit()
await db.refresh(reg)
return reg
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
async def delete(db: AsyncSession, reg: Registration) -> None:
await db.delete(reg)
await db.commit()

View File

@@ -0,0 +1,57 @@
import uuid
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_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 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=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()
await db.refresh(user)
return user
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:
user.status = status
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())

17
backend/app/database.py Normal file
View File

@@ -0,0 +1,17 @@
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
async def get_db() -> AsyncSession:
async with AsyncSessionLocal() as session:
yield session

View File

@@ -0,0 +1,42 @@
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.ext.asyncio import AsyncSession
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
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
async def get_current_user(
token: str = Depends(oauth2_scheme),
db: AsyncSession = Depends(get_db),
) -> 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(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(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(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

41
backend/app/main.py Normal file
View File

@@ -0,0 +1,41 @@
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.config import settings
from app.routers import auth, championships, registrations, participant_lists, users
@asynccontextmanager
async def lifespan(app: FastAPI):
# 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.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins_list,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
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=["health"])
async def health():
return {"status": "ok"}

View File

@@ -0,0 +1,14 @@
from app.models.user import User, RefreshToken
from app.models.championship import Championship
from app.models.registration import Registration
from app.models.participant import ParticipantList
from app.models.notification import NotificationLog
__all__ = [
"User",
"RefreshToken",
"Championship",
"Registration",
"ParticipantList",
"NotificationLog",
]

View File

@@ -0,0 +1,42 @@
import uuid
from datetime import datetime
from sqlalchemy import DateTime, Float, Integer, String, Text, Uuid, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class Championship(Base):
__tablename__ = "championships"
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))
# 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")
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), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
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

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

@@ -0,0 +1,36 @@
import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, String, Text, UniqueConstraint, Uuid, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class Registration(Base):
__tablename__ = "registrations"
__table_args__ = (UniqueConstraint("championship_id", "user_id", name="uq_registration_champ_user"),)
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
)
user_id: Mapped[uuid.UUID] = mapped_column(
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(100))
notes: Mapped[str | None] = mapped_column(Text)
# Multi-stage status:
# 'submitted' → 'form_submitted' → 'payment_pending' → 'payment_confirmed' →
# 'video_submitted' → 'accepted' | 'rejected' | 'waitlisted'
status: Mapped[str] = mapped_column(String(30), nullable=False, default="submitted")
video_url: Mapped[str | None] = mapped_column(String(2048))
submitted_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
decided_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
championship: Mapped["Championship"] = relationship(back_populates="registrations") # type: ignore[name-defined]
user: Mapped["User"] = relationship(back_populates="registrations") # type: ignore[name-defined]
notification_logs: Mapped[list["NotificationLog"]] = relationship(back_populates="registration") # type: ignore[name-defined]

View File

@@ -0,0 +1,44 @@
import uuid
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, String, Uuid, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class User(Base):
__tablename__ = "users"
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")
# 'pending' | 'approved' | 'rejected'
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), 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") # 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

@@ -0,0 +1,79 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
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 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,
revoke_refresh_token,
rotate_refresh_token,
verify_password,
)
router = APIRouter()
@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,
)
# 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),
)
@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(data: LogoutRequest, db: AsyncSession = Depends(get_db)):
await revoke_refresh_token(db, data.refresh_token)
@router.get("/me", response_model=UserOut)
async def me(current_user: User = Depends(get_current_user)):
return current_user
@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

@@ -0,0 +1,69 @@
import uuid
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_approved_user, get_organizer
from app.models.user import User
from app.schemas.championship import ChampionshipCreate, ChampionshipOut, ChampionshipUpdate
router = APIRouter()
@router.get("", response_model=list[ChampionshipOut])
async def list_championships(
status: str | None = Query(None),
skip: int = 0,
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("/{champ_id}", response_model=ChampionshipOut)
async def get_championship(
champ_id: uuid.UUID,
_user: User = Depends(get_approved_user),
db: AsyncSession = Depends(get_db),
):
champ = await crud_championship.get(db, champ_id)
if not champ:
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(
data: ChampionshipCreate,
_user: User = Depends(get_organizer),
db: AsyncSession = Depends(get_db),
):
return await crud_championship.create(db, data)
@router.patch("/{champ_id}", response_model=ChampionshipOut)
async def update_championship(
champ_id: uuid.UUID,
data: ChampionshipUpdate,
_user: User = Depends(get_organizer),
db: AsyncSession = Depends(get_db),
):
champ = await crud_championship.get(db, champ_id)
if not champ:
raise HTTPException(status_code=404, detail="Championship not found")
return await crud_championship.update(db, champ, data)
@router.delete("/{champ_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_championship(
champ_id: uuid.UUID,
_user: User = Depends(get_organizer),
db: AsyncSession = Depends(get_db),
):
champ = await crud_championship.get(db, champ_id)
if not champ:
raise HTTPException(status_code=404, detail="Championship not found")
await crud_championship.delete(db, champ)

View File

@@ -0,0 +1,55 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
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 import ParticipantListOut, ParticipantListPublish
from app.schemas.registration import RegistrationWithUser
router = APIRouter()
@router.get("/championships/{champ_id}/participant-list", response_model=ParticipantListOut | None)
async def get_participant_list(
champ_id: uuid.UUID,
_user: User = Depends(get_approved_user),
db: AsyncSession = Depends(get_db),
):
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.get(
"/championships/{champ_id}/participant-list/registrations",
response_model=list[RegistrationWithUser],
)
async def list_accepted_registrations(
champ_id: uuid.UUID,
_user: User = Depends(get_approved_user),
db: AsyncSession = Depends(get_db),
):
regs = await crud_registration.list_for_championship(db, champ_id)
return [r for r in regs if r.status == "accepted"]

View File

@@ -0,0 +1,108 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
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,
RegistrationListItem,
RegistrationOut,
RegistrationUpdate,
RegistrationWithUser,
)
router = APIRouter()
@router.post("", response_model=RegistrationOut, status_code=status.HTTP_201_CREATED)
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, data.championship_id)
if not champ:
raise HTTPException(status_code=404, detail="Championship not found")
if champ.status != "open":
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=409, detail="Already registered for this championship")
return await crud_registration.create(db, current_user.id, data)
@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_for_user(db, current_user.id)
@router.get("/{reg_id}", response_model=RegistrationOut)
async def get_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")
return reg
@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, reg_id)
if not reg:
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

@@ -0,0 +1,46 @@
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
from app.models.user import User
from app.schemas.user import UserOut
router = APIRouter()
@router.get("", response_model=list[UserOut])
async def list_users(
_admin: User = Depends(get_admin),
db: AsyncSession = Depends(get_db),
skip: int = 0,
limit: int = 100,
):
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: uuid.UUID,
_admin: User = Depends(get_admin),
db: AsyncSession = Depends(get_db),
):
user = await crud_user.get_by_id(db, user_id)
if not 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: uuid.UUID,
_admin: User = Depends(get_admin),
db: AsyncSession = Depends(get_db),
):
user = await crud_user.get_by_id(db, user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
return await crud_user.set_status(db, user, "rejected")

View File

View File

@@ -0,0 +1,36 @@
from pydantic import BaseModel
from app.schemas.user import UserOut
class TokenPair(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
user: UserOut
class RefreshRequest(BaseModel):
refresh_token: str
class TokenRefreshed(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
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

@@ -0,0 +1,73 @@
import json
import uuid
from datetime import datetime
from pydantic import BaseModel, model_validator
class ChampionshipCreate(BaseModel):
title: str
description: str | None = None
location: str | None = None
event_date: datetime | None = None
registration_open_at: datetime | None = None
registration_close_at: datetime | None = None
form_url: str | None = None
entry_fee: float | None = None
video_max_duration: int | None = None
judges: list[dict] | None = None # [{name, bio, instagram}]
categories: list[str] | None = None
status: str = "draft"
image_url: str | None = None
class ChampionshipUpdate(BaseModel):
title: str | None = None
description: str | None = None
location: str | None = None
event_date: datetime | None = None
registration_open_at: datetime | None = None
registration_close_at: datetime | None = None
form_url: str | None = None
entry_fee: float | None = None
video_max_duration: int | None = None
judges: list[dict] | None = None
categories: list[str] | None = None
status: str | None = None
image_url: str | None = None
class ChampionshipOut(BaseModel):
model_config = {"from_attributes": True}
id: uuid.UUID
title: str
description: str | None
location: str | None
event_date: datetime | None
registration_open_at: datetime | None
registration_close_at: datetime | None
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_validator(mode="before")
@classmethod
def parse_json_fields(cls, v):
# judges and categories are stored as JSON strings in the DB
if hasattr(v, "__dict__"):
raw_j = getattr(v, "judges", None)
raw_c = getattr(v, "categories", None)
if isinstance(raw_j, str):
v.__dict__["judges"] = json.loads(raw_j)
if isinstance(raw_c, str):
v.__dict__["categories"] = json.loads(raw_c)
return v

View File

@@ -0,0 +1,19 @@
import uuid
from datetime import datetime
from pydantic import BaseModel
class ParticipantListOut(BaseModel):
model_config = {"from_attributes": True}
id: uuid.UUID
championship_id: uuid.UUID
is_published: bool
published_at: datetime | None
notes: str | None
created_at: datetime
class ParticipantListPublish(BaseModel):
notes: str | None = None

View File

@@ -0,0 +1,57 @@
import uuid
from datetime import datetime
from typing import Any
from pydantic import BaseModel, model_validator
from app.schemas.user import UserOut
class RegistrationCreate(BaseModel):
championship_id: uuid.UUID
category: str | None = None
level: str | None = None
notes: str | None = None
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
category: str | None
level: str | None
notes: str | None
status: str
video_url: str | None
submitted_at: datetime
decided_at: datetime | None
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

@@ -0,0 +1,52 @@
import uuid
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, EmailStr, field_validator
class UserRegister(BaseModel):
email: EmailStr
password: str
full_name: str
phone: str | None = None
# Role requested at registration: 'member' or 'organizer'
requested_role: Literal["member", "organizer"] = "member"
# Organizer-only fields
organization_name: str | None = None
instagram_handle: str | None = None
@field_validator("organization_name")
@classmethod
def org_name_required_for_organizer(cls, v, info):
if info.data.get("requested_role") == "organizer" and not v:
raise ValueError("Organization name is required for organizer registration")
return v
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
organization_name: str | None
instagram_handle: str | None
expo_push_token: str | None
created_at: datetime
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

View File

@@ -0,0 +1,81 @@
import hashlib
import uuid
from datetime import UTC, datetime, timedelta
import bcrypt
from jose import JWTError, jwt
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.models.user import RefreshToken
def hash_password(password: str) -> str:
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
def verify_password(plain: str, hashed: str) -> bool:
return bcrypt.checkpw(plain.encode(), hashed.encode())
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 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())
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),
)
)
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
async def revoke_refresh_token(db: AsyncSession, raw_token: str) -> None:
from sqlalchemy import select
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()

18
backend/requirements.txt Normal file
View File

@@ -0,0 +1,18 @@
fastapi==0.115.6
uvicorn[standard]==0.32.1
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
python-jose[cryptography]==3.3.0
bcrypt==4.2.1
pydantic[email]
python-multipart==0.0.20
httpx==0.28.1
apscheduler==3.10.4
slowapi==0.1.9
pytest==8.3.4
pytest-asyncio==0.24.0
pytest-httpx==0.35.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

View File

@@ -1,310 +0,0 @@
# Pole Dance Championships App — Technical Document
## 1. Project Overview
### Problem
Pole dance championships lack dedicated tools for organizers and participants. Organizers rely on Instagram posts to announce events, and registrations are managed via Google Forms — leading to:
- **No visibility** — Participants can't track their registration status in one place.
- **Scattered info** — Dates, rules, and results are buried across Instagram posts.
- **No access control** — Anyone can register; organizers need a way to accept or reject participants.
### Solution
A **members-only mobile app** for pole dance championship participants and organizers.
- New users register and wait for admin approval before accessing the app.
- Championship announcements are auto-imported from Instagram (Graph API).
- Members browse championships, submit registrations, and track their status.
- Organizers accept, reject, or waitlist participants and publish the final participant list.
- Push notifications alert members when results are published.
---
## 2. User Roles
| Role | Access |
|---|---|
| **Member** | Browse championships, register, track own registration status |
| **Organizer** | All of the above + manage registrations, publish participant lists |
| **Admin** | All of the above + approve/reject new member accounts |
New accounts start as `pending` and must be approved by an admin before they can use the app.
---
## 3. Tech Stack
| Layer | Technology |
|---|---|
| **Mobile** | React Native + TypeScript (Expo managed workflow) |
| **Navigation** | React Navigation v6 |
| **Server state** | TanStack React Query v5 |
| **Client state** | Zustand |
| **Backend** | FastAPI (Python, async) |
| **ORM** | SQLAlchemy 2.0 async |
| **Database** | SQLite (local dev) / PostgreSQL (production) |
| **Migrations** | Alembic |
| **Auth** | JWT — access tokens (15 min) + refresh tokens (7 days, rotation-based) |
| **Instagram sync** | Instagram Graph API, polled every 30 min via APScheduler |
| **Push notifications** | Expo Push API (routes to FCM + APNs) |
| **Token storage** | expo-secure-store (with in-memory cache for reliability) |
---
## 4. Architecture
```
┌─────────────────────┐ ┌──────────────────────────────┐
│ React Native App │◄──────►│ FastAPI Backend │
│ (Expo Go / APK) │ HTTPS │ /api/v1/... │
└─────────────────────┘ │ │
│ ┌──────────────────────┐ │
│ │ APScheduler │ │
│ │ - Instagram poll/30m│ │
│ │ - Token refresh/7d │ │
│ └──────────────────────┘ │
│ │
│ ┌──────────────────────┐ │
│ │ SQLite / PostgreSQL │ │
│ └──────────────────────┘ │
└──────────────────────────────┘
```
---
## 5. Data Model
### User
```
id UUID (PK)
email String (unique)
full_name String
phone String (nullable)
hashed_password String
role Enum: member | organizer | admin
status Enum: pending | approved | rejected
expo_push_token String (nullable)
created_at DateTime
updated_at DateTime
```
### Championship
```
id UUID (PK)
title String
description String (nullable)
location String (nullable)
event_date DateTime (nullable)
registration_open_at DateTime (nullable)
registration_close_at DateTime (nullable)
status Enum: draft | open | closed | completed
source Enum: manual | instagram
instagram_media_id String (nullable, unique)
image_url String (nullable)
raw_caption_text Text (nullable)
created_at DateTime
updated_at DateTime
```
### Registration
```
id UUID (PK)
championship_id UUID (FK → Championship)
user_id UUID (FK → User)
category String (nullable)
level String (nullable)
notes Text (nullable)
status Enum: submitted | accepted | rejected | waitlisted
submitted_at DateTime
decided_at DateTime (nullable)
```
### ParticipantList
```
id UUID (PK)
championship_id UUID (FK → Championship, unique)
published_by UUID (FK → User)
is_published Boolean
published_at DateTime (nullable)
notes Text (nullable)
```
### RefreshToken
```
id UUID (PK)
user_id UUID (FK → User)
token_hash String (SHA-256, unique)
expires_at DateTime
revoked Boolean
created_at DateTime
```
### NotificationLog
```
id UUID (PK)
user_id UUID (FK → User)
championship_id UUID (FK → Championship, nullable)
title String
body String
sent_at DateTime
status String
```
---
## 6. API Endpoints
### Auth
| Method | Path | Description |
|---|---|---|
| POST | `/api/v1/auth/register` | Register new account (status=pending) |
| POST | `/api/v1/auth/login` | Login, get access + refresh tokens |
| POST | `/api/v1/auth/refresh` | Refresh access token |
| POST | `/api/v1/auth/logout` | Revoke refresh token |
| GET | `/api/v1/auth/me` | Get current user |
### Championships
| Method | Path | Access |
|---|---|---|
| GET | `/api/v1/championships` | Approved members |
| GET | `/api/v1/championships/{id}` | Approved members |
| POST | `/api/v1/championships` | Organizer+ |
| PATCH | `/api/v1/championships/{id}` | Organizer+ |
| DELETE | `/api/v1/championships/{id}` | Admin |
### Registrations
| Method | Path | Access |
|---|---|---|
| POST | `/api/v1/championships/{id}/register` | Approved member |
| GET | `/api/v1/championships/{id}/registrations` | Organizer+ |
| PATCH | `/api/v1/registrations/{id}/status` | Organizer+ |
| GET | `/api/v1/registrations/mine` | Member (own only) |
| DELETE | `/api/v1/registrations/{id}` | Member (own, before decision) |
### Participant Lists
| Method | Path | Access |
|---|---|---|
| GET | `/api/v1/championships/{id}/participant-list` | Approved member |
| PUT | `/api/v1/championships/{id}/participant-list` | Organizer+ |
| POST | `/api/v1/championships/{id}/participant-list/publish` | Organizer+ |
| POST | `/api/v1/championships/{id}/participant-list/unpublish` | Organizer+ |
### Users (Admin)
| Method | Path | Description |
|---|---|---|
| GET | `/api/v1/users` | List all users |
| PATCH | `/api/v1/users/{id}/status` | Approve/reject member |
| PATCH | `/api/v1/users/me/push-token` | Register push token |
---
## 7. Mobile Screens
### Auth Flow
- **LoginScreen** — Email + password login
- **RegisterScreen** — Full name, phone, email, password
- **PendingApprovalScreen** — Shown after register until admin approves
### App (approved members)
- **ChampionshipListScreen** — All championships with status badges
- **ChampionshipDetailScreen** — Full info + registration button
- **RegistrationFormScreen** — Category, level, notes
- **ProfileScreen** — User info, logout
### Navigation
```
No user ──► AuthStack (Login / Register)
Pending ──► PendingApprovalScreen
Approved ──► AppStack (Championships / Profile)
```
---
## 8. Instagram Integration
### How It Works
1. Admin configures `INSTAGRAM_USER_ID` and `INSTAGRAM_ACCESS_TOKEN` in `.env`.
2. APScheduler polls the Instagram Graph API every 30 minutes.
3. New media posts are parsed: title (first line of caption), location (`Место:` / `Location:` prefix), dates (regex for RU + EN date formats).
4. New championships are created with `status=draft` for admin review.
5. Long-lived tokens are refreshed weekly automatically.
### Parsing Logic
- **Title** — First non-empty line of the caption.
- **Location** — Line starting with `Место:` or `Location:`.
- **Date** — Regex matches formats like `15 апреля 2026`, `April 15, 2026`, `15.04.2026`.
- **Deduplication** — Championships are matched by `instagram_media_id`.
---
## 9. Local Development Setup
### Prerequisites
- Python 3.11+
- Node.js 18+
- Expo Go app on phone
### Backend
```bat
cd backend
python -m venv .venv
.venv\Scripts\pip install -r requirements.txt
alembic upgrade head
python scripts/create_admin.py # creates admin@poledance.app / Admin1234
uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
```
Or use: `start-backend.bat`
### Mobile
```bat
cd mobile
npm install
npx expo start --lan --clear
```
Or use: `start-mobile.bat`
### Environment (backend/.env)
```
DATABASE_URL=sqlite+aiosqlite:///./poledance.db
SECRET_KEY=dev-secret-key-change-before-production-32x
INSTAGRAM_USER_ID=
INSTAGRAM_ACCESS_TOKEN=
INSTAGRAM_POLL_INTERVAL=1800
```
### Environment (mobile/.env)
```
EXPO_PUBLIC_API_URL=http://<your-local-ip>:8000/api/v1
```
### Test Accounts
| Email | Password | Role | Status |
|---|---|---|---|
| admin@poledance.app | Admin1234 | admin | approved |
| anna@example.com | Member1234 | member | approved |
| maria@example.com | Member1234 | member | approved |
| elena@example.com | Member1234 | member | pending |
---
## 10. Known Limitations (Local Dev)
- **SQLite** is used instead of PostgreSQL — change `DATABASE_URL` for production.
- **Push notifications** don't work in Expo Go SDK 53 — requires a development build.
- **Instagram polling** requires a valid Business/Creator account token.
- Windows Firewall must allow inbound TCP on port 8000 for phone to reach the backend.
---
## 11. Future Features
- **Web admin panel** — Browser-based dashboard for organizers to manage registrations and championships.
- **In-app notifications feed** — History of received push notifications.
- **Calendar sync** — Export championship dates to phone calendar.
- **Production deployment** — Docker + PostgreSQL + nginx + SSL.
- **OCR / LLM parsing** — Better extraction of championship details from Instagram images and captions.
- **Multi-organizer** — Support multiple Instagram accounts for different championships.

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

36
docker-compose.yml Normal file
View File

@@ -0,0 +1,36 @@
version: "3.9"
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: pole
POSTGRES_PASSWORD: pole
POSTGRES_DB: poledance
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U pole -d poledance"]
interval: 5s
timeout: 5s
retries: 10
api:
build: ./backend
ports:
- "8000:8000"
environment:
DATABASE_URL: postgresql+asyncpg://pole:pole@db:5432/poledance
env_file:
- .env
depends_on:
db:
condition: service_healthy
volumes:
- ./backend:/app
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
volumes:
pgdata:

41
mobile/.gitignore vendored Normal file
View File

@@ -0,0 +1,41 @@
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
# dependencies
node_modules/
# Expo
.expo/
dist/
web-build/
expo-env.d.ts
# Native
.kotlin/
*.orig.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
# Metro
.metro-health-check*
# debug
npm-debug.*
yarn-debug.*
yarn-error.*
# macOS
.DS_Store
*.pem
# local env files
.env*.local
# typescript
*.tsbuildinfo
# generated native folders
/ios
/android

19
mobile/App.tsx Normal file
View File

@@ -0,0 +1,19 @@
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 initialize = useAuthStore((s) => s.initialize);
useEffect(() => {
initialize();
}, []);
return (
<>
<StatusBar style="dark" />
<RootNavigator />
</>
);
}

28
mobile/app.json Normal file
View File

@@ -0,0 +1,28 @@
{
"expo": {
"name": "Pole Championships",
"slug": "pole-championships",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "light",
"newArchEnabled": false,
"splash": {
"image": "./assets/splash-icon.png",
"resizeMode": "contain",
"backgroundColor": "#ffffff"
},
"ios": {
"supportsTablet": true
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#ffffff"
}
},
"web": {
"favicon": "./assets/favicon.png"
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
mobile/assets/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

BIN
mobile/assets/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

8
mobile/index.ts Normal file
View File

@@ -0,0 +1,8 @@
import { registerRootComponent } from 'expo';
import App from './App';
// registerRootComponent calls AppRegistry.registerComponent('main', () => App);
// It also ensures that whether you load the app in Expo Go or in a native build,
// the environment is set up appropriately
registerRootComponent(App);

8705
mobile/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

34
mobile/package.json Normal file
View File

@@ -0,0 +1,34 @@
{
"name": "mobile",
"version": "1.0.0",
"main": "index.ts",
"scripts": {
"start": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web"
},
"dependencies": {
"@hookform/resolvers": "^5.2.2",
"@react-navigation/bottom-tabs": "^7.14.0",
"@react-navigation/native": "^7.1.28",
"@react-navigation/native-stack": "^7.13.0",
"@tanstack/react-query": "^5.90.21",
"axios": "^1.13.5",
"expo": "~54.0.33",
"expo-secure-store": "^15.0.8",
"expo-status-bar": "~3.0.9",
"react": "19.1.0",
"react-hook-form": "^7.71.2",
"react-native": "0.81.5",
"react-native-safe-area-context": "^5.7.0",
"react-native-screens": "4.16.0",
"zod": "^4.3.6",
"zustand": "^5.0.11"
},
"devDependencies": {
"@types/react": "~19.1.0",
"typescript": "~5.9.2"
},
"private": true
}

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

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

73
mobile/src/api/client.ts Normal file
View File

@@ -0,0 +1,73 @@
import axios from 'axios';
import { tokenStorage } from '../utils/tokenStorage';
// 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: BASE_URL,
timeout: 10000,
});
// 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;
});
// Refresh token on 401
let isRefreshing = false;
let queue: Array<{ resolve: (token: string) => void; reject: (err: unknown) => void }> = [];
function processQueue(error: unknown, token: string | null = null) {
queue.forEach((p) => (error ? p.reject(error) : p.resolve(token!)));
queue = [];
}
apiClient.interceptors.response.use(
(res) => res,
async (error) => {
const original = error.config;
if (error.response?.status === 401 && !original._retry) {
if (isRefreshing) {
return new Promise((resolve, reject) => {
queue.push({
resolve: (token) => {
original.headers.Authorization = `Bearer ${token}`;
resolve(apiClient(original));
},
reject,
});
});
}
original._retry = true;
isRefreshing = true;
try {
const refreshToken = tokenStorage.getRefreshTokenSync();
if (!refreshToken) throw new Error('No refresh token');
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);
original.headers.Authorization = `Bearer ${data.access_token}`;
return apiClient(original);
} catch (err) {
processQueue(err, null);
await tokenStorage.clearTokens();
return Promise.reject(err);
} finally {
isRefreshing = false;
}
}
return Promise.reject(error);
}
);

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

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

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

@@ -0,0 +1,128 @@
import { useRef, useState } from 'react';
import {
View,
Text,
TextInput,
TouchableOpacity,
StyleSheet,
KeyboardAvoidingView,
Platform,
Alert,
ActivityIndicator,
} from 'react-native';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import { Ionicons } from '@expo/vector-icons';
import { useAuthStore } from '../../store/auth.store';
import type { AuthStackParams } from '../../navigation';
type Props = NativeStackScreenProps<AuthStackParams, 'Login'>;
export default function LoginScreen({ navigation }: Props) {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const passwordRef = useRef<TextInput>(null);
const { login, isLoading } = useAuthStore();
const handleLogin = async () => {
if (!email.trim() || !password.trim()) {
Alert.alert('Error', 'Please enter email and password');
return;
}
try {
await login(email.trim().toLowerCase(), password);
} catch (err: any) {
const msg = err?.response?.data?.detail ?? 'Login failed. Check your credentials.';
Alert.alert('Login failed', msg);
}
};
return (
<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"
keyboardType="email-address"
autoCapitalize="none"
autoComplete="email"
returnKeyType="next"
onSubmitEditing={() => passwordRef.current?.focus()}
value={email}
onChangeText={setEmail}
/>
<View style={styles.passwordRow}>
<TextInput
ref={passwordRef}
style={styles.passwordInput}
placeholder="Password"
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.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't have an account? Register</Text>
</TouchableOpacity>
</View>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
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: 10,
padding: 14,
marginBottom: 14,
fontSize: 16,
backgroundColor: '#fafafa',
},
passwordRow: {
flexDirection: 'row',
alignItems: 'center',
borderWidth: 1,
borderColor: '#ddd',
borderRadius: 10,
backgroundColor: '#fafafa',
marginBottom: 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

@@ -0,0 +1,36 @@
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import type { AuthStackParams } from '../../navigation';
type Props = NativeStackScreenProps<AuthStackParams, 'PendingApproval'>;
export default function PendingApprovalScreen({ navigation }: Props) {
return (
<View style={styles.container}>
<Text style={styles.icon}></Text>
<Text style={styles.title}>Application Submitted</Text>
<Text style={styles.body}>
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.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' },
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,
},
btnText: { color: '#fff', fontSize: 16, fontWeight: '600' },
});

View File

@@ -0,0 +1,297 @@
import { useState } from 'react';
import {
View,
Text,
TextInput,
TouchableOpacity,
StyleSheet,
KeyboardAvoidingView,
Platform,
Alert,
ActivityIndicator,
ScrollView,
} from 'react-native';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import { Ionicons } from '@expo/vector-icons';
import { useAuthStore } from '../../store/auth.store';
import type { AuthStackParams } from '../../navigation';
type Props = NativeStackScreenProps<AuthStackParams, 'Register'>;
type Role = 'member' | 'organizer';
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 [password, setPassword] = useState('');
const [orgName, setOrgName] = useState('');
const [instagram, setInstagram] = useState('');
const [showPassword, setShowPassword] = useState(false);
const { register, isLoading } = useAuthStore();
const handleRegister = async () => {
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;
}
try {
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 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={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={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="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="+375 29 000 0000 (optional)"
keyboardType="phone-pad"
returnKeyType="next"
value={phone}
onChangeText={setPhone}
/>
<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? Sign In</Text>
</TouchableOpacity>
</ScrollView>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
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: '#e5e7eb',
borderRadius: 10,
padding: 13,
marginBottom: 14,
fontSize: 15,
backgroundColor: '#fafafa',
},
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,
},
btnText: { color: '#fff', fontSize: 16, fontWeight: '600' },
link: { textAlign: 'center', color: '#7c3aed', fontSize: 14 },
});

View File

@@ -0,0 +1,246 @@
import { useEffect, useState } from 'react';
import {
View,
Text,
ScrollView,
StyleSheet,
TouchableOpacity,
Alert,
ActivityIndicator,
Image,
Linking,
} from 'react-native';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import { championshipsApi } from '../../api/championships';
import type { Championship, Registration } from '../../types';
import type { AppStackParams } from '../../navigation';
type Props = NativeStackScreenProps<AppStackParams, 'ChampionshipDetail'>;
export default function ChampionshipDetailScreen({ route }: Props) {
const { id } = route.params;
const [champ, setChamp] = useState<Championship | null>(null);
const [myReg, setMyReg] = useState<Registration | null>(null);
const [loading, setLoading] = useState(true);
const [registering, setRegistering] = useState(false);
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="#7c3aed" />
</View>
);
}
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} contentContainerStyle={styles.content}>
{champ.image_url && (
<Image source={{ uri: champ.image_url }} style={styles.image} resizeMode="cover" />
)}
<Text style={styles.title}>{champ.title}</Text>
{champ.location && <Text style={styles.meta}>📍 {champ.location}</Text>}
{champ.event_date && (
<Text style={styles.meta}>
📅 {new Date(champ.event_date).toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })}
</Text>
)}
{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 },
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,
alignItems: 'center',
},
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

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

@@ -0,0 +1,149 @@
import { View, Text, StyleSheet, TouchableOpacity, Alert, ScrollView } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useAuthStore } from '../../store/auth.store';
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' },
};
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: 'Sign Out', style: 'destructive', onPress: logout },
]);
};
if (!user) return null;
const roleConfig = ROLE_CONFIG[user.role] ?? { color: '#6b7280', bg: '#f3f4f6', label: user.role };
return (
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
{/* Avatar + Name */}
<View style={styles.header}>
<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>
{/* 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>
{/* 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: '#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,
},
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

@@ -0,0 +1,94 @@
import { create } from 'zustand';
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;
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: 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 });
},
}));

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

@@ -0,0 +1,49 @@
import * as SecureStore from 'expo-secure-store';
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 = {
async saveTokens(access: string, refresh: string): Promise<void> {
_accessToken = access;
_refreshToken = refresh;
await SecureStore.setItemAsync(ACCESS_KEY, access);
await SecureStore.setItemAsync(REFRESH_KEY, refresh);
},
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);
},
};

6
mobile/tsconfig.json Normal file
View File

@@ -0,0 +1,6 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true
}
}

4
start-backend.bat Normal file
View File

@@ -0,0 +1,4 @@
@echo off
echo Starting Pole Championships backend...
cd /d "%~dp0backend"
.venv\Scripts\python -m uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload