Compare commits
17 Commits
789d2bf0a6
...
0716f09e3f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0716f09e3f | ||
|
|
7e123f1a31 | ||
|
|
b5fa1fe746 | ||
|
|
95836f441d | ||
|
|
6fbd0326fa | ||
|
|
cf4104069e | ||
|
|
c948179b5b | ||
|
|
0767b87c1e | ||
|
|
5b7260de84 | ||
|
|
9fcd7c1d63 | ||
|
|
390c338b32 | ||
|
|
17277836eb | ||
|
|
e1e9de2bce | ||
|
|
d4a0daebb2 | ||
|
|
6528e89b69 | ||
|
|
d96d5560cf | ||
|
|
4c1870ebb4 |
91
CLAUDE.md
Normal 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
@@ -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)
|
||||||
@@ -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",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
20
backend/app/models/discipline.py
Normal 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
@@ -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]
|
||||||
22
backend/app/models/judge.py
Normal 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]
|
||||||
30
backend/app/models/organization.py
Normal 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]
|
||||||
22
backend/app/models/rule.py
Normal 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]
|
||||||
19
backend/app/models/style.py
Normal 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]
|
||||||
@@ -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
@@ -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
|
|
||||||
@@ -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 />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 17 KiB |
@@ -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
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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),
|
|
||||||
};
|
|
||||||
@@ -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}`),
|
|
||||||
};
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
@@ -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),
|
|
||||||
};
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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' },
|
|
||||||
});
|
|
||||||
@@ -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 },
|
|
||||||
});
|
|
||||||
@@ -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' },
|
|
||||||
});
|
|
||||||
@@ -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 },
|
|
||||||
});
|
|
||||||
@@ -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' },
|
|
||||||
});
|
|
||||||
@@ -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 },
|
|
||||||
});
|
|
||||||
@@ -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' },
|
|
||||||
});
|
|
||||||
@@ -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' },
|
|
||||||
});
|
|
||||||
@@ -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 });
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "expo/tsconfig.base",
|
|
||||||
"compilerOptions": {
|
|
||||||
"strict": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
41
web/.gitignore
vendored
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
36
web/package.json
Normal 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
@@ -0,0 +1,7 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
1
web/public/file.svg
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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 |
85
web/src/app/(app)/admin/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
135
web/src/app/(app)/championships/[id]/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
49
web/src/app/(app)/championships/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
web/src/app/(app)/layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
104
web/src/app/(app)/profile/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
49
web/src/app/(app)/registrations/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
web/src/app/(auth)/layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
90
web/src/app/(auth)/login/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
web/src/app/(auth)/pending/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
85
web/src/app/(auth)/register/page.tsx
Normal 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
|
After Width: | Height: | Size: 25 KiB |
72
web/src/app/globals.css
Normal 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
@@ -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
@@ -0,0 +1,5 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
redirect("/championships");
|
||||||
|
}
|
||||||
42
web/src/app/providers.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
82
web/src/components/admin/UserCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
web/src/components/auth/MemberFields.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
web/src/components/auth/OrganizerFields.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
70
web/src/components/championships/ChampionshipCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
153
web/src/components/layout/Navbar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
62
web/src/components/registrations/RegistrationCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
67
web/src/components/registrations/RegistrationTimeline.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
web/src/components/shared/StatusBadge.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
109
web/src/components/ui/avatar.tsx
Normal 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,
|
||||||
|
}
|
||||||
48
web/src/components/ui/badge.tsx
Normal 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 }
|
||||||
64
web/src/components/ui/button.tsx
Normal 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 }
|
||||||
92
web/src/components/ui/card.tsx
Normal 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,
|
||||||
|
}
|
||||||
257
web/src/components/ui/dropdown-menu.tsx
Normal 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,
|
||||||
|
}
|
||||||
21
web/src/components/ui/input.tsx
Normal 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 }
|
||||||
24
web/src/components/ui/label.tsx
Normal 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 }
|
||||||
28
web/src/components/ui/separator.tsx
Normal 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
@@ -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 }),
|
||||||
|
}));
|
||||||
69
web/src/hooks/useAuthForms.ts
Normal 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 };
|
||||||
|
}
|
||||||
19
web/src/hooks/useChampionships.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
22
web/src/hooks/useRegistrations.ts
Normal 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
@@ -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
@@ -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),
|
||||||
|
};
|
||||||
21
web/src/lib/api/championships.ts
Normal 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
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
21
web/src/lib/api/registrations.ts
Normal 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}`),
|
||||||
|
};
|
||||||
60
web/src/lib/api/tokenStorage.ts
Normal 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
@@ -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
@@ -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
@@ -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).*)"],
|
||||||
|
};
|
||||||
62
web/src/styles/animations.css
Normal 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
@@ -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;
|
||||||
|
}
|
||||||
108
web/src/styles/utilities.css
Normal 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;
|
||||||
|
}
|
||||||
20
web/src/types/championship.ts
Normal 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;
|
||||||
|
}
|
||||||