Compare commits

..

17 Commits

Author SHA1 Message Date
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
105 changed files with 14819 additions and 10778 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

@@ -1,14 +1,26 @@
from app.models.user import User, RefreshToken from app.models.user import User, RefreshToken
from app.models.organization import Organization
from app.models.championship import Championship from app.models.championship import Championship
from app.models.registration import Registration from app.models.registration import Registration
from app.models.participant import ParticipantList from app.models.participant import ParticipantList
from app.models.notification import NotificationLog from app.models.notification import NotificationLog
from app.models.discipline import Discipline
from app.models.style import Style
from app.models.fee import Fee
from app.models.rule import Rule
from app.models.judge import Judge
__all__ = [ __all__ = [
"User", "User",
"RefreshToken", "RefreshToken",
"Organization",
"Championship", "Championship",
"Registration", "Registration",
"ParticipantList", "ParticipantList",
"NotificationLog", "NotificationLog",
"Discipline",
"Style",
"Fee",
"Rule",
"Judge",
] ]

View File

@@ -1,7 +1,7 @@
import uuid import uuid
from datetime import datetime 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 sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base from app.database import Base
@@ -11,19 +11,21 @@ class Championship(Base):
__tablename__ = "championships" __tablename__ = "championships"
id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4) 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) title: Mapped[str] = mapped_column(String(255), nullable=False)
subtitle: Mapped[str | None] = mapped_column(String(255))
description: Mapped[str | None] = mapped_column(Text) description: Mapped[str | None] = mapped_column(Text)
location: Mapped[str | None] = mapped_column(String(500)) location: Mapped[str | None] = mapped_column(String(500))
venue: Mapped[str | None] = mapped_column(String(255))
event_date: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) event_date: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
registration_open_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) registration_open_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
registration_close_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) registration_close_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
accent_color: Mapped[str | None] = mapped_column(String(20)) # hex color e.g. #FF5CA8
# Extended fields # Legacy flat fields (kept for backwards compat, replaced by relational tables in POL-7 to POL-11)
form_url: Mapped[str | None] = mapped_column(String(2048)) form_url: Mapped[str | None] = mapped_column(String(2048))
entry_fee: Mapped[float | None] = mapped_column(Float) entry_fee: Mapped[float | None] = mapped_column(Float)
video_max_duration: Mapped[int | None] = mapped_column(Integer) # seconds video_max_duration: Mapped[int | None] = mapped_column(Integer) # seconds
judges: Mapped[str | None] = mapped_column(Text) # JSON string: [{name, bio, instagram}]
categories: Mapped[str | None] = mapped_column(Text) # JSON string: [str]
# Status: 'draft' | 'open' | 'closed' | 'completed' # Status: 'draft' | 'open' | 'closed' | 'completed'
status: Mapped[str] = mapped_column(String(20), nullable=False, default="draft") status: Mapped[str] = mapped_column(String(20), nullable=False, default="draft")
@@ -38,5 +40,11 @@ class Championship(Base):
DateTime(timezone=True), server_default=func.now(), onupdate=func.now() 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] 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] participant_list: Mapped["ParticipantList | None"] = relationship(back_populates="championship", uselist=False, cascade="all, delete-orphan") # type: ignore[name-defined]
disciplines: Mapped[list["Discipline"]] = relationship(back_populates="championship", cascade="all, delete-orphan") # type: ignore[name-defined]
styles: Mapped[list["Style"]] = relationship(back_populates="championship", cascade="all, delete-orphan") # type: ignore[name-defined]
fees: Mapped["Fee | None"] = relationship(back_populates="championship", uselist=False, cascade="all, delete-orphan") # type: ignore[name-defined]
rules: Mapped[list["Rule"]] = relationship(back_populates="championship", cascade="all, delete-orphan") # type: ignore[name-defined]
judges_list: Mapped[list["Judge"]] = relationship(back_populates="championship", 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

@@ -30,6 +30,7 @@ class User(Base):
refresh_tokens: Mapped[list["RefreshToken"]] = relationship(back_populates="user", cascade="all, delete-orphan") refresh_tokens: Mapped[list["RefreshToken"]] = relationship(back_populates="user", cascade="all, delete-orphan")
registrations: Mapped[list["Registration"]] = relationship(back_populates="user") # type: ignore[name-defined] 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] 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): class RefreshToken(Base):

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,135 @@
"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, Building2, 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>
{championship.subtitle && <p className="text-muted-foreground mt-1">{championship.subtitle}</p>}
</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>
)}
{championship.venue && (
<div className="flex items-center gap-2.5 rounded-xl bg-surface-elevated border border-border/30 px-4 py-3 text-sm">
<Building2 size={16} className="text-purple-accent shrink-0" />
<span className="text-muted-foreground">{championship.venue}</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

72
web/src/app/globals.css Normal file
View File

@@ -0,0 +1,72 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@import "../styles/theme.css";
@import "../styles/utilities.css";
@import "../styles/animations.css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-outfit);
--font-display: var(--font-cormorant);
--font-mono: var(--font-geist-mono);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
--radius-3xl: calc(var(--radius) + 12px);
--radius-4xl: calc(var(--radius) + 16px);
--color-rose-accent: #E91E63;
--color-rose-soft: rgba(233, 30, 99, 0.15);
--color-rose-glow: rgba(233, 30, 99, 0.25);
--color-purple-accent: #9C27B0;
--color-purple-soft: rgba(156, 39, 176, 0.15);
--color-gold-accent: #D4A843;
--color-gold-soft: rgba(212, 168, 67, 0.15);
--color-surface: #0E0D18;
--color-surface-elevated: #15142A;
--color-surface-hover: #1C1B35;
--color-dim: #5C5880;
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

33
web/src/app/layout.tsx Normal file
View File

@@ -0,0 +1,33 @@
import type { Metadata } from "next";
import { Cormorant_Garamond, Outfit } from "next/font/google";
import "./globals.css";
import { Providers } from "./providers";
const cormorant = Cormorant_Garamond({
subsets: ["latin", "cyrillic"],
weight: ["400", "600", "700"],
variable: "--font-cormorant",
display: "swap",
});
const outfit = Outfit({
subsets: ["latin"],
weight: ["300", "400", "500", "600", "700"],
variable: "--font-outfit",
display: "swap",
});
export const metadata: Metadata = {
title: "Pole Dance Championships",
description: "Register and track pole dance championship events",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body className={`${cormorant.variable} ${outfit.variable} font-sans antialiased`}>
<Providers>{children}</Providers>
</body>
</html>
);
}

5
web/src/app/page.tsx Normal file
View File

@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function Home() {
redirect("/championships");
}

42
web/src/app/providers.tsx Normal file
View File

@@ -0,0 +1,42 @@
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState, useEffect } from "react";
import { useAuth } from "@/hooks/useAuth";
function AuthInitializer({ children }: { children: React.ReactNode }) {
const initialize = useAuth((s) => s.initialize);
const isInitialized = useAuth((s) => s.isInitialized);
useEffect(() => {
initialize();
}, [initialize]);
if (!isInitialized) {
return (
<div className="flex h-screen items-center justify-center bg-background">
<div className="flex flex-col items-center gap-4 animate-fade-in">
<div className="relative h-10 w-10">
<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>
<p className="text-sm text-muted-foreground tracking-wide">Loading</p>
</div>
</div>
);
}
return <>{children}</>;
}
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() => new QueryClient({ defaultOptions: { queries: { retry: 1, staleTime: 30_000 } } })
);
return (
<QueryClientProvider client={queryClient}>
<AuthInitializer>{children}</AuthInitializer>
</QueryClientProvider>
);
}

