Compare commits

..

19 Commits

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 23:22:08 +03:00
142 changed files with 15230 additions and 10958 deletions

91
CLAUDE.md Normal file
View File

@@ -0,0 +1,91 @@
# CLAUDE.md — Pole Dance Championships App
## Target Vision
See `dancechamp-claude-code/` for full spec (3-app Supabase platform).
Current implementation is a simplified MVP working toward that goal.
## Quick Start
```bash
# Backend
cd backend && .venv/Scripts/python -m uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
# Web frontend
cd web && npm run dev # runs on http://localhost:3000
# Seed test data
cd backend && .venv/Scripts/python seed.py
```
## Current Architecture
- **Backend**: FastAPI + SQLAlchemy 2 (async) + aiosqlite (SQLite dev) + Alembic + bcrypt
- **Web**: Next.js 15 (App Router) + Tailwind CSS + shadcn/ui + TanStack Query + Zustand
- **Auth**: JWT access (15min) + refresh token rotation (7 days, SHA-256 hashed in DB)
## Project Layout
```
backend/app/ FastAPI app (models, schemas, routers, crud, services)
backend/alembic/ Async migrations
backend/.venv/ Virtual env (use .venv/Scripts/python)
web/src/app/ Next.js App Router pages
web/src/lib/api/ Axios client + API modules (auth, championships, users)
web/src/store/ Zustand auth store
web/src/components/ Shared UI components
web/src/middleware.ts Route protection
dancechamp-claude-code/ Target spec: SPEC.md, PLAN.md, DATABASE.md, DESIGN-SYSTEM.md, SCREENS.md
```
## Gotchas
- **passlib + bcrypt 5.x incompatible** — use `bcrypt` directly (NOT passlib)
- **Circular import**: auth_service.py must NOT import crud_user
- **SQLAlchemy UUID**: Use `sqlalchemy.Uuid(as_uuid=True)` not `postgresql.UUID`
- **Stale uvicorn**: Kill with `powershell.exe -NoProfile -Command "Get-Process python | Stop-Process -Force"`
- **Web API URL**: set `NEXT_PUBLIC_API_URL` in `web/.env.local` (default: http://localhost:8000/api/v1)
## Test Credentials (run seed.py)
```
admin@pole.dev / Admin1234 (admin, approved)
organizer@pole.dev / Org1234 (organizer, approved)
member@pole.dev / Member1234 (member, approved)
```
## API Routes
- Auth: POST /api/v1/auth/{register,login,refresh,logout,me}
- Championships: GET/POST/PATCH/DELETE /api/v1/championships
- Registrations: POST/GET/PATCH/DELETE /api/v1/registrations
- Users (admin): GET /api/v1/users, PATCH /api/v1/users/{id}/{approve,reject}
- Health: GET /internal/health | Swagger: GET /docs
## Code Conventions
- Backend: Pydantic v2 schemas, async SQLAlchemy, `from_attributes = True`
- Web: Functional components, TanStack Query for server state, Zustand for auth, shadcn/ui components
- Registration: members auto-approved, organizers require admin approval
## Environment
- Windows 10, Python 3.12, Node.js
- Docker NOT installed, PostgreSQL NOT installed (use SQLite for dev)
- venv always in backend/.venv
## Linear Workflow (MANDATORY)
**Every action** (coding, migrations, docs, planning, bug fixes) MUST have a Linear issue.
Before starting ANY task:
1. Check if a matching Linear issue exists in the "Pole dance app" workspace
2. If not — create one (title, description, priority)
3. Set status to **In Progress** before writing any code
After completing:
- Set status to **Done**
- Make a git commit with message: `POL-N: <task title>`
- Report the issue number to the user (e.g. "Done — POL-42")
**Unrelated fixes during a task:**
If you discover and fix something unrelated to the current issue (e.g. a bug spotted while working on a feature), create a **separate Linear issue** describing what was fixed, commit it separately with that issue's `POL-N` prefix. Never mix unrelated changes into another issue's commit.

348
LINEAR-ROADMAP.md Normal file
View File

@@ -0,0 +1,348 @@
# DanceChamp — Linear Roadmap
> Development plan for the Pole Dance Championships app.
> Tech: FastAPI + SQLAlchemy (async) + Expo (React Native) + PostgreSQL.
> Copy each section as a **Project** in Linear, each checkbox as an **Issue**.
---
## Status Legend
- ✅ = Already implemented (current MVP)
- 🔴 = Blocker (must have)
- 🟡 = Important
- 🟢 = Nice to have
---
## Project 1: Database & Models Expansion
> Expand the database schema from 4 tables to the full 12+ table schema required by the spec.
-`users` table (id, email, password, full_name, phone, role, status)
-`refresh_tokens` table (JWT refresh rotation)
-`championships` table (basic: title, description, location, event_date, status)
-`registrations` table (basic: user_id, championship_id, category, level, status)
- [ ] 🔴 Add `organizations` table — id, user_id (FK), name, instagram, email, city, logo_url, verified, status, block_reason
- [ ] 🔴 Expand `championships` — add subtitle, reg_start, reg_end, venue, accent_color, source (manual/instagram), instagram_media_id, image_url, raw_caption_text
- [ ] 🔴 Add `disciplines` table — id, championship_id (FK), name, levels (JSON array)
- [ ] 🔴 Add `styles` table — id, championship_id (FK), name
- [ ] 🔴 Add `fees` table — id, championship_id (FK), video_selection, solo, duet, group, refund_note
- [ ] 🔴 Add `rules` table — id, championship_id (FK), section (general/costume/scoring/penalty), name, value
- [ ] 🔴 Add `judges` table — id, championship_id (FK), name, instagram, bio, photo_url
- [ ] 🔴 Expand `registrations` — add discipline_id, style, participation_type (solo/duet/group), current_step (1-10), video_url, receipt_url, insurance_url, passed (null/true/false)
- [ ] 🔴 Add `participant_lists` table — id, championship_id (FK), published_by, is_published, published_at, notes
- [ ] 🔴 Add `notifications` table — id, user_id (FK), championship_id, type, title, message, read, created_at
- [ ] 🟡 Add `activity_logs` table — id, actor_id, action, target_type, target_id, details (JSON), created_at
- [ ] 🟡 Add `sync_state` table — key, value, updated_at (for Instagram sync tracking)
- [ ] 🔴 Create Alembic migration for all new tables
- [ ] 🔴 Update Pydantic schemas for all new/expanded models
- [ ] 🟡 Add seed data for new tables (disciplines, fees, rules, judges)
---
## Project 2: Organization System
> Organizations are separate entities from users. An organizer user creates/manages an organization.
- [ ] 🔴 Organization CRUD API — POST /api/v1/organizations, GET, PATCH
- [ ] 🔴 Organization approval flow — pending → admin approves → active
- [ ] 🔴 Organization verification system — verified orgs auto-approve championships
- [ ] 🔴 Link championships to organizations (not directly to users)
- [ ] 🔴 `get_org_owner` dependency — checks user owns the organization
- [ ] 🟡 Organization profile with logo upload
- [ ] 🟡 Mobile: Organization setup screen (name, instagram, city)
- [ ] 🟡 Mobile: Organization dashboard (list own championships)
---
## Project 3: Championship Configuration (Org Side)
> Organizers configure championships through tabbed interface with 5 sections.
- ✅ Basic championship CRUD (title, description, location, date)
- [ ] 🔴 Quick Create — minimal 3-field form (name, date, location) → creates draft
- [ ] 🔴 Championship setup progress tracking — 5 sections (info, categories, fees, rules, judges)
- [ ] 🔴 Categories API — CRUD for disciplines + levels per championship
- [ ] 🔴 Styles API — CRUD for styles per championship
- [ ] 🔴 Fees API — set video_selection, solo, duet, group fees
- [ ] 🔴 Rules API — add/remove general rules, costume rules, scoring criteria, penalties
- [ ] 🔴 Judges API — CRUD for judge profiles per championship
- [ ] 🔴 "Go Live" endpoint — validates all sections done, sets status
- [ ] 🔴 Registration period — reg_start, reg_end dates; auto-close when expired
- [ ] 🔴 Mobile: Championship config tabs (Overview, Categories, Fees, Rules, Judges)
- [ ] 🔴 Mobile: Tag editor component (for rules, levels, styles)
- [ ] 🔴 Mobile: "Mark as Done" per section + progress checklist
- [ ] 🟡 Mobile: Inline editing for championship info fields
- [ ] 🟡 Championship status guard — prevent edits on live championships
---
## Project 4: Enhanced Member Experience
> Rich championship browsing with 5-tab detail view, search, and filtering.
- ✅ Championship list screen
- ✅ Basic championship detail screen
- [ ] 🔴 Championship Detail — 5 tabs: Overview, Categories, Rules, Fees, Judges
- [ ] 🔴 Overview tab — event info, registration period, member count, "Register" button
- [ ] 🔴 Categories tab — show disciplines with levels and styles
- [ ] 🔴 Rules tab — general rules, costume rules, scoring criteria, penalties
- [ ] 🔴 Fees tab — video selection + championship fees breakdown
- [ ] 🔴 Judges tab — judge profiles with photo, instagram, bio
- [ ] 🔴 Search championships — full-text search by name/org
- [ ] 🔴 Filter championships — by discipline, location, status (open/upcoming/past)
- [ ] 🟡 Sort championships — by date, by popularity
- [ ] 🟡 Home screen dashboard — active registrations with progress, upcoming championships
- [ ] 🟡 Pull-to-refresh on all list screens
- [ ] 🟢 Championship image/poster display
---
## Project 5: 10-Step Registration Progress Tracker
> Members track their championship registration through 10 steps with uploads and auto-updates.
- ✅ Basic registration (submit application)
- ✅ Registration status display (submitted/accepted/rejected)
- [ ] 🔴 10-step progress model — current_step field on registration
- [ ] 🔴 Step 1: Review rules — auto-mark when user opens Rules tab
- [ ] 🔴 Step 2: Select category — saved from registration form (discipline + level + style)
- [ ] 🔴 Step 3: Record video — manual toggle
- [ ] 🔴 Step 4: Submit video form — manual toggle or external link
- [ ] 🔴 Step 5: Pay video fee — upload receipt screenshot
- [ ] 🔴 Step 6: Wait for results — pending state until org decides
- [ ] 🔴 Step 7: Results — auto-updates when org passes/fails video
- [ ] 🔴 Step 8: Pay championship fee — upload receipt (only if passed)
- [ ] 🔴 Step 9: Submit "About Me" — manual toggle or external link
- [ ] 🔴 Step 10: Confirm insurance — upload insurance document
- [ ] 🔴 Mobile: Vertical step list with icons, status (locked/available/done/failed)
- [ ] 🔴 Mobile: Progress bar (X/10 completed)
- [ ] 🔴 Mobile: Step detail expansion with action area
- [ ] 🟡 Mobile: "My Championships" with Active/Past tabs and progress bars
---
## Project 6: File Upload System
> Receipt photos, insurance documents, judge photos, org logos.
- [ ] 🔴 File upload API endpoint — POST /api/v1/uploads
- [ ] 🔴 Local file storage (dev) with configurable S3/Supabase Storage (prod)
- [ ] 🔴 Receipt photo upload — camera/gallery picker → upload → pending confirmation
- [ ] 🔴 Insurance document upload — same flow
- [ ] 🔴 Serve uploaded files — GET /api/v1/uploads/{filename}
- [ ] 🟡 Judge profile photo upload
- [ ] 🟡 Organization logo upload
- [ ] 🟡 Image compression before upload (mobile side)
- [ ] 🟡 File type validation (images only for receipts, PDF/images for insurance)
---
## Project 7: Organizer — Member Management
> Organizers review members, pass/fail videos, confirm payments, publish results.
- [ ] 🔴 Members list per championship — GET /api/v1/championships/{id}/members
- [ ] 🔴 Member detail — registration info, video link, receipt, progress
- [ ] 🔴 Edit member's level/style (with notification trigger)
- [ ] 🔴 Video review — Pass/Fail buttons per member
- [ ] 🔴 Payment confirmation — Confirm receipt button
- [ ] 🔴 Results tab — pending/passed/failed counts, "Publish Results" button
- [ ] 🔴 Mobile: Member list with search + filter chips (All, Receipts, Videos, Passed)
- [ ] 🔴 Mobile: Member detail screen with action buttons
- [ ] 🔴 Mobile: Results screen with Pass/Fail workflow
- [ ] 🟡 Filter members by status (pending receipt, pending video, passed, failed)
- [ ] 🟡 Bulk actions — pass/fail multiple members at once
---
## Project 8: Notification System
> In-app notifications + push notifications via Expo Push Service.
- [ ] 🔴 Notification model + CRUD API
- [ ] 🔴 In-app notification feed — GET /api/v1/notifications
- [ ] 🔴 Mark notification as read — PATCH /api/v1/notifications/{id}/read
- [ ] 🔴 Mark all as read — POST /api/v1/notifications/read-all
- [ ] 🔴 Notification triggers:
- Video passed/failed → member notification
- Payment confirmed → member notification
- Level/style changed → member notification
- Results published → all registrants notified
- User approved → welcome notification
- Championship goes live → all platform notification
- [ ] 🔴 Expo Push Token registration — PATCH /api/v1/users/me/push-token
- [ ] 🔴 Expo Push Service integration — send push on notification create
- [ ] 🔴 Mobile: Bell icon with unread count badge
- [ ] 🔴 Mobile: Notification feed screen (cards with icon, type, message, timestamp)
- [ ] 🟡 Push notification preferences (toggles for each type)
- [ ] 🟡 Deadline reminders — auto-send 7d, 3d, 1d before registration closes
- [ ] 🟢 Email notifications (secondary channel)
---
## Project 9: Instagram Integration
> Automatically sync championship announcements from organizer's Instagram Business account.
- [ ] 🔴 Instagram Graph API client — fetch recent posts from Business/Creator account
- [ ] 🔴 Caption parser — extract title (first line), location, date from caption text
- [ ] 🔴 Date parser — support Russian ("15 марта 2025") and English ("March 15 2025") + numeric (15.03.2025)
- [ ] 🔴 Location parser — detect "Место:", "Адрес:", "Location:", "Venue:", "Зал:"
- [ ] 🔴 Championship upsert — use instagram_media_id as dedup key, create as draft
- [ ] 🔴 APScheduler polling — run every 30 min via FastAPI lifespan event
- [ ] 🔴 Sync state tracking — store last sync timestamp, only process new posts
- [ ] 🔴 Long-lived token management — store in .env, auto-refresh before 60-day expiry
- [ ] 🔴 Token refresh scheduler — weekly job to call Graph API token exchange
- [ ] 🟡 Image sync — save media_url as championship image
- [ ] 🟡 Manual import button — "Import from Instagram" in org dashboard
- [ ] 🟡 Error handling — failed parsing saves raw caption for manual review
- [ ] 🟢 Multiple Instagram accounts support (one per org)
---
## Project 10: Enhanced Admin Panel
> Full admin capabilities: org management, championship moderation, user management.
- ✅ Basic admin panel (approve/reject pending users)
- [ ] 🔴 Admin dashboard — stats (active orgs, live champs, total users)
- [ ] 🔴 "Needs Attention" section — pending orgs + pending championships
- [ ] 🔴 Organization management — list, detail, approve/reject/block/unblock/verify
- [ ] 🔴 Championship moderation — list, detail, approve/reject (from unverified orgs), suspend/reinstate
- [ ] 🔴 Enhanced user management — warn, block/unblock, view activity
- [ ] 🔴 Activity log — recent actions across the platform
- [ ] 🟡 Mobile: Admin dashboard with stat cards
- [ ] 🟡 Mobile: Organization list + detail screens
- [ ] 🟡 Mobile: Championship moderation screens
- [ ] 🟢 Web admin panel (React + Vite, same API)
---
## Project 11: Auth & Security Hardening
> Production-ready auth, rate limiting, CORS, input validation.
- ✅ JWT access + refresh token rotation
- ✅ bcrypt password hashing
- ✅ Role-based route protection (dependency chain)
- [ ] 🔴 Rate limiting on auth endpoints (slowapi)
- [ ] 🔴 CORS configuration (whitelist mobile app + admin panel origins)
- [ ] 🔴 Input validation — Pydantic strict mode, length limits, email format
- [ ] 🔴 Password strength requirements (min 8 chars, mixed case, number)
- [ ] 🟡 Account lockout after N failed login attempts
- [ ] 🟡 HTTPS enforcement in production
- [ ] 🟡 Audit logging — log all admin actions
- [ ] 🟢 Google OAuth login (alternative to email/password)
- [ ] 🟢 Two-factor authentication
---
## Project 12: PostgreSQL & Production Infrastructure
> Migrate from SQLite to PostgreSQL, containerize with Docker.
- [ ] 🔴 PostgreSQL support in database.py (async via asyncpg)
- [ ] 🔴 docker-compose.yml — PostgreSQL 16 + FastAPI containers
- [ ] 🔴 Dockerfile for backend
- [ ] 🔴 Environment config — .env.example with all required variables
- [ ] 🔴 Alembic migration compatibility — ensure all migrations work on PostgreSQL
- [ ] 🔴 Production ASGI server — gunicorn + uvicorn workers
- [ ] 🟡 Health check endpoint improvements
- [ ] 🟡 Structured logging (JSON format for production)
- [ ] 🟡 Database connection pooling
- [ ] 🟢 CI/CD pipeline (GitHub Actions — lint, test, build)
- [ ] 🟢 Deployment to cloud (VPS / Railway / Fly.io)
---
## Project 13: Testing
> Comprehensive test coverage for backend and mobile.
- [ ] 🔴 pytest setup with async fixtures (httpx + AsyncClient)
- [ ] 🔴 Auth tests — register, login, refresh, logout, me, role checks
- [ ] 🔴 Championship tests — CRUD, status transitions, permission checks
- [ ] 🔴 Registration tests — submit, duplicate guard, closed registration guard
- [ ] 🔴 Admin tests — approve/reject users, org management
- [ ] 🔴 Instagram parser tests — various caption formats, edge cases
- [ ] 🔴 Notification tests — trigger conditions, push delivery
- [ ] 🟡 Integration tests — full flows (register → apply → review → publish)
- [ ] 🟡 Mobile: TypeScript strict mode + path aliases (@api/*, @store/*, @screens/*)
- [ ] 🟢 E2E tests (Detox or Maestro for mobile)
- [ ] 🟢 Load testing (locust or k6)
---
## Project 14: Design System & UI Polish
> Consistent dark luxury theme across all screens, smooth animations.
- [ ] 🟡 Design tokens — colors, typography, spacing constants file
- [ ] 🟡 Typography — Playfair Display (headings) + DM Sans (body) + JetBrains Mono (badges)
- [ ] 🟡 Component library — Button, Card, Badge, TabBar, TagEditor, LoadingOverlay
- [ ] 🟡 Dark luxury theme — dark backgrounds (#08070D), pink/purple accents
- [ ] 🟡 Loading skeletons on all list screens
- [ ] 🟡 Empty states — custom illustrations per screen
- [ ] 🟡 Error states — friendly messages + retry buttons
- [ ] 🟡 Animations — tab transitions, card press, progress bar fill
- [ ] 🟢 Haptic feedback on key actions
- [ ] 🟢 Swipe gestures on member cards (pass/fail)
- [ ] 🟢 Onboarding walkthrough for first-time users
---
## Project 15: Internationalization (Post-MVP)
> Russian + English language support.
- [ ] 🟢 i18n setup (react-i18next or similar)
- [ ] 🟢 Extract all strings to translation files
- [ ] 🟢 Russian translations
- [ ] 🟢 Language switcher in profile settings
- [ ] 🟢 Date/time formatting per locale
---
## Recommended Priority Order
If you need to ship fast, work in this order:
| Priority | Project | Why |
|:---:|---|---|
| 1 | Project 1: Database Expansion | Foundation for everything else |
| 2 | Project 2: Organization System | Core business entity |
| 3 | Project 3: Championship Config | Orgs need to create proper championships |
| 4 | Project 4: Enhanced Member UX | Members need to see championship details |
| 5 | Project 5: 10-Step Progress | Core differentiator — step-by-step registration |
| 6 | Project 6: File Uploads | Required for receipts + insurance steps |
| 7 | Project 7: Member Management | Orgs need to review and manage members |
| 8 | Project 8: Notifications | Users need to know what's happening |
| 9 | Project 9: Instagram Sync | Automate championship creation |
| 10 | Project 10: Enhanced Admin | Full platform control |
| 11 | Project 11: Security | Production hardening |
| 12 | Project 12: PostgreSQL + Docker | Production deployment |
| 13 | Project 13: Testing | Quality assurance |
| 14 | Project 14: Design System | Visual polish |
| 15 | Project 15: i18n | Post-launch |
---
## Current State Summary
### What's Built (MVP)
- FastAPI backend with SQLite (async)
- JWT auth with refresh token rotation
- 4 tables: users, refresh_tokens, championships, registrations
- Member auto-approve, organizer requires admin approval
- Championship list + basic detail
- Registration form + "My Registrations" with championship info
- Admin panel (approve/reject pending users)
- Profile screen with role badges
- Tab navigation with Ionicons
### What's Next (Sprint 1 recommended)
1. Database expansion (Project 1)
2. Organization system (Project 2)
3. Championship configuration tabs (Project 3)

View File

@@ -0,0 +1,86 @@
"""add organizations table and championship ownership
Revision ID: a1b2c3d4e5f6
Revises: 43d947192af5
Create Date: 2026-03-01 18:00:00.000000
"""
import uuid
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'a1b2c3d4e5f6'
down_revision: Union[str, None] = '43d947192af5'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Phase 1: Create organizations table
op.create_table('organizations',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('user_id', sa.Uuid(), nullable=False),
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('instagram', sa.String(length=100), nullable=True),
sa.Column('email', sa.String(length=255), nullable=True),
sa.Column('city', sa.String(length=100), nullable=True),
sa.Column('logo_url', sa.String(length=500), nullable=True),
sa.Column('verified', sa.Boolean(), nullable=False),
sa.Column('status', sa.String(length=20), nullable=False),
sa.Column('block_reason', sa.String(length=500), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('user_id'),
)
# Phase 1b: Add org_id to championships
with op.batch_alter_table('championships') as batch_op:
batch_op.add_column(sa.Column('org_id', sa.Uuid(), nullable=True))
batch_op.create_foreign_key('fk_championships_org_id', 'organizations', ['org_id'], ['id'], ondelete='SET NULL')
# Phase 2: Migrate existing organizer data to organizations table
conn = op.get_bind()
rows = conn.execute(
sa.text("SELECT id, organization_name FROM users WHERE role = 'organizer' AND organization_name IS NOT NULL")
).fetchall()
for row in rows:
org_id = str(uuid.uuid4())
conn.execute(
sa.text(
"INSERT INTO organizations (id, user_id, name, verified, status, created_at, updated_at) "
"VALUES (:id, :user_id, :name, 0, 'active', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)"
),
{"id": org_id, "user_id": str(row[0]), "name": row[1]}
)
# Phase 3: Remove organization_name from users (keep instagram_handle)
with op.batch_alter_table('users') as batch_op:
batch_op.drop_column('organization_name')
def downgrade() -> None:
# Re-add organization_name to users
with op.batch_alter_table('users') as batch_op:
batch_op.add_column(sa.Column('organization_name', sa.String(length=255), nullable=True))
# Migrate data back from organizations to users
conn = op.get_bind()
orgs = conn.execute(sa.text("SELECT user_id, name FROM organizations")).fetchall()
for org in orgs:
conn.execute(
sa.text("UPDATE users SET organization_name = :name WHERE id = :user_id"),
{"name": org[1], "user_id": str(org[0])}
)
# Remove org_id from championships
with op.batch_alter_table('championships') as batch_op:
batch_op.drop_constraint('fk_championships_org_id', type_='foreignkey')
batch_op.drop_column('org_id')
op.drop_table('organizations')

View File

@@ -18,7 +18,7 @@ class Settings(BaseSettings):
EXPO_ACCESS_TOKEN: str = ""
CORS_ORIGINS: str = "http://localhost:8081,exp://"
CORS_ORIGINS: str = "http://localhost:3000,http://localhost:8081,exp://"
@property
def cors_origins_list(self) -> list[str]:

View File

@@ -1,3 +1,3 @@
from app.crud import crud_user, crud_championship, crud_registration, crud_participant
from app.crud import crud_user, crud_organization, crud_championship, crud_registration, crud_participant
__all__ = ["crud_user", "crud_championship", "crud_registration", "crud_participant"]
__all__ = ["crud_user", "crud_organization", "crud_championship", "crud_registration", "crud_participant"]

View File

@@ -3,6 +3,7 @@ import uuid
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models.championship import Championship
from app.schemas.championship import ChampionshipCreate, ChampionshipUpdate
@@ -16,7 +17,9 @@ def _serialize(value) -> str | None:
async def get(db: AsyncSession, champ_id: str | uuid.UUID) -> Championship | None:
cid = champ_id if isinstance(champ_id, uuid.UUID) else uuid.UUID(str(champ_id))
result = await db.execute(select(Championship).where(Championship.id == cid))
result = await db.execute(
select(Championship).where(Championship.id == cid).options(selectinload(Championship.organization))
)
return result.scalar_one_or_none()
@@ -26,7 +29,7 @@ async def list_all(
skip: int = 0,
limit: int = 50,
) -> list[Championship]:
q = select(Championship).order_by(Championship.event_date.asc())
q = select(Championship).order_by(Championship.event_date.asc()).options(selectinload(Championship.organization))
if status:
q = q.where(Championship.status == status)
q = q.offset(skip).limit(limit)
@@ -34,14 +37,16 @@ async def list_all(
return list(result.scalars().all())
async def create(db: AsyncSession, data: ChampionshipCreate) -> Championship:
async def create(db: AsyncSession, data: ChampionshipCreate, org_id: uuid.UUID | None = None) -> Championship:
payload = data.model_dump(exclude={"judges", "categories"})
payload["judges"] = _serialize(data.judges)
payload["categories"] = _serialize(data.categories)
if org_id:
payload["org_id"] = org_id
champ = Championship(**payload)
db.add(champ)
await db.commit()
await db.refresh(champ)
await db.refresh(champ, attribute_names=["organization"])
return champ

View File

@@ -0,0 +1,11 @@
import uuid
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.organization import Organization
async def get_by_user_id(db: AsyncSession, user_id: uuid.UUID) -> Organization | None:
result = await db.execute(select(Organization).where(Organization.user_id == user_id))
return result.scalar_one_or_none()

View File

@@ -2,7 +2,9 @@ import uuid
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models.organization import Organization
from app.models.user import User
from app.schemas.user import UserRegister, UserUpdate
from app.services.auth_service import hash_password
@@ -10,12 +12,16 @@ 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))
result = await db.execute(
select(User).where(User.id == uid).options(selectinload(User.organization))
)
return result.scalar_one_or_none()
async def get_by_email(db: AsyncSession, email: str) -> User | None:
result = await db.execute(select(User).where(User.email == email.lower()))
result = await db.execute(
select(User).where(User.email == email.lower()).options(selectinload(User.organization))
)
return result.scalar_one_or_none()
@@ -25,23 +31,40 @@ async def create(db: AsyncSession, data: UserRegister) -> User:
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,
role=data.requested_role,
# Members are auto-approved; organizers require admin review
status="approved" if data.requested_role == "member" else "pending",
)
db.add(user)
await db.flush() # get user.id for the FK
# Create Organization row for organizer registrations
if data.requested_role == "organizer" and data.organization_name:
org = Organization(
user_id=user.id,
name=data.organization_name,
status="pending",
verified=False,
)
db.add(org)
await db.commit()
await db.refresh(user)
await db.refresh(user, attribute_names=["organization"])
return user
async def update(db: AsyncSession, user: User, data: UserUpdate) -> User:
for field, value in data.model_dump(exclude_none=True).items():
user_fields = data.model_dump(exclude_none=True, exclude={"organization_name"})
for field, value in user_fields.items():
setattr(user, field, value)
# Route org field updates to Organization table
if data.organization_name is not None and user.organization:
user.organization.name = data.organization_name
await db.commit()
await db.refresh(user)
await db.refresh(user, attribute_names=["organization"])
return user
@@ -53,5 +76,7 @@ async def set_status(db: AsyncSession, user: User, status: str) -> 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))
result = await db.execute(
select(User).options(selectinload(User.organization)).offset(skip).limit(limit)
)
return list(result.scalars().all())

View File

@@ -1,5 +1,3 @@
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
@@ -7,19 +5,7 @@ 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 = FastAPI(title="Pole Dance Championships API", version="1.0.0")
app.add_middleware(
CORSMiddleware,

View File

@@ -1,4 +1,5 @@
from app.models.user import User, RefreshToken
from app.models.organization import Organization
from app.models.championship import Championship
from app.models.registration import Registration
from app.models.participant import ParticipantList
@@ -7,6 +8,7 @@ from app.models.notification import NotificationLog
__all__ = [
"User",
"RefreshToken",
"Organization",
"Championship",
"Registration",
"ParticipantList",

View File

@@ -1,7 +1,7 @@
import uuid
from datetime import datetime
from sqlalchemy import DateTime, Float, Integer, String, Text, Uuid, func
from sqlalchemy import DateTime, Float, ForeignKey, Integer, String, Text, Uuid, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
@@ -11,6 +11,7 @@ class Championship(Base):
__tablename__ = "championships"
id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4)
org_id: Mapped[uuid.UUID | None] = mapped_column(Uuid(as_uuid=True), ForeignKey("organizations.id", ondelete="SET NULL"))
title: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text)
location: Mapped[str | None] = mapped_column(String(500))
@@ -18,12 +19,11 @@ class Championship(Base):
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]
categories: Mapped[str | None] = mapped_column(Text) # JSON string: ["cat1", "cat2"]
# Status: 'draft' | 'open' | 'closed' | 'completed'
status: Mapped[str] = mapped_column(String(20), nullable=False, default="draft")
@@ -38,5 +38,6 @@ class Championship(Base):
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
organization: Mapped["Organization | None"] = relationship(back_populates="championships") # type: ignore[name-defined]
registrations: Mapped[list["Registration"]] = relationship(back_populates="championship", cascade="all, delete-orphan") # type: ignore[name-defined]
participant_list: Mapped["ParticipantList | None"] = relationship(back_populates="championship", uselist=False, cascade="all, delete-orphan") # type: ignore[name-defined]

View File

@@ -0,0 +1,20 @@
import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, JSON, String, Uuid, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class Discipline(Base):
__tablename__ = "disciplines"
id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4)
championship_id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), ForeignKey("championships.id", ondelete="CASCADE"), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
levels: Mapped[list | None] = mapped_column(JSON) # e.g. ["Amateur", "Pro", "Open"]
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
championship: Mapped["Championship"] = relationship(back_populates="disciplines") # type: ignore[name-defined]

25
backend/app/models/fee.py Normal file
View File

@@ -0,0 +1,25 @@
import uuid
from datetime import datetime
from decimal import Decimal
from sqlalchemy import DateTime, ForeignKey, Numeric, Text, Uuid, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class Fee(Base):
__tablename__ = "fees"
id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4)
championship_id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), ForeignKey("championships.id", ondelete="CASCADE"), nullable=False, unique=True)
video_selection: Mapped[Decimal | None] = mapped_column(Numeric(10, 2)) # video review/selection fee
solo: Mapped[Decimal | None] = mapped_column(Numeric(10, 2))
duet: Mapped[Decimal | None] = mapped_column(Numeric(10, 2))
group: Mapped[Decimal | None] = mapped_column(Numeric(10, 2))
refund_note: Mapped[str | None] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
championship: Mapped["Championship"] = relationship(back_populates="fees") # type: ignore[name-defined]

View File

@@ -0,0 +1,22 @@
import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, Uuid, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class Judge(Base):
__tablename__ = "judges"
id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4)
championship_id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), ForeignKey("championships.id", ondelete="CASCADE"), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
instagram: Mapped[str | None] = mapped_column(String(255))
bio: Mapped[str | None] = mapped_column(Text)
photo_url: Mapped[str | None] = mapped_column(String(2048))
sort_order: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
championship: Mapped["Championship"] = relationship(back_populates="judges_list") # type: ignore[name-defined]

View File

@@ -0,0 +1,30 @@
import uuid
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, String, Uuid, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class Organization(Base):
__tablename__ = "organizations"
id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), unique=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
instagram: Mapped[str | None] = mapped_column(String(100))
email: Mapped[str | None] = mapped_column(String(255))
city: Mapped[str | None] = mapped_column(String(100))
logo_url: Mapped[str | None] = mapped_column(String(500))
verified: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
# 'pending' | 'active' | 'rejected' | 'blocked'
status: Mapped[str] = mapped_column(String(20), nullable=False, default="pending")
block_reason: Mapped[str | None] = mapped_column(String(500))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
user: Mapped["User"] = relationship(back_populates="organization") # type: ignore[name-defined]
championships: Mapped[list["Championship"]] = relationship(back_populates="organization") # type: ignore[name-defined]

View File

@@ -0,0 +1,22 @@
import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, String, Text, Uuid, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class Rule(Base):
__tablename__ = "rules"
id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4)
championship_id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), ForeignKey("championships.id", ondelete="CASCADE"), nullable=False)
# section: 'general' | 'costume' | 'scoring' | 'penalty'
section: Mapped[str] = mapped_column(String(20), nullable=False, default="general")
name: Mapped[str] = mapped_column(String(255), nullable=False)
value: Mapped[str | None] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
championship: Mapped["Championship"] = relationship(back_populates="rules") # type: ignore[name-defined]

View File

@@ -0,0 +1,19 @@
import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, String, Uuid, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class Style(Base):
__tablename__ = "styles"
id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4)
championship_id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), ForeignKey("championships.id", ondelete="CASCADE"), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False) # e.g. "Exotic", "Sport", "Art"
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
championship: Mapped["Championship"] = relationship(back_populates="styles") # type: ignore[name-defined]

View File

@@ -15,8 +15,6 @@ class User(Base):
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'
@@ -30,6 +28,7 @@ class User(Base):
refresh_tokens: Mapped[list["RefreshToken"]] = relationship(back_populates="user", cascade="all, delete-orphan")
registrations: Mapped[list["Registration"]] = relationship(back_populates="user") # type: ignore[name-defined]
notification_logs: Mapped[list["NotificationLog"]] = relationship(back_populates="user") # type: ignore[name-defined]
organization: Mapped["Organization | None"] = relationship(back_populates="user", uselist=False) # type: ignore[name-defined]
class RefreshToken(Base):

View File

@@ -38,10 +38,11 @@ async def get_championship(
@router.post("", response_model=ChampionshipOut, status_code=status.HTTP_201_CREATED)
async def create_championship(
data: ChampionshipCreate,
_user: User = Depends(get_organizer),
user: User = Depends(get_organizer),
db: AsyncSession = Depends(get_db),
):
return await crud_championship.create(db, data)
org_id = user.organization.id if user.organization else None
return await crud_championship.create(db, data, org_id=org_id)
@router.patch("/{champ_id}", response_model=ChampionshipOut)

View File

@@ -0,0 +1,7 @@
from app.schemas.auth.token_pair import TokenPair
from app.schemas.auth.refresh_request import RefreshRequest
from app.schemas.auth.token_refreshed import TokenRefreshed
from app.schemas.auth.logout_request import LogoutRequest
from app.schemas.auth.register_response import RegisterResponse
__all__ = ["TokenPair", "RefreshRequest", "TokenRefreshed", "LogoutRequest", "RegisterResponse"]

View File

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

View File

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

View File

@@ -3,27 +3,6 @@ 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.

View File

@@ -0,0 +1,10 @@
from pydantic import BaseModel
from app.schemas.user import UserOut
class TokenPair(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
user: UserOut

View File

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

View File

@@ -0,0 +1,5 @@
from app.schemas.championship.create import ChampionshipCreate
from app.schemas.championship.update import ChampionshipUpdate
from app.schemas.championship.out import ChampionshipOut
__all__ = ["ChampionshipCreate", "ChampionshipUpdate", "ChampionshipOut"]

View File

@@ -0,0 +1,19 @@
from datetime import datetime
from pydantic import BaseModel
class ChampionshipCreate(BaseModel):
title: str
description: str | None = None
location: str | None = None
event_date: datetime | None = None
registration_open_at: datetime | None = None
registration_close_at: datetime | None = None
form_url: str | None = None
entry_fee: float | None = None
video_max_duration: int | None = None
judges: list[dict] | None = None # [{name, bio, instagram}]
categories: list[str] | None = None
status: str = "draft"
image_url: str | None = None

View File

@@ -4,43 +4,14 @@ 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
from app.schemas.organization import OrganizationBrief
class ChampionshipOut(BaseModel):
model_config = {"from_attributes": True}
id: uuid.UUID
org_id: uuid.UUID | None = None
title: str
description: str | None
location: str | None
@@ -56,6 +27,7 @@ class ChampionshipOut(BaseModel):
source: str
instagram_media_id: str | None
image_url: str | None
organization: OrganizationBrief | None = None
created_at: datetime
updated_at: datetime

View File

@@ -0,0 +1,19 @@
from datetime import datetime
from pydantic import BaseModel
class ChampionshipUpdate(BaseModel):
title: str | None = None
description: str | None = None
location: str | None = None
event_date: datetime | None = None
registration_open_at: datetime | None = None
registration_close_at: datetime | None = None
form_url: str | None = None
entry_fee: float | None = None
video_max_duration: int | None = None
judges: list[dict] | None = None
categories: list[str] | None = None
status: str | None = None
image_url: str | None = None

View File

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

View File

@@ -0,0 +1,13 @@
import uuid
from pydantic import BaseModel
class OrganizationBrief(BaseModel):
"""Minimal org info for embedding in ChampionshipOut."""
model_config = {"from_attributes": True}
id: uuid.UUID
name: str
instagram: str | None
logo_url: str | None

View File

@@ -0,0 +1,16 @@
import uuid
from pydantic import BaseModel
class OrganizationOut(BaseModel):
model_config = {"from_attributes": True}
id: uuid.UUID
name: str
instagram: str | None
email: str | None
city: str | None
logo_url: str | None
verified: bool
status: str

View File

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

View File

@@ -13,7 +13,3 @@ class ParticipantListOut(BaseModel):
published_at: datetime | None
notes: str | None
created_at: datetime
class ParticipantListPublish(BaseModel):
notes: str | None = None

View File

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

View File

@@ -1,57 +0,0 @@
import uuid
from datetime import datetime
from typing import Any
from pydantic import BaseModel, model_validator
from app.schemas.user import UserOut
class RegistrationCreate(BaseModel):
championship_id: uuid.UUID
category: str | None = None
level: str | None = None
notes: str | None = None
class RegistrationUpdate(BaseModel):
status: str | None = None
video_url: str | None = None
category: str | None = None
level: str | None = None
notes: str | None = None
class RegistrationOut(BaseModel):
model_config = {"from_attributes": True}
id: uuid.UUID
championship_id: uuid.UUID
user_id: uuid.UUID
category: str | None
level: str | None
notes: str | None
status: str
video_url: str | None
submitted_at: datetime
decided_at: datetime | None
class RegistrationListItem(RegistrationOut):
championship_title: str | None = None
championship_event_date: datetime | None = None
championship_location: str | None = None
@model_validator(mode="before")
@classmethod
def extract_championship(cls, data: Any) -> Any:
if hasattr(data, "championship") and data.championship:
champ = data.championship
data.__dict__["championship_title"] = champ.title
data.__dict__["championship_event_date"] = champ.event_date
data.__dict__["championship_location"] = champ.location
return data
class RegistrationWithUser(RegistrationOut):
user: UserOut

View File

@@ -0,0 +1,13 @@
from app.schemas.registration.create import RegistrationCreate
from app.schemas.registration.update import RegistrationUpdate
from app.schemas.registration.out import RegistrationOut
from app.schemas.registration.list_item import RegistrationListItem
from app.schemas.registration.with_user import RegistrationWithUser
__all__ = [
"RegistrationCreate",
"RegistrationUpdate",
"RegistrationOut",
"RegistrationListItem",
"RegistrationWithUser",
]

View File

@@ -0,0 +1,10 @@
import uuid
from pydantic import BaseModel
class RegistrationCreate(BaseModel):
championship_id: uuid.UUID
category: str | None = None
level: str | None = None
notes: str | None = None

View File

@@ -0,0 +1,22 @@
from datetime import datetime
from typing import Any
from pydantic import model_validator
from app.schemas.registration.out import RegistrationOut
class RegistrationListItem(RegistrationOut):
championship_title: str | None = None
championship_event_date: datetime | None = None
championship_location: str | None = None
@model_validator(mode="before")
@classmethod
def extract_championship(cls, data: Any) -> Any:
if hasattr(data, "championship") and data.championship:
champ = data.championship
data.__dict__["championship_title"] = champ.title
data.__dict__["championship_event_date"] = champ.event_date
data.__dict__["championship_location"] = champ.location
return data

View File

@@ -0,0 +1,19 @@
import uuid
from datetime import datetime
from pydantic import BaseModel
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

View File

@@ -0,0 +1,9 @@
from pydantic import BaseModel
class RegistrationUpdate(BaseModel):
status: str | None = None
video_url: str | None = None
category: str | None = None
level: str | None = None
notes: str | None = None

View File

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

View File

@@ -0,0 +1,6 @@
from app.schemas.user.register import UserRegister
from app.schemas.user.login import UserLogin
from app.schemas.user.out import UserOut
from app.schemas.user.update import UserUpdate
__all__ = ["UserRegister", "UserLogin", "UserOut", "UserUpdate"]

View File

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

View File

@@ -0,0 +1,21 @@
import uuid
from datetime import datetime
from pydantic import BaseModel
from app.schemas.organization import OrganizationOut
class UserOut(BaseModel):
model_config = {"from_attributes": True}
id: uuid.UUID
email: str
full_name: str
phone: str | None
role: str
status: str
instagram_handle: str | None
organization: OrganizationOut | None = None
expo_push_token: str | None
created_at: datetime

View File

@@ -1,5 +1,3 @@
import uuid
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, EmailStr, field_validator
@@ -22,31 +20,3 @@ class UserRegister(BaseModel):
if info.data.get("requested_role") == "organizer" and not v:
raise ValueError("Organization name is required for organizer registration")
return v
class UserLogin(BaseModel):
email: EmailStr
password: str
class UserOut(BaseModel):
model_config = {"from_attributes": True}
id: uuid.UUID
email: str
full_name: str
phone: str | None
role: str
status: str
organization_name: str | None
instagram_handle: str | None
expo_push_token: str | None
created_at: datetime
class UserUpdate(BaseModel):
full_name: str | None = None
phone: str | None = None
organization_name: str | None = None
instagram_handle: str | None = None
expo_push_token: str | None = None

View File

@@ -0,0 +1,10 @@
from pydantic import BaseModel
class UserUpdate(BaseModel):
full_name: str | None = None
phone: str | None = None
instagram_handle: str | None = None
expo_push_token: str | None = None
# Org fields — routed to Organization table in CRUD
organization_name: str | None = None

View File

@@ -1,4 +1,4 @@
"""Seed script — creates test users and one championship.
"""Seed script — creates test users, organization, and championships.
Run from backend/: .venv/Scripts/python seed.py
"""
import asyncio
@@ -7,6 +7,7 @@ from datetime import UTC, datetime, timedelta
from app.database import AsyncSessionLocal
from app.models.championship import Championship
from app.models.organization import Organization
from app.models.user import User
from app.services.auth_service import hash_password
from sqlalchemy import select
@@ -29,6 +30,7 @@ async def seed():
"password": "Org1234",
"role": "organizer",
"status": "approved",
"instagram_handle": "@ekaterina_pole",
},
{
"email": "member@pole.dev",
@@ -36,6 +38,7 @@ async def seed():
"password": "Member1234",
"role": "member",
"status": "approved",
"instagram_handle": "@anna_petrova",
},
{
"email": "pending@pole.dev",
@@ -57,11 +60,11 @@ async def seed():
full_name=ud["full_name"],
role=ud["role"],
status=ud["status"],
instagram_handle=ud.get("instagram_handle"),
)
db.add(user)
print(f" Created user: {ud['email']}")
else:
# Update role/status if needed
user.role = ud["role"]
user.status = ud["status"]
user.hashed_password = hash_password(ud["password"])
@@ -70,6 +73,27 @@ async def seed():
await db.flush()
# ── Organization ──────────────────────────────────────────────────────
organizer = created_users["organizer@pole.dev"]
result = await db.execute(select(Organization).where(Organization.user_id == organizer.id))
org = result.scalar_one_or_none()
if org is None:
org = Organization(
user_id=organizer.id,
name="Pole Studio Minsk",
instagram="@polestudio_minsk",
email="organizer@pole.dev",
city="Minsk",
verified=True,
status="active",
)
db.add(org)
print(f" Created organization: {org.name}")
else:
print(f" Organization already exists: {org.name}")
await db.flush()
# ── Championships ──────────────────────────────────────────────────────
championships_data = [
{
@@ -115,11 +139,12 @@ async def seed():
)
champ = result.scalar_one_or_none()
if champ is None:
champ = Championship(**cd)
champ = Championship(org_id=org.id, **cd)
db.add(champ)
print(f" Created championship: {cd['title']}")
else:
print(f" Championship already exists: {cd['title']}")
champ.org_id = org.id
print(f" Updated championship: {cd['title']}")
await db.commit()
print("\nSeed complete!")

41
mobile/.gitignore vendored
View File

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

View File

@@ -1,19 +0,0 @@
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 />
</>
);
}

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -1,8 +0,0 @@
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

File diff suppressed because it is too large Load Diff

View File

@@ -1,34 +0,0 @@
{
"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
}

View File

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

View File

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

View File

@@ -1,73 +0,0 @@
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);
}
);

View File

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

View File

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

View File

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

View File

@@ -1,128 +0,0 @@
import { useRef, useState } from 'react';
import {
View,
Text,
TextInput,
TouchableOpacity,
StyleSheet,
KeyboardAvoidingView,
Platform,
Alert,
ActivityIndicator,
} from 'react-native';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import { Ionicons } from '@expo/vector-icons';
import { useAuthStore } from '../../store/auth.store';
import type { AuthStackParams } from '../../navigation';
type Props = NativeStackScreenProps<AuthStackParams, 'Login'>;
export default function LoginScreen({ navigation }: Props) {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const passwordRef = useRef<TextInput>(null);
const { login, isLoading } = useAuthStore();
const handleLogin = async () => {
if (!email.trim() || !password.trim()) {
Alert.alert('Error', 'Please enter email and password');
return;
}
try {
await login(email.trim().toLowerCase(), password);
} catch (err: any) {
const msg = err?.response?.data?.detail ?? 'Login failed. Check your credentials.';
Alert.alert('Login failed', msg);
}
};
return (
<KeyboardAvoidingView style={styles.container} behavior={Platform.OS === 'ios' ? 'padding' : 'height'}>
<View style={styles.inner}>
<Text style={styles.title}>Pole Championships</Text>
<Text style={styles.subtitle}>Sign in to your account</Text>
<TextInput
style={styles.input}
placeholder="Email"
keyboardType="email-address"
autoCapitalize="none"
autoComplete="email"
returnKeyType="next"
onSubmitEditing={() => passwordRef.current?.focus()}
value={email}
onChangeText={setEmail}
/>
<View style={styles.passwordRow}>
<TextInput
ref={passwordRef}
style={styles.passwordInput}
placeholder="Password"
secureTextEntry={!showPassword}
autoComplete="password"
returnKeyType="done"
onSubmitEditing={handleLogin}
value={password}
onChangeText={setPassword}
/>
<TouchableOpacity style={styles.eyeBtn} onPress={() => setShowPassword((v) => !v)}>
<Ionicons name={showPassword ? 'eye-off-outline' : 'eye-outline'} size={20} color="#6b7280" />
</TouchableOpacity>
</View>
<TouchableOpacity style={styles.btn} onPress={handleLogin} disabled={isLoading}>
{isLoading ? <ActivityIndicator color="#fff" /> : <Text style={styles.btnText}>Sign In</Text>}
</TouchableOpacity>
<TouchableOpacity onPress={() => navigation.navigate('Register')}>
<Text style={styles.link}>Don't have an account? Register</Text>
</TouchableOpacity>
</View>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#fff' },
inner: { flex: 1, justifyContent: 'center', padding: 24 },
title: { fontSize: 28, fontWeight: '700', textAlign: 'center', marginBottom: 8, color: '#1a1a2e' },
subtitle: { fontSize: 15, textAlign: 'center', color: '#666', marginBottom: 32 },
input: {
borderWidth: 1,
borderColor: '#ddd',
borderRadius: 10,
padding: 14,
marginBottom: 14,
fontSize: 16,
backgroundColor: '#fafafa',
},
passwordRow: {
flexDirection: 'row',
alignItems: 'center',
borderWidth: 1,
borderColor: '#ddd',
borderRadius: 10,
backgroundColor: '#fafafa',
marginBottom: 14,
},
passwordInput: {
flex: 1,
padding: 14,
fontSize: 16,
},
eyeBtn: {
paddingHorizontal: 14,
paddingVertical: 14,
},
btn: {
backgroundColor: '#7c3aed',
padding: 16,
borderRadius: 10,
alignItems: 'center',
marginTop: 8,
marginBottom: 20,
},
btnText: { color: '#fff', fontSize: 16, fontWeight: '600' },
link: { textAlign: 'center', color: '#7c3aed', fontSize: 14 },
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,149 +0,0 @@
import { View, Text, StyleSheet, TouchableOpacity, Alert, ScrollView } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useAuthStore } from '../../store/auth.store';
const ROLE_CONFIG: Record<string, { color: string; bg: string; label: string }> = {
member: { color: '#16a34a', bg: '#f0fdf4', label: 'Member' },
organizer: { color: '#7c3aed', bg: '#f3f0ff', label: 'Organizer' },
admin: { color: '#dc2626', bg: '#fef2f2', label: 'Admin' },
};
export default function ProfileScreen() {
const { user, logout } = useAuthStore();
const handleLogout = () => {
Alert.alert('Sign Out', 'Are you sure you want to sign out?', [
{ text: 'Cancel', style: 'cancel' },
{ text: 'Sign Out', style: 'destructive', onPress: logout },
]);
};
if (!user) return null;
const roleConfig = ROLE_CONFIG[user.role] ?? { color: '#6b7280', bg: '#f3f4f6', label: user.role };
return (
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
{/* Avatar + Name */}
<View style={styles.header}>
<View style={styles.avatar}>
<Text style={styles.avatarText}>{user.full_name.charAt(0).toUpperCase()}</Text>
</View>
<Text style={styles.name}>{user.full_name}</Text>
<Text style={styles.email}>{user.email}</Text>
<View style={[styles.roleBadge, { backgroundColor: roleConfig.bg }]}>
<Text style={[styles.roleText, { color: roleConfig.color }]}>{roleConfig.label}</Text>
</View>
</View>
{/* Info Card */}
<View style={styles.card}>
{user.phone && (
<Row icon="call-outline" label="Phone" value={user.phone} />
)}
{user.organization_name && (
<Row icon="business-outline" label="Organization" value={user.organization_name} />
)}
{user.instagram_handle && (
<Row icon="logo-instagram" label="Instagram" value={user.instagram_handle} />
)}
<Row
icon="calendar-outline"
label="Member since"
value={new Date(user.created_at).toLocaleDateString('en-GB', {
day: 'numeric',
month: 'long',
year: 'numeric',
})}
isLast
/>
</View>
{/* Sign Out */}
<TouchableOpacity style={styles.logoutBtn} onPress={handleLogout}>
<Ionicons name="log-out-outline" size={18} color="#ef4444" />
<Text style={styles.logoutText}>Sign Out</Text>
</TouchableOpacity>
</ScrollView>
);
}
function Row({
icon,
label,
value,
isLast,
}: {
icon: string;
label: string;
value: string;
isLast?: boolean;
}) {
return (
<View style={[styles.row, isLast && styles.rowLast]}>
<View style={styles.rowLeft}>
<Ionicons name={icon as any} size={16} color="#7c3aed" />
<Text style={styles.rowLabel}>{label}</Text>
</View>
<Text style={styles.rowValue}>{value}</Text>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#fff' },
content: { padding: 24, paddingBottom: 40 },
header: { alignItems: 'center', marginBottom: 28 },
avatar: {
width: 80,
height: 80,
borderRadius: 40,
backgroundColor: '#7c3aed',
justifyContent: 'center',
alignItems: 'center',
marginBottom: 14,
},
avatarText: { color: '#fff', fontSize: 32, fontWeight: '700' },
name: { fontSize: 22, fontWeight: '700', color: '#1a1a2e', marginBottom: 4 },
email: { fontSize: 14, color: '#6b7280', marginBottom: 10 },
roleBadge: {
paddingHorizontal: 14,
paddingVertical: 5,
borderRadius: 20,
},
roleText: { fontSize: 13, fontWeight: '700' },
card: {
backgroundColor: '#f9fafb',
borderRadius: 14,
marginBottom: 28,
overflow: 'hidden',
},
row: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingVertical: 14,
paddingHorizontal: 16,
borderBottomWidth: 1,
borderBottomColor: '#f3f4f6',
},
rowLast: { borderBottomWidth: 0 },
rowLeft: { flexDirection: 'row', alignItems: 'center', gap: 8 },
rowLabel: { fontSize: 14, color: '#6b7280' },
rowValue: { fontSize: 14, color: '#1a1a2e', fontWeight: '500' },
logoutBtn: {
flexDirection: 'row',
gap: 8,
justifyContent: 'center',
alignItems: 'center',
borderWidth: 1.5,
borderColor: '#fecaca',
backgroundColor: '#fef2f2',
borderRadius: 12,
padding: 14,
},
logoutText: { color: '#ef4444', fontSize: 15, fontWeight: '600' },
});

