Commit c7a56749 authored by Mahmoud Aglan's avatar Mahmoud Aglan

Deploy 1: Skeleton + Brand foundation

Full project scaffold with React 19, Vite, Tailwind v4, Framer Motion.
EL3AB brand colors (gold/dark theme), Cairo Arabic font, RTL layout.
Animated bottom nav, header with coin display, splash screen.
All pages scaffolded: home, play, matchmaking, profile, tournaments,
friends, leaderboard, notifications, shop, settings.
Auth flow with Supabase (login/register).
Dockerfile + nginx for CapRover deploy on port 80.
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parents
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
screenshots/
Connections and docs /
*.pem
EL3AB_PLAYER_APP_DATA.md
V1_PLAN.md
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
{
"schemaVersion": 2,
"dockerfilePath": "./Dockerfile"
}
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
globals: globals.browser,
},
},
])
<!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, maximum-scale=1.0, user-scalable=no" />
<meta name="theme-color" content="#0A0A14" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<title>EL3AB - العب</title>
<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=Cairo:wght@400;600;700;900&display=swap" rel="stylesheet" />
</head>
<body class="bg-background text-text-primary font-cairo antialiased overflow-x-hidden">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
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";
}
}
This source diff could not be displayed because it is too large. You can view the blob instead.
{
"name": "el3ab-player",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@supabase/supabase-js": "^2.106.2",
"chess.js": "^1.4.0",
"framer-motion": "^12.40.0",
"howler": "^2.2.4",
"lucide-react": "^1.16.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-router-dom": "^7.15.1",
"zustand": "^5.0.13"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@tailwindcss/vite": "^4.3.0",
"@types/howler": "^2.2.13",
"@types/node": "^24.12.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^10.3.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0",
"playwright": "^1.60.0",
"tailwindcss": "^4.3.0",
"typescript": "~6.0.2",
"typescript-eslint": "^8.59.2",
"vite": "^8.0.12"
}
}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<defs>
<linearGradient id="g" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#E8C55A"/>
<stop offset="100%" stop-color="#B8964A"/>
</linearGradient>
</defs>
<rect width="32" height="32" rx="6" fill="#0A0A14"/>
<path d="M16 5L20 11L27 8L24 19H8L5 8L12 11L16 5Z" fill="url(#g)"/>
<rect x="8" y="19" width="16" height="3" rx="1" fill="url(#g)"/>
<circle cx="16" cy="8" r="1.2" fill="#FFF8E1"/>
<circle cx="11" cy="11" r="1" fill="#FFF8E1"/>
<circle cx="21" cy="11" r="1" fill="#FFF8E1"/>
</svg>
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import { AnimatePresence } from 'framer-motion'
import { useAuthStore } from './stores/authStore'
import { useUIStore } from './stores/uiStore'
import { useAuthListener } from './hooks/useAuth'
import { AppShell } from './components/layout/AppShell'
import { SplashPage } from './pages/SplashPage'
import { LoginPage } from './pages/LoginPage'
import { RegisterPage } from './pages/RegisterPage'
import { HomePage } from './pages/HomePage'
import { PlayPage } from './pages/PlayPage'
import { MatchmakingPage } from './pages/MatchmakingPage'
import { ProfilePage } from './pages/ProfilePage'
import { TournamentsPage } from './pages/TournamentsPage'
import { FriendsPage } from './pages/FriendsPage'
import { LeaderboardPage } from './pages/LeaderboardPage'
import { NotificationsPage } from './pages/NotificationsPage'
import { ShopPage } from './pages/ShopPage'
import { SettingsPage } from './pages/SettingsPage'
import { NotFoundPage } from './pages/NotFoundPage'
import type { ReactNode } from 'react'
function ProtectedRoute({ children }: { children: ReactNode }) {
const { session, loading } = useAuthStore()
if (loading) {
return (
<div className="flex-1 flex items-center justify-center">
<div className="w-8 h-8 border-2 border-gold/30 border-t-gold rounded-full animate-spin" />
</div>
)
}
if (!session) {
return <Navigate to="/login" replace />
}
return <>{children}</>
}
function PublicRoute({ children }: { children: ReactNode }) {
const { session, loading } = useAuthStore()
if (loading) {
return (
<div className="flex-1 flex items-center justify-center">
<div className="w-8 h-8 border-2 border-gold/30 border-t-gold rounded-full animate-spin" />
</div>
)
}
if (session) {
return <Navigate to="/" replace />
}
return <>{children}</>
}
export default function App() {
useAuthListener()
const { splashShown } = useUIStore()
return (
<BrowserRouter>
<AnimatePresence mode="wait">
<Routes>
{!splashShown && <Route path="*" element={<SplashPage />} />}
<Route path="/login" element={<PublicRoute><LoginPage /></PublicRoute>} />
<Route path="/register" element={<PublicRoute><RegisterPage /></PublicRoute>} />
<Route path="/splash" element={<SplashPage />} />
<Route element={<ProtectedRoute><AppShell /></ProtectedRoute>}>
<Route path="/" element={<HomePage />} />
<Route path="/play" element={<PlayPage />} />
<Route path="/matchmaking" element={<MatchmakingPage />} />
<Route path="/profile" element={<ProfilePage />} />
<Route path="/tournaments" element={<TournamentsPage />} />
<Route path="/friends" element={<FriendsPage />} />
<Route path="/leaderboard" element={<LeaderboardPage />} />
<Route path="/notifications" element={<NotificationsPage />} />
<Route path="/shop" element={<ShopPage />} />
<Route path="/settings" element={<SettingsPage />} />
</Route>
<Route path="*" element={<NotFoundPage />} />
</Routes>
</AnimatePresence>
</BrowserRouter>
)
}
import { motion } from 'framer-motion'
import type { LucideIcon } from 'lucide-react'
interface AnimatedIconProps {
icon: LucideIcon
size?: number
color?: string
className?: string
animate?: boolean
pulse?: boolean
onClick?: () => void
}
export function AnimatedIcon({
icon: Icon,
size = 24,
color = 'currentColor',
className = '',
animate = true,
pulse = false,
onClick,
}: AnimatedIconProps) {
return (
<motion.div
className={`inline-flex items-center justify-center ${className}`}
initial={animate ? { scale: 0.8, opacity: 0 } : false}
animate={{
scale: 1,
opacity: 1,
...(pulse ? { scale: [1, 1.1, 1] } : {}),
}}
transition={
pulse
? { repeat: Infinity, duration: 2, ease: 'easeInOut' }
: { type: 'spring', stiffness: 400, damping: 20 }
}
whileTap={onClick ? { scale: 0.9 } : undefined}
onClick={onClick}
style={{ cursor: onClick ? 'pointer' : 'default' }}
>
<Icon size={size} color={color} strokeWidth={2} />
</motion.div>
)
}
import { motion } from 'framer-motion'
interface GoldCrownProps {
size?: number
className?: string
animate?: boolean
}
export function GoldCrown({ size = 48, className = '', animate = true }: GoldCrownProps) {
return (
<motion.svg
width={size}
height={size}
viewBox="0 0 48 48"
fill="none"
className={className}
initial={animate ? { scale: 0.5, opacity: 0, rotate: -10 } : false}
animate={{ scale: 1, opacity: 1, rotate: 0 }}
transition={{ type: 'spring', stiffness: 300, damping: 20, delay: 0.1 }}
>
<defs>
<linearGradient id="crownGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor="#E8C55A" />
<stop offset="50%" stopColor="#D4A843" />
<stop offset="100%" stopColor="#B8964A" />
</linearGradient>
<filter id="crownGlow">
<feGaussianBlur stdDeviation="2" result="blur" />
<feComposite in="SourceGraphic" in2="blur" operator="over" />
</filter>
</defs>
<motion.path
d="M24 6L30 16L40 12L36 28H12L8 12L18 16L24 6Z"
fill="url(#crownGradient)"
filter="url(#crownGlow)"
initial={{ pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: 0.8, ease: 'easeOut' }}
/>
<rect x="12" y="28" width="24" height="4" rx="1" fill="url(#crownGradient)" />
<circle cx="24" cy="12" r="2" fill="#FFF8E1" />
<circle cx="16" cy="16" r="1.5" fill="#FFF8E1" />
<circle cx="32" cy="16" r="1.5" fill="#FFF8E1" />
</motion.svg>
)
}
export { AnimatedIcon } from './AnimatedIcon'
export { GoldCrown } from './GoldCrown'
import { Outlet } from 'react-router-dom'
import { Header } from './Header'
import { BottomNav } from './BottomNav'
import { ToastContainer } from '../ui/ToastContainer'
export function AppShell() {
return (
<div className="flex flex-col min-h-dvh">
<Header />
<main className="flex-1 pb-20 overflow-y-auto">
<Outlet />
</main>
<BottomNav />
<ToastContainer />
</div>
)
}
import { motion } from 'framer-motion'
import { Home, Gamepad2, Trophy, Users, User } from 'lucide-react'
import { useLocation, useNavigate } from 'react-router-dom'
const NAV_ITEMS = [
{ path: '/', icon: Home, label: 'الرئيسية' },
{ path: '/play', icon: Gamepad2, label: 'العب' },
{ path: '/tournaments', icon: Trophy, label: 'البطولات' },
{ path: '/friends', icon: Users, label: 'الأصدقاء' },
{ path: '/profile', icon: User, label: 'حسابي' },
]
export function BottomNav() {
const location = useLocation()
const navigate = useNavigate()
return (
<nav className="fixed bottom-0 left-0 right-0 z-50 bg-surface-1/90 backdrop-blur-xl border-t border-border">
<div className="flex items-center justify-around px-2 py-2 max-w-lg mx-auto">
{NAV_ITEMS.map((item) => {
const isActive = location.pathname === item.path
const Icon = item.icon
return (
<motion.button
key={item.path}
onClick={() => navigate(item.path)}
className="flex flex-col items-center gap-0.5 px-3 py-1.5 rounded-xl relative"
whileTap={{ scale: 0.9 }}
transition={{ type: 'spring', stiffness: 500, damping: 25 }}
>
<motion.div
animate={{
scale: isActive ? 1.15 : 1,
y: isActive ? -2 : 0,
}}
transition={{ type: 'spring', stiffness: 400, damping: 20 }}
>
<Icon
size={22}
className={isActive ? 'text-gold' : 'text-text-muted'}
strokeWidth={isActive ? 2.5 : 2}
/>
</motion.div>
<span
className={`text-[10px] font-semibold ${isActive ? 'text-gold' : 'text-text-muted'}`}
>
{item.label}
</span>
{isActive && (
<motion.div
className="absolute -bottom-1 w-5 h-1 rounded-full bg-gold"
layoutId="nav-indicator"
transition={{ type: 'spring', stiffness: 400, damping: 30 }}
style={{ boxShadow: '0 0 8px rgba(212, 168, 67, 0.6)' }}
/>
)}
</motion.button>
)
})}
</div>
</nav>
)
}
import { motion } from 'framer-motion'
import { Bell, Coins } from 'lucide-react'
import { useNotificationStore } from '../../stores/notificationStore'
import { useAuthStore } from '../../stores/authStore'
import { AnimatedIcon } from '../icons/AnimatedIcon'
import { GoldCrown } from '../icons/GoldCrown'
import { useNavigate } from 'react-router-dom'
export function Header() {
const { profile } = useAuthStore()
const { unreadCount } = useNotificationStore()
const navigate = useNavigate()
return (
<header className="sticky top-0 z-50 flex items-center justify-between px-4 py-3 bg-surface-1/80 backdrop-blur-xl border-b border-border">
<div className="flex items-center gap-2">
<GoldCrown size={28} animate={false} />
<span className="text-lg font-bold text-gold">EL3AB</span>
</div>
<div className="flex items-center gap-4">
{profile && (
<motion.div
className="flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-surface-2 border border-border-gold"
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: 'spring', stiffness: 400, damping: 25 }}
>
<Coins size={14} className="text-gold" />
<span className="text-sm font-semibold text-gold">{profile.coins}</span>
</motion.div>
)}
<div className="relative" onClick={() => navigate('/notifications')}>
<AnimatedIcon icon={Bell} size={22} color="var(--color-text-secondary)" onClick={() => {}} />
{unreadCount > 0 && (
<motion.div
className="absolute -top-1 -right-1 w-4 h-4 rounded-full bg-coral flex items-center justify-center"
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: 'spring', stiffness: 500, damping: 20 }}
>
<span className="text-[10px] font-bold text-white">{unreadCount > 9 ? '9+' : unreadCount}</span>
</motion.div>
)}
</div>
</div>
</header>
)
}
import { motion } from 'framer-motion'
import type { ReactNode } from 'react'
interface PageTransitionProps {
children: ReactNode
className?: string
}
export function PageTransition({ children, className = '' }: PageTransitionProps) {
return (
<motion.div
className={className}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 20 }}
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
>
{children}
</motion.div>
)
}
export { AppShell } from './AppShell'
export { Header } from './Header'
export { BottomNav } from './BottomNav'
export { PageTransition } from './PageTransition'
import { motion } from 'framer-motion'
import type { ReactNode } from 'react'
import { Loader2 } from 'lucide-react'
interface ButtonProps {
children: ReactNode
onClick?: () => void
variant?: 'gold' | 'ghost' | 'coral' | 'cyan'
size?: 'sm' | 'md' | 'lg'
disabled?: boolean
loading?: boolean
className?: string
type?: 'button' | 'submit'
}
const variants = {
gold: 'bg-gradient-to-l from-gold to-gold-light text-background font-bold shadow-lg shadow-gold/20',
ghost: 'bg-surface-2 border border-border text-text-primary hover:border-gold/40',
coral: 'bg-coral/90 text-white font-bold',
cyan: 'bg-cyan/90 text-background font-bold',
}
const sizes = {
sm: 'px-4 py-2 text-sm rounded-lg',
md: 'px-6 py-3 text-base rounded-xl',
lg: 'px-8 py-4 text-lg rounded-2xl',
}
export function Button({
children,
onClick,
variant = 'gold',
size = 'md',
disabled = false,
loading = false,
className = '',
type = 'button',
}: ButtonProps) {
return (
<motion.button
type={type}
onClick={onClick}
disabled={disabled || loading}
className={`inline-flex items-center justify-center gap-2 transition-colors ${variants[variant]} ${sizes[size]} ${disabled ? 'opacity-50 cursor-not-allowed' : ''} ${className}`}
whileTap={!disabled ? { scale: 0.95 } : undefined}
transition={{ type: 'spring', stiffness: 500, damping: 20 }}
>
{loading && <Loader2 size={18} className="animate-spin" />}
{children}
</motion.button>
)
}
import { motion } from 'framer-motion'
import type { ReactNode } from 'react'
interface CardProps {
children: ReactNode
className?: string
glow?: boolean
onClick?: () => void
}
export function Card({ children, className = '', glow = false, onClick }: CardProps) {
return (
<motion.div
className={`rounded-2xl bg-surface-1 border border-border p-4 ${glow ? 'border-border-gold shadow-[0_0_15px_rgba(212,168,67,0.1)]' : ''} ${onClick ? 'cursor-pointer' : ''} ${className}`}
whileHover={onClick ? { y: -2, boxShadow: '0 8px 30px rgba(212,168,67,0.12)' } : undefined}
whileTap={onClick ? { scale: 0.98 } : undefined}
onClick={onClick}
transition={{ type: 'spring', stiffness: 400, damping: 25 }}
>
{children}
</motion.div>
)
}
import { motion } from 'framer-motion'
import { useState } from 'react'
interface InputProps {
label: string
type?: string
value: string
onChange: (value: string) => void
placeholder?: string
error?: string
disabled?: boolean
}
export function Input({
label,
type = 'text',
value,
onChange,
placeholder,
error,
disabled = false,
}: InputProps) {
const [focused, setFocused] = useState(false)
return (
<div className="flex flex-col gap-1.5">
<label className="text-sm font-semibold text-text-secondary">{label}</label>
<div className="relative">
<input
type={type}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
disabled={disabled}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
className="w-full px-4 py-3 rounded-xl bg-surface-3 text-text-primary placeholder:text-text-muted border-2 border-transparent outline-none transition-colors focus:border-gold/60 disabled:opacity-50"
dir="auto"
/>
<motion.div
className="absolute bottom-0 left-0 right-0 h-0.5 bg-gold rounded-full origin-center"
initial={{ scaleX: 0 }}
animate={{ scaleX: focused ? 1 : 0 }}
transition={{ type: 'spring', stiffness: 400, damping: 30 }}
/>
</div>
{error && (
<motion.span
className="text-xs text-coral"
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
>
{error}
</motion.span>
)}
</div>
)
}
import { motion, AnimatePresence } from 'framer-motion'
import { useNotificationStore } from '../../stores/notificationStore'
import { useEffect } from 'react'
import { CheckCircle2, XCircle, Info } from 'lucide-react'
const TOAST_ICONS = {
success: CheckCircle2,
error: XCircle,
info: Info,
}
const TOAST_COLORS = {
success: 'border-cyan/40 bg-cyan/10',
error: 'border-coral/40 bg-coral/10',
info: 'border-royal-blue/40 bg-royal-blue/10',
}
export function ToastContainer() {
const { toasts, dismissToast } = useNotificationStore()
return (
<div className="fixed top-16 left-4 right-4 z-[100] flex flex-col gap-2 pointer-events-none">
<AnimatePresence mode="popLayout">
{toasts.map((toast) => {
const Icon = TOAST_ICONS[toast.type]
return (
<ToastItem
key={toast.id}
id={toast.id}
title={toast.title}
type={toast.type}
Icon={Icon}
colorClass={TOAST_COLORS[toast.type]}
onDismiss={dismissToast}
/>
)
})}
</AnimatePresence>
</div>
)
}
function ToastItem({
id,
title,
Icon,
colorClass,
onDismiss,
}: {
id: string
title: string
type: string
Icon: typeof CheckCircle2
colorClass: string
onDismiss: (id: string) => void
}) {
useEffect(() => {
const timer = setTimeout(() => onDismiss(id), 4000)
return () => clearTimeout(timer)
}, [id, onDismiss])
return (
<motion.div
layout
initial={{ opacity: 0, y: -40, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -20, scale: 0.95 }}
transition={{ type: 'spring', stiffness: 400, damping: 25 }}
className={`pointer-events-auto rounded-xl border p-3 flex items-center gap-3 backdrop-blur-xl ${colorClass}`}
onClick={() => onDismiss(id)}
>
<Icon size={18} />
<span className="text-sm font-medium text-text-primary">{title}</span>
</motion.div>
)
}
export { Button } from './Button'
export { Card } from './Card'
export { Input } from './Input'
export { ToastContainer } from './ToastContainer'
export const ENV = {
SUPABASE_URL: 'https://safe-supabase-kong.caprover.al-arcade.com',
SUPABASE_ANON_KEY: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzM1Njg5NjAwLCJleHAiOjE4OTM0NTYwMDB9.bFnS-YBhykTQ6vqrfTKJqmAB_aSW6GUgCat3QLkgCv8',
APP_URL: 'https://el3ab-player.caprover.al-arcade.com',
} as const
import { useEffect } from 'react'
import { supabase } from '../lib/supabase'
import { useAuthStore } from '../stores/authStore'
export function useAuthListener() {
const { setUser, setSession, setProfile, setLoading } = useAuthStore()
useEffect(() => {
supabase.auth.getSession().then(({ data: { session } }) => {
setSession(session)
setUser(session?.user ?? null)
if (session?.user) {
fetchProfile(session.user.id)
} else {
setLoading(false)
}
})
const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, session) => {
setSession(session)
setUser(session?.user ?? null)
if (session?.user) {
fetchProfile(session.user.id)
} else {
setProfile(null)
setLoading(false)
}
})
return () => subscription.unsubscribe()
}, [setUser, setSession, setProfile, setLoading])
async function fetchProfile(userId: string) {
const { data } = await supabase
.from('profiles')
.select('*')
.eq('id', userId)
.single()
if (data) {
setProfile(data)
}
setLoading(false)
}
}
@import "tailwindcss";
@theme {
--color-background: #0A0A14;
--color-surface-1: #12121F;
--color-surface-2: #1A1A2E;
--color-surface-3: #242438;
--color-border: #2A2A4A;
--color-border-gold: rgba(212, 168, 67, 0.2);
--color-gold: #D4A843;
--color-gold-light: #E8C55A;
--color-gold-muted: #B8964A;
--color-coral: #E06B4A;
--color-royal-blue: #5B7FD6;
--color-cyan: #4ECDC4;
--color-purple: #7B5EA7;
--color-electric-blue: #3B82F6;
--color-text-primary: #F5F5F5;
--color-text-secondary: #A0A0B8;
--color-text-muted: #6B6B80;
--color-win: #4ECDC4;
--color-loss: #E06B4A;
--color-draw: #A0A0B8;
--font-family-cairo: 'Cairo', sans-serif;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
font-family: var(--font-family-cairo);
}
body {
min-height: 100dvh;
background-color: var(--color-background);
color: var(--color-text-primary);
overflow-x: hidden;
-webkit-tap-highlight-color: transparent;
}
#root {
min-height: 100dvh;
display: flex;
flex-direction: column;
}
::-webkit-scrollbar {
width: 4px;
}
::-webkit-scrollbar-track {
background: var(--color-surface-1);
}
::-webkit-scrollbar-thumb {
background: var(--color-border);
border-radius: 2px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--color-gold-muted);
}
export const TIME_CONTROLS = {
bullet_1_0: { label: '1+0', labelAr: '١+٠', category: 'bullet', initial: 60000, increment: 0 },
bullet_1_1: { label: '1+1', labelAr: '١+١', category: 'bullet', initial: 60000, increment: 1000 },
bullet_2_1: { label: '2+1', labelAr: '٢+١', category: 'bullet', initial: 120000, increment: 1000 },
blitz_3_0: { label: '3+0', labelAr: '٣+٠', category: 'blitz', initial: 180000, increment: 0 },
blitz_3_2: { label: '3+2', labelAr: '٣+٢', category: 'blitz', initial: 180000, increment: 2000 },
blitz_5_0: { label: '5+0', labelAr: '٥+٠', category: 'blitz', initial: 300000, increment: 0 },
blitz_5_3: { label: '5+3', labelAr: '٥+٣', category: 'blitz', initial: 300000, increment: 3000 },
rapid_10_0: { label: '10+0', labelAr: '١٠+٠', category: 'rapid', initial: 600000, increment: 0 },
rapid_10_5: { label: '10+5', labelAr: '١٠+٥', category: 'rapid', initial: 600000, increment: 5000 },
rapid_15_10: { label: '15+10', labelAr: '١٥+١٠', category: 'rapid', initial: 900000, increment: 10000 },
rapid_30_0: { label: '30+0', labelAr: '٣٠+٠', category: 'rapid', initial: 1800000, increment: 0 },
classical_60_0: { label: '60+0', labelAr: '٦٠+٠', category: 'classical', initial: 3600000, increment: 0 },
classical_90_30: { label: '90+30', labelAr: '٩٠+٣٠', category: 'classical', initial: 5400000, increment: 30000 },
} as const
export const GAMES = [
{ key: 'chess', name: 'Chess', nameAr: 'شطرنج', available: true },
{ key: 'backgammon', name: 'Backgammon', nameAr: 'طاولة', available: false },
{ key: 'dominoes', name: 'Dominoes', nameAr: 'دومينو', available: false },
{ key: 'ludo', name: 'Ludo', nameAr: 'لودو', available: false },
{ key: 'trivia', name: 'Trivia Party', nameAr: 'تريفيا بارتي', available: false },
] as const
export const RARITY_COLORS = {
common: '#6B6B80',
uncommon: '#5B7FD6',
rare: '#7B5EA7',
epic: '#E06B4A',
legendary: '#D4A843',
} as const
import { Howl } from 'howler'
const soundCache: Record<string, Howl> = {}
function loadSound(name: string, path: string): Howl {
if (soundCache[name]) return soundCache[name]
const howl = new Howl({
src: [path],
preload: true,
volume: 0.5,
onloaderror: () => {},
})
soundCache[name] = howl
return howl
}
const sounds = {
tap: () => loadSound('tap', '/sounds/tap.mp3'),
move: () => loadSound('move', '/sounds/move.mp3'),
capture: () => loadSound('capture', '/sounds/capture.mp3'),
check: () => loadSound('check', '/sounds/check.mp3'),
gameStart: () => loadSound('game-start', '/sounds/game-start.mp3'),
matchFound: () => loadSound('match-found', '/sounds/match-found.mp3'),
win: () => loadSound('win', '/sounds/win.mp3'),
lose: () => loadSound('lose', '/sounds/lose.mp3'),
notification: () => loadSound('notification', '/sounds/notification.mp3'),
coin: () => loadSound('coin', '/sounds/coin.mp3'),
levelUp: () => loadSound('level-up', '/sounds/level-up.mp3'),
tick: () => loadSound('tick', '/sounds/tick.mp3'),
error: () => loadSound('error', '/sounds/error.mp3'),
}
export function playSound(name: keyof typeof sounds) {
try {
sounds[name]().play()
} catch {
// silently fail
}
}
import { createClient } from '@supabase/supabase-js'
import { ENV } from '../env'
export const supabase = createClient(ENV.SUPABASE_URL, ENV.SUPABASE_ANON_KEY, {
auth: {
persistSession: true,
autoRefreshToken: true,
},
realtime: {
params: {
eventsPerSecond: 10,
},
},
})
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
import { motion } from 'framer-motion'
import { UserPlus, Search } from 'lucide-react'
import { PageTransition } from '../components/layout/PageTransition'
export function FriendsPage() {
return (
<PageTransition className="px-4 py-6 flex flex-col gap-5">
<div className="flex items-center justify-between">
<h1 className="text-xl font-bold">الاصدقاء</h1>
<motion.button
className="p-2 rounded-lg bg-gold/10 border border-gold/20"
whileTap={{ scale: 0.9 }}
>
<UserPlus size={18} className="text-gold" />
</motion.button>
</div>
<div className="relative">
<Search size={16} className="absolute right-3 top-1/2 -translate-y-1/2 text-text-muted" />
<input
type="text"
placeholder="بحث عن لاعب..."
className="w-full pr-10 pl-4 py-2.5 rounded-xl bg-surface-2 border border-border text-sm placeholder:text-text-muted outline-none focus:border-gold/40"
dir="rtl"
/>
</div>
<div className="flex-1 flex flex-col items-center justify-center py-12">
<motion.div
className="w-16 h-16 rounded-full bg-surface-2 border border-border flex items-center justify-center mb-4"
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: 'spring', stiffness: 300, damping: 20 }}
>
<UserPlus size={24} className="text-text-muted" />
</motion.div>
<p className="text-text-muted text-sm">لا يوجد اصدقاء بعد</p>
<p className="text-text-muted text-xs mt-1">ابحث عن لاعبين لاضافتهم</p>
</div>
</PageTransition>
)
}
import { motion } from 'framer-motion'
import { Play, TrendingUp, Swords, Flame } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import { useAuthStore } from '../stores/authStore'
import { PageTransition } from '../components/layout/PageTransition'
import { Card } from '../components/ui/Card'
export function HomePage() {
const navigate = useNavigate()
const { profile } = useAuthStore()
return (
<PageTransition className="px-4 py-6 flex flex-col gap-6">
{profile && (
<motion.div
className="flex items-center gap-3"
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
>
<div className="w-12 h-12 rounded-full bg-gradient-to-br from-gold/30 to-purple/30 border-2 border-gold/40 flex items-center justify-center">
<span className="text-lg font-bold text-gold">
{profile.display_name?.charAt(0) || 'L'}
</span>
</div>
<div>
<h2 className="text-lg font-bold">اهلا، {profile.display_name}</h2>
<p className="text-sm text-text-muted">المستوى {profile.level}</p>
</div>
</motion.div>
)}
<motion.button
onClick={() => navigate('/play')}
className="relative w-full py-8 rounded-3xl bg-gradient-to-bl from-gold via-gold-light to-gold overflow-hidden"
whileTap={{ scale: 0.97 }}
transition={{ type: 'spring', stiffness: 400, damping: 20 }}
>
<motion.div
className="absolute inset-0 bg-gradient-to-r from-transparent via-white/10 to-transparent"
animate={{ x: ['-200%', '200%'] }}
transition={{ duration: 3, repeat: Infinity, ease: 'linear' }}
/>
<div className="relative flex flex-col items-center gap-2">
<motion.div
animate={{ scale: [1, 1.05, 1] }}
transition={{ duration: 2, repeat: Infinity, ease: 'easeInOut' }}
>
<Play size={40} className="text-background" fill="currentColor" />
</motion.div>
<span className="text-2xl font-black text-background">العب الان</span>
</div>
</motion.button>
{profile && (
<div className="grid grid-cols-3 gap-3">
<StatCard
icon={<Swords size={18} className="text-cyan" />}
value={profile.total_games_played}
label="مباراة"
delay={0.2}
/>
<StatCard
icon={<TrendingUp size={18} className="text-gold" />}
value={profile.elo_blitz}
label="تقييم"
delay={0.3}
/>
<StatCard
icon={<Flame size={18} className="text-coral" />}
value={profile.win_streak}
label="سلسلة فوز"
delay={0.4}
/>
</div>
)}
<div>
<h3 className="text-base font-bold mb-3 text-text-secondary">اخر المباريات</h3>
<div className="flex flex-col gap-2">
<Card className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-surface-3 flex items-center justify-center">
<span className="text-xs font-bold text-text-muted">?</span>
</div>
<div>
<p className="text-sm font-semibold">لا توجد مباريات بعد</p>
<p className="text-xs text-text-muted">ابدا اللعب الان</p>
</div>
</div>
</Card>
</div>
</div>
</PageTransition>
)
}
function StatCard({
icon,
value,
label,
delay,
}: {
icon: React.ReactNode
value: number
label: string
delay: number
}) {
return (
<motion.div
className="flex flex-col items-center gap-1 p-3 rounded-xl bg-surface-1 border border-border"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay, type: 'spring', stiffness: 400, damping: 25 }}
>
{icon}
<span className="text-lg font-bold">{value}</span>
<span className="text-[10px] text-text-muted">{label}</span>
</motion.div>
)
}
import { PageTransition } from '../components/layout/PageTransition'
import { Card } from '../components/ui/Card'
export function LeaderboardPage() {
return (
<PageTransition className="px-4 py-6 flex flex-col gap-5">
<h1 className="text-xl font-bold">لوحة المتصدرين</h1>
<Card className="flex flex-col items-center py-8">
<p className="text-text-muted text-sm">لا توجد بيانات بعد</p>
<p className="text-text-muted text-xs mt-1">العب مباريات لتظهر في القائمة</p>
</Card>
</PageTransition>
)
}
import { motion } from 'framer-motion'
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { supabase } from '../lib/supabase'
import { Button } from '../components/ui/Button'
import { Input } from '../components/ui/Input'
import { GoldCrown } from '../components/icons/GoldCrown'
export function LoginPage() {
const navigate = useNavigate()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
async function handleLogin(e: React.FormEvent) {
e.preventDefault()
setError('')
setLoading(true)
const { error: authError } = await supabase.auth.signInWithPassword({
email,
password,
})
if (authError) {
setError('البريد الالكتروني او كلمة المرور غير صحيحة')
} else {
navigate('/', { replace: true })
}
setLoading(false)
}
return (
<div className="min-h-dvh flex flex-col items-center justify-center px-6 relative overflow-hidden">
<motion.div
className="absolute inset-0 opacity-30"
style={{
background: 'radial-gradient(ellipse at 50% 30%, rgba(212,168,67,0.15) 0%, transparent 60%)',
}}
animate={{
background: [
'radial-gradient(ellipse at 50% 30%, rgba(212,168,67,0.15) 0%, transparent 60%)',
'radial-gradient(ellipse at 50% 30%, rgba(212,168,67,0.08) 0%, transparent 60%)',
'radial-gradient(ellipse at 50% 30%, rgba(212,168,67,0.15) 0%, transparent 60%)',
],
}}
transition={{ duration: 4, repeat: Infinity, ease: 'easeInOut' }}
/>
<motion.div
className="relative w-full max-w-sm"
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ type: 'spring', stiffness: 300, damping: 25 }}
>
<div className="flex flex-col items-center mb-8">
<GoldCrown size={64} />
<h1 className="mt-4 text-3xl font-black text-gold">EL3AB</h1>
<p className="mt-1 text-text-secondary">تسجيل الدخول</p>
</div>
<form onSubmit={handleLogin} className="flex flex-col gap-4 p-6 rounded-2xl bg-surface-1/60 backdrop-blur-xl border border-border-gold">
<Input
label="البريد الالكتروني"
type="email"
value={email}
onChange={setEmail}
placeholder="email@example.com"
/>
<Input
label="كلمة المرور"
type="password"
value={password}
onChange={setPassword}
placeholder="••••••••"
/>
{error && (
<motion.p
className="text-sm text-coral text-center"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
>
{error}
</motion.p>
)}
<Button type="submit" loading={loading} className="w-full mt-2">
دخول
</Button>
</form>
<motion.button
onClick={() => navigate('/register')}
className="mt-4 w-full text-center text-sm text-text-secondary hover:text-gold transition-colors"
whileTap={{ scale: 0.98 }}
>
ليس لديك حساب؟ <span className="text-gold font-semibold">سجل الان</span>
</motion.button>
</motion.div>
</div>
)
}
import { motion } from 'framer-motion'
import { useNavigate } from 'react-router-dom'
import { Button } from '../components/ui/Button'
export function MatchmakingPage() {
const navigate = useNavigate()
return (
<div className="flex-1 flex flex-col items-center justify-center px-6 py-12 relative overflow-hidden">
<div className="relative w-40 h-40 flex items-center justify-center">
{[0, 1, 2].map((i) => (
<motion.div
key={i}
className="absolute inset-0 rounded-full border-2 border-gold/30"
initial={{ scale: 0.5, opacity: 0.8 }}
animate={{ scale: 2.5, opacity: 0 }}
transition={{
duration: 2.5,
repeat: Infinity,
delay: i * 0.8,
ease: 'easeOut',
}}
/>
))}
<motion.div
className="w-20 h-20 rounded-full bg-gradient-to-br from-gold/20 to-gold/5 border-2 border-gold/40 flex items-center justify-center"
animate={{ scale: [1, 1.05, 1] }}
transition={{ duration: 2, repeat: Infinity, ease: 'easeInOut' }}
>
<span className="text-3xl font-black text-gold">?</span>
</motion.div>
</div>
<motion.h2
className="mt-8 text-xl font-bold"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.3 }}
>
جاري البحث عن خصم
</motion.h2>
<motion.div
className="mt-2 flex gap-1"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.5 }}
>
{[0, 1, 2].map((i) => (
<motion.span
key={i}
className="w-2 h-2 rounded-full bg-gold"
animate={{ opacity: [0.3, 1, 0.3] }}
transition={{ duration: 1, repeat: Infinity, delay: i * 0.3 }}
/>
))}
</motion.div>
<motion.div
className="mt-12"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 1 }}
>
<Button variant="ghost" onClick={() => navigate('/play')}>
الغاء
</Button>
</motion.div>
</div>
)
}
import { motion } from 'framer-motion'
import { useNavigate } from 'react-router-dom'
import { Button } from '../components/ui/Button'
export function NotFoundPage() {
const navigate = useNavigate()
return (
<div className="flex-1 flex flex-col items-center justify-center px-6 py-12">
<motion.div
className="text-6xl font-black text-gold/30"
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: 'spring', stiffness: 200, damping: 15 }}
>
404
</motion.div>
<motion.p
className="mt-4 text-text-muted"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.3 }}
>
الصفحة غير موجودة
</motion.p>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.5 }}
className="mt-6"
>
<Button variant="ghost" onClick={() => navigate('/')}>
العودة للرئيسية
</Button>
</motion.div>
</div>
)
}
import { Bell } from 'lucide-react'
import { motion } from 'framer-motion'
import { PageTransition } from '../components/layout/PageTransition'
export function NotificationsPage() {
return (
<PageTransition className="px-4 py-6 flex flex-col gap-5">
<h1 className="text-xl font-bold">الاشعارات</h1>
<div className="flex-1 flex flex-col items-center justify-center py-12">
<motion.div
className="w-16 h-16 rounded-full bg-surface-2 border border-border flex items-center justify-center mb-4"
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: 'spring', stiffness: 300, damping: 20 }}
>
<Bell size={24} className="text-text-muted" />
</motion.div>
<p className="text-text-muted text-sm">لا توجد اشعارات</p>
</div>
</PageTransition>
)
}
import { motion } from 'framer-motion'
import { Lock, Zap, Timer, Clock, Hourglass } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import { PageTransition } from '../components/layout/PageTransition'
import { GAMES, TIME_CONTROLS } from '../lib/constants'
import { useState } from 'react'
import { Button } from '../components/ui/Button'
const CATEGORIES = [
{ key: 'bullet', label: 'رصاصة', icon: Zap },
{ key: 'blitz', label: 'خاطف', icon: Timer },
{ key: 'rapid', label: 'سريع', icon: Clock },
{ key: 'classical', label: 'كلاسيكي', icon: Hourglass },
] as const
export function PlayPage() {
const navigate = useNavigate()
const [selectedTC, setSelectedTC] = useState<string>('blitz_5_0')
return (
<PageTransition className="px-4 py-6 flex flex-col gap-6">
<h1 className="text-xl font-bold">اختر اللعبة</h1>
<div className="grid grid-cols-2 gap-3">
{GAMES.map((game, i) => (
<motion.div
key={game.key}
className={`relative rounded-2xl overflow-hidden border-2 ${
game.available
? 'border-gold/40 bg-gradient-to-br from-surface-2 to-surface-1'
: 'border-border bg-surface-1 opacity-60'
} p-4 flex flex-col items-center gap-2`}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.08, type: 'spring', stiffness: 400, damping: 25 }}
whileTap={game.available ? { scale: 0.95 } : undefined}
>
<div className="w-12 h-12 rounded-xl bg-surface-3 flex items-center justify-center">
<span className="text-2xl font-bold text-gold">
{game.key === 'chess' && '♚'}
{game.key === 'backgammon' && '⚀'}
{game.key === 'dominoes' && '’'}
{game.key === 'ludo' && '⚄'}
{game.key === 'trivia' && '?'}
</span>
</div>
<span className="text-sm font-bold">{game.nameAr}</span>
{!game.available && (
<div className="absolute inset-0 flex items-center justify-center bg-background/60 backdrop-blur-sm">
<Lock size={20} className="text-text-muted" />
<span className="mr-2 text-xs text-text-muted">قريبا</span>
</div>
)}
</motion.div>
))}
</div>
<div>
<h2 className="text-base font-bold mb-3">نظام الوقت</h2>
<div className="flex gap-2 mb-3">
{CATEGORIES.map((cat) => {
const isActive = Object.entries(TIME_CONTROLS).find(
([k]) => k === selectedTC
)?.[1].category === cat.key
const Icon = cat.icon
return (
<motion.button
key={cat.key}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-semibold border ${
isActive
? 'bg-gold/10 border-gold/40 text-gold'
: 'bg-surface-2 border-border text-text-muted'
}`}
whileTap={{ scale: 0.93 }}
onClick={() => {
const first = Object.entries(TIME_CONTROLS).find(([, v]) => v.category === cat.key)
if (first) setSelectedTC(first[0])
}}
>
<Icon size={12} />
{cat.label}
</motion.button>
)
})}
</div>
<div className="grid grid-cols-3 gap-2">
{Object.entries(TIME_CONTROLS)
.filter(([, v]) => {
const activeCategory = Object.entries(TIME_CONTROLS).find(
([k]) => k === selectedTC
)?.[1].category
return v.category === activeCategory
})
.map(([key, tc]) => (
<motion.button
key={key}
className={`py-3 rounded-xl text-center font-bold border ${
selectedTC === key
? 'bg-gold/15 border-gold text-gold'
: 'bg-surface-2 border-border text-text-secondary'
}`}
whileTap={{ scale: 0.93 }}
onClick={() => setSelectedTC(key)}
>
{tc.labelAr}
</motion.button>
))}
</div>
</div>
<Button onClick={() => navigate('/matchmaking')} className="w-full" size="lg">
البحث عن خصم
</Button>
</PageTransition>
)
}
import { motion } from 'framer-motion'
import { Settings, TrendingUp, Target, Flame, Trophy } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import { useAuthStore } from '../stores/authStore'
import { PageTransition } from '../components/layout/PageTransition'
import { Card } from '../components/ui/Card'
import { Button } from '../components/ui/Button'
import { supabase } from '../lib/supabase'
export function ProfilePage() {
const { profile } = useAuthStore()
const navigate = useNavigate()
async function handleLogout() {
await supabase.auth.signOut()
navigate('/login', { replace: true })
}
if (!profile) return null
const winRate = profile.total_games_played > 0
? Math.round((profile.total_wins / profile.total_games_played) * 100)
: 0
return (
<PageTransition className="px-4 py-6 flex flex-col gap-5">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<motion.div
className="w-16 h-16 rounded-full bg-gradient-to-br from-gold/30 to-purple/20 border-2 border-gold flex items-center justify-center"
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: 'spring', stiffness: 300, damping: 20 }}
>
<span className="text-2xl font-black text-gold">
{profile.display_name?.charAt(0) || '?'}
</span>
</motion.div>
<div>
<h1 className="text-lg font-bold">{profile.display_name}</h1>
<p className="text-sm text-text-muted">@{profile.username}</p>
</div>
</div>
<motion.button
whileTap={{ scale: 0.9 }}
onClick={() => navigate('/settings')}
className="p-2 rounded-lg bg-surface-2"
>
<Settings size={18} className="text-text-muted" />
</motion.button>
</div>
<Card className="flex items-center gap-4">
<div className="flex-1">
<div className="flex items-center justify-between mb-1">
<span className="text-xs text-text-muted">المستوى {profile.level}</span>
<span className="text-xs text-gold">{profile.xp} XP</span>
</div>
<div className="w-full h-2 rounded-full bg-surface-3 overflow-hidden">
<motion.div
className="h-full rounded-full bg-gradient-to-l from-gold to-gold-light"
initial={{ width: 0 }}
animate={{ width: `${Math.min((profile.xp % 500) / 5, 100)}%` }}
transition={{ duration: 1, ease: 'easeOut' }}
/>
</div>
</div>
</Card>
<div>
<h2 className="text-sm font-bold text-text-secondary mb-2">التقييمات</h2>
<div className="grid grid-cols-2 gap-2">
{[
{ label: 'رصاصة', value: profile.elo_bullet, icon: '1+0' },
{ label: 'خاطف', value: profile.elo_blitz, icon: '5+0' },
{ label: 'سريع', value: profile.elo_rapid, icon: '10+0' },
{ label: 'كلاسيكي', value: profile.elo_classical, icon: '60+0' },
].map((rating, i) => (
<motion.div
key={rating.label}
className="p-3 rounded-xl bg-surface-1 border border-border flex items-center gap-3"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 + i * 0.05 }}
>
<div className="w-8 h-8 rounded-lg bg-surface-3 flex items-center justify-center">
<span className="text-[10px] font-bold text-text-muted">{rating.icon}</span>
</div>
<div>
<p className="text-base font-bold">{rating.value}</p>
<p className="text-[10px] text-text-muted">{rating.label}</p>
</div>
</motion.div>
))}
</div>
</div>
<div>
<h2 className="text-sm font-bold text-text-secondary mb-2">الاحصائيات</h2>
<div className="grid grid-cols-4 gap-2">
<StatMini icon={<Flame size={14} className="text-coral" />} value={profile.best_win_streak} label="افضل سلسلة" />
<StatMini icon={<Trophy size={14} className="text-gold" />} value={profile.total_wins} label="انتصارات" />
<StatMini icon={<Target size={14} className="text-cyan" />} value={`${winRate}%`} label="نسبة الفوز" />
<StatMini icon={<TrendingUp size={14} className="text-purple" />} value={profile.total_games_played} label="مباريات" />
</div>
</div>
<Button variant="ghost" onClick={handleLogout} className="mt-4">
تسجيل الخروج
</Button>
</PageTransition>
)
}
function StatMini({ icon, value, label }: { icon: React.ReactNode; value: number | string; label: string }) {
return (
<div className="flex flex-col items-center gap-1 p-2 rounded-lg bg-surface-1 border border-border">
{icon}
<span className="text-sm font-bold">{value}</span>
<span className="text-[9px] text-text-muted text-center leading-tight">{label}</span>
</div>
)
}
import { motion } from 'framer-motion'
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { supabase } from '../lib/supabase'
import { Button } from '../components/ui/Button'
import { Input } from '../components/ui/Input'
import { GoldCrown } from '../components/icons/GoldCrown'
export function RegisterPage() {
const navigate = useNavigate()
const [username, setUsername] = useState('')
const [displayName, setDisplayName] = useState('')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
async function handleRegister(e: React.FormEvent) {
e.preventDefault()
setError('')
if (!username || !displayName || !email || !password) {
setError('جميع الحقول مطلوبة')
return
}
if (password.length < 6) {
setError('كلمة المرور يجب ان تكون 6 احرف على الاقل')
return
}
setLoading(true)
const { error: authError } = await supabase.auth.signUp({
email,
password,
options: {
data: {
username,
display_name: displayName,
},
},
})
if (authError) {
setError(authError.message)
} else {
navigate('/', { replace: true })
}
setLoading(false)
}
return (
<div className="min-h-dvh flex flex-col items-center justify-center px-6 relative overflow-hidden">
<motion.div
className="absolute inset-0 opacity-30"
style={{
background: 'radial-gradient(ellipse at 50% 30%, rgba(212,168,67,0.15) 0%, transparent 60%)',
}}
/>
<motion.div
className="relative w-full max-w-sm"
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ type: 'spring', stiffness: 300, damping: 25 }}
>
<div className="flex flex-col items-center mb-6">
<GoldCrown size={56} />
<h1 className="mt-3 text-2xl font-black text-gold">حساب جديد</h1>
<p className="mt-1 text-sm text-text-secondary">انضم الى مجتمع EL3AB</p>
</div>
<form onSubmit={handleRegister} className="flex flex-col gap-3.5 p-6 rounded-2xl bg-surface-1/60 backdrop-blur-xl border border-border-gold">
<Input
label="اسم المستخدم"
value={username}
onChange={setUsername}
placeholder="username"
/>
<Input
label="الاسم المعروض"
value={displayName}
onChange={setDisplayName}
placeholder="اسمك في اللعبة"
/>
<Input
label="البريد الالكتروني"
type="email"
value={email}
onChange={setEmail}
placeholder="email@example.com"
/>
<Input
label="كلمة المرور"
type="password"
value={password}
onChange={setPassword}
placeholder="••••••••"
/>
{error && (
<motion.p
className="text-sm text-coral text-center"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
>
{error}
</motion.p>
)}
<Button type="submit" loading={loading} className="w-full mt-2">
انشاء حساب
</Button>
</form>
<motion.button
onClick={() => navigate('/login')}
className="mt-4 w-full text-center text-sm text-text-secondary hover:text-gold transition-colors"
whileTap={{ scale: 0.98 }}
>
لديك حساب بالفعل؟ <span className="text-gold font-semibold">سجل دخول</span>
</motion.button>
</motion.div>
</div>
)
}
import { Volume2, VolumeX, Info } from 'lucide-react'
import { motion } from 'framer-motion'
import { PageTransition } from '../components/layout/PageTransition'
import { Card } from '../components/ui/Card'
import { useUIStore } from '../stores/uiStore'
export function SettingsPage() {
const { soundEnabled, setSoundEnabled } = useUIStore()
return (
<PageTransition className="px-4 py-6 flex flex-col gap-5">
<h1 className="text-xl font-bold">الاعدادات</h1>
<Card className="flex items-center justify-between">
<div className="flex items-center gap-3">
{soundEnabled ? <Volume2 size={18} className="text-cyan" /> : <VolumeX size={18} className="text-text-muted" />}
<span className="text-sm font-semibold">الاصوات</span>
</div>
<motion.button
className={`w-12 h-6 rounded-full p-0.5 ${soundEnabled ? 'bg-cyan' : 'bg-surface-3'}`}
onClick={() => setSoundEnabled(!soundEnabled)}
whileTap={{ scale: 0.9 }}
>
<motion.div
className="w-5 h-5 rounded-full bg-white"
animate={{ x: soundEnabled ? 0 : 24 }}
transition={{ type: 'spring', stiffness: 500, damping: 30 }}
/>
</motion.button>
</Card>
<Card className="flex items-center gap-3">
<Info size={18} className="text-text-muted" />
<div>
<p className="text-sm font-semibold">EL3AB Player</p>
<p className="text-xs text-text-muted">الاصدار 1.0.0</p>
</div>
</Card>
</PageTransition>
)
}
import { ShoppingBag } from 'lucide-react'
import { motion } from 'framer-motion'
import { PageTransition } from '../components/layout/PageTransition'
export function ShopPage() {
return (
<PageTransition className="px-4 py-6 flex flex-col gap-5">
<h1 className="text-xl font-bold">المتجر</h1>
<div className="flex-1 flex flex-col items-center justify-center py-12">
<motion.div
className="w-16 h-16 rounded-full bg-surface-2 border border-border flex items-center justify-center mb-4"
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: 'spring', stiffness: 300, damping: 20 }}
>
<ShoppingBag size={24} className="text-gold" />
</motion.div>
<p className="text-text-muted text-sm">المتجر قريبا</p>
</div>
</PageTransition>
)
}
import { motion } from 'framer-motion'
import { useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { useUIStore } from '../stores/uiStore'
import { GoldCrown } from '../components/icons/GoldCrown'
export function SplashPage() {
const navigate = useNavigate()
const { setSplashShown } = useUIStore()
useEffect(() => {
const timer = setTimeout(() => {
setSplashShown(true)
navigate('/', { replace: true })
}, 2500)
return () => clearTimeout(timer)
}, [navigate, setSplashShown])
return (
<div className="fixed inset-0 z-[200] bg-background flex flex-col items-center justify-center">
<motion.div
className="absolute inset-0"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 1 }}
>
{Array.from({ length: 20 }).map((_, i) => (
<motion.div
key={i}
className="absolute w-1 h-1 rounded-full bg-gold/40"
initial={{
x: '50%',
y: '50%',
scale: 0,
}}
animate={{
x: `${20 + Math.random() * 60}%`,
y: `${20 + Math.random() * 60}%`,
scale: [0, 1, 0],
opacity: [0, 0.8, 0],
}}
transition={{
duration: 2 + Math.random() * 1.5,
delay: 0.3 + Math.random() * 0.8,
ease: 'easeOut',
}}
/>
))}
</motion.div>
<motion.div
initial={{ scale: 0.5, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ type: 'spring', stiffness: 200, damping: 15, delay: 0.2 }}
>
<GoldCrown size={80} />
</motion.div>
<motion.h1
className="mt-6 text-4xl font-black text-gold tracking-wider"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.6, duration: 0.5 }}
>
EL3AB
</motion.h1>
<motion.p
className="mt-2 text-lg text-text-secondary font-semibold"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 1, duration: 0.5 }}
>
العب
</motion.p>
<motion.div
className="mt-12 w-16 h-0.5 rounded-full bg-gold/30 overflow-hidden"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 1.2 }}
>
<motion.div
className="h-full bg-gold rounded-full"
initial={{ x: '-100%' }}
animate={{ x: '100%' }}
transition={{ duration: 1, repeat: Infinity, ease: 'easeInOut' }}
/>
</motion.div>
</div>
)
}
import { motion } from 'framer-motion'
import { Trophy, Users, Calendar } from 'lucide-react'
import { PageTransition } from '../components/layout/PageTransition'
import { Card } from '../components/ui/Card'
export function TournamentsPage() {
return (
<PageTransition className="px-4 py-6 flex flex-col gap-5">
<h1 className="text-xl font-bold">البطولات</h1>
<div className="flex flex-col gap-3">
<Card glow className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-lg bg-gold/10 flex items-center justify-center">
<Trophy size={16} className="text-gold" />
</div>
<div>
<h3 className="text-sm font-bold">لا توجد بطولات حالية</h3>
<p className="text-xs text-text-muted">سيتم اضافة بطولات قريبا</p>
</div>
</div>
</div>
</Card>
{[1, 2].map((i) => (
<motion.div
key={i}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.1 }}
>
<Card className="flex flex-col gap-3 opacity-50">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-lg bg-surface-3 flex items-center justify-center">
<Trophy size={16} className="text-text-muted" />
</div>
<div>
<div className="w-32 h-3 rounded bg-surface-3" />
<div className="w-20 h-2 rounded bg-surface-3 mt-1.5" />
</div>
</div>
<div className="px-2 py-0.5 rounded-full bg-surface-3 text-[10px] text-text-muted">
قريبا
</div>
</div>
<div className="flex items-center gap-4 text-[10px] text-text-muted">
<span className="flex items-center gap-1">
<Users size={10} /> --
</span>
<span className="flex items-center gap-1">
<Calendar size={10} /> --
</span>
</div>
</Card>
</motion.div>
))}
</div>
</PageTransition>
)
}
import { create } from 'zustand'
import type { Session, User } from '@supabase/supabase-js'
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
country_code: string | null
elo_bullet: number
elo_blitz: number
elo_rapid: number
elo_classical: number
xp: number
level: number
coins: number
gems: number
total_games_played: number
total_wins: number
total_draws: number
total_losses: number
win_streak: number
best_win_streak: number
is_online: boolean
avatar_frame_id: string | null
daily_streak: number
last_daily_reward: string | null
created_at: string
}
interface AuthState {
user: User | null
session: Session | null
profile: Profile | null
loading: boolean
setUser: (user: User | null) => void
setSession: (session: Session | null) => void
setProfile: (profile: Profile | null) => void
setLoading: (loading: boolean) => void
reset: () => void
}
export const useAuthStore = create<AuthState>((set) => ({
user: null,
session: null,
profile: null,
loading: true,
setUser: (user) => set({ user }),
setSession: (session) => set({ session }),
setProfile: (profile) => set({ profile }),
setLoading: (loading) => set({ loading }),
reset: () => set({ user: null, session: null, profile: null, loading: false }),
}))
import { create } from 'zustand'
interface MatchState {
matchId: string | null
status: 'idle' | 'searching' | 'found' | 'playing' | 'ended'
gameKey: string
timeControl: string | null
opponentId: string | null
setMatchId: (id: string | null) => void
setStatus: (status: MatchState['status']) => void
setGameKey: (key: string) => void
setTimeControl: (tc: string | null) => void
setOpponentId: (id: string | null) => void
reset: () => void
}
export const useMatchStore = create<MatchState>((set) => ({
matchId: null,
status: 'idle',
gameKey: 'chess',
timeControl: null,
opponentId: null,
setMatchId: (id) => set({ matchId: id }),
setStatus: (status) => set({ status }),
setGameKey: (key) => set({ gameKey: key }),
setTimeControl: (tc) => set({ timeControl: tc }),
setOpponentId: (id) => set({ opponentId: id }),
reset: () => set({ matchId: null, status: 'idle', timeControl: null, opponentId: null }),
}))
import { create } from 'zustand'
interface Notification {
id: string
type: string
title: string
title_ar: string | null
body: string | null
body_ar: string | null
data: Record<string, unknown>
is_read: boolean
created_at: string
}
interface Toast {
id: string
title: string
type: 'success' | 'error' | 'info'
}
interface NotificationState {
notifications: Notification[]
unreadCount: number
toasts: Toast[]
setNotifications: (notifications: Notification[]) => void
addNotification: (notification: Notification) => void
markAsRead: (id: string) => void
showToast: (toast: Omit<Toast, 'id'>) => void
dismissToast: (id: string) => void
}
export const useNotificationStore = create<NotificationState>((set) => ({
notifications: [],
unreadCount: 0,
toasts: [],
setNotifications: (notifications) =>
set({ notifications, unreadCount: notifications.filter((n) => !n.is_read).length }),
addNotification: (notification) =>
set((state) => ({
notifications: [notification, ...state.notifications],
unreadCount: state.unreadCount + (notification.is_read ? 0 : 1),
})),
markAsRead: (id) =>
set((state) => ({
notifications: state.notifications.map((n) => (n.id === id ? { ...n, is_read: true } : n)),
unreadCount: Math.max(0, state.unreadCount - 1),
})),
showToast: (toast) =>
set((state) => ({
toasts: [...state.toasts, { ...toast, id: crypto.randomUUID() }],
})),
dismissToast: (id) =>
set((state) => ({
toasts: state.toasts.filter((t) => t.id !== id),
})),
}))
import { create } from 'zustand'
interface UIState {
soundEnabled: boolean
splashShown: boolean
activeModal: string | null
setSoundEnabled: (enabled: boolean) => void
setSplashShown: (shown: boolean) => void
openModal: (id: string) => void
closeModal: () => void
}
export const useUIStore = create<UIState>((set) => ({
soundEnabled: true,
splashShown: localStorage.getItem('el3ab_splash_shown') === 'true',
activeModal: null,
setSoundEnabled: (enabled) => set({ soundEnabled: enabled }),
setSplashShown: (shown) => {
localStorage.setItem('el3ab_splash_shown', 'true')
set({ splashShown: shown })
},
openModal: (id) => set({ activeModal: id }),
closeModal: () => set({ activeModal: null }),
}))
{
"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": ["node"],
"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()],
resolve: {
alias: {
'@': '/src',
},
},
})
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