View File

@@ -0,0 +1,82 @@
import { UserOut } from "@/types/user";
import { Card, CardContent } from "@/components/ui/card";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Building2, Phone, AtSign, CheckCircle, XCircle } from "lucide-react";
interface Props {
user: UserOut;
onApprove?: (id: string) => void;
onReject?: (id: string) => void;
isActing?: boolean;
}
const STATUS_DOT: Record<string, string> = {
pending: "bg-gold-accent",
approved: "bg-emerald-500",
rejected: "bg-destructive",
};
export function UserCard({ user, onApprove, onReject, isActing }: Props) {
const initials = user.full_name.split(" ").map((n) => n[0]).join("").toUpperCase().slice(0, 2);
return (
<Card className="border-border/40 bg-surface-elevated">
<CardContent className="p-4 flex gap-4 items-start">
<Avatar className="h-10 w-10 shrink-0 border border-border/40">
<AvatarFallback className="bg-rose-accent/10 text-rose-accent text-sm font-semibold">
{initials}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0 space-y-1">
<div className="flex items-center gap-2">
<p className="font-semibold text-foreground">{user.full_name}</p>
<span className={`h-2 w-2 rounded-full shrink-0 ${STATUS_DOT[user.status] ?? "bg-dim"}`} />
</div>
<p className="text-sm text-muted-foreground">{user.email}</p>
{user.organization_name && (
<p className="flex items-center gap-1.5 text-sm text-muted-foreground">
<Building2 size={12} className="text-dim" />
{user.organization_name}
</p>
)}
{user.phone && (
<p className="flex items-center gap-1.5 text-sm text-muted-foreground">
<Phone size={12} className="text-dim" />
{user.phone}
</p>
)}
{user.instagram_handle && (
<p className="flex items-center gap-1.5 text-sm text-muted-foreground">
<AtSign size={12} className="text-dim" />
{user.instagram_handle}
</p>
)}
</div>
{user.status === "pending" && onApprove && onReject && (
<div className="flex gap-2 shrink-0">
<Button
size="sm"
className="bg-emerald-600 hover:bg-emerald-500 text-white"
disabled={isActing}
onClick={() => onApprove(user.id)}
>
<CheckCircle size={14} />
Approve
</Button>
<Button size="sm" variant="destructive" disabled={isActing} onClick={() => onReject(user.id)}>
<XCircle size={14} />
Reject
</Button>
</div>
)}
{user.status !== "pending" && (
<span className="text-xs text-dim capitalize shrink-0">{user.status}</span>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,33 @@
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
interface Props {
full_name: string;
email: string;
password: string;
phone: string;
onChange: (field: string, value: string) => void;
}
export function MemberFields({ full_name, email, password, phone, onChange }: Props) {
return (
<>
<div className="space-y-2">
<Label htmlFor="full_name" className="text-xs uppercase tracking-widest text-dim">Full name</Label>
<Input id="full_name" value={full_name} onChange={(e) => onChange("full_name", e.target.value)} required className="bg-surface border-border/60 focus:border-rose-accent focus:ring-rose-accent/30" />
</div>
<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) => onChange("email", e.target.value)} required className="bg-surface border-border/60 focus:border-rose-accent focus:ring-rose-accent/30" />
</div>
<div className="space-y-2">
<Label htmlFor="password" className="text-xs uppercase tracking-widest text-dim">Password</Label>
<Input id="password" type="password" value={password} onChange={(e) => onChange("password", e.target.value)} required className="bg-surface border-border/60 focus:border-rose-accent focus:ring-rose-accent/30" />
</div>
<div className="space-y-2">
<Label htmlFor="phone" className="text-xs uppercase tracking-widest text-dim">Phone (optional)</Label>
<Input id="phone" type="tel" value={phone} onChange={(e) => onChange("phone", e.target.value)} className="bg-surface border-border/60 focus:border-rose-accent focus:ring-rose-accent/30" />
</div>
</>
);
}

View File

@@ -0,0 +1,28 @@
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { AlertTriangle } from "lucide-react";
interface Props {
organization_name: string;
instagram_handle: string;
onChange: (field: string, value: string) => void;
}
export function OrganizerFields({ organization_name, instagram_handle, onChange }: Props) {
return (
<>
<div className="flex items-center gap-2 rounded-lg bg-gold-soft border border-gold-accent/20 px-3 py-2 text-xs text-gold-accent">
<AlertTriangle size={14} className="shrink-0" />
Organizer accounts require admin approval before you can log in.
</div>
<div className="space-y-2">
<Label htmlFor="org" className="text-xs uppercase tracking-widest text-dim">Organization name</Label>
<Input id="org" value={organization_name} onChange={(e) => onChange("organization_name", e.target.value)} required className="bg-surface border-border/60 focus:border-rose-accent focus:ring-rose-accent/30" />
</div>
<div className="space-y-2">
<Label htmlFor="ig" className="text-xs uppercase tracking-widest text-dim">Instagram (optional)</Label>
<Input id="ig" placeholder="@yourstudio" value={instagram_handle} onChange={(e) => onChange("instagram_handle", e.target.value)} className="bg-surface border-border/60 focus:border-rose-accent focus:ring-rose-accent/30 placeholder:text-dim" />
</div>
</>
);
}

View File

@@ -0,0 +1,70 @@
import Link from "next/link";
import { Championship } from "@/types/championship";
import { StatusBadge } from "@/components/shared/StatusBadge";
import { Card, CardContent } from "@/components/ui/card";
import { MapPin, Calendar, CreditCard } from "lucide-react";
interface Props {
championship: Championship;
}
export function ChampionshipCard({ championship: c }: Props) {
const date = c.event_date
? new Date(c.event_date).toLocaleDateString("en-GB", { day: "numeric", month: "long", year: "numeric" })
: null;
return (
<Link href={`/championships/${c.id}`}>
<Card className="group overflow-hidden border-border/40 bg-surface-elevated hover:border-rose-accent/30 transition-all duration-300 cursor-pointer h-full hover:glow-rose">
{/* Image / gradient header */}
<div className="relative overflow-hidden">
{c.image_url ? (
<img
src={c.image_url}
alt={c.title}
className="h-44 w-full object-cover transition-transform duration-500 group-hover:scale-105"
/>
) : (
<div className="h-44 w-full bg-gradient-to-br from-rose-accent/30 via-purple-accent/20 to-gold-accent/10 flex items-center justify-center">
<div className="text-5xl opacity-40 group-hover:opacity-60 transition-opacity duration-300 group-hover:animate-float">
💃
</div>
</div>
)}
{/* Gradient overlay */}
<div className="absolute inset-0 bg-gradient-to-t from-surface-elevated via-transparent to-transparent" />
{/* Status badge floating on image */}
<div className="absolute top-3 right-3">
<StatusBadge status={c.status} />
</div>
</div>
<CardContent className="p-4 space-y-2.5">
<h2 className="font-semibold text-foreground leading-tight group-hover:text-rose-accent transition-colors">
{c.title}
</h2>
<div className="space-y-1.5">
{c.location && (
<p className="flex items-center gap-1.5 text-sm text-muted-foreground">
<MapPin size={13} className="text-dim shrink-0" />
{c.location}
</p>
)}
{date && (
<p className="flex items-center gap-1.5 text-sm text-muted-foreground">
<Calendar size={13} className="text-dim shrink-0" />
{date}
</p>
)}
{c.entry_fee != null && (
<p className="flex items-center gap-1.5 text-sm font-medium text-gold-accent">
<CreditCard size={13} className="shrink-0" />
{c.entry_fee}
</p>
)}
</div>
</CardContent>
</Card>
</Link>
);
}

View File

@@ -0,0 +1,153 @@
"use client";
import Link from "next/link";
import { useRouter, usePathname } from "next/navigation";
import { useAuth } from "@/hooks/useAuth";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Trophy, ListChecks, Shield, Menu } from "lucide-react";
import { useState } from "react";
const NAV_LINKS = [
{ href: "/championships", label: "Championships", icon: Trophy },
{ href: "/registrations", label: "My Registrations", icon: ListChecks },
];
export function Navbar() {
const router = useRouter();
const pathname = usePathname();
const user = useAuth((s) => s.user);
const logout = useAuth((s) => s.logout);
const [mobileOpen, setMobileOpen] = useState(false);
async function handleLogout() {
await logout();
router.push("/login");
}
const initials = user?.full_name
.split(" ")
.map((n) => n[0])
.join("")
.toUpperCase()
.slice(0, 2) ?? "?";
return (
<header className="sticky top-0 z-50 glass-strong">
<div className="mx-auto flex h-14 max-w-6xl items-center justify-between px-4">
<div className="flex items-center gap-6">
<Link href="/championships" className="flex items-center gap-2 group">
<span className="text-gradient-rose font-display text-xl font-bold tracking-wide">
DanceChamp
</span>
</Link>
{/* Desktop nav */}
<nav className="hidden gap-1 sm:flex">
{NAV_LINKS.map((link) => {
const active = pathname.startsWith(link.href);
return (
<Link
key={link.href}
href={link.href}
className={`flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium transition-all duration-200 ${
active
? "bg-rose-accent/10 text-rose-accent"
: "text-muted-foreground hover:text-foreground hover:bg-surface-hover"
}`}
>
<link.icon size={15} />
{link.label}
</Link>
);
})}
{user?.role === "admin" && (
<Link
href="/admin"
className={`flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium transition-all duration-200 ${
pathname.startsWith("/admin")
? "bg-rose-accent/10 text-rose-accent"
: "text-muted-foreground hover:text-foreground hover:bg-surface-hover"
}`}
>
<Shield size={15} />
Admin
</Link>
)}
</nav>
</div>
<div className="flex items-center gap-2">
{/* Mobile menu toggle */}
<button
onClick={() => setMobileOpen(!mobileOpen)}
className="rounded-lg p-1.5 text-muted-foreground hover:text-foreground hover:bg-surface-hover transition-colors sm:hidden"
>
<Menu size={20} />
</button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="rounded-full focus:outline-none focus:ring-2 focus:ring-rose-accent/50">
<Avatar className="h-8 w-8 cursor-pointer border border-rose-accent/30">
<AvatarFallback className="bg-rose-accent/10 text-rose-accent text-xs font-semibold">
{initials}
</AvatarFallback>
</Avatar>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44 glass-strong border-border/60">
<DropdownMenuItem asChild>
<Link href="/profile" className="text-foreground">Profile</Link>
</DropdownMenuItem>
<DropdownMenuSeparator className="bg-border/40" />
<DropdownMenuItem onClick={handleLogout} className="text-destructive focus:text-destructive">
Sign out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{/* Mobile nav */}
{mobileOpen && (
<nav className="border-t border-border/30 px-4 pb-3 pt-2 sm:hidden animate-fade-in">
{NAV_LINKS.map((link) => {
const active = pathname.startsWith(link.href);
return (
<Link
key={link.href}
href={link.href}
onClick={() => setMobileOpen(false)}
className={`flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors ${
active ? "text-rose-accent" : "text-muted-foreground hover:text-foreground"
}`}
>
<link.icon size={16} />
{link.label}
</Link>
);
})}
{user?.role === "admin" && (
<Link
href="/admin"
onClick={() => setMobileOpen(false)}
className={`flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors ${
pathname.startsWith("/admin") ? "text-rose-accent" : "text-muted-foreground hover:text-foreground"
}`}
>
<Shield size={16} />
Admin
</Link>
)}
</nav>
)}
</header>
);
}

View File

@@ -0,0 +1,62 @@
import Link from "next/link";
import { Registration } from "@/types/registration";
import { StatusBadge } from "@/components/shared/StatusBadge";
import { Card, CardContent } from "@/components/ui/card";
import { MapPin, Calendar } from "lucide-react";
const STEPS = ["submitted", "form_submitted", "payment_pending", "payment_confirmed", "video_submitted", "accepted"];
interface Props {
registration: Registration;
}
export function RegistrationCard({ registration: r }: Props) {
const date = r.championship_event_date
? new Date(r.championship_event_date).toLocaleDateString("en-GB", { day: "numeric", month: "long", year: "numeric" })
: null;
const stepIndex = STEPS.indexOf(r.status);
return (
<Link href={`/championships/${r.championship_id}`}>
<Card className="group border-border/40 bg-surface-elevated hover:border-rose-accent/30 transition-all duration-300 cursor-pointer hover:glow-rose">
<CardContent className="p-4 space-y-3">
<div className="flex items-start justify-between gap-2">
<div className="space-y-1.5">
<p className="font-semibold text-foreground group-hover:text-rose-accent transition-colors">
{r.championship_title ?? "Championship"}
</p>
{r.championship_location && (
<p className="flex items-center gap-1.5 text-sm text-muted-foreground">
<MapPin size={13} className="text-dim shrink-0" />
{r.championship_location}
</p>
)}
{date && (
<p className="flex items-center gap-1.5 text-sm text-muted-foreground">
<Calendar size={13} className="text-dim shrink-0" />
{date}
</p>
)}
</div>
<StatusBadge status={r.status} type="registration" />
</div>
{/* Progress bar */}
<div className="flex gap-1.5">
{STEPS.map((_, i) => (
<div
key={i}
className={`h-1.5 flex-1 rounded-full transition-colors ${
i <= stepIndex
? "bg-gradient-to-r from-rose-accent to-purple-accent"
: "bg-border/40"
}`}
/>
))}
</div>
</CardContent>
</Card>
</Link>
);
}

View File

@@ -0,0 +1,67 @@
import { Registration } from "@/types/registration";
import { Check, AlertTriangle } from "lucide-react";
const STEPS: { key: string; label: string }[] = [
{ key: "submitted", label: "Submitted" },
{ key: "form_submitted", label: "Form submitted" },
{ key: "payment_pending", label: "Payment pending" },
{ key: "payment_confirmed", label: "Payment confirmed" },
{ key: "video_submitted", label: "Video submitted" },
{ key: "accepted", label: "Accepted" },
];
interface Props {
registration: Registration;
}
export function RegistrationTimeline({ registration }: Props) {
const currentIndex = STEPS.findIndex((s) => s.key === registration.status);
const isRejected = registration.status === "rejected";
const isWaitlisted = registration.status === "waitlisted";
if (isRejected) {
return (
<div className="flex items-center gap-3 rounded-xl bg-destructive/10 border border-destructive/20 px-4 py-3 text-sm text-destructive">
<AlertTriangle size={16} className="shrink-0" />
Your registration was <strong>rejected</strong>.
</div>
);
}
if (isWaitlisted) {
return (
<div className="flex items-center gap-3 rounded-xl bg-gold-soft border border-gold-accent/20 px-4 py-3 text-sm text-gold-accent">
<AlertTriangle size={16} className="shrink-0" />
You are on the <strong>waitlist</strong>.
</div>
);
}
return (
<div className="space-y-3">
<p className="text-sm font-medium text-foreground">Registration progress</p>
<ol className="space-y-1">
{STEPS.map((step, i) => {
const done = i <= currentIndex;
const isCurrent = i === currentIndex;
return (
<li key={step.key} className="flex items-center gap-3 rounded-lg px-3 py-2 text-sm">
<span className={`flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-xs font-bold transition-all ${
done
? "bg-rose-accent text-white"
: "bg-surface-elevated border border-border/40 text-dim"
} ${isCurrent ? "glow-rose" : ""}`}>
{done ? <Check size={13} /> : i + 1}
</span>
<span className={`${
done ? "text-foreground" : "text-dim"
} ${isCurrent ? "font-medium" : ""}`}>
{step.label}
</span>
</li>
);
})}
</ol>
</div>
);
}

View File

@@ -0,0 +1,34 @@
import { Badge } from "@/components/ui/badge";
const CHAMPIONSHIP_COLORS: Record<string, string> = {
open: "bg-emerald-500/15 text-emerald-400 border-emerald-500/20",
closed: "bg-surface-elevated text-dim border-border/40",
draft: "bg-gold-soft text-gold-accent border-gold-accent/20",
completed: "bg-blue-500/15 text-blue-400 border-blue-500/20",
};
const REGISTRATION_COLORS: Record<string, string> = {
submitted: "bg-surface-elevated text-muted-foreground border-border/40",
form_submitted: "bg-gold-soft text-gold-accent border-gold-accent/20",
payment_pending: "bg-orange-500/15 text-orange-400 border-orange-500/20",
payment_confirmed: "bg-blue-500/15 text-blue-400 border-blue-500/20",
video_submitted: "bg-purple-soft text-purple-accent border-purple-accent/20",
accepted: "bg-emerald-500/15 text-emerald-400 border-emerald-500/20",
rejected: "bg-destructive/15 text-destructive border-destructive/20",
waitlisted: "bg-gold-soft text-gold-accent border-gold-accent/20",
};
interface Props {
status: string;
type?: "championship" | "registration";
}
export function StatusBadge({ status, type = "championship" }: Props) {
const map = type === "championship" ? CHAMPIONSHIP_COLORS : REGISTRATION_COLORS;
const color = map[status] ?? "bg-surface-elevated text-dim border-border/40";
return (
<Badge className={`${color} border capitalize text-[11px] tracking-wide`}>
{status.replace("_", " ")}
</Badge>
);
}

View File

@@ -0,0 +1,109 @@
"use client"
import * as React from "react"
import { Avatar as AvatarPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Avatar({
className,
size = "default",
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root> & {
size?: "default" | "sm" | "lg"
}) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
data-size={size}
className={cn(
"group/avatar relative flex size-8 shrink-0 overflow-hidden rounded-full select-none data-[size=lg]:size-10 data-[size=sm]:size-6",
className
)}
{...props}
/>
)
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn("aspect-square size-full", className)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"bg-muted text-muted-foreground flex size-full items-center justify-center rounded-full text-sm group-data-[size=sm]/avatar:text-xs",
className
)}
{...props}
/>
)
}
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="avatar-badge"
className={cn(
"bg-primary text-primary-foreground ring-background absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full ring-2 select-none",
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
className
)}
{...props}
/>
)
}
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group"
className={cn(
"*:data-[slot=avatar]:ring-background group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2",
className
)}
{...props}
/>
)
}
function AvatarGroupCount({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group-count"
className={cn(
"bg-muted text-muted-foreground ring-background relative flex size-8 shrink-0 items-center justify-center rounded-full text-sm ring-2 group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
className
)}
{...props}
/>
)
}
export {
Avatar,
AvatarImage,
AvatarFallback,
AvatarBadge,
AvatarGroup,
AvatarGroupCount,
}