View File

@@ -1,94 +0,0 @@
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 });
},
}));

View File

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

View File

@@ -1,49 +0,0 @@
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);
},
};

View File

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

41
web/.gitignore vendored Normal file
View File

@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

36
web/README.md Normal file
View File

@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

23
web/components.json Normal file
View File

@@ -0,0 +1,23 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}

18
web/eslint.config.mjs Normal file
View File

@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

7
web/next.config.ts Normal file
View File

@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;

11264
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

36
web/package.json Normal file
View File

@@ -0,0 +1,36 @@
{
"name": "web",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"@tanstack/react-query": "^5.90.21",
"axios": "^1.13.5",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.575.0",
"next": "16.1.6",
"radix-ui": "^1.4.3",
"react": "19.2.3",
"react-dom": "19.2.3",
"tailwind-merge": "^3.5.0",
"zustand": "^5.0.11"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.1.6",
"shadcn": "^3.8.5",
"tailwindcss": "^4",
"tw-animate-css": "^1.4.0",
"typescript": "^5"
}
}

7
web/postcss.config.mjs Normal file
View File

@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

1
web/public/file.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

1
web/public/globe.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

1
web/public/next.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

1
web/public/vercel.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

1
web/public/window.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

View File

@@ -0,0 +1,85 @@
"use client";
import { useState, useEffect } from "react";
import { useUsers, useUserActions } from "@/hooks/useUsers";
import { useAuth } from "@/hooks/useAuth";
import { UserCard } from "@/components/admin/UserCard";
import { useRouter } from "next/navigation";
import { Shield } from "lucide-react";
type Filter = "pending" | "all";
export default function AdminPage() {
const router = useRouter();
const user = useAuth((s) => s.user);
const { data, isLoading, error } = useUsers();
const { approve, reject } = useUserActions();
const [filter, setFilter] = useState<Filter>("pending");
useEffect(() => {
if (user && user.role !== "admin") router.replace("/championships");
}, [user, router]);
if (isLoading) {
return (
<div className="flex justify-center py-20">
<div className="relative h-8 w-8">
<div className="absolute inset-0 rounded-full border-2 border-rose-accent/20" />
<div className="absolute inset-0 rounded-full border-2 border-transparent border-t-rose-accent animate-spin" />
</div>
</div>
);
}
if (error) return <p className="text-center text-destructive py-20">Failed to load users.</p>;
const pending = data?.filter((u) => u.status === "pending") ?? [];
const shown = filter === "pending" ? pending : (data ?? []);
return (
<div className="animate-fade-in">
<div className="mb-8">
<h1 className="font-display text-4xl font-bold tracking-wide">User Management</h1>
<p className="mt-1 text-muted-foreground">Review and manage user accounts</p>
</div>
<div className="mb-5 flex gap-2">
{(["pending", "all"] as const).map((f) => (
<button
key={f}
onClick={() => setFilter(f)}
className={`rounded-full px-4 py-1.5 text-sm font-medium transition-all duration-200 ${
filter === f
? "bg-rose-accent text-white glow-rose"
: "bg-surface-elevated border border-border/40 text-muted-foreground hover:text-foreground hover:border-border"
}`}
>
{f === "pending" ? `Pending (${pending.length})` : `All users (${data?.length ?? 0})`}
</button>
))}
</div>
{shown.length === 0 ? (
<div className="text-center py-12">
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-surface-elevated border border-border/40">
<Shield className="h-6 w-6 text-dim" />
</div>
<p className="text-muted-foreground">No users in this category.</p>
</div>
) : (
<div className="space-y-3 max-w-2xl">
{shown.map((u, i) => (
<div key={u.id} className={`animate-fade-in-up stagger-${Math.min(i + 1, 9)}`}>
<UserCard
user={u}
onApprove={(id) => approve.mutate(id)}
onReject={(id) => reject.mutate(id)}
isActing={approve.isPending || reject.isPending}
/>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,128 @@
"use client";
import { use } from "react";
import { useChampionship } from "@/hooks/useChampionships";
import { useMyRegistrations } from "@/hooks/useRegistrations";
import { useRegisterForChampionship } from "@/hooks/useRegistrations";
import { RegistrationTimeline } from "@/components/registrations/RegistrationTimeline";
import { StatusBadge } from "@/components/shared/StatusBadge";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { MapPin, Calendar, CreditCard, Film, ExternalLink } from "lucide-react";
export default function ChampionshipDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params);
const { data: championship, isLoading, error } = useChampionship(id);
const { data: myRegs } = useMyRegistrations();
const registerMutation = useRegisterForChampionship(id);
if (isLoading) {
return (
<div className="flex justify-center py-20">
<div className="relative h-8 w-8">
<div className="absolute inset-0 rounded-full border-2 border-rose-accent/20" />
<div className="absolute inset-0 rounded-full border-2 border-transparent border-t-rose-accent animate-spin" />
</div>
</div>
);
}
if (error || !championship) return <p className="text-center text-destructive py-20">Championship not found.</p>;
const myReg = myRegs?.find((r) => r.championship_id === id);
const canRegister = championship.status === "open" && !myReg;
const eventDate = championship.event_date
? new Date(championship.event_date).toLocaleDateString("en-GB", { day: "numeric", month: "long", year: "numeric" })
: null;
return (
<div className="max-w-2xl mx-auto space-y-6 animate-fade-in">
{/* Header image */}
<div className="relative overflow-hidden rounded-2xl">
{championship.image_url ? (
<img src={championship.image_url} alt={championship.title} className="w-full h-56 object-cover" />
) : (
<div className="w-full h-56 bg-gradient-to-br from-rose-accent/20 via-purple-accent/15 to-gold-accent/10 flex items-center justify-center">
<span className="text-6xl opacity-30">💃</span>
</div>
)}
<div className="absolute inset-0 bg-gradient-to-t from-background via-background/20 to-transparent" />
</div>
{/* Title + status */}
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="font-display text-3xl font-bold tracking-wide">{championship.title}</h1>
</div>
<StatusBadge status={championship.status} />
</div>
{/* Details grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{championship.location && (
<div className="flex items-center gap-2.5 rounded-xl bg-surface-elevated border border-border/30 px-4 py-3 text-sm">
<MapPin size={16} className="text-rose-accent shrink-0" />
<span className="text-muted-foreground">{championship.location}</span>
</div>
)}
{eventDate && (
<div className="flex items-center gap-2.5 rounded-xl bg-surface-elevated border border-border/30 px-4 py-3 text-sm">
<Calendar size={16} className="text-gold-accent shrink-0" />
<span className="text-muted-foreground">{eventDate}</span>
</div>
)}
{championship.entry_fee != null && (
<div className="flex items-center gap-2.5 rounded-xl bg-surface-elevated border border-border/30 px-4 py-3 text-sm">
<CreditCard size={16} className="text-gold-accent shrink-0" />
<span className="text-muted-foreground">Entry fee: <strong className="text-foreground">{championship.entry_fee} </strong></span>
</div>
)}
{championship.video_max_duration != null && (
<div className="flex items-center gap-2.5 rounded-xl bg-surface-elevated border border-border/30 px-4 py-3 text-sm">
<Film size={16} className="text-purple-accent shrink-0" />
<span className="text-muted-foreground">Max video: <strong className="text-foreground">{Math.floor(championship.video_max_duration / 60)}m {championship.video_max_duration % 60}s</strong></span>
</div>
)}
</div>
{championship.description && (
<>
<Separator className="bg-border/30" />
<p className="text-muted-foreground whitespace-pre-line leading-relaxed">{championship.description}</p>
</>
)}
<Separator className="bg-border/30" />
{/* Registration section */}
{myReg && <RegistrationTimeline registration={myReg} />}
{canRegister && (
<Button
className="w-full bg-rose-accent hover:bg-rose-accent/90 text-white font-medium tracking-wide h-11"
disabled={registerMutation.isPending}
onClick={() => registerMutation.mutate()}
>
{registerMutation.isPending ? "Registering…" : "Register for this championship"}
</Button>
)}
{championship.status !== "open" && !myReg && (
<p className="text-center text-sm text-dim">Registration is not open.</p>
)}
{championship.form_url && (
<a
href={championship.form_url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-1.5 text-sm text-rose-accent hover:text-rose-accent/80 transition-colors"
>
Open registration form
<ExternalLink size={14} />
</a>
)}
</div>
);
}

View File

@@ -0,0 +1,49 @@
"use client";
import { useChampionships } from "@/hooks/useChampionships";
import { ChampionshipCard } from "@/components/championships/ChampionshipCard";
import { Trophy } from "lucide-react";
export default function ChampionshipsPage() {
const { data, isLoading, error } = useChampionships();
if (isLoading) {
return (
<div className="flex justify-center py-20">
<div className="relative h-8 w-8">
<div className="absolute inset-0 rounded-full border-2 border-rose-accent/20" />
<div className="absolute inset-0 rounded-full border-2 border-transparent border-t-rose-accent animate-spin" />
</div>
</div>
);
}
if (error) return <p className="text-center text-destructive py-20">Failed to load championships.</p>;
if (!data?.length) {
return (
<div className="text-center py-20 animate-fade-in">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-surface-elevated border border-border/40">
<Trophy className="h-7 w-7 text-dim" />
</div>
<p className="text-muted-foreground">No championships yet.</p>
</div>
);
}
return (
<div className="animate-fade-in">
<div className="mb-8">
<h1 className="font-display text-4xl font-bold tracking-wide">Championships</h1>
<p className="mt-1 text-muted-foreground">Browse upcoming competitions</p>
</div>
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
{data.map((c, i) => (
<div key={c.id} className={`animate-fade-in-up stagger-${Math.min(i + 1, 9)}`}>
<ChampionshipCard championship={c} />
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,10 @@
import { Navbar } from "@/components/layout/Navbar";
export default function AppLayout({ children }: { children: React.ReactNode }) {
return (
<div className="min-h-screen bg-mesh">
<Navbar />
<main className="mx-auto max-w-6xl px-4 py-8">{children}</main>
</div>
);
}

View File

@@ -0,0 +1,104 @@
"use client";
import { useRouter } from "next/navigation";
import { useAuth } from "@/hooks/useAuth";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { Phone, Building2, AtSign, CalendarDays, LogOut } from "lucide-react";
const ROLE_COLORS: Record<string, string> = {
admin: "bg-destructive/15 text-destructive border-destructive/20",
organizer: "bg-purple-soft text-purple-accent border-purple-accent/20",
member: "bg-rose-soft text-rose-accent border-rose-accent/20",
};
export default function ProfilePage() {
const router = useRouter();
const user = useAuth((s) => s.user);
const logout = useAuth((s) => s.logout);
if (!user) return null;
const initials = user.full_name.split(" ").map((n) => n[0]).join("").toUpperCase().slice(0, 2);
const joinedDate = new Date(user.created_at).toLocaleDateString("en-GB", { month: "long", year: "numeric" });
async function handleLogout() {
await logout();
router.push("/login");
}
return (
<div className="max-w-md mx-auto space-y-6 animate-fade-in">
<div className="flex flex-col items-center gap-4 pt-4">
{/* Avatar with gradient ring */}
<div className="relative">
<div className="absolute -inset-1 rounded-full bg-gradient-to-br from-rose-accent via-purple-accent to-gold-accent opacity-50 blur-sm" />
<Avatar className="relative h-20 w-20 border-2 border-background">
<AvatarFallback className="bg-surface-elevated text-rose-accent text-2xl font-bold">
{initials}
</AvatarFallback>
</Avatar>
</div>
<div className="text-center">
<p className="font-display text-2xl font-bold tracking-wide">{user.full_name}</p>
<p className="text-sm text-muted-foreground mt-1">{user.email}</p>
</div>
<Badge className={`${ROLE_COLORS[user.role] ?? "bg-surface-elevated text-muted-foreground"} border capitalize`}>
{user.role}
</Badge>
</div>
<Separator className="bg-border/30" />
<div className="space-y-1 rounded-xl bg-surface-elevated border border-border/30 p-4">
{user.phone && (
<div className="flex items-center justify-between py-2">
<span className="flex items-center gap-2 text-sm text-dim">
<Phone size={14} />
Phone
</span>
<span className="text-sm text-foreground">{user.phone}</span>
</div>
)}
{user.organization?.name && (
<div className="flex items-center justify-between py-2">
<span className="flex items-center gap-2 text-sm text-dim">
<Building2 size={14} />
Organization
</span>
<span className="text-sm text-foreground">{user.organization.name}</span>
</div>
)}
{user.instagram_handle && (
<div className="flex items-center justify-between py-2">
<span className="flex items-center gap-2 text-sm text-dim">
<AtSign size={14} />
Instagram
</span>
<span className="text-sm text-foreground">{user.instagram_handle}</span>
</div>
)}
<div className="flex items-center justify-between py-2">
<span className="flex items-center gap-2 text-sm text-dim">
<CalendarDays size={14} />
Member since
</span>
<span className="text-sm text-foreground">{joinedDate}</span>
</div>
</div>
<Button
variant="outline"
className="w-full border-destructive/30 text-destructive hover:bg-destructive/10 hover:border-destructive/50"
onClick={handleLogout}
>
<LogOut size={16} />
Sign out
</Button>
</div>
);
}

View File

@@ -0,0 +1,49 @@
"use client";
import { useMyRegistrations } from "@/hooks/useRegistrations";
import { RegistrationCard } from "@/components/registrations/RegistrationCard";
import { ListChecks } from "lucide-react";
export default function RegistrationsPage() {
const { data, isLoading, error } = useMyRegistrations();
if (isLoading) {
return (
<div className="flex justify-center py-20">
<div className="relative h-8 w-8">
<div className="absolute inset-0 rounded-full border-2 border-rose-accent/20" />
<div className="absolute inset-0 rounded-full border-2 border-transparent border-t-rose-accent animate-spin" />
</div>
</div>
);
}
if (error) return <p className="text-center text-destructive py-20">Failed to load registrations.</p>;
if (!data?.length) {
return (
<div className="text-center py-20 animate-fade-in">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-surface-elevated border border-border/40">
<ListChecks className="h-7 w-7 text-dim" />
</div>
<p className="text-muted-foreground">No registrations yet.</p>
</div>
);
}
return (
<div className="animate-fade-in">
<div className="mb-8">
<h1 className="font-display text-4xl font-bold tracking-wide">My Registrations</h1>
<p className="mt-1 text-muted-foreground">Track your championship progress</p>
</div>
<div className="space-y-3 max-w-2xl">
{data.map((r, i) => (
<div key={r.id} className={`animate-fade-in-up stagger-${Math.min(i + 1, 9)}`}>
<RegistrationCard registration={r} />
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,38 @@
export default function AuthLayout({ children }: { children: React.ReactNode }) {
return (
<div className="relative flex min-h-screen items-center justify-center overflow-hidden p-4">
{/* Gradient mesh background */}
<div className="fixed inset-0 bg-background bg-mesh-strong" />
{/* Decorative flowing lines — pole dance silhouette abstraction */}
<svg
className="fixed inset-0 h-full w-full opacity-[0.04]"
viewBox="0 0 1200 800"
fill="none"
preserveAspectRatio="xMidYMid slice"
>
<path
d="M-100,400 C100,200 300,600 500,350 C700,100 900,500 1100,300 C1300,100 1400,400 1400,400"
stroke="url(#line-grad)"
strokeWidth="2"
strokeLinecap="round"
/>
<path
d="M-100,500 C200,300 400,700 600,450 C800,200 1000,600 1300,350"
stroke="url(#line-grad)"
strokeWidth="1.5"
strokeLinecap="round"
/>
<defs>
<linearGradient id="line-grad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#E91E63" />
<stop offset="50%" stopColor="#9C27B0" />
<stop offset="100%" stopColor="#D4A843" />
</linearGradient>
</defs>
</svg>
<div className="relative z-10 w-full max-w-md animate-fade-in-up">{children}</div>
</div>
);
}

View File

@@ -0,0 +1,90 @@
"use client";
import Link from "next/link";
import { Eye, EyeOff } from "lucide-react";
import { useLoginForm } from "@/hooks/useAuthForms";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
export default function LoginPage() {
const { email, setEmail, password, setPassword, showPassword, setShowPassword, error, isLoading, submit } = useLoginForm();
return (
<Card className="glass-strong glow-rose overflow-hidden">
<CardHeader className="text-center pb-2">
<div className="mx-auto mb-4 h-px w-16 bg-gradient-to-r from-transparent via-rose-accent to-transparent" />
<CardTitle className="font-display text-3xl font-semibold tracking-wide">
Welcome back
</CardTitle>
<CardDescription className="text-muted-foreground">
Sign in to your account
</CardDescription>
</CardHeader>
<form onSubmit={submit}>
<CardContent className="space-y-4">
{error && (
<p className="rounded-lg bg-destructive/10 border border-destructive/20 px-3 py-2 text-sm text-destructive">
{error}
</p>
)}
<div className="space-y-2">
<Label htmlFor="email" className="text-xs uppercase tracking-widest text-dim">
Email
</Label>
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="bg-surface border-border/60 focus:border-rose-accent focus:ring-rose-accent/30 placeholder:text-dim"
placeholder="your@email.com"
/>
</div>
<div className="space-y-2">
<Label htmlFor="password" className="text-xs uppercase tracking-widest text-dim">
Password
</Label>
<div className="relative">
<Input
id="password"
type={showPassword ? "text" : "password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="bg-surface border-border/60 pr-10 focus:border-rose-accent focus:ring-rose-accent/30"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-dim hover:text-foreground transition-colors"
>
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
</div>
</CardContent>
<CardFooter className="flex flex-col gap-4 pt-2">
<Button
type="submit"
className="w-full bg-rose-accent hover:bg-rose-accent/90 text-white font-medium tracking-wide"
disabled={isLoading}
>
{isLoading ? "Signing in…" : "Sign in"}
</Button>
<p className="text-center text-sm text-muted-foreground">
No account?{" "}
<Link href="/register" className="font-medium text-rose-accent hover:text-rose-accent/80 transition-colors">
Register
</Link>
</p>
</CardFooter>
</form>
</Card>
);
}

View File

@@ -0,0 +1,30 @@
import Link from "next/link";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Clock } from "lucide-react";
export default function PendingPage() {
return (
<Card className="glass-strong glow-purple text-center overflow-hidden">
<CardHeader>
<div className="mx-auto mb-3 flex h-14 w-14 items-center justify-center rounded-full bg-purple-accent/10 border border-purple-accent/20">
<Clock className="h-7 w-7 text-purple-accent" />
</div>
<CardTitle className="font-display text-3xl font-semibold tracking-wide">
Awaiting approval
</CardTitle>
<CardDescription className="text-muted-foreground">
Your organizer account has been submitted. An admin will review it shortly.
</CardDescription>
</CardHeader>
<CardContent>
<p className="mb-6 text-sm text-dim">
Once approved you can log in and start creating championships.
</p>
<Button asChild variant="outline" className="w-full border-border/60 hover:bg-surface-hover">
<Link href="/login">Back to login</Link>
</Button>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,85 @@
"use client";
import Link from "next/link";
import { useRegisterForm } from "@/hooks/useAuthForms";
import { MemberFields } from "@/components/auth/MemberFields";
import { OrganizerFields } from "@/components/auth/OrganizerFields";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
export default function RegisterPage() {
const { role, setRole, form, update, error, isLoading, submit } = useRegisterForm();
return (
<Card className="glass-strong glow-rose overflow-hidden">
<CardHeader className="text-center pb-2">
<div className="mx-auto mb-4 h-px w-16 bg-gradient-to-r from-transparent via-purple-accent to-transparent" />
<CardTitle className="font-display text-3xl font-semibold tracking-wide">
Create account
</CardTitle>
<CardDescription className="text-muted-foreground">
Join the pole dance community
</CardDescription>
</CardHeader>
<form onSubmit={submit}>
<CardContent className="space-y-4">
{error && (
<p className="rounded-lg bg-destructive/10 border border-destructive/20 px-3 py-2 text-sm text-destructive">
{error}
</p>
)}
<div className="grid grid-cols-2 gap-2">
{(["member", "organizer"] as const).map((r) => (
<button
key={r}
type="button"
onClick={() => setRole(r)}
className={`rounded-xl border-2 p-3 text-sm font-medium transition-all duration-200 ${
role === r
? "border-rose-accent bg-rose-accent/10 text-foreground glow-rose"
: "border-border/40 text-muted-foreground hover:border-border hover:text-foreground"
}`}
>
{r === "member" ? "Athlete" : "Organizer"}
</button>
))}
</div>
<MemberFields
full_name={form.full_name}
email={form.email}
password={form.password}
phone={form.phone}
onChange={update}
/>
{role === "organizer" && (
<OrganizerFields
organization_name={form.organization_name}
instagram_handle={form.instagram_handle}
onChange={update}
/>
)}
</CardContent>
<CardFooter className="flex flex-col gap-4 pt-2">
<Button
type="submit"
className="w-full bg-rose-accent hover:bg-rose-accent/90 text-white font-medium tracking-wide"
disabled={isLoading}
>
{isLoading ? "Creating…" : role === "member" ? "Create account" : "Submit for approval"}
</Button>
<p className="text-center text-sm text-muted-foreground">
Have an account?{" "}
<Link href="/login" className="font-medium text-rose-accent hover:text-rose-accent/80 transition-colors">
Sign in
</Link>
</p>
</CardFooter>
</form>
</Card>
);
}

BIN
web/src/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

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