Compare commits
19 Commits
789d2bf0a6
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4f0a05707 | ||
|
|
96e02bf64a | ||
|
|
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)
|
||||
@@ -0,0 +1,86 @@
|
||||
"""add organizations table and championship ownership
|
||||
|
||||
Revision ID: a1b2c3d4e5f6
|
||||
Revises: 43d947192af5
|
||||
Create Date: 2026-03-01 18:00:00.000000
|
||||
|
||||
"""
|
||||
import uuid
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'a1b2c3d4e5f6'
|
||||
down_revision: Union[str, None] = '43d947192af5'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Phase 1: Create organizations table
|
||||
op.create_table('organizations',
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('user_id', sa.Uuid(), nullable=False),
|
||||
sa.Column('name', sa.String(length=255), nullable=False),
|
||||
sa.Column('instagram', sa.String(length=100), nullable=True),
|
||||
sa.Column('email', sa.String(length=255), nullable=True),
|
||||
sa.Column('city', sa.String(length=100), nullable=True),
|
||||
sa.Column('logo_url', sa.String(length=500), nullable=True),
|
||||
sa.Column('verified', sa.Boolean(), nullable=False),
|
||||
sa.Column('status', sa.String(length=20), nullable=False),
|
||||
sa.Column('block_reason', sa.String(length=500), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('user_id'),
|
||||
)
|
||||
|
||||
# Phase 1b: Add org_id to championships
|
||||
with op.batch_alter_table('championships') as batch_op:
|
||||
batch_op.add_column(sa.Column('org_id', sa.Uuid(), nullable=True))
|
||||
batch_op.create_foreign_key('fk_championships_org_id', 'organizations', ['org_id'], ['id'], ondelete='SET NULL')
|
||||
|
||||
# Phase 2: Migrate existing organizer data to organizations table
|
||||
conn = op.get_bind()
|
||||
rows = conn.execute(
|
||||
sa.text("SELECT id, organization_name FROM users WHERE role = 'organizer' AND organization_name IS NOT NULL")
|
||||
).fetchall()
|
||||
for row in rows:
|
||||
org_id = str(uuid.uuid4())
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"INSERT INTO organizations (id, user_id, name, verified, status, created_at, updated_at) "
|
||||
"VALUES (:id, :user_id, :name, 0, 'active', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)"
|
||||
),
|
||||
{"id": org_id, "user_id": str(row[0]), "name": row[1]}
|
||||
)
|
||||
|
||||
# Phase 3: Remove organization_name from users (keep instagram_handle)
|
||||
with op.batch_alter_table('users') as batch_op:
|
||||
batch_op.drop_column('organization_name')
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Re-add organization_name to users
|
||||
with op.batch_alter_table('users') as batch_op:
|
||||
batch_op.add_column(sa.Column('organization_name', sa.String(length=255), nullable=True))
|
||||
|
||||
# Migrate data back from organizations to users
|
||||
conn = op.get_bind()
|
||||
orgs = conn.execute(sa.text("SELECT user_id, name FROM organizations")).fetchall()
|
||||
for org in orgs:
|
||||
conn.execute(
|
||||
sa.text("UPDATE users SET organization_name = :name WHERE id = :user_id"),
|
||||
{"name": org[1], "user_id": str(org[0])}
|
||||
)
|
||||
|
||||
# Remove org_id from championships
|
||||
with op.batch_alter_table('championships') as batch_op:
|
||||
batch_op.drop_constraint('fk_championships_org_id', type_='foreignkey')
|
||||
batch_op.drop_column('org_id')
|
||||
|
||||
op.drop_table('organizations')
|
||||
@@ -18,7 +18,7 @@ class Settings(BaseSettings):
|
||||
|
||||
EXPO_ACCESS_TOKEN: str = ""
|
||||
|
||||
CORS_ORIGINS: str = "http://localhost:8081,exp://"
|
||||
CORS_ORIGINS: str = "http://localhost:3000,http://localhost:8081,exp://"
|
||||
|
||||
@property
|
||||
def cors_origins_list(self) -> list[str]:
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
from app.crud import crud_user, crud_championship, crud_registration, crud_participant
|
||||
from app.crud import crud_user, crud_organization, crud_championship, crud_registration, crud_participant
|
||||
|
||||
__all__ = ["crud_user", "crud_championship", "crud_registration", "crud_participant"]
|
||||
__all__ = ["crud_user", "crud_organization", "crud_championship", "crud_registration", "crud_participant"]
|
||||
|
||||
@@ -3,6 +3,7 @@ import uuid
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models.championship import Championship
|
||||
from app.schemas.championship import ChampionshipCreate, ChampionshipUpdate
|
||||
@@ -16,7 +17,9 @@ def _serialize(value) -> str | None:
|
||||
|
||||
async def get(db: AsyncSession, champ_id: str | uuid.UUID) -> Championship | None:
|
||||
cid = champ_id if isinstance(champ_id, uuid.UUID) else uuid.UUID(str(champ_id))
|
||||
result = await db.execute(select(Championship).where(Championship.id == cid))
|
||||
result = await db.execute(
|
||||
select(Championship).where(Championship.id == cid).options(selectinload(Championship.organization))
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
@@ -26,7 +29,7 @@ async def list_all(
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
) -> list[Championship]:
|
||||
q = select(Championship).order_by(Championship.event_date.asc())
|
||||
q = select(Championship).order_by(Championship.event_date.asc()).options(selectinload(Championship.organization))
|
||||
if status:
|
||||
q = q.where(Championship.status == status)
|
||||
q = q.offset(skip).limit(limit)
|
||||
@@ -34,14 +37,16 @@ async def list_all(
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def create(db: AsyncSession, data: ChampionshipCreate) -> Championship:
|
||||
async def create(db: AsyncSession, data: ChampionshipCreate, org_id: uuid.UUID | None = None) -> Championship:
|
||||
payload = data.model_dump(exclude={"judges", "categories"})
|
||||
payload["judges"] = _serialize(data.judges)
|
||||
payload["categories"] = _serialize(data.categories)
|
||||
if org_id:
|
||||
payload["org_id"] = org_id
|
||||
champ = Championship(**payload)
|
||||
db.add(champ)
|
||||
await db.commit()
|
||||
await db.refresh(champ)
|
||||
await db.refresh(champ, attribute_names=["organization"])
|
||||
return champ
|
||||
|
||||
|
||||
|
||||
11
backend/app/crud/crud_organization.py
Normal file
@@ -0,0 +1,11 @@
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.organization import Organization
|
||||
|
||||
|
||||
async def get_by_user_id(db: AsyncSession, user_id: uuid.UUID) -> Organization | None:
|
||||
result = await db.execute(select(Organization).where(Organization.user_id == user_id))
|
||||
return result.scalar_one_or_none()
|
||||
@@ -2,7 +2,9 @@ import uuid
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models.organization import Organization
|
||||
from app.models.user import User
|
||||
from app.schemas.user import UserRegister, UserUpdate
|
||||
from app.services.auth_service import hash_password
|
||||
@@ -10,12 +12,16 @@ from app.services.auth_service import hash_password
|
||||
|
||||
async def get_by_id(db: AsyncSession, user_id: str | uuid.UUID) -> User | None:
|
||||
uid = user_id if isinstance(user_id, uuid.UUID) else uuid.UUID(str(user_id))
|
||||
result = await db.execute(select(User).where(User.id == uid))
|
||||
result = await db.execute(
|
||||
select(User).where(User.id == uid).options(selectinload(User.organization))
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_by_email(db: AsyncSession, email: str) -> User | None:
|
||||
result = await db.execute(select(User).where(User.email == email.lower()))
|
||||
result = await db.execute(
|
||||
select(User).where(User.email == email.lower()).options(selectinload(User.organization))
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
@@ -25,23 +31,40 @@ async def create(db: AsyncSession, data: UserRegister) -> User:
|
||||
hashed_password=hash_password(data.password),
|
||||
full_name=data.full_name,
|
||||
phone=data.phone,
|
||||
role=data.requested_role,
|
||||
organization_name=data.organization_name,
|
||||
instagram_handle=data.instagram_handle,
|
||||
role=data.requested_role,
|
||||
# Members are auto-approved; organizers require admin review
|
||||
status="approved" if data.requested_role == "member" else "pending",
|
||||
)
|
||||
db.add(user)
|
||||
await db.flush() # get user.id for the FK
|
||||
|
||||
# Create Organization row for organizer registrations
|
||||
if data.requested_role == "organizer" and data.organization_name:
|
||||
org = Organization(
|
||||
user_id=user.id,
|
||||
name=data.organization_name,
|
||||
status="pending",
|
||||
verified=False,
|
||||
)
|
||||
db.add(org)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
await db.refresh(user, attribute_names=["organization"])
|
||||
return user
|
||||
|
||||
|
||||
async def update(db: AsyncSession, user: User, data: UserUpdate) -> User:
|
||||
for field, value in data.model_dump(exclude_none=True).items():
|
||||
user_fields = data.model_dump(exclude_none=True, exclude={"organization_name"})
|
||||
for field, value in user_fields.items():
|
||||
setattr(user, field, value)
|
||||
|
||||
# Route org field updates to Organization table
|
||||
if data.organization_name is not None and user.organization:
|
||||
user.organization.name = data.organization_name
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
await db.refresh(user, attribute_names=["organization"])
|
||||
return user
|
||||
|
||||
|
||||
@@ -53,5 +76,7 @@ async def set_status(db: AsyncSession, user: User, status: str) -> User:
|
||||
|
||||
|
||||
async def list_all(db: AsyncSession, skip: int = 0, limit: int = 100) -> list[User]:
|
||||
result = await db.execute(select(User).offset(skip).limit(limit))
|
||||
result = await db.execute(
|
||||
select(User).options(selectinload(User.organization)).offset(skip).limit(limit)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
@@ -7,19 +5,7 @@ from app.config import settings
|
||||
from app.routers import auth, championships, registrations, participant_lists, users
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# Start Instagram sync scheduler if configured
|
||||
if settings.INSTAGRAM_USER_ID and settings.INSTAGRAM_ACCESS_TOKEN:
|
||||
from app.services.instagram_service import start_scheduler
|
||||
scheduler = start_scheduler()
|
||||
yield
|
||||
scheduler.shutdown()
|
||||
else:
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(title="Pole Dance Championships API", version="1.0.0", lifespan=lifespan)
|
||||
app = FastAPI(title="Pole Dance Championships API", version="1.0.0")
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from app.models.user import User, RefreshToken
|
||||
from app.models.organization import Organization
|
||||
from app.models.championship import Championship
|
||||
from app.models.registration import Registration
|
||||
from app.models.participant import ParticipantList
|
||||
@@ -7,6 +8,7 @@ from app.models.notification import NotificationLog
|
||||
__all__ = [
|
||||
"User",
|
||||
"RefreshToken",
|
||||
"Organization",
|
||||
"Championship",
|
||||
"Registration",
|
||||
"ParticipantList",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, Float, Integer, String, Text, Uuid, func
|
||||
from sqlalchemy import DateTime, Float, ForeignKey, Integer, String, Text, Uuid, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
@@ -11,6 +11,7 @@ class Championship(Base):
|
||||
__tablename__ = "championships"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
org_id: Mapped[uuid.UUID | None] = mapped_column(Uuid(as_uuid=True), ForeignKey("organizations.id", ondelete="SET NULL"))
|
||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
location: Mapped[str | None] = mapped_column(String(500))
|
||||
@@ -18,12 +19,11 @@ class Championship(Base):
|
||||
registration_open_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
registration_close_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
# Extended fields
|
||||
form_url: Mapped[str | None] = mapped_column(String(2048))
|
||||
entry_fee: Mapped[float | None] = mapped_column(Float)
|
||||
video_max_duration: Mapped[int | None] = mapped_column(Integer) # seconds
|
||||
judges: Mapped[str | None] = mapped_column(Text) # JSON string: [{name, bio, instagram}]
|
||||
categories: Mapped[str | None] = mapped_column(Text) # JSON string: [str]
|
||||
categories: Mapped[str | None] = mapped_column(Text) # JSON string: ["cat1", "cat2"]
|
||||
|
||||
# Status: 'draft' | 'open' | 'closed' | 'completed'
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False, default="draft")
|
||||
@@ -38,5 +38,6 @@ class Championship(Base):
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
organization: Mapped["Organization | None"] = relationship(back_populates="championships") # type: ignore[name-defined]
|
||||
registrations: Mapped[list["Registration"]] = relationship(back_populates="championship", cascade="all, delete-orphan") # type: ignore[name-defined]
|
||||
participant_list: Mapped["ParticipantList | None"] = relationship(back_populates="championship", uselist=False, cascade="all, delete-orphan") # type: ignore[name-defined]
|
||||
|
||||
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]
|
||||
@@ -15,8 +15,6 @@ class User(Base):
|
||||
hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
full_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
phone: Mapped[str | None] = mapped_column(String(50))
|
||||
# Organizer-specific fields
|
||||
organization_name: Mapped[str | None] = mapped_column(String(255))
|
||||
instagram_handle: Mapped[str | None] = mapped_column(String(100))
|
||||
role: Mapped[str] = mapped_column(String(20), nullable=False, default="member")
|
||||
# 'pending' | 'approved' | 'rejected'
|
||||
@@ -30,6 +28,7 @@ class User(Base):
|
||||
refresh_tokens: Mapped[list["RefreshToken"]] = relationship(back_populates="user", cascade="all, delete-orphan")
|
||||
registrations: Mapped[list["Registration"]] = relationship(back_populates="user") # type: ignore[name-defined]
|
||||
notification_logs: Mapped[list["NotificationLog"]] = relationship(back_populates="user") # type: ignore[name-defined]
|
||||
organization: Mapped["Organization | None"] = relationship(back_populates="user", uselist=False) # type: ignore[name-defined]
|
||||
|
||||
|
||||
class RefreshToken(Base):
|
||||
|
||||
@@ -38,10 +38,11 @@ async def get_championship(
|
||||
@router.post("", response_model=ChampionshipOut, status_code=status.HTTP_201_CREATED)
|
||||
async def create_championship(
|
||||
data: ChampionshipCreate,
|
||||
_user: User = Depends(get_organizer),
|
||||
user: User = Depends(get_organizer),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return await crud_championship.create(db, data)
|
||||
org_id = user.organization.id if user.organization else None
|
||||
return await crud_championship.create(db, data, org_id=org_id)
|
||||
|
||||
|
||||
@router.patch("/{champ_id}", response_model=ChampionshipOut)
|
||||
|
||||
7
backend/app/schemas/auth/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from app.schemas.auth.token_pair import TokenPair
|
||||
from app.schemas.auth.refresh_request import RefreshRequest
|
||||
from app.schemas.auth.token_refreshed import TokenRefreshed
|
||||
from app.schemas.auth.logout_request import LogoutRequest
|
||||
from app.schemas.auth.register_response import RegisterResponse
|
||||
|
||||
__all__ = ["TokenPair", "RefreshRequest", "TokenRefreshed", "LogoutRequest", "RegisterResponse"]
|
||||
5
backend/app/schemas/auth/logout_request.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class LogoutRequest(BaseModel):
|
||||
refresh_token: str
|
||||
5
backend/app/schemas/auth/refresh_request.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class RefreshRequest(BaseModel):
|
||||
refresh_token: str
|
||||
@@ -3,27 +3,6 @@ from pydantic import BaseModel
|
||||
from app.schemas.user import UserOut
|
||||
|
||||
|
||||
class TokenPair(BaseModel):
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str = "bearer"
|
||||
user: UserOut
|
||||
|
||||
|
||||
class RefreshRequest(BaseModel):
|
||||
refresh_token: str
|
||||
|
||||
|
||||
class TokenRefreshed(BaseModel):
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str = "bearer"
|
||||
|
||||
|
||||
class LogoutRequest(BaseModel):
|
||||
refresh_token: str
|
||||
|
||||
|
||||
class RegisterResponse(BaseModel):
|
||||
"""
|
||||
Returned after registration.
|
||||
10
backend/app/schemas/auth/token_pair.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.schemas.user import UserOut
|
||||
|
||||
|
||||
class TokenPair(BaseModel):
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str = "bearer"
|
||||
user: UserOut
|
||||
7
backend/app/schemas/auth/token_refreshed.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class TokenRefreshed(BaseModel):
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str = "bearer"
|
||||
5
backend/app/schemas/championship/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from app.schemas.championship.create import ChampionshipCreate
|
||||
from app.schemas.championship.update import ChampionshipUpdate
|
||||
from app.schemas.championship.out import ChampionshipOut
|
||||
|
||||
__all__ = ["ChampionshipCreate", "ChampionshipUpdate", "ChampionshipOut"]
|
||||
19
backend/app/schemas/championship/create.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class ChampionshipCreate(BaseModel):
|
||||
title: str
|
||||
description: str | None = None
|
||||
location: str | None = None
|
||||
event_date: datetime | None = None
|
||||
registration_open_at: datetime | None = None
|
||||
registration_close_at: datetime | None = None
|
||||
form_url: str | None = None
|
||||
entry_fee: float | None = None
|
||||
video_max_duration: int | None = None
|
||||
judges: list[dict] | None = None # [{name, bio, instagram}]
|
||||
categories: list[str] | None = None
|
||||
status: str = "draft"
|
||||
image_url: str | None = None
|
||||
@@ -4,43 +4,14 @@ from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, model_validator
|
||||
|
||||
|
||||
class ChampionshipCreate(BaseModel):
|
||||
title: str
|
||||
description: str | None = None
|
||||
location: str | None = None
|
||||
event_date: datetime | None = None
|
||||
registration_open_at: datetime | None = None
|
||||
registration_close_at: datetime | None = None
|
||||
form_url: str | None = None
|
||||
entry_fee: float | None = None
|
||||
video_max_duration: int | None = None
|
||||
judges: list[dict] | None = None # [{name, bio, instagram}]
|
||||
categories: list[str] | None = None
|
||||
status: str = "draft"
|
||||
image_url: str | None = None
|
||||
|
||||
|
||||
class ChampionshipUpdate(BaseModel):
|
||||
title: str | None = None
|
||||
description: str | None = None
|
||||
location: str | None = None
|
||||
event_date: datetime | None = None
|
||||
registration_open_at: datetime | None = None
|
||||
registration_close_at: datetime | None = None
|
||||
form_url: str | None = None
|
||||
entry_fee: float | None = None
|
||||
video_max_duration: int | None = None
|
||||
judges: list[dict] | None = None
|
||||
categories: list[str] | None = None
|
||||
status: str | None = None
|
||||
image_url: str | None = None
|
||||
from app.schemas.organization import OrganizationBrief
|
||||
|
||||
|
||||
class ChampionshipOut(BaseModel):
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
id: uuid.UUID
|
||||
org_id: uuid.UUID | None = None
|
||||
title: str
|
||||
description: str | None
|
||||
location: str | None
|
||||
@@ -56,6 +27,7 @@ class ChampionshipOut(BaseModel):
|
||||
source: str
|
||||
instagram_media_id: str | None
|
||||
image_url: str | None
|
||||
organization: OrganizationBrief | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
19
backend/app/schemas/championship/update.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class ChampionshipUpdate(BaseModel):
|
||||
title: str | None = None
|
||||
description: str | None = None
|
||||
location: str | None = None
|
||||
event_date: datetime | None = None
|
||||
registration_open_at: datetime | None = None
|
||||
registration_close_at: datetime | None = None
|
||||
form_url: str | None = None
|
||||
entry_fee: float | None = None
|
||||
video_max_duration: int | None = None
|
||||
judges: list[dict] | None = None
|
||||
categories: list[str] | None = None
|
||||
status: str | None = None
|
||||
image_url: str | None = None
|
||||
4
backend/app/schemas/organization/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from app.schemas.organization.out import OrganizationOut
|
||||
from app.schemas.organization.brief import OrganizationBrief
|
||||
|
||||
__all__ = ["OrganizationOut", "OrganizationBrief"]
|
||||
13
backend/app/schemas/organization/brief.py
Normal file
@@ -0,0 +1,13 @@
|
||||
import uuid
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class OrganizationBrief(BaseModel):
|
||||
"""Minimal org info for embedding in ChampionshipOut."""
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
id: uuid.UUID
|
||||
name: str
|
||||
instagram: str | None
|
||||
logo_url: str | None
|
||||
16
backend/app/schemas/organization/out.py
Normal file
@@ -0,0 +1,16 @@
|
||||
import uuid
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class OrganizationOut(BaseModel):
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
id: uuid.UUID
|
||||
name: str
|
||||
instagram: str | None
|
||||
email: str | None
|
||||
city: str | None
|
||||
logo_url: str | None
|
||||
verified: bool
|
||||
status: str
|
||||
4
backend/app/schemas/participant/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from app.schemas.participant.out import ParticipantListOut
|
||||
from app.schemas.participant.publish import ParticipantListPublish
|
||||
|
||||
__all__ = ["ParticipantListOut", "ParticipantListPublish"]
|
||||
@@ -13,7 +13,3 @@ class ParticipantListOut(BaseModel):
|
||||
published_at: datetime | None
|
||||
notes: str | None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class ParticipantListPublish(BaseModel):
|
||||
notes: str | None = None
|
||||
5
backend/app/schemas/participant/publish.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class ParticipantListPublish(BaseModel):
|
||||
notes: str | None = None
|
||||
@@ -1,57 +0,0 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, model_validator
|
||||
|
||||
from app.schemas.user import UserOut
|
||||
|
||||
|
||||
class RegistrationCreate(BaseModel):
|
||||
championship_id: uuid.UUID
|
||||
category: str | None = None
|
||||
level: str | None = None
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class RegistrationUpdate(BaseModel):
|
||||
status: str | None = None
|
||||
video_url: str | None = None
|
||||
category: str | None = None
|
||||
level: str | None = None
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class RegistrationOut(BaseModel):
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
id: uuid.UUID
|
||||
championship_id: uuid.UUID
|
||||
user_id: uuid.UUID
|
||||
category: str | None
|
||||
level: str | None
|
||||
notes: str | None
|
||||
status: str
|
||||
video_url: str | None
|
||||
submitted_at: datetime
|
||||
decided_at: datetime | None
|
||||
|
||||
|
||||
class RegistrationListItem(RegistrationOut):
|
||||
championship_title: str | None = None
|
||||
championship_event_date: datetime | None = None
|
||||
championship_location: str | None = None
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def extract_championship(cls, data: Any) -> Any:
|
||||
if hasattr(data, "championship") and data.championship:
|
||||
champ = data.championship
|
||||
data.__dict__["championship_title"] = champ.title
|
||||
data.__dict__["championship_event_date"] = champ.event_date
|
||||
data.__dict__["championship_location"] = champ.location
|
||||
return data
|
||||
|
||||
|
||||
class RegistrationWithUser(RegistrationOut):
|
||||
user: UserOut
|
||||
13
backend/app/schemas/registration/__init__.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from app.schemas.registration.create import RegistrationCreate
|
||||
from app.schemas.registration.update import RegistrationUpdate
|
||||
from app.schemas.registration.out import RegistrationOut
|
||||
from app.schemas.registration.list_item import RegistrationListItem
|
||||
from app.schemas.registration.with_user import RegistrationWithUser
|
||||
|
||||
__all__ = [
|
||||
"RegistrationCreate",
|
||||
"RegistrationUpdate",
|
||||
"RegistrationOut",
|
||||
"RegistrationListItem",
|
||||
"RegistrationWithUser",
|
||||
]
|
||||
10
backend/app/schemas/registration/create.py
Normal file
@@ -0,0 +1,10 @@
|
||||
import uuid
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class RegistrationCreate(BaseModel):
|
||||
championship_id: uuid.UUID
|
||||
category: str | None = None
|
||||
level: str | None = None
|
||||
notes: str | None = None
|
||||
22
backend/app/schemas/registration/list_item.py
Normal file
@@ -0,0 +1,22 @@
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import model_validator
|
||||
|
||||
from app.schemas.registration.out import RegistrationOut
|
||||
|
||||
|
||||
class RegistrationListItem(RegistrationOut):
|
||||
championship_title: str | None = None
|
||||
championship_event_date: datetime | None = None
|
||||
championship_location: str | None = None
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def extract_championship(cls, data: Any) -> Any:
|
||||
if hasattr(data, "championship") and data.championship:
|
||||
champ = data.championship
|
||||
data.__dict__["championship_title"] = champ.title
|
||||
data.__dict__["championship_event_date"] = champ.event_date
|
||||
data.__dict__["championship_location"] = champ.location
|
||||
return data
|
||||
19
backend/app/schemas/registration/out.py
Normal file
@@ -0,0 +1,19 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class RegistrationOut(BaseModel):
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
id: uuid.UUID
|
||||
championship_id: uuid.UUID
|
||||
user_id: uuid.UUID
|
||||
category: str | None
|
||||
level: str | None
|
||||
notes: str | None
|
||||
status: str
|
||||
video_url: str | None
|
||||
submitted_at: datetime
|
||||
decided_at: datetime | None
|
||||
9
backend/app/schemas/registration/update.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class RegistrationUpdate(BaseModel):
|
||||
status: str | None = None
|
||||
video_url: str | None = None
|
||||
category: str | None = None
|
||||
level: str | None = None
|
||||
notes: str | None = None
|
||||
6
backend/app/schemas/registration/with_user.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from app.schemas.registration.out import RegistrationOut
|
||||
from app.schemas.user import UserOut
|
||||
|
||||
|
||||
class RegistrationWithUser(RegistrationOut):
|
||||
user: UserOut
|
||||
6
backend/app/schemas/user/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from app.schemas.user.register import UserRegister
|
||||
from app.schemas.user.login import UserLogin
|
||||
from app.schemas.user.out import UserOut
|
||||
from app.schemas.user.update import UserUpdate
|
||||
|
||||
__all__ = ["UserRegister", "UserLogin", "UserOut", "UserUpdate"]
|
||||
6
backend/app/schemas/user/login.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from pydantic import BaseModel, EmailStr
|
||||
|
||||
|
||||
class UserLogin(BaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
21
backend/app/schemas/user/out.py
Normal file
@@ -0,0 +1,21 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.schemas.organization import OrganizationOut
|
||||
|
||||
|
||||
class UserOut(BaseModel):
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
id: uuid.UUID
|
||||
email: str
|
||||
full_name: str
|
||||
phone: str | None
|
||||
role: str
|
||||
status: str
|
||||
instagram_handle: str | None
|
||||
organization: OrganizationOut | None = None
|
||||
expo_push_token: str | None
|
||||
created_at: datetime
|
||||
@@ -1,5 +1,3 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, EmailStr, field_validator
|
||||
@@ -22,31 +20,3 @@ class UserRegister(BaseModel):
|
||||
if info.data.get("requested_role") == "organizer" and not v:
|
||||
raise ValueError("Organization name is required for organizer registration")
|
||||
return v
|
||||
|
||||
|
||||
class UserLogin(BaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
|
||||
|
||||
class UserOut(BaseModel):
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
id: uuid.UUID
|
||||
email: str
|
||||
full_name: str
|
||||
phone: str | None
|
||||
role: str
|
||||
status: str
|
||||
organization_name: str | None
|
||||
instagram_handle: str | None
|
||||
expo_push_token: str | None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
full_name: str | None = None
|
||||
phone: str | None = None
|
||||
organization_name: str | None = None
|
||||
instagram_handle: str | None = None
|
||||
expo_push_token: str | None = None
|
||||
10
backend/app/schemas/user/update.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
full_name: str | None = None
|
||||
phone: str | None = None
|
||||
instagram_handle: str | None = None
|
||||
expo_push_token: str | None = None
|
||||
# Org fields — routed to Organization table in CRUD
|
||||
organization_name: str | None = None
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Seed script — creates test users and one championship.
|
||||
"""Seed script — creates test users, organization, and championships.
|
||||
Run from backend/: .venv/Scripts/python seed.py
|
||||
"""
|
||||
import asyncio
|
||||
@@ -7,6 +7,7 @@ from datetime import UTC, datetime, timedelta
|
||||
|
||||
from app.database import AsyncSessionLocal
|
||||
from app.models.championship import Championship
|
||||
from app.models.organization import Organization
|
||||
from app.models.user import User
|
||||
from app.services.auth_service import hash_password
|
||||
from sqlalchemy import select
|
||||
@@ -29,6 +30,7 @@ async def seed():
|
||||
"password": "Org1234",
|
||||
"role": "organizer",
|
||||
"status": "approved",
|
||||
"instagram_handle": "@ekaterina_pole",
|
||||
},
|
||||
{
|
||||
"email": "member@pole.dev",
|
||||
@@ -36,6 +38,7 @@ async def seed():
|
||||
"password": "Member1234",
|
||||
"role": "member",
|
||||
"status": "approved",
|
||||
"instagram_handle": "@anna_petrova",
|
||||
},
|
||||
{
|
||||
"email": "pending@pole.dev",
|
||||
@@ -57,11 +60,11 @@ async def seed():
|
||||
full_name=ud["full_name"],
|
||||
role=ud["role"],
|
||||
status=ud["status"],
|
||||
instagram_handle=ud.get("instagram_handle"),
|
||||
)
|
||||
db.add(user)
|
||||
print(f" Created user: {ud['email']}")
|
||||
else:
|
||||
# Update role/status if needed
|
||||
user.role = ud["role"]
|
||||
user.status = ud["status"]
|
||||
user.hashed_password = hash_password(ud["password"])
|
||||
@@ -70,6 +73,27 @@ async def seed():
|
||||
|
||||
await db.flush()
|
||||
|
||||
# ── Organization ──────────────────────────────────────────────────────
|
||||
organizer = created_users["organizer@pole.dev"]
|
||||
result = await db.execute(select(Organization).where(Organization.user_id == organizer.id))
|
||||
org = result.scalar_one_or_none()
|
||||
if org is None:
|
||||
org = Organization(
|
||||
user_id=organizer.id,
|
||||
name="Pole Studio Minsk",
|
||||
instagram="@polestudio_minsk",
|
||||
email="organizer@pole.dev",
|
||||
city="Minsk",
|
||||
verified=True,
|
||||
status="active",
|
||||
)
|
||||
db.add(org)
|
||||
print(f" Created organization: {org.name}")
|
||||
else:
|
||||
print(f" Organization already exists: {org.name}")
|
||||
|
||||
await db.flush()
|
||||
|
||||
# ── Championships ──────────────────────────────────────────────────────
|
||||
championships_data = [
|
||||
{
|
||||
@@ -115,11 +139,12 @@ async def seed():
|
||||
)
|
||||
champ = result.scalar_one_or_none()
|
||||
if champ is None:
|
||||
champ = Championship(**cd)
|
||||
champ = Championship(org_id=org.id, **cd)
|
||||
db.add(champ)
|
||||
print(f" Created championship: {cd['title']}")
|
||||
else:
|
||||
print(f" Championship already exists: {cd['title']}")
|
||||
champ.org_id = org.id
|
||||
print(f" Updated championship: {cd['title']}")
|
||||
|
||||
await db.commit()
|
||||
print("\nSeed complete!")
|
||||
|
||||
41
mobile/.gitignore
vendored
@@ -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>
|
||||
);
|
||||
}
|
||||
128
web/src/app/(app)/championships/[id]/page.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
"use client";
|
||||
|
||||
import { use } from "react";
|
||||
import { useChampionship } from "@/hooks/useChampionships";
|
||||
import { useMyRegistrations } from "@/hooks/useRegistrations";
|
||||
import { useRegisterForChampionship } from "@/hooks/useRegistrations";
|
||||
import { RegistrationTimeline } from "@/components/registrations/RegistrationTimeline";
|
||||
import { StatusBadge } from "@/components/shared/StatusBadge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { MapPin, Calendar, CreditCard, Film, ExternalLink } from "lucide-react";
|
||||
|
||||
export default function ChampionshipDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = use(params);
|
||||
const { data: championship, isLoading, error } = useChampionship(id);
|
||||
const { data: myRegs } = useMyRegistrations();
|
||||
const registerMutation = useRegisterForChampionship(id);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center py-20">
|
||||
<div className="relative h-8 w-8">
|
||||
<div className="absolute inset-0 rounded-full border-2 border-rose-accent/20" />
|
||||
<div className="absolute inset-0 rounded-full border-2 border-transparent border-t-rose-accent animate-spin" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !championship) return <p className="text-center text-destructive py-20">Championship not found.</p>;
|
||||
|
||||
const myReg = myRegs?.find((r) => r.championship_id === id);
|
||||
const canRegister = championship.status === "open" && !myReg;
|
||||
|
||||
const eventDate = championship.event_date
|
||||
? new Date(championship.event_date).toLocaleDateString("en-GB", { day: "numeric", month: "long", year: "numeric" })
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto space-y-6 animate-fade-in">
|
||||
{/* Header image */}
|
||||
<div className="relative overflow-hidden rounded-2xl">
|
||||
{championship.image_url ? (
|
||||
<img src={championship.image_url} alt={championship.title} className="w-full h-56 object-cover" />
|
||||
) : (
|
||||
<div className="w-full h-56 bg-gradient-to-br from-rose-accent/20 via-purple-accent/15 to-gold-accent/10 flex items-center justify-center">
|
||||
<span className="text-6xl opacity-30">💃</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-background via-background/20 to-transparent" />
|
||||
</div>
|
||||
|
||||
{/* Title + status */}
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="font-display text-3xl font-bold tracking-wide">{championship.title}</h1>
|
||||
</div>
|
||||
<StatusBadge status={championship.status} />
|
||||
</div>
|
||||
|
||||
{/* Details grid */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{championship.location && (
|
||||
<div className="flex items-center gap-2.5 rounded-xl bg-surface-elevated border border-border/30 px-4 py-3 text-sm">
|
||||
<MapPin size={16} className="text-rose-accent shrink-0" />
|
||||
<span className="text-muted-foreground">{championship.location}</span>
|
||||
</div>
|
||||
)}
|
||||
{eventDate && (
|
||||
<div className="flex items-center gap-2.5 rounded-xl bg-surface-elevated border border-border/30 px-4 py-3 text-sm">
|
||||
<Calendar size={16} className="text-gold-accent shrink-0" />
|
||||
<span className="text-muted-foreground">{eventDate}</span>
|
||||
</div>
|
||||
)}
|
||||
{championship.entry_fee != null && (
|
||||
<div className="flex items-center gap-2.5 rounded-xl bg-surface-elevated border border-border/30 px-4 py-3 text-sm">
|
||||
<CreditCard size={16} className="text-gold-accent shrink-0" />
|
||||
<span className="text-muted-foreground">Entry fee: <strong className="text-foreground">{championship.entry_fee} ₽</strong></span>
|
||||
</div>
|
||||
)}
|
||||
{championship.video_max_duration != null && (
|
||||
<div className="flex items-center gap-2.5 rounded-xl bg-surface-elevated border border-border/30 px-4 py-3 text-sm">
|
||||
<Film size={16} className="text-purple-accent shrink-0" />
|
||||
<span className="text-muted-foreground">Max video: <strong className="text-foreground">{Math.floor(championship.video_max_duration / 60)}m {championship.video_max_duration % 60}s</strong></span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{championship.description && (
|
||||
<>
|
||||
<Separator className="bg-border/30" />
|
||||
<p className="text-muted-foreground whitespace-pre-line leading-relaxed">{championship.description}</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Separator className="bg-border/30" />
|
||||
|
||||
{/* Registration section */}
|
||||
{myReg && <RegistrationTimeline registration={myReg} />}
|
||||
|
||||
{canRegister && (
|
||||
<Button
|
||||
className="w-full bg-rose-accent hover:bg-rose-accent/90 text-white font-medium tracking-wide h-11"
|
||||
disabled={registerMutation.isPending}
|
||||
onClick={() => registerMutation.mutate()}
|
||||
>
|
||||
{registerMutation.isPending ? "Registering…" : "Register for this championship"}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{championship.status !== "open" && !myReg && (
|
||||
<p className="text-center text-sm text-dim">Registration is not open.</p>
|
||||
)}
|
||||
|
||||
{championship.form_url && (
|
||||
<a
|
||||
href={championship.form_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center gap-1.5 text-sm text-rose-accent hover:text-rose-accent/80 transition-colors"
|
||||
>
|
||||
Open registration form
|
||||
<ExternalLink size={14} />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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 |