View File

@@ -0,0 +1,48 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-full border border-transparent px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
link: "text-primary underline-offset-4 [a&]:hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span"
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,64 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View File

@@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@@ -0,0 +1,257 @@
"use client"
import * as React from "react"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}

View File

@@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
}
export { Input }

View File

@@ -0,0 +1,24 @@
"use client"
import * as React from "react"
import { Label as LabelPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

View File

@@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import { Separator as SeparatorPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
)
}
export { Separator }

81
web/src/hooks/useAuth.ts Normal file
View File

@@ -0,0 +1,81 @@
import { create } from "zustand";
import { authApi } from "@/lib/api/auth";
import { UserOut } from "@/types/user";
import { saveTokens, getRefreshToken, clearTokens, loadFromStorage } from "@/lib/api/tokenStorage";
interface AuthState {
user: UserOut | null;
isLoading: boolean;
isInitialized: boolean;
initialize: () => Promise<void>;
login: (email: string, password: string) => Promise<void>;
register: (data: {
email: string;
password: string;
full_name: string;
phone?: string;
requested_role?: "member" | "organizer";
organization_name?: string;
instagram_handle?: string;
}) => Promise<"approved" | "pending">;
logout: () => Promise<void>;
setUser: (user: UserOut) => void;
}
export const useAuth = create<AuthState>((set) => ({
user: null,
isLoading: false,
isInitialized: false,
initialize: async () => {
loadFromStorage();
try {
const user = await authApi.me();
set({ user, isInitialized: true });
} catch {
clearTokens();
set({ user: null, isInitialized: true });
}
},
login: async (email, password) => {
set({ isLoading: true });
try {
const data = await authApi.login({ email, password });
saveTokens(data.access_token, data.refresh_token);
set({ user: data.user });
} finally {
set({ isLoading: false });
}
},
register: async (data) => {
set({ isLoading: true });
try {
const res = await authApi.register(data);
if (res.access_token && res.refresh_token) {
saveTokens(res.access_token, res.refresh_token);
set({ user: res.user });
return "approved";
}
return "pending";
} finally {
set({ isLoading: false });
}
},
logout: async () => {
const refresh = getRefreshToken();
if (refresh) {
try {
await authApi.logout(refresh);
} catch {
// clear locally regardless
}
}
clearTokens();
set({ user: null });
},
setUser: (user) => set({ user }),
}));

