Full app rebuild: FastAPI backend + React Native mobile with auth, championships, admin

Backend (FastAPI + SQLAlchemy + SQLite):
- JWT auth with access/refresh tokens, bcrypt password hashing
- User model with member/organizer/admin roles, auto-approve members
- Championship, Registration, ParticipantList, Notification models
- Alembic async migrations, seed data with test users
- Registration endpoint returns tokens for members, pending for organizers
- /registrations/my returns championship title/date/location via eager loading
- Admin endpoints: list users, approve/reject organizers

Mobile (React Native + Expo + TypeScript):
- Zustand auth store, Axios client with token refresh interceptor
- Role-based registration (Member vs Organizer) with contextual form labels
- Tab navigation with Ionicons, safe area headers, admin tab for admin role
- Championships list with status badges, detail screen with registration progress
- My Registrations with championship title, progress bar, and tap-to-navigate
- Admin panel with pending/all filter, approve/reject with confirmation
- Profile screen with role badge, Ionicons info rows, sign out
- Password visibility toggle (Ionicons), keyboard flow hints (returnKeyType)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dianaka123
2026-02-25 22:46:50 +03:00
parent 9eb68695e9
commit 789d2bf0a6
81 changed files with 16283 additions and 310 deletions

View File

@@ -0,0 +1,176 @@
# CLAUDE.md — DanceChamp
## What is this project?
DanceChamp is a mobile platform for **pole dance championships**. Three apps, one database:
- **Member App** (React Native / Expo) — Dancers discover championships, register, track their 10-step progress
- **Org App** (React Native / Expo) — Championship organizers create events, manage members, review videos, confirm payments
- **Admin Panel** (React + Vite, web) — Platform admin approves orgs, reviews championships from unverified orgs, manages users
## Project Structure
```
/
├── CLAUDE.md ← You are here
├── apps/
│ ├── mobile/ ← Expo app (Member + Org views, switched by role)
│ │ ├── src/
│ │ │ ├── screens/
│ │ │ │ ├── member/ ← Home, MyChamps, Search, Profile, ChampDetail, Progress
│ │ │ │ ├── org/ ← Dashboard, ChampDetail (tabbed), MemberDetail, Settings
│ │ │ │ └── auth/ ← SignIn, SignUp, Onboarding
│ │ │ ├── components/ ← Shared UI components
│ │ │ ├── navigation/ ← Tab + Stack navigators
│ │ │ ├── store/ ← Zustand stores
│ │ │ ├── lib/ ← Supabase client, helpers
│ │ │ └── theme/ ← Colors, fonts, spacing
│ │ └── app.json
│ └── admin/ ← Vite React app
│ └── src/
│ ├── pages/ ← Dashboard, Orgs, Champs, Users
│ ├── components/
│ └── lib/
├── packages/
│ └── shared/ ← Shared types, constants, validation
│ ├── types.ts ← TypeScript interfaces (User, Championship, etc.)
│ └── constants.ts ← Status enums, role enums
├── supabase/
│ ├── migrations/ ← SQL migration files
│ └── seed.sql ← Demo data
└── docs/
├── SPEC.md ← Full technical specification
├── PLAN.md ← Phase-by-phase dev plan with checkboxes
├── DATABASE.md ← Complete database schema + RLS policies
├── DESIGN-SYSTEM.md ← Colors, fonts, components, patterns
└── SCREENS.md ← Screen-by-screen reference for all 3 apps
```
## Tech Stack
| Layer | Choice | Notes |
|---|---|---|
| Mobile | React Native (Expo) | `npx create-expo-app` with TypeScript |
| Admin | React + Vite | Separate web app |
| Language | TypeScript | Everywhere |
| Navigation | React Navigation | Bottom tabs + stack |
| State | Zustand | Lightweight stores |
| Backend | Supabase | Auth, Postgres DB, Storage, Realtime, Edge Functions |
| Push | Expo Notifications | Via Supabase Edge Function triggers |
## Key Architecture Decisions
### 1. One mobile app, two views
Member and Org use the **same Expo app**. After login, the app checks `user.role` and shows the appropriate navigation:
- `role === "member"` → Member tabs (Home, My Champs, Search, Profile)
- `role === "organization"` → Org tabs (Dashboard, Settings)
### 2. Everything is scoped per-championship
Members, results, categories, rules, fees, judges — all belong to a specific championship. There is no "global members list" for an org. Each championship is self-contained.
### 3. Configurable tabs (Org)
Orgs don't fill a giant wizard. They quick-create a championship (name + date + location), then configure each section (Categories, Fees, Rules, Judges) at their own pace. Each section has a "✓ Mark as Done" button. Championship can only go live when all sections are done.
### 4. Approval flow
- **Verified orgs** → "Go Live" sets status to `live` immediately (auto-approved)
- **Unverified orgs** → "Go Live" sets status to `pending_approval` → admin must approve
### 5. Registration dates (not deadline)
Championships have: `event_date`, `reg_start`, `reg_end`. Registration close date must be before event date. No single "deadline" field.
### 6. Judges = People, Scoring = Rules
The "Judges" tab shows judge profiles (name, instagram, bio). Scoring criteria and penalties live in the "Rules" tab.
## Conventions
### Code Style
- Functional components only, no class components
- Use hooks: `useState`, `useEffect`, custom hooks for data fetching
- Zustand for global state (auth, current user, championships cache)
- Local state for UI-only state (modals, form inputs, filters)
- TypeScript strict mode
### Naming
- Files: `kebab-case.ts` / `kebab-case.tsx`
- Components: `PascalCase`
- Hooks: `useCamelCase`
- Zustand stores: `use[Name]Store`
- DB tables: `snake_case`
- DB columns: `snake_case`
### Supabase Patterns
```typescript
// Client init
import { createClient } from '@supabase/supabase-js'
const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY)
// Fetching
const { data, error } = await supabase
.from('championships')
.select('*, disciplines(*), fees(*)')
.eq('status', 'live')
// Realtime subscription
supabase.channel('registrations')
.on('postgres_changes', { event: '*', schema: 'public', table: 'registrations' }, handler)
.subscribe()
```
### Navigation Pattern
```typescript
// Member
const MemberTabs = () => (
<Tab.Navigator>
<Tab.Screen name="Home" component={HomeStack} />
<Tab.Screen name="MyChamps" component={MyChampsStack} />
<Tab.Screen name="Search" component={SearchStack} />
<Tab.Screen name="Profile" component={ProfileStack} />
</Tab.Navigator>
)
// Org
const OrgTabs = () => (
<Tab.Navigator>
<Tab.Screen name="Dashboard" component={DashboardStack} />
<Tab.Screen name="Settings" component={SettingsStack} />
</Tab.Navigator>
)
```
## Important Docs
Before coding any feature, read the relevant doc:
| Doc | When to read |
|---|---|
| `docs/SPEC.md` | Full feature spec — read first for any new feature |
| `docs/PLAN.md` | Dev plan with phases — check what's next |
| `docs/DATABASE.md` | Schema — read before any DB work |
| `docs/DESIGN-SYSTEM.md` | UI — read before any screen work |
| `docs/SCREENS.md` | Screen details — read before building specific screens |
## Quick Commands
```bash
# Start mobile app
cd apps/mobile && npx expo start
# Start admin panel
cd apps/admin && npm run dev
# Supabase local dev
npx supabase start
npx supabase db reset # Reset + re-seed
# Generate types from Supabase
npx supabase gen types typescript --local > packages/shared/database.types.ts
```
## Current Status
Prototypes completed (JSX files in `/prototypes`):
- `dance-champ-mvp.jsx` — Member app prototype
- `dance-champ-org.jsx` — Org app prototype
- `dance-champ-admin.jsx` — Admin panel prototype
These are reference implementations showing the exact UI, data structure, and flows. Use them as visual guides — don't copy the code directly (they're single-file React prototypes, not production React Native).

View File

@@ -0,0 +1,357 @@
# DanceChamp — Database Schema
## Overview
Backend: **Supabase** (PostgreSQL + Auth + Storage + Realtime)
All tables use `uuid` primary keys generated by `gen_random_uuid()`.
All tables have `created_at` and `updated_at` timestamps.
---
## Tables
### users
Extended from Supabase Auth. This is a `public.users` table that mirrors `auth.users` via trigger.
```sql
create table public.users (
id uuid primary key references auth.users(id) on delete cascade,
email text not null,
name text not null,
role text not null check (role in ('admin', 'organization', 'member')),
city text,
instagram_handle text,
experience_years integer,
disciplines text[] default '{}', -- ['Pole Exotic', 'Pole Art']
auth_provider text default 'email', -- 'email' | 'google' | 'instagram'
avatar_url text,
status text not null default 'active' check (status in ('active', 'warned', 'blocked')),
warn_reason text,
block_reason text,
created_at timestamptz default now(),
updated_at timestamptz default now()
);
```
### organizations
One-to-one with a user (role = 'organization').
```sql
create table public.organizations (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references public.users(id) on delete cascade,
name text not null,
instagram_handle text,
email text,
city text,
logo_url text,
verified boolean not null default false,
status text not null default 'pending' check (status in ('active', 'pending', 'blocked')),
block_reason text,
created_at timestamptz default now(),
updated_at timestamptz default now()
);
```
### championships
Belongs to an organization. Core entity.
```sql
create table public.championships (
id uuid primary key default gen_random_uuid(),
org_id uuid not null references public.organizations(id) on delete cascade,
name text not null,
subtitle text,
event_date text, -- "May 30, 2026" or ISO date
reg_start text, -- registration opens
reg_end text, -- registration closes (must be before event_date)
location text, -- "Minsk, Belarus"
venue text, -- "Prime Hall"
accent_color text default '#D4145A',
image_emoji text default '💃',
status text not null default 'draft' check (status in ('draft', 'pending_approval', 'live', 'completed', 'blocked')),
-- configurable sections progress
config_info boolean not null default false,
config_categories boolean not null default false,
config_fees boolean not null default false,
config_rules boolean not null default false,
config_judges boolean not null default false,
-- links
form_url text, -- Google Forms URL
rules_doc_url text, -- Rules document URL
created_at timestamptz default now(),
updated_at timestamptz default now()
);
```
### disciplines
Championship has many disciplines. Each discipline has levels.
```sql
create table public.disciplines (
id uuid primary key default gen_random_uuid(),
championship_id uuid not null references public.championships(id) on delete cascade,
name text not null, -- "Exotic Pole Dance"
levels text[] default '{}', -- ['Beginners', 'Amateur', 'Semi-Pro', 'Profi', 'Elite']
sort_order integer default 0,
created_at timestamptz default now()
);
```
### styles
Championship-level styles (not per-discipline).
```sql
create table public.styles (
id uuid primary key default gen_random_uuid(),
championship_id uuid not null references public.championships(id) on delete cascade,
name text not null, -- "Classic", "Flow", "Theater"
sort_order integer default 0,
created_at timestamptz default now()
);
```
### fees
One-to-one with championship.
```sql
create table public.fees (
id uuid primary key default gen_random_uuid(),
championship_id uuid not null unique references public.championships(id) on delete cascade,
video_selection text, -- "50 BYN / 1,500 RUB"
solo text, -- "280 BYN / 7,500 RUB"
duet text, -- "210 BYN / 5,800 RUB pp"
"group" text, -- "190 BYN / 4,500 RUB pp"
refund_note text,
created_at timestamptz default now(),
updated_at timestamptz default now()
);
```
### rules
Championship has many rules across sections.
```sql
create table public.rules (
id uuid primary key default gen_random_uuid(),
championship_id uuid not null references public.championships(id) on delete cascade,
section text not null check (section in ('general', 'costume', 'scoring', 'penalty')),
name text not null, -- rule text or criterion name
value text, -- for scoring: "10" (max), for penalty: "-2" or "DQ"
sort_order integer default 0,
created_at timestamptz default now()
);
```
### judges
Championship has many judges.
```sql
create table public.judges (
id uuid primary key default gen_random_uuid(),
championship_id uuid not null references public.championships(id) on delete cascade,
name text not null,
instagram text,
bio text,
photo_url text,
sort_order integer default 0,
created_at timestamptz default now()
);
```
### registrations
Links a member to a championship. Tracks the 10-step progress.
```sql
create table public.registrations (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references public.users(id) on delete cascade,
championship_id uuid not null references public.championships(id) on delete cascade,
discipline_id uuid references public.disciplines(id),
level text, -- "Semi-Pro"
style text, -- "Classic"
participation_type text default 'solo' check (participation_type in ('solo', 'duet', 'group')),
-- Progress steps (step 110)
step_rules_reviewed boolean default false,
step_category_selected boolean default false,
step_video_recorded boolean default false,
step_form_submitted boolean default false,
step_video_fee_paid boolean default false, -- confirmed by org
step_video_fee_receipt_url text, -- uploaded receipt
step_results text check (step_results in ('pending', 'passed', 'failed')),
step_champ_fee_paid boolean default false,
step_champ_fee_receipt_url text,
step_about_me_submitted boolean default false,
step_insurance_confirmed boolean default false,
step_insurance_doc_url text,
-- Video
video_url text,
-- Meta
current_step integer default 1,
created_at timestamptz default now(),
updated_at timestamptz default now(),
unique(user_id, championship_id)
);
```
### notifications
Push to member's in-app feed.
```sql
create table public.notifications (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references public.users(id) on delete cascade,
championship_id uuid references public.championships(id) on delete set null,
type text not null check (type in (
'category_changed', 'payment_confirmed', 'results',
'deadline_reminder', 'registration_confirmed', 'announcement',
'champ_approved', 'champ_rejected', 'org_approved', 'org_rejected'
)),
title text not null,
message text not null,
read boolean not null default false,
created_at timestamptz default now()
);
```
### activity_logs
Admin audit trail.
```sql
create table public.activity_logs (
id uuid primary key default gen_random_uuid(),
actor_id uuid references public.users(id) on delete set null,
action text not null, -- "org_approved", "user_blocked", "champ_auto_approved"
target_type text not null, -- "organization", "championship", "user"
target_id uuid,
target_name text, -- denormalized for display
details jsonb, -- extra context
created_at timestamptz default now()
);
```
---
## Relationships Diagram
```
users (1) ──── (1) organizations
│ has many
championships
┌────┼────┬────┬────┐
│ │ │ │ │
disciplines styles fees rules judges
registrations ─┘
(user + championship)
notifications
```
---
## Row Level Security (RLS)
Enable RLS on all tables.
### users
```sql
-- Members can read/update their own row
create policy "Users can read own" on users for select using (auth.uid() = id);
create policy "Users can update own" on users for update using (auth.uid() = id);
-- Org admins can read members registered to their championships
create policy "Orgs can read their members" on users for select using (
id in (
select r.user_id from registrations r
join championships c on r.championship_id = c.id
join organizations o on c.org_id = o.id
where o.user_id = auth.uid()
)
);
-- Admin can read/update all
create policy "Admin full access" on users for all using (
exists (select 1 from users where id = auth.uid() and role = 'admin')
);
```
### championships
```sql
-- Anyone can read live championships
create policy "Public read live" on championships for select using (status = 'live');
-- Org can CRUD their own
create policy "Org manages own" on championships for all using (
org_id in (select id from organizations where user_id = auth.uid())
);
-- Admin full access
create policy "Admin full access" on championships for all using (
exists (select 1 from users where id = auth.uid() and role = 'admin')
);
```
### registrations
```sql
-- Members can read/create their own
create policy "Member own registrations" on registrations for select using (user_id = auth.uid());
create policy "Member can register" on registrations for insert with check (user_id = auth.uid());
-- Org can read/update registrations for their championships
create policy "Org manages registrations" on registrations for all using (
championship_id in (
select c.id from championships c
join organizations o on c.org_id = o.id
where o.user_id = auth.uid()
)
);
-- Admin full access
create policy "Admin full access" on registrations for all using (
exists (select 1 from users where id = auth.uid() and role = 'admin')
);
```
### notifications
```sql
-- Users can read their own notifications
create policy "Read own" on notifications for select using (user_id = auth.uid());
-- Users can mark their own as read
create policy "Update own" on notifications for update using (user_id = auth.uid());
```
---
## Storage Buckets
```
receipts/ -- Payment receipt screenshots
{user_id}/{registration_id}/receipt.jpg
insurance/ -- Insurance documents
{user_id}/{registration_id}/insurance.pdf
judge-photos/ -- Judge profile photos
{championship_id}/{judge_id}.jpg
org-logos/ -- Organization logos
{org_id}/logo.jpg
```
---
## Seed Data
For development, seed with:
- 1 admin user
- 2 organizations (1 verified, 1 unverified/pending)
- 2 championships for verified org (1 live, 1 draft)
- 1 championship for unverified org (pending_approval)
- 7 member users with registrations at various progress stages
- Sample notifications, activity logs
This matches the prototype demo data.

View File

@@ -0,0 +1,258 @@
# DanceChamp — Design System
## Theme: Dark Luxury
The app has a premium dark aesthetic. Think high-end dance competition branding — elegant, minimal, confident.
---
## Colors
### Core Palette
```
Background: #08070D (near-black with slight purple)
Card: #12111A (elevated surface)
Card Hover: #1A1926 (pressed/active state)
Border: #1F1E2E (subtle separator)
Text Primary: #F2F0FA (off-white)
Text Dim: #5E5C72 (labels, placeholders)
Text Mid: #8F8DA6 (secondary info)
```
### Accent Colors
```
Pink (Primary): #D4145A ← Member app + Org app default
Purple: #7C3AED ← Secondary accent (styles, alt champ branding)
Indigo: #6366F1 ← Admin panel accent
```
### Semantic Colors
```
Green: #10B981 (success, passed, active, confirmed)
Yellow: #F59E0B (warning, pending, draft)
Red: #EF4444 (error, failed, blocked, danger)
Blue: #60A5FA (info, links, video)
Orange: #F97316 (warned status, awaiting review)
```
### Transparent Variants
Each semantic color has a 10% opacity background:
```
Green Soft: rgba(16,185,129,0.10)
Yellow Soft: rgba(245,158,11,0.10)
Red Soft: rgba(239,68,68,0.10)
Blue Soft: rgba(96,165,250,0.10)
Purple Soft: rgba(139,92,246,0.10)
```
For accent overlays use 15% opacity: `${color}15`
For accent borders use 30% opacity: `${color}30`
### Per-Championship Branding
Each championship can have its own accent color:
- Zero Gravity: `#D4145A` (pink)
- Pole Star: `#7C3AED` (purple)
This color is used for the championship's tab highlights, buttons, and member tags.
---
## Typography
### Font Stack
```
Display: 'Playfair Display', Georgia, serif ← Headings, numbers, titles
Body: 'DM Sans', 'Segoe UI', sans-serif ← Body text, labels, buttons
Mono: 'JetBrains Mono', monospace ← Badges, timestamps, codes, small labels
```
### Sizes & Usage
```
Screen title: Playfair Display, 20px, 700 weight
Section title: Playfair Display, 14px, 700 weight, Text Mid color
Card title: DM Sans, 14-16px, 600 weight
Body text: DM Sans, 12-13px, 400 weight
Small label: JetBrains Mono, 9-10px, 500 weight, uppercase, letter-spacing 0.3-0.5
Badge: JetBrains Mono, 8px, 700 weight, uppercase, letter-spacing 0.8
Stat number: Playfair Display, 16-20px, 700 weight
```
---
## Components
### Card
```
Background: #12111A
Border: 1px solid #1F1E2E
Border Radius: 14px
Padding: 16px
```
### Status Badge
Small pill with semantic color + soft background.
```
Font: JetBrains Mono, 8px, 700 weight, uppercase
Padding: 3px 8px
Border Radius: 4px
```
Status mappings:
| Status | Label | Color | Background |
|---|---|---|---|
| active / live | ACTIVE / LIVE | Green | Green Soft |
| pending | PENDING | Yellow | Yellow Soft |
| pending_approval | AWAITING REVIEW | Orange | Orange Soft |
| draft | DRAFT | Dim | Dim 15% |
| blocked | BLOCKED | Red | Red Soft |
| warned | WARNED | Orange | Orange Soft |
| passed | PASSED | Green | Green Soft |
| failed | FAILED | Red | Red Soft |
### Tab Bar (in-screen tabs, not bottom nav)
```
Container: horizontal scroll, no scrollbar, gap 3px
Tab: JetBrains Mono, 9px, 600 weight
Active: accent color text, accent 15% bg, accent 30% border
Inactive: Dim color text, transparent bg
Border Radius: 16px (pill shape)
Padding: 5px 10px
```
Configurable tabs have a status dot (6px circle):
- Green dot = section configured ✓
- Yellow dot = section pending
### Input Field
```
Background: #08070D (same as page bg)
Border: 1px solid #1F1E2E
Border Radius: 10px
Padding: 10px 12px
Font: DM Sans, 13px
Label: JetBrains Mono, 9px, uppercase, Dim color, 6px margin bottom
```
### Action Button
Two variants:
- **Filled**: solid background, white text (for primary actions)
- **Outline**: transparent bg, colored border 30%, colored text (for secondary/danger)
```
Padding: 8px 14px
Border Radius: 8px
Font: DM Sans, 11px, 700 weight
```
### Tag Editor
For lists of editable items (rules, levels, styles):
```
Tag: DM Sans 11px, colored bg 10%, colored border 25%, 4px 10px padding, 16px radius
Remove (×): 10px, Dim color
Add input: same as Input Field but smaller (8px 12px, 12px font)
Add button: colored bg, white "+" text, 8px 14px
```
### Header
```
Padding: 14px 20px 6px
Title: Playfair Display, 20px, 700
Subtitle: DM Sans, 11px, Dim color
Back button: 32×32px, Card bg, Border, 8px radius, "←" centered
```
### Bottom Navigation
```
Border top: 1px solid #1F1E2E
Padding: 10px 0 8px
Items: flex, space-around
Icon: 18px emoji
Label: JetBrains Mono, 8px, letter-spacing 0.3
Active: opacity 1
Inactive: opacity 0.35
```
---
## Patterns
### Progress/Setup Checklist
For configurable tabs on org side:
```
Each row:
[Checkbox 22×22] [Label capitalize] [Configure or ✓]
Checkbox: 6px radius, 2px border
Done: Green border, Green Soft bg, "✓" inside
Pending: Yellow border, transparent bg
Label done: Dim color, line-through
Label pending: Text Primary, clickable → navigates to tab
```
### Readiness Bar (dashboard cards)
```
Track: 4px height, Border color bg, 2px radius
Fill: accent color, width = (done/total * 100)%
Below: list of section names with ✓ (green) or ○ (yellow)
```
### Member Card
```
Container: Card style, 12px padding
Name: DM Sans 13px, 600 weight
Instagram: JetBrains Mono 10px, accent color
Tags: DM Sans 9px, Mid color, Mid 10% bg, 2px 7px padding, 10px radius
Status badge: top-right corner
```
### Stat Box
```
Container: Card style, 10px 6px padding, centered
Number: Playfair Display, 16-20px, 700 weight, semantic color
Label: JetBrains Mono, 7px, uppercase, Dim color
```
---
## Phone Frame (for prototypes)
```
Width: 375px
Height: 740px
Border Radius: 36px
Border: 1.5px solid #1F1E2E
Shadow: 0 0 80px rgba(accent, 0.06), 0 20px 40px rgba(0,0,0,0.5)
Status bar: 8px 24px padding
Time: JetBrains Mono 11px, Dim
Notch: 100×28px black, 14px radius
Indicators: "●●●" JetBrains Mono 11px, Dim
```
---
## React Native Adaptation
The prototypes use inline styles. For React Native:
| Prototype | React Native |
|---|---|
| `div` | `View` |
| `span`, `p`, `h1` | `Text` |
| `input` | `TextInput` |
| `onClick` | `onPress` (via `Pressable` or `TouchableOpacity`) |
| `overflow: auto` | `ScrollView` or `FlatList` |
| `cursor: pointer` | Not needed |
| `border: 1px solid` | `borderWidth: 1, borderColor:` |
| `fontFamily: 'DM Sans'` | Loaded via `expo-font` |
| `gap` | Use `marginBottom` on children (gap not fully supported) |
| `overflowX: auto` with scrollbar hidden | `ScrollView horizontal showsHorizontalScrollIndicator={false}` |
### Fonts Loading (Expo)
```typescript
import { useFonts } from 'expo-font';
import { PlayfairDisplay_700Bold } from '@expo-google-fonts/playfair-display';
import { DMSans_400Regular, DMSans_500Medium, DMSans_600SemiBold } from '@expo-google-fonts/dm-sans';
import { JetBrainsMono_400Regular, JetBrainsMono_500Medium, JetBrainsMono_700Bold } from '@expo-google-fonts/jetbrains-mono';
```

View File

