Created statistics personal and general
This commit is contained in:
@@ -29,16 +29,23 @@ class StatisticsController extends Controller
|
|||||||
{
|
{
|
||||||
$type = $request->query('type');
|
$type = $request->query('type');
|
||||||
|
|
||||||
$query = User::where('type', 'P')
|
$leaderboard = User::where('type', 'P')
|
||||||
->withCount(['wonGames' => function ($query) use ($type) {
|
->withCount(['wonGames' => function ($query) use ($type) {
|
||||||
if ($type) {
|
if ($type) {
|
||||||
$query->where('type', $type);
|
$query->where('type', $type);
|
||||||
}
|
}
|
||||||
}])
|
}])
|
||||||
->orderBy('won_games_count', 'desc')
|
->orderBy('won_games_count', 'desc')
|
||||||
->take(10);
|
->paginate(10);
|
||||||
|
|
||||||
$leaderboard = $query->get(['id', 'nickname', 'photo_avatar_filename']);
|
$leaderboard->through(function ($user) {
|
||||||
|
return [
|
||||||
|
'id' => $user->id,
|
||||||
|
'nickname' => $user->nickname,
|
||||||
|
'photo_avatar_filename' => $user->photo_avatar_filename,
|
||||||
|
'won_games_count' => $user->won_games_count,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
return response()->json($leaderboard);
|
return response()->json($leaderboard);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,3 +54,14 @@ Authorization: Bearer {{token}}
|
|||||||
"payment_type": "MBWAY",
|
"payment_type": "MBWAY",
|
||||||
"payment_reference": "912345678"
|
"payment_reference": "912345678"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
### Get public stats
|
||||||
|
GET http://localhost:8000/api/v1/leaderboard
|
||||||
|
Content-Type: application/json
|
||||||
|
Accept: application/json
|
||||||
|
|
||||||
|
### Get me stats
|
||||||
|
GET http://localhost:8000/api/v1/statistics/me
|
||||||
|
Content-Type: application/json
|
||||||
|
Accept: application/json
|
||||||
|
Authorization: Bearer {{token}}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, nextTick } from 'vue'
|
import { User, Trophy } from 'lucide-vue-next'
|
||||||
|
import { ref, onMounted, nextTick, inject } from 'vue'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -10,40 +11,51 @@ import {
|
|||||||
} from '@/components/ui/card'
|
} from '@/components/ui/card'
|
||||||
import { useAPIStore } from '@/stores/api'
|
import { useAPIStore } from '@/stores/api'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
|
const API_BASE_URL = inject('apiBaseURL')
|
||||||
const apiStore = useAPIStore()
|
const apiStore = useAPIStore()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const openGames = ref([])
|
const openGames = ref([])
|
||||||
const selectedMode = ref('9')
|
const selectedMode = ref('9')
|
||||||
const observerTarget = ref(null);
|
const gObserverTarget = ref(null);
|
||||||
const observer = ref(null);
|
const gObserver = ref(null);
|
||||||
const isLoading = ref(false);
|
const gLoading = ref(false);
|
||||||
const pageCounter = ref(1);
|
const gPage = ref(1);
|
||||||
const showHostModal = ref(false)
|
const showHostModal = ref(false)
|
||||||
const showJoinModal = ref(false)
|
const showJoinModal = ref(false)
|
||||||
const selectedGame = ref(null)
|
const selectedGame = ref(null)
|
||||||
const isReady = ref(false)
|
const isReady = ref(false)
|
||||||
|
const showLeaderboardModal = ref(false)
|
||||||
|
const showStatsModal = ref(false)
|
||||||
|
const userStats = ref(null)
|
||||||
|
const statsLoading = ref(false)
|
||||||
|
const leaderboardEntries = ref([])
|
||||||
|
const lbLoading = ref(false)
|
||||||
|
const lbPage = ref(1)
|
||||||
|
const lbObserverTarget = ref(null)
|
||||||
|
const lbObserver = ref(null)
|
||||||
|
const lbMorePages = ref(true)
|
||||||
|
|
||||||
const setupObserver = () => {
|
const setupGamesObserver = () => {
|
||||||
if (observer.value) observer.value.disconnect();
|
if (gObserver.value) gObserver.value.disconnect();
|
||||||
|
|
||||||
observer.value = new IntersectionObserver((entries) => {
|
gObserver.value = new IntersectionObserver((entries) => {
|
||||||
if (entries[0].isIntersecting && !isLoading.value && openGames.value.length > 0) {
|
if (entries[0].isIntersecting && !gLoading.value && openGames.value.length > 0) {
|
||||||
getPendingGames();
|
getPendingGames();
|
||||||
}
|
}
|
||||||
}, { threshold: 0.1 });
|
}, { threshold: 0.1 });
|
||||||
|
|
||||||
if (observerTarget.value) {
|
if (gObserverTarget.value) {
|
||||||
observer.value.observe(observerTarget.value);
|
gObserver.value.observe(gObserverTarget.value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const getPendingGames = async (mode = selectedMode.value) => {
|
const getPendingGames = async (mode = selectedMode.value) => {
|
||||||
if (isLoading.value) return;
|
if (gLoading.value) return;
|
||||||
isLoading.value = true;
|
gLoading.value = true;
|
||||||
|
|
||||||
|
apiStore.gameQueryParameters.page = gPage.value++;
|
||||||
apiStore.gameQueryParameters.page = pageCounter.value++;
|
|
||||||
apiStore.gameQueryParameters.filters.sort_direction = "asc"
|
apiStore.gameQueryParameters.filters.sort_direction = "asc"
|
||||||
apiStore.gameQueryParameters.filters.type = mode
|
apiStore.gameQueryParameters.filters.type = mode
|
||||||
apiStore.gameQueryParameters.filters.status = 'Pending'
|
apiStore.gameQueryParameters.filters.status = 'Pending'
|
||||||
@@ -52,7 +64,66 @@ const getPendingGames = async (mode = selectedMode.value) => {
|
|||||||
const newGames = response.data.data;
|
const newGames = response.data.data;
|
||||||
openGames.value = [...openGames.value, ...newGames];
|
openGames.value = [...openGames.value, ...newGames];
|
||||||
} finally {
|
} finally {
|
||||||
isLoading.value = false;
|
gLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openStats = async () => {
|
||||||
|
showStatsModal.value = true
|
||||||
|
statsLoading.value = true
|
||||||
|
try {
|
||||||
|
const response = await apiStore.getCurrentUserStats()
|
||||||
|
userStats.value = response.data
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch stats", error)
|
||||||
|
} finally {
|
||||||
|
statsLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getAvatarUrl = (filename) => {
|
||||||
|
return filename
|
||||||
|
? `${API_BASE_URL.replace('/api/v1', '').replace('v1/')}/storage/photos_avatars/${filename}`
|
||||||
|
: `${API_BASE_URL.replace('/api/v1', '').replace('v1/')}/storage/photos_avatars/anonymous.png`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchLeaderboard = async () => {
|
||||||
|
if (lbLoading.value || !lbMorePages.value) return;
|
||||||
|
lbLoading.value = true;
|
||||||
|
try {
|
||||||
|
const response = await apiStore.getLeaderboard({ page: lbPage.value });
|
||||||
|
const newEntries = response.data.data;
|
||||||
|
|
||||||
|
if (newEntries.length < 10) {
|
||||||
|
lbMorePages.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
leaderboardEntries.value = [...leaderboardEntries.value, ...newEntries];
|
||||||
|
lbPage.value++;
|
||||||
|
} finally {
|
||||||
|
lbLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openLeaderboard = async () => {
|
||||||
|
leaderboardEntries.value = [];
|
||||||
|
lbPage.value = 1;
|
||||||
|
showLeaderboardModal.value = true;
|
||||||
|
await nextTick();
|
||||||
|
setupLeaderboardObserver();
|
||||||
|
await fetchLeaderboard();
|
||||||
|
}
|
||||||
|
|
||||||
|
const setupLeaderboardObserver = () => {
|
||||||
|
if (lbObserver.value) lbObserver.value.disconnect();
|
||||||
|
lbObserver.value = new IntersectionObserver((entries) => {
|
||||||
|
if (entries[0].isIntersecting && !lbLoading.value && leaderboardEntries.value.length > 0) {
|
||||||
|
fetchLeaderboard();
|
||||||
|
}
|
||||||
|
}, { threshold: 0.1 });
|
||||||
|
|
||||||
|
if (lbObserverTarget.value) {
|
||||||
|
lbObserver.value.observe(lbObserverTarget.value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,9 +131,9 @@ const clickMode = async (mode) => {
|
|||||||
if (selectedMode.value === mode) return;
|
if (selectedMode.value === mode) return;
|
||||||
openGames.value = [];
|
openGames.value = [];
|
||||||
selectedMode.value = mode;
|
selectedMode.value = mode;
|
||||||
pageCounter.value = 1;
|
gPage.value = 1;
|
||||||
await getPendingGames();
|
await getPendingGames();
|
||||||
setupObserver();
|
setupGamesObserver();
|
||||||
}
|
}
|
||||||
|
|
||||||
const startSingle = () => {
|
const startSingle = () => {
|
||||||
@@ -86,7 +157,7 @@ const toggleReady = () => {
|
|||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await getPendingGames();
|
await getPendingGames();
|
||||||
await nextTick();
|
await nextTick();
|
||||||
setupObserver();
|
setupGamesObserver();
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -171,8 +242,8 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div ref="observerTarget" class="h-10 flex items-center justify-center">
|
<div ref="gObserverTarget" class="h-10 flex items-center justify-center">
|
||||||
<span v-if="isLoading" class="text-xs text-purple-500 animate-pulse">Loading
|
<span v-if="gLoading" class="text-xs text-purple-500 animate-pulse">Loading
|
||||||
more...</span>
|
more...</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -238,5 +309,117 @@ onMounted(async () => {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="showStatsModal"
|
||||||
|
class="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
|
||||||
|
<Card class="w-full max-w-sm max-h-2/3 border-purple-500 shadow-2xl">
|
||||||
|
<CardHeader class="border-b bg-muted/20">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<CardTitle class="flex items-center gap-2">
|
||||||
|
<User class="text-yellow-500" /> My Statistics
|
||||||
|
</CardTitle>
|
||||||
|
<Button variant="ghost" size="sm" @click="showStatsModal = false">X</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent class="py-6">
|
||||||
|
<div v-if="statsLoading" class="flex flex-col items-center py-10 gap-4">
|
||||||
|
<div class="h-8 w-8 animate-spin rounded-full border-4 border-purple-500 border-t-transparent">
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-muted-foreground">Calculating your glory...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="userStats" class="space-y-6">
|
||||||
|
<div class="text-center">
|
||||||
|
<h3 class="text-2xl font-bold text-purple-600">{{ userStats.nickname }}</h3>
|
||||||
|
<p class="text-sm text-muted-foreground">Performance Overview</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div
|
||||||
|
class="rounded-lg bg-muted/50 p-4 text-center border border-transparent hover:border-purple-500/30 transition-all">
|
||||||
|
<p class="text-xs uppercase text-muted-foreground font-semibold">Total Games</p>
|
||||||
|
<p class="text-2xl font-bold">{{ userStats.total_games }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg bg-purple-500/10 p-4 text-center border border-purple-500/20">
|
||||||
|
<p class="text-xs uppercase text-purple-600 font-semibold">Win Rate</p>
|
||||||
|
<p class="text-2xl font-bold text-purple-600">{{ userStats.win_rate }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg bg-green-500/10 p-4 text-center border border-green-500/20">
|
||||||
|
<p class="text-xs uppercase text-green-600 font-semibold">Wins</p>
|
||||||
|
<p class="text-2xl font-bold text-green-600">{{ userStats.total_wins }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg bg-red-500/10 p-4 text-center border border-red-500/20">
|
||||||
|
<p class="text-xs uppercase text-red-600 font-semibold">Losses</p>
|
||||||
|
<p class="text-2xl font-bold text-red-600">{{ userStats.total_losses }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex justify-between text-xs font-medium">
|
||||||
|
<span>Win/Loss Ratio</span>
|
||||||
|
<span>{{ userStats.total_wins }}W - {{ userStats.total_losses }}L</span>
|
||||||
|
</div>
|
||||||
|
<div class="h-2 w-full bg-red-500/20 rounded-full overflow-hidden flex">
|
||||||
|
<div class="bg-green-500 h-full" :style="{ width: userStats.win_rate }"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="showLeaderboardModal"
|
||||||
|
class="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
|
||||||
|
<Card class="w-full max-w-lg max-h-1/2 h-[80vh] flex flex-col border-purple-500 shadow-2xl">
|
||||||
|
<CardHeader class="border-b">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<CardTitle class="flex items-center gap-2">
|
||||||
|
<Trophy class="text-purple-500" /> Leaderboard
|
||||||
|
</CardTitle>
|
||||||
|
<Button variant="ghost" size="sm" @click="showLeaderboardModal = false">X</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent class="flex-1 overflow-y-auto custom-scrollbar p-0">
|
||||||
|
<div v-for="(user, index) in leaderboardEntries" :key="user.id"
|
||||||
|
class="flex items-center justify-between p-4 border-b last:border-0 hover:bg-muted/30 transition-colors">
|
||||||
|
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<span class="font-bold text-muted-foreground w-6 text-center"
|
||||||
|
:class="{ 'text-yellow-500': index === 0, 'text-slate-400': index === 1, 'text-orange-400': index === 2 }">
|
||||||
|
#{{ index + 1 }}
|
||||||
|
</span>
|
||||||
|
<img :src="getAvatarUrl(user.photo_avatar_filename)"
|
||||||
|
class="h-10 w-10 rounded-full border border-purple-200 object-cover" alt="Avatar" />
|
||||||
|
<div>
|
||||||
|
<p class="font-semibold text-sm">{{ user.nickname }}</p>
|
||||||
|
<p class="text-[10px] text-muted-foreground uppercase tracking-wider">Player</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-right">
|
||||||
|
<p class="text-lg font-bold text-purple-600">{{ user.won_games_count }}</p>
|
||||||
|
<p class="text-[10px] text-muted-foreground uppercase">Wins</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div ref="lbObserverTarget" class="h-20 flex items-center justify-center">
|
||||||
|
<span v-if="lbLoading" class="text-purple-500 animate-bounce">Loading rankings...</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="fixed right-4 top-1/2 -translate-y-1/2 flex flex-col gap-4 z-40">
|
||||||
|
<Button @click="openLeaderboard" variant="outline" size="icon"
|
||||||
|
class="h-12 w-12 rounded-full shadow-lg border-purple-500 bg-card hover:bg-purple-500 hover:text-white transition-all">
|
||||||
|
<Trophy class="h-6 w-6" />
|
||||||
|
</Button>
|
||||||
|
<Button v-if="useAuthStore().isLoggedIn" @click="openStats" variant="outline" size="icon"
|
||||||
|
class="h-12 w-12 rounded-full shadow-lg border-purple-500 bg-card hover:bg-purple-500 hover:text-white transition-all">
|
||||||
|
<User class="h-6 w-6" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -102,6 +102,18 @@ export const useAPIStore = defineStore('api', () => {
|
|||||||
return axios.get(`${API_BASE_URL}/users/${authStore.currentUserID}/transactions?${queryParams}`)
|
return axios.get(`${API_BASE_URL}/users/${authStore.currentUserID}/transactions?${queryParams}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getLeaderboard = (params = {}) => {
|
||||||
|
const queryParams = new URLSearchParams({
|
||||||
|
page: params.page || 1,
|
||||||
|
}).toString()
|
||||||
|
|
||||||
|
return axios.get(`${API_BASE_URL}/leaderboard?${queryParams}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getCurrentUserStats = () => {
|
||||||
|
return axios.get(`${API_BASE_URL}/statistics/me`)
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
postLogin,
|
postLogin,
|
||||||
postLogout,
|
postLogout,
|
||||||
@@ -109,6 +121,8 @@ export const useAPIStore = defineStore('api', () => {
|
|||||||
getGames,
|
getGames,
|
||||||
getCurrentUserMatches,
|
getCurrentUserMatches,
|
||||||
getCurrentUserTransactions,
|
getCurrentUserTransactions,
|
||||||
|
getCurrentUserStats,
|
||||||
|
getLeaderboard,
|
||||||
gameQueryParameters,
|
gameQueryParameters,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user