View File

@@ -0,0 +1,69 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/hooks/useAuth";
export function useLoginForm() {
const router = useRouter();
const login = useAuth((s) => s.login);
const isLoading = useAuth((s) => s.isLoading);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [error, setError] = useState("");
async function submit(e: React.SyntheticEvent) {
e.preventDefault();
setError("");
try {
await login(email, password);
router.push("/championships");
} catch {
setError("Invalid email or password");
}
}
return { email, setEmail, password, setPassword, showPassword, setShowPassword, error, isLoading, submit };
}
export function useRegisterForm() {
const router = useRouter();
const register = useAuth((s) => s.register);
const isLoading = useAuth((s) => s.isLoading);
const [role, setRole] = useState<"member" | "organizer">("member");
const [form, setForm] = useState({
full_name: "",
email: "",
password: "",
phone: "",
organization_name: "",
instagram_handle: "",
});
const [error, setError] = useState("");
function update(field: string, value: string) {
setForm((prev) => ({ ...prev, [field]: value }));
}
async function submit(e: React.SyntheticEvent) {
e.preventDefault();
setError("");
try {
const result = await register({
...form,
requested_role: role,
phone: form.phone || undefined,
organization_name: role === "organizer" ? form.organization_name : undefined,
instagram_handle: form.instagram_handle || undefined,
});
router.push(result === "approved" ? "/championships" : "/pending");
} catch {
setError("Registration failed. Please check your details and try again.");
}
}
return { role, setRole, form, update, error, isLoading, submit };
}