@@ -0,0 +1,316 @@
# DanceChamp — Vibe Coding Plan
## How to use this plan
- Work phase by phase, top to bottom
- Check off tasks as you go: `[ ]``[x]`
- Each phase has a **"Done when"** — don't move on until it's met
- 🔴 = blocker (must do), 🟡 = important, 🟢 = nice to have for MVP
- Estimated time is for vibe coding with AI (Claude Code / Cursor)
---
## Phase 0: Project Setup
**Time: ~1 hour**
- [ ] 🔴 Init Expo project (React Native): `npx create-expo-app DanceChamp --template blank-typescript`
- [ ] 🔴 Init Web admin panel: `npm create vite@latest admin-panel -- --template react-ts`
- [ ] 🔴 Setup Supabase project (or Firebase): create account, new project
- [ ] 🔴 Setup database tables (see Phase 1)
- [ ] 🔴 Install core deps: `react-navigation`, `zustand`, `supabase-js`
- [ ] 🟡 Setup Git repo + `.gitignore`
- [ ] 🟡 Create `/apps/mobile`, `/apps/admin`, `/packages/shared` monorepo structure
- [ ] 🟢 Add ESLint + Prettier
**Done when:** Both apps run locally, Supabase dashboard is accessible
---
## Phase 1: Database & Auth
**Time: ~2-3 hours**
### 1.1 Database Tables
- [ ] 🔴 `users` — id, email, name, role (admin | organization | member), city, instagram_handle, experience_years, disciplines[], auth_provider, status, created_at
- [ ] 🔴 `organizations` — id, user_id (FK), name, instagram_handle, email, city, logo_url, verified (bool), status (active | pending | blocked), block_reason, created_at
- [ ] 🔴 `championships` — id, org_id (FK), name, subtitle, event_date, reg_start, reg_end, location, venue, status (draft | pending_approval | live | completed | blocked), accent_color, created_at
- [ ] 🔴 `disciplines` — id, championship_id (FK), name, levels[], styles[]
- [ ] 🔴 `fees` — id, championship_id (FK), video_selection, solo, duet, group, refund_note
- [ ] 🔴 `rules` — id, championship_id (FK), section (general | costume | scoring | penalty), text, value (for penalties)
- [ ] 🔴 `judges` — id, championship_id (FK), name, instagram, bio, photo_url
- [ ] 🔴 `registrations` — id, user_id (FK), championship_id (FK), discipline_id, level, style, type (solo | duet | group), current_step, video_url, fee_paid, receipt_uploaded, insurance_uploaded, passed (null | true | false), created_at
- [ ] 🔴 `notifications` — id, user_id (FK), championship_id, type, title, message, read (bool), created_at
- [ ] 🟡 `activity_logs` — id, actor_id, action, target_type, target_id, details, created_at
### 1.2 Auth
- [ ] 🔴 Supabase Auth: enable Email + Google OAuth
- [ ] 🔴 Role-based access: Row Level Security (RLS) policies
- Members see only their own registrations
- Orgs see only their own championships & members
- Admin sees everything
- [ ] 🔴 Sign up / Sign in screens (mobile)
- [ ] 🔴 Admin login (web panel)
- [ ] 🟡 Instagram OAuth (for member profiles)
- [ ] 🟡 Onboarding flow: name → city → discipline → experience → done
**Done when:** Can sign up as member, org, and admin. RLS blocks cross-access.
---
## Phase 2: Member App — Core Screens
**Time: ~4-5 hours**
### 2.1 Navigation
- [ ] 🔴 Bottom tab nav: Home, My Champs, Search, Profile
- [ ] 🔴 Stack navigation: screens → detail → sub-screens
### 2.2 Home Screen
- [ ] 🔴 "Upcoming championships" feed — cards with name, date, location, status badge
- [ ] 🔴 "My active registrations" section with progress bars
- [ ] 🟡 Bell icon → notifications feed
- [ ] 🟡 Deadline urgency banners ("Registration closes in 3 days!")
### 2.3 Championship Detail
- [ ] 🔴 Header: name, dates, location, venue, registration period
- [ ] 🔴 Tab: Overview (info + registration funnel)
- [ ] 🔴 Tab: Categories (disciplines, levels, styles + eligibility)
- [ ] 🔴 Tab: Rules (general, costume, scoring criteria, penalties)
- [ ] 🔴 Tab: Fees (video selection + championship fees)
- [ ] 🔴 Tab: Judges (judge profiles with photo, instagram, bio)
- [ ] 🔴 "Register" button → starts onboarding
### 2.4 Search & Discover
- [ ] 🔴 Search by championship name
- [ ] 🔴 Filter by: discipline, location, status (open/upcoming/past)
- [ ] 🟡 Sort by: date, popularity
### 2.5 Profile
- [ ] 🔴 View/edit: name, city, instagram, disciplines, experience
- [ ] 🔴 "My Championships" list (past + active)
- [ ] 🟢 Competition history
**Done when:** Can browse championships, view full details across all tabs, search/filter, see profile.
---
## Phase 3: Member App — Registration & Progress Tracker
**Time: ~4-5 hours**
### 3.1 Registration Flow
- [ ] 🔴 Choose discipline → level → style → solo/duet/group
- [ ] 🔴 Create `registration` record in DB
- [ ] 🔴 Show 10-step progress checklist
### 3.2 Progress Steps (per championship)
- [ ] 🔴 Step 1: Review rules — mark done when user opens Rules tab
- [ ] 🔴 Step 2: Select category — saved from registration
- [ ] 🔴 Step 3: Record video — manual toggle ("I've recorded my video")
- [ ] 🔴 Step 4: Submit video form — manual toggle or link to Google Form
- [ ] 🔴 Step 5: Pay video fee — upload receipt screenshot
- [ ] 🔴 Step 6: Wait for results — shows "pending" until org decides
- [ ] 🔴 Step 7: Results — auto-updates when org passes/fails
- [ ] 🔴 Step 8: Pay championship fee — upload receipt (only if passed)
- [ ] 🔴 Step 9: Submit "About Me" — manual toggle or link
- [ ] 🔴 Step 10: Confirm insurance — upload document
### 3.3 Receipt & Document Upload
- [ ] 🔴 Camera / gallery picker for receipt photos
- [ ] 🔴 Upload to Supabase Storage
- [ ] 🔴 Show upload status (pending org confirmation)
### 3.4 Notifications
- [ ] 🔴 In-app notification feed (bell icon + unread count)
- [ ] 🔴 Notification types: category changed, payment confirmed, results, deadline reminder, announcement
- [ ] 🟡 Push notifications via Expo Notifications
- [ ] 🟢 Notification preferences (toggle on/off)
**Done when:** Can register for a championship, track all 10 steps, upload receipts, receive notifications.
---
## Phase 4: Org App — Dashboard & Championship Management
**Time: ~5-6 hours**
### 4.1 Org Dashboard
- [ ] 🔴 Championship cards: name, dates, status badge, member count, progress bar (if draft)
- [ ] 🔴 "+" button → Quick Create (name, date, location → creates draft)
- [ ] 🔴 Tap card → championship detail
### 4.2 Championship Detail (tabbed, configurable)
- [ ] 🔴 Overview tab: setup progress checklist, event info (editable), stats (if live)
- [ ] 🔴 Categories tab: add/remove levels, add/remove styles → "Mark as Done"
- [ ] 🔴 Fees tab: video selection + solo/duet/group fees → "Mark as Done"
- [ ] 🔴 Rules tab: general rules + costume rules + scoring criteria + penalties → "Mark as Done"
- [ ] 🔴 Judges tab: add judge profiles (name, instagram, bio) → "Mark as Done"
- [ ] 🔴 "Go Live" button — appears when all sections are done
- [ ] 🔴 If org is verified → status = `live` (auto-approved)
- [ ] 🔴 If org is unverified → status = `pending_approval` (needs admin)
### 4.3 Members Tab (only for live championships)
- [ ] 🔴 Member list with search + filters (All, Receipts, Videos, Passed)
- [ ] 🔴 Member card: name, instagram, level, style, status badge, progress
- [ ] 🔴 Tap member → member detail
### 4.4 Member Detail
- [ ] 🔴 Profile info, registration details
- [ ] 🔴 Edit level (picker + "member will be notified" warning)
- [ ] 🔴 Edit style (picker + notification)
- [ ] 🔴 Video section: view link + Pass/Fail buttons
- [ ] 🔴 Payment section: view receipt + Confirm button
- [ ] 🔴 "Send Notification" button
### 4.5 Results Tab
- [ ] 🔴 Pending review list with Pass/Fail buttons per member
- [ ] 🔴 Decided list (passed/failed)
- [ ] 🔴 "Publish Results" button → notifies all members
### 4.6 Org Settings
- [ ] 🔴 Edit org profile (name, instagram)
- [ ] 🔴 Notification preferences (toggles)
- [ ] 🟡 Connected accounts (Instagram, Gmail, Telegram)
**Done when:** Org can create championship, configure all tabs, go live, manage members, pass/fail videos, publish results.
---
## Phase 5: Admin Panel (Web)
**Time: ~3-4 hours**
### 5.1 Dashboard
- [ ] 🔴 Platform stats: orgs count, live champs, total users
- [ ] 🔴 "Needs Attention" section: pending orgs, pending champs (from unverified orgs)
- [ ] 🔴 Platform health: revenue, blocked users
- [ ] 🔴 Recent activity log
### 5.2 Organizations Management
- [ ] 🔴 List with search + filters (Active, Pending, Blocked)
- [ ] 🔴 Org detail: profile, championships list, approval policy
- [ ] 🔴 Actions: Approve / Reject, Block / Unblock, Verify, Delete
### 5.3 Championships Management
- [ ] 🔴 List with search + filters (Live, Awaiting Review, Draft, Blocked)
- [ ] 🔴 Champ detail: stats, approval policy indicator
- [ ] 🔴 Actions: Approve / Reject (for unverified orgs), Suspend, Reinstate, Delete
### 5.4 Users Management
- [ ] 🔴 List with search + filters (Active, Warned, Blocked, Org Admins)
- [ ] 🔴 User detail: profile, role, championships joined
- [ ] 🔴 Actions: Warn, Block / Unblock, Delete
**Done when:** Admin can approve/reject orgs, review championships from unverified orgs, manage users.
---
## Phase 6: Connecting It All
**Time: ~3-4 hours**
### 6.1 Real-Time Sync
- [ ] 🔴 Supabase Realtime: members see status updates instantly (pass/fail, payment confirmed)
- [ ] 🔴 Org dashboard updates when new member registers
- [ ] 🟡 Admin panel live counters
### 6.2 Notification Triggers
- [ ] 🔴 Org passes/fails video → member gets notification
- [ ] 🔴 Org confirms receipt → member gets notification
- [ ] 🔴 Org changes member's level/style → member gets notification
- [ ] 🔴 Org publishes results → all members get notification
- [ ] 🟡 Auto deadline reminders (cron job: 7 days, 3 days, 1 day before)
### 6.3 Approval Flow
- [ ] 🔴 Unverified org clicks "Go Live" → status = pending_approval
- [ ] 🔴 Admin sees it in "Needs Attention"
- [ ] 🔴 Admin approves → status = live, org gets notification
- [ ] 🔴 Admin rejects → status = blocked, org gets notification with reason
### 6.4 File Uploads
- [ ] 🔴 Receipt photo upload → Supabase Storage → org sees thumbnail in member detail
- [ ] 🔴 Insurance doc upload → same flow
- [ ] 🟢 Judge profile photos
**Done when:** All three apps talk to the same DB. Actions in one app reflect in others in real time.
---
## Phase 7: Polish & UX
**Time: ~2-3 hours**
- [ ] 🟡 Loading states (skeletons, spinners)
- [ ] 🟡 Empty states ("No championships yet", "No members match")
- [ ] 🟡 Error handling (network errors, failed uploads, toast messages)
- [ ] 🟡 Pull-to-refresh on lists
- [ ] 🟡 Haptic feedback on key actions (pass/fail, payment confirm)
- [ ] 🟡 Dark theme consistency across all screens
- [ ] 🟡 Animations: tab transitions, card press, progress bar fill
- [ ] 🟢 Onboarding walkthrough (first-time user tips)
- [ ] 🟢 Swipe gestures on member cards (swipe right = pass, left = fail)
**Done when:** App feels smooth and professional. No raw errors shown to users.
---
## Phase 8: Integrations (Post-MVP)
**Time: varies**
### 8.1 Instagram Parsing
- [ ] 🟢 Apify Instagram scraper setup
- [ ] 🟢 Claude API: extract structured data from post captions
- [ ] 🟢 OCR (Google Vision): parse results from photo posts
- [ ] 🟢 "Import from Instagram" button in org's championship creation
### 8.2 Gmail Integration
- [ ] 🟢 Google OAuth for members
- [ ] 🟢 Detect Google Forms confirmation emails → auto-mark steps
### 8.3 Payments
- [ ] 🟢 In-app payment tracking
- [ ] 🟢 Replace receipt uploads with direct payment
### 8.4 Multi-Language
- [ ] 🟢 i18n setup (Russian + English)
---
## Phase 9: Testing & Launch
**Time: ~2-3 hours**
- [ ] 🔴 Test full flow: member registers → org reviews → admin monitors
- [ ] 🔴 Test approval flow: unverified org → pending → admin approves → live
- [ ] 🔴 Test notifications: level change, payment confirm, results
- [ ] 🔴 Test on real device (iOS + Android via Expo Go)
- [ ] 🟡 Test edge cases: empty championships, blocked orgs, duplicate registrations
- [ ] 🟡 Performance check: list with 50+ members, 10+ championships
- [ ] 🟡 Expo build: `eas build` for iOS/Android
- [ ] 🟢 TestFlight / Google Play internal testing
- [ ] 🟢 Admin panel deploy (Vercel / Netlify)
**Done when:** All three roles can complete their full flow without bugs.
---
## Quick Reference: What Goes Where
| Feature | Member App | Org App | Admin Panel |
|---|:---:|:---:|:---:|
| Browse championships | ✅ | — | ✅ (view all) |
| Register for championship | ✅ | — | — |
| Progress tracker | ✅ | — | — |
| Create/edit championship | — | ✅ | ✅ (edit/delete) |
| Review members | — | ✅ | ✅ (view) |
| Pass/Fail videos | — | ✅ | — |
| Confirm payments | — | ✅ | — |
| Approve orgs & champs | — | — | ✅ |
| Block/warn users | — | — | ✅ |
| Notifications | ✅ (receive) | ✅ (send) | — |
---
## Priority Order (if short on time)
If you need to ship fast, do these phases in order and stop when you have enough:
1. **Phase 0 + 1** — Foundation (can't skip)
2. **Phase 2** — Member app core (users need to see something)
3. **Phase 4** — Org app (orgs need to create championships)
4. **Phase 3** — Registration flow (connects member ↔ org)
5. **Phase 6** — Wire it together
6. **Phase 5** — Admin panel (can manage via Supabase dashboard temporarily)
7. **Phase 7** — Polish
8. **Phase 8** — Integrations (post-launch)

View File

@@ -0,0 +1,371 @@
# DanceChamp — Screen Reference
## Member App Screens
### Navigation: Bottom Tabs
`Home` | `My Champs` | `Search` | `Profile`
---
### M1: Home
**Purpose:** Dashboard for the dancer
**Elements:**
- Header: "DanceChamp" title + bell icon (🔔) with unread count badge
- "Your Active" section: cards for championships they're registered in, showing progress bar (e.g. "Step 5/10")
- "Upcoming Championships" section: browseable cards for live championships
- Each card: championship name, org name, dates, location, status badge, accent color bar
**Data:** `championships` (status = 'live') + `registrations` (for current user)
**Navigation:**
- Tap card → M5 (Championship Detail)
- Tap bell → M7 (Notifications)
---
### M2: My Champs
**Purpose:** All championships user is registered for
**Elements:**
- Tabs: Active | Past
- Championship cards with progress indicator
- Status per card: "Step 3/10 — Pay video fee" or "✅ Registered" or "❌ Failed"
**Data:** `registrations` joined with `championships`
**Navigation:**
- Tap card → M6 (Progress Tracker)
---
### M3: Search
**Purpose:** Discover championships
**Elements:**
- Search bar (text input)
- Filter chips: All, Pole Exotic, Pole Art, Registration Open, Upcoming
- Championship result cards
**Data:** `championships` + `organizations` (for org name)
**Navigation:**
- Tap card → M5 (Championship Detail)
---
### M4: Profile
**Purpose:** User profile and settings
**Elements:**
- Avatar, name, instagram handle, city
- Dance profile: disciplines, experience years
- "My Championships" summary (X active, Y completed)
- Settings list: Edit Profile, Notification Preferences, Connected Accounts, Help, Log Out
**Data:** `users` (current user)
---
### M5: Championship Detail
**Purpose:** Full championship info — 5 tabs
**Header:** Championship name, org name, back button
**Tabs:** Overview | Categories | Rules | Fees | Judges
#### Tab: Overview
- Event info: date, location, venue, registration period (start → end)
- Stats: members registered, spots left (if limited)
- "Register" button (if registration open and user not registered)
- If already registered: shows current progress step
#### Tab: Categories
- Disciplines list, each with levels
- Styles list
- If registered: user's selected level/style highlighted
#### Tab: Rules
- General rules (expandable list)
- Costume rules
- Scoring criteria: name + max score (010)
- Penalties: name + deduction / DQ
#### Tab: Fees
- Video selection fee
- Championship fees: solo, duet, group
- Refund note
#### Tab: Judges
- Judge profile cards: photo/emoji, name, instagram link, bio
**Data:** Full championship with all related tables
**Navigation:**
- "Register" → M6 (starts registration flow, then shows Progress Tracker)
---
### M6: Progress Tracker
**Purpose:** 10-step registration checklist for a specific championship
**Header:** Championship name, back button
**Elements:**
- Vertical step list (110)
- Each step: number, icon, title, status (locked/available/done/failed)
- Current step expanded with action area:
- Step 3 "Record Video": toggle "I've recorded my video"
- Step 5 "Pay Video Fee": upload receipt button, status after upload
- Step 7 "Results": shows PASS ✅ / FAIL ❌ / ⏳ Pending
- Step 10 "Insurance": upload document button
- Progress bar at top: X/10 completed
**Data:** `registrations` (single record for this user + championship)
**Actions:** Update step fields in `registrations` table
---
### M7: Notifications
**Purpose:** In-app notification feed
**Header:** "Notifications" + "Read all" button
**Elements:**
- Notification cards: icon, type badge, championship name, message, time ago
- Unread: colored left border + accent background tint + dot indicator
- Tap: marks as read
**Types:** 🔄 Category Changed, ✅ Payment Confirmed, 🏆 Results, ⏰ Deadline, 📋 Registered, 📢 Announcement
**Data:** `notifications` (for current user, ordered by created_at desc)
---
## Org App Screens
### Navigation: Bottom Tabs
`Dashboard` | `Settings`
---
### O1: Dashboard
**Purpose:** Overview of all org's championships
**Elements:**
- Header: "Dashboard" + org name + org logo
- "New Championship" card (accent gradient, "+" icon)
- Championship cards: name, dates, location, status badge (LIVE / SETUP 3/5 / AWAITING REVIEW)
- For drafts: readiness bar + section checklist (info ✓, categories ○, fees ○, etc.)
- For live: mini stats (Members, Passed, Pending)
**Data:** `championships` (where org_id = current org) + `registrations` (counts)
**Navigation:**
- Tap "New Championship" → O2 (Quick Create)
- Tap championship card → O3 (Championship Detail)
---
### O2: Quick Create
**Purpose:** Minimal form to create a draft championship
**Elements:**
- Header: "New Championship" + back button
- 3 inputs: Championship Name, Event Date, Location
- Info card: "Your championship will be created as a draft. Configure details later."
- "✨ Create Draft" button (disabled until name filled)
**Action:** Creates championship with status = 'draft', navigates to O3
---
### O3: Championship Detail (Tabbed)
**Purpose:** Configure and manage a championship
**Header:** Championship name, subtitle, back button, "🚀 Go Live" (if all config done)
**Tabs (with config status dots):**
`Overview` | `Categories` (🟢/🟡) | `Fees` (🟢/🟡) | `Rules` (🟢/🟡) | `Judges` (🟢/🟡)
For live championships, add: | `Members (N)` | `Results`
#### Tab: Overview
- **If draft:** Setup Progress checklist (5 items with checkmarks, tap incomplete → jumps to tab)
- **If all done:** "🚀 Open Registration" button (or "Go Live")
- Event Info card: inline edit (✎ Edit / ✕ Close toggle)
- Editable fields: name, subtitle, event date, location, venue
- Registration period: Opens (date) + Closes (date), side by side
- Warning: "⚠️ Registration close date must be before event date"
- **If live:** Stats boxes (Members, Passed, Failed, Pending) + "⚡ Needs Action" (receipts to review, videos to review)
#### Tab: Categories
- Levels: tag editor (add/remove levels)
- Styles: tag editor (add/remove styles)
- "✓ Mark Categories as Done" button (shown when at least 1 level + 1 style)
#### Tab: Fees
- Video Selection Fee input
- Championship Fees: Solo, Duet (pp), Group (pp) inputs
- "✓ Mark Fees as Done" button (shown when video fee filled)
#### Tab: Rules
- General Rules: tag editor
- Costume Rules: tag editor
- Scoring Criteria (010): list + tag editor to add new
- Penalties: list with colored values (-2 yellow, DQ red) + tag editor
- "✓ Mark Rules as Done" button (shown when at least 1 rule)
#### Tab: Judges
- Judge profile cards: emoji avatar, name, instagram, bio, × to remove
- "Add Judge" form: name, instagram, bio inputs + "+ Add Judge" button
- "✓ Mark Judges as Done" button (shown when at least 1 judge)
#### Tab: Members (live only)
- Search bar
- Filter chips: All (N), 📸 Receipts (N), 🎬 Videos (N), ✅ Passed (N)
- Member cards: name, instagram, level, style, city tags, status badge
- Tap member → O4 (Member Detail)
#### Tab: Results (live only)
- Stat boxes: Pending, Passed, Failed
- Pending review cards: member name/level + "🎥 View" + Pass/Fail buttons
- Decided list: member name + badge
- "📢 Publish Results" button
---
### O4: Member Detail
**Purpose:** View/edit a single member's registration
**Header:** Member name, championship + instagram, back button
**Elements:**
- Profile card: avatar, name, instagram, city, status badge
- Registration section:
- Discipline (read-only)
- Type: solo/duet/group (read-only)
- **Level:** value + "✎ Edit" button → expands picker with ⚠️ "Member will be notified"
- **Style:** value + "✎ Edit" button → expands picker with ⚠️ warning
- Video section: link display + Pass/Fail buttons (if pending) or status badge
- Payment section: fee amount, receipt status, "📸 Confirm" button (if receipt uploaded)
- "🔔 Send Notification" button
**Actions:** Update `registrations` fields + create `notifications` record
---
### O5: Org Settings
**Purpose:** Organization profile and preferences
**Elements:**
- Org profile: logo, name, instagram (editable inline when "Edit Organization Profile" tapped)
- Menu items:
- ✎ Edit Organization Profile → inline form (name + instagram) replaces menu
- 🔔 Notification Preferences → sub-screen with toggle switches
- 🔗 Connected Accounts → sub-screen (Instagram ✅, Gmail ✅, Telegram ❌)
- ❓ Help & Support
- 🚪 Log Out (red text)
---
## Admin Panel Screens (Web)
### Navigation: Bottom Tabs (mobile-style for prototype, sidebar for production)
`Overview` | `Orgs` | `Champs` | `Users`
---
### A1: Overview (Dashboard)
**Purpose:** Platform health at a glance
**Elements:**
- Header: "Admin Panel" + platform name + version
- Stat boxes: Active Orgs, Live Champs, Total Users
- "⚡ Needs Attention" card (yellow tint):
- 🏢 Organizations awaiting approval (count) → tap goes to A2
- 🏆 Champs awaiting approval from unverified orgs (count) → tap goes to A4
- Platform Health table: total revenue, active/total orgs, blocked users, avg members/champ
- Recent Activity log: action + target (colored by type) + date + actor
---
### A2: Organizations List
**Purpose:** All organizations on the platform
**Elements:**
- Search bar
- Filter chips: All (N), ✅ Active (N), ⏳ Pending (N), 🚫 Blocked (N)
- Org cards: logo, name, instagram, status badge, city, champs count, members count
- Tap → A3 (Org Detail)
---
### A3: Organization Detail
**Purpose:** Review and manage a single org
**Elements:**
- Profile card: logo, name, instagram, email, city, status badge
- Details table: Joined, Championships, Total members, Verified (✅/❌)
- Approval Policy card:
- Verified: "🛡️ Verified — Auto-approve events" (green tint)
- Unverified: "⏳ Unverified — Events need manual approval" (yellow tint)
- Championships list (belonging to this org)
- Block reason card (if blocked, red tint)
- Actions:
- Pending: [Approve ✅] [Reject ❌]
- Active: [Block 🚫] + [Verify 🛡️] (if not verified)
- Blocked: [Unblock ✅]
- Always: [Delete 🗑️]
---
### A4: Championships List
**Purpose:** All championships across all orgs
**Elements:**
- Search bar
- Filter chips: All (N), 🟢 Live (N), ⏳ Awaiting (N), 📝 Draft (N), 🚫 Blocked (N)
- Champ cards: name, "by Org Name 🛡️" (shield if verified), status badge, dates, location, members count
- Tap → A5 (Champ Detail)
---
### A5: Championship Detail
**Purpose:** Review and manage a single championship
**Elements:**
- Profile card: trophy emoji, name, org name, dates, location, status badge
- Stats table: Members, Passed, Pending, Revenue
- Approval Policy card: explains why auto-approved or needs review
- Actions:
- Pending approval: [Approve ✅] [Reject ❌]
- Live: [Suspend ⏸️]
- Blocked: [Reinstate ✅]
- Always: [Delete 🗑️]
---
### A6: Users List
**Purpose:** All platform users
**Elements:**
- Search bar (name, @handle, email)
- Filter chips: All (N), ✅ Active (N), ⚠️ Warned (N), 🚫 Blocked (N), 🏢 Org Admins (N)
- User cards: avatar (👤 or 🏢 for org admins), name, instagram, city, status badge
- Tap → A7 (User Detail)
---
### A7: User Detail
**Purpose:** Review and manage a single user
**Elements:**
- Profile card: avatar, name, instagram, email, status badge
- Info table: City, Joined, Championships joined, Role (Member or Org Admin + org name)
- Warning/Block reason card (if applicable, orange or red tint)
- Actions:
- Active: [Warn ⚠️] [Block 🚫]
- Warned: [Remove Warning ✅] [Block 🚫]
- Blocked: [Unblock ✅]
- Always: [Delete 🗑️]

View File

@@ -0,0 +1,267 @@
# DanceChamp — Technical Specification
## 1. Overview
A mobile platform for pole dance championships. Dancers discover events and track registration. Organizers create and manage championships. Platform admin oversees everything.
### The Problem
Championship info is scattered across Instagram posts, Telegram chats, and Google Docs. Dancers miss deadlines, lose track of multi-step registration, and can't verify submissions went through. Organizers manually manage everything via spreadsheets and DMs.
### The Solution
One app with three roles:
- **Members** browse championships, register, track 10-step progress
- **Organizations** create championships, configure rules/fees/categories, review videos, confirm payments
- **Admin** approves organizations, reviews championships from unverified orgs, manages users
### Real-World Reference: "Zero Gravity"
International Pole Exotic Championship, Minsk, Belarus. Two disciplines (Exotic Pole Dance + Pole Art), 6+ levels per discipline, two-stage payment (video selection fee + championship fee), video selection with pass/fail, detailed judging criteria (6 categories, 010), strict costume/equipment rules, insurance required.
---
## 2. Roles & Permissions
### Member (Dancer)
**Access:** Mobile app (member view)
- Browse & search championships
- View full details (rules, fees, categories, judges)
- Register for championships
- Track 10-step progress per championship
- Upload receipts & documents
- Receive notifications (results, deadline reminders, announcements)
- View past participation history
**Cannot:** Create/edit championships, see other members' data, access org/admin features
### Organization
**Access:** Mobile app (org view)
- Create championships (quick create → configure tabs)
- Manage disciplines, levels, styles, fees, rules, scoring, judges
- View & manage registered members per championship
- Review videos (pass/fail)
- Confirm receipt payments
- Edit member's level/style (triggers notification)
- Publish results
- Send announcements
**Cannot:** See other orgs' data, access admin features
### Admin
**Access:** Web admin panel
- View all orgs, championships, users
- Approve/reject pending organizations
- Approve/reject championships from unverified orgs
- Block/unblock orgs and users
- Warn users
- Verify organizations (changes approval policy)
- Delete any entity
- View platform stats and activity logs
---
## 3. Championship Lifecycle
```
[Org: Quick Create]
name + date + location → status: DRAFT
[Org: Configure Tabs]
Categories ✓ → Fees ✓ → Rules ✓ → Judges ✓
(any order, mark each as done)
[Org: "Go Live"]
├── Org is VERIFIED (🛡️)
│ → status: LIVE (auto-approved)
│ → visible to members immediately
└── Org is UNVERIFIED
→ status: PENDING_APPROVAL
→ admin sees in "Needs Attention"
→ admin approves → LIVE
→ admin rejects → BLOCKED
[LIVE — Registration Open]
reg_start ≤ today ≤ reg_end
Members can register
[Registration Closed]
today > reg_end
No new registrations
[Event Day]
event_date
[COMPLETED]
```
---
## 4. Championship Data Structure
Each championship contains:
### Event Info
- Name, subtitle
- Event date (when it happens)
- Registration period: start date → end date (must be before event date)
- Location (city, country)
- Venue name
- Accent color (for branding)
### Categories (configurable tab)
- Disciplines: e.g. "Exotic Pole Dance", "Pole Art"
- Levels per discipline: e.g. "Beginners", "Amateur", "Semi-Pro", "Profi", "Elite", "Duets & Groups"
- Styles: e.g. "Classic", "Flow", "Theater"
### Fees (configurable tab)
- Video selection fee (e.g. "50 BYN / 1,500 RUB")
- Championship fees by type:
- Solo (e.g. "280 BYN / 7,500 RUB")
- Duet per person (e.g. "210 BYN / 5,800 RUB pp")
- Group per person (e.g. "190 BYN / 4,500 RUB pp")
- Refund note (typically non-refundable)
### Rules (configurable tab)
- General rules (list of text items)
- Costume rules (list of text items)
- Scoring criteria: name + max score (e.g. "Artistry: 010", "Technique: 010")
- Penalties: name + value (e.g. "Fall: -2", "Exposure: DQ")
### Judges (configurable tab)
- Judge profiles: name, Instagram handle, bio/description
- These are people, not scoring criteria
### Members (only when LIVE)
- Registered members scoped to this championship
- Each with: discipline, level, style, type (solo/duet/group), progress steps, video URL, payment status, pass/fail result
---
## 5. Member Registration Flow (10 Steps)
```
Step 1: 📋 Review Rules → Auto (tracked when user opens Rules tab)
Step 2: 🎯 Select Category → Auto (saved from registration picker)
Step 3: 🎬 Record Video → Manual toggle ("I've recorded my video")
Step 4: 📝 Submit Video Form → Manual / link to Google Form
Step 5: 💳 Pay Video Fee → Upload receipt screenshot → Org confirms
Step 6: ⏳ Wait for Results → Pending until org decides
Step 7: 🏆 Results → Auto-updates on pass/fail
├── FAIL → Flow ends
└── PASS → Continue ▼
Step 8: 💰 Pay Championship Fee → Upload receipt → Org confirms
Step 9: 📄 Submit "About Me" → Manual / link to form
Step 10: 🛡️ Confirm Insurance → Upload document → Org confirms
└── ✅ REGISTERED!
```
### Detection Methods
| Step | Method |
|---|---|
| Review rules | Auto — tracked on tab open |
| Select category | Auto — saved from picker |
| Record video | Manual toggle |
| Submit video form | Manual or Gmail auto-detect (future) |
| Pay video fee | Receipt upload → org confirms |
| Results | Auto — org pass/fail updates member |
| Pay championship fee | Receipt upload → org confirms |
| About Me form | Manual or Gmail auto-detect (future) |
| Insurance | Document upload → org confirms |
---
## 6. Notifications
### Types
| Type | Trigger | Direction |
|---|---|---|
| 🔄 Category Changed | Org changes member's level/style | Org → Member |
| ✅ Payment Confirmed | Org confirms uploaded receipt | Org → Member |
| 🏆 Results | Org passes/fails video selection | Org → Member |
| ⏰ Deadline Reminder | Auto (7d, 3d, 1d before reg_end) | System → Member |
| 📋 Registration Confirmed | All 10 steps complete | System → Member |
| 📢 Announcement | Org sends broadcast | Org → All Members |
### Delivery
- In-app: Bell icon with unread count, notification feed
- Push: Expo Notifications for critical updates
- Email: Future enhancement
---
## 7. Org App — Configurable Tabs
When org creates a championship, it starts as DRAFT with 5 configurable sections:
| Section | Tab | What to configure | Mark as Done when |
|---|---|---|---|
| Info | Overview | Name, dates, location, venue, reg period | Has date + location |
| Categories | Categories | Levels + styles | At least 1 level + 1 style |
| Fees | Fees | Video fee + championship fees | Video fee filled |
| Rules | Rules | General rules, costume rules, scoring criteria, penalties | At least 1 rule |
| Judges | Judges | Judge profiles (name, instagram, bio) | At least 1 judge |
Setup progress shown on Overview tab as checklist. Each section shows green dot (done) or yellow dot (pending) in tab bar. "Go Live" button appears when all 5 sections are ✓.
---
## 8. Admin — Approval Flow
### Organization Approval
- New org registers → status: `pending`
- Admin reviews → Approve (status: `active`) or Reject (status: `blocked`)
- Admin can also **verify** an active org (🛡️ badge)
### Championship Approval
- Depends on org's verification status:
- **Verified org** → Go Live = instant `live`
- **Unverified org** → Go Live = `pending_approval` → admin reviews
### Admin Actions
| Entity | Actions |
|---|---|
| Organization | Approve, Reject, Block, Unblock, Verify, Delete |
| Championship | Approve, Reject, Suspend, Reinstate, Delete |
| User | Warn, Block, Unblock, Delete |
---
## 9. Org Settings
- **Edit Organization Profile**: name, instagram (inline edit form)
- **Notification Preferences**: toggles for push, email, registration alerts, payment alerts, deadline reminders
- **Connected Accounts**: Instagram (connected/not), Gmail, Telegram
- Help & Support
- Log Out
---
## 10. Search & Discovery (Member)
Members can find championships by:
- Text search (name, org name)
- Filters: discipline, location, status (Registration Open / Upcoming / Past)
- Sort: by date, by popularity
Championship cards show: name, org, dates, location, status badge, member count.
---
## 11. Future Features (Out of MVP Scope)
- Instagram parsing: auto-import championship data from org's posts
- Gmail integration: auto-detect Google Forms confirmations
- OCR results detection: parse results from Instagram photo posts
- In-app payments: replace receipt uploads
- In-app forms: replace Google Forms links
- Telegram monitoring: detect results from Telegram chats
- Category recommendation engine
- Calendar sync (export to phone calendar)
- Social features (see which friends are registered)
- Multi-language (Russian + English)

View File

@@ -0,0 +1,517 @@
import { useState } from "react";
/* ── Platform Data ── */
const PLATFORM = { name: "DanceChamp", version: "1.0 MVP", totalRevenue: "12,450 BYN" };
const ORGS_DATA = [
{ id: "o1", name: "Zero Gravity Team", instagram: "@zerogravity_pole", logo: "💃", status: "active", joined: "Jan 15, 2026", champsCount: 2, membersCount: 24, city: "Minsk", email: "team@zerogravity.by", verified: true },
{ id: "o2", name: "Pole Universe", instagram: "@pole_universe", logo: "🌌", status: "active", joined: "Feb 2, 2026", champsCount: 1, membersCount: 12, city: "Moscow", email: "info@poleuniverse.ru", verified: true },
{ id: "o3", name: "Sky Pole Studio", instagram: "@sky_pole", logo: "☁️", status: "pending", joined: "Feb 20, 2026", champsCount: 0, membersCount: 0, city: "St. Petersburg", email: "hello@skypole.ru", verified: false },
{ id: "o4", name: "Dance Flames", instagram: "@dance_flames", logo: "🔥", status: "blocked", joined: "Dec 10, 2025", champsCount: 1, membersCount: 5, city: "Kyiv", email: "admin@danceflames.ua", verified: false, blockReason: "Fake organization — no real events" },
];
const CHAMPS_DATA = [
{ id: "c1", orgId: "o1", orgName: "Zero Gravity Team", name: "Zero Gravity", dates: "May 30, 2026", location: "Minsk", status: "live", members: 24, passed: 8, pending: 8, revenue: "4,200 BYN", orgVerified: true },
{ id: "c2", orgId: "o1", orgName: "Zero Gravity Team", name: "Pole Star", dates: "Jul 12, 2026", location: "Moscow", status: "draft", members: 1, passed: 0, pending: 0, revenue: "0", orgVerified: true },
{ id: "c3", orgId: "o2", orgName: "Pole Universe", name: "Galactic Pole", dates: "Sep 15, 2026", location: "Moscow", status: "live", members: 12, passed: 0, pending: 12, revenue: "1,800 BYN", orgVerified: true },
{ id: "c4", orgId: "o3", orgName: "Sky Pole Studio", name: "Sky Open", dates: "Oct 5, 2026", location: "St. Petersburg", status: "pending_approval", members: 0, passed: 0, pending: 0, revenue: "0", orgVerified: false },
{ id: "c5", orgId: "o4", orgName: "Dance Flames", name: "Fire Cup", dates: "Mar 1, 2026", location: "Kyiv", status: "blocked", members: 5, passed: 0, pending: 0, revenue: "250 BYN", orgVerified: false },
];
const USERS_DATA = [
{ id: "u1", name: "Alex Petrova", instagram: "@alex_pole", email: "alex@mail.ru", city: "Moscow", joined: "Jan 20, 2026", champsJoined: 2, status: "active", role: "member" },
{ id: "u2", name: "Maria Ivanova", instagram: "@maria_exotic", email: "maria@gmail.com", city: "Minsk", joined: "Jan 22, 2026", champsJoined: 1, status: "active", role: "member" },
{ id: "u3", name: "Elena Kozlova", instagram: "@elena.pole", email: "elena@ya.ru", city: "St. Petersburg", joined: "Feb 1, 2026", champsJoined: 1, status: "active", role: "member" },
{ id: "u4", name: "Daria Sokolova", instagram: "@daria_art", email: "daria@mail.ru", city: "Kyiv", joined: "Feb 5, 2026", champsJoined: 1, status: "active", role: "member" },
{ id: "u5", name: "Anna Belova", instagram: "@anna.b_pole", email: "anna@gmail.com", city: "Minsk", joined: "Feb 10, 2026", champsJoined: 1, status: "active", role: "member" },
{ id: "u6", name: "Olga Morozova", instagram: "@olga_exotic", email: "olga@mail.ru", city: "Moscow", joined: "Feb 12, 2026", champsJoined: 3, status: "warned", role: "member", warnReason: "Disputed payment — under review" },
{ id: "u7", name: "Ivan Petrov", instagram: "@ivan_admin", email: "ivan@zerogravity.by", city: "Minsk", joined: "Jan 10, 2026", champsJoined: 0, status: "active", role: "org_admin", org: "Zero Gravity Team" },
{ id: "u8", name: "Spam Bot", instagram: "@totally_real", email: "spam@fake.com", city: "Unknown", joined: "Feb 22, 2026", champsJoined: 0, status: "blocked", role: "member", blockReason: "Spam account" },
];
const LOGS_DATA = [
{ id: "l1", action: "Org approved & verified", target: "Pole Universe", by: "Admin", date: "Feb 2, 2026", type: "org" },
{ id: "l2", action: "User blocked", target: "Spam Bot", by: "Admin", date: "Feb 22, 2026", type: "user" },
{ id: "l3", action: "Org blocked", target: "Dance Flames", by: "Admin", date: "Feb 23, 2026", type: "org" },
{ id: "l4", action: "Champ auto-approved (verified org)", target: "Zero Gravity", by: "System", date: "Feb 1, 2026", type: "champ" },
{ id: "l5", action: "User warned", target: "Olga Morozova", by: "Admin", date: "Feb 20, 2026", type: "user" },
{ id: "l6", action: "New org registered (pending)", target: "Sky Pole Studio", by: "System", date: "Feb 20, 2026", type: "org" },
{ id: "l7", action: "Champ submitted for review", target: "Sky Open", by: "Sky Pole Studio", date: "Feb 21, 2026", type: "champ" },
];
/* ── Theme (admin = darker, more neutral accent) ── */
const c = { bg: "#07060C", card: "#111019", cardH: "#18172290", brd: "#1D1C2B", text: "#F2F0FA", dim: "#5E5C72", mid: "#8F8DA6", accent: "#6366F1", accentS: "rgba(99,102,241,0.10)", green: "#10B981", greenS: "rgba(16,185,129,0.10)", yellow: "#F59E0B", yellowS: "rgba(245,158,11,0.10)", purple: "#8B5CF6", purpleS: "rgba(139,92,246,0.10)", blue: "#60A5FA", blueS: "rgba(96,165,250,0.10)", red: "#EF4444", redS: "rgba(239,68,68,0.10)", orange: "#F97316", orangeS: "rgba(249,115,22,0.10)" };
const f = { d: "'Playfair Display',Georgia,serif", b: "'DM Sans','Segoe UI',sans-serif", m: "'JetBrains Mono',monospace" };
/* ── Shared UI ── */
const Cd = ({ children, style: s }) => <div style={{ background: c.card, border: `1px solid ${c.brd}`, borderRadius: 14, padding: 16, ...s }}>{children}</div>;
const ST = ({ children, right }) => <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", margin: "0 0 10px" }}><h3 style={{ fontFamily: f.d, fontSize: 14, fontWeight: 700, color: c.mid, margin: 0 }}>{children}</h3>{right}</div>;
const Bg = ({ label, color, bg }) => <span style={{ fontFamily: f.m, fontSize: 8, fontWeight: 700, letterSpacing: 0.8, color, background: bg, padding: "3px 8px", borderRadius: 4 }}>{label}</span>;
const statusConfig = {
active: { l: "ACTIVE", c: c.green, b: c.greenS }, live: { l: "LIVE", c: c.green, b: c.greenS },
pending: { l: "PENDING", c: c.yellow, b: c.yellowS }, pending_approval: { l: "AWAITING REVIEW", c: c.orange, b: c.orangeS },
draft: { l: "DRAFT", c: c.dim, b: `${c.dim}15` },
blocked: { l: "BLOCKED", c: c.red, b: c.redS }, warned: { l: "WARNED", c: c.orange, b: c.orangeS },
};
function Hdr({ title, subtitle, onBack, right }) {
return <div style={{ padding: "14px 20px 6px", display: "flex", alignItems: "center", gap: 12 }}>
{onBack && <div onClick={onBack} style={{ width: 32, height: 32, borderRadius: 8, background: c.card, border: `1px solid ${c.brd}`, display: "flex", alignItems: "center", justifyContent: "center", cursor: "pointer", fontSize: 15, color: c.text }}></div>}
<div style={{ flex: 1, minWidth: 0 }}><h1 style={{ fontFamily: f.d, fontSize: 20, fontWeight: 700, color: c.text, margin: 0 }}>{title}</h1>{subtitle && <p style={{ fontFamily: f.b, fontSize: 11, color: c.dim, margin: "2px 0 0" }}>{subtitle}</p>}</div>
{right}
</div>;
}
function Nav({ active, onChange }) {
return <div style={{ display: "flex", justifyContent: "space-around", padding: "10px 0 8px", borderTop: `1px solid ${c.brd}`, background: c.bg, flexShrink: 0 }}>
{[{ id: "dash", i: "📊", l: "Overview" }, { id: "orgs", i: "🏢", l: "Orgs" }, { id: "champs", i: "🏆", l: "Champs" }, { id: "users", i: "👥", l: "Users" }].map(x =>
<div key={x.id} onClick={() => onChange(x.id)} style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 2, cursor: "pointer", opacity: active === x.id ? 1 : 0.35 }}><span style={{ fontSize: 18 }}>{x.i}</span><span style={{ fontFamily: f.m, fontSize: 8, color: c.text, letterSpacing: 0.3 }}>{x.l}</span></div>
)}
</div>;
}
function SearchBar({ value, onChange, placeholder }) {
return <div style={{ background: c.card, border: `1px solid ${c.brd}`, borderRadius: 12, padding: "10px 14px", display: "flex", alignItems: "center", gap: 10 }}>
<span style={{ fontSize: 14, opacity: 0.4 }}>🔍</span>
<input type="text" placeholder={placeholder || "Search..."} value={value} onChange={e => onChange(e.target.value)} style={{ background: "transparent", border: "none", outline: "none", color: c.text, fontFamily: f.b, fontSize: 13, width: "100%" }} />
</div>;
}
function FilterChips({ filters, active, onChange, accent }) {
return <div style={{ display: "flex", gap: 4, overflowX: "auto", scrollbarWidth: "none" }}>
{filters.map(fi => <div key={fi.id} onClick={() => onChange(fi.id)} style={{ fontFamily: f.m, fontSize: 9, fontWeight: 600, whiteSpace: "nowrap", color: active === fi.id ? accent || c.accent : c.dim, background: active === fi.id ? `${accent || c.accent}15` : "transparent", border: `1px solid ${active === fi.id ? `${accent || c.accent}30` : "transparent"}`, padding: "5px 10px", borderRadius: 16, cursor: "pointer" }}>{fi.l}{fi.n !== undefined ? ` (${fi.n})` : ""}</div>)}
</div>;
}
function ActionBtn({ label, color, onClick, icon, filled }) {
return <div onClick={onClick} style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 5, padding: "8px 14px", borderRadius: 8, background: filled ? color : `${color}15`, border: `1px solid ${filled ? color : `${color}30`}`, cursor: "pointer" }}>
{icon && <span style={{ fontSize: 12 }}>{icon}</span>}
<span style={{ fontFamily: f.b, fontSize: 11, fontWeight: 700, color: filled ? "#fff" : color }}>{label}</span>
</div>;
}
/* ── Dashboard ── */
function Dashboard({ orgs, champs, users, onNav }) {
const pendingOrgs = orgs.filter(o => o.status === "pending").length;
const pendingChamps = champs.filter(c2 => c2.status === "pending_approval").length;
const blockedUsers = users.filter(u => u.status === "blocked").length;
return <div>
<Hdr title="Admin Panel" subtitle={`${PLATFORM.name} · ${PLATFORM.version}`} right={
<div style={{ width: 36, height: 36, borderRadius: 10, background: `linear-gradient(135deg,${c.accent}25,${c.accent}10)`, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 14, fontFamily: f.m, fontWeight: 700, color: c.accent }}></div>
} />
<div style={{ padding: "6px 16px 16px", display: "flex", flexDirection: "column", gap: 12 }}>
{/* Platform stats */}
<div style={{ display: "flex", gap: 6 }}>
{[{ n: orgs.filter(o => o.status === "active").length, l: "Orgs", co: c.accent, go: "orgs" },
{ n: champs.filter(c2 => c2.status === "live").length, l: "Live Champs", co: c.green, go: "champs" },
{ n: users.length, l: "Users", co: c.blue, go: "users" },
].map(s => <div key={s.l} onClick={() => onNav(s.go)} style={{ flex: 1, background: c.card, border: `1px solid ${c.brd}`, borderRadius: 12, padding: "10px 6px", textAlign: "center", cursor: "pointer" }}>
<p style={{ fontFamily: f.d, fontSize: 20, fontWeight: 700, color: s.co, margin: 0 }}>{s.n}</p>
<p style={{ fontFamily: f.m, fontSize: 7, color: c.dim, margin: "2px 0 0", textTransform: "uppercase" }}>{s.l}</p>
</div>)}
</div>
{/* Needs attention */}
{(pendingOrgs > 0 || pendingChamps > 0) && <Cd style={{ background: `${c.yellow}06`, border: `1px solid ${c.yellow}20` }}>
<ST right={<Bg label="ACTION" color={c.yellow} bg={c.yellowS} />}> Needs Attention</ST>
{pendingOrgs > 0 && <div onClick={() => onNav("orgs")} style={{ display: "flex", alignItems: "center", gap: 10, padding: "10px 0", borderBottom: `1px solid ${c.brd}`, cursor: "pointer" }}>
<span style={{ fontSize: 16 }}>🏢</span>
<span style={{ fontFamily: f.b, fontSize: 13, color: c.text, flex: 1 }}>Organizations awaiting approval</span>
<span style={{ fontFamily: f.m, fontSize: 14, fontWeight: 700, color: c.yellow }}>{pendingOrgs}</span>
<span style={{ color: c.dim }}></span>
</div>}
{pendingChamps > 0 && <div onClick={() => onNav("champs")} style={{ display: "flex", alignItems: "center", gap: 10, padding: "10px 0", cursor: "pointer" }}>
<span style={{ fontSize: 16 }}>🏆</span>
<span style={{ fontFamily: f.b, fontSize: 13, color: c.text, flex: 1 }}>Champs awaiting approval (unverified orgs)</span>
<span style={{ fontFamily: f.m, fontSize: 14, fontWeight: 700, color: c.yellow }}>{pendingChamps}</span>
<span style={{ color: c.dim }}></span>
</div>}
</Cd>}
{/* Quick stats */}
<Cd>
<ST>Platform Health</ST>
{[{ l: "Total revenue", v: PLATFORM.totalRevenue, co: c.green },
{ l: "Active orgs", v: `${orgs.filter(o => o.status === "active").length}/${orgs.length}`, co: c.accent },
{ l: "Blocked users", v: `${blockedUsers}`, co: blockedUsers > 0 ? c.red : c.green },
{ l: "Avg members/champ", v: Math.round(users.filter(u => u.role === "member").length / Math.max(champs.filter(c2 => c2.status === "live").length, 1)), co: c.blue },
].map(s => <div key={s.l} style={{ display: "flex", justifyContent: "space-between", padding: "8px 0", borderBottom: `1px solid ${c.brd}` }}>
<span style={{ fontFamily: f.b, fontSize: 12, color: c.mid }}>{s.l}</span>
<span style={{ fontFamily: f.m, fontSize: 13, fontWeight: 700, color: s.co }}>{s.v}</span>
</div>)}
</Cd>
{/* Recent activity */}
<Cd>
<ST right={<span style={{ fontFamily: f.m, fontSize: 10, color: c.dim }}>{LOGS_DATA.length} entries</span>}>Recent Activity</ST>
{LOGS_DATA.slice(0, 5).map(log => {
const tc = { org: c.accent, user: c.blue, champ: c.green }[log.type] || c.dim;
return <div key={log.id} style={{ display: "flex", alignItems: "center", gap: 10, padding: "7px 0", borderBottom: `1px solid ${c.brd}` }}>
<div style={{ width: 6, height: 6, borderRadius: 3, background: tc, flexShrink: 0 }} />
<div style={{ flex: 1, minWidth: 0 }}>
<p style={{ fontFamily: f.b, fontSize: 12, color: c.text, margin: 0 }}>{log.action}: <span style={{ color: tc }}>{log.target}</span></p>
<p style={{ fontFamily: f.m, fontSize: 9, color: c.dim, margin: "2px 0 0" }}>{log.date} · {log.by}</p>
</div>
</div>;
})}
</Cd>
</div>
</div>;
}
/* ── Organizations ── */
function OrgsList({ orgs, onOrgTap }) {
const [search, setSearch] = useState("");
const [filter, setFilter] = useState("all");
const filters = [
{ id: "all", l: "All", n: orgs.length },
{ id: "active", l: "✅ Active", n: orgs.filter(o => o.status === "active").length },
{ id: "pending", l: "⏳ Pending", n: orgs.filter(o => o.status === "pending").length },
{ id: "blocked", l: "🚫 Blocked", n: orgs.filter(o => o.status === "blocked").length },
];
const filtered = orgs.filter(o => {
const q = !search || o.name.toLowerCase().includes(search.toLowerCase()) || o.instagram.toLowerCase().includes(search.toLowerCase());
if (!q) return false;
if (filter === "active") return o.status === "active";
if (filter === "pending") return o.status === "pending";
if (filter === "blocked") return o.status === "blocked";
return true;
});
return <div>
<Hdr title="Organizations" subtitle={`${orgs.length} registered`} />
<div style={{ padding: "6px 16px 16px", display: "flex", flexDirection: "column", gap: 10 }}>
<SearchBar value={search} onChange={setSearch} placeholder="Search org name or @handle..." />
<FilterChips filters={filters} active={filter} onChange={setFilter} />
{filtered.map(o => {
const st = statusConfig[o.status] || statusConfig.active;
return <div key={o.id} onClick={() => onOrgTap(o)} style={{ background: c.card, border: `1px solid ${c.brd}`, borderRadius: 14, padding: 14, cursor: "pointer" }}>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<div style={{ width: 40, height: 40, borderRadius: 10, background: `${c.accent}15`, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 18 }}>{o.logo}</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<p style={{ fontFamily: f.b, fontSize: 14, fontWeight: 600, color: c.text, margin: 0 }}>{o.name}</p>
<Bg label={st.l} color={st.c} bg={st.b} />
</div>
<p style={{ fontFamily: f.m, fontSize: 10, color: c.accent, margin: "2px 0 0" }}>{o.instagram}</p>
</div>
</div>
<div style={{ display: "flex", gap: 12, marginTop: 8, paddingTop: 8, borderTop: `1px solid ${c.brd}` }}>
<span style={{ fontFamily: f.b, fontSize: 10, color: c.mid }}>📍 {o.city}</span>
<span style={{ fontFamily: f.b, fontSize: 10, color: c.mid }}>🏆 {o.champsCount} champs</span>
<span style={{ fontFamily: f.b, fontSize: 10, color: c.mid }}>👥 {o.membersCount} members</span>
</div>
</div>;
})}
</div>
</div>;
}
/* ── Org Detail ── */
function OrgDetail({ org: initial, onBack, champs }) {
const [o, setO] = useState(initial);
const st = statusConfig[o.status] || statusConfig.active;
const orgChamps = champs.filter(c2 => c2.orgId === o.id);
return <div style={{ flex: 1, overflow: "auto" }}>
<Hdr title={o.name} subtitle={o.instagram} onBack={onBack} />
<div style={{ padding: "6px 16px 20px", display: "flex", flexDirection: "column", gap: 12 }}>
{/* Profile */}
<Cd style={{ display: "flex", alignItems: "center", gap: 14, padding: "14px 16px" }}>
<div style={{ width: 54, height: 54, borderRadius: 14, background: `${c.accent}15`, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 24 }}>{o.logo}</div>
<div style={{ flex: 1 }}>
<p style={{ fontFamily: f.b, fontSize: 16, fontWeight: 600, color: c.text, margin: 0 }}>{o.name}</p>
<p style={{ fontFamily: f.m, fontSize: 11, color: c.accent, margin: "2px 0 0" }}>{o.instagram}</p>
<p style={{ fontFamily: f.b, fontSize: 11, color: c.dim, margin: "2px 0 0" }}>📍 {o.city} · 📧 {o.email}</p>
</div>
<Bg label={st.l} color={st.c} bg={st.b} />
</Cd>
{/* Info */}
<Cd>
<ST>Details</ST>
{[{ l: "Joined", v: o.joined }, { l: "Championships", v: o.champsCount }, { l: "Total members", v: o.membersCount }, { l: "Verified", v: o.verified ? "✅ Yes" : "❌ No" }].map(r =>
<div key={r.l} style={{ display: "flex", justifyContent: "space-between", padding: "7px 0", borderBottom: `1px solid ${c.brd}` }}>
<span style={{ fontFamily: f.m, fontSize: 10, color: c.dim, textTransform: "uppercase" }}>{r.l}</span>
<span style={{ fontFamily: f.b, fontSize: 12, color: c.text }}>{r.v}</span>
</div>
)}
</Cd>
{/* Approval policy */}
<Cd style={{ background: o.verified ? `${c.green}06` : `${c.yellow}06`, border: `1px solid ${o.verified ? `${c.green}20` : `${c.yellow}20`}` }}>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<span style={{ fontSize: 18 }}>{o.verified ? "🛡️" : "⏳"}</span>
<div>
<p style={{ fontFamily: f.b, fontSize: 12, fontWeight: 600, color: o.verified ? c.green : c.yellow, margin: 0 }}>{o.verified ? "Verified — Auto-approve events" : "Unverified — Events need manual approval"}</p>
<p style={{ fontFamily: f.b, fontSize: 10, color: c.dim, margin: "2px 0 0" }}>{o.verified ? "Championships go live instantly when org clicks 'Go Live'" : "Admin must review & approve each championship before it becomes visible"}</p>
</div>
</div>
</Cd>
{/* Championships */}
{orgChamps.length > 0 && <Cd>
<ST right={<span style={{ fontFamily: f.m, fontSize: 10, color: c.dim }}>{orgChamps.length}</span>}>Championships</ST>
{orgChamps.map(ch => {
const cs = statusConfig[ch.status] || statusConfig.draft;
return <div key={ch.id} style={{ display: "flex", justifyContent: "space-between", alignItems: "center", padding: "8px 0", borderBottom: `1px solid ${c.brd}` }}>
<div><p style={{ fontFamily: f.b, fontSize: 12, color: c.text, margin: 0 }}>{ch.name}</p><p style={{ fontFamily: f.m, fontSize: 10, color: c.dim, margin: "2px 0 0" }}>{ch.dates} · {ch.location}</p></div>
<Bg label={cs.l} color={cs.c} bg={cs.b} />
</div>;
})}
</Cd>}
{/* Block reason */}
{o.blockReason && <Cd style={{ background: `${c.red}06`, border: `1px solid ${c.red}20` }}>
<p style={{ fontFamily: f.m, fontSize: 9, color: c.red, margin: "0 0 4px", textTransform: "uppercase", letterSpacing: 0.5 }}>Block Reason</p>
<p style={{ fontFamily: f.b, fontSize: 12, color: c.text, margin: 0 }}>{o.blockReason}</p>
</Cd>}
{/* Actions */}
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{o.status === "pending" && <div style={{ display: "flex", gap: 8 }}>
<ActionBtn label="Approve" color={c.green} onClick={() => setO(p => ({ ...p, status: "active", verified: true }))} icon="✅" filled />
<ActionBtn label="Reject" color={c.red} onClick={() => setO(p => ({ ...p, status: "blocked", blockReason: "Rejected by admin" }))} icon="❌" filled />
</div>}
{o.status === "active" && <ActionBtn label="Block Organization" color={c.red} onClick={() => setO(p => ({ ...p, status: "blocked", blockReason: "Blocked by admin" }))} icon="🚫" />}
{o.status === "blocked" && <ActionBtn label="Unblock Organization" color={c.green} onClick={() => setO(p => ({ ...p, status: "active", blockReason: null }))} icon="✅" />}
{!o.verified && o.status !== "blocked" && <ActionBtn label="Verify Organization" color={c.accent} onClick={() => setO(p => ({ ...p, verified: true }))} icon="🛡️" />}
<ActionBtn label="Delete Organization" color={c.red} onClick={() => {}} icon="🗑️" />
</div>
</div>
</div>;
}
/* ── Championships ── */
function ChampsList({ champs, onChampTap }) {
const [search, setSearch] = useState("");
const [filter, setFilter] = useState("all");
const filters = [
{ id: "all", l: "All", n: champs.length },
{ id: "live", l: "🟢 Live", n: champs.filter(c2 => c2.status === "live").length },
{ id: "pending_approval", l: "⏳ Awaiting", n: champs.filter(c2 => c2.status === "pending_approval").length },
{ id: "draft", l: "📝 Draft", n: champs.filter(c2 => c2.status === "draft").length },
{ id: "blocked", l: "🚫 Blocked", n: champs.filter(c2 => c2.status === "blocked").length },
];
const filtered = champs.filter(ch => {
const q = !search || ch.name.toLowerCase().includes(search.toLowerCase()) || ch.orgName.toLowerCase().includes(search.toLowerCase());
if (!q) return false;
if (filter !== "all") return ch.status === filter;
return true;
});
return <div>
<Hdr title="Championships" subtitle={`${champs.length} total`} />
<div style={{ padding: "6px 16px 16px", display: "flex", flexDirection: "column", gap: 10 }}>
<SearchBar value={search} onChange={setSearch} placeholder="Search championship or org..." />
<FilterChips filters={filters} active={filter} onChange={setFilter} />
{filtered.map(ch => {
const st = statusConfig[ch.status] || statusConfig.draft;
return <div key={ch.id} onClick={() => onChampTap(ch)} style={{ background: c.card, border: `1px solid ${c.brd}`, borderRadius: 14, padding: 14, cursor: "pointer" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: 4 }}>
<div><p style={{ fontFamily: f.b, fontSize: 14, fontWeight: 600, color: c.text, margin: 0 }}>{ch.name}</p><p style={{ fontFamily: f.m, fontSize: 10, color: c.accent, margin: "2px 0 0" }}>by {ch.orgName} {ch.orgVerified ? "🛡️" : ""}</p></div>
<Bg label={st.l} color={st.c} bg={st.b} />
</div>
<div style={{ display: "flex", gap: 12, marginTop: 6 }}>
<span style={{ fontFamily: f.b, fontSize: 10, color: c.mid }}>📅 {ch.dates}</span>
<span style={{ fontFamily: f.b, fontSize: 10, color: c.mid }}>📍 {ch.location}</span>
<span style={{ fontFamily: f.b, fontSize: 10, color: c.mid }}>👥 {ch.members}</span>
</div>
</div>;
})}
</div>
</div>;
}
/* ── Championship Detail ── */
function ChampDetail({ ch: initial, onBack }) {
const [ch, setCh] = useState(initial);
const st = statusConfig[ch.status] || statusConfig.draft;
return <div style={{ flex: 1, overflow: "auto" }}>
<Hdr title={ch.name} subtitle={`by ${ch.orgName}`} onBack={onBack} />
<div style={{ padding: "6px 16px 20px", display: "flex", flexDirection: "column", gap: 12 }}>
<Cd style={{ display: "flex", alignItems: "center", gap: 14, padding: "14px 16px" }}>
<div style={{ width: 50, height: 50, borderRadius: 14, background: `${c.accent}15`, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 22 }}>🏆</div>
<div style={{ flex: 1 }}>
<p style={{ fontFamily: f.b, fontSize: 16, fontWeight: 600, color: c.text, margin: 0 }}>{ch.name}</p>
<p style={{ fontFamily: f.m, fontSize: 11, color: c.accent, margin: "2px 0 0" }}>{ch.orgName}</p>
<p style={{ fontFamily: f.b, fontSize: 11, color: c.dim, margin: "2px 0 0" }}>📅 {ch.dates} · 📍 {ch.location}</p>
</div>
<Bg label={st.l} color={st.c} bg={st.b} />
</Cd>
<Cd>
<ST>Stats</ST>
{[{ l: "Members", v: ch.members }, { l: "Passed", v: ch.passed }, { l: "Pending", v: ch.pending }, { l: "Revenue", v: ch.revenue }].map(r =>
<div key={r.l} style={{ display: "flex", justifyContent: "space-between", padding: "7px 0", borderBottom: `1px solid ${c.brd}` }}>
<span style={{ fontFamily: f.m, fontSize: 10, color: c.dim, textTransform: "uppercase" }}>{r.l}</span>
<span style={{ fontFamily: f.b, fontSize: 12, color: c.text }}>{r.v}</span>
</div>
)}
</Cd>
{/* Approval info */}
{ch.orgVerified !== undefined && <Cd>
<ST>Approval Policy</ST>
<div style={{ display: "flex", alignItems: "center", gap: 10, padding: "8px 0" }}>
<span style={{ fontSize: 16 }}>{ch.orgVerified ? "🛡️" : "⏳"}</span>
<div style={{ flex: 1 }}>
<p style={{ fontFamily: f.b, fontSize: 12, color: c.text, margin: 0 }}>{ch.orgVerified ? "Verified org — auto-approved" : "Unverified org — manual review required"}</p>
<p style={{ fontFamily: f.b, fontSize: 10, color: c.dim, margin: "2px 0 0" }}>{ch.orgVerified ? "This org can go live without admin approval" : "Admin must approve before members can see this event"}</p>
</div>
</div>
</Cd>}
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{ch.status === "pending_approval" && <div style={{ display: "flex", gap: 8 }}>
<ActionBtn label="Approve" color={c.green} onClick={() => setCh(p => ({ ...p, status: "live" }))} icon="✅" filled />
<ActionBtn label="Reject" color={c.red} onClick={() => setCh(p => ({ ...p, status: "blocked" }))} icon="❌" filled />
</div>}
{ch.status === "live" && <ActionBtn label="Suspend Event" color={c.red} onClick={() => setCh(p => ({ ...p, status: "blocked" }))} icon="⏸️" />}
{ch.status === "blocked" && <ActionBtn label="Reinstate Event" color={c.green} onClick={() => setCh(p => ({ ...p, status: "live" }))} icon="✅" />}
<ActionBtn label="Delete Championship" color={c.red} onClick={() => {}} icon="🗑️" />
</div>
</div>
</div>;
}
/* ── Users ── */
function UsersList({ users, onUserTap }) {
const [search, setSearch] = useState("");
const [filter, setFilter] = useState("all");
const filters = [
{ id: "all", l: "All", n: users.length },
{ id: "active", l: "✅ Active", n: users.filter(u => u.status === "active").length },
{ id: "warned", l: "⚠️ Warned", n: users.filter(u => u.status === "warned").length },
{ id: "blocked", l: "🚫 Blocked", n: users.filter(u => u.status === "blocked").length },
{ id: "org_admin", l: "🏢 Org Admins", n: users.filter(u => u.role === "org_admin").length },
];
const filtered = users.filter(u => {
const q = !search || u.name.toLowerCase().includes(search.toLowerCase()) || u.instagram.toLowerCase().includes(search.toLowerCase()) || u.email.toLowerCase().includes(search.toLowerCase());
if (!q) return false;
if (filter === "active") return u.status === "active";
if (filter === "warned") return u.status === "warned";
if (filter === "blocked") return u.status === "blocked";
if (filter === "org_admin") return u.role === "org_admin";
return true;
});
return <div>
<Hdr title="Users" subtitle={`${users.length} total`} />
<div style={{ padding: "6px 16px 16px", display: "flex", flexDirection: "column", gap: 10 }}>
<SearchBar value={search} onChange={setSearch} placeholder="Search name, @handle, or email..." />
<FilterChips filters={filters} active={filter} onChange={setFilter} />
{filtered.map(u => {
const st = statusConfig[u.status] || statusConfig.active;
return <div key={u.id} onClick={() => onUserTap(u)} style={{ background: c.card, border: `1px solid ${c.brd}`, borderRadius: 14, padding: 12, cursor: "pointer" }}>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<div style={{ width: 38, height: 38, borderRadius: 10, background: u.role === "org_admin" ? `${c.purple}15` : `${c.blue}15`, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 16 }}>{u.role === "org_admin" ? "🏢" : "👤"}</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<p style={{ fontFamily: f.b, fontSize: 13, fontWeight: 600, color: c.text, margin: 0 }}>{u.name}</p>
<Bg label={st.l} color={st.c} bg={st.b} />
</div>
<div style={{ display: "flex", gap: 8, marginTop: 2 }}>
<span style={{ fontFamily: f.m, fontSize: 10, color: c.accent }}>{u.instagram}</span>
<span style={{ fontFamily: f.b, fontSize: 10, color: c.dim }}>{u.city}</span>
</div>
</div>
</div>
</div>;
})}
</div>
</div>;
}
/* ── User Detail ── */
function UserDetail({ user: initial, onBack }) {
const [u, setU] = useState(initial);
const st = statusConfig[u.status] || statusConfig.active;
return <div style={{ flex: 1, overflow: "auto" }}>
<Hdr title={u.name} subtitle={u.instagram} onBack={onBack} />
<div style={{ padding: "6px 16px 20px", display: "flex", flexDirection: "column", gap: 12 }}>
<Cd style={{ display: "flex", alignItems: "center", gap: 14, padding: "14px 16px" }}>
<div style={{ width: 50, height: 50, borderRadius: 14, background: `${c.blue}15`, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 22 }}>👤</div>
<div style={{ flex: 1 }}>
<p style={{ fontFamily: f.b, fontSize: 16, fontWeight: 600, color: c.text, margin: 0 }}>{u.name}</p>
<p style={{ fontFamily: f.m, fontSize: 11, color: c.accent, margin: "2px 0 0" }}>{u.instagram}</p>
<p style={{ fontFamily: f.b, fontSize: 11, color: c.dim, margin: "2px 0 0" }}>📧 {u.email}</p>
</div>
<Bg label={st.l} color={st.c} bg={st.b} />
</Cd>
<Cd>
<ST>Info</ST>
{[{ l: "City", v: u.city }, { l: "Joined", v: u.joined }, { l: "Championships", v: u.champsJoined },
{ l: "Role", v: u.role === "org_admin" ? `Org Admin (${u.org})` : "Member" },
].map(r => <div key={r.l} style={{ display: "flex", justifyContent: "space-between", padding: "7px 0", borderBottom: `1px solid ${c.brd}` }}>
<span style={{ fontFamily: f.m, fontSize: 10, color: c.dim, textTransform: "uppercase" }}>{r.l}</span>
<span style={{ fontFamily: f.b, fontSize: 12, color: c.text }}>{r.v}</span>
</div>)}
</Cd>
{(u.blockReason || u.warnReason) && <Cd style={{ background: `${u.status === "blocked" ? c.red : c.orange}06`, border: `1px solid ${u.status === "blocked" ? c.red : c.orange}20` }}>
<p style={{ fontFamily: f.m, fontSize: 9, color: u.status === "blocked" ? c.red : c.orange, margin: "0 0 4px", textTransform: "uppercase", letterSpacing: 0.5 }}>{u.status === "blocked" ? "Block" : "Warning"} Reason</p>
<p style={{ fontFamily: f.b, fontSize: 12, color: c.text, margin: 0 }}>{u.blockReason || u.warnReason}</p>
</Cd>}
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{u.status === "active" && <>
<ActionBtn label="Warn User" color={c.orange} onClick={() => setU(p => ({ ...p, status: "warned", warnReason: "Warning issued by admin" }))} icon="⚠️" />
<ActionBtn label="Block User" color={c.red} onClick={() => setU(p => ({ ...p, status: "blocked", blockReason: "Blocked by admin" }))} icon="🚫" />
</>}
{u.status === "warned" && <>
<ActionBtn label="Remove Warning" color={c.green} onClick={() => setU(p => ({ ...p, status: "active", warnReason: null }))} icon="✅" />
<ActionBtn label="Block User" color={c.red} onClick={() => setU(p => ({ ...p, status: "blocked", blockReason: "Blocked after warning" }))} icon="🚫" />
</>}
{u.status === "blocked" && <ActionBtn label="Unblock User" color={c.green} onClick={() => setU(p => ({ ...p, status: "active", blockReason: null }))} icon="✅" />}
<ActionBtn label="Delete User" color={c.red} onClick={() => {}} icon="🗑️" />
</div>
</div>
</div>;
}
/* ── App Shell ── */
export default function AdminApp() {
const [scr, setScr] = useState("dash");
const [sel, setSel] = useState(null);
const go = (screen, data) => { setScr(screen); setSel(data || null); };
const render = () => {
if (scr === "orgDetail" && sel) return <OrgDetail org={sel} onBack={() => go("orgs")} champs={CHAMPS_DATA} />;
if (scr === "champDetail" && sel) return <ChampDetail ch={sel} onBack={() => go("champs")} />;
if (scr === "userDetail" && sel) return <UserDetail user={sel} onBack={() => go("users")} />;
if (scr === "orgs") return <OrgsList orgs={ORGS_DATA} onOrgTap={o => go("orgDetail", o)} />;
if (scr === "champs") return <ChampsList champs={CHAMPS_DATA} onChampTap={ch => go("champDetail", ch)} />;
if (scr === "users") return <UsersList users={USERS_DATA} onUserTap={u => go("userDetail", u)} />;
return <Dashboard orgs={ORGS_DATA} champs={CHAMPS_DATA} users={USERS_DATA} onNav={setScr} />;
};
const showNav = ["dash", "orgs", "champs", "users"].includes(scr);
return <div style={{ display: "flex", justifyContent: "center", alignItems: "center", minHeight: "100vh", background: "#020106", padding: 20, fontFamily: f.b }}>
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;600;700&family=DM+Sans:wght@400;500;600&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet" />
<style>{`*::-webkit-scrollbar{display:none}*{scrollbar-width:none}`}</style>
<div style={{ width: 375, height: 740, background: c.bg, borderRadius: 36, overflow: "hidden", display: "flex", flexDirection: "column", border: `1.5px solid ${c.brd}`, boxShadow: `0 0 80px rgba(99,102,241,0.06),0 20px 40px rgba(0,0,0,0.5)` }}>
<div style={{ padding: "8px 24px", display: "flex", justifyContent: "space-between", alignItems: "center", flexShrink: 0 }}>
<span style={{ fontFamily: f.m, fontSize: 11, color: c.dim }}>9:41</span>
<div style={{ width: 100, height: 28, background: "#000", borderRadius: 14 }} />
<span style={{ fontFamily: f.m, fontSize: 11, color: c.dim }}></span>
</div>
<div style={{ flex: 1, overflow: "auto", minHeight: 0 }}>{render()}</div>
{showNav && <Nav active={scr} onChange={setScr} />}
</div>
</div>;
}

