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'
import { Header } from './Header'
import { BottomNav } from './BottomNav'
import { ToastContainer } from '../ui/ToastContainer'
import { usePresence } from '../../hooks/usePresence'
import { useNotifications } from '../../hooks/useNotifications'
export function AppShell() {
usePresence()
useNotifications()
return (
<div className="flex flex-col min-h-dvh bg-[#0a0a12]">
<Header />
......
......@@ -10,16 +10,16 @@ const TOAST_ICONS = {
}
const TOAST_COLORS = {
success: 'border-cyan/40 bg-cyan/10',
error: 'border-coral/40 bg-coral/10',
info: 'border-royal-blue/40 bg-royal-blue/10',
success: 'border-cyan/40 bg-cyan/10 text-cyan',
error: 'border-coral/40 bg-coral/10 text-coral',
info: 'border-gold/40 bg-gold/10 text-gold',
}
export function ToastContainer() {
const { toasts, dismissToast } = useNotificationStore()
return (
<div className="fixed top-16 left-4 right-4 z-[100] flex flex-col gap-2 pointer-events-none">
<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">
{toasts.map((toast) => {
const Icon = TOAST_ICONS[toast.type]
......@@ -62,14 +62,14 @@ function ToastItem({
return (
<motion.div
layout
initial={{ opacity: 0, y: -40, scale: 0.95 }}
initial={{ opacity: 0, y: -40, scale: 0.9 }}
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 }}
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)}
>
<Icon size={18} />
<Icon size={18} className="flex-shrink-0" />
<span className="text-sm font-medium text-text-primary">{title}</span>
</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)
}
This diff is collapsed.
This diff is collapsed.
import { useState } from 'react'
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 { useAuthStore } from '../stores/authStore'
import { PageTransition } from '../components/layout/PageTransition'
import { DailyRewardModal } from '../components/DailyRewardModal'
import { useDailyReward } from '../hooks/useDailyReward'
import { useRecentGames } from '../hooks/useRecentGames'
const dailyTips = [
'تحكم بالمركز في بداية اللعبة - الاحصنة والفيلة تكون اقوى من المركز',
......@@ -16,11 +20,29 @@ const dailyTips = [
export function HomePage() {
const navigate = useNavigate()
const { profile } = useAuthStore()
const { user, profile } = useAuthStore()
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 (
<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 && (
<motion.div
className="flex items-center gap-4"
......@@ -105,27 +127,65 @@ export function HomePage() {
</div>
<div>
<h3 className="text-sm font-bold mb-4 text-text-muted">اخر المباريات</h3>
<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 className="flex items-center justify-between mb-4">
<h3 className="text-sm font-bold text-text-muted">اخر المباريات</h3>
{games.length > 0 && (
<motion.button
onClick={() => navigate('/profile')}
className="text-[11px] font-bold text-gold"
whileTap={{ scale: 0.95 }}
>
عرض الكل
</motion.button>
)}
</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 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
</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 { 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() {
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 (
<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>
<Card className="flex flex-col items-center py-8">
<p className="text-text-muted text-sm">لا توجد بيانات بعد</p>
<p className="text-text-muted text-xs mt-1">العب مباريات لتظهر في القائمة</p>
</Card>
<div className="flex gap-2 overflow-x-auto no-scrollbar pb-1">
{TIME_CONTROLS.map((tc) => (
<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>
)
}
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 { useNavigate } from 'react-router-dom'
import { useEffect } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { useNavigate, useSearchParams } from 'react-router-dom'
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() {
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 (
<div className="flex-1 flex flex-col items-center justify-center px-6 py-12 relative overflow-hidden">
<div className="relative w-40 h-40 flex items-center justify-center">
{[0, 1, 2].map((i) => (
<AnimatePresence mode="wait">
{matchFound ? (
<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',
}}
/>
))}
key="found"
className="flex flex-col items-center"
initial={{ scale: 0.5, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ type: 'spring', stiffness: 300, damping: 20 }}
>
<motion.div
className="w-24 h-24 rounded-full bg-gold/20 border-2 border-gold flex items-center justify-center"
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
className="w-20 h-20 rounded-full bg-gradient-to-br from-gold/20 to-gold/5 border-2 border-gold/40 flex items-center justify-center"
animate={{ scale: [1, 1.05, 1] }}
transition={{ duration: 2, repeat: Infinity, ease: 'easeInOut' }}
>
<span className="text-3xl font-black text-gold">?</span>
</motion.div>
</div>
<motion.h2
className="mt-8 text-xl font-bold"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.3 }}
>
جاري البحث عن خصم
</motion.h2>
<motion.h2
className="mt-8 text-xl font-bold"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.3 }}
>
جاري البحث عن خصم
</motion.h2>
<motion.div
className="mt-3 text-lg font-mono text-gold tabular-nums"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.4 }}
>
{formatElapsed(elapsed)}
</motion.div>
<motion.div
className="mt-2 flex gap-1"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.5 }}
>
{[0, 1, 2].map((i) => (
<motion.span
key={i}
className="w-2 h-2 rounded-full bg-gold"
animate={{ opacity: [0.3, 1, 0.3] }}
transition={{ duration: 1, repeat: Infinity, delay: i * 0.3 }}
/>
))}
</motion.div>
<motion.div
className="mt-2 flex gap-1"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.5 }}
>
{[0, 1, 2].map((i) => (
<motion.span
key={i}
className="w-2 h-2 rounded-full bg-gold"
animate={{ opacity: [0.3, 1, 0.3] }}
transition={{ duration: 1, repeat: Infinity, delay: i * 0.3 }}
/>
))}
</motion.div>
<motion.div
className="mt-12"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 1 }}
>
<Button variant="ghost" onClick={() => navigate('/play')}>
الغاء
</Button>
</motion.div>
<motion.div
className="mt-12"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 1 }}
>
<Button variant="ghost" onClick={handleCancel}>
الغاء
</Button>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</div>
)
}
import { motion } from 'framer-motion'
import { useNavigate } from 'react-router-dom'
import { Home } from 'lucide-react'
import { Button } from '../components/ui/Button'
export function NotFoundPage() {
const navigate = useNavigate()
return (
<div className="flex-1 flex flex-col items-center justify-center px-6 py-12">
<div className="flex-1 flex flex-col items-center justify-center px-6 py-12 gap-6">
<motion.div
className="text-6xl font-black text-gold/30"
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: 'spring', stiffness: 200, damping: 15 }}
className="text-6xl"
animate={{
rotate: [0, -8, 8, -5, 5, 0],
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
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
className="mt-4 text-text-muted"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="text-lg font-bold text-text-secondary"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
>
الصفحة غير موجودة
</motion.p>
<motion.div
<motion.p
className="text-sm text-text-muted"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
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>
</motion.div>
</div>
......
import { Bell } from 'lucide-react'
import { motion } from 'framer-motion'
import { Bell, UserPlus, Trophy, Swords, CheckCheck, Loader2 } from 'lucide-react'
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() {
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 (
<PageTransition className="px-4 py-6 flex flex-col gap-5">
<h1 className="text-xl font-bold">الاشعارات</h1>
<div className="flex-1 flex flex-col items-center justify-center py-12">
<motion.div
className="w-16 h-16 rounded-full bg-surface-2 border border-border flex items-center justify-center mb-4"
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: 'spring', stiffness: 300, damping: 20 }}
>
<Bell size={24} className="text-text-muted" />
</motion.div>
<p className="text-text-muted text-sm">لا توجد اشعارات</p>
<PageTransition className="px-7 py-7 flex flex-col gap-6">
<div className="flex items-center justify-between">
<h1 className="text-xl font-bold">الاشعارات</h1>
{notifications.some((n) => !n.is_read) && (
<motion.button
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"
whileTap={{ scale: 0.9 }}
onClick={markAllRead}
>
<CheckCheck size={14} />
قراءة الكل
</motion.button>
)}
</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>
)
}
......@@ -137,7 +137,7 @@ export function PlayPage() {
{/* Action Buttons */}
<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 onClick={() => navigate('/bot-select')} variant="ghost" className="w-[85%]" size="md">
......
This diff is collapsed.
import { Volume2, VolumeX, Info } from 'lucide-react'
import { Volume2, VolumeX, Info, LogOut, Trash2, Bug } from 'lucide-react'
import { motion } from 'framer-motion'
import { useNavigate } from 'react-router-dom'
import { PageTransition } from '../components/layout/PageTransition'
import { Card } from '../components/ui/Card'
import { useUIStore } from '../stores/uiStore'
import { supabase } from '../lib/supabase'
export function SettingsPage() {
const { soundEnabled, setSoundEnabled } = useUIStore()
const navigate = useNavigate()
async function handleLogout() {
await supabase.auth.signOut()
navigate('/login', { replace: true })
}
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>
<Card className="flex items-center justify-between">
......@@ -36,6 +44,37 @@ export function SettingsPage() {
<p className="text-xs text-text-muted">الاصدار 1.0.0</p>
</div>
</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>
)
}
This diff is collapsed.
This diff is collapsed.
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