View File

@@ -0,0 +1,19 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { championshipsApi } from "@/lib/api/championships";
export function useChampionships() {
return useQuery({
queryKey: ["championships"],
queryFn: () => championshipsApi.list(),
});
}
export function useChampionship(id: string) {
return useQuery({
queryKey: ["championship", id],
queryFn: () => championshipsApi.get(id),
enabled: !!id,
});
}

View File

@@ -0,0 +1,22 @@
"use client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { registrationsApi } from "@/lib/api/registrations";
export function useMyRegistrations() {
return useQuery({
queryKey: ["registrations", "my"],
queryFn: () => registrationsApi.my(),
});
}
export function useRegisterForChampionship(championshipId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => registrationsApi.create({ championship_id: championshipId }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["registrations", "my"] });
},
});
}

27
web/src/hooks/useUsers.ts Normal file
View File

@@ -0,0 +1,27 @@
"use client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { usersApi } from "@/lib/api/users";
export function useUsers() {
return useQuery({
queryKey: ["users"],
queryFn: () => usersApi.list(),
});
}
export function useUserActions() {
const queryClient = useQueryClient();
const approve = useMutation({
mutationFn: (id: string) => usersApi.approve(id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["users"] }),
});
const reject = useMutation({
mutationFn: (id: string) => usersApi.reject(id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["users"] }),
});
return { approve, reject };
}