View File

@@ -0,0 +1,643 @@
import { useState } from "react";
/* ── Data ── */
const CHAMPS = [
{
id: "1", name: "Zero Gravity", subtitle: "International Pole Exotic Championship",
org: "Zero Gravity Team", dates: "May 30, 2026", location: "Minsk, Belarus",
venue: "Prime Hall", address: "Pr. Pobeditelei, 65",
disciplines: [
{ name: "Exotic Pole Dance", performanceReq: "70% floor & mid-level, 30% upper level", categories: [
{ name: "Beginners", duration: "2:003:00", eligibility: "Up to 2 yrs, no instructor/pro background", type: "solo" },
{ name: "Amateur", duration: "2:303:00", eligibility: "24 yrs, no instructor/pro background", type: "solo" },
{ name: "Semi-Pro", duration: "2:503:20", eligibility: "3+ yrs, instructor OR pro OR prizes in Amateur", type: "solo" },
{ name: "Profi", duration: "3:003:30", eligibility: "4+ yrs, instructor OR pro OR prizes in Semi-Pro", type: "solo" },
{ name: "Elite", duration: "3:004:00", eligibility: "3+ prizes in Profi OR widely known", type: "solo" },
{ name: "Duets & Groups", duration: "3:004:20", eligibility: "Open to all levels", type: "group" },
]},
{ name: "Pole Art", performanceReq: "60% floor & mid-level, 40% upper level", categories: [
{ name: "Amateur", duration: "2:303:00", eligibility: "Up to 2 yrs, no instructor/pro background", type: "solo" },
{ name: "Semi-Pro", duration: "2:503:20", eligibility: "3+ yrs, instructor OR pro OR prizes in Amateur", type: "solo" },
{ name: "Profi", duration: "3:003:30", eligibility: "4+ yrs, instructor OR pro OR prizes in Semi-Pro", type: "solo" },
]},
],
fees: { videoSelection: "50 BYN / 1,500 RUB", championship: { solo: "280 BYN / 7,500 RUB", duet: "210 BYN / 5,800 RUB pp", group: "190 BYN / 4,500 RUB pp" }, refundNote: "Non-refundable. All fees are charitable contributions." },
videoReqs: { minDuration: "1:30", editing: "No editing or splicing", maxAge: "Less than 1 year old", note: "Must reflect your level" },
judging: [
{ name: "Image", max: 10, desc: "Costume, hair, makeup, originality" },
{ name: "Artistry", max: 10, desc: "Charisma, stage presence, emotion" },
{ name: "Choreography", max: 10, desc: "Body control, complexity, originality" },
{ name: "Musicality", max: 10, desc: "Timing, feeling, accent play" },
{ name: "Technique", max: 10, desc: "Clean execution, transitions, tricks" },
{ name: "Overall", max: 10, desc: "General impression" },
{ name: "Synchronicity", max: 10, desc: "Duets only" },
],
penalties: [
{ name: "Missed element", points: -2 }, { name: "Fall", points: -2 },
{ name: "Leaving stage", consequence: "DQ" }, { name: "Exposure", consequence: "DQ" },
{ name: "Substance influence", consequence: "DQ" }, { name: "No special shoes", consequence: "DQ" },
],
venueSpecs: { poles: "2 (Static & Spinning)", poleHeight: "3.5 m", poleDiameter: "42 mm", stageSize: "6m × 14m" },
costumeRules: ["Neat and well-fitted", "No advertising", "No spikes/sharp objects", "No thongs/sheer/pasties", "Specialized shoes for Exotic", "Creativity is scored"],
generalRules: ["Must be 18+", "No medical contraindications", "Valid life & health insurance", "No lotions/bronzers 24h before", "Grip aids allowed (no wax/rosin)", "Judges' decision is final", "Organizers may change your category"],
prizes: ["1st3rd in each category", "Nominations per block", "Medals, diplomas, sponsor prizes", "All get participation diplomas", "1st Elite → judge next champ"],
resultsChannels: ["Email", "Instagram", "Telegram"],
applicationDeadline: "August 22, 2026",
formUrl: "https://docs.google.com/forms/d/e/1FAIpQLSfLaNg5Sf2QMAI6anpMrnLu-2qYfT3tdwh0dsynQFn8xMhi2g/viewform",
status: "registration_open", accent: "#D4145A", image: "💃",
},
{
id: "2", name: "Pole Star", subtitle: "National Pole Championship",
org: "Pole Star Events", dates: "Jul 1213, 2026", location: "Moscow, Russia", venue: "Crystal Hall",
disciplines: [{ name: "Exotic Pole Dance", categories: [
{ name: "Amateur", duration: "2:303:00", eligibility: "24 years", type: "solo" },
{ name: "Profi", duration: "3:003:30", eligibility: "4+ years", type: "solo" },
]}],
fees: { videoSelection: "2,000 RUB", championship: { solo: "8,000 RUB" } },
videoReqs: { minDuration: "1:00", editing: "No editing", maxAge: "6 months" },
status: "upcoming", applicationDeadline: "Jun 1, 2026", accent: "#7C3AED", image: "⭐",
},
];
const STEPS = [
{ id: "s1", label: "Review rules & eligibility", icon: "📋", detect: "auto", detectLabel: "Auto: tracked in app" },
{ id: "s2", label: "Select category", icon: "🏷️", detect: "auto", detectLabel: "Auto: saved in app" },
{ id: "s3", label: "Record video (min 1:30)", icon: "🎬", detect: "manual", detectLabel: "You confirm" },
{ id: "s4", label: "Submit video selection form", icon: "📤", detect: "email", detectLabel: "Auto: Gmail confirmation" },
{ id: "s5", label: "Pay video selection fee", icon: "💳", warn: true, detect: "receipt", detectLabel: "Upload receipt → Org confirms" },
{ id: "s6", label: "Results (auto-detected)", icon: "🤖", detect: "auto", detectLabel: "Auto: Instagram OCR" },
{ id: "s7", label: "Pay championship fee", icon: "💰", warn: true, detect: "receipt", detectLabel: "Upload receipt → Org confirms" },
{ id: "s8", label: 'Fill "About Me" form', icon: "👤", detect: "email", detectLabel: "Auto: Gmail confirmation" },
{ id: "s9", label: "Confirm insurance", icon: "🛡️", detect: "receipt", detectLabel: "Upload doc → Org confirms" },
{ id: "s10", label: "Submit music & performance", icon: "🎶", detect: "email", detectLabel: "Auto: Gmail confirmation" },
];
const USER = { name: "Alex", city: "Moscow", disciplines: ["Pole Exotic", "Pole Art"], experienceYears: 3, isInstructor: false, instagram: "@alex_pole" };
const NOTIFICATIONS = [
{ id: "n1", type: "category_change", champ: "Zero Gravity", from: "Amateur", to: "Semi-Pro", field: "Level", date: "Feb 24, 2026", read: false, message: "Your level was changed from Amateur to Semi-Pro by the organizer." },
{ id: "n2", type: "payment_confirmed", champ: "Zero Gravity", date: "Feb 23, 2026", read: false, message: "Your video selection fee payment has been confirmed." },
{ id: "n3", type: "result", champ: "Zero Gravity", date: "Feb 22, 2026", read: true, message: "Video selection results are out! You passed! 🎉" },
{ id: "n4", type: "deadline", champ: "Zero Gravity", date: "Feb 20, 2026", read: true, message: "Reminder: registration deadline is Aug 22, 2026." },
];
const MY_REGISTRATIONS = [
{ champId: "1", discipline: "Exotic Pole Dance", category: "Semi-Pro", status: "in_progress", currentStep: 4, stepsCompleted: 3, nextAction: "Submit video selection form", deadline: "Aug 22, 2026" },
{ champId: "2", discipline: "Exotic Pole Dance", category: "Profi", status: "planned", currentStep: 1, stepsCompleted: 0, nextAction: "Review rules & eligibility", deadline: "Jun 1, 2026" },
];
/* ── Theme ── */
const c = { bg: "#08070D", card: "#12111A", cardH: "#1A1926", brd: "#1F1E2E", text: "#F2F0FA", dim: "#5E5C72", mid: "#8F8DA6", accent: "#D4145A", accentS: "rgba(212,20,90,0.10)", green: "#10B981", greenS: "rgba(16,185,129,0.10)", yellow: "#F59E0B", yellowS: "rgba(245,158,11,0.10)", purple: "#8B5CF6" };
const f = { d: "'Playfair Display',Georgia,serif", b: "'DM Sans','Segoe UI',sans-serif", m: "'JetBrains Mono',monospace" };
/* ── Shared ── */
const Badge = ({ status }) => { const m = { registration_open: { l: "REG OPEN", c: c.green, b: c.greenS }, upcoming: { l: "UPCOMING", c: c.yellow, b: c.yellowS } }; const s = m[status] || m.upcoming; return <span style={{ fontFamily: f.m, fontSize: 9, fontWeight: 700, letterSpacing: 1.2, color: s.c, background: s.b, padding: "3px 8px", borderRadius: 4 }}>{s.l}</span>; };
const Chip = ({ text, color = c.mid, bg = c.card, border = c.brd }) => <span style={{ fontFamily: f.b, fontSize: 11, fontWeight: 600, color, background: bg, border: `1px solid ${border}`, padding: "4px 10px", borderRadius: 16, whiteSpace: "nowrap" }}>{text}</span>;
const Info = ({ icon, text }) => <span style={{ fontFamily: f.b, fontSize: 12, color: c.mid, display: "flex", alignItems: "center", gap: 5 }}><span style={{ fontSize: 13 }}>{icon}</span> {text}</span>;
const ST = ({ children, right }) => <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", margin: "0 0 10px" }}><h3 style={{ fontFamily: f.d, fontSize: 14, fontWeight: 700, color: c.mid, margin: 0 }}>{children}</h3>{right && <span style={{ fontFamily: f.m, fontSize: 10, color: c.dim }}>{right}</span>}</div>;
const Cd = ({ children, style: s }) => <div style={{ background: c.card, border: `1px solid ${c.brd}`, borderRadius: 14, padding: 16, ...s }}>{children}</div>;
function Tabs({ tabs, active, onChange, accent: ac }) {
return <div style={{ display: "flex", gap: 4, overflowX: "auto", paddingBottom: 2, marginBottom: 14, scrollbarWidth: "none", msOverflowStyle: "none" }}>
{tabs.map(t => <div key={t} onClick={() => onChange(t)} style={{ fontFamily: f.m, fontSize: 10, fontWeight: 600, letterSpacing: 0.4, color: active === t ? ac || c.accent : c.dim, background: active === t ? `${ac || c.accent}15` : "transparent", border: `1px solid ${active === t ? `${ac || c.accent}30` : "transparent"}`, padding: "5px 12px", borderRadius: 16, cursor: "pointer", whiteSpace: "nowrap" }}>{t}</div>)}
</div>;
}
function Nav({ active, onChange }) {
return <div style={{ display: "flex", justifyContent: "space-around", padding: "10px 0 8px", borderTop: `1px solid ${c.brd}`, background: c.bg, flexShrink: 0 }}>
{[{ id: "home", i: "🏠", l: "Home" }, { id: "my", i: "🎯", l: "My Champs" }, { id: "search", i: "🔍", l: "Search" }, { id: "profile", i: "👤", l: "Profile" }].map(x =>
<div key={x.id} onClick={() => onChange(x.id)} style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 2, cursor: "pointer", opacity: active === x.id ? 1 : 0.35 }}><span style={{ fontSize: 18 }}>{x.i}</span><span style={{ fontFamily: f.m, fontSize: 8, color: c.text, letterSpacing: 0.3 }}>{x.l}</span></div>
)}
</div>;
}
function Hdr({ title, subtitle, onBack, right }) {
return <div style={{ padding: "14px 20px 6px", display: "flex", alignItems: "center", gap: 12 }}>
{onBack && <div onClick={onBack} style={{ width: 32, height: 32, borderRadius: 8, background: c.card, border: `1px solid ${c.brd}`, display: "flex", alignItems: "center", justifyContent: "center", cursor: "pointer", fontSize: 15, color: c.text }}></div>}
<div style={{ flex: 1 }}><h1 style={{ fontFamily: f.d, fontSize: 20, fontWeight: 700, color: c.text, margin: 0 }}>{title}</h1>{subtitle && <p style={{ fontFamily: f.b, fontSize: 11, color: c.dim, margin: "2px 0 0" }}>{subtitle}</p>}</div>
{right}
</div>;
}
/* ── Home ── */
function Home({ onTap, onNotifications }) {
const unread = NOTIFICATIONS.filter(n => !n.read).length;
return <div>
<Hdr title="Dance Hub" subtitle={`Hey ${USER.name} 👋`} right={
<div onClick={onNotifications} style={{ position: "relative", width: 36, height: 36, borderRadius: 10, background: c.card, border: `1px solid ${c.brd}`, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 18, cursor: "pointer" }}>
🔔
{unread > 0 && <div style={{ position: "absolute", top: -4, right: -4, width: 18, height: 18, borderRadius: 9, background: c.accent, display: "flex", alignItems: "center", justifyContent: "center" }}>
<span style={{ fontFamily: f.m, fontSize: 9, fontWeight: 700, color: "#fff" }}>{unread}</span>
</div>}
</div>
} />
<div style={{ padding: "6px 16px 16px", display: "flex", flexDirection: "column", gap: 12 }}>
<div style={{ background: `linear-gradient(135deg,${c.accent}15,${c.accent}05)`, border: `1px solid ${c.accent}25`, borderRadius: 14, padding: "12px 16px" }}>
<p style={{ fontFamily: f.b, fontSize: 12, color: c.accent, margin: 0, fontWeight: 600 }}>🔔 Zero Gravity Deadline: Aug 22!</p>
</div>
<ST right={`${CHAMPS.length} events`}>Championships</ST>
{CHAMPS.map(ch => <ChampCard key={ch.id} ch={ch} onTap={onTap} />)}
</div>
</div>;
}
function ChampCard({ ch, onTap }) {
const [h, setH] = useState(false);
return <div onClick={() => onTap(ch)} onMouseEnter={() => setH(true)} onMouseLeave={() => setH(false)} style={{ background: h ? c.cardH : c.card, border: `1px solid ${c.brd}`, borderRadius: 16, padding: 16, cursor: "pointer", transition: "all 0.2s", transform: h ? "translateY(-2px)" : "none", boxShadow: h ? "0 8px 24px rgba(0,0,0,0.3)" : "none" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: 10 }}>
<div style={{ width: 46, height: 46, borderRadius: 12, background: `linear-gradient(135deg,${ch.accent}20,${ch.accent}40)`, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 22 }}>{ch.image}</div>
<Badge status={ch.status} />
</div>
<h3 style={{ fontFamily: f.d, fontSize: 16, fontWeight: 700, color: c.text, margin: "0 0 2px" }}>{ch.name}</h3>
<p style={{ fontFamily: f.b, fontSize: 11, color: c.dim, margin: "0 0 10px" }}>{ch.subtitle}</p>
<div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}><Info icon="📅" text={ch.dates} /><Info icon="📍" text={ch.location} /></div>
{ch.disciplines && <div style={{ display: "flex", gap: 6, marginTop: 10, flexWrap: "wrap" }}>{ch.disciplines.map(d => <Chip key={d.name} text={d.name} color={ch.accent} bg={`${ch.accent}10`} border={`${ch.accent}25`} />)}</div>}
</div>;
}
/* ── Championship Detail ── */
function Detail({ ch, onBack, onProgress }) {
const [tab, setTab] = useState("Info");
const tabs = ["Info", "Categories", "Fees", "Judging", "Rules"];
const completedCount = 3; // mock
return <div style={{ flex: 1, overflow: "auto" }}>
<Hdr title={ch.name} subtitle={ch.subtitle} onBack={onBack} />
<div style={{ padding: "6px 16px 20px" }}>
{/* Hero */}
<div style={{ background: `linear-gradient(135deg,${ch.accent}15,${ch.accent}05)`, border: `1px solid ${ch.accent}25`, borderRadius: 16, padding: 16, marginBottom: 14 }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 12 }}>
<span style={{ fontSize: 32 }}>{ch.image}</span><Badge status={ch.status} />
</div>
<div style={{ display: "flex", flexWrap: "wrap", gap: "8px 18px" }}>
<Info icon="📅" text={ch.dates} />
<Info icon="📍" text={`${ch.venue ? ch.venue + ", " : ""}${ch.location}`} />
{ch.applicationDeadline && <Info icon="⏰" text={`Deadline: ${ch.applicationDeadline}`} />}
{ch.resultsChannels && <Info icon="📢" text={`Results: ${ch.resultsChannels.join(", ")}`} />}
</div>
</div>
{/* Register + Progress buttons */}
<div style={{ display: "flex", gap: 8, marginBottom: 14 }}>
<div onClick={() => onProgress(ch)} style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center", gap: 6, padding: "11px 12px", borderRadius: 12, background: c.card, border: `1px solid ${c.brd}`, cursor: "pointer" }}>
<span style={{ fontSize: 14 }}>📋</span>
<span style={{ fontFamily: f.b, fontSize: 12, fontWeight: 600, color: c.text }}>Progress</span>
<span style={{ fontFamily: f.m, fontSize: 10, color: c.dim }}>{completedCount}/{STEPS.length}</span>
</div>
{ch.formUrl && ch.status === "registration_open" && <a href={ch.formUrl} target="_blank" rel="noopener noreferrer" style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center", gap: 6, padding: "11px 12px", borderRadius: 12, background: ch.accent, fontFamily: f.b, fontSize: 12, fontWeight: 700, color: "#fff", textDecoration: "none", cursor: "pointer" }}>
Register
</a>}
</div>
<Tabs tabs={tabs} active={tab} onChange={setTab} accent={ch.accent} />
{/* Info */}
{tab === "Info" && <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
{ch.disciplines && <Cd><ST>Disciplines</ST>{ch.disciplines.map(d => <div key={d.name} style={{ marginBottom: 10 }}>
<p style={{ fontFamily: f.b, fontSize: 13, fontWeight: 600, color: c.text, margin: "0 0 4px" }}>{d.name}</p>
{d.performanceReq && <p style={{ fontFamily: f.b, fontSize: 11, color: c.dim, margin: 0 }}>{d.performanceReq}</p>}
<div style={{ display: "flex", gap: 6, flexWrap: "wrap", marginTop: 6 }}>{d.categories.map(cat => <Chip key={cat.name} text={`${cat.name}${cat.type === "group" ? " 👥" : ""}`} />)}</div>
</div>)}</Cd>}
{ch.videoReqs && <Cd><ST>Video Requirements</ST><div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<Info icon="⏱" text={`Min duration: ${ch.videoReqs.minDuration}`} />
<Info icon="🚫" text={ch.videoReqs.editing} />
<Info icon="📅" text={ch.videoReqs.maxAge} />
<Info icon="📊" text={ch.videoReqs.note} />
</div></Cd>}
{ch.prizes && <Cd><ST>Prizes</ST>{ch.prizes.map((p, i) => <p key={i} style={{ fontFamily: f.b, fontSize: 12, color: c.mid, margin: "0 0 4px" }}>🏆 {p}</p>)}</Cd>}
</div>}
{/* Categories */}
{tab === "Categories" && ch.disciplines && <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
{ch.disciplines.map(d => <Cd key={d.name}><ST>{d.name}</ST>{d.categories.map(cat => {
const match = (USER.experienceYears >= 2 && USER.experienceYears <= 4 && !USER.isInstructor && cat.name === "Amateur") || (USER.experienceYears >= 3 && cat.name === "Semi-Pro") || cat.name === "Duets & Groups";
return <div key={cat.name} style={{ padding: "10px 0", borderBottom: `1px solid ${c.brd}` }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span style={{ fontFamily: f.b, fontSize: 13, fontWeight: 600, color: c.text }}>{cat.name}</span>
<div style={{ display: "flex", gap: 6, alignItems: "center" }}>
<span style={{ fontFamily: f.m, fontSize: 10, color: c.dim }}>{cat.duration}</span>
{match && <span style={{ fontFamily: f.m, fontSize: 9, fontWeight: 700, color: c.green, background: c.greenS, padding: "2px 7px", borderRadius: 4 }}>MATCH</span>}
</div>
</div>
<p style={{ fontFamily: f.b, fontSize: 11, color: c.dim, margin: "4px 0 0" }}>{cat.eligibility}</p>
</div>;
})}</Cd>)}
<Cd style={{ background: `${c.yellow}08`, border: `1px solid ${c.yellow}20` }}><p style={{ fontFamily: f.b, fontSize: 11, color: c.yellow, margin: 0 }}> Organizers may change your category if level doesn't match</p></Cd>
</div>}
{/* Fees */}
{tab === "Fees" && ch.fees && <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<Cd><ST>Stage 1: Video Selection</ST>
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 6 }}><span style={{ fontFamily: f.b, fontSize: 13, color: c.text }}>Fee</span><span style={{ fontFamily: f.m, fontSize: 13, fontWeight: 700, color: ch.accent }}>{ch.fees.videoSelection}</span></div>
<p style={{ fontFamily: f.b, fontSize: 11, color: c.yellow, margin: 0 }}>⚠️ Non-refundable even if you don't pass</p>
</Cd>
<Cd><ST>Stage 2: Championship (after passing)</ST>
{Object.entries(ch.fees.championship).map(([t, a]) => <div key={t} style={{ display: "flex", justifyContent: "space-between", padding: "6px 0", borderBottom: `1px solid ${c.brd}` }}><span style={{ fontFamily: f.b, fontSize: 13, color: c.text, textTransform: "capitalize" }}>{t}</span><span style={{ fontFamily: f.m, fontSize: 13, fontWeight: 700, color: ch.accent }}>{a}</span></div>)}
{ch.fees.refundNote && <p style={{ fontFamily: f.b, fontSize: 11, color: c.yellow, margin: "8px 0 0" }}> {ch.fees.refundNote}</p>}
</Cd>
</div>}
{/* Judging */}
{tab === "Judging" && ch.judging && <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<Cd><ST>Scoring (010 each)</ST>{ch.judging.map(j => <div key={j.name} style={{ padding: "8px 0", borderBottom: `1px solid ${c.brd}` }}>
<div style={{ display: "flex", justifyContent: "space-between" }}><span style={{ fontFamily: f.b, fontSize: 13, fontWeight: 600, color: c.text }}>{j.name}</span><span style={{ fontFamily: f.m, fontSize: 11, color: c.purple }}>0{j.max}</span></div>
<p style={{ fontFamily: f.b, fontSize: 11, color: c.dim, margin: "2px 0 0" }}>{j.desc}</p>
</div>)}</Cd>
{ch.penalties && <Cd><ST>Penalties</ST>{ch.penalties.map((p, i) => <div key={i} style={{ display: "flex", justifyContent: "space-between", alignItems: "center", padding: "6px 0", borderBottom: `1px solid ${c.brd}` }}>
<span style={{ fontFamily: f.b, fontSize: 12, color: c.text }}>{p.name}</span>
<span style={{ fontFamily: f.m, fontSize: 10, fontWeight: 700, color: p.consequence ? "#EF4444" : c.yellow, background: p.consequence ? "rgba(239,68,68,0.1)" : c.yellowS, padding: "2px 8px", borderRadius: 4 }}>{p.consequence || `${p.points}`}</span>
</div>)}</Cd>}
</div>}
{/* Rules + Venue */}
{tab === "Rules" && <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
{ch.generalRules && <Cd><ST>General</ST>{ch.generalRules.map((r, i) => <p key={i} style={{ fontFamily: f.b, fontSize: 12, color: c.mid, margin: "0 0 6px" }}> {r}</p>)}</Cd>}
{ch.costumeRules && <Cd><ST>Costume & Shoes</ST>{ch.costumeRules.map((r, i) => <p key={i} style={{ fontFamily: f.b, fontSize: 12, color: c.mid, margin: "0 0 6px" }}> {r}</p>)}</Cd>}
{ch.venueSpecs && <Cd><ST>Stage & Equipment</ST>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10 }}>{Object.entries(ch.venueSpecs).map(([k, v]) => <div key={k} style={{ background: c.bg, borderRadius: 10, padding: 12 }}><p style={{ fontFamily: f.m, fontSize: 9, color: c.dim, margin: "0 0 4px", letterSpacing: 0.5, textTransform: "uppercase" }}>{k.replace(/([A-Z])/g, " $1")}</p><p style={{ fontFamily: f.b, fontSize: 13, fontWeight: 600, color: c.text, margin: 0 }}>{v}</p></div>)}</div>
</Cd>}
</div>}
</div>
</div>;
}
/* ── Progress Screen (separate full view) ── */
function Progress({ ch, onBack }) {
const [done, setDone] = useState({ s1: true, s2: true, s3: true });
const [uploads, setUploads] = useState({});
const [orgConfirmed, setOrgConfirmed] = useState({});
const cnt = Object.values(done).filter(Boolean).length;
const pct = (cnt / STEPS.length) * 100;
const detectColors = { auto: { c: c.green, bg: c.greenS, label: "AUTO" }, email: { c: "#60A5FA", bg: "rgba(96,165,250,0.10)", label: "GMAIL" }, receipt: { c: c.yellow, bg: c.yellowS, label: "UPLOAD" }, manual: { c: c.mid, bg: `${c.mid}15`, label: "MANUAL" } };
const handleUpload = (stepId) => {
setUploads(p => ({ ...p, [stepId]: true }));
};
return <div style={{ flex: 1, overflow: "auto" }}>
<Hdr title="Progress" subtitle={ch.name} onBack={onBack} />
<div style={{ padding: "6px 16px 20px", display: "flex", flexDirection: "column", gap: 12 }}>
{/* Summary */}
<div style={{ background: `linear-gradient(135deg,${ch.accent}15,${ch.accent}05)`, border: `1px solid ${ch.accent}25`, borderRadius: 16, padding: 16 }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 10 }}>
<span style={{ fontFamily: f.d, fontSize: 32, fontWeight: 700, color: ch.accent }}>{Math.round(pct)}%</span>
<span style={{ fontFamily: f.m, fontSize: 11, color: c.dim }}>{cnt} of {STEPS.length} steps</span>
</div>
<div style={{ height: 6, background: `${ch.accent}20`, borderRadius: 3, overflow: "hidden" }}>
<div style={{ height: "100%", width: `${pct}%`, background: `linear-gradient(90deg,${ch.accent},${ch.accent}BB)`, borderRadius: 3, transition: "width 0.3s" }} />
</div>
</div>
{/* Legend */}
<div style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>
{Object.entries(detectColors).map(([k, v]) =>
<span key={k} style={{ fontFamily: f.m, fontSize: 8, fontWeight: 700, color: v.c, background: v.bg, padding: "3px 8px", borderRadius: 4, letterSpacing: 0.5 }}>{v.label}</span>
)}
<span style={{ fontFamily: f.m, fontSize: 8, fontWeight: 700, color: c.purple, background: `${c.purple}15`, padding: "3px 8px", borderRadius: 4, letterSpacing: 0.5 }}>ORG </span>
</div>
{/* Steps */}
<Cd style={{ padding: "4px 10px" }}>
{STEPS.map((s, i) => {
const d = done[s.id];
const isN = !d && cnt === i;
const uploaded = uploads[s.id];
const confirmed = orgConfirmed[s.id];
const dc = detectColors[s.detect];
return <div key={s.id} style={{ padding: "10px 4px", borderBottom: i < STEPS.length - 1 ? `1px solid ${c.brd}` : "none" }}>
{/* Main row */}
<div onClick={() => { if (s.detect === "manual" || s.detect === "auto") setDone(p => ({ ...p, [s.id]: !p[s.id] })); }} style={{ display: "flex", alignItems: "center", gap: 10, cursor: s.detect === "manual" || s.detect === "auto" ? "pointer" : "default", background: isN ? `${ch.accent}06` : "transparent", borderRadius: 8, padding: "2px 0" }}>
<div style={{
width: 26, height: 26, borderRadius: 8, flexShrink: 0,
border: `2px solid ${d ? c.green : isN ? ch.accent : c.brd}`,
background: d ? c.greenS : "transparent",
display: "flex", alignItems: "center", justifyContent: "center",
fontFamily: f.m, fontSize: 11, fontWeight: 700,
color: d ? c.green : isN ? ch.accent : c.dim,
}}>{d ? "✓" : i + 1}</div>
<span style={{ fontSize: 15 }}>{s.icon}</span>
<div style={{ flex: 1 }}>
<span style={{ fontFamily: f.b, fontSize: 12, color: d ? c.dim : isN ? c.text : c.mid, textDecoration: d ? "line-through" : "none", fontWeight: isN ? 600 : 400 }}>{s.label}</span>
</div>
{s.warn && !d && <span style={{ fontSize: 10 }}></span>}
{isN && <span style={{ fontFamily: f.m, fontSize: 8, color: ch.accent, background: c.accentS, padding: "2px 8px", borderRadius: 4, fontWeight: 700 }}>NEXT</span>}
</div>
{/* Detection method + action */}
{!d && <div style={{ marginLeft: 36, marginTop: 6, display: "flex", alignItems: "center", gap: 6, flexWrap: "wrap" }}>
<span style={{ fontFamily: f.m, fontSize: 8, fontWeight: 700, color: dc.c, background: dc.bg, padding: "2px 7px", borderRadius: 4, letterSpacing: 0.3 }}>{dc.label}</span>
<span style={{ fontFamily: f.b, fontSize: 10, color: c.dim }}>{s.detectLabel}</span>
</div>}
{/* Upload action for receipt steps */}
{!d && isN && s.detect === "receipt" && <div style={{ marginLeft: 36, marginTop: 8 }}>
{!uploaded ? (
<div onClick={() => handleUpload(s.id)} style={{ display: "inline-flex", alignItems: "center", gap: 6, padding: "7px 14px", borderRadius: 8, background: `${c.yellow}15`, border: `1px solid ${c.yellow}30`, cursor: "pointer" }}>
<span style={{ fontSize: 13 }}>📸</span>
<span style={{ fontFamily: f.b, fontSize: 11, fontWeight: 600, color: c.yellow }}>Upload receipt</span>
</div>
) : !confirmed ? (
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span style={{ fontFamily: f.m, fontSize: 9, fontWeight: 700, color: c.green, background: c.greenS, padding: "2px 8px", borderRadius: 4 }}>📸 Uploaded</span>
<span style={{ fontFamily: f.b, fontSize: 10, color: c.dim }}>Waiting for org to confirm...</span>
{/* Demo: simulate org confirm */}
<span onClick={() => { setOrgConfirmed(p => ({ ...p, [s.id]: true })); setDone(p => ({ ...p, [s.id]: true })); }} style={{ fontFamily: f.m, fontSize: 8, fontWeight: 700, color: c.purple, background: `${c.purple}15`, padding: "2px 8px", borderRadius: 4, cursor: "pointer" }}>Demo: Org </span>
</div>
) : null}
</div>}
{/* Email detection indicator */}
{!d && isN && s.detect === "email" && <div style={{ marginLeft: 36, marginTop: 8, display: "flex", alignItems: "center", gap: 6 }}>
<span style={{ fontSize: 12 }}>📧</span>
<span style={{ fontFamily: f.b, fontSize: 10, color: c.dim }}>Monitoring Gmail for confirmation...</span>
{/* Demo: simulate detection */}
<span onClick={() => setDone(p => ({ ...p, [s.id]: true }))} style={{ fontFamily: f.m, fontSize: 8, fontWeight: 700, color: "#60A5FA", background: "rgba(96,165,250,0.10)", padding: "2px 8px", borderRadius: 4, cursor: "pointer" }}>Demo: Detected</span>
</div>}
{/* Auto completed indicator */}
{d && s.detect === "auto" && <div style={{ marginLeft: 36, marginTop: 4 }}>
<span style={{ fontFamily: f.m, fontSize: 9, color: c.green }}> Auto-detected</span>
</div>}
{d && s.detect === "email" && <div style={{ marginLeft: 36, marginTop: 4 }}>
<span style={{ fontFamily: f.m, fontSize: 9, color: "#60A5FA" }}> Gmail confirmation received</span>
</div>}
{d && s.detect === "receipt" && <div style={{ marginLeft: 36, marginTop: 4 }}>
<span style={{ fontFamily: f.m, fontSize: 9, color: c.purple }}> Receipt uploaded · Org confirmed</span>
</div>}
</div>;
})}
</Cd>
{/* Auto-detection monitoring */}
<Cd style={{ background: `${c.purple}08`, border: `1px solid ${c.purple}20` }}>
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 12 }}>
<span style={{ fontSize: 20 }}>🤖</span>
<div>
<p style={{ fontFamily: f.b, fontSize: 14, fontWeight: 600, color: c.text, margin: 0 }}>Auto-Detection Active</p>
<p style={{ fontFamily: f.b, fontSize: 11, color: c.dim, margin: "2px 0 0" }}>Monitoring multiple channels</p>
</div>
</div>
{[
{ ch: "Instagram", icon: "📸", desc: "Results photo OCR", status: "Monitoring" },
{ ch: "Gmail", icon: "📧", desc: "Form confirmations & results", status: "Connected" },
{ ch: "Telegram", icon: "💬", desc: "Championship chat", status: "Monitoring" },
].map(x => <div key={x.ch} style={{ display: "flex", alignItems: "center", gap: 10, padding: "10px 12px", background: c.card, borderRadius: 10, marginBottom: 6 }}>
<span style={{ fontSize: 16 }}>{x.icon}</span>
<div style={{ flex: 1 }}>
<p style={{ fontFamily: f.b, fontSize: 12, fontWeight: 500, color: c.text, margin: 0 }}>{x.ch}</p>
<p style={{ fontFamily: f.b, fontSize: 10, color: c.dim, margin: "2px 0 0" }}>{x.desc}</p>
</div>
<span style={{ fontFamily: f.m, fontSize: 9, fontWeight: 700, color: x.status === "Connected" ? c.green : c.yellow, background: x.status === "Connected" ? c.greenS : c.yellowS, padding: "3px 8px", borderRadius: 4 }}>{x.status}</span>
</div>)}
</Cd>
{/* Register */}
{ch.formUrl && ch.status === "registration_open" && <a href={ch.formUrl} target="_blank" rel="noopener noreferrer" style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 6, padding: "14px", borderRadius: 12, background: ch.accent, fontFamily: f.b, fontSize: 14, fontWeight: 700, color: "#fff", textDecoration: "none" }}>
Register Now
</a>}
</div>
</div>;
}
/* ── My Championships ── */
function MyChamps({ onTap, onProgress }) {
const active = MY_REGISTRATIONS.filter(r => r.status === "in_progress");
const planned = MY_REGISTRATIONS.filter(r => r.status === "planned");
const completed = MY_REGISTRATIONS.filter(r => r.status === "completed");
const RegCard = ({ reg }) => {
const ch = CHAMPS.find(c2 => c2.id === reg.champId);
if (!ch) return null;
const pct = (reg.stepsCompleted / STEPS.length) * 100;
const statusMap = { in_progress: { label: "IN PROGRESS", color: c.green, bg: c.greenS }, planned: { label: "PLANNED", color: c.yellow, bg: c.yellowS }, completed: { label: "COMPLETED", color: c.purple, bg: `${c.purple}15` } };
const st = statusMap[reg.status];
return <Cd style={{ padding: 0, overflow: "hidden" }}>
{/* Color accent bar */}
<div style={{ height: 3, background: `linear-gradient(90deg,${ch.accent},${ch.accent}88)` }} />
<div style={{ padding: 14 }}>
{/* Header */}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: 10 }}>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<div style={{ width: 40, height: 40, borderRadius: 10, background: `linear-gradient(135deg,${ch.accent}20,${ch.accent}40)`, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 18 }}>{ch.image}</div>
<div>
<h3 style={{ fontFamily: f.d, fontSize: 15, fontWeight: 700, color: c.text, margin: 0 }}>{ch.name}</h3>
<p style={{ fontFamily: f.b, fontSize: 11, color: c.dim, margin: "1px 0 0" }}>{ch.dates} · {ch.location}</p>
</div>
</div>
<span style={{ fontFamily: f.m, fontSize: 8, fontWeight: 700, letterSpacing: 0.8, color: st.color, background: st.bg, padding: "3px 8px", borderRadius: 4 }}>{st.label}</span>
</div>
{/* Category */}
<div style={{ display: "flex", gap: 6, marginBottom: 12 }}>
<Chip text={reg.discipline} color={ch.accent} bg={`${ch.accent}10`} border={`${ch.accent}25`} />
<Chip text={reg.category} />
</div>
{/* Progress bar */}
<div style={{ marginBottom: 10 }}>
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 4 }}>
<span style={{ fontFamily: f.m, fontSize: 10, color: c.dim }}>{reg.stepsCompleted}/{STEPS.length} steps</span>
<span style={{ fontFamily: f.m, fontSize: 10, color: ch.accent }}>{Math.round(pct)}%</span>
</div>
<div style={{ height: 4, background: c.brd, borderRadius: 2, overflow: "hidden" }}>
<div style={{ height: "100%", width: `${pct}%`, background: ch.accent, borderRadius: 2, transition: "width 0.3s" }} />
</div>
</div>
{/* Next action */}
{reg.nextAction && <div style={{ background: `${ch.accent}08`, border: `1px solid ${ch.accent}15`, borderRadius: 10, padding: "10px 12px", marginBottom: 10 }}>
<p style={{ fontFamily: f.m, fontSize: 9, color: c.dim, margin: "0 0 3px", letterSpacing: 0.5, textTransform: "uppercase" }}>Next step</p>
<p style={{ fontFamily: f.b, fontSize: 12, fontWeight: 600, color: c.text, margin: 0 }}>{STEPS[reg.currentStep - 1]?.icon} {reg.nextAction}</p>
</div>}
{/* Deadline */}
{reg.deadline && <div style={{ display: "flex", alignItems: "center", gap: 6, marginBottom: 12 }}>
<span style={{ fontSize: 12 }}></span>
<span style={{ fontFamily: f.b, fontSize: 11, color: c.yellow }}>Deadline: {reg.deadline}</span>
</div>}
{/* Actions */}
<div style={{ display: "flex", gap: 8 }}>
<div onClick={() => onTap(ch)} style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center", gap: 5, padding: "9px", borderRadius: 10, background: c.bg, border: `1px solid ${c.brd}`, cursor: "pointer" }}>
<span style={{ fontSize: 12 }}></span>
<span style={{ fontFamily: f.b, fontSize: 11, fontWeight: 600, color: c.mid }}>Details</span>
</div>
<div onClick={() => onProgress(ch)} style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center", gap: 5, padding: "9px", borderRadius: 10, background: ch.accent, cursor: "pointer" }}>
<span style={{ fontSize: 12 }}>📋</span>
<span style={{ fontFamily: f.b, fontSize: 11, fontWeight: 700, color: "#fff" }}>Progress</span>
</div>
</div>
</div>
</Cd>;
};
return <div>
<Hdr title="My Championships" subtitle={`${MY_REGISTRATIONS.length} registrations`} />
<div style={{ padding: "6px 16px 16px", display: "flex", flexDirection: "column", gap: 12 }}>
{active.length > 0 && <>
<ST right={`${active.length}`}>Active</ST>
{active.map(r => <RegCard key={r.champId} reg={r} />)}
</>}
{planned.length > 0 && <>
<ST right={`${planned.length}`}>Planned</ST>
{planned.map(r => <RegCard key={r.champId} reg={r} />)}
</>}
{completed.length > 0 && <>
<ST right={`${completed.length}`}>Completed</ST>
{completed.map(r => <RegCard key={r.champId} reg={r} />)}
</>}
{MY_REGISTRATIONS.length === 0 && <div style={{ textAlign: "center", padding: "60px 20px" }}>
<span style={{ fontSize: 40 }}>🔍</span>
<p style={{ fontFamily: f.b, fontSize: 14, color: c.mid, margin: "12px 0 4px" }}>No championships yet</p>
<p style={{ fontFamily: f.b, fontSize: 12, color: c.dim }}>Browse championships and start your journey!</p>
</div>}
</div>
</div>;
}
/* ── Notifications ── */
function Notifications({ onBack }) {
const [notifs, setNotifs] = useState(NOTIFICATIONS);
const markRead = (id) => setNotifs(p => p.map(n => n.id === id ? { ...n, read: true } : n));
const markAllRead = () => setNotifs(p => p.map(n => ({ ...n, read: true })));
const unread = notifs.filter(n => !n.read).length;
const typeConfig = {
category_change: { icon: "🔄", color: c.yellow, label: "Category Changed" },
payment_confirmed: { icon: "✅", color: c.green, label: "Payment Confirmed" },
result: { icon: "🏆", color: c.accent, label: "Results" },
deadline: { icon: "⏰", color: c.yellow, label: "Deadline Reminder" },
style_change: { icon: "🔄", color: c.purple, label: "Style Changed" },
registration_confirmed: { icon: "📋", color: c.green, label: "Registration" },
announcement: { icon: "📢", color: c.blue, label: "Announcement" },
};
return <div>
<Hdr title="Notifications" subtitle={unread > 0 ? `${unread} unread` : "All caught up ✓"} onBack={onBack} right={
unread > 0 ? <div onClick={markAllRead} style={{ fontFamily: f.b, fontSize: 11, color: c.accent, cursor: "pointer", padding: "4px 8px" }}>Read all</div> : null
} />
<div style={{ padding: "6px 16px 16px", display: "flex", flexDirection: "column", gap: 6 }}>
{notifs.length === 0 && <div style={{ textAlign: "center", padding: "60px 20px" }}>
<span style={{ fontSize: 40 }}>🔕</span>
<p style={{ fontFamily: f.b, fontSize: 14, color: c.mid, margin: "12px 0 4px" }}>No notifications</p>
<p style={{ fontFamily: f.b, fontSize: 12, color: c.dim }}>You'll see updates from championships here</p>
</div>}
{notifs.map(n => {
const tc = typeConfig[n.type] || typeConfig.announcement;
return <div key={n.id} onClick={() => markRead(n.id)} style={{
display: "flex", gap: 12, padding: "12px 14px", borderRadius: 12, cursor: "pointer",
background: n.read ? c.card : `${tc.color}08`,
border: `1px solid ${n.read ? c.brd : `${tc.color}20`}`,
}}>
<div style={{ width: 36, height: 36, borderRadius: 10, background: `${tc.color}15`, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 16, flexShrink: 0 }}>{tc.icon}</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 3 }}>
<span style={{ fontFamily: f.m, fontSize: 9, fontWeight: 700, color: tc.color, letterSpacing: 0.5 }}>{tc.label}</span>
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<span style={{ fontFamily: f.m, fontSize: 9, color: c.dim }}>{n.date}</span>
{!n.read && <div style={{ width: 7, height: 7, borderRadius: 4, background: c.accent }} />}
</div>
</div>
<p style={{ fontFamily: f.b, fontSize: 12, color: n.read ? c.mid : c.text, margin: 0, lineHeight: 1.4 }}>{n.message}</p>
<p style={{ fontFamily: f.m, fontSize: 10, color: c.dim, margin: "4px 0 0" }}>{n.champ}</p>
</div>
</div>;
})}
</div>
</div>;
}
/* ── Search ── */
function Search({ onTap }) {
const [q, setQ] = useState("");
const [fl, setFl] = useState("all");
const fs = [{ id: "all", l: "All" }, { id: "registration_open", l: "Open" }, { id: "upcoming", l: "Upcoming" }];
const res = CHAMPS.filter(ch => (!q || ch.name.toLowerCase().includes(q.toLowerCase()) || ch.location.toLowerCase().includes(q.toLowerCase())) && (fl === "all" || ch.status === fl));
return <div>
<Hdr title="Discover" subtitle="Find your next championship" />
<div style={{ padding: "4px 16px 16px" }}>
<div style={{ background: c.card, border: `1px solid ${c.brd}`, borderRadius: 12, padding: "10px 14px", display: "flex", alignItems: "center", gap: 10, marginBottom: 12 }}>
<span style={{ fontSize: 15, opacity: 0.4 }}>🔍</span>
<input type="text" placeholder="Search..." value={q} onChange={e => setQ(e.target.value)} style={{ background: "transparent", border: "none", outline: "none", color: c.text, fontFamily: f.b, fontSize: 13, width: "100%" }} />
</div>
<div style={{ display: "flex", gap: 6, marginBottom: 14 }}>{fs.map(x => <div key={x.id} onClick={() => setFl(x.id)} style={{ fontFamily: f.m, fontSize: 10, fontWeight: 600, color: fl === x.id ? c.accent : c.dim, background: fl === x.id ? c.accentS : c.card, border: `1px solid ${fl === x.id ? `${c.accent}30` : c.brd}`, padding: "5px 12px", borderRadius: 16, cursor: "pointer" }}>{x.l}</div>)}</div>
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>{res.length ? res.map(ch => <ChampCard key={ch.id} ch={ch} onTap={onTap} />) : <div style={{ textAlign: "center", padding: 40 }}><span style={{ fontSize: 28 }}>🤷</span><p style={{ fontFamily: f.b, fontSize: 13, color: c.dim, marginTop: 8 }}>No results</p></div>}</div>
</div>
</div>;
}
/* ── Profile ── */
function Profile() {
return <div>
<Hdr title="Profile" />
<div style={{ padding: "6px 20px 20px" }}>
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", marginBottom: 24 }}>
<div style={{ width: 68, height: 68, borderRadius: 18, background: `linear-gradient(135deg,${c.accent}25,${c.accent}10)`, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 30, marginBottom: 10, border: `2px solid ${c.accent}35` }}>💃</div>
<h2 style={{ fontFamily: f.d, fontSize: 19, fontWeight: 700, color: c.text, margin: "0 0 2px" }}>{USER.name}</h2>
<p style={{ fontFamily: f.m, fontSize: 11, color: c.accent, margin: 0 }}>{USER.instagram}</p>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 8, marginBottom: 20 }}>
{[{ i: "📍", l: "City", v: USER.city }, { i: "💃", l: "Disciplines", v: USER.disciplines.join(", ") }, { i: "📅", l: "Experience", v: `${USER.experienceYears} years` }, { i: "🎓", l: "Instructor", v: USER.isInstructor ? "Yes" : "No" }].map(r =>
<Cd key={r.l} style={{ padding: "11px 14px", display: "flex", alignItems: "center", gap: 12 }}><span style={{ fontSize: 17 }}>{r.i}</span><div><p style={{ fontFamily: f.m, fontSize: 9, color: c.dim, margin: 0, letterSpacing: 0.5, textTransform: "uppercase" }}>{r.l}</p><p style={{ fontFamily: f.b, fontSize: 13, color: c.text, margin: "2px 0 0" }}>{r.v}</p></div></Cd>
)}
</div>
<ST>Eligible Categories</ST>
<Cd style={{ marginBottom: 20 }}>{["Amateur (Exotic)", "Semi-Pro (Exotic)", "Duets & Groups", "Amateur (Pole Art)", "Semi-Pro (Pole Art)"].map(cat =>
<div key={cat} style={{ display: "flex", alignItems: "center", gap: 8, padding: "7px 0", borderBottom: `1px solid ${c.brd}` }}><span style={{ fontFamily: f.m, fontSize: 9, fontWeight: 700, color: c.green, background: c.greenS, padding: "2px 7px", borderRadius: 4 }}></span><span style={{ fontFamily: f.b, fontSize: 12, color: c.text }}>{cat}</span></div>
)}</Cd>
<ST>Stats</ST>
<div style={{ display: "flex", gap: 8, marginBottom: 20 }}>{[{ n: "2", l: "Champs", co: c.accent }, { n: "1", l: "Passed", co: c.green }, { n: "1", l: "Pending", co: c.yellow }].map(s =>
<div key={s.l} style={{ flex: 1, background: c.card, border: `1px solid ${c.brd}`, borderRadius: 12, padding: "14px 8px", textAlign: "center" }}><p style={{ fontFamily: f.d, fontSize: 24, fontWeight: 700, color: s.co, margin: 0 }}>{s.n}</p><p style={{ fontFamily: f.m, fontSize: 9, color: c.dim, margin: "4px 0 0", textTransform: "uppercase" }}>{s.l}</p></div>
)}</div>
<div style={{ background: c.card, border: `1px solid ${c.brd}`, borderRadius: 12, overflow: "hidden" }}>{["Edit Profile", "Competition History", "Notifications", "Settings", "Log Out"].map((x, i, a) =>
<div key={x} style={{ padding: "13px 16px", fontFamily: f.b, fontSize: 13, color: x === "Log Out" ? "#EF4444" : c.text, borderBottom: i < a.length - 1 ? `1px solid ${c.brd}` : "none", cursor: "pointer", display: "flex", justifyContent: "space-between" }}>{x}<span style={{ color: c.dim }}></span></div>
)}</div>
</div>
</div>;
}
/* ── App Shell ── */
export default function App() {
const [scr, setScr] = useState("home");
const [sel, setSel] = useState(null);
const [prev, setPrev] = useState("home");
const go = (s, ch) => { setPrev(scr); setScr(s); if (ch) setSel(ch); };
const goBack = () => { setScr(prev || "home"); setSel(null); };
const render = () => {
if (scr === "progress" && sel) return <Progress ch={sel} onBack={() => go("detail")} />;
if (scr === "detail" && sel) return <Detail ch={sel} onBack={goBack} onProgress={ch => go("progress", ch)} />;
if (scr === "notifications") return <Notifications onBack={() => go("home")} />;
if (scr === "my") return <MyChamps onTap={ch => go("detail", ch)} onProgress={ch => go("progress", ch)} />;
if (scr === "search") return <Search onTap={ch => go("detail", ch)} />;
if (scr === "profile") return <Profile />;
return <Home onTap={ch => go("detail", ch)} onNotifications={() => go("notifications")} />;
};
const showNav = scr === "home" || scr === "search" || scr === "profile" || scr === "my";
return <div style={{ display: "flex", justifyContent: "center", alignItems: "center", minHeight: "100vh", background: "#030206", padding: 20, fontFamily: f.b }}>
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;600;700&family=DM+Sans:wght@400;500;600&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet" />
<style>{`*::-webkit-scrollbar{display:none}*{scrollbar-width:none}`}</style>
<div style={{ width: 375, height: 740, background: c.bg, borderRadius: 36, overflow: "hidden", display: "flex", flexDirection: "column", border: `1.5px solid ${c.brd}`, boxShadow: `0 0 80px rgba(212,20,90,0.06),0 20px 40px rgba(0,0,0,0.5)` }}>
<div style={{ padding: "8px 24px", display: "flex", justifyContent: "space-between", alignItems: "center", flexShrink: 0 }}>
<span style={{ fontFamily: f.m, fontSize: 11, color: c.dim }}>9:41</span>
<div style={{ width: 100, height: 28, background: "#000", borderRadius: 14 }} />
<span style={{ fontFamily: f.m, fontSize: 11, color: c.dim }}></span>
</div>
<div style={{ flex: 1, overflow: "auto", minHeight: 0 }}>{render()}</div>
{showNav && <Nav active={scr} onChange={s => { setScr(s); setSel(null); }} />}
</div>
</div>;
}

