Commit f574992e authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat: complete all remaining phases (multiplayer, social, tournaments, shop, polish)

Phase 5: Real-time multiplayer with matchmaking queue, live clock sync,
and Supabase Realtime for move broadcasting between players.

Phase 7: Friends system with requests/accept/block, notifications with
Realtime subscription, and online presence heartbeat.

Phase 8: Tournaments list with registration and status filters,
leaderboard with time control/period filters and top-3 podium.

Phase 9: Cosmetics shop with rarity system, purchase flow,
daily reward with streak multiplier.

Phase 10: Edit profile modal, recent games on home/profile,
improved settings/404/toast system.
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 4ce8aec3
import { motion, AnimatePresence } from 'framer-motion'
import { Gift, Coins, Flame, Sparkles } from 'lucide-react'
import { Button } from './ui/Button'
interface DailyRewardModalProps {
open: boolean
streak: number
reward: number
loading: boolean
onClaim: () => void
onClose: () => void
}
export function DailyRewardModal({ open, streak, reward, loading, onClaim, onClose }: DailyRewardModalProps) {
return (
<AnimatePresence>
{open && (
<motion.div
className="fixed inset-0 z-50 bg-background/80 backdrop-blur-sm flex items-center justify-center p-6"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
>
<motion.div
className="w-full max-w-[320px] rounded-3xl bg-surface-1 border border-border p-7 flex flex-col items-center gap-5 relative overflow-hidden"
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.8, opacity: 0 }}
transition={{ type: 'spring', stiffness: 300, damping: 25 }}
onClick={(e) => e.stopPropagation()}
>
<div className="absolute top-0 left-0 w-full h-32 bg-gradient-to-b from-gold/8 to-transparent" />
<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 relative"
animate={{ rotate: [0, 5, -5, 0] }}
transition={{ duration: 3, repeat: Infinity }}
>
<Gift size={36} className="text-gold" />
<motion.div
className="absolute -top-1 -right-1"
animate={{ scale: [1, 1.3, 1], opacity: [1, 0.6, 1] }}
transition={{ duration: 1.5, repeat: Infinity }}
>
<Sparkles size={16} className="text-gold" />
</motion.div>
</motion.div>
<div className="text-center z-10">
<h3 className="text-xl font-black text-text-primary">المكافأة اليومية</h3>
<p className="text-sm text-text-muted mt-1">ادخل كل يوم واحصل على مكافآت اكثر</p>
</div>
<div className="flex items-center gap-3 bg-surface-2 rounded-2xl px-5 py-3 border border-border">
<Flame size={20} className="text-coral" />
<span className="text-sm font-bold text-text-secondary">سلسلة:</span>
<span className="text-lg font-black text-coral">{streak}</span>
<span className="text-sm text-text-muted">يوم</span>
</div>
<motion.div
className="flex items-center gap-3 bg-gold/10 rounded-2xl px-6 py-4 border border-gold/30"
animate={{ scale: [1, 1.02, 1] }}
transition={{ duration: 2, repeat: Infinity }}
>
<Coins size={28} className="text-gold" />
<span className="text-3xl font-black text-gold">+{reward}</span>
</motion.div>
<Button
variant="gold"
size="lg"
onClick={onClaim}
loading={loading}
className="w-full"
>
استلم المكافأة
</Button>
</motion.div>
</motion.div>
)}
</AnimatePresence>
)
}
...@@ -2,8 +2,13 @@ import { Outlet } from 'react-router-dom' ...@@ -2,8 +2,13 @@ import { Outlet } from 'react-router-dom'
import { Header } from './Header' import { Header } from './Header'
import { BottomNav } from './BottomNav' import { BottomNav } from './BottomNav'
import { ToastContainer } from '../ui/ToastContainer' import { ToastContainer } from '../ui/ToastContainer'
import { usePresence } from '../../hooks/usePresence'
import { useNotifications } from '../../hooks/useNotifications'
export function AppShell() { export function AppShell() {
usePresence()
useNotifications()
return ( return (
<div className="flex flex-col min-h-dvh bg-[#0a0a12]"> <div className="flex flex-col min-h-dvh bg-[#0a0a12]">
<Header /> <Header />
......
...@@ -10,16 +10,16 @@ const TOAST_ICONS = { ...@@ -10,16 +10,16 @@ const TOAST_ICONS = {
} }
const TOAST_COLORS = { const TOAST_COLORS = {
success: 'border-cyan/40 bg-cyan/10', success: 'border-cyan/40 bg-cyan/10 text-cyan',
error: 'border-coral/40 bg-coral/10', error: 'border-coral/40 bg-coral/10 text-coral',
info: 'border-royal-blue/40 bg-royal-blue/10', info: 'border-gold/40 bg-gold/10 text-gold',
} }
export function ToastContainer() { export function ToastContainer() {
const { toasts, dismissToast } = useNotificationStore() const { toasts, dismissToast } = useNotificationStore()
return ( return (
<div className="fixed top-16 left-4 right-4 z-[100] flex flex-col gap-2 pointer-events-none"> <div className="fixed top-4 left-1/2 -translate-x-1/2 z-[60] flex flex-col items-center gap-2 pointer-events-none w-[calc(100%-2rem)] max-w-[360px]">
<AnimatePresence mode="popLayout"> <AnimatePresence mode="popLayout">
{toasts.map((toast) => { {toasts.map((toast) => {
const Icon = TOAST_ICONS[toast.type] const Icon = TOAST_ICONS[toast.type]
...@@ -62,14 +62,14 @@ function ToastItem({ ...@@ -62,14 +62,14 @@ function ToastItem({
return ( return (
<motion.div <motion.div
layout layout
initial={{ opacity: 0, y: -40, scale: 0.95 }} initial={{ opacity: 0, y: -40, scale: 0.9 }}
animate={{ opacity: 1, y: 0, scale: 1 }} animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -20, scale: 0.95 }} exit={{ opacity: 0, y: -30, scale: 0.9 }}
transition={{ type: 'spring', stiffness: 400, damping: 25 }} 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}`} className={`pointer-events-auto w-full rounded-xl border p-3.5 flex items-center gap-3 backdrop-blur-xl shadow-lg ${colorClass}`}
onClick={() => onDismiss(id)} onClick={() => onDismiss(id)}
> >
<Icon size={18} /> <Icon size={18} className="flex-shrink-0" />
<span className="text-sm font-medium text-text-primary">{title}</span> <span className="text-sm font-medium text-text-primary">{title}</span>
</motion.div> </motion.div>
) )
......
import { useState, useEffect } from 'react'
import { supabase } from '../lib/supabase'
import { useAuthStore } from '../stores/authStore'
export function useDailyReward() {
const { user, profile, setProfile } = useAuthStore()
const [canClaim, setCanClaim] = useState(false)
const [loading, setLoading] = useState(false)
const streak = profile?.daily_streak || 0
const multiplier = Math.min(streak + 1, 7)
const reward = 25 * multiplier
useEffect(() => {
if (!profile) {
setCanClaim(false)
return
}
const today = new Date().toISOString().split('T')[0]
const lastClaim = profile.last_daily_reward
? new Date(profile.last_daily_reward).toISOString().split('T')[0]
: null
setCanClaim(lastClaim !== today)
}, [profile])
async function claim(): Promise<{ success: boolean; error?: string }> {
if (!user || !profile) return { success: false, error: 'غير مسجل' }
if (!canClaim) return { success: false, error: 'تم الاستلام اليوم' }
setLoading(true)
const today = new Date().toISOString().split('T')[0]
const lastClaim = profile.last_daily_reward
? new Date(profile.last_daily_reward).toISOString().split('T')[0]
: null
const yesterday = new Date()
yesterday.setDate(yesterday.getDate() - 1)
const yesterdayStr = yesterday.toISOString().split('T')[0]
const newStreak = lastClaim === yesterdayStr ? streak + 1 : 1
const newMultiplier = Math.min(newStreak, 7)
const actualReward = 25 * newMultiplier
const newCoins = profile.coins + actualReward
const { error } = await supabase
.from('profiles')
.update({
coins: newCoins,
daily_streak: newStreak,
last_daily_reward: new Date().toISOString(),
})
.eq('id', user.id)
if (error) {
setLoading(false)
return { success: false, error: 'فشل في استلام المكافأة' }
}
await supabase.from('economy_transactions').insert({
player_id: user.id,
type: 'credit',
currency: 'coins',
amount: actualReward,
balance_after: newCoins,
reason: `daily_reward:day_${newStreak}`,
})
setProfile({
...profile,
coins: newCoins,
daily_streak: newStreak,
last_daily_reward: new Date().toISOString(),
})
setCanClaim(false)
setLoading(false)
return { success: true }
}
return { canClaim, streak, reward, claim, loading }
}
import { useEffect, useState, useCallback } from 'react'
import { supabase } from '../lib/supabase'
import { useAuthStore } from '../stores/authStore'
import type { RealtimeChannel } from '@supabase/supabase-js'
interface FriendProfile {
id: string
username: string
display_name: string
avatar_url: string | null
elo_blitz: number
is_online: boolean
last_seen_at: string | null
}
interface Friendship {
id: string
requester_id: string
addressee_id: string
status: 'pending' | 'accepted' | 'blocked'
created_at: string
profile: FriendProfile
}
export function useFriends() {
const { user } = useAuthStore()
const [friends, setFriends] = useState<Friendship[]>([])
const [pendingReceived, setPendingReceived] = useState<Friendship[]>([])
const [pendingSent, setPendingSent] = useState<Friendship[]>([])
const [loading, setLoading] = useState(true)
const fetchFriendships = useCallback(async () => {
if (!user) return
const { data: rows } = await supabase
.from('friendships')
.select('*')
.or(`requester_id.eq.${user.id},addressee_id.eq.${user.id}`)
if (!rows) {
setLoading(false)
return
}
const otherIds = rows.map((r) => (r.requester_id === user.id ? r.addressee_id : r.requester_id))
let profilesMap: Record<string, FriendProfile> = {}
if (otherIds.length > 0) {
const { data: profiles } = await supabase
.from('profiles')
.select('id, username, display_name, avatar_url, elo_blitz, is_online, last_seen_at')
.in('id', otherIds)
if (profiles) {
profilesMap = Object.fromEntries(profiles.map((p) => [p.id, p]))
}
}
const enriched: Friendship[] = rows
.filter((r) => r.status !== 'blocked')
.map((r) => {
const otherId = r.requester_id === user.id ? r.addressee_id : r.requester_id
return {
...r,
profile: profilesMap[otherId] || {
id: otherId,
username: '',
display_name: '',
avatar_url: null,
elo_blitz: 1500,
is_online: false,
last_seen_at: null,
},
}
})
const accepted = enriched
.filter((f) => f.status === 'accepted')
.sort((a, b) => {
if (a.profile.is_online && !b.profile.is_online) return -1
if (!a.profile.is_online && b.profile.is_online) return 1
return 0
})
const received = enriched.filter((f) => f.status === 'pending' && f.addressee_id === user.id)
const sent = enriched.filter((f) => f.status === 'pending' && f.requester_id === user.id)
setFriends(accepted)
setPendingReceived(received)
setPendingSent(sent)
setLoading(false)
}, [user])
useEffect(() => {
if (!user) return
let channel: RealtimeChannel | null = null
fetchFriendships()
channel = supabase
.channel(`friendships:${user.id}`)
.on(
'postgres_changes',
{
event: '*',
schema: 'public',
table: 'friendships',
filter: `requester_id=eq.${user.id}`,
},
() => fetchFriendships()
)
.on(
'postgres_changes',
{
event: '*',
schema: 'public',
table: 'friendships',
filter: `addressee_id=eq.${user.id}`,
},
() => fetchFriendships()
)
.subscribe()
return () => {
if (channel) {
supabase.removeChannel(channel)
}
}
}, [user, fetchFriendships])
const sendRequest = useCallback(
async (userId: string) => {
if (!user) return
await supabase.from('friendships').insert({
requester_id: user.id,
addressee_id: userId,
status: 'pending',
})
},
[user]
)
const acceptRequest = useCallback(async (friendshipId: string) => {
await supabase
.from('friendships')
.update({ status: 'accepted', updated_at: new Date().toISOString() })
.eq('id', friendshipId)
}, [])
const rejectRequest = useCallback(async (friendshipId: string) => {
await supabase.from('friendships').delete().eq('id', friendshipId)
}, [])
const removeFriend = useCallback(async (friendshipId: string) => {
await supabase.from('friendships').delete().eq('id', friendshipId)
}, [])
const blockUser = useCallback(async (friendshipId: string) => {
await supabase
.from('friendships')
.update({ status: 'blocked', updated_at: new Date().toISOString() })
.eq('id', friendshipId)
}, [])
return {
friends,
pendingReceived,
pendingSent,
loading,
sendRequest,
acceptRequest,
rejectRequest,
removeFriend,
blockUser,
}
}
import { useState, useEffect, useCallback } from 'react'
import { supabase } from '../lib/supabase'
import { useAuthStore } from '../stores/authStore'
type TimeControlType = 'bullet' | 'blitz' | 'rapid' | 'classical'
type Period = 'weekly' | 'monthly' | 'all_time'
interface LeaderboardEntry {
rank: number
player_id: string
rating: number
games_played: number
win_rate: number
display_name: string
username: string
avatar_url: string | null
country_code: string | null
}
export function useLeaderboard(timeControlType: TimeControlType, period: Period) {
const [entries, setEntries] = useState<LeaderboardEntry[]>([])
const [loading, setLoading] = useState(true)
const [myRank, setMyRank] = useState<number | null>(null)
const { user } = useAuthStore()
const eloField: Record<TimeControlType, string> = {
bullet: 'elo_bullet',
blitz: 'elo_blitz',
rapid: 'elo_rapid',
classical: 'elo_classical',
}
const fetchLeaderboard = useCallback(async () => {
setLoading(true)
const { data: lbData } = await supabase
.from('leaderboards')
.select('rank, player_id, rating, games_played, win_rate, profiles(display_name, username, avatar_url, country_code)')
.eq('time_control_type', timeControlType)
.eq('period', period)
.order('rank', { ascending: true })
.limit(100)
if (lbData && lbData.length > 0) {
const mapped: LeaderboardEntry[] = lbData.map((row: any) => ({
rank: row.rank,
player_id: row.player_id,
rating: row.rating,
games_played: row.games_played,
win_rate: row.win_rate,
display_name: row.profiles?.display_name ?? 'لاعب',
username: row.profiles?.username ?? '',
avatar_url: row.profiles?.avatar_url ?? null,
country_code: row.profiles?.country_code ?? null,
}))
setEntries(mapped)
if (user) {
const myEntry = mapped.find((e) => e.player_id === user.id)
setMyRank(myEntry?.rank ?? null)
}
} else {
const field = eloField[timeControlType]
const { data: profiles } = await supabase
.from('profiles')
.select('id, display_name, username, avatar_url, country_code, total_games_played, total_wins, elo_bullet, elo_blitz, elo_rapid, elo_classical')
.order(field, { ascending: false })
.limit(100)
if (profiles) {
const mapped: LeaderboardEntry[] = profiles.map((p: any, i: number) => ({
rank: i + 1,
player_id: p.id,
rating: p[field],
games_played: p.total_games_played,
win_rate: p.total_games_played > 0 ? Math.round((p.total_wins / p.total_games_played) * 100) : 0,
display_name: p.display_name ?? 'لاعب',
username: p.username ?? '',
avatar_url: p.avatar_url ?? null,
country_code: p.country_code ?? null,
}))
setEntries(mapped)
if (user) {
const myEntry = mapped.find((e) => e.player_id === user.id)
setMyRank(myEntry?.rank ?? null)
}
}
}
setLoading(false)
}, [timeControlType, period, user])
useEffect(() => {
fetchLeaderboard()
}, [fetchLeaderboard])
return { entries, loading, myRank }
}
import { useState, useEffect, useRef, useCallback } from 'react'
import { useNavigate } from 'react-router-dom'
import { supabase } from '../lib/supabase'
import { joinQueue, leaveQueue, findMatch } from '../lib/matchmaking'
import { useAuthStore } from '../stores/authStore'
import { useMatchStore } from '../stores/matchStore'
import { getTimeControlCategory } from '../lib/matchmaking'
import { playSound } from '../lib/sounds'
import type { TIME_CONTROLS } from '../lib/constants'
import type { RealtimeChannel } from '@supabase/supabase-js'
type TimeControlKey = keyof typeof TIME_CONTROLS
export function useMatchmaking(timeControl: TimeControlKey, gameKey: string = 'chess') {
const navigate = useNavigate()
const { profile, user } = useAuthStore()
const { setMatchId, setStatus, setOpponentId } = useMatchStore()
const [searching, setSearching] = useState(false)
const [elapsed, setElapsed] = useState(0)
const [matchFound, setMatchFound] = useState(false)
const channelRef = useRef<RealtimeChannel | null>(null)
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
const searchIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
const startTimeRef = useRef<number>(0)
const ratingRangeRef = useRef(200)
const activeRef = useRef(false)
const rating = profile
? (profile[getTimeControlCategory(timeControl) as keyof typeof profile] as number)
: 1500
const startSearch = useCallback(async () => {
if (!user || !profile) return
activeRef.current = true
setSearching(true)
setElapsed(0)
startTimeRef.current = Date.now()
ratingRangeRef.current = 200
try {
const { channel } = await joinQueue(gameKey, timeControl, rating)
channelRef.current = channel
channelRef.current.on(
'postgres_changes',
{
event: 'UPDATE',
schema: 'public',
table: 'matchmaking_queue',
filter: `player_id=eq.${user.id}`,
},
(payload) => {
const row = payload.new as { status: string; match_id: string; matched_with: string }
if (row.status === 'matched' && row.match_id) {
handleMatchFound(row.match_id, row.matched_with)
}
}
)
timerRef.current = setInterval(() => {
setElapsed(Math.floor((Date.now() - startTimeRef.current) / 1000))
}, 1000)
const immediateMatch = await findMatch(gameKey, timeControl, rating, ratingRangeRef.current)
if (immediateMatch) {
handleMatchFound(immediateMatch, '')
return
}
searchIntervalRef.current = setInterval(async () => {
if (!activeRef.current) return
ratingRangeRef.current = Math.min(ratingRangeRef.current + 50, 500)
const found = await findMatch(gameKey, timeControl, rating, ratingRangeRef.current)
if (found) {
handleMatchFound(found, '')
}
}, 5000)
} catch {
setSearching(false)
activeRef.current = false
}
}, [user, profile, gameKey, timeControl, rating])
const handleMatchFound = useCallback((matchId: string, opponentId: string) => {
if (!activeRef.current) return
activeRef.current = false
cleanup()
setMatchFound(true)
setMatchId(matchId)
setStatus('found')
if (opponentId) setOpponentId(opponentId)
playSound('matchFound')
setTimeout(() => {
navigate(`/game/${matchId}`)
}, 1500)
}, [navigate, setMatchId, setStatus, setOpponentId])
const cleanup = useCallback(() => {
if (timerRef.current) {
clearInterval(timerRef.current)
timerRef.current = null
}
if (searchIntervalRef.current) {
clearInterval(searchIntervalRef.current)
searchIntervalRef.current = null
}
if (channelRef.current) {
supabase.removeChannel(channelRef.current)
channelRef.current = null
}
}, [])
const cancel = useCallback(async () => {
activeRef.current = false
cleanup()
setSearching(false)
setElapsed(0)
setStatus('idle')
await leaveQueue()
}, [cleanup, setStatus])
useEffect(() => {
return () => {
activeRef.current = false
cleanup()
leaveQueue()
}
}, [cleanup])
return { searching, elapsed, matchFound, startSearch, cancel }
}
import { useState, useEffect, useRef, useCallback } from 'react'
import { Chess } from 'chess.js'
import { supabase } from '../lib/supabase'
import { subscribeToMatch, fetchMatch, makeMove as sendMove, endMatch, setMatchReady } from '../lib/realtime'
import type { MatchRow } from '../lib/realtime'
import { useAuthStore } from '../stores/authStore'
import { playSound } from '../lib/sounds'
import type { RealtimeChannel } from '@supabase/supabase-js'
export function useMultiplayerGame(matchId: string) {
const { user } = useAuthStore()
const [game, setGame] = useState(new Chess())
const [myColor, setMyColor] = useState<'w' | 'b'>('w')
const [isMyTurn, setIsMyTurn] = useState(false)
const [myTimeMs, setMyTimeMs] = useState(0)
const [opponentTimeMs, setOpponentTimeMs] = useState(0)
const [matchData, setMatchData] = useState<MatchRow | null>(null)
const [gameOver, setGameOver] = useState(false)
const [result, setResult] = useState<string | null>(null)
const [opponentProfile, setOpponentProfile] = useState<{ username: string; display_name: string } | null>(null)
const [lastMove, setLastMove] = useState<{ from: string; to: string } | null>(null)
const [loading, setLoading] = useState(true)
const channelRef = useRef<RealtimeChannel | null>(null)
const rafRef = useRef<number | null>(null)
const lastTickRef = useRef<number>(0)
const myTimeMsRef = useRef(0)
const opponentTimeMsRef = useRef(0)
const isMyTurnRef = useRef(false)
const gameOverRef = useRef(false)
const matchDataRef = useRef<MatchRow | null>(null)
useEffect(() => {
if (!matchId || !user) return
let mounted = true
async function init() {
const match = await fetchMatch(matchId)
if (!match || !mounted) return
const color = match.white_player_id === user!.id ? 'w' : 'b'
setMyColor(color)
const g = new Chess(match.current_fen)
setGame(g)
const turn = g.turn() === color
setIsMyTurn(turn)
isMyTurnRef.current = turn
const myTime = color === 'w' ? match.white_time_remaining_ms : match.black_time_remaining_ms
const oppTime = color === 'w' ? match.black_time_remaining_ms : match.white_time_remaining_ms
setMyTimeMs(myTime)
setOpponentTimeMs(oppTime)
myTimeMsRef.current = myTime
opponentTimeMsRef.current = oppTime
setMatchData(match)
matchDataRef.current = match
if (match.moves && match.moves.length > 0) {
const lastMoveData = match.moves[match.moves.length - 1]
setLastMove({ from: lastMoveData.from, to: lastMoveData.to })
}
if (match.status === 'completed' || match.result) {
setGameOver(true)
gameOverRef.current = true
setResult(match.result)
}
const opponentId = color === 'w' ? match.black_player_id : match.white_player_id
const { data: profile } = await supabase
.from('profiles')
.select('username, display_name')
.eq('id', opponentId)
.single()
if (profile && mounted) setOpponentProfile(profile)
if (match.status === 'waiting') {
await setMatchReady(matchId)
}
setLoading(false)
}
init()
const channel = subscribeToMatch(matchId, (updated) => {
if (!mounted) return
handleMatchUpdate(updated)
})
channelRef.current = channel
return () => {
mounted = false
if (channelRef.current) {
supabase.removeChannel(channelRef.current)
}
if (rafRef.current) {
cancelAnimationFrame(rafRef.current)
}
}
}, [matchId, user])
useEffect(() => {
if (gameOver || loading) {
if (rafRef.current) cancelAnimationFrame(rafRef.current)
return
}
if (!matchData || matchData.status === 'waiting' || matchData.status === 'ready') return
lastTickRef.current = performance.now()
function tick() {
if (gameOverRef.current) return
const now = performance.now()
const delta = now - lastTickRef.current
lastTickRef.current = now
if (isMyTurnRef.current) {
myTimeMsRef.current = Math.max(0, myTimeMsRef.current - delta)
setMyTimeMs(Math.round(myTimeMsRef.current))
if (myTimeMsRef.current <= 0) {
handleTimeout('me')
return
}
} else {
opponentTimeMsRef.current = Math.max(0, opponentTimeMsRef.current - delta)
setOpponentTimeMs(Math.round(opponentTimeMsRef.current))
if (opponentTimeMsRef.current <= 0) {
handleTimeout('opponent')
return
}
}
rafRef.current = requestAnimationFrame(tick)
}
rafRef.current = requestAnimationFrame(tick)
return () => {
if (rafRef.current) cancelAnimationFrame(rafRef.current)
}
}, [gameOver, loading, matchData?.status])
const handleMatchUpdate = useCallback((updated: MatchRow) => {
setMatchData(updated)
matchDataRef.current = updated
if (updated.status === 'completed' || updated.result) {
setGameOver(true)
gameOverRef.current = true
setResult(updated.result)
if (rafRef.current) cancelAnimationFrame(rafRef.current)
return
}
const g = new Chess(updated.current_fen)
setGame(g)
const color = updated.white_player_id === user?.id ? 'w' : 'b'
const turn = g.turn() === color
setIsMyTurn(turn)
isMyTurnRef.current = turn
const myTime = color === 'w' ? updated.white_time_remaining_ms : updated.black_time_remaining_ms
const oppTime = color === 'w' ? updated.black_time_remaining_ms : updated.white_time_remaining_ms
setMyTimeMs(myTime)
setOpponentTimeMs(oppTime)
myTimeMsRef.current = myTime
opponentTimeMsRef.current = oppTime
if (updated.moves && updated.moves.length > 0) {
const lastMoveData = updated.moves[updated.moves.length - 1]
setLastMove({ from: lastMoveData.from, to: lastMoveData.to })
if (!turn) {
// Opponent just moved
} else {
const prevMove = updated.moves[updated.moves.length - 1]
if (prevMove) {
const tempG = new Chess(updated.current_fen)
if (tempG.inCheck()) {
playSound('check')
} else {
playSound('move')
}
}
}
}
lastTickRef.current = performance.now()
}, [user])
const handleTimeout = useCallback(async (who: 'me' | 'opponent') => {
if (gameOverRef.current) return
gameOverRef.current = true
setGameOver(true)
const resultStr = who === 'me'
? (myColor === 'w' ? 'white_timeout' : 'black_timeout')
: (myColor === 'w' ? 'black_timeout' : 'white_timeout')
setResult(resultStr)
await endMatch(matchId, resultStr)
playSound(who === 'me' ? 'lose' : 'win')
}, [matchId, myColor])
const handleMakeMove = useCallback(async (from: string, to: string, promotion?: string) => {
if (!isMyTurnRef.current || gameOverRef.current || !matchDataRef.current) return false
const g = new Chess(game.fen())
const move = g.move({ from, to, promotion: promotion || 'q' })
if (!move) return false
setGame(g)
setLastMove({ from, to })
setIsMyTurn(false)
isMyTurnRef.current = false
const increment = matchDataRef.current.increment_ms
const newTimeRemaining = Math.round(myTimeMsRef.current + increment)
myTimeMsRef.current = newTimeRemaining
setMyTimeMs(newTimeRemaining)
const newMoves = [
...(matchDataRef.current.moves || []),
{ from, to, san: move.san, fen: g.fen(), timestamp: Date.now() },
]
playSound(move.captured ? 'capture' : 'move')
if (g.inCheck()) playSound('check')
await sendMove(
matchId,
g.fen(),
newMoves,
matchDataRef.current.move_count + 1,
newTimeRemaining,
myColor
)
if (g.isGameOver()) {
let resultStr: string
if (g.isCheckmate()) {
resultStr = myColor === 'w' ? 'white_wins' : 'black_wins'
playSound('win')
} else {
resultStr = 'draw'
}
setGameOver(true)
gameOverRef.current = true
setResult(resultStr)
await endMatch(matchId, resultStr)
}
return true
}, [game, matchId, myColor])
const resign = useCallback(async () => {
if (gameOverRef.current) return
gameOverRef.current = true
setGameOver(true)
const resultStr = myColor === 'w' ? 'white_resign' : 'black_resign'
setResult(resultStr)
await endMatch(matchId, resultStr)
playSound('lose')
}, [matchId, myColor])
const offerDraw = useCallback(async () => {
// Simplified: immediately ends as draw (full draw offer flow would need another table)
if (gameOverRef.current) return
gameOverRef.current = true
setGameOver(true)
setResult('draw')
await endMatch(matchId, 'draw')
}, [matchId])
return {
game,
myColor,
isMyTurn,
myTimeMs,
opponentTimeMs,
matchData,
gameOver,
result,
opponentProfile,
lastMove,
loading,
makeMove: handleMakeMove,
resign,
offerDraw,
}
}
import { useEffect, useState, useCallback } from 'react'
import { supabase } from '../lib/supabase'
import { useAuthStore } from '../stores/authStore'
import { useNotificationStore } from '../stores/notificationStore'
import { playSound } from '../lib/sounds'
import type { RealtimeChannel } from '@supabase/supabase-js'
export function useNotifications() {
const { user } = useAuthStore()
const { notifications, setNotifications, addNotification, markAsRead: storeMarkAsRead, showToast } =
useNotificationStore()
const [loading, setLoading] = useState(true)
useEffect(() => {
if (!user) return
let channel: RealtimeChannel | null = null
async function fetchNotifications() {
const { data } = await supabase
.from('notifications')
.select('*')
.eq('user_id', user!.id)
.order('created_at', { ascending: false })
.limit(50)
if (data) {
setNotifications(data)
}
setLoading(false)
}
fetchNotifications()
channel = supabase
.channel(`notifications:${user.id}`)
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'notifications',
filter: `user_id=eq.${user.id}`,
},
(payload) => {
const notification = payload.new as {
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
}
addNotification(notification)
playSound('notification')
showToast({
title: notification.title_ar || notification.title,
type: 'info',
})
}
)
.subscribe()
return () => {
if (channel) {
supabase.removeChannel(channel)
}
}
}, [user, setNotifications, addNotification, showToast])
const markAsRead = useCallback(
async (id: string) => {
storeMarkAsRead(id)
await supabase
.from('notifications')
.update({ is_read: true, read_at: new Date().toISOString() })
.eq('id', id)
},
[storeMarkAsRead]
)
const markAllRead = useCallback(async () => {
if (!user) return
const unread = notifications.filter((n) => !n.is_read)
unread.forEach((n) => storeMarkAsRead(n.id))
await supabase
.from('notifications')
.update({ is_read: true, read_at: new Date().toISOString() })
.eq('user_id', user.id)
.eq('is_read', false)
}, [user, notifications, storeMarkAsRead])
return { notifications, markAsRead, markAllRead, loading }
}
import { useEffect, useRef } from 'react'
import { supabase } from '../lib/supabase'
import { useAuthStore } from '../stores/authStore'
export function usePresence() {
const { user } = useAuthStore()
const heartbeatRef = useRef<ReturnType<typeof setInterval> | null>(null)
useEffect(() => {
if (!user) return
const setOnline = () => {
supabase
.from('profiles')
.update({ is_online: true, last_seen_at: new Date().toISOString() })
.eq('id', user.id)
.then(() => {})
}
const setOffline = () => {
supabase
.from('profiles')
.update({ is_online: false, last_seen_at: new Date().toISOString() })
.eq('id', user.id)
.then(() => {})
}
setOnline()
heartbeatRef.current = setInterval(() => {
supabase
.from('profiles')
.update({ last_seen_at: new Date().toISOString() })
.eq('id', user.id)
.then(() => {})
}, 30000)
const handleVisibility = () => {
if (document.visibilityState === 'hidden') {
setOffline()
} else {
setOnline()
}
}
const handleBeforeUnload = () => {
navigator.sendBeacon?.(
`${supabase.supabaseUrl}/rest/v1/profiles?id=eq.${user.id}`,
JSON.stringify({ is_online: false, last_seen_at: new Date().toISOString() })
)
}
document.addEventListener('visibilitychange', handleVisibility)
window.addEventListener('beforeunload', handleBeforeUnload)
return () => {
if (heartbeatRef.current) {
clearInterval(heartbeatRef.current)
heartbeatRef.current = null
}
document.removeEventListener('visibilitychange', handleVisibility)
window.removeEventListener('beforeunload', handleBeforeUnload)
setOffline()
}
}, [user])
}
import { useState, useEffect } from 'react'
import { supabase } from '../lib/supabase'
interface RecentGame {
id: string
opponent: string
result: 'win' | 'loss' | 'draw'
timeControl: string
ratingChange: number
completedAt: string
}
export function useRecentGames(playerId: string | undefined, limit = 5) {
const [games, setGames] = useState<RecentGame[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
if (!playerId) {
setLoading(false)
return
}
async function fetchGames() {
setLoading(true)
const { data, error } = await supabase
.from('matches')
.select('*')
.or(`white_player_id.eq.${playerId},black_player_id.eq.${playerId}`)
.not('completed_at', 'is', null)
.order('completed_at', { ascending: false })
.limit(limit)
if (error || !data) {
setLoading(false)
return
}
const formatted: RecentGame[] = data.map((match) => {
const isWhite = match.white_player_id === playerId
const ratingChange = isWhite
? match.rating_change_white || 0
: match.rating_change_black || 0
let result: 'win' | 'loss' | 'draw' = 'draw'
if (match.result === 'white_wins') result = isWhite ? 'win' : 'loss'
else if (match.result === 'black_wins') result = isWhite ? 'loss' : 'win'
else result = 'draw'
const opponent = match.bot_id
? match.bot_id
: isWhite
? 'لاعب'
: 'لاعب'
return {
id: match.id,
opponent,
result,
timeControl: match.time_control || '5+0',
ratingChange,
completedAt: match.completed_at,
}
})
setGames(formatted)
setLoading(false)
}
fetchGames()
}, [playerId, limit])
return { games, loading }
}
import { useState, useEffect, useCallback } from 'react'
import { supabase } from '../lib/supabase'
import { useAuthStore } from '../stores/authStore'
type CosmeticType = 'avatar_frame' | 'board_theme' | 'piece_set' | 'profile_banner' | 'title_badge' | 'chat_emoji' | 'victory_animation' | 'trail_effect' | 'sound_pack'
type CosmeticRarity = 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary'
export interface Cosmetic {
id: string
name: string
name_ar: string
description: string | null
type: CosmeticType
rarity: CosmeticRarity
preview_url: string | null
asset_url: string | null
price_coins: number | null
price_gems: number | null
unlock_condition: Record<string, unknown> | null
is_purchasable: boolean
is_limited_edition: boolean
available_until: string | null
created_at: string
}
interface PlayerCosmetic {
id: string
player_id: string
cosmetic_id: string
acquired_at: string
acquired_via: string
is_equipped: boolean
}
export function useShop(filterType?: CosmeticType | 'all') {
const { user, profile, setProfile } = useAuthStore()
const [items, setItems] = useState<Cosmetic[]>([])
const [playerCosmetics, setPlayerCosmetics] = useState<PlayerCosmetic[]>([])
const [loading, setLoading] = useState(true)
const ownedIds = playerCosmetics.map((pc) => pc.cosmetic_id)
const equippedIds = playerCosmetics.filter((pc) => pc.is_equipped).map((pc) => pc.cosmetic_id)
const fetchItems = useCallback(async () => {
setLoading(true)
let query = supabase.from('cosmetics').select('*').eq('is_purchasable', true)
if (filterType && filterType !== 'all') {
query = query.eq('type', filterType)
}
const { data } = await query.order('rarity', { ascending: false })
if (data) setItems(data)
setLoading(false)
}, [filterType])
const fetchPlayerCosmetics = useCallback(async () => {
if (!user) return
const { data } = await supabase
.from('player_cosmetics')
.select('*')
.eq('player_id', user.id)
if (data) setPlayerCosmetics(data)
}, [user])
useEffect(() => {
fetchItems()
fetchPlayerCosmetics()
}, [fetchItems, fetchPlayerCosmetics])
async function purchase(cosmeticId: string): Promise<{ success: boolean; error?: string }> {
if (!user || !profile) return { success: false, error: 'غير مسجل' }
const item = items.find((i) => i.id === cosmeticId)
if (!item) return { success: false, error: 'العنصر غير موجود' }
if (ownedIds.includes(cosmeticId)) return { success: false, error: 'تمتلك هذا العنصر بالفعل' }
const currency = item.price_gems ? 'gems' : 'coins'
const price = item.price_gems || item.price_coins || 0
const balance = currency === 'gems' ? profile.gems : profile.coins
if (balance < price) return { success: false, error: 'رصيد غير كافي' }
const newBalance = balance - price
const { error: profileError } = await supabase
.from('profiles')
.update({ [currency]: newBalance })
.eq('id', user.id)
if (profileError) return { success: false, error: 'فشل في تحديث الرصيد' }
const { error: cosmeticError } = await supabase.from('player_cosmetics').insert({
player_id: user.id,
cosmetic_id: cosmeticId,
acquired_via: 'shop',
is_equipped: false,
})
if (cosmeticError) {
await supabase.from('profiles').update({ [currency]: balance }).eq('id', user.id)
return { success: false, error: 'فشل في اضافة العنصر' }
}
await supabase.from('economy_transactions').insert({
player_id: user.id,
type: 'debit',
currency,
amount: price,
balance_after: newBalance,
reason: `purchase_cosmetic:${cosmeticId}`,
source_id: cosmeticId,
})
setProfile({ ...profile, [currency]: newBalance })
await fetchPlayerCosmetics()
return { success: true }
}
async function equip(cosmeticId: string): Promise<{ success: boolean; error?: string }> {
if (!user) return { success: false, error: 'غير مسجل' }
const item = items.find((i) => i.id === cosmeticId)
if (!item) return { success: false, error: 'العنصر غير موجود' }
await supabase
.from('player_cosmetics')
.update({ is_equipped: false })
.eq('player_id', user.id)
.eq('is_equipped', true)
.in('cosmetic_id', items.filter((i) => i.type === item.type).map((i) => i.id))
const { error } = await supabase
.from('player_cosmetics')
.update({ is_equipped: true })
.eq('player_id', user.id)
.eq('cosmetic_id', cosmeticId)
if (error) return { success: false, error: 'فشل في التفعيل' }
await fetchPlayerCosmetics()
return { success: true }
}
return { items, ownedIds, equippedIds, loading, purchase, equip }
}
import { useState, useEffect, useCallback } from 'react'
import { supabase } from '../lib/supabase'
import { useAuthStore } from '../stores/authStore'
type TournamentStatus = 'draft' | 'registration' | 'in_progress' | 'completed' | 'cancelled'
interface Tournament {
id: string
name: string
name_ar: string | null
game_key: string
format: string
time_control: string
rounds_total: number
min_players: number
max_players: number
prize_pool_coins: number
prize_pool_gems: number
starts_at: string
status: TournamentStatus
current_round: number
is_rated: boolean
created_at: string
registrations_count: number
}
interface Registration {
id: string
tournament_id: string
player_id: string
status: string
registered_at: string
}
export function useTournaments(statusFilter?: TournamentStatus | null) {
const [tournaments, setTournaments] = useState<Tournament[]>([])
const [myRegistrations, setMyRegistrations] = useState<Registration[]>([])
const [loading, setLoading] = useState(true)
const { user } = useAuthStore()
const fetchTournaments = useCallback(async () => {
setLoading(true)
let query = supabase
.from('el3ab_tournaments')
.select('*, registrations_count:tournament_registrations(count)')
.neq('status', 'draft')
.order('starts_at', { ascending: false })
if (statusFilter) {
query = query.eq('status', statusFilter)
}
const { data } = await query
if (data) {
const mapped = data.map((t: any) => ({
...t,
registrations_count: t.registrations_count?.[0]?.count ?? 0,
}))
setTournaments(mapped)
}
if (user) {
const { data: regs } = await supabase
.from('tournament_registrations')
.select('*')
.eq('player_id', user.id)
.eq('status', 'registered')
if (regs) setMyRegistrations(regs)
}
setLoading(false)
}, [statusFilter, user])
useEffect(() => {
fetchTournaments()
}, [fetchTournaments])
const register = async (tournamentId: string) => {
if (!user) return
const { data, error } = await supabase
.from('tournament_registrations')
.insert({ tournament_id: tournamentId, player_id: user.id, status: 'registered' })
.select()
.single()
if (!error && data) {
setMyRegistrations((prev) => [...prev, data])
setTournaments((prev) =>
prev.map((t) =>
t.id === tournamentId ? { ...t, registrations_count: t.registrations_count + 1 } : t
)
)
}
}
const unregister = async (tournamentId: string) => {
if (!user) return
await supabase
.from('tournament_registrations')
.delete()
.eq('tournament_id', tournamentId)
.eq('player_id', user.id)
setMyRegistrations((prev) => prev.filter((r) => r.tournament_id !== tournamentId))
setTournaments((prev) =>
prev.map((t) =>
t.id === tournamentId ? { ...t, registrations_count: Math.max(0, t.registrations_count - 1) } : t
)
)
}
return { tournaments, myRegistrations, loading, register, unregister, refetch: fetchTournaments }
}
import { supabase } from './supabase'
import { TIME_CONTROLS } from './constants'
import type { RealtimeChannel } from '@supabase/supabase-js'
type TimeControlKey = keyof typeof TIME_CONTROLS
export function getTimeControlCategory(tc: TimeControlKey): string {
const category = TIME_CONTROLS[tc].category
return `elo_${category}`
}
export async function joinQueue(
gameKey: string,
timeControl: TimeControlKey,
rating: number
): Promise<{ queueId: string; channel: RealtimeChannel }> {
const { data: { user } } = await supabase.auth.getUser()
if (!user) throw new Error('Not authenticated')
await supabase
.from('matchmaking_queue')
.delete()
.eq('player_id', user.id)
const { data, error } = await supabase
.from('matchmaking_queue')
.insert({
player_id: user.id,
game_key: gameKey,
time_control: timeControl,
rating,
status: 'searching',
})
.select('id')
.single()
if (error) throw error
const channel = supabase
.channel(`queue:${data.id}`)
.on(
'postgres_changes',
{
event: 'UPDATE',
schema: 'public',
table: 'matchmaking_queue',
filter: `id=eq.${data.id}`,
},
() => {}
)
.subscribe()
return { queueId: data.id, channel }
}
export async function leaveQueue(): Promise<void> {
const { data: { user } } = await supabase.auth.getUser()
if (!user) return
await supabase
.from('matchmaking_queue')
.delete()
.eq('player_id', user.id)
}
export async function findMatch(
gameKey: string,
timeControl: TimeControlKey,
rating: number,
ratingRange: number = 200
): Promise<string | null> {
const { data: { user } } = await supabase.auth.getUser()
if (!user) return null
const { data: opponents } = await supabase
.from('matchmaking_queue')
.select('*')
.eq('game_key', gameKey)
.eq('time_control', timeControl)
.eq('status', 'searching')
.neq('player_id', user.id)
.gte('rating', rating - ratingRange)
.lte('rating', rating + ratingRange)
.order('created_at', { ascending: true })
.limit(1)
if (!opponents || opponents.length === 0) return null
const opponent = opponents[0]
const tc = TIME_CONTROLS[timeControl]
const whiteRandom = Math.random() < 0.5
const whiteId = whiteRandom ? user.id : opponent.player_id
const blackId = whiteRandom ? opponent.player_id : user.id
const { data: match, error: matchError } = await supabase
.from('matches')
.insert({
white_player_id: whiteId,
black_player_id: blackId,
status: 'waiting',
current_fen: 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1',
moves: [],
time_control: timeControl,
initial_time_ms: tc.initial,
increment_ms: tc.increment,
white_time_remaining_ms: tc.initial,
black_time_remaining_ms: tc.initial,
move_count: 0,
is_rated: true,
})
.select('id')
.single()
if (matchError) return null
await supabase
.from('matchmaking_queue')
.update({ status: 'matched', matched_with: opponent.player_id, match_id: match.id })
.eq('player_id', user.id)
await supabase
.from('matchmaking_queue')
.update({ status: 'matched', matched_with: user.id, match_id: match.id })
.eq('id', opponent.id)
return match.id
}
import { supabase } from './supabase'
import type { RealtimeChannel } from '@supabase/supabase-js'
export interface MatchRow {
id: string
white_player_id: string
black_player_id: string
status: string
current_fen: string
moves: Array<{ from: string; to: string; san: string; fen: string; timestamp: number }>
time_control: string
initial_time_ms: number
increment_ms: number
white_time_remaining_ms: number
black_time_remaining_ms: number
result: string | null
move_count: number
started_at: string | null
completed_at: string | null
is_rated: boolean
}
export function subscribeToMatch(
matchId: string,
onUpdate: (match: MatchRow) => void
): RealtimeChannel {
const channel = supabase
.channel(`match:${matchId}`)
.on(
'postgres_changes',
{
event: 'UPDATE',
schema: 'public',
table: 'matches',
filter: `id=eq.${matchId}`,
},
(payload) => {
onUpdate(payload.new as MatchRow)
}
)
.subscribe()
return channel
}
export async function fetchMatch(matchId: string): Promise<MatchRow | null> {
const { data, error } = await supabase
.from('matches')
.select('*')
.eq('id', matchId)
.single()
if (error) return null
return data as MatchRow
}
export async function makeMove(
matchId: string,
newFen: string,
moves: MatchRow['moves'],
moveCount: number,
timeRemaining: number,
turn: 'w' | 'b'
): Promise<void> {
const timeField = turn === 'w'
? 'black_time_remaining_ms'
: 'white_time_remaining_ms'
const updateData: Record<string, unknown> = {
current_fen: newFen,
moves,
move_count: moveCount,
[timeField]: timeRemaining,
}
if (moveCount === 1) {
updateData.status = 'in_progress'
updateData.started_at = new Date().toISOString()
}
await supabase
.from('matches')
.update(updateData)
.eq('id', matchId)
}
export async function endMatch(
matchId: string,
result: string
): Promise<void> {
await supabase
.from('matches')
.update({
status: 'completed',
result,
completed_at: new Date().toISOString(),
})
.eq('id', matchId)
}
export async function setMatchReady(matchId: string): Promise<void> {
await supabase
.from('matches')
.update({ status: 'ready' })
.eq('id', matchId)
}
import { motion } from 'framer-motion' import { useState } from 'react'
import { UserPlus, Search } from 'lucide-react' import { motion, AnimatePresence } from 'framer-motion'
import { UserPlus, UserCheck, UserX, Search, X, Loader2 } from 'lucide-react'
import { PageTransition } from '../components/layout/PageTransition' import { PageTransition } from '../components/layout/PageTransition'
import { Card } from '../components/ui/Card'
import { Button } from '../components/ui/Button'
import { useFriends } from '../hooks/useFriends'
import { supabase } from '../lib/supabase'
import { useAuthStore } from '../stores/authStore'
interface SearchResult {
id: string
username: string
display_name: string
avatar_url: string | null
elo_blitz: number
}
function relativeTime(date: string | null): string {
if (!date) return ''
const diff = Date.now() - new Date(date).getTime()
const minutes = Math.floor(diff / 60000)
if (minutes < 1) return 'الان'
if (minutes < 60) return `${minutes} د`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours} س`
const days = Math.floor(hours / 24)
return `${days} ي`
}
export function FriendsPage() { export function FriendsPage() {
const { user } = useAuthStore()
const { friends, pendingReceived, loading, sendRequest, acceptRequest, rejectRequest, removeFriend } = useFriends()
const [showSearch, setShowSearch] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const [searchResults, setSearchResults] = useState<SearchResult[]>([])
const [searching, setSearching] = useState(false)
const [sentIds, setSentIds] = useState<Set<string>>(new Set())
const handleSearch = async (query: string) => {
setSearchQuery(query)
if (query.length < 2) {
setSearchResults([])
return
}
setSearching(true)
const { data } = await supabase
.from('profiles')
.select('id, username, display_name, avatar_url, elo_blitz')
.neq('id', user?.id ?? '')
.ilike('username', `%${query}%`)
.limit(10)
setSearchResults(data || [])
setSearching(false)
}
const handleSendRequest = async (userId: string) => {
await sendRequest(userId)
setSentIds((prev) => new Set([...prev, userId]))
}
const onlineFriends = friends.filter((f) => f.profile.is_online)
const offlineFriends = friends.filter((f) => !f.profile.is_online)
const stagger = {
hidden: { opacity: 0 },
show: { opacity: 1, transition: { staggerChildren: 0.05 } },
}
const item = {
hidden: { opacity: 0, y: 10 },
show: { opacity: 1, y: 0 },
}
return ( return (
<PageTransition className="px-4 py-6 flex flex-col gap-5"> <PageTransition className="px-7 py-7 flex flex-col gap-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h1 className="text-xl font-bold">الاصدقاء</h1> <h1 className="text-xl font-bold">الاصدقاء</h1>
<motion.button <motion.button
className="p-2 rounded-lg bg-gold/10 border border-gold/20" className="p-2 rounded-lg bg-gold/10 border border-gold/20"
whileTap={{ scale: 0.9 }} whileTap={{ scale: 0.9 }}
onClick={() => setShowSearch(!showSearch)}
> >
<UserPlus size={18} className="text-gold" /> {showSearch ? <X size={18} className="text-gold" /> : <UserPlus size={18} className="text-gold" />}
</motion.button> </motion.button>
</div> </div>
<div className="relative"> <AnimatePresence>
<Search size={16} className="absolute right-3 top-1/2 -translate-y-1/2 text-text-muted" /> {showSearch && (
<input <motion.div
type="text" initial={{ height: 0, opacity: 0 }}
placeholder="بحث عن لاعب..." animate={{ height: 'auto', opacity: 1 }}
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" exit={{ height: 0, opacity: 0 }}
dir="rtl" className="overflow-hidden"
/> >
</div> <div className="relative mb-3">
<Search size={16} className="absolute right-3 top-1/2 -translate-y-1/2 text-text-muted" />
<input
type="text"
placeholder="بحث عن لاعب..."
value={searchQuery}
onChange={(e) => handleSearch(e.target.value)}
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"
autoFocus
/>
</div>
<div className="flex-1 flex flex-col items-center justify-center py-12"> {searching && (
<motion.div <div className="flex justify-center py-3">
className="w-16 h-16 rounded-full bg-surface-2 border border-border flex items-center justify-center mb-4" <Loader2 size={20} className="animate-spin text-text-muted" />
initial={{ scale: 0 }} </div>
animate={{ scale: 1 }} )}
transition={{ type: 'spring', stiffness: 300, damping: 20 }}
> {searchResults.length > 0 && (
<UserPlus size={24} className="text-text-muted" /> <motion.div className="flex flex-col gap-2 mb-4" variants={stagger} initial="hidden" animate="show">
</motion.div> {searchResults.map((result) => {
<p className="text-text-muted text-sm">لا يوجد اصدقاء بعد</p> const alreadyFriend = friends.some((f) => f.profile.id === result.id)
<p className="text-text-muted text-xs mt-1">ابحث عن لاعبين لاضافتهم</p> const alreadySent = sentIds.has(result.id)
</div> return (
<motion.div key={result.id} variants={item}>
<Card className="!p-3 flex items-center gap-3">
<div className="w-9 h-9 rounded-full bg-surface-3 border border-border flex items-center justify-center shrink-0">
<span className="text-sm font-bold text-gold">
{result.display_name?.[0] || result.username[0]}
</span>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold truncate">{result.display_name || result.username}</p>
<p className="text-xs text-text-muted">{result.elo_blitz}</p>
</div>
{alreadyFriend ? (
<UserCheck size={16} className="text-cyan shrink-0" />
) : alreadySent ? (
<span className="text-xs text-text-muted">تم الارسال</span>
) : (
<Button size="sm" variant="ghost" onClick={() => handleSendRequest(result.id)}>
اضافة
</Button>
)}
</Card>
</motion.div>
)
})}
</motion.div>
)}
</motion.div>
)}
</AnimatePresence>
{loading ? (
<div className="flex-1 flex items-center justify-center">
<Loader2 size={24} className="animate-spin text-text-muted" />
</div>
) : (
<>
{pendingReceived.length > 0 && (
<div className="flex flex-col gap-2">
<h2 className="text-sm font-semibold text-text-muted">طلبات الصداقة</h2>
<motion.div className="flex flex-col gap-2" variants={stagger} initial="hidden" animate="show">
{pendingReceived.map((req) => (
<motion.div key={req.id} variants={item}>
<Card className="!p-3 flex items-center gap-3">
<div className="w-9 h-9 rounded-full bg-surface-3 border border-gold/30 flex items-center justify-center shrink-0">
<span className="text-sm font-bold text-gold">
{req.profile.display_name?.[0] || req.profile.username[0]}
</span>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold truncate">
{req.profile.display_name || req.profile.username}
</p>
<p className="text-xs text-text-muted">{req.profile.elo_blitz}</p>
</div>
<div className="flex gap-1.5">
<motion.button
className="p-1.5 rounded-lg bg-cyan/10 border border-cyan/20"
whileTap={{ scale: 0.85 }}
onClick={() => acceptRequest(req.id)}
>
<UserCheck size={14} className="text-cyan" />
</motion.button>
<motion.button
className="p-1.5 rounded-lg bg-coral/10 border border-coral/20"
whileTap={{ scale: 0.85 }}
onClick={() => rejectRequest(req.id)}
>
<UserX size={14} className="text-coral" />
</motion.button>
</div>
</Card>
</motion.div>
))}
</motion.div>
</div>
)}
{onlineFriends.length > 0 && (
<div className="flex flex-col gap-2">
<h2 className="text-sm font-semibold text-cyan">
متصل ({onlineFriends.length})
</h2>
<motion.div className="flex flex-col gap-2" variants={stagger} initial="hidden" animate="show">
{onlineFriends.map((friend) => (
<motion.div key={friend.id} variants={item}>
<Card className="!p-3 flex items-center gap-3">
<div className="relative">
<div className="w-9 h-9 rounded-full bg-surface-3 border border-border flex items-center justify-center">
<span className="text-sm font-bold text-gold">
{friend.profile.display_name?.[0] || friend.profile.username[0]}
</span>
</div>
<div className="absolute -bottom-0.5 -left-0.5 w-3 h-3 rounded-full bg-cyan border-2 border-surface-1" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold truncate">
{friend.profile.display_name || friend.profile.username}
</p>
<p className="text-xs text-text-muted">{friend.profile.elo_blitz}</p>
</div>
<motion.button
className="p-1.5 rounded-lg bg-coral/10 border border-coral/20 opacity-0 group-hover:opacity-100"
whileTap={{ scale: 0.85 }}
onClick={() => removeFriend(friend.id)}
>
<UserX size={14} className="text-coral" />
</motion.button>
</Card>
</motion.div>
))}
</motion.div>
</div>
)}
{offlineFriends.length > 0 && (
<div className="flex flex-col gap-2">
<h2 className="text-sm font-semibold text-text-muted">
غير متصل ({offlineFriends.length})
</h2>
<motion.div className="flex flex-col gap-2" variants={stagger} initial="hidden" animate="show">
{offlineFriends.map((friend) => (
<motion.div key={friend.id} variants={item}>
<Card className="!p-3 flex items-center gap-3">
<div className="relative">
<div className="w-9 h-9 rounded-full bg-surface-3 border border-border flex items-center justify-center opacity-60">
<span className="text-sm font-bold text-text-muted">
{friend.profile.display_name?.[0] || friend.profile.username[0]}
</span>
</div>
<div className="absolute -bottom-0.5 -left-0.5 w-3 h-3 rounded-full bg-text-muted/50 border-2 border-surface-1" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold truncate opacity-60">
{friend.profile.display_name || friend.profile.username}
</p>
<p className="text-xs text-text-muted">
آخر ظهور {relativeTime(friend.profile.last_seen_at)}
</p>
</div>
<motion.button
className="p-1.5 rounded-lg bg-coral/10 border border-coral/20"
whileTap={{ scale: 0.85 }}
onClick={() => removeFriend(friend.id)}
>
<UserX size={14} className="text-coral" />
</motion.button>
</Card>
</motion.div>
))}
</motion.div>
</div>
)}
{friends.length === 0 && pendingReceived.length === 0 && (
<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> </PageTransition>
) )
} }
...@@ -6,6 +6,7 @@ import { useNavigate, useParams, useLocation } from 'react-router-dom' ...@@ -6,6 +6,7 @@ import { useNavigate, useParams, useLocation } from 'react-router-dom'
import { Button } from '../components/ui/Button' import { Button } from '../components/ui/Button'
import { getBotMove, uciToMove, getBotPortraitUrl, fetchBots } from '../lib/stockfish' import { getBotMove, uciToMove, getBotPortraitUrl, fetchBots } from '../lib/stockfish'
import { playSound } from '../lib/sounds' import { playSound } from '../lib/sounds'
import { useMultiplayerGame } from '../hooks/useMultiplayerGame'
const PIECE_UNICODE: Record<string, string> = { const PIECE_UNICODE: Record<string, string> = {
wp: '♙', wn: '♘', wb: '♗', wr: '♖', wq: '♕', wk: '♔', wp: '♙', wn: '♘', wb: '♗', wr: '♖', wq: '♕', wk: '♔',
...@@ -22,12 +23,473 @@ const PIECE_VALUES: Record<string, number> = { p: 1, n: 3, b: 3, r: 5, q: 9 } ...@@ -22,12 +23,473 @@ const PIECE_VALUES: Record<string, number> = { p: 1, n: 3, b: 3, r: 5, q: 9 }
const FILES = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'] const FILES = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h']
const RANKS = ['8', '7', '6', '5', '4', '3', '2', '1'] const RANKS = ['8', '7', '6', '5', '4', '3', '2', '1']
function formatClock(ms: number): string {
const totalSeconds = Math.max(0, Math.floor(ms / 1000))
const minutes = Math.floor(totalSeconds / 60)
const seconds = totalSeconds % 60
if (minutes >= 60) {
const hours = Math.floor(minutes / 60)
const mins = minutes % 60
return `${hours}:${mins.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`
}
return `${minutes}:${seconds.toString().padStart(2, '0')}`
}
function getResultText(result: string | null, myColor: 'w' | 'b'): string {
if (!result) return ''
if (result === 'draw') return 'تعادل'
if (result === 'white_wins' || result === 'black_wins') {
const winnerColor = result === 'white_wins' ? 'w' : 'b'
return winnerColor === myColor ? 'فوز' : 'هزيمة'
}
if (result === 'white_timeout' || result === 'black_timeout') {
const loserColor = result === 'white_timeout' ? 'w' : 'b'
return loserColor === myColor ? 'هزيمة (انتهى الوقت)' : 'فوز (انتهى وقت الخصم)'
}
if (result === 'white_resign' || result === 'black_resign') {
const loserColor = result === 'white_resign' ? 'w' : 'b'
return loserColor === myColor ? 'استسلام' : 'فوز (استسلم الخصم)'
}
return result
}
export function GamePage() { export function GamePage() {
const navigate = useNavigate() const navigate = useNavigate()
const { id } = useParams<{ id: string }>() const { id } = useParams<{ id: string }>()
const location = useLocation() const location = useLocation()
const isBot = location.pathname.startsWith('/game/bot/') const isBot = location.pathname.startsWith('/game/bot/')
const isMultiplayer = !isBot && !!id && location.pathname.startsWith('/game/')
const botId = isBot ? id : undefined const botId = isBot ? id : undefined
const matchId = isMultiplayer ? id : undefined
if (isMultiplayer && matchId) {
return <MultiplayerGameView matchId={matchId} />
}
return <BotGameView botId={botId} />
}
function MultiplayerGameView({ matchId }: { matchId: string }) {
const navigate = useNavigate()
const {
game,
myColor,
isMyTurn,
myTimeMs,
opponentTimeMs,
gameOver,
result,
opponentProfile,
lastMove,
loading,
makeMove,
resign,
} = useMultiplayerGame(matchId)
const [selectedSquare, setSelectedSquare] = useState<string | null>(null)
const [legalMoves, setLegalMoves] = useState<string[]>([])
const [boardFlipped, setBoardFlipped] = useState(false)
const moveListRef = useRef<HTMLDivElement>(null)
useEffect(() => {
setBoardFlipped(myColor === 'b')
}, [myColor])
useEffect(() => {
if (moveListRef.current) {
moveListRef.current.scrollLeft = moveListRef.current.scrollWidth
}
}, [game])
const getSquareName = useCallback((row: number, col: number): string => {
const file = String.fromCharCode(97 + col)
const rank = String(8 - row)
return `${file}${rank}`
}, [])
const getDisplayCoords = useCallback((row: number, col: number) => {
if (boardFlipped) {
return { row: 7 - row, col: 7 - col }
}
return { row, col }
}, [boardFlipped])
const handleSquareClick = useCallback((row: number, col: number) => {
if (gameOver) return
if (!isMyTurn) return
const { row: displayRow, col: displayCol } = getDisplayCoords(row, col)
const square = getSquareName(displayRow, displayCol)
const piece = game.get(square as any)
if (selectedSquare) {
if (legalMoves.includes(square)) {
makeMove(selectedSquare, square)
setSelectedSquare(null)
setLegalMoves([])
} else if (piece && piece.color === game.turn()) {
setSelectedSquare(square)
const moves = game.moves({ square: square as any, verbose: true })
setLegalMoves(moves.map((m) => m.to))
} else {
setSelectedSquare(null)
setLegalMoves([])
}
} else {
if (piece && piece.color === myColor) {
setSelectedSquare(square)
const moves = game.moves({ square: square as any, verbose: true })
setLegalMoves(moves.map((m) => m.to))
}
}
}, [game, selectedSquare, legalMoves, gameOver, isMyTurn, myColor, getSquareName, getDisplayCoords, makeMove])
const getCapturedPieces = useCallback((captureColor: 'w' | 'b') => {
const initial: Record<string, number> = { p: 8, n: 2, b: 2, r: 2, q: 1 }
const board = game.board()
const remaining: Record<string, number> = { p: 0, n: 0, b: 0, r: 0, q: 0 }
for (const row of board) {
for (const sq of row) {
if (sq && sq.color === captureColor && sq.type !== 'k') {
remaining[sq.type] = (remaining[sq.type] || 0) + 1
}
}
}
const captured: string[] = []
for (const [type, count] of Object.entries(initial)) {
const diff = count - (remaining[type] || 0)
for (let i = 0; i < diff; i++) captured.push(type)
}
return captured
}, [game])
const renderCapturedPieces = (capturedColor: 'w' | 'b') => {
const pieces = getCapturedPieces(capturedColor)
const sorted = [...pieces].sort((a, b) => (PIECE_VALUES[b] || 0) - (PIECE_VALUES[a] || 0))
const myPieces = getCapturedPieces(capturedColor === 'w' ? 'b' : 'w')
const myVal = myPieces.reduce((s, p) => s + (PIECE_VALUES[p] || 0), 0)
const oppVal = sorted.reduce((s, p) => s + (PIECE_VALUES[p] || 0), 0)
const advantage = oppVal - myVal
return (
<div className="flex items-center gap-0.5 min-h-[20px]">
{sorted.map((piece, i) => (
<span key={i} className="text-sm leading-none opacity-80">
{PIECE_UNICODE[`${capturedColor}${piece}`]}
</span>
))}
{advantage > 0 && (
<span className="text-[10px] text-gold font-bold mr-1">+{advantage}</span>
)}
</div>
)
}
const renderFileLabels = () => {
const files = boardFlipped ? [...FILES].reverse() : FILES
return (
<div className="grid grid-cols-8 w-full" style={{ paddingRight: '16px' }}>
{files.map((f) => (
<div key={f} className="flex items-center justify-center">
<span className="text-[10px] text-text-muted/60 font-mono">{f}</span>
</div>
))}
</div>
)
}
const renderRankLabels = () => {
const ranks = boardFlipped ? [...RANKS].reverse() : RANKS
return (
<div className="grid grid-rows-8 h-full w-4 shrink-0">
{ranks.map((r) => (
<div key={r} className="flex items-center justify-center">
<span className="text-[10px] text-text-muted/60 font-mono">{r}</span>
</div>
))}
</div>
)
}
const renderBoard = () => {
const board = game.board()
const squares = []
for (let row = 0; row < 8; row++) {
for (let col = 0; col < 8; col++) {
const { row: displayRow, col: displayCol } = getDisplayCoords(row, col)
const square = getSquareName(displayRow, displayCol)
const piece = board[displayRow][displayCol]
const isLight = (displayRow + displayCol) % 2 === 0
const isSelected = selectedSquare === square
const isLegalMove = legalMoves.includes(square)
const isLastMoveSquare = lastMove && (lastMove.from === square || lastMove.to === square)
const isInCheck = game.inCheck() && piece?.type === 'k' && piece.color === game.turn()
squares.push(
<div
key={`${row}-${col}`}
className="relative flex items-center justify-center cursor-pointer"
style={{
backgroundColor: isSelected
? HIGHLIGHT_COLOR
: isLastMoveSquare
? 'rgba(212, 168, 67, 0.2)'
: isLight
? LIGHT_SQUARE
: DARK_SQUARE,
aspectRatio: '1',
}}
onClick={() => handleSquareClick(row, col)}
>
{isInCheck && (
<div className="absolute inset-0 bg-red-500/30 rounded-full m-1" />
)}
{piece && (
<span
className="text-[clamp(1.8rem,8vw,3rem)] leading-none select-none"
style={{
textShadow: piece.color === 'w'
? '0 1px 3px rgba(0,0,0,0.4)'
: '0 1px 2px rgba(0,0,0,0.6)',
}}
>
{PIECE_UNICODE[`${piece.color}${piece.type}`]}
</span>
)}
{isLegalMove && !piece && (
<motion.div
className="absolute w-[25%] h-[25%] rounded-full"
style={{ backgroundColor: LEGAL_DOT_COLOR }}
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: 'spring', stiffness: 500, damping: 25 }}
/>
)}
{isLegalMove && piece && (
<motion.div
className="absolute inset-1 rounded-full border-[3px]"
style={{ borderColor: LEGAL_DOT_COLOR }}
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: 'spring', stiffness: 500, damping: 25 }}
/>
)}
</div>
)
}
}
return squares
}
const renderMoveList = () => {
const history = game.history()
if (history.length === 0) return null
const pairs: { num: number; white: string; black?: string }[] = []
for (let i = 0; i < history.length; i += 2) {
pairs.push({
num: Math.floor(i / 2) + 1,
white: history[i],
black: history[i + 1],
})
}
return (
<div
ref={moveListRef}
className="flex items-center gap-1 overflow-x-auto scrollbar-hide py-2 px-1"
style={{ direction: 'ltr' }}
>
{pairs.map((pair) => (
<div
key={pair.num}
className="flex items-center gap-0.5 shrink-0 px-1.5 py-0.5 rounded bg-surface-2/60 text-[11px]"
>
<span className="text-text-muted/50 font-mono">{pair.num}.</span>
<span className="text-text-primary font-medium">{pair.white}</span>
{pair.black && (
<span className="text-text-secondary">{pair.black}</span>
)}
</div>
))}
</div>
)
}
if (loading) {
return (
<div className="flex-1 flex items-center justify-center min-h-dvh bg-background">
<div className="w-8 h-8 border-2 border-gold/30 border-t-gold rounded-full animate-spin" />
</div>
)
}
const opponentName = opponentProfile?.display_name || opponentProfile?.username || 'الخصم'
return (
<div className="flex flex-col min-h-dvh bg-background">
{/* Header */}
<div className="flex items-center justify-between px-6 py-3.5 bg-surface-1 border-b border-border">
<motion.button
onClick={() => navigate(-1)}
className="flex items-center gap-0.5 text-text-muted p-2"
whileTap={{ scale: 0.9 }}
>
<ChevronRight size={20} className="text-text-muted" />
</motion.button>
<motion.div
className={`flex items-center gap-1.5 px-4 py-1.5 rounded-full text-xs font-bold ${
isMyTurn
? 'bg-gold/20 text-gold border border-gold/40'
: 'bg-surface-2 text-text-secondary border border-border'
}`}
animate={isMyTurn ? {
boxShadow: ['0 0 4px rgba(212,168,67,0.3)', '0 0 12px rgba(212,168,67,0.5)', '0 0 4px rgba(212,168,67,0.3)'],
} : {
boxShadow: '0 0 0px transparent',
}}
transition={{ duration: 1.5, repeat: Infinity }}
>
{isMyTurn ? 'دورك' : 'دور الخصم'}
</motion.div>
<motion.button
onClick={() => setBoardFlipped(!boardFlipped)}
whileTap={{ scale: 0.9 }}
className="p-2 -m-2 rounded-lg"
>
<RotateCcw size={18} className="text-text-muted" />
</motion.button>
</div>
{/* Main content */}
<div className="flex-1 flex flex-col items-center px-2 pt-2 pb-4 gap-2">
{/* Opponent panel (top) */}
<div className="w-full max-w-[min(100vw-16px,400px)] flex items-center gap-2 px-2 py-2.5 bg-surface-1/30 rounded-xl">
<div className="w-10 h-10 rounded-full bg-surface-3 border border-border flex items-center justify-center shrink-0">
<Crown size={14} className="text-text-muted" />
</div>
<div className="flex flex-col flex-1 min-w-0">
<span className="text-xs font-bold text-text-primary truncate">{opponentName}</span>
{renderCapturedPieces(myColor === 'w' ? 'w' : 'b')}
</div>
<div className={`px-3 py-1.5 rounded-lg font-mono text-sm font-bold tabular-nums ${
!isMyTurn ? 'bg-gold/15 text-gold border border-gold/30' : 'bg-surface-2 text-text-secondary border border-border'
}`}>
{formatClock(opponentTimeMs)}
</div>
</div>
{/* Board with coordinates */}
<div className="w-full max-w-[min(100vw-16px,400px)]">
<div className="flex">
{renderRankLabels()}
<div className="flex-1 aspect-square">
<div className="grid grid-cols-8 grid-rows-8 w-full h-full rounded-lg overflow-hidden shadow-xl border border-border">
{renderBoard()}
</div>
</div>
</div>
<div className="mr-4 mt-1">
{renderFileLabels()}
</div>
</div>
{/* Player panel (bottom) */}
<div className="w-full max-w-[min(100vw-16px,400px)] flex items-center gap-2 px-2 py-2.5 bg-surface-1/30 rounded-xl">
<div className="w-10 h-10 rounded-full bg-surface-3 border border-border flex items-center justify-center shrink-0 relative">
<Crown size={14} className="text-gold" />
<div className="absolute -bottom-0.5 -left-0.5 w-2.5 h-2.5 rounded-full bg-green-500 border-2 border-background" />
</div>
<div className="flex flex-col flex-1 min-w-0">
<span className="text-xs font-bold text-text-primary">أنت</span>
{renderCapturedPieces(myColor === 'w' ? 'b' : 'w')}
</div>
<div className={`px-3 py-1.5 rounded-lg font-mono text-sm font-bold tabular-nums ${
isMyTurn ? 'bg-gold/15 text-gold border border-gold/30' : 'bg-surface-2 text-text-secondary border border-border'
}`}>
{formatClock(myTimeMs)}
</div>
</div>
{/* Action buttons */}
<div className="flex items-center gap-4 mt-5">
<Button
variant="coral"
size="md"
onClick={resign}
disabled={gameOver}
>
<Flag size={18} />
استسلام
</Button>
</div>
{/* Move list */}
<div className="w-full max-w-[min(100vw-16px,400px)] mt-4">
<div className="text-[10px] text-text-muted/50 px-1 mb-0.5">النقلات</div>
<div className="bg-surface-1 rounded-lg border border-border min-h-[36px] max-h-[56px] overflow-hidden">
{renderMoveList() || (
<div className="flex items-center justify-center h-[36px] text-[11px] text-text-muted/40">
لم تبدأ اللعبة بعد
</div>
)}
</div>
</div>
<div className="mt-3 text-[11px] text-text-muted text-center">
النقلة {game.moveNumber()} {game.inCheck() ? '- كش!' : ''}
</div>
</div>
{/* Game over modal */}
<AnimatePresence>
{gameOver && result && (
<motion.div
className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
<motion.div
className="bg-surface-1 border border-border rounded-2xl p-10 flex flex-col items-center gap-4 mx-6"
initial={{ scale: 0.8, y: 30 }}
animate={{ scale: 1, y: 0 }}
transition={{ type: 'spring', stiffness: 300, damping: 22 }}
>
<h2 className={`text-2xl font-bold ${
getResultText(result, myColor).includes('فوز') ? 'text-cyan' :
getResultText(result, myColor).includes('تعادل') ? 'text-gold' : 'text-coral'
}`}>
{getResultText(result, myColor)}
</h2>
<p className="text-sm text-text-muted">
{game.moveNumber()} نقلة
</p>
<div className="flex gap-3 mt-2">
<Button onClick={() => navigate('/play')}>لعبة جديدة</Button>
<Button variant="ghost" onClick={() => navigate('/')}>
الرئيسية
</Button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</div>
)
}
function BotGameView({ botId }: { botId: string | undefined }) {
const navigate = useNavigate()
const [game, setGame] = useState(new Chess()) const [game, setGame] = useState(new Chess())
const [selectedSquare, setSelectedSquare] = useState<string | null>(null) const [selectedSquare, setSelectedSquare] = useState<string | null>(null)
...@@ -44,7 +506,6 @@ export function GamePage() { ...@@ -44,7 +506,6 @@ export function GamePage() {
const botMoveInProgress = useRef(false) const botMoveInProgress = useRef(false)
const moveListRef = useRef<HTMLDivElement>(null) const moveListRef = useRef<HTMLDivElement>(null)
// Fetch bot Arabic name
useEffect(() => { useEffect(() => {
if (!botId) return if (!botId) return
fetchBots() fetchBots()
...@@ -74,11 +535,7 @@ export function GamePage() { ...@@ -74,11 +535,7 @@ export function GamePage() {
setGameOver(true) setGameOver(true)
if (g.isCheckmate()) { if (g.isCheckmate()) {
const loser = g.turn() const loser = g.turn()
if (isBot) { setResult(loser === playerColor ? 'هزيمة' : 'فوز')
setResult(loser === playerColor ? 'هزيمة' : 'فوز')
} else {
setResult(loser === 'w' ? 'فوز الاسود' : 'فوز الابيض')
}
playSound(loser === playerColor ? 'lose' : 'win') playSound(loser === playerColor ? 'lose' : 'win')
} else { } else {
setResult('تعادل') setResult('تعادل')
...@@ -86,11 +543,10 @@ export function GamePage() { ...@@ -86,11 +543,10 @@ export function GamePage() {
return true return true
} }
return false return false
}, [isBot, playerColor]) }, [playerColor])
const addCapturedPiece = useCallback((move: { captured?: string; color: string }) => { const addCapturedPiece = useCallback((move: { captured?: string; color: string }) => {
if (move.captured) { if (move.captured) {
// The color in the move is who made the capture
if (move.color === 'w') { if (move.color === 'w') {
setCapturedByWhite((prev) => [...prev, move.captured!]) setCapturedByWhite((prev) => [...prev, move.captured!])
} else { } else {
...@@ -123,7 +579,7 @@ export function GamePage() { ...@@ -123,7 +579,7 @@ export function GamePage() {
checkGameEnd(newGame) checkGameEnd(newGame)
} }
} catch { } catch {
// silently fail - bot might be unavailable // silently fail
} finally { } finally {
setBotThinking(false) setBotThinking(false)
botMoveInProgress.current = false botMoveInProgress.current = false
...@@ -131,15 +587,15 @@ export function GamePage() { ...@@ -131,15 +587,15 @@ export function GamePage() {
}, [botId, playerColor, checkGameEnd, addCapturedPiece]) }, [botId, playerColor, checkGameEnd, addCapturedPiece])
useEffect(() => { useEffect(() => {
if (isBot && game.turn() !== playerColor && !gameOver && !botMoveInProgress.current) { if (game.turn() !== playerColor && !gameOver && !botMoveInProgress.current) {
const timer = setTimeout(() => makeBotMove(game), 300) const timer = setTimeout(() => makeBotMove(game), 300)
return () => clearTimeout(timer) return () => clearTimeout(timer)
} }
}, [game, isBot, playerColor, gameOver, makeBotMove]) }, [game, playerColor, gameOver, makeBotMove])
const handleSquareClick = useCallback((row: number, col: number) => { const handleSquareClick = useCallback((row: number, col: number) => {
if (gameOver) return if (gameOver) return
if (isBot && game.turn() !== playerColor) return if (game.turn() !== playerColor) return
if (botThinking) return if (botThinking) return
const { row: displayRow, col: displayCol } = getDisplayCoords(row, col) const { row: displayRow, col: displayCol } = getDisplayCoords(row, col)
...@@ -176,7 +632,7 @@ export function GamePage() { ...@@ -176,7 +632,7 @@ export function GamePage() {
setLegalMoves(moves.map((m) => m.to)) setLegalMoves(moves.map((m) => m.to))
} }
} }
}, [game, selectedSquare, legalMoves, gameOver, isBot, playerColor, botThinking, getSquareName, getDisplayCoords, checkGameEnd, addCapturedPiece]) }, [game, selectedSquare, legalMoves, gameOver, playerColor, botThinking, getSquareName, getDisplayCoords, checkGameEnd, addCapturedPiece])
const resetGame = () => { const resetGame = () => {
setGame(new Chess()) setGame(new Chess())
...@@ -191,7 +647,6 @@ export function GamePage() { ...@@ -191,7 +647,6 @@ export function GamePage() {
botMoveInProgress.current = false botMoveInProgress.current = false
} }
// Scroll move list to end when moves change
useEffect(() => { useEffect(() => {
if (moveListRef.current) { if (moveListRef.current) {
moveListRef.current.scrollLeft = moveListRef.current.scrollWidth moveListRef.current.scrollLeft = moveListRef.current.scrollWidth
...@@ -359,7 +814,7 @@ export function GamePage() { ...@@ -359,7 +814,7 @@ export function GamePage() {
) )
} }
const opponentName = isBot ? (botNameAr || botId || '') : 'الخصم' const opponentName = botNameAr || botId || ''
const isPlayerTurn = game.turn() === playerColor const isPlayerTurn = game.turn() === playerColor
return ( return (
...@@ -374,7 +829,6 @@ export function GamePage() { ...@@ -374,7 +829,6 @@ export function GamePage() {
<ChevronRight size={20} className="text-text-muted" /> <ChevronRight size={20} className="text-text-muted" />
</motion.button> </motion.button>
{/* Turn indicator - glowing pill */}
<motion.div <motion.div
className={`flex items-center gap-1.5 px-4 py-1.5 rounded-full text-xs font-bold ${ className={`flex items-center gap-1.5 px-4 py-1.5 rounded-full text-xs font-bold ${
isPlayerTurn isPlayerTurn
...@@ -406,7 +860,7 @@ export function GamePage() { ...@@ -406,7 +860,7 @@ export function GamePage() {
{/* Opponent panel (top) */} {/* Opponent panel (top) */}
<div className="w-full max-w-[min(100vw-16px,400px)] flex items-center gap-2 px-2 py-2.5 bg-surface-1/30 rounded-xl"> <div className="w-full max-w-[min(100vw-16px,400px)] flex items-center gap-2 px-2 py-2.5 bg-surface-1/30 rounded-xl">
<div className="w-10 h-10 rounded-full overflow-hidden bg-surface-3 border border-border flex items-center justify-center shrink-0"> <div className="w-10 h-10 rounded-full overflow-hidden bg-surface-3 border border-border flex items-center justify-center shrink-0">
{isBot && botId ? ( {botId ? (
<img <img
src={getBotPortraitUrl(botId)} src={getBotPortraitUrl(botId)}
alt="" alt=""
...@@ -420,10 +874,10 @@ export function GamePage() { ...@@ -420,10 +874,10 @@ export function GamePage() {
<div className="flex flex-col flex-1 min-w-0"> <div className="flex flex-col flex-1 min-w-0">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span className="text-xs font-bold text-text-primary truncate"> <span className="text-xs font-bold text-text-primary truncate">
{isBot && <span className="text-text-muted font-normal">ضد </span>} <span className="text-text-muted font-normal">ضد </span>
{opponentName} {opponentName}
</span> </span>
{isBot && <Cpu size={10} className="text-text-muted shrink-0" />} <Cpu size={10} className="text-text-muted shrink-0" />
</div> </div>
{renderCapturedPieces(capturedByBlack, 'b')} {renderCapturedPieces(capturedByBlack, 'b')}
</div> </div>
...@@ -432,18 +886,13 @@ export function GamePage() { ...@@ -432,18 +886,13 @@ export function GamePage() {
{/* Board with coordinates */} {/* Board with coordinates */}
<div className="w-full max-w-[min(100vw-16px,400px)]"> <div className="w-full max-w-[min(100vw-16px,400px)]">
<div className="flex"> <div className="flex">
{/* Rank labels (left side) */}
{renderRankLabels()} {renderRankLabels()}
{/* Board */}
<div className="flex-1 aspect-square"> <div className="flex-1 aspect-square">
<div data-testid="chess-board" className="grid grid-cols-8 grid-rows-8 w-full h-full rounded-lg overflow-hidden shadow-xl border border-border"> <div data-testid="chess-board" className="grid grid-cols-8 grid-rows-8 w-full h-full rounded-lg overflow-hidden shadow-xl border border-border">
{renderBoard()} {renderBoard()}
</div> </div>
</div> </div>
</div> </div>
{/* File labels (bottom) */}
<div className="mr-4 mt-1"> <div className="mr-4 mt-1">
{renderFileLabels()} {renderFileLabels()}
</div> </div>
...@@ -489,7 +938,6 @@ export function GamePage() { ...@@ -489,7 +938,6 @@ export function GamePage() {
</div> </div>
</div> </div>
{/* Move counter */}
<div className="mt-3 text-[11px] text-text-muted text-center"> <div className="mt-3 text-[11px] text-text-muted text-center">
النقلة {game.moveNumber()} {game.inCheck() ? '- كش!' : ''} النقلة {game.moveNumber()} {game.inCheck() ? '- كش!' : ''}
</div> </div>
...@@ -518,8 +966,8 @@ export function GamePage() { ...@@ -518,8 +966,8 @@ export function GamePage() {
</p> </p>
<div className="flex gap-3 mt-2"> <div className="flex gap-3 mt-2">
<Button onClick={resetGame}>لعبة جديدة</Button> <Button onClick={resetGame}>لعبة جديدة</Button>
<Button variant="ghost" onClick={() => navigate(isBot ? '/bot-select' : '/')}> <Button variant="ghost" onClick={() => navigate('/bot-select')}>
{isBot ? 'اختر روبوت' : 'الرئيسية'} اختر روبوت
</Button> </Button>
</div> </div>
</motion.div> </motion.div>
......
import { useState } from 'react'
import { motion } from 'framer-motion' import { motion } from 'framer-motion'
import { Play, TrendingUp, Swords, Flame, Grid3X3, Bot, Users, Lightbulb, Crown } from 'lucide-react' import { Play, TrendingUp, Swords, Flame, Grid3X3, Bot, Users, Lightbulb, Crown, Trophy, X, Minus, ArrowUpRight, ArrowDownRight } from 'lucide-react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { useAuthStore } from '../stores/authStore' import { useAuthStore } from '../stores/authStore'
import { PageTransition } from '../components/layout/PageTransition' import { PageTransition } from '../components/layout/PageTransition'
import { DailyRewardModal } from '../components/DailyRewardModal'
import { useDailyReward } from '../hooks/useDailyReward'
import { useRecentGames } from '../hooks/useRecentGames'
const dailyTips = [ const dailyTips = [
'تحكم بالمركز في بداية اللعبة - الاحصنة والفيلة تكون اقوى من المركز', 'تحكم بالمركز في بداية اللعبة - الاحصنة والفيلة تكون اقوى من المركز',
...@@ -16,11 +20,29 @@ const dailyTips = [ ...@@ -16,11 +20,29 @@ const dailyTips = [
export function HomePage() { export function HomePage() {
const navigate = useNavigate() const navigate = useNavigate()
const { profile } = useAuthStore() const { user, profile } = useAuthStore()
const todayTip = dailyTips[new Date().getDay()] const todayTip = dailyTips[new Date().getDay()]
const { canClaim, streak, reward, claim, loading: dailyLoading } = useDailyReward()
const [showDailyModal, setShowDailyModal] = useState(true)
const { games, loading: gamesLoading } = useRecentGames(user?.id, 3)
async function handleClaimReward() {
const result = await claim()
if (result.success) {
setTimeout(() => setShowDailyModal(false), 1500)
}
}
return ( return (
<PageTransition className="px-7 py-7 flex flex-col gap-8"> <PageTransition className="px-7 py-7 flex flex-col gap-8">
<DailyRewardModal
open={canClaim && showDailyModal}
streak={streak}
reward={reward}
loading={dailyLoading}
onClaim={handleClaimReward}
onClose={() => setShowDailyModal(false)}
/>
{profile && ( {profile && (
<motion.div <motion.div
className="flex items-center gap-4" className="flex items-center gap-4"
...@@ -105,27 +127,65 @@ export function HomePage() { ...@@ -105,27 +127,65 @@ export function HomePage() {
</div> </div>
<div> <div>
<h3 className="text-sm font-bold mb-4 text-text-muted">اخر المباريات</h3> <div className="flex items-center justify-between mb-4">
<div className="flex flex-col items-center py-10 gap-4 rounded-2xl bg-surface-1 border border-border"> <h3 className="text-sm font-bold text-text-muted">اخر المباريات</h3>
<motion.div {games.length > 0 && (
className="w-14 h-14 rounded-2xl bg-surface-3/60 flex items-center justify-center" <motion.button
animate={{ rotate: [0, 3, -3, 0] }} onClick={() => navigate('/profile')}
transition={{ duration: 5, repeat: Infinity }} className="text-[11px] font-bold text-gold"
> whileTap={{ scale: 0.95 }}
<Grid3X3 size={28} className="text-text-muted/40" /> >
</motion.div> عرض الكل
<div className="text-center space-y-1"> </motion.button>
<p className="text-sm font-semibold text-text-secondary">لا توجد مباريات بعد</p> )}
<p className="text-xs text-text-muted">العب اول مباراة وابدا رحلتك</p>
</div>
<motion.button
onClick={() => navigate('/play')}
className="px-6 py-2.5 rounded-xl bg-gold/10 border border-gold/30 text-gold text-sm font-bold"
whileTap={{ scale: 0.95 }}
>
ابدا الان
</motion.button>
</div> </div>
{gamesLoading ? (
<div className="flex items-center justify-center py-8">
<div className="w-6 h-6 border-2 border-gold/30 border-t-gold rounded-full animate-spin" />
</div>
) : games.length === 0 ? (
<div className="flex flex-col items-center py-10 gap-4 rounded-2xl bg-surface-1 border border-border">
<motion.div
className="w-14 h-14 rounded-2xl bg-surface-3/60 flex items-center justify-center"
animate={{ rotate: [0, 3, -3, 0] }}
transition={{ duration: 5, repeat: Infinity }}
>
<Grid3X3 size={28} className="text-text-muted/40" />
</motion.div>
<div className="text-center space-y-1">
<p className="text-sm font-semibold text-text-secondary">لا توجد مباريات بعد</p>
<p className="text-xs text-text-muted">العب اول مباراة وابدا رحلتك</p>
</div>
<motion.button
onClick={() => navigate('/play')}
className="px-6 py-2.5 rounded-xl bg-gold/10 border border-gold/30 text-gold text-sm font-bold"
whileTap={{ scale: 0.95 }}
>
ابدا الان
</motion.button>
</div>
) : (
<div className="flex flex-col gap-2">
{games.map((game, i) => (
<motion.div
key={game.id}
className="flex items-center justify-between p-3.5 rounded-xl bg-surface-1 border border-border/80"
initial={{ opacity: 0, x: 10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.3 + 0.05 * i }}
>
<div className="flex items-center gap-3">
<HomeResultBadge result={game.result} />
<div>
<p className="text-sm font-semibold">{game.opponent}</p>
<p className="text-[10px] text-text-muted">{game.timeControl}</p>
</div>
</div>
<HomeRatingBadge change={game.ratingChange} />
</motion.div>
))}
</div>
)}
</div> </div>
<div className="p-5 rounded-2xl bg-surface-1 border border-border relative overflow-hidden"> <div className="p-5 rounded-2xl bg-surface-1 border border-border relative overflow-hidden">
...@@ -162,3 +222,33 @@ function StatCard({ icon, value, label, delay, accent }: { icon: React.ReactNode ...@@ -162,3 +222,33 @@ function StatCard({ icon, value, label, delay, accent }: { icon: React.ReactNode
</motion.div> </motion.div>
) )
} }
function HomeResultBadge({ result }: { result: 'win' | 'loss' | 'draw' }) {
const config = {
win: { icon: <Trophy size={14} />, bg: 'bg-cyan/15 text-cyan' },
loss: { icon: <X size={14} />, bg: 'bg-coral/15 text-coral' },
draw: { icon: <Minus size={14} />, bg: 'bg-text-muted/15 text-text-muted' },
}
const c = config[result]
return (
<div className={`w-8 h-8 rounded-lg flex items-center justify-center ${c.bg}`}>
{c.icon}
</div>
)
}
function HomeRatingBadge({ change }: { change: number }) {
if (change === 0) return <span className="text-xs text-text-muted font-medium">+0</span>
if (change > 0) {
return (
<span className="flex items-center gap-0.5 text-xs font-bold text-cyan">
<ArrowUpRight size={12} />+{change}
</span>
)
}
return (
<span className="flex items-center gap-0.5 text-xs font-bold text-coral">
<ArrowDownRight size={12} />{change}
</span>
)
}
import { useState } from 'react'
import { motion } from 'framer-motion'
import { Crown, Medal, Trophy } from 'lucide-react'
import { PageTransition } from '../components/layout/PageTransition' import { PageTransition } from '../components/layout/PageTransition'
import { Card } from '../components/ui/Card' import { useAuthStore } from '../stores/authStore'
import { useLeaderboard } from '../hooks/useLeaderboard'
type TimeControlType = 'bullet' | 'blitz' | 'rapid' | 'classical'
type Period = 'weekly' | 'monthly' | 'all_time'
const TIME_CONTROLS: { label: string; value: TimeControlType }[] = [
{ label: 'رصاصة', value: 'bullet' },
{ label: 'خاطف', value: 'blitz' },
{ label: 'سريع', value: 'rapid' },
{ label: 'كلاسيكي', value: 'classical' },
]
const PERIODS: { label: string; value: Period }[] = [
{ label: 'اسبوعي', value: 'weekly' },
{ label: 'شهري', value: 'monthly' },
{ label: 'الكل', value: 'all_time' },
]
function Avatar({ name, size = 44 }: { name: string; size?: number }) {
return (
<div
className="rounded-full bg-gradient-to-br from-surface-3 to-surface-2 flex items-center justify-center font-bold text-text-secondary"
style={{ width: size, height: size, fontSize: size * 0.4 }}
>
{name?.charAt(0) || '?'}
</div>
)
}
export function LeaderboardPage() { export function LeaderboardPage() {
const [timeControl, setTimeControl] = useState<TimeControlType>('blitz')
const [period, setPeriod] = useState<Period>('weekly')
const { entries, loading } = useLeaderboard(timeControl, period)
const { user } = useAuthStore()
const top3 = entries.slice(0, 3)
const rest = entries.slice(3)
return ( return (
<PageTransition className="px-4 py-6 flex flex-col gap-5"> <PageTransition className="px-7 py-7 flex flex-col gap-5">
<h1 className="text-xl font-bold">لوحة المتصدرين</h1> <h1 className="text-xl font-bold">لوحة المتصدرين</h1>
<Card className="flex flex-col items-center py-8">
<p className="text-text-muted text-sm">لا توجد بيانات بعد</p> <div className="flex gap-2 overflow-x-auto no-scrollbar pb-1">
<p className="text-text-muted text-xs mt-1">العب مباريات لتظهر في القائمة</p> {TIME_CONTROLS.map((tc) => (
</Card> <motion.button
key={tc.value}
whileTap={{ scale: 0.93 }}
onClick={() => setTimeControl(tc.value)}
className={`px-4 py-1.5 rounded-full text-xs font-bold whitespace-nowrap transition-colors ${
timeControl === tc.value
? 'bg-gold text-bg-primary'
: 'bg-surface-2 text-text-muted border border-border'
}`}
>
{tc.label}
</motion.button>
))}
</div>
<div className="flex gap-2 overflow-x-auto no-scrollbar">
{PERIODS.map((p) => (
<motion.button
key={p.value}
whileTap={{ scale: 0.93 }}
onClick={() => setPeriod(p.value)}
className={`px-3 py-1 rounded-full text-[11px] font-bold whitespace-nowrap transition-colors ${
period === p.value
? 'bg-surface-3 text-text-primary border border-gold/40'
: 'bg-surface-1 text-text-muted border border-border'
}`}
>
{p.label}
</motion.button>
))}
</div>
{loading ? (
<div className="flex flex-col items-center py-16">
<div className="w-10 h-10 rounded-full border-2 border-gold border-t-transparent animate-spin" />
</div>
) : entries.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 gap-4">
<motion.div
className="w-16 h-16 rounded-full bg-surface-2 border border-border flex items-center justify-center"
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: 'spring', stiffness: 300, damping: 20 }}
>
<Trophy size={24} className="text-gold" />
</motion.div>
<p className="text-text-muted text-sm">لا توجد بيانات بعد</p>
<p className="text-text-muted text-xs">العب مباريات لتظهر في القائمة</p>
</div>
) : (
<>
{top3.length > 0 && <Podium entries={top3} currentUserId={user?.id} />}
<div className="flex flex-col gap-2">
{rest.map((entry, i) => (
<motion.div
key={entry.player_id}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.3 + i * 0.03 }}
className={`flex items-center gap-3 p-3 rounded-xl bg-surface-1 border ${
entry.player_id === user?.id ? 'border-gold/50 bg-gold/5' : 'border-border'
}`}
>
<span className="w-7 text-center text-sm font-bold text-text-muted">
{entry.rank}
</span>
<Avatar name={entry.display_name} size={36} />
<div className="flex-1 min-w-0">
<p className="text-sm font-bold truncate">{entry.display_name}</p>
<p className="text-[10px] text-text-muted">
{entry.games_played} مباراة &middot; {entry.win_rate}% فوز
{entry.country_code && ` &middot; ${entry.country_code}`}
</p>
</div>
<span className="text-sm font-bold text-gold">{entry.rating}</span>
</motion.div>
))}
</div>
</>
)}
</PageTransition> </PageTransition>
) )
} }
function Podium({
entries,
currentUserId,
}: {
entries: { rank: number; player_id: string; rating: number; games_played: number; display_name: string }[]
currentUserId?: string
}) {
const ordered = [entries[1], entries[0], entries[2]].filter(Boolean)
const podiumConfig = [
{ color: 'text-[#C0C0C0]', bg: 'bg-[#C0C0C0]/10', border: 'border-[#C0C0C0]/30', size: 52, label: '2' },
{ color: 'text-gold', bg: 'bg-gold/10', border: 'border-gold/30', size: 64, label: '1' },
{ color: 'text-[#CD7F32]', bg: 'bg-[#CD7F32]/10', border: 'border-[#CD7F32]/30', size: 48, label: '3' },
]
return (
<div className="flex items-end justify-center gap-4 py-4">
{ordered.map((entry, i) => {
if (!entry) return null
const config = podiumConfig[i]
const isMe = entry.player_id === currentUserId
return (
<motion.div
key={entry.player_id}
className="flex flex-col items-center gap-2"
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ type: 'spring', stiffness: 300, damping: 20, delay: 0.1 + i * 0.12 }}
>
{i === 1 && (
<motion.div
initial={{ scale: 0, rotate: -20 }}
animate={{ scale: 1, rotate: 0 }}
transition={{ delay: 0.5, type: 'spring' }}
>
<Crown size={20} className="text-gold fill-gold/20" />
</motion.div>
)}
{i === 0 && <Medal size={16} className="text-[#C0C0C0]" />}
{i === 2 && <Medal size={16} className="text-[#CD7F32]" />}
<div
className={`rounded-full ${config.bg} border-2 ${config.border} flex items-center justify-center font-bold ${config.color} ${isMe ? 'ring-2 ring-gold/50' : ''}`}
style={{ width: config.size, height: config.size, fontSize: config.size * 0.35 }}
>
{entry.display_name?.charAt(0) || '?'}
</div>
<div className="text-center">
<p className="text-xs font-bold truncate max-w-[70px]">{entry.display_name}</p>
<p className={`text-sm font-bold ${config.color}`}>{entry.rating}</p>
<p className="text-[9px] text-text-muted">{entry.games_played} مباراة</p>
</div>
</motion.div>
)
})}
</div>
)
}
import { motion } from 'framer-motion' import { useEffect } from 'react'
import { useNavigate } from 'react-router-dom' import { motion, AnimatePresence } from 'framer-motion'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { Button } from '../components/ui/Button' import { Button } from '../components/ui/Button'
import { useMatchmaking } from '../hooks/useMatchmaking'
import { useMatchStore } from '../stores/matchStore'
import type { TIME_CONTROLS } from '../lib/constants'
type TimeControlKey = keyof typeof TIME_CONTROLS
function formatElapsed(seconds: number): string {
const m = Math.floor(seconds / 60)
const s = seconds % 60
return `${m}:${s.toString().padStart(2, '0')}`
}
export function MatchmakingPage() { export function MatchmakingPage() {
const navigate = useNavigate() const navigate = useNavigate()
const [searchParams] = useSearchParams()
const timeControl = (searchParams.get('tc') || 'blitz_5_0') as TimeControlKey
const gameKey = searchParams.get('game') || 'chess'
const { gameKey: storeGameKey } = useMatchStore()
const { searching, elapsed, matchFound, startSearch, cancel } = useMatchmaking(
timeControl,
gameKey || storeGameKey
)
useEffect(() => {
startSearch()
}, [])
const handleCancel = async () => {
await cancel()
navigate('/play')
}
return ( return (
<div className="flex-1 flex flex-col items-center justify-center px-6 py-12 relative overflow-hidden"> <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"> <AnimatePresence mode="wait">
{[0, 1, 2].map((i) => ( {matchFound ? (
<motion.div <motion.div
key={i} key="found"
className="absolute inset-0 rounded-full border-2 border-gold/30" className="flex flex-col items-center"
initial={{ scale: 0.5, opacity: 0.8 }} initial={{ scale: 0.5, opacity: 0 }}
animate={{ scale: 2.5, opacity: 0 }} animate={{ scale: 1, opacity: 1 }}
transition={{ transition={{ type: 'spring', stiffness: 300, damping: 20 }}
duration: 2.5, >
repeat: Infinity, <motion.div
delay: i * 0.8, className="w-24 h-24 rounded-full bg-gold/20 border-2 border-gold flex items-center justify-center"
ease: 'easeOut', animate={{ scale: [1, 1.1, 1] }}
}} transition={{ duration: 0.5, repeat: 2 }}
/> >
))} <span className="text-4xl">⚔️</span>
</motion.div>
<motion.h2
className="mt-6 text-xl font-bold text-gold"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
>
تم العثور على خصم!
</motion.h2>
</motion.div>
) : (
<motion.div
key="searching"
className="flex flex-col items-center"
exit={{ opacity: 0, scale: 0.8 }}
>
<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.div <motion.h2
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" className="mt-8 text-xl font-bold"
animate={{ scale: [1, 1.05, 1] }} initial={{ opacity: 0 }}
transition={{ duration: 2, repeat: Infinity, ease: 'easeInOut' }} animate={{ opacity: 1 }}
> transition={{ delay: 0.3 }}
<span className="text-3xl font-black text-gold">?</span> >
</motion.div> جاري البحث عن خصم
</div> </motion.h2>
<motion.h2 <motion.div
className="mt-8 text-xl font-bold" className="mt-3 text-lg font-mono text-gold tabular-nums"
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
transition={{ delay: 0.3 }} transition={{ delay: 0.4 }}
> >
جاري البحث عن خصم {formatElapsed(elapsed)}
</motion.h2> </motion.div>
<motion.div <motion.div
className="mt-2 flex gap-1" className="mt-2 flex gap-1"
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
transition={{ delay: 0.5 }} transition={{ delay: 0.5 }}
> >
{[0, 1, 2].map((i) => ( {[0, 1, 2].map((i) => (
<motion.span <motion.span
key={i} key={i}
className="w-2 h-2 rounded-full bg-gold" className="w-2 h-2 rounded-full bg-gold"
animate={{ opacity: [0.3, 1, 0.3] }} animate={{ opacity: [0.3, 1, 0.3] }}
transition={{ duration: 1, repeat: Infinity, delay: i * 0.3 }} transition={{ duration: 1, repeat: Infinity, delay: i * 0.3 }}
/> />
))} ))}
</motion.div> </motion.div>
<motion.div <motion.div
className="mt-12" className="mt-12"
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
transition={{ delay: 1 }} transition={{ delay: 1 }}
> >
<Button variant="ghost" onClick={() => navigate('/play')}> <Button variant="ghost" onClick={handleCancel}>
الغاء الغاء
</Button> </Button>
</motion.div> </motion.div>
</motion.div>
)}
</AnimatePresence>
</div> </div>
) )
} }
import { motion } from 'framer-motion' import { motion } from 'framer-motion'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { Home } from 'lucide-react'
import { Button } from '../components/ui/Button' import { Button } from '../components/ui/Button'
export function NotFoundPage() { export function NotFoundPage() {
const navigate = useNavigate() const navigate = useNavigate()
return ( return (
<div className="flex-1 flex flex-col items-center justify-center px-6 py-12"> <div className="flex-1 flex flex-col items-center justify-center px-6 py-12 gap-6">
<motion.div <motion.div
className="text-6xl font-black text-gold/30" className="text-6xl"
initial={{ scale: 0 }} animate={{
animate={{ scale: 1 }} rotate: [0, -8, 8, -5, 5, 0],
transition={{ type: 'spring', stiffness: 200, damping: 15 }} y: [0, -6, 0],
}}
transition={{
rotate: { duration: 2, repeat: Infinity, ease: 'easeInOut' },
y: { duration: 1.5, repeat: Infinity, ease: 'easeInOut' },
}}
> >
404
</motion.div> </motion.div>
<motion.div
className="text-7xl font-black text-gold"
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ type: 'spring', stiffness: 200, damping: 12, delay: 0.1 }}
>
٤٠٤
</motion.div>
<motion.p <motion.p
className="mt-4 text-text-muted" className="text-lg font-bold text-text-secondary"
initial={{ opacity: 0 }} initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1 }} animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }} transition={{ delay: 0.3 }}
> >
الصفحة غير موجودة الصفحة غير موجودة
</motion.p> </motion.p>
<motion.div
<motion.p
className="text-sm text-text-muted"
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
transition={{ delay: 0.5 }} transition={{ delay: 0.5 }}
className="mt-6"
> >
<Button variant="ghost" onClick={() => navigate('/')}> ضعت؟ ارجع للرئيسية
العودة للرئيسية </motion.p>
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.6, type: 'spring', stiffness: 300, damping: 20 }}
>
<Button variant="gold" onClick={() => navigate('/')}>
<Home size={18} />
<span>الرئيسية</span>
</Button> </Button>
</motion.div> </motion.div>
</div> </div>
......
import { Bell } from 'lucide-react'
import { motion } from 'framer-motion' import { motion } from 'framer-motion'
import { Bell, UserPlus, Trophy, Swords, CheckCheck, Loader2 } from 'lucide-react'
import { PageTransition } from '../components/layout/PageTransition' import { PageTransition } from '../components/layout/PageTransition'
import { useNotifications } from '../hooks/useNotifications'
function relativeTime(date: string): string {
const diff = Date.now() - new Date(date).getTime()
const minutes = Math.floor(diff / 60000)
if (minutes < 1) return 'الان'
if (minutes < 60) return `منذ ${minutes} د`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `منذ ${hours} س`
const days = Math.floor(hours / 24)
return `منذ ${days} ي`
}
function getIcon(type: string) {
switch (type) {
case 'friend_request':
case 'friend_accepted':
return <UserPlus size={16} className="text-cyan" />
case 'match_result':
case 'win':
return <Trophy size={16} className="text-gold" />
case 'match_invite':
case 'challenge':
return <Swords size={16} className="text-coral" />
default:
return <Bell size={16} className="text-text-muted" />
}
}
export function NotificationsPage() { export function NotificationsPage() {
const { notifications, markAsRead, markAllRead, loading } = useNotifications()
const stagger = {
hidden: { opacity: 0 },
show: { opacity: 1, transition: { staggerChildren: 0.04 } },
}
const item = {
hidden: { opacity: 0, y: 10 },
show: { opacity: 1, y: 0 },
}
return ( return (
<PageTransition className="px-4 py-6 flex flex-col gap-5"> <PageTransition className="px-7 py-7 flex flex-col gap-6">
<h1 className="text-xl font-bold">الاشعارات</h1> <div className="flex items-center justify-between">
<div className="flex-1 flex flex-col items-center justify-center py-12"> <h1 className="text-xl font-bold">الاشعارات</h1>
<motion.div {notifications.some((n) => !n.is_read) && (
className="w-16 h-16 rounded-full bg-surface-2 border border-border flex items-center justify-center mb-4" <motion.button
initial={{ scale: 0 }} className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-surface-2 border border-border text-xs text-text-muted"
animate={{ scale: 1 }} whileTap={{ scale: 0.9 }}
transition={{ type: 'spring', stiffness: 300, damping: 20 }} onClick={markAllRead}
> >
<Bell size={24} className="text-text-muted" /> <CheckCheck size={14} />
</motion.div> قراءة الكل
<p className="text-text-muted text-sm">لا توجد اشعارات</p> </motion.button>
)}
</div> </div>
{loading ? (
<div className="flex-1 flex items-center justify-center">
<Loader2 size={24} className="animate-spin text-text-muted" />
</div>
) : notifications.length === 0 ? (
<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>
) : (
<motion.div className="flex flex-col gap-2" variants={stagger} initial="hidden" animate="show">
{notifications.map((notification) => (
<motion.div
key={notification.id}
variants={item}
className={`rounded-xl p-3.5 border cursor-pointer transition-colors ${
notification.is_read
? 'bg-surface-1 border-border'
: 'bg-surface-2 border-gold/30 border-r-4 border-r-gold'
}`}
whileTap={{ scale: 0.97 }}
onClick={() => {
if (!notification.is_read) markAsRead(notification.id)
}}
>
<div className="flex items-start gap-3">
<div className="w-8 h-8 rounded-full bg-surface-3 border border-border flex items-center justify-center shrink-0 mt-0.5">
{getIcon(notification.type)}
</div>
<div className="flex-1 min-w-0">
<p className={`text-sm font-semibold ${notification.is_read ? 'text-text-muted' : 'text-text-primary'}`}>
{notification.title_ar || notification.title}
</p>
{(notification.body_ar || notification.body) && (
<p className="text-xs text-text-muted mt-0.5 line-clamp-2">
{notification.body_ar || notification.body}
</p>
)}
<p className="text-[10px] text-text-muted mt-1">
{relativeTime(notification.created_at)}
</p>
</div>
{!notification.is_read && (
<div className="w-2 h-2 rounded-full bg-gold shrink-0 mt-2" />
)}
</div>
</motion.div>
))}
</motion.div>
)}
</PageTransition> </PageTransition>
) )
} }
...@@ -137,7 +137,7 @@ export function PlayPage() { ...@@ -137,7 +137,7 @@ export function PlayPage() {
{/* Action Buttons */} {/* Action Buttons */}
<div className="flex flex-col items-center gap-3 mt-2 pb-4"> <div className="flex flex-col items-center gap-3 mt-2 pb-4">
<Button onClick={() => navigate('/matchmaking')} className="w-[85%]" size="lg"> <Button onClick={() => navigate(`/matchmaking?tc=${selectedTC}&game=chess`)} className="w-[85%]" size="lg">
البحث عن خصم البحث عن خصم
</Button> </Button>
<Button onClick={() => navigate('/bot-select')} variant="ghost" className="w-[85%]" size="md"> <Button onClick={() => navigate('/bot-select')} variant="ghost" className="w-[85%]" size="md">
......
import { motion } from 'framer-motion' import { useState } from 'react'
import { Settings, TrendingUp, Target, Flame, Trophy, LogOut, Lock } from 'lucide-react' import { motion, AnimatePresence } from 'framer-motion'
import { Settings, TrendingUp, Target, Flame, Trophy, Lock, Pencil, X, Clock, ArrowUpRight, ArrowDownRight, Minus } from 'lucide-react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { useAuthStore } from '../stores/authStore' import { useAuthStore } from '../stores/authStore'
import { useNotificationStore } from '../stores/notificationStore'
import { PageTransition } from '../components/layout/PageTransition' import { PageTransition } from '../components/layout/PageTransition'
import { Card } from '../components/ui/Card' import { Card } from '../components/ui/Card'
import { Button } from '../components/ui/Button'
import { supabase } from '../lib/supabase' import { supabase } from '../lib/supabase'
import { useRecentGames } from '../hooks/useRecentGames'
function SectionDivider() { function SectionDivider() {
return <div className="w-full h-px bg-gradient-to-l from-transparent via-border to-transparent my-1" /> return <div className="w-full h-px bg-gradient-to-l from-transparent via-border to-transparent my-1" />
} }
export function ProfilePage() { export function ProfilePage() {
const { profile } = useAuthStore() const { user, profile, setProfile } = useAuthStore()
const { showToast } = useNotificationStore()
const navigate = useNavigate() const navigate = useNavigate()
const [editOpen, setEditOpen] = useState(false)
const [editForm, setEditForm] = useState({
display_name: '',
bio: '',
country_code: '',
})
const [saving, setSaving] = useState(false)
const { games, loading: gamesLoading } = useRecentGames(user?.id, 5)
async function handleLogout() { function openEdit() {
await supabase.auth.signOut() if (!profile) return
navigate('/login', { replace: true }) setEditForm({
display_name: profile.display_name || '',
bio: profile.bio || '',
country_code: profile.country_code || '',
})
setEditOpen(true)
}
async function handleSave() {
if (!user || !profile) return
setSaving(true)
const { error } = await supabase
.from('profiles')
.update({
display_name: editForm.display_name.trim(),
bio: editForm.bio.trim() || null,
country_code: editForm.country_code.trim() || null,
})
.eq('id', user.id)
setSaving(false)
if (error) {
showToast({ type: 'error', title: 'حدث خطأ في حفظ البيانات' })
return
}
setProfile({
...profile,
display_name: editForm.display_name.trim(),
bio: editForm.bio.trim() || null,
country_code: editForm.country_code.trim() || null,
})
showToast({ type: 'success', title: 'تم حفظ التعديلات' })
setEditOpen(false)
} }
if (!profile) return null if (!profile) return null
...@@ -27,7 +75,6 @@ export function ProfilePage() { ...@@ -27,7 +75,6 @@ export function ProfilePage() {
return ( return (
<PageTransition className="px-7 py-7 flex flex-col gap-6"> <PageTransition className="px-7 py-7 flex flex-col gap-6">
{/* Avatar + Name */}
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<motion.div <motion.div
...@@ -44,18 +91,29 @@ export function ProfilePage() { ...@@ -44,18 +91,29 @@ export function ProfilePage() {
<div className="space-y-1"> <div className="space-y-1">
<h1 className="text-xl font-bold">{profile.display_name}</h1> <h1 className="text-xl font-bold">{profile.display_name}</h1>
<p className="text-sm text-text-muted">@{profile.username}</p> <p className="text-sm text-text-muted">@{profile.username}</p>
{profile.bio && (
<p className="text-xs text-text-secondary mt-1 max-w-[180px] line-clamp-2">{profile.bio}</p>
)}
</div> </div>
</div> </div>
<motion.button <div className="flex items-center gap-2">
whileTap={{ scale: 0.9 }} <motion.button
onClick={() => navigate('/settings')} whileTap={{ scale: 0.9 }}
className="p-2.5 rounded-xl bg-surface-2 border border-border" onClick={openEdit}
> className="p-2.5 rounded-xl bg-surface-2 border border-border"
<Settings size={18} className="text-text-muted" /> >
</motion.button> <Pencil size={18} className="text-text-muted" />
</motion.button>
<motion.button
whileTap={{ scale: 0.9 }}
onClick={() => navigate('/settings')}
className="p-2.5 rounded-xl bg-surface-2 border border-border"
>
<Settings size={18} className="text-text-muted" />
</motion.button>
</div>
</div> </div>
{/* XP Card */}
<Card className="p-5"> <Card className="p-5">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-bold">المستوى {profile.level}</h2> <h2 className="text-lg font-bold">المستوى {profile.level}</h2>
...@@ -74,7 +132,6 @@ export function ProfilePage() { ...@@ -74,7 +132,6 @@ export function ProfilePage() {
<SectionDivider /> <SectionDivider />
{/* Ratings */}
<div> <div>
<h2 className="text-sm font-bold text-text-secondary mb-3">التقييمات</h2> <h2 className="text-sm font-bold text-text-secondary mb-3">التقييمات</h2>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
...@@ -105,7 +162,6 @@ export function ProfilePage() { ...@@ -105,7 +162,6 @@ export function ProfilePage() {
<SectionDivider /> <SectionDivider />
{/* Stats */}
<div> <div>
<h2 className="text-sm font-bold text-text-secondary mb-3">الاحصائيات</h2> <h2 className="text-sm font-bold text-text-secondary mb-3">الاحصائيات</h2>
<div className="grid grid-cols-4 gap-2.5"> <div className="grid grid-cols-4 gap-2.5">
...@@ -118,7 +174,6 @@ export function ProfilePage() { ...@@ -118,7 +174,6 @@ export function ProfilePage() {
<SectionDivider /> <SectionDivider />
{/* Achievements */}
<div> <div>
<h2 className="text-sm font-bold text-text-secondary mb-3">الانجازات</h2> <h2 className="text-sm font-bold text-text-secondary mb-3">الانجازات</h2>
<div className="grid grid-cols-3 gap-3"> <div className="grid grid-cols-3 gap-3">
...@@ -144,21 +199,168 @@ export function ProfilePage() { ...@@ -144,21 +199,168 @@ export function ProfilePage() {
</div> </div>
</div> </div>
{/* Logout */} <SectionDivider />
<div className="pt-4 pb-6 flex justify-center">
<motion.button <div>
whileTap={{ scale: 0.95 }} <h2 className="text-sm font-bold text-text-secondary mb-3">اخر المباريات</h2>
onClick={handleLogout} {gamesLoading ? (
className="flex items-center gap-2 px-4 py-2 rounded-lg text-xs text-coral/70 hover:text-coral transition-colors" <div className="flex items-center justify-center py-8">
> <div className="w-6 h-6 border-2 border-gold/30 border-t-gold rounded-full animate-spin" />
<LogOut size={14} /> </div>
<span>تسجيل الخروج</span> ) : games.length === 0 ? (
</motion.button> <div className="flex flex-col items-center py-8 gap-2 rounded-2xl bg-surface-1 border border-border">
<Clock size={24} className="text-text-muted/40" />
<p className="text-sm text-text-muted">لا توجد مباريات بعد</p>
</div>
) : (
<div className="flex flex-col gap-2">
{games.map((game, i) => (
<motion.div
key={game.id}
className="flex items-center justify-between p-3.5 rounded-xl bg-surface-1 border border-border/80"
initial={{ opacity: 0, x: 10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.05 * i }}
>
<div className="flex items-center gap-3">
<ResultBadge result={game.result} />
<div>
<p className="text-sm font-semibold">{game.opponent}</p>
<p className="text-[10px] text-text-muted">{game.timeControl} &middot; {formatRelativeTime(game.completedAt)}</p>
</div>
</div>
<RatingBadge change={game.ratingChange} />
</motion.div>
))}
</div>
)}
</div> </div>
<AnimatePresence>
{editOpen && (
<motion.div
className="fixed inset-0 z-50 flex items-center justify-center px-6"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
<motion.div
className="absolute inset-0 bg-background/80 backdrop-blur-sm"
onClick={() => setEditOpen(false)}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
/>
<motion.div
className="relative w-full max-w-[340px] rounded-2xl bg-surface-1 border border-border p-6 flex flex-col gap-5"
initial={{ scale: 0.9, opacity: 0, y: 20 }}
animate={{ scale: 1, opacity: 1, y: 0 }}
exit={{ scale: 0.9, opacity: 0, y: 20 }}
transition={{ type: 'spring', stiffness: 400, damping: 25 }}
>
<div className="flex items-center justify-between">
<h2 className="text-lg font-bold">تعديل الملف الشخصي</h2>
<motion.button
whileTap={{ scale: 0.9 }}
onClick={() => setEditOpen(false)}
className="p-2 rounded-lg bg-surface-3"
>
<X size={16} className="text-text-muted" />
</motion.button>
</div>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold text-text-secondary">الاسم</label>
<input
type="text"
value={editForm.display_name}
onChange={(e) => setEditForm({ ...editForm, display_name: e.target.value })}
className="px-4 py-3 rounded-xl bg-surface-3 border-2 border-transparent focus:border-gold/60 text-sm outline-none transition-colors"
maxLength={30}
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold text-text-secondary">نبذة</label>
<textarea
value={editForm.bio}
onChange={(e) => setEditForm({ ...editForm, bio: e.target.value })}
className="px-4 py-3 rounded-xl bg-surface-3 border-2 border-transparent focus:border-gold/60 text-sm outline-none transition-colors resize-none h-20"
maxLength={150}
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold text-text-secondary">رمز الدولة</label>
<input
type="text"
value={editForm.country_code}
onChange={(e) => setEditForm({ ...editForm, country_code: e.target.value })}
placeholder="EG"
className="px-4 py-3 rounded-xl bg-surface-3 border-2 border-transparent focus:border-gold/60 text-sm outline-none transition-colors"
maxLength={2}
/>
</div>
</div>
<Button
onClick={handleSave}
loading={saving}
disabled={!editForm.display_name.trim()}
>
حفظ التعديلات
</Button>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</PageTransition> </PageTransition>
) )
} }
function ResultBadge({ result }: { result: 'win' | 'loss' | 'draw' }) {
const config = {
win: { icon: <Trophy size={14} />, bg: 'bg-cyan/15 text-cyan', label: 'فوز' },
loss: { icon: <X size={14} />, bg: 'bg-coral/15 text-coral', label: 'خسارة' },
draw: { icon: <Minus size={14} />, bg: 'bg-text-muted/15 text-text-muted', label: 'تعادل' },
}
const c = config[result]
return (
<div className={`w-8 h-8 rounded-lg flex items-center justify-center ${c.bg}`}>
{c.icon}
</div>
)
}
function RatingBadge({ change }: { change: number }) {
if (change === 0) return <span className="text-xs text-text-muted font-medium">+0</span>
if (change > 0) {
return (
<span className="flex items-center gap-0.5 text-xs font-bold text-cyan">
<ArrowUpRight size={12} />+{change}
</span>
)
}
return (
<span className="flex items-center gap-0.5 text-xs font-bold text-coral">
<ArrowDownRight size={12} />{change}
</span>
)
}
function formatRelativeTime(dateStr: string) {
const now = Date.now()
const then = new Date(dateStr).getTime()
const diff = Math.floor((now - then) / 1000)
if (diff < 60) return 'الان'
if (diff < 3600) return `${Math.floor(diff / 60)} د`
if (diff < 86400) return `${Math.floor(diff / 3600)} س`
if (diff < 604800) return `${Math.floor(diff / 86400)} ي`
return `${Math.floor(diff / 604800)} أ`
}
function StatMini({ icon, value, label }: { icon: React.ReactNode; value: number | string; label: string }) { function StatMini({ icon, value, label }: { icon: React.ReactNode; value: number | string; label: string }) {
return ( return (
<div className="flex flex-col items-center gap-1.5 p-3 rounded-xl bg-surface-1 border border-border/80"> <div className="flex flex-col items-center gap-1.5 p-3 rounded-xl bg-surface-1 border border-border/80">
......
import { Volume2, VolumeX, Info } from 'lucide-react' import { Volume2, VolumeX, Info, LogOut, Trash2, Bug } from 'lucide-react'
import { motion } from 'framer-motion' import { motion } from 'framer-motion'
import { useNavigate } from 'react-router-dom'
import { PageTransition } from '../components/layout/PageTransition' import { PageTransition } from '../components/layout/PageTransition'
import { Card } from '../components/ui/Card' import { Card } from '../components/ui/Card'
import { useUIStore } from '../stores/uiStore' import { useUIStore } from '../stores/uiStore'
import { supabase } from '../lib/supabase'
export function SettingsPage() { export function SettingsPage() {
const { soundEnabled, setSoundEnabled } = useUIStore() const { soundEnabled, setSoundEnabled } = useUIStore()
const navigate = useNavigate()
async function handleLogout() {
await supabase.auth.signOut()
navigate('/login', { replace: true })
}
return ( return (
<PageTransition className="px-4 py-6 flex flex-col gap-5"> <PageTransition className="px-7 py-7 flex flex-col gap-5">
<h1 className="text-xl font-bold">الاعدادات</h1> <h1 className="text-xl font-bold">الاعدادات</h1>
<Card className="flex items-center justify-between"> <Card className="flex items-center justify-between">
...@@ -36,6 +44,37 @@ export function SettingsPage() { ...@@ -36,6 +44,37 @@ export function SettingsPage() {
<p className="text-xs text-text-muted">الاصدار 1.0.0</p> <p className="text-xs text-text-muted">الاصدار 1.0.0</p>
</div> </div>
</Card> </Card>
<Card className="flex items-center gap-3">
<Bug size={18} className="text-text-muted" />
<div>
<p className="text-sm font-semibold">الابلاغ عن مشكلة</p>
<p className="text-xs text-text-muted">ساعدنا نحسن التطبيق</p>
</div>
</Card>
<div className="pt-4">
<motion.button
whileTap={{ scale: 0.95 }}
onClick={handleLogout}
className="w-full flex items-center justify-center gap-2.5 px-5 py-3.5 rounded-xl bg-coral/10 border border-coral/20 text-coral text-sm font-bold"
>
<LogOut size={16} />
<span>تسجيل الخروج</span>
</motion.button>
</div>
<div className="pt-2">
<h3 className="text-xs font-bold text-text-muted mb-3">منطقة الخطر</h3>
<button
disabled
className="w-full flex items-center justify-center gap-2.5 px-5 py-3.5 rounded-xl bg-surface-2 border border-border text-text-muted text-sm font-semibold opacity-50 cursor-not-allowed"
>
<Trash2 size={16} />
<span>حذف الحساب</span>
<span className="text-[10px] mr-1 px-2 py-0.5 rounded-full bg-surface-3 text-text-muted">قريبا</span>
</button>
</div>
</PageTransition> </PageTransition>
) )
} }
import { ShoppingBag } from 'lucide-react' import { useState } from 'react'
import { motion } from 'framer-motion' import { motion, AnimatePresence } from 'framer-motion'
import { Coins, Gem, ShoppingBag, Sparkles, Check, X } from 'lucide-react'
import { PageTransition } from '../components/layout/PageTransition' import { PageTransition } from '../components/layout/PageTransition'
import { Button } from '../components/ui/Button'
import { useAuthStore } from '../stores/authStore'
import { useShop, type Cosmetic } from '../hooks/useShop'
import { RARITY_COLORS } from '../lib/constants'
type FilterType = 'all' | 'avatar_frame' | 'board_theme' | 'piece_set' | 'profile_banner' | 'title_badge'
const FILTER_OPTIONS: { key: FilterType; label: string }[] = [
{ key: 'all', label: 'الكل' },
{ key: 'avatar_frame', label: 'اطارات' },
{ key: 'board_theme', label: 'رقع' },
{ key: 'piece_set', label: 'قطع' },
{ key: 'profile_banner', label: 'خلفيات' },
{ key: 'title_badge', label: 'القاب' },
]
const RARITY_LABELS: Record<string, string> = {
common: 'عادي',
uncommon: 'غير عادي',
rare: 'نادر',
epic: 'ملحمي',
legendary: 'اسطوري',
}
export function ShopPage() { export function ShopPage() {
const { profile } = useAuthStore()
const [filter, setFilter] = useState<FilterType>('all')
const { items, ownedIds, equippedIds, loading, purchase, equip } = useShop(filter)
const [selectedItem, setSelectedItem] = useState<Cosmetic | null>(null)
const [purchasing, setPurchasing] = useState(false)
const [purchaseSuccess, setPurchaseSuccess] = useState(false)
async function handlePurchase() {
if (!selectedItem) return
setPurchasing(true)
const result = await purchase(selectedItem.id)
setPurchasing(false)
if (result.success) {
setPurchaseSuccess(true)
setTimeout(() => {
setPurchaseSuccess(false)
setSelectedItem(null)
}, 1500)
}
}
async function handleEquip() {
if (!selectedItem) return
setPurchasing(true)
await equip(selectedItem.id)
setPurchasing(false)
setSelectedItem(null)
}
return ( return (
<PageTransition className="px-4 py-6 flex flex-col gap-5"> <PageTransition className="px-7 py-7 flex flex-col gap-5">
<h1 className="text-xl font-bold">المتجر</h1> <div className="flex items-center justify-between">
<div className="flex-1 flex flex-col items-center justify-center py-12"> <div className="flex items-center gap-2">
<motion.div <ShoppingBag size={22} className="text-gold" />
className="w-16 h-16 rounded-full bg-surface-2 border border-border flex items-center justify-center mb-4" <h1 className="text-xl font-bold">المتجر</h1>
initial={{ scale: 0 }} </div>
animate={{ scale: 1 }} <div className="flex items-center gap-3">
transition={{ type: 'spring', stiffness: 300, damping: 20 }} <div className="flex items-center gap-1.5 bg-surface-2 border border-border rounded-xl px-3 py-1.5">
> <Coins size={14} className="text-gold" />
<ShoppingBag size={24} className="text-gold" /> <span className="text-sm font-bold text-gold">{profile?.coins || 0}</span>
</motion.div> </div>
<p className="text-text-muted text-sm">المتجر قريبا</p> <div className="flex items-center gap-1.5 bg-surface-2 border border-border rounded-xl px-3 py-1.5">
<Gem size={14} className="text-purple" />
<span className="text-sm font-bold text-purple">{profile?.gems || 0}</span>
</div>
</div>
</div> </div>
<div className="flex gap-2 overflow-x-auto pb-1 -mx-1 px-1 scrollbar-hide">
{FILTER_OPTIONS.map((opt) => (
<motion.button
key={opt.key}
onClick={() => setFilter(opt.key)}
className={`px-4 py-2 rounded-xl text-xs font-bold whitespace-nowrap transition-colors ${
filter === opt.key
? 'bg-gold/15 border border-gold/40 text-gold'
: 'bg-surface-2 border border-border text-text-muted'
}`}
whileTap={{ scale: 0.95 }}
>
{opt.label}
</motion.button>
))}
</div>
{loading ? (
<div className="flex-1 flex items-center justify-center py-20">
<motion.div
className="w-8 h-8 border-2 border-gold/30 border-t-gold rounded-full"
animate={{ rotate: 360 }}
transition={{ duration: 1, repeat: Infinity, ease: 'linear' }}
/>
</div>
) : items.length === 0 ? (
<div className="flex-1 flex flex-col items-center justify-center py-16 gap-4">
<motion.div
className="w-16 h-16 rounded-full bg-surface-2 border border-border flex items-center justify-center"
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: 'spring', stiffness: 300, damping: 20 }}
>
<ShoppingBag size={24} className="text-text-muted" />
</motion.div>
<p className="text-text-muted text-sm">لا توجد عناصر متاحة</p>
</div>
) : (
<div className="grid grid-cols-2 gap-3">
{items.map((item, index) => {
const isOwned = ownedIds.includes(item.id)
const isEquipped = equippedIds.includes(item.id)
const rarityColor = RARITY_COLORS[item.rarity]
const isLegendary = item.rarity === 'legendary'
return (
<motion.button
key={item.id}
onClick={() => setSelectedItem(item)}
className="relative flex flex-col items-center gap-2 p-4 rounded-2xl bg-surface-1 border overflow-hidden text-center"
style={{ borderColor: `${rarityColor}40` }}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.05 }}
whileTap={{ scale: 0.96 }}
>
{isLegendary && (
<motion.div
className="absolute inset-0 rounded-2xl"
style={{ boxShadow: `inset 0 0 20px ${rarityColor}20, 0 0 15px ${rarityColor}15` }}
animate={{ opacity: [0.5, 1, 0.5] }}
transition={{ duration: 2, repeat: Infinity }}
/>
)}
<div
className="w-full aspect-square rounded-xl bg-surface-2 flex items-center justify-center relative"
style={{ borderColor: `${rarityColor}30` }}
>
{item.preview_url ? (
<img
src={item.preview_url}
alt={item.name_ar}
className="w-full h-full object-cover rounded-xl"
/>
) : (
<div
className="w-12 h-12 rounded-xl"
style={{ background: `linear-gradient(135deg, ${rarityColor}40, ${rarityColor}10)` }}
/>
)}
</div>
<div className="flex items-center gap-1.5">
<div
className="w-2 h-2 rounded-full"
style={{ backgroundColor: rarityColor }}
/>
<span className="text-xs font-bold text-text-primary truncate max-w-[90px]">
{item.name_ar}
</span>
</div>
{isOwned ? (
<div className="flex items-center gap-1 bg-green-500/10 border border-green-500/30 rounded-lg px-2 py-0.5">
<Check size={10} className="text-green-400" />
<span className="text-[10px] font-bold text-green-400">
{isEquipped ? 'مفعّل' : 'مملوك'}
</span>
</div>
) : (
<div className="flex items-center gap-1">
{item.price_gems ? (
<>
<Gem size={12} className="text-purple" />
<span className="text-xs font-bold text-purple">{item.price_gems}</span>
</>
) : (
<>
<Coins size={12} className="text-gold" />
<span className="text-xs font-bold text-gold">{item.price_coins}</span>
</>
)}
</div>
)}
</motion.button>
)
})}
</div>
)}
<AnimatePresence>
{selectedItem && (
<motion.div
className="fixed inset-0 z-50 bg-background/80 backdrop-blur-sm flex items-center justify-center p-6"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => !purchasing && setSelectedItem(null)}
>
<motion.div
className="w-full max-w-[320px] rounded-3xl bg-surface-1 border border-border p-6 flex flex-col items-center gap-4 relative overflow-hidden"
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.8, opacity: 0 }}
transition={{ type: 'spring', stiffness: 300, damping: 25 }}
onClick={(e) => e.stopPropagation()}
>
<button
onClick={() => !purchasing && setSelectedItem(null)}
className="absolute top-4 left-4 w-8 h-8 rounded-full bg-surface-2 border border-border flex items-center justify-center"
>
<X size={16} className="text-text-muted" />
</button>
{purchaseSuccess ? (
<motion.div
className="flex flex-col items-center gap-4 py-8"
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: 'spring', stiffness: 300, damping: 20 }}
>
<motion.div
className="w-16 h-16 rounded-full bg-green-500/10 border-2 border-green-500/40 flex items-center justify-center"
animate={{ scale: [1, 1.1, 1] }}
transition={{ duration: 0.5 }}
>
<Check size={32} className="text-green-400" />
</motion.div>
<p className="text-lg font-bold text-green-400">تم الشراء بنجاح</p>
<motion.div
className="flex items-center gap-2"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
>
<Coins size={16} className="text-gold" />
<span className="text-sm text-text-muted">
-{selectedItem.price_gems || selectedItem.price_coins}
</span>
</motion.div>
</motion.div>
) : (
<>
<div
className="w-full aspect-[4/3] rounded-2xl bg-surface-2 flex items-center justify-center mt-4"
style={{ borderColor: `${RARITY_COLORS[selectedItem.rarity]}30` }}
>
{selectedItem.preview_url ? (
<img
src={selectedItem.preview_url}
alt={selectedItem.name_ar}
className="w-full h-full object-cover rounded-2xl"
/>
) : (
<div
className="w-20 h-20 rounded-2xl"
style={{
background: `linear-gradient(135deg, ${RARITY_COLORS[selectedItem.rarity]}40, ${RARITY_COLORS[selectedItem.rarity]}10)`,
}}
/>
)}
</div>
<div className="text-center">
<h3 className="text-lg font-black text-text-primary">{selectedItem.name_ar}</h3>
{selectedItem.description && (
<p className="text-xs text-text-muted mt-1">{selectedItem.description}</p>
)}
</div>
<div className="flex items-center gap-2">
<div
className="w-2.5 h-2.5 rounded-full"
style={{ backgroundColor: RARITY_COLORS[selectedItem.rarity] }}
/>
<span
className="text-xs font-bold"
style={{ color: RARITY_COLORS[selectedItem.rarity] }}
>
{RARITY_LABELS[selectedItem.rarity]}
</span>
{selectedItem.rarity === 'legendary' && (
<Sparkles size={12} style={{ color: RARITY_COLORS.legendary }} />
)}
</div>
{ownedIds.includes(selectedItem.id) ? (
<div className="flex flex-col gap-3 w-full">
{equippedIds.includes(selectedItem.id) ? (
<div className="flex items-center justify-center gap-2 py-3 rounded-xl bg-green-500/10 border border-green-500/30">
<Check size={16} className="text-green-400" />
<span className="text-sm font-bold text-green-400">مفعّل حاليا</span>
</div>
) : (
<Button
variant="cyan"
size="md"
onClick={handleEquip}
loading={purchasing}
className="w-full"
>
تفعيل
</Button>
)}
</div>
) : (
<div className="flex flex-col gap-3 w-full">
<div className="flex items-center justify-center gap-2 bg-surface-2 rounded-xl px-4 py-3 border border-border">
{selectedItem.price_gems ? (
<>
<Gem size={18} className="text-purple" />
<span className="text-lg font-black text-purple">{selectedItem.price_gems}</span>
</>
) : (
<>
<Coins size={18} className="text-gold" />
<span className="text-lg font-black text-gold">{selectedItem.price_coins}</span>
</>
)}
</div>
<Button
variant="gold"
size="md"
onClick={handlePurchase}
loading={purchasing}
disabled={
selectedItem.price_gems
? (profile?.gems || 0) < (selectedItem.price_gems || 0)
: (profile?.coins || 0) < (selectedItem.price_coins || 0)
}
className="w-full"
>
شراء
</Button>
{(selectedItem.price_gems
? (profile?.gems || 0) < (selectedItem.price_gems || 0)
: (profile?.coins || 0) < (selectedItem.price_coins || 0)) && (
<p className="text-[10px] text-coral text-center">رصيد غير كافي</p>
)}
</div>
)}
</>
)}
</motion.div>
</motion.div>
)}
</AnimatePresence>
</PageTransition> </PageTransition>
) )
} }
import { motion } from 'framer-motion' import { useState } from 'react'
import { Trophy, Users, Calendar } from 'lucide-react' import { motion, AnimatePresence } from 'framer-motion'
import { Trophy, Users, Calendar, Clock, Coins, Gem } from 'lucide-react'
import { PageTransition } from '../components/layout/PageTransition' import { PageTransition } from '../components/layout/PageTransition'
import { Card } from '../components/ui/Card' import { Card } from '../components/ui/Card'
import { useAuthStore } from '../stores/authStore'
import { useTournaments } from '../hooks/useTournaments'
type StatusFilter = null | 'registration' | 'in_progress' | 'completed'
const FILTERS: { label: string; value: StatusFilter }[] = [
{ label: 'الكل', value: null },
{ label: 'مفتوحة', value: 'registration' },
{ label: 'جارية', value: 'in_progress' },
{ label: 'منتهية', value: 'completed' },
]
const FORMAT_LABELS: Record<string, string> = {
swiss: 'سويسري',
round_robin: 'دوري كامل',
single_elimination: 'خروج مباشر',
double_elimination: 'خروج مزدوج',
arena: 'أرينا',
team_battle: 'معركة فرق',
swiss_to_bracket: 'سويسري + أقواس',
}
const TIME_CONTROL_LABELS: Record<string, string> = {
bullet: 'رصاصة',
blitz: 'خاطف',
rapid: 'سريع',
classical: 'كلاسيكي',
}
function formatRelativeTime(dateStr: string): string {
const date = new Date(dateStr)
const now = new Date()
const diff = date.getTime() - now.getTime()
const absDiff = Math.abs(diff)
const minutes = Math.floor(absDiff / 60000)
const hours = Math.floor(minutes / 60)
const days = Math.floor(hours / 24)
if (diff > 0) {
if (days > 0) return `بعد ${days} يوم`
if (hours > 0) return `بعد ${hours} ساعة`
return `بعد ${minutes} دقيقة`
} else {
if (days > 0) return `منذ ${days} يوم`
if (hours > 0) return `منذ ${hours} ساعة`
return `منذ ${minutes} دقيقة`
}
}
function StatusBadge({ status }: { status: string }) {
const config: Record<string, { label: string; classes: string }> = {
registration: { label: 'مفتوحة', classes: 'bg-cyan/10 text-cyan border-cyan/30' },
in_progress: { label: 'جارية', classes: 'bg-gold/10 text-gold border-gold/30 animate-pulse' },
completed: { label: 'منتهية', classes: 'bg-surface-3 text-text-muted border-border' },
cancelled: { label: 'ملغاة', classes: 'bg-coral/10 text-coral border-coral/30' },
}
const c = config[status] ?? config.completed
return (
<span className={`px-2 py-0.5 rounded-full text-[10px] font-bold border ${c.classes}`}>
{c.label}
</span>
)
}
export function TournamentsPage() { export function TournamentsPage() {
const [activeFilter, setActiveFilter] = useState<StatusFilter>(null)
const { tournaments, myRegistrations, loading, register, unregister } = useTournaments(activeFilter)
const { user } = useAuthStore()
const [expandedId, setExpandedId] = useState<string | null>(null)
const isRegistered = (tournamentId: string) =>
myRegistrations.some((r) => r.tournament_id === tournamentId)
return ( return (
<PageTransition className="px-4 py-6 flex flex-col gap-5"> <PageTransition className="px-7 py-7 flex flex-col gap-5">
<h1 className="text-xl font-bold">البطولات</h1> <h1 className="text-xl font-bold">البطولات</h1>
<div className="flex flex-col gap-3"> <div className="flex gap-2 overflow-x-auto no-scrollbar pb-1">
<Card glow className="flex flex-col gap-3"> {FILTERS.map((f) => (
<div className="flex items-center justify-between"> <motion.button
<div className="flex items-center gap-2"> key={f.label}
<div className="w-8 h-8 rounded-lg bg-gold/10 flex items-center justify-center"> whileTap={{ scale: 0.93 }}
<Trophy size={16} className="text-gold" /> onClick={() => setActiveFilter(f.value)}
</div> className={`px-4 py-1.5 rounded-full text-xs font-bold whitespace-nowrap transition-colors ${
<div> activeFilter === f.value
<h3 className="text-sm font-bold">لا توجد بطولات حالية</h3> ? 'bg-gold text-bg-primary'
<p className="text-xs text-text-muted">سيتم اضافة بطولات قريبا</p> : 'bg-surface-2 text-text-muted border border-border'
</div> }`}
</div> >
</div> {f.label}
</Card> </motion.button>
))}
</div>
{[1, 2].map((i) => ( {loading ? (
<div className="flex flex-col gap-3">
{[1, 2, 3].map((i) => (
<div key={i} className="rounded-2xl bg-surface-1 border border-border p-5 animate-pulse">
<div className="w-32 h-4 rounded bg-surface-3 mb-3" />
<div className="w-20 h-3 rounded bg-surface-3" />
</div>
))}
</div>
) : tournaments.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 gap-4">
<motion.div <motion.div
key={i} className="w-16 h-16 rounded-full bg-surface-2 border border-border flex items-center justify-center"
initial={{ opacity: 0, y: 10 }} initial={{ scale: 0 }}
animate={{ opacity: 1, y: 0 }} animate={{ scale: 1 }}
transition={{ delay: i * 0.1 }} transition={{ type: 'spring', stiffness: 300, damping: 20 }}
> >
<Card className="flex flex-col gap-3 opacity-50"> <Trophy size={24} className="text-gold" />
<div className="flex items-center justify-between"> </motion.div>
<div className="flex items-center gap-2"> <p className="text-text-muted text-sm">لا توجد بطولات حالية</p>
<div className="w-8 h-8 rounded-lg bg-surface-3 flex items-center justify-center"> </div>
<Trophy size={16} className="text-text-muted" /> ) : (
<div className="flex flex-col gap-3">
<AnimatePresence>
{tournaments.map((t, i) => (
<motion.div
key={t.id}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -12 }}
transition={{ delay: i * 0.05 }}
>
<Card
className="flex flex-col gap-3"
onClick={() => setExpandedId(expandedId === t.id ? null : t.id)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-lg bg-gold/10 flex items-center justify-center">
<Trophy size={16} className="text-gold" />
</div>
<div>
<h3 className="text-sm font-bold leading-tight">{t.name_ar || t.name}</h3>
<p className="text-[11px] text-text-muted mt-0.5">
{FORMAT_LABELS[t.format] ?? t.format}
</p>
</div>
</div>
<StatusBadge status={t.status} />
</div> </div>
<div>
<div className="w-32 h-3 rounded bg-surface-3" /> <div className="flex items-center gap-4 text-[11px] text-text-muted">
<div className="w-20 h-2 rounded bg-surface-3 mt-1.5" /> <span className="flex items-center gap-1">
<Clock size={11} />
{TIME_CONTROL_LABELS[t.time_control] ?? t.time_control}
</span>
<span className="flex items-center gap-1">
<Users size={11} />
{t.registrations_count}/{t.max_players}
</span>
<span className="flex items-center gap-1">
<Calendar size={11} />
{formatRelativeTime(t.starts_at)}
</span>
</div> </div>
</div>
<div className="px-2 py-0.5 rounded-full bg-surface-3 text-[10px] text-text-muted"> {(t.prize_pool_coins > 0 || t.prize_pool_gems > 0) && (
قريبا <div className="flex items-center gap-3 text-[11px]">
</div> {t.prize_pool_coins > 0 && (
</div> <span className="flex items-center gap-1 text-gold font-bold">
<div className="flex items-center gap-4 text-[10px] text-text-muted"> <Coins size={12} className="text-gold" />
<span className="flex items-center gap-1"> {t.prize_pool_coins.toLocaleString('ar-EG')}
<Users size={10} /> -- </span>
</span> )}
<span className="flex items-center gap-1"> {t.prize_pool_gems > 0 && (
<Calendar size={10} /> -- <span className="flex items-center gap-1 text-purple font-bold">
</span> <Gem size={12} className="text-purple" />
</div> {t.prize_pool_gems.toLocaleString('ar-EG')}
</Card> </span>
</motion.div> )}
))} </div>
</div> )}
<AnimatePresence>
{expandedId === t.id && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
<div className="pt-3 border-t border-border flex flex-col gap-2">
<div className="grid grid-cols-2 gap-2 text-[11px] text-text-muted">
<span>الجولات: {t.rounds_total}</span>
<span>الجولة الحالية: {t.current_round}</span>
<span>الحد الأدنى: {t.min_players} لاعب</span>
<span>مصنفة: {t.is_rated ? 'نعم' : 'لا'}</span>
</div>
{t.status === 'registration' && user && (
<motion.button
whileTap={{ scale: 0.95 }}
onClick={(e) => {
e.stopPropagation()
isRegistered(t.id) ? unregister(t.id) : register(t.id)
}}
className={`mt-2 w-full py-2.5 rounded-xl text-sm font-bold transition-colors ${
isRegistered(t.id)
? 'bg-coral/10 text-coral border border-coral/30'
: 'bg-gold text-bg-primary'
}`}
>
{isRegistered(t.id) ? 'إلغاء التسجيل' : 'سجّل الآن'}
</motion.button>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</Card>
</motion.div>
))}
</AnimatePresence>
</div>
)}
</PageTransition> </PageTransition>
) )
} }
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