32
web/src/lib/api/auth.ts Normal file
View File

@@ -0,0 +1,32 @@
import { apiClient } from "./client";
import { UserOut } from "@/types/user";
import { TokenPair } from "@/types/tokenPair";
import { RegisterResponse } from "@/types/registerResponse";
export type { UserOut, TokenPair, RegisterResponse };
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<RegisterResponse>("/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<UserOut>("/auth/me").then((r) => r.data),
updateMe: (data: Partial<Pick<UserOut, "full_name" | "phone" | "organization_name" | "instagram_handle">>) =>
apiClient.patch<UserOut>("/auth/me", data).then((r) => r.data),
};

View File

@@ -0,0 +1,21 @@
import { apiClient } from "./client";
import { Championship } from "@/types/championship";
export type { Championship };
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),
create: (data: Partial<Championship>) =>
apiClient.post<Championship>("/championships", data).then((r) => r.data),
update: (id: string, data: Partial<Championship>) =>
apiClient.patch<Championship>(`/championships/${id}`, data).then((r) => r.data),
delete: (id: string) =>
apiClient.delete(`/championships/${id}`),
};

72
web/src/lib/api/client.ts Normal file
View File

@@ -0,0 +1,72 @@
import axios from "axios";
import { getAccessToken, getRefreshToken, saveTokens, clearTokens } from "./tokenStorage";
const BASE_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000/api/v1";
export const apiClient = axios.create({
baseURL: BASE_URL,
timeout: 10000,
});
// Attach access token to every request
apiClient.interceptors.request.use((config) => {
const token = getAccessToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// Queue for requests waiting on token refresh
let isRefreshing = false;
let waitQueue: Array<(token: string) => void> = [];
function processQueue(newToken: string) {
waitQueue.forEach((resolve) => resolve(newToken));
waitQueue = [];
}
// Auto-refresh on 401
apiClient.interceptors.response.use(
(res) => res,
async (error) => {
const original = error.config;
if (error.response?.status !== 401 || original._retry) {
return Promise.reject(error);
}
const refreshToken = getRefreshToken();
if (!refreshToken) {
clearTokens();
if (typeof window !== "undefined") window.location.href = "/login";
return Promise.reject(error);
}
if (isRefreshing) {
return new Promise((resolve) => {
waitQueue.push((token) => {
original.headers.Authorization = `Bearer ${token}`;
resolve(apiClient(original));
});
});
}
original._retry = true;
isRefreshing = true;
try {
const res = await axios.post(`${BASE_URL}/auth/refresh`, { refresh_token: refreshToken });
const { access_token, refresh_token: newRefresh } = res.data;
saveTokens(access_token, newRefresh);
processQueue(access_token);
original.headers.Authorization = `Bearer ${access_token}`;
return apiClient(original);
} catch {
clearTokens();
if (typeof window !== "undefined") window.location.href = "/login";
return Promise.reject(error);
} finally {
isRefreshing = false;
}
}
);

View File

@@ -0,0 +1,21 @@
import { apiClient } from "./client";
import { Registration } from "@/types/registration";
export type { Registration };
export const registrationsApi = {
create: (data: { championship_id: string; category?: string; level?: string; notes?: string }) =>
apiClient.post<Registration>("/registrations", data).then((r) => r.data),
my: () =>
apiClient.get<Registration[]>("/registrations/my").then((r) => r.data),
get: (id: string) =>
apiClient.get<Registration>(`/registrations/${id}`).then((r) => r.data),
update: (id: string, data: { video_url?: string; notes?: string; status?: string }) =>
apiClient.patch<Registration>(`/registrations/${id}`, data).then((r) => r.data),
cancel: (id: string) =>
apiClient.delete(`/registrations/${id}`),
};

View File

@@ -0,0 +1,60 @@
const ACCESS_KEY = "access_token";
const REFRESH_KEY = "refresh_token";
let _accessToken: string | null = null;
let _refreshToken: string | null = null;
function setCookie(name: string, days: number) {
const expires = new Date(Date.now() + days * 864e5).toUTCString();
document.cookie = `${name}=1; expires=${expires}; path=/; SameSite=Lax`;
}
function deleteCookie(name: string) {
document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`;
}
export function saveTokens(access: string, refresh: string) {
_accessToken = access;
_refreshToken = refresh;
if (typeof window !== "undefined") {
localStorage.setItem(ACCESS_KEY, access);
localStorage.setItem(REFRESH_KEY, refresh);
// Presence-only cookies so Next.js middleware can check auth on the edge
setCookie(ACCESS_KEY, 1);
setCookie(REFRESH_KEY, 7);
}
}
export function getAccessToken(): string | null {
if (_accessToken) return _accessToken;
if (typeof window !== "undefined") {
_accessToken = localStorage.getItem(ACCESS_KEY);
}
return _accessToken;
}
export function getRefreshToken(): string | null {
if (_refreshToken) return _refreshToken;
if (typeof window !== "undefined") {
_refreshToken = localStorage.getItem(REFRESH_KEY);
}
return _refreshToken;
}
export function clearTokens() {
_accessToken = null;
_refreshToken = null;
if (typeof window !== "undefined") {
localStorage.removeItem(ACCESS_KEY);
localStorage.removeItem(REFRESH_KEY);
deleteCookie(ACCESS_KEY);
deleteCookie(REFRESH_KEY);
}
}
export function loadFromStorage() {
if (typeof window !== "undefined") {
_accessToken = localStorage.getItem(ACCESS_KEY);
_refreshToken = localStorage.getItem(REFRESH_KEY);
}
}

15
web/src/lib/api/users.ts Normal file
View File

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

6
web/src/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

31
web/src/proxy.ts Normal file
View File

@@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from "next/server";
// Middleware runs on the edge — no localStorage access.
// We protect routes by checking if the access_token cookie exists.
// The client sets this cookie on login; the store validates it with /auth/me.
const PUBLIC_PATHS = ["/login", "/register", "/pending"];
export function proxy(req: NextRequest) {
const { pathname } = req.nextUrl;
// Allow public routes
if (PUBLIC_PATHS.some((p) => pathname.startsWith(p))) {
return NextResponse.next();
}
// Check for token cookie (set by the client after login)
const hasToken = req.cookies.has("access_token");
if (!hasToken) {
const loginUrl = req.nextUrl.clone();
loginUrl.pathname = "/login";
return NextResponse.redirect(loginUrl);
}
return NextResponse.next();
}
export const config = {
matcher: ["/((?!_next|favicon.ico|api).*)"],
};

View File

@@ -0,0 +1,62 @@
/* ─── Keyframes ─── */
@keyframes fade-in-up {
from { opacity: 0; transform: translateY(16px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slide-in-left {
from { opacity: 0; transform: translateX(-20px); }
to { opacity: 1; transform: translateX(0); }
}
@keyframes pulse-glow {
0%, 100% { box-shadow: 0 0 20px rgba(233, 30, 99, 0.2); }
50% { box-shadow: 0 0 40px rgba(233, 30, 99, 0.4); }
}
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-6px); }
}
@keyframes spin-slow {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* ─── Animation utilities ─── */
.animate-fade-in-up { animation: fade-in-up 0.5s ease-out both; }
.animate-fade-in { animation: fade-in 0.4s ease-out both; }
.animate-slide-in-left { animation: slide-in-left 0.5s ease-out both; }
.animate-pulse-glow { animation: pulse-glow 3s ease-in-out infinite; }
.animate-float { animation: float 4s ease-in-out infinite; }
.animate-spin-slow { animation: spin-slow 8s linear infinite; }
.animate-shimmer {
background-size: 200% 100%;
animation: shimmer 2s linear infinite;
}
/* ─── Stagger delays ─── */
.stagger-1 { animation-delay: 0.05s; }
.stagger-2 { animation-delay: 0.1s; }
.stagger-3 { animation-delay: 0.15s; }
.stagger-4 { animation-delay: 0.2s; }
.stagger-5 { animation-delay: 0.25s; }
.stagger-6 { animation-delay: 0.3s; }
.stagger-7 { animation-delay: 0.35s; }
.stagger-8 { animation-delay: 0.4s; }
.stagger-9 { animation-delay: 0.45s; }

38
web/src/styles/theme.css Normal file
View File

@@ -0,0 +1,38 @@
/* ──────────────────────────────────────────
DARK LUXURY THEME — CSS Variables
────────────────────────────────────────── */
:root {
--radius: 0.75rem;
--background: #07060E;
--foreground: #F2EFFF;
--card: #12111F;
--card-foreground: #F2EFFF;
--popover: #15142A;
--popover-foreground: #F2EFFF;
--primary: #E91E63;
--primary-foreground: #FFFFFF;
--secondary: #1A1935;
--secondary-foreground: #C8C4E0;
--muted: #15142A;
--muted-foreground: #7B78A0;
--accent: #1F1E38;
--accent-foreground: #E8E5FF;
--destructive: #FF1744;
--border: #232040;
--input: #1A1935;
--ring: #E91E63;
--chart-1: #E91E63;
--chart-2: #9C27B0;
--chart-3: #D4A843;
--chart-4: #40C4FF;
--chart-5: #00E676;
--sidebar: #0A091A;
--sidebar-foreground: #F2EFFF;
--sidebar-primary: #E91E63;
--sidebar-primary-foreground: #FFFFFF;
--sidebar-accent: #1A1935;
--sidebar-accent-foreground: #F2EFFF;
--sidebar-border: #232040;
--sidebar-ring: #E91E63;
}

View File

@@ -0,0 +1,108 @@
/* ─── Glass morphism ─── */
.glass {
background: rgba(18, 17, 31, 0.7);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border: 1px solid rgba(35, 32, 64, 0.6);
}
.glass-strong {
background: rgba(18, 17, 31, 0.85);
backdrop-filter: blur(40px);
-webkit-backdrop-filter: blur(40px);
border: 1px solid rgba(35, 32, 64, 0.8);
}
/* ─── Glow effects ─── */
.glow-rose {
box-shadow: 0 0 20px rgba(233, 30, 99, 0.15), 0 0 60px rgba(233, 30, 99, 0.05);
}
.glow-rose-strong {
box-shadow: 0 0 30px rgba(233, 30, 99, 0.25), 0 0 80px rgba(233, 30, 99, 0.1);
}
.glow-purple {
box-shadow: 0 0 20px rgba(156, 39, 176, 0.15), 0 0 60px rgba(156, 39, 176, 0.05);
}
.glow-gold {
box-shadow: 0 0 20px rgba(212, 168, 67, 0.15), 0 0 60px rgba(212, 168, 67, 0.05);
}
/* ─── Gradient border ─── */
.gradient-border {
position: relative;
}
.gradient-border::before {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
padding: 1px;
background: linear-gradient(135deg, rgba(233, 30, 99, 0.4), rgba(156, 39, 176, 0.2), rgba(212, 168, 67, 0.3));
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
}
/* ─── Text gradients ─── */
.text-gradient-rose {
background: linear-gradient(135deg, #E91E63, #F48FB1);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.text-gradient-gold {
background: linear-gradient(135deg, #D4A843, #F5D58E);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.text-gradient-mixed {
background: linear-gradient(135deg, #E91E63, #9C27B0, #D4A843);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* ─── Background meshes ─── */
.bg-mesh {
background:
radial-gradient(ellipse 80% 50% at 20% 40%, rgba(233, 30, 99, 0.08), transparent),
radial-gradient(ellipse 60% 40% at 80% 20%, rgba(156, 39, 176, 0.06), transparent),
radial-gradient(ellipse 50% 60% at 50% 80%, rgba(212, 168, 67, 0.04), transparent);
}
.bg-mesh-strong {
background:
radial-gradient(ellipse 80% 50% at 20% 40%, rgba(233, 30, 99, 0.12), transparent),
radial-gradient(ellipse 60% 40% at 80% 20%, rgba(156, 39, 176, 0.10), transparent),
radial-gradient(ellipse 50% 60% at 50% 80%, rgba(212, 168, 67, 0.06), transparent);
}
/* ─── Scrollbar ─── */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #232040;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #2E2B55;
}

View File

@@ -0,0 +1,20 @@
export interface Championship {
id: string;
title: string;
subtitle: string | null;
description: string | null;
location: string | null;
venue: 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;
status: "draft" | "open" | "closed" | "completed";
source: string;
image_url: string | null;
accent_color: string | null;
created_at: string;
updated_at: string;
}

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