View File

@@ -0,0 +1,683 @@
import { useState } from "react";
/* ── Data ── */
const ORG = { name: "Zero Gravity Team", instagram: "@zerogravity_pole", logo: "💃" };
const makeCh = (overrides) => ({
id: "", name: "", subtitle: "", eventDate: "", regStart: "", regEnd: "", location: "", venue: "", accent: "#D4145A", image: "💃", status: "draft",
disciplines: [], styles: [], fees: null, judging: [], penalties: [], judges: [], rules: [], costumeRules: [], members: [],
formUrl: "", rulesUrl: "",
configured: { info: false, categories: false, fees: false, rules: false, judging: false },
...overrides,
});
const INITIAL_CHAMPS = [
makeCh({
id: "ch1", name: "Zero Gravity", subtitle: "International Pole Exotic Championship",
eventDate: "May 30, 2026", regStart: "Feb 1, 2026", regEnd: "Apr 22, 2026", location: "Minsk, Belarus", venue: "Prime Hall", status: "registration_open", accent: "#D4145A", image: "💃",
disciplines: [
{ name: "Exotic Pole Dance", levels: ["Beginners", "Amateur", "Semi-Pro", "Profi", "Elite", "Duets & Groups"] },
{ name: "Pole Art", levels: ["Amateur", "Semi-Pro", "Profi"] },
],
styles: ["Classic", "Flow", "Theater"],
fees: { videoSelection: "50 BYN / 1,500 RUB", solo: "280 BYN / 7,500 RUB", duet: "210 BYN / 5,800 RUB pp", group: "190 BYN / 4,500 RUB pp" },
judging: [{ name: "Image", max: 10 }, { name: "Artistry", max: 10 }, { name: "Choreography", max: 10 }, { name: "Musicality", max: 10 }, { name: "Technique", max: 10 }, { name: "Overall", max: 10 }],
penalties: [{ name: "Missed element", val: "-2" }, { name: "Fall", val: "-2" }, { name: "Leaving stage", val: "DQ" }, { name: "Exposure", val: "DQ" }],
judges: [
{ id: "j1", name: "Anastasia Skukhtorova", instagram: "@skukhtorova", bio: "World Pole Art Champion. International judge with 10+ years experience." },
{ id: "j2", name: "Marion Crampe", instagram: "@marioncrampe", bio: "Pole Art legend, multiple international championship winner and judge." },
{ id: "j3", name: "Dmitry Politov", instagram: "@dmitry_politov", bio: "World Pole Sports Champion. Certified IPSF judge." },
],
rules: ["Must be 18+", "Valid life & health insurance", "No lotions/bronzers 24h before", "Grip aids allowed (no wax/rosin)", "Judges' decision is final"],
costumeRules: ["Neat and well-fitted", "No advertising", "No spikes/sharp objects", "Specialized shoes for Exotic"],
configured: { info: true, categories: true, fees: true, rules: true, judging: true },
members: [
{ id: "m1", name: "Alex Petrova", instagram: "@alex_pole", level: "Semi-Pro", style: "Classic", discipline: "Exotic Pole Dance", type: "solo", stepsCompleted: 3, videoUrl: "https://youtube.com/...", feePaid: false, receiptUploaded: true, insuranceUploaded: false, passed: null, city: "Moscow" },
{ id: "m2", name: "Maria Ivanova", instagram: "@maria_exotic", level: "Amateur", style: "Flow", discipline: "Exotic Pole Dance", type: "solo", stepsCompleted: 5, videoUrl: "https://youtube.com/...", feePaid: true, receiptUploaded: true, insuranceUploaded: true, passed: true, city: "Minsk" },
{ id: "m3", name: "Elena Kozlova", instagram: "@elena.pole", level: "Profi", style: "Theater", discipline: "Exotic Pole Dance", type: "solo", stepsCompleted: 5, videoUrl: "https://youtube.com/...", feePaid: true, receiptUploaded: true, insuranceUploaded: false, passed: true, city: "St. Petersburg" },
{ id: "m4", name: "Daria Sokolova", instagram: "@daria_art", level: "Amateur", style: "Classic", discipline: "Pole Art", type: "solo", stepsCompleted: 4, videoUrl: "https://youtube.com/...", feePaid: false, receiptUploaded: true, insuranceUploaded: false, passed: null, city: "Kyiv" },
{ id: "m5", name: "Anna Belova", instagram: "@anna.b_pole", level: "Beginners", style: "Flow", discipline: "Exotic Pole Dance", type: "solo", stepsCompleted: 2, videoUrl: null, feePaid: false, receiptUploaded: false, insuranceUploaded: false, passed: null, city: "Minsk" },
{ id: "m6", name: "Olga Morozova", instagram: "@olga_exotic", level: "Elite", style: "Classic", discipline: "Exotic Pole Dance", type: "solo", stepsCompleted: 5, videoUrl: "https://youtube.com/...", feePaid: true, receiptUploaded: true, insuranceUploaded: true, passed: false, city: "Moscow" },
{ id: "m7", name: "Katya & Nina", instagram: "@katya_nina", level: "Semi-Pro", style: "Theater", discipline: "Exotic Pole Dance", type: "duet", stepsCompleted: 5, videoUrl: "https://youtube.com/...", feePaid: true, receiptUploaded: true, insuranceUploaded: false, passed: null, city: "Kazan" },
],
}),
makeCh({
id: "ch2", name: "Pole Star", subtitle: "National Pole Championship",
eventDate: "Jul 1213, 2026", regStart: "", regEnd: "", location: "Moscow, Russia", venue: "Crystal Hall", status: "draft", accent: "#7C3AED", image: "⭐",
configured: { info: true, categories: false, fees: false, rules: false, judging: false },
members: [],
}),
];
/* ── Theme ── */
const c = { bg: "#08070D", card: "#12111A", cardH: "#1A1926", brd: "#1F1E2E", text: "#F2F0FA", dim: "#5E5C72", mid: "#8F8DA6", accent: "#D4145A", accentS: "rgba(212,20,90,0.10)", green: "#10B981", greenS: "rgba(16,185,129,0.10)", yellow: "#F59E0B", yellowS: "rgba(245,158,11,0.10)", purple: "#8B5CF6", purpleS: "rgba(139,92,246,0.10)", blue: "#60A5FA", blueS: "rgba(96,165,250,0.10)", red: "#EF4444", redS: "rgba(239,68,68,0.10)" };
const f = { d: "'Playfair Display',Georgia,serif", b: "'DM Sans','Segoe UI',sans-serif", m: "'JetBrains Mono',monospace" };
/* ── Shared UI ── */
const Cd = ({ children, style: s }) => <div style={{ background: c.card, border: `1px solid ${c.brd}`, borderRadius: 14, padding: 16, ...s }}>{children}</div>;
const ST = ({ children, right }) => <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", margin: "0 0 10px" }}><h3 style={{ fontFamily: f.d, fontSize: 14, fontWeight: 700, color: c.mid, margin: 0 }}>{children}</h3>{right}</div>;
const Bg = ({ label, color, bg }) => <span style={{ fontFamily: f.m, fontSize: 8, fontWeight: 700, letterSpacing: 0.8, color, background: bg, padding: "3px 8px", borderRadius: 4 }}>{label}</span>;
function Hdr({ title, subtitle, onBack, right }) {
return <div style={{ padding: "14px 20px 6px", display: "flex", alignItems: "center", gap: 12 }}>
{onBack && <div onClick={onBack} style={{ width: 32, height: 32, borderRadius: 8, background: c.card, border: `1px solid ${c.brd}`, display: "flex", alignItems: "center", justifyContent: "center", cursor: "pointer", fontSize: 15, color: c.text }}></div>}
<div style={{ flex: 1, minWidth: 0 }}><h1 style={{ fontFamily: f.d, fontSize: 20, fontWeight: 700, color: c.text, margin: 0 }}>{title}</h1>{subtitle && <p style={{ fontFamily: f.b, fontSize: 11, color: c.dim, margin: "2px 0 0", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{subtitle}</p>}</div>
{right}
</div>;
}
function Tabs({ tabs, active, onChange, accent: ac }) {
return <div style={{ display: "flex", gap: 3, overflowX: "auto", paddingBottom: 2, marginBottom: 14, scrollbarWidth: "none" }}>
{tabs.map(t => <div key={t.id} onClick={() => onChange(t.id)} style={{ fontFamily: f.m, fontSize: 9, fontWeight: 600, letterSpacing: 0.3, display: "flex", alignItems: "center", gap: 4, color: active === t.id ? ac || c.accent : c.dim, background: active === t.id ? `${ac || c.accent}15` : "transparent", border: `1px solid ${active === t.id ? `${ac || c.accent}30` : "transparent"}`, padding: "5px 10px", borderRadius: 16, cursor: "pointer", whiteSpace: "nowrap" }}>
{t.done !== undefined && <span style={{ width: 6, height: 6, borderRadius: 3, background: t.done ? c.green : c.yellow, flexShrink: 0 }} />}
{t.label}
</div>)}
</div>;
}
function Nav({ active, onChange }) {
return <div style={{ display: "flex", justifyContent: "space-around", padding: "10px 0 8px", borderTop: `1px solid ${c.brd}`, background: c.bg, flexShrink: 0 }}>
{[{ id: "dash", i: "📊", l: "Dashboard" }, { id: "orgSettings", i: "⚙️", l: "Settings" }].map(x =>
<div key={x.id} onClick={() => onChange(x.id)} style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 2, cursor: "pointer", opacity: active === x.id ? 1 : 0.35 }}><span style={{ fontSize: 18 }}>{x.i}</span><span style={{ fontFamily: f.m, fontSize: 8, color: c.text, letterSpacing: 0.3 }}>{x.l}</span></div>
)}
</div>;
}
function Input({ label, value, onChange, placeholder }) {
return <div style={{ marginBottom: 12 }}>
<p style={{ fontFamily: f.m, fontSize: 9, color: c.dim, margin: "0 0 6px", letterSpacing: 0.5, textTransform: "uppercase" }}>{label}</p>
<input type="text" value={value || ""} onChange={e => onChange(e.target.value)} placeholder={placeholder} style={{ width: "100%", padding: "10px 12px", borderRadius: 10, background: c.bg, border: `1px solid ${c.brd}`, color: c.text, fontFamily: f.b, fontSize: 13, outline: "none", boxSizing: "border-box" }} />
</div>;
}
function TagEditor({ items, onAdd, onRemove, color, placeholder, addLabel }) {
const [val, setVal] = useState("");
const submit = () => { if (val.trim()) { onAdd(val.trim()); setVal(""); } };
return <div>
<div style={{ display: "flex", gap: 6, flexWrap: "wrap", marginBottom: 8 }}>
{items.map((item, i) => <div key={item} style={{ display: "flex", alignItems: "center", gap: 4, padding: "4px 10px", borderRadius: 16, background: `${color}10`, border: `1px solid ${color}25` }}>
<span style={{ fontFamily: f.b, fontSize: 11, color: c.text }}>{item}</span>
<span onClick={() => onRemove(i)} style={{ fontSize: 10, color: c.dim, cursor: "pointer", lineHeight: 1 }}>×</span>
</div>)}
{items.length === 0 && <span style={{ fontFamily: f.b, fontSize: 11, color: c.dim, fontStyle: "italic" }}>None added yet</span>}
</div>
<div style={{ display: "flex", gap: 6 }}>
<input value={val} onChange={e => setVal(e.target.value)} placeholder={placeholder} onKeyDown={e => e.key === "Enter" && submit()} style={{ flex: 1, padding: "8px 12px", borderRadius: 8, background: c.bg, border: `1px solid ${c.brd}`, color: c.text, fontFamily: f.b, fontSize: 12, outline: "none" }} />
<div onClick={submit} style={{ padding: "8px 14px", borderRadius: 8, background: color, color: "#fff", fontFamily: f.b, fontSize: 12, fontWeight: 700, cursor: "pointer" }}>+</div>
</div>
</div>;
}
/* ── Dashboard ── */
function Dashboard({ champs, org, onChampTap, onCreateChamp }) {
return <div>
<Hdr title="Dashboard" subtitle={org.name} right={
<div style={{ width: 36, height: 36, borderRadius: 10, background: `linear-gradient(135deg,${c.accent}25,${c.accent}10)`, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 18 }}>{org.logo}</div>
} />
<div style={{ padding: "6px 16px 16px", display: "flex", flexDirection: "column", gap: 10 }}>
<div onClick={onCreateChamp} style={{ display: "flex", alignItems: "center", gap: 12, padding: "14px 16px", borderRadius: 14, background: `linear-gradient(135deg,${c.accent}15,${c.accent}08)`, border: `1px solid ${c.accent}30`, cursor: "pointer" }}>
<div style={{ width: 42, height: 42, borderRadius: 12, background: c.accent, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 22, color: "#fff", fontWeight: 700, flexShrink: 0 }}>+</div>
<div style={{ flex: 1 }}><p style={{ fontFamily: f.b, fontSize: 14, fontWeight: 700, color: c.text, margin: 0 }}>New Championship</p><p style={{ fontFamily: f.b, fontSize: 11, color: c.dim, margin: "2px 0 0" }}>Quick create configure later</p></div>
<span style={{ color: c.accent, fontSize: 16 }}></span>
</div>
<ST right={<span style={{ fontFamily: f.m, fontSize: 10, color: c.dim }}>{champs.length} events</span>}>Your Championships</ST>
{champs.map(ch => {
const cfg = ch.configured;
const done = Object.values(cfg).filter(Boolean).length;
const total = Object.keys(cfg).length;
const ready = done === total;
const stMap = { registration_open: { l: "LIVE", c: c.green, b: c.greenS }, draft: { l: `SETUP ${done}/${total}`, c: c.yellow, b: c.yellowS } };
const st = stMap[ch.status] || stMap.draft;
return <div key={ch.id} onClick={() => onChampTap(ch)} style={{ background: c.card, border: `1px solid ${c.brd}`, borderRadius: 14, overflow: "hidden", cursor: "pointer" }}>
<div style={{ height: 3, background: `linear-gradient(90deg,${ch.accent},${ch.accent}88)` }} />
<div style={{ padding: "12px 14px" }}>
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 6 }}>
<div style={{ width: 38, height: 38, borderRadius: 10, background: `linear-gradient(135deg,${ch.accent}20,${ch.accent}40)`, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 17, flexShrink: 0 }}>{ch.image}</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<h3 style={{ fontFamily: f.d, fontSize: 15, fontWeight: 700, color: c.text, margin: 0 }}>{ch.name}</h3>
<Bg label={st.l} color={st.c} bg={st.b} />
</div>
<p style={{ fontFamily: f.b, fontSize: 11, color: c.dim, margin: "2px 0 0" }}>{ch.eventDate} · {ch.location}</p>
</div>
</div>
{/* Readiness bar */}
{!ready && <div style={{ marginTop: 6 }}>
<div style={{ height: 4, background: c.brd, borderRadius: 2, overflow: "hidden" }}>
<div style={{ height: "100%", width: `${(done / total) * 100}%`, background: ch.accent, borderRadius: 2 }} />
</div>
<div style={{ display: "flex", gap: 6, marginTop: 6, flexWrap: "wrap" }}>
{Object.entries(cfg).map(([k, v]) => <span key={k} style={{ fontFamily: f.m, fontSize: 8, color: v ? c.green : c.yellow, letterSpacing: 0.3 }}>{v ? "✓" : "○"} {k}</span>)}
</div>
</div>}
{/* Stats for live champs */}
{ch.status === "registration_open" && <div style={{ display: "flex", gap: 10, paddingTop: 8, marginTop: 6, borderTop: `1px solid ${c.brd}` }}>
{[{ n: ch.members.length, l: "Members", co: c.mid }, { n: ch.members.filter(m => m.passed === true).length, l: "Passed", co: c.green }, { n: ch.members.filter(m => m.videoUrl && m.passed === null).length, l: "Pending", co: c.yellow }].map(s =>
<div key={s.l} style={{ flex: 1, textAlign: "center" }}><p style={{ fontFamily: f.d, fontSize: 16, fontWeight: 700, color: s.co, margin: 0 }}>{s.n}</p><p style={{ fontFamily: f.m, fontSize: 7, color: c.dim, margin: "2px 0 0", textTransform: "uppercase" }}>{s.l}</p></div>
)}
</div>}
</div>
</div>;
})}
</div>
</div>;
}
/* ── Championship Detail (configurable tabs) ── */
function ChampDetail({ ch: initial, onBack, onMemberTap, onUpdate }) {
const [ch, setCh] = useState(initial);
const [tab, setTab] = useState("Overview");
const [members, setMembers] = useState(ch.members);
const [memFilter, setMemFilter] = useState("all");
const [memSearch, setMemSearch] = useState("");
const [editing, setEditing] = useState(null);
const [newJudge, setNewJudge] = useState({ name: "", instagram: "", bio: "" });
const upd = (key, val) => setCh(p => ({ ...p, [key]: val }));
const markDone = (section) => setCh(p => ({ ...p, configured: { ...p.configured, [section]: true } }));
const allDone = Object.values(ch.configured).every(Boolean);
const stats = {
total: members.length, videoSent: members.filter(m => m.videoUrl).length,
passed: members.filter(m => m.passed === true).length, failed: members.filter(m => m.passed === false).length,
pending: members.filter(m => m.videoUrl && m.passed === null).length, feePaid: members.filter(m => m.feePaid).length,
receipts: members.filter(m => m.receiptUploaded && !m.feePaid).length,
};
const decide = (id, pass) => setMembers(p => p.map(m => m.id === id ? { ...m, passed: pass } : m));
const tabDefs = [
{ id: "Overview", label: "Overview" },
{ id: "Categories", label: "Categories", done: ch.configured.categories },
{ id: "Fees", label: "Fees", done: ch.configured.fees },
{ id: "Rules", label: "Rules", done: ch.configured.rules },
{ id: "Judges", label: "Judges", done: ch.configured.judging },
...(ch.status === "registration_open" ? [{ id: "Members", label: `Members (${members.length})` }, { id: "Results", label: "Results" }] : []),
];
const memFilters = [
{ id: "all", l: "All", n: members.length }, { id: "receipts", l: "📸 Receipts", n: stats.receipts },
{ id: "videos", l: "🎬 Videos", n: stats.pending }, { id: "passed", l: "✅ Passed", n: stats.passed },
];
const filteredMem = members.filter(m => {
const q = !memSearch || m.name.toLowerCase().includes(memSearch.toLowerCase()) || m.instagram.toLowerCase().includes(memSearch.toLowerCase());
if (!q) return false;
if (memFilter === "receipts") return m.receiptUploaded && !m.feePaid;
if (memFilter === "videos") return m.videoUrl && m.passed === null;
if (memFilter === "passed") return m.passed === true;
return true;
});
return <div style={{ flex: 1, overflow: "auto" }}>
<Hdr title={ch.name} subtitle={ch.subtitle || ch.location} onBack={onBack} right={
ch.status === "draft" && allDone ? <div onClick={() => setCh(p => ({ ...p, status: "registration_open" }))} style={{ fontFamily: f.b, fontSize: 10, fontWeight: 700, color: "#fff", background: c.green, padding: "6px 12px", borderRadius: 8, cursor: "pointer" }}>🚀 Go Live</div> : null
} />
<div style={{ padding: "4px 16px 20px" }}>
<Tabs tabs={tabDefs} active={tab} onChange={setTab} accent={ch.accent} />
{/* ═══ OVERVIEW ═══ */}
{tab === "Overview" && <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
{/* Setup progress */}
{ch.status === "draft" && <Cd style={{ background: `${c.yellow}06`, border: `1px solid ${c.yellow}20` }}>
<ST right={<span style={{ fontFamily: f.m, fontSize: 10, color: c.yellow }}>{Object.values(ch.configured).filter(Boolean).length}/{Object.keys(ch.configured).length}</span>}> Setup Progress</ST>
{Object.entries(ch.configured).map(([section, done]) => {
const tabMap = { info: "Overview", categories: "Categories", fees: "Fees", rules: "Rules", judging: "Judges" };
return <div key={section} onClick={() => !done && setTab(tabMap[section] || section)} style={{ display: "flex", alignItems: "center", gap: 10, padding: "8px 0", borderBottom: `1px solid ${c.brd}`, cursor: done ? "default" : "pointer" }}>
<div style={{ width: 22, height: 22, borderRadius: 6, border: `2px solid ${done ? c.green : c.yellow}`, background: done ? c.greenS : "transparent", display: "flex", alignItems: "center", justifyContent: "center", fontFamily: f.m, fontSize: 10, fontWeight: 700, color: done ? c.green : c.yellow }}>{done ? "✓" : ""}</div>
<span style={{ fontFamily: f.b, fontSize: 12, color: done ? c.dim : c.text, textTransform: "capitalize", textDecoration: done ? "line-through" : "none", flex: 1 }}>{section === "judging" ? "judges" : section}</span>
{!done && <span style={{ fontFamily: f.b, fontSize: 10, color: ch.accent }}>Configure </span>}
</div>;
})}
{allDone && <div onClick={() => setCh(p => ({ ...p, status: "registration_open" }))} style={{ marginTop: 10, padding: "12px", borderRadius: 10, background: c.green, textAlign: "center", cursor: "pointer" }}>
<span style={{ fontFamily: f.b, fontSize: 13, fontWeight: 700, color: "#fff" }}>🚀 Open Registration</span>
</div>}
</Cd>}
{/* Info (always editable) */}
<Cd>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 10 }}>
<h3 style={{ fontFamily: f.d, fontSize: 14, fontWeight: 700, color: c.mid, margin: 0 }}>Event Info</h3>
<div onClick={() => setEditing(editing === "info" ? null : "info")} style={{ fontFamily: f.b, fontSize: 10, fontWeight: 600, color: editing === "info" ? c.dim : "#fff", background: editing === "info" ? "transparent" : ch.accent, border: `1px solid ${editing === "info" ? c.brd : ch.accent}`, padding: "3px 10px", borderRadius: 6, cursor: "pointer" }}>{editing === "info" ? "✕ Close" : "✎ Edit"}</div>
</div>
{editing === "info" ? <>
<Input label="Name" value={ch.name} onChange={v => upd("name", v)} placeholder="Championship name" />
<Input label="Subtitle" value={ch.subtitle} onChange={v => upd("subtitle", v)} placeholder="Subtitle" />
<Input label="Event Date" value={ch.eventDate} onChange={v => upd("eventDate", v)} placeholder="e.g. May 30, 2026" />
<Input label="Location" value={ch.location} onChange={v => upd("location", v)} placeholder="City, Country" />
<Input label="Venue" value={ch.venue} onChange={v => upd("venue", v)} placeholder="Venue name" />
<div style={{ height: 1, background: c.brd, margin: "4px 0 8px" }} />
<p style={{ fontFamily: f.m, fontSize: 9, color: c.dim, margin: "0 0 6px", letterSpacing: 0.5 }}>REGISTRATION PERIOD</p>
<div style={{ display: "flex", gap: 8 }}>
<div style={{ flex: 1 }}><Input label="Opens" value={ch.regStart} onChange={v => upd("regStart", v)} placeholder="e.g. Feb 1, 2026" /></div>
<div style={{ flex: 1 }}><Input label="Closes" value={ch.regEnd} onChange={v => upd("regEnd", v)} placeholder="e.g. Apr 22, 2026" /></div>
</div>
<p style={{ fontFamily: f.m, fontSize: 9, color: c.yellow, margin: "-6px 0 8px" }}> Registration close date must be before event date</p>
<div onClick={() => { markDone("info"); setEditing(null); }} style={{ padding: "10px", borderRadius: 8, background: c.green, textAlign: "center", cursor: "pointer" }}><span style={{ fontFamily: f.b, fontSize: 12, fontWeight: 700, color: "#fff" }}> Save</span></div>
</> : <div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<div style={{ display: "flex", flexWrap: "wrap", gap: "6px 16px" }}>
<span style={{ fontFamily: f.b, fontSize: 11, color: c.mid }}>📅 {ch.eventDate || "—"}</span>
<span style={{ fontFamily: f.b, fontSize: 11, color: c.mid }}>📍 {ch.venue ? `${ch.venue}, ` : ""}{ch.location || "—"}</span>
</div>
{(ch.regStart || ch.regEnd) && <div style={{ display: "flex", alignItems: "center", gap: 6, padding: "6px 10px", borderRadius: 8, background: `${c.green}08`, border: `1px solid ${c.green}15` }}>
<span style={{ fontFamily: f.m, fontSize: 10, color: c.green }}>📋 Registration:</span>
<span style={{ fontFamily: f.b, fontSize: 11, color: c.text }}>{ch.regStart || "?"} {ch.regEnd || "?"}</span>
</div>}
</div>}
</Cd>
{/* Stats (only for live) */}
{ch.status === "registration_open" && <>
<div style={{ display: "flex", gap: 6 }}>
{[{ n: stats.total, l: "Members", co: c.mid }, { n: stats.passed, l: "Passed", co: c.green }, { n: stats.failed, l: "Failed", co: c.red }, { n: stats.pending, l: "Pending", co: c.yellow }].map(s =>
<div key={s.l} style={{ flex: 1, background: c.card, border: `1px solid ${c.brd}`, borderRadius: 12, padding: "10px 6px", textAlign: "center" }}>
<p style={{ fontFamily: f.d, fontSize: 20, fontWeight: 700, color: s.co, margin: 0 }}>{s.n}</p>
<p style={{ fontFamily: f.m, fontSize: 7, color: c.dim, margin: "2px 0 0", textTransform: "uppercase" }}>{s.l}</p>
</div>
)}
</div>
<Cd>
<ST right={<span style={{ fontFamily: f.m, fontSize: 10, color: c.dim }}>tap to view</span>}> Needs Action</ST>
{[
{ l: "Receipts to review", n: stats.receipts, icon: "📸", co: c.yellow, go: "Members" },
{ l: "Videos to review", n: stats.pending, icon: "🎬", co: c.blue, go: "Results" },
].map(a => <div key={a.l} onClick={() => { if (a.go === "Members") setMemFilter("receipts"); setTab(a.go); }} style={{ display: "flex", alignItems: "center", gap: 10, padding: "10px 0", borderBottom: `1px solid ${c.brd}`, cursor: "pointer" }}>
<span style={{ fontSize: 16 }}>{a.icon}</span>
<span style={{ fontFamily: f.b, fontSize: 13, color: c.text, flex: 1 }}>{a.l}</span>
<span style={{ fontFamily: f.m, fontSize: 14, fontWeight: 700, color: a.co }}>{a.n}</span>
<span style={{ color: c.dim }}></span>
</div>)}
</Cd>
</>}
</div>}
{/* ═══ CATEGORIES ═══ */}
{tab === "Categories" && <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<Cd>
<ST right={ch.configured.categories ? <Bg label="✓ DONE" color={c.green} bg={c.greenS} /> : null}>Levels</ST>
<TagEditor items={ch.disciplines.flatMap(d => d.levels).filter((v, i, a) => a.indexOf(v) === i)} color={ch.accent} placeholder="Add level (e.g. Amateur)"
onAdd={v => { const d = ch.disciplines.length ? [...ch.disciplines] : [{ name: "Exotic Pole Dance", levels: [] }]; d[0] = { ...d[0], levels: [...d[0].levels, v] }; upd("disciplines", d); }}
onRemove={i => { const all = ch.disciplines.flatMap(d => d.levels).filter((v, idx, a) => a.indexOf(v) === idx); const rm = all[i]; const d = ch.disciplines.map(d2 => ({ ...d2, levels: d2.levels.filter(l => l !== rm) })); upd("disciplines", d); }} />
</Cd>
<Cd>
<ST>Styles</ST>
<TagEditor items={ch.styles} color={c.purple} placeholder="Add style (e.g. Classic)"
onAdd={v => upd("styles", [...ch.styles, v])} onRemove={i => upd("styles", ch.styles.filter((_, j) => j !== i))} />
</Cd>
{!ch.configured.categories && (ch.disciplines.some(d => d.levels.length > 0) && ch.styles.length > 0) && <div onClick={() => markDone("categories")} style={{ padding: "12px", borderRadius: 10, background: c.green, textAlign: "center", cursor: "pointer" }}>
<span style={{ fontFamily: f.b, fontSize: 13, fontWeight: 700, color: "#fff" }}> Mark Categories as Done</span>
</div>}
</div>}
{/* ═══ FEES ═══ */}
{tab === "Fees" && <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<Cd>
<ST right={ch.configured.fees ? <Bg label="✓ DONE" color={c.green} bg={c.greenS} /> : null}>Video Selection Fee</ST>
<Input label="Fee amount" value={ch.fees?.videoSelection || ""} onChange={v => upd("fees", { ...ch.fees, videoSelection: v })} placeholder="e.g. 50 BYN / 1,500 RUB" />
</Cd>
<Cd>
<ST>Championship Fees</ST>
<Input label="Solo" value={ch.fees?.solo || ""} onChange={v => upd("fees", { ...ch.fees, solo: v })} placeholder="e.g. 280 BYN" />
<Input label="Duet (per person)" value={ch.fees?.duet || ""} onChange={v => upd("fees", { ...ch.fees, duet: v })} placeholder="e.g. 210 BYN" />
<Input label="Group (per person)" value={ch.fees?.group || ""} onChange={v => upd("fees", { ...ch.fees, group: v })} placeholder="e.g. 190 BYN" />
</Cd>
{!ch.configured.fees && ch.fees?.videoSelection && <div onClick={() => markDone("fees")} style={{ padding: "12px", borderRadius: 10, background: c.green, textAlign: "center", cursor: "pointer" }}>
<span style={{ fontFamily: f.b, fontSize: 13, fontWeight: 700, color: "#fff" }}> Mark Fees as Done</span>
</div>}
</div>}
{/* ═══ RULES ═══ */}
{tab === "Rules" && <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<Cd>
<ST right={ch.configured.rules ? <Bg label="✓ DONE" color={c.green} bg={c.greenS} /> : null}>General Rules</ST>
<TagEditor items={ch.rules} color={c.blue} placeholder="Add rule" onAdd={v => upd("rules", [...ch.rules, v])} onRemove={i => upd("rules", ch.rules.filter((_, j) => j !== i))} />
</Cd>
<Cd>
<ST>Costume Rules</ST>
<TagEditor items={ch.costumeRules} color={c.yellow} placeholder="Add costume rule" onAdd={v => upd("costumeRules", [...ch.costumeRules, v])} onRemove={i => upd("costumeRules", ch.costumeRules.filter((_, j) => j !== i))} />
</Cd>
<Cd>
<ST>Scoring Criteria (010)</ST>
{ch.judging.map((j, i) => <div key={i} style={{ display: "flex", alignItems: "center", gap: 8, padding: "6px 0", borderBottom: `1px solid ${c.brd}` }}>
<span style={{ fontFamily: f.b, fontSize: 12, color: c.text, flex: 1 }}>{j.name}</span>
<span style={{ fontFamily: f.m, fontSize: 11, color: c.purple }}>0{j.max}</span>
<span onClick={() => upd("judging", ch.judging.filter((_, k) => k !== i))} style={{ fontSize: 10, color: c.dim, cursor: "pointer" }}>×</span>
</div>)}
<TagEditor items={[]} color={c.purple} placeholder="Add criterion (e.g. Artistry)"
onAdd={v => upd("judging", [...ch.judging, { name: v, max: 10 }])} onRemove={() => {}} />
</Cd>
<Cd>
<ST>Penalties</ST>
{ch.penalties.map((p, i) => <div key={i} style={{ display: "flex", alignItems: "center", gap: 8, padding: "6px 0", borderBottom: `1px solid ${c.brd}` }}>
<span style={{ fontFamily: f.b, fontSize: 12, color: c.text, flex: 1 }}>{p.name}</span>
<span style={{ fontFamily: f.m, fontSize: 10, fontWeight: 700, color: p.val === "DQ" ? c.red : c.yellow, background: p.val === "DQ" ? c.redS : c.yellowS, padding: "2px 8px", borderRadius: 4 }}>{p.val}</span>
<span onClick={() => upd("penalties", ch.penalties.filter((_, k) => k !== i))} style={{ fontSize: 10, color: c.dim, cursor: "pointer" }}>×</span>
</div>)}
<TagEditor items={[]} color={c.red} placeholder="Add penalty (e.g. Fall: -2)"
onAdd={v => { const [name, val] = v.includes(":") ? v.split(":").map(s => s.trim()) : [v, "-2"]; upd("penalties", [...ch.penalties, { name, val }]); }} onRemove={() => {}} />
</Cd>
{!ch.configured.rules && ch.rules.length > 0 && <div onClick={() => markDone("rules")} style={{ padding: "12px", borderRadius: 10, background: c.green, textAlign: "center", cursor: "pointer" }}>
<span style={{ fontFamily: f.b, fontSize: 13, fontWeight: 700, color: "#fff" }}> Mark Rules as Done</span>
</div>}
</div>}
{/* ═══ JUDGES ═══ */}
{tab === "Judges" && <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<ST right={ch.configured.judging ? <Bg label="✓ DONE" color={c.green} bg={c.greenS} /> : <span style={{ fontFamily: f.m, fontSize: 10, color: c.dim }}>{ch.judges.length} judges</span>}>Jury Panel</ST>
{ch.judges.map((j, i) => <Cd key={j.id || i} style={{ padding: 14 }}>
<div style={{ display: "flex", gap: 12, alignItems: "flex-start" }}>
<div style={{ width: 44, height: 44, borderRadius: 12, background: `linear-gradient(135deg,${c.purple}20,${c.purple}40)`, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 20, flexShrink: 0 }}>👩</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<p style={{ fontFamily: f.b, fontSize: 14, fontWeight: 600, color: c.text, margin: 0 }}>{j.name}</p>
<span onClick={() => upd("judges", ch.judges.filter((_, k) => k !== i))} style={{ fontSize: 10, color: c.dim, cursor: "pointer", padding: "4px" }}>×</span>
</div>
<p style={{ fontFamily: f.m, fontSize: 11, color: c.purple, margin: "2px 0 4px" }}>{j.instagram}</p>
<p style={{ fontFamily: f.b, fontSize: 11, color: c.mid, margin: 0, lineHeight: 1.4 }}>{j.bio}</p>
</div>
</div>
</Cd>)}
{/* Add judge form */}
<Cd style={{ background: `${c.purple}06`, border: `1px solid ${c.purple}20` }}>
<ST>Add Judge</ST>
<Input label="Name" value={newJudge.name} onChange={v => setNewJudge(p => ({ ...p, name: v }))} placeholder="e.g. Anastasia Skukhtorova" />
<Input label="Instagram" value={newJudge.instagram} onChange={v => setNewJudge(p => ({ ...p, instagram: v }))} placeholder="e.g. @skukhtorova" />
<Input label="Bio / Description" value={newJudge.bio} onChange={v => setNewJudge(p => ({ ...p, bio: v }))} placeholder="Experience, titles, achievements..." />
<div onClick={() => { if (newJudge.name) { upd("judges", [...ch.judges, { ...newJudge, id: `j${Date.now()}` }]); setNewJudge({ name: "", instagram: "", bio: "" }); } }} style={{ padding: "10px", borderRadius: 8, background: newJudge.name ? c.purple : c.brd, textAlign: "center", cursor: newJudge.name ? "pointer" : "default", opacity: newJudge.name ? 1 : 0.5 }}>
<span style={{ fontFamily: f.b, fontSize: 12, fontWeight: 700, color: "#fff" }}>+ Add Judge</span>
</div>
</Cd>
{!ch.configured.judging && ch.judges.length > 0 && <div onClick={() => markDone("judging")} style={{ padding: "12px", borderRadius: 10, background: c.green, textAlign: "center", cursor: "pointer" }}>
<span style={{ fontFamily: f.b, fontSize: 13, fontWeight: 700, color: "#fff" }}> Mark Judges as Done</span>
</div>}
</div>}
{/* ═══ MEMBERS ═══ */}
{tab === "Members" && <div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<div style={{ background: c.card, border: `1px solid ${c.brd}`, borderRadius: 12, padding: "10px 14px", display: "flex", alignItems: "center", gap: 10 }}>
<span style={{ fontSize: 14, opacity: 0.4 }}>🔍</span>
<input type="text" placeholder="Search..." value={memSearch} onChange={e => setMemSearch(e.target.value)} style={{ background: "transparent", border: "none", outline: "none", color: c.text, fontFamily: f.b, fontSize: 13, width: "100%" }} />
</div>
<div style={{ display: "flex", gap: 4, overflowX: "auto", scrollbarWidth: "none" }}>
{memFilters.map(fi => <div key={fi.id} onClick={() => setMemFilter(fi.id)} style={{ fontFamily: f.m, fontSize: 9, fontWeight: 600, whiteSpace: "nowrap", color: memFilter === fi.id ? ch.accent : c.dim, background: memFilter === fi.id ? `${ch.accent}15` : "transparent", border: `1px solid ${memFilter === fi.id ? `${ch.accent}30` : "transparent"}`, padding: "5px 10px", borderRadius: 16, cursor: "pointer" }}>{fi.l} ({fi.n})</div>)}
</div>
{filteredMem.map(m => <div key={m.id} onClick={() => onMemberTap(m, ch)} style={{ background: c.card, border: `1px solid ${c.brd}`, borderRadius: 14, padding: 12, cursor: "pointer" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: 4 }}>
<div><p style={{ fontFamily: f.b, fontSize: 13, fontWeight: 600, color: c.text, margin: 0 }}>{m.name}</p><p style={{ fontFamily: f.m, fontSize: 10, color: ch.accent, margin: "1px 0 0" }}>{m.instagram}</p></div>
<Bg label={m.passed === true ? "PASSED" : m.passed === false ? "FAILED" : "PENDING"} color={m.passed === true ? c.green : m.passed === false ? c.red : c.yellow} bg={m.passed === true ? c.greenS : m.passed === false ? c.redS : c.yellowS} />
</div>
<div style={{ display: "flex", gap: 5, flexWrap: "wrap" }}>{[m.level, m.style, m.city].map(t => <span key={t} style={{ fontFamily: f.b, fontSize: 9, color: c.mid, background: `${c.mid}10`, padding: "2px 7px", borderRadius: 10 }}>{t}</span>)}</div>
</div>)}
{filteredMem.length === 0 && <div style={{ textAlign: "center", padding: 30 }}><span style={{ fontSize: 28 }}>🤷</span><p style={{ fontFamily: f.b, fontSize: 13, color: c.dim, marginTop: 8 }}>No members match</p></div>}
</div>}
{/* ═══ RESULTS ═══ */}
{tab === "Results" && <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<div style={{ display: "flex", gap: 6 }}>
{[{ n: stats.pending, l: "Pending", co: c.yellow }, { n: stats.passed, l: "Passed", co: c.green }, { n: stats.failed, l: "Failed", co: c.red }].map(s =>
<div key={s.l} style={{ flex: 1, background: c.card, border: `1px solid ${c.brd}`, borderRadius: 12, padding: "10px 6px", textAlign: "center" }}>
<p style={{ fontFamily: f.d, fontSize: 20, fontWeight: 700, color: s.co, margin: 0 }}>{s.n}</p>
<p style={{ fontFamily: f.m, fontSize: 7, color: c.dim, margin: "2px 0 0", textTransform: "uppercase" }}>{s.l}</p>
</div>
)}
</div>
{members.filter(m => m.videoUrl && m.passed === null).map(m => <Cd key={m.id} style={{ padding: 12 }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 8 }}>
<div><p style={{ fontFamily: f.b, fontSize: 13, fontWeight: 600, color: c.text, margin: 0 }}>{m.name}</p><p style={{ fontFamily: f.m, fontSize: 10, color: c.dim, margin: "2px 0 0" }}>{m.level} · {m.style}</p></div>
<span style={{ fontFamily: f.b, fontSize: 10, color: c.blue, background: c.blueS, padding: "3px 8px", borderRadius: 8, cursor: "pointer" }}>🎥 View</span>
</div>
<div style={{ display: "flex", gap: 8 }}>
<div onClick={() => decide(m.id, true)} style={{ flex: 1, padding: "10px", borderRadius: 10, background: `${c.green}15`, border: `1px solid ${c.green}30`, cursor: "pointer", textAlign: "center" }}><span style={{ fontFamily: f.b, fontSize: 12, fontWeight: 700, color: c.green }}> Pass</span></div>
<div onClick={() => decide(m.id, false)} style={{ flex: 1, padding: "10px", borderRadius: 10, background: `${c.red}15`, border: `1px solid ${c.red}30`, cursor: "pointer", textAlign: "center" }}><span style={{ fontFamily: f.b, fontSize: 12, fontWeight: 700, color: c.red }}> Fail</span></div>
</div>
</Cd>)}
{members.filter(m => m.passed !== null).length > 0 && <>
<ST>Decided</ST>
{members.filter(m => m.passed !== null).map(m => <div key={m.id} style={{ display: "flex", alignItems: "center", gap: 10, padding: "10px 12px", background: c.card, border: `1px solid ${c.brd}`, borderRadius: 12 }}>
<div style={{ flex: 1 }}><p style={{ fontFamily: f.b, fontSize: 13, color: c.text, margin: 0 }}>{m.name}</p><p style={{ fontFamily: f.m, fontSize: 10, color: c.dim, margin: "2px 0 0" }}>{m.level}</p></div>
<Bg label={m.passed ? "PASSED" : "FAILED"} color={m.passed ? c.green : c.red} bg={m.passed ? c.greenS : c.redS} />
</div>)}
</>}
<div style={{ padding: "14px", borderRadius: 12, background: ch.accent, textAlign: "center", cursor: "pointer" }}><span style={{ fontFamily: f.b, fontSize: 14, fontWeight: 700, color: "#fff" }}>📢 Publish Results</span></div>
</div>}
</div>
</div>;
}
/* ── Member Detail ── */
function MemberDetail({ member, champ, onBack }) {
const [m, setM] = useState(member);
const [showLvl, setShowLvl] = useState(false);
const [showSty, setShowSty] = useState(false);
const levels = champ.disciplines.flatMap(d => d.levels).filter((v, i, a) => a.indexOf(v) === i);
return <div style={{ flex: 1, overflow: "auto" }}>
<Hdr title={m.name} subtitle={`${champ.name} · ${m.instagram}`} onBack={onBack} />
<div style={{ padding: "6px 16px 20px", display: "flex", flexDirection: "column", gap: 12 }}>
<Cd style={{ display: "flex", alignItems: "center", gap: 14, padding: "14px 16px" }}>
<div style={{ width: 50, height: 50, borderRadius: 14, background: `linear-gradient(135deg,${champ.accent}20,${champ.accent}40)`, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 22, flexShrink: 0 }}>👤</div>
<div style={{ flex: 1 }}><p style={{ fontFamily: f.b, fontSize: 16, fontWeight: 600, color: c.text, margin: 0 }}>{m.name}</p><p style={{ fontFamily: f.m, fontSize: 11, color: champ.accent, margin: "2px 0 0" }}>{m.instagram}</p><p style={{ fontFamily: f.b, fontSize: 11, color: c.dim, margin: "2px 0 0" }}>📍 {m.city}</p></div>
<Bg label={m.passed === true ? "PASSED" : m.passed === false ? "FAILED" : "PENDING"} color={m.passed === true ? c.green : m.passed === false ? c.red : c.yellow} bg={m.passed === true ? c.greenS : m.passed === false ? c.redS : c.yellowS} />
</Cd>
<Cd>
<ST>Registration</ST>
{[{ l: "Discipline", v: m.discipline }, { l: "Type", v: m.type }].map(r => <div key={r.l} style={{ display: "flex", justifyContent: "space-between", padding: "7px 0", borderBottom: `1px solid ${c.brd}` }}><span style={{ fontFamily: f.m, fontSize: 10, color: c.dim, textTransform: "uppercase" }}>{r.l}</span><span style={{ fontFamily: f.b, fontSize: 12, color: c.text }}>{r.v}</span></div>)}
{/* Level */}
<div style={{ padding: "7px 0", borderBottom: `1px solid ${c.brd}` }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span style={{ fontFamily: f.m, fontSize: 10, color: c.dim, textTransform: "uppercase" }}>Level</span>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span style={{ fontFamily: f.b, fontSize: 12, fontWeight: 600, color: c.text }}>{m.level}</span>
<div onClick={() => { setShowLvl(!showLvl); setShowSty(false); }} style={{ fontFamily: f.b, fontSize: 10, fontWeight: 600, color: showLvl ? c.dim : "#fff", background: showLvl ? "transparent" : champ.accent, border: `1px solid ${showLvl ? c.brd : champ.accent}`, padding: "3px 10px", borderRadius: 6, cursor: "pointer" }}>{showLvl ? "✕" : "✎ Edit"}</div>
</div>
</div>
{showLvl && <div style={{ marginTop: 8, padding: 8, background: c.bg, borderRadius: 8 }}>
<p style={{ fontFamily: f.m, fontSize: 9, color: c.yellow, margin: "0 0 6px" }}> Member will be notified</p>
{levels.map(l => <div key={l} onClick={() => { setM(p => ({ ...p, level: l })); setShowLvl(false); }} style={{ padding: "8px 10px", borderRadius: 6, cursor: "pointer", marginBottom: 3, background: l === m.level ? `${champ.accent}15` : "transparent", border: `1px solid ${l === m.level ? `${champ.accent}30` : c.brd}` }}>
<span style={{ fontFamily: f.b, fontSize: 12, color: l === m.level ? champ.accent : c.text }}>{l}</span>
</div>)}
</div>}
</div>
{/* Style */}
<div style={{ padding: "7px 0" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span style={{ fontFamily: f.m, fontSize: 10, color: c.dim, textTransform: "uppercase" }}>Style</span>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span style={{ fontFamily: f.b, fontSize: 12, fontWeight: 600, color: c.text }}>{m.style}</span>
<div onClick={() => { setShowSty(!showSty); setShowLvl(false); }} style={{ fontFamily: f.b, fontSize: 10, fontWeight: 600, color: showSty ? c.dim : "#fff", background: showSty ? "transparent" : c.purple, border: `1px solid ${showSty ? c.brd : c.purple}`, padding: "3px 10px", borderRadius: 6, cursor: "pointer" }}>{showSty ? "✕" : "✎ Edit"}</div>
</div>
</div>
{showSty && <div style={{ marginTop: 8, padding: 8, background: c.bg, borderRadius: 8 }}>
<p style={{ fontFamily: f.m, fontSize: 9, color: c.yellow, margin: "0 0 6px" }}> Member will be notified</p>
{champ.styles.map(s => <div key={s} onClick={() => { setM(p => ({ ...p, style: s })); setShowSty(false); }} style={{ padding: "8px 10px", borderRadius: 6, cursor: "pointer", marginBottom: 3, background: s === m.style ? `${c.purple}15` : "transparent", border: `1px solid ${s === m.style ? `${c.purple}30` : c.brd}` }}>
<span style={{ fontFamily: f.b, fontSize: 12, color: s === m.style ? c.purple : c.text }}>{s}</span>
</div>)}
</div>}
</div>
</Cd>
{/* Video */}
<Cd>
<ST>🎬 Video</ST>
{m.videoUrl ? <>
<div style={{ background: c.bg, borderRadius: 8, padding: 10, marginBottom: 8, display: "flex", alignItems: "center", gap: 8 }}><span style={{ fontSize: 18 }}>🎥</span><p style={{ fontFamily: f.m, fontSize: 10, color: c.blue, margin: 0, flex: 1, overflow: "hidden", textOverflow: "ellipsis" }}>{m.videoUrl}</p></div>
{m.passed === null ? <div style={{ display: "flex", gap: 8 }}>
<div onClick={() => setM(p => ({ ...p, passed: true }))} style={{ flex: 1, padding: "10px", borderRadius: 10, background: c.green, cursor: "pointer", textAlign: "center" }}><span style={{ fontFamily: f.b, fontSize: 12, fontWeight: 700, color: "#fff" }}> Pass</span></div>
<div onClick={() => setM(p => ({ ...p, passed: false }))} style={{ flex: 1, padding: "10px", borderRadius: 10, background: c.red, cursor: "pointer", textAlign: "center" }}><span style={{ fontFamily: f.b, fontSize: 12, fontWeight: 700, color: "#fff" }}> Fail</span></div>
</div> : <Bg label={m.passed ? "PASSED" : "FAILED"} color={m.passed ? c.green : c.red} bg={m.passed ? c.greenS : c.redS} />}
</> : <p style={{ fontFamily: f.b, fontSize: 12, color: c.dim, margin: 0 }}>No video yet</p>}
</Cd>
{/* Payment */}
<Cd>
<ST>💳 Payment</ST>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<div><p style={{ fontFamily: f.b, fontSize: 12, color: c.text, margin: 0 }}>Video fee</p><p style={{ fontFamily: f.m, fontSize: 10, color: c.dim, margin: "2px 0 0" }}>{champ.fees?.videoSelection || "—"}</p></div>
{m.receiptUploaded && !m.feePaid ? <div onClick={() => setM(p => ({ ...p, feePaid: true }))} style={{ padding: "6px 12px", borderRadius: 8, background: `${c.green}15`, border: `1px solid ${c.green}30`, cursor: "pointer" }}><span style={{ fontFamily: f.b, fontSize: 11, fontWeight: 600, color: c.green }}>📸 Confirm</span></div>
: <Bg label={m.feePaid ? "CONFIRMED" : "PENDING"} color={m.feePaid ? c.green : c.yellow} bg={m.feePaid ? c.greenS : c.yellowS} />}
</div>
</Cd>
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 6, padding: "12px", borderRadius: 12, background: c.card, border: `1px solid ${c.brd}`, cursor: "pointer" }}><span style={{ fontSize: 14 }}>🔔</span><span style={{ fontFamily: f.b, fontSize: 12, fontWeight: 600, color: c.text }}>Send Notification</span></div>
</div>
</div>;
}
/* ── Quick Create ── */
function QuickCreate({ onBack, onDone }) {
const [name, setName] = useState("");
const [eventDate, setEventDate] = useState("");
const [location, setLocation] = useState("");
return <div style={{ flex: 1, overflow: "auto" }}>
<Hdr title="New Championship" subtitle="Quick create — configure details later" onBack={onBack} />
<div style={{ padding: "6px 16px 20px", display: "flex", flexDirection: "column", gap: 12 }}>
<Cd>
<Input label="Championship Name" value={name} onChange={setName} placeholder="e.g. Zero Gravity" />
<Input label="Event Date" value={eventDate} onChange={setEventDate} placeholder="e.g. May 30, 2026" />
<Input label="Location" value={location} onChange={setLocation} placeholder="e.g. Minsk, Belarus" />
</Cd>
<Cd style={{ background: `${c.blue}06`, border: `1px solid ${c.blue}20` }}>
<p style={{ fontFamily: f.b, fontSize: 12, color: c.blue, margin: "0 0 6px" }}>💡 What happens next?</p>
<p style={{ fontFamily: f.b, fontSize: 11, color: c.mid, margin: 0, lineHeight: 1.6 }}>Your championship will be created as a draft. Configure categories, fees, rules, and judging at your own pace. Once everything is set, hit "Go Live" to open registration.</p>
</Cd>
<div onClick={() => name && onDone(makeCh({ id: `ch${Date.now()}`, name, eventDate, location, status: "draft", configured: { info: !!eventDate && !!location, categories: false, fees: false, rules: false, judging: false } }))} style={{ padding: "14px", borderRadius: 12, background: name ? c.accent : c.brd, textAlign: "center", cursor: name ? "pointer" : "default", opacity: name ? 1 : 0.5 }}>
<span style={{ fontFamily: f.b, fontSize: 14, fontWeight: 700, color: "#fff" }}> Create Draft</span>
</div>
</div>
</div>;
}
/* ── Org Settings ── */
function OrgSettings({ org, onUpdateOrg }) {
const [editing, setEditing] = useState(false);
const [name, setName] = useState(org.name);
const [instagram, setInstagram] = useState(org.instagram);
const [subScreen, setSubScreen] = useState(null);
if (subScreen === "notifications") return <div>
<Hdr title="Notifications" subtitle="Notification preferences" onBack={() => setSubScreen(null)} />
<div style={{ padding: "6px 16px 20px", display: "flex", flexDirection: "column", gap: 10 }}>
{[{ l: "Push notifications", d: "Get notified on new registrations", on: true },
{ l: "Email notifications", d: "Receive email for payments & uploads", on: true },
{ l: "Registration alerts", d: "When a new member registers", on: true },
{ l: "Payment alerts", d: "When a receipt is uploaded", on: true },
{ l: "Deadline reminders", d: "Auto-remind members before deadlines", on: false },
].map(n => <ToggleRow key={n.l} label={n.l} desc={n.d} defaultOn={n.on} />)}
</div>
</div>;
if (subScreen === "accounts") return <div>
<Hdr title="Connected Accounts" subtitle="Integrations" onBack={() => setSubScreen(null)} />
<div style={{ padding: "6px 16px 20px", display: "flex", flexDirection: "column", gap: 10 }}>
{[{ name: "Instagram", handle: org.instagram, icon: "📸", connected: true, color: c.purple },
{ name: "Gmail", handle: "zerogravity@gmail.com", icon: "📧", connected: true, color: c.red },
{ name: "Telegram", handle: "@zerogravity_bot", icon: "💬", connected: false, color: c.blue },
].map(a => <Cd key={a.name} style={{ display: "flex", alignItems: "center", gap: 12, padding: 14 }}>
<div style={{ width: 38, height: 38, borderRadius: 10, background: `${a.color}15`, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 17 }}>{a.icon}</div>
<div style={{ flex: 1 }}>
<p style={{ fontFamily: f.b, fontSize: 13, fontWeight: 600, color: c.text, margin: 0 }}>{a.name}</p>
<p style={{ fontFamily: f.m, fontSize: 10, color: a.connected ? a.color : c.dim, margin: "2px 0 0" }}>{a.connected ? a.handle : "Not connected"}</p>
</div>
<Bg label={a.connected ? "CONNECTED" : "CONNECT"} color={a.connected ? c.green : c.accent} bg={a.connected ? c.greenS : c.accentS} />
</Cd>)}
</div>
</div>;
return <div>
<Hdr title="Settings" subtitle="Organization profile" />
<div style={{ padding: "6px 20px 20px" }}>
{/* Profile header */}
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", marginBottom: 20 }}>
<div style={{ width: 68, height: 68, borderRadius: 18, background: `linear-gradient(135deg,${c.accent}25,${c.accent}10)`, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 30, marginBottom: 10, border: `2px solid ${c.accent}35` }}>{org.logo}</div>
{editing ? <div style={{ width: "100%", display: "flex", flexDirection: "column", gap: 8, marginTop: 4 }}>
<Input label="Organization Name" value={name} onChange={setName} placeholder="Your org name" />
<Input label="Instagram" value={instagram} onChange={setInstagram} placeholder="@handle" />
<div style={{ display: "flex", gap: 8 }}>
<div onClick={() => setEditing(false)} style={{ flex: 1, padding: "10px", borderRadius: 8, background: c.card, border: `1px solid ${c.brd}`, textAlign: "center", cursor: "pointer" }}><span style={{ fontFamily: f.b, fontSize: 12, color: c.dim }}>Cancel</span></div>
<div onClick={() => { onUpdateOrg({ name, instagram }); setEditing(false); }} style={{ flex: 1, padding: "10px", borderRadius: 8, background: c.green, textAlign: "center", cursor: "pointer" }}><span style={{ fontFamily: f.b, fontSize: 12, fontWeight: 700, color: "#fff" }}> Save</span></div>
</div>
</div> : <>
<h2 style={{ fontFamily: f.d, fontSize: 19, fontWeight: 700, color: c.text, margin: "0 0 2px" }}>{org.name}</h2>
<p style={{ fontFamily: f.m, fontSize: 11, color: c.accent, margin: 0 }}>{org.instagram}</p>
</>}
</div>
{/* Menu items — hide when editing */}
{!editing && <div style={{ background: c.card, border: `1px solid ${c.brd}`, borderRadius: 12, overflow: "hidden" }}>
{[
{ label: "Edit Organization Profile", action: () => setEditing(true), icon: "✎" },
{ label: "Notification Preferences", action: () => setSubScreen("notifications"), icon: "🔔" },
{ label: "Connected Accounts", action: () => setSubScreen("accounts"), icon: "🔗" },
{ label: "Help & Support", action: () => {}, icon: "❓" },
{ label: "Log Out", action: () => {}, icon: "🚪", danger: true },
].map((x, i, a) =>
<div key={x.label} onClick={x.action} style={{ padding: "13px 16px", fontFamily: f.b, fontSize: 13, color: x.danger ? c.red : c.text, borderBottom: i < a.length - 1 ? `1px solid ${c.brd}` : "none", cursor: "pointer", display: "flex", alignItems: "center", gap: 10 }}>
<span style={{ fontSize: 14, opacity: 0.6 }}>{x.icon}</span>
<span style={{ flex: 1 }}>{x.label}</span>
<span style={{ color: c.dim, fontSize: 12 }}></span>
</div>
)}
</div>}
</div>
</div>;
}
/* Toggle row helper */
function ToggleRow({ label, desc, defaultOn }) {
const [on, setOn] = useState(defaultOn);
return <Cd style={{ display: "flex", alignItems: "center", gap: 12, padding: 14 }}>
<div style={{ flex: 1 }}>
<p style={{ fontFamily: f.b, fontSize: 13, color: c.text, margin: 0 }}>{label}</p>
{desc && <p style={{ fontFamily: f.b, fontSize: 10, color: c.dim, margin: "2px 0 0" }}>{desc}</p>}
</div>
<div onClick={() => setOn(!on)} style={{ width: 42, height: 24, borderRadius: 12, background: on ? c.green : c.brd, padding: 2, cursor: "pointer", transition: "background 0.2s" }}>
<div style={{ width: 20, height: 20, borderRadius: 10, background: "#fff", transform: on ? "translateX(18px)" : "translateX(0)", transition: "transform 0.2s" }} />
</div>
</Cd>;
}
/* ── App Shell ── */
export default function OrgApp() {
const [champs, setChamps] = useState(INITIAL_CHAMPS);
const [org, setOrg] = useState(ORG);
const [scr, setScr] = useState("dash");
const [selChamp, setSelChamp] = useState(null);
const [selMember, setSelMember] = useState(null);
const addChamp = ch => { setChamps(p => [...p, ch]); setSelChamp(ch); setScr("champ"); };
const updateOrg = updates => setOrg(p => ({ ...p, ...updates }));
const render = () => {
if (scr === "create") return <QuickCreate onBack={() => setScr("dash")} onDone={addChamp} />;
if (scr === "champ" && selChamp) return <ChampDetail ch={selChamp} onBack={() => { setScr("dash"); setSelChamp(null); }} onMemberTap={(m, ch) => { setSelMember({ m, ch }); setScr("member"); }} />;
if (scr === "member" && selMember) return <MemberDetail member={selMember.m} champ={selMember.ch} onBack={() => setScr("champ")} />;
if (scr === "orgSettings") return <OrgSettings org={org} onUpdateOrg={updateOrg} />;
return <Dashboard champs={champs} org={org} onChampTap={ch => { setSelChamp(ch); setScr("champ"); }} onCreateChamp={() => setScr("create")} />;
};
const showNav = scr === "dash" || scr === "orgSettings";
return <div style={{ display: "flex", justifyContent: "center", alignItems: "center", minHeight: "100vh", background: "#030206", padding: 20, fontFamily: f.b }}>
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;600;700&family=DM+Sans:wght@400;500;600&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet" />
<style>{`*::-webkit-scrollbar{display:none}*{scrollbar-width:none}`}</style>
<div style={{ width: 375, height: 740, background: c.bg, borderRadius: 36, overflow: "hidden", display: "flex", flexDirection: "column", border: `1.5px solid ${c.brd}`, boxShadow: `0 0 80px rgba(212,20,90,0.06),0 20px 40px rgba(0,0,0,0.5)` }}>
<div style={{ padding: "8px 24px", display: "flex", justifyContent: "space-between", alignItems: "center", flexShrink: 0 }}>
<span style={{ fontFamily: f.m, fontSize: 11, color: c.dim }}>9:41</span>
<div style={{ width: 100, height: 28, background: "#000", borderRadius: 14 }} />
<span style={{ fontFamily: f.m, fontSize: 11, color: c.dim }}></span>
</div>
<div style={{ flex: 1, overflow: "auto", minHeight: 0 }}>{render()}</div>
{showNav && <Nav active={scr} onChange={s => { setScr(s); setSelChamp(null); setSelMember(null); }} />}
</div>
</div>;
}