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:
19
.env.example
Normal file
19
.env.example
Normal 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
24
.gitignore
vendored
Normal 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
159
MANUAL_TESTS.md
Normal file
@@ -0,0 +1,159 @@
|
||||
# Manual Test Tracker — Pole Championships App
|
||||
|
||||
Mark each item with ✅ PASS / ❌ FAIL / ⏭ SKIP
|
||||
|
||||
---
|
||||
|
||||
## Setup
|
||||
|
||||
| # | Action | Expected | Result |
|
||||
|---|--------|----------|--------|
|
||||
| S1 | Backend running at `http://localhost:8000` | `{"status":"ok"}` at `/internal/health` | |
|
||||
| S2 | Expo app loaded on phone | Login screen visible | |
|
||||
| S3 | Seed data present | Run `backend/.venv/Scripts/python seed.py` — no errors | |
|
||||
|
||||
---
|
||||
|
||||
## 1. Registration (new account)
|
||||
|
||||
| # | Action | Expected | Result | Notes |
|
||||
|---|--------|----------|--------|-------|
|
||||
| 1.1 | Tap "Don't have an account? Register" | Register screen opens | | |
|
||||
| 1.2 | Submit form with all fields empty | Error: "fill in all required fields" | | |
|
||||
| 1.3 | Fill: Name=`Test User`, Email=`test@test.com`, Password=`Test1234` → Submit | "Application Submitted" screen shown | | |
|
||||
| 1.4 | Tap "Go to Sign In" | Login screen shown | | |
|
||||
| 1.5 | Try to register same email again | Error: "Email already registered" | | |
|
||||
|
||||
---
|
||||
|
||||
## 2. Login — Pending account (cannot access app)
|
||||
|
||||
| # | Action | Expected | Result | Notes |
|
||||
|---|--------|----------|--------|-------|
|
||||
| 2.1 | Login with `pending@pole.dev` / `Pending1234` | Login succeeds but stays on auth screen (status=pending) | | |
|
||||
| 2.2 | Login with wrong password `pending@pole.dev` / `wrongpass` | Error: "Invalid credentials" | | |
|
||||
| 2.3 | Login with non-existent email | Error: "Invalid credentials" | | |
|
||||
|
||||
---
|
||||
|
||||
## 3. Login — Approved member
|
||||
|
||||
| # | Action | Expected | Result | Notes |
|
||||
|---|--------|----------|--------|-------|
|
||||
| 3.1 | Login with `member@pole.dev` / `Member1234` | App opens, Championships tab visible | | |
|
||||
| 3.2 | Championships list loads | 2 championships shown (Spring Open 2026, Summer Championship 2026) | | |
|
||||
| 3.3 | Spring Open 2026 shows green OPEN badge | Green badge visible | | |
|
||||
| 3.4 | Summer Championship 2026 shows grey DRAFT badge | Grey badge visible | | |
|
||||
| 3.5 | Spring Open shows entry fee 50 BYN | "💰 Entry fee: 50 BYN" visible | | |
|
||||
| 3.6 | Spring Open shows location | "📍 Cultural Center, Minsk" visible | | |
|
||||
| 3.7 | Spring Open shows image | Banner image loads | | |
|
||||
|
||||
---
|
||||
|
||||
## 4. Championship Detail
|
||||
|
||||
| # | Action | Expected | Result | Notes |
|
||||
|---|--------|----------|--------|-------|
|
||||
| 4.1 | Tap "Spring Open 2026" | Detail screen opens | | |
|
||||
| 4.2 | Detail shows title, location, date | All fields visible | | |
|
||||
| 4.3 | Categories shown: Novice, Amateur, Professional | 3 purple tags visible | | |
|
||||
| 4.4 | Judges section shows 2 judges | Oksana Ivanova + Marta Sokolova | | |
|
||||
| 4.5 | "Open Registration Form ↗" button visible | Tapping opens link in browser | | |
|
||||
| 4.6 | "Register for Championship" button visible | Button at bottom | | |
|
||||
| 4.7 | Tap "Summer Championship 2026" | Detail opens, no Register button (status=draft) | | |
|
||||
|
||||
---
|
||||
|
||||
## 5. Registration Flow
|
||||
|
||||
| # | Action | Expected | Result | Notes |
|
||||
|---|--------|----------|--------|-------|
|
||||
| 5.1 | On Spring Open detail, tap "Register for Championship" | Confirmation alert appears | | |
|
||||
| 5.2 | Tap Cancel in alert | Alert dismisses, no registration created | | |
|
||||
| 5.3 | Tap Register → confirm | Success alert shown | | |
|
||||
| 5.4 | After registering: progress steps appear | "Application submitted" step shown as green | | |
|
||||
| 5.5 | "Register for Championship" button disappears | No duplicate registration possible | | |
|
||||
| 5.6 | Go back, tap Spring Open again | Progress steps still visible | | |
|
||||
| 5.7 | Try registering for same championship again (via API or re-tap) | Error: "Already registered" | | |
|
||||
|
||||
---
|
||||
|
||||
## 6. My Registrations Tab
|
||||
|
||||
| # | Action | Expected | Result | Notes |
|
||||
|---|--------|----------|--------|-------|
|
||||
| 6.1 | Tap "My Registrations" tab | Screen opens | | |
|
||||
| 6.2 | Registration for Spring Open visible | Shows reg ID + SUBMITTED badge (yellow) | | |
|
||||
| 6.3 | Pull to refresh | List refreshes | | |
|
||||
| 6.4 | Login as `pending@pole.dev` | My Registrations shows empty | | |
|
||||
|
||||
---
|
||||
|
||||
## 7. Profile Tab
|
||||
|
||||
| # | Action | Expected | Result | Notes |
|
||||
|---|--------|----------|--------|-------|
|
||||
| 7.1 | Tap Profile tab | Profile screen opens | | |
|
||||
| 7.2 | Shows full name, email, role=member, status=approved | All correct | | |
|
||||
| 7.3 | Avatar shows first letter of name | "A" for Anna Petrova | | |
|
||||
| 7.4 | Tap "Sign Out" | Confirmation alert appears | | |
|
||||
| 7.5 | Confirm sign out | Redirected to Login screen | | |
|
||||
| 7.6 | After logout, reopen app | Login screen shown (not auto-logged in) | | |
|
||||
|
||||
---
|
||||
|
||||
## 8. Token Persistence (session restore)
|
||||
|
||||
| # | Action | Expected | Result | Notes |
|
||||
|---|--------|----------|--------|-------|
|
||||
| 8.1 | Login as member, close Expo Go completely | — | | |
|
||||
| 8.2 | Reopen Expo Go, scan QR | App opens directly on Championships (no re-login) | | |
|
||||
| 8.3 | Restart backend server while logged in | App still works after backend restarts | | |
|
||||
|
||||
---
|
||||
|
||||
## 9. Admin user (via API — no admin UI yet)
|
||||
|
||||
| # | Action | Expected | Result | Notes |
|
||||
|---|--------|----------|--------|-------|
|
||||
| 9.1 | Login as `admin@pole.dev` / `Admin1234` | App opens (role=admin, status=approved) | | |
|
||||
| 9.2 | Championships visible | Same list as member | | |
|
||||
| 9.3 | Approve pending user via API:<br>`curl -X PATCH http://localhost:8000/api/v1/users/<user_id>/approve -H "Authorization: Bearer <token>"` | Returns `status: approved` | | |
|
||||
|
||||
---
|
||||
|
||||
## 10. Edge Cases & Error Handling
|
||||
|
||||
| # | Action | Expected | Result | Notes |
|
||||
|---|--------|----------|--------|-------|
|
||||
| 10.1 | Stop backend server, open app | Championships shows "Failed to load championships" | | |
|
||||
| 10.2 | Stop backend, try login | Login shows error message (not crash) | | |
|
||||
| 10.3 | Pull to refresh on Championships | Spinner appears, list reloads | | |
|
||||
| 10.4 | Very slow network (throttle in phone settings) | Loading spinners show while waiting | | |
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Section | Total | Pass | Fail | Skip |
|
||||
|---------|-------|------|------|------|
|
||||
| 1. Registration | 5 | | | |
|
||||
| 2. Login — Pending | 3 | | | |
|
||||
| 3. Login — Member | 7 | | | |
|
||||
| 4. Championship Detail | 7 | | | |
|
||||
| 5. Registration Flow | 7 | | | |
|
||||
| 6. My Registrations | 4 | | | |
|
||||
| 7. Profile | 6 | | | |
|
||||
| 8. Session Restore | 3 | | | |
|
||||
| 9. Admin | 3 | | | |
|
||||
| 10. Edge Cases | 4 | | | |
|
||||
| **Total** | **49** | | | |
|
||||
|
||||
---
|
||||
|
||||
## Known Limitations (not bugs)
|
||||
|
||||
- No admin UI — user approval done via API only
|
||||
- Organizer role has no extra UI in the mobile app yet
|
||||
- Push notifications not implemented
|
||||
- Instagram sync not implemented (requires credentials)
|
||||
10
backend/Dockerfile
Normal file
10
backend/Dockerfile
Normal 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
38
backend/alembic.ini
Normal 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
59
backend/alembic/env.py
Normal 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()
|
||||
26
backend/alembic/script.py.mako
Normal file
26
backend/alembic/script.py.mako
Normal 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"}
|
||||
@@ -0,0 +1,32 @@
|
||||
"""add organizer fields to users
|
||||
|
||||
Revision ID: 43d947192af5
|
||||
Revises: 657f22c8aa55
|
||||
Create Date: 2026-02-25 21:18:04.707870
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '43d947192af5'
|
||||
down_revision: Union[str, None] = '657f22c8aa55'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('users', sa.Column('organization_name', sa.String(length=255), nullable=True))
|
||||
op.add_column('users', sa.Column('instagram_handle', sa.String(length=100), nullable=True))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column('users', 'instagram_handle')
|
||||
op.drop_column('users', 'organization_name')
|
||||
# ### end Alembic commands ###
|
||||
125
backend/alembic/versions/657f22c8aa55_initial_schema.py
Normal file
125
backend/alembic/versions/657f22c8aa55_initial_schema.py
Normal file
@@ -0,0 +1,125 @@
|
||||
"""initial schema
|
||||
|
||||
Revision ID: 657f22c8aa55
|
||||
Revises:
|
||||
Create Date: 2026-02-25 00:23:12.480733
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '657f22c8aa55'
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('championships',
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('title', sa.String(length=255), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('location', sa.String(length=500), nullable=True),
|
||||
sa.Column('event_date', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('registration_open_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('registration_close_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('form_url', sa.String(length=2048), nullable=True),
|
||||
sa.Column('entry_fee', sa.Float(), nullable=True),
|
||||
sa.Column('video_max_duration', sa.Integer(), nullable=True),
|
||||
sa.Column('judges', sa.Text(), nullable=True),
|
||||
sa.Column('categories', sa.Text(), nullable=True),
|
||||
sa.Column('status', sa.String(length=20), nullable=False),
|
||||
sa.Column('source', sa.String(length=20), nullable=False),
|
||||
sa.Column('instagram_media_id', sa.String(length=255), nullable=True),
|
||||
sa.Column('image_url', sa.String(length=2048), nullable=True),
|
||||
sa.Column('raw_caption_text', sa.Text(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('instagram_media_id')
|
||||
)
|
||||
op.create_table('users',
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('email', sa.String(length=255), nullable=False),
|
||||
sa.Column('hashed_password', sa.String(length=255), nullable=False),
|
||||
sa.Column('full_name', sa.String(length=255), nullable=False),
|
||||
sa.Column('phone', sa.String(length=50), nullable=True),
|
||||
sa.Column('role', sa.String(length=20), nullable=False),
|
||||
sa.Column('status', sa.String(length=20), nullable=False),
|
||||
sa.Column('expo_push_token', sa.String(length=255), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
|
||||
op.create_table('participant_lists',
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('championship_id', sa.Uuid(), nullable=False),
|
||||
sa.Column('published_by', sa.Uuid(), nullable=True),
|
||||
sa.Column('is_published', sa.Boolean(), nullable=False),
|
||||
sa.Column('published_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('notes', sa.Text(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['championship_id'], ['championships.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['published_by'], ['users.id'], ondelete='SET NULL'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('championship_id')
|
||||
)
|
||||
op.create_table('refresh_tokens',
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('user_id', sa.Uuid(), nullable=False),
|
||||
sa.Column('token_hash', sa.String(length=64), nullable=False),
|
||||
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('revoked', sa.Boolean(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_refresh_tokens_token_hash'), 'refresh_tokens', ['token_hash'], unique=False)
|
||||
op.create_table('registrations',
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('championship_id', sa.Uuid(), nullable=False),
|
||||
sa.Column('user_id', sa.Uuid(), nullable=False),
|
||||
sa.Column('category', sa.String(length=255), nullable=True),
|
||||
sa.Column('level', sa.String(length=100), nullable=True),
|
||||
sa.Column('notes', sa.Text(), nullable=True),
|
||||
sa.Column('status', sa.String(length=30), nullable=False),
|
||||
sa.Column('video_url', sa.String(length=2048), nullable=True),
|
||||
sa.Column('submitted_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||
sa.Column('decided_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(['championship_id'], ['championships.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('championship_id', 'user_id', name='uq_registration_champ_user')
|
||||
)
|
||||
op.create_table('notification_log',
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('user_id', sa.Uuid(), nullable=False),
|
||||
sa.Column('registration_id', sa.Uuid(), nullable=True),
|
||||
sa.Column('type', sa.String(length=50), nullable=False),
|
||||
sa.Column('title', sa.String(length=255), nullable=False),
|
||||
sa.Column('body', sa.Text(), nullable=False),
|
||||
sa.Column('sent_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||
sa.Column('delivery_status', sa.String(length=20), nullable=False),
|
||||
sa.ForeignKeyConstraint(['registration_id'], ['registrations.id'], ondelete='SET NULL'),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_table('notification_log')
|
||||
op.drop_table('registrations')
|
||||
op.drop_index(op.f('ix_refresh_tokens_token_hash'), table_name='refresh_tokens')
|
||||
op.drop_table('refresh_tokens')
|
||||
op.drop_table('participant_lists')
|
||||
op.drop_index(op.f('ix_users_email'), table_name='users')
|
||||
op.drop_table('users')
|
||||
op.drop_table('championships')
|
||||
# ### end Alembic commands ###
|
||||
0
backend/app/__init__.py
Normal file
0
backend/app/__init__.py
Normal file
28
backend/app/config.py
Normal file
28
backend/app/config.py
Normal 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()
|
||||
3
backend/app/crud/__init__.py
Normal file
3
backend/app/crud/__init__.py
Normal 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"]
|
||||
63
backend/app/crud/crud_championship.py
Normal file
63
backend/app/crud/crud_championship.py
Normal 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()
|
||||
34
backend/app/crud/crud_participant.py
Normal file
34
backend/app/crud/crud_participant.py
Normal 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
|
||||
86
backend/app/crud/crud_registration.py
Normal file
86
backend/app/crud/crud_registration.py
Normal 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()
|
||||
57
backend/app/crud/crud_user.py
Normal file
57
backend/app/crud/crud_user.py
Normal 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
17
backend/app/database.py
Normal 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
|
||||
42
backend/app/dependencies.py
Normal file
42
backend/app/dependencies.py
Normal 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
41
backend/app/main.py
Normal 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"}
|
||||
14
backend/app/models/__init__.py
Normal file
14
backend/app/models/__init__.py
Normal 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",
|
||||
]
|
||||
42
backend/app/models/championship.py
Normal file
42
backend/app/models/championship.py
Normal 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]
|
||||
27
backend/app/models/notification.py
Normal file
27
backend/app/models/notification.py
Normal file
@@ -0,0 +1,27 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, String, Text, Uuid, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class NotificationLog(Base):
|
||||
__tablename__ = "notification_log"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
Uuid(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
registration_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
Uuid(as_uuid=True), ForeignKey("registrations.id", ondelete="SET NULL")
|
||||
)
|
||||
type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
body: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
sent_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
delivery_status: Mapped[str] = mapped_column(String(20), default="pending")
|
||||
|
||||
user: Mapped["User"] = relationship(back_populates="notification_logs") # type: ignore[name-defined]
|
||||
registration: Mapped["Registration | None"] = relationship(back_populates="notification_logs") # type: ignore[name-defined]
|
||||
25
backend/app/models/participant.py
Normal file
25
backend/app/models/participant.py
Normal file
@@ -0,0 +1,25 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Text, Uuid, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class ParticipantList(Base):
|
||||
__tablename__ = "participant_lists"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
championship_id: Mapped[uuid.UUID] = mapped_column(
|
||||
Uuid(as_uuid=True), ForeignKey("championships.id", ondelete="CASCADE"), nullable=False, unique=True
|
||||
)
|
||||
published_by: Mapped[uuid.UUID | None] = mapped_column(
|
||||
Uuid(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL")
|
||||
)
|
||||
is_published: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
notes: Mapped[str | None] = mapped_column(Text)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
championship: Mapped["Championship"] = relationship(back_populates="participant_list") # type: ignore[name-defined]
|
||||
36
backend/app/models/registration.py
Normal file
36
backend/app/models/registration.py
Normal 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]
|
||||
44
backend/app/models/user.py
Normal file
44
backend/app/models/user.py
Normal 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")
|
||||
3
backend/app/routers/__init__.py
Normal file
3
backend/app/routers/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from app.routers import auth, championships, registrations, participant_lists, users
|
||||
|
||||
__all__ = ["auth", "championships", "registrations", "participant_lists", "users"]
|
||||
79
backend/app/routers/auth.py
Normal file
79
backend/app/routers/auth.py
Normal 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)
|
||||
69
backend/app/routers/championships.py
Normal file
69
backend/app/routers/championships.py
Normal 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)
|
||||
55
backend/app/routers/participant_lists.py
Normal file
55
backend/app/routers/participant_lists.py
Normal 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"]
|
||||
108
backend/app/routers/registrations.py
Normal file
108
backend/app/routers/registrations.py
Normal 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)
|
||||
46
backend/app/routers/users.py
Normal file
46
backend/app/routers/users.py
Normal 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")
|
||||
0
backend/app/schemas/__init__.py
Normal file
0
backend/app/schemas/__init__.py
Normal file
36
backend/app/schemas/auth.py
Normal file
36
backend/app/schemas/auth.py
Normal 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"
|
||||
73
backend/app/schemas/championship.py
Normal file
73
backend/app/schemas/championship.py
Normal 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
|
||||
19
backend/app/schemas/participant.py
Normal file
19
backend/app/schemas/participant.py
Normal 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
|
||||
57
backend/app/schemas/registration.py
Normal file
57
backend/app/schemas/registration.py
Normal 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
|
||||
52
backend/app/schemas/user.py
Normal file
52
backend/app/schemas/user.py
Normal 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
|
||||
0
backend/app/services/__init__.py
Normal file
0
backend/app/services/__init__.py
Normal file
81
backend/app/services/auth_service.py
Normal file
81
backend/app/services/auth_service.py
Normal 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
18
backend/requirements.txt
Normal 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
134
backend/seed.py
Normal 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())
|
||||
0
backend/tests/__init__.py
Normal file
0
backend/tests/__init__.py
Normal 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.
|
||||
176
dancechamp-claude-code/CLAUDE.md
Normal file
176
dancechamp-claude-code/CLAUDE.md
Normal file
@@ -0,0 +1,176 @@
|
||||
# CLAUDE.md — DanceChamp
|
||||
|
||||
## What is this project?
|
||||
|
||||
DanceChamp is a mobile platform for **pole dance championships**. Three apps, one database:
|
||||
|
||||
- **Member App** (React Native / Expo) — Dancers discover championships, register, track their 10-step progress
|
||||
- **Org App** (React Native / Expo) — Championship organizers create events, manage members, review videos, confirm payments
|
||||
- **Admin Panel** (React + Vite, web) — Platform admin approves orgs, reviews championships from unverified orgs, manages users
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
/
|
||||
├── CLAUDE.md ← You are here
|
||||
├── apps/
|
||||
│ ├── mobile/ ← Expo app (Member + Org views, switched by role)
|
||||
│ │ ├── src/
|
||||
│ │ │ ├── screens/
|
||||
│ │ │ │ ├── member/ ← Home, MyChamps, Search, Profile, ChampDetail, Progress
|
||||
│ │ │ │ ├── org/ ← Dashboard, ChampDetail (tabbed), MemberDetail, Settings
|
||||
│ │ │ │ └── auth/ ← SignIn, SignUp, Onboarding
|
||||
│ │ │ ├── components/ ← Shared UI components
|
||||
│ │ │ ├── navigation/ ← Tab + Stack navigators
|
||||
│ │ │ ├── store/ ← Zustand stores
|
||||
│ │ │ ├── lib/ ← Supabase client, helpers
|
||||
│ │ │ └── theme/ ← Colors, fonts, spacing
|
||||
│ │ └── app.json
|
||||
│ └── admin/ ← Vite React app
|
||||
│ └── src/
|
||||
│ ├── pages/ ← Dashboard, Orgs, Champs, Users
|
||||
│ ├── components/
|
||||
│ └── lib/
|
||||
├── packages/
|
||||
│ └── shared/ ← Shared types, constants, validation
|
||||
│ ├── types.ts ← TypeScript interfaces (User, Championship, etc.)
|
||||
│ └── constants.ts ← Status enums, role enums
|
||||
├── supabase/
|
||||
│ ├── migrations/ ← SQL migration files
|
||||
│ └── seed.sql ← Demo data
|
||||
└── docs/
|
||||
├── SPEC.md ← Full technical specification
|
||||
├── PLAN.md ← Phase-by-phase dev plan with checkboxes
|
||||
├── DATABASE.md ← Complete database schema + RLS policies
|
||||
├── DESIGN-SYSTEM.md ← Colors, fonts, components, patterns
|
||||
└── SCREENS.md ← Screen-by-screen reference for all 3 apps
|
||||
```
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Layer | Choice | Notes |
|
||||
|---|---|---|
|
||||
| Mobile | React Native (Expo) | `npx create-expo-app` with TypeScript |
|
||||
| Admin | React + Vite | Separate web app |
|
||||
| Language | TypeScript | Everywhere |
|
||||
| Navigation | React Navigation | Bottom tabs + stack |
|
||||
| State | Zustand | Lightweight stores |
|
||||
| Backend | Supabase | Auth, Postgres DB, Storage, Realtime, Edge Functions |
|
||||
| Push | Expo Notifications | Via Supabase Edge Function triggers |
|
||||
|
||||
## Key Architecture Decisions
|
||||
|
||||
### 1. One mobile app, two views
|
||||
Member and Org use the **same Expo app**. After login, the app checks `user.role` and shows the appropriate navigation:
|
||||
- `role === "member"` → Member tabs (Home, My Champs, Search, Profile)
|
||||
- `role === "organization"` → Org tabs (Dashboard, Settings)
|
||||
|
||||
### 2. Everything is scoped per-championship
|
||||
Members, results, categories, rules, fees, judges — all belong to a specific championship. There is no "global members list" for an org. Each championship is self-contained.
|
||||
|
||||
### 3. Configurable tabs (Org)
|
||||
Orgs don't fill a giant wizard. They quick-create a championship (name + date + location), then configure each section (Categories, Fees, Rules, Judges) at their own pace. Each section has a "✓ Mark as Done" button. Championship can only go live when all sections are done.
|
||||
|
||||
### 4. Approval flow
|
||||
- **Verified orgs** → "Go Live" sets status to `live` immediately (auto-approved)
|
||||
- **Unverified orgs** → "Go Live" sets status to `pending_approval` → admin must approve
|
||||
|
||||
### 5. Registration dates (not deadline)
|
||||
Championships have: `event_date`, `reg_start`, `reg_end`. Registration close date must be before event date. No single "deadline" field.
|
||||
|
||||
### 6. Judges = People, Scoring = Rules
|
||||
The "Judges" tab shows judge profiles (name, instagram, bio). Scoring criteria and penalties live in the "Rules" tab.
|
||||
|
||||
## Conventions
|
||||
|
||||
### Code Style
|
||||
- Functional components only, no class components
|
||||
- Use hooks: `useState`, `useEffect`, custom hooks for data fetching
|
||||
- Zustand for global state (auth, current user, championships cache)
|
||||
- Local state for UI-only state (modals, form inputs, filters)
|
||||
- TypeScript strict mode
|
||||
|
||||
### Naming
|
||||
- Files: `kebab-case.ts` / `kebab-case.tsx`
|
||||
- Components: `PascalCase`
|
||||
- Hooks: `useCamelCase`
|
||||
- Zustand stores: `use[Name]Store`
|
||||
- DB tables: `snake_case`
|
||||
- DB columns: `snake_case`
|
||||
|
||||
### Supabase Patterns
|
||||
```typescript
|
||||
// Client init
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY)
|
||||
|
||||
// Fetching
|
||||
const { data, error } = await supabase
|
||||
.from('championships')
|
||||
.select('*, disciplines(*), fees(*)')
|
||||
.eq('status', 'live')
|
||||
|
||||
// Realtime subscription
|
||||
supabase.channel('registrations')
|
||||
.on('postgres_changes', { event: '*', schema: 'public', table: 'registrations' }, handler)
|
||||
.subscribe()
|
||||
```
|
||||
|
||||
### Navigation Pattern
|
||||
```typescript
|
||||
// Member
|
||||
const MemberTabs = () => (
|
||||
<Tab.Navigator>
|
||||
<Tab.Screen name="Home" component={HomeStack} />
|
||||
<Tab.Screen name="MyChamps" component={MyChampsStack} />
|
||||
<Tab.Screen name="Search" component={SearchStack} />
|
||||
<Tab.Screen name="Profile" component={ProfileStack} />
|
||||
</Tab.Navigator>
|
||||
)
|
||||
|
||||
// Org
|
||||
const OrgTabs = () => (
|
||||
<Tab.Navigator>
|
||||
<Tab.Screen name="Dashboard" component={DashboardStack} />
|
||||
<Tab.Screen name="Settings" component={SettingsStack} />
|
||||
</Tab.Navigator>
|
||||
)
|
||||
```
|
||||
|
||||
## Important Docs
|
||||
|
||||
Before coding any feature, read the relevant doc:
|
||||
|
||||
| Doc | When to read |
|
||||
|---|---|
|
||||
| `docs/SPEC.md` | Full feature spec — read first for any new feature |
|
||||
| `docs/PLAN.md` | Dev plan with phases — check what's next |
|
||||
| `docs/DATABASE.md` | Schema — read before any DB work |
|
||||
| `docs/DESIGN-SYSTEM.md` | UI — read before any screen work |
|
||||
| `docs/SCREENS.md` | Screen details — read before building specific screens |
|
||||
|
||||
## Quick Commands
|
||||
|
||||
```bash
|
||||
# Start mobile app
|
||||
cd apps/mobile && npx expo start
|
||||
|
||||
# Start admin panel
|
||||
cd apps/admin && npm run dev
|
||||
|
||||
# Supabase local dev
|
||||
npx supabase start
|
||||
npx supabase db reset # Reset + re-seed
|
||||
|
||||
# Generate types from Supabase
|
||||
npx supabase gen types typescript --local > packages/shared/database.types.ts
|
||||
```
|
||||
|
||||
## Current Status
|
||||
|
||||
Prototypes completed (JSX files in `/prototypes`):
|
||||
- `dance-champ-mvp.jsx` — Member app prototype
|
||||
- `dance-champ-org.jsx` — Org app prototype
|
||||
- `dance-champ-admin.jsx` — Admin panel prototype
|
||||
|
||||
These are reference implementations showing the exact UI, data structure, and flows. Use them as visual guides — don't copy the code directly (they're single-file React prototypes, not production React Native).
|
||||
357
dancechamp-claude-code/docs/DATABASE.md
Normal file
357
dancechamp-claude-code/docs/DATABASE.md
Normal file
@@ -0,0 +1,357 @@
|
||||
# DanceChamp — Database Schema
|
||||
|
||||
## Overview
|
||||
|
||||
Backend: **Supabase** (PostgreSQL + Auth + Storage + Realtime)
|
||||
|
||||
All tables use `uuid` primary keys generated by `gen_random_uuid()`.
|
||||
All tables have `created_at` and `updated_at` timestamps.
|
||||
|
||||
---
|
||||
|
||||
## Tables
|
||||
|
||||
### users
|
||||
Extended from Supabase Auth. This is a `public.users` table that mirrors `auth.users` via trigger.
|
||||
|
||||
```sql
|
||||
create table public.users (
|
||||
id uuid primary key references auth.users(id) on delete cascade,
|
||||
email text not null,
|
||||
name text not null,
|
||||
role text not null check (role in ('admin', 'organization', 'member')),
|
||||
city text,
|
||||
instagram_handle text,
|
||||
experience_years integer,
|
||||
disciplines text[] default '{}', -- ['Pole Exotic', 'Pole Art']
|
||||
auth_provider text default 'email', -- 'email' | 'google' | 'instagram'
|
||||
avatar_url text,
|
||||
status text not null default 'active' check (status in ('active', 'warned', 'blocked')),
|
||||
warn_reason text,
|
||||
block_reason text,
|
||||
created_at timestamptz default now(),
|
||||
updated_at timestamptz default now()
|
||||
);
|
||||
```
|
||||
|
||||
### organizations
|
||||
One-to-one with a user (role = 'organization').
|
||||
|
||||
```sql
|
||||
create table public.organizations (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
user_id uuid not null references public.users(id) on delete cascade,
|
||||
name text not null,
|
||||
instagram_handle text,
|
||||
email text,
|
||||
city text,
|
||||
logo_url text,
|
||||
verified boolean not null default false,
|
||||
status text not null default 'pending' check (status in ('active', 'pending', 'blocked')),
|
||||
block_reason text,
|
||||
created_at timestamptz default now(),
|
||||
updated_at timestamptz default now()
|
||||
);
|
||||
```
|
||||
|
||||
### championships
|
||||
Belongs to an organization. Core entity.
|
||||
|
||||
```sql
|
||||
create table public.championships (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
org_id uuid not null references public.organizations(id) on delete cascade,
|
||||
name text not null,
|
||||
subtitle text,
|
||||
event_date text, -- "May 30, 2026" or ISO date
|
||||
reg_start text, -- registration opens
|
||||
reg_end text, -- registration closes (must be before event_date)
|
||||
location text, -- "Minsk, Belarus"
|
||||
venue text, -- "Prime Hall"
|
||||
accent_color text default '#D4145A',
|
||||
image_emoji text default '💃',
|
||||
status text not null default 'draft' check (status in ('draft', 'pending_approval', 'live', 'completed', 'blocked')),
|
||||
-- configurable sections progress
|
||||
config_info boolean not null default false,
|
||||
config_categories boolean not null default false,
|
||||
config_fees boolean not null default false,
|
||||
config_rules boolean not null default false,
|
||||
config_judges boolean not null default false,
|
||||
-- links
|
||||
form_url text, -- Google Forms URL
|
||||
rules_doc_url text, -- Rules document URL
|
||||
created_at timestamptz default now(),
|
||||
updated_at timestamptz default now()
|
||||
);
|
||||
```
|
||||
|
||||
### disciplines
|
||||
Championship has many disciplines. Each discipline has levels.
|
||||
|
||||
```sql
|
||||
create table public.disciplines (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
championship_id uuid not null references public.championships(id) on delete cascade,
|
||||
name text not null, -- "Exotic Pole Dance"
|
||||
levels text[] default '{}', -- ['Beginners', 'Amateur', 'Semi-Pro', 'Profi', 'Elite']
|
||||
sort_order integer default 0,
|
||||
created_at timestamptz default now()
|
||||
);
|
||||
```
|
||||
|
||||
### styles
|
||||
Championship-level styles (not per-discipline).
|
||||
|
||||
```sql
|
||||
create table public.styles (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
championship_id uuid not null references public.championships(id) on delete cascade,
|
||||
name text not null, -- "Classic", "Flow", "Theater"
|
||||
sort_order integer default 0,
|
||||
created_at timestamptz default now()
|
||||
);
|
||||
```
|
||||
|
||||
### fees
|
||||
One-to-one with championship.
|
||||
|
||||
```sql
|
||||
create table public.fees (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
championship_id uuid not null unique references public.championships(id) on delete cascade,
|
||||
video_selection text, -- "50 BYN / 1,500 RUB"
|
||||
solo text, -- "280 BYN / 7,500 RUB"
|
||||
duet text, -- "210 BYN / 5,800 RUB pp"
|
||||
"group" text, -- "190 BYN / 4,500 RUB pp"
|
||||
refund_note text,
|
||||
created_at timestamptz default now(),
|
||||
updated_at timestamptz default now()
|
||||
);
|
||||
```
|
||||
|
||||
### rules
|
||||
Championship has many rules across sections.
|
||||
|
||||
```sql
|
||||
create table public.rules (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
championship_id uuid not null references public.championships(id) on delete cascade,
|
||||
section text not null check (section in ('general', 'costume', 'scoring', 'penalty')),
|
||||
name text not null, -- rule text or criterion name
|
||||
value text, -- for scoring: "10" (max), for penalty: "-2" or "DQ"
|
||||
sort_order integer default 0,
|
||||
created_at timestamptz default now()
|
||||
);
|
||||
```
|
||||
|
||||
### judges
|
||||
Championship has many judges.
|
||||
|
||||
```sql
|
||||
create table public.judges (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
championship_id uuid not null references public.championships(id) on delete cascade,
|
||||
name text not null,
|
||||
instagram text,
|
||||
bio text,
|
||||
photo_url text,
|
||||
sort_order integer default 0,
|
||||
created_at timestamptz default now()
|
||||
);
|
||||
```
|
||||
|
||||
### registrations
|
||||
Links a member to a championship. Tracks the 10-step progress.
|
||||
|
||||
```sql
|
||||
create table public.registrations (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
user_id uuid not null references public.users(id) on delete cascade,
|
||||
championship_id uuid not null references public.championships(id) on delete cascade,
|
||||
discipline_id uuid references public.disciplines(id),
|
||||
level text, -- "Semi-Pro"
|
||||
style text, -- "Classic"
|
||||
participation_type text default 'solo' check (participation_type in ('solo', 'duet', 'group')),
|
||||
-- Progress steps (step 1–10)
|
||||
step_rules_reviewed boolean default false,
|
||||
step_category_selected boolean default false,
|
||||
step_video_recorded boolean default false,
|
||||
step_form_submitted boolean default false,
|
||||
step_video_fee_paid boolean default false, -- confirmed by org
|
||||
step_video_fee_receipt_url text, -- uploaded receipt
|
||||
step_results text check (step_results in ('pending', 'passed', 'failed')),
|
||||
step_champ_fee_paid boolean default false,
|
||||
step_champ_fee_receipt_url text,
|
||||
step_about_me_submitted boolean default false,
|
||||
step_insurance_confirmed boolean default false,
|
||||
step_insurance_doc_url text,
|
||||
-- Video
|
||||
video_url text,
|
||||
-- Meta
|
||||
current_step integer default 1,
|
||||
created_at timestamptz default now(),
|
||||
updated_at timestamptz default now(),
|
||||
unique(user_id, championship_id)
|
||||
);
|
||||
```
|
||||
|
||||
### notifications
|
||||
Push to member's in-app feed.
|
||||
|
||||
```sql
|
||||
create table public.notifications (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
user_id uuid not null references public.users(id) on delete cascade,
|
||||
championship_id uuid references public.championships(id) on delete set null,
|
||||
type text not null check (type in (
|
||||
'category_changed', 'payment_confirmed', 'results',
|
||||
'deadline_reminder', 'registration_confirmed', 'announcement',
|
||||
'champ_approved', 'champ_rejected', 'org_approved', 'org_rejected'
|
||||
)),
|
||||
title text not null,
|
||||
message text not null,
|
||||
read boolean not null default false,
|
||||
created_at timestamptz default now()
|
||||
);
|
||||
```
|
||||
|
||||
### activity_logs
|
||||
Admin audit trail.
|
||||
|
||||
```sql
|
||||
create table public.activity_logs (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
actor_id uuid references public.users(id) on delete set null,
|
||||
action text not null, -- "org_approved", "user_blocked", "champ_auto_approved"
|
||||
target_type text not null, -- "organization", "championship", "user"
|
||||
target_id uuid,
|
||||
target_name text, -- denormalized for display
|
||||
details jsonb, -- extra context
|
||||
created_at timestamptz default now()
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Relationships Diagram
|
||||
|
||||
```
|
||||
users (1) ──── (1) organizations
|
||||
│
|
||||
│ has many
|
||||
▼
|
||||
championships
|
||||
┌────┼────┬────┬────┐
|
||||
│ │ │ │ │
|
||||
disciplines styles fees rules judges
|
||||
│
|
||||
registrations ─┘
|
||||
(user + championship)
|
||||
│
|
||||
notifications
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Row Level Security (RLS)
|
||||
|
||||
Enable RLS on all tables.
|
||||
|
||||
### users
|
||||
```sql
|
||||
-- Members can read/update their own row
|
||||
create policy "Users can read own" on users for select using (auth.uid() = id);
|
||||
create policy "Users can update own" on users for update using (auth.uid() = id);
|
||||
|
||||
-- Org admins can read members registered to their championships
|
||||
create policy "Orgs can read their members" on users for select using (
|
||||
id in (
|
||||
select r.user_id from registrations r
|
||||
join championships c on r.championship_id = c.id
|
||||
join organizations o on c.org_id = o.id
|
||||
where o.user_id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
-- Admin can read/update all
|
||||
create policy "Admin full access" on users for all using (
|
||||
exists (select 1 from users where id = auth.uid() and role = 'admin')
|
||||
);
|
||||
```
|
||||
|
||||
### championships
|
||||
```sql
|
||||
-- Anyone can read live championships
|
||||
create policy "Public read live" on championships for select using (status = 'live');
|
||||
|
||||
-- Org can CRUD their own
|
||||
create policy "Org manages own" on championships for all using (
|
||||
org_id in (select id from organizations where user_id = auth.uid())
|
||||
);
|
||||
|
||||
-- Admin full access
|
||||
create policy "Admin full access" on championships for all using (
|
||||
exists (select 1 from users where id = auth.uid() and role = 'admin')
|
||||
);
|
||||
```
|
||||
|
||||
### registrations
|
||||
```sql
|
||||
-- Members can read/create their own
|
||||
create policy "Member own registrations" on registrations for select using (user_id = auth.uid());
|
||||
create policy "Member can register" on registrations for insert with check (user_id = auth.uid());
|
||||
|
||||
-- Org can read/update registrations for their championships
|
||||
create policy "Org manages registrations" on registrations for all using (
|
||||
championship_id in (
|
||||
select c.id from championships c
|
||||
join organizations o on c.org_id = o.id
|
||||
where o.user_id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
-- Admin full access
|
||||
create policy "Admin full access" on registrations for all using (
|
||||
exists (select 1 from users where id = auth.uid() and role = 'admin')
|
||||
);
|
||||
```
|
||||
|
||||
### notifications
|
||||
```sql
|
||||
-- Users can read their own notifications
|
||||
create policy "Read own" on notifications for select using (user_id = auth.uid());
|
||||
-- Users can mark their own as read
|
||||
create policy "Update own" on notifications for update using (user_id = auth.uid());
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Storage Buckets
|
||||
|
||||
```
|
||||
receipts/ -- Payment receipt screenshots
|
||||
{user_id}/{registration_id}/receipt.jpg
|
||||
|
||||
insurance/ -- Insurance documents
|
||||
{user_id}/{registration_id}/insurance.pdf
|
||||
|
||||
judge-photos/ -- Judge profile photos
|
||||
{championship_id}/{judge_id}.jpg
|
||||
|
||||
org-logos/ -- Organization logos
|
||||
{org_id}/logo.jpg
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Seed Data
|
||||
|
||||
For development, seed with:
|
||||
- 1 admin user
|
||||
- 2 organizations (1 verified, 1 unverified/pending)
|
||||
- 2 championships for verified org (1 live, 1 draft)
|
||||
- 1 championship for unverified org (pending_approval)
|
||||
- 7 member users with registrations at various progress stages
|
||||
- Sample notifications, activity logs
|
||||
|
||||
This matches the prototype demo data.
|
||||
258
dancechamp-claude-code/docs/DESIGN-SYSTEM.md
Normal file
258
dancechamp-claude-code/docs/DESIGN-SYSTEM.md
Normal file
@@ -0,0 +1,258 @@
|
||||
# DanceChamp — Design System
|
||||
|
||||
## Theme: Dark Luxury
|
||||
|
||||
The app has a premium dark aesthetic. Think high-end dance competition branding — elegant, minimal, confident.
|
||||
|
||||
---
|
||||
|
||||
## Colors
|
||||
|
||||
### Core Palette
|
||||
```
|
||||
Background: #08070D (near-black with slight purple)
|
||||
Card: #12111A (elevated surface)
|
||||
Card Hover: #1A1926 (pressed/active state)
|
||||
Border: #1F1E2E (subtle separator)
|
||||
Text Primary: #F2F0FA (off-white)
|
||||
Text Dim: #5E5C72 (labels, placeholders)
|
||||
Text Mid: #8F8DA6 (secondary info)
|
||||
```
|
||||
|
||||
### Accent Colors
|
||||
```
|
||||
Pink (Primary): #D4145A ← Member app + Org app default
|
||||
Purple: #7C3AED ← Secondary accent (styles, alt champ branding)
|
||||
Indigo: #6366F1 ← Admin panel accent
|
||||
```
|
||||
|
||||
### Semantic Colors
|
||||
```
|
||||
Green: #10B981 (success, passed, active, confirmed)
|
||||
Yellow: #F59E0B (warning, pending, draft)
|
||||
Red: #EF4444 (error, failed, blocked, danger)
|
||||
Blue: #60A5FA (info, links, video)
|
||||
Orange: #F97316 (warned status, awaiting review)
|
||||
```
|
||||
|
||||
### Transparent Variants
|
||||
Each semantic color has a 10% opacity background:
|
||||
```
|
||||
Green Soft: rgba(16,185,129,0.10)
|
||||
Yellow Soft: rgba(245,158,11,0.10)
|
||||
Red Soft: rgba(239,68,68,0.10)
|
||||
Blue Soft: rgba(96,165,250,0.10)
|
||||
Purple Soft: rgba(139,92,246,0.10)
|
||||
```
|
||||
|
||||
For accent overlays use 15% opacity: `${color}15`
|
||||
For accent borders use 30% opacity: `${color}30`
|
||||
|
||||
### Per-Championship Branding
|
||||
Each championship can have its own accent color:
|
||||
- Zero Gravity: `#D4145A` (pink)
|
||||
- Pole Star: `#7C3AED` (purple)
|
||||
|
||||
This color is used for the championship's tab highlights, buttons, and member tags.
|
||||
|
||||
---
|
||||
|
||||
## Typography
|
||||
|
||||
### Font Stack
|
||||
```
|
||||
Display: 'Playfair Display', Georgia, serif ← Headings, numbers, titles
|
||||
Body: 'DM Sans', 'Segoe UI', sans-serif ← Body text, labels, buttons
|
||||
Mono: 'JetBrains Mono', monospace ← Badges, timestamps, codes, small labels
|
||||
```
|
||||
|
||||
### Sizes & Usage
|
||||
```
|
||||
Screen title: Playfair Display, 20px, 700 weight
|
||||
Section title: Playfair Display, 14px, 700 weight, Text Mid color
|
||||
Card title: DM Sans, 14-16px, 600 weight
|
||||
Body text: DM Sans, 12-13px, 400 weight
|
||||
Small label: JetBrains Mono, 9-10px, 500 weight, uppercase, letter-spacing 0.3-0.5
|
||||
Badge: JetBrains Mono, 8px, 700 weight, uppercase, letter-spacing 0.8
|
||||
Stat number: Playfair Display, 16-20px, 700 weight
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Components
|
||||
|
||||
### Card
|
||||
```
|
||||
Background: #12111A
|
||||
Border: 1px solid #1F1E2E
|
||||
Border Radius: 14px
|
||||
Padding: 16px
|
||||
```
|
||||
|
||||
### Status Badge
|
||||
Small pill with semantic color + soft background.
|
||||
```
|
||||
Font: JetBrains Mono, 8px, 700 weight, uppercase
|
||||
Padding: 3px 8px
|
||||
Border Radius: 4px
|
||||
```
|
||||
|
||||
Status mappings:
|
||||
| Status | Label | Color | Background |
|
||||
|---|---|---|---|
|
||||
| active / live | ACTIVE / LIVE | Green | Green Soft |
|
||||
| pending | PENDING | Yellow | Yellow Soft |
|
||||
| pending_approval | AWAITING REVIEW | Orange | Orange Soft |
|
||||
| draft | DRAFT | Dim | Dim 15% |
|
||||
| blocked | BLOCKED | Red | Red Soft |
|
||||
| warned | WARNED | Orange | Orange Soft |
|
||||
| passed | PASSED | Green | Green Soft |
|
||||
| failed | FAILED | Red | Red Soft |
|
||||
|
||||
### Tab Bar (in-screen tabs, not bottom nav)
|
||||
```
|
||||
Container: horizontal scroll, no scrollbar, gap 3px
|
||||
Tab: JetBrains Mono, 9px, 600 weight
|
||||
Active: accent color text, accent 15% bg, accent 30% border
|
||||
Inactive: Dim color text, transparent bg
|
||||
Border Radius: 16px (pill shape)
|
||||
Padding: 5px 10px
|
||||
```
|
||||
|
||||
Configurable tabs have a status dot (6px circle):
|
||||
- Green dot = section configured ✓
|
||||
- Yellow dot = section pending
|
||||
|
||||
### Input Field
|
||||
```
|
||||
Background: #08070D (same as page bg)
|
||||
Border: 1px solid #1F1E2E
|
||||
Border Radius: 10px
|
||||
Padding: 10px 12px
|
||||
Font: DM Sans, 13px
|
||||
Label: JetBrains Mono, 9px, uppercase, Dim color, 6px margin bottom
|
||||
```
|
||||
|
||||
### Action Button
|
||||
Two variants:
|
||||
- **Filled**: solid background, white text (for primary actions)
|
||||
- **Outline**: transparent bg, colored border 30%, colored text (for secondary/danger)
|
||||
|
||||
```
|
||||
Padding: 8px 14px
|
||||
Border Radius: 8px
|
||||
Font: DM Sans, 11px, 700 weight
|
||||
```
|
||||
|
||||
### Tag Editor
|
||||
For lists of editable items (rules, levels, styles):
|
||||
```
|
||||
Tag: DM Sans 11px, colored bg 10%, colored border 25%, 4px 10px padding, 16px radius
|
||||
Remove (×): 10px, Dim color
|
||||
Add input: same as Input Field but smaller (8px 12px, 12px font)
|
||||
Add button: colored bg, white "+" text, 8px 14px
|
||||
```
|
||||
|
||||
### Header
|
||||
```
|
||||
Padding: 14px 20px 6px
|
||||
Title: Playfair Display, 20px, 700
|
||||
Subtitle: DM Sans, 11px, Dim color
|
||||
Back button: 32×32px, Card bg, Border, 8px radius, "←" centered
|
||||
```
|
||||
|
||||
### Bottom Navigation
|
||||
```
|
||||
Border top: 1px solid #1F1E2E
|
||||
Padding: 10px 0 8px
|
||||
Items: flex, space-around
|
||||
Icon: 18px emoji
|
||||
Label: JetBrains Mono, 8px, letter-spacing 0.3
|
||||
Active: opacity 1
|
||||
Inactive: opacity 0.35
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Patterns
|
||||
|
||||
### Progress/Setup Checklist
|
||||
For configurable tabs on org side:
|
||||
```
|
||||
Each row:
|
||||
[Checkbox 22×22] [Label capitalize] [Configure › or ✓]
|
||||
|
||||
Checkbox: 6px radius, 2px border
|
||||
Done: Green border, Green Soft bg, "✓" inside
|
||||
Pending: Yellow border, transparent bg
|
||||
|
||||
Label done: Dim color, line-through
|
||||
Label pending: Text Primary, clickable → navigates to tab
|
||||
```
|
||||
|
||||
### Readiness Bar (dashboard cards)
|
||||
```
|
||||
Track: 4px height, Border color bg, 2px radius
|
||||
Fill: accent color, width = (done/total * 100)%
|
||||
Below: list of section names with ✓ (green) or ○ (yellow)
|
||||
```
|
||||
|
||||
### Member Card
|
||||
```
|
||||
Container: Card style, 12px padding
|
||||
Name: DM Sans 13px, 600 weight
|
||||
Instagram: JetBrains Mono 10px, accent color
|
||||
Tags: DM Sans 9px, Mid color, Mid 10% bg, 2px 7px padding, 10px radius
|
||||
Status badge: top-right corner
|
||||
```
|
||||
|
||||
### Stat Box
|
||||
```
|
||||
Container: Card style, 10px 6px padding, centered
|
||||
Number: Playfair Display, 16-20px, 700 weight, semantic color
|
||||
Label: JetBrains Mono, 7px, uppercase, Dim color
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phone Frame (for prototypes)
|
||||
|
||||
```
|
||||
Width: 375px
|
||||
Height: 740px
|
||||
Border Radius: 36px
|
||||
Border: 1.5px solid #1F1E2E
|
||||
Shadow: 0 0 80px rgba(accent, 0.06), 0 20px 40px rgba(0,0,0,0.5)
|
||||
|
||||
Status bar: 8px 24px padding
|
||||
Time: JetBrains Mono 11px, Dim
|
||||
Notch: 100×28px black, 14px radius
|
||||
Indicators: "●●●" JetBrains Mono 11px, Dim
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## React Native Adaptation
|
||||
|
||||
The prototypes use inline styles. For React Native:
|
||||
|
||||
| Prototype | React Native |
|
||||
|---|---|
|
||||
| `div` | `View` |
|
||||
| `span`, `p`, `h1` | `Text` |
|
||||
| `input` | `TextInput` |
|
||||
| `onClick` | `onPress` (via `Pressable` or `TouchableOpacity`) |
|
||||
| `overflow: auto` | `ScrollView` or `FlatList` |
|
||||
| `cursor: pointer` | Not needed |
|
||||
| `border: 1px solid` | `borderWidth: 1, borderColor:` |
|
||||
| `fontFamily: 'DM Sans'` | Loaded via `expo-font` |
|
||||
| `gap` | Use `marginBottom` on children (gap not fully supported) |
|
||||
| `overflowX: auto` with scrollbar hidden | `ScrollView horizontal showsHorizontalScrollIndicator={false}` |
|
||||
|
||||
### Fonts Loading (Expo)
|
||||
```typescript
|
||||
import { useFonts } from 'expo-font';
|
||||
import { PlayfairDisplay_700Bold } from '@expo-google-fonts/playfair-display';
|
||||
import { DMSans_400Regular, DMSans_500Medium, DMSans_600SemiBold } from '@expo-google-fonts/dm-sans';
|
||||
import { JetBrainsMono_400Regular, JetBrainsMono_500Medium, JetBrainsMono_700Bold } from '@expo-google-fonts/jetbrains-mono';
|
||||
```
|
||||
316
dancechamp-claude-code/docs/PLAN.md
Normal file
316
dancechamp-claude-code/docs/PLAN.md
Normal file
@@ -0,0 +1,316 @@
|
||||
# DanceChamp — Vibe Coding Plan
|
||||
|
||||
## How to use this plan
|
||||
- Work phase by phase, top to bottom
|
||||
- Check off tasks as you go: `[ ]` → `[x]`
|
||||
- Each phase has a **"Done when"** — don't move on until it's met
|
||||
- 🔴 = blocker (must do), 🟡 = important, 🟢 = nice to have for MVP
|
||||
- Estimated time is for vibe coding with AI (Claude Code / Cursor)
|
||||
|
||||
---
|
||||
|
||||
## Phase 0: Project Setup
|
||||
**Time: ~1 hour**
|
||||
|
||||
- [ ] 🔴 Init Expo project (React Native): `npx create-expo-app DanceChamp --template blank-typescript`
|
||||
- [ ] 🔴 Init Web admin panel: `npm create vite@latest admin-panel -- --template react-ts`
|
||||
- [ ] 🔴 Setup Supabase project (or Firebase): create account, new project
|
||||
- [ ] 🔴 Setup database tables (see Phase 1)
|
||||
- [ ] 🔴 Install core deps: `react-navigation`, `zustand`, `supabase-js`
|
||||
- [ ] 🟡 Setup Git repo + `.gitignore`
|
||||
- [ ] 🟡 Create `/apps/mobile`, `/apps/admin`, `/packages/shared` monorepo structure
|
||||
- [ ] 🟢 Add ESLint + Prettier
|
||||
|
||||
**Done when:** Both apps run locally, Supabase dashboard is accessible
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Database & Auth
|
||||
**Time: ~2-3 hours**
|
||||
|
||||
### 1.1 Database Tables
|
||||
- [ ] 🔴 `users` — id, email, name, role (admin | organization | member), city, instagram_handle, experience_years, disciplines[], auth_provider, status, created_at
|
||||
- [ ] 🔴 `organizations` — id, user_id (FK), name, instagram_handle, email, city, logo_url, verified (bool), status (active | pending | blocked), block_reason, created_at
|
||||
- [ ] 🔴 `championships` — id, org_id (FK), name, subtitle, event_date, reg_start, reg_end, location, venue, status (draft | pending_approval | live | completed | blocked), accent_color, created_at
|
||||
- [ ] 🔴 `disciplines` — id, championship_id (FK), name, levels[], styles[]
|
||||
- [ ] 🔴 `fees` — id, championship_id (FK), video_selection, solo, duet, group, refund_note
|
||||
- [ ] 🔴 `rules` — id, championship_id (FK), section (general | costume | scoring | penalty), text, value (for penalties)
|
||||
- [ ] 🔴 `judges` — id, championship_id (FK), name, instagram, bio, photo_url
|
||||
- [ ] 🔴 `registrations` — id, user_id (FK), championship_id (FK), discipline_id, level, style, type (solo | duet | group), current_step, video_url, fee_paid, receipt_uploaded, insurance_uploaded, passed (null | true | false), created_at
|
||||
- [ ] 🔴 `notifications` — id, user_id (FK), championship_id, type, title, message, read (bool), created_at
|
||||
- [ ] 🟡 `activity_logs` — id, actor_id, action, target_type, target_id, details, created_at
|
||||
|
||||
### 1.2 Auth
|
||||
- [ ] 🔴 Supabase Auth: enable Email + Google OAuth
|
||||
- [ ] 🔴 Role-based access: Row Level Security (RLS) policies
|
||||
- Members see only their own registrations
|
||||
- Orgs see only their own championships & members
|
||||
- Admin sees everything
|
||||
- [ ] 🔴 Sign up / Sign in screens (mobile)
|
||||
- [ ] 🔴 Admin login (web panel)
|
||||
- [ ] 🟡 Instagram OAuth (for member profiles)
|
||||
- [ ] 🟡 Onboarding flow: name → city → discipline → experience → done
|
||||
|
||||
**Done when:** Can sign up as member, org, and admin. RLS blocks cross-access.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Member App — Core Screens
|
||||
**Time: ~4-5 hours**
|
||||
|
||||
### 2.1 Navigation
|
||||
- [ ] 🔴 Bottom tab nav: Home, My Champs, Search, Profile
|
||||
- [ ] 🔴 Stack navigation: screens → detail → sub-screens
|
||||
|
||||
### 2.2 Home Screen
|
||||
- [ ] 🔴 "Upcoming championships" feed — cards with name, date, location, status badge
|
||||
- [ ] 🔴 "My active registrations" section with progress bars
|
||||
- [ ] 🟡 Bell icon → notifications feed
|
||||
- [ ] 🟡 Deadline urgency banners ("Registration closes in 3 days!")
|
||||
|
||||
### 2.3 Championship Detail
|
||||
- [ ] 🔴 Header: name, dates, location, venue, registration period
|
||||
- [ ] 🔴 Tab: Overview (info + registration funnel)
|
||||
- [ ] 🔴 Tab: Categories (disciplines, levels, styles + eligibility)
|
||||
- [ ] 🔴 Tab: Rules (general, costume, scoring criteria, penalties)
|
||||
- [ ] 🔴 Tab: Fees (video selection + championship fees)
|
||||
- [ ] 🔴 Tab: Judges (judge profiles with photo, instagram, bio)
|
||||
- [ ] 🔴 "Register" button → starts onboarding
|
||||
|
||||
### 2.4 Search & Discover
|
||||
- [ ] 🔴 Search by championship name
|
||||
- [ ] 🔴 Filter by: discipline, location, status (open/upcoming/past)
|
||||
- [ ] 🟡 Sort by: date, popularity
|
||||
|
||||
### 2.5 Profile
|
||||
- [ ] 🔴 View/edit: name, city, instagram, disciplines, experience
|
||||
- [ ] 🔴 "My Championships" list (past + active)
|
||||
- [ ] 🟢 Competition history
|
||||
|
||||
**Done when:** Can browse championships, view full details across all tabs, search/filter, see profile.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Member App — Registration & Progress Tracker
|
||||
**Time: ~4-5 hours**
|
||||
|
||||
### 3.1 Registration Flow
|
||||
- [ ] 🔴 Choose discipline → level → style → solo/duet/group
|
||||
- [ ] 🔴 Create `registration` record in DB
|
||||
- [ ] 🔴 Show 10-step progress checklist
|
||||
|
||||
### 3.2 Progress Steps (per championship)
|
||||
- [ ] 🔴 Step 1: Review rules — mark done when user opens Rules tab
|
||||
- [ ] 🔴 Step 2: Select category — saved from registration
|
||||
- [ ] 🔴 Step 3: Record video — manual toggle ("I've recorded my video")
|
||||
- [ ] 🔴 Step 4: Submit video form — manual toggle or link to Google Form
|
||||
- [ ] 🔴 Step 5: Pay video fee — upload receipt screenshot
|
||||
- [ ] 🔴 Step 6: Wait for results — shows "pending" until org decides
|
||||
- [ ] 🔴 Step 7: Results — auto-updates when org passes/fails
|
||||
- [ ] 🔴 Step 8: Pay championship fee — upload receipt (only if passed)
|
||||
- [ ] 🔴 Step 9: Submit "About Me" — manual toggle or link
|
||||
- [ ] 🔴 Step 10: Confirm insurance — upload document
|
||||
|
||||
### 3.3 Receipt & Document Upload
|
||||
- [ ] 🔴 Camera / gallery picker for receipt photos
|
||||
- [ ] 🔴 Upload to Supabase Storage
|
||||
- [ ] 🔴 Show upload status (pending org confirmation)
|
||||
|
||||
### 3.4 Notifications
|
||||
- [ ] 🔴 In-app notification feed (bell icon + unread count)
|
||||
- [ ] 🔴 Notification types: category changed, payment confirmed, results, deadline reminder, announcement
|
||||
- [ ] 🟡 Push notifications via Expo Notifications
|
||||
- [ ] 🟢 Notification preferences (toggle on/off)
|
||||
|
||||
**Done when:** Can register for a championship, track all 10 steps, upload receipts, receive notifications.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Org App — Dashboard & Championship Management
|
||||
**Time: ~5-6 hours**
|
||||
|
||||
### 4.1 Org Dashboard
|
||||
- [ ] 🔴 Championship cards: name, dates, status badge, member count, progress bar (if draft)
|
||||
- [ ] 🔴 "+" button → Quick Create (name, date, location → creates draft)
|
||||
- [ ] 🔴 Tap card → championship detail
|
||||
|
||||
### 4.2 Championship Detail (tabbed, configurable)
|
||||
- [ ] 🔴 Overview tab: setup progress checklist, event info (editable), stats (if live)
|
||||
- [ ] 🔴 Categories tab: add/remove levels, add/remove styles → "Mark as Done"
|
||||
- [ ] 🔴 Fees tab: video selection + solo/duet/group fees → "Mark as Done"
|
||||
- [ ] 🔴 Rules tab: general rules + costume rules + scoring criteria + penalties → "Mark as Done"
|
||||
- [ ] 🔴 Judges tab: add judge profiles (name, instagram, bio) → "Mark as Done"
|
||||
- [ ] 🔴 "Go Live" button — appears when all sections are done
|
||||
- [ ] 🔴 If org is verified → status = `live` (auto-approved)
|
||||
- [ ] 🔴 If org is unverified → status = `pending_approval` (needs admin)
|
||||
|
||||
### 4.3 Members Tab (only for live championships)
|
||||
- [ ] 🔴 Member list with search + filters (All, Receipts, Videos, Passed)
|
||||
- [ ] 🔴 Member card: name, instagram, level, style, status badge, progress
|
||||
- [ ] 🔴 Tap member → member detail
|
||||
|
||||
### 4.4 Member Detail
|
||||
- [ ] 🔴 Profile info, registration details
|
||||
- [ ] 🔴 Edit level (picker + "member will be notified" warning)
|
||||
- [ ] 🔴 Edit style (picker + notification)
|
||||
- [ ] 🔴 Video section: view link + Pass/Fail buttons
|
||||
- [ ] 🔴 Payment section: view receipt + Confirm button
|
||||
- [ ] 🔴 "Send Notification" button
|
||||
|
||||
### 4.5 Results Tab
|
||||
- [ ] 🔴 Pending review list with Pass/Fail buttons per member
|
||||
- [ ] 🔴 Decided list (passed/failed)
|
||||
- [ ] 🔴 "Publish Results" button → notifies all members
|
||||
|
||||
### 4.6 Org Settings
|
||||
- [ ] 🔴 Edit org profile (name, instagram)
|
||||
- [ ] 🔴 Notification preferences (toggles)
|
||||
- [ ] 🟡 Connected accounts (Instagram, Gmail, Telegram)
|
||||
|
||||
**Done when:** Org can create championship, configure all tabs, go live, manage members, pass/fail videos, publish results.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Admin Panel (Web)
|
||||
**Time: ~3-4 hours**
|
||||
|
||||
### 5.1 Dashboard
|
||||
- [ ] 🔴 Platform stats: orgs count, live champs, total users
|
||||
- [ ] 🔴 "Needs Attention" section: pending orgs, pending champs (from unverified orgs)
|
||||
- [ ] 🔴 Platform health: revenue, blocked users
|
||||
- [ ] 🔴 Recent activity log
|
||||
|
||||
### 5.2 Organizations Management
|
||||
- [ ] 🔴 List with search + filters (Active, Pending, Blocked)
|
||||
- [ ] 🔴 Org detail: profile, championships list, approval policy
|
||||
- [ ] 🔴 Actions: Approve / Reject, Block / Unblock, Verify, Delete
|
||||
|
||||
### 5.3 Championships Management
|
||||
- [ ] 🔴 List with search + filters (Live, Awaiting Review, Draft, Blocked)
|
||||
- [ ] 🔴 Champ detail: stats, approval policy indicator
|
||||
- [ ] 🔴 Actions: Approve / Reject (for unverified orgs), Suspend, Reinstate, Delete
|
||||
|
||||
### 5.4 Users Management
|
||||
- [ ] 🔴 List with search + filters (Active, Warned, Blocked, Org Admins)
|
||||
- [ ] 🔴 User detail: profile, role, championships joined
|
||||
- [ ] 🔴 Actions: Warn, Block / Unblock, Delete
|
||||
|
||||
**Done when:** Admin can approve/reject orgs, review championships from unverified orgs, manage users.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Connecting It All
|
||||
**Time: ~3-4 hours**
|
||||
|
||||
### 6.1 Real-Time Sync
|
||||
- [ ] 🔴 Supabase Realtime: members see status updates instantly (pass/fail, payment confirmed)
|
||||
- [ ] 🔴 Org dashboard updates when new member registers
|
||||
- [ ] 🟡 Admin panel live counters
|
||||
|
||||
### 6.2 Notification Triggers
|
||||
- [ ] 🔴 Org passes/fails video → member gets notification
|
||||
- [ ] 🔴 Org confirms receipt → member gets notification
|
||||
- [ ] 🔴 Org changes member's level/style → member gets notification
|
||||
- [ ] 🔴 Org publishes results → all members get notification
|
||||
- [ ] 🟡 Auto deadline reminders (cron job: 7 days, 3 days, 1 day before)
|
||||
|
||||
### 6.3 Approval Flow
|
||||
- [ ] 🔴 Unverified org clicks "Go Live" → status = pending_approval
|
||||
- [ ] 🔴 Admin sees it in "Needs Attention"
|
||||
- [ ] 🔴 Admin approves → status = live, org gets notification
|
||||
- [ ] 🔴 Admin rejects → status = blocked, org gets notification with reason
|
||||
|
||||
### 6.4 File Uploads
|
||||
- [ ] 🔴 Receipt photo upload → Supabase Storage → org sees thumbnail in member detail
|
||||
- [ ] 🔴 Insurance doc upload → same flow
|
||||
- [ ] 🟢 Judge profile photos
|
||||
|
||||
**Done when:** All three apps talk to the same DB. Actions in one app reflect in others in real time.
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: Polish & UX
|
||||
**Time: ~2-3 hours**
|
||||
|
||||
- [ ] 🟡 Loading states (skeletons, spinners)
|
||||
- [ ] 🟡 Empty states ("No championships yet", "No members match")
|
||||
- [ ] 🟡 Error handling (network errors, failed uploads, toast messages)
|
||||
- [ ] 🟡 Pull-to-refresh on lists
|
||||
- [ ] 🟡 Haptic feedback on key actions (pass/fail, payment confirm)
|
||||
- [ ] 🟡 Dark theme consistency across all screens
|
||||
- [ ] 🟡 Animations: tab transitions, card press, progress bar fill
|
||||
- [ ] 🟢 Onboarding walkthrough (first-time user tips)
|
||||
- [ ] 🟢 Swipe gestures on member cards (swipe right = pass, left = fail)
|
||||
|
||||
**Done when:** App feels smooth and professional. No raw errors shown to users.
|
||||
|
||||
---
|
||||
|
||||
## Phase 8: Integrations (Post-MVP)
|
||||
**Time: varies**
|
||||
|
||||
### 8.1 Instagram Parsing
|
||||
- [ ] 🟢 Apify Instagram scraper setup
|
||||
- [ ] 🟢 Claude API: extract structured data from post captions
|
||||
- [ ] 🟢 OCR (Google Vision): parse results from photo posts
|
||||
- [ ] 🟢 "Import from Instagram" button in org's championship creation
|
||||
|
||||
### 8.2 Gmail Integration
|
||||
- [ ] 🟢 Google OAuth for members
|
||||
- [ ] 🟢 Detect Google Forms confirmation emails → auto-mark steps
|
||||
|
||||
### 8.3 Payments
|
||||
- [ ] 🟢 In-app payment tracking
|
||||
- [ ] 🟢 Replace receipt uploads with direct payment
|
||||
|
||||
### 8.4 Multi-Language
|
||||
- [ ] 🟢 i18n setup (Russian + English)
|
||||
|
||||
---
|
||||
|
||||
## Phase 9: Testing & Launch
|
||||
**Time: ~2-3 hours**
|
||||
|
||||
- [ ] 🔴 Test full flow: member registers → org reviews → admin monitors
|
||||
- [ ] 🔴 Test approval flow: unverified org → pending → admin approves → live
|
||||
- [ ] 🔴 Test notifications: level change, payment confirm, results
|
||||
- [ ] 🔴 Test on real device (iOS + Android via Expo Go)
|
||||
- [ ] 🟡 Test edge cases: empty championships, blocked orgs, duplicate registrations
|
||||
- [ ] 🟡 Performance check: list with 50+ members, 10+ championships
|
||||
- [ ] 🟡 Expo build: `eas build` for iOS/Android
|
||||
- [ ] 🟢 TestFlight / Google Play internal testing
|
||||
- [ ] 🟢 Admin panel deploy (Vercel / Netlify)
|
||||
|
||||
**Done when:** All three roles can complete their full flow without bugs.
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference: What Goes Where
|
||||
|
||||
| Feature | Member App | Org App | Admin Panel |
|
||||
|---|:---:|:---:|:---:|
|
||||
| Browse championships | ✅ | — | ✅ (view all) |
|
||||
| Register for championship | ✅ | — | — |
|
||||
| Progress tracker | ✅ | — | — |
|
||||
| Create/edit championship | — | ✅ | ✅ (edit/delete) |
|
||||
| Review members | — | ✅ | ✅ (view) |
|
||||
| Pass/Fail videos | — | ✅ | — |
|
||||
| Confirm payments | — | ✅ | — |
|
||||
| Approve orgs & champs | — | — | ✅ |
|
||||
| Block/warn users | — | — | ✅ |
|
||||
| Notifications | ✅ (receive) | ✅ (send) | — |
|
||||
|
||||
---
|
||||
|
||||
## Priority Order (if short on time)
|
||||
|
||||
If you need to ship fast, do these phases in order and stop when you have enough:
|
||||
|
||||
1. **Phase 0 + 1** — Foundation (can't skip)
|
||||
2. **Phase 2** — Member app core (users need to see something)
|
||||
3. **Phase 4** — Org app (orgs need to create championships)
|
||||
4. **Phase 3** — Registration flow (connects member ↔ org)
|
||||
5. **Phase 6** — Wire it together
|
||||
6. **Phase 5** — Admin panel (can manage via Supabase dashboard temporarily)
|
||||
7. **Phase 7** — Polish
|
||||
8. **Phase 8** — Integrations (post-launch)
|
||||
371
dancechamp-claude-code/docs/SCREENS.md
Normal file
371
dancechamp-claude-code/docs/SCREENS.md
Normal file
@@ -0,0 +1,371 @@
|
||||
# DanceChamp — Screen Reference
|
||||
|
||||
## Member App Screens
|
||||
|
||||
### Navigation: Bottom Tabs
|
||||
`Home` | `My Champs` | `Search` | `Profile`
|
||||
|
||||
---
|
||||
|
||||
### M1: Home
|
||||
**Purpose:** Dashboard for the dancer
|
||||
|
||||
**Elements:**
|
||||
- Header: "DanceChamp" title + bell icon (🔔) with unread count badge
|
||||
- "Your Active" section: cards for championships they're registered in, showing progress bar (e.g. "Step 5/10")
|
||||
- "Upcoming Championships" section: browseable cards for live championships
|
||||
- Each card: championship name, org name, dates, location, status badge, accent color bar
|
||||
|
||||
**Data:** `championships` (status = 'live') + `registrations` (for current user)
|
||||
|
||||
**Navigation:**
|
||||
- Tap card → M5 (Championship Detail)
|
||||
- Tap bell → M7 (Notifications)
|
||||
|
||||
---
|
||||
|
||||
### M2: My Champs
|
||||
**Purpose:** All championships user is registered for
|
||||
|
||||
**Elements:**
|
||||
- Tabs: Active | Past
|
||||
- Championship cards with progress indicator
|
||||
- Status per card: "Step 3/10 — Pay video fee" or "✅ Registered" or "❌ Failed"
|
||||
|
||||
**Data:** `registrations` joined with `championships`
|
||||
|
||||
**Navigation:**
|
||||
- Tap card → M6 (Progress Tracker)
|
||||
|
||||
---
|
||||
|
||||
### M3: Search
|
||||
**Purpose:** Discover championships
|
||||
|
||||
**Elements:**
|
||||
- Search bar (text input)
|
||||
- Filter chips: All, Pole Exotic, Pole Art, Registration Open, Upcoming
|
||||
- Championship result cards
|
||||
|
||||
**Data:** `championships` + `organizations` (for org name)
|
||||
|
||||
**Navigation:**
|
||||
- Tap card → M5 (Championship Detail)
|
||||
|
||||
---
|
||||
|
||||
### M4: Profile
|
||||
**Purpose:** User profile and settings
|
||||
|
||||
**Elements:**
|
||||
- Avatar, name, instagram handle, city
|
||||
- Dance profile: disciplines, experience years
|
||||
- "My Championships" summary (X active, Y completed)
|
||||
- Settings list: Edit Profile, Notification Preferences, Connected Accounts, Help, Log Out
|
||||
|
||||
**Data:** `users` (current user)
|
||||
|
||||
---
|
||||
|
||||
### M5: Championship Detail
|
||||
**Purpose:** Full championship info — 5 tabs
|
||||
|
||||
**Header:** Championship name, org name, back button
|
||||
**Tabs:** Overview | Categories | Rules | Fees | Judges
|
||||
|
||||
#### Tab: Overview
|
||||
- Event info: date, location, venue, registration period (start → end)
|
||||
- Stats: members registered, spots left (if limited)
|
||||
- "Register" button (if registration open and user not registered)
|
||||
- If already registered: shows current progress step
|
||||
|
||||
#### Tab: Categories
|
||||
- Disciplines list, each with levels
|
||||
- Styles list
|
||||
- If registered: user's selected level/style highlighted
|
||||
|
||||
#### Tab: Rules
|
||||
- General rules (expandable list)
|
||||
- Costume rules
|
||||
- Scoring criteria: name + max score (0–10)
|
||||
- Penalties: name + deduction / DQ
|
||||
|
||||
#### Tab: Fees
|
||||
- Video selection fee
|
||||
- Championship fees: solo, duet, group
|
||||
- Refund note
|
||||
|
||||
#### Tab: Judges
|
||||
- Judge profile cards: photo/emoji, name, instagram link, bio
|
||||
|
||||
**Data:** Full championship with all related tables
|
||||
|
||||
**Navigation:**
|
||||
- "Register" → M6 (starts registration flow, then shows Progress Tracker)
|
||||
|
||||
---
|
||||
|
||||
### M6: Progress Tracker
|
||||
**Purpose:** 10-step registration checklist for a specific championship
|
||||
|
||||
**Header:** Championship name, back button
|
||||
|
||||
**Elements:**
|
||||
- Vertical step list (1–10)
|
||||
- Each step: number, icon, title, status (locked/available/done/failed)
|
||||
- Current step expanded with action area:
|
||||
- Step 3 "Record Video": toggle "I've recorded my video"
|
||||
- Step 5 "Pay Video Fee": upload receipt button, status after upload
|
||||
- Step 7 "Results": shows PASS ✅ / FAIL ❌ / ⏳ Pending
|
||||
- Step 10 "Insurance": upload document button
|
||||
- Progress bar at top: X/10 completed
|
||||
|
||||
**Data:** `registrations` (single record for this user + championship)
|
||||
|
||||
**Actions:** Update step fields in `registrations` table
|
||||
|
||||
---
|
||||
|
||||
### M7: Notifications
|
||||
**Purpose:** In-app notification feed
|
||||
|
||||
**Header:** "Notifications" + "Read all" button
|
||||
|
||||
**Elements:**
|
||||
- Notification cards: icon, type badge, championship name, message, time ago
|
||||
- Unread: colored left border + accent background tint + dot indicator
|
||||
- Tap: marks as read
|
||||
|
||||
**Types:** 🔄 Category Changed, ✅ Payment Confirmed, 🏆 Results, ⏰ Deadline, 📋 Registered, 📢 Announcement
|
||||
|
||||
**Data:** `notifications` (for current user, ordered by created_at desc)
|
||||
|
||||
---
|
||||
|
||||
## Org App Screens
|
||||
|
||||
### Navigation: Bottom Tabs
|
||||
`Dashboard` | `Settings`
|
||||
|
||||
---
|
||||
|
||||
### O1: Dashboard
|
||||
**Purpose:** Overview of all org's championships
|
||||
|
||||
**Elements:**
|
||||
- Header: "Dashboard" + org name + org logo
|
||||
- "New Championship" card (accent gradient, "+" icon)
|
||||
- Championship cards: name, dates, location, status badge (LIVE / SETUP 3/5 / AWAITING REVIEW)
|
||||
- For drafts: readiness bar + section checklist (info ✓, categories ○, fees ○, etc.)
|
||||
- For live: mini stats (Members, Passed, Pending)
|
||||
|
||||
**Data:** `championships` (where org_id = current org) + `registrations` (counts)
|
||||
|
||||
**Navigation:**
|
||||
- Tap "New Championship" → O2 (Quick Create)
|
||||
- Tap championship card → O3 (Championship Detail)
|
||||
|
||||
---
|
||||
|
||||
### O2: Quick Create
|
||||
**Purpose:** Minimal form to create a draft championship
|
||||
|
||||
**Elements:**
|
||||
- Header: "New Championship" + back button
|
||||
- 3 inputs: Championship Name, Event Date, Location
|
||||
- Info card: "Your championship will be created as a draft. Configure details later."
|
||||
- "✨ Create Draft" button (disabled until name filled)
|
||||
|
||||
**Action:** Creates championship with status = 'draft', navigates to O3
|
||||
|
||||
---
|
||||
|
||||
### O3: Championship Detail (Tabbed)
|
||||
**Purpose:** Configure and manage a championship
|
||||
|
||||
**Header:** Championship name, subtitle, back button, "🚀 Go Live" (if all config done)
|
||||
|
||||
**Tabs (with config status dots):**
|
||||
`Overview` | `Categories` (🟢/🟡) | `Fees` (🟢/🟡) | `Rules` (🟢/🟡) | `Judges` (🟢/🟡)
|
||||
|
||||
For live championships, add: | `Members (N)` | `Results`
|
||||
|
||||
#### Tab: Overview
|
||||
- **If draft:** Setup Progress checklist (5 items with checkmarks, tap incomplete → jumps to tab)
|
||||
- **If all done:** "🚀 Open Registration" button (or "Go Live")
|
||||
- Event Info card: inline edit (✎ Edit / ✕ Close toggle)
|
||||
- Editable fields: name, subtitle, event date, location, venue
|
||||
- Registration period: Opens (date) + Closes (date), side by side
|
||||
- Warning: "⚠️ Registration close date must be before event date"
|
||||
- **If live:** Stats boxes (Members, Passed, Failed, Pending) + "⚡ Needs Action" (receipts to review, videos to review)
|
||||
|
||||
#### Tab: Categories
|
||||
- Levels: tag editor (add/remove levels)
|
||||
- Styles: tag editor (add/remove styles)
|
||||
- "✓ Mark Categories as Done" button (shown when at least 1 level + 1 style)
|
||||
|
||||
#### Tab: Fees
|
||||
- Video Selection Fee input
|
||||
- Championship Fees: Solo, Duet (pp), Group (pp) inputs
|
||||
- "✓ Mark Fees as Done" button (shown when video fee filled)
|
||||
|
||||
#### Tab: Rules
|
||||
- General Rules: tag editor
|
||||
- Costume Rules: tag editor
|
||||
- Scoring Criteria (0–10): list + tag editor to add new
|
||||
- Penalties: list with colored values (-2 yellow, DQ red) + tag editor
|
||||
- "✓ Mark Rules as Done" button (shown when at least 1 rule)
|
||||
|
||||
#### Tab: Judges
|
||||
- Judge profile cards: emoji avatar, name, instagram, bio, × to remove
|
||||
- "Add Judge" form: name, instagram, bio inputs + "+ Add Judge" button
|
||||
- "✓ Mark Judges as Done" button (shown when at least 1 judge)
|
||||
|
||||
#### Tab: Members (live only)
|
||||
- Search bar
|
||||
- Filter chips: All (N), 📸 Receipts (N), 🎬 Videos (N), ✅ Passed (N)
|
||||
- Member cards: name, instagram, level, style, city tags, status badge
|
||||
- Tap member → O4 (Member Detail)
|
||||
|
||||
#### Tab: Results (live only)
|
||||
- Stat boxes: Pending, Passed, Failed
|
||||
- Pending review cards: member name/level + "🎥 View" + Pass/Fail buttons
|
||||
- Decided list: member name + badge
|
||||
- "📢 Publish Results" button
|
||||
|
||||
---
|
||||
|
||||
### O4: Member Detail
|
||||
**Purpose:** View/edit a single member's registration
|
||||
|
||||
**Header:** Member name, championship + instagram, back button
|
||||
|
||||
**Elements:**
|
||||
- Profile card: avatar, name, instagram, city, status badge
|
||||
- Registration section:
|
||||
- Discipline (read-only)
|
||||
- Type: solo/duet/group (read-only)
|
||||
- **Level:** value + "✎ Edit" button → expands picker with ⚠️ "Member will be notified"
|
||||
- **Style:** value + "✎ Edit" button → expands picker with ⚠️ warning
|
||||
- Video section: link display + Pass/Fail buttons (if pending) or status badge
|
||||
- Payment section: fee amount, receipt status, "📸 Confirm" button (if receipt uploaded)
|
||||
- "🔔 Send Notification" button
|
||||
|
||||
**Actions:** Update `registrations` fields + create `notifications` record
|
||||
|
||||
---
|
||||
|
||||
### O5: Org Settings
|
||||
**Purpose:** Organization profile and preferences
|
||||
|
||||
**Elements:**
|
||||
- Org profile: logo, name, instagram (editable inline when "Edit Organization Profile" tapped)
|
||||
- Menu items:
|
||||
- ✎ Edit Organization Profile → inline form (name + instagram) replaces menu
|
||||
- 🔔 Notification Preferences → sub-screen with toggle switches
|
||||
- 🔗 Connected Accounts → sub-screen (Instagram ✅, Gmail ✅, Telegram ❌)
|
||||
- ❓ Help & Support
|
||||
- 🚪 Log Out (red text)
|
||||
|
||||
---
|
||||
|
||||
## Admin Panel Screens (Web)
|
||||
|
||||
### Navigation: Bottom Tabs (mobile-style for prototype, sidebar for production)
|
||||
`Overview` | `Orgs` | `Champs` | `Users`
|
||||
|
||||
---
|
||||
|
||||
### A1: Overview (Dashboard)
|
||||
**Purpose:** Platform health at a glance
|
||||
|
||||
**Elements:**
|
||||
- Header: "Admin Panel" + platform name + version
|
||||
- Stat boxes: Active Orgs, Live Champs, Total Users
|
||||
- "⚡ Needs Attention" card (yellow tint):
|
||||
- 🏢 Organizations awaiting approval (count) → tap goes to A2
|
||||
- 🏆 Champs awaiting approval from unverified orgs (count) → tap goes to A4
|
||||
- Platform Health table: total revenue, active/total orgs, blocked users, avg members/champ
|
||||
- Recent Activity log: action + target (colored by type) + date + actor
|
||||
|
||||
---
|
||||
|
||||
### A2: Organizations List
|
||||
**Purpose:** All organizations on the platform
|
||||
|
||||
**Elements:**
|
||||
- Search bar
|
||||
- Filter chips: All (N), ✅ Active (N), ⏳ Pending (N), 🚫 Blocked (N)
|
||||
- Org cards: logo, name, instagram, status badge, city, champs count, members count
|
||||
- Tap → A3 (Org Detail)
|
||||
|
||||
---
|
||||
|
||||
### A3: Organization Detail
|
||||
**Purpose:** Review and manage a single org
|
||||
|
||||
**Elements:**
|
||||
- Profile card: logo, name, instagram, email, city, status badge
|
||||
- Details table: Joined, Championships, Total members, Verified (✅/❌)
|
||||
- Approval Policy card:
|
||||
- Verified: "🛡️ Verified — Auto-approve events" (green tint)
|
||||
- Unverified: "⏳ Unverified — Events need manual approval" (yellow tint)
|
||||
- Championships list (belonging to this org)
|
||||
- Block reason card (if blocked, red tint)
|
||||
- Actions:
|
||||
- Pending: [Approve ✅] [Reject ❌]
|
||||
- Active: [Block 🚫] + [Verify 🛡️] (if not verified)
|
||||
- Blocked: [Unblock ✅]
|
||||
- Always: [Delete 🗑️]
|
||||
|
||||
---
|
||||
|
||||
### A4: Championships List
|
||||
**Purpose:** All championships across all orgs
|
||||
|
||||
**Elements:**
|
||||
- Search bar
|
||||
- Filter chips: All (N), 🟢 Live (N), ⏳ Awaiting (N), 📝 Draft (N), 🚫 Blocked (N)
|
||||
- Champ cards: name, "by Org Name 🛡️" (shield if verified), status badge, dates, location, members count
|
||||
- Tap → A5 (Champ Detail)
|
||||
|
||||
---
|
||||
|
||||
### A5: Championship Detail
|
||||
**Purpose:** Review and manage a single championship
|
||||
|
||||
**Elements:**
|
||||
- Profile card: trophy emoji, name, org name, dates, location, status badge
|
||||
- Stats table: Members, Passed, Pending, Revenue
|
||||
- Approval Policy card: explains why auto-approved or needs review
|
||||
- Actions:
|
||||
- Pending approval: [Approve ✅] [Reject ❌]
|
||||
- Live: [Suspend ⏸️]
|
||||
- Blocked: [Reinstate ✅]
|
||||
- Always: [Delete 🗑️]
|
||||
|
||||
---
|
||||
|
||||
### A6: Users List
|
||||
**Purpose:** All platform users
|
||||
|
||||
**Elements:**
|
||||
- Search bar (name, @handle, email)
|
||||
- Filter chips: All (N), ✅ Active (N), ⚠️ Warned (N), 🚫 Blocked (N), 🏢 Org Admins (N)
|
||||
- User cards: avatar (👤 or 🏢 for org admins), name, instagram, city, status badge
|
||||
- Tap → A7 (User Detail)
|
||||
|
||||
---
|
||||
|
||||
### A7: User Detail
|
||||
**Purpose:** Review and manage a single user
|
||||
|
||||
**Elements:**
|
||||
- Profile card: avatar, name, instagram, email, status badge
|
||||
- Info table: City, Joined, Championships joined, Role (Member or Org Admin + org name)
|
||||
- Warning/Block reason card (if applicable, orange or red tint)
|
||||
- Actions:
|
||||
- Active: [Warn ⚠️] [Block 🚫]
|
||||
- Warned: [Remove Warning ✅] [Block 🚫]
|
||||
- Blocked: [Unblock ✅]
|
||||
- Always: [Delete 🗑️]
|
||||
267
dancechamp-claude-code/docs/SPEC.md
Normal file
267
dancechamp-claude-code/docs/SPEC.md
Normal file
@@ -0,0 +1,267 @@
|
||||
# DanceChamp — Technical Specification
|
||||
|
||||
## 1. Overview
|
||||
|
||||
A mobile platform for pole dance championships. Dancers discover events and track registration. Organizers create and manage championships. Platform admin oversees everything.
|
||||
|
||||
### The Problem
|
||||
Championship info is scattered across Instagram posts, Telegram chats, and Google Docs. Dancers miss deadlines, lose track of multi-step registration, and can't verify submissions went through. Organizers manually manage everything via spreadsheets and DMs.
|
||||
|
||||
### The Solution
|
||||
One app with three roles:
|
||||
- **Members** browse championships, register, track 10-step progress
|
||||
- **Organizations** create championships, configure rules/fees/categories, review videos, confirm payments
|
||||
- **Admin** approves organizations, reviews championships from unverified orgs, manages users
|
||||
|
||||
### Real-World Reference: "Zero Gravity"
|
||||
International Pole Exotic Championship, Minsk, Belarus. Two disciplines (Exotic Pole Dance + Pole Art), 6+ levels per discipline, two-stage payment (video selection fee + championship fee), video selection with pass/fail, detailed judging criteria (6 categories, 0–10), strict costume/equipment rules, insurance required.
|
||||
|
||||
---
|
||||
|
||||
## 2. Roles & Permissions
|
||||
|
||||
### Member (Dancer)
|
||||
**Access:** Mobile app (member view)
|
||||
- Browse & search championships
|
||||
- View full details (rules, fees, categories, judges)
|
||||
- Register for championships
|
||||
- Track 10-step progress per championship
|
||||
- Upload receipts & documents
|
||||
- Receive notifications (results, deadline reminders, announcements)
|
||||
- View past participation history
|
||||
|
||||
**Cannot:** Create/edit championships, see other members' data, access org/admin features
|
||||
|
||||
### Organization
|
||||
**Access:** Mobile app (org view)
|
||||
- Create championships (quick create → configure tabs)
|
||||
- Manage disciplines, levels, styles, fees, rules, scoring, judges
|
||||
- View & manage registered members per championship
|
||||
- Review videos (pass/fail)
|
||||
- Confirm receipt payments
|
||||
- Edit member's level/style (triggers notification)
|
||||
- Publish results
|
||||
- Send announcements
|
||||
|
||||
**Cannot:** See other orgs' data, access admin features
|
||||
|
||||
### Admin
|
||||
**Access:** Web admin panel
|
||||
- View all orgs, championships, users
|
||||
- Approve/reject pending organizations
|
||||
- Approve/reject championships from unverified orgs
|
||||
- Block/unblock orgs and users
|
||||
- Warn users
|
||||
- Verify organizations (changes approval policy)
|
||||
- Delete any entity
|
||||
- View platform stats and activity logs
|
||||
|
||||
---
|
||||
|
||||
## 3. Championship Lifecycle
|
||||
|
||||
```
|
||||
[Org: Quick Create]
|
||||
name + date + location → status: DRAFT
|
||||
│
|
||||
▼
|
||||
[Org: Configure Tabs]
|
||||
Categories ✓ → Fees ✓ → Rules ✓ → Judges ✓
|
||||
(any order, mark each as done)
|
||||
│
|
||||
▼
|
||||
[Org: "Go Live"]
|
||||
│
|
||||
├── Org is VERIFIED (🛡️)
|
||||
│ → status: LIVE (auto-approved)
|
||||
│ → visible to members immediately
|
||||
│
|
||||
└── Org is UNVERIFIED
|
||||
→ status: PENDING_APPROVAL
|
||||
→ admin sees in "Needs Attention"
|
||||
→ admin approves → LIVE
|
||||
→ admin rejects → BLOCKED
|
||||
│
|
||||
▼
|
||||
[LIVE — Registration Open]
|
||||
reg_start ≤ today ≤ reg_end
|
||||
Members can register
|
||||
│
|
||||
▼
|
||||
[Registration Closed]
|
||||
today > reg_end
|
||||
No new registrations
|
||||
│
|
||||
▼
|
||||
[Event Day]
|
||||
event_date
|
||||
│
|
||||
▼
|
||||
[COMPLETED]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Championship Data Structure
|
||||
|
||||
Each championship contains:
|
||||
|
||||
### Event Info
|
||||
- Name, subtitle
|
||||
- Event date (when it happens)
|
||||
- Registration period: start date → end date (must be before event date)
|
||||
- Location (city, country)
|
||||
- Venue name
|
||||
- Accent color (for branding)
|
||||
|
||||
### Categories (configurable tab)
|
||||
- Disciplines: e.g. "Exotic Pole Dance", "Pole Art"
|
||||
- Levels per discipline: e.g. "Beginners", "Amateur", "Semi-Pro", "Profi", "Elite", "Duets & Groups"
|
||||
- Styles: e.g. "Classic", "Flow", "Theater"
|
||||
|
||||
### Fees (configurable tab)
|
||||
- Video selection fee (e.g. "50 BYN / 1,500 RUB")
|
||||
- Championship fees by type:
|
||||
- Solo (e.g. "280 BYN / 7,500 RUB")
|
||||
- Duet per person (e.g. "210 BYN / 5,800 RUB pp")
|
||||
- Group per person (e.g. "190 BYN / 4,500 RUB pp")
|
||||
- Refund note (typically non-refundable)
|
||||
|
||||
### Rules (configurable tab)
|
||||
- General rules (list of text items)
|
||||
- Costume rules (list of text items)
|
||||
- Scoring criteria: name + max score (e.g. "Artistry: 0–10", "Technique: 0–10")
|
||||
- Penalties: name + value (e.g. "Fall: -2", "Exposure: DQ")
|
||||
|
||||
### Judges (configurable tab)
|
||||
- Judge profiles: name, Instagram handle, bio/description
|
||||
- These are people, not scoring criteria
|
||||
|
||||
### Members (only when LIVE)
|
||||
- Registered members scoped to this championship
|
||||
- Each with: discipline, level, style, type (solo/duet/group), progress steps, video URL, payment status, pass/fail result
|
||||
|
||||
---
|
||||
|
||||
## 5. Member Registration Flow (10 Steps)
|
||||
|
||||
```
|
||||
Step 1: 📋 Review Rules → Auto (tracked when user opens Rules tab)
|
||||
Step 2: 🎯 Select Category → Auto (saved from registration picker)
|
||||
Step 3: 🎬 Record Video → Manual toggle ("I've recorded my video")
|
||||
Step 4: 📝 Submit Video Form → Manual / link to Google Form
|
||||
Step 5: 💳 Pay Video Fee → Upload receipt screenshot → Org confirms
|
||||
Step 6: ⏳ Wait for Results → Pending until org decides
|
||||
Step 7: 🏆 Results → Auto-updates on pass/fail
|
||||
├── FAIL → Flow ends
|
||||
└── PASS → Continue ▼
|
||||
Step 8: 💰 Pay Championship Fee → Upload receipt → Org confirms
|
||||
Step 9: 📄 Submit "About Me" → Manual / link to form
|
||||
Step 10: 🛡️ Confirm Insurance → Upload document → Org confirms
|
||||
└── ✅ REGISTERED!
|
||||
```
|
||||
|
||||
### Detection Methods
|
||||
| Step | Method |
|
||||
|---|---|
|
||||
| Review rules | Auto — tracked on tab open |
|
||||
| Select category | Auto — saved from picker |
|
||||
| Record video | Manual toggle |
|
||||
| Submit video form | Manual or Gmail auto-detect (future) |
|
||||
| Pay video fee | Receipt upload → org confirms |
|
||||
| Results | Auto — org pass/fail updates member |
|
||||
| Pay championship fee | Receipt upload → org confirms |
|
||||
| About Me form | Manual or Gmail auto-detect (future) |
|
||||
| Insurance | Document upload → org confirms |
|
||||
|
||||
---
|
||||
|
||||
## 6. Notifications
|
||||
|
||||
### Types
|
||||
| Type | Trigger | Direction |
|
||||
|---|---|---|
|
||||
| 🔄 Category Changed | Org changes member's level/style | Org → Member |
|
||||
| ✅ Payment Confirmed | Org confirms uploaded receipt | Org → Member |
|
||||
| 🏆 Results | Org passes/fails video selection | Org → Member |
|
||||
| ⏰ Deadline Reminder | Auto (7d, 3d, 1d before reg_end) | System → Member |
|
||||
| 📋 Registration Confirmed | All 10 steps complete | System → Member |
|
||||
| 📢 Announcement | Org sends broadcast | Org → All Members |
|
||||
|
||||
### Delivery
|
||||
- In-app: Bell icon with unread count, notification feed
|
||||
- Push: Expo Notifications for critical updates
|
||||
- Email: Future enhancement
|
||||
|
||||
---
|
||||
|
||||
## 7. Org App — Configurable Tabs
|
||||
|
||||
When org creates a championship, it starts as DRAFT with 5 configurable sections:
|
||||
|
||||
| Section | Tab | What to configure | Mark as Done when |
|
||||
|---|---|---|---|
|
||||
| Info | Overview | Name, dates, location, venue, reg period | Has date + location |
|
||||
| Categories | Categories | Levels + styles | At least 1 level + 1 style |
|
||||
| Fees | Fees | Video fee + championship fees | Video fee filled |
|
||||
| Rules | Rules | General rules, costume rules, scoring criteria, penalties | At least 1 rule |
|
||||
| Judges | Judges | Judge profiles (name, instagram, bio) | At least 1 judge |
|
||||
|
||||
Setup progress shown on Overview tab as checklist. Each section shows green dot (done) or yellow dot (pending) in tab bar. "Go Live" button appears when all 5 sections are ✓.
|
||||
|
||||
---
|
||||
|
||||
## 8. Admin — Approval Flow
|
||||
|
||||
### Organization Approval
|
||||
- New org registers → status: `pending`
|
||||
- Admin reviews → Approve (status: `active`) or Reject (status: `blocked`)
|
||||
- Admin can also **verify** an active org (🛡️ badge)
|
||||
|
||||
### Championship Approval
|
||||
- Depends on org's verification status:
|
||||
- **Verified org** → Go Live = instant `live`
|
||||
- **Unverified org** → Go Live = `pending_approval` → admin reviews
|
||||
|
||||
### Admin Actions
|
||||
| Entity | Actions |
|
||||
|---|---|
|
||||
| Organization | Approve, Reject, Block, Unblock, Verify, Delete |
|
||||
| Championship | Approve, Reject, Suspend, Reinstate, Delete |
|
||||
| User | Warn, Block, Unblock, Delete |
|
||||
|
||||
---
|
||||
|
||||
## 9. Org Settings
|
||||
|
||||
- **Edit Organization Profile**: name, instagram (inline edit form)
|
||||
- **Notification Preferences**: toggles for push, email, registration alerts, payment alerts, deadline reminders
|
||||
- **Connected Accounts**: Instagram (connected/not), Gmail, Telegram
|
||||
- Help & Support
|
||||
- Log Out
|
||||
|
||||
---
|
||||
|
||||
## 10. Search & Discovery (Member)
|
||||
|
||||
Members can find championships by:
|
||||
- Text search (name, org name)
|
||||
- Filters: discipline, location, status (Registration Open / Upcoming / Past)
|
||||
- Sort: by date, by popularity
|
||||
|
||||
Championship cards show: name, org, dates, location, status badge, member count.
|
||||
|
||||
---
|
||||
|
||||
## 11. Future Features (Out of MVP Scope)
|
||||
|
||||
- Instagram parsing: auto-import championship data from org's posts
|
||||
- Gmail integration: auto-detect Google Forms confirmations
|
||||
- OCR results detection: parse results from Instagram photo posts
|
||||
- In-app payments: replace receipt uploads
|
||||
- In-app forms: replace Google Forms links
|
||||
- Telegram monitoring: detect results from Telegram chats
|
||||
- Category recommendation engine
|
||||
- Calendar sync (export to phone calendar)
|
||||
- Social features (see which friends are registered)
|
||||
- Multi-language (Russian + English)
|
||||
517
dancechamp-claude-code/prototypes/admin-panel.jsx
Normal file
517
dancechamp-claude-code/prototypes/admin-panel.jsx
Normal file
@@ -0,0 +1,517 @@
|
||||
import { useState } from "react";
|
||||
|
||||
/* ── Platform Data ── */
|
||||
const PLATFORM = { name: "DanceChamp", version: "1.0 MVP", totalRevenue: "12,450 BYN" };
|
||||
|
||||
const ORGS_DATA = [
|
||||
{ id: "o1", name: "Zero Gravity Team", instagram: "@zerogravity_pole", logo: "💃", status: "active", joined: "Jan 15, 2026", champsCount: 2, membersCount: 24, city: "Minsk", email: "team@zerogravity.by", verified: true },
|
||||
{ id: "o2", name: "Pole Universe", instagram: "@pole_universe", logo: "🌌", status: "active", joined: "Feb 2, 2026", champsCount: 1, membersCount: 12, city: "Moscow", email: "info@poleuniverse.ru", verified: true },
|
||||
{ id: "o3", name: "Sky Pole Studio", instagram: "@sky_pole", logo: "☁️", status: "pending", joined: "Feb 20, 2026", champsCount: 0, membersCount: 0, city: "St. Petersburg", email: "hello@skypole.ru", verified: false },
|
||||
{ id: "o4", name: "Dance Flames", instagram: "@dance_flames", logo: "🔥", status: "blocked", joined: "Dec 10, 2025", champsCount: 1, membersCount: 5, city: "Kyiv", email: "admin@danceflames.ua", verified: false, blockReason: "Fake organization — no real events" },
|
||||
];
|
||||
|
||||
const CHAMPS_DATA = [
|
||||
{ id: "c1", orgId: "o1", orgName: "Zero Gravity Team", name: "Zero Gravity", dates: "May 30, 2026", location: "Minsk", status: "live", members: 24, passed: 8, pending: 8, revenue: "4,200 BYN", orgVerified: true },
|
||||
{ id: "c2", orgId: "o1", orgName: "Zero Gravity Team", name: "Pole Star", dates: "Jul 12, 2026", location: "Moscow", status: "draft", members: 1, passed: 0, pending: 0, revenue: "0", orgVerified: true },
|
||||
{ id: "c3", orgId: "o2", orgName: "Pole Universe", name: "Galactic Pole", dates: "Sep 15, 2026", location: "Moscow", status: "live", members: 12, passed: 0, pending: 12, revenue: "1,800 BYN", orgVerified: true },
|
||||
{ id: "c4", orgId: "o3", orgName: "Sky Pole Studio", name: "Sky Open", dates: "Oct 5, 2026", location: "St. Petersburg", status: "pending_approval", members: 0, passed: 0, pending: 0, revenue: "0", orgVerified: false },
|
||||
{ id: "c5", orgId: "o4", orgName: "Dance Flames", name: "Fire Cup", dates: "Mar 1, 2026", location: "Kyiv", status: "blocked", members: 5, passed: 0, pending: 0, revenue: "250 BYN", orgVerified: false },
|
||||
];
|
||||
|
||||
const USERS_DATA = [
|
||||
{ id: "u1", name: "Alex Petrova", instagram: "@alex_pole", email: "alex@mail.ru", city: "Moscow", joined: "Jan 20, 2026", champsJoined: 2, status: "active", role: "member" },
|
||||
{ id: "u2", name: "Maria Ivanova", instagram: "@maria_exotic", email: "maria@gmail.com", city: "Minsk", joined: "Jan 22, 2026", champsJoined: 1, status: "active", role: "member" },
|
||||
{ id: "u3", name: "Elena Kozlova", instagram: "@elena.pole", email: "elena@ya.ru", city: "St. Petersburg", joined: "Feb 1, 2026", champsJoined: 1, status: "active", role: "member" },
|
||||
{ id: "u4", name: "Daria Sokolova", instagram: "@daria_art", email: "daria@mail.ru", city: "Kyiv", joined: "Feb 5, 2026", champsJoined: 1, status: "active", role: "member" },
|
||||
{ id: "u5", name: "Anna Belova", instagram: "@anna.b_pole", email: "anna@gmail.com", city: "Minsk", joined: "Feb 10, 2026", champsJoined: 1, status: "active", role: "member" },
|
||||
{ id: "u6", name: "Olga Morozova", instagram: "@olga_exotic", email: "olga@mail.ru", city: "Moscow", joined: "Feb 12, 2026", champsJoined: 3, status: "warned", role: "member", warnReason: "Disputed payment — under review" },
|
||||
{ id: "u7", name: "Ivan Petrov", instagram: "@ivan_admin", email: "ivan@zerogravity.by", city: "Minsk", joined: "Jan 10, 2026", champsJoined: 0, status: "active", role: "org_admin", org: "Zero Gravity Team" },
|
||||
{ id: "u8", name: "Spam Bot", instagram: "@totally_real", email: "spam@fake.com", city: "Unknown", joined: "Feb 22, 2026", champsJoined: 0, status: "blocked", role: "member", blockReason: "Spam account" },
|
||||
];
|
||||
|
||||
const LOGS_DATA = [
|
||||
{ id: "l1", action: "Org approved & verified", target: "Pole Universe", by: "Admin", date: "Feb 2, 2026", type: "org" },
|
||||
{ id: "l2", action: "User blocked", target: "Spam Bot", by: "Admin", date: "Feb 22, 2026", type: "user" },
|
||||
{ id: "l3", action: "Org blocked", target: "Dance Flames", by: "Admin", date: "Feb 23, 2026", type: "org" },
|
||||
{ id: "l4", action: "Champ auto-approved (verified org)", target: "Zero Gravity", by: "System", date: "Feb 1, 2026", type: "champ" },
|
||||
{ id: "l5", action: "User warned", target: "Olga Morozova", by: "Admin", date: "Feb 20, 2026", type: "user" },
|
||||
{ id: "l6", action: "New org registered (pending)", target: "Sky Pole Studio", by: "System", date: "Feb 20, 2026", type: "org" },
|
||||
{ id: "l7", action: "Champ submitted for review", target: "Sky Open", by: "Sky Pole Studio", date: "Feb 21, 2026", type: "champ" },
|
||||
];
|
||||
|
||||
/* ── Theme (admin = darker, more neutral accent) ── */
|
||||
const c = { bg: "#07060C", card: "#111019", cardH: "#18172290", brd: "#1D1C2B", text: "#F2F0FA", dim: "#5E5C72", mid: "#8F8DA6", accent: "#6366F1", accentS: "rgba(99,102,241,0.10)", green: "#10B981", greenS: "rgba(16,185,129,0.10)", yellow: "#F59E0B", yellowS: "rgba(245,158,11,0.10)", purple: "#8B5CF6", purpleS: "rgba(139,92,246,0.10)", blue: "#60A5FA", blueS: "rgba(96,165,250,0.10)", red: "#EF4444", redS: "rgba(239,68,68,0.10)", orange: "#F97316", orangeS: "rgba(249,115,22,0.10)" };
|
||||
const f = { d: "'Playfair Display',Georgia,serif", b: "'DM Sans','Segoe UI',sans-serif", m: "'JetBrains Mono',monospace" };
|
||||
|
||||
/* ── Shared UI ── */
|
||||
const Cd = ({ children, style: s }) => <div style={{ background: c.card, border: `1px solid ${c.brd}`, borderRadius: 14, padding: 16, ...s }}>{children}</div>;
|
||||
const ST = ({ children, right }) => <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", margin: "0 0 10px" }}><h3 style={{ fontFamily: f.d, fontSize: 14, fontWeight: 700, color: c.mid, margin: 0 }}>{children}</h3>{right}</div>;
|
||||
const Bg = ({ label, color, bg }) => <span style={{ fontFamily: f.m, fontSize: 8, fontWeight: 700, letterSpacing: 0.8, color, background: bg, padding: "3px 8px", borderRadius: 4 }}>{label}</span>;
|
||||
|
||||
const statusConfig = {
|
||||
active: { l: "ACTIVE", c: c.green, b: c.greenS }, live: { l: "LIVE", c: c.green, b: c.greenS },
|
||||
pending: { l: "PENDING", c: c.yellow, b: c.yellowS }, pending_approval: { l: "AWAITING REVIEW", c: c.orange, b: c.orangeS },
|
||||
draft: { l: "DRAFT", c: c.dim, b: `${c.dim}15` },
|
||||
blocked: { l: "BLOCKED", c: c.red, b: c.redS }, warned: { l: "WARNED", c: c.orange, b: c.orangeS },
|
||||
};
|
||||
|
||||
function Hdr({ title, subtitle, onBack, right }) {
|
||||
return <div style={{ padding: "14px 20px 6px", display: "flex", alignItems: "center", gap: 12 }}>
|
||||
{onBack && <div onClick={onBack} style={{ width: 32, height: 32, borderRadius: 8, background: c.card, border: `1px solid ${c.brd}`, display: "flex", alignItems: "center", justifyContent: "center", cursor: "pointer", fontSize: 15, color: c.text }}>←</div>}
|
||||
<div style={{ flex: 1, minWidth: 0 }}><h1 style={{ fontFamily: f.d, fontSize: 20, fontWeight: 700, color: c.text, margin: 0 }}>{title}</h1>{subtitle && <p style={{ fontFamily: f.b, fontSize: 11, color: c.dim, margin: "2px 0 0" }}>{subtitle}</p>}</div>
|
||||
{right}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function Nav({ active, onChange }) {
|
||||
return <div style={{ display: "flex", justifyContent: "space-around", padding: "10px 0 8px", borderTop: `1px solid ${c.brd}`, background: c.bg, flexShrink: 0 }}>
|
||||
{[{ id: "dash", i: "📊", l: "Overview" }, { id: "orgs", i: "🏢", l: "Orgs" }, { id: "champs", i: "🏆", l: "Champs" }, { id: "users", i: "👥", l: "Users" }].map(x =>
|
||||
<div key={x.id} onClick={() => onChange(x.id)} style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 2, cursor: "pointer", opacity: active === x.id ? 1 : 0.35 }}><span style={{ fontSize: 18 }}>{x.i}</span><span style={{ fontFamily: f.m, fontSize: 8, color: c.text, letterSpacing: 0.3 }}>{x.l}</span></div>
|
||||
)}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function SearchBar({ value, onChange, placeholder }) {
|
||||
return <div style={{ background: c.card, border: `1px solid ${c.brd}`, borderRadius: 12, padding: "10px 14px", display: "flex", alignItems: "center", gap: 10 }}>
|
||||
<span style={{ fontSize: 14, opacity: 0.4 }}>🔍</span>
|
||||
<input type="text" placeholder={placeholder || "Search..."} value={value} onChange={e => onChange(e.target.value)} style={{ background: "transparent", border: "none", outline: "none", color: c.text, fontFamily: f.b, fontSize: 13, width: "100%" }} />
|
||||
</div>;
|
||||
}
|
||||
|
||||
function FilterChips({ filters, active, onChange, accent }) {
|
||||
return <div style={{ display: "flex", gap: 4, overflowX: "auto", scrollbarWidth: "none" }}>
|
||||
{filters.map(fi => <div key={fi.id} onClick={() => onChange(fi.id)} style={{ fontFamily: f.m, fontSize: 9, fontWeight: 600, whiteSpace: "nowrap", color: active === fi.id ? accent || c.accent : c.dim, background: active === fi.id ? `${accent || c.accent}15` : "transparent", border: `1px solid ${active === fi.id ? `${accent || c.accent}30` : "transparent"}`, padding: "5px 10px", borderRadius: 16, cursor: "pointer" }}>{fi.l}{fi.n !== undefined ? ` (${fi.n})` : ""}</div>)}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function ActionBtn({ label, color, onClick, icon, filled }) {
|
||||
return <div onClick={onClick} style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 5, padding: "8px 14px", borderRadius: 8, background: filled ? color : `${color}15`, border: `1px solid ${filled ? color : `${color}30`}`, cursor: "pointer" }}>
|
||||
{icon && <span style={{ fontSize: 12 }}>{icon}</span>}
|
||||
<span style={{ fontFamily: f.b, fontSize: 11, fontWeight: 700, color: filled ? "#fff" : color }}>{label}</span>
|
||||
</div>;
|
||||
}
|
||||
|
||||
/* ── Dashboard ── */
|
||||
function Dashboard({ orgs, champs, users, onNav }) {
|
||||
const pendingOrgs = orgs.filter(o => o.status === "pending").length;
|
||||
const pendingChamps = champs.filter(c2 => c2.status === "pending_approval").length;
|
||||
const blockedUsers = users.filter(u => u.status === "blocked").length;
|
||||
|
||||
return <div>
|
||||
<Hdr title="Admin Panel" subtitle={`${PLATFORM.name} · ${PLATFORM.version}`} right={
|
||||
<div style={{ width: 36, height: 36, borderRadius: 10, background: `linear-gradient(135deg,${c.accent}25,${c.accent}10)`, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 14, fontFamily: f.m, fontWeight: 700, color: c.accent }}>⚡</div>
|
||||
} />
|
||||
<div style={{ padding: "6px 16px 16px", display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
|
||||
{/* Platform stats */}
|
||||
<div style={{ display: "flex", gap: 6 }}>
|
||||
{[{ n: orgs.filter(o => o.status === "active").length, l: "Orgs", co: c.accent, go: "orgs" },
|
||||
{ n: champs.filter(c2 => c2.status === "live").length, l: "Live Champs", co: c.green, go: "champs" },
|
||||
{ n: users.length, l: "Users", co: c.blue, go: "users" },
|
||||
].map(s => <div key={s.l} onClick={() => onNav(s.go)} style={{ flex: 1, background: c.card, border: `1px solid ${c.brd}`, borderRadius: 12, padding: "10px 6px", textAlign: "center", cursor: "pointer" }}>
|
||||
<p style={{ fontFamily: f.d, fontSize: 20, fontWeight: 700, color: s.co, margin: 0 }}>{s.n}</p>
|
||||
<p style={{ fontFamily: f.m, fontSize: 7, color: c.dim, margin: "2px 0 0", textTransform: "uppercase" }}>{s.l}</p>
|
||||
</div>)}
|
||||
</div>
|
||||
|
||||
{/* Needs attention */}
|
||||
{(pendingOrgs > 0 || pendingChamps > 0) && <Cd style={{ background: `${c.yellow}06`, border: `1px solid ${c.yellow}20` }}>
|
||||
<ST right={<Bg label="ACTION" color={c.yellow} bg={c.yellowS} />}>⚡ Needs Attention</ST>
|
||||
{pendingOrgs > 0 && <div onClick={() => onNav("orgs")} style={{ display: "flex", alignItems: "center", gap: 10, padding: "10px 0", borderBottom: `1px solid ${c.brd}`, cursor: "pointer" }}>
|
||||
<span style={{ fontSize: 16 }}>🏢</span>
|
||||
<span style={{ fontFamily: f.b, fontSize: 13, color: c.text, flex: 1 }}>Organizations awaiting approval</span>
|
||||
<span style={{ fontFamily: f.m, fontSize: 14, fontWeight: 700, color: c.yellow }}>{pendingOrgs}</span>
|
||||
<span style={{ color: c.dim }}>›</span>
|
||||
</div>}
|
||||
{pendingChamps > 0 && <div onClick={() => onNav("champs")} style={{ display: "flex", alignItems: "center", gap: 10, padding: "10px 0", cursor: "pointer" }}>
|
||||
<span style={{ fontSize: 16 }}>🏆</span>
|
||||
<span style={{ fontFamily: f.b, fontSize: 13, color: c.text, flex: 1 }}>Champs awaiting approval (unverified orgs)</span>
|
||||
<span style={{ fontFamily: f.m, fontSize: 14, fontWeight: 700, color: c.yellow }}>{pendingChamps}</span>
|
||||
<span style={{ color: c.dim }}>›</span>
|
||||
</div>}
|
||||
</Cd>}
|
||||
|
||||
{/* Quick stats */}
|
||||
<Cd>
|
||||
<ST>Platform Health</ST>
|
||||
{[{ l: "Total revenue", v: PLATFORM.totalRevenue, co: c.green },
|
||||
{ l: "Active orgs", v: `${orgs.filter(o => o.status === "active").length}/${orgs.length}`, co: c.accent },
|
||||
{ l: "Blocked users", v: `${blockedUsers}`, co: blockedUsers > 0 ? c.red : c.green },
|
||||
{ l: "Avg members/champ", v: Math.round(users.filter(u => u.role === "member").length / Math.max(champs.filter(c2 => c2.status === "live").length, 1)), co: c.blue },
|
||||
].map(s => <div key={s.l} style={{ display: "flex", justifyContent: "space-between", padding: "8px 0", borderBottom: `1px solid ${c.brd}` }}>
|
||||
<span style={{ fontFamily: f.b, fontSize: 12, color: c.mid }}>{s.l}</span>
|
||||
<span style={{ fontFamily: f.m, fontSize: 13, fontWeight: 700, color: s.co }}>{s.v}</span>
|
||||
</div>)}
|
||||
</Cd>
|
||||
|
||||
{/* Recent activity */}
|
||||
<Cd>
|
||||
<ST right={<span style={{ fontFamily: f.m, fontSize: 10, color: c.dim }}>{LOGS_DATA.length} entries</span>}>Recent Activity</ST>
|
||||
{LOGS_DATA.slice(0, 5).map(log => {
|
||||
const tc = { org: c.accent, user: c.blue, champ: c.green }[log.type] || c.dim;
|
||||
return <div key={log.id} style={{ display: "flex", alignItems: "center", gap: 10, padding: "7px 0", borderBottom: `1px solid ${c.brd}` }}>
|
||||
<div style={{ width: 6, height: 6, borderRadius: 3, background: tc, flexShrink: 0 }} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<p style={{ fontFamily: f.b, fontSize: 12, color: c.text, margin: 0 }}>{log.action}: <span style={{ color: tc }}>{log.target}</span></p>
|
||||
<p style={{ fontFamily: f.m, fontSize: 9, color: c.dim, margin: "2px 0 0" }}>{log.date} · {log.by}</p>
|
||||
</div>
|
||||
</div>;
|
||||
})}
|
||||
</Cd>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
/* ── Organizations ── */
|
||||
function OrgsList({ orgs, onOrgTap }) {
|
||||
const [search, setSearch] = useState("");
|
||||
const [filter, setFilter] = useState("all");
|
||||
|
||||
const filters = [
|
||||
{ id: "all", l: "All", n: orgs.length },
|
||||
{ id: "active", l: "✅ Active", n: orgs.filter(o => o.status === "active").length },
|
||||
{ id: "pending", l: "⏳ Pending", n: orgs.filter(o => o.status === "pending").length },
|
||||
{ id: "blocked", l: "🚫 Blocked", n: orgs.filter(o => o.status === "blocked").length },
|
||||
];
|
||||
|
||||
const filtered = orgs.filter(o => {
|
||||
const q = !search || o.name.toLowerCase().includes(search.toLowerCase()) || o.instagram.toLowerCase().includes(search.toLowerCase());
|
||||
if (!q) return false;
|
||||
if (filter === "active") return o.status === "active";
|
||||
if (filter === "pending") return o.status === "pending";
|
||||
if (filter === "blocked") return o.status === "blocked";
|
||||
return true;
|
||||
});
|
||||
|
||||
return <div>
|
||||
<Hdr title="Organizations" subtitle={`${orgs.length} registered`} />
|
||||
<div style={{ padding: "6px 16px 16px", display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
<SearchBar value={search} onChange={setSearch} placeholder="Search org name or @handle..." />
|
||||
<FilterChips filters={filters} active={filter} onChange={setFilter} />
|
||||
{filtered.map(o => {
|
||||
const st = statusConfig[o.status] || statusConfig.active;
|
||||
return <div key={o.id} onClick={() => onOrgTap(o)} style={{ background: c.card, border: `1px solid ${c.brd}`, borderRadius: 14, padding: 14, cursor: "pointer" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||
<div style={{ width: 40, height: 40, borderRadius: 10, background: `${c.accent}15`, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 18 }}>{o.logo}</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<p style={{ fontFamily: f.b, fontSize: 14, fontWeight: 600, color: c.text, margin: 0 }}>{o.name}</p>
|
||||
<Bg label={st.l} color={st.c} bg={st.b} />
|
||||
</div>
|
||||
<p style={{ fontFamily: f.m, fontSize: 10, color: c.accent, margin: "2px 0 0" }}>{o.instagram}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 12, marginTop: 8, paddingTop: 8, borderTop: `1px solid ${c.brd}` }}>
|
||||
<span style={{ fontFamily: f.b, fontSize: 10, color: c.mid }}>📍 {o.city}</span>
|
||||
<span style={{ fontFamily: f.b, fontSize: 10, color: c.mid }}>🏆 {o.champsCount} champs</span>
|
||||
<span style={{ fontFamily: f.b, fontSize: 10, color: c.mid }}>👥 {o.membersCount} members</span>
|
||||
</div>
|
||||
</div>;
|
||||
})}
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
/* ── Org Detail ── */
|
||||
function OrgDetail({ org: initial, onBack, champs }) {
|
||||
const [o, setO] = useState(initial);
|
||||
const st = statusConfig[o.status] || statusConfig.active;
|
||||
const orgChamps = champs.filter(c2 => c2.orgId === o.id);
|
||||
|
||||
return <div style={{ flex: 1, overflow: "auto" }}>
|
||||
<Hdr title={o.name} subtitle={o.instagram} onBack={onBack} />
|
||||
<div style={{ padding: "6px 16px 20px", display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
{/* Profile */}
|
||||
<Cd style={{ display: "flex", alignItems: "center", gap: 14, padding: "14px 16px" }}>
|
||||
<div style={{ width: 54, height: 54, borderRadius: 14, background: `${c.accent}15`, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 24 }}>{o.logo}</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<p style={{ fontFamily: f.b, fontSize: 16, fontWeight: 600, color: c.text, margin: 0 }}>{o.name}</p>
|
||||
<p style={{ fontFamily: f.m, fontSize: 11, color: c.accent, margin: "2px 0 0" }}>{o.instagram}</p>
|
||||
<p style={{ fontFamily: f.b, fontSize: 11, color: c.dim, margin: "2px 0 0" }}>📍 {o.city} · 📧 {o.email}</p>
|
||||
</div>
|
||||
<Bg label={st.l} color={st.c} bg={st.b} />
|
||||
</Cd>
|
||||
|
||||
{/* Info */}
|
||||
<Cd>
|
||||
<ST>Details</ST>
|
||||
{[{ l: "Joined", v: o.joined }, { l: "Championships", v: o.champsCount }, { l: "Total members", v: o.membersCount }, { l: "Verified", v: o.verified ? "✅ Yes" : "❌ No" }].map(r =>
|
||||
<div key={r.l} style={{ display: "flex", justifyContent: "space-between", padding: "7px 0", borderBottom: `1px solid ${c.brd}` }}>
|
||||
<span style={{ fontFamily: f.m, fontSize: 10, color: c.dim, textTransform: "uppercase" }}>{r.l}</span>
|
||||
<span style={{ fontFamily: f.b, fontSize: 12, color: c.text }}>{r.v}</span>
|
||||
</div>
|
||||
)}
|
||||
</Cd>
|
||||
|
||||
{/* Approval policy */}
|
||||
<Cd style={{ background: o.verified ? `${c.green}06` : `${c.yellow}06`, border: `1px solid ${o.verified ? `${c.green}20` : `${c.yellow}20`}` }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||
<span style={{ fontSize: 18 }}>{o.verified ? "🛡️" : "⏳"}</span>
|
||||
<div>
|
||||
<p style={{ fontFamily: f.b, fontSize: 12, fontWeight: 600, color: o.verified ? c.green : c.yellow, margin: 0 }}>{o.verified ? "Verified — Auto-approve events" : "Unverified — Events need manual approval"}</p>
|
||||
<p style={{ fontFamily: f.b, fontSize: 10, color: c.dim, margin: "2px 0 0" }}>{o.verified ? "Championships go live instantly when org clicks 'Go Live'" : "Admin must review & approve each championship before it becomes visible"}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Cd>
|
||||
|
||||
{/* Championships */}
|
||||
{orgChamps.length > 0 && <Cd>
|
||||
<ST right={<span style={{ fontFamily: f.m, fontSize: 10, color: c.dim }}>{orgChamps.length}</span>}>Championships</ST>
|
||||
{orgChamps.map(ch => {
|
||||
const cs = statusConfig[ch.status] || statusConfig.draft;
|
||||
return <div key={ch.id} style={{ display: "flex", justifyContent: "space-between", alignItems: "center", padding: "8px 0", borderBottom: `1px solid ${c.brd}` }}>
|
||||
<div><p style={{ fontFamily: f.b, fontSize: 12, color: c.text, margin: 0 }}>{ch.name}</p><p style={{ fontFamily: f.m, fontSize: 10, color: c.dim, margin: "2px 0 0" }}>{ch.dates} · {ch.location}</p></div>
|
||||
<Bg label={cs.l} color={cs.c} bg={cs.b} />
|
||||
</div>;
|
||||
})}
|
||||
</Cd>}
|
||||
|
||||
{/* Block reason */}
|
||||
{o.blockReason && <Cd style={{ background: `${c.red}06`, border: `1px solid ${c.red}20` }}>
|
||||
<p style={{ fontFamily: f.m, fontSize: 9, color: c.red, margin: "0 0 4px", textTransform: "uppercase", letterSpacing: 0.5 }}>Block Reason</p>
|
||||
<p style={{ fontFamily: f.b, fontSize: 12, color: c.text, margin: 0 }}>{o.blockReason}</p>
|
||||
</Cd>}
|
||||
|
||||
{/* Actions */}
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{o.status === "pending" && <div style={{ display: "flex", gap: 8 }}>
|
||||
<ActionBtn label="Approve" color={c.green} onClick={() => setO(p => ({ ...p, status: "active", verified: true }))} icon="✅" filled />
|
||||
<ActionBtn label="Reject" color={c.red} onClick={() => setO(p => ({ ...p, status: "blocked", blockReason: "Rejected by admin" }))} icon="❌" filled />
|
||||
</div>}
|
||||
{o.status === "active" && <ActionBtn label="Block Organization" color={c.red} onClick={() => setO(p => ({ ...p, status: "blocked", blockReason: "Blocked by admin" }))} icon="🚫" />}
|
||||
{o.status === "blocked" && <ActionBtn label="Unblock Organization" color={c.green} onClick={() => setO(p => ({ ...p, status: "active", blockReason: null }))} icon="✅" />}
|
||||
{!o.verified && o.status !== "blocked" && <ActionBtn label="Verify Organization" color={c.accent} onClick={() => setO(p => ({ ...p, verified: true }))} icon="🛡️" />}
|
||||
<ActionBtn label="Delete Organization" color={c.red} onClick={() => {}} icon="🗑️" />
|
||||
</div>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
/* ── Championships ── */
|
||||
function ChampsList({ champs, onChampTap }) {
|
||||
const [search, setSearch] = useState("");
|
||||
const [filter, setFilter] = useState("all");
|
||||
|
||||
const filters = [
|
||||
{ id: "all", l: "All", n: champs.length },
|
||||
{ id: "live", l: "🟢 Live", n: champs.filter(c2 => c2.status === "live").length },
|
||||
{ id: "pending_approval", l: "⏳ Awaiting", n: champs.filter(c2 => c2.status === "pending_approval").length },
|
||||
{ id: "draft", l: "📝 Draft", n: champs.filter(c2 => c2.status === "draft").length },
|
||||
{ id: "blocked", l: "🚫 Blocked", n: champs.filter(c2 => c2.status === "blocked").length },
|
||||
];
|
||||
|
||||
const filtered = champs.filter(ch => {
|
||||
const q = !search || ch.name.toLowerCase().includes(search.toLowerCase()) || ch.orgName.toLowerCase().includes(search.toLowerCase());
|
||||
if (!q) return false;
|
||||
if (filter !== "all") return ch.status === filter;
|
||||
return true;
|
||||
});
|
||||
|
||||
return <div>
|
||||
<Hdr title="Championships" subtitle={`${champs.length} total`} />
|
||||
<div style={{ padding: "6px 16px 16px", display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
<SearchBar value={search} onChange={setSearch} placeholder="Search championship or org..." />
|
||||
<FilterChips filters={filters} active={filter} onChange={setFilter} />
|
||||
{filtered.map(ch => {
|
||||
const st = statusConfig[ch.status] || statusConfig.draft;
|
||||
return <div key={ch.id} onClick={() => onChampTap(ch)} style={{ background: c.card, border: `1px solid ${c.brd}`, borderRadius: 14, padding: 14, cursor: "pointer" }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: 4 }}>
|
||||
<div><p style={{ fontFamily: f.b, fontSize: 14, fontWeight: 600, color: c.text, margin: 0 }}>{ch.name}</p><p style={{ fontFamily: f.m, fontSize: 10, color: c.accent, margin: "2px 0 0" }}>by {ch.orgName} {ch.orgVerified ? "🛡️" : ""}</p></div>
|
||||
<Bg label={st.l} color={st.c} bg={st.b} />
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 12, marginTop: 6 }}>
|
||||
<span style={{ fontFamily: f.b, fontSize: 10, color: c.mid }}>📅 {ch.dates}</span>
|
||||
<span style={{ fontFamily: f.b, fontSize: 10, color: c.mid }}>📍 {ch.location}</span>
|
||||
<span style={{ fontFamily: f.b, fontSize: 10, color: c.mid }}>👥 {ch.members}</span>
|
||||
</div>
|
||||
</div>;
|
||||
})}
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
/* ── Championship Detail ── */
|
||||
function ChampDetail({ ch: initial, onBack }) {
|
||||
const [ch, setCh] = useState(initial);
|
||||
const st = statusConfig[ch.status] || statusConfig.draft;
|
||||
|
||||
return <div style={{ flex: 1, overflow: "auto" }}>
|
||||
<Hdr title={ch.name} subtitle={`by ${ch.orgName}`} onBack={onBack} />
|
||||
<div style={{ padding: "6px 16px 20px", display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
<Cd style={{ display: "flex", alignItems: "center", gap: 14, padding: "14px 16px" }}>
|
||||
<div style={{ width: 50, height: 50, borderRadius: 14, background: `${c.accent}15`, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 22 }}>🏆</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<p style={{ fontFamily: f.b, fontSize: 16, fontWeight: 600, color: c.text, margin: 0 }}>{ch.name}</p>
|
||||
<p style={{ fontFamily: f.m, fontSize: 11, color: c.accent, margin: "2px 0 0" }}>{ch.orgName}</p>
|
||||
<p style={{ fontFamily: f.b, fontSize: 11, color: c.dim, margin: "2px 0 0" }}>📅 {ch.dates} · 📍 {ch.location}</p>
|
||||
</div>
|
||||
<Bg label={st.l} color={st.c} bg={st.b} />
|
||||
</Cd>
|
||||
|
||||
<Cd>
|
||||
<ST>Stats</ST>
|
||||
{[{ l: "Members", v: ch.members }, { l: "Passed", v: ch.passed }, { l: "Pending", v: ch.pending }, { l: "Revenue", v: ch.revenue }].map(r =>
|
||||
<div key={r.l} style={{ display: "flex", justifyContent: "space-between", padding: "7px 0", borderBottom: `1px solid ${c.brd}` }}>
|
||||
<span style={{ fontFamily: f.m, fontSize: 10, color: c.dim, textTransform: "uppercase" }}>{r.l}</span>
|
||||
<span style={{ fontFamily: f.b, fontSize: 12, color: c.text }}>{r.v}</span>
|
||||
</div>
|
||||
)}
|
||||
</Cd>
|
||||
|
||||
{/* Approval info */}
|
||||
{ch.orgVerified !== undefined && <Cd>
|
||||
<ST>Approval Policy</ST>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, padding: "8px 0" }}>
|
||||
<span style={{ fontSize: 16 }}>{ch.orgVerified ? "🛡️" : "⏳"}</span>
|
||||
<div style={{ flex: 1 }}>
|
||||
<p style={{ fontFamily: f.b, fontSize: 12, color: c.text, margin: 0 }}>{ch.orgVerified ? "Verified org — auto-approved" : "Unverified org — manual review required"}</p>
|
||||
<p style={{ fontFamily: f.b, fontSize: 10, color: c.dim, margin: "2px 0 0" }}>{ch.orgVerified ? "This org can go live without admin approval" : "Admin must approve before members can see this event"}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Cd>}
|
||||
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{ch.status === "pending_approval" && <div style={{ display: "flex", gap: 8 }}>
|
||||
<ActionBtn label="Approve" color={c.green} onClick={() => setCh(p => ({ ...p, status: "live" }))} icon="✅" filled />
|
||||
<ActionBtn label="Reject" color={c.red} onClick={() => setCh(p => ({ ...p, status: "blocked" }))} icon="❌" filled />
|
||||
</div>}
|
||||
{ch.status === "live" && <ActionBtn label="Suspend Event" color={c.red} onClick={() => setCh(p => ({ ...p, status: "blocked" }))} icon="⏸️" />}
|
||||
{ch.status === "blocked" && <ActionBtn label="Reinstate Event" color={c.green} onClick={() => setCh(p => ({ ...p, status: "live" }))} icon="✅" />}
|
||||
<ActionBtn label="Delete Championship" color={c.red} onClick={() => {}} icon="🗑️" />
|
||||
</div>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
/* ── Users ── */
|
||||
function UsersList({ users, onUserTap }) {
|
||||
const [search, setSearch] = useState("");
|
||||
const [filter, setFilter] = useState("all");
|
||||
|
||||
const filters = [
|
||||
{ id: "all", l: "All", n: users.length },
|
||||
{ id: "active", l: "✅ Active", n: users.filter(u => u.status === "active").length },
|
||||
{ id: "warned", l: "⚠️ Warned", n: users.filter(u => u.status === "warned").length },
|
||||
{ id: "blocked", l: "🚫 Blocked", n: users.filter(u => u.status === "blocked").length },
|
||||
{ id: "org_admin", l: "🏢 Org Admins", n: users.filter(u => u.role === "org_admin").length },
|
||||
];
|
||||
|
||||
const filtered = users.filter(u => {
|
||||
const q = !search || u.name.toLowerCase().includes(search.toLowerCase()) || u.instagram.toLowerCase().includes(search.toLowerCase()) || u.email.toLowerCase().includes(search.toLowerCase());
|
||||
if (!q) return false;
|
||||
if (filter === "active") return u.status === "active";
|
||||
if (filter === "warned") return u.status === "warned";
|
||||
if (filter === "blocked") return u.status === "blocked";
|
||||
if (filter === "org_admin") return u.role === "org_admin";
|
||||
return true;
|
||||
});
|
||||
|
||||
return <div>
|
||||
<Hdr title="Users" subtitle={`${users.length} total`} />
|
||||
<div style={{ padding: "6px 16px 16px", display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
<SearchBar value={search} onChange={setSearch} placeholder="Search name, @handle, or email..." />
|
||||
<FilterChips filters={filters} active={filter} onChange={setFilter} />
|
||||
{filtered.map(u => {
|
||||
const st = statusConfig[u.status] || statusConfig.active;
|
||||
return <div key={u.id} onClick={() => onUserTap(u)} style={{ background: c.card, border: `1px solid ${c.brd}`, borderRadius: 14, padding: 12, cursor: "pointer" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||
<div style={{ width: 38, height: 38, borderRadius: 10, background: u.role === "org_admin" ? `${c.purple}15` : `${c.blue}15`, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 16 }}>{u.role === "org_admin" ? "🏢" : "👤"}</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<p style={{ fontFamily: f.b, fontSize: 13, fontWeight: 600, color: c.text, margin: 0 }}>{u.name}</p>
|
||||
<Bg label={st.l} color={st.c} bg={st.b} />
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 8, marginTop: 2 }}>
|
||||
<span style={{ fontFamily: f.m, fontSize: 10, color: c.accent }}>{u.instagram}</span>
|
||||
<span style={{ fontFamily: f.b, fontSize: 10, color: c.dim }}>{u.city}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>;
|
||||
})}
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
/* ── User Detail ── */
|
||||
function UserDetail({ user: initial, onBack }) {
|
||||
const [u, setU] = useState(initial);
|
||||
const st = statusConfig[u.status] || statusConfig.active;
|
||||
|
||||
return <div style={{ flex: 1, overflow: "auto" }}>
|
||||
<Hdr title={u.name} subtitle={u.instagram} onBack={onBack} />
|
||||
<div style={{ padding: "6px 16px 20px", display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
<Cd style={{ display: "flex", alignItems: "center", gap: 14, padding: "14px 16px" }}>
|
||||
<div style={{ width: 50, height: 50, borderRadius: 14, background: `${c.blue}15`, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 22 }}>👤</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<p style={{ fontFamily: f.b, fontSize: 16, fontWeight: 600, color: c.text, margin: 0 }}>{u.name}</p>
|
||||
<p style={{ fontFamily: f.m, fontSize: 11, color: c.accent, margin: "2px 0 0" }}>{u.instagram}</p>
|
||||
<p style={{ fontFamily: f.b, fontSize: 11, color: c.dim, margin: "2px 0 0" }}>📧 {u.email}</p>
|
||||
</div>
|
||||
<Bg label={st.l} color={st.c} bg={st.b} />
|
||||
</Cd>
|
||||
|
||||
<Cd>
|
||||
<ST>Info</ST>
|
||||
{[{ l: "City", v: u.city }, { l: "Joined", v: u.joined }, { l: "Championships", v: u.champsJoined },
|
||||
{ l: "Role", v: u.role === "org_admin" ? `Org Admin (${u.org})` : "Member" },
|
||||
].map(r => <div key={r.l} style={{ display: "flex", justifyContent: "space-between", padding: "7px 0", borderBottom: `1px solid ${c.brd}` }}>
|
||||
<span style={{ fontFamily: f.m, fontSize: 10, color: c.dim, textTransform: "uppercase" }}>{r.l}</span>
|
||||
<span style={{ fontFamily: f.b, fontSize: 12, color: c.text }}>{r.v}</span>
|
||||
</div>)}
|
||||
</Cd>
|
||||
|
||||
{(u.blockReason || u.warnReason) && <Cd style={{ background: `${u.status === "blocked" ? c.red : c.orange}06`, border: `1px solid ${u.status === "blocked" ? c.red : c.orange}20` }}>
|
||||
<p style={{ fontFamily: f.m, fontSize: 9, color: u.status === "blocked" ? c.red : c.orange, margin: "0 0 4px", textTransform: "uppercase", letterSpacing: 0.5 }}>{u.status === "blocked" ? "Block" : "Warning"} Reason</p>
|
||||
<p style={{ fontFamily: f.b, fontSize: 12, color: c.text, margin: 0 }}>{u.blockReason || u.warnReason}</p>
|
||||
</Cd>}
|
||||
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{u.status === "active" && <>
|
||||
<ActionBtn label="Warn User" color={c.orange} onClick={() => setU(p => ({ ...p, status: "warned", warnReason: "Warning issued by admin" }))} icon="⚠️" />
|
||||
<ActionBtn label="Block User" color={c.red} onClick={() => setU(p => ({ ...p, status: "blocked", blockReason: "Blocked by admin" }))} icon="🚫" />
|
||||
</>}
|
||||
{u.status === "warned" && <>
|
||||
<ActionBtn label="Remove Warning" color={c.green} onClick={() => setU(p => ({ ...p, status: "active", warnReason: null }))} icon="✅" />
|
||||
<ActionBtn label="Block User" color={c.red} onClick={() => setU(p => ({ ...p, status: "blocked", blockReason: "Blocked after warning" }))} icon="🚫" />
|
||||
</>}
|
||||
{u.status === "blocked" && <ActionBtn label="Unblock User" color={c.green} onClick={() => setU(p => ({ ...p, status: "active", blockReason: null }))} icon="✅" />}
|
||||
<ActionBtn label="Delete User" color={c.red} onClick={() => {}} icon="🗑️" />
|
||||
</div>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
/* ── App Shell ── */
|
||||
export default function AdminApp() {
|
||||
const [scr, setScr] = useState("dash");
|
||||
const [sel, setSel] = useState(null);
|
||||
|
||||
const go = (screen, data) => { setScr(screen); setSel(data || null); };
|
||||
|
||||
const render = () => {
|
||||
if (scr === "orgDetail" && sel) return <OrgDetail org={sel} onBack={() => go("orgs")} champs={CHAMPS_DATA} />;
|
||||
if (scr === "champDetail" && sel) return <ChampDetail ch={sel} onBack={() => go("champs")} />;
|
||||
if (scr === "userDetail" && sel) return <UserDetail user={sel} onBack={() => go("users")} />;
|
||||
if (scr === "orgs") return <OrgsList orgs={ORGS_DATA} onOrgTap={o => go("orgDetail", o)} />;
|
||||
if (scr === "champs") return <ChampsList champs={CHAMPS_DATA} onChampTap={ch => go("champDetail", ch)} />;
|
||||
if (scr === "users") return <UsersList users={USERS_DATA} onUserTap={u => go("userDetail", u)} />;
|
||||
return <Dashboard orgs={ORGS_DATA} champs={CHAMPS_DATA} users={USERS_DATA} onNav={setScr} />;
|
||||
};
|
||||
|
||||
const showNav = ["dash", "orgs", "champs", "users"].includes(scr);
|
||||
|
||||
return <div style={{ display: "flex", justifyContent: "center", alignItems: "center", minHeight: "100vh", background: "#020106", padding: 20, fontFamily: f.b }}>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;600;700&family=DM+Sans:wght@400;500;600&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet" />
|
||||
<style>{`*::-webkit-scrollbar{display:none}*{scrollbar-width:none}`}</style>
|
||||
<div style={{ width: 375, height: 740, background: c.bg, borderRadius: 36, overflow: "hidden", display: "flex", flexDirection: "column", border: `1.5px solid ${c.brd}`, boxShadow: `0 0 80px rgba(99,102,241,0.06),0 20px 40px rgba(0,0,0,0.5)` }}>
|
||||
<div style={{ padding: "8px 24px", display: "flex", justifyContent: "space-between", alignItems: "center", flexShrink: 0 }}>
|
||||
<span style={{ fontFamily: f.m, fontSize: 11, color: c.dim }}>9:41</span>
|
||||
<div style={{ width: 100, height: 28, background: "#000", borderRadius: 14 }} />
|
||||
<span style={{ fontFamily: f.m, fontSize: 11, color: c.dim }}>●●●</span>
|
||||
</div>
|
||||
<div style={{ flex: 1, overflow: "auto", minHeight: 0 }}>{render()}</div>
|
||||
{showNav && <Nav active={scr} onChange={setScr} />}
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
643
dancechamp-claude-code/prototypes/member-app.jsx
Normal file
643
dancechamp-claude-code/prototypes/member-app.jsx
Normal file
@@ -0,0 +1,643 @@
|
||||
import { useState } from "react";
|
||||
|
||||
/* ── Data ── */
|
||||
const CHAMPS = [
|
||||
{
|
||||
id: "1", name: "Zero Gravity", subtitle: "International Pole Exotic Championship",
|
||||
org: "Zero Gravity Team", dates: "May 30, 2026", location: "Minsk, Belarus",
|
||||
venue: "Prime Hall", address: "Pr. Pobeditelei, 65",
|
||||
disciplines: [
|
||||
{ name: "Exotic Pole Dance", performanceReq: "70% floor & mid-level, 30% upper level", categories: [
|
||||
{ name: "Beginners", duration: "2:00–3:00", eligibility: "Up to 2 yrs, no instructor/pro background", type: "solo" },
|
||||
{ name: "Amateur", duration: "2:30–3:00", eligibility: "2–4 yrs, no instructor/pro background", type: "solo" },
|
||||
{ name: "Semi-Pro", duration: "2:50–3:20", eligibility: "3+ yrs, instructor OR pro OR prizes in Amateur", type: "solo" },
|
||||
{ name: "Profi", duration: "3:00–3:30", eligibility: "4+ yrs, instructor OR pro OR prizes in Semi-Pro", type: "solo" },
|
||||
{ name: "Elite", duration: "3:00–4:00", eligibility: "3+ prizes in Profi OR widely known", type: "solo" },
|
||||
{ name: "Duets & Groups", duration: "3:00–4:20", eligibility: "Open to all levels", type: "group" },
|
||||
]},
|
||||
{ name: "Pole Art", performanceReq: "60% floor & mid-level, 40% upper level", categories: [
|
||||
{ name: "Amateur", duration: "2:30–3:00", eligibility: "Up to 2 yrs, no instructor/pro background", type: "solo" },
|
||||
{ name: "Semi-Pro", duration: "2:50–3:20", eligibility: "3+ yrs, instructor OR pro OR prizes in Amateur", type: "solo" },
|
||||
{ name: "Profi", duration: "3:00–3:30", eligibility: "4+ yrs, instructor OR pro OR prizes in Semi-Pro", type: "solo" },
|
||||
]},
|
||||
],
|
||||
fees: { videoSelection: "50 BYN / 1,500 RUB", championship: { solo: "280 BYN / 7,500 RUB", duet: "210 BYN / 5,800 RUB pp", group: "190 BYN / 4,500 RUB pp" }, refundNote: "Non-refundable. All fees are charitable contributions." },
|
||||
videoReqs: { minDuration: "1:30", editing: "No editing or splicing", maxAge: "Less than 1 year old", note: "Must reflect your level" },
|
||||
judging: [
|
||||
{ name: "Image", max: 10, desc: "Costume, hair, makeup, originality" },
|
||||
{ name: "Artistry", max: 10, desc: "Charisma, stage presence, emotion" },
|
||||
{ name: "Choreography", max: 10, desc: "Body control, complexity, originality" },
|
||||
{ name: "Musicality", max: 10, desc: "Timing, feeling, accent play" },
|
||||
{ name: "Technique", max: 10, desc: "Clean execution, transitions, tricks" },
|
||||
{ name: "Overall", max: 10, desc: "General impression" },
|
||||
{ name: "Synchronicity", max: 10, desc: "Duets only" },
|
||||
],
|
||||
penalties: [
|
||||
{ name: "Missed element", points: -2 }, { name: "Fall", points: -2 },
|
||||
{ name: "Leaving stage", consequence: "DQ" }, { name: "Exposure", consequence: "DQ" },
|
||||
{ name: "Substance influence", consequence: "DQ" }, { name: "No special shoes", consequence: "DQ" },
|
||||
],
|
||||
venueSpecs: { poles: "2 (Static & Spinning)", poleHeight: "3.5 m", poleDiameter: "42 mm", stageSize: "6m × 14m" },
|
||||
costumeRules: ["Neat and well-fitted", "No advertising", "No spikes/sharp objects", "No thongs/sheer/pasties", "Specialized shoes for Exotic", "Creativity is scored"],
|
||||
generalRules: ["Must be 18+", "No medical contraindications", "Valid life & health insurance", "No lotions/bronzers 24h before", "Grip aids allowed (no wax/rosin)", "Judges' decision is final", "Organizers may change your category"],
|
||||
prizes: ["1st–3rd in each category", "Nominations per block", "Medals, diplomas, sponsor prizes", "All get participation diplomas", "1st Elite → judge next champ"],
|
||||
resultsChannels: ["Email", "Instagram", "Telegram"],
|
||||
applicationDeadline: "August 22, 2026",
|
||||
formUrl: "https://docs.google.com/forms/d/e/1FAIpQLSfLaNg5Sf2QMAI6anpMrnLu-2qYfT3tdwh0dsynQFn8xMhi2g/viewform",
|
||||
status: "registration_open", accent: "#D4145A", image: "💃",
|
||||
},
|
||||
{
|
||||
id: "2", name: "Pole Star", subtitle: "National Pole Championship",
|
||||
org: "Pole Star Events", dates: "Jul 12–13, 2026", location: "Moscow, Russia", venue: "Crystal Hall",
|
||||
disciplines: [{ name: "Exotic Pole Dance", categories: [
|
||||
{ name: "Amateur", duration: "2:30–3:00", eligibility: "2–4 years", type: "solo" },
|
||||
{ name: "Profi", duration: "3:00–3:30", eligibility: "4+ years", type: "solo" },
|
||||
]}],
|
||||
fees: { videoSelection: "2,000 RUB", championship: { solo: "8,000 RUB" } },
|
||||
videoReqs: { minDuration: "1:00", editing: "No editing", maxAge: "6 months" },
|
||||
status: "upcoming", applicationDeadline: "Jun 1, 2026", accent: "#7C3AED", image: "⭐",
|
||||
},
|
||||
];
|
||||
|
||||
const STEPS = [
|
||||
{ id: "s1", label: "Review rules & eligibility", icon: "📋", detect: "auto", detectLabel: "Auto: tracked in app" },
|
||||
{ id: "s2", label: "Select category", icon: "🏷️", detect: "auto", detectLabel: "Auto: saved in app" },
|
||||
{ id: "s3", label: "Record video (min 1:30)", icon: "🎬", detect: "manual", detectLabel: "You confirm" },
|
||||
{ id: "s4", label: "Submit video selection form", icon: "📤", detect: "email", detectLabel: "Auto: Gmail confirmation" },
|
||||
{ id: "s5", label: "Pay video selection fee", icon: "💳", warn: true, detect: "receipt", detectLabel: "Upload receipt → Org confirms" },
|
||||
{ id: "s6", label: "Results (auto-detected)", icon: "🤖", detect: "auto", detectLabel: "Auto: Instagram OCR" },
|
||||
{ id: "s7", label: "Pay championship fee", icon: "💰", warn: true, detect: "receipt", detectLabel: "Upload receipt → Org confirms" },
|
||||
{ id: "s8", label: 'Fill "About Me" form', icon: "👤", detect: "email", detectLabel: "Auto: Gmail confirmation" },
|
||||
{ id: "s9", label: "Confirm insurance", icon: "🛡️", detect: "receipt", detectLabel: "Upload doc → Org confirms" },
|
||||
{ id: "s10", label: "Submit music & performance", icon: "🎶", detect: "email", detectLabel: "Auto: Gmail confirmation" },
|
||||
];
|
||||
|
||||
const USER = { name: "Alex", city: "Moscow", disciplines: ["Pole Exotic", "Pole Art"], experienceYears: 3, isInstructor: false, instagram: "@alex_pole" };
|
||||
|
||||
const NOTIFICATIONS = [
|
||||
{ id: "n1", type: "category_change", champ: "Zero Gravity", from: "Amateur", to: "Semi-Pro", field: "Level", date: "Feb 24, 2026", read: false, message: "Your level was changed from Amateur to Semi-Pro by the organizer." },
|
||||
{ id: "n2", type: "payment_confirmed", champ: "Zero Gravity", date: "Feb 23, 2026", read: false, message: "Your video selection fee payment has been confirmed." },
|
||||
{ id: "n3", type: "result", champ: "Zero Gravity", date: "Feb 22, 2026", read: true, message: "Video selection results are out! You passed! 🎉" },
|
||||
{ id: "n4", type: "deadline", champ: "Zero Gravity", date: "Feb 20, 2026", read: true, message: "Reminder: registration deadline is Aug 22, 2026." },
|
||||
];
|
||||
|
||||
const MY_REGISTRATIONS = [
|
||||
{ champId: "1", discipline: "Exotic Pole Dance", category: "Semi-Pro", status: "in_progress", currentStep: 4, stepsCompleted: 3, nextAction: "Submit video selection form", deadline: "Aug 22, 2026" },
|
||||
{ champId: "2", discipline: "Exotic Pole Dance", category: "Profi", status: "planned", currentStep: 1, stepsCompleted: 0, nextAction: "Review rules & eligibility", deadline: "Jun 1, 2026" },
|
||||
];
|
||||
|
||||
/* ── Theme ── */
|
||||
const c = { bg: "#08070D", card: "#12111A", cardH: "#1A1926", brd: "#1F1E2E", text: "#F2F0FA", dim: "#5E5C72", mid: "#8F8DA6", accent: "#D4145A", accentS: "rgba(212,20,90,0.10)", green: "#10B981", greenS: "rgba(16,185,129,0.10)", yellow: "#F59E0B", yellowS: "rgba(245,158,11,0.10)", purple: "#8B5CF6" };
|
||||
const f = { d: "'Playfair Display',Georgia,serif", b: "'DM Sans','Segoe UI',sans-serif", m: "'JetBrains Mono',monospace" };
|
||||
|
||||
/* ── Shared ── */
|
||||
const Badge = ({ status }) => { const m = { registration_open: { l: "REG OPEN", c: c.green, b: c.greenS }, upcoming: { l: "UPCOMING", c: c.yellow, b: c.yellowS } }; const s = m[status] || m.upcoming; return <span style={{ fontFamily: f.m, fontSize: 9, fontWeight: 700, letterSpacing: 1.2, color: s.c, background: s.b, padding: "3px 8px", borderRadius: 4 }}>{s.l}</span>; };
|
||||
const Chip = ({ text, color = c.mid, bg = c.card, border = c.brd }) => <span style={{ fontFamily: f.b, fontSize: 11, fontWeight: 600, color, background: bg, border: `1px solid ${border}`, padding: "4px 10px", borderRadius: 16, whiteSpace: "nowrap" }}>{text}</span>;
|
||||
const Info = ({ icon, text }) => <span style={{ fontFamily: f.b, fontSize: 12, color: c.mid, display: "flex", alignItems: "center", gap: 5 }}><span style={{ fontSize: 13 }}>{icon}</span> {text}</span>;
|
||||
const ST = ({ children, right }) => <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", margin: "0 0 10px" }}><h3 style={{ fontFamily: f.d, fontSize: 14, fontWeight: 700, color: c.mid, margin: 0 }}>{children}</h3>{right && <span style={{ fontFamily: f.m, fontSize: 10, color: c.dim }}>{right}</span>}</div>;
|
||||
const Cd = ({ children, style: s }) => <div style={{ background: c.card, border: `1px solid ${c.brd}`, borderRadius: 14, padding: 16, ...s }}>{children}</div>;
|
||||
|
||||
function Tabs({ tabs, active, onChange, accent: ac }) {
|
||||
return <div style={{ display: "flex", gap: 4, overflowX: "auto", paddingBottom: 2, marginBottom: 14, scrollbarWidth: "none", msOverflowStyle: "none" }}>
|
||||
{tabs.map(t => <div key={t} onClick={() => onChange(t)} style={{ fontFamily: f.m, fontSize: 10, fontWeight: 600, letterSpacing: 0.4, color: active === t ? ac || c.accent : c.dim, background: active === t ? `${ac || c.accent}15` : "transparent", border: `1px solid ${active === t ? `${ac || c.accent}30` : "transparent"}`, padding: "5px 12px", borderRadius: 16, cursor: "pointer", whiteSpace: "nowrap" }}>{t}</div>)}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function Nav({ active, onChange }) {
|
||||
return <div style={{ display: "flex", justifyContent: "space-around", padding: "10px 0 8px", borderTop: `1px solid ${c.brd}`, background: c.bg, flexShrink: 0 }}>
|
||||
{[{ id: "home", i: "🏠", l: "Home" }, { id: "my", i: "🎯", l: "My Champs" }, { id: "search", i: "🔍", l: "Search" }, { id: "profile", i: "👤", l: "Profile" }].map(x =>
|
||||
<div key={x.id} onClick={() => onChange(x.id)} style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 2, cursor: "pointer", opacity: active === x.id ? 1 : 0.35 }}><span style={{ fontSize: 18 }}>{x.i}</span><span style={{ fontFamily: f.m, fontSize: 8, color: c.text, letterSpacing: 0.3 }}>{x.l}</span></div>
|
||||
)}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function Hdr({ title, subtitle, onBack, right }) {
|
||||
return <div style={{ padding: "14px 20px 6px", display: "flex", alignItems: "center", gap: 12 }}>
|
||||
{onBack && <div onClick={onBack} style={{ width: 32, height: 32, borderRadius: 8, background: c.card, border: `1px solid ${c.brd}`, display: "flex", alignItems: "center", justifyContent: "center", cursor: "pointer", fontSize: 15, color: c.text }}>←</div>}
|
||||
<div style={{ flex: 1 }}><h1 style={{ fontFamily: f.d, fontSize: 20, fontWeight: 700, color: c.text, margin: 0 }}>{title}</h1>{subtitle && <p style={{ fontFamily: f.b, fontSize: 11, color: c.dim, margin: "2px 0 0" }}>{subtitle}</p>}</div>
|
||||
{right}
|
||||
</div>;
|
||||
}
|
||||
|
||||
/* ── Home ── */
|
||||
function Home({ onTap, onNotifications }) {
|
||||
const unread = NOTIFICATIONS.filter(n => !n.read).length;
|
||||
return <div>
|
||||
<Hdr title="Dance Hub" subtitle={`Hey ${USER.name} 👋`} right={
|
||||
<div onClick={onNotifications} style={{ position: "relative", width: 36, height: 36, borderRadius: 10, background: c.card, border: `1px solid ${c.brd}`, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 18, cursor: "pointer" }}>
|
||||
🔔
|
||||
{unread > 0 && <div style={{ position: "absolute", top: -4, right: -4, width: 18, height: 18, borderRadius: 9, background: c.accent, display: "flex", alignItems: "center", justifyContent: "center" }}>
|
||||
<span style={{ fontFamily: f.m, fontSize: 9, fontWeight: 700, color: "#fff" }}>{unread}</span>
|
||||
</div>}
|
||||
</div>
|
||||
} />
|
||||
<div style={{ padding: "6px 16px 16px", display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
<div style={{ background: `linear-gradient(135deg,${c.accent}15,${c.accent}05)`, border: `1px solid ${c.accent}25`, borderRadius: 14, padding: "12px 16px" }}>
|
||||
<p style={{ fontFamily: f.b, fontSize: 12, color: c.accent, margin: 0, fontWeight: 600 }}>🔔 Zero Gravity — Deadline: Aug 22!</p>
|
||||
</div>
|
||||
<ST right={`${CHAMPS.length} events`}>Championships</ST>
|
||||
{CHAMPS.map(ch => <ChampCard key={ch.id} ch={ch} onTap={onTap} />)}
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function ChampCard({ ch, onTap }) {
|
||||
const [h, setH] = useState(false);
|
||||
return <div onClick={() => onTap(ch)} onMouseEnter={() => setH(true)} onMouseLeave={() => setH(false)} style={{ background: h ? c.cardH : c.card, border: `1px solid ${c.brd}`, borderRadius: 16, padding: 16, cursor: "pointer", transition: "all 0.2s", transform: h ? "translateY(-2px)" : "none", boxShadow: h ? "0 8px 24px rgba(0,0,0,0.3)" : "none" }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: 10 }}>
|
||||
<div style={{ width: 46, height: 46, borderRadius: 12, background: `linear-gradient(135deg,${ch.accent}20,${ch.accent}40)`, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 22 }}>{ch.image}</div>
|
||||
<Badge status={ch.status} />
|
||||
</div>
|
||||
<h3 style={{ fontFamily: f.d, fontSize: 16, fontWeight: 700, color: c.text, margin: "0 0 2px" }}>{ch.name}</h3>
|
||||
<p style={{ fontFamily: f.b, fontSize: 11, color: c.dim, margin: "0 0 10px" }}>{ch.subtitle}</p>
|
||||
<div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}><Info icon="📅" text={ch.dates} /><Info icon="📍" text={ch.location} /></div>
|
||||
{ch.disciplines && <div style={{ display: "flex", gap: 6, marginTop: 10, flexWrap: "wrap" }}>{ch.disciplines.map(d => <Chip key={d.name} text={d.name} color={ch.accent} bg={`${ch.accent}10`} border={`${ch.accent}25`} />)}</div>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
/* ── Championship Detail ── */
|
||||
function Detail({ ch, onBack, onProgress }) {
|
||||
const [tab, setTab] = useState("Info");
|
||||
const tabs = ["Info", "Categories", "Fees", "Judging", "Rules"];
|
||||
const completedCount = 3; // mock
|
||||
|
||||
return <div style={{ flex: 1, overflow: "auto" }}>
|
||||
<Hdr title={ch.name} subtitle={ch.subtitle} onBack={onBack} />
|
||||
<div style={{ padding: "6px 16px 20px" }}>
|
||||
|
||||
{/* Hero */}
|
||||
<div style={{ background: `linear-gradient(135deg,${ch.accent}15,${ch.accent}05)`, border: `1px solid ${ch.accent}25`, borderRadius: 16, padding: 16, marginBottom: 14 }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 12 }}>
|
||||
<span style={{ fontSize: 32 }}>{ch.image}</span><Badge status={ch.status} />
|
||||
</div>
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: "8px 18px" }}>
|
||||
<Info icon="📅" text={ch.dates} />
|
||||
<Info icon="📍" text={`${ch.venue ? ch.venue + ", " : ""}${ch.location}`} />
|
||||
{ch.applicationDeadline && <Info icon="⏰" text={`Deadline: ${ch.applicationDeadline}`} />}
|
||||
{ch.resultsChannels && <Info icon="📢" text={`Results: ${ch.resultsChannels.join(", ")}`} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Register + Progress buttons */}
|
||||
<div style={{ display: "flex", gap: 8, marginBottom: 14 }}>
|
||||
<div onClick={() => onProgress(ch)} style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center", gap: 6, padding: "11px 12px", borderRadius: 12, background: c.card, border: `1px solid ${c.brd}`, cursor: "pointer" }}>
|
||||
<span style={{ fontSize: 14 }}>📋</span>
|
||||
<span style={{ fontFamily: f.b, fontSize: 12, fontWeight: 600, color: c.text }}>Progress</span>
|
||||
<span style={{ fontFamily: f.m, fontSize: 10, color: c.dim }}>{completedCount}/{STEPS.length}</span>
|
||||
</div>
|
||||
{ch.formUrl && ch.status === "registration_open" && <a href={ch.formUrl} target="_blank" rel="noopener noreferrer" style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center", gap: 6, padding: "11px 12px", borderRadius: 12, background: ch.accent, fontFamily: f.b, fontSize: 12, fontWeight: 700, color: "#fff", textDecoration: "none", cursor: "pointer" }}>
|
||||
✍️ Register
|
||||
</a>}
|
||||
</div>
|
||||
|
||||
<Tabs tabs={tabs} active={tab} onChange={setTab} accent={ch.accent} />
|
||||
|
||||
{/* Info */}
|
||||
{tab === "Info" && <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
{ch.disciplines && <Cd><ST>Disciplines</ST>{ch.disciplines.map(d => <div key={d.name} style={{ marginBottom: 10 }}>
|
||||
<p style={{ fontFamily: f.b, fontSize: 13, fontWeight: 600, color: c.text, margin: "0 0 4px" }}>{d.name}</p>
|
||||
{d.performanceReq && <p style={{ fontFamily: f.b, fontSize: 11, color: c.dim, margin: 0 }}>{d.performanceReq}</p>}
|
||||
<div style={{ display: "flex", gap: 6, flexWrap: "wrap", marginTop: 6 }}>{d.categories.map(cat => <Chip key={cat.name} text={`${cat.name}${cat.type === "group" ? " 👥" : ""}`} />)}</div>
|
||||
</div>)}</Cd>}
|
||||
{ch.videoReqs && <Cd><ST>Video Requirements</ST><div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
<Info icon="⏱" text={`Min duration: ${ch.videoReqs.minDuration}`} />
|
||||
<Info icon="🚫" text={ch.videoReqs.editing} />
|
||||
<Info icon="📅" text={ch.videoReqs.maxAge} />
|
||||
<Info icon="📊" text={ch.videoReqs.note} />
|
||||
</div></Cd>}
|
||||
{ch.prizes && <Cd><ST>Prizes</ST>{ch.prizes.map((p, i) => <p key={i} style={{ fontFamily: f.b, fontSize: 12, color: c.mid, margin: "0 0 4px" }}>🏆 {p}</p>)}</Cd>}
|
||||
</div>}
|
||||
|
||||
{/* Categories */}
|
||||
{tab === "Categories" && ch.disciplines && <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
{ch.disciplines.map(d => <Cd key={d.name}><ST>{d.name}</ST>{d.categories.map(cat => {
|
||||
const match = (USER.experienceYears >= 2 && USER.experienceYears <= 4 && !USER.isInstructor && cat.name === "Amateur") || (USER.experienceYears >= 3 && cat.name === "Semi-Pro") || cat.name === "Duets & Groups";
|
||||
return <div key={cat.name} style={{ padding: "10px 0", borderBottom: `1px solid ${c.brd}` }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<span style={{ fontFamily: f.b, fontSize: 13, fontWeight: 600, color: c.text }}>{cat.name}</span>
|
||||
<div style={{ display: "flex", gap: 6, alignItems: "center" }}>
|
||||
<span style={{ fontFamily: f.m, fontSize: 10, color: c.dim }}>{cat.duration}</span>
|
||||
{match && <span style={{ fontFamily: f.m, fontSize: 9, fontWeight: 700, color: c.green, background: c.greenS, padding: "2px 7px", borderRadius: 4 }}>MATCH</span>}
|
||||
</div>
|
||||
</div>
|
||||
<p style={{ fontFamily: f.b, fontSize: 11, color: c.dim, margin: "4px 0 0" }}>{cat.eligibility}</p>
|
||||
</div>;
|
||||
})}</Cd>)}
|
||||
<Cd style={{ background: `${c.yellow}08`, border: `1px solid ${c.yellow}20` }}><p style={{ fontFamily: f.b, fontSize: 11, color: c.yellow, margin: 0 }}>⚠️ Organizers may change your category if level doesn't match</p></Cd>
|
||||
</div>}
|
||||
|
||||
{/* Fees */}
|
||||
{tab === "Fees" && ch.fees && <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
<Cd><ST>Stage 1: Video Selection</ST>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 6 }}><span style={{ fontFamily: f.b, fontSize: 13, color: c.text }}>Fee</span><span style={{ fontFamily: f.m, fontSize: 13, fontWeight: 700, color: ch.accent }}>{ch.fees.videoSelection}</span></div>
|
||||
<p style={{ fontFamily: f.b, fontSize: 11, color: c.yellow, margin: 0 }}>⚠️ Non-refundable even if you don't pass</p>
|
||||
</Cd>
|
||||
<Cd><ST>Stage 2: Championship (after passing)</ST>
|
||||
{Object.entries(ch.fees.championship).map(([t, a]) => <div key={t} style={{ display: "flex", justifyContent: "space-between", padding: "6px 0", borderBottom: `1px solid ${c.brd}` }}><span style={{ fontFamily: f.b, fontSize: 13, color: c.text, textTransform: "capitalize" }}>{t}</span><span style={{ fontFamily: f.m, fontSize: 13, fontWeight: 700, color: ch.accent }}>{a}</span></div>)}
|
||||
{ch.fees.refundNote && <p style={{ fontFamily: f.b, fontSize: 11, color: c.yellow, margin: "8px 0 0" }}>⚠️ {ch.fees.refundNote}</p>}
|
||||
</Cd>
|
||||
</div>}
|
||||
|
||||
{/* Judging */}
|
||||
{tab === "Judging" && ch.judging && <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
<Cd><ST>Scoring (0–10 each)</ST>{ch.judging.map(j => <div key={j.name} style={{ padding: "8px 0", borderBottom: `1px solid ${c.brd}` }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between" }}><span style={{ fontFamily: f.b, fontSize: 13, fontWeight: 600, color: c.text }}>{j.name}</span><span style={{ fontFamily: f.m, fontSize: 11, color: c.purple }}>0–{j.max}</span></div>
|
||||
<p style={{ fontFamily: f.b, fontSize: 11, color: c.dim, margin: "2px 0 0" }}>{j.desc}</p>
|
||||
</div>)}</Cd>
|
||||
{ch.penalties && <Cd><ST>Penalties</ST>{ch.penalties.map((p, i) => <div key={i} style={{ display: "flex", justifyContent: "space-between", alignItems: "center", padding: "6px 0", borderBottom: `1px solid ${c.brd}` }}>
|
||||
<span style={{ fontFamily: f.b, fontSize: 12, color: c.text }}>{p.name}</span>
|
||||
<span style={{ fontFamily: f.m, fontSize: 10, fontWeight: 700, color: p.consequence ? "#EF4444" : c.yellow, background: p.consequence ? "rgba(239,68,68,0.1)" : c.yellowS, padding: "2px 8px", borderRadius: 4 }}>{p.consequence || `${p.points}`}</span>
|
||||
</div>)}</Cd>}
|
||||
</div>}
|
||||
|
||||
{/* Rules + Venue */}
|
||||
{tab === "Rules" && <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
{ch.generalRules && <Cd><ST>General</ST>{ch.generalRules.map((r, i) => <p key={i} style={{ fontFamily: f.b, fontSize: 12, color: c.mid, margin: "0 0 6px" }}>• {r}</p>)}</Cd>}
|
||||
{ch.costumeRules && <Cd><ST>Costume & Shoes</ST>{ch.costumeRules.map((r, i) => <p key={i} style={{ fontFamily: f.b, fontSize: 12, color: c.mid, margin: "0 0 6px" }}>• {r}</p>)}</Cd>}
|
||||
{ch.venueSpecs && <Cd><ST>Stage & Equipment</ST>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10 }}>{Object.entries(ch.venueSpecs).map(([k, v]) => <div key={k} style={{ background: c.bg, borderRadius: 10, padding: 12 }}><p style={{ fontFamily: f.m, fontSize: 9, color: c.dim, margin: "0 0 4px", letterSpacing: 0.5, textTransform: "uppercase" }}>{k.replace(/([A-Z])/g, " $1")}</p><p style={{ fontFamily: f.b, fontSize: 13, fontWeight: 600, color: c.text, margin: 0 }}>{v}</p></div>)}</div>
|
||||
</Cd>}
|
||||
</div>}
|
||||
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
/* ── Progress Screen (separate full view) ── */
|
||||
function Progress({ ch, onBack }) {
|
||||
const [done, setDone] = useState({ s1: true, s2: true, s3: true });
|
||||
const [uploads, setUploads] = useState({});
|
||||
const [orgConfirmed, setOrgConfirmed] = useState({});
|
||||
const cnt = Object.values(done).filter(Boolean).length;
|
||||
const pct = (cnt / STEPS.length) * 100;
|
||||
|
||||
const detectColors = { auto: { c: c.green, bg: c.greenS, label: "AUTO" }, email: { c: "#60A5FA", bg: "rgba(96,165,250,0.10)", label: "GMAIL" }, receipt: { c: c.yellow, bg: c.yellowS, label: "UPLOAD" }, manual: { c: c.mid, bg: `${c.mid}15`, label: "MANUAL" } };
|
||||
|
||||
const handleUpload = (stepId) => {
|
||||
setUploads(p => ({ ...p, [stepId]: true }));
|
||||
};
|
||||
|
||||
return <div style={{ flex: 1, overflow: "auto" }}>
|
||||
<Hdr title="Progress" subtitle={ch.name} onBack={onBack} />
|
||||
<div style={{ padding: "6px 16px 20px", display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
|
||||
{/* Summary */}
|
||||
<div style={{ background: `linear-gradient(135deg,${ch.accent}15,${ch.accent}05)`, border: `1px solid ${ch.accent}25`, borderRadius: 16, padding: 16 }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 10 }}>
|
||||
<span style={{ fontFamily: f.d, fontSize: 32, fontWeight: 700, color: ch.accent }}>{Math.round(pct)}%</span>
|
||||
<span style={{ fontFamily: f.m, fontSize: 11, color: c.dim }}>{cnt} of {STEPS.length} steps</span>
|
||||
</div>
|
||||
<div style={{ height: 6, background: `${ch.accent}20`, borderRadius: 3, overflow: "hidden" }}>
|
||||
<div style={{ height: "100%", width: `${pct}%`, background: `linear-gradient(90deg,${ch.accent},${ch.accent}BB)`, borderRadius: 3, transition: "width 0.3s" }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>
|
||||
{Object.entries(detectColors).map(([k, v]) =>
|
||||
<span key={k} style={{ fontFamily: f.m, fontSize: 8, fontWeight: 700, color: v.c, background: v.bg, padding: "3px 8px", borderRadius: 4, letterSpacing: 0.5 }}>{v.label}</span>
|
||||
)}
|
||||
<span style={{ fontFamily: f.m, fontSize: 8, fontWeight: 700, color: c.purple, background: `${c.purple}15`, padding: "3px 8px", borderRadius: 4, letterSpacing: 0.5 }}>ORG ✓</span>
|
||||
</div>
|
||||
|
||||
{/* Steps */}
|
||||
<Cd style={{ padding: "4px 10px" }}>
|
||||
{STEPS.map((s, i) => {
|
||||
const d = done[s.id];
|
||||
const isN = !d && cnt === i;
|
||||
const uploaded = uploads[s.id];
|
||||
const confirmed = orgConfirmed[s.id];
|
||||
const dc = detectColors[s.detect];
|
||||
|
||||
return <div key={s.id} style={{ padding: "10px 4px", borderBottom: i < STEPS.length - 1 ? `1px solid ${c.brd}` : "none" }}>
|
||||
{/* Main row */}
|
||||
<div onClick={() => { if (s.detect === "manual" || s.detect === "auto") setDone(p => ({ ...p, [s.id]: !p[s.id] })); }} style={{ display: "flex", alignItems: "center", gap: 10, cursor: s.detect === "manual" || s.detect === "auto" ? "pointer" : "default", background: isN ? `${ch.accent}06` : "transparent", borderRadius: 8, padding: "2px 0" }}>
|
||||
<div style={{
|
||||
width: 26, height: 26, borderRadius: 8, flexShrink: 0,
|
||||
border: `2px solid ${d ? c.green : isN ? ch.accent : c.brd}`,
|
||||
background: d ? c.greenS : "transparent",
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
fontFamily: f.m, fontSize: 11, fontWeight: 700,
|
||||
color: d ? c.green : isN ? ch.accent : c.dim,
|
||||
}}>{d ? "✓" : i + 1}</div>
|
||||
<span style={{ fontSize: 15 }}>{s.icon}</span>
|
||||
<div style={{ flex: 1 }}>
|
||||
<span style={{ fontFamily: f.b, fontSize: 12, color: d ? c.dim : isN ? c.text : c.mid, textDecoration: d ? "line-through" : "none", fontWeight: isN ? 600 : 400 }}>{s.label}</span>
|
||||
</div>
|
||||
{s.warn && !d && <span style={{ fontSize: 10 }}>⚠️</span>}
|
||||
{isN && <span style={{ fontFamily: f.m, fontSize: 8, color: ch.accent, background: c.accentS, padding: "2px 8px", borderRadius: 4, fontWeight: 700 }}>NEXT</span>}
|
||||
</div>
|
||||
|
||||
{/* Detection method + action */}
|
||||
{!d && <div style={{ marginLeft: 36, marginTop: 6, display: "flex", alignItems: "center", gap: 6, flexWrap: "wrap" }}>
|
||||
<span style={{ fontFamily: f.m, fontSize: 8, fontWeight: 700, color: dc.c, background: dc.bg, padding: "2px 7px", borderRadius: 4, letterSpacing: 0.3 }}>{dc.label}</span>
|
||||
<span style={{ fontFamily: f.b, fontSize: 10, color: c.dim }}>{s.detectLabel}</span>
|
||||
</div>}
|
||||
|
||||
{/* Upload action for receipt steps */}
|
||||
{!d && isN && s.detect === "receipt" && <div style={{ marginLeft: 36, marginTop: 8 }}>
|
||||
{!uploaded ? (
|
||||
<div onClick={() => handleUpload(s.id)} style={{ display: "inline-flex", alignItems: "center", gap: 6, padding: "7px 14px", borderRadius: 8, background: `${c.yellow}15`, border: `1px solid ${c.yellow}30`, cursor: "pointer" }}>
|
||||
<span style={{ fontSize: 13 }}>📸</span>
|
||||
<span style={{ fontFamily: f.b, fontSize: 11, fontWeight: 600, color: c.yellow }}>Upload receipt</span>
|
||||
</div>
|
||||
) : !confirmed ? (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<span style={{ fontFamily: f.m, fontSize: 9, fontWeight: 700, color: c.green, background: c.greenS, padding: "2px 8px", borderRadius: 4 }}>📸 Uploaded</span>
|
||||
<span style={{ fontFamily: f.b, fontSize: 10, color: c.dim }}>Waiting for org to confirm...</span>
|
||||
{/* Demo: simulate org confirm */}
|
||||
<span onClick={() => { setOrgConfirmed(p => ({ ...p, [s.id]: true })); setDone(p => ({ ...p, [s.id]: true })); }} style={{ fontFamily: f.m, fontSize: 8, fontWeight: 700, color: c.purple, background: `${c.purple}15`, padding: "2px 8px", borderRadius: 4, cursor: "pointer" }}>Demo: Org ✓</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>}
|
||||
|
||||
{/* Email detection indicator */}
|
||||
{!d && isN && s.detect === "email" && <div style={{ marginLeft: 36, marginTop: 8, display: "flex", alignItems: "center", gap: 6 }}>
|
||||
<span style={{ fontSize: 12 }}>📧</span>
|
||||
<span style={{ fontFamily: f.b, fontSize: 10, color: c.dim }}>Monitoring Gmail for confirmation...</span>
|
||||
{/* Demo: simulate detection */}
|
||||
<span onClick={() => setDone(p => ({ ...p, [s.id]: true }))} style={{ fontFamily: f.m, fontSize: 8, fontWeight: 700, color: "#60A5FA", background: "rgba(96,165,250,0.10)", padding: "2px 8px", borderRadius: 4, cursor: "pointer" }}>Demo: Detected</span>
|
||||
</div>}
|
||||
|
||||
{/* Auto completed indicator */}
|
||||
{d && s.detect === "auto" && <div style={{ marginLeft: 36, marginTop: 4 }}>
|
||||
<span style={{ fontFamily: f.m, fontSize: 9, color: c.green }}>✓ Auto-detected</span>
|
||||
</div>}
|
||||
{d && s.detect === "email" && <div style={{ marginLeft: 36, marginTop: 4 }}>
|
||||
<span style={{ fontFamily: f.m, fontSize: 9, color: "#60A5FA" }}>✓ Gmail confirmation received</span>
|
||||
</div>}
|
||||
{d && s.detect === "receipt" && <div style={{ marginLeft: 36, marginTop: 4 }}>
|
||||
<span style={{ fontFamily: f.m, fontSize: 9, color: c.purple }}>✓ Receipt uploaded · Org confirmed</span>
|
||||
</div>}
|
||||
</div>;
|
||||
})}
|
||||
</Cd>
|
||||
|
||||
{/* Auto-detection monitoring */}
|
||||
<Cd style={{ background: `${c.purple}08`, border: `1px solid ${c.purple}20` }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 12 }}>
|
||||
<span style={{ fontSize: 20 }}>🤖</span>
|
||||
<div>
|
||||
<p style={{ fontFamily: f.b, fontSize: 14, fontWeight: 600, color: c.text, margin: 0 }}>Auto-Detection Active</p>
|
||||
<p style={{ fontFamily: f.b, fontSize: 11, color: c.dim, margin: "2px 0 0" }}>Monitoring multiple channels</p>
|
||||
</div>
|
||||
</div>
|
||||
{[
|
||||
{ ch: "Instagram", icon: "📸", desc: "Results photo OCR", status: "Monitoring" },
|
||||
{ ch: "Gmail", icon: "📧", desc: "Form confirmations & results", status: "Connected" },
|
||||
{ ch: "Telegram", icon: "💬", desc: "Championship chat", status: "Monitoring" },
|
||||
].map(x => <div key={x.ch} style={{ display: "flex", alignItems: "center", gap: 10, padding: "10px 12px", background: c.card, borderRadius: 10, marginBottom: 6 }}>
|
||||
<span style={{ fontSize: 16 }}>{x.icon}</span>
|
||||
<div style={{ flex: 1 }}>
|
||||
<p style={{ fontFamily: f.b, fontSize: 12, fontWeight: 500, color: c.text, margin: 0 }}>{x.ch}</p>
|
||||
<p style={{ fontFamily: f.b, fontSize: 10, color: c.dim, margin: "2px 0 0" }}>{x.desc}</p>
|
||||
</div>
|
||||
<span style={{ fontFamily: f.m, fontSize: 9, fontWeight: 700, color: x.status === "Connected" ? c.green : c.yellow, background: x.status === "Connected" ? c.greenS : c.yellowS, padding: "3px 8px", borderRadius: 4 }}>{x.status}</span>
|
||||
</div>)}
|
||||
</Cd>
|
||||
|
||||
{/* Register */}
|
||||
{ch.formUrl && ch.status === "registration_open" && <a href={ch.formUrl} target="_blank" rel="noopener noreferrer" style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 6, padding: "14px", borderRadius: 12, background: ch.accent, fontFamily: f.b, fontSize: 14, fontWeight: 700, color: "#fff", textDecoration: "none" }}>
|
||||
✍️ Register Now
|
||||
</a>}
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
/* ── My Championships ── */
|
||||
function MyChamps({ onTap, onProgress }) {
|
||||
const active = MY_REGISTRATIONS.filter(r => r.status === "in_progress");
|
||||
const planned = MY_REGISTRATIONS.filter(r => r.status === "planned");
|
||||
const completed = MY_REGISTRATIONS.filter(r => r.status === "completed");
|
||||
|
||||
const RegCard = ({ reg }) => {
|
||||
const ch = CHAMPS.find(c2 => c2.id === reg.champId);
|
||||
if (!ch) return null;
|
||||
const pct = (reg.stepsCompleted / STEPS.length) * 100;
|
||||
const statusMap = { in_progress: { label: "IN PROGRESS", color: c.green, bg: c.greenS }, planned: { label: "PLANNED", color: c.yellow, bg: c.yellowS }, completed: { label: "COMPLETED", color: c.purple, bg: `${c.purple}15` } };
|
||||
const st = statusMap[reg.status];
|
||||
|
||||
return <Cd style={{ padding: 0, overflow: "hidden" }}>
|
||||
{/* Color accent bar */}
|
||||
<div style={{ height: 3, background: `linear-gradient(90deg,${ch.accent},${ch.accent}88)` }} />
|
||||
<div style={{ padding: 14 }}>
|
||||
{/* Header */}
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: 10 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||
<div style={{ width: 40, height: 40, borderRadius: 10, background: `linear-gradient(135deg,${ch.accent}20,${ch.accent}40)`, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 18 }}>{ch.image}</div>
|
||||
<div>
|
||||
<h3 style={{ fontFamily: f.d, fontSize: 15, fontWeight: 700, color: c.text, margin: 0 }}>{ch.name}</h3>
|
||||
<p style={{ fontFamily: f.b, fontSize: 11, color: c.dim, margin: "1px 0 0" }}>{ch.dates} · {ch.location}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span style={{ fontFamily: f.m, fontSize: 8, fontWeight: 700, letterSpacing: 0.8, color: st.color, background: st.bg, padding: "3px 8px", borderRadius: 4 }}>{st.label}</span>
|
||||
</div>
|
||||
|
||||
{/* Category */}
|
||||
<div style={{ display: "flex", gap: 6, marginBottom: 12 }}>
|
||||
<Chip text={reg.discipline} color={ch.accent} bg={`${ch.accent}10`} border={`${ch.accent}25`} />
|
||||
<Chip text={reg.category} />
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div style={{ marginBottom: 10 }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 4 }}>
|
||||
<span style={{ fontFamily: f.m, fontSize: 10, color: c.dim }}>{reg.stepsCompleted}/{STEPS.length} steps</span>
|
||||
<span style={{ fontFamily: f.m, fontSize: 10, color: ch.accent }}>{Math.round(pct)}%</span>
|
||||
</div>
|
||||
<div style={{ height: 4, background: c.brd, borderRadius: 2, overflow: "hidden" }}>
|
||||
<div style={{ height: "100%", width: `${pct}%`, background: ch.accent, borderRadius: 2, transition: "width 0.3s" }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Next action */}
|
||||
{reg.nextAction && <div style={{ background: `${ch.accent}08`, border: `1px solid ${ch.accent}15`, borderRadius: 10, padding: "10px 12px", marginBottom: 10 }}>
|
||||
<p style={{ fontFamily: f.m, fontSize: 9, color: c.dim, margin: "0 0 3px", letterSpacing: 0.5, textTransform: "uppercase" }}>Next step</p>
|
||||
<p style={{ fontFamily: f.b, fontSize: 12, fontWeight: 600, color: c.text, margin: 0 }}>{STEPS[reg.currentStep - 1]?.icon} {reg.nextAction}</p>
|
||||
</div>}
|
||||
|
||||
{/* Deadline */}
|
||||
{reg.deadline && <div style={{ display: "flex", alignItems: "center", gap: 6, marginBottom: 12 }}>
|
||||
<span style={{ fontSize: 12 }}>⏰</span>
|
||||
<span style={{ fontFamily: f.b, fontSize: 11, color: c.yellow }}>Deadline: {reg.deadline}</span>
|
||||
</div>}
|
||||
|
||||
{/* Actions */}
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<div onClick={() => onTap(ch)} style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center", gap: 5, padding: "9px", borderRadius: 10, background: c.bg, border: `1px solid ${c.brd}`, cursor: "pointer" }}>
|
||||
<span style={{ fontSize: 12 }}>ℹ️</span>
|
||||
<span style={{ fontFamily: f.b, fontSize: 11, fontWeight: 600, color: c.mid }}>Details</span>
|
||||
</div>
|
||||
<div onClick={() => onProgress(ch)} style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center", gap: 5, padding: "9px", borderRadius: 10, background: ch.accent, cursor: "pointer" }}>
|
||||
<span style={{ fontSize: 12 }}>📋</span>
|
||||
<span style={{ fontFamily: f.b, fontSize: 11, fontWeight: 700, color: "#fff" }}>Progress</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Cd>;
|
||||
};
|
||||
|
||||
return <div>
|
||||
<Hdr title="My Championships" subtitle={`${MY_REGISTRATIONS.length} registrations`} />
|
||||
<div style={{ padding: "6px 16px 16px", display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
|
||||
{active.length > 0 && <>
|
||||
<ST right={`${active.length}`}>Active</ST>
|
||||
{active.map(r => <RegCard key={r.champId} reg={r} />)}
|
||||
</>}
|
||||
|
||||
{planned.length > 0 && <>
|
||||
<ST right={`${planned.length}`}>Planned</ST>
|
||||
{planned.map(r => <RegCard key={r.champId} reg={r} />)}
|
||||
</>}
|
||||
|
||||
{completed.length > 0 && <>
|
||||
<ST right={`${completed.length}`}>Completed</ST>
|
||||
{completed.map(r => <RegCard key={r.champId} reg={r} />)}
|
||||
</>}
|
||||
|
||||
{MY_REGISTRATIONS.length === 0 && <div style={{ textAlign: "center", padding: "60px 20px" }}>
|
||||
<span style={{ fontSize: 40 }}>🔍</span>
|
||||
<p style={{ fontFamily: f.b, fontSize: 14, color: c.mid, margin: "12px 0 4px" }}>No championships yet</p>
|
||||
<p style={{ fontFamily: f.b, fontSize: 12, color: c.dim }}>Browse championships and start your journey!</p>
|
||||
</div>}
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
/* ── Notifications ── */
|
||||
function Notifications({ onBack }) {
|
||||
const [notifs, setNotifs] = useState(NOTIFICATIONS);
|
||||
const markRead = (id) => setNotifs(p => p.map(n => n.id === id ? { ...n, read: true } : n));
|
||||
const markAllRead = () => setNotifs(p => p.map(n => ({ ...n, read: true })));
|
||||
const unread = notifs.filter(n => !n.read).length;
|
||||
|
||||
const typeConfig = {
|
||||
category_change: { icon: "🔄", color: c.yellow, label: "Category Changed" },
|
||||
payment_confirmed: { icon: "✅", color: c.green, label: "Payment Confirmed" },
|
||||
result: { icon: "🏆", color: c.accent, label: "Results" },
|
||||
deadline: { icon: "⏰", color: c.yellow, label: "Deadline Reminder" },
|
||||
style_change: { icon: "🔄", color: c.purple, label: "Style Changed" },
|
||||
registration_confirmed: { icon: "📋", color: c.green, label: "Registration" },
|
||||
announcement: { icon: "📢", color: c.blue, label: "Announcement" },
|
||||
};
|
||||
|
||||
return <div>
|
||||
<Hdr title="Notifications" subtitle={unread > 0 ? `${unread} unread` : "All caught up ✓"} onBack={onBack} right={
|
||||
unread > 0 ? <div onClick={markAllRead} style={{ fontFamily: f.b, fontSize: 11, color: c.accent, cursor: "pointer", padding: "4px 8px" }}>Read all</div> : null
|
||||
} />
|
||||
<div style={{ padding: "6px 16px 16px", display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
{notifs.length === 0 && <div style={{ textAlign: "center", padding: "60px 20px" }}>
|
||||
<span style={{ fontSize: 40 }}>🔕</span>
|
||||
<p style={{ fontFamily: f.b, fontSize: 14, color: c.mid, margin: "12px 0 4px" }}>No notifications</p>
|
||||
<p style={{ fontFamily: f.b, fontSize: 12, color: c.dim }}>You'll see updates from championships here</p>
|
||||
</div>}
|
||||
{notifs.map(n => {
|
||||
const tc = typeConfig[n.type] || typeConfig.announcement;
|
||||
return <div key={n.id} onClick={() => markRead(n.id)} style={{
|
||||
display: "flex", gap: 12, padding: "12px 14px", borderRadius: 12, cursor: "pointer",
|
||||
background: n.read ? c.card : `${tc.color}08`,
|
||||
border: `1px solid ${n.read ? c.brd : `${tc.color}20`}`,
|
||||
}}>
|
||||
<div style={{ width: 36, height: 36, borderRadius: 10, background: `${tc.color}15`, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 16, flexShrink: 0 }}>{tc.icon}</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 3 }}>
|
||||
<span style={{ fontFamily: f.m, fontSize: 9, fontWeight: 700, color: tc.color, letterSpacing: 0.5 }}>{tc.label}</span>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
||||
<span style={{ fontFamily: f.m, fontSize: 9, color: c.dim }}>{n.date}</span>
|
||||
{!n.read && <div style={{ width: 7, height: 7, borderRadius: 4, background: c.accent }} />}
|
||||
</div>
|
||||
</div>
|
||||
<p style={{ fontFamily: f.b, fontSize: 12, color: n.read ? c.mid : c.text, margin: 0, lineHeight: 1.4 }}>{n.message}</p>
|
||||
<p style={{ fontFamily: f.m, fontSize: 10, color: c.dim, margin: "4px 0 0" }}>{n.champ}</p>
|
||||
</div>
|
||||
</div>;
|
||||
})}
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
/* ── Search ── */
|
||||
function Search({ onTap }) {
|
||||
const [q, setQ] = useState("");
|
||||
const [fl, setFl] = useState("all");
|
||||
const fs = [{ id: "all", l: "All" }, { id: "registration_open", l: "Open" }, { id: "upcoming", l: "Upcoming" }];
|
||||
const res = CHAMPS.filter(ch => (!q || ch.name.toLowerCase().includes(q.toLowerCase()) || ch.location.toLowerCase().includes(q.toLowerCase())) && (fl === "all" || ch.status === fl));
|
||||
return <div>
|
||||
<Hdr title="Discover" subtitle="Find your next championship" />
|
||||
<div style={{ padding: "4px 16px 16px" }}>
|
||||
<div style={{ background: c.card, border: `1px solid ${c.brd}`, borderRadius: 12, padding: "10px 14px", display: "flex", alignItems: "center", gap: 10, marginBottom: 12 }}>
|
||||
<span style={{ fontSize: 15, opacity: 0.4 }}>🔍</span>
|
||||
<input type="text" placeholder="Search..." value={q} onChange={e => setQ(e.target.value)} style={{ background: "transparent", border: "none", outline: "none", color: c.text, fontFamily: f.b, fontSize: 13, width: "100%" }} />
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 6, marginBottom: 14 }}>{fs.map(x => <div key={x.id} onClick={() => setFl(x.id)} style={{ fontFamily: f.m, fontSize: 10, fontWeight: 600, color: fl === x.id ? c.accent : c.dim, background: fl === x.id ? c.accentS : c.card, border: `1px solid ${fl === x.id ? `${c.accent}30` : c.brd}`, padding: "5px 12px", borderRadius: 16, cursor: "pointer" }}>{x.l}</div>)}</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>{res.length ? res.map(ch => <ChampCard key={ch.id} ch={ch} onTap={onTap} />) : <div style={{ textAlign: "center", padding: 40 }}><span style={{ fontSize: 28 }}>🤷</span><p style={{ fontFamily: f.b, fontSize: 13, color: c.dim, marginTop: 8 }}>No results</p></div>}</div>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
/* ── Profile ── */
|
||||
function Profile() {
|
||||
return <div>
|
||||
<Hdr title="Profile" />
|
||||
<div style={{ padding: "6px 20px 20px" }}>
|
||||
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", marginBottom: 24 }}>
|
||||
<div style={{ width: 68, height: 68, borderRadius: 18, background: `linear-gradient(135deg,${c.accent}25,${c.accent}10)`, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 30, marginBottom: 10, border: `2px solid ${c.accent}35` }}>💃</div>
|
||||
<h2 style={{ fontFamily: f.d, fontSize: 19, fontWeight: 700, color: c.text, margin: "0 0 2px" }}>{USER.name}</h2>
|
||||
<p style={{ fontFamily: f.m, fontSize: 11, color: c.accent, margin: 0 }}>{USER.instagram}</p>
|
||||
</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8, marginBottom: 20 }}>
|
||||
{[{ i: "📍", l: "City", v: USER.city }, { i: "💃", l: "Disciplines", v: USER.disciplines.join(", ") }, { i: "📅", l: "Experience", v: `${USER.experienceYears} years` }, { i: "🎓", l: "Instructor", v: USER.isInstructor ? "Yes" : "No" }].map(r =>
|
||||
<Cd key={r.l} style={{ padding: "11px 14px", display: "flex", alignItems: "center", gap: 12 }}><span style={{ fontSize: 17 }}>{r.i}</span><div><p style={{ fontFamily: f.m, fontSize: 9, color: c.dim, margin: 0, letterSpacing: 0.5, textTransform: "uppercase" }}>{r.l}</p><p style={{ fontFamily: f.b, fontSize: 13, color: c.text, margin: "2px 0 0" }}>{r.v}</p></div></Cd>
|
||||
)}
|
||||
</div>
|
||||
<ST>Eligible Categories</ST>
|
||||
<Cd style={{ marginBottom: 20 }}>{["Amateur (Exotic)", "Semi-Pro (Exotic)", "Duets & Groups", "Amateur (Pole Art)", "Semi-Pro (Pole Art)"].map(cat =>
|
||||
<div key={cat} style={{ display: "flex", alignItems: "center", gap: 8, padding: "7px 0", borderBottom: `1px solid ${c.brd}` }}><span style={{ fontFamily: f.m, fontSize: 9, fontWeight: 700, color: c.green, background: c.greenS, padding: "2px 7px", borderRadius: 4 }}>✓</span><span style={{ fontFamily: f.b, fontSize: 12, color: c.text }}>{cat}</span></div>
|
||||
)}</Cd>
|
||||
<ST>Stats</ST>
|
||||
<div style={{ display: "flex", gap: 8, marginBottom: 20 }}>{[{ n: "2", l: "Champs", co: c.accent }, { n: "1", l: "Passed", co: c.green }, { n: "1", l: "Pending", co: c.yellow }].map(s =>
|
||||
<div key={s.l} style={{ flex: 1, background: c.card, border: `1px solid ${c.brd}`, borderRadius: 12, padding: "14px 8px", textAlign: "center" }}><p style={{ fontFamily: f.d, fontSize: 24, fontWeight: 700, color: s.co, margin: 0 }}>{s.n}</p><p style={{ fontFamily: f.m, fontSize: 9, color: c.dim, margin: "4px 0 0", textTransform: "uppercase" }}>{s.l}</p></div>
|
||||
)}</div>
|
||||
<div style={{ background: c.card, border: `1px solid ${c.brd}`, borderRadius: 12, overflow: "hidden" }}>{["Edit Profile", "Competition History", "Notifications", "Settings", "Log Out"].map((x, i, a) =>
|
||||
<div key={x} style={{ padding: "13px 16px", fontFamily: f.b, fontSize: 13, color: x === "Log Out" ? "#EF4444" : c.text, borderBottom: i < a.length - 1 ? `1px solid ${c.brd}` : "none", cursor: "pointer", display: "flex", justifyContent: "space-between" }}>{x}<span style={{ color: c.dim }}>›</span></div>
|
||||
)}</div>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
/* ── App Shell ── */
|
||||
export default function App() {
|
||||
const [scr, setScr] = useState("home");
|
||||
const [sel, setSel] = useState(null);
|
||||
const [prev, setPrev] = useState("home");
|
||||
|
||||
const go = (s, ch) => { setPrev(scr); setScr(s); if (ch) setSel(ch); };
|
||||
const goBack = () => { setScr(prev || "home"); setSel(null); };
|
||||
|
||||
const render = () => {
|
||||
if (scr === "progress" && sel) return <Progress ch={sel} onBack={() => go("detail")} />;
|
||||
if (scr === "detail" && sel) return <Detail ch={sel} onBack={goBack} onProgress={ch => go("progress", ch)} />;
|
||||
if (scr === "notifications") return <Notifications onBack={() => go("home")} />;
|
||||
if (scr === "my") return <MyChamps onTap={ch => go("detail", ch)} onProgress={ch => go("progress", ch)} />;
|
||||
if (scr === "search") return <Search onTap={ch => go("detail", ch)} />;
|
||||
if (scr === "profile") return <Profile />;
|
||||
return <Home onTap={ch => go("detail", ch)} onNotifications={() => go("notifications")} />;
|
||||
};
|
||||
|
||||
const showNav = scr === "home" || scr === "search" || scr === "profile" || scr === "my";
|
||||
|
||||
return <div style={{ display: "flex", justifyContent: "center", alignItems: "center", minHeight: "100vh", background: "#030206", padding: 20, fontFamily: f.b }}>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;600;700&family=DM+Sans:wght@400;500;600&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet" />
|
||||
<style>{`*::-webkit-scrollbar{display:none}*{scrollbar-width:none}`}</style>
|
||||
<div style={{ width: 375, height: 740, background: c.bg, borderRadius: 36, overflow: "hidden", display: "flex", flexDirection: "column", border: `1.5px solid ${c.brd}`, boxShadow: `0 0 80px rgba(212,20,90,0.06),0 20px 40px rgba(0,0,0,0.5)` }}>
|
||||
<div style={{ padding: "8px 24px", display: "flex", justifyContent: "space-between", alignItems: "center", flexShrink: 0 }}>
|
||||
<span style={{ fontFamily: f.m, fontSize: 11, color: c.dim }}>9:41</span>
|
||||
<div style={{ width: 100, height: 28, background: "#000", borderRadius: 14 }} />
|
||||
<span style={{ fontFamily: f.m, fontSize: 11, color: c.dim }}>●●●</span>
|
||||
</div>
|
||||
<div style={{ flex: 1, overflow: "auto", minHeight: 0 }}>{render()}</div>
|
||||
{showNav && <Nav active={scr} onChange={s => { setScr(s); setSel(null); }} />}
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
683
dancechamp-claude-code/prototypes/org-app.jsx
Normal file
683
dancechamp-claude-code/prototypes/org-app.jsx
Normal file
@@ -0,0 +1,683 @@
|
||||
import { useState } from "react";
|
||||
|
||||
/* ── Data ── */
|
||||
const ORG = { name: "Zero Gravity Team", instagram: "@zerogravity_pole", logo: "💃" };
|
||||
|
||||
const makeCh = (overrides) => ({
|
||||
id: "", name: "", subtitle: "", eventDate: "", regStart: "", regEnd: "", location: "", venue: "", accent: "#D4145A", image: "💃", status: "draft",
|
||||
disciplines: [], styles: [], fees: null, judging: [], penalties: [], judges: [], rules: [], costumeRules: [], members: [],
|
||||
formUrl: "", rulesUrl: "",
|
||||
configured: { info: false, categories: false, fees: false, rules: false, judging: false },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const INITIAL_CHAMPS = [
|
||||
makeCh({
|
||||
id: "ch1", name: "Zero Gravity", subtitle: "International Pole Exotic Championship",
|
||||
eventDate: "May 30, 2026", regStart: "Feb 1, 2026", regEnd: "Apr 22, 2026", location: "Minsk, Belarus", venue: "Prime Hall", status: "registration_open", accent: "#D4145A", image: "💃",
|
||||
disciplines: [
|
||||
{ name: "Exotic Pole Dance", levels: ["Beginners", "Amateur", "Semi-Pro", "Profi", "Elite", "Duets & Groups"] },
|
||||
{ name: "Pole Art", levels: ["Amateur", "Semi-Pro", "Profi"] },
|
||||
],
|
||||
styles: ["Classic", "Flow", "Theater"],
|
||||
fees: { videoSelection: "50 BYN / 1,500 RUB", solo: "280 BYN / 7,500 RUB", duet: "210 BYN / 5,800 RUB pp", group: "190 BYN / 4,500 RUB pp" },
|
||||
judging: [{ name: "Image", max: 10 }, { name: "Artistry", max: 10 }, { name: "Choreography", max: 10 }, { name: "Musicality", max: 10 }, { name: "Technique", max: 10 }, { name: "Overall", max: 10 }],
|
||||
penalties: [{ name: "Missed element", val: "-2" }, { name: "Fall", val: "-2" }, { name: "Leaving stage", val: "DQ" }, { name: "Exposure", val: "DQ" }],
|
||||
judges: [
|
||||
{ id: "j1", name: "Anastasia Skukhtorova", instagram: "@skukhtorova", bio: "World Pole Art Champion. International judge with 10+ years experience." },
|
||||
{ id: "j2", name: "Marion Crampe", instagram: "@marioncrampe", bio: "Pole Art legend, multiple international championship winner and judge." },
|
||||
{ id: "j3", name: "Dmitry Politov", instagram: "@dmitry_politov", bio: "World Pole Sports Champion. Certified IPSF judge." },
|
||||
],
|
||||
rules: ["Must be 18+", "Valid life & health insurance", "No lotions/bronzers 24h before", "Grip aids allowed (no wax/rosin)", "Judges' decision is final"],
|
||||
costumeRules: ["Neat and well-fitted", "No advertising", "No spikes/sharp objects", "Specialized shoes for Exotic"],
|
||||
configured: { info: true, categories: true, fees: true, rules: true, judging: true },
|
||||
members: [
|
||||
{ id: "m1", name: "Alex Petrova", instagram: "@alex_pole", level: "Semi-Pro", style: "Classic", discipline: "Exotic Pole Dance", type: "solo", stepsCompleted: 3, videoUrl: "https://youtube.com/...", feePaid: false, receiptUploaded: true, insuranceUploaded: false, passed: null, city: "Moscow" },
|
||||
{ id: "m2", name: "Maria Ivanova", instagram: "@maria_exotic", level: "Amateur", style: "Flow", discipline: "Exotic Pole Dance", type: "solo", stepsCompleted: 5, videoUrl: "https://youtube.com/...", feePaid: true, receiptUploaded: true, insuranceUploaded: true, passed: true, city: "Minsk" },
|
||||
{ id: "m3", name: "Elena Kozlova", instagram: "@elena.pole", level: "Profi", style: "Theater", discipline: "Exotic Pole Dance", type: "solo", stepsCompleted: 5, videoUrl: "https://youtube.com/...", feePaid: true, receiptUploaded: true, insuranceUploaded: false, passed: true, city: "St. Petersburg" },
|
||||
{ id: "m4", name: "Daria Sokolova", instagram: "@daria_art", level: "Amateur", style: "Classic", discipline: "Pole Art", type: "solo", stepsCompleted: 4, videoUrl: "https://youtube.com/...", feePaid: false, receiptUploaded: true, insuranceUploaded: false, passed: null, city: "Kyiv" },
|
||||
{ id: "m5", name: "Anna Belova", instagram: "@anna.b_pole", level: "Beginners", style: "Flow", discipline: "Exotic Pole Dance", type: "solo", stepsCompleted: 2, videoUrl: null, feePaid: false, receiptUploaded: false, insuranceUploaded: false, passed: null, city: "Minsk" },
|
||||
{ id: "m6", name: "Olga Morozova", instagram: "@olga_exotic", level: "Elite", style: "Classic", discipline: "Exotic Pole Dance", type: "solo", stepsCompleted: 5, videoUrl: "https://youtube.com/...", feePaid: true, receiptUploaded: true, insuranceUploaded: true, passed: false, city: "Moscow" },
|
||||
{ id: "m7", name: "Katya & Nina", instagram: "@katya_nina", level: "Semi-Pro", style: "Theater", discipline: "Exotic Pole Dance", type: "duet", stepsCompleted: 5, videoUrl: "https://youtube.com/...", feePaid: true, receiptUploaded: true, insuranceUploaded: false, passed: null, city: "Kazan" },
|
||||
],
|
||||
}),
|
||||
makeCh({
|
||||
id: "ch2", name: "Pole Star", subtitle: "National Pole Championship",
|
||||
eventDate: "Jul 12–13, 2026", regStart: "", regEnd: "", location: "Moscow, Russia", venue: "Crystal Hall", status: "draft", accent: "#7C3AED", image: "⭐",
|
||||
configured: { info: true, categories: false, fees: false, rules: false, judging: false },
|
||||
members: [],
|
||||
}),
|
||||
];
|
||||
|
||||
/* ── Theme ── */
|
||||
const c = { bg: "#08070D", card: "#12111A", cardH: "#1A1926", brd: "#1F1E2E", text: "#F2F0FA", dim: "#5E5C72", mid: "#8F8DA6", accent: "#D4145A", accentS: "rgba(212,20,90,0.10)", green: "#10B981", greenS: "rgba(16,185,129,0.10)", yellow: "#F59E0B", yellowS: "rgba(245,158,11,0.10)", purple: "#8B5CF6", purpleS: "rgba(139,92,246,0.10)", blue: "#60A5FA", blueS: "rgba(96,165,250,0.10)", red: "#EF4444", redS: "rgba(239,68,68,0.10)" };
|
||||
const f = { d: "'Playfair Display',Georgia,serif", b: "'DM Sans','Segoe UI',sans-serif", m: "'JetBrains Mono',monospace" };
|
||||
|
||||
/* ── Shared UI ── */
|
||||
const Cd = ({ children, style: s }) => <div style={{ background: c.card, border: `1px solid ${c.brd}`, borderRadius: 14, padding: 16, ...s }}>{children}</div>;
|
||||
const ST = ({ children, right }) => <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", margin: "0 0 10px" }}><h3 style={{ fontFamily: f.d, fontSize: 14, fontWeight: 700, color: c.mid, margin: 0 }}>{children}</h3>{right}</div>;
|
||||
const Bg = ({ label, color, bg }) => <span style={{ fontFamily: f.m, fontSize: 8, fontWeight: 700, letterSpacing: 0.8, color, background: bg, padding: "3px 8px", borderRadius: 4 }}>{label}</span>;
|
||||
|
||||
function Hdr({ title, subtitle, onBack, right }) {
|
||||
return <div style={{ padding: "14px 20px 6px", display: "flex", alignItems: "center", gap: 12 }}>
|
||||
{onBack && <div onClick={onBack} style={{ width: 32, height: 32, borderRadius: 8, background: c.card, border: `1px solid ${c.brd}`, display: "flex", alignItems: "center", justifyContent: "center", cursor: "pointer", fontSize: 15, color: c.text }}>←</div>}
|
||||
<div style={{ flex: 1, minWidth: 0 }}><h1 style={{ fontFamily: f.d, fontSize: 20, fontWeight: 700, color: c.text, margin: 0 }}>{title}</h1>{subtitle && <p style={{ fontFamily: f.b, fontSize: 11, color: c.dim, margin: "2px 0 0", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{subtitle}</p>}</div>
|
||||
{right}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function Tabs({ tabs, active, onChange, accent: ac }) {
|
||||
return <div style={{ display: "flex", gap: 3, overflowX: "auto", paddingBottom: 2, marginBottom: 14, scrollbarWidth: "none" }}>
|
||||
{tabs.map(t => <div key={t.id} onClick={() => onChange(t.id)} style={{ fontFamily: f.m, fontSize: 9, fontWeight: 600, letterSpacing: 0.3, display: "flex", alignItems: "center", gap: 4, color: active === t.id ? ac || c.accent : c.dim, background: active === t.id ? `${ac || c.accent}15` : "transparent", border: `1px solid ${active === t.id ? `${ac || c.accent}30` : "transparent"}`, padding: "5px 10px", borderRadius: 16, cursor: "pointer", whiteSpace: "nowrap" }}>
|
||||
{t.done !== undefined && <span style={{ width: 6, height: 6, borderRadius: 3, background: t.done ? c.green : c.yellow, flexShrink: 0 }} />}
|
||||
{t.label}
|
||||
</div>)}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function Nav({ active, onChange }) {
|
||||
return <div style={{ display: "flex", justifyContent: "space-around", padding: "10px 0 8px", borderTop: `1px solid ${c.brd}`, background: c.bg, flexShrink: 0 }}>
|
||||
{[{ id: "dash", i: "📊", l: "Dashboard" }, { id: "orgSettings", i: "⚙️", l: "Settings" }].map(x =>
|
||||
<div key={x.id} onClick={() => onChange(x.id)} style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 2, cursor: "pointer", opacity: active === x.id ? 1 : 0.35 }}><span style={{ fontSize: 18 }}>{x.i}</span><span style={{ fontFamily: f.m, fontSize: 8, color: c.text, letterSpacing: 0.3 }}>{x.l}</span></div>
|
||||
)}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function Input({ label, value, onChange, placeholder }) {
|
||||
return <div style={{ marginBottom: 12 }}>
|
||||
<p style={{ fontFamily: f.m, fontSize: 9, color: c.dim, margin: "0 0 6px", letterSpacing: 0.5, textTransform: "uppercase" }}>{label}</p>
|
||||
<input type="text" value={value || ""} onChange={e => onChange(e.target.value)} placeholder={placeholder} style={{ width: "100%", padding: "10px 12px", borderRadius: 10, background: c.bg, border: `1px solid ${c.brd}`, color: c.text, fontFamily: f.b, fontSize: 13, outline: "none", boxSizing: "border-box" }} />
|
||||
</div>;
|
||||
}
|
||||
|
||||
function TagEditor({ items, onAdd, onRemove, color, placeholder, addLabel }) {
|
||||
const [val, setVal] = useState("");
|
||||
const submit = () => { if (val.trim()) { onAdd(val.trim()); setVal(""); } };
|
||||
return <div>
|
||||
<div style={{ display: "flex", gap: 6, flexWrap: "wrap", marginBottom: 8 }}>
|
||||
{items.map((item, i) => <div key={item} style={{ display: "flex", alignItems: "center", gap: 4, padding: "4px 10px", borderRadius: 16, background: `${color}10`, border: `1px solid ${color}25` }}>
|
||||
<span style={{ fontFamily: f.b, fontSize: 11, color: c.text }}>{item}</span>
|
||||
<span onClick={() => onRemove(i)} style={{ fontSize: 10, color: c.dim, cursor: "pointer", lineHeight: 1 }}>×</span>
|
||||
</div>)}
|
||||
{items.length === 0 && <span style={{ fontFamily: f.b, fontSize: 11, color: c.dim, fontStyle: "italic" }}>None added yet</span>}
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 6 }}>
|
||||
<input value={val} onChange={e => setVal(e.target.value)} placeholder={placeholder} onKeyDown={e => e.key === "Enter" && submit()} style={{ flex: 1, padding: "8px 12px", borderRadius: 8, background: c.bg, border: `1px solid ${c.brd}`, color: c.text, fontFamily: f.b, fontSize: 12, outline: "none" }} />
|
||||
<div onClick={submit} style={{ padding: "8px 14px", borderRadius: 8, background: color, color: "#fff", fontFamily: f.b, fontSize: 12, fontWeight: 700, cursor: "pointer" }}>+</div>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
/* ── Dashboard ── */
|
||||
function Dashboard({ champs, org, onChampTap, onCreateChamp }) {
|
||||
return <div>
|
||||
<Hdr title="Dashboard" subtitle={org.name} right={
|
||||
<div style={{ width: 36, height: 36, borderRadius: 10, background: `linear-gradient(135deg,${c.accent}25,${c.accent}10)`, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 18 }}>{org.logo}</div>
|
||||
} />
|
||||
<div style={{ padding: "6px 16px 16px", display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
<div onClick={onCreateChamp} style={{ display: "flex", alignItems: "center", gap: 12, padding: "14px 16px", borderRadius: 14, background: `linear-gradient(135deg,${c.accent}15,${c.accent}08)`, border: `1px solid ${c.accent}30`, cursor: "pointer" }}>
|
||||
<div style={{ width: 42, height: 42, borderRadius: 12, background: c.accent, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 22, color: "#fff", fontWeight: 700, flexShrink: 0 }}>+</div>
|
||||
<div style={{ flex: 1 }}><p style={{ fontFamily: f.b, fontSize: 14, fontWeight: 700, color: c.text, margin: 0 }}>New Championship</p><p style={{ fontFamily: f.b, fontSize: 11, color: c.dim, margin: "2px 0 0" }}>Quick create — configure later</p></div>
|
||||
<span style={{ color: c.accent, fontSize: 16 }}>›</span>
|
||||
</div>
|
||||
|
||||
<ST right={<span style={{ fontFamily: f.m, fontSize: 10, color: c.dim }}>{champs.length} events</span>}>Your Championships</ST>
|
||||
|
||||
{champs.map(ch => {
|
||||
const cfg = ch.configured;
|
||||
const done = Object.values(cfg).filter(Boolean).length;
|
||||
const total = Object.keys(cfg).length;
|
||||
const ready = done === total;
|
||||
const stMap = { registration_open: { l: "LIVE", c: c.green, b: c.greenS }, draft: { l: `SETUP ${done}/${total}`, c: c.yellow, b: c.yellowS } };
|
||||
const st = stMap[ch.status] || stMap.draft;
|
||||
return <div key={ch.id} onClick={() => onChampTap(ch)} style={{ background: c.card, border: `1px solid ${c.brd}`, borderRadius: 14, overflow: "hidden", cursor: "pointer" }}>
|
||||
<div style={{ height: 3, background: `linear-gradient(90deg,${ch.accent},${ch.accent}88)` }} />
|
||||
<div style={{ padding: "12px 14px" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 6 }}>
|
||||
<div style={{ width: 38, height: 38, borderRadius: 10, background: `linear-gradient(135deg,${ch.accent}20,${ch.accent}40)`, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 17, flexShrink: 0 }}>{ch.image}</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<h3 style={{ fontFamily: f.d, fontSize: 15, fontWeight: 700, color: c.text, margin: 0 }}>{ch.name}</h3>
|
||||
<Bg label={st.l} color={st.c} bg={st.b} />
|
||||
</div>
|
||||
<p style={{ fontFamily: f.b, fontSize: 11, color: c.dim, margin: "2px 0 0" }}>{ch.eventDate} · {ch.location}</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* Readiness bar */}
|
||||
{!ready && <div style={{ marginTop: 6 }}>
|
||||
<div style={{ height: 4, background: c.brd, borderRadius: 2, overflow: "hidden" }}>
|
||||
<div style={{ height: "100%", width: `${(done / total) * 100}%`, background: ch.accent, borderRadius: 2 }} />
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 6, marginTop: 6, flexWrap: "wrap" }}>
|
||||
{Object.entries(cfg).map(([k, v]) => <span key={k} style={{ fontFamily: f.m, fontSize: 8, color: v ? c.green : c.yellow, letterSpacing: 0.3 }}>{v ? "✓" : "○"} {k}</span>)}
|
||||
</div>
|
||||
</div>}
|
||||
{/* Stats for live champs */}
|
||||
{ch.status === "registration_open" && <div style={{ display: "flex", gap: 10, paddingTop: 8, marginTop: 6, borderTop: `1px solid ${c.brd}` }}>
|
||||
{[{ n: ch.members.length, l: "Members", co: c.mid }, { n: ch.members.filter(m => m.passed === true).length, l: "Passed", co: c.green }, { n: ch.members.filter(m => m.videoUrl && m.passed === null).length, l: "Pending", co: c.yellow }].map(s =>
|
||||
<div key={s.l} style={{ flex: 1, textAlign: "center" }}><p style={{ fontFamily: f.d, fontSize: 16, fontWeight: 700, color: s.co, margin: 0 }}>{s.n}</p><p style={{ fontFamily: f.m, fontSize: 7, color: c.dim, margin: "2px 0 0", textTransform: "uppercase" }}>{s.l}</p></div>
|
||||
)}
|
||||
</div>}
|
||||
</div>
|
||||
</div>;
|
||||
})}
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
/* ── Championship Detail (configurable tabs) ── */
|
||||
function ChampDetail({ ch: initial, onBack, onMemberTap, onUpdate }) {
|
||||
const [ch, setCh] = useState(initial);
|
||||
const [tab, setTab] = useState("Overview");
|
||||
const [members, setMembers] = useState(ch.members);
|
||||
const [memFilter, setMemFilter] = useState("all");
|
||||
const [memSearch, setMemSearch] = useState("");
|
||||
const [editing, setEditing] = useState(null);
|
||||
const [newJudge, setNewJudge] = useState({ name: "", instagram: "", bio: "" });
|
||||
|
||||
const upd = (key, val) => setCh(p => ({ ...p, [key]: val }));
|
||||
const markDone = (section) => setCh(p => ({ ...p, configured: { ...p.configured, [section]: true } }));
|
||||
const allDone = Object.values(ch.configured).every(Boolean);
|
||||
|
||||
const stats = {
|
||||
total: members.length, videoSent: members.filter(m => m.videoUrl).length,
|
||||
passed: members.filter(m => m.passed === true).length, failed: members.filter(m => m.passed === false).length,
|
||||
pending: members.filter(m => m.videoUrl && m.passed === null).length, feePaid: members.filter(m => m.feePaid).length,
|
||||
receipts: members.filter(m => m.receiptUploaded && !m.feePaid).length,
|
||||
};
|
||||
|
||||
const decide = (id, pass) => setMembers(p => p.map(m => m.id === id ? { ...m, passed: pass } : m));
|
||||
|
||||
const tabDefs = [
|
||||
{ id: "Overview", label: "Overview" },
|
||||
{ id: "Categories", label: "Categories", done: ch.configured.categories },
|
||||
{ id: "Fees", label: "Fees", done: ch.configured.fees },
|
||||
{ id: "Rules", label: "Rules", done: ch.configured.rules },
|
||||
{ id: "Judges", label: "Judges", done: ch.configured.judging },
|
||||
...(ch.status === "registration_open" ? [{ id: "Members", label: `Members (${members.length})` }, { id: "Results", label: "Results" }] : []),
|
||||
];
|
||||
|
||||
const memFilters = [
|
||||
{ id: "all", l: "All", n: members.length }, { id: "receipts", l: "📸 Receipts", n: stats.receipts },
|
||||
{ id: "videos", l: "🎬 Videos", n: stats.pending }, { id: "passed", l: "✅ Passed", n: stats.passed },
|
||||
];
|
||||
const filteredMem = members.filter(m => {
|
||||
const q = !memSearch || m.name.toLowerCase().includes(memSearch.toLowerCase()) || m.instagram.toLowerCase().includes(memSearch.toLowerCase());
|
||||
if (!q) return false;
|
||||
if (memFilter === "receipts") return m.receiptUploaded && !m.feePaid;
|
||||
if (memFilter === "videos") return m.videoUrl && m.passed === null;
|
||||
if (memFilter === "passed") return m.passed === true;
|
||||
return true;
|
||||
});
|
||||
|
||||
return <div style={{ flex: 1, overflow: "auto" }}>
|
||||
<Hdr title={ch.name} subtitle={ch.subtitle || ch.location} onBack={onBack} right={
|
||||
ch.status === "draft" && allDone ? <div onClick={() => setCh(p => ({ ...p, status: "registration_open" }))} style={{ fontFamily: f.b, fontSize: 10, fontWeight: 700, color: "#fff", background: c.green, padding: "6px 12px", borderRadius: 8, cursor: "pointer" }}>🚀 Go Live</div> : null
|
||||
} />
|
||||
<div style={{ padding: "4px 16px 20px" }}>
|
||||
<Tabs tabs={tabDefs} active={tab} onChange={setTab} accent={ch.accent} />
|
||||
|
||||
{/* ═══ OVERVIEW ═══ */}
|
||||
{tab === "Overview" && <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
{/* Setup progress */}
|
||||
{ch.status === "draft" && <Cd style={{ background: `${c.yellow}06`, border: `1px solid ${c.yellow}20` }}>
|
||||
<ST right={<span style={{ fontFamily: f.m, fontSize: 10, color: c.yellow }}>{Object.values(ch.configured).filter(Boolean).length}/{Object.keys(ch.configured).length}</span>}>⚙️ Setup Progress</ST>
|
||||
{Object.entries(ch.configured).map(([section, done]) => {
|
||||
const tabMap = { info: "Overview", categories: "Categories", fees: "Fees", rules: "Rules", judging: "Judges" };
|
||||
return <div key={section} onClick={() => !done && setTab(tabMap[section] || section)} style={{ display: "flex", alignItems: "center", gap: 10, padding: "8px 0", borderBottom: `1px solid ${c.brd}`, cursor: done ? "default" : "pointer" }}>
|
||||
<div style={{ width: 22, height: 22, borderRadius: 6, border: `2px solid ${done ? c.green : c.yellow}`, background: done ? c.greenS : "transparent", display: "flex", alignItems: "center", justifyContent: "center", fontFamily: f.m, fontSize: 10, fontWeight: 700, color: done ? c.green : c.yellow }}>{done ? "✓" : ""}</div>
|
||||
<span style={{ fontFamily: f.b, fontSize: 12, color: done ? c.dim : c.text, textTransform: "capitalize", textDecoration: done ? "line-through" : "none", flex: 1 }}>{section === "judging" ? "judges" : section}</span>
|
||||
{!done && <span style={{ fontFamily: f.b, fontSize: 10, color: ch.accent }}>Configure ›</span>}
|
||||
</div>;
|
||||
})}
|
||||
{allDone && <div onClick={() => setCh(p => ({ ...p, status: "registration_open" }))} style={{ marginTop: 10, padding: "12px", borderRadius: 10, background: c.green, textAlign: "center", cursor: "pointer" }}>
|
||||
<span style={{ fontFamily: f.b, fontSize: 13, fontWeight: 700, color: "#fff" }}>🚀 Open Registration</span>
|
||||
</div>}
|
||||
</Cd>}
|
||||
|
||||
{/* Info (always editable) */}
|
||||
<Cd>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 10 }}>
|
||||
<h3 style={{ fontFamily: f.d, fontSize: 14, fontWeight: 700, color: c.mid, margin: 0 }}>Event Info</h3>
|
||||
<div onClick={() => setEditing(editing === "info" ? null : "info")} style={{ fontFamily: f.b, fontSize: 10, fontWeight: 600, color: editing === "info" ? c.dim : "#fff", background: editing === "info" ? "transparent" : ch.accent, border: `1px solid ${editing === "info" ? c.brd : ch.accent}`, padding: "3px 10px", borderRadius: 6, cursor: "pointer" }}>{editing === "info" ? "✕ Close" : "✎ Edit"}</div>
|
||||
</div>
|
||||
{editing === "info" ? <>
|
||||
<Input label="Name" value={ch.name} onChange={v => upd("name", v)} placeholder="Championship name" />
|
||||
<Input label="Subtitle" value={ch.subtitle} onChange={v => upd("subtitle", v)} placeholder="Subtitle" />
|
||||
<Input label="Event Date" value={ch.eventDate} onChange={v => upd("eventDate", v)} placeholder="e.g. May 30, 2026" />
|
||||
<Input label="Location" value={ch.location} onChange={v => upd("location", v)} placeholder="City, Country" />
|
||||
<Input label="Venue" value={ch.venue} onChange={v => upd("venue", v)} placeholder="Venue name" />
|
||||
<div style={{ height: 1, background: c.brd, margin: "4px 0 8px" }} />
|
||||
<p style={{ fontFamily: f.m, fontSize: 9, color: c.dim, margin: "0 0 6px", letterSpacing: 0.5 }}>REGISTRATION PERIOD</p>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<div style={{ flex: 1 }}><Input label="Opens" value={ch.regStart} onChange={v => upd("regStart", v)} placeholder="e.g. Feb 1, 2026" /></div>
|
||||
<div style={{ flex: 1 }}><Input label="Closes" value={ch.regEnd} onChange={v => upd("regEnd", v)} placeholder="e.g. Apr 22, 2026" /></div>
|
||||
</div>
|
||||
<p style={{ fontFamily: f.m, fontSize: 9, color: c.yellow, margin: "-6px 0 8px" }}>⚠️ Registration close date must be before event date</p>
|
||||
<div onClick={() => { markDone("info"); setEditing(null); }} style={{ padding: "10px", borderRadius: 8, background: c.green, textAlign: "center", cursor: "pointer" }}><span style={{ fontFamily: f.b, fontSize: 12, fontWeight: 700, color: "#fff" }}>✓ Save</span></div>
|
||||
</> : <div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: "6px 16px" }}>
|
||||
<span style={{ fontFamily: f.b, fontSize: 11, color: c.mid }}>📅 {ch.eventDate || "—"}</span>
|
||||
<span style={{ fontFamily: f.b, fontSize: 11, color: c.mid }}>📍 {ch.venue ? `${ch.venue}, ` : ""}{ch.location || "—"}</span>
|
||||
</div>
|
||||
{(ch.regStart || ch.regEnd) && <div style={{ display: "flex", alignItems: "center", gap: 6, padding: "6px 10px", borderRadius: 8, background: `${c.green}08`, border: `1px solid ${c.green}15` }}>
|
||||
<span style={{ fontFamily: f.m, fontSize: 10, color: c.green }}>📋 Registration:</span>
|
||||
<span style={{ fontFamily: f.b, fontSize: 11, color: c.text }}>{ch.regStart || "?"} → {ch.regEnd || "?"}</span>
|
||||
</div>}
|
||||
</div>}
|
||||
</Cd>
|
||||
|
||||
{/* Stats (only for live) */}
|
||||
{ch.status === "registration_open" && <>
|
||||
<div style={{ display: "flex", gap: 6 }}>
|
||||
{[{ n: stats.total, l: "Members", co: c.mid }, { n: stats.passed, l: "Passed", co: c.green }, { n: stats.failed, l: "Failed", co: c.red }, { n: stats.pending, l: "Pending", co: c.yellow }].map(s =>
|
||||
<div key={s.l} style={{ flex: 1, background: c.card, border: `1px solid ${c.brd}`, borderRadius: 12, padding: "10px 6px", textAlign: "center" }}>
|
||||
<p style={{ fontFamily: f.d, fontSize: 20, fontWeight: 700, color: s.co, margin: 0 }}>{s.n}</p>
|
||||
<p style={{ fontFamily: f.m, fontSize: 7, color: c.dim, margin: "2px 0 0", textTransform: "uppercase" }}>{s.l}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Cd>
|
||||
<ST right={<span style={{ fontFamily: f.m, fontSize: 10, color: c.dim }}>tap to view</span>}>⚡ Needs Action</ST>
|
||||
{[
|
||||
{ l: "Receipts to review", n: stats.receipts, icon: "📸", co: c.yellow, go: "Members" },
|
||||
{ l: "Videos to review", n: stats.pending, icon: "🎬", co: c.blue, go: "Results" },
|
||||
].map(a => <div key={a.l} onClick={() => { if (a.go === "Members") setMemFilter("receipts"); setTab(a.go); }} style={{ display: "flex", alignItems: "center", gap: 10, padding: "10px 0", borderBottom: `1px solid ${c.brd}`, cursor: "pointer" }}>
|
||||
<span style={{ fontSize: 16 }}>{a.icon}</span>
|
||||
<span style={{ fontFamily: f.b, fontSize: 13, color: c.text, flex: 1 }}>{a.l}</span>
|
||||
<span style={{ fontFamily: f.m, fontSize: 14, fontWeight: 700, color: a.co }}>{a.n}</span>
|
||||
<span style={{ color: c.dim }}>›</span>
|
||||
</div>)}
|
||||
</Cd>
|
||||
</>}
|
||||
</div>}
|
||||
|
||||
{/* ═══ CATEGORIES ═══ */}
|
||||
{tab === "Categories" && <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
<Cd>
|
||||
<ST right={ch.configured.categories ? <Bg label="✓ DONE" color={c.green} bg={c.greenS} /> : null}>Levels</ST>
|
||||
<TagEditor items={ch.disciplines.flatMap(d => d.levels).filter((v, i, a) => a.indexOf(v) === i)} color={ch.accent} placeholder="Add level (e.g. Amateur)"
|
||||
onAdd={v => { const d = ch.disciplines.length ? [...ch.disciplines] : [{ name: "Exotic Pole Dance", levels: [] }]; d[0] = { ...d[0], levels: [...d[0].levels, v] }; upd("disciplines", d); }}
|
||||
onRemove={i => { const all = ch.disciplines.flatMap(d => d.levels).filter((v, idx, a) => a.indexOf(v) === idx); const rm = all[i]; const d = ch.disciplines.map(d2 => ({ ...d2, levels: d2.levels.filter(l => l !== rm) })); upd("disciplines", d); }} />
|
||||
</Cd>
|
||||
<Cd>
|
||||
<ST>Styles</ST>
|
||||
<TagEditor items={ch.styles} color={c.purple} placeholder="Add style (e.g. Classic)"
|
||||
onAdd={v => upd("styles", [...ch.styles, v])} onRemove={i => upd("styles", ch.styles.filter((_, j) => j !== i))} />
|
||||
</Cd>
|
||||
{!ch.configured.categories && (ch.disciplines.some(d => d.levels.length > 0) && ch.styles.length > 0) && <div onClick={() => markDone("categories")} style={{ padding: "12px", borderRadius: 10, background: c.green, textAlign: "center", cursor: "pointer" }}>
|
||||
<span style={{ fontFamily: f.b, fontSize: 13, fontWeight: 700, color: "#fff" }}>✓ Mark Categories as Done</span>
|
||||
</div>}
|
||||
</div>}
|
||||
|
||||
{/* ═══ FEES ═══ */}
|
||||
{tab === "Fees" && <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
<Cd>
|
||||
<ST right={ch.configured.fees ? <Bg label="✓ DONE" color={c.green} bg={c.greenS} /> : null}>Video Selection Fee</ST>
|
||||
<Input label="Fee amount" value={ch.fees?.videoSelection || ""} onChange={v => upd("fees", { ...ch.fees, videoSelection: v })} placeholder="e.g. 50 BYN / 1,500 RUB" />
|
||||
</Cd>
|
||||
<Cd>
|
||||
<ST>Championship Fees</ST>
|
||||
<Input label="Solo" value={ch.fees?.solo || ""} onChange={v => upd("fees", { ...ch.fees, solo: v })} placeholder="e.g. 280 BYN" />
|
||||
<Input label="Duet (per person)" value={ch.fees?.duet || ""} onChange={v => upd("fees", { ...ch.fees, duet: v })} placeholder="e.g. 210 BYN" />
|
||||
<Input label="Group (per person)" value={ch.fees?.group || ""} onChange={v => upd("fees", { ...ch.fees, group: v })} placeholder="e.g. 190 BYN" />
|
||||
</Cd>
|
||||
{!ch.configured.fees && ch.fees?.videoSelection && <div onClick={() => markDone("fees")} style={{ padding: "12px", borderRadius: 10, background: c.green, textAlign: "center", cursor: "pointer" }}>
|
||||
<span style={{ fontFamily: f.b, fontSize: 13, fontWeight: 700, color: "#fff" }}>✓ Mark Fees as Done</span>
|
||||
</div>}
|
||||
</div>}
|
||||
|
||||
{/* ═══ RULES ═══ */}
|
||||
{tab === "Rules" && <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
<Cd>
|
||||
<ST right={ch.configured.rules ? <Bg label="✓ DONE" color={c.green} bg={c.greenS} /> : null}>General Rules</ST>
|
||||
<TagEditor items={ch.rules} color={c.blue} placeholder="Add rule" onAdd={v => upd("rules", [...ch.rules, v])} onRemove={i => upd("rules", ch.rules.filter((_, j) => j !== i))} />
|
||||
</Cd>
|
||||
<Cd>
|
||||
<ST>Costume Rules</ST>
|
||||
<TagEditor items={ch.costumeRules} color={c.yellow} placeholder="Add costume rule" onAdd={v => upd("costumeRules", [...ch.costumeRules, v])} onRemove={i => upd("costumeRules", ch.costumeRules.filter((_, j) => j !== i))} />
|
||||
</Cd>
|
||||
<Cd>
|
||||
<ST>Scoring Criteria (0–10)</ST>
|
||||
{ch.judging.map((j, i) => <div key={i} style={{ display: "flex", alignItems: "center", gap: 8, padding: "6px 0", borderBottom: `1px solid ${c.brd}` }}>
|
||||
<span style={{ fontFamily: f.b, fontSize: 12, color: c.text, flex: 1 }}>{j.name}</span>
|
||||
<span style={{ fontFamily: f.m, fontSize: 11, color: c.purple }}>0–{j.max}</span>
|
||||
<span onClick={() => upd("judging", ch.judging.filter((_, k) => k !== i))} style={{ fontSize: 10, color: c.dim, cursor: "pointer" }}>×</span>
|
||||
</div>)}
|
||||
<TagEditor items={[]} color={c.purple} placeholder="Add criterion (e.g. Artistry)"
|
||||
onAdd={v => upd("judging", [...ch.judging, { name: v, max: 10 }])} onRemove={() => {}} />
|
||||
</Cd>
|
||||
<Cd>
|
||||
<ST>Penalties</ST>
|
||||
{ch.penalties.map((p, i) => <div key={i} style={{ display: "flex", alignItems: "center", gap: 8, padding: "6px 0", borderBottom: `1px solid ${c.brd}` }}>
|
||||
<span style={{ fontFamily: f.b, fontSize: 12, color: c.text, flex: 1 }}>{p.name}</span>
|
||||
<span style={{ fontFamily: f.m, fontSize: 10, fontWeight: 700, color: p.val === "DQ" ? c.red : c.yellow, background: p.val === "DQ" ? c.redS : c.yellowS, padding: "2px 8px", borderRadius: 4 }}>{p.val}</span>
|
||||
<span onClick={() => upd("penalties", ch.penalties.filter((_, k) => k !== i))} style={{ fontSize: 10, color: c.dim, cursor: "pointer" }}>×</span>
|
||||
</div>)}
|
||||
<TagEditor items={[]} color={c.red} placeholder="Add penalty (e.g. Fall: -2)"
|
||||
onAdd={v => { const [name, val] = v.includes(":") ? v.split(":").map(s => s.trim()) : [v, "-2"]; upd("penalties", [...ch.penalties, { name, val }]); }} onRemove={() => {}} />
|
||||
</Cd>
|
||||
{!ch.configured.rules && ch.rules.length > 0 && <div onClick={() => markDone("rules")} style={{ padding: "12px", borderRadius: 10, background: c.green, textAlign: "center", cursor: "pointer" }}>
|
||||
<span style={{ fontFamily: f.b, fontSize: 13, fontWeight: 700, color: "#fff" }}>✓ Mark Rules as Done</span>
|
||||
</div>}
|
||||
</div>}
|
||||
|
||||
{/* ═══ JUDGES ═══ */}
|
||||
{tab === "Judges" && <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
<ST right={ch.configured.judging ? <Bg label="✓ DONE" color={c.green} bg={c.greenS} /> : <span style={{ fontFamily: f.m, fontSize: 10, color: c.dim }}>{ch.judges.length} judges</span>}>Jury Panel</ST>
|
||||
{ch.judges.map((j, i) => <Cd key={j.id || i} style={{ padding: 14 }}>
|
||||
<div style={{ display: "flex", gap: 12, alignItems: "flex-start" }}>
|
||||
<div style={{ width: 44, height: 44, borderRadius: 12, background: `linear-gradient(135deg,${c.purple}20,${c.purple}40)`, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 20, flexShrink: 0 }}>👩⚖️</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<p style={{ fontFamily: f.b, fontSize: 14, fontWeight: 600, color: c.text, margin: 0 }}>{j.name}</p>
|
||||
<span onClick={() => upd("judges", ch.judges.filter((_, k) => k !== i))} style={{ fontSize: 10, color: c.dim, cursor: "pointer", padding: "4px" }}>×</span>
|
||||
</div>
|
||||
<p style={{ fontFamily: f.m, fontSize: 11, color: c.purple, margin: "2px 0 4px" }}>{j.instagram}</p>
|
||||
<p style={{ fontFamily: f.b, fontSize: 11, color: c.mid, margin: 0, lineHeight: 1.4 }}>{j.bio}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Cd>)}
|
||||
|
||||
{/* Add judge form */}
|
||||
<Cd style={{ background: `${c.purple}06`, border: `1px solid ${c.purple}20` }}>
|
||||
<ST>Add Judge</ST>
|
||||
<Input label="Name" value={newJudge.name} onChange={v => setNewJudge(p => ({ ...p, name: v }))} placeholder="e.g. Anastasia Skukhtorova" />
|
||||
<Input label="Instagram" value={newJudge.instagram} onChange={v => setNewJudge(p => ({ ...p, instagram: v }))} placeholder="e.g. @skukhtorova" />
|
||||
<Input label="Bio / Description" value={newJudge.bio} onChange={v => setNewJudge(p => ({ ...p, bio: v }))} placeholder="Experience, titles, achievements..." />
|
||||
<div onClick={() => { if (newJudge.name) { upd("judges", [...ch.judges, { ...newJudge, id: `j${Date.now()}` }]); setNewJudge({ name: "", instagram: "", bio: "" }); } }} style={{ padding: "10px", borderRadius: 8, background: newJudge.name ? c.purple : c.brd, textAlign: "center", cursor: newJudge.name ? "pointer" : "default", opacity: newJudge.name ? 1 : 0.5 }}>
|
||||
<span style={{ fontFamily: f.b, fontSize: 12, fontWeight: 700, color: "#fff" }}>+ Add Judge</span>
|
||||
</div>
|
||||
</Cd>
|
||||
|
||||
{!ch.configured.judging && ch.judges.length > 0 && <div onClick={() => markDone("judging")} style={{ padding: "12px", borderRadius: 10, background: c.green, textAlign: "center", cursor: "pointer" }}>
|
||||
<span style={{ fontFamily: f.b, fontSize: 13, fontWeight: 700, color: "#fff" }}>✓ Mark Judges as Done</span>
|
||||
</div>}
|
||||
</div>}
|
||||
|
||||
{/* ═══ MEMBERS ═══ */}
|
||||
{tab === "Members" && <div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
<div style={{ background: c.card, border: `1px solid ${c.brd}`, borderRadius: 12, padding: "10px 14px", display: "flex", alignItems: "center", gap: 10 }}>
|
||||
<span style={{ fontSize: 14, opacity: 0.4 }}>🔍</span>
|
||||
<input type="text" placeholder="Search..." value={memSearch} onChange={e => setMemSearch(e.target.value)} style={{ background: "transparent", border: "none", outline: "none", color: c.text, fontFamily: f.b, fontSize: 13, width: "100%" }} />
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 4, overflowX: "auto", scrollbarWidth: "none" }}>
|
||||
{memFilters.map(fi => <div key={fi.id} onClick={() => setMemFilter(fi.id)} style={{ fontFamily: f.m, fontSize: 9, fontWeight: 600, whiteSpace: "nowrap", color: memFilter === fi.id ? ch.accent : c.dim, background: memFilter === fi.id ? `${ch.accent}15` : "transparent", border: `1px solid ${memFilter === fi.id ? `${ch.accent}30` : "transparent"}`, padding: "5px 10px", borderRadius: 16, cursor: "pointer" }}>{fi.l} ({fi.n})</div>)}
|
||||
</div>
|
||||
{filteredMem.map(m => <div key={m.id} onClick={() => onMemberTap(m, ch)} style={{ background: c.card, border: `1px solid ${c.brd}`, borderRadius: 14, padding: 12, cursor: "pointer" }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: 4 }}>
|
||||
<div><p style={{ fontFamily: f.b, fontSize: 13, fontWeight: 600, color: c.text, margin: 0 }}>{m.name}</p><p style={{ fontFamily: f.m, fontSize: 10, color: ch.accent, margin: "1px 0 0" }}>{m.instagram}</p></div>
|
||||
<Bg label={m.passed === true ? "PASSED" : m.passed === false ? "FAILED" : "PENDING"} color={m.passed === true ? c.green : m.passed === false ? c.red : c.yellow} bg={m.passed === true ? c.greenS : m.passed === false ? c.redS : c.yellowS} />
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 5, flexWrap: "wrap" }}>{[m.level, m.style, m.city].map(t => <span key={t} style={{ fontFamily: f.b, fontSize: 9, color: c.mid, background: `${c.mid}10`, padding: "2px 7px", borderRadius: 10 }}>{t}</span>)}</div>
|
||||
</div>)}
|
||||
{filteredMem.length === 0 && <div style={{ textAlign: "center", padding: 30 }}><span style={{ fontSize: 28 }}>🤷</span><p style={{ fontFamily: f.b, fontSize: 13, color: c.dim, marginTop: 8 }}>No members match</p></div>}
|
||||
</div>}
|
||||
|
||||
{/* ═══ RESULTS ═══ */}
|
||||
{tab === "Results" && <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
<div style={{ display: "flex", gap: 6 }}>
|
||||
{[{ n: stats.pending, l: "Pending", co: c.yellow }, { n: stats.passed, l: "Passed", co: c.green }, { n: stats.failed, l: "Failed", co: c.red }].map(s =>
|
||||
<div key={s.l} style={{ flex: 1, background: c.card, border: `1px solid ${c.brd}`, borderRadius: 12, padding: "10px 6px", textAlign: "center" }}>
|
||||
<p style={{ fontFamily: f.d, fontSize: 20, fontWeight: 700, color: s.co, margin: 0 }}>{s.n}</p>
|
||||
<p style={{ fontFamily: f.m, fontSize: 7, color: c.dim, margin: "2px 0 0", textTransform: "uppercase" }}>{s.l}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{members.filter(m => m.videoUrl && m.passed === null).map(m => <Cd key={m.id} style={{ padding: 12 }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 8 }}>
|
||||
<div><p style={{ fontFamily: f.b, fontSize: 13, fontWeight: 600, color: c.text, margin: 0 }}>{m.name}</p><p style={{ fontFamily: f.m, fontSize: 10, color: c.dim, margin: "2px 0 0" }}>{m.level} · {m.style}</p></div>
|
||||
<span style={{ fontFamily: f.b, fontSize: 10, color: c.blue, background: c.blueS, padding: "3px 8px", borderRadius: 8, cursor: "pointer" }}>🎥 View</span>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<div onClick={() => decide(m.id, true)} style={{ flex: 1, padding: "10px", borderRadius: 10, background: `${c.green}15`, border: `1px solid ${c.green}30`, cursor: "pointer", textAlign: "center" }}><span style={{ fontFamily: f.b, fontSize: 12, fontWeight: 700, color: c.green }}>✅ Pass</span></div>
|
||||
<div onClick={() => decide(m.id, false)} style={{ flex: 1, padding: "10px", borderRadius: 10, background: `${c.red}15`, border: `1px solid ${c.red}30`, cursor: "pointer", textAlign: "center" }}><span style={{ fontFamily: f.b, fontSize: 12, fontWeight: 700, color: c.red }}>❌ Fail</span></div>
|
||||
</div>
|
||||
</Cd>)}
|
||||
{members.filter(m => m.passed !== null).length > 0 && <>
|
||||
<ST>Decided</ST>
|
||||
{members.filter(m => m.passed !== null).map(m => <div key={m.id} style={{ display: "flex", alignItems: "center", gap: 10, padding: "10px 12px", background: c.card, border: `1px solid ${c.brd}`, borderRadius: 12 }}>
|
||||
<div style={{ flex: 1 }}><p style={{ fontFamily: f.b, fontSize: 13, color: c.text, margin: 0 }}>{m.name}</p><p style={{ fontFamily: f.m, fontSize: 10, color: c.dim, margin: "2px 0 0" }}>{m.level}</p></div>
|
||||
<Bg label={m.passed ? "PASSED" : "FAILED"} color={m.passed ? c.green : c.red} bg={m.passed ? c.greenS : c.redS} />
|
||||
</div>)}
|
||||
</>}
|
||||
<div style={{ padding: "14px", borderRadius: 12, background: ch.accent, textAlign: "center", cursor: "pointer" }}><span style={{ fontFamily: f.b, fontSize: 14, fontWeight: 700, color: "#fff" }}>📢 Publish Results</span></div>
|
||||
</div>}
|
||||
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
/* ── Member Detail ── */
|
||||
function MemberDetail({ member, champ, onBack }) {
|
||||
const [m, setM] = useState(member);
|
||||
const [showLvl, setShowLvl] = useState(false);
|
||||
const [showSty, setShowSty] = useState(false);
|
||||
const levels = champ.disciplines.flatMap(d => d.levels).filter((v, i, a) => a.indexOf(v) === i);
|
||||
|
||||
return <div style={{ flex: 1, overflow: "auto" }}>
|
||||
<Hdr title={m.name} subtitle={`${champ.name} · ${m.instagram}`} onBack={onBack} />
|
||||
<div style={{ padding: "6px 16px 20px", display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
<Cd style={{ display: "flex", alignItems: "center", gap: 14, padding: "14px 16px" }}>
|
||||
<div style={{ width: 50, height: 50, borderRadius: 14, background: `linear-gradient(135deg,${champ.accent}20,${champ.accent}40)`, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 22, flexShrink: 0 }}>👤</div>
|
||||
<div style={{ flex: 1 }}><p style={{ fontFamily: f.b, fontSize: 16, fontWeight: 600, color: c.text, margin: 0 }}>{m.name}</p><p style={{ fontFamily: f.m, fontSize: 11, color: champ.accent, margin: "2px 0 0" }}>{m.instagram}</p><p style={{ fontFamily: f.b, fontSize: 11, color: c.dim, margin: "2px 0 0" }}>📍 {m.city}</p></div>
|
||||
<Bg label={m.passed === true ? "PASSED" : m.passed === false ? "FAILED" : "PENDING"} color={m.passed === true ? c.green : m.passed === false ? c.red : c.yellow} bg={m.passed === true ? c.greenS : m.passed === false ? c.redS : c.yellowS} />
|
||||
</Cd>
|
||||
|
||||
<Cd>
|
||||
<ST>Registration</ST>
|
||||
{[{ l: "Discipline", v: m.discipline }, { l: "Type", v: m.type }].map(r => <div key={r.l} style={{ display: "flex", justifyContent: "space-between", padding: "7px 0", borderBottom: `1px solid ${c.brd}` }}><span style={{ fontFamily: f.m, fontSize: 10, color: c.dim, textTransform: "uppercase" }}>{r.l}</span><span style={{ fontFamily: f.b, fontSize: 12, color: c.text }}>{r.v}</span></div>)}
|
||||
|
||||
{/* Level */}
|
||||
<div style={{ padding: "7px 0", borderBottom: `1px solid ${c.brd}` }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<span style={{ fontFamily: f.m, fontSize: 10, color: c.dim, textTransform: "uppercase" }}>Level</span>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<span style={{ fontFamily: f.b, fontSize: 12, fontWeight: 600, color: c.text }}>{m.level}</span>
|
||||
<div onClick={() => { setShowLvl(!showLvl); setShowSty(false); }} style={{ fontFamily: f.b, fontSize: 10, fontWeight: 600, color: showLvl ? c.dim : "#fff", background: showLvl ? "transparent" : champ.accent, border: `1px solid ${showLvl ? c.brd : champ.accent}`, padding: "3px 10px", borderRadius: 6, cursor: "pointer" }}>{showLvl ? "✕" : "✎ Edit"}</div>
|
||||
</div>
|
||||
</div>
|
||||
{showLvl && <div style={{ marginTop: 8, padding: 8, background: c.bg, borderRadius: 8 }}>
|
||||
<p style={{ fontFamily: f.m, fontSize: 9, color: c.yellow, margin: "0 0 6px" }}>⚠️ Member will be notified</p>
|
||||
{levels.map(l => <div key={l} onClick={() => { setM(p => ({ ...p, level: l })); setShowLvl(false); }} style={{ padding: "8px 10px", borderRadius: 6, cursor: "pointer", marginBottom: 3, background: l === m.level ? `${champ.accent}15` : "transparent", border: `1px solid ${l === m.level ? `${champ.accent}30` : c.brd}` }}>
|
||||
<span style={{ fontFamily: f.b, fontSize: 12, color: l === m.level ? champ.accent : c.text }}>{l}</span>
|
||||
</div>)}
|
||||
</div>}
|
||||
</div>
|
||||
|
||||
{/* Style */}
|
||||
<div style={{ padding: "7px 0" }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<span style={{ fontFamily: f.m, fontSize: 10, color: c.dim, textTransform: "uppercase" }}>Style</span>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<span style={{ fontFamily: f.b, fontSize: 12, fontWeight: 600, color: c.text }}>{m.style}</span>
|
||||
<div onClick={() => { setShowSty(!showSty); setShowLvl(false); }} style={{ fontFamily: f.b, fontSize: 10, fontWeight: 600, color: showSty ? c.dim : "#fff", background: showSty ? "transparent" : c.purple, border: `1px solid ${showSty ? c.brd : c.purple}`, padding: "3px 10px", borderRadius: 6, cursor: "pointer" }}>{showSty ? "✕" : "✎ Edit"}</div>
|
||||
</div>
|
||||
</div>
|
||||
{showSty && <div style={{ marginTop: 8, padding: 8, background: c.bg, borderRadius: 8 }}>
|
||||
<p style={{ fontFamily: f.m, fontSize: 9, color: c.yellow, margin: "0 0 6px" }}>⚠️ Member will be notified</p>
|
||||
{champ.styles.map(s => <div key={s} onClick={() => { setM(p => ({ ...p, style: s })); setShowSty(false); }} style={{ padding: "8px 10px", borderRadius: 6, cursor: "pointer", marginBottom: 3, background: s === m.style ? `${c.purple}15` : "transparent", border: `1px solid ${s === m.style ? `${c.purple}30` : c.brd}` }}>
|
||||
<span style={{ fontFamily: f.b, fontSize: 12, color: s === m.style ? c.purple : c.text }}>{s}</span>
|
||||
</div>)}
|
||||
</div>}
|
||||
</div>
|
||||
</Cd>
|
||||
|
||||
{/* Video */}
|
||||
<Cd>
|
||||
<ST>🎬 Video</ST>
|
||||
{m.videoUrl ? <>
|
||||
<div style={{ background: c.bg, borderRadius: 8, padding: 10, marginBottom: 8, display: "flex", alignItems: "center", gap: 8 }}><span style={{ fontSize: 18 }}>🎥</span><p style={{ fontFamily: f.m, fontSize: 10, color: c.blue, margin: 0, flex: 1, overflow: "hidden", textOverflow: "ellipsis" }}>{m.videoUrl}</p></div>
|
||||
{m.passed === null ? <div style={{ display: "flex", gap: 8 }}>
|
||||
<div onClick={() => setM(p => ({ ...p, passed: true }))} style={{ flex: 1, padding: "10px", borderRadius: 10, background: c.green, cursor: "pointer", textAlign: "center" }}><span style={{ fontFamily: f.b, fontSize: 12, fontWeight: 700, color: "#fff" }}>✅ Pass</span></div>
|
||||
<div onClick={() => setM(p => ({ ...p, passed: false }))} style={{ flex: 1, padding: "10px", borderRadius: 10, background: c.red, cursor: "pointer", textAlign: "center" }}><span style={{ fontFamily: f.b, fontSize: 12, fontWeight: 700, color: "#fff" }}>❌ Fail</span></div>
|
||||
</div> : <Bg label={m.passed ? "PASSED" : "FAILED"} color={m.passed ? c.green : c.red} bg={m.passed ? c.greenS : c.redS} />}
|
||||
</> : <p style={{ fontFamily: f.b, fontSize: 12, color: c.dim, margin: 0 }}>No video yet</p>}
|
||||
</Cd>
|
||||
|
||||
{/* Payment */}
|
||||
<Cd>
|
||||
<ST>💳 Payment</ST>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<div><p style={{ fontFamily: f.b, fontSize: 12, color: c.text, margin: 0 }}>Video fee</p><p style={{ fontFamily: f.m, fontSize: 10, color: c.dim, margin: "2px 0 0" }}>{champ.fees?.videoSelection || "—"}</p></div>
|
||||
{m.receiptUploaded && !m.feePaid ? <div onClick={() => setM(p => ({ ...p, feePaid: true }))} style={{ padding: "6px 12px", borderRadius: 8, background: `${c.green}15`, border: `1px solid ${c.green}30`, cursor: "pointer" }}><span style={{ fontFamily: f.b, fontSize: 11, fontWeight: 600, color: c.green }}>📸 Confirm</span></div>
|
||||
: <Bg label={m.feePaid ? "CONFIRMED" : "PENDING"} color={m.feePaid ? c.green : c.yellow} bg={m.feePaid ? c.greenS : c.yellowS} />}
|
||||
</div>
|
||||
</Cd>
|
||||
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 6, padding: "12px", borderRadius: 12, background: c.card, border: `1px solid ${c.brd}`, cursor: "pointer" }}><span style={{ fontSize: 14 }}>🔔</span><span style={{ fontFamily: f.b, fontSize: 12, fontWeight: 600, color: c.text }}>Send Notification</span></div>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
/* ── Quick Create ── */
|
||||
function QuickCreate({ onBack, onDone }) {
|
||||
const [name, setName] = useState("");
|
||||
const [eventDate, setEventDate] = useState("");
|
||||
const [location, setLocation] = useState("");
|
||||
|
||||
return <div style={{ flex: 1, overflow: "auto" }}>
|
||||
<Hdr title="New Championship" subtitle="Quick create — configure details later" onBack={onBack} />
|
||||
<div style={{ padding: "6px 16px 20px", display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
<Cd>
|
||||
<Input label="Championship Name" value={name} onChange={setName} placeholder="e.g. Zero Gravity" />
|
||||
<Input label="Event Date" value={eventDate} onChange={setEventDate} placeholder="e.g. May 30, 2026" />
|
||||
<Input label="Location" value={location} onChange={setLocation} placeholder="e.g. Minsk, Belarus" />
|
||||
</Cd>
|
||||
|
||||
<Cd style={{ background: `${c.blue}06`, border: `1px solid ${c.blue}20` }}>
|
||||
<p style={{ fontFamily: f.b, fontSize: 12, color: c.blue, margin: "0 0 6px" }}>💡 What happens next?</p>
|
||||
<p style={{ fontFamily: f.b, fontSize: 11, color: c.mid, margin: 0, lineHeight: 1.6 }}>Your championship will be created as a draft. Configure categories, fees, rules, and judging at your own pace. Once everything is set, hit "Go Live" to open registration.</p>
|
||||
</Cd>
|
||||
|
||||
<div onClick={() => name && onDone(makeCh({ id: `ch${Date.now()}`, name, eventDate, location, status: "draft", configured: { info: !!eventDate && !!location, categories: false, fees: false, rules: false, judging: false } }))} style={{ padding: "14px", borderRadius: 12, background: name ? c.accent : c.brd, textAlign: "center", cursor: name ? "pointer" : "default", opacity: name ? 1 : 0.5 }}>
|
||||
<span style={{ fontFamily: f.b, fontSize: 14, fontWeight: 700, color: "#fff" }}>✨ Create Draft</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
/* ── Org Settings ── */
|
||||
function OrgSettings({ org, onUpdateOrg }) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [name, setName] = useState(org.name);
|
||||
const [instagram, setInstagram] = useState(org.instagram);
|
||||
const [subScreen, setSubScreen] = useState(null);
|
||||
|
||||
if (subScreen === "notifications") return <div>
|
||||
<Hdr title="Notifications" subtitle="Notification preferences" onBack={() => setSubScreen(null)} />
|
||||
<div style={{ padding: "6px 16px 20px", display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
{[{ l: "Push notifications", d: "Get notified on new registrations", on: true },
|
||||
{ l: "Email notifications", d: "Receive email for payments & uploads", on: true },
|
||||
{ l: "Registration alerts", d: "When a new member registers", on: true },
|
||||
{ l: "Payment alerts", d: "When a receipt is uploaded", on: true },
|
||||
{ l: "Deadline reminders", d: "Auto-remind members before deadlines", on: false },
|
||||
].map(n => <ToggleRow key={n.l} label={n.l} desc={n.d} defaultOn={n.on} />)}
|
||||
</div>
|
||||
</div>;
|
||||
|
||||
if (subScreen === "accounts") return <div>
|
||||
<Hdr title="Connected Accounts" subtitle="Integrations" onBack={() => setSubScreen(null)} />
|
||||
<div style={{ padding: "6px 16px 20px", display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
{[{ name: "Instagram", handle: org.instagram, icon: "📸", connected: true, color: c.purple },
|
||||
{ name: "Gmail", handle: "zerogravity@gmail.com", icon: "📧", connected: true, color: c.red },
|
||||
{ name: "Telegram", handle: "@zerogravity_bot", icon: "💬", connected: false, color: c.blue },
|
||||
].map(a => <Cd key={a.name} style={{ display: "flex", alignItems: "center", gap: 12, padding: 14 }}>
|
||||
<div style={{ width: 38, height: 38, borderRadius: 10, background: `${a.color}15`, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 17 }}>{a.icon}</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<p style={{ fontFamily: f.b, fontSize: 13, fontWeight: 600, color: c.text, margin: 0 }}>{a.name}</p>
|
||||
<p style={{ fontFamily: f.m, fontSize: 10, color: a.connected ? a.color : c.dim, margin: "2px 0 0" }}>{a.connected ? a.handle : "Not connected"}</p>
|
||||
</div>
|
||||
<Bg label={a.connected ? "CONNECTED" : "CONNECT"} color={a.connected ? c.green : c.accent} bg={a.connected ? c.greenS : c.accentS} />
|
||||
</Cd>)}
|
||||
</div>
|
||||
</div>;
|
||||
|
||||
return <div>
|
||||
<Hdr title="Settings" subtitle="Organization profile" />
|
||||
<div style={{ padding: "6px 20px 20px" }}>
|
||||
{/* Profile header */}
|
||||
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", marginBottom: 20 }}>
|
||||
<div style={{ width: 68, height: 68, borderRadius: 18, background: `linear-gradient(135deg,${c.accent}25,${c.accent}10)`, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 30, marginBottom: 10, border: `2px solid ${c.accent}35` }}>{org.logo}</div>
|
||||
{editing ? <div style={{ width: "100%", display: "flex", flexDirection: "column", gap: 8, marginTop: 4 }}>
|
||||
<Input label="Organization Name" value={name} onChange={setName} placeholder="Your org name" />
|
||||
<Input label="Instagram" value={instagram} onChange={setInstagram} placeholder="@handle" />
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<div onClick={() => setEditing(false)} style={{ flex: 1, padding: "10px", borderRadius: 8, background: c.card, border: `1px solid ${c.brd}`, textAlign: "center", cursor: "pointer" }}><span style={{ fontFamily: f.b, fontSize: 12, color: c.dim }}>Cancel</span></div>
|
||||
<div onClick={() => { onUpdateOrg({ name, instagram }); setEditing(false); }} style={{ flex: 1, padding: "10px", borderRadius: 8, background: c.green, textAlign: "center", cursor: "pointer" }}><span style={{ fontFamily: f.b, fontSize: 12, fontWeight: 700, color: "#fff" }}>✓ Save</span></div>
|
||||
</div>
|
||||
</div> : <>
|
||||
<h2 style={{ fontFamily: f.d, fontSize: 19, fontWeight: 700, color: c.text, margin: "0 0 2px" }}>{org.name}</h2>
|
||||
<p style={{ fontFamily: f.m, fontSize: 11, color: c.accent, margin: 0 }}>{org.instagram}</p>
|
||||
</>}
|
||||
</div>
|
||||
|
||||
{/* Menu items — hide when editing */}
|
||||
{!editing && <div style={{ background: c.card, border: `1px solid ${c.brd}`, borderRadius: 12, overflow: "hidden" }}>
|
||||
{[
|
||||
{ label: "Edit Organization Profile", action: () => setEditing(true), icon: "✎" },
|
||||
{ label: "Notification Preferences", action: () => setSubScreen("notifications"), icon: "🔔" },
|
||||
{ label: "Connected Accounts", action: () => setSubScreen("accounts"), icon: "🔗" },
|
||||
{ label: "Help & Support", action: () => {}, icon: "❓" },
|
||||
{ label: "Log Out", action: () => {}, icon: "🚪", danger: true },
|
||||
].map((x, i, a) =>
|
||||
<div key={x.label} onClick={x.action} style={{ padding: "13px 16px", fontFamily: f.b, fontSize: 13, color: x.danger ? c.red : c.text, borderBottom: i < a.length - 1 ? `1px solid ${c.brd}` : "none", cursor: "pointer", display: "flex", alignItems: "center", gap: 10 }}>
|
||||
<span style={{ fontSize: 14, opacity: 0.6 }}>{x.icon}</span>
|
||||
<span style={{ flex: 1 }}>{x.label}</span>
|
||||
<span style={{ color: c.dim, fontSize: 12 }}>›</span>
|
||||
</div>
|
||||
)}
|
||||
</div>}
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
/* Toggle row helper */
|
||||
function ToggleRow({ label, desc, defaultOn }) {
|
||||
const [on, setOn] = useState(defaultOn);
|
||||
return <Cd style={{ display: "flex", alignItems: "center", gap: 12, padding: 14 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<p style={{ fontFamily: f.b, fontSize: 13, color: c.text, margin: 0 }}>{label}</p>
|
||||
{desc && <p style={{ fontFamily: f.b, fontSize: 10, color: c.dim, margin: "2px 0 0" }}>{desc}</p>}
|
||||
</div>
|
||||
<div onClick={() => setOn(!on)} style={{ width: 42, height: 24, borderRadius: 12, background: on ? c.green : c.brd, padding: 2, cursor: "pointer", transition: "background 0.2s" }}>
|
||||
<div style={{ width: 20, height: 20, borderRadius: 10, background: "#fff", transform: on ? "translateX(18px)" : "translateX(0)", transition: "transform 0.2s" }} />
|
||||
</div>
|
||||
</Cd>;
|
||||
}
|
||||
|
||||
/* ── App Shell ── */
|
||||
export default function OrgApp() {
|
||||
const [champs, setChamps] = useState(INITIAL_CHAMPS);
|
||||
const [org, setOrg] = useState(ORG);
|
||||
const [scr, setScr] = useState("dash");
|
||||
const [selChamp, setSelChamp] = useState(null);
|
||||
const [selMember, setSelMember] = useState(null);
|
||||
|
||||
const addChamp = ch => { setChamps(p => [...p, ch]); setSelChamp(ch); setScr("champ"); };
|
||||
const updateOrg = updates => setOrg(p => ({ ...p, ...updates }));
|
||||
|
||||
const render = () => {
|
||||
if (scr === "create") return <QuickCreate onBack={() => setScr("dash")} onDone={addChamp} />;
|
||||
if (scr === "champ" && selChamp) return <ChampDetail ch={selChamp} onBack={() => { setScr("dash"); setSelChamp(null); }} onMemberTap={(m, ch) => { setSelMember({ m, ch }); setScr("member"); }} />;
|
||||
if (scr === "member" && selMember) return <MemberDetail member={selMember.m} champ={selMember.ch} onBack={() => setScr("champ")} />;
|
||||
if (scr === "orgSettings") return <OrgSettings org={org} onUpdateOrg={updateOrg} />;
|
||||
return <Dashboard champs={champs} org={org} onChampTap={ch => { setSelChamp(ch); setScr("champ"); }} onCreateChamp={() => setScr("create")} />;
|
||||
};
|
||||
|
||||
const showNav = scr === "dash" || scr === "orgSettings";
|
||||
|
||||
return <div style={{ display: "flex", justifyContent: "center", alignItems: "center", minHeight: "100vh", background: "#030206", padding: 20, fontFamily: f.b }}>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;600;700&family=DM+Sans:wght@400;500;600&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet" />
|
||||
<style>{`*::-webkit-scrollbar{display:none}*{scrollbar-width:none}`}</style>
|
||||
<div style={{ width: 375, height: 740, background: c.bg, borderRadius: 36, overflow: "hidden", display: "flex", flexDirection: "column", border: `1.5px solid ${c.brd}`, boxShadow: `0 0 80px rgba(212,20,90,0.06),0 20px 40px rgba(0,0,0,0.5)` }}>
|
||||
<div style={{ padding: "8px 24px", display: "flex", justifyContent: "space-between", alignItems: "center", flexShrink: 0 }}>
|
||||
<span style={{ fontFamily: f.m, fontSize: 11, color: c.dim }}>9:41</span>
|
||||
<div style={{ width: 100, height: 28, background: "#000", borderRadius: 14 }} />
|
||||
<span style={{ fontFamily: f.m, fontSize: 11, color: c.dim }}>●●●</span>
|
||||
</div>
|
||||
<div style={{ flex: 1, overflow: "auto", minHeight: 0 }}>{render()}</div>
|
||||
{showNav && <Nav active={scr} onChange={s => { setScr(s); setSelChamp(null); setSelMember(null); }} />}
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
36
docker-compose.yml
Normal file
36
docker-compose.yml
Normal 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
41
mobile/.gitignore
vendored
Normal 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
19
mobile/App.tsx
Normal 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
28
mobile/app.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
mobile/assets/adaptive-icon.png
Normal file
BIN
mobile/assets/adaptive-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
mobile/assets/favicon.png
Normal file
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
BIN
mobile/assets/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
BIN
mobile/assets/splash-icon.png
Normal file
BIN
mobile/assets/splash-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
8
mobile/index.ts
Normal file
8
mobile/index.ts
Normal 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
8705
mobile/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
34
mobile/package.json
Normal file
34
mobile/package.json
Normal 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
33
mobile/src/api/auth.ts
Normal 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),
|
||||
};
|
||||
25
mobile/src/api/championships.ts
Normal file
25
mobile/src/api/championships.ts
Normal 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
73
mobile/src/api/client.ts
Normal 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
12
mobile/src/api/users.ts
Normal 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),
|
||||
};
|
||||
114
mobile/src/navigation/index.tsx
Normal file
114
mobile/src/navigation/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
290
mobile/src/screens/admin/AdminScreen.tsx
Normal file
290
mobile/src/screens/admin/AdminScreen.tsx
Normal 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' },
|
||||
});
|
||||
128
mobile/src/screens/auth/LoginScreen.tsx
Normal file
128
mobile/src/screens/auth/LoginScreen.tsx
Normal 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 },
|
||||
});
|
||||
36
mobile/src/screens/auth/PendingApprovalScreen.tsx
Normal file
36
mobile/src/screens/auth/PendingApprovalScreen.tsx
Normal 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' },
|
||||
});
|
||||
297
mobile/src/screens/auth/RegisterScreen.tsx
Normal file
297
mobile/src/screens/auth/RegisterScreen.tsx
Normal 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 },
|
||||
});
|
||||
246
mobile/src/screens/championships/ChampionshipDetailScreen.tsx
Normal file
246
mobile/src/screens/championships/ChampionshipDetailScreen.tsx
Normal 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' },
|
||||
});
|
||||
135
mobile/src/screens/championships/ChampionshipsScreen.tsx
Normal file
135
mobile/src/screens/championships/ChampionshipsScreen.tsx
Normal 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 },
|
||||
});
|
||||
189
mobile/src/screens/championships/MyRegistrationsScreen.tsx
Normal file
189
mobile/src/screens/championships/MyRegistrationsScreen.tsx
Normal 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' },
|
||||
});
|
||||
149
mobile/src/screens/profile/ProfileScreen.tsx
Normal file
149
mobile/src/screens/profile/ProfileScreen.tsx
Normal 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' },
|
||||
});
|
||||
94
mobile/src/store/auth.store.ts
Normal file
94
mobile/src/store/auth.store.ts
Normal 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
63
mobile/src/types/index.ts
Normal 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;
|
||||
}
|
||||
49
mobile/src/utils/tokenStorage.ts
Normal file
49
mobile/src/utils/tokenStorage.ts
Normal 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
6
mobile/tsconfig.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true
|
||||
}
|
||||
}
|
||||
4
start-backend.bat
Normal file
4
start-backend.bat
Normal 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
|
||||
Reference in New Issue
Block a user