Commit 233f0436 authored by Mahmoud Aglan's avatar Mahmoud Aglan

rebuild: PHP foundation + auth + layout system

Complete rewrite from React/Vite to PHP/HTML/CSS/JS stack.
- PHP 8.3 Apache Docker setup with routing
- Supabase auth (login/register) working
- Full CSS design system (dark theme, RTL, responsive)
- SVG icon sprite (no emojis)
- Desktop side nav + mobile bottom nav
- All page routes with placeholders
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 168e1845
node_modules # OS
dist .DS_Store
.env Thumbs.db
*.local
# IDE
.idea/
.vscode/
*.swp
*.swo
# Sensitive
*.pem
Connections and docs /
# Deps (none for now, but future-proofing)
vendor/
node_modules/
RewriteEngine On
# Force HTTPS (CapRover handles SSL termination via X-Forwarded-Proto)
RewriteCond %{HTTP:X-Forwarded-Proto} =http
RewriteRule .* https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
# Route all non-file requests to index.php
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)$ index.php?route=$1 [QSA,L]
# Security headers
<IfModule mod_headers.c>
Header set X-Content-Type-Options "nosniff"
Header set X-Frame-Options "SAMEORIGIN"
Header set X-XSS-Protection "1; mode=block"
Header set Referrer-Policy "strict-origin-when-cross-origin"
</IfModule>
# Deny access to sensitive files
<FilesMatch "\.(pem|md|gitignore)$">
Require all denied
</FilesMatch>
<Files "config/*">
Require all denied
</Files>
# EL3AB — UI/UX Design System & Screen Specifications
## Design Philosophy
EL3AB is a competitive gaming platform that must feel like a native app — not a website pretending to be one. Every screen should breathe. Content needs room. The user's thumb is the primary input device.
### Core Principles
1. **Breathing Room** — Nothing touches edges. Content floats in generous padding. White space is a feature, not wasted space.
2. **Thumb-First** — The bottom 60% of the screen is the action zone. Critical buttons live there. Navigation is always reachable without stretching.
3. **Glanceable** — A player mid-game shouldn't need to read paragraphs. Key info (rating, coins, time) should be parseable in under 1 second.
4. **Celebration Moments** — Wins, unlocks, level-ups get the full screen. These moments build emotional attachment.
5. **Progressive Disclosure** — Show the minimum needed. Let users drill down. Never dump everything on one screen.
6. **Consistent Rhythm** — Every screen follows the same vertical rhythm. Scrolling feels musical, not chaotic.
---
## Spacing & Grid System
### Base Unit: 8px
Everything is a multiple of 8. No exceptions.
```
4px — micro (icon padding, hairline gaps)
8px — tight (between related items in a group)
12px — compact (internal card padding on small elements)
16px — standard (screen edge padding on mobile, gap between cards)
24px — comfortable (section gaps, card internal padding)
32px — spacious (between major sections)
48px — breathing (hero spacing, before/after major CTAs)
64px — dramatic (splash/celebration vertical breathing)
```
### Screen Edge Padding
| Breakpoint | Edge Padding | Max Content Width |
|-----------|-------------|-------------------|
| Mobile (<428px) | 20px | 100% - 40px |
| Large Mobile (428-768px) | 24px | 100% - 48px |
| Tablet (768-1024px) | 32px | 720px centered |
| Desktop (1024-1440px) | 48px | 1080px centered |
| Wide (>1440px) | auto | 1280px centered |
### Why 20px edge padding (not 16px)
16px on modern phones (390px+ width) makes content feel pressed against the glass. 20px gives the content a visible "float" above the device frame. This is what separates premium apps from cramped web apps.
### Responsive Grid
| Breakpoint | Columns | Gutter | Behavior |
|-----------|---------|--------|----------|
| Mobile | 4 | 16px | Stack most things vertically |
| Tablet | 8 | 20px | 2-column layouts appear |
| Desktop | 12 | 24px | Full dashboard layouts |
---
## Touch Targets
### Minimum Sizes
| Element | Min Size | Recommended | Spacing Between |
|---------|----------|-------------|-----------------|
| Primary CTA Button | 48x48px | 56x56px | 12px |
| Navigation Item | 48x48px | 56x48px | 0 (full-width tap area) |
| Icon Button | 44x44px | 48x48px | 8px |
| List Item | full-width x 56px | full-width x 64px | 0 (dividers only) |
| Card (tappable) | full-width x 80px | full-width x 96px | 12px |
| Chip/Tag | 32x32px | 36x36px | 8px |
| Toggle/Switch | 44x28px | 52x32px | — |
### The 48px Rule
No interactive element is ever smaller than 48x48px tap area, even if visually smaller. Use padding to expand hit area invisibly.
---
## Typography Scale
### Font Stack
- Primary (Arabic): IBM Plex Sans Arabic — clean, modern, excellent game readability
- Secondary (English/Numbers): Inter — tight letter spacing, great for stats/numbers
- Monospace (Timers/Codes): JetBrains Mono — for clocks, match IDs
### Scale (Mobile)
| Token | Size | Weight | Line Height | Use Case |
|-------|------|--------|-------------|----------|
| display | 36px | 800 | 1.1 | Splash logo, win/loss result |
| h1 | 28px | 700 | 1.2 | Page titles |
| h2 | 22px | 700 | 1.3 | Section headings |
| h3 | 18px | 600 | 1.3 | Card titles, player names |
| body | 16px | 400 | 1.5 | Default text, descriptions |
| body-sm | 14px | 400 | 1.4 | Secondary info, metadata |
| caption | 12px | 500 | 1.3 | Timestamps, labels, badges |
| timer | 24px | 700 | 1.0 | Chess clock display |
| timer-lg | 32px | 700 | 1.0 | Active clock (your turn) |
| stat | 20px | 700 | 1.0 | Rating numbers, coin counts |
### Scale (Desktop) — multiply by 1.15x
### Rules
- Never go below 12px for any text
- Arabic text gets +1px size boost (Arabic glyphs are denser)
- Numbers always use Inter regardless of language
- Timer font is always monospace
---
## Color System
### Background Layers (Dark Theme)
```
Layer 0 (App BG): #071120 — deepest, behind everything
Layer 1 (Surface): #0D1B2A — cards, panels, nav background
Layer 2 (Elevated): #132D4A — modals, popovers, active cards
Layer 3 (Input): #1A3A5C — input fields, dropdowns, hover states
```
Each layer has exactly enough contrast to create depth without bright borders.
### Brand Colors
```
Gold (Primary Brand): #E7A832 — reserved for: rank badges, premium items, hero CTAs, win states
Cyan (Action): #15D7FF — primary buttons, links, online status, interactive elements
Royal Blue (Info): #2979FF — secondary actions, tournaments, info badges
```
### Status Colors
```
Win/Success: #34D399 — emerald green, victories, confirmations
Loss/Error: #EF4444 — red, defeats, errors, destructive actions
Draw/Warning: #F59E0B — amber, draws, warnings, pending states
Online: #22C55E — green dot, friend online
Offline: #6B7280 — gray, unavailable
Live: #EF4444 — red pulse, live tournament/match
```
### Text Colors
```
Primary: #F1F5F9 — main content text
Secondary: #94A3B8 — labels, metadata, placeholders
Muted: #64748B — disabled, timestamps
Inverse: #0F172A — text on gold/cyan buttons
```
### Border & Divider
```
Subtle: rgba(255, 255, 255, 0.06) — card borders, dividers
Medium: rgba(255, 255, 255, 0.12) — input borders, active states
Strong: rgba(255, 255, 255, 0.20) — focus rings
Gold Glow: rgba(231, 168, 50, 0.15) — premium item border
Cyan Glow: rgba(21, 215, 255, 0.12) — active selection border
```
### Gold Usage Rules (Restraint Protocol)
Gold is PREMIUM. Overusing it destroys its power. Only use gold for:
- Top 3 leaderboard badges
- Premium/legendary cosmetics
- Hero CTA on home page ("Play Now")
- Win result accent
- Currency (coins)
- Level-up moment
- Tournament prizes
Everything else uses Cyan as the primary interactive color.
---
## Border Radius
```
none: 0px — sharp elements (progress bar fill, dividers)
sm: 8px — chips, tags, small badges
md: 12px — cards, buttons, inputs
lg: 16px — modals, bottom sheets, hero cards
xl: 24px — avatar circles (with overflow hidden), pills
full: 9999px — circular elements (avatar, status dot)
```
### Rules
- Cards are always 12px
- Buttons are always 12px
- Modals/sheets are 16px (top corners only for bottom sheets)
- Never use 3px, 5px, 6px, 10px, 14px, 20px — stay on the system
---
## Shadows & Elevation
### Three Levels Only
```
Level 1 (Subtle): 0 1px 3px rgba(0,0,0,0.3), 0 1px 2px rgba(0,0,0,0.2)
Use: cards at rest, nav bar
Level 2 (Medium): 0 4px 12px rgba(0,0,0,0.4), 0 2px 4px rgba(0,0,0,0.3)
Use: floating buttons, active cards, dropdowns
Level 3 (Heavy): 0 12px 40px rgba(0,0,0,0.5), 0 4px 12px rgba(0,0,0,0.3)
Use: modals, bottom sheets, celebration overlays
```
### Glow Effects (Sparingly)
```
Gold Glow: 0 0 20px rgba(231, 168, 50, 0.25) — hero CTA, legendary items
Cyan Glow: 0 0 16px rgba(21, 215, 255, 0.2) — active game, online pulse
Red Pulse: 0 0 12px rgba(239, 68, 68, 0.3) — low time warning, live indicator
```
---
## Animation & Motion
### Core Principle: Fast, Purposeful, Never Decorative
Every animation must answer: "What is this telling the user?" If the answer is "nothing, it just looks cool" — remove it.
### Timing
```
Instant: 100ms — button press feedback, toggle, checkbox
Fast: 200ms — page transitions, card appear, dropdown open
Normal: 300ms — modal open/close, bottom sheet slide
Celebration: 600ms — win reveal, level-up, achievement unlock
Dramatic: 1000ms — splash screen, first-time onboarding
```
### Easing
```
Enter: cubic-bezier(0, 0, 0.2, 1) — elements appearing (decelerate in)
Exit: cubic-bezier(0.4, 0, 1, 1) — elements leaving (accelerate out)
Standard: cubic-bezier(0.4, 0, 0.2, 1) — moving between states
Spring: spring(1, 80, 10) — bouncy for celebrations only
```
### What Gets Animated
| Action | Animation | Duration |
|--------|-----------|----------|
| Page enter | Fade in + slide up 12px | 200ms |
| Page exit | Fade out | 150ms |
| Card appear (list) | Fade in + slide up 8px, stagger 30ms | 200ms |
| Button press | Scale to 0.97 | 100ms |
| Modal open | Fade overlay + slide up content | 300ms |
| Bottom sheet | Slide up from bottom | 300ms |
| Toast notification | Slide down from top | 200ms in, 150ms out |
| Number change (rating) | Count up/down with spring | 600ms |
| Win celebration | Scale from 0.8 → 1 + confetti | 800ms |
| Match found | Pulse + opponent slide in | 500ms |
### What Does NOT Get Animated
- Background decorations
- Floating particles
- Idle state looping effects (except: online pulse dot, live indicator)
- Text appearing (unless it's a reveal moment)
- Navigation between tabs (instant swap, no slide)
---
## Component Library
### Button Variants
| Variant | Height | Padding | Use |
|---------|--------|---------|-----|
| Primary (Cyan) | 48px | 20px 32px | Main actions: "Play", "Send", "Confirm" |
| Gold (Hero) | 56px | 24px 40px | ONE per screen max: "Play Now", "Claim Reward" |
| Secondary | 44px | 16px 24px | Secondary actions: "Cancel", "Skip" |
| Ghost | 44px | 16px 24px | Tertiary: "View All", "Edit" |
| Destructive | 48px | 20px 32px | "Resign", "Delete", "Block" |
| Icon-only | 44x44px | 12px | Settings gear, close X, back arrow |
### Button Rules
- Max one Gold button per screen
- Primary Cyan is the default action button
- Full-width buttons on mobile for important actions (with 20px side margins)
- Never stack more than 2 buttons vertically without spacing (24px between)
- Loading state: icon spins, text stays, button width locked
### Cards
| Type | Padding | Radius | Gap Between |
|------|---------|--------|-------------|
| Standard | 16px | 12px | 12px |
| Compact | 12px | 12px | 8px |
| Hero | 24px | 16px | 16px |
| Game Result | 20px | 12px | 12px |
| Player Row | 12px 16px | 12px | 0 (dividers) |
### Card Anatomy
```
┌─────────────────────────────┐ ← 12px radius
│ 16px padding │
│ ┌─────────────────────────┐│
│ │ Content Area ││
│ │ ││
│ └─────────────────────────┘│
│ 16px padding │
└─────────────────────────────┘
↕ 12px gap to next card
```
### Inputs
| Property | Value |
|----------|-------|
| Height | 48px |
| Padding | 12px 16px |
| Border radius | 12px |
| Border | 1px solid rgba(255,255,255,0.12) |
| Focus border | 2px solid #15D7FF |
| Label | 14px, above input, 8px gap |
| Error text | 12px, below input, 4px gap, red |
| Placeholder | #64748B (muted) |
### Bottom Sheet
```
┌──────────────────────────────────────┐
│ │
│ (dimmed background) │
│ │
├──────────────────────────────────────┤ ← 16px radius top
│ ┌────┐ drag handle (32x4px) │ ← 12px top padding
│ │ │ centered │
│ └────┘ │
│ │ ← 8px
│ Sheet Title (h2) │ ← 24px side padding
│ │ ← 16px
│ Content... │
│ │
│ │
│ ┌──────────────────────────────┐ │ ← 24px bottom padding
│ │ Primary Action Button │ │ (+ safe area)
│ └──────────────────────────────┘ │
└──────────────────────────────────────┘
```
- Max height: 85% of viewport
- Dismiss: drag down, tap backdrop, or explicit close
- Always include safe area padding at bottom (env(safe-area-inset-bottom))
---
## Screen Layouts
### Global Structure
```
┌──────────────────────────────────────┐
│ Status Bar (OS) │ ← respect safe area
├──────────────────────────────────────┤
│ Header (56px) │ ← sticky
│ Logo | Level | Coins Gems | Bell │
├──────────────────────────────────────┤
│ │
│ │
│ Scrollable Content │
│ (page body) │
│ │
│ │
│ │
├──────────────────────────────────────┤
│ Bottom Nav (64px + safe area) │ ← fixed bottom
│ Home | Play | Tourney | Social | Me │
└──────────────────────────────────────┘
```
### Header (56px)
```
┌──────────────────────────────────────────────┐
│ [Logo] Lv.12 │ │ 🪙 1,250 💎 45 🔔│
│ 24px stat │ │ currency bell │
└──────────────────────────────────────────────┘
← 20px 20px →
```
- Fixed/sticky at top
- Background: Layer 1 with subtle bottom border
- Height: 56px (content) + safe area top
- Logo: 28px height
- Level badge: compact chip
- Currencies: right-aligned with icons
- Bell: 24px icon with red dot badge (if unread)
### Bottom Navigation (64px)
```
┌──────────────────────────────────────────────┐
│ │
│ 🏠 🎮 🏆 👥 👤 │
│ Home Play Tourney Social Profile │
│ │
│ (safe area padding below) │
└──────────────────────────────────────────────┘
```
- Height: 64px content + env(safe-area-inset-bottom)
- Background: Layer 1 with top border (subtle)
- 5 items, equal width
- Active: Cyan icon + Cyan label + tiny dot indicator above
- Inactive: Muted icon + Muted label
- Icon size: 24px
- Label: 10px (exception to 12px min — industry standard for tab bars)
- Touch target: full item width x 64px height
### Desktop Layout (>1024px)
```
┌─────┬──────────────────────────────┬──────────┐
│ │ Header (full width) │ │
│ ├──────────────────────────────┤ │
│ Nav │ │ Context │
│ Rail│ Main Content │ Panel │
│ │ (scrollable) │ │
│ 72px│ │ 320px │
│ │ │ │
│ │ │ │
└─────┴──────────────────────────────┴──────────┘
```
- Nav Rail: 72px wide, icons + labels vertically stacked
- Context Panel: 320px, shows contextual info (friends online, recent games)
- Main Content: fills remaining space, max 800px for readability
- No bottom nav on desktop
---
## Screen Specifications
### 1. Splash Screen
```
Duration: 1.5s (first visit), skip on return
Layout: Full screen, centered vertically
┌──────────────────────────────────────┐
│ │
│ │
│ │
│ [EL3AB Logo] │ ← centered, 120px
│ العب │ ← 24px below, display font
│ │
│ │
│ │
└──────────────────────────────────────┘
Background: Layer 0
Animation: Logo fades in (0→1, 400ms) then subtle scale (1→1.02→1, 600ms)
Skip condition: localStorage has 'visited' flag
```
### 2. Login / Register
```
┌──────────────────────────────────────┐
│ │
│ [Logo] 64px │ ← 80px from top safe area
│ │ ← 48px
│ ┌──────────────────────────────┐ │
│ │ │ │ ← Card: Layer 1, 24px padding
│ │ مرحباً بك │ │ h2, centered
│ │ │ │ ← 24px
│ │ [Email Input] │ │ 48px height
│ │ │ │ ← 16px
│ │ [Password Input] │ │ 48px height
│ │ │ │ ← 24px
│ │ [تسجيل الدخول] Cyan, full │ │ 48px height, full-width
│ │ │ │ ← 16px
│ │ ليس لديك حساب؟ سجّل الآن │ │ body-sm, centered link
│ │ │ │
│ └──────────────────────────────┘ │
│ │
└──────────────────────────────────────┘
- No bottom nav on auth screens
- Card has subtle border (subtle opacity)
- Register has extra fields: username, display name, preferred language
- Transition between login↔register: crossfade 200ms
- Error messages appear below inputs with 4px gap
```
### 3. Home Screen
```
┌──────────────────────────────────────┐
│ [Header - sticky] │
├──────────────────────────────────────┤
│ │ ← 20px padding
│ أهلاً يا محمود 👋 │ ← h2
│ المستوى 12 • 1,847 بليتز │ ← body-sm, secondary color
│ │ ← 32px
│ ┌──────────────────────────────┐ │
│ │ ★ العب الآن ★ │ │ ← Gold Hero Button, 56px
│ └──────────────────────────────┘ │ full-width, gold glow shadow
│ │ ← 32px
│ ── المكافأة اليومية ────────────── │ ← Section header, 14px caps
│ │ ← 12px
│ ┌──────────────────────────────┐ │
│ │ 🔥 Day 5 │ +50 XP │ اجمع! │ │ ← compact card, 56px height
│ └──────────────────────────────┘ │
│ │ ← 32px
│ ── أصدقاء متصلون (3) ────────── │ ← Section header
│ │ ← 12px
│ ┌──────┐┌──────┐┌──────┐ │ ← horizontal scroll
│ │Avatar││Avatar││Avatar│ │ 64px circles + name below
│ │ Name ││ Name ││ Name │ │
│ └──────┘└──────┘└──────┘ │
│ │ ← 32px
│ ── بطولات مباشرة ──────────────── │ ← Section header + "عرض الكل" link
│ │ ← 12px
│ ┌──────────────────────────────┐ │
│ │ Tournament Card 1 │ │ ← Hero card, 120px height
│ │ name, players, starts in... │ │
│ └──────────────────────────────┘ │
│ │ ← 12px
│ ┌──────────────────────────────┐ │
│ │ Tournament Card 2 │ │
│ └──────────────────────────────┘ │
│ │ ← 32px
│ ── آخر المباريات ───────────────── │
│ │ ← 12px
│ ┌──────────────────────────────┐ │
│ │ [Av] Player 1847 W +12 │ │ ← compact row, 64px
│ ├──────────────────────────────┤ │
│ │ [Av] Player 1623 L -8 │ │
│ ├──────────────────────────────┤ │
│ │ [Av] Player 1720 D +1 │ │
│ └──────────────────────────────┘ │
│ │
├──────────────────────────────────────┤
│ [Bottom Nav] │
└──────────────────────────────────────┘
Sections:
1. Welcome + hero play button
2. Daily reward (if unclaimed)
3. Friends online (horizontal scroll)
4. Live tournaments (vertical cards)
5. Recent games (compact list)
6. Activity feed (expandable)
Section spacing: 32px between sections
Section headers: 14px, uppercase, secondary color, with optional "View All" link aligned right
```
### 4. Play Screen
```
┌──────────────────────────────────────┐
│ [Header] │
├──────────────────────────────────────┤
│ │ ← 20px
│ اختر اللعبة │ ← h1
│ │ ← 24px
│ ┌─────────────┐ ┌─────────────┐ │ ← 2-column grid
│ │ │ │ │ │ Card ratio: 3:4
│ │ ♟ شطرنج │ │ 🎲 طاولة │ │ 120px height
│ │ [Active] │ │ [قريباً] │ │
│ └─────────────┘ └─────────────┘ │ ← 12px gap
│ ┌─────────────┐ ┌─────────────┐ │
│ │ │ │ │ │
│ │ 🁣 دومينو │ │ 🎯 ليدو │ │
│ │ [قريباً] │ │ [قريباً] │ │
│ └─────────────┘ └─────────────┘ │
│ │ ← 32px
│ ── إعدادات المباراة ──────────── │
│ │ ← 16px
│ الوقت: │ ← body-sm label
│ ┌────┐┌────┐┌────┐┌────┐┌────┐ │ ← pill selector row
│ │ 1 ││ 3 ││ 5 ││10 ││15 │ │ each pill: 44px h, 56px w
│ │min ││min ││min ││min ││min │ │ selected: cyan bg
│ └────┘└────┘└────┘└────┘└────┘ │
│ │ ← 16px
│ الزيادة: │
│ ┌────┐┌────┐┌────┐┌────┐ │
│ │ 0 ││ 1 ││ 2 ││ 5 │ │ same style
│ │ sec││sec ││sec ││sec │ │
│ └────┘└────┘└────┘└────┘ │
│ │ ← 16px
│ النوع: │
│ ┌──────────┐ ┌──────────┐ │
│ │ مُصنّف │ │ ودّي │ │ toggle pair, 44px h
│ └──────────┘ └──────────┘ │
│ │ ← 32px
│ ┌──────────────────────────────┐ │
│ │ ابحث عن خصم │ │ ← Primary button, 48px
│ └──────────────────────────────┘ │
│ │ ← 16px
│ ┌──────────────────────────────┐ │
│ │ العب ضد الكمبيوتر │ │ ← Secondary button, 44px
│ └──────────────────────────────┘ │
│ │
├──────────────────────────────────────┤
│ [Bottom Nav] │
└──────────────────────────────────────┘
Key decisions:
- Game cards are a 2-column grid (not horizontal scroll — users need to see all options)
- "Coming soon" games are visually muted (0.5 opacity) with lock icon
- Time control is pill-based selector (not dropdown — faster, more tactile)
- Only ONE game can be active at a time — selecting chess deselects others
- Bottom area: two clear CTAs separated by purpose
```
### 5. Matchmaking Screen
```
┌──────────────────────────────────────┐
│ │
│ │
│ │
│ ┌──────────┐ │
│ │ Your │ │ ← 80px avatar circle
│ │ Avatar │ │
│ └──────────┘ │
│ Your Name │ ← h3, centered
│ 1847 بليتز │ ← body-sm, secondary
│ │ ← 48px
│ ╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍ │ ← animated dashed line
│ │
│ جاري البحث... │ ← h2, centered
│ 0:12 │ ← timer, secondary
│ │ ← 8px
│ 1800 - 1900 │ ← rating range, expanding
│ │
│ │
│ │ ← 64px
│ ┌────────────────────────┐ │
│ │ إلغاء │ │ ← Ghost button, 44px
│ └────────────────────────┘ │
│ │
└──────────────────────────────────────┘
- Full screen (no header, no bottom nav)
- Clean, focused, calming
- Animated indicator: pulsing ring around avatar (cyan glow, 2s loop)
- Rating range text updates every 5s as range expands
- Timer counts up (shows wait time)
- Cancel button is ghost/secondary (not aggressive)
- On match found:
- Pulse stops
- Sound plays
- Opponent avatar slides in from right (300ms)
- Both names + ratings visible
- "Match Found!" text (h1)
- Auto-navigate to game after 2s
```
### 6. Game Screen (Chess)
```
┌──────────────────────────────────────┐
│ ┌──────────────────────────────┐ │ ← Opponent bar, 56px
│ │ [Av] Opponent 1847 5:00 │ │ avatar(32px) + name + rating + clock
│ └──────────────────────────────┘ │
│ │ ← 8px
│ ┌──────────────────────────────┐ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ CHESS BOARD │ │ ← Square board
│ │ (fills width) │ │ width: 100% - 8px (4px each side)
│ │ │ │ min: 320px, max: 560px
│ │ │ │ aspect-ratio: 1
│ │ │ │
│ │ │ │
│ └──────────────────────────────┘ │
│ │ ← 8px
│ ┌──────────────────────────────┐ │ ← Your bar, 56px
│ │ [Av] You 1823 5:00 │ │
│ └──────────────────────────────┘ │
│ │ ← 12px
│ ┌──────────────────────────────┐ │ ← Move history, 40px
│ │ 1.e4 e5 2.Nf3 Nc6 3.Bb5 ► │ │ horizontal scroll
│ └──────────────────────────────┘ │
│ │ ← 12px
│ ┌────┐ ┌────┐ ┌────┐ ┌────┐ │ ← Action buttons, 44px each
│ │ ⚑ │ │ ½ │ │ ↺ │ │ ⚙ │ │ resign, draw, flip, settings
│ │resign│ │draw│ │flip│ │set │ │
│ └────┘ └────┘ └────┘ └────┘ │
│ │
└──────────────────────────────────────┘
CRITICAL LAYOUT RULES:
- Board takes 70-78% of vertical space (between player bars)
- NO header, NO bottom nav during game (full immersion)
- Player bars are compact: avatar(32px) + name + rating + clock
- Clock is RIGHT-aligned (RTL: LEFT-aligned), bold, monospace
- Active player's clock: timer-lg size, cyan color
- Inactive clock: timer size, secondary color
- Low time (<30s): clock turns red, subtle pulse
- Low time (<10s): clock background pulses red
- Move history: single-line horizontal scroll, latest move visible
- Action buttons: icon + label below, 44x44px minimum
BOARD SPECIFICS:
- Light squares: #E8D5B0 (warm cream)
- Dark squares: #8B6B47 (walnut)
- Selected piece square: cyan highlight (20% opacity)
- Legal move indicators: 12px cyan dots (center of square)
- Last move: subtle cyan border on from+to squares
- Check: red highlight on king square
- Drag: piece follows finger with 8px offset above touch point, slight scale(1.1)
- Drop: snap to grid with subtle settle animation (100ms)
MOBILE OPTIMIZATION:
- Board edge: only 4px from screen edge (exception to 20px rule — board needs max space)
- Touch to select + touch to move (not drag-only — accessibility)
- Captured pieces: small icons (16px) next to player name
- Material advantage: "+3" badge next to captured pieces
```
### 7. Game Result Overlay
```
┌──────────────────────────────────────┐
│ │
│ │
│ │ ← backdrop: Layer 0, 80% opacity
│ │
│ ┌──────────────────────────────┐ │
│ │ │ │ ← Card: Layer 2, 24px padding
│ │ 🏆 فوز! │ │ display font, gold for win
│ │ │ │
│ │ ♚ كش ملك │ │ ← body, reason
│ │ │ │ ← 24px
│ │ 1847 → 1859 │ │ ← stat font, animated count
│ │ +12 │ │ ← green badge
│ │ │ │ ← 24px
│ │ [Opponent Avatar + Name] │ │ ← opponent info row
│ │ │ │ ← 32px
│ │ ┌────────────────────────┐ │ │
│ │ │ العب مرة أخرى │ │ │ ← Primary, 48px
│ │ └────────────────────────┘ │ │
│ │ │ │ ← 12px
│ │ ┌────────────────────────┐ │ │
│ │ │ خصم جديد │ │ │ ← Secondary, 44px
│ │ └────────────────────────┘ │ │
│ │ │ │ ← 12px
│ │ الصفحة الرئيسية ← │ │ ← Ghost link
│ │ │ │
│ └──────────────────────────────┘ │
│ │
└──────────────────────────────────────┘
States:
- WIN: Gold accent, trophy icon, confetti particles (brief, 800ms)
- LOSS: Muted, no celebration, "حظاً أوفر" (better luck), subtle red accent
- DRAW: Neutral, handshake icon, amber accent
Animation:
- Overlay fades in (200ms)
- Card slides up from bottom (300ms, decelerate)
- Rating number animates from old→new (600ms, spring)
- Win confetti: 20-30 small particles, gravity fall, 800ms duration
```
### 8. Profile Screen
```
┌──────────────────────────────────────┐
│ [Header] │
├──────────────────────────────────────┤
│ │ ← 20px
│ ┌──────────────────────────────┐ │
│ │ ┌──────────┐ │ │ ← Profile hero section
│ │ │ Avatar │ 96px │ │ centered
│ │ │ + Frame │ │ │
│ │ └──────────┘ │ │
│ │ محمود أجلان │ │ ← h2, centered
│ │ @mahmoud │ │ ← body-sm, secondary
│ │ 🇪🇬 القاهرة │ │ ← caption + flag
│ │ │ │
│ │ ┌──────────────────────┐ │ │ ← XP bar
│ │ │████████░░░░░░░░░░░░░ │ │ │ height: 8px, rounded
│ │ └──────────────────────┘ │ │ Lv.12 — 2,450/3,000 XP
│ │ │ │
│ └──────────────────────────────┘ │
│ │ ← 24px
│ ┌──────┐┌──────┐┌──────┐┌──────┐ │ ← Rating cards (horizontal scroll)
│ │Bullet││Blitz ││Rapid ││Class.│ │ each: 80px wide, 96px tall
│ │ 1623 ││ 1847 ││ 1720 ││ 1580 │ │ icon + label + rating
│ └──────┘└──────┘└──────┘└──────┘ │
│ │ ← 24px
│ ── إحصائيات ────────────────────── │
│ │ ← 12px
│ ┌──────────────────────────────┐ │
│ │ 342 مباراة │ 58% فوز │ 🔥12 │ │ ← stats row, 3 items
│ └──────────────────────────────┘ │
│ │ ← 24px
│ ── الإنجازات (12/50) ───────────── │
│ │ ← 12px
│ ┌────┐┌────┐┌────┐┌────┐┌────┐ │ ← badge grid, 48px each
│ │ 🥇 ││ 🎯 ││ ⚡ ││ 🏆 ││ +8 │ │ last one: "+N more"
│ └────┘└────┘└────┘└────┘└────┘ │
│ │ ← 24px
│ ── آخر المباريات ───────────────── │
│ │ ← 12px
│ [Recent games list...] │
│ │
├──────────────────────────────────────┤
│ [Bottom Nav] │
└──────────────────────────────────────┘
Key decisions:
- Avatar is prominent (96px) with equipped frame
- Rating cards scroll horizontally (don't force 4 columns on narrow screens)
- Stats are single-row summary (not a full grid — save detail for drill-down)
- Achievements show 4-5 badges + "more" link
- Edit button: small icon button top-right of profile section
```
### 9. Leaderboard Screen
```
┌──────────────────────────────────────┐
│ [Header] │
├──────────────────────────────────────┤
│ │ ← 20px
│ المتصدرون │ ← h1
│ │ ← 16px
│ ┌────┐┌────┐┌────┐┌────┐ │ ← filter pills (horizontal scroll)
│ │بليتز││سريع││كلاس││رصاص│ │ selected: cyan bg
│ └────┘└────┘└────┘└────┘ │
│ │ ← 12px
│ ┌────┐┌────┐┌────┐┌────┐ │ ← period pills
│ │أسبوع││شهر ││الكل││أصدقاء│ │
│ └────┘└────┘└────┘└────┘ │
│ │ ← 24px
│ ┌──────────────────────────────┐ │ ← Top 3 podium
│ │ [2nd] [1st] [3rd] │ │ 1st: 72px avatar, gold border
│ │ 48px 72px 48px │ │ 2nd/3rd: 48px, silver/bronze
│ │ Name Name Name │ │ names + ratings below
│ │ 1923 1987 1901 │ │
│ └──────────────────────────────┘ │
│ │ ← 24px
│ ┌──────────────────────────────┐ │ ← Scrollable list
│ │ 4 [Av] Name 1889 │ │ row height: 56px
│ ├──────────────────────────────┤ │ rank + avatar(36px) + name + rating
│ │ 5 [Av] Name 1876 │ │
│ ├──────────────────────────────┤ │
│ │ 6 [Av] Name 1854 │ │
│ ├──────────────────────────────┤ │
│ │ ... │ │
│ ├══════════════════════════════┤ │ ← YOUR position (highlighted)
│ │ 23 [Av] YOU 1847 ★ │ │ cyan left border, Layer 2 bg
│ ├══════════════════════════════┤ │
│ │ ... │ │
│ └──────────────────────────────┘ │
│ │
├──────────────────────────────────────┤
│ [Bottom Nav] │
└──────────────────────────────────────┘
Key decisions:
- Top 3 get special treatment (podium layout) — this is standard in all gaming apps
- Your position always visible (sticky at bottom of list OR highlighted in scroll)
- Filter pills are horizontal scroll (accommodates translations)
- Two-level filtering: category + period
- Country flag next to player names (tiny, 16px)
```
### 10. Tournament List Screen
```
┌──────────────────────────────────────┐
│ [Header] │
├──────────────────────────────────────┤
│ │ ← 20px
│ البطولات │ ← h1
│ │ ← 16px
│ ┌────┐┌────┐┌────┐ │ ← status filter
│ │قادمة││مباشر││منتهية│ │
│ └────┘└────┘└────┘ │
│ │ ← 24px
│ ┌──────────────────────────────┐ │ ← Tournament card
│ │ [Banner image - 160px h] │ │ full-width, 12px radius
│ │ │ │
│ │ ┌─────┐ │ │ ← LIVE badge (if active)
│ │ │LIVE │ │ │ red, pulsing
│ │ └─────┘ │ │
│ ├──────────────────────────────┤ │
│ │ بطولة الربيع 2025 │ │ ← h3
│ │ شطرنج • بليتز • سويسري │ │ ← caption, secondary
│ │ │ │
│ │ 👥 24/32 │ 🪙 5000 │ ⏰ │ │ ← stats row
│ │ players prize time │ │
│ └──────────────────────────────┘ │
│ │ ← 16px
│ ┌──────────────────────────────┐ │ ← Next tournament card
│ │ ... │ │
│ └──────────────────────────────┘ │
│ │
├──────────────────────────────────────┤
│ [Bottom Nav] │
└──────────────────────────────────────┘
Tournament Card Anatomy:
- Banner: 160px height, image or gradient fallback
- Status badge: top-left overlay (LIVE=red, UPCOMING=cyan, ENDED=gray)
- Title: h3, below banner
- Meta line: game + time control + format
- Stats row: player count, prize pool, time until start
- Tap → Tournament Detail page
```
### 11. Tournament Detail Screen
```
┌──────────────────────────────────────┐
│ [← Back] بطولة الربيع │ ← simplified header
├──────────────────────────────────────┤
│ │
│ [Banner - 200px, full bleed] │ ← no side padding for banner
│ │
│ ┌──────────────────────────────┐ │ ← 20px padding resumes
│ │ بطولة الربيع 2025 │ │ ← h1
│ │ 🟢 مباشر • الجولة 3/7 │ │ ← status + round
│ │ │ │ ← 16px
│ │ ┌────┐┌────┐┌────┐┌────┐ │ │ ← info chips
│ │ │سويس││بليتز││مصنّف││32 لاعب││ │
│ │ └────┘└────┘└────┘└────┘ │ │
│ │ │ │ ← 24px
│ │ ┌────────────────────────┐ │ │
│ │ │ سجّل الآن (500 🪙) │ │ │ ← Primary, 48px (or "مسجّل ✓")
│ │ └────────────────────────┘ │ │
│ └──────────────────────────────┘ │
│ │ ← 24px
│ ┌────┐┌────┐┌────┐┌────┐ │ ← Tab bar
│ │ترتيب││أدوار││قرعة ││جوائز│ │ sticky below header on scroll
│ └────┘└────┘└────┘└────┘ │
│ │ ← 16px
│ [Tab Content - depends on tab] │
│ │
│ Standings tab: │
│ ┌──────────────────────────────┐ │
│ │ # │ Name │ Pts │ W-D-L │ │ ← table header, 40px
│ ├──────────────────────────────┤ │
│ │ 1 │ Ahmed │ 5.5 │ 5-1-0 │ │ ← rows, 48px each
│ │ 2 │ Omar │ 5.0 │ 4-2-0 │ │
│ │ 3 │ Khaled │ 4.5 │ 4-1-1 │ │
│ └──────────────────────────────┘ │
│ │
├──────────────────────────────────────┤
│ [Bottom Nav] │
└──────────────────────────────────────┘
```
### 12. Friends / Social Screen
```
┌──────────────────────────────────────┐
│ [Header] │
├──────────────────────────────────────┤
│ │ ← 20px
│ ┌────┐┌────┐┌────┐ │ ← sub-tabs
│ │أصدقاء││رسائل││نشاط │ │
│ └────┘└────┘└────┘ │
│ │ ← 16px
│ ┌──────────────────────────────┐ │ ← search input
│ │ 🔍 ابحث عن لاعب... │ │ 48px, full-width
│ └──────────────────────────────┘ │
│ │ ← 24px
│ ── متصلون (3) ─────────────────── │
│ │ ← 8px
│ ┌──────────────────────────────┐ │
│ │ [🟢Av] Ahmed 1847 [⚔️] │ │ ← 56px row, green dot = online
│ ├──────────────────────────────┤ │ challenge button right side
│ │ [🟢Av] Omar 1623 [⚔️] │ │
│ ├──────────────────────────────┤ │
│ │ [🟢Av] Sara 1720 [⚔️] │ │
│ └──────────────────────────────┘ │
│ │ ← 24px
│ ── غير متصلين (12) ────────────── │
│ │ ← 8px
│ ┌──────────────────────────────┐ │
│ │ [⚫Av] Khaled 1580 │ │ ← no challenge button
│ ├──────────────────────────────┤ │ "آخر ظهور: 2 ساعة" subtitle
│ │ [⚫Av] Youssef 1445 │ │
│ └──────────────────────────────┘ │
│ │ ← 24px
│ ── طلبات معلّقة (2) ────────────── │
│ │ ← 8px
│ ┌──────────────────────────────┐ │
│ │ [Av] NewPlayer [✓] [✗] │ │ ← accept/reject buttons
│ └──────────────────────────────┘ │
│ │
├──────────────────────────────────────┤
│ [Bottom Nav] │
└──────────────────────────────────────┘
Key decisions:
- Online friends ALWAYS show first (they're actionable)
- Challenge button only appears for online friends
- Sub-tabs switch between Friends, Messages (DM), Activity
- Search is always visible (not hidden behind an icon)
- Sections are collapsible
```
### 13. Shop Screen
```
┌──────────────────────────────────────┐
│ [Header - shows coin/gem balance] │
├──────────────────────────────────────┤
│ │ ← 20px
│ المتجر │ ← h1
│ │ ← 16px
│ ┌────┐┌────┐┌────┐┌────┐ │ ← category pills
│ │إطارات││لوحات││قطع ││ألقاب│ │
│ └────┘└────┘└────┘└────┘ │
│ │ ← 24px
│ ┌─────────────┐ ┌─────────────┐ │ ← 2-column grid
│ │ │ │ │ │ card ratio: 4:5
│ │ [Preview] │ │ [Preview] │ │ image fills top 60%
│ │ │ │ │ │
│ │ إطار ذهبي │ │ إطار فضي │ │ ← name
│ │ 🪙 500 │ │ 🪙 200 │ │ ← price
│ │ ★★★☆☆ │ │ ★★☆☆☆ │ │ ← rarity
│ └─────────────┘ └─────────────┘ │ ← 12px gap
│ ┌─────────────┐ ┌─────────────┐ │
│ │ │ │ [OWNED ✓] │ │ ← owned indicator
│ │ [Preview] │ │ [Preview] │ │
│ │ │ │ │ │
│ │ إطار ملكي │ │ إطار بسيط │ │
│ │ 💎 50 │ │ 🪙 100 │ │
│ │ ★★★★☆ │ │ ★☆☆☆☆ │ │
│ └─────────────┘ └─────────────┘ │
│ │
├──────────────────────────────────────┤
│ [Bottom Nav] │
└──────────────────────────────────────┘
Purchase flow (bottom sheet):
1. Tap item → bottom sheet slides up
2. Shows: large preview, name, description, rarity, price
3. "شراء (500 🪙)" button — shows balance change
4. Confirm → coin deduct animation → "مملوكة!" success state
5. If insufficient: "رصيد غير كافٍ" with link to earn coins
Key decisions:
- 2-column grid (items need visual preview space)
- 4:5 ratio cards (taller than wide — shows cosmetic preview well)
- Owned items get a subtle checkmark overlay
- Rarity communicated via stars (not just border colors)
- Limited edition: countdown timer on card
```
### 14. Settings Screen
```
┌──────────────────────────────────────┐
│ [Header] │
├──────────────────────────────────────┤
│ │ ← 20px
│ الإعدادات │ ← h1
│ │ ← 24px
│ ── الحساب ──────────────────────── │
│ ┌──────────────────────────────┐ │
│ │ اللغة العربية ▸│ │ ← 56px row, chevron
│ ├──────────────────────────────┤ │
│ │ تغيير كلمة المرور ▸│ │
│ ├──────────────────────────────┤ │
│ │ الخصوصية ▸│ │
│ └──────────────────────────────┘ │
│ │ ← 32px
│ ── اللعب ───────────────────────── │
│ ┌──────────────────────────────┐ │
│ │ الأصوات [═══●]│ │ ← toggle switch
│ ├──────────────────────────────┤ │
│ │ الاهتزاز [═══●]│ │
│ ├──────────────────────────────┤ │
│ │ تأكيد النقلة [●═══]│ │ ← off state
│ └──────────────────────────────┘ │
│ │ ← 32px
│ ── الإشعارات ───────────────────── │
│ ┌──────────────────────────────┐ │
│ │ إشعارات المباراة [═══●]│ │
│ ├──────────────────────────────┤ │
│ │ إشعارات الأصدقاء [═══●]│ │
│ ├──────────────────────────────┤ │
│ │ إشعارات البطولات [═══●]│ │
│ └──────────────────────────────┘ │
│ │ ← 32px
│ ── حول ─────────────────────────── │
│ ┌──────────────────────────────┐ │
│ │ الإصدار 2.1.0 │ │
│ ├──────────────────────────────┤ │
│ │ قائمة الحظر ▸│ │
│ ├──────────────────────────────┤ │
│ │ حذف الحساب ▸│ │ ← destructive red text
│ └──────────────────────────────┘ │
│ │ ← 48px
│ ┌──────────────────────────────┐ │
│ │ تسجيل الخروج │ │ ← Destructive button
│ └──────────────────────────────┘ │
│ │
├──────────────────────────────────────┤
│ [Bottom Nav] │
└──────────────────────────────────────┘
Pattern: iOS-style grouped list
- Group label above each section (14px, secondary, uppercase)
- Rows: 56px height, full-width tap target
- Chevron (▸) for drill-down rows
- Toggle for on/off settings
- Value text right-aligned (secondary color)
- 32px between groups
```
### 15. Notifications Screen
```
┌──────────────────────────────────────┐
│ [Header] │
├──────────────────────────────────────┤
│ │ ← 20px
│ الإشعارات [تحديد الكل كمقروء]│ ← h1 + action link
│ │ ← 16px
│ ┌──────────────────────────────┐ │
│ │▌[🏆] فزت على أحمد! │ │ ← UNREAD: cyan left border
│ │▌ +12 تقييم • منذ 5 دقائق│ │ Layer 2 background
│ ├──────────────────────────────┤ │ 72px height
│ │▌[👤] طلب صداقة من عمر │ │
│ │▌ منذ 20 دقيقة │ │ action buttons inline
│ ├──────────────────────────────┤ │
│ │ [🏟] بطولة الربيع تبدأ غداً │ │ ← READ: no border
│ │ منذ 3 ساعات │ │ Layer 1 background
│ ├──────────────────────────────┤ │
│ │ [🎖] إنجاز جديد: 100 مباراة │ │
│ │ منذ يوم │ │
│ └──────────────────────────────┘ │
│ │
│ [Empty state if no notifications: │
│ illustration + "لا إشعارات جديدة"] │
│ │
├──────────────────────────────────────┤
│ [Bottom Nav] │
└──────────────────────────────────────┘
Key decisions:
- Unread: cyan left border (4px) + elevated background
- Read: no border, flat background
- Each notification has: icon (by type) + title + subtitle + timestamp
- Tap → navigates to relevant screen (match, profile, tournament)
- Swipe left → dismiss (with red background reveal)
- Friend request notifications have accept/reject inline
```
---
## Responsive Behavior
### Mobile → Tablet Transitions
| Element | Mobile | Tablet (768px+) |
|---------|--------|-----------------|
| Game cards (Play) | 2 columns | 3 columns |
| Shop grid | 2 columns | 3 columns |
| Tournament cards | Full-width stacked | 2-column grid |
| Leaderboard | Full screen | Centered, max 600px |
| Chess board | 100% - 8px width | Max 480px, centered |
| Player bars | Compact (name + rating) | Full (avatar + name + rating + country) |
| Bottom nav | Visible | Visible |
| Header | Compact | Full |
### Tablet → Desktop Transitions
| Element | Tablet | Desktop (1024px+) |
|---------|--------|-------------------|
| Navigation | Bottom nav | Left rail (72px) |
| Layout | Single column | Main + right panel |
| Chess board | Centered | Left section, move list on right |
| Tournament detail | Tabs below | Sidebar tabs |
| Profile | Single column | Two-column (info left, stats right) |
| Shop | 3-column grid | 4-column grid |
| Context panel | Hidden | 320px right side (friends, activity) |
---
## Safe Area Handling
```css
/* Applied to every screen wrapper */
padding-top: env(safe-area-inset-top);
padding-bottom: env(safe-area-inset-bottom);
padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right);
```
### Specific Rules:
- Header: includes safe-area-top in its height
- Bottom nav: includes safe-area-bottom as extra padding below icons
- Game screen: board position accounts for both bars + safe areas
- Modals: respect all safe areas
- Bottom sheets: safe-area-bottom adds to bottom padding
---
## RTL-Specific Rules
### Layout Mirroring
- All flexbox layouts reverse automatically (`dir="rtl"`)
- Icons that imply direction (arrows, chevrons) get mirrored
- Progress bars fill from right to left
- Swipe gestures reverse (swipe right = back)
- Card layouts: text aligns right, numbers can stay left-to-right
### Exceptions (DO NOT Mirror)
- Chess board (always white bottom-left = a1)
- Clocks (numbers are universal)
- Media playback controls
- Phone numbers
- Mathematical expressions
- Timestamps
### Arabic Text Rules
- Text starts 16px body, gets +1px visual boost
- Numbers within Arabic text use Eastern Arabic numerals (٠١٢٣٤٥٦٧٨٩) for immersion, OR stay Western (0-9) for game stats — player's choice in settings
- Truncation: use `…` at the END of text (which is LEFT side in RTL)
- Line height: 1.6 for Arabic body text (glyphs have more vertical complexity)
---
## Loading States
### Skeleton Screens (NOT Spinners)
Every component has a skeleton variant that matches its exact dimensions:
```
Card skeleton: Rectangle (same size as card), Layer 2, animated shimmer
Text skeleton: Rounded rectangle, 60-80% width, 14px height, shimmer
Avatar skeleton: Circle, Layer 2, shimmer
Number skeleton: Rounded rectangle, 48px width, shimmer
```
### Shimmer Animation
- Direction: right-to-left (RTL!)
- Duration: 1.5s, infinite loop
- Gradient: Layer 1 → Layer 2 → Layer 1 (subtle, not flashy)
### Loading Hierarchy
1. Show skeleton immediately (0ms)
2. Content replaces skeleton with fade-in (100ms)
3. If loading > 3s: show subtle "loading" text below skeleton
4. If loading > 10s: show error state with retry button
### Error States
- Inline error: red text below component + retry link
- Page error: centered illustration + message + retry button
- Connection lost: top banner (red) with "لا يوجد اتصال" + retry
- 404: centered message + back-to-home button
---
## Celebration Moments (Full-Screen Takeovers)
These are the moments that make players come back. They get special treatment.
### 1. Match Win
- Full-screen overlay (Layer 0, 85% opacity backdrop)
- Trophy icon animates in (scale 0.5 → 1, spring, 400ms)
- "فوز!" text appears (fade + slide up, 200ms delay)
- Rating counter animates (old → new, 600ms)
- Confetti: 30 particles, randomized colors (gold, cyan), 800ms, gravity
- Haptic feedback (if available)
### 2. Level Up
- Full-screen overlay
- Level number animates (old → new, scale spring)
- "مستوى جديد!" text
- Reward cards appear below (slide up, stagger 100ms each)
- XP bar fills visually even though it's already calculated
- Dismiss: tap anywhere or auto-dismiss after 4s
### 3. Achievement Unlock
- NOT full screen — appears as a toast/banner (top)
- Slides down, stays 3s, slides up
- Shows: achievement icon + name + "+50 XP"
- Tap → navigates to achievements page
### 4. Daily Reward Claim
- Bottom sheet (not full screen)
- Streak calendar visual (7 days, current highlighted)
- Reward card appears with flip animation (300ms)
- Coin/XP counter ticks up
- Dismiss: close button or tap outside
### 5. Tournament Win
- Full-screen (most dramatic)
- Podium animation (your position rises)
- Prize display (coins falling animation)
- Rating change
- Share option
---
## Empty States
Every list/collection has an empty state. Never show a blank screen.
| Context | Message | Action |
|---------|---------|--------|
| No friends | "أضف أصدقاء للعب معهم" | "ابحث عن لاعبين" button |
| No matches | "لم تلعب أي مباراة بعد" | "العب الآن" button |
| No notifications | "لا إشعارات جديدة" | — (no action needed) |
| No tournaments | "لا بطولات متاحة حالياً" | — |
| No achievements | "ابدأ اللعب لكسب الإنجازات" | "العب الآن" button |
| Search no results | "لم يتم العثور على نتائج" | "جرّب كلمات أخرى" |
Each empty state has:
- A simple illustration (minimal, abstract shapes — not cartoon)
- Arabic text (body size, secondary color)
- Optional CTA button below
---
## Accessibility Baseline
### Minimum Contrast
- Text on background: 4.5:1 (WCAG AA)
- Large text (18px+): 3:1
- Interactive elements: 3:1 against adjacent colors
- Focus ring: 2px cyan, 2px offset, always visible on keyboard focus
### Touch & Interaction
- All interactive elements: 48x48px minimum tap area
- Focus order: logical reading order (top→bottom, right→left in RTL)
- All images: alt text (Arabic)
- All icons: aria-label
- Chess pieces: announced by screen reader ("حصان أبيض على e4")
- Timer: aria-live region (announces time warnings)
### Motion
- Respect `prefers-reduced-motion`:
- Disable all transitions/animations
- Celebration confetti → static badge
- Page transitions → instant swap
- Shimmer → static gray
---
## Performance Targets
| Metric | Target | How |
|--------|--------|-----|
| First Contentful Paint | <1.5s | Code split, preload critical CSS |
| Largest Contentful Paint | <2.5s | Lazy load images, prioritize above-fold |
| Time to Interactive | <3s | Defer non-critical JS |
| Cumulative Layout Shift | <0.1 | Fixed dimensions on all media |
| Navigation (page switch) | <150ms | Prefetch on hover/focus, keep in memory |
| Move response (chess) | <50ms | Local validation, then sync |
| Animation frame rate | 60fps | GPU-accelerated transforms only |
---
## Sound Design Integration
| Action | Sound Character | Duration |
|--------|----------------|----------|
| Button tap | Soft click | 50ms |
| Chess move | Wood piece place | 150ms |
| Chess capture | Firm knock | 200ms |
| Check | Sharp alert ping | 300ms |
| Match found | Triumphant chime | 800ms |
| Win | Victorious fanfare | 1200ms |
| Lose | Soft descending tone | 600ms |
| Notification | Gentle bell | 400ms |
| Coin collect | Metallic ting | 200ms |
| Level up | Ascending sparkle | 1000ms |
| Error | Low buzz | 200ms |
| Clock low time | Soft tick (repeating) | 100ms/tick |
Rules:
- All sounds optional (graceful fallback to silence)
- Volume respects system settings
- Can be toggled off in settings
- Never auto-play sounds on page load
- Match sounds should never overlap (queue them)
---
## Design Tokens Summary (For Implementation)
```
--space-1: 4px
--space-2: 8px
--space-3: 12px
--space-4: 16px
--space-5: 20px
--space-6: 24px
--space-7: 32px
--space-8: 48px
--space-9: 64px
--radius-sm: 8px
--radius-md: 12px
--radius-lg: 16px
--radius-xl: 24px
--radius-full: 9999px
--color-bg-0: #071120
--color-bg-1: #0D1B2A
--color-bg-2: #132D4A
--color-bg-3: #1A3A5C
--color-gold: #E7A832
--color-cyan: #15D7FF
--color-blue: #2979FF
--color-success: #34D399
--color-error: #EF4444
--color-warning: #F59E0B
--color-online: #22C55E
--color-text-1: #F1F5F9
--color-text-2: #94A3B8
--color-text-3: #64748B
--color-border-subtle: rgba(255,255,255,0.06)
--color-border-medium: rgba(255,255,255,0.12)
--color-border-strong: rgba(255,255,255,0.20)
--font-arabic: 'IBM Plex Sans Arabic', 'Cairo', sans-serif
--font-latin: 'Inter', sans-serif
--font-mono: 'JetBrains Mono', monospace
--shadow-1: 0 1px 3px rgba(0,0,0,0.3), 0 1px 2px rgba(0,0,0,0.2)
--shadow-2: 0 4px 12px rgba(0,0,0,0.4), 0 2px 4px rgba(0,0,0,0.3)
--shadow-3: 0 12px 40px rgba(0,0,0,0.5), 0 4px 12px rgba(0,0,0,0.3)
--duration-instant: 100ms
--duration-fast: 200ms
--duration-normal: 300ms
--duration-celebrate: 600ms
--ease-enter: cubic-bezier(0, 0, 0.2, 1)
--ease-exit: cubic-bezier(0.4, 0, 1, 1)
--ease-standard: cubic-bezier(0.4, 0, 0.2, 1)
--header-height: 56px
--bottom-nav-height: 64px
--touch-min: 48px
--touch-comfortable: 56px
```
---
*This document is the visual law. Every screen, every pixel, every interaction follows these specifications. The goal: a player opens EL3AB and forgets they're in a browser.*
FROM node:20-alpine AS build FROM php:8.3-apache
WORKDIR /app
COPY package*.json ./ RUN apt-get update && apt-get install -y libpq-dev \
RUN npm ci && docker-php-ext-install pdo pdo_pgsql pgsql \
COPY . . && apt-get clean && rm -rf /var/lib/apt/lists/*
RUN npm run build
RUN a2enmod rewrite headers
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html ENV APACHE_DOCUMENT_ROOT=/var/www/html
COPY nginx.conf /etc/nginx/conf.d/default.conf RUN sed -ri -e 's!/var/www/html!${APACHE_DOCUMENT_ROOT}!g' /etc/apache2/sites-available/*.conf
RUN sed -ri -e 's!/var/www/!${APACHE_DOCUMENT_ROOT}!g' /etc/apache2/apache2.conf /etc/apache2/conf-available/*.conf
COPY . /var/www/html/
RUN chown -R www-data:www-data /var/www/html
EXPOSE 80 EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
# EL3AB Player App — Complete Server Data Reference
> This document contains ALL actual production data from the El3ab self-hosted Supabase instance.
> Every schema, enum value, config setting, and function listed here was queried directly from the live database.
> **Do NOT invent or assume data that is not in this file.**
---
## 1. CONNECTION DETAILS
### Supabase (Self-hosted on Docker/CapRover)
| Key | Value |
|-----|-------|
| **SUPABASE_URL** | `https://safe-supabase-kong.caprover.al-arcade.com` |
| **ANON KEY** | `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzM1Njg5NjAwLCJleHAiOjE4OTM0NTYwMDB9.bFnS-YBhykTQ6vqrfTKJqmAB_aSW6GUgCat3QLkgCv8` |
| **SERVICE_ROLE KEY** | `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoic2VydmljZV9yb2xlIiwiaXNzIjoic3VwYWJhc2UiLCJpYXQiOjE3MzU2ODk2MDAsImV4cCI6MTg5MzQ1NjAwMH0.wNfmuJNkX-bZwD7RbjxOChlRf_3Xm4I7bswEYTcDCg4` |
| **JWT Secret** | `super-secret-jwt-token-with-at-least-32-characters-long` |
| **Server IP** | `3.68.63.185` (AWS Frankfurt) |
| **DB Container** | `safe-supabase-db` |
| **DB User** | `supabase_admin` |
| **DB Name** | `postgres` |
### Auth Configuration
- Provider: Supabase Auth (email/password + OAuth)
- New user trigger: `handle_new_user()` creates profile automatically
- Default language: Arabic (`'ar'`)
- Default display_name: `'لاعب جديد'` (New Player)
### Swiss API (Tournament Engine)
| Key | Value |
|-----|-------|
| **URL** | `https://swissapi.caprover.al-arcade.com/api/v1` |
| **Purpose** | Swiss pairing generation, standings calculation, TRF export |
### Storage Base URL
```
https://safe-supabase-kong.caprover.al-arcade.com/storage/v1/object/public/{bucket-name}/{path}
```
---
## 2. DATABASE SCHEMA (99 Tables)
### 2.1 PROFILES (Core Player Table)
```sql
-- Table: profiles
-- RLS: SELECT open to everyone, UPDATE only own profile
-- Realtime: enabled
-- FK: profiles.id -> auth.users(id) ON DELETE CASCADE
id UUID PK (= auth.users.id)
username TEXT NOT NULL UNIQUE
display_name TEXT NOT NULL
display_name_ar TEXT
avatar_url TEXT
banner_url TEXT
bio TEXT
bio_ar TEXT
country_code CHAR(3)
city TEXT
preferred_language TEXT DEFAULT 'ar'
-- Ratings (per time control)
elo_bullet INT DEFAULT 1200
elo_blitz INT DEFAULT 1200
elo_rapid INT DEFAULT 1200
elo_classical INT DEFAULT 1200
-- FIDE Integration
fide_id TEXT
fide_rating_standard INT
fide_rating_rapid INT
fide_rating_blitz INT
fide_title TEXT -- GM, IM, FM, CM, WGM, etc.
-- Progression
xp INT DEFAULT 0
level INT DEFAULT 1
coins INT DEFAULT 0
gems INT DEFAULT 0
premium_currency INT DEFAULT 0
-- Status
is_online BOOLEAN DEFAULT false
last_seen_at TIMESTAMPTZ
is_banned BOOLEAN DEFAULT false
ban_reason TEXT
ban_expires_at TIMESTAMPTZ
banned_by UUID
-- Stats
total_games_played INT DEFAULT 0
total_wins INT DEFAULT 0
total_draws INT DEFAULT 0
total_losses INT DEFAULT 0
total_tournaments_played INT DEFAULT 0
total_tournaments_won INT DEFAULT 0
win_streak INT DEFAULT 0
best_win_streak INT DEFAULT 0
games_played INT DEFAULT 0
-- Cosmetics
current_game TEXT
avatar_frame_id UUID
avatar_border_color TEXT
active_org_frame_id UUID
-- Timestamps
created_at TIMESTAMPTZ DEFAULT now()
updated_at TIMESTAMPTZ DEFAULT now()
-- Daily Rewards
last_daily_reward TIMESTAMPTZ
daily_streak INT DEFAULT 0
```
### 2.2 MATCHES
```sql
-- Table: matches
-- RLS: SELECT open to everyone, INSERT/UPDATE only players in match
-- Realtime: enabled
id UUID PK DEFAULT gen_random_uuid()
game_key TEXT NOT NULL DEFAULT 'chess'
white_player_id UUID -> profiles(id)
black_player_id UUID -> profiles(id)
match_type TEXT NOT NULL -- 'ranked', 'casual', 'tournament', 'bot'
tournament_id UUID
tournament_round INT
pairing_id UUID
status match_status DEFAULT 'waiting'
result match_result
time_control time_control NOT NULL
initial_time_ms INT NOT NULL
increment_ms INT DEFAULT 0
white_time_remaining_ms INT
black_time_remaining_ms INT
starting_fen TEXT DEFAULT 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1'
current_fen TEXT
pgn TEXT
moves JSONB DEFAULT '[]'
move_count INT DEFAULT 0
game_state JSONB DEFAULT '{}'
white_rating_before INT
black_rating_before INT
white_rating_after INT
black_rating_after INT
rating_change_white INT
rating_change_black INT
bot_id TEXT
bot_difficulty TEXT
is_flagged BOOLEAN DEFAULT false
cheat_score NUMERIC(5,2) DEFAULT 0
analysis_complete BOOLEAN DEFAULT false
is_rated BOOLEAN DEFAULT true
is_rematch BOOLEAN DEFAULT false
rematch_of UUID -> matches(id)
started_at TIMESTAMPTZ
completed_at TIMESTAMPTZ
created_at TIMESTAMPTZ DEFAULT now()
updated_at TIMESTAMPTZ DEFAULT now()
```
### 2.3 MATCHMAKING_QUEUE
```sql
-- Table: matchmaking_queue
-- RLS: Players see/insert/delete only their own entries
-- Realtime: enabled
id UUID PK DEFAULT gen_random_uuid()
player_id UUID -> profiles(id) ON DELETE CASCADE
game_key TEXT NOT NULL DEFAULT 'chess'
time_control time_control NOT NULL
rating INT NOT NULL
rating_range_min INT
rating_range_max INT
is_rated BOOLEAN DEFAULT true
queued_at TIMESTAMPTZ DEFAULT now()
range_expansion_per_sec INT DEFAULT 10
max_wait_seconds INT DEFAULT 60
block_list UUID[] DEFAULT '{}'
region_preference TEXT
status TEXT DEFAULT 'searching' -- 'searching', 'matched'
matched_with UUID -> profiles(id)
match_id UUID -> matches(id)
expires_at TIMESTAMPTZ
```
### 2.4 EL3AB_TOURNAMENTS
```sql
-- Table: el3ab_tournaments
-- RLS: SELECT open to everyone, INSERT requires platform role
-- Realtime: enabled
id UUID PK DEFAULT gen_random_uuid()
swiss_api_tournament_id UUID
org_id UUID -> el3ab_organizations(id)
game_key TEXT NOT NULL DEFAULT 'chess'
name TEXT NOT NULL
name_ar TEXT
description TEXT
description_ar TEXT
banner_url TEXT
format tournament_format NOT NULL
time_control time_control NOT NULL
custom_time_initial_ms INT
custom_time_increment_ms INT
rounds_total INT
swiss_rounds INT
bracket_size INT
bracket_best_of INT DEFAULT 1
entry_fee_coins INT DEFAULT 0
entry_fee_gems INT DEFAULT 0
min_rating INT
max_rating INT
min_players INT DEFAULT 4
max_players INT DEFAULT 256
prize_pool_coins INT DEFAULT 0
prize_pool_gems INT DEFAULT 0
prize_distribution JSONB DEFAULT '[]'
cosmetic_rewards JSONB DEFAULT '[]'
registration_opens_at TIMESTAMPTZ
registration_closes_at TIMESTAMPTZ
starts_at TIMESTAMPTZ NOT NULL
status TEXT DEFAULT 'draft' -- draft, registration, in_progress, completed, cancelled
current_round INT DEFAULT 0
charity_id UUID
charity_percent NUMERIC(5,2) DEFAULT 0
sponsor_id UUID
sponsor_branding JSONB DEFAULT '{}'
is_rated BOOLEAN DEFAULT true
is_fide_rated BOOLEAN DEFAULT false
allow_berserk BOOLEAN DEFAULT false
auto_start BOOLEAN DEFAULT true
created_by UUID -> profiles(id)
created_at TIMESTAMPTZ DEFAULT now()
updated_at TIMESTAMPTZ DEFAULT now()
tournament_mode TEXT DEFAULT 'single' CHECK (IN ('single', 'multi_phase'))
phase_config JSONB DEFAULT '[]'
current_phase INT DEFAULT 1
total_phases INT DEFAULT 1
tiebreak_rules JSONB DEFAULT '["buchholz_cut_1", "buchholz", "sonneborn_berger"]'
acceleration_method TEXT
acceleration_rounds INT DEFAULT 0
-- Live page columns
slug TEXT UNIQUE
live_enabled BOOLEAN DEFAULT false
live_theme TEXT DEFAULT 'default'
live_custom_css TEXT
live_visibility JSONB DEFAULT '{"rules":true,"stats":true,"prizes":true,"ticker":true,"bracket":true,"gallery":true,"players":true,"pairings":true,"schedule":true,"standings":true,"announcements":true}'
live_branding JSONB DEFAULT '{}'
view_count INT DEFAULT 0
unique_visitors INT DEFAULT 0
```
### 2.5 TOURNAMENT_REGISTRATIONS
```sql
-- Table: tournament_registrations
-- UNIQUE: (tournament_id, player_id)
id UUID PK DEFAULT gen_random_uuid()
tournament_id UUID -> el3ab_tournaments(id) ON DELETE CASCADE
player_id UUID -> profiles(id) ON DELETE CASCADE
entry_fee_paid_coins INT DEFAULT 0
entry_fee_paid_gems INT DEFAULT 0
status TEXT DEFAULT 'registered' -- registered, withdrawn, disqualified
seed INT
final_standing INT
registered_at TIMESTAMPTZ DEFAULT now()
withdrawn_at TIMESTAMPTZ
```
### 2.6 EL3AB_TOURNAMENT_ROUNDS
```sql
-- Table: el3ab_tournament_rounds
-- UNIQUE: (tournament_id, round_number)
id UUID PK DEFAULT gen_random_uuid()
tournament_id UUID NOT NULL -> el3ab_tournaments(id) ON DELETE CASCADE
round_number INT NOT NULL
status TEXT DEFAULT 'pending' -- pending, in_progress, completed
started_at TIMESTAMPTZ
completed_at TIMESTAMPTZ
pairings JSONB DEFAULT '[]'
results JSONB DEFAULT '[]'
created_at TIMESTAMPTZ DEFAULT now()
```
### 2.7 TOURNAMENT_PHASES (Multi-Phase)
```sql
-- Table: tournament_phases
-- UNIQUE: (tournament_id, phase_number)
id UUID PK DEFAULT gen_random_uuid()
tournament_id UUID NOT NULL -> el3ab_tournaments(id) ON DELETE CASCADE
phase_number INT NOT NULL
name TEXT NOT NULL
name_ar TEXT
type TEXT NOT NULL CHECK (IN ('swiss','round_robin','single_elimination','double_elimination','arena','group_stage'))
config JSONB DEFAULT '{}'
swiss_api_tournament_id UUID
status TEXT DEFAULT 'pending' CHECK (IN ('pending','in_progress','completed'))
advancement_rule JSONB DEFAULT '{}'
started_at TIMESTAMPTZ
completed_at TIMESTAMPTZ
created_at TIMESTAMPTZ DEFAULT now()
updated_at TIMESTAMPTZ DEFAULT now()
```
### 2.8 BRACKET_MATCHES
```sql
-- Table: bracket_matches
id UUID PK DEFAULT gen_random_uuid()
bracket_id UUID NOT NULL -> tournament_brackets(id) ON DELETE CASCADE
tournament_id UUID NOT NULL -> el3ab_tournaments(id) ON DELETE CASCADE
phase_id UUID NOT NULL -> tournament_phases(id) ON DELETE CASCADE
round_number INT NOT NULL
match_number INT NOT NULL
player_a_id TEXT
player_a_name TEXT
player_a_seed INT
player_b_id TEXT
player_b_name TEXT
player_b_seed INT
result TEXT CHECK (IN ('player_a_wins','player_b_wins','draw','not_played','forfeit_a','forfeit_b','bye'))
score_a TEXT
score_b TEXT
winner_id TEXT
next_match_id UUID -> bracket_matches(id)
next_match_slot TEXT CHECK (IN ('player_a','player_b'))
loser_next_match_id UUID -> bracket_matches(id)
loser_next_match_slot TEXT CHECK (IN ('player_a','player_b'))
source_match_a_id UUID -> bracket_matches(id)
source_match_b_id UUID -> bracket_matches(id)
scheduled_at TIMESTAMPTZ
started_at TIMESTAMPTZ
completed_at TIMESTAMPTZ
status TEXT DEFAULT 'pending' CHECK (IN ('pending','ready','in_progress','completed','bye'))
metadata JSONB DEFAULT '{}'
created_at TIMESTAMPTZ DEFAULT now()
updated_at TIMESTAMPTZ DEFAULT now()
```
### 2.9 EL3AB_ORGANIZATIONS
```sql
-- Table: el3ab_organizations
-- RLS: SELECT where is_active=true, UPDATE requires org_admin role
id UUID PK DEFAULT gen_random_uuid()
swiss_api_org_id UUID
name TEXT NOT NULL
name_ar TEXT
slug TEXT NOT NULL UNIQUE
logo_url TEXT
banner_url TEXT
description TEXT
description_ar TEXT
website TEXT
contact_email TEXT
country_code CHAR(3)
city TEXT
tier TEXT DEFAULT 'free' -- free, bronze, silver, gold, diamond
max_members INT DEFAULT 50
max_concurrent_tournaments INT DEFAULT 3
features_enabled JSONB DEFAULT '{}'
custom_branding JSONB DEFAULT '{}'
revenue_share_percent NUMERIC(5,2) DEFAULT 0
charity_percent NUMERIC(5,2) DEFAULT 0
is_verified BOOLEAN DEFAULT false
is_active BOOLEAN DEFAULT true
created_at TIMESTAMPTZ DEFAULT now()
updated_at TIMESTAMPTZ DEFAULT now()
primary_color TEXT DEFAULT '#6366f1'
secondary_color TEXT DEFAULT '#8b5cf6'
social_links JSONB DEFAULT '{}'
custom_css TEXT
tier_points INT DEFAULT 0
member_count INT DEFAULT 0
total_tournaments INT DEFAULT 0
total_matches INT DEFAULT 0
founded_at DATE
features JSONB DEFAULT '{}'
```
### 2.10 ORG_MEMBERS
```sql
-- Table: org_members
-- UNIQUE: (org_id, player_id)
id UUID PK DEFAULT gen_random_uuid()
org_id UUID -> el3ab_organizations(id) ON DELETE CASCADE
player_id UUID -> profiles(id) ON DELETE CASCADE
role TEXT DEFAULT 'member' -- owner, admin, moderator, member
joined_via TEXT DEFAULT 'invite' -- invite, application, link, referral
application_id UUID -> org_membership_applications(id)
joined_at TIMESTAMPTZ DEFAULT now()
display_name TEXT
custom_title TEXT
permissions JSONB DEFAULT '{}'
is_muted BOOLEAN DEFAULT false
muted_until TIMESTAMPTZ
contribution_points INT DEFAULT 0
last_active_at TIMESTAMPTZ
invited_by UUID
invite_link_id UUID
```
### 2.11 FRIENDSHIPS
```sql
-- Table: friendships
-- RLS: Only involved parties can SELECT; requester can INSERT
-- UNIQUE: (requester_id, addressee_id)
-- Realtime: enabled
id UUID PK DEFAULT gen_random_uuid()
requester_id UUID -> profiles(id) ON DELETE CASCADE
addressee_id UUID -> profiles(id) ON DELETE CASCADE
status TEXT DEFAULT 'pending' -- pending, accepted, blocked
created_at TIMESTAMPTZ DEFAULT now()
updated_at TIMESTAMPTZ DEFAULT now()
```
### 2.12 NOTIFICATIONS
```sql
-- Table: notifications
-- RLS: Users see and update only their own
-- Realtime: enabled
id UUID PK DEFAULT gen_random_uuid()
user_id UUID -> profiles(id) ON DELETE CASCADE
type TEXT NOT NULL -- friend_request, match_invite, tournament_start, achievement_unlocked, etc.
title TEXT NOT NULL
title_ar TEXT
body TEXT
body_ar TEXT
data JSONB DEFAULT '{}' -- e.g. {match_id, tournament_id, sender_id}
is_read BOOLEAN DEFAULT false
read_at TIMESTAMPTZ
created_at TIMESTAMPTZ DEFAULT now()
```
### 2.13 CHAT_MESSAGES
```sql
-- Table: chat_messages
-- RLS: Authenticated can INSERT own; SELECT open to authenticated
-- Realtime: enabled
id UUID PK DEFAULT gen_random_uuid()
channel_type TEXT NOT NULL -- 'match', 'tournament', 'club', 'direct', 'org'
channel_id UUID NOT NULL -- References match_id, tournament_id, club_id, etc.
sender_id UUID -> profiles(id)
content TEXT NOT NULL
is_system BOOLEAN DEFAULT false
is_deleted BOOLEAN DEFAULT false
created_at TIMESTAMPTZ DEFAULT now()
```
### 2.14 COSMETICS
```sql
-- Table: cosmetics
-- RLS: SELECT open to everyone
id TEXT PK -- e.g. 'frame_gold_dragon', 'board_wood_classic'
name TEXT NOT NULL
name_ar TEXT
description TEXT
type cosmetic_type NOT NULL
rarity cosmetic_rarity NOT NULL
preview_url TEXT
asset_url TEXT
price_coins INT
price_gems INT
unlock_condition JSONB -- e.g. {"type":"level","value":10} or {"type":"achievement","id":"win_100"}
is_purchasable BOOLEAN DEFAULT true
is_limited_edition BOOLEAN DEFAULT false
available_until TIMESTAMPTZ
created_at TIMESTAMPTZ DEFAULT now()
```
### 2.15 PLAYER_COSMETICS
```sql
-- Table: player_cosmetics
-- RLS: Players see only their own
-- UNIQUE: (player_id, cosmetic_id)
id UUID PK DEFAULT gen_random_uuid()
player_id UUID -> profiles(id) ON DELETE CASCADE
cosmetic_id TEXT -> cosmetics(id)
acquired_at TIMESTAMPTZ DEFAULT now()
acquired_via TEXT -- 'purchase', 'reward', 'gift', 'achievement', 'event'
is_equipped BOOLEAN DEFAULT false
```
### 2.16 ECONOMY_TRANSACTIONS
```sql
-- Table: economy_transactions
-- RLS: Players see only their own
id UUID PK DEFAULT uuid_generate_v4()
player_id UUID NOT NULL -> profiles(id) ON DELETE CASCADE
type TEXT NOT NULL CHECK (IN ('credit', 'debit'))
currency TEXT NOT NULL CHECK (IN ('coins', 'gems', 'premium_currency'))
amount INT NOT NULL
balance_after INT NOT NULL
reason TEXT NOT NULL -- 'match_win', 'tournament_prize', 'purchase', 'daily_reward', 'level_up', etc.
source_id TEXT -- Reference to match_id, tournament_id, etc.
created_at TIMESTAMPTZ DEFAULT now()
```
### 2.17 LEADERBOARDS
```sql
-- Table: leaderboards
-- RLS: SELECT open to everyone
-- UNIQUE: (player_id, game_key, time_control_type, period, period_key)
id UUID PK DEFAULT gen_random_uuid()
player_id UUID -> profiles(id) ON DELETE CASCADE
game_key TEXT NOT NULL DEFAULT 'chess'
time_control_type TEXT NOT NULL -- 'bullet', 'blitz', 'rapid', 'classical'
period TEXT NOT NULL -- 'daily', 'weekly', 'monthly', 'all_time'
period_key TEXT -- '2026-05-26', '2026-W22', '2026-05'
rank INT
rating INT NOT NULL
games_played INT DEFAULT 0
win_rate NUMERIC(5,2)
country_code CHAR(3)
updated_at TIMESTAMPTZ DEFAULT now()
```
### 2.18 ACHIEVEMENTS
```sql
-- Table: achievements
-- RLS: SELECT open to everyone
id TEXT PK -- e.g. 'first_win', 'win_streak_10', 'tournament_champion'
name TEXT NOT NULL
name_ar TEXT
description TEXT
description_ar TEXT
icon_url TEXT
category TEXT NOT NULL -- 'games', 'social', 'tournaments', 'progression', 'collection'
tier INT DEFAULT 1 -- 1=bronze, 2=silver, 3=gold
condition JSONB NOT NULL -- e.g. {"type":"wins","count":100,"game":"chess"}
xp_reward INT DEFAULT 0
coins_reward INT DEFAULT 0
cosmetic_reward TEXT -- cosmetic_id
is_hidden BOOLEAN DEFAULT false
created_at TIMESTAMPTZ DEFAULT now()
```
### 2.19 PLAYER_ACHIEVEMENTS
```sql
-- Table: player_achievements
-- RLS: Players see only their own
-- UNIQUE: (player_id, achievement_id)
id UUID PK DEFAULT gen_random_uuid()
player_id UUID -> profiles(id) ON DELETE CASCADE
achievement_id TEXT -> achievements(id)
progress INT DEFAULT 0
completed BOOLEAN DEFAULT false
completed_at TIMESTAMPTZ
created_at TIMESTAMPTZ DEFAULT now()
```
### 2.20 RATING_HISTORY
```sql
-- Table: rating_history
-- RLS: SELECT open to everyone
id UUID PK DEFAULT gen_random_uuid()
player_id UUID -> profiles(id) ON DELETE CASCADE
game_key TEXT NOT NULL DEFAULT 'chess'
time_control_type TEXT NOT NULL -- 'bullet', 'blitz', 'rapid', 'classical'
rating_before INT NOT NULL
rating_after INT NOT NULL
rating_change INT NOT NULL
match_id UUID -> matches(id)
opponent_id UUID -> profiles(id)
opponent_rating INT
result TEXT -- 'win', 'loss', 'draw'
k_factor INT
created_at TIMESTAMPTZ DEFAULT now()
```
### 2.21 ACTIVITY_FEED
```sql
-- Table: activity_feed
id UUID PK DEFAULT gen_random_uuid()
actor_id UUID -> profiles(id)
action TEXT NOT NULL -- 'won_match', 'joined_tournament', 'leveled_up', 'achievement_unlocked', etc.
target_type TEXT -- 'match', 'tournament', 'achievement', 'player'
target_id UUID
metadata JSONB DEFAULT '{}'
visibility TEXT DEFAULT 'friends' -- 'public', 'friends', 'private'
created_at TIMESTAMPTZ DEFAULT now()
```
### 2.22 XP_LEVELS
```sql
-- Table: xp_levels
-- RLS: SELECT open to everyone
level INT PK
xp_required INT NOT NULL
title TEXT
title_ar TEXT
reward_coins INT DEFAULT 0
reward_cosmetic TEXT -- cosmetic_id
```
### 2.23 PROFILE_FRAMES
```sql
-- Table: profile_frames
id UUID PK DEFAULT gen_random_uuid()
name TEXT NOT NULL
name_ar TEXT
description TEXT
description_ar TEXT
image_url TEXT NOT NULL
thumbnail_url TEXT
category TEXT NOT NULL DEFAULT 'general' CHECK (IN ('general','seasonal','achievement','org','event','premium'))
rarity TEXT DEFAULT 'common' CHECK (IN ('common','uncommon','rare','epic','legendary'))
price_coins INT DEFAULT 0
price_gems INT DEFAULT 0
is_purchasable BOOLEAN DEFAULT true
is_active BOOLEAN DEFAULT true
required_level INT DEFAULT 0
required_achievement_id UUID
org_id UUID -> el3ab_organizations(id) ON DELETE SET NULL
max_supply INT
current_supply INT DEFAULT 0
available_from TIMESTAMPTZ
available_until TIMESTAMPTZ
sort_order INT DEFAULT 0
metadata JSONB DEFAULT '{}'
created_at TIMESTAMPTZ DEFAULT now()
updated_at TIMESTAMPTZ DEFAULT now()
```
### 2.24 PLAYER_FRAMES
```sql
-- Table: player_frames
-- UNIQUE: (player_id, frame_id)
id UUID PK DEFAULT gen_random_uuid()
player_id UUID NOT NULL -> profiles(id) ON DELETE CASCADE
frame_id UUID NOT NULL -> profile_frames(id) ON DELETE CASCADE
acquired_at TIMESTAMPTZ DEFAULT now()
acquisition_type TEXT DEFAULT 'purchase' CHECK (IN ('purchase','reward','gift','achievement','org_membership','admin_grant'))
source_id TEXT
is_equipped BOOLEAN DEFAULT false
```
### 2.25 CLUBS
```sql
-- Table: clubs
-- RLS: SELECT open to everyone
id UUID PK DEFAULT gen_random_uuid()
name TEXT NOT NULL
name_ar TEXT
slug TEXT UNIQUE
description TEXT
logo_url TEXT
banner_url TEXT
owner_id UUID -> profiles(id)
country_code CHAR(3)
max_members INT DEFAULT 100
member_count INT DEFAULT 0
is_public BOOLEAN DEFAULT true
rating_avg INT DEFAULT 1200
created_at TIMESTAMPTZ DEFAULT now()
```
### 2.26 CLUB_MEMBERS
```sql
-- Table: club_members
id UUID PK DEFAULT gen_random_uuid()
club_id UUID -> clubs(id) ON DELETE CASCADE
player_id UUID -> profiles(id) ON DELETE CASCADE
role TEXT DEFAULT 'member' -- owner, admin, member
joined_at TIMESTAMPTZ DEFAULT now()
```
### 2.27 ORG_MEMBERSHIP_APPLICATIONS
```sql
-- Table: org_membership_applications
-- Players submit these to join organizations
-- Realtime: enabled
id UUID PK DEFAULT gen_random_uuid()
org_id UUID -> el3ab_organizations(id) ON DELETE CASCADE
player_id UUID -> profiles(id) ON DELETE CASCADE
invite_code_id UUID -> org_invite_codes(id)
document_url TEXT -- proof document (uploaded to membership-proofs bucket)
document_type TEXT
notes TEXT -- player's application message
status TEXT DEFAULT 'pending' -- pending, approved, rejected
reviewed_by UUID -> profiles(id)
reviewed_at TIMESTAMPTZ
rejection_reason TEXT
created_at TIMESTAMPTZ DEFAULT now()
updated_at TIMESTAMPTZ DEFAULT now()
```
### 2.28 ORG_INVITE_CODES
```sql
-- Table: org_invite_codes
-- Used by players to join organizations via invite link/code
id UUID PK DEFAULT gen_random_uuid()
org_id UUID -> el3ab_organizations(id) ON DELETE CASCADE
code TEXT NOT NULL UNIQUE -- the shareable code string
code_type TEXT DEFAULT 'single' -- 'single', 'multi'
max_uses INT
current_uses INT DEFAULT 0
created_by UUID -> profiles(id)
expires_at TIMESTAMPTZ
is_active BOOLEAN DEFAULT true
created_at TIMESTAMPTZ DEFAULT now()
```
### 2.29 TOURNAMENT_PLAYERS (Swiss API — internal)
```sql
-- NOTE: This table is used by the Swiss API system (different from tournament_registrations)
-- Used for detailed player data within Swiss-paired tournaments
id UUID PK DEFAULT gen_random_uuid()
tournament_id UUID NOT NULL -> tournaments(id) ON DELETE CASCADE
organization_id UUID NOT NULL -> organizations(id) ON DELETE CASCADE
category_id UUID -> categories(id)
user_id UUID -> auth.users(id)
start_number INT NOT NULL
name TEXT NOT NULL
birth_date DATE
country_code TEXT
city TEXT
club TEXT
title TEXT -- FIDE title: GM, IM, FM, etc.
fide_id TEXT
fide_rating_standard INT
fide_rating_rapid INT
fide_rating_blitz INT
national_id TEXT
national_rating INT
is_active BOOLEAN DEFAULT true
withdrawn_after_round INT
rounds_excluded INT[] DEFAULT '{}'
received_bye_in_rounds INT[] DEFAULT '{}'
total_points NUMERIC(4,1) DEFAULT 0
float_history JSONB DEFAULT '[]'
color_history JSONB DEFAULT '[]'
created_at TIMESTAMPTZ DEFAULT now()
updated_at TIMESTAMPTZ DEFAULT now()
```
### 2.30 STANDINGS (Swiss API)
```sql
-- Table: standings
-- UNIQUE: (tournament_id, round_id, player_id)
id UUID PK DEFAULT gen_random_uuid()
tournament_id UUID NOT NULL -> tournaments(id) ON DELETE CASCADE
round_id UUID NOT NULL -> rounds(id) ON DELETE CASCADE
player_id UUID NOT NULL -> tournament_players(id) ON DELETE CASCADE
organization_id UUID NOT NULL -> organizations(id) ON DELETE CASCADE
round_number INT NOT NULL
rank_overall INT NOT NULL
rank_category INT
category_id UUID -> categories(id)
points NUMERIC(4,1) NOT NULL DEFAULT 0
tiebreak_values JSONB NOT NULL DEFAULT '[]'
games_played INT DEFAULT 0
wins INT DEFAULT 0
draws INT DEFAULT 0
losses INT DEFAULT 0
byes INT DEFAULT 0
rating_change NUMERIC(5,2)
performance_rating INT
created_at TIMESTAMPTZ DEFAULT now()
updated_at TIMESTAMPTZ DEFAULT now()
```
### 2.31 PAIRINGS (Swiss API)
```sql
-- Table: pairings
-- UNIQUE: (round_id, board_number)
id UUID PK DEFAULT gen_random_uuid()
round_id UUID NOT NULL -> rounds(id) ON DELETE CASCADE
tournament_id UUID NOT NULL -> tournaments(id) ON DELETE CASCADE
organization_id UUID NOT NULL -> organizations(id) ON DELETE CASCADE
board_number INT NOT NULL
white_player_id UUID NOT NULL -> tournament_players(id)
black_player_id UUID -> tournament_players(id)
result game_result NOT NULL DEFAULT 'not_played'
white_points NUMERIC(3,2) DEFAULT 0
black_points NUMERIC(3,2) DEFAULT 0
is_forfeit BOOLEAN DEFAULT false
is_bye BOOLEAN DEFAULT false
result_entered_by UUID -> auth.users(id)
result_entered_at TIMESTAMPTZ
result_confirmed BOOLEAN DEFAULT false
created_at TIMESTAMPTZ DEFAULT now()
updated_at TIMESTAMPTZ DEFAULT now()
```
---
## 3. ENUM TYPES (All 27)
### Player/Platform Enums
```sql
-- platform_role (11 values)
superadmin, platform_admin, org_owner, org_admin, tournament_director,
arbiter, content_manager, match_moderator, sponsor_admin, player, spectator
-- user_role (Swiss API roles, 5 values)
super_admin, org_admin, arbiter, player, spectator
```
### Match Enums
```sql
-- match_status (7 values)
waiting, ready, in_progress, paused, completed, aborted, abandoned
-- match_result (15 values)
white_wins, black_wins, draw, white_timeout, black_timeout,
white_resign, black_resign, white_abandon, black_abandon,
stalemate, insufficient_material, threefold_repetition,
fifty_moves, mutual_draw, aborted
-- game_result (Swiss pairings, 10 values)
white_wins, black_wins, draw, white_forfeit, black_forfeit,
double_forfeit, bye_full, bye_half, bye_zero, not_played
-- color (2 values)
white, black
```
### Time Control Enums
```sql
-- time_control (14 values)
bullet_1_0, bullet_1_1, bullet_2_1,
blitz_3_0, blitz_3_2, blitz_5_0, blitz_5_3,
rapid_10_0, rapid_10_5, rapid_15_10, rapid_30_0,
classical_60_0, classical_90_30,
custom
-- time_control_type (4 values)
standard, rapid, blitz, bullet
```
### Tournament Enums
```sql
-- tournament_format (7 values)
swiss, round_robin, single_elimination, double_elimination,
swiss_to_bracket, arena, team_battle
-- tournament_status (5 values)
draft, registration, in_progress, completed, cancelled
-- tournament_type (Swiss API, 3 values)
swiss, round_robin, double_round_robin
-- round_status (4 values)
pending, paired, in_progress, completed
-- tiebreak_type (11 values)
buchholz, buchholz_cut_1, buchholz_median, sonneborn_berger,
direct_encounter, number_of_wins, number_of_blacks, koya,
progressive_score, average_rating_opponents, performance_rating
```
### Cosmetic Enums
```sql
-- cosmetic_type (9 values)
avatar_frame, board_theme, piece_set, profile_banner,
chat_emoji, victory_animation, title_badge, trail_effect, sound_pack
-- cosmetic_rarity (5 values)
common, uncommon, rare, epic, legendary
```
### Organization Enums
```sql
-- org_membership_status (3 values)
active, suspended, invited
-- audit_action (9 values)
create, update, delete, pair_round, unpair_round,
enter_result, modify_result, generate_standings, export_trf
```
---
## 4. GAME PLUGINS (5 Games)
| game_key | name | name_ar | min_players | max_players | supports_ranked | supports_tournament | supports_bot | supports_spectator | supports_arena |
|----------|------|---------|-------------|-------------|-----------------|--------------------|--------------|--------------------|----------------|
| chess | Chess | شطرنج | 2 | 2 | true | true | true | true | true |
| backgammon | Backgammon | طاولة | 2 | 2 | true | true | false | true | true |
| dominoes | Dominoes | دومينو | 2 | 4 | true | true | false | true | false |
| ludo | Ludo | لودو | 2 | 4 | true | false | false | true | true |
| trivia | Trivia Party | تريفيا بارتي | 2 | 8 | true | true | false | true | true |
All games use `elo` rating system. All are enabled and not beta.
---
## 5. XP LEVELS (15 Levels)
| Level | XP Required | Title (EN) | Title (AR) | Coin Reward |
|-------|-------------|------------|------------|-------------|
| 1 | 0 | Beginner | مبتدئ | 0 |
| 2 | 100 | Pawn | بيدق | 50 |
| 3 | 300 | Pawn II | بيدق ٢ | 50 |
| 4 | 600 | Knight | حصان | 100 |
| 5 | 1000 | Knight II | حصان ٢ | 100 |
| 6 | 1500 | Bishop | فيل | 150 |
| 7 | 2100 | Bishop II | فيل ٢ | 150 |
| 8 | 2800 | Rook | قلعة | 200 |
| 9 | 3600 | Rook II | قلعة ٢ | 200 |
| 10 | 4500 | Queen | ملكة | 300 |
| 11 | 5500 | Queen II | ملكة ٢ | 300 |
| 12 | 6600 | King | ملك | 500 |
| 13 | 7800 | King II | ملك ٢ | 500 |
| 14 | 9100 | Master | أستاذ | 750 |
| 15 | 10500 | Grandmaster | غراندماستر | 1000 |
---
## 6. SYSTEM CONFIG (15 Settings)
| Key | Value | Description | Category |
|-----|-------|-------------|----------|
| platform_name | "EL3AB" | Platform name | general |
| platform_name_ar | "إل٣اب" | Platform name in Arabic | general |
| elo_default_rating | 1200 | Default starting rating | rating |
| elo_k_factor_new | 40 | K-factor for < 30 games | rating |
| elo_k_factor_established | 20 | K-factor for established players | rating |
| elo_k_factor_master | 10 | K-factor for 2400+ players | rating |
| matchmaking_range_expansion | 10 | Rating range expansion per second | matchmaking |
| matchmaking_max_wait_sec | 60 | Maximum matchmaking wait time | matchmaking |
| coins_per_win_ranked | 25 | Coins earned per ranked win | economy |
| coins_per_win_casual | 10 | Coins earned per casual win | economy |
| xp_per_game | 50 | XP earned per game played | economy |
| xp_per_win_bonus | 30 | Bonus XP for winning | economy |
| tournament_max_players | 256 | Maximum players per tournament | tournaments |
| anticheat_cpl_threshold | 15 | Average centipawn loss threshold | anticheat |
| anticheat_correlation_threshold | 85 | Engine correlation % to auto-flag | anticheat |
---
## 7. STORAGE BUCKETS (9 Buckets)
| Bucket ID | Purpose | Public URL Pattern |
|-----------|---------|-------------------|
| avatars | Player avatar images | `/storage/v1/object/public/avatars/{user_id}/{filename}` |
| profile-images | Additional profile images | `/storage/v1/object/public/profile-images/{user_id}/{filename}` |
| profile-frames | Frame image assets | `/storage/v1/object/public/profile-frames/{filename}` |
| org-logos | Organization logos | `/storage/v1/object/public/org-logos/{org_id}/{filename}` |
| org-banners | Organization banners | `/storage/v1/object/public/org-banners/{org_id}/{filename}` |
| org-media | Organization media gallery | `/storage/v1/object/public/org-media/{org_id}/{filename}` |
| org-content-attachments | Org content post attachments | `/storage/v1/object/public/org-content-attachments/{org_id}/{filename}` |
| membership-proofs | Membership proof documents | `/storage/v1/object/public/membership-proofs/{filename}` |
| chat-attachments | Chat media (images in chat) | `/storage/v1/object/public/chat-attachments/{channel_id}/{filename}` |
---
## 8. RPC FUNCTIONS (Custom)
### 8.1 handle_new_user() — TRIGGER
Triggered on `auth.users` INSERT. Creates profile:
```sql
INSERT INTO profiles (id, username, display_name, preferred_language)
VALUES (
NEW.id,
COALESCE(NEW.raw_user_meta_data->>'username', 'user_' || substr(NEW.id::text, 1, 8)),
COALESCE(NEW.raw_user_meta_data->>'display_name', 'لاعب جديد'),
'ar'
);
```
### 8.2 check_app_version(client_version TEXT) → JSON
Returns whether client needs update:
```json
{
"min_app_version": "1.0.0",
"google_play_link": "...",
"app_store_link": "...",
"shouldupdate": true/false
}
```
### 8.3 register_tournament_player(p_tournament_id INT, p_player_auth_id TEXT) → JSON
Registers player in tournament. Checks:
- Tournament status must be `'registration_open'`
- `current_players < max_players`
Returns: `{"status":"success","message":"تم التسجيل بنجاح"}` or error.
### 8.4 cancel_tournament_registration(p_tournament_id INT) → JSON
Cancels player's registration. Decrements `current_players`.
### 8.5 get_all_tournaments() → JSON
Returns all tournaments with embedded players and brackets data. Uses the `tournaments` table (legacy).
### 8.6 get_tournament_by_id(p_id INT) → JSON
Returns single tournament with embedded players and brackets.
### 8.7 get_tournament_room(p_tournament_id INT, p_bracket_id INT) → JSON
Returns lobby_id for a bracket match. Checks if player is late based on `max_join_delay`.
### 8.8 check_opponent_timeout(p_bracket_id INT, p_user_numeric_id INT) → JSON
Checks if opponent has timed out for a bracket match.
Returns: `{"status":"opponent_timeout"}`, `{"status":"waiting"}`, or `{"status":"already_finished"}`.
### 8.9 get_friends_by_auth_ids(auth_ids TEXT[]) → JSON
Returns friend profiles for given auth IDs:
```json
{"status":"success", "friendsData":[{"FriendAuthId":"...","FriendName":"...","FriendClub":"...","FriendImage":"..."}]}
```
### 8.10 get_user_org_ids() → UUID[]
Returns array of organization IDs where current user is an active member.
### 8.11 user_has_role_in_org(p_org_id UUID, p_roles user_role[]) → BOOLEAN
Checks if current user has any of the specified roles in the given organization.
---
## 9. ROW LEVEL SECURITY (RLS) POLICIES
### Public Read (No Auth Required)
- `profiles` — SELECT open to everyone
- `matches` — SELECT open to everyone
- `el3ab_tournaments` — SELECT open to everyone
- `el3ab_organizations` — SELECT where `is_active = true`
- `achievements` — SELECT open to everyone
- `cosmetics` — SELECT open to everyone
- `leaderboards` — SELECT open to everyone
- `rating_history` — SELECT open to everyone
- `clubs` — SELECT open to everyone
- `xp_levels` — SELECT open to everyone
### Authenticated User's Own Data
- `notifications` — SELECT/UPDATE only `user_id = auth.uid()`
- `economy_transactions` — SELECT only `player_id = auth.uid()`
- `player_cosmetics` — SELECT only `player_id = auth.uid()`
- `player_achievements` — SELECT only `player_id = auth.uid()`
- `friendships` — SELECT only where user is requester or addressee
- `matchmaking_queue` — full CRUD only for `player_id = auth.uid()`
### Authenticated Mutations
- `profiles` — UPDATE only own (auth.uid() = id)
- `matches` — INSERT/UPDATE only if player is white_player or black_player
- `friendships` — INSERT only as requester
- `chat_messages` — INSERT only as sender
### Admin-Protected
- `el3ab_tournaments` — INSERT requires platform role (superadmin, platform_admin, tournament_director, org_owner, org_admin)
- `el3ab_organizations` — UPDATE requires org_admin role
### Service Role Only
- `player_achievements` — full access for service_role
- `bracket_matches` — full access for service_role
- `tournament_phases` — full access for service_role
- `profile_frames` — full access for service_role
- `player_frames` — full access for service_role
---
## 10. REALTIME SUBSCRIPTIONS
Tables with Realtime enabled (via `supabase_realtime` publication):
- `profiles`
- `matches`
- `matchmaking_queue`
- `friendships`
- `notifications`
- `chat_messages`
- `el3ab_tournaments`
- `el3ab_organizations`
- `tournament_registrations`
- `el3ab_tournament_rounds`
- `bracket_matches`
- `tournament_phases`
- `leaderboards`
- `rating_history`
- `player_cosmetics`
- `player_achievements`
- `economy_transactions`
- `activity_feed`
- `clubs`
- `cosmetics`
- `achievements`
- `xp_levels`
- `org_members`
- `profile_frames`
- `player_frames`
---
## 11. SUPABASE AUTH SIGNUP
When signing up a new user, pass metadata:
```javascript
const { data, error } = await supabase.auth.signUp({
email: 'user@example.com',
password: 'password123',
options: {
data: {
username: 'chosen_username',
display_name: 'اسم اللاعب'
}
}
});
```
The `handle_new_user()` trigger automatically creates the profile row.
---
## 12. KEY RELATIONSHIPS DIAGRAM
```
auth.users (Supabase Auth)
└── profiles (1:1, id = auth.users.id)
├── matches (white_player_id / black_player_id)
├── matchmaking_queue (player_id)
├── friendships (requester_id / addressee_id)
├── notifications (user_id)
├── chat_messages (sender_id)
├── player_cosmetics (player_id)
├── player_achievements (player_id)
├── economy_transactions (player_id)
├── leaderboards (player_id)
├── rating_history (player_id)
├── activity_feed (actor_id)
├── player_frames (player_id)
├── club_members (player_id)
├── org_members (player_id)
└── tournament_registrations (player_id)
el3ab_tournaments
├── tournament_registrations (tournament_id)
├── el3ab_tournament_rounds (tournament_id)
├── tournament_phases (tournament_id)
│ └── bracket_matches (phase_id)
├── tournament_ad_slots (tournament_id)
├── tournament_announcements (tournament_id)
├── tournament_media (tournament_id)
└── tournament_page_views (tournament_id)
el3ab_organizations
├── org_members (org_id)
├── org_events (org_id)
├── org_chat_channels (org_id)
├── org_chat_messages (org_id)
├── org_announcements (org_id)
├── org_content (org_id)
├── org_challenges (challenger/challenged)
├── org_partnerships (org_a/org_b)
├── org_rosters (org_id)
├── org_treasury (org_id)
├── org_loyalty_rewards (org_id)
└── el3ab_tournaments (org_id)
```
---
## 13. API PATTERNS
### Supabase REST API (PostgREST)
All tables accessible via:
```
GET/POST/PATCH/DELETE {SUPABASE_URL}/rest/v1/{table_name}
Headers:
apikey: {ANON_KEY or SERVICE_ROLE_KEY}
Authorization: Bearer {user_jwt_or_service_role_key}
Content-Type: application/json
Prefer: return=representation (for mutations)
```
### Common Query Patterns
```javascript
// Get player profile
const { data } = await supabase
.from('profiles')
.select('*')
.eq('id', userId)
.single();
// Get matches for a player
const { data } = await supabase
.from('matches')
.select('*')
.or(`white_player_id.eq.${userId},black_player_id.eq.${userId}`)
.order('created_at', { ascending: false });
// Get leaderboard
const { data } = await supabase
.from('leaderboards')
.select('*, profiles(username, display_name, avatar_url, country_code)')
.eq('game_key', 'chess')
.eq('time_control_type', 'blitz')
.eq('period', 'weekly')
.order('rank', { ascending: true })
.limit(100);
// Join matchmaking queue
const { data } = await supabase
.from('matchmaking_queue')
.insert({
player_id: userId,
game_key: 'chess',
time_control: 'blitz_5_0',
rating: playerRating,
is_rated: true
});
// Subscribe to match updates (Realtime)
supabase
.channel('match-updates')
.on('postgres_changes', {
event: 'UPDATE',
schema: 'public',
table: 'matches',
filter: `id=eq.${matchId}`
}, (payload) => {
// Handle match state update
})
.subscribe();
// Subscribe to notifications
supabase
.channel('my-notifications')
.on('postgres_changes', {
event: 'INSERT',
schema: 'public',
table: 'notifications',
filter: `user_id=eq.${userId}`
}, (payload) => {
// Show notification
})
.subscribe();
// Call RPC function
const { data } = await supabase.rpc('register_tournament_player', {
p_tournament_id: tournamentId,
p_player_auth_id: authId
});
```
---
## 14. TIME CONTROL MAPPINGS
| Enum Value | Initial (ms) | Increment (ms) | Category |
|------------|-------------|----------------|----------|
| bullet_1_0 | 60000 | 0 | bullet |
| bullet_1_1 | 60000 | 1000 | bullet |
| bullet_2_1 | 120000 | 1000 | bullet |
| blitz_3_0 | 180000 | 0 | blitz |
| blitz_3_2 | 180000 | 2000 | blitz |
| blitz_5_0 | 300000 | 0 | blitz |
| blitz_5_3 | 300000 | 3000 | blitz |
| rapid_10_0 | 600000 | 0 | rapid |
| rapid_10_5 | 600000 | 5000 | rapid |
| rapid_15_10 | 900000 | 10000 | rapid |
| rapid_30_0 | 1800000 | 0 | rapid |
| classical_60_0 | 3600000 | 0 | classical |
| classical_90_30 | 5400000 | 30000 | classical |
| custom | varies | varies | varies |
---
## 15. ELO RATING SYSTEM
### Calculation Rules
- **Default Rating**: 1200
- **K-Factor New** (< 30 games): 40
- **K-Factor Established** (30+ games): 20
- **K-Factor Master** (2400+ rating): 10
- **Rating per game type**: `elo_bullet`, `elo_blitz`, `elo_rapid`, `elo_classical`
### Standard ELO Formula
```
Expected = 1 / (1 + 10^((opponent_rating - player_rating) / 400))
New_Rating = Old_Rating + K * (Score - Expected)
Score: 1 (win), 0.5 (draw), 0 (loss)
```
---
## 16. ECONOMY SYSTEM
### Currencies
| Currency | Column in profiles | Earn Methods |
|----------|-------------------|--------------|
| Coins | `coins` | Match wins, daily rewards, level-up, tournaments |
| Gems | `gems` | Rare rewards, premium purchases |
| Premium Currency | `premium_currency` | Real money purchase |
### Earning Rates
| Action | Reward |
|--------|--------|
| Ranked win | 25 coins |
| Casual win | 10 coins |
| Game played (any) | 50 XP |
| Win bonus | +30 XP |
| Level up | varies (50-1000 coins) |
| Daily login | coins + streak bonus |
### Transaction Types
- `credit` / `debit`
- Currencies: `coins`, `gems`, `premium_currency`
- Reasons: `match_win`, `tournament_prize`, `purchase`, `daily_reward`, `level_up`, `refund`, `gift`, `admin_grant`
---
## 17. MATCHMAKING ALGORITHM
1. Player enters queue with: `game_key`, `time_control`, `rating`, `is_rated`
2. Rating range starts at `±50` (or custom `rating_range_min/max`)
3. Expands by `range_expansion_per_sec` (default: 10) every second
4. Max wait: `max_wait_seconds` (default: 60)
5. Respects `block_list` (UUID array of blocked players)
6. When matched: both entries get `status='matched'`, `matched_with` set, `match_id` created
---
## 18. TOURNAMENT FORMATS
| Format | Description |
|--------|-------------|
| `swiss` | Swiss pairing system, configurable rounds |
| `round_robin` | Everyone plays everyone |
| `single_elimination` | Bracket, lose once = out |
| `double_elimination` | Winners + losers bracket |
| `swiss_to_bracket` | Swiss group phase → elimination bracket |
| `arena` | Continuous play, points for speed |
| `team_battle` | Organization vs organization |
### Multi-Phase Tournaments
- `tournament_mode`: `'single'` or `'multi_phase'`
- `phase_config` JSONB array defines each phase
- `tournament_phases` table tracks individual phase state
- `advancement_rule` JSONB defines how players advance between phases
---
## 19. ORGANIZATION TIERS
| Tier | Max Members | Max Tournaments | Features |
|------|-------------|-----------------|----------|
| free | 50 | 3 | Basic |
| bronze | 100 | 5 | + Custom branding |
| silver | 250 | 10 | + Revenue share |
| gold | 500 | 25 | + Priority support |
| diamond | Unlimited | Unlimited | + API access, white-label |
---
## 20. ALL 99 TABLE NAMES
### Core Player (7)
`profiles`, `friendships`, `notifications`, `activity_feed`, `player_cosmetics`, `player_achievements`, `player_frames`
### Matches & Matchmaking (3)
`matches`, `matchmaking_queue`, `rating_history`
### Economy & Progression (4)
`economy_transactions`, `leaderboards`, `cosmetics`, `xp_levels`
### Cosmetics & Frames (3)
`cosmetic_type_registry`, `profile_frames`, `player_loyalty_claims`
### Chat (1)
`chat_messages`
### Achievements (2)
`achievements`, `player_achievements`
### Tournaments (El3ab System) (9)
`el3ab_tournaments`, `el3ab_tournament_rounds`, `tournament_registrations`, `tournament_phases`, `bracket_matches`, `tournament_ad_slots`, `tournament_announcements`, `tournament_media`, `tournament_page_views`
### Tournaments (Swiss API) (7)
`tournaments`, `tournament_players`, `rounds`, `pairings`, `standings`, `tournament_brackets`, `tournament_format_registry`
### Tournaments (Misc) (3)
`tournament_prize_payouts`, `tournament_sponsorships`, `categories`
### Organizations (30)
`el3ab_organizations`, `org_members`, `org_memberships`, `org_achievements`, `org_activity_log`, `org_announcements`, `org_asset_overrides`, `org_challenges`, `org_chat_channels`, `org_chat_messages`, `org_chat_moderation`, `org_content`, `org_events`, `org_event_participants`, `org_invite_codes`, `org_invite_links`, `org_invite_uses`, `org_leaderboards`, `org_loyalty_rewards`, `org_media`, `org_member_spotlights`, `org_membership_applications`, `org_partnerships`, `org_player_transfers`, `org_recruitment_posts`, `org_referrals`, `org_rosters`, `org_roster_players`, `org_seasonal_rankings`, `org_theme_overrides`, `org_training_sessions`, `org_treasury`, `org_treasury_transactions`
### Clubs (2)
`clubs`, `club_members`
### Games (2)
`game_plugins`, `game_theme_overrides`
### Platform Admin (10)
`admin_users`, `platform_roles`, `platform_assets`, `platform_branding`, `platform_theme`, `system_config`, `audit_log`, `audit_logs`, `approval_requests`, `workflow_rules`
### Sponsors & Charities (5)
`sponsors`, `charities`, `charity_donations`, `ad_campaigns`, `ad_creatives`, `ad_impressions`, `ad_slots`
### Anti-Cheat (1)
`cheat_reports`
### Features & Events (3)
`feature_flags`, `events`, `trivia_questions`
### Internal (2)
`_migrations`, `organizations` (Swiss API orgs)
---
## 21. IMPORTANT NOTES FOR PLAYER APP DEVELOPMENT
### Architecture Decisions
1. **Supabase Auth** handles all authentication — email/password and OAuth
2. **PostgREST** (Supabase REST API) is the primary data layer — use `@supabase/supabase-js`
3. **Realtime** for live updates (matches, chat, notifications, matchmaking)
4. **RLS policies** enforce security — the app uses the ANON key + user JWT
5. **Service Role key** should NEVER be in client code — only for admin/backend operations
6. **Arabic-first**: All user-facing strings should have `_ar` variants, UI is RTL
### Player App Scope (what the app needs to do)
- Sign up / login (email + social OAuth)
- View/edit profile
- Play matches (chess, backgammon, dominoes, ludo, trivia)
- Matchmaking queue with rating-based pairing
- Friend system (request, accept, block)
- Chat (match chat, direct messages)
- View/join tournaments
- Leaderboards per game/time-control/period
- Cosmetics shop (buy with coins/gems)
- Achievements tracking
- Activity feed
- Notifications
- Organization membership
- Daily rewards / streaks
- Rating history graphs
### What NOT to build in the player app
- Tournament pairing/management (admin panel handles this)
- Organization creation/management (admin panel)
- Content moderation (admin panel)
- Advertising management (admin panel)
- System config changes (admin panel)
### Two Tournament Systems
There are TWO tournament systems in the database:
1. **El3ab Tournaments** (`el3ab_tournaments`, `el3ab_tournament_rounds`, `tournament_registrations`) — the main system used by the player app for registration, viewing, and live pages
2. **Swiss API Tournaments** (`tournaments`, `tournament_players`, `rounds`, `pairings`, `standings`) — the backend pairing engine used by the admin panel
The player app should primarily interact with the `el3ab_tournaments` system for browsing/registering. For detailed standings/pairings during a live tournament, data flows: Swiss API → admin enters results → el3ab_tournament_rounds.pairings/results JSONB.
### Organization Join Flow (Player Side)
1. Player discovers org (browse, invite link, invite code)
2. If invite code: validate via `org_invite_codes` (check `is_active`, `expires_at`, `current_uses < max_uses`)
3. Player submits `org_membership_applications` with optional document upload to `membership-proofs` bucket
4. Admin approves → player gets added to `org_members` + receives notification
5. Player can also join directly if org has an open invite link
### Realtime Channels to Subscribe
- `matches` — live game state updates during play
- `matchmaking_queue` — matched_with notification
- `notifications` — push notification triggers
- `chat_messages` — live chat
- `el3ab_tournaments` — status changes (registration → in_progress → completed)
- `el3ab_tournament_rounds` — round state changes
- `friendships` — friend request received
- `org_membership_applications` — application status changes (pending → approved/rejected)
---
## 22. LIVE TOURNAMENT PUBLIC API
These endpoints are publicly accessible (no auth required):
| Endpoint | Method | Returns |
|----------|--------|---------|
| `/api/live/{id}/data` | GET | Full tournament data (standings, pairings, bracket, players, config) |
| `/api/live/{id}/standings` | GET | Current standings array |
| `/api/live/{id}/pairings` | GET | Current round pairings |
| `/api/live/{id}/bracket` | GET | Bracket tree data |
| `/api/live/{id}/arena` | GET | Arena standings (if arena format) |
| `/api/live/{id}/announcements` | GET | Tournament announcements |
| `/api/live/{id}/results` | GET | Latest results |
| `/api/live/{id}/track` | POST | Analytics beacon (tracks page view) |
Base URL: `https://el3ab-management.caprover.al-arcade.com`
These routes are served by the admin panel PHP app (not Supabase directly). The player app can hit them for live tournament data without authentication.
---
## 23. PLAYER APP RECOMMENDED TECH STACK
The existing admin panel is pure PHP. The player app should be:
- **Mobile**: Flutter or React Native (cross-platform)
- **Web**: React/Next.js or Flutter Web
- **State**: Supabase Realtime subscriptions
- **Auth**: `@supabase/supabase-js` or `supabase_flutter`
- **Game Logic**: Depends on game (chess.js for chess, custom for others)
- **Real-time multiplayer**: Supabase Realtime channels + Presence
---
## 24. GAME-SPECIFIC DATA STRUCTURES
### Chess (game_key: 'chess')
```json
// matches.moves JSONB example:
[
{"from":"e2","to":"e4","san":"e4","ts":1234567890},
{"from":"e7","to":"e5","san":"e5","ts":1234567891}
]
// matches.game_state JSONB:
{
"fen": "current position FEN",
"lastMove": {"from":"e2","to":"e4"},
"inCheck": false,
"drawOffer": null
}
```
### Match moves JSONB format (for all games)
```json
// Generic structure game_state holds game-specific state
// moves[] holds ordered move history
```
---
## 25. NOTIFICATION TYPES
| Type | When | Data |
|------|------|------|
| `friend_request` | Someone sends friend request | `{sender_id, sender_name}` |
| `friend_accepted` | Friend request accepted | `{friend_id, friend_name}` |
| `match_invite` | Invited to play | `{match_id, game_key, sender_id}` |
| `match_result` | Game completed | `{match_id, result, rating_change}` |
| `tournament_start` | Tournament starting | `{tournament_id, tournament_name}` |
| `tournament_round` | New round paired | `{tournament_id, round_number}` |
| `achievement_unlocked` | Achievement completed | `{achievement_id, achievement_name}` |
| `level_up` | Player leveled up | `{new_level, title}` |
| `daily_reward` | Daily reward available | `{coins, streak}` |
| `org_invite` | Invited to organization | `{org_id, org_name, inviter_id}` |
| `system` | System announcement | `{message}` |
---
## END OF DOCUMENT
This document represents the complete, verified state of the El3ab production database as of May 2026. Every table schema, enum value, RPC function, and configuration setting was queried directly from the live server. Use this as the single source of truth — do not invent tables, columns, or values that are not listed here.
# EL3AB Player App — Combined Build Plan # EL3AB Player App - Build Plan (PHP)
## Objective ## Objective
Build an Arabic-first competitive gaming web app. Chess is the first fully playable game. All config baked into the build (no CapRover env vars). Port 80, nginx serves the SPA. Build an Arabic-first competitive gaming web app. Chess is the first fully playable game. PHP backend, vanilla HTML/CSS/JS frontend. Icons only (no emojis). chess.js for client-side move validation. Stockfish bot API for AI opponents.
--- ---
## Tech Stack ## Tech Stack
| Layer | Tool | Version | | Layer | Tool |
|-------|------|---------| |-------|------|
| Framework | React + TypeScript | React 19, TS 5.x | | Backend | PHP 8.x (server-rendered pages + JSON API endpoints) |
| Build | Vite | 6.x | | Frontend | HTML5, CSS3 (custom properties + flexbox/grid), vanilla JS |
| Styling | Tailwind CSS | v4 | | Chess Logic | chess.js (browser, move validation + game state) |
| Animations | Framer Motion | 12.x | | Chess Board | Custom HTML/CSS/JS board (drag & drop) |
| State | Zustand | 5.x | | Icons | SVG icon system (inline SVGs, no emojis anywhere) |
| Router | React Router | v7 | | Database | PostgreSQL 15 via Supabase (self-hosted) |
| Backend | @supabase/supabase-js | 2.x | | Auth | Supabase Auth (GoTrue) via REST API |
| Chess Logic | chess.js | 1.x | | Realtime | Supabase Realtime (WebSocket for live games) |
| Chess Board | Custom (drag/drop with Framer Motion) |
| Sound | Howler.js | 2.x |
| Bot API | Stockfish API at stockfishapi.caprover.al-arcade.com | | Bot API | Stockfish API at stockfishapi.caprover.al-arcade.com |
| Deploy | Docker (vite build -> nginx:alpine) | | Deploy | Docker (php:8.3-apache) on CapRover, auto-deploy on git push |
--- ---
## Hardcoded Environment ## Server & Connections
All config lives in `src/env.ts`, committed to the repo: | Resource | URL/Host |
|----------|----------|
```typescript | Server IP | 3.68.63.185 |
export const ENV = { | SSH Key | `Connections and docs /NewServer.pem` (user: ubuntu) |
SUPABASE_URL: 'https://safe-supabase-kong.caprover.al-arcade.com', | Supabase Kong | https://safe-supabase-kong.caprover.al-arcade.com |
SUPABASE_ANON_KEY: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzM1Njg5NjAwLCJleHAiOjE4OTM0NTYwMDB9.bFnS-YBhykTQ6vqrfTKJqmAB_aSW6GUgCat3QLkgCv8', | Supabase Anon Key | `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzM1Njg5NjAwLCJleHAiOjE4OTM0NTYwMDB9.31PF6PvP-pSrvRuQwLFptQoejR0W1A7o53lZhEbnz84` |
APP_URL: 'https://el3ab-player.caprover.al-arcade.com', | Supabase Service Key | `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoic2VydmljZV9yb2xlIiwiaXNzIjoic3VwYWJhc2UiLCJpYXQiOjE3MzU2ODk2MDAsImV4cCI6MTg5MzQ1NjAwMH0.wNfmuJNkX-bZwD7RbjxOChlRf_3Xm4I7bswEYTcDCg4` |
} as const; | App URL | https://el3ab-player.caprover.al-arcade.com |
``` | Stockfish API | https://stockfishapi.caprover.al-arcade.com |
| Stockfish Admin | admin / Alarcade123# |
--- | Stockfish Mgmt Key | sk-alarc-stockfish-mgmt-2024 |
## Database Overview
### All Tables (99 total)
#### Core Player Tables
- `profiles` (48 columns) — player profile, ratings, stats, economy, ban status
- `rating_history` — rating over time per time control
- `xp_levels` — XP thresholds per level
- `player_achievements` — achievements unlocked by player + progress
- `player_cosmetics` — cosmetics owned/equipped by player
- `player_frames` — frames owned by player (acquired_at, acquisition_type, is_equipped)
- `profile_frames` — frame definitions (name, rarity, price, availability, supply limits)
- `player_loyalty_claims` — loyalty rewards claimed by player
- `economy_transactions` — all coin/gem transactions
- `platform_roles` — user roles (granted_by, expires_at, scope_org_id)
#### Game Tables
- `game_plugins` (22 columns) — game registry (chess, etc.), time controls, capabilities
- `game_theme_overrides` — per-game color/board theming
- `matches` (37 columns) — all games played (players, result, moves, ratings, analysis)
- `matchmaking_queue` (17 columns) — matchmaking state, rating range, status
- `trivia_questions` — trivia game question bank (bilingual, categorized)
#### Social Tables
- `friendships` — friend relationships + status
- `chat_messages` — all chat (DM, match, org channels)
- `activity_feed` — player activity (visibility-filtered)
- `notifications` — bilingual notifications with routing data
#### Tournament Tables (EL3AB system)
- `el3ab_tournaments` (58 columns) — tournament definitions
- `el3ab_tournament_rounds` — round status, pairings, results
- `tournament_players` — players registered in tournament
- `tournament_registrations` — registration with fee tracking, seed, final standing
- `tournament_phases` — multi-phase tournament config
- `tournament_brackets` — bracket structure
- `bracket_matches` (29 columns) — individual bracket matches
- `standings` — current standings
- `pairings` — round pairings
- `tournament_announcements` — tournament news
- `tournament_media` — tournament gallery
- `tournament_page_views` — view tracking
- `tournament_prize_payouts` — prize distribution records
- `tournament_sponsorships` — sponsor links
- `tournament_ad_slots` — ad placement in tournaments
- `tournament_format_registry` — format definitions (name, supported games, config schema)
- `categories` — tournament categories (rating/age/gender filters)
#### Tournament Tables (Legacy/FIDE system)
- `tournaments` — FIDE-style tournament management
- `rounds` — round management with pairing tracking
- `events` — FIDE events (venue, arbiters, FIDE event ID)
- `organizations` — FIDE federations/clubs (subscription tier, limits)
#### Organization Tables (EL3AB Clubs)
- `el3ab_organizations` — org definitions
- `org_members` — membership with roles
- `org_memberships` — extended membership data (status, invited_by)
- `org_chat_channels` — channel definitions
- `org_chat_messages` — channel messages
- `org_chat_moderation` — moderation actions
- `org_events` — org-hosted events
- `org_event_participants` — RSVP tracking
- `org_challenges` — org vs org challenges
- `org_rosters` — team rosters
- `org_roster_players` — roster members
- `org_treasury` — org balance
- `org_treasury_transactions` — org financial log
- `org_seasonal_rankings` — org standings by season
- `org_achievements` — org-level achievements
- `org_announcements` — org news
- `org_media` — org media gallery
- `org_invite_codes` — invite codes
- `org_invite_links` — invite links
- `org_invite_uses` — invite tracking
- `org_membership_applications` — join applications
- `org_recruitment_posts` — recruitment listings
- `org_loyalty_rewards` — org loyalty program
- `org_training_sessions` — training schedule
- `org_content` — org articles/posts (bilingual, categories, visibility)
- `org_leaderboards` — org internal player rankings
- `org_member_spotlights` — member highlights (player of month etc.)
- `org_partnerships` — org-to-org partnerships
- `org_player_transfers` — player transfers between orgs (fees, consent)
- `org_referrals` — referral tracking with rewards
- `org_activity_log` — org admin audit trail
- `org_theme_overrides` — org custom theme tokens
- `org_asset_overrides` — org custom asset URLs
#### Clubs (Lightweight system)
- `clubs` — lightweight clubs (name, slug, logo, country, member_count)
- `club_members` — club membership (role)
#### Cosmetics & Economy
- `cosmetics` — cosmetic item definitions
- `cosmetic_type_registry` — cosmetic categories
- `achievements` — achievement definitions (conditions, rewards, tiers)
#### Anti-Cheat
- `cheat_reports` — player reports
#### Sponsorship & Charity
- `sponsors` — sponsor entities
- `charities` — charity definitions
- `charity_donations` — donation records
#### Advertising
- `ad_slots` — placement definitions (page context, dimensions, refresh)
- `ad_campaigns` — campaign targeting and budget
- `ad_creatives` — ad content (images, video, CTA)
- `ad_impressions` — impression/click tracking
#### Platform Config
- `platform_theme` — global theme tokens
- `platform_branding` — platform branding assets
- `platform_assets` — asset registry
- `system_config` — system-wide settings
- `feature_flags` — feature toggles with rollout %
#### Admin-Only (not player-facing)
- `admin_users` — admin panel users
- `approval_requests` — admin approval workflow
- `audit_log` / `audit_logs` — audit trails
- `workflow_rules` — automated workflow rules
- `_migrations` — schema migrations
### RPC Functions (Player-Facing)
- `register_tournament_player` — register for tournament
- `cancel_tournament_registration` — cancel registration
- `check_opponent_timeout` — check if opponent flagged
- `check_app_version` — version check
- `get_tournament_room` — live tournament room data
- `get_all_tournaments` — tournament listing
- `get_tournament_by_id` — single tournament detail
- `get_friends_by_auth_ids` — bulk friend lookup
- `get_user_org_ids` — user's org memberships
- `user_has_role_in_org` — role check
- `handle_new_user` — trigger on auth.users insert (creates profile)
---
## App Structure
```
el3ab-player/
├── public/
│ ├── assets/
│ │ ├── logo/
│ │ ├── chess/pieces/
│ │ ├── games/
│ │ ├── frames/
│ │ ├── economy/
│ │ ├── illustrations/
│ │ ├── matchmaking/
│ │ ├── icons/
│ │ └── avatars/
│ ├── sounds/
│ └── favicon.svg
├── src/
│ ├── main.tsx
│ ├── App.tsx
│ ├── index.css
│ ├── env.ts
│ ├── lib/
│ │ ├── supabase.ts
│ │ ├── sounds.ts
│ │ ├── constants.ts
│ │ ├── matchmaking.ts
│ │ ├── realtime.ts
│ │ └── stockfish.ts
│ ├── stores/
│ │ ├── authStore.ts
│ │ ├── matchStore.ts
│ │ ├── notificationStore.ts
│ │ └── uiStore.ts
│ ├── hooks/
│ │ ├── useAuth.ts
│ │ ├── usePresence.ts
│ │ ├── useMatchmaking.ts
│ │ ├── useMultiplayerGame.ts
│ │ ├── useNotifications.ts
│ │ ├── useFriends.ts
│ │ ├── useLeaderboard.ts
│ │ ├── useShop.ts
│ │ ├── useTournaments.ts
│ │ ├── useRecentGames.ts
│ │ └── useDailyReward.ts
│ ├── components/
│ │ ├── ui/
│ │ ├── layout/
│ │ ├── chess/
│ │ ├── match/
│ │ ├── profile/
│ │ ├── icons/
│ │ └── effects/
│ ├── pages/
│ │ ├── SplashPage.tsx
│ │ ├── LoginPage.tsx
│ │ ├── RegisterPage.tsx
│ │ ├── HomePage.tsx
│ │ ├── PlayPage.tsx
│ │ ├── BotSelectPage.tsx
│ │ ├── MatchmakingPage.tsx
│ │ ├── GamePage.tsx
│ │ ├── ProfilePage.tsx
│ │ ├── LeaderboardPage.tsx
│ │ ├── TournamentsPage.tsx
│ │ ├── TournamentDetailPage.tsx
│ │ ├── OrganizationsPage.tsx
│ │ ├── OrgDetailPage.tsx
│ │ ├── ClubsPage.tsx
│ │ ├── FriendsPage.tsx
│ │ ├── NotificationsPage.tsx
│ │ ├── ShopPage.tsx
│ │ ├── AchievementsPage.tsx
│ │ ├── SettingsPage.tsx
│ │ └── NotFoundPage.tsx
│ └── types/
│ └── index.ts
├── Dockerfile
├── nginx.conf
├── captain-definition
├── vite.config.ts
├── tsconfig.json
└── package.json
```
--- ---
## Deployment ## Deployment
### Dockerfile Docker image: `php:8.3-apache`
```dockerfile - Apache serves PHP directly (no nginx needed)
FROM node:20-alpine AS build - `captain-definition` points to Dockerfile
WORKDIR /app - CapRover auto-deploys on `git push`
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine ```dockerfile
COPY --from=build /app/dist /usr/share/nginx/html FROM php:8.3-apache
COPY nginx.conf /etc/nginx/conf.d/default.conf RUN docker-php-ext-install pdo pdo_pgsql pgsql
RUN a2enmod rewrite headers
COPY . /var/www/html/
COPY .htaccess /var/www/html/
RUN chown -R www-data:www-data /var/www/html
EXPOSE 80 EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
``` ```
### captain-definition
```json
{
"schemaVersion": 2,
"dockerfilePath": "./Dockerfile"
}
```
### nginx.conf
```nginx
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /assets {
expires 1y;
add_header Cache-Control "public, immutable";
}
location /sounds {
expires 1y;
add_header Cache-Control "public, immutable";
}
}
```
Port 80. No env vars. Push = deploy = done.
--- ---
## Multiplayer Architecture (Chess) ## Project Structure
``` ```
Player A makes move /
-> chess.js validates locally captain-definition
-> UPDATE matches SET moves, current_fen, time_remaining, move_count Dockerfile
-> Supabase Realtime broadcasts row change .htaccess
-> Player B receives, renders new position, starts their clock config/
database.php -- Supabase REST + direct PG connection config
constants.php -- App constants, URLs, keys
public/
css/
app.css -- All styles (design tokens, components, layout)
chessboard.css -- Board-specific styles
js/
app.js -- Global utilities, nav, toast system
chess.min.js -- chess.js library
board.js -- Chessboard rendering, drag/drop, animations
game.js -- Game logic (vs bot, timers, move submission)
auth.js -- Login/register/session management
realtime.js -- Supabase Realtime WebSocket client
icons/
sprite.svg -- SVG sprite sheet (all icons)
img/
bots/ -- Bot portrait images
pages/
home.php -- Dashboard (welcome, quick play, recent games)
login.php -- Login form
register.php -- Registration form
play.php -- Game selection + match settings
game.php -- Active chess game (board, clocks, controls)
bots.php -- Bot selection grid
profile.php -- Player profile + stats
leaderboard.php -- Rating leaderboard
friends.php -- Friends list + requests
tournaments.php -- Tournament listing
tournament.php -- Single tournament detail
shop.php -- Cosmetics store
achievements.php -- Achievement grid
notifications.php -- Notification center
settings.php -- User settings
orgs.php -- Organizations listing
org.php -- Single org detail
api/
auth.php -- POST login/register/logout endpoints
game.php -- POST start-game, submit-move, resign, etc.
profile.php -- GET/PATCH profile data
friends.php -- POST add/accept/remove friend
matchmaking.php -- POST queue/dequeue
leaderboard.php -- GET rankings
notifications.php -- GET/PATCH notifications
shop.php -- GET items, POST purchase
tournaments.php -- GET list, POST register
includes/
header.php -- HTML head + top nav
footer.php -- Bottom nav + closing tags
auth_check.php -- Session guard (redirect to login if not authed)
supabase.php -- Supabase REST API helper class
templates/
nav-desktop.php -- Side navigation (desktop)
nav-bottom.php -- Bottom tab bar (mobile)
toast.php -- Toast notification markup
``` ```
### Clock Management
- Local countdown (requestAnimationFrame for smooth ms display)
- On move: send remaining time to server
- Low time (<30s): urgency state
- Low time (<10s): countdown tick sound each second
- Flag (0:00): call `check_opponent_timeout` RPC, match ends
### Game End
- chess.js detects: checkmate, stalemate, insufficient material, threefold, fifty-move
- Player actions: resign, offer draw (opponent accepts/declines), abandon
- Result written to `matches` table -> both clients see via realtime
- Rating changes calculated and written to `rating_history`
### Matchmaking
- INSERT into `matchmaking_queue` (with rating, time_control, is_rated, block_list)
- Subscribe to own row via Realtime
- `range_expansion_per_sec` widens search over time
- On status='matched': navigate to /game/{match_id}
---
## Rebuild Rules
1. Every phase ends with a deployable build (`npm run build` must pass)
2. All data from DB — no hardcoded constants for things that exist in tables
3. Arabic-first, RTL-native
4. Mobile-first, desktop expands
5. Query `feature_flags` before enabling any gated feature
6. Use RPCs where they exist instead of raw queries
---
## PHASE 0 — Foundation & Token Reset
**Goal:** Establish correct design token system. Clean slate for all components.
### Tasks:
1. Set up token system in CSS (colors, spacing, typography, radii, shadows)
2. Load fonts: Inter (English) + IBM Plex Sans Arabic (Arabic primary) + Cairo (fallback)
3. Set up responsive container system (mobile/tablet/desktop breakpoints)
4. Remove any spec-violating styles from existing code
5. Query `platform_theme` and `platform_branding` tables for dynamic theme values
6. Query `platform_assets` for logo/favicon URLs
7. All existing components updated to use new tokens
### Tables Used:
- `platform_theme`
- `platform_branding`
- `platform_assets`
- `system_config`
### Deliverable:
- Clean token system (static + DB-driven)
- `npm run build` passes
---
## PHASE 1 — Core Layout & Navigation
**Goal:** Rebuild AppShell, Header, BottomNav, page transitions. Responsive shell.
### Tasks:
1. Rebuild `BottomNav.tsx`: 5 items (Home, Play, Tournaments, Social, Profile)
2. Rebuild `Header.tsx`: Logo + Level + Currency (coins/gems from `profiles`) + Notifications bell (count from `notifications`)
3. Rebuild `AppShell.tsx`: Desktop sidebar + content, mobile bottom nav
4. Create layout primitives: Section, Container, Grid (12/8/4 column responsive)
5. Page transitions via AnimatePresence
6. Check `platform_roles` to conditionally show admin links
### Tables Used:
- `profiles` (coins, gems, level)
- `notifications` (unread count)
- `platform_roles` (role checks)
### Deliverable:
- Responsive shell on mobile/tablet/desktop
- Navigation functional
- `npm run build` passes
---
## PHASE 2 — Authentication & Profile System
**Goal:** Complete auth flow + full profile utilizing ALL 48 profile columns.
### Tasks:
1. Rebuild Login + Register pages:
- Username + display_name + preferred_language on register
- `handle_new_user` trigger auto-creates profile row
- Error handling
2. Rebuild Profile page showing ALL data:
- Avatar + Frame (query `player_frames` + `profile_frames` for equipped frame)
- `avatar_frame_id`, `avatar_border_color`, `active_org_frame_id`
- Display name (AR + EN), Country, City, Bio (bilingual)
- All 4 ELO ratings (bullet/blitz/rapid/classical)
- FIDE data (fide_id, fide_rating_standard, fide_rating_rapid, fide_rating_blitz, fide_title)
- XP bar with level progress (query `xp_levels` table for thresholds)
- Stats: total_games_played, total_wins, total_draws, total_losses, win_streak, best_win_streak
- Tournament stats: total_tournaments_played, total_tournaments_won
- Coins + Gems + premium_currency display
- Online status (is_online, last_seen_at)
- Ban status (is_banned, ban_reason, ban_expires_at)
- Daily reward info (last_daily_reward, daily_streak)
- Edit profile modal
3. Create `useProfile` hook:
- Query full `profiles` row
- Query `xp_levels` for level requirements
- Query `player_achievements` for badge display
- Query `player_cosmetics` for equipped items
- Query `player_frames` joined with `profile_frames` for frame display
- Query `rating_history` for rating graph data
- Call `get_user_org_ids` for org affiliations
4. Rating history graph component (rating over time per time control)
5. Player view page (view other players' profiles)
### Tables Used:
- `profiles` (all 48 columns)
- `xp_levels`
- `rating_history`
- `player_achievements`
- `player_cosmetics`
- `player_frames`
- `profile_frames`
- `platform_roles`
### RPCs Used:
- `get_user_org_ids`
### Deliverable:
- Auth working (trigger creates profile)
- Profile shows ALL available data
- Rating graph displays history
- XP/level progression visible
- Frame system working
- `npm run build` passes
---
## PHASE 3 — Home Screen & Activity Feed
**Goal:** Build engagement hub (daily return, social visibility, recommendations).
### Tasks:
1. Rebuild Home page with sections:
- Welcome Header (display_name, level, greeting)
- Daily Missions / Daily Reward (last_daily_reward, daily_streak from profiles)
- Continue Playing (last match from `matches` where status != completed)
- Live Tournaments (from `el3ab_tournaments` where status = active)
- Friends Online (from `friendships` + `profiles.is_online`)
- Recommended Matches (based on rating)
- Leaderboard preview (from `leaderboards`)
- Organizations (user's orgs via `get_user_org_ids`)
- Store Promotions (featured cosmetics from `cosmetics`)
2. Activity Feed component:
- Query `activity_feed` table
- Visibility filtering (public/friends/org)
- Navigate to target entity on tap
3. Daily Reward system:
- Check `profiles.last_daily_reward` and `daily_streak`
- XP + coin rewards (query `xp_levels` for reward amounts)
- Economy transaction on claim (insert to `economy_transactions`)
- Update `profiles.daily_streak`, `profiles.last_daily_reward`
4. Create `useDashboard` hook:
- Friends online count (friendships + profiles.is_online)
- Active tournaments count (el3ab_tournaments)
- Unread notifications count (notifications where read = false)
- Current game check (profiles.current_game)
### Tables Used:
- `profiles`
- `matches`
- `el3ab_tournaments`
- `friendships`
- `leaderboards`
- `activity_feed`
- `cosmetics`
- `economy_transactions`
- `xp_levels`
- `notifications`
### RPCs Used:
- `get_user_org_ids`
- `get_friends_by_auth_ids`
### Deliverable:
- Home screen shows real data from all relevant tables
- Activity feed functional
- Daily reward system working
- `npm run build` passes
---
## PHASE 4 — Play & Matchmaking System
**Goal:** Zero-friction play flow. Dynamic game selection from DB.
### Tasks:
1. Rebuild Play page:
- Query `game_plugins` table (NOT hardcoded)
- Show enabled games dynamically (`is_enabled`, `is_beta` flags)
- Per-game: time controls from `default_time_controls` jsonb
- Game > Time Control > Rated/Casual flow
- Check `feature_flags` for `matchmaking_enabled`, `ranked_games_enabled`, `bot_games_enabled`
- Load `game_theme_overrides` for per-game accent colors
2. Rebuild Matchmaking page:
- INSERT to `matchmaking_queue` with full data:
- player_id, game_key, time_control, rating, is_rated
- rating_range_min/max, range_expansion_per_sec, max_wait_seconds
- block_list, region_preference
- Subscribe to own queue row via Realtime
- Show rating range expansion over time
- Opponent found: query opponent `profiles`
- Navigate to /game/{match_id}
3. Rebuild Bot Select page:
- Query bots from Stockfish API (`GET /api/chess/bots`)
- Show portraits from `portrait_url`
- Arabic names + styles from API
- ELO range display
- Check `game_plugins.supports_bot`
4. Create `useFeatureFlags` hook:
- Query `feature_flags` table
- Respect `is_enabled`, `rollout_percentage`, `conditions` jsonb
- Cache with short TTL
5. Create `useGamePlugins` hook:
- Query `game_plugins` table
- Replace hardcoded game constants
- Respect `is_enabled`, `is_beta`, `supports_ranked`, `supports_tournament`, `supports_bot`, `supports_spectator`
### Tables Used:
- `game_plugins`
- `game_theme_overrides`
- `feature_flags`
- `matchmaking_queue`
- `profiles` (rating for matchmaking)
### Deliverable:
- Play page driven entirely by database
- Feature flags respected with rollout %
- Matchmaking functional with full queue data
- Bot selection from live API
- `npm run build` passes
---
## PHASE 5 — In-Game Experience
**Goal:** Fully playable chess (bot + multiplayer). Results saved. Ratings tracked.
### Tasks:
1. Game page (Bot mode):
- Chess board with drag/drop + legal move validation (chess.js)
- Timer management (initial_time_ms, increment_ms)
- Material advantage display
- Move history (scrollable)
- Resign/Draw/Rematch actions
- Sound integration (Howler.js)
- Save completed game to `matches` with full PGN, all fields populated:
- game_key, white/black_player_id, match_type='bot'
- status, result, time_control, moves (jsonb), move_count
- current_fen, bot_id, bot_difficulty
- white/black_rating_before/after, rating_change
- Insert to `rating_history` on completion
- Update `profiles` stats (total_games, wins, losses, etc.)
2. Game page (Multiplayer mode):
- Same board + timer
- Realtime sync via Supabase channel (subscribe to `matches` row)
- Opponent profile display
- Clock: initial_time_ms, increment_ms, white/black_time_remaining_ms
- Call `check_opponent_timeout` RPC for flag detection
- On game end: update `matches`, insert `rating_history`
- If tournament match: update `pairings` result
3. In-game chat:
- Insert to `chat_messages` (channel_type='match', channel_id=match.id)
- Realtime subscription for messages
- Check `feature_flags` for `chat_enabled`
4. Spectator mode:
- Check `game_plugins.supports_spectator` and feature flag `spectator_enabled`
- Read-only board view
- Subscribe to match channel (no write)
5. Post-game screen:
- Show result, rating change, move count, duration
- Link to analysis (if `analysis_complete`)
- Rematch button (creates new match with `is_rematch=true`, `rematch_of=match.id`)
### Tables Used:
- `matches` (full 37 columns)
- `rating_history`
- `profiles` (stats update)
- `chat_messages`
- `game_plugins` (capabilities check)
- `feature_flags`
- `pairings` (if tournament match)
### RPCs Used:
- `check_opponent_timeout`
### Deliverable:
- Games fully playable (bot + multiplayer)
- All match data saved to DB correctly
- Rating changes recorded in history
- In-game chat working
- Spectator view functional
- `npm run build` passes
---
## PHASE 6 — Tournament System (Full)
**Goal:** Complete tournament system with brackets, standings, registration, live mode.
### Tasks:
1. Tournament listing page:
- Call `get_all_tournaments` RPC or query `el3ab_tournaments`
- Filter by status (upcoming/active/completed), game_key, format
- Show: name (bilingual), format, time_control, prize pool, player count, dates
- Check `feature_flags` for `tournaments_enabled`
- Show `categories` for filtered tournament groups (rating/age/gender)
- Show `tournament_format_registry` for format names/descriptions
2. Tournament detail page:
- Call `get_tournament_by_id` RPC
- All 58 columns of `el3ab_tournaments` used:
- Banner, status, countdown to start
- Format info (format, time_control, custom times, rounds_total)
- Multi-phase info (tournament_mode, phase_config, current_phase, total_phases)
- Entry requirements (min/max_rating, entry_fee_coins/gems)
- Prize pool (prize_pool_coins/gems, prize_distribution jsonb, cosmetic_rewards)
- Swiss settings (swiss_rounds, tiebreak_rules, acceleration_method/rounds)
- Bracket settings (bracket_size, bracket_best_of)
- Flags (is_rated, is_fide_rated, allow_berserk, auto_start)
- Live mode (live_enabled, live_theme, live_custom_css, live_visibility, live_branding)
- Engagement (view_count, unique_visitors)
- Standings (query `standings`)
- Bracket visualization (query `tournament_brackets` + `bracket_matches`)
- Pairings per round (query `pairings` + `el3ab_tournament_rounds`)
- Sponsors (from `tournament_sponsorships` + `sponsors`)
- Announcements (from `tournament_announcements`)
- Media gallery (from `tournament_media`)
- Insert to `tournament_page_views` on visit
- Prize payout records (from `tournament_prize_payouts`)
3. Bracket visualization:
- Query `bracket_matches` (all 29 columns):
- match_number, round, phase, seed positions
- player IDs + names + ratings
- scores, winner_id
- next_match_id, loser_next_match_id (double elimination)
- match_id link to actual `matches` row
- status, scheduled_at
- Support single/double elimination
4. Live Tournament Mode:
- Only if `live_enabled = true`
- Standings + active board(s) + tournament chat
- Realtime updates via `get_tournament_room` RPC
- Auto-refresh standings
- Tournament announcements feed
5. Registration flow:
- Check rating limits (min_rating, max_rating) against player's rating
- Check entry fee (entry_fee_coins, entry_fee_gems) against player balance
- Deduct fee → insert `economy_transactions`
- Call `register_tournament_player` RPC
- Creates row in `tournament_registrations` (tracks seed, final_standing)
- Also tracked in `tournament_players`
- Cancel via `cancel_tournament_registration` RPC (refund)
6. Multi-phase tournament support:
- Query `tournament_phases`
- Phase navigation (swiss → bracket → finals)
- Advancement rules from `phase_config` jsonb
- Current phase indicator (current_phase / total_phases)
7. Round management display:
- Query `el3ab_tournament_rounds` for round status/results
- Show round-by-round navigation
### Tables Used:
- `el3ab_tournaments` (58 columns)
- `el3ab_tournament_rounds`
- `tournament_players`
- `tournament_registrations`
- `tournament_phases`
- `tournament_brackets`
- `bracket_matches`
- `standings`
- `pairings`
- `tournament_announcements`
- `tournament_media`
- `tournament_page_views`
- `tournament_prize_payouts`
- `tournament_sponsorships`
- `tournament_ad_slots`
- `tournament_format_registry`
- `categories`
- `sponsors`
- `economy_transactions`
- `feature_flags`
### RPCs Used:
- `get_all_tournaments`
- `get_tournament_by_id`
- `get_tournament_room`
- `register_tournament_player`
- `cancel_tournament_registration`
### Deliverable:
- Full tournament listing with filters
- Tournament detail page with ALL data
- Bracket visualization (single + double elimination)
- Live mode working
- Registration + cancellation + fee handling
- Multi-phase support
- `npm run build` passes
---
## PHASE 7 — Organizations & Clubs
**Goal:** Full org system + lightweight clubs.
### Tasks:
1. Organizations discovery page:
- Query `el3ab_organizations`
- Filter by tier, country, game
- Search functionality
- Show verified badge, member count
2. Org detail page:
- Org info (description bilingual, social links, website)
- Tier + Verified status
- Custom branding (primary_color, secondary_color)
- Custom theme (from `org_theme_overrides`)
- Custom assets (from `org_asset_overrides`)
- Members list (from `org_members` with roles)
- Active Tournaments (org's `el3ab_tournaments` where org_id matches)
- Org Achievements (from `org_achievements`)
- Seasonal Rankings (from `org_seasonal_rankings`)
- Internal Leaderboard (from `org_leaderboards` — points, matches, tournaments, streaks)
- Member Spotlights (from `org_member_spotlights` — player of month, etc.)
- Content/Articles (from `org_content` — bilingual posts, categories, pinned)
- Recruitment posts (from `org_recruitment_posts`)
- Media gallery (from `org_media`)
- Activity log (from `org_activity_log` — admin only)
3. Org chat:
- Channels list (from `org_chat_channels`)
- Channel types: general, announcements, game-specific
- Messages (from `org_chat_messages`)
- Realtime subscription
- Moderation actions (from `org_chat_moderation` — admin only)
4. Org events:
- Events list (from `org_events`)
- RSVP (from `org_event_participants`)
- Link to tournament if `tournament_id` set
5. Membership flow:
- Join via invite code (`org_invite_codes`)
- Join via invite link (`org_invite_links`, track in `org_invite_uses`)
- Application flow (`org_membership_applications`)
- Role display (from `org_members.role`)
- Extended membership data (from `org_memberships`)
- Call `user_has_role_in_org` for permission checks
6. Org Challenges (Org vs Org):
- Display from `org_challenges`
- Roster selection (from `org_rosters` + `org_roster_players`)
- Score display
7. Org Treasury (admin only):
- Balance (from `org_treasury`)
- Transaction history (from `org_treasury_transactions`)
8. Org Partnerships:
- Display partnerships (from `org_partnerships` — partner org, status, type, benefits)
9. Player Transfers:
- Display transfers (from `org_player_transfers` — status, fees, consent tracking)
10. Referral System:
- Track referrals (from `org_referrals` — referrer, referred, reward status)
11. Training Sessions:
- List sessions (from `org_training_sessions`)
- Schedule + join flow
12. Loyalty Rewards:
- Org loyalty program (from `org_loyalty_rewards`)
- Player claims (from `player_loyalty_claims`)
13. Clubs (lightweight):
- List clubs (from `clubs` — name, name_ar, slug, logo, country, member_count, is_public)
- Club detail with member list (from `club_members`)
- Join/leave club
### Tables Used:
- `el3ab_organizations`
- `org_members`
- `org_memberships`
- `org_chat_channels`
- `org_chat_messages`
- `org_chat_moderation`
- `org_events`
- `org_event_participants`
- `org_challenges`
- `org_rosters`
- `org_roster_players`
- `org_treasury`
- `org_treasury_transactions`
- `org_seasonal_rankings`
- `org_achievements`
- `org_announcements`
- `org_media`
- `org_invite_codes`
- `org_invite_links`
- `org_invite_uses`
- `org_membership_applications`
- `org_recruitment_posts`
- `org_loyalty_rewards`
- `org_training_sessions`
- `org_content`
- `org_leaderboards`
- `org_member_spotlights`
- `org_partnerships`
- `org_player_transfers`
- `org_referrals`
- `org_activity_log`
- `org_theme_overrides`
- `org_asset_overrides`
- `clubs`
- `club_members`
- `player_loyalty_claims`
### RPCs Used:
- `user_has_role_in_org`
- `get_user_org_ids`
### Deliverable:
- Org discovery + detail pages (all sub-sections)
- Org chat with channels
- Events + RSVP
- Membership flow (invite/apply)
- Challenges, partnerships, transfers display
- Treasury (admin view)
- Clubs CRUD
- `npm run build` passes
---
## PHASE 8 — Social System & Chat
**Goal:** Friends, DMs, activity feed, online presence.
### Tasks:
1. Friends page:
- Query `friendships` (status: pending/accepted/blocked)
- Friends list with online status (join `profiles.is_online`)
- Pending requests (sent/received based on requester_id/addressee_id)
- Quick actions: challenge, message, view profile
- Search/add by username
- Block functionality (update friendships.status)
- Call `get_friends_by_auth_ids` for bulk lookup
2. DM chat system:
- Direct messages via `chat_messages` (channel_type='dm')
- Conversation list (group by channel_id)
- Realtime message subscription
- Unread indicators
3. Activity Feed:
- Query `activity_feed`
- Filter by visibility (public/friends)
- Types: completed match, won tournament, unlocked achievement, joined org
- Navigate to target entity on tap
4. Online presence system:
- Supabase Presence channel
- Update `profiles.is_online` and `profiles.last_seen_at`
- Show online friends prominently
- "Challenge friend" action (creates matchmaking or direct invite)
### Tables Used:
- `friendships`
- `profiles` (is_online, last_seen_at)
- `chat_messages`
- `activity_feed`
### RPCs Used:
- `get_friends_by_auth_ids`
### Deliverable:
- Friends system fully functional (add/accept/block)
- DM chat working with realtime
- Activity feed populated
- Online presence visible
- `npm run build` passes
---
## PHASE 9 — Achievements & XP System
**Goal:** Achievement system with categories, tiers, progress tracking.
### Tasks:
1. Seed `achievements` table:
- Categories: gameplay, social, tournament, collection, milestone
- Tiers: 1 (bronze), 2 (silver), 3 (gold), 4 (diamond)
- Arabic + English names/descriptions
- Conditions (jsonb: type, target, game_key)
- Rewards (XP, coins, cosmetics)
- Hidden achievements
2. Achievements page:
- All achievements grouped by category
- Progress bars (from `player_achievements.progress`)
- Locked/unlocked state (from `player_achievements.unlocked_at`)
- Hidden achievements show "???" until unlocked
- Reward preview (XP, coins, cosmetic unlock)
3. Achievement unlock flow:
- Toast notification on unlock
- XP + reward granted
- Insert `economy_transactions` for coin rewards
- Update `profiles.xp` and check level-up via `xp_levels`
4. Achievement display in Profile:
- Recent achievements section
- Showcase (pinned achievements)
- Completion percentage per category
5. Progress tracking (triggered after events):
- After each game: check gameplay conditions
- After tournament results: check tournament conditions
- Social actions (friend added, org joined): check social conditions
- Update `player_achievements.progress`
- On target met: set `unlocked_at`, grant rewards
### Tables Used:
- `achievements`
- `player_achievements`
- `xp_levels`
- `profiles` (xp, level)
- `economy_transactions`
- `cosmetics` (reward cosmetics)
### Deliverable:
- Achievements seeded in DB
- Achievement page with categories/tiers
- Progress tracking functional
- Unlock flow with rewards
- Profile integration
- `npm run build` passes
---
## PHASE 10 — Shop & Cosmetics
**Goal:** Cosmetics store with purchase/equip flow and frame system.
### Tasks:
1. Seed `cosmetics` table:
- Avatar frames (common → legendary)
- Board themes
- Piece sets
- Profile banners
- Titles/badges
- All with Arabic names, preview_url, asset_url
- Price in coins and/or gems
- Limited edition items with `available_until`
2. Shop page:
- Category tabs from `cosmetic_type_registry` (type definitions)
- Rarity filter
- Price display (coins/gems)
- Limited edition countdown (available_until)
- Owned indicator (check `player_cosmetics`)
- Purchase flow with confirmation
3. Frame system:
- Query `profile_frames` for all available frames:
- name (bilingual), rarity, price_coins/gems
- is_purchasable, required_level, required_achievement_id
- org_id (org-exclusive frames)
- max_supply / current_supply (limited editions)
- available_from / available_until
- Player's owned frames from `player_frames` (acquisition_type, is_equipped)
- Equip frame → update `profiles.avatar_frame_id`
4. Equip system:
- Toggle equipped via `player_cosmetics.is_equipped`
- Frame equip via `player_frames.is_equipped`
- Update `profiles.avatar_frame_id`, `profiles.avatar_border_color`
5. Economy transaction logging:
- Every purchase → `economy_transactions` insert
- Balance validation (check profiles.coins/gems before purchase)
- Insufficient funds handling
- Deduct from `profiles.coins` or `profiles.gems`
### Tables Used:
- `cosmetics`
- `cosmetic_type_registry`
- `player_cosmetics`
- `profile_frames`
- `player_frames`
- `profiles` (coins, gems, avatar_frame_id, avatar_border_color)
- `economy_transactions`
### Deliverable:
- Cosmetics seeded in DB
- Shop fully functional with categories
- Frame system (browse, purchase, equip)
- Purchase + equip flow
- Economy tracking
- `npm run build` passes
--- ---
## PHASE 11 — Leaderboard & Rankings ## Database (Supabase PostgreSQL - 99 tables exist)
**Goal:** Global, org, and friend leaderboards from real DB data. ### Key Tables Used by Player App
### Tasks: | Table | Purpose |
1. Leaderboard page: |-------|---------|
- Query `leaderboards` table | profiles (48 cols) | Player data, ratings, stats, economy, bans |
- Filter: game_key, time_control_type, period (daily/weekly/monthly/all-time) | matches (39 cols) | Game records (FEN, PGN, moves, ratings, bot_id) |
- Country filter (using profiles.country_code) | friendships | Friend requests and connections |
- Show: rank, avatar+frame, display_name, rating, games_played, win_rate | notifications | Push-style notifications |
- Highlight current user position | tournaments | Tournament definitions |
| tournament_registrations | Player signups |
2. Org leaderboard: | leaderboards | Cached rankings |
- Query `org_seasonal_rankings` (org-level standings) | cosmetics | Shop items |
- Query `org_leaderboards` (individual player rankings within org) | player_cosmetics | Owned items |
- Points, matches won/lost, tournaments won, streaks | player_achievements | Achievement progress |
| achievements | Achievement definitions |
3. Friends leaderboard: | economy_transactions | Coin/gem history |
- Filter to friends only (join `friendships`) | matchmaking_queue | Live matchmaking |
- Quick challenge from leaderboard | feature_flags | Feature toggles |
| game_plugins | Available games (chess, backgammon, etc.) |
4. Rating distribution: | organizations | Clubs/orgs |
- Show where user falls in global distribution | org_members | Org membership |
- Percentile display | rating_history | Rating over time |
| activity_feed | Social feed |
### Tables Used:
- `leaderboards` ### profiles table columns
- `org_seasonal_rankings` id, username, display_name, display_name_ar, avatar_url, banner_url, bio, bio_ar, country_code, city, preferred_language, elo_bullet, elo_blitz, elo_rapid, elo_classical, fide_id, fide_rating_standard, fide_rating_rapid, fide_rating_blitz, fide_title, xp, level, coins, gems, is_online, last_seen_at, is_banned, ban_reason, ban_expires_at, total_games_played, total_wins, total_draws, total_losses, total_tournaments_played, total_tournaments_won, win_streak, best_win_streak, created_at, updated_at, premium_currency, last_daily_reward, daily_streak, banned_by, current_game, games_played, avatar_frame_id, avatar_border_color, active_org_frame_id
- `org_leaderboards`
- `profiles` (for player data) ### matches table columns
- `friendships` (for friends filter) id, game_key, white_player_id, black_player_id, match_type, tournament_id, tournament_round, pairing_id, status, result, time_control, initial_time_ms, increment_ms, white_time_remaining_ms, black_time_remaining_ms, starting_fen, current_fen, pgn, moves (jsonb), move_count, game_state (jsonb), white_rating_before, black_rating_before, white_rating_after, black_rating_after, rating_change_white, rating_change_black, bot_id, bot_difficulty, is_flagged, cheat_score, analysis_complete, is_rated, is_rematch, rematch_of, started_at, completed_at, created_at, updated_at
- `player_frames` + `profile_frames` (for avatar frames)
### Deliverable:
- Full leaderboard with all filters
- Org rankings (org + individual)
- Friends filter
- `npm run build` passes
--- ---
## PHASE 12 — Notifications & Settings ## Stockfish Bot API
**Goal:** Complete notification system + app settings. Base: https://stockfishapi.caprover.al-arcade.com
### Tasks: ### Endpoints Used
1. Notifications page: - `POST /api/chess/move` -- Get bot move (fen + bot_id)
- Query `notifications` table (bilingual title/body via title_ar/title_en) - `POST /api/chess/analyze` -- Position analysis (post-game)
- Types: match_invite, friend_request, tournament_start, achievement_unlock, org_invite, system - `GET /api/chess/bots` -- List all bots
- Mark as read (single + all)
- Tap to navigate (use `data` jsonb for routing info)
- Realtime subscription for new notifications
- Unread count in header bell
2. Settings page: ### Available Bots (7)
- Language preference (update `profiles.preferred_language`) | ID | Name (AR) | ELO | Style |
- Notification preferences |----|-----------|-----|-------|
- Sound toggle | amina | Amina al-Mubtadi'a | 400-600 | beginner |
- Account management (change password, delete account) | tarek | Tariq al-Mutahaffiz | 800-1000 | defensive |
- Privacy settings | nour | Nour al-Muhajima | 1000-1200 | aggressive |
- Block list management (from `friendships` where status='blocked') | omar | Omar al-Istratiji | 1200-1400 | positional |
- App version (call `check_app_version` RPC) | layla | Layla al-Mubdi'a | 1400-1600 | creative |
- Logout | ziad | Ziad al-Sulb | 1600-1800 | solid |
| grandmaster | al-Grand Master | 2000-2200 | near_perfect |
3. Push notification registration (if applicable): Portrait URLs: `https://stockfishapi.caprover.al-arcade.com/portraits/{bot_id}.jpg`
- Check `feature_flags` for push_notifications_enabled
- Web push subscription flow
### Tables Used:
- `notifications`
- `profiles` (preferred_language)
- `friendships` (block list)
- `feature_flags`
### RPCs Used:
- `check_app_version`
### Deliverable:
- Notifications fully working with realtime
- Settings page complete
- Unread badge accurate
- `npm run build` passes
--- ---
## PHASE 13 — Anti-Cheat & Reporting ## Design System
**Goal:** Trust system. Players can report. Engine analysis visible. ### Colors (CSS custom properties)
```css
### Tasks: :root {
1. Report modal (in-game + from profile): --bg-0: #050D17; /* page background */
- Report reason selection --bg-1: #0A1525; /* header/nav background */
- Description field --bg-2: #142640; /* card background */
- Submit to `cheat_reports` table --bg-3: #1C3254; /* elevated elements, inputs */
- Attach match_id automatically if in-game --gold: #E7A832; /* brand accent, primary CTA */
--cyan: #15D7FF; /* secondary accent, active states */
2. Post-game analysis display: --blue: #2979FF; /* links */
- If `matches.analysis_complete = true` --success: #34D399;
- Show `matches.cheat_score` --error: #EF4444;
- Average centipawn loss (from game_state jsonb or analysis data) --warning: #F59E0B;
- Move accuracy percentage --text-1: #F1F5F9; /* primary text */
- Flag indicator if `matches.is_flagged = true` --text-2: #94A3B8; /* secondary text */
--text-3: #64748B; /* muted text */
3. Fair play badge on profile: --border: rgba(255,255,255,0.10);
- Clean record indicator (no cheat_reports against player) --radius-sm: 8px;
- Analysis stats for recent games --radius-md: 12px;
--radius-lg: 16px;
### Tables Used: }
- `cheat_reports` ```
- `matches` (is_flagged, cheat_score, analysis_complete, game_state)
### Deliverable:
- Report system functional
- Post-game analysis display
- `npm run build` passes
---
## PHASE 14 — Sponsorship, Charity & Ads
**Goal:** Display sponsors in tournaments. Charity integration. Ad system.
### Tasks:
1. Sponsor display:
- Query `sponsors` for sponsor info
- Show in tournament pages (via `tournament_sponsorships`)
- Sponsor branding from `el3ab_tournaments.sponsor_branding` jsonb
- Link to sponsor website
2. Charity integration:
- If `el3ab_tournaments.charity_id` is set
- Query `charities` table for charity info
- Show `charity_percent` going to charity
- Donation records from `charity_donations`
- Verified charity badge
3. Ad system (player-facing):
- Query `ad_slots` for placement definitions (page_context, is_enabled)
- Query active `ad_campaigns` + `ad_creatives` for current slot
- Display ad creative (image, CTA text bilingual)
- Track impressions/clicks → insert `ad_impressions`
- Respect `refresh_interval_sec` for ad rotation
4. Seed sponsors + charities in DB for demo
### Tables Used:
- `sponsors`
- `tournament_sponsorships`
- `charities`
- `charity_donations`
- `ad_slots`
- `ad_campaigns`
- `ad_creatives`
- `ad_impressions`
- `el3ab_tournaments` (charity_id, charity_percent, sponsor_id, sponsor_branding)
### Deliverable:
- Sponsors visible in tournament detail
- Charity donation display
- Ad system rendering and tracking
- `npm run build` passes
---
## PHASE 15 — Legacy Systems & Events
**Goal:** Connect legacy tournament/event tables (FIDE integration).
### Tasks:
1. FIDE Events display:
- Query `events` table (venue, city, country, dates, arbiters, FIDE event ID)
- Link events to `organizations` (FIDE federations)
- Show `events.is_fide_rated` indicator
2. Legacy Tournaments:
- Query `tournaments` (FIDE-style: pairing_system, acceleration, tiebreaks, k_factor)
- Linked to `events` via event_id
- `rounds` table for round management
- Tournament type (swiss, round_robin, etc.)
3. Legacy Organizations (FIDE federations):
- Query `organizations` (name, slug, logo, website, country, FIDE federation ID)
- Subscription tier, limits (max_tournaments, max_players_per_tournament)
- Settings jsonb
4. User Profiles (legacy):
- `user_profiles` table (FIDE-oriented: fide_id, national_id, birth_date, title)
- Cross-reference with `profiles` for unified display
### Tables Used:
- `events`
- `tournaments`
- `rounds`
- `organizations`
- `user_profiles`
### Deliverable:
- FIDE events display
- Legacy tournament viewing
- Org/federation display
- `npm run build` passes
---
## PHASE 16 — Polish, Performance & Accessibility
**Goal:** Final pass. RTL perfect. Performance targets met.
### Tasks:
1. Accessibility:
- Focus indicators on all interactive elements
- Screen reader labels (aria)
- Keyboard navigation
2. RTL perfection:
- Mirror all layouts
- RTL tables (leaderboard, standings)
- Test with Arabic content throughout
3. Performance:
- Code splitting per route (React.lazy)
- Image optimization (WebP, lazy load)
- Supabase query optimization (select specific columns, not *)
- Navigation target: <150ms
- Initial load target: <2s
4. Error boundaries + loading states:
- Skeleton screens
- Error fallback UI
- Offline indicator (profiles.is_online = false on disconnect)
- Network retry logic
5. Trivia game content prep:
- `trivia_questions` table populated (bilingual, categorized, difficulty levels)
- Ready for future trivia game plugin activation
6. Feature flag final audit:
- Ensure ALL gated features check `feature_flags`
- Rollout percentages working correctly
### Tables Used:
- `trivia_questions` (seeding)
- `feature_flags` (audit)
- All tables (query optimization)
### Deliverable:
- RTL perfect
- Performance targets met
- Zero console errors
- All 99 tables accounted for
- `npm run build` passes
---
## Full Table Coverage Summary
| Phase | Tables Connected | ### Typography
|-------|-----------------| - Arabic: 'IBM Plex Sans Arabic', system sans-serif
| 0 | platform_theme, platform_branding, platform_assets, system_config | - Latin/numbers: 'Inter', system sans-serif
| 1 | profiles, notifications, platform_roles | - Direction: RTL (dir="rtl" on html)
| 2 | profiles (full), xp_levels, rating_history, player_achievements, player_cosmetics, player_frames, profile_frames |
| 3 | matches, el3ab_tournaments, friendships, leaderboards, activity_feed, cosmetics, economy_transactions |
| 4 | game_plugins, game_theme_overrides, feature_flags, matchmaking_queue |
| 5 | matches (full), rating_history, chat_messages, pairings |
| 6 | el3ab_tournaments (full), el3ab_tournament_rounds, tournament_players, tournament_registrations, tournament_phases, tournament_brackets, bracket_matches, standings, pairings, tournament_announcements, tournament_media, tournament_page_views, tournament_prize_payouts, tournament_sponsorships, tournament_ad_slots, tournament_format_registry, categories, sponsors |
| 7 | el3ab_organizations, org_members, org_memberships, org_chat_channels, org_chat_messages, org_chat_moderation, org_events, org_event_participants, org_challenges, org_rosters, org_roster_players, org_treasury, org_treasury_transactions, org_seasonal_rankings, org_achievements, org_announcements, org_media, org_invite_codes, org_invite_links, org_invite_uses, org_membership_applications, org_recruitment_posts, org_loyalty_rewards, org_training_sessions, org_content, org_leaderboards, org_member_spotlights, org_partnerships, org_player_transfers, org_referrals, org_activity_log, org_theme_overrides, org_asset_overrides, clubs, club_members, player_loyalty_claims |
| 8 | friendships, chat_messages, activity_feed |
| 9 | achievements, player_achievements |
| 10 | cosmetics, cosmetic_type_registry, player_cosmetics, profile_frames, player_frames, economy_transactions |
| 11 | leaderboards, org_seasonal_rankings, org_leaderboards |
| 12 | notifications |
| 13 | cheat_reports |
| 14 | sponsors, charities, charity_donations, ad_slots, ad_campaigns, ad_creatives, ad_impressions, tournament_ad_slots |
| 15 | events, tournaments, rounds, organizations, user_profiles |
| 16 | trivia_questions, feature_flags (audit) |
### Admin-Only Tables (not player-facing, excluded): ### Icons
- `_migrations` — schema migration tracking - All icons are inline SVGs stored in a sprite (`public/icons/sprite.svg`)
- `admin_users` — admin panel authentication - Usage: `<svg class="icon"><use href="/icons/sprite.svg#icon-name"></use></svg>`
- `approval_requests` — admin approval workflows - NO emojis anywhere in the UI
- `audit_log` / `audit_logs` — system audit trails - Icon set needed: home, play, trophy, leaderboard, friends, shop, star, settings, profile, bell, coin, gem, clock, flag, crown, check, x, arrow-left, arrow-right, menu, search, plus, minus, edit, trash, send, heart, shield, sword, bot, board, piece
- `workflow_rules` — automated admin workflows
**Final coverage: 94/99 tables connected** (5 admin-only excluded) ### Layout
- Mobile-first, max-width 600px content area centered
- Desktop: 72px side nav on right (RTL), content centered in remaining space
- Header: sticky top, 56px height
- Bottom nav: 64px, mobile only (hidden on lg+)
- All interactive elements min 48px touch target
--- ---
## RPC Functions Used ## Build Phases
| Function | Phase | Purpose | ### Phase 1: Foundation
|----------|-------|---------| - [ ] Dockerfile + captain-definition + .htaccess
| `handle_new_user` | 2 | Auto-creates profile on signup (trigger) | - [ ] config/database.php + config/constants.php
| `get_user_org_ids` | 2, 3, 7 | Get user's org memberships | - [ ] includes/supabase.php (REST helper: GET, POST, PATCH via cURL)
| `get_friends_by_auth_ids` | 3, 8 | Bulk friend lookup | - [ ] public/css/app.css (full design system + layout)
| `check_opponent_timeout` | 5 | Detect opponent flag in game | - [ ] public/icons/sprite.svg (all icons)
| `register_tournament_player` | 6 | Tournament registration | - [ ] includes/header.php + includes/footer.php
| `cancel_tournament_registration` | 6 | Cancel + refund | - [ ] templates/nav-desktop.php + templates/nav-bottom.php
| `get_all_tournaments` | 6 | Tournament listing |
| `get_tournament_by_id` | 6 | Tournament detail | ### Phase 2: Auth
| `get_tournament_room` | 6 | Live tournament room data | - [ ] pages/login.php (form + JS validation)
| `user_has_role_in_org` | 7 | Org permission check | - [ ] pages/register.php (form + JS)
| `check_app_version` | 12 | Version check | - [ ] api/auth.php (proxy to Supabase GoTrue: signup, login, logout, refresh)
- [ ] public/js/auth.js (session storage, token refresh, guards)
- [ ] includes/auth_check.php (server-side redirect)
### Phase 3: Home + Profile
- [ ] pages/home.php (welcome, play button, daily reward, recent games)
- [ ] pages/profile.php (avatar, ratings, stats, W/D/L, economy)
- [ ] api/profile.php (GET profile, PATCH update)
- [ ] public/js/app.js (global utils, fetch wrapper, toast)
### Phase 4: Chess Game (Core)
- [ ] public/js/chess.min.js (chess.js library)
- [ ] public/css/chessboard.css (board, pieces, squares, highlights)
- [ ] public/js/board.js (render board, drag/drop pieces, flip, animations)
- [ ] public/js/game.js (game flow: start, moves, clock, resign, draw offer)
- [ ] pages/game.php (game page: board + clocks + move list + controls)
- [ ] api/game.php (start game, record moves, end game, update ratings)
### Phase 5: Bot Play
- [ ] pages/bots.php (bot selection grid with portraits + difficulty)
- [ ] pages/play.php (game setup: select game, time control, mode)
- [ ] Bot integration in game.js (fetch from Stockfish API on bot turn)
- [ ] Thinking indicator animation
### Phase 6: Social
- [ ] pages/friends.php (friends list, pending requests, search)
- [ ] api/friends.php (add/accept/remove/block)
- [ ] pages/notifications.php (notification list, mark read)
- [ ] api/notifications.php (GET list, PATCH read)
### Phase 7: Competitive
- [ ] pages/leaderboard.php (rankings by time control)
- [ ] api/leaderboard.php (GET top 100 by category)
- [ ] pages/tournaments.php (tournament listing + filters)
- [ ] pages/tournament.php (single tournament: bracket, standings)
- [ ] api/tournaments.php (GET list, POST register)
### Phase 8: Economy & Cosmetics
- [ ] pages/shop.php (cosmetics grid with prices)
- [ ] api/shop.php (GET items, POST purchase)
- [ ] pages/achievements.php (achievement grid + progress)
- [ ] Daily reward system (claim endpoint)
### Phase 9: Organizations
- [ ] pages/orgs.php (org listing)
- [ ] pages/org.php (single org: members, info)
- [ ] Org membership API
### Phase 10: Settings + Polish
- [ ] pages/settings.php (account, preferences, language, sounds)
- [ ] Responsive testing (mobile 375px, tablet 768px, desktop 1920px)
- [ ] RTL polish pass
- [ ] Error pages (404, 500)
- [ ] Loading states, empty states
- [ ] Final deployment + smoke test
--- ---
## What's NOT in Scope ## Auth Flow (Supabase GoTrue via REST)
- Other games (backgammon, dominoes, ludo) — locked with "coming soon" (trivia content seeded for future) ```
- Push notifications (web push / FCM) — basic only 1. User submits login form
- OAuth login (Google, Apple) — email only 2. JS sends POST to /api/auth.php with email+password
- Multi-theme — dark only 3. PHP proxies to Supabase: POST {KONG_URL}/auth/v1/token?grant_type=password
- Language toggle beyond AR/EN 4. On success: returns access_token + refresh_token
5. JS stores tokens in localStorage
6. All subsequent API calls include: Authorization: Bearer {access_token}
7. Token refresh: POST {KONG_URL}/auth/v1/token?grant_type=refresh_token
```
--- ---
## Phase Execution Protocol ## Game Flow (vs Bot)
``` ```
1. Implement the phase 1. Player selects bot + time control on /play.php
2. Run `npm run build` — must pass 2. JS creates match via POST /api/game.php (action=start, bot_id, time_control)
3. Report "Phase X ready for deploy" 3. PHP inserts into matches table, returns match_id + starting FEN
4. Human confirms build is live 4. Redirect to /game.php?id={match_id}
5. Screenshot + analyze against requirements 5. Board renders from FEN using chess.js + board.js
6. If issues → fix → rebuild → confirm again 6. Player makes move -> chess.js validates -> POST /api/game.php (action=move)
7. If clean → proceed to next phase 7. PHP updates matches.moves + current_fen
8. JS calls Stockfish API: POST /api/chess/move with current FEN + bot_id
9. Bot response arrives -> animate move on board -> update state
10. Repeat until checkmate/stalemate/resign/timeout
11. PHP updates match result + rating changes
``` ```
--- ---
## Success Criteria ## Key Principles
1. New user signs up → `handle_new_user` trigger creates profile with defaults 1. NO emojis - only SVG icons from the sprite
2. Two users find each other via matchmaking and play chess to completion 2. Arabic-first RTL layout throughout
3. Clocks work, moves sync in realtime, game ends correctly 3. PHP renders pages server-side, JS handles interactivity
4. Ratings update after game (written to `rating_history`) 4. All API calls go through /api/*.php (acts as proxy to Supabase)
5. Friends can be added, notifications appear in realtime 5. chess.js runs client-side for instant move validation
6. Tournaments can be browsed, registered for (with fee deduction), and played 6. Clean, dark gaming aesthetic with gold/cyan accents
7. Shop displays cosmetics with purchase flow (economy_transactions logged) 7. Mobile-first but desktop must look proper (centered content, side nav)
8. Organizations + clubs fully functional 8. Every page loads fast - no SPA overhead, no build step for frontend
9. Achievements track progress and unlock with rewards
10. Leaderboards show real rankings from DB
11. RTL Arabic layout correct on all pages
12. All missing assets degrade gracefully (no broken images)
13. No console errors, no white screens, no broken states
14. Sounds play on interactions (or silently fail if files missing)
15. All feature flags respected
16. 94/99 tables connected (5 admin-only excluded)
<?php
require_once __DIR__ . '/../config/database.php';
header('Content-Type: application/json');
$input = json_decode(file_get_contents('php://input'), true);
$action = $input['action'] ?? '';
if ($action === 'login') {
$email = $input['email'] ?? '';
$password = $input['password'] ?? '';
if (!$email || !$password) {
http_response_code(400);
echo json_encode(['error' => 'البريد وكلمة المرور مطلوبين']);
exit;
}
$result = supabase_auth('token?grant_type=password', [
'email' => $email,
'password' => $password,
]);
if ($result['status'] !== 200) {
$msg = $result['data']['error_description'] ?? $result['data']['msg'] ?? 'بيانات الدخول غير صحيحة';
http_response_code(401);
echo json_encode(['error' => $msg]);
exit;
}
echo json_encode([
'access_token' => $result['data']['access_token'],
'refresh_token' => $result['data']['refresh_token'],
'user' => $result['data']['user'],
]);
} elseif ($action === 'register') {
$email = $input['email'] ?? '';
$password = $input['password'] ?? '';
$username = $input['username'] ?? '';
if (!$email || !$password || !$username) {
http_response_code(400);
echo json_encode(['error' => 'جميع الحقول مطلوبة']);
exit;
}
if (strlen($password) < 6) {
http_response_code(400);
echo json_encode(['error' => 'كلمة المرور يجب ان تكون 6 احرف على الاقل']);
exit;
}
$result = supabase_auth('signup', [
'email' => $email,
'password' => $password,
'data' => ['username' => $username, 'display_name' => $username],
]);
if ($result['status'] !== 200 && $result['status'] !== 201) {
$msg = $result['data']['error_description'] ?? $result['data']['msg'] ?? 'فشل التسجيل';
http_response_code(400);
echo json_encode(['error' => $msg]);
exit;
}
if (isset($result['data']['access_token'])) {
echo json_encode([
'access_token' => $result['data']['access_token'],
'refresh_token' => $result['data']['refresh_token'],
'user' => $result['data']['user'],
]);
} else {
echo json_encode(['message' => 'تم التسجيل بنجاح، يرجى تاكيد البريد']);
}
} elseif ($action === 'refresh') {
$refreshToken = $input['refresh_token'] ?? '';
if (!$refreshToken) {
http_response_code(400);
echo json_encode(['error' => 'refresh_token مطلوب']);
exit;
}
$result = supabase_auth('token?grant_type=refresh_token', [
'refresh_token' => $refreshToken,
]);
if ($result['status'] !== 200) {
http_response_code(401);
echo json_encode(['error' => 'الجلسة منتهية']);
exit;
}
echo json_encode([
'access_token' => $result['data']['access_token'],
'refresh_token' => $result['data']['refresh_token'],
]);
} else {
http_response_code(400);
echo json_encode(['error' => 'action غير معروف']);
}
<?php
require_once __DIR__ . '/../config/database.php';
header('Content-Type: application/json');
$authHeader = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
$token = str_replace('Bearer ', '', $authHeader);
if (!$token) {
http_response_code(401);
echo json_encode(['error' => 'غير مصرح']);
exit;
}
$method = $_SERVER['REQUEST_METHOD'];
if ($method === 'GET') {
$userRes = supabase_auth('user', [], $token);
if ($userRes['status'] !== 200 || !isset($userRes['data']['id'])) {
http_response_code(401);
echo json_encode(['error' => 'جلسة غير صالحة']);
exit;
}
$userId = $userRes['data']['id'];
$profileRes = supabase_rest('GET', 'profiles?id=eq.' . $userId . '&select=*', [], $token);
if ($profileRes['status'] === 200 && !empty($profileRes['data'])) {
echo json_encode(['profile' => $profileRes['data'][0]]);
} else {
http_response_code(404);
echo json_encode(['error' => 'الملف الشخصي غير موجود']);
}
} elseif ($method === 'PATCH') {
$input = json_decode(file_get_contents('php://input'), true);
$userRes = supabase_auth('user', [], $token);
if ($userRes['status'] !== 200) {
http_response_code(401);
echo json_encode(['error' => 'غير مصرح']);
exit;
}
$userId = $userRes['data']['id'];
$allowed = ['display_name', 'display_name_ar', 'bio', 'bio_ar', 'avatar_url', 'country_code', 'city'];
$update = array_intersect_key($input, array_flip($allowed));
if (empty($update)) {
http_response_code(400);
echo json_encode(['error' => 'لا حقول للتحديث']);
exit;
}
$result = supabase_rest('PATCH', 'profiles?id=eq.' . $userId, $update, $token);
echo json_encode(['success' => true]);
} else {
http_response_code(405);
echo json_encode(['error' => 'method not allowed']);
}
<?php
define('APP_NAME', 'EL3AB');
define('APP_URL', 'https://el3ab-player.caprover.al-arcade.com');
define('APP_VERSION', '2.0.0');
define('SUPABASE_URL', 'https://safe-supabase-kong.caprover.al-arcade.com');
define('SUPABASE_ANON_KEY', 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzM1Njg5NjAwLCJleHAiOjE4OTM0NTYwMDB9.31PF6PvP-pSrvRuQwLFptQoejR0W1A7o53lZhEbnz84');
define('SUPABASE_SERVICE_KEY', 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoic2VydmljZV9yb2xlIiwiaXNzIjoic3VwYWJhc2UiLCJpYXQiOjE3MzU2ODk2MDAsImV4cCI6MTg5MzQ1NjAwMH0.wNfmuJNkX-bZwD7RbjxOChlRf_3Xm4I7bswEYTcDCg4');
define('STOCKFISH_API', 'https://stockfishapi.caprover.al-arcade.com');
define('STOCKFISH_MGMT_KEY', 'sk-alarc-stockfish-mgmt-2024');
<?php
require_once __DIR__ . '/constants.php';
function supabase_rest(string $method, string $endpoint, array $data = [], ?string $token = null): array {
$url = SUPABASE_URL . '/rest/v1/' . ltrim($endpoint, '/');
$headers = [
'Content-Type: application/json',
'apikey: ' . SUPABASE_ANON_KEY,
'Authorization: Bearer ' . ($token ?: SUPABASE_ANON_KEY),
];
if ($method === 'GET' && !empty($data)) {
$url .= '?' . http_build_query($data);
$data = [];
}
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_TIMEOUT, 15);
if ($method === 'POST') {
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
} elseif ($method === 'PATCH') {
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PATCH');
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
} elseif ($method === 'DELETE') {
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE');
}
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
unset($ch);
return [
'status' => $httpCode,
'data' => json_decode($response, true),
];
}
function supabase_auth(string $endpoint, array $data = [], ?string $token = null): array {
$url = SUPABASE_URL . '/auth/v1/' . ltrim($endpoint, '/');
$headers = [
'Content-Type: application/json',
'apikey: ' . SUPABASE_ANON_KEY,
];
if ($token) {
$headers[] = 'Authorization: Bearer ' . $token;
}
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
curl_setopt($ch, CURLOPT_TIMEOUT, 15);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
unset($ch);
return [
'status' => $httpCode,
'data' => json_decode($response, true),
];
}
</div>
</main>
<?php require __DIR__ . '/../templates/nav-bottom.php'; ?>
</div>
<div class="toast-container" id="toast-container"></div>
<script src="/public/js/app.js"></script>
<?php if (isset($extraJs)): ?>
<script src="<?= $extraJs ?>"></script>
<?php endif; ?>
</body>
</html>
<!DOCTYPE html>
<html lang="ar" dir="rtl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="theme-color" content="#050D17">
<title><?= $pageTitle ?? 'EL3AB' ?></title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans+Arabic:wght@400;500;600;700&family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/public/css/app.css">
<?php if (isset($extraCss)): ?>
<link rel="stylesheet" href="<?= $extraCss ?>">
<?php endif; ?>
</head>
<body>
<div class="app">
<?php require __DIR__ . '/../templates/nav-desktop.php'; ?>
<header class="header">
<div class="header-inner">
<a href="/" class="header-logo">EL3AB</a>
<div class="header-stats">
<div class="header-stat">
<svg class="icon-sm icon-fill" style="color:var(--gold)"><use href="/public/icons/sprite.svg#icon-coin"></use></svg>
<span id="header-coins">0</span>
</div>
<div class="header-stat">
<svg class="icon-sm icon-fill" style="color:var(--purple)"><use href="/public/icons/sprite.svg#icon-gem"></use></svg>
<span id="header-gems">0</span>
</div>
<a href="/notifications" class="header-bell">
<svg class="icon"><use href="/public/icons/sprite.svg#icon-bell"></use></svg>
<span class="header-badge" id="header-notif-badge" style="display:none">0</span>
</a>
</div>
</div>
</header>
<main class="main">
<div class="main-inner">
<!doctype html>
<html lang="ar" dir="rtl">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#071120" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans+Arabic:wght@400;500;600;700&family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@500;700&display=swap" rel="stylesheet" />
<title>EL3AB - العب</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
<?php
$route = $_GET['route'] ?? '';
$route = trim($route, '/');
if ($route === '' || $route === 'home') {
require 'pages/home.php';
} elseif ($route === 'login') {
require 'pages/login.php';
} elseif ($route === 'register') {
require 'pages/register.php';
} elseif ($route === 'play') {
require 'pages/play.php';
} elseif ($route === 'game') {
require 'pages/game.php';
} elseif ($route === 'bots') {
require 'pages/bots.php';
} elseif ($route === 'profile') {
require 'pages/profile.php';
} elseif ($route === 'leaderboard') {
require 'pages/leaderboard.php';
} elseif ($route === 'friends') {
require 'pages/friends.php';
} elseif ($route === 'tournaments') {
require 'pages/tournaments.php';
} elseif ($route === 'tournament') {
require 'pages/tournament.php';
} elseif ($route === 'shop') {
require 'pages/shop.php';
} elseif ($route === 'achievements') {
require 'pages/achievements.php';
} elseif ($route === 'notifications') {
require 'pages/notifications.php';
} elseif ($route === 'settings') {
require 'pages/settings.php';
} elseif ($route === 'orgs') {
require 'pages/orgs.php';
} elseif ($route === 'org') {
require 'pages/org.php';
} elseif (str_starts_with($route, 'api/')) {
$apiFile = str_replace('api/', '', $route);
$apiPath = __DIR__ . '/api/' . basename($apiFile) . '.php';
if (file_exists($apiPath)) {
require $apiPath;
} else {
http_response_code(404);
echo json_encode(['error' => 'endpoint not found']);
}
} else {
http_response_code(404);
require 'pages/404.php';
}
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /assets {
expires 1y;
add_header Cache-Control "public, immutable";
}
location /sounds {
expires 1y;
add_header Cache-Control "public, immutable";
}
}
{
"name": "el3ab-player",
"version": "2.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "el3ab-player",
"version": "2.0.0",
"dependencies": {
"@supabase/supabase-js": "^2.49.0",
"chess.js": "^1.0.0-beta.8",
"framer-motion": "^12.0.0",
"howler": "^2.2.4",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-router-dom": "^7.1.0",
"zustand": "^5.0.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"@types/howler": "^2.2.12",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"tailwindcss": "^4.0.0",
"typescript": "~6.0.2",
"vite": "^8.0.12"
}
},
"node_modules/@emnapi/core": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.1",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/wasi-threads": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0",
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"node_modules/@jridgewell/remapping": {
"version": "2.3.5",
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"dev": true,
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
"integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@tybys/wasm-util": "^0.10.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"peerDependencies": {
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1"
}
},
"node_modules/@oxc-project/types": {
"version": "0.132.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.132.0.tgz",
"integrity": "sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/Boshen"
}
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz",
"integrity": "sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.2.tgz",
"integrity": "sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-darwin-x64": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.2.tgz",
"integrity": "sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.2.tgz",
"integrity": "sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.2.tgz",
"integrity": "sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.2.tgz",
"integrity": "sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.2.tgz",
"integrity": "sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.2.tgz",
"integrity": "sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-s390x-gnu": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.2.tgz",
"integrity": "sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.2.tgz",
"integrity": "sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-x64-musl": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.2.tgz",
"integrity": "sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.2.tgz",
"integrity": "sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-wasm32-wasi": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.2.tgz",
"integrity": "sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ==",
"cpu": [
"wasm32"
],
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "1.10.0",
"@emnapi/runtime": "1.10.0",
"@napi-rs/wasm-runtime": "^1.1.4"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.2.tgz",
"integrity": "sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.2.tgz",
"integrity": "sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
"dev": true,
"license": "MIT"
},
"node_modules/@supabase/auth-js": {
"version": "2.106.2",
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.106.2.tgz",
"integrity": "sha512-VcAjUErkHkhC5Jaf+g/G1qbkQrFh8edaCdHa7pxJmHUjkWKjT7UnYCtPA89XV0N0GIYRkEqJZw5V62CtOxTmBQ==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/functions-js": {
"version": "2.106.2",
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.106.2.tgz",
"integrity": "sha512-oRnr0QrL8H+zTO1YyQ1QjiHZU/957jvubbxSJTUm2XLAgzoGGV9Tahfyd+uvLsBLRVmXLtpU3oyCjdQIvkGMOA==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/phoenix": {
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.2.tgz",
"integrity": "sha512-YSAGnmDAfuleFCVt3CeurQZAhxRfXWeZIIkwp7NhYzQ1UwW6ePSnzsFAiUm/mbCkfoCf70QQHKW/K6RKh52a4A==",
"license": "MIT"
},
"node_modules/@supabase/postgrest-js": {
"version": "2.106.2",
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.106.2.tgz",
"integrity": "sha512-tDOzyPgp9pIRMR2x6C9+uDSJrnXSzxLtt3d7nC+Lrsy3jnJDHYfdQC/xcRyhJE/TOBJ0heSqRKR3UmejDjZxsw==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/realtime-js": {
"version": "2.106.2",
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.106.2.tgz",
"integrity": "sha512-LdRGT7DNhyZkPjubUv5bSdAZ0jSEX8wTHvx7htj7+K59TOZRvz4TuQK7tL2RWxyIZVeFMRluL04SzWS61rKnUA==",
"license": "MIT",
"dependencies": {
"@supabase/phoenix": "^0.4.2",
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/storage-js": {
"version": "2.106.2",
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.106.2.tgz",
"integrity": "sha512-xgKCSYuev1YarV+iVqr+zlfgSyremnJtn8T0NCT8L4XmMv1CLtESc0Q6kNp8+mKWdX/8ND0nzm7OMKx08kwNAw==",
"license": "MIT",
"dependencies": {
"iceberg-js": "^0.8.1",
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/supabase-js": {
"version": "2.106.2",
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.106.2.tgz",
"integrity": "sha512-2/RZ/1fmJx/MRSEDG2Xk8+J4JVk5clM9V0uSI6kUTrcS32KA89DtqI5RUOC9r6mzY3WBC9qexLjssIHjbLyVJA==",
"license": "MIT",
"dependencies": {
"@supabase/auth-js": "2.106.2",
"@supabase/functions-js": "2.106.2",
"@supabase/postgrest-js": "2.106.2",
"@supabase/realtime-js": "2.106.2",
"@supabase/storage-js": "2.106.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@tailwindcss/node": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz",
"integrity": "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/remapping": "^2.3.5",
"enhanced-resolve": "^5.21.0",
"jiti": "^2.6.1",
"lightningcss": "1.32.0",
"magic-string": "^0.30.21",
"source-map-js": "^1.2.1",
"tailwindcss": "4.3.0"
}
},
"node_modules/@tailwindcss/oxide": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.0.tgz",
"integrity": "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 20"
},
"optionalDependencies": {
"@tailwindcss/oxide-android-arm64": "4.3.0",
"@tailwindcss/oxide-darwin-arm64": "4.3.0",
"@tailwindcss/oxide-darwin-x64": "4.3.0",
"@tailwindcss/oxide-freebsd-x64": "4.3.0",
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0",
"@tailwindcss/oxide-linux-arm64-gnu": "4.3.0",
"@tailwindcss/oxide-linux-arm64-musl": "4.3.0",
"@tailwindcss/oxide-linux-x64-gnu": "4.3.0",
"@tailwindcss/oxide-linux-x64-musl": "4.3.0",
"@tailwindcss/oxide-wasm32-wasi": "4.3.0",
"@tailwindcss/oxide-win32-arm64-msvc": "4.3.0",
"@tailwindcss/oxide-win32-x64-msvc": "4.3.0"
}
},
"node_modules/@tailwindcss/oxide-android-arm64": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.0.tgz",
"integrity": "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-darwin-arm64": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.0.tgz",
"integrity": "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-darwin-x64": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.0.tgz",
"integrity": "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-freebsd-x64": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.0.tgz",
"integrity": "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.0.tgz",
"integrity": "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.0.tgz",
"integrity": "sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-musl": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.0.tgz",
"integrity": "sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.0.tgz",
"integrity": "sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-linux-x64-musl": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.0.tgz",
"integrity": "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.0.tgz",
"integrity": "sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==",
"bundleDependencies": [
"@napi-rs/wasm-runtime",
"@emnapi/core",
"@emnapi/runtime",
"@tybys/wasm-util",
"@emnapi/wasi-threads",
"tslib"
],
"cpu": [
"wasm32"
],
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.10.0",
"@emnapi/runtime": "^1.10.0",
"@emnapi/wasi-threads": "^1.2.1",
"@napi-rs/wasm-runtime": "^1.1.4",
"@tybys/wasm-util": "^0.10.1",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz",
"integrity": "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-win32-x64-msvc": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.0.tgz",
"integrity": "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/vite": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.0.tgz",
"integrity": "sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@tailwindcss/node": "4.3.0",
"@tailwindcss/oxide": "4.3.0",
"tailwindcss": "4.3.0"
},
"peerDependencies": {
"vite": "^5.2.0 || ^6 || ^7 || ^8"
}
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.2",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
"integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@types/howler": {
"version": "2.2.13",
"resolved": "https://registry.npmjs.org/@types/howler/-/howler-2.2.13.tgz",
"integrity": "sha512-40+EBjqIHHrC4VShlz/7i0lBUsE3QkgzZinQQji74Hd8sBkJZUBaT7LWFLK6rcabsDOOQpoMbEJvtaFQwxOu/g==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/react": {
"version": "19.2.15",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz",
"integrity": "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
}
},
"node_modules/@types/react-dom": {
"version": "19.2.3",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^19.2.0"
}
},
"node_modules/@vitejs/plugin-react": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.2.tgz",
"integrity": "sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@rolldown/pluginutils": "^1.0.0"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"peerDependencies": {
"@rolldown/plugin-babel": "^0.1.7 || ^0.2.0",
"babel-plugin-react-compiler": "^1.0.0",
"vite": "^8.0.0"
},
"peerDependenciesMeta": {
"@rolldown/plugin-babel": {
"optional": true
},
"babel-plugin-react-compiler": {
"optional": true
}
}
},
"node_modules/chess.js": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/chess.js/-/chess.js-1.4.0.tgz",
"integrity": "sha512-BBJgrrtKQOzFLonR0l+k64A98NLemPwNsCskwb+29bRwobUa4iTm51E1kwGPbWXAcfdDa18nad6vpPPKPWarqw==",
"license": "BSD-2-Clause"
},
"node_modules/cookie": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"devOptional": true,
"license": "MIT"
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=8"
}
},
"node_modules/enhanced-resolve": {
"version": "5.22.0",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.22.0.tgz",
"integrity": "sha512-xYcDWrpELkFzz9SpZ3PlI6Eu6eD93Yf0WLDRxikGhWJ3MAir2SNZTIVCVZqZ/NUyx8AdMc2gT9C0gPiw18kG+A==",
"dev": true,
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.4",
"tapable": "^2.3.3"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12.0.0"
},
"peerDependencies": {
"picomatch": "^3 || ^4"
},
"peerDependenciesMeta": {
"picomatch": {
"optional": true
}
}
},
"node_modules/framer-motion": {
"version": "12.40.0",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.40.0.tgz",
"integrity": "sha512-uaBd3qC1v3KQqBEjwTUd183K6PbS+j0yR9w9VmEOLWA/tnUcSn8Xa3uck7t4dgpDoUss8xQTcj8W2L07lrnLFg==",
"license": "MIT",
"dependencies": {
"motion-dom": "^12.40.0",
"motion-utils": "^12.39.0",
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"dev": true,
"license": "ISC"
},
"node_modules/howler": {
"version": "2.2.4",
"resolved": "https://registry.npmjs.org/howler/-/howler-2.2.4.tgz",
"integrity": "sha512-iARIBPgcQrwtEr+tALF+rapJ8qSc+Set2GJQl7xT1MQzWaVkFebdJhR3alVlSiUf5U7nAANKuj3aWpwerocD5w==",
"license": "MIT"
},
"node_modules/iceberg-js": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz",
"integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==",
"license": "MIT",
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/jiti": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
"integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
"dev": true,
"license": "MIT",
"bin": {
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/lightningcss": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
"dev": true,
"license": "MPL-2.0",
"dependencies": {
"detect-libc": "^2.0.3"
},
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
},
"optionalDependencies": {
"lightningcss-android-arm64": "1.32.0",
"lightningcss-darwin-arm64": "1.32.0",
"lightningcss-darwin-x64": "1.32.0",
"lightningcss-freebsd-x64": "1.32.0",
"lightningcss-linux-arm-gnueabihf": "1.32.0",
"lightningcss-linux-arm64-gnu": "1.32.0",
"lightningcss-linux-arm64-musl": "1.32.0",
"lightningcss-linux-x64-gnu": "1.32.0",
"lightningcss-linux-x64-musl": "1.32.0",
"lightningcss-win32-arm64-msvc": "1.32.0",
"lightningcss-win32-x64-msvc": "1.32.0"
}
},
"node_modules/lightningcss-android-arm64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
"integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-darwin-arm64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
"integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-darwin-x64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
"integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-freebsd-x64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
"integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm-gnueabihf": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
"integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm64-gnu": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
"integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm64-musl": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
"integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-x64-gnu": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
"integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-x64-musl": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
"integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-win32-arm64-msvc": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
"integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-win32-x64-msvc": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
"integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/motion-dom": {
"version": "12.40.0",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.40.0.tgz",
"integrity": "sha512-HxU3ZaBwNPVQUBQf1xxgq+7JrPNZvjLVxgbpEZL7RrWJnsxOf0/OM+yrHG9ogLQ31Do/r57Oz2gQWPK+6q62mg==",
"license": "MIT",
"dependencies": {
"motion-utils": "^12.39.0"
}
},
"node_modules/motion-utils": {
"version": "12.39.0",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.39.0.tgz",
"integrity": "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==",
"license": "MIT"
},
"node_modules/nanoid": {
"version": "3.3.12",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"dev": true,
"license": "ISC"
},
"node_modules/picomatch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/postcss": {
"version": "8.5.15",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
"dev": true,
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.12",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/react": {
"version": "19.2.6",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz",
"integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-dom": {
"version": "19.2.6",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz",
"integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==",
"license": "MIT",
"dependencies": {
"scheduler": "^0.27.0"
},
"peerDependencies": {
"react": "^19.2.6"
}
},
"node_modules/react-router": {
"version": "7.15.1",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.15.1.tgz",
"integrity": "sha512-R8rl9HhgikFYoPJymnUtPXWbnDb3oget6lQnfIoupbt61aT9aOhRkDsY2XRhZRyX1Z/8a5sL74fXmFNm3NRK5A==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
}
},
"node_modules/react-router-dom": {
"version": "7.15.1",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.15.1.tgz",
"integrity": "sha512-AzF62gjY6U9rkMq4RfP/r2EVtQ7DMfNMjyOp/flLTCrtRylLiK4wT4pSq6O8rOXZ2eXdZYJPEYe+ifomiv+Igg==",
"license": "MIT",
"dependencies": {
"react-router": "7.15.1"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
}
},
"node_modules/rolldown": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz",
"integrity": "sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@oxc-project/types": "=0.132.0",
"@rolldown/pluginutils": "^1.0.0"
},
"bin": {
"rolldown": "bin/cli.mjs"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
"@rolldown/binding-android-arm64": "1.0.2",
"@rolldown/binding-darwin-arm64": "1.0.2",
"@rolldown/binding-darwin-x64": "1.0.2",
"@rolldown/binding-freebsd-x64": "1.0.2",
"@rolldown/binding-linux-arm-gnueabihf": "1.0.2",
"@rolldown/binding-linux-arm64-gnu": "1.0.2",
"@rolldown/binding-linux-arm64-musl": "1.0.2",
"@rolldown/binding-linux-ppc64-gnu": "1.0.2",
"@rolldown/binding-linux-s390x-gnu": "1.0.2",
"@rolldown/binding-linux-x64-gnu": "1.0.2",
"@rolldown/binding-linux-x64-musl": "1.0.2",
"@rolldown/binding-openharmony-arm64": "1.0.2",
"@rolldown/binding-wasm32-wasi": "1.0.2",
"@rolldown/binding-win32-arm64-msvc": "1.0.2",
"@rolldown/binding-win32-x64-msvc": "1.0.2"
}
},
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"license": "MIT"
},
"node_modules/set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/tailwindcss": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz",
"integrity": "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==",
"dev": true,
"license": "MIT"
},
"node_modules/tapable": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz",
"integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/webpack"
}
},
"node_modules/tinyglobby": {
"version": "0.2.16",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
"integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
"dev": true,
"license": "MIT",
"dependencies": {
"fdir": "^6.5.0",
"picomatch": "^4.0.4"
},
"engines": {
"node": ">=12.0.0"
},
"funding": {
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/typescript": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/vite": {
"version": "8.0.14",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.14.tgz",
"integrity": "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==",
"dev": true,
"license": "MIT",
"dependencies": {
"lightningcss": "^1.32.0",
"picomatch": "^4.0.4",
"postcss": "^8.5.15",
"rolldown": "1.0.2",
"tinyglobby": "^0.2.16"
},
"bin": {
"vite": "bin/vite.js"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"funding": {
"url": "https://github.com/vitejs/vite?sponsor=1"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
},
"peerDependencies": {
"@types/node": "^20.19.0 || >=22.12.0",
"@vitejs/devtools": "^0.1.18",
"esbuild": "^0.27.0 || ^0.28.0",
"jiti": ">=1.21.0",
"less": "^4.0.0",
"sass": "^1.70.0",
"sass-embedded": "^1.70.0",
"stylus": ">=0.54.8",
"sugarss": "^5.0.0",
"terser": "^5.16.0",
"tsx": "^4.8.1",
"yaml": "^2.4.2"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
},
"@vitejs/devtools": {
"optional": true
},
"esbuild": {
"optional": true
},
"jiti": {
"optional": true
},
"less": {
"optional": true
},
"sass": {
"optional": true
},
"sass-embedded": {
"optional": true
},
"stylus": {
"optional": true
},
"sugarss": {
"optional": true
},
"terser": {
"optional": true
},
"tsx": {
"optional": true
},
"yaml": {
"optional": true
}
}
},
"node_modules/zustand": {
"version": "5.0.13",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.13.tgz",
"integrity": "sha512-efI2tVaVQPqtOh114loML/Z80Y4NP3yc+Ff0fYiZJPauNeWZeIp/bRFD7I9bfmCOYBh/PHxlglQ9+wvlwnPikQ==",
"license": "MIT",
"engines": {
"node": ">=12.20.0"
},
"peerDependencies": {
"@types/react": ">=18.0.0",
"immer": ">=9.0.6",
"react": ">=18.0.0",
"use-sync-external-store": ">=1.2.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
},
"use-sync-external-store": {
"optional": true
}
}
}
}
}
{
"name": "el3ab-player",
"private": true,
"version": "2.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@supabase/supabase-js": "^2.49.0",
"chess.js": "^1.0.0-beta.8",
"framer-motion": "^12.0.0",
"howler": "^2.2.4",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-router-dom": "^7.1.0",
"zustand": "^5.0.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"@types/howler": "^2.2.12",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"tailwindcss": "^4.0.0",
"typescript": "~6.0.2",
"vite": "^8.0.12"
}
}
<?php $pageTitle = 'EL3AB - 404'; ?>
<?php require __DIR__ . '/../includes/header.php'; ?>
<div class="text-center" style="padding:64px 20px;">
<svg class="icon-xl" style="color:var(--text-3);margin:0 auto 16px;width:48px;height:48px;">
<use href="/public/icons/sprite.svg#icon-search"></use>
</svg>
<h1 style="font-size:24px;font-weight:700;margin-bottom:8px;">404</h1>
<p class="text-muted" style="margin-bottom:24px;">الصفحة غير موجودة</p>
<a href="/" class="btn btn-cyan">الرئيسية</a>
</div>
<?php require __DIR__ . '/../includes/footer.php'; ?>
<?php $pageTitle = 'EL3AB'; ?>
<?php require __DIR__ . '/../includes/header.php'; ?>
<div class="text-center" style="padding:48px 20px;">
<p class="page-title">قريبا</p>
<p class="text-muted">هذه الصفحة قيد البناء</p>
</div>
<?php require __DIR__ . '/../includes/footer.php'; ?>
<?php $pageTitle = 'EL3AB'; ?>
<?php require __DIR__ . '/../includes/header.php'; ?>
<div class="text-center" style="padding:48px 20px;">
<p class="page-title">قريبا</p>
<p class="text-muted">هذه الصفحة قيد البناء</p>
</div>
<?php require __DIR__ . '/../includes/footer.php'; ?>
<?php $pageTitle = 'EL3AB'; ?>
<?php require __DIR__ . '/../includes/header.php'; ?>
<div class="text-center" style="padding:48px 20px;">
<p class="page-title">قريبا</p>
<p class="text-muted">هذه الصفحة قيد البناء</p>
</div>
<?php require __DIR__ . '/../includes/footer.php'; ?>
<?php $pageTitle = 'EL3AB'; ?>
<?php require __DIR__ . '/../includes/header.php'; ?>
<div class="text-center" style="padding:48px 20px;">
<p class="page-title">قريبا</p>
<p class="text-muted">هذه الصفحة قيد البناء</p>
</div>
<?php require __DIR__ . '/../includes/footer.php'; ?>
<?php $pageTitle = 'EL3AB - الرئيسية'; ?>
<?php require __DIR__ . '/../includes/header.php'; ?>
<div class="space-y-6" id="home-content">
<!-- Welcome -->
<div class="text-center">
<h2 style="font-size:22px;font-weight:700;" id="home-welcome">اهلا يا لاعب</h2>
<p class="text-muted text-sm" id="home-subtitle">المستوى 1</p>
</div>
<!-- Play Button -->
<a href="/play" class="btn btn-gold btn-block btn-lg">
<svg class="icon"><use href="/public/icons/sprite.svg#icon-play"></use></svg>
العب الان
</a>
<!-- Daily Reward -->
<div class="card">
<div class="card-body" style="display:flex;align-items:center;justify-content:space-between;">
<div style="display:flex;align-items:center;gap:12px;">
<svg class="icon-lg" style="color:var(--warning)"><use href="/public/icons/sprite.svg#icon-fire"></use></svg>
<div>
<p style="font-size:14px;font-weight:600;" id="home-streak">اليوم 0</p>
<p class="text-muted text-xs" id="home-streak-reward">+50 عملة</p>
</div>
</div>
<button class="btn btn-cyan btn-sm" id="home-claim-btn">اجمع</button>
</div>
</div>
<!-- Recent Games -->
<section>
<p class="section-title">اخر المباريات</p>
<div class="card" id="home-recent-games">
<div class="empty-state">لم تلعب اي مباراة بعد</div>
</div>
</section>
</div>
<script>
document.addEventListener('DOMContentLoaded', async () => {
if (!App.isLoggedIn()) {
window.location.href = '/login';
return;
}
const data = await App.fetch('/api/profile');
if (data && data.profile) {
const p = data.profile;
document.getElementById('home-welcome').textContent = 'اهلا يا ' + (p.display_name || p.username);
document.getElementById('home-subtitle').textContent = 'المستوى ' + (p.level || 1) + ' • ' + (p.elo_blitz || 1200) + ' بليتز';
document.getElementById('home-streak').textContent = 'اليوم ' + (p.daily_streak || 0);
document.getElementById('home-streak-reward').textContent = '+' + (50 + (p.daily_streak || 0) * 10) + ' عملة';
}
});
</script>
<?php require __DIR__ . '/../includes/footer.php'; ?>
<?php $pageTitle = 'EL3AB'; ?>
<?php require __DIR__ . '/../includes/header.php'; ?>
<div class="text-center" style="padding:48px 20px;">
<p class="page-title">قريبا</p>
<p class="text-muted">هذه الصفحة قيد البناء</p>
</div>
<?php require __DIR__ . '/../includes/footer.php'; ?>
<?php $pageTitle = 'EL3AB - تسجيل الدخول'; ?>
<!DOCTYPE html>
<html lang="ar" dir="rtl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="theme-color" content="#050D17">
<title><?= $pageTitle ?></title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans+Arabic:wght@400;500;600;700&family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/public/css/app.css">
</head>
<body>
<div class="app" style="justify-content:center;align-items:center;padding:20px;">
<div style="width:100%;max-width:380px;">
<div class="text-center mb-6">
<h1 style="font-family:var(--font-en);font-size:36px;font-weight:800;color:var(--gold);margin-bottom:8px;">EL3AB</h1>
<p style="color:var(--text-2);font-size:14px;">سجل دخولك للعب</p>
</div>
<form id="login-form" class="space-y-4">
<div class="input-group">
<label class="input-label">البريد الالكتروني</label>
<input type="email" class="input" id="login-email" placeholder="email@example.com" required dir="ltr">
</div>
<div class="input-group">
<label class="input-label">كلمة المرور</label>
<input type="password" class="input" id="login-password" placeholder="********" required dir="ltr">
</div>
<button type="submit" class="btn btn-gold btn-block btn-lg" id="login-btn">
تسجيل الدخول
</button>
</form>
<p class="text-center mt-4" style="font-size:13px;color:var(--text-3);">
ما عندك حساب؟ <a href="/register" style="color:var(--cyan);">سجل الان</a>
</p>
<div id="login-error" style="display:none;margin-top:16px;padding:12px;background:rgba(239,68,68,0.1);border:1px solid rgba(239,68,68,0.2);border-radius:var(--radius-md);color:var(--error);font-size:13px;text-align:center;"></div>
</div>
</div>
<script src="/public/js/app.js"></script>
<script>
document.getElementById('login-form').addEventListener('submit', async (e) => {
e.preventDefault();
const btn = document.getElementById('login-btn');
const errEl = document.getElementById('login-error');
errEl.style.display = 'none';
btn.disabled = true;
btn.textContent = 'جاري الدخول...';
const email = document.getElementById('login-email').value;
const password = document.getElementById('login-password').value;
try {
const res = await fetch('/api/auth', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'login', email, password })
});
const data = await res.json();
if (data.error) {
errEl.textContent = data.error;
errEl.style.display = 'block';
} else {
App.setAuth(data.access_token, data.user);
window.location.href = '/';
}
} catch (err) {
errEl.textContent = 'حدث خطا في الاتصال';
errEl.style.display = 'block';
}
btn.disabled = false;
btn.textContent = 'تسجيل الدخول';
});
if (App.isLoggedIn()) window.location.href = '/';
</script>
</body>
</html>
<?php $pageTitle = 'EL3AB'; ?>
<?php require __DIR__ . '/../includes/header.php'; ?>
<div class="text-center" style="padding:48px 20px;">
<p class="page-title">قريبا</p>
<p class="text-muted">هذه الصفحة قيد البناء</p>
</div>
<?php require __DIR__ . '/../includes/footer.php'; ?>
<?php $pageTitle = 'EL3AB'; ?>
<?php require __DIR__ . '/../includes/header.php'; ?>
<div class="text-center" style="padding:48px 20px;">
<p class="page-title">قريبا</p>
<p class="text-muted">هذه الصفحة قيد البناء</p>
</div>
<?php require __DIR__ . '/../includes/footer.php'; ?>
<?php $pageTitle = 'EL3AB'; ?>
<?php require __DIR__ . '/../includes/header.php'; ?>
<div class="text-center" style="padding:48px 20px;">
<p class="page-title">قريبا</p>
<p class="text-muted">هذه الصفحة قيد البناء</p>
</div>
<?php require __DIR__ . '/../includes/footer.php'; ?>
<?php $pageTitle = 'EL3AB'; ?>
<?php require __DIR__ . '/../includes/header.php'; ?>
<div class="text-center" style="padding:48px 20px;">
<p class="page-title">قريبا</p>
<p class="text-muted">هذه الصفحة قيد البناء</p>
</div>
<?php require __DIR__ . '/../includes/footer.php'; ?>
<?php $pageTitle = 'EL3AB'; ?>
<?php require __DIR__ . '/../includes/header.php'; ?>
<div class="text-center" style="padding:48px 20px;">
<p class="page-title">قريبا</p>
<p class="text-muted">هذه الصفحة قيد البناء</p>
</div>
<?php require __DIR__ . '/../includes/footer.php'; ?>
<?php $pageTitle = 'EL3AB - حساب جديد'; ?>
<!DOCTYPE html>
<html lang="ar" dir="rtl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="theme-color" content="#050D17">
<title><?= $pageTitle ?></title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans+Arabic:wght@400;500;600;700&family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/public/css/app.css">
</head>
<body>
<div class="app" style="justify-content:center;align-items:center;padding:20px;">
<div style="width:100%;max-width:380px;">
<div class="text-center mb-6">
<h1 style="font-family:var(--font-en);font-size:36px;font-weight:800;color:var(--gold);margin-bottom:8px;">EL3AB</h1>
<p style="color:var(--text-2);font-size:14px;">انشئ حسابك وابدا اللعب</p>
</div>
<form id="register-form" class="space-y-4">
<div class="input-group">
<label class="input-label">اسم المستخدم</label>
<input type="text" class="input" id="reg-username" placeholder="player123" required dir="ltr">
</div>
<div class="input-group">
<label class="input-label">البريد الالكتروني</label>
<input type="email" class="input" id="reg-email" placeholder="email@example.com" required dir="ltr">
</div>
<div class="input-group">
<label class="input-label">كلمة المرور</label>
<input type="password" class="input" id="reg-password" placeholder="6 احرف على الاقل" required dir="ltr" minlength="6">
</div>
<button type="submit" class="btn btn-gold btn-block btn-lg" id="reg-btn">
انشاء حساب
</button>
</form>
<p class="text-center mt-4" style="font-size:13px;color:var(--text-3);">
عندك حساب؟ <a href="/login" style="color:var(--cyan);">سجل دخول</a>
</p>
<div id="reg-error" style="display:none;margin-top:16px;padding:12px;background:rgba(239,68,68,0.1);border:1px solid rgba(239,68,68,0.2);border-radius:var(--radius-md);color:var(--error);font-size:13px;text-align:center;"></div>
</div>
</div>
<script src="/public/js/app.js"></script>
<script>
document.getElementById('register-form').addEventListener('submit', async (e) => {
e.preventDefault();
const btn = document.getElementById('reg-btn');
const errEl = document.getElementById('reg-error');
errEl.style.display = 'none';
btn.disabled = true;
btn.textContent = 'جاري التسجيل...';
const username = document.getElementById('reg-username').value;
const email = document.getElementById('reg-email').value;
const password = document.getElementById('reg-password').value;
try {
const res = await fetch('/api/auth', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'register', email, password, username })
});
const data = await res.json();
if (data.error) {
errEl.textContent = data.error;
errEl.style.display = 'block';
} else {
App.setAuth(data.access_token, data.user);
window.location.href = '/';
}
} catch (err) {
errEl.textContent = 'حدث خطا في الاتصال';
errEl.style.display = 'block';
}
btn.disabled = false;
btn.textContent = 'انشاء حساب';
});
if (App.isLoggedIn()) window.location.href = '/';
</script>
</body>
</html>
<?php $pageTitle = 'EL3AB'; ?>
<?php require __DIR__ . '/../includes/header.php'; ?>
<div class="text-center" style="padding:48px 20px;">
<p class="page-title">قريبا</p>
<p class="text-muted">هذه الصفحة قيد البناء</p>
</div>
<?php require __DIR__ . '/../includes/footer.php'; ?>
<?php $pageTitle = 'EL3AB'; ?>
<?php require __DIR__ . '/../includes/header.php'; ?>
<div class="text-center" style="padding:48px 20px;">
<p class="page-title">قريبا</p>
<p class="text-muted">هذه الصفحة قيد البناء</p>
</div>
<?php require __DIR__ . '/../includes/footer.php'; ?>
<?php $pageTitle = 'EL3AB'; ?>
<?php require __DIR__ . '/../includes/header.php'; ?>
<div class="text-center" style="padding:48px 20px;">
<p class="page-title">قريبا</p>
<p class="text-muted">هذه الصفحة قيد البناء</p>
</div>
<?php require __DIR__ . '/../includes/footer.php'; ?>
<?php $pageTitle = 'EL3AB'; ?>
<?php require __DIR__ . '/../includes/header.php'; ?>
<div class="text-center" style="padding:48px 20px;">
<p class="page-title">قريبا</p>
<p class="text-muted">هذه الصفحة قيد البناء</p>
</div>
<?php require __DIR__ . '/../includes/footer.php'; ?>
/* EL3AB Design System */
:root {
/* Background Layers */
--bg-0: #050D17;
--bg-1: #0A1525;
--bg-2: #142640;
--bg-3: #1C3254;
/* Brand */
--gold: #E7A832;
--gold-dark: #C48B1A;
--cyan: #15D7FF;
--cyan-dark: #0BA8C9;
--blue: #2979FF;
--purple: #7C4DFF;
/* Status */
--success: #34D399;
--error: #EF4444;
--warning: #F59E0B;
--online: #22C55E;
/* Text */
--text-1: #F1F5F9;
--text-2: #94A3B8;
--text-3: #64748B;
--text-inverse: #0F172A;
/* Border */
--border: rgba(255, 255, 255, 0.10);
--border-strong: rgba(255, 255, 255, 0.16);
/* Radius */
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
--radius-xl: 24px;
--radius-full: 9999px;
/* Shadows */
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 12px 40px rgba(0, 0, 0, 0.5);
/* Sizes */
--header-h: 56px;
--nav-bottom-h: 64px;
--nav-desktop-w: 72px;
--content-max: 600px;
--touch-min: 48px;
/* Fonts */
--font-ar: 'IBM Plex Sans Arabic', 'Segoe UI', sans-serif;
--font-en: 'Inter', 'Segoe UI', sans-serif;
/* Transitions */
--ease: cubic-bezier(0.4, 0, 0.2, 1);
}
/* Reset */
*, *::before, *::after {
margin: 0;
padding: 0;
box-sizing: border-box;
-webkit-tap-highlight-color: transparent;
}
html {
direction: rtl;
font-family: var(--font-ar);
font-size: 16px;
line-height: 1.5;
color: var(--text-1);
background: var(--bg-0);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
min-height: 100dvh;
overflow-x: hidden;
}
a {
color: inherit;
text-decoration: none;
}
button {
cursor: pointer;
border: none;
background: none;
font: inherit;
color: inherit;
}
input, select, textarea {
font: inherit;
color: inherit;
background: none;
border: none;
outline: none;
}
img {
display: block;
max-width: 100%;
}
/* Scrollbar */
::-webkit-scrollbar { width: 4px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: var(--radius-full); }
/* Latin text override */
.font-en { font-family: var(--font-en); direction: ltr; unicode-bidi: embed; }
/* Icon base */
.icon {
width: 20px;
height: 20px;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
flex-shrink: 0;
}
.icon-sm { width: 16px; height: 16px; }
.icon-lg { width: 24px; height: 24px; }
.icon-xl { width: 32px; height: 32px; }
.icon-fill { fill: currentColor; stroke: none; }
/* ==================== LAYOUT ==================== */
.app {
display: flex;
flex-direction: column;
min-height: 100dvh;
}
/* Header */
.header {
position: sticky;
top: 0;
z-index: 40;
height: var(--header-h);
background: var(--bg-1);
border-bottom: 1px solid var(--border);
backdrop-filter: blur(8px);
padding-top: env(safe-area-inset-top);
}
.header-inner {
display: flex;
align-items: center;
justify-content: space-between;
height: 100%;
max-width: calc(var(--content-max) + 40px);
margin: 0 auto;
padding: 0 20px;
}
.header-logo {
font-family: var(--font-en);
font-weight: 800;
font-size: 20px;
color: var(--gold);
letter-spacing: -0.5px;
}
.header-stats {
display: flex;
align-items: center;
gap: 16px;
}
.header-stat {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 500;
font-family: var(--font-en);
}
.header-bell {
position: relative;
padding: 8px;
margin: -8px;
color: var(--text-2);
}
.header-badge {
position: absolute;
top: 2px;
left: 2px;
width: 16px;
height: 16px;
background: var(--error);
border-radius: var(--radius-full);
font-size: 9px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
}
/* Main content */
.main {
flex: 1;
padding-bottom: calc(var(--nav-bottom-h) + env(safe-area-inset-bottom));
}
.main-inner {
width: 100%;
max-width: var(--content-max);
margin: 0 auto;
padding: 28px 20px 32px;
}
/* Desktop nav */
.nav-desktop {
display: none;
position: fixed;
left: 0;
top: 0;
bottom: 0;
width: var(--nav-desktop-w);
flex-direction: column;
align-items: center;
padding: 16px 0;
gap: 4px;
background: var(--bg-1);
border-right: 1px solid var(--border);
z-index: 50;
overflow-y: auto;
}
.nav-desktop-logo {
font-family: var(--font-en);
font-weight: 800;
font-size: 14px;
color: var(--gold);
margin-bottom: 16px;
}
.nav-desktop-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
border-radius: var(--radius-sm);
color: var(--text-3);
transition: color 0.2s var(--ease), background 0.2s var(--ease);
text-decoration: none;
}
.nav-desktop-item:hover { color: var(--text-2); }
.nav-desktop-item.active { background: rgba(21, 215, 255, 0.08); color: var(--cyan); }
.nav-desktop-label {
font-size: 9px;
margin-top: 2px;
}
/* Bottom nav (mobile) */
.nav-bottom {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 40;
height: var(--nav-bottom-h);
background: var(--bg-1);
border-top: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-around;
padding-bottom: env(safe-area-inset-bottom);
}
.nav-bottom-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
min-width: var(--touch-min);
min-height: var(--touch-min);
color: var(--text-3);
transition: color 0.2s var(--ease);
text-decoration: none;
}
.nav-bottom-item.active { color: var(--cyan); }
.nav-bottom-label {
font-size: 10px;
font-weight: 500;
}
/* Desktop responsive */
@media (min-width: 1024px) {
.nav-desktop { display: flex; }
.nav-bottom { display: none; }
.main { padding-bottom: 24px; margin-right: var(--nav-desktop-w); }
.header { margin-right: var(--nav-desktop-w); }
}
/* ==================== COMPONENTS ==================== */
/* Cards */
.card {
background: var(--bg-2);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
overflow: hidden;
}
.card-body { padding: 16px; }
.card-body-lg { padding: 20px; }
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: var(--touch-min);
padding: 12px 24px;
font-weight: 600;
font-size: 14px;
border-radius: var(--radius-md);
transition: transform 0.1s var(--ease), opacity 0.2s var(--ease);
text-decoration: none;
white-space: nowrap;
}
.btn:active { transform: scale(0.97); }
.btn:disabled { opacity: 0.5; pointer-events: none; }
.btn-gold { background: var(--gold); color: var(--text-inverse); }
.btn-cyan { background: var(--cyan); color: var(--text-inverse); }
.btn-outline { background: transparent; border: 1px solid var(--border); color: var(--text-2); }
.btn-error { background: rgba(239,68,68,0.1); border: 1px solid rgba(239,68,68,0.2); color: var(--error); }
.btn-ghost { background: transparent; color: var(--text-2); }
.btn-block { display: flex; width: 100%; }
.btn-lg { min-height: 56px; font-size: 16px; font-weight: 700; border-radius: var(--radius-lg); }
.btn-sm { min-height: 36px; padding: 8px 16px; font-size: 12px; }
/* Inputs */
.input-group { margin-bottom: 16px; }
.input-label {
display: block;
font-size: 13px;
font-weight: 500;
color: var(--text-2);
margin-bottom: 6px;
}
.input {
width: 100%;
height: var(--touch-min);
padding: 0 16px;
background: var(--bg-3);
border: 1px solid var(--border);
border-radius: var(--radius-md);
color: var(--text-1);
font-size: 14px;
transition: border-color 0.2s var(--ease);
}
.input:focus { border-color: var(--cyan); }
.input::placeholder { color: var(--text-3); }
/* Section headers */
.section-title {
font-size: 12px;
font-weight: 600;
color: var(--text-3);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 12px;
}
/* Page title */
.page-title {
font-size: 28px;
font-weight: 700;
text-align: center;
margin-bottom: 24px;
}
/* Tabs */
.tabs {
display: flex;
gap: 8px;
justify-content: center;
overflow-x: auto;
padding-bottom: 4px;
margin-bottom: 24px;
}
.tabs::-webkit-scrollbar { display: none; }
.tab {
padding: 8px 16px;
font-size: 13px;
font-weight: 500;
border-radius: var(--radius-md);
white-space: nowrap;
background: var(--bg-2);
border: 1px solid var(--border);
color: var(--text-2);
transition: all 0.2s var(--ease);
}
.tab.active { background: var(--cyan); border-color: var(--cyan); color: var(--text-inverse); }
/* List items */
.list-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border-bottom: 1px solid var(--border);
}
.list-item:last-child { border-bottom: none; }
/* Avatar */
.avatar {
width: 40px;
height: 40px;
border-radius: var(--radius-full);
background: var(--bg-3);
overflow: hidden;
flex-shrink: 0;
}
.avatar img { width: 100%; height: 100%; object-fit: cover; }
.avatar-sm { width: 32px; height: 32px; }
.avatar-lg { width: 64px; height: 64px; }
.avatar-xl { width: 96px; height: 96px; }
/* Badge */
.badge {
display: inline-flex;
align-items: center;
padding: 2px 8px;
font-size: 10px;
font-weight: 600;
border-radius: var(--radius-full);
}
.badge-gold { background: rgba(231,168,50,0.15); color: var(--gold); }
.badge-cyan { background: rgba(21,215,255,0.15); color: var(--cyan); }
.badge-error { background: rgba(239,68,68,0.15); color: var(--error); }
.badge-success { background: rgba(52,211,153,0.15); color: var(--success); }
/* Toast */
.toast-container {
position: fixed;
top: calc(var(--header-h) + 12px);
left: 50%;
transform: translateX(-50%);
z-index: 100;
display: flex;
flex-direction: column;
gap: 8px;
pointer-events: none;
}
.toast {
background: var(--bg-2);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 12px 20px;
font-size: 13px;
font-weight: 500;
box-shadow: var(--shadow-md);
animation: toast-in 0.3s var(--ease);
pointer-events: auto;
}
.toast-success { border-color: var(--success); }
.toast-error { border-color: var(--error); }
@keyframes toast-in {
from { opacity: 0; transform: translateY(-8px); }
to { opacity: 1; transform: translateY(0); }
}
/* Empty state */
.empty-state {
padding: 48px 24px;
text-align: center;
color: var(--text-3);
font-size: 14px;
}
/* Skeleton loader */
.skeleton {
background: var(--bg-2);
border-radius: var(--radius-md);
animation: skeleton-pulse 1.5s ease-in-out infinite;
}
@keyframes skeleton-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
/* Stat grid */
.stat-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.stat-card {
background: var(--bg-2);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 12px;
text-align: center;
}
.stat-value {
font-size: 18px;
font-weight: 700;
font-family: var(--font-en);
}
.stat-label {
font-size: 10px;
color: var(--text-3);
margin-top: 2px;
}
/* Toggle */
.toggle {
width: 44px;
height: 24px;
border-radius: var(--radius-full);
background: var(--bg-3);
position: relative;
cursor: pointer;
transition: background 0.2s var(--ease);
}
.toggle.active { background: var(--cyan); }
.toggle-knob {
position: absolute;
top: 2px;
right: 2px;
width: 20px;
height: 20px;
border-radius: var(--radius-full);
background: #fff;
transition: right 0.2s var(--ease);
}
.toggle.active .toggle-knob { right: 22px; }
/* Spacing utilities */
.space-y-4 > * + * { margin-top: 16px; }
.space-y-6 > * + * { margin-top: 24px; }
.mb-2 { margin-bottom: 8px; }
.mb-4 { margin-bottom: 16px; }
.mb-6 { margin-bottom: 24px; }
.mt-4 { margin-top: 16px; }
.text-center { text-align: center; }
.text-gold { color: var(--gold); }
.text-cyan { color: var(--cyan); }
.text-error { color: var(--error); }
.text-success { color: var(--success); }
.text-muted { color: var(--text-3); }
.text-sm { font-size: 13px; }
.text-xs { font-size: 11px; }
.fw-bold { font-weight: 700; }
/* Reduced motion */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<rect width="32" height="32" rx="6" fill="#071120"/>
<text x="16" y="22" font-family="Inter, sans-serif" font-size="14" font-weight="800" fill="#E7A832" text-anchor="middle">E3</text>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" style="display:none">
<symbol id="icon-home" viewBox="0 0 24 24">
<path d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"/>
</symbol>
<symbol id="icon-play" viewBox="0 0 24 24">
<path d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"/>
<path d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</symbol>
<symbol id="icon-trophy" viewBox="0 0 24 24">
<path d="M8 21h8m-4-4v4M6 3h12M6 3a2 2 0 00-2 2v2a4 4 0 004 4h0M18 3a2 2 0 012 2v2a4 4 0 01-4 4h0m-6 0a4 4 0 004-4V3H8v4a4 4 0 004 4z"/>
</symbol>
<symbol id="icon-leaderboard" viewBox="0 0 24 24">
<path d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/>
</symbol>
<symbol id="icon-friends" viewBox="0 0 24 24">
<path d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/>
</symbol>
<symbol id="icon-shop" viewBox="0 0 24 24">
<path d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 100 4 2 2 0 000-4z"/>
</symbol>
<symbol id="icon-star" viewBox="0 0 24 24">
<path d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z"/>
</symbol>
<symbol id="icon-settings" viewBox="0 0 24 24">
<path d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
<path d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
</symbol>
<symbol id="icon-profile" viewBox="0 0 24 24">
<path d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/>
</symbol>
<symbol id="icon-bell" viewBox="0 0 24 24">
<path d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"/>
</symbol>
<symbol id="icon-coin" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="9"/>
<path d="M12 7v10M9 9.5h4.5a1.5 1.5 0 010 3H10.5a1.5 1.5 0 000 3H15"/>
</symbol>
<symbol id="icon-gem" viewBox="0 0 24 24">
<path d="M5 3l4 4-4 4m14-8l-4 4 4 4M12 3v4m0 10v4M3 12h4m10 0h4"/>
<path d="M12 8l4 4-4 4-4-4 4-4z"/>
</symbol>
<symbol id="icon-clock" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="9"/>
<path d="M12 7v5l3 3"/>
</symbol>
<symbol id="icon-flag" viewBox="0 0 24 24">
<path d="M3 21v-4m0 0V5a2 2 0 012-2h6.5l1 1H21l-3 6 3 6h-8.5l-1-1H5a2 2 0 00-2 2z"/>
</symbol>
<symbol id="icon-crown" viewBox="0 0 24 24">
<path d="M2 16l3-8 4 4 3-8 3 8 4-4 3 8H2zM4 20h16"/>
</symbol>
<symbol id="icon-check" viewBox="0 0 24 24">
<path d="M5 13l4 4L19 7"/>
</symbol>
<symbol id="icon-x" viewBox="0 0 24 24">
<path d="M6 18L18 6M6 6l12 12"/>
</symbol>
<symbol id="icon-arrow-left" viewBox="0 0 24 24">
<path d="M19 12H5m7-7l-7 7 7 7"/>
</symbol>
<symbol id="icon-arrow-right" viewBox="0 0 24 24">
<path d="M5 12h14m-7-7l7 7-7 7"/>
</symbol>
<symbol id="icon-search" viewBox="0 0 24 24">
<path d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</symbol>
<symbol id="icon-plus" viewBox="0 0 24 24">
<path d="M12 4v16m8-8H4"/>
</symbol>
<symbol id="icon-send" viewBox="0 0 24 24">
<path d="M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z"/>
</symbol>
<symbol id="icon-shield" viewBox="0 0 24 24">
<path d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"/>
</symbol>
<symbol id="icon-sword" viewBox="0 0 24 24">
<path d="M14.5 2L20 7.5 7.5 20 2 14.5 14.5 2zM2 22l4-4M16 8l-4 4"/>
</symbol>
<symbol id="icon-bot" viewBox="0 0 24 24">
<rect x="4" y="8" width="16" height="12" rx="2"/>
<path d="M9 13h.01M15 13h.01M12 3v5M8 8h8"/>
<circle cx="12" cy="3" r="1"/>
</symbol>
<symbol id="icon-board" viewBox="0 0 24 24">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<path d="M3 9h18M3 15h18M9 3v18M15 3v18"/>
</symbol>
<symbol id="icon-piece" viewBox="0 0 24 24">
<path d="M12 2a3 3 0 00-3 3c0 1.1.6 2.1 1.5 2.6V9H9a1 1 0 00-1 1v1H6v3h2v1a1 1 0 001 1h6a1 1 0 001-1v-1h2v-3h-2v-1a1 1 0 00-1-1h-1.5V7.6A3 3 0 0015 5a3 3 0 00-3-3zM7 19h10v2H7v-2z"/>
</symbol>
<symbol id="icon-org" viewBox="0 0 24 24">
<path d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0H5m14 0h2m-16 0H3m6-11h.01M12 10h.01M15 10h.01M9 14h.01M12 14h.01M15 14h.01"/>
</symbol>
<symbol id="icon-logout" viewBox="0 0 24 24">
<path d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/>
</symbol>
<symbol id="icon-edit" viewBox="0 0 24 24">
<path d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
</symbol>
<symbol id="icon-fire" viewBox="0 0 24 24">
<path d="M17.657 18.657A8 8 0 016.343 7.343S7 9 9 10c0-2 .5-5 2.986-7C14 5 16.09 5.777 17.656 7.343A7.975 7.975 0 0120 13a7.975 7.975 0 01-2.343 5.657z"/>
<path d="M9.879 16.121A3 3 0 1012.015 11L11 14H9c0 .768.293 1.536.879 2.121z"/>
</symbol>
</svg>
// EL3AB Global App JS
const App = {
token: localStorage.getItem('el3ab_token'),
user: JSON.parse(localStorage.getItem('el3ab_user') || 'null'),
async fetch(url, options = {}) {
const headers = {
'Content-Type': 'application/json',
...(options.headers || {}),
};
if (this.token) {
headers['Authorization'] = 'Bearer ' + this.token;
}
const res = await fetch(url, { ...options, headers });
if (res.status === 401) {
this.logout();
return null;
}
return res.json();
},
setAuth(token, user) {
this.token = token;
this.user = user;
localStorage.setItem('el3ab_token', token);
localStorage.setItem('el3ab_user', JSON.stringify(user));
},
logout() {
this.token = null;
this.user = null;
localStorage.removeItem('el3ab_token');
localStorage.removeItem('el3ab_user');
window.location.href = '/login';
},
isLoggedIn() {
return !!this.token;
},
toast(message, type = 'info') {
const container = document.getElementById('toast-container');
if (!container) return;
const el = document.createElement('div');
el.className = 'toast' + (type !== 'info' ? ' toast-' + type : '');
el.textContent = message;
container.appendChild(el);
setTimeout(() => {
el.style.opacity = '0';
el.style.transform = 'translateY(-8px)';
el.style.transition = 'all 0.3s';
setTimeout(() => el.remove(), 300);
}, 3000);
},
async loadProfile() {
if (!this.token) return;
const data = await this.fetch('/api/profile');
if (data && data.profile) {
const p = data.profile;
const coins = document.getElementById('header-coins');
const gems = document.getElementById('header-gems');
if (coins) coins.textContent = (p.coins || 0).toLocaleString();
if (gems) gems.textContent = (p.gems || 0).toLocaleString();
}
}
};
document.addEventListener('DOMContentLoaded', () => {
if (App.isLoggedIn()) {
App.loadProfile();
}
});
import { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { AppShell } from './components/layout/AppShell';
import { useAuth } from './hooks/useAuth';
import { HomePage } from './pages/HomePage';
import { PlayPage } from './pages/PlayPage';
import { LoginPage } from './pages/LoginPage';
import { RegisterPage } from './pages/RegisterPage';
import { NotFoundPage } from './pages/NotFoundPage';
const ProfilePage = lazy(() => import('./pages/ProfilePage').then(m => ({ default: m.ProfilePage })));
const TournamentsPage = lazy(() => import('./pages/TournamentsPage').then(m => ({ default: m.TournamentsPage })));
const TournamentDetailPage = lazy(() => import('./pages/TournamentDetailPage').then(m => ({ default: m.TournamentDetailPage })));
const FriendsPage = lazy(() => import('./pages/FriendsPage').then(m => ({ default: m.FriendsPage })));
const NotificationsPage = lazy(() => import('./pages/NotificationsPage').then(m => ({ default: m.NotificationsPage })));
const BotSelectPage = lazy(() => import('./pages/BotSelectPage').then(m => ({ default: m.BotSelectPage })));
const MatchmakingPage = lazy(() => import('./pages/MatchmakingPage').then(m => ({ default: m.MatchmakingPage })));
const GamePage = lazy(() => import('./pages/GamePage').then(m => ({ default: m.GamePage })));
const OrganizationsPage = lazy(() => import('./pages/OrganizationsPage').then(m => ({ default: m.OrganizationsPage })));
const OrgDetailPage = lazy(() => import('./pages/OrgDetailPage').then(m => ({ default: m.OrgDetailPage })));
const AchievementsPage = lazy(() => import('./pages/AchievementsPage').then(m => ({ default: m.AchievementsPage })));
const ShopPage = lazy(() => import('./pages/ShopPage').then(m => ({ default: m.ShopPage })));
const LeaderboardPage = lazy(() => import('./pages/LeaderboardPage').then(m => ({ default: m.LeaderboardPage })));
const SettingsPage = lazy(() => import('./pages/SettingsPage').then(m => ({ default: m.SettingsPage })));
function PageLoader() {
return (
<div className="min-h-[50dvh] flex items-center justify-center">
<span className="text-gold font-bold text-xl font-latin animate-pulse">EL3AB</span>
</div>
);
}
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { user, loading } = useAuth();
if (loading) {
return (
<div className="min-h-dvh flex items-center justify-center">
<span className="text-gold font-bold text-2xl font-latin animate-pulse">EL3AB</span>
</div>
);
}
if (!user) return <Navigate to="/login" replace />;
return <>{children}</>;
}
export default function App() {
return (
<BrowserRouter>
<Suspense fallback={<PageLoader />}>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
<Route path="/game/bot" element={
<ProtectedRoute><GamePage /></ProtectedRoute>
} />
<Route path="/matchmaking" element={
<ProtectedRoute><MatchmakingPage /></ProtectedRoute>
} />
<Route element={
<ProtectedRoute><AppShell /></ProtectedRoute>
}>
<Route path="/" element={<HomePage />} />
<Route path="/play" element={<PlayPage />} />
<Route path="/bots" element={<BotSelectPage />} />
<Route path="/tournaments" element={<TournamentsPage />} />
<Route path="/tournaments/:id" element={<TournamentDetailPage />} />
<Route path="/friends" element={<FriendsPage />} />
<Route path="/orgs" element={<OrganizationsPage />} />
<Route path="/orgs/:slug" element={<OrgDetailPage />} />
<Route path="/achievements" element={<AchievementsPage />} />
<Route path="/shop" element={<ShopPage />} />
<Route path="/leaderboard" element={<LeaderboardPage />} />
<Route path="/profile" element={<ProfilePage />} />
<Route path="/notifications" element={<NotificationsPage />} />
<Route path="/settings" element={<SettingsPage />} />
</Route>
<Route path="*" element={<NotFoundPage />} />
</Routes>
</Suspense>
</BrowserRouter>
);
}
import { useAds } from '../hooks/useAds';
interface AdBannerProps {
pageContext: string;
}
export function AdBanner({ pageContext }: AdBannerProps) {
const { creative, trackClick } = useAds(pageContext);
if (!creative) return null;
const handleClick = () => {
trackClick();
if (creative.cta_url) window.open(creative.cta_url, '_blank', 'noopener');
};
return (
<div className="w-full bg-bg-1 border border-white/6 rounded-md overflow-hidden">
{creative.image_url && (
<button onClick={handleClick} className="w-full block">
<img src={creative.image_url} alt={creative.title || ''} className="w-full h-auto" />
</button>
)}
{creative.cta_text && !creative.image_url && (
<button
onClick={handleClick}
className="w-full px-4 py-3 text-sm text-cyan font-medium text-center"
>
{creative.cta_text}
</button>
)}
</div>
);
}
import { useState } from 'react';
import { useReporting, type ReportReason } from '../hooks/useReporting';
const REASONS: { key: ReportReason; label: string }[] = [
{ key: 'cheating', label: 'غش' },
{ key: 'harassment', label: 'تحرش أو إساءة' },
{ key: 'inappropriate_name', label: 'اسم غير لائق' },
{ key: 'sandbagging', label: 'خسارة متعمدة' },
{ key: 'other', label: 'أخرى' },
];
interface ReportModalProps {
targetId: string;
targetName: string;
matchId?: string;
onClose: () => void;
}
export function ReportModal({ targetId, targetName, matchId, onClose }: ReportModalProps) {
const { reportPlayer, submitting } = useReporting();
const [reason, setReason] = useState<ReportReason | null>(null);
const [details, setDetails] = useState('');
const [submitted, setSubmitted] = useState(false);
const handleSubmit = async () => {
if (!reason) return;
const success = await reportPlayer(targetId, reason, details, matchId);
if (success) setSubmitted(true);
};
if (submitted) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-bg-0/85 p-5">
<div className="bg-bg-2 border border-white/12 rounded-lg p-6 text-center max-w-xs w-full">
<span className="text-3xl mb-3 block"></span>
<h3 className="text-lg font-bold mb-2">تم الإبلاغ</h3>
<p className="text-sm text-text-3 mb-5">سيتم مراجعة البلاغ من فريقنا</p>
<button onClick={onClose} className="w-full py-2.5 bg-cyan text-text-inverse rounded-md text-sm font-medium">
إغلاق
</button>
</div>
</div>
);
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-bg-0/85 p-5">
<div className="bg-bg-2 border border-white/12 rounded-lg p-6 max-w-xs w-full">
<h3 className="text-lg font-bold mb-1">إبلاغ عن لاعب</h3>
<p className="text-xs text-text-3 mb-4">{targetName}</p>
<div className="space-y-2 mb-4">
{REASONS.map((r) => (
<button
key={r.key}
onClick={() => setReason(r.key)}
className={`w-full text-start px-4 py-2.5 rounded-md text-sm border transition-colors ${
reason === r.key
? 'border-error/30 bg-error/10 text-error'
: 'border-white/6 bg-bg-1 text-text-2'
}`}
>
{r.label}
</button>
))}
</div>
<textarea
value={details}
onChange={(e) => setDetails(e.target.value)}
placeholder="تفاصيل إضافية (اختياري)..."
className="w-full h-20 px-3 py-2 bg-bg-1 border border-white/6 rounded-md text-sm text-text-1 placeholder:text-text-3 focus:outline-none focus:border-cyan resize-none mb-4"
/>
<div className="flex gap-3">
<button onClick={onClose} className="flex-1 py-2.5 bg-bg-1 border border-white/6 rounded-md text-sm">
إلغاء
</button>
<button
onClick={handleSubmit}
disabled={!reason || submitting}
className="flex-1 py-2.5 bg-error text-white rounded-md text-sm font-medium disabled:opacity-50"
>
{submitting ? '...' : 'إبلاغ'}
</button>
</div>
</div>
</div>
);
}
import { Outlet } from 'react-router-dom';
import { Header } from './Header';
import { BottomNav } from './BottomNav';
import { DesktopNav } from './DesktopNav';
import { usePresence } from '../../hooks/usePresence';
export function AppShell() {
usePresence();
return (
<div className="min-h-dvh">
<DesktopNav />
<div className="flex flex-col min-h-dvh" style={{ marginRight: 'var(--desktop-nav-width, 0px)' }}>
<Header />
<main className="flex-1 pb-[calc(var(--bottom-nav-height)+env(safe-area-inset-bottom))] lg:pb-6">
<div className="w-full max-w-[680px] mx-auto">
<Outlet />
</div>
</main>
<div className="lg:hidden">
<BottomNav />
</div>
</div>
</div>
);
}
import { NavLink } from 'react-router-dom';
const navItems = [
{ to: '/', label: 'الرئيسية', icon: 'home' },
{ to: '/play', label: 'العب', icon: 'play' },
{ to: '/tournaments', label: 'بطولات', icon: 'trophy' },
{ to: '/friends', label: 'اجتماعي', icon: 'users' },
{ to: '/profile', label: 'حسابي', icon: 'user' },
];
const icons: Record<string, React.ReactNode> = {
home: <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>,
play: <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="6" width="20" height="12" rx="2"/><path d="M12 12h.01"/><path d="M17 12h.01"/><path d="M7 12h.01"/></svg>,
trophy: <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M6 9H4.5a2.5 2.5 0 0 1 0-5H6"/><path d="M18 9h1.5a2.5 2.5 0 0 0 0-5H18"/><path d="M4 22h16"/><path d="M10 14.66V17c0 .55-.47.98-.97 1.21C7.85 18.75 7 20.24 7 22"/><path d="M14 14.66V17c0 .55.47.98.97 1.21C16.15 18.75 17 20.24 17 22"/><path d="M18 2H6v7a6 6 0 0 0 12 0V2Z"/></svg>,
users: <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>,
user: <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>,
};
export function BottomNav() {
return (
<nav className="fixed bottom-0 inset-x-0 z-50 bg-bg-1 border-t border-white/6"
style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}>
<div className="flex items-center justify-around h-[var(--bottom-nav-height)]">
{navItems.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.to === '/'}
className={({ isActive }) =>
`flex flex-col items-center justify-center gap-1 w-full h-full transition-colors duration-100 ${
isActive ? 'text-cyan' : 'text-text-3'
}`
}
>
{({ isActive }) => (
<>
{isActive && (
<span className="absolute top-1 w-5 h-0.5 rounded-full bg-cyan" />
)}
<span className="relative">{icons[item.icon]}</span>
<span className="text-[10px] font-medium">{item.label}</span>
</>
)}
</NavLink>
))}
</div>
</nav>
);
}
import { NavLink } from 'react-router-dom';
const navItems = [
{ to: '/', label: 'الرئيسية', icon: '⌂' },
{ to: '/play', label: 'العب', icon: '♟' },
{ to: '/tournaments', label: 'بطولات', icon: '🏆' },
{ to: '/leaderboard', label: 'متصدرون', icon: '📊' },
{ to: '/friends', label: 'اجتماعي', icon: '👥' },
{ to: '/orgs', label: 'أندية', icon: '🏢' },
{ to: '/shop', label: 'متجر', icon: '🛒' },
{ to: '/achievements', label: 'إنجازات', icon: '⭐' },
{ to: '/profile', label: 'حسابي', icon: '👤' },
{ to: '/settings', label: 'إعدادات', icon: '⚙️' },
];
export function DesktopNav() {
return (
<nav className="hidden lg:flex fixed right-0 top-0 bottom-0 w-[72px] flex-col items-center py-4 gap-1 bg-bg-1 border-l border-white/6 z-50 overflow-y-auto scrollbar-none">
<span className="text-gold font-bold text-sm font-latin mb-4">E3</span>
{navItems.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.to === '/'}
className={({ isActive }) =>
`flex flex-col items-center justify-center w-12 h-12 rounded-md text-xs transition-colors ${
isActive ? 'bg-cyan/10 text-cyan' : 'text-text-3 hover:text-text-2'
}`
}
>
<span className="text-lg mb-0.5">{item.icon}</span>
<span className="text-[9px]">{item.label}</span>
</NavLink>
))}
</nav>
);
}
import { Link } from 'react-router-dom';
interface HeaderProps {
coins?: number;
gems?: number;
level?: number;
unreadCount?: number;
}
export function Header({ coins = 0, gems = 0, level = 1, unreadCount = 0 }: HeaderProps) {
return (
<header className="sticky top-0 z-40 bg-bg-1/95 backdrop-blur-sm border-b border-white/6"
style={{ paddingTop: 'env(safe-area-inset-top)', marginRight: 'var(--desktop-nav-width)' }}>
<div className="flex items-center justify-between h-[var(--header-height)] px-5 max-w-[680px] mx-auto">
<div className="flex items-center gap-3">
<Link to="/" className="text-gold font-bold text-lg font-latin tracking-tight">
EL3AB
</Link>
<span className="text-[12px] font-medium text-text-2 bg-bg-2 px-2 py-0.5 rounded-full">
Lv.{level}
</span>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-1.5 text-[13px] font-medium font-latin">
<span className="text-gold"></span>
<span className="text-text-1">{coins.toLocaleString()}</span>
</div>
<div className="flex items-center gap-1.5 text-[13px] font-medium font-latin">
<span className="text-purple-400"></span>
<span className="text-text-1">{gems.toLocaleString()}</span>
</div>
<Link to="/notifications" className="relative p-2 -m-2">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-text-2">
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/>
</svg>
{unreadCount > 0 && (
<span className="absolute top-0.5 right-0.5 w-4 h-4 bg-error rounded-full text-[9px] font-bold flex items-center justify-center text-white">
{unreadCount > 9 ? '9+' : unreadCount}
</span>
)}
</Link>
</div>
</div>
</header>
);
}
export const ENV = {
SUPABASE_URL: 'https://safe-supabase-kong.caprover.al-arcade.com',
SUPABASE_ANON_KEY: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzM1Njg5NjAwLCJleHAiOjE4OTM0NTYwMDB9.31PF6PvP-pSrvRuQwLFptQoejR0W1A7o53lZhEbnz84',
APP_URL: 'https://el3ab-player.caprover.al-arcade.com',
STOCKFISH_API: 'https://stockfishapi.caprover.al-arcade.com',
} as const;
import { useEffect, useState } from 'react';
import { supabase } from '../lib/supabase';
import { useAuthStore } from '../stores/authStore';
export interface Achievement {
id: string;
key: string;
name: string;
name_ar: string | null;
description: string | null;
description_ar: string | null;
icon_url: string | null;
tier: string;
reward_coins: number;
reward_gems: number;
reward_xp: number;
condition_type: string;
condition_value: number;
}
export interface PlayerAchievement {
achievement_id: string;
progress: number;
completed: boolean;
completed_at: string | null;
}
export function useAchievements() {
const { user } = useAuthStore();
const [achievements, setAchievements] = useState<Achievement[]>([]);
const [playerProgress, setPlayerProgress] = useState<Record<string, PlayerAchievement>>({});
const [loading, setLoading] = useState(true);
useEffect(() => {
const load = async () => {
const { data: allAchievements } = await supabase
.from('achievements')
.select('*')
.order('tier', { ascending: true });
setAchievements((allAchievements as Achievement[]) || []);
if (user) {
const { data: progress } = await supabase
.from('player_achievements')
.select('achievement_id, progress, completed, completed_at')
.eq('player_id', user.id);
if (progress) {
const map: Record<string, PlayerAchievement> = {};
progress.forEach((p: any) => { map[p.achievement_id] = p; });
setPlayerProgress(map);
}
}
setLoading(false);
};
load();
}, [user]);
const completed = achievements.filter((a) => playerProgress[a.id]?.completed);
const inProgress = achievements.filter((a) => !playerProgress[a.id]?.completed);
return { achievements, playerProgress, completed, inProgress, loading };
}
import { useEffect, useState } from 'react';
import { supabase } from '../lib/supabase';
export interface AdCreative {
id: string;
campaign_id: string;
image_url: string | null;
video_url: string | null;
cta_text: string | null;
cta_url: string | null;
title: string | null;
}
export interface AdSlot {
id: string;
slot_key: string;
page_context: string;
width: number;
height: number;
}
export function useAds(pageContext: string) {
const [creative, setCreative] = useState<AdCreative | null>(null);
const [slot, setSlot] = useState<AdSlot | null>(null);
useEffect(() => {
const load = async () => {
const { data: slotData } = await supabase
.from('ad_slots')
.select('id, slot_key, page_context, width, height')
.eq('page_context', pageContext)
.eq('is_active', true)
.limit(1)
.single();
if (!slotData) return;
setSlot(slotData as AdSlot);
const { data: campaignData } = await supabase
.from('ad_campaigns')
.select('id')
.eq('status', 'active')
.limit(1)
.single();
if (!campaignData) return;
const { data: creativeData } = await supabase
.from('ad_creatives')
.select('id, campaign_id, image_url, video_url, cta_text, cta_url, title')
.eq('campaign_id', campaignData.id)
.limit(1)
.single();
if (creativeData) setCreative(creativeData as AdCreative);
await supabase.from('ad_impressions').insert({
slot_id: slotData.id,
campaign_id: campaignData.id,
creative_id: creativeData?.id,
event_type: 'impression',
});
};
load();
}, [pageContext]);
const trackClick = async () => {
if (!creative || !slot) return;
await supabase.from('ad_impressions').insert({
slot_id: slot.id,
campaign_id: creative.campaign_id,
creative_id: creative.id,
event_type: 'click',
});
};
return { creative, slot, trackClick };
}
import { useEffect } from 'react';
import { supabase } from '../lib/supabase';
import { useAuthStore } from '../stores/authStore';
export function useAuth() {
const { user, session, loading, setAuth, setLoading } = useAuthStore();
useEffect(() => {
supabase.auth.getSession().then(({ data: { session } }) => {
setAuth(session?.user ?? null, session);
});
const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, session) => {
setAuth(session?.user ?? null, session);
});
return () => subscription.unsubscribe();
}, [setAuth]);
const signIn = async (email: string, password: string) => {
setLoading(true);
const { error } = await supabase.auth.signInWithPassword({ email, password });
setLoading(false);
return { error };
};
const signUp = async (email: string, password: string, metadata: Record<string, string>) => {
setLoading(true);
const { error } = await supabase.auth.signUp({
email,
password,
options: { data: metadata },
});
setLoading(false);
return { error };
};
const signOut = async () => {
await supabase.auth.signOut();
};
return { user, session, loading, signIn, signUp, signOut };
}
import { useEffect, useState } from 'react';
import { supabase } from '../lib/supabase';
import { useAuthStore } from '../stores/authStore';
export interface Charity {
id: string;
name: string;
name_ar: string | null;
description: string | null;
description_ar: string | null;
logo_url: string | null;
total_raised: number;
goal: number;
}
export function useCharity() {
const { user } = useAuthStore();
const [charities, setCharities] = useState<Charity[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const load = async () => {
const { data } = await supabase
.from('charities')
.select('*')
.eq('is_active', true)
.order('created_at', { ascending: false });
setCharities((data as Charity[]) || []);
setLoading(false);
};
load();
}, []);
const donate = async (charityId: string, amount: number) => {
if (!user) return false;
await supabase.from('charity_donations').insert({
charity_id: charityId,
player_id: user.id,
amount,
currency: 'coins',
});
await supabase.from('economy_transactions').insert({
player_id: user.id,
type: 'charity_donation',
amount: -amount,
currency: 'coins',
metadata: { charity_id: charityId },
});
return true;
};
return { charities, loading, donate };
}
import { useState } from 'react';
import { supabase } from '../lib/supabase';
import { useAuthStore } from '../stores/authStore';
export function useDailyReward() {
const { user } = useAuthStore();
const [claiming, setClaiming] = useState(false);
const canClaim = (lastReward: string | null) => {
if (!lastReward) return true;
const last = new Date(lastReward);
const now = new Date();
return now.getTime() - last.getTime() > 20 * 60 * 60 * 1000;
};
const claim = async (currentStreak: number) => {
if (!user) return null;
setClaiming(true);
const reward = 50 + currentStreak * 10;
const { error } = await supabase.from('profiles').update({
last_daily_reward: new Date().toISOString(),
daily_streak: currentStreak + 1,
coins: undefined,
}).eq('id', user.id);
if (!error) {
await supabase.from('economy_transactions').insert({
player_id: user.id,
type: 'daily_reward',
amount: reward,
currency: 'coins',
});
}
setClaiming(false);
return reward;
};
return { canClaim, claim, claiming };
}
import { useEffect, useState } from 'react';
import { supabase } from '../lib/supabase';
interface FeatureFlag {
id: string;
is_enabled: boolean;
rollout_percentage: number;
}
let cache: Map<string, boolean> | null = null;
export function useFeatureFlags() {
const [flags, setFlags] = useState<Map<string, boolean>>(cache || new Map());
const [loading, setLoading] = useState(!cache);
useEffect(() => {
if (cache) return;
supabase
.from('feature_flags')
.select('id, is_enabled, rollout_percentage')
.then(({ data }) => {
const map = new Map<string, boolean>();
(data as FeatureFlag[] || []).forEach((f) => {
const enabled = f.is_enabled && (f.rollout_percentage >= 100 || Math.random() * 100 < f.rollout_percentage);
map.set(f.id, enabled);
});
cache = map;
setFlags(map);
setLoading(false);
});
}, []);
const isEnabled = (flagId: string) => flags.get(flagId) ?? false;
return { isEnabled, loading };
}
import { useEffect, useState } from 'react';
import { supabase } from '../lib/supabase';
import { useAuthStore } from '../stores/authStore';
export interface Friend {
id: string;
username: string;
display_name: string;
avatar_url: string | null;
is_online: boolean;
elo_blitz: number;
friendship_id: string;
}
export interface FriendRequest {
id: string;
requester_id: string;
username: string;
display_name: string;
avatar_url: string | null;
}
export function useFriends() {
const { user } = useAuthStore();
const [friends, setFriends] = useState<Friend[]>([]);
const [requests, setRequests] = useState<FriendRequest[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!user) { setLoading(false); return; }
const loadFriends = async () => {
const { data: friendships } = await supabase
.from('friendships')
.select('id, requester_id, addressee_id, status')
.or(`requester_id.eq.${user.id},addressee_id.eq.${user.id}`);
if (!friendships) { setLoading(false); return; }
const accepted = friendships.filter((f) => f.status === 'accepted');
const pending = friendships.filter((f) => f.status === 'pending' && f.addressee_id === user.id);
const friendIds = accepted.map((f) =>
f.requester_id === user.id ? f.addressee_id : f.requester_id
);
const requesterIds = pending.map((f) => f.requester_id);
if (friendIds.length > 0) {
const { data: profiles } = await supabase
.from('profiles')
.select('id, username, display_name, avatar_url, is_online, elo_blitz')
.in('id', friendIds);
if (profiles) {
setFriends(profiles.map((p: any) => ({
...p,
friendship_id: accepted.find((f) =>
f.requester_id === p.id || f.addressee_id === p.id
)?.id || '',
})).sort((a, b) => (b.is_online ? 1 : 0) - (a.is_online ? 1 : 0)));
}
}
if (requesterIds.length > 0) {
const { data: profiles } = await supabase
.from('profiles')
.select('id, username, display_name, avatar_url')
.in('id', requesterIds);
if (profiles) {
setRequests(profiles.map((p: any) => ({
...p,
requester_id: p.id,
id: pending.find((f) => f.requester_id === p.id)?.id || '',
})));
}
}
setLoading(false);
};
loadFriends();
}, [user]);
const acceptRequest = async (friendshipId: string) => {
await supabase.from('friendships').update({ status: 'accepted' }).eq('id', friendshipId);
setRequests((r) => r.filter((req) => req.id !== friendshipId));
};
const rejectRequest = async (friendshipId: string) => {
await supabase.from('friendships').delete().eq('id', friendshipId);
setRequests((r) => r.filter((req) => req.id !== friendshipId));
};
const addFriend = async (targetId: string) => {
if (!user) return;
await supabase.from('friendships').insert({
requester_id: user.id,
addressee_id: targetId,
status: 'pending',
});
};
return { friends, requests, loading, acceptRequest, rejectRequest, addFriend };
}
import { useEffect, useState } from 'react';
import { supabase } from '../lib/supabase';
export interface GamePlugin {
game_key: string;
name: string;
name_ar: string;
description_ar: string | null;
icon_url: string | null;
is_enabled: boolean;
is_beta: boolean;
supports_ranked: boolean;
supports_bot: boolean;
supports_tournament: boolean;
supports_spectator: boolean;
default_time_controls: any;
sort_order: number;
}
export function useGamePlugins() {
const [plugins, setPlugins] = useState<GamePlugin[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
supabase
.from('game_plugins')
.select('*')
.order('sort_order')
.then(({ data }) => {
setPlugins((data as GamePlugin[]) || []);
setLoading(false);
});
}, []);
return { plugins, loading };
}
import { useEffect, useState } from 'react';
import { supabase } from '../lib/supabase';
import { useAuthStore } from '../stores/authStore';
export interface LeaderboardEntry {
id: string;
username: string;
display_name: string;
avatar_url: string | null;
country: string | null;
rating: number;
total_games_played: number;
total_wins: number;
}
export type TimeControl = 'bullet' | 'blitz' | 'rapid' | 'classical';
export function useLeaderboard(timeControl: TimeControl = 'blitz') {
const { user } = useAuthStore();
const [entries, setEntries] = useState<LeaderboardEntry[]>([]);
const [myRank, setMyRank] = useState<number | null>(null);
const [loading, setLoading] = useState(true);
const ratingColumn = `elo_${timeControl}` as const;
useEffect(() => {
const load = async () => {
setLoading(true);
const { data } = await supabase
.from('profiles')
.select(`id, username, display_name, avatar_url, country, ${ratingColumn}, total_games_played, total_wins`)
.gt(ratingColumn, 0)
.order(ratingColumn, { ascending: false })
.limit(100);
if (data) {
const mapped = data.map((p: any) => ({
id: p.id,
username: p.username,
display_name: p.display_name,
avatar_url: p.avatar_url,
country: p.country,
rating: p[ratingColumn],
total_games_played: p.total_games_played,
total_wins: p.total_wins,
}));
setEntries(mapped);
if (user) {
const idx = mapped.findIndex((e) => e.id === user.id);
setMyRank(idx >= 0 ? idx + 1 : null);
}
}
setLoading(false);
};
load();
}, [timeControl, user, ratingColumn]);
return { entries, myRank, loading };
}
import { useEffect, useState } from 'react';
import { supabase } from '../lib/supabase';
import { useAuthStore } from '../stores/authStore';
export interface Notification {
id: string;
type: string;
title: string;
title_ar: string | null;
body: string | null;
body_ar: string | null;
is_read: boolean;
created_at: string;
action_url: string | null;
metadata: Record<string, any> | null;
}
export function useNotifications() {
const { user } = useAuthStore();
const [notifications, setNotifications] = useState<Notification[]>([]);
const [unreadCount, setUnreadCount] = useState(0);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!user) { setLoading(false); return; }
const load = async () => {
const { data } = await supabase
.from('notifications')
.select('*')
.eq('player_id', user.id)
.order('created_at', { ascending: false })
.limit(50);
const items = (data as Notification[]) || [];
setNotifications(items);
setUnreadCount(items.filter((n) => !n.is_read).length);
setLoading(false);
};
load();
const channel = supabase
.channel('notifications')
.on('postgres_changes', {
event: 'INSERT',
schema: 'public',
table: 'notifications',
filter: `player_id=eq.${user.id}`,
}, (payload) => {
const newNotif = payload.new as Notification;
setNotifications((prev) => [newNotif, ...prev]);
setUnreadCount((c) => c + 1);
})
.subscribe();
return () => { supabase.removeChannel(channel); };
}, [user]);
const markRead = async (id: string) => {
await supabase.from('notifications').update({ is_read: true }).eq('id', id);
setNotifications((prev) => prev.map((n) => n.id === id ? { ...n, is_read: true } : n));
setUnreadCount((c) => Math.max(0, c - 1));
};
const markAllRead = async () => {
if (!user) return;
await supabase.from('notifications').update({ is_read: true }).eq('player_id', user.id).eq('is_read', false);
setNotifications((prev) => prev.map((n) => ({ ...n, is_read: true })));
setUnreadCount(0);
};
return { notifications, unreadCount, loading, markRead, markAllRead };
}
import { useEffect, useState } from 'react';
import { supabase } from '../lib/supabase';
export interface OrgDetail {
id: string;
name: string;
name_ar: string | null;
slug: string;
logo_url: string | null;
banner_url: string | null;
description: string | null;
description_ar: string | null;
country: string | null;
city: string | null;
member_count: number;
is_verified: boolean;
subscription_tier: string;
created_at: string;
}
export interface OrgMember {
id: string;
username: string;
display_name: string;
avatar_url: string | null;
role: string;
is_online: boolean;
}
export function useOrgDetail(orgId: string | null) {
const [org, setOrg] = useState<OrgDetail | null>(null);
const [members, setMembers] = useState<OrgMember[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!orgId) { setLoading(false); return; }
const load = async () => {
const { data: orgData } = await supabase
.from('el3ab_organizations')
.select('*')
.eq('id', orgId)
.single();
if (orgData) setOrg(orgData as OrgDetail);
const { data: memberData } = await supabase
.from('org_members')
.select('player_id, role')
.eq('org_id', orgId)
.limit(50);
if (memberData && memberData.length > 0) {
const ids = memberData.map((m: any) => m.player_id);
const { data: profiles } = await supabase
.from('profiles')
.select('id, username, display_name, avatar_url, is_online')
.in('id', ids);
if (profiles) {
setMembers(profiles.map((p: any) => ({
...p,
role: memberData.find((m: any) => m.player_id === p.id)?.role || 'member',
})));
}
}
setLoading(false);
};
load();
}, [orgId]);
return { org, members, loading };
}
import { useEffect, useState } from 'react';
import { supabase } from '../lib/supabase';
import { useAuthStore } from '../stores/authStore';
export interface Organization {
id: string;
name: string;
name_ar: string | null;
slug: string;
logo_url: string | null;
banner_url: string | null;
description: string | null;
description_ar: string | null;
country: string | null;
member_count: number;
is_verified: boolean;
subscription_tier: string;
}
export interface OrgMembership {
org_id: string;
role: string;
joined_at: string;
}
export function useOrganizations() {
const { user } = useAuthStore();
const [organizations, setOrganizations] = useState<Organization[]>([]);
const [myOrgs, setMyOrgs] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const load = async () => {
const { data } = await supabase
.from('el3ab_organizations')
.select('id, name, name_ar, slug, logo_url, banner_url, description, description_ar, country, member_count, is_verified, subscription_tier')
.order('member_count', { ascending: false })
.limit(50);
setOrganizations((data as Organization[]) || []);
if (user) {
const { data: memberships } = await supabase
.from('org_members')
.select('org_id')
.eq('player_id', user.id);
setMyOrgs((memberships || []).map((m: any) => m.org_id));
}
setLoading(false);
};
load();
}, [user]);
const joinOrg = async (orgId: string) => {
if (!user) return;
await supabase.from('org_membership_applications').insert({
org_id: orgId,
player_id: user.id,
status: 'pending',
});
};
return { organizations, myOrgs, loading, joinOrg };
}
import { useEffect } from 'react';
import { supabase } from '../lib/supabase';
import { useAuthStore } from '../stores/authStore';
export function usePresence() {
const { user } = useAuthStore();
useEffect(() => {
if (!user) return;
supabase.from('profiles').update({ is_online: true, last_seen_at: new Date().toISOString() }).eq('id', user.id).then(() => {});
const interval = setInterval(() => {
supabase.from('profiles').update({ last_seen_at: new Date().toISOString() }).eq('id', user.id).then(() => {});
}, 60000);
const handleVisibility = () => {
if (document.hidden) {
supabase.from('profiles').update({ is_online: false, last_seen_at: new Date().toISOString() }).eq('id', user.id).then(() => {});
} else {
supabase.from('profiles').update({ is_online: true, last_seen_at: new Date().toISOString() }).eq('id', user.id).then(() => {});
}
};
const handleBeforeUnload = () => {
supabase.from('profiles').update({ is_online: false }).eq('id', user.id).then(() => {});
};
document.addEventListener('visibilitychange', handleVisibility);
window.addEventListener('beforeunload', handleBeforeUnload);
return () => {
clearInterval(interval);
document.removeEventListener('visibilitychange', handleVisibility);
window.removeEventListener('beforeunload', handleBeforeUnload);
supabase.from('profiles').update({ is_online: false }).eq('id', user.id).then(() => {});
};
}, [user]);
}
import { useEffect, useState } from 'react';
import { supabase } from '../lib/supabase';
import { useAuthStore } from '../stores/authStore';
export interface Profile {
id: string;
username: string;
display_name: string;
display_name_ar: string | null;
avatar_url: string | null;
banner_url: string | null;
bio: string | null;
bio_ar: string | null;
country_code: string | null;
city: string | null;
preferred_language: string;
elo_bullet: number;
elo_blitz: number;
elo_rapid: number;
elo_classical: number;
fide_id: string | null;
fide_rating_standard: number | null;
fide_rating_rapid: number | null;
fide_rating_blitz: number | null;
fide_title: string | null;
xp: number;
level: number;
coins: number;
gems: number;
premium_currency: number;
is_online: boolean;
last_seen_at: string | null;
is_banned: boolean;
ban_reason: string | null;
ban_expires_at: string | null;
total_games_played: number;
total_wins: number;
total_draws: number;
total_losses: number;
total_tournaments_played: number;
total_tournaments_won: number;
win_streak: number;
best_win_streak: number;
last_daily_reward: string | null;
daily_streak: number;
avatar_frame_id: string | null;
avatar_border_color: string | null;
active_org_frame_id: string | null;
games_played: number;
current_game: string | null;
}
export function useProfile(userId?: string) {
const { user } = useAuthStore();
const [profile, setProfile] = useState<Profile | null>(null);
const [loading, setLoading] = useState(true);
const targetId = userId || user?.id;
useEffect(() => {
if (!targetId) { setLoading(false); return; }
supabase
.from('profiles')
.select('*')
.eq('id', targetId)
.single()
.then(({ data }) => {
setProfile(data as Profile | null);
setLoading(false);
});
}, [targetId]);
const updateProfile = async (updates: Partial<Profile>) => {
if (!targetId) return;
const { error } = await supabase.from('profiles').update(updates).eq('id', targetId);
if (!error) setProfile((p) => p ? { ...p, ...updates } : p);
return { error };
};
return { profile, loading, updateProfile };
}
import { useEffect, useState } from 'react';
import { supabase } from '../lib/supabase';
import { useAuthStore } from '../stores/authStore';
export interface RecentGame {
id: string;
opponent_name: string;
opponent_rating: number;
result: 'white_win' | 'black_win' | 'draw';
rating_change: number;
player_color: 'white' | 'black';
time_control: string;
completed_at: string;
}
export function useRecentGames(limit = 5) {
const { user } = useAuthStore();
const [games, setGames] = useState<RecentGame[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!user) { setLoading(false); return; }
supabase
.from('matches')
.select('id, white_player_id, black_player_id, result, time_control, rating_change_white, rating_change_black, white_rating_before, black_rating_before, completed_at')
.or(`white_player_id.eq.${user.id},black_player_id.eq.${user.id}`)
.eq('status', 'completed')
.order('completed_at', { ascending: false })
.limit(limit)
.then(({ data }) => {
if (data) {
setGames(data.map((m: any) => {
const isWhite = m.white_player_id === user.id;
return {
id: m.id,
opponent_name: 'خصم',
opponent_rating: isWhite ? m.black_rating_before : m.white_rating_before,
result: m.result,
rating_change: isWhite ? (m.rating_change_white || 0) : (m.rating_change_black || 0),
player_color: isWhite ? 'white' : 'black',
time_control: m.time_control || 'blitz',
completed_at: m.completed_at,
};
}));
}
setLoading(false);
});
}, [user, limit]);
return { games, loading };
}
import { useState } from 'react';
import { supabase } from '../lib/supabase';
import { useAuthStore } from '../stores/authStore';
export type ReportReason = 'cheating' | 'harassment' | 'inappropriate_name' | 'sandbagging' | 'other';
export function useReporting() {
const { user } = useAuthStore();
const [submitting, setSubmitting] = useState(false);
const reportPlayer = async (targetId: string, reason: ReportReason, details?: string, matchId?: string) => {
if (!user) return false;
setSubmitting(true);
const { error } = await supabase.from('cheat_reports').insert({
reporter_id: user.id,
reported_player_id: targetId,
reason,
details: details || null,
match_id: matchId || null,
status: 'pending',
});
setSubmitting(false);
return !error;
};
return { reportPlayer, submitting };
}
import { useEffect, useState } from 'react';
import { supabase } from '../lib/supabase';
import { useAuthStore } from '../stores/authStore';
export interface ShopItem {
id: string;
name: string;
name_ar: string | null;
description: string | null;
description_ar: string | null;
type: string;
rarity: string;
price_coins: number;
price_gems: number;
image_url: string | null;
preview_url: string | null;
is_limited: boolean;
available_until: string | null;
}
export function useShop() {
const { user } = useAuthStore();
const [items, setItems] = useState<ShopItem[]>([]);
const [owned, setOwned] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const load = async () => {
const { data } = await supabase
.from('cosmetics')
.select('id, name, name_ar, description, description_ar, type, rarity, price_coins, price_gems, image_url, preview_url, is_limited, available_until')
.order('rarity', { ascending: false });
setItems((data as ShopItem[]) || []);
if (user) {
const { data: playerCosmetics } = await supabase
.from('player_cosmetics')
.select('cosmetic_id')
.eq('player_id', user.id);
setOwned((playerCosmetics || []).map((c: any) => c.cosmetic_id));
}
setLoading(false);
};
load();
}, [user]);
const purchase = async (itemId: string, currency: 'coins' | 'gems') => {
if (!user) return false;
const item = items.find((i) => i.id === itemId);
if (!item) return false;
const price = currency === 'coins' ? item.price_coins : item.price_gems;
await supabase.from('economy_transactions').insert({
player_id: user.id,
type: 'shop_purchase',
amount: -price,
currency,
metadata: { cosmetic_id: itemId },
});
await supabase.from('player_cosmetics').insert({
player_id: user.id,
cosmetic_id: itemId,
acquired_at: new Date().toISOString(),
acquisition_type: 'purchase',
});
setOwned((prev) => [...prev, itemId]);
return true;
};
return { items, owned, loading, purchase };
}
import { useEffect, useState } from 'react';
import { supabase } from '../lib/supabase';
import { useAuthStore } from '../stores/authStore';
export interface TournamentDetail {
id: string;
name: string;
name_ar: string | null;
game_key: string;
format: string;
time_control: string;
status: string;
max_players: number;
prize_pool_coins: number;
prize_pool_gems: number;
entry_fee_coins: number;
entry_fee_gems: number;
starts_at: string | null;
registration_closes_at: string | null;
banner_url: string | null;
current_round: number;
rounds_total: number;
description: string | null;
description_ar: string | null;
}
export interface TournamentPlayer {
id: string;
username: string;
display_name: string;
avatar_url: string | null;
rating: number;
standing: number | null;
points: number;
}
export function useTournamentDetail(tournamentId: string | null) {
const { user } = useAuthStore();
const [tournament, setTournament] = useState<TournamentDetail | null>(null);
const [players, setPlayers] = useState<TournamentPlayer[]>([]);
const [isRegistered, setIsRegistered] = useState(false);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!tournamentId) { setLoading(false); return; }
const load = async () => {
const { data: tData } = await supabase
.from('el3ab_tournaments')
.select('*')
.eq('id', tournamentId)
.single();
if (tData) setTournament(tData as TournamentDetail);
const { data: regData } = await supabase
.from('tournament_registrations')
.select('player_id, seed, final_standing, points')
.eq('tournament_id', tournamentId)
.order('points', { ascending: false })
.limit(100);
if (regData && regData.length > 0) {
const ids = regData.map((r: any) => r.player_id);
const { data: profiles } = await supabase
.from('profiles')
.select('id, username, display_name, avatar_url, elo_blitz')
.in('id', ids);
if (profiles) {
setPlayers(profiles.map((p: any) => {
const reg = regData.find((r: any) => r.player_id === p.id);
return {
id: p.id,
username: p.username,
display_name: p.display_name,
avatar_url: p.avatar_url,
rating: p.elo_blitz,
standing: reg?.final_standing || null,
points: reg?.points || 0,
};
}));
}
if (user) {
setIsRegistered(ids.includes(user.id));
}
}
setLoading(false);
};
load();
}, [tournamentId, user]);
const register = async () => {
if (!user || !tournamentId) return false;
const { error } = await supabase.rpc('register_tournament_player', {
p_tournament_id: tournamentId,
p_player_id: user.id,
});
if (!error) setIsRegistered(true);
return !error;
};
const cancelRegistration = async () => {
if (!user || !tournamentId) return false;
const { error } = await supabase.rpc('cancel_tournament_registration', {
p_tournament_id: tournamentId,
p_player_id: user.id,
});
if (!error) setIsRegistered(false);
return !error;
};
return { tournament, players, isRegistered, loading, register, cancelRegistration };
}
import { useEffect, useState } from 'react';
import { supabase } from '../lib/supabase';
export interface Tournament {
id: string;
name: string;
name_ar: string | null;
game_key: string;
format: string;
time_control: string;
status: string;
max_players: number;
prize_pool_coins: number;
prize_pool_gems: number;
entry_fee_coins: number;
entry_fee_gems: number;
starts_at: string | null;
registration_closes_at: string | null;
banner_url: string | null;
current_round: number;
rounds_total: number;
slug: string | null;
}
export function useTournaments(status?: string) {
const [tournaments, setTournaments] = useState<Tournament[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
let query = supabase
.from('el3ab_tournaments')
.select('id, name, name_ar, game_key, format, time_control, status, max_players, prize_pool_coins, prize_pool_gems, entry_fee_coins, entry_fee_gems, starts_at, registration_closes_at, banner_url, current_round, rounds_total, slug')
.order('starts_at', { ascending: true });
if (status) query = query.eq('status', status);
query.then(({ data }) => {
setTournaments((data as Tournament[]) || []);
setLoading(false);
});
}, [status]);
return { tournaments, loading };
}
@import "tailwindcss";
@theme {
/* Background Layers */
--color-bg-0: #071120;
--color-bg-1: #0C1829;
--color-bg-2: #111F33;
--color-bg-3: #162638;
/* Brand */
--color-gold: #E7A832;
--color-cyan: #15D7FF;
--color-blue: #2979FF;
/* Status */
--color-success: #34D399;
--color-error: #EF4444;
--color-warning: #F59E0B;
--color-online: #22C55E;
/* Text */
--color-text-1: #F1F5F9;
--color-text-2: #94A3B8;
--color-text-3: #64748B;
--color-text-inverse: #0F172A;
/* Spacing */
--spacing-1: 4px;
--spacing-2: 8px;
--spacing-3: 12px;
--spacing-4: 16px;
--spacing-5: 20px;
--spacing-6: 24px;
--spacing-7: 32px;
--spacing-8: 48px;
--spacing-9: 64px;
/* Radius */
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
--radius-xl: 24px;
--radius-full: 9999px;
/* Shadows */
--shadow-1: 0 1px 3px rgba(0, 0, 0, 0.3), 0 1px 2px rgba(0, 0, 0, 0.2);
--shadow-2: 0 4px 12px rgba(0, 0, 0, 0.4), 0 2px 4px rgba(0, 0, 0, 0.3);
--shadow-3: 0 12px 40px rgba(0, 0, 0, 0.5), 0 4px 12px rgba(0, 0, 0, 0.3);
/* Fonts */
--font-arabic: 'IBM Plex Sans Arabic', 'Cairo', sans-serif;
--font-latin: 'Inter', sans-serif;
--font-mono: 'JetBrains Mono', monospace;
/* Sizes */
--header-height: 56px;
--bottom-nav-height: 64px;
--desktop-nav-width: 0px;
--touch-min: 48px;
--touch-comfortable: 56px;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
-webkit-tap-highlight-color: transparent;
}
html {
font-family: var(--font-arabic);
background-color: var(--color-bg-0);
color: var(--color-text-1);
font-size: 16px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
min-height: 100dvh;
overflow-x: hidden;
}
#root {
min-height: 100dvh;
display: flex;
flex-direction: column;
}
::-webkit-scrollbar {
width: 4px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1);
border-radius: var(--radius-full);
}
input, button, textarea, select {
font-family: inherit;
font-size: inherit;
}
[dir="rtl"] .font-latin,
[dir="rtl"] .font-mono {
direction: ltr;
unicode-bidi: embed;
}
@media (min-width: 1024px) {
:root {
--desktop-nav-width: 72px;
}
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
import { Howl } from 'howler';
const cache = new Map<string, Howl>();
function getSound(name: string): Howl | null {
if (cache.has(name)) return cache.get(name)!;
const sound = new Howl({
src: [`/sounds/${name}.mp3`],
volume: 0.5,
preload: true,
onloaderror: () => cache.delete(name),
});
cache.set(name, sound);
return sound;
}
export function playSound(name: string) {
try {
getSound(name)?.play();
} catch {
// silent fail
}
}
export function setSoundEnabled(enabled: boolean) {
Howler.mute(!enabled);
}
import { createClient } from '@supabase/supabase-js';
import { ENV } from '../env';
export const supabase = createClient(ENV.SUPABASE_URL, ENV.SUPABASE_ANON_KEY);
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
import { useAchievements } from '../hooks/useAchievements';
const TIER_COLORS: Record<string, string> = {
bronze: 'text-[#CD7F32]',
silver: 'text-[#C0C0C0]',
gold: 'text-gold',
diamond: 'text-cyan',
};
const TIER_BG: Record<string, string> = {
bronze: 'bg-[#CD7F32]/10',
silver: 'bg-[#C0C0C0]/10',
gold: 'bg-gold/10',
diamond: 'bg-cyan/10',
};
export function AchievementsPage() {
const { achievements, playerProgress, completed, inProgress, loading } = useAchievements();
if (loading) {
return (
<div className="px-5 pt-7 space-y-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="h-20 bg-bg-1 rounded-md animate-pulse" />
))}
</div>
);
}
return (
<div className="px-5 pt-7 pb-8">
<h1 className="text-[28px] font-bold mb-2">الإنجازات</h1>
<p className="text-sm text-text-3 mb-6">
{completed.length}/{achievements.length} مكتمل
</p>
{/* Progress bar */}
<div className="w-full h-2 bg-bg-2 rounded-full mb-6 overflow-hidden">
<div
className="h-full bg-gold rounded-full transition-all"
style={{ width: `${achievements.length ? (completed.length / achievements.length) * 100 : 0}%` }}
/>
</div>
{/* Completed */}
{completed.length > 0 && (
<section className="mb-6">
<h3 className="text-sm font-semibold text-text-2 mb-3">مكتملة ({completed.length})</h3>
<div className="space-y-2">
{completed.map((a) => (
<div key={a.id} className="bg-bg-1 border border-white/6 rounded-md p-4 flex items-center gap-3">
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${TIER_BG[a.tier] || 'bg-bg-2'}`}>
{a.icon_url ? (
<img src={a.icon_url} alt="" className="w-6 h-6" />
) : (
<span className={`text-lg ${TIER_COLORS[a.tier] || 'text-text-2'}`}></span>
)}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">{a.name_ar || a.name}</p>
<p className="text-xs text-text-3 truncate">{a.description_ar || a.description}</p>
</div>
<span className="text-xs text-success font-bold shrink-0"></span>
</div>
))}
</div>
</section>
)}
{/* In Progress */}
{inProgress.length > 0 && (
<section>
<h3 className="text-sm font-semibold text-text-2 mb-3">قيد التقدم ({inProgress.length})</h3>
<div className="space-y-2">
{inProgress.map((a) => {
const progress = playerProgress[a.id];
const pct = progress ? Math.min(100, (progress.progress / a.condition_value) * 100) : 0;
return (
<div key={a.id} className="bg-bg-1 border border-white/6 rounded-md p-4">
<div className="flex items-center gap-3 mb-2">
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${TIER_BG[a.tier] || 'bg-bg-2'}`}>
{a.icon_url ? (
<img src={a.icon_url} alt="" className="w-6 h-6 opacity-50" />
) : (
<span className={`text-lg opacity-50 ${TIER_COLORS[a.tier] || 'text-text-2'}`}></span>
)}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">{a.name_ar || a.name}</p>
<p className="text-xs text-text-3 truncate">{a.description_ar || a.description}</p>
</div>
<div className="text-xs text-text-3 shrink-0">
{a.reward_coins > 0 && <span>🪙{a.reward_coins}</span>}
{a.reward_xp > 0 && <span className="mr-1">{a.reward_xp}</span>}
</div>
</div>
<div className="w-full h-1.5 bg-bg-2 rounded-full overflow-hidden">
<div className="h-full bg-cyan/60 rounded-full transition-all" style={{ width: `${pct}%` }} />
</div>
<p className="text-[10px] text-text-3 mt-1 text-left font-latin">
{progress?.progress || 0}/{a.condition_value}
</p>
</div>
);
})}
</div>
</section>
)}
{achievements.length === 0 && (
<div className="bg-bg-1 border border-white/6 rounded-md p-6 text-center text-text-3 text-sm">
لا إنجازات متاحة
</div>
)}
</div>
);
}
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { ENV } from '../env';
interface Bot {
id: string;
name: string;
name_ar: string;
style_ar: string;
elo_min: number;
elo_max: number;
portrait_url: string;
}
export function BotSelectPage() {
const [bots, setBots] = useState<Bot[]>([]);
const [loading, setLoading] = useState(true);
const navigate = useNavigate();
useEffect(() => {
fetch(`${ENV.STOCKFISH_API}/api/chess/bots`)
.then((r) => r.json())
.then((data) => setBots(data.bots || []))
.catch(() => {})
.finally(() => setLoading(false));
}, []);
const startGame = (botId: string) => {
navigate(`/game/bot?bot=${botId}`);
};
if (loading) {
return (
<div className="px-5 pt-7">
<h1 className="text-[28px] font-bold mb-6">اختر خصمك</h1>
<div className="space-y-3">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="h-20 bg-bg-1 rounded-md animate-pulse" />
))}
</div>
</div>
);
}
return (
<div className="px-5 pt-7">
<h1 className="text-[28px] font-bold mb-6">اختر خصمك</h1>
<div className="space-y-3">
{bots.map((bot) => (
<button
key={bot.id}
onClick={() => startGame(bot.id)}
className="w-full flex items-center gap-4 bg-bg-1 border border-white/6 rounded-md p-4 text-right active:scale-[0.98] transition-transform"
>
<img
src={`${ENV.STOCKFISH_API}${bot.portrait_url}`}
alt={bot.name_ar}
className="w-14 h-14 rounded-full bg-bg-2 object-cover"
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
/>
<div className="flex-1">
<h3 className="font-bold text-base">{bot.name_ar}</h3>
<p className="text-sm text-text-2">{bot.style_ar}</p>
</div>
<div className="text-left">
<p className="text-sm font-bold font-latin text-cyan">
{bot.elo_min}-{bot.elo_max}
</p>
<p className="text-[10px] text-text-3">ELO</p>
</div>
</button>
))}
</div>
</div>
);
}
import { useState } from 'react';
import { useFriends } from '../hooks/useFriends';
import { supabase } from '../lib/supabase';
export function FriendsPage() {
const { friends, requests, loading, acceptRequest, rejectRequest, addFriend } = useFriends();
const [search, setSearch] = useState('');
const [searchResults, setSearchResults] = useState<any[]>([]);
const onlineFriends = friends.filter((f) => f.is_online);
const offlineFriends = friends.filter((f) => !f.is_online);
const handleSearch = async () => {
if (!search.trim()) return;
const { data } = await supabase
.from('profiles')
.select('id, username, display_name, avatar_url')
.ilike('username', `%${search}%`)
.limit(5);
setSearchResults(data || []);
};
if (loading) {
return (
<div className="px-5 pt-7 space-y-3">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="h-14 bg-bg-1 rounded-md animate-pulse" />
))}
</div>
);
}
return (
<div className="px-5 pt-7 pb-8">
<h1 className="text-[28px] font-bold mb-4">اجتماعي</h1>
{/* Search */}
<div className="flex gap-2 mb-6">
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
placeholder="ابحث عن لاعب..."
className="flex-1 h-12 px-4 bg-bg-1 border border-white/6 rounded-md text-text-1 placeholder:text-text-3 focus:outline-none focus:border-cyan"
/>
<button onClick={handleSearch} className="h-12 px-4 bg-cyan text-text-inverse rounded-md font-medium text-sm">
بحث
</button>
</div>
{/* Search Results */}
{searchResults.length > 0 && (
<div className="mb-6 bg-bg-1 border border-white/6 rounded-md overflow-hidden">
{searchResults.map((p) => (
<div key={p.id} className="flex items-center justify-between px-4 py-3 border-b border-white/6 last:border-0">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-full bg-bg-2" />
<div>
<p className="text-sm font-medium">{p.display_name || p.username}</p>
<p className="text-xs text-text-3">@{p.username}</p>
</div>
</div>
<button
onClick={() => addFriend(p.id)}
className="text-xs font-medium text-cyan border border-cyan/30 px-3 py-1.5 rounded-md"
>
أضف
</button>
</div>
))}
</div>
)}
{/* Pending Requests */}
{requests.length > 0 && (
<section className="mb-6">
<h3 className="text-sm font-semibold text-text-2 mb-3">طلبات معلّقة ({requests.length})</h3>
<div className="bg-bg-1 border border-white/6 rounded-md overflow-hidden">
{requests.map((req) => (
<div key={req.id} className="flex items-center justify-between px-4 py-3 border-b border-white/6 last:border-0">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-full bg-bg-2" />
<span className="text-sm font-medium">{req.display_name || req.username}</span>
</div>
<div className="flex gap-2">
<button onClick={() => acceptRequest(req.id)} className="w-8 h-8 flex items-center justify-center bg-success/20 text-success rounded-md text-sm"></button>
<button onClick={() => rejectRequest(req.id)} className="w-8 h-8 flex items-center justify-center bg-error/20 text-error rounded-md text-sm"></button>
</div>
</div>
))}
</div>
</section>
)}
{/* Online Friends */}
{onlineFriends.length > 0 && (
<section className="mb-6">
<h3 className="text-sm font-semibold text-text-2 mb-3">متصلون ({onlineFriends.length})</h3>
<div className="bg-bg-1 border border-white/6 rounded-md overflow-hidden">
{onlineFriends.map((f) => (
<div key={f.id} className="flex items-center justify-between px-4 py-3 border-b border-white/6 last:border-0">
<div className="flex items-center gap-3">
<div className="relative">
<div className="w-9 h-9 rounded-full bg-bg-2" />
<div className="absolute bottom-0 right-0 w-3 h-3 bg-online rounded-full border-2 border-bg-1" />
</div>
<div>
<p className="text-sm font-medium">{f.display_name}</p>
<p className="text-xs text-text-3 font-latin">{f.elo_blitz}</p>
</div>
</div>
<button className="text-xs font-medium text-cyan border border-cyan/30 px-3 py-1.5 rounded-md">
تحدّي
</button>
</div>
))}
</div>
</section>
)}
{/* Offline Friends */}
{offlineFriends.length > 0 && (
<section>
<h3 className="text-sm font-semibold text-text-2 mb-3">غير متصلين ({offlineFriends.length})</h3>
<div className="bg-bg-1 border border-white/6 rounded-md overflow-hidden">
{offlineFriends.map((f) => (
<div key={f.id} className="flex items-center gap-3 px-4 py-3 border-b border-white/6 last:border-0">
<div className="w-9 h-9 rounded-full bg-bg-2" />
<div>
<p className="text-sm font-medium">{f.display_name}</p>
<p className="text-xs text-text-3 font-latin">{f.elo_blitz}</p>
</div>
</div>
))}
</div>
</section>
)}
{friends.length === 0 && requests.length === 0 && (
<div className="bg-bg-1 border border-white/6 rounded-md p-6 text-center text-text-3 text-sm">
أضف أصدقاء للعب معهم
</div>
)}
</div>
);
}
import { useEffect, useState, useCallback, useRef } from 'react';
import { useSearchParams, useNavigate } from 'react-router-dom';
import { Chess } from 'chess.js';
import { ENV } from '../env';
import { supabase } from '../lib/supabase';
import { useAuthStore } from '../stores/authStore';
import { playSound } from '../lib/sounds';
const BOARD_COLORS = { light: '#E8D5B0', dark: '#8B6B47' };
const FILES = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'];
const RANKS = ['8', '7', '6', '5', '4', '3', '2', '1'];
const PIECE_UNICODE: Record<string, string> = {
wp: '♙', wn: '♘', wb: '♗', wr: '♖', wq: '♕', wk: '♔',
bp: '♟', bn: '♞', bb: '♝', br: '♜', bq: '♛', bk: '♚',
};
export function GamePage() {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const { user } = useAuthStore();
const botId = searchParams.get('bot') || 'amina';
const [game] = useState(() => new Chess());
const [fen, setFen] = useState(game.fen());
const [selected, setSelected] = useState<string | null>(null);
const [legalMoves, setLegalMoves] = useState<string[]>([]);
const [thinking, setThinking] = useState(false);
const [gameOver, setGameOver] = useState<{ result: string; reason: string } | null>(null);
const [moveHistory, setMoveHistory] = useState<string[]>([]);
const [whiteTime, setWhiteTime] = useState(300000);
const [blackTime, setBlackTime] = useState(300000);
const timerRef = useRef<number | null>(null);
const lastMoveRef = useRef<{ from: string; to: string } | null>(null);
// Timer
useEffect(() => {
if (gameOver) {
if (timerRef.current) cancelAnimationFrame(timerRef.current);
return;
}
let lastTick = Date.now();
const tick = () => {
const now = Date.now();
const delta = now - lastTick;
lastTick = now;
if (game.turn() === 'w') {
setWhiteTime((t) => Math.max(0, t - delta));
} else {
setBlackTime((t) => Math.max(0, t - delta));
}
timerRef.current = requestAnimationFrame(tick);
};
timerRef.current = requestAnimationFrame(tick);
return () => { if (timerRef.current) cancelAnimationFrame(timerRef.current); };
}, [fen, gameOver, game]);
// Flag detection
useEffect(() => {
if (whiteTime <= 0 && !gameOver) endGame('خسارة بالوقت', 'black_win');
if (blackTime <= 0 && !gameOver) endGame('فوز بالوقت!', 'white_win');
}, [whiteTime, blackTime, gameOver]);
const endGame = useCallback(async (reason: string, result: string) => {
setGameOver({ result, reason });
if (timerRef.current) cancelAnimationFrame(timerRef.current);
if (result.includes('win') && result.includes('white')) {
playSound('win');
} else {
playSound('lose');
}
// Save to DB
if (user) {
await supabase.from('matches').insert({
game_key: 'chess',
white_player_id: user.id,
black_player_id: null,
match_type: 'bot',
status: 'completed',
result,
time_control: 'blitz',
initial_time_ms: 300000,
increment_ms: 0,
white_time_remaining_ms: Math.max(0, whiteTime),
black_time_remaining_ms: Math.max(0, blackTime),
current_fen: game.fen(),
pgn: game.pgn(),
moves: game.history({ verbose: true }),
move_count: game.moveNumber(),
bot_id: botId,
bot_difficulty: botId,
is_rated: false,
started_at: new Date(Date.now() - 300000 + whiteTime).toISOString(),
completed_at: new Date().toISOString(),
});
}
}, [user, game, botId, whiteTime, blackTime]);
const checkGameOver = useCallback(() => {
if (game.isCheckmate()) {
const winner = game.turn() === 'w' ? 'black_win' : 'white_win';
endGame(winner === 'white_win' ? 'كش ملك - فوز!' : 'كش ملك - خسارة', winner);
} else if (game.isStalemate()) {
endGame('تعادل - لا حركات', 'draw');
} else if (game.isDraw()) {
endGame('تعادل', 'draw');
}
}, [game, endGame]);
const makeBotMove = useCallback(async () => {
if (game.isGameOver()) return;
setThinking(true);
try {
const res = await fetch(`${ENV.STOCKFISH_API}/api/chess/move`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fen: game.fen(), bot_id: botId }),
});
const data = await res.json();
if (data.best_move) {
const from = data.best_move.slice(0, 2);
const to = data.best_move.slice(2, 4);
const promotion = data.best_move[4] || undefined;
const move = game.move({ from, to, promotion });
if (move) {
lastMoveRef.current = { from, to };
setFen(game.fen());
setMoveHistory([...game.history()]);
playSound(move.captured ? 'capture' : 'move');
if (game.inCheck()) playSound('check');
checkGameOver();
}
}
} catch { /* silent */ }
setThinking(false);
}, [game, botId, checkGameOver]);
useEffect(() => {
if (game.turn() === 'b' && !game.isGameOver() && !gameOver) {
const timeout = setTimeout(makeBotMove, 400);
return () => clearTimeout(timeout);
}
}, [fen, game, makeBotMove, gameOver]);
const handleSquareClick = (square: string) => {
if (gameOver || thinking || game.turn() !== 'w') return;
if (selected) {
const move = game.move({ from: selected, to: square, promotion: 'q' });
if (move) {
lastMoveRef.current = { from: selected, to: square };
setFen(game.fen());
setSelected(null);
setLegalMoves([]);
setMoveHistory([...game.history()]);
playSound(move.captured ? 'capture' : 'move');
if (game.inCheck()) playSound('check');
checkGameOver();
return;
}
}
const piece = game.get(square as any);
if (piece && piece.color === 'w') {
setSelected(square);
const moves = game.moves({ square: square as any, verbose: true });
setLegalMoves(moves.map((m) => m.to));
} else {
setSelected(null);
setLegalMoves([]);
}
};
const resign = () => endGame('استسلام', 'black_win');
const formatTime = (ms: number) => {
const min = Math.floor(ms / 60000);
const sec = Math.floor((ms % 60000) / 1000);
return `${min}:${sec.toString().padStart(2, '0')}`;
};
const getPiece = (square: string) => {
const piece = game.get(square as any);
if (!piece) return null;
return PIECE_UNICODE[`${piece.color}${piece.type}`];
};
return (
<div className="min-h-dvh flex flex-col items-center justify-center bg-bg-0 px-1 py-2">
{/* Opponent bar */}
<div className="w-full max-w-[560px] px-2 mb-1 flex items-center justify-between h-14">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-full bg-bg-2" />
<div>
<span className="text-sm font-semibold block">{botId}</span>
{thinking && <span className="text-[10px] text-text-3 animate-pulse">يفكر...</span>}
</div>
</div>
<span className={`font-mono text-lg font-bold ${game.turn() === 'b' && !gameOver ? 'text-cyan' : 'text-text-2'} ${blackTime < 30000 ? 'text-error' : ''}`}>
{formatTime(blackTime)}
</span>
</div>
{/* Board */}
<div className="w-full max-w-[560px] aspect-square grid grid-cols-8 grid-rows-8 rounded-sm overflow-hidden shadow-2">
{RANKS.map((rank, ri) =>
FILES.map((file, fi) => {
const square = `${file}${rank}`;
const isLight = (ri + fi) % 2 === 0;
const isSelected = selected === square;
const isLegal = legalMoves.includes(square);
const isLastMove = lastMoveRef.current && (lastMoveRef.current.from === square || lastMoveRef.current.to === square);
const piece = getPiece(square);
return (
<button
key={square}
onClick={() => handleSquareClick(square)}
className="relative flex items-center justify-center"
style={{ backgroundColor: isLight ? BOARD_COLORS.light : BOARD_COLORS.dark }}
>
{isLastMove && <div className="absolute inset-0 bg-cyan/15" />}
{isSelected && <div className="absolute inset-0 bg-cyan/25" />}
{isLegal && !piece && <div className="absolute w-3 h-3 rounded-full bg-cyan/50" />}
{isLegal && piece && <div className="absolute inset-1 rounded-sm border-2 border-cyan/50" />}
{piece && (
<span className="relative text-[clamp(22px,4.5vw,40px)] leading-none select-none drop-shadow-sm">
{piece}
</span>
)}
</button>
);
})
)}
</div>
{/* Player bar */}
<div className="w-full max-w-[560px] px-2 mt-1 flex items-center justify-between h-14">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-full bg-bg-2 border border-cyan" />
<span className="text-sm font-semibold">أنت</span>
</div>
<span className={`font-mono text-lg font-bold ${game.turn() === 'w' && !gameOver ? 'text-cyan' : 'text-text-2'} ${whiteTime < 30000 ? 'text-error' : ''}`}>
{formatTime(whiteTime)}
</span>
</div>
{/* Move History */}
<div className="w-full max-w-[560px] px-2 mt-2 overflow-x-auto scrollbar-none">
<div className="flex gap-1 text-xs text-text-2 font-mono whitespace-nowrap">
{moveHistory.map((move, i) => (
<span key={i} className={`px-1.5 py-0.5 rounded ${i === moveHistory.length - 1 ? 'bg-bg-2 text-text-1' : ''}`}>
{i % 2 === 0 && <span className="text-text-3 mr-0.5">{Math.floor(i / 2) + 1}.</span>}
{move}
</span>
))}
</div>
</div>
{/* Action Buttons */}
{!gameOver && (
<div className="w-full max-w-[560px] px-2 mt-3 flex gap-2">
<button
onClick={resign}
className="flex-1 h-11 text-sm font-medium text-error border border-error/20 rounded-md active:scale-[0.97] transition-transform"
>
استسلام
</button>
</div>
)}
{/* Game Over */}
{gameOver && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-bg-0/85 p-5">
<div className="bg-bg-2 border border-white/12 rounded-lg p-8 text-center max-w-xs w-full">
<h2 className={`text-2xl font-bold mb-2 ${
gameOver.result === 'white_win' ? 'text-gold' : gameOver.result === 'draw' ? 'text-warning' : 'text-error'
}`}>
{gameOver.result === 'white_win' ? 'فوز!' : gameOver.result === 'draw' ? 'تعادل' : 'خسارة'}
</h2>
<p className="text-sm text-text-2 mb-6">{gameOver.reason}</p>
<button
onClick={() => window.location.reload()}
className="w-full py-3 bg-cyan text-text-inverse font-bold rounded-md mb-3 active:scale-[0.97] transition-transform"
>
العب مرة أخرى
</button>
<button
onClick={() => navigate('/play')}
className="w-full py-2.5 text-sm text-text-2 active:scale-[0.97] transition-transform"
>
العودة
</button>
</div>
</div>
)}
</div>
);
}
import { Link } from 'react-router-dom';
import { useProfile } from '../hooks/useProfile';
import { useRecentGames } from '../hooks/useRecentGames';
export function HomePage() {
const { profile } = useProfile();
const { games } = useRecentGames(5);
const displayName = profile?.display_name || profile?.username || 'لاعب';
const level = profile?.level || 1;
const blitz = profile?.elo_blitz || 1200;
return (
<div className="px-5 pt-7 pb-8">
{/* Welcome */}
<h2 className="text-[22px] font-bold mb-1">أهلاً يا {displayName}</h2>
<p className="text-text-2 text-sm mb-8">المستوى {level}{blitz} بليتز</p>
{/* Hero Play Button */}
<Link
to="/play"
className="block w-full py-4 text-center text-lg font-bold rounded-md bg-gold text-text-inverse shadow-2 active:scale-[0.97] transition-transform mb-8"
>
★ العب الآن ★
</Link>
{/* Daily Reward */}
{profile && (
<section className="mb-8">
<div className="bg-bg-1 border border-white/6 rounded-md p-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-xl">🔥</span>
<div>
<p className="text-sm font-semibold">اليوم {profile.daily_streak}</p>
<p className="text-xs text-text-3">+{50 + profile.daily_streak * 10} عملة</p>
</div>
</div>
<button className="px-4 py-2 text-xs font-bold bg-cyan text-text-inverse rounded-md active:scale-[0.97] transition-transform">
اجمع
</button>
</div>
</section>
)}
{/* Recent Games */}
<section>
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-text-2 uppercase tracking-wide">
آخر المباريات
</h3>
</div>
{games.length === 0 ? (
<div className="bg-bg-1 border border-white/6 rounded-md p-6 text-center text-text-3 text-sm">
لم تلعب أي مباراة بعد
</div>
) : (
<div className="bg-bg-1 border border-white/6 rounded-md overflow-hidden">
{games.map((game, i) => {
const isWin = (game.player_color === 'white' && game.result === 'white_win') ||
(game.player_color === 'black' && game.result === 'black_win');
const isDraw = game.result === 'draw';
return (
<div key={game.id} className={`flex items-center justify-between px-4 py-3 ${i > 0 ? 'border-t border-white/6' : ''}`}>
<div className="flex items-center gap-3">
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold ${
isWin ? 'bg-success/20 text-success' : isDraw ? 'bg-warning/20 text-warning' : 'bg-error/20 text-error'
}`}>
{isWin ? 'W' : isDraw ? 'D' : 'L'}
</div>
<div>
<p className="text-sm font-medium">{game.opponent_name}</p>
<p className="text-xs text-text-3">{game.time_control}</p>
</div>
</div>
<div className="text-left">
<p className={`text-sm font-bold font-latin ${
game.rating_change > 0 ? 'text-success' : game.rating_change < 0 ? 'text-error' : 'text-text-3'
}`}>
{game.rating_change > 0 ? '+' : ''}{game.rating_change}
</p>
</div>
</div>
);
})}
</div>
)}
</section>
</div>
);
}
import { useState } from 'react';
import { useLeaderboard, type TimeControl } from '../hooks/useLeaderboard';
const TIME_CONTROLS: { key: TimeControl; label: string }[] = [
{ key: 'bullet', label: 'بوليت' },
{ key: 'blitz', label: 'بليتز' },
{ key: 'rapid', label: 'سريع' },
{ key: 'classical', label: 'كلاسيك' },
];
export function LeaderboardPage() {
const [tc, setTc] = useState<TimeControl>('blitz');
const { entries, myRank, loading } = useLeaderboard(tc);
return (
<div className="px-5 pt-7 pb-8">
<h1 className="text-[28px] font-bold mb-4">المتصدرون</h1>
{/* Time Control Tabs */}
<div className="flex gap-2 mb-6 overflow-x-auto scrollbar-none">
{TIME_CONTROLS.map((t) => (
<button
key={t.key}
onClick={() => setTc(t.key)}
className={`px-4 py-2 text-sm font-medium rounded-md whitespace-nowrap transition-colors ${
tc === t.key
? 'bg-gold text-text-inverse'
: 'bg-bg-1 border border-white/6 text-text-2'
}`}
>
{t.label}
</button>
))}
</div>
{/* My Rank */}
{myRank && (
<div className="bg-cyan/10 border border-cyan/20 rounded-md px-4 py-3 mb-4 flex items-center justify-between">
<span className="text-sm text-cyan font-medium">ترتيبك</span>
<span className="text-lg font-bold text-cyan font-latin">#{myRank}</span>
</div>
)}
{/* Leaderboard */}
{loading ? (
<div className="space-y-2">
{Array.from({ length: 10 }).map((_, i) => (
<div key={i} className="h-14 bg-bg-1 rounded-md animate-pulse" />
))}
</div>
) : entries.length === 0 ? (
<div className="bg-bg-1 border border-white/6 rounded-md p-6 text-center text-text-3 text-sm">
لا بيانات متاحة
</div>
) : (
<div className="bg-bg-1 border border-white/6 rounded-md overflow-hidden">
{entries.map((entry, idx) => {
const rank = idx + 1;
const isTop3 = rank <= 3;
const medalColors = ['text-gold', 'text-[#C0C0C0]', 'text-[#CD7F32]'];
return (
<div
key={entry.id}
className={`flex items-center gap-3 px-4 py-3 border-b border-white/6 last:border-0 ${
isTop3 ? 'bg-white/[0.02]' : ''
}`}
>
{/* Rank */}
<div className="w-8 text-center shrink-0">
{isTop3 ? (
<span className={`text-lg font-bold ${medalColors[rank - 1]}`}>
{rank === 1 ? '🥇' : rank === 2 ? '🥈' : '🥉'}
</span>
) : (
<span className="text-sm text-text-3 font-latin font-medium">{rank}</span>
)}
</div>
{/* Avatar */}
<div className="w-9 h-9 rounded-full bg-bg-2 overflow-hidden shrink-0">
{entry.avatar_url && <img src={entry.avatar_url} alt="" className="w-full h-full object-cover" />}
</div>
{/* Info */}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{entry.display_name}</p>
<p className="text-[10px] text-text-3">
{entry.total_wins}W • {entry.total_games_played}G
{entry.country && ` • ${entry.country}`}
</p>
</div>
{/* Rating */}
<span className="text-sm font-bold font-latin text-gold shrink-0">{entry.rating}</span>
</div>
);
})}
</div>
)}
</div>
);
}
import { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useAuth } from '../hooks/useAuth';
export function LoginPage() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const { signIn, loading } = useAuth();
const navigate = useNavigate();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
const { error } = await signIn(email, password);
if (error) {
setError(error.message);
} else {
navigate('/');
}
};
return (
<div className="min-h-dvh flex flex-col items-center justify-center px-6 bg-bg-0">
<div className="w-full max-w-[340px]">
{/* Logo */}
<div className="text-center mb-10">
<h1 className="text-gold font-bold text-4xl font-latin tracking-tight mb-1">EL3AB</h1>
<p className="text-text-3 text-xs">العب • نافس • فُز</p>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<label className="block text-xs font-medium text-text-2 mb-1.5">البريد الإلكتروني</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full h-12 px-4 bg-white/[0.04] border border-white/[0.08] rounded-lg text-text-1 placeholder:text-text-3 focus:outline-none focus:border-cyan/50 focus:bg-white/[0.06] transition-all text-sm"
placeholder="email@example.com"
dir="ltr"
required
/>
</div>
<div>
<label className="block text-xs font-medium text-text-2 mb-1.5">كلمة المرور</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full h-12 px-4 bg-white/[0.04] border border-white/[0.08] rounded-lg text-text-1 placeholder:text-text-3 focus:outline-none focus:border-cyan/50 focus:bg-white/[0.06] transition-all text-sm"
placeholder="••••••••"
dir="ltr"
required
/>
</div>
{error && (
<p className="text-error text-xs bg-error/10 border border-error/20 rounded-lg px-3 py-2">{error}</p>
)}
<button
type="submit"
disabled={loading}
className="w-full h-12 bg-gradient-to-l from-cyan to-[#0EADD4] text-text-inverse font-bold rounded-lg active:scale-[0.97] transition-all disabled:opacity-50 shadow-[0_4px_16px_rgba(21,215,255,0.2)]"
>
{loading ? '...' : 'تسجيل الدخول'}
</button>
</form>
<p className="text-center text-sm text-text-3 mt-6">
ليس لديك حساب؟{' '}
<Link to="/register" className="text-cyan font-medium">
سجّل الآن
</Link>
</p>
</div>
</div>
);
}
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
export function MatchmakingPage() {
const [elapsed, setElapsed] = useState(0);
const navigate = useNavigate();
useEffect(() => {
const interval = setInterval(() => setElapsed((s) => s + 1), 1000);
return () => clearInterval(interval);
}, []);
const minutes = Math.floor(elapsed / 60);
const seconds = elapsed % 60;
return (
<div className="min-h-dvh flex flex-col items-center justify-center px-5">
<div className="w-20 h-20 rounded-full bg-bg-2 border-2 border-cyan mb-6 animate-pulse" />
<h2 className="text-xl font-bold mb-2">جاري البحث...</h2>
<p className="text-text-2 font-mono text-lg mb-2">
{minutes}:{seconds.toString().padStart(2, '0')}
</p>
<p className="text-sm text-text-3 mb-12">1150 - 1250 ELO</p>
<button
onClick={() => navigate('/play')}
className="px-8 py-3 text-sm font-medium text-text-2 border border-white/12 rounded-md active:scale-[0.97] transition-transform"
>
إلغاء
</button>
</div>
);
}
import { Link } from 'react-router-dom';
export function NotFoundPage() {
return (
<div className="min-h-dvh flex flex-col items-center justify-center px-5 text-center">
<p className="text-6xl mb-4"></p>
<h1 className="text-xl font-bold mb-2">الصفحة غير موجودة</h1>
<p className="text-text-2 text-sm mb-6">يبدو أنك ضللت الطريق</p>
<Link to="/" className="px-6 py-3 bg-cyan text-text-inverse font-bold rounded-md">
العودة للرئيسية
</Link>
</div>
);
}
import { useNotifications } from '../hooks/useNotifications';
import { useNavigate } from 'react-router-dom';
const TYPE_ICONS: Record<string, string> = {
friend_request: '👥',
match_invite: '⚔️',
tournament: '🏆',
achievement: '🏅',
reward: '🎁',
system: '📢',
};
function timeAgo(dateStr: string): string {
const diff = Date.now() - new Date(dateStr).getTime();
const min = Math.floor(diff / 60000);
if (min < 1) return 'الآن';
if (min < 60) return `${min}د`;
const hr = Math.floor(min / 60);
if (hr < 24) return `${hr}س`;
const days = Math.floor(hr / 24);
return `${days}ي`;
}
export function NotificationsPage() {
const { notifications, unreadCount, loading, markRead, markAllRead } = useNotifications();
const navigate = useNavigate();
const handleClick = (notif: typeof notifications[0]) => {
if (!notif.is_read) markRead(notif.id);
if (notif.action_url) navigate(notif.action_url);
};
if (loading) {
return (
<div className="px-5 pt-7 space-y-3">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="h-16 bg-bg-1 rounded-md animate-pulse" />
))}
</div>
);
}
return (
<div className="px-5 pt-7 pb-8">
<div className="flex items-center justify-between mb-4">
<h1 className="text-[28px] font-bold">الإشعارات</h1>
{unreadCount > 0 && (
<button onClick={markAllRead} className="text-xs text-cyan font-medium">
قراءة الكل
</button>
)}
</div>
{notifications.length === 0 ? (
<div className="bg-bg-1 border border-white/6 rounded-md p-6 text-center text-text-3 text-sm">
لا إشعارات جديدة
</div>
) : (
<div className="space-y-2">
{notifications.map((notif) => (
<button
key={notif.id}
onClick={() => handleClick(notif)}
className={`w-full text-start bg-bg-1 border rounded-md p-4 flex items-start gap-3 transition-colors ${
notif.is_read ? 'border-white/6' : 'border-cyan/20 bg-cyan/[0.03]'
}`}
>
<span className="text-xl shrink-0 mt-0.5">
{TYPE_ICONS[notif.type] || '🔔'}
</span>
<div className="flex-1 min-w-0">
<p className={`text-sm ${notif.is_read ? 'text-text-2' : 'text-text-1 font-medium'}`}>
{notif.title_ar || notif.title}
</p>
{(notif.body_ar || notif.body) && (
<p className="text-xs text-text-3 mt-0.5 truncate">{notif.body_ar || notif.body}</p>
)}
</div>
<span className="text-[10px] text-text-3 shrink-0">{timeAgo(notif.created_at)}</span>
{!notif.is_read && <div className="w-2 h-2 rounded-full bg-cyan shrink-0 mt-1.5" />}
</button>
))}
</div>
)}
</div>
);
}
import { useParams } from 'react-router-dom';
import { useOrgDetail } from '../hooks/useOrgDetail';
export function OrgDetailPage() {
const { slug } = useParams<{ slug: string }>();
const { org, members, loading } = useOrgDetail(slug || null);
if (loading) {
return (
<div className="px-5 pt-7 space-y-4">
<div className="h-40 bg-bg-1 rounded-md animate-pulse" />
<div className="h-20 bg-bg-1 rounded-md animate-pulse" />
</div>
);
}
if (!org) {
return (
<div className="px-5 pt-7 text-center text-text-3">
النادي غير موجود
</div>
);
}
const admins = members.filter((m) => m.role === 'owner' || m.role === 'admin');
const regularMembers = members.filter((m) => m.role !== 'owner' && m.role !== 'admin');
return (
<div className="pb-8">
{/* Banner */}
<div className="h-36 bg-bg-2 overflow-hidden">
{org.banner_url && <img src={org.banner_url} alt="" className="w-full h-full object-cover" />}
</div>
<div className="px-5 -mt-8">
{/* Logo + Name */}
<div className="flex items-end gap-3 mb-4">
<div className="w-16 h-16 rounded-xl bg-bg-1 border-2 border-bg-0 overflow-hidden shadow-1">
{org.logo_url && <img src={org.logo_url} alt="" className="w-full h-full object-cover" />}
</div>
<div className="pb-1">
<div className="flex items-center gap-2">
<h1 className="text-xl font-bold">{org.name_ar || org.name}</h1>
{org.is_verified && <span className="text-cyan"></span>}
</div>
<p className="text-xs text-text-3">{org.member_count} عضو • {org.country || 'عالمي'}</p>
</div>
</div>
{/* Description */}
{(org.description_ar || org.description) && (
<p className="text-sm text-text-2 mb-6 leading-relaxed">
{org.description_ar || org.description}
</p>
)}
{/* Admins */}
{admins.length > 0 && (
<section className="mb-6">
<h3 className="text-sm font-semibold text-text-2 mb-3">الإدارة</h3>
<div className="bg-bg-1 border border-white/6 rounded-md overflow-hidden">
{admins.map((m) => (
<div key={m.id} className="flex items-center gap-3 px-4 py-3 border-b border-white/6 last:border-0">
<div className="relative">
<div className="w-9 h-9 rounded-full bg-bg-2 overflow-hidden">
{m.avatar_url && <img src={m.avatar_url} alt="" className="w-full h-full object-cover" />}
</div>
{m.is_online && <div className="absolute bottom-0 right-0 w-2.5 h-2.5 bg-online rounded-full border-2 border-bg-1" />}
</div>
<div className="flex-1">
<p className="text-sm font-medium">{m.display_name}</p>
<p className="text-[10px] text-gold">{m.role === 'owner' ? 'مالك' : 'مدير'}</p>
</div>
</div>
))}
</div>
</section>
)}
{/* Members */}
{regularMembers.length > 0 && (
<section>
<h3 className="text-sm font-semibold text-text-2 mb-3">الأعضاء ({regularMembers.length})</h3>
<div className="bg-bg-1 border border-white/6 rounded-md overflow-hidden">
{regularMembers.map((m) => (
<div key={m.id} className="flex items-center gap-3 px-4 py-3 border-b border-white/6 last:border-0">
<div className="relative">
<div className="w-9 h-9 rounded-full bg-bg-2 overflow-hidden">
{m.avatar_url && <img src={m.avatar_url} alt="" className="w-full h-full object-cover" />}
</div>
{m.is_online && <div className="absolute bottom-0 right-0 w-2.5 h-2.5 bg-online rounded-full border-2 border-bg-1" />}
</div>
<p className="text-sm font-medium">{m.display_name}</p>
</div>
))}
</div>
</section>
)}
</div>
</div>
);
}
import { useOrganizations } from '../hooks/useOrganizations';
import { Link } from 'react-router-dom';
export function OrganizationsPage() {
const { organizations, myOrgs, loading, joinOrg } = useOrganizations();
if (loading) {
return (
<div className="px-5 pt-7 space-y-4">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="h-24 bg-bg-1 rounded-md animate-pulse" />
))}
</div>
);
}
return (
<div className="px-5 pt-7 pb-8">
<h1 className="text-[28px] font-bold mb-4">الأندية</h1>
{organizations.length === 0 ? (
<div className="bg-bg-1 border border-white/6 rounded-md p-6 text-center text-text-3 text-sm">
لا أندية متاحة حالياً
</div>
) : (
<div className="space-y-3">
{organizations.map((org) => {
const isMember = myOrgs.includes(org.id);
return (
<Link
key={org.id}
to={`/orgs/${org.slug}`}
className="block bg-bg-1 border border-white/6 rounded-md overflow-hidden"
>
{org.banner_url && (
<div className="h-20 bg-bg-2 overflow-hidden">
<img src={org.banner_url} alt="" className="w-full h-full object-cover" />
</div>
)}
<div className="p-4 flex items-center gap-3">
<div className="w-12 h-12 rounded-lg bg-bg-2 overflow-hidden shrink-0">
{org.logo_url && <img src={org.logo_url} alt="" className="w-full h-full object-cover" />}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="text-sm font-bold truncate">{org.name_ar || org.name}</h3>
{org.is_verified && <span className="text-cyan text-xs"></span>}
</div>
<p className="text-xs text-text-3 mt-0.5">
{org.member_count} عضو {org.country && `• ${org.country}`}
</p>
</div>
{isMember ? (
<span className="text-[10px] font-bold px-2 py-1 rounded bg-cyan/10 text-cyan shrink-0">عضو</span>
) : (
<button
onClick={(e) => { e.preventDefault(); joinOrg(org.id); }}
className="text-xs font-medium text-cyan border border-cyan/30 px-3 py-1.5 rounded-md shrink-0"
>
انضمام
</button>
)}
</div>
</Link>
);
})}
</div>
)}
</div>
);
}
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { useGamePlugins } from '../hooks/useGamePlugins';
import { useFeatureFlags } from '../hooks/useFeatureFlags';
const DEFAULT_TIME_CONTROLS = [
{ label: '1', sub: 'min', value: 60000 },
{ label: '3', sub: 'min', value: 180000 },
{ label: '5', sub: 'min', value: 300000 },
{ label: '10', sub: 'min', value: 600000 },
{ label: '15', sub: 'min', value: 900000 },
];
const INCREMENTS = [
{ label: '0', sub: 'sec', value: 0 },
{ label: '1', sub: 'sec', value: 1000 },
{ label: '2', sub: 'sec', value: 2000 },
{ label: '5', sub: 'sec', value: 5000 },
];
export function PlayPage() {
const { plugins, loading: pluginsLoading } = useGamePlugins();
const { isEnabled } = useFeatureFlags();
const [selectedGame, setSelectedGame] = useState('chess');
const [selectedTime, setSelectedTime] = useState(300000);
const [selectedIncrement, setSelectedIncrement] = useState(0);
const [isRated, setIsRated] = useState(true);
const matchmakingEnabled = isEnabled('matchmaking_enabled');
const botEnabled = isEnabled('bot_games_enabled');
const gameList = plugins.length > 0 ? plugins : [
{ game_key: 'chess', name_ar: 'شطرنج', is_enabled: true, is_beta: false, supports_bot: true, supports_ranked: true } as any
];
const activePlugin = gameList.find((g) => g.game_key === selectedGame);
const timeControls = activePlugin?.default_time_controls
? (activePlugin.default_time_controls as Array<{ label: string; ms: number }>).map((tc: any) => ({
label: String(tc.ms / 60000),
sub: 'min',
value: tc.ms,
}))
: DEFAULT_TIME_CONTROLS;
return (
<div className="px-5 pt-7 pb-8">
<h1 className="text-[28px] font-bold mb-6">اختر اللعبة</h1>
{/* Game Grid */}
<div className="grid grid-cols-2 gap-3 mb-8">
{pluginsLoading ? (
Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="h-24 bg-bg-1 rounded-md animate-pulse" />
))
) : (
gameList.map((game) => (
<button
key={game.game_key}
disabled={!game.is_enabled}
onClick={() => setSelectedGame(game.game_key)}
className={`relative bg-bg-1 rounded-md p-4 text-center transition-all ${
selectedGame === game.game_key
? 'border-2 border-cyan'
: 'border border-white/6'
} ${!game.is_enabled ? 'opacity-40' : 'active:scale-[0.97]'}`}
>
<div className="text-2xl mb-1">
{game.game_key === 'chess' ? '♟' : game.game_key === 'backgammon' ? '🎲' : '🎯'}
</div>
<span className="text-sm font-semibold">
{game.name_ar || game.game_key}
</span>
{!game.is_enabled && (
<span className="block text-[10px] text-text-3 mt-0.5">قريباً</span>
)}
{game.is_beta && game.is_enabled && (
<span className="absolute top-2 left-2 text-[9px] bg-warning/20 text-warning px-1.5 py-0.5 rounded">
تجريبي
</span>
)}
</button>
))
)}
</div>
{/* Time Controls */}
<h3 className="text-sm font-semibold text-text-2 uppercase tracking-wide mb-3">
إعدادات المباراة
</h3>
<div className="mb-4">
<p className="text-sm text-text-2 mb-2">الوقت:</p>
<div className="flex gap-2">
{timeControls.map((tc) => (
<button
key={tc.value}
onClick={() => setSelectedTime(tc.value)}
className={`flex-1 flex flex-col items-center justify-center h-11 rounded-md text-sm font-semibold transition-colors ${
selectedTime === tc.value
? 'bg-cyan text-text-inverse'
: 'bg-bg-1 border border-white/6 text-text-2'
}`}
>
<span>{tc.label}</span>
<span className="text-[10px] opacity-70">{tc.sub}</span>
</button>
))}
</div>
</div>
<div className="mb-4">
<p className="text-sm text-text-2 mb-2">الزيادة:</p>
<div className="flex gap-2">
{INCREMENTS.map((inc) => (
<button
key={inc.value}
onClick={() => setSelectedIncrement(inc.value)}
className={`flex-1 flex flex-col items-center justify-center h-11 rounded-md text-sm font-semibold transition-colors ${
selectedIncrement === inc.value
? 'bg-cyan text-text-inverse'
: 'bg-bg-1 border border-white/6 text-text-2'
}`}
>
<span>{inc.label}</span>
<span className="text-[10px] opacity-70">{inc.sub}</span>
</button>
))}
</div>
</div>
<div className="mb-8">
<p className="text-sm text-text-2 mb-2">النوع:</p>
<div className="flex gap-2">
<button
onClick={() => setIsRated(true)}
className={`flex-1 h-11 rounded-md text-sm font-semibold transition-colors ${
isRated ? 'bg-cyan text-text-inverse' : 'bg-bg-1 border border-white/6 text-text-2'
}`}
>
مُصنّف
</button>
<button
onClick={() => setIsRated(false)}
className={`flex-1 h-11 rounded-md text-sm font-semibold transition-colors ${
!isRated ? 'bg-cyan text-text-inverse' : 'bg-bg-1 border border-white/6 text-text-2'
}`}
>
ودّي
</button>
</div>
</div>
{/* Action Buttons */}
{(matchmakingEnabled || plugins.length === 0) && (
<Link
to={`/matchmaking?game=${selectedGame}&time=${selectedTime}&inc=${selectedIncrement}&rated=${isRated}`}
className="block w-full py-3.5 text-center font-bold rounded-md bg-cyan text-text-inverse mb-3 active:scale-[0.97] transition-transform"
>
ابحث عن خصم
</Link>
)}
{(botEnabled || plugins.length === 0) && activePlugin?.supports_bot !== false && (
<Link
to="/bots"
className="block w-full py-3 text-center font-semibold rounded-md bg-bg-1 border border-white/6 text-text-2 active:scale-[0.97] transition-transform"
>
العب ضد الكمبيوتر
</Link>
)}
</div>
);
}
import { useProfile } from '../hooks/useProfile';
import { useAuth } from '../hooks/useAuth';
export function ProfilePage() {
const { profile, loading } = useProfile();
const { signOut } = useAuth();
if (loading) {
return (
<div className="px-5 pt-7 space-y-4">
<div className="flex flex-col items-center gap-3">
<div className="w-24 h-24 rounded-full bg-bg-2 animate-pulse" />
<div className="w-32 h-5 bg-bg-2 rounded animate-pulse" />
<div className="w-20 h-4 bg-bg-2 rounded animate-pulse" />
</div>
</div>
);
}
if (!profile) return null;
const winRate = profile.total_games_played > 0
? Math.round((profile.total_wins / profile.total_games_played) * 100)
: 0;
const ratings = [
{ label: 'رصاص', value: profile.elo_bullet },
{ label: 'بليتز', value: profile.elo_blitz },
{ label: 'سريع', value: profile.elo_rapid },
{ label: 'كلاسيك', value: profile.elo_classical },
];
return (
<div className="px-5 pt-7 pb-8">
{/* Avatar + Info */}
<div className="flex flex-col items-center mb-6">
<div
className="w-24 h-24 rounded-full bg-bg-2 border-2 mb-3 overflow-hidden"
style={{ borderColor: profile.avatar_border_color || 'rgba(255,255,255,0.12)' }}
>
{profile.avatar_url && (
<img src={profile.avatar_url} alt="" className="w-full h-full object-cover" />
)}
</div>
<h2 className="text-xl font-bold">{profile.display_name || profile.username}</h2>
<p className="text-sm text-text-2">@{profile.username}</p>
{profile.country_code && (
<p className="text-xs text-text-3 mt-1">
{profile.city && `${profile.city}، `}{profile.country_code}
</p>
)}
{profile.fide_title && (
<span className="mt-1 text-xs font-bold text-gold bg-gold/10 px-2 py-0.5 rounded">
{profile.fide_title}
</span>
)}
</div>
{/* XP Bar */}
<div className="mb-6">
<div className="flex justify-between text-xs text-text-2 mb-1">
<span>المستوى {profile.level}</span>
<span>{profile.xp} XP</span>
</div>
<div className="h-2 bg-bg-2 rounded-full overflow-hidden">
<div
className="h-full bg-gold rounded-full transition-all"
style={{ width: `${Math.min((profile.xp % 1000) / 10, 100)}%` }}
/>
</div>
</div>
{/* Rating Cards */}
<div className="flex gap-2 overflow-x-auto pb-2 mb-6 scrollbar-none">
{ratings.map((r) => (
<div key={r.label} className="flex-shrink-0 w-20 bg-bg-1 border border-white/6 rounded-md p-3 text-center">
<p className="text-[10px] text-text-3 mb-1">{r.label}</p>
<p className="text-lg font-bold font-latin">{r.value}</p>
</div>
))}
</div>
{/* FIDE Ratings */}
{profile.fide_id && (
<div className="bg-bg-1 border border-white/6 rounded-md p-4 mb-6">
<p className="text-xs text-text-3 mb-2">FIDE #{profile.fide_id}</p>
<div className="flex justify-around text-center">
{profile.fide_rating_standard && (
<div>
<p className="text-sm font-bold font-latin">{profile.fide_rating_standard}</p>
<p className="text-[10px] text-text-3">Standard</p>
</div>
)}
{profile.fide_rating_rapid && (
<div>
<p className="text-sm font-bold font-latin">{profile.fide_rating_rapid}</p>
<p className="text-[10px] text-text-3">Rapid</p>
</div>
)}
{profile.fide_rating_blitz && (
<div>
<p className="text-sm font-bold font-latin">{profile.fide_rating_blitz}</p>
<p className="text-[10px] text-text-3">Blitz</p>
</div>
)}
</div>
</div>
)}
{/* Stats */}
<div className="bg-bg-1 border border-white/6 rounded-md p-4 flex justify-around text-center mb-6">
<div>
<p className="text-lg font-bold font-latin">{profile.total_games_played}</p>
<p className="text-[11px] text-text-3">مباراة</p>
</div>
<div>
<p className="text-lg font-bold font-latin">{winRate}%</p>
<p className="text-[11px] text-text-3">فوز</p>
</div>
<div>
<p className="text-lg font-bold font-latin">{profile.best_win_streak}</p>
<p className="text-[11px] text-text-3">أفضل سلسلة</p>
</div>
</div>
{/* Win/Draw/Loss */}
<div className="flex gap-2 mb-6">
<div className="flex-1 bg-success/10 border border-success/20 rounded-md p-3 text-center">
<p className="text-sm font-bold text-success font-latin">{profile.total_wins}</p>
<p className="text-[10px] text-text-3">فوز</p>
</div>
<div className="flex-1 bg-warning/10 border border-warning/20 rounded-md p-3 text-center">
<p className="text-sm font-bold text-warning font-latin">{profile.total_draws}</p>
<p className="text-[10px] text-text-3">تعادل</p>
</div>
<div className="flex-1 bg-error/10 border border-error/20 rounded-md p-3 text-center">
<p className="text-sm font-bold text-error font-latin">{profile.total_losses}</p>
<p className="text-[10px] text-text-3">خسارة</p>
</div>
</div>
{/* Economy */}
<div className="bg-bg-1 border border-white/6 rounded-md p-4 flex justify-around text-center mb-6">
<div>
<p className="text-lg font-bold font-latin text-gold">{profile.coins}</p>
<p className="text-[11px] text-text-3">عملات</p>
</div>
<div>
<p className="text-lg font-bold font-latin text-purple-400">{profile.gems}</p>
<p className="text-[11px] text-text-3">جواهر</p>
</div>
</div>
{/* Tournaments */}
{profile.total_tournaments_played > 0 && (
<div className="bg-bg-1 border border-white/6 rounded-md p-4 flex justify-around text-center mb-6">
<div>
<p className="text-sm font-bold font-latin">{profile.total_tournaments_played}</p>
<p className="text-[10px] text-text-3">بطولة</p>
</div>
<div>
<p className="text-sm font-bold font-latin text-gold">{profile.total_tournaments_won}</p>
<p className="text-[10px] text-text-3">فاز</p>
</div>
</div>
)}
{/* Bio */}
{(profile.bio || profile.bio_ar) && (
<div className="bg-bg-1 border border-white/6 rounded-md p-4 mb-6">
<p className="text-sm text-text-2">{profile.bio_ar || profile.bio}</p>
</div>
)}
{/* Daily streak */}
<div className="bg-bg-1 border border-white/6 rounded-md p-4 flex items-center justify-between mb-6">
<span className="text-sm text-text-2">سلسلة يومية</span>
<span className="text-sm font-bold font-latin">{profile.daily_streak} يوم</span>
</div>
{/* Logout */}
<button
onClick={signOut}
className="w-full py-3 text-center text-sm font-medium text-error border border-error/20 rounded-md active:scale-[0.97] transition-transform"
>
تسجيل الخروج
</button>
</div>
);
}
import { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useAuth } from '../hooks/useAuth';
export function RegisterPage() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [username, setUsername] = useState('');
const [displayName, setDisplayName] = useState('');
const [error, setError] = useState('');
const { signUp, loading } = useAuth();
const navigate = useNavigate();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
const { error } = await signUp(email, password, {
username,
display_name: displayName,
preferred_language: 'ar',
});
if (error) {
setError(error.message);
} else {
navigate('/');
}
};
return (
<div className="min-h-dvh flex flex-col items-center justify-center px-5 py-10">
<div className="w-full max-w-sm">
<div className="text-center mb-10">
<h1 className="text-gold font-bold text-3xl font-latin tracking-tight mb-2">EL3AB</h1>
<p className="text-text-2 text-sm">أنشئ حسابك وابدأ اللعب</p>
</div>
<div className="bg-bg-1 border border-white/6 rounded-lg p-6">
<h2 className="text-xl font-bold text-center mb-6">حساب جديد</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm text-text-2 mb-2">اسم المستخدم</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full h-12 px-4 bg-bg-3 border border-white/12 rounded-md text-text-1 placeholder:text-text-3 focus:outline-none focus:border-cyan transition-colors"
placeholder="username"
dir="ltr"
required
/>
</div>
<div>
<label className="block text-sm text-text-2 mb-2">الاسم المعروض</label>
<input
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
className="w-full h-12 px-4 bg-bg-3 border border-white/12 rounded-md text-text-1 placeholder:text-text-3 focus:outline-none focus:border-cyan transition-colors"
placeholder="اسمك الذي سيظهر للآخرين"
required
/>
</div>
<div>
<label className="block text-sm text-text-2 mb-2">البريد الإلكتروني</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full h-12 px-4 bg-bg-3 border border-white/12 rounded-md text-text-1 placeholder:text-text-3 focus:outline-none focus:border-cyan transition-colors"
placeholder="email@example.com"
dir="ltr"
required
/>
</div>
<div>
<label className="block text-sm text-text-2 mb-2">كلمة المرور</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full h-12 px-4 bg-bg-3 border border-white/12 rounded-md text-text-1 placeholder:text-text-3 focus:outline-none focus:border-cyan transition-colors"
placeholder="6 أحرف على الأقل"
dir="ltr"
minLength={6}
required
/>
</div>
{error && (
<p className="text-error text-sm">{error}</p>
)}
<button
type="submit"
disabled={loading}
className="w-full h-12 bg-cyan text-text-inverse font-bold rounded-md active:scale-[0.97] transition-transform disabled:opacity-50"
>
{loading ? '...' : 'إنشاء الحساب'}
</button>
</form>
<p className="text-center text-sm text-text-2 mt-4">
لديك حساب بالفعل؟{' '}
<Link to="/login" className="text-cyan font-medium">
سجّل دخولك
</Link>
</p>
</div>
</div>
</div>
);
}
import { useState } from 'react';
import { useAuthStore } from '../stores/authStore';
import { supabase } from '../lib/supabase';
import { useNavigate } from 'react-router-dom';
export function SettingsPage() {
const { user } = useAuthStore();
const navigate = useNavigate();
const [soundEnabled, setSoundEnabled] = useState(true);
const [notifEnabled, setNotifEnabled] = useState(true);
const [language, setLanguage] = useState('ar');
const [showConfirm, setShowConfirm] = useState(false);
const handleSignOut = async () => {
await supabase.auth.signOut();
navigate('/login');
};
return (
<div className="px-5 pt-7 pb-8">
<h1 className="text-[28px] font-bold mb-6">الإعدادات</h1>
{/* Account */}
<section className="mb-6">
<h3 className="text-sm font-semibold text-text-2 mb-3">الحساب</h3>
<div className="bg-bg-1 border border-white/6 rounded-md overflow-hidden">
<div className="px-4 py-3 border-b border-white/6 flex items-center justify-between">
<span className="text-sm">البريد الإلكتروني</span>
<span className="text-xs text-text-3 font-latin">{user?.email || '—'}</span>
</div>
<button
onClick={() => navigate('/profile')}
className="w-full px-4 py-3 border-b border-white/6 flex items-center justify-between text-start"
>
<span className="text-sm">تعديل الملف الشخصي</span>
<span className="text-text-3 text-xs"></span>
</button>
<button className="w-full px-4 py-3 flex items-center justify-between text-start">
<span className="text-sm">تغيير كلمة المرور</span>
<span className="text-text-3 text-xs"></span>
</button>
</div>
</section>
{/* Preferences */}
<section className="mb-6">
<h3 className="text-sm font-semibold text-text-2 mb-3">التفضيلات</h3>
<div className="bg-bg-1 border border-white/6 rounded-md overflow-hidden">
<div className="px-4 py-3 border-b border-white/6 flex items-center justify-between">
<span className="text-sm">الأصوات</span>
<button
onClick={() => setSoundEnabled(!soundEnabled)}
className={`w-11 h-6 rounded-full transition-colors relative ${soundEnabled ? 'bg-cyan' : 'bg-bg-2'}`}
>
<div className={`absolute top-0.5 w-5 h-5 rounded-full bg-white transition-all ${soundEnabled ? 'right-0.5' : 'right-[22px]'}`} />
</button>
</div>
<div className="px-4 py-3 border-b border-white/6 flex items-center justify-between">
<span className="text-sm">الإشعارات</span>
<button
onClick={() => setNotifEnabled(!notifEnabled)}
className={`w-11 h-6 rounded-full transition-colors relative ${notifEnabled ? 'bg-cyan' : 'bg-bg-2'}`}
>
<div className={`absolute top-0.5 w-5 h-5 rounded-full bg-white transition-all ${notifEnabled ? 'right-0.5' : 'right-[22px]'}`} />
</button>
</div>
<div className="px-4 py-3 flex items-center justify-between">
<span className="text-sm">اللغة</span>
<select
value={language}
onChange={(e) => setLanguage(e.target.value)}
className="bg-bg-2 text-sm text-text-1 border border-white/6 rounded px-2 py-1"
>
<option value="ar">العربية</option>
<option value="en">English</option>
</select>
</div>
</div>
</section>
{/* Game Settings */}
<section className="mb-6">
<h3 className="text-sm font-semibold text-text-2 mb-3">اللعبة</h3>
<div className="bg-bg-1 border border-white/6 rounded-md overflow-hidden">
<div className="px-4 py-3 border-b border-white/6 flex items-center justify-between">
<span className="text-sm">تأكيد الحركة</span>
<span className="text-xs text-text-3">قريباً</span>
</div>
<div className="px-4 py-3 flex items-center justify-between">
<span className="text-sm">رسوم الحركة</span>
<span className="text-xs text-text-3">قريباً</span>
</div>
</div>
</section>
{/* Danger Zone */}
<section className="mb-6">
<div className="space-y-3">
<button
onClick={() => setShowConfirm(true)}
className="w-full py-3 bg-error/10 border border-error/20 text-error text-sm font-medium rounded-md"
>
تسجيل الخروج
</button>
</div>
</section>
{/* About */}
<p className="text-center text-[10px] text-text-3 font-latin">EL3AB v2.0.0</p>
{/* Confirm Modal */}
{showConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-bg-0/85 p-5">
<div className="bg-bg-2 border border-white/12 rounded-lg p-6 text-center max-w-xs w-full">
<h3 className="text-lg font-bold mb-2">تسجيل الخروج؟</h3>
<p className="text-sm text-text-3 mb-5">هل أنت متأكد من تسجيل الخروج؟</p>
<div className="flex gap-3">
<button
onClick={() => setShowConfirm(false)}
className="flex-1 py-2.5 bg-bg-1 border border-white/6 rounded-md text-sm font-medium"
>
إلغاء
</button>
<button
onClick={handleSignOut}
className="flex-1 py-2.5 bg-error text-white rounded-md text-sm font-medium"
>
خروج
</button>
</div>
</div>
</div>
)}
</div>
);
}
import { useState } from 'react';
import { useShop } from '../hooks/useShop';
const RARITY_COLORS: Record<string, string> = {
common: 'border-white/10',
rare: 'border-blue/30',
epic: 'border-purple-500/30',
legendary: 'border-gold/30',
};
const RARITY_LABELS: Record<string, string> = {
common: 'عادي',
rare: 'نادر',
epic: 'ملحمي',
legendary: 'أسطوري',
};
const CATEGORIES = [
{ key: undefined, label: 'الكل' },
{ key: 'avatar', label: 'صور' },
{ key: 'frame', label: 'إطارات' },
{ key: 'board', label: 'رقع' },
{ key: 'piece', label: 'قطع' },
{ key: 'effect', label: 'مؤثرات' },
];
export function ShopPage() {
const { items, owned, loading, purchase } = useShop();
const [category, setCategory] = useState<string | undefined>(undefined);
const [purchasing, setPurchasing] = useState<string | null>(null);
const filtered = category ? items.filter((i) => i.type === category) : items;
const handlePurchase = async (itemId: string, currency: 'coins' | 'gems') => {
setPurchasing(itemId);
await purchase(itemId, currency);
setPurchasing(null);
};
if (loading) {
return (
<div className="px-5 pt-7">
<div className="grid grid-cols-2 gap-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="h-48 bg-bg-1 rounded-md animate-pulse" />
))}
</div>
</div>
);
}
return (
<div className="px-5 pt-7 pb-8">
<h1 className="text-[28px] font-bold mb-4">المتجر</h1>
{/* Category Tabs */}
<div className="flex gap-2 mb-6 overflow-x-auto scrollbar-none">
{CATEGORIES.map((cat) => (
<button
key={cat.key || 'all'}
onClick={() => setCategory(cat.key)}
className={`px-4 py-2 text-sm font-medium rounded-md whitespace-nowrap transition-colors ${
category === cat.key
? 'bg-gold text-text-inverse'
: 'bg-bg-1 border border-white/6 text-text-2'
}`}
>
{cat.label}
</button>
))}
</div>
{/* Items Grid */}
{filtered.length === 0 ? (
<div className="bg-bg-1 border border-white/6 rounded-md p-6 text-center text-text-3 text-sm">
لا منتجات في هذا القسم
</div>
) : (
<div className="grid grid-cols-2 gap-3">
{filtered.map((item) => {
const isOwned = owned.includes(item.id);
const isPurchasing = purchasing === item.id;
return (
<div
key={item.id}
className={`bg-bg-1 border rounded-md overflow-hidden ${RARITY_COLORS[item.rarity] || 'border-white/6'}`}
>
{/* Image */}
<div className="aspect-square bg-bg-2 flex items-center justify-center overflow-hidden">
{item.image_url ? (
<img src={item.image_url} alt="" className="w-full h-full object-cover" />
) : (
<span className="text-3xl opacity-30">🎨</span>
)}
</div>
<div className="p-3">
<p className="text-xs font-medium truncate">{item.name_ar || item.name}</p>
<p className="text-[10px] text-text-3 mt-0.5">
{RARITY_LABELS[item.rarity] || item.rarity}
{item.is_limited && ' • محدود'}
</p>
{isOwned ? (
<div className="mt-2 text-[10px] text-success font-bold text-center py-1.5 bg-success/10 rounded">
مملوك
</div>
) : (
<div className="mt-2 flex gap-1">
{item.price_coins > 0 && (
<button
onClick={() => handlePurchase(item.id, 'coins')}
disabled={isPurchasing}
className="flex-1 text-[10px] font-bold text-gold bg-gold/10 py-1.5 rounded disabled:opacity-50"
>
🪙 {item.price_coins}
</button>
)}
{item.price_gems > 0 && (
<button
onClick={() => handlePurchase(item.id, 'gems')}
disabled={isPurchasing}
className="flex-1 text-[10px] font-bold text-cyan bg-cyan/10 py-1.5 rounded disabled:opacity-50"
>
💎 {item.price_gems}
</button>
)}
</div>
)}
</div>
</div>
);
})}
</div>
)}
</div>
);
}
import { useParams } from 'react-router-dom';
import { useTournamentDetail } from '../hooks/useTournamentDetail';
import { useState } from 'react';
export function TournamentDetailPage() {
const { id } = useParams<{ id: string }>();
const { tournament, players, isRegistered, loading, register, cancelRegistration } = useTournamentDetail(id || null);
const [tab, setTab] = useState<'info' | 'players' | 'rounds'>('info');
const [registering, setRegistering] = useState(false);
const handleRegister = async () => {
setRegistering(true);
await register();
setRegistering(false);
};
const handleCancel = async () => {
setRegistering(true);
await cancelRegistration();
setRegistering(false);
};
if (loading) {
return (
<div className="px-5 pt-7 space-y-4">
<div className="h-40 bg-bg-1 rounded-md animate-pulse" />
<div className="h-60 bg-bg-1 rounded-md animate-pulse" />
</div>
);
}
if (!tournament) {
return <div className="px-5 pt-7 text-center text-text-3">البطولة غير موجودة</div>;
}
return (
<div className="pb-8">
{/* Banner */}
<div className="h-40 bg-bg-2 overflow-hidden">
{tournament.banner_url && <img src={tournament.banner_url} alt="" className="w-full h-full object-cover" />}
</div>
<div className="px-5 -mt-6">
{/* Header Card */}
<div className="bg-bg-1 border border-white/6 rounded-md p-4 mb-4">
<div className="flex items-start justify-between mb-2">
<h1 className="text-lg font-bold">{tournament.name_ar || tournament.name}</h1>
<span className={`text-[10px] font-bold px-2 py-0.5 rounded ${
tournament.status === 'active' ? 'bg-error/20 text-error' :
tournament.status === 'upcoming' ? 'bg-cyan/20 text-cyan' :
'bg-white/10 text-text-3'
}`}>
{tournament.status === 'active' ? 'مباشر' : tournament.status === 'upcoming' ? 'قادمة' : 'انتهت'}
</span>
</div>
<div className="flex flex-wrap gap-3 text-xs text-text-3">
<span>{tournament.game_key}</span>
<span>{tournament.format}</span>
<span>{tournament.time_control}</span>
<span>👥 {tournament.max_players}</span>
{tournament.rounds_total > 0 && <span>جولة {tournament.current_round}/{tournament.rounds_total}</span>}
</div>
{/* Prizes */}
{(tournament.prize_pool_coins > 0 || tournament.prize_pool_gems > 0) && (
<div className="mt-3 flex gap-3 text-sm">
{tournament.prize_pool_coins > 0 && <span className="text-gold font-bold">🪙 {tournament.prize_pool_coins}</span>}
{tournament.prize_pool_gems > 0 && <span className="text-cyan font-bold">💎 {tournament.prize_pool_gems}</span>}
</div>
)}
{/* Registration */}
{tournament.status === 'upcoming' && (
<div className="mt-4">
{isRegistered ? (
<button
onClick={handleCancel}
disabled={registering}
className="w-full py-2.5 bg-error/10 border border-error/20 text-error text-sm font-medium rounded-md disabled:opacity-50"
>
إلغاء التسجيل
</button>
) : (
<button
onClick={handleRegister}
disabled={registering}
className="w-full py-2.5 bg-cyan text-text-inverse text-sm font-bold rounded-md disabled:opacity-50"
>
{tournament.entry_fee_coins > 0
? `سجّل (🪙${tournament.entry_fee_coins})`
: 'سجّل مجاناً'
}
</button>
)}
</div>
)}
</div>
{/* Tabs */}
<div className="flex gap-1 bg-bg-1 border border-white/6 rounded-md p-1 mb-4">
{(['info', 'players', 'rounds'] as const).map((t) => (
<button
key={t}
onClick={() => setTab(t)}
className={`flex-1 py-2 text-sm font-medium rounded transition-colors ${
tab === t ? 'bg-bg-2 text-text-1' : 'text-text-3'
}`}
>
{t === 'info' ? 'معلومات' : t === 'players' ? 'اللاعبون' : 'الجولات'}
</button>
))}
</div>
{/* Tab Content */}
{tab === 'info' && (
<div className="bg-bg-1 border border-white/6 rounded-md p-4">
{tournament.starts_at && (
<div className="flex justify-between text-sm mb-2">
<span className="text-text-3">البداية</span>
<span className="font-latin">{new Date(tournament.starts_at).toLocaleString('ar')}</span>
</div>
)}
{tournament.registration_closes_at && (
<div className="flex justify-between text-sm mb-2">
<span className="text-text-3">إغلاق التسجيل</span>
<span className="font-latin">{new Date(tournament.registration_closes_at).toLocaleString('ar')}</span>
</div>
)}
{tournament.entry_fee_coins > 0 && (
<div className="flex justify-between text-sm mb-2">
<span className="text-text-3">رسوم الدخول</span>
<span>🪙 {tournament.entry_fee_coins}</span>
</div>
)}
</div>
)}
{tab === 'players' && (
<div className="bg-bg-1 border border-white/6 rounded-md overflow-hidden">
{players.length === 0 ? (
<p className="p-4 text-center text-sm text-text-3">لا مسجلين بعد</p>
) : (
players.map((p, idx) => (
<div key={p.id} className="flex items-center gap-3 px-4 py-3 border-b border-white/6 last:border-0">
<span className="w-6 text-center text-xs text-text-3 font-latin">{idx + 1}</span>
<div className="w-8 h-8 rounded-full bg-bg-2 overflow-hidden shrink-0">
{p.avatar_url && <img src={p.avatar_url} alt="" className="w-full h-full object-cover" />}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{p.display_name}</p>
</div>
<span className="text-xs font-latin text-text-2">{p.rating}</span>
{p.points > 0 && <span className="text-xs font-bold text-gold font-latin">{p.points}pts</span>}
</div>
))
)}
</div>
)}
{tab === 'rounds' && (
<div className="bg-bg-1 border border-white/6 rounded-md p-4 text-center text-sm text-text-3">
{tournament.current_round > 0
? `الجولة الحالية: ${tournament.current_round} من ${tournament.rounds_total}`
: 'لم تبدأ الجولات بعد'
}
</div>
)}
</div>
</div>
);
}
import { useState } from 'react';
import { useTournaments } from '../hooks/useTournaments';
const TABS = [
{ key: undefined, label: 'الكل' },
{ key: 'upcoming', label: 'قادمة' },
{ key: 'active', label: 'مباشر' },
{ key: 'completed', label: 'منتهية' },
];
export function TournamentsPage() {
const [statusFilter, setStatusFilter] = useState<string | undefined>(undefined);
const { tournaments, loading } = useTournaments(statusFilter);
return (
<div className="px-5 pt-7 pb-8">
<h1 className="text-[28px] font-bold mb-4">البطولات</h1>
{/* Filter Tabs */}
<div className="flex gap-2 mb-6 overflow-x-auto scrollbar-none">
{TABS.map((tab) => (
<button
key={tab.key || 'all'}
onClick={() => setStatusFilter(tab.key)}
className={`px-4 py-2 text-sm font-medium rounded-md whitespace-nowrap transition-colors ${
statusFilter === tab.key
? 'bg-cyan text-text-inverse'
: 'bg-bg-1 border border-white/6 text-text-2'
}`}
>
{tab.label}
</button>
))}
</div>
{/* Tournament List */}
{loading ? (
<div className="space-y-4">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="h-40 bg-bg-1 rounded-md animate-pulse" />
))}
</div>
) : tournaments.length === 0 ? (
<div className="bg-bg-1 border border-white/6 rounded-md p-6 text-center text-text-3 text-sm">
لا بطولات متاحة حالياً
</div>
) : (
<div className="space-y-4">
{tournaments.map((t) => (
<div key={t.id} className="bg-bg-1 border border-white/6 rounded-md overflow-hidden">
{t.banner_url && (
<div className="h-32 bg-bg-2 overflow-hidden">
<img src={t.banner_url} alt="" className="w-full h-full object-cover" />
</div>
)}
<div className="p-4">
<div className="flex items-start justify-between mb-2">
<h3 className="text-base font-bold">{t.name_ar || t.name}</h3>
<span className={`text-[10px] font-bold px-2 py-0.5 rounded ${
t.status === 'active' ? 'bg-error/20 text-error' :
t.status === 'upcoming' ? 'bg-cyan/20 text-cyan' :
'bg-white/10 text-text-3'
}`}>
{t.status === 'active' ? 'مباشر' : t.status === 'upcoming' ? 'قادمة' : 'انتهت'}
</span>
</div>
<p className="text-xs text-text-3 mb-3">
{t.game_key}{t.time_control}{t.format}
</p>
<div className="flex items-center gap-4 text-xs text-text-2">
<span>👥 {t.max_players}</span>
{t.prize_pool_coins > 0 && <span>🪙 {t.prize_pool_coins}</span>}
{t.starts_at && (
<span>{new Date(t.starts_at).toLocaleDateString('ar')}</span>
)}
</div>
</div>
</div>
))}
</div>
)}
</div>
);
}
import { create } from 'zustand';
import type { User, Session } from '@supabase/supabase-js';
interface AuthState {
user: User | null;
session: Session | null;
loading: boolean;
setAuth: (user: User | null, session: Session | null) => void;
setLoading: (loading: boolean) => void;
}
export const useAuthStore = create<AuthState>((set) => ({
user: null,
session: null,
loading: true,
setAuth: (user, session) => set({ user, session, loading: false }),
setLoading: (loading) => set({ loading }),
}));
<nav class="nav-bottom">
<?php
$currentRoute = $_GET['route'] ?? '';
$bottomItems = [
['/', 'icon-home', 'الرئيسية'],
['/play', 'icon-play', 'العب'],
['/tournaments', 'icon-trophy', 'بطولات'],
['/friends', 'icon-friends', 'اجتماعي'],
['/profile', 'icon-profile', 'حسابي'],
];
foreach ($bottomItems as $item):
$href = $item[0];
$icon = $item[1];
$label = $item[2];
$route = trim($href, '/');
$isActive = ($route === '' && $currentRoute === '') || ($route !== '' && $currentRoute === $route);
?>
<a href="<?= $href ?>" class="nav-bottom-item <?= $isActive ? 'active' : '' ?>">
<svg class="icon"><use href="/public/icons/sprite.svg#<?= $icon ?>"></use></svg>
<span class="nav-bottom-label"><?= $label ?></span>
</a>
<?php endforeach; ?>
</nav>
<nav class="nav-desktop">
<span class="nav-desktop-logo">E3</span>
<?php
$currentRoute = $_GET['route'] ?? '';
$navItems = [
['/', 'icon-home', 'الرئيسية'],
['/play', 'icon-play', 'العب'],
['/tournaments', 'icon-trophy', 'بطولات'],
['/leaderboard', 'icon-leaderboard', 'متصدرون'],
['/friends', 'icon-friends', 'اجتماعي'],
['/orgs', 'icon-org', 'اندية'],
['/shop', 'icon-shop', 'متجر'],
['/achievements', 'icon-star', 'انجازات'],
['/profile', 'icon-profile', 'حسابي'],
['/settings', 'icon-settings', 'اعدادات'],
];
foreach ($navItems as $item):
$href = $item[0];
$icon = $item[1];
$label = $item[2];
$route = trim($href, '/');
$isActive = ($route === '' && $currentRoute === '') || ($route !== '' && $currentRoute === $route);
?>
<a href="<?= $href ?>" class="nav-desktop-item <?= $isActive ? 'active' : '' ?>">
<svg class="icon"><use href="/public/icons/sprite.svg#<?= $icon ?>"></use></svg>
<span class="nav-desktop-label"><?= $label ?></span>
</a>
<?php endforeach; ?>
</nav>
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "esnext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": false,
"noUnusedParameters": false,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"module": "esnext",
"types": [],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
port: 5173,
host: true,
},
})
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment