feature/bestof-series-interface #99
@@ -255,14 +255,19 @@ class MatchController extends Controller
|
||||
|
||||
public function open(Request $request)
|
||||
{
|
||||
$type = $request->query('type', '9');
|
||||
$GHOST_ID = 1;
|
||||
// FIX: Allow matches where P2 is NULL OR 'Ghost' (ID 1)
|
||||
$query = MatchGame::where('status', 'Pending')
|
||||
->where(function($q) {
|
||||
$q->whereNull('player2_user_id')
|
||||
->orWhere('player2_user_id', 1); // 1 = Ghost ID
|
||||
});
|
||||
|
||||
$matches = MatchGame::where('status', 'Pending')
|
||||
->where('player2_user_id', $GHOST_ID)
|
||||
->where('type', $type)
|
||||
->with('player1')
|
||||
->orderBy('began_at', 'asc')
|
||||
if ($request->has('type')) {
|
||||
$query->where('type', $request->type);
|
||||
}
|
||||
|
||||
$matches = $query->with('player1:id,nickname')
|
||||
->orderBy('began_at', 'desc')
|
||||
->paginate(10);
|
||||
|
||||
return response()->json($matches);
|
||||
|
||||
+4
-3
@@ -58,10 +58,11 @@ Route::prefix('v1')->group(function () {
|
||||
});
|
||||
|
||||
Route::prefix('matches')->group(function () {
|
||||
Route::apiResource('/', MatchController::class)->parameters(['' => 'match']);
|
||||
Route::post('/{match}/join', [MatchController::class, 'join']);
|
||||
Route::post('host', [MatchController::class, 'host']); // <--- NOVA
|
||||
Route::post('host', [MatchController::class, 'host']);
|
||||
Route::get('open', [MatchController::class, 'open']);
|
||||
Route::post('/{match}/join', [MatchController::class, 'join']);
|
||||
Route::post('/{match}/start', [MatchController::class, 'start']); // Changed to /start to be explicit
|
||||
Route::apiResource('/', MatchController::class)->parameters(['' => 'match']);
|
||||
});
|
||||
|
||||
// Admin Routes
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.1.17",
|
||||
"@tanstack/vue-table": "^8.21.3",
|
||||
"@vueuse/core": "^14.0.0",
|
||||
"@vueuse/core": "^14.1.0",
|
||||
"axios": "^1.13.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -24,7 +24,7 @@
|
||||
"lucide-react": "^0.562.0",
|
||||
"lucide-vue-next": "^0.554.0",
|
||||
"pinia": "^3.0.3",
|
||||
"reka-ui": "^2.6.0",
|
||||
"reka-ui": "^2.7.0",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss": "^4.1.17",
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<script setup>
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { Label } from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps({
|
||||
for: { type: String, required: false },
|
||||
asChild: { type: Boolean, required: false },
|
||||
as: { type: null, required: false },
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class");
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Label
|
||||
data-slot="label"
|
||||
v-bind="delegatedProps"
|
||||
:class="
|
||||
cn(
|
||||
'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</Label>
|
||||
</template>
|
||||
@@ -0,0 +1 @@
|
||||
export { default as Label } from "./Label.vue";
|
||||
@@ -0,0 +1,49 @@
|
||||
<script setup>
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { SwitchRoot, SwitchThumb, useForwardPropsEmits } from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps({
|
||||
defaultValue: { type: Boolean, required: false },
|
||||
modelValue: { type: [Boolean, null], required: false },
|
||||
disabled: { type: Boolean, required: false },
|
||||
id: { type: String, required: false },
|
||||
value: { type: String, required: false },
|
||||
asChild: { type: Boolean, required: false },
|
||||
as: { type: null, required: false },
|
||||
name: { type: String, required: false },
|
||||
required: { type: Boolean, required: false },
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
|
||||
const emits = defineEmits(["update:modelValue"]);
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class");
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SwitchRoot
|
||||
v-slot="slotProps"
|
||||
data-slot="switch"
|
||||
v-bind="forwarded"
|
||||
:class="
|
||||
cn(
|
||||
'peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<SwitchThumb
|
||||
data-slot="switch-thumb"
|
||||
:class="
|
||||
cn(
|
||||
'bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0',
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot name="thumb" v-bind="slotProps" />
|
||||
</SwitchThumb>
|
||||
</SwitchRoot>
|
||||
</template>
|
||||
@@ -0,0 +1 @@
|
||||
export { default as Switch } from "./Switch.vue";
|
||||
@@ -0,0 +1,112 @@
|
||||
<template>
|
||||
<div class="relative min-h-screen bg-slate-950 text-white">
|
||||
|
||||
<div class="fixed top-0 left-0 right-0 z-40 bg-slate-900/90 backdrop-blur border-b border-slate-700 p-2 shadow-lg">
|
||||
<div class="max-w-4xl mx-auto flex items-center justify-between">
|
||||
|
||||
<div class="flex flex-col items-start">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-bold text-lg text-blue-400">{{ myName }}</span>
|
||||
<span v-if="matchState.wager > 0" class="flex items-center text-xs text-yellow-400 bg-yellow-400/10 px-2 rounded">
|
||||
<Coins class="w-3 h-3 mr-1" /> {{ matchState.wager }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex gap-1 mt-1">
|
||||
<div class="w-3 h-3 rounded-full border border-blue-500"
|
||||
:class="{ 'bg-blue-500': matchState.myWins >= 1 }"></div>
|
||||
<div class="w-3 h-3 rounded-full border border-blue-500"
|
||||
:class="{ 'bg-blue-500': matchState.myWins >= 2 }"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-center">
|
||||
<span class="text-xs uppercase tracking-widest text-slate-400">Match {{ matchState.id }}</span>
|
||||
<span class="text-xl font-black font-mono">
|
||||
{{ matchState.myWins }} - {{ matchState.oppWins }}
|
||||
</span>
|
||||
<span class="text-[10px] text-slate-500">Best of 3</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-end">
|
||||
<span class="font-bold text-lg text-red-400">{{ opponentName }}</span>
|
||||
<div class="flex gap-1 mt-1">
|
||||
<div class="w-3 h-3 rounded-full border border-red-500"
|
||||
:class="{ 'bg-red-500': matchState.oppWins >= 1 }"></div>
|
||||
<div class="w-3 h-3 rounded-full border border-red-500"
|
||||
:class="{ 'bg-red-500': matchState.oppWins >= 2 }"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pt-20 h-screen">
|
||||
<GameBoard
|
||||
v-if="matchState.currentGameId && !matchOver"
|
||||
:game-id="matchState.currentGameId"
|
||||
@game-end="handleGameEnd"
|
||||
/>
|
||||
|
||||
<div v-else-if="!matchOver" class="flex h-full items-center justify-center flex-col gap-4">
|
||||
<div class="text-2xl font-bold animate-pulse">Preparing next round...</div>
|
||||
<p class="text-slate-400">Switching sides and shuffling deck</p>
|
||||
</div>
|
||||
|
||||
<div v-if="matchOver" class="absolute inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-md">
|
||||
<div class="text-center space-y-6 p-8 bg-slate-900 border-2 border-purple-500 rounded-2xl shadow-2xl max-w-md w-full">
|
||||
<Trophy v-if="iWonMatch" class="w-20 h-20 text-yellow-400 mx-auto animate-bounce" />
|
||||
<Frown v-else class="w-20 h-20 text-slate-500 mx-auto" />
|
||||
|
||||
<div>
|
||||
<h1 class="text-4xl font-black uppercase mb-2"
|
||||
:class="iWonMatch ? 'text-yellow-400' : 'text-slate-400'">
|
||||
{{ iWonMatch ? 'Victory!' : 'Defeat' }}
|
||||
</h1>
|
||||
<p class="text-slate-300">Final Score: {{ matchState.myWins }} - {{ matchState.oppWins }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="matchState.wager > 0" class="bg-yellow-500/10 p-4 rounded-xl border border-yellow-500/20">
|
||||
<p class="text-xs uppercase text-yellow-600 font-bold mb-1">Total Reward</p>
|
||||
<div class="flex items-center justify-center gap-2 text-2xl font-bold text-yellow-400">
|
||||
<Coins class="w-6 h-6" />
|
||||
<span>{{ iWonMatch ? '+' + (matchState.wager * 2) : '-' + matchState.wager }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button @click="leaveMatch" class="w-full bg-purple-600 hover:bg-purple-700">
|
||||
Return to Lobby
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// Imports and setup similar to GamePage, but tracking Match State
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { Coins, Trophy, Frown } from 'lucide-vue-next'
|
||||
import GameBoard from '@/components/game/GameBoard.vue' // Reuse your existing board logic
|
||||
import axios from 'axios'
|
||||
// ... socket logic for match_updates ...
|
||||
|
||||
const matchState = ref({
|
||||
id: null,
|
||||
wager: 0,
|
||||
myWins: 0,
|
||||
oppWins: 0,
|
||||
currentGameId: null,
|
||||
players: {}
|
||||
})
|
||||
|
||||
const matchOver = computed(() => matchState.value.myWins >= 2 || matchState.value.oppWins >= 2)
|
||||
const iWonMatch = computed(() => matchState.value.myWins > matchState.value.oppWins)
|
||||
|
||||
// This function listens for Socket events on the "Match Channel"
|
||||
// e.g., channel: `match.{id}` event: `RoundEnded`
|
||||
const listenToMatch = () => {
|
||||
// When a game ends, the server should send:
|
||||
// { nextGameId: 123, scores: { p1: 1, p2: 0 } }
|
||||
// Update matchState.currentGameId to trigger the GameBoard to reload
|
||||
}
|
||||
</script>
|
||||
@@ -1,7 +1,11 @@
|
||||
<script setup>
|
||||
import { User, Trophy } from 'lucide-vue-next'
|
||||
import { ref, onMounted, nextTick, inject } from 'vue'
|
||||
import { User, Trophy, Coins } from 'lucide-vue-next'
|
||||
import { ref, onMounted, nextTick, inject, computed } from 'vue'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { watch } from 'vue';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -12,7 +16,9 @@ import {
|
||||
import { useAPIStore } from '@/stores/api'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import axios from 'axios'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
const API_BASE_URL = inject('apiBaseURL')
|
||||
const apiStore = useAPIStore()
|
||||
@@ -44,6 +50,13 @@ const lbObserver = ref(null)
|
||||
const lbMorePages = ref(true)
|
||||
const publicStats = ref(null)
|
||||
const isLoggedIn = useAuthStore().isLoggedIn
|
||||
const isBestOfThree = ref(false)
|
||||
const wagerAmount = ref(0)
|
||||
const authStore = useAuthStore()
|
||||
const userStore = useUserStore()
|
||||
const userLoggedIn = computed(() => authStore.isLoggedIn)
|
||||
const userCoins = ref(0)
|
||||
const hostLoading = ref(false)
|
||||
|
||||
const setupMatchesObserver = () => {
|
||||
if (mObserver.value) mObserver.value.disconnect();
|
||||
@@ -211,28 +224,95 @@ onMounted(async () => {
|
||||
}
|
||||
})
|
||||
|
||||
// Update Host Game to handle Matches
|
||||
const hostGame = async () => {
|
||||
showHostModal.value = true
|
||||
// 1. Validation
|
||||
if (isBestOfThree.value) {
|
||||
if (wagerAmount.value <= 0) {
|
||||
alert("For a match, the stake must be at least 1 coin.");
|
||||
return;
|
||||
}
|
||||
if (wagerAmount.value > userCoins.value) {
|
||||
alert("You don't have enough coins to stake this amount!");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
hostLoading.value = true; // DISABLE BUTTON
|
||||
|
||||
// 2. Prepare Payload
|
||||
const endpoint = isBestOfThree.value ? '/matches' : '/games/host'
|
||||
|
||||
// Note: Matches use 'stake', Games don't need it.
|
||||
const payload = isBestOfThree.value
|
||||
? { type: parseInt(selectedMode.value), stake: parseInt(wagerAmount.value) }
|
||||
: { type: parseInt(selectedMode.value) }
|
||||
|
||||
try {
|
||||
const response = await axios.post(`${API_BASE_URL}/games/host`, {
|
||||
type: selectedMode.value
|
||||
})
|
||||
const response = await axios.post(`${API_BASE_URL}${endpoint}`, payload)
|
||||
|
||||
const gameId = response.data.id
|
||||
const resultId = response.data.match?.id || response.data.id || response.data.data?.id;
|
||||
|
||||
console.log('Game hosted:', gameId)
|
||||
if (!resultId) {
|
||||
throw new Error("Server response missing ID");
|
||||
}
|
||||
|
||||
const routeName = isBestOfThree.value ? 'multiplayer-match' : 'multiplayer-game'
|
||||
|
||||
console.log(`${isBestOfThree.value ? 'Match' : 'Game'} hosted:`, resultId)
|
||||
|
||||
// Close modal immediately
|
||||
showHostModal.value = false;
|
||||
|
||||
router.push({
|
||||
name: 'multiplayer-game',
|
||||
params: { id: gameId },
|
||||
name: routeName,
|
||||
params: { id: resultId },
|
||||
query: { type: selectedMode.value }
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to host:', error);
|
||||
|
||||
// Handle Validation Errors (422)
|
||||
if (error.response?.data?.errors) {
|
||||
const firstError = Object.values(error.response.data.errors)[0][0];
|
||||
alert(`Validation Error: ${firstError}`);
|
||||
}
|
||||
// Handle Logic Errors (e.g., "You already have a match")
|
||||
else if (error.response?.data?.message) {
|
||||
alert(error.response.data.message);
|
||||
}
|
||||
else {
|
||||
alert('Failed to host: ' + error.message);
|
||||
}
|
||||
} finally {
|
||||
hostLoading.value = false; // RE-ENABLE BUTTON
|
||||
}
|
||||
}
|
||||
|
||||
const joinMatch = async (match) => {
|
||||
selectedGame.value = match // Reusing selectedGame variable for modal context
|
||||
|
||||
// Check balance
|
||||
if (match.wager > userCoins.value) {
|
||||
alert(`Insufficient funds! You need ${match.wager} coins to join this match.`)
|
||||
return
|
||||
}
|
||||
|
||||
if (!confirm(`This match requires a wager of ${match.wager} coins. Do you want to join?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await axios.post(`${API_BASE_URL}/matches/${match.id}/join`)
|
||||
router.push({
|
||||
name: 'multiplayer-match', // Redirect to Match Interface
|
||||
params: { id: match.id },
|
||||
query: { type: selectedMode.value }
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to host game:', error)
|
||||
console.error('Error details:', error.response?.data)
|
||||
showHostModal.value = false
|
||||
alert('Failed to host game: ' + (error.response?.data?.message || error.message))
|
||||
console.error('Failed to join match:', error)
|
||||
alert(error.response?.data?.message || 'Failed to join match')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -256,6 +336,21 @@ const joinGame = async (game) => {
|
||||
}
|
||||
}
|
||||
|
||||
const openHostModal = (forMatch) => {
|
||||
isBestOfThree.value = forMatch; // Set the toggle automatically
|
||||
showHostModal.value = true; // Open the modal
|
||||
wagerAmount.value = 0; // Reset wager amount
|
||||
}
|
||||
|
||||
watch(() => userLoggedIn, async (isLoggedIn) => {
|
||||
if (isLoggedIn) {
|
||||
await userStore.fetchCoins()
|
||||
userCoins.value = userStore.coins
|
||||
} else {
|
||||
userStore.coins = 0
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -338,19 +433,19 @@ const joinGame = async (game) => {
|
||||
<div class="flex justify-between items-end mb-2">
|
||||
<label class="text-sm font-bold uppercase tracking-wider text-muted-foreground">Open
|
||||
Matches</label>
|
||||
<Button v-if="isLoggedIn" @click="showHostModal()" size="sm" variant="outline"
|
||||
<Button v-if="isLoggedIn" @click="openHostModal(true)" size="sm" variant="outline"
|
||||
class="border-purple-500 text-purple-500 hover:bg-purple-500 hover:text-white transition-all duration-300 font-bold">
|
||||
Host
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border bg-card text-card-foreground shadow-sm">
|
||||
<div v-if="openGames.length === 0"
|
||||
class="p-6 text-center text-sm text-muted-foreground">
|
||||
No open macthes yet. Try hosting one!
|
||||
<div v-if="openMatches.length === 0" class="p-6 text-center text-sm text-muted-foreground">
|
||||
No open matches yet. Try hosting one!
|
||||
</div>
|
||||
|
||||
<div v-else class="max-h-[350px] overflow-y-auto custom-scrollbar p-1">
|
||||
<div v-for="(match, index) in openMatches" :key="match.id"
|
||||
<div v-for="(match, index) in openMatches" :key="match.id" @click="joinMatch(match)"
|
||||
class="flex items-center justify-between p-3 mb-2 border border-transparent rounded-lg cursor-pointer transition-all hover:bg-muted/50 hover:border-purple-500">
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
@@ -358,19 +453,21 @@ const joinGame = async (game) => {
|
||||
{{ index + 1 }}
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-medium text-sm">Match ID: {{ match.id }}</div>
|
||||
<div class="text-[10px] text-muted-foreground">{{ new
|
||||
Date(game.began_at).toLocaleDateString() }} • {{ new
|
||||
Date(game.began_at).toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit' }) }}
|
||||
<div class="font-medium text-sm flex items-center gap-2">
|
||||
Match #{{ match.id }}
|
||||
<span
|
||||
class="flex items-center text-xs bg-yellow-100 text-yellow-700 px-2 py-0.5 rounded-full">
|
||||
<Coins class="w-3 h-3 mr-1" /> {{ match.wager }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-[10px] text-muted-foreground">
|
||||
Host: {{ match.host?.nickname || 'Waiting...' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div ref="mObserverTarget" class="h-10 flex items-center justify-center">
|
||||
<span v-if="mLoading" class="text-xs text-purple-500 animate-pulse">Loading
|
||||
matches...</span>
|
||||
<span v-if="mLoading" class="text-xs text-purple-500 animate-pulse">Loading matches...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -380,15 +477,14 @@ const joinGame = async (game) => {
|
||||
<div class="flex justify-between items-end mb-2">
|
||||
<label class="text-sm font-medium uppercase tracking-wider text-muted-foreground">Open
|
||||
Games</label>
|
||||
<Button v-if="isLoggedIn" @click="hostGame()" size="sm" variant="outline"
|
||||
<Button v-if="isLoggedIn" @click="openHostModal(false)" size="sm" variant="outline"
|
||||
class="border-purple-500 text-purple-500 hover:bg-purple-500 hover:text-white transition-all duration-300 font-bold">
|
||||
Host
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border bg-card text-card-foreground shadow-sm">
|
||||
<div v-if="openGames.length === 0"
|
||||
class="p-6 text-center text-sm text-muted-foreground">
|
||||
<div v-if="openGames.length === 0" class="p-6 text-center text-sm text-muted-foreground">
|
||||
No open games yet. Try hosting one!
|
||||
</div>
|
||||
<div v-else class="max-h-[350px] overflow-y-auto custom-scrollbar p-1">
|
||||
@@ -406,7 +502,8 @@ const joinGame = async (game) => {
|
||||
Date(game.began_at).toLocaleDateString() }} • {{ new
|
||||
Date(game.began_at).toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit' }) }}</div>
|
||||
minute: '2-digit'
|
||||
}) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -421,54 +518,49 @@ const joinGame = async (game) => {
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="showHostModal"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||
|
||||
<div v-if="showHostModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||
<Card class="w-full max-w-sm border-purple-500 shadow-2xl">
|
||||
<CardHeader>
|
||||
<CardTitle class="text-center">Hosting Game</CardTitle>
|
||||
<CardTitle class="text-center">
|
||||
{{ isBestOfThree ? 'Host a (Best of 3)' : 'Host Single Game' }}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="flex flex-col items-center gap-6 py-10">
|
||||
<CardContent class="flex flex-col gap-6 py-4">
|
||||
|
||||
<div v-if="isBestOfThree" class="space-y-2 transition-all">
|
||||
<Label>Wager (Coins)</Label>
|
||||
<div class="relative">
|
||||
<div
|
||||
class="h-16 w-16 animate-spin rounded-full border-4 border-purple-500 border-t-transparent">
|
||||
<Coins class="absolute left-3 top-2.5 h-4 w-4 text-yellow-500" />
|
||||
<Input v-model="wagerAmount" type="number" min="0" class="pl-9" placeholder="Enter wager amount" />
|
||||
</div>
|
||||
</div>
|
||||
<p class="animate-pulse text-lg font-medium text-muted-foreground">Waiting for opponents...</p>
|
||||
<Button variant="outline" @click="showHostModal = false">Cancel Hosting</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<div v-if="showJoinModal"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||
<Card class="w-full max-w-md border-purple-500 shadow-2xl">
|
||||
<CardHeader>
|
||||
<CardTitle>Join Game</CardTitle>
|
||||
<CardDescription v-if="selectedGame">
|
||||
Hosted by {{ selectedGame.player1?.nickname || 'Anonymous' }}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent class="space-y-4">
|
||||
<div class="rounded-lg bg-muted p-4 text-sm">
|
||||
<p><strong>Game Type:</strong> Bisca de {{ selectedMode }}</p>
|
||||
<p><strong>Started at:</strong> {{ selectedGame?.began_at }}</p>
|
||||
<p class="text-xs text-muted-foreground text-right">
|
||||
Balance: {{ userCoins }}
|
||||
</p>
|
||||
<p class="text-[10px] text-yellow-600 bg-yellow-100 p-2 rounded">
|
||||
⚠️ Winner takes all. Coins are deducted when you join.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="isReady"
|
||||
class="flex items-center justify-center rounded-md bg-green-500/10 p-3 text-green-600 dark:text-green-400">
|
||||
<span class="animate-pulse font-semibold">Waiting for the host to start...</span>
|
||||
<div v-else class="text-center text-sm text-muted-foreground">
|
||||
<p>No coins required.</p>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 pt-4">
|
||||
<Button variant="destructive" class="flex-1" @click="showJoinModal = false">
|
||||
Leave
|
||||
<div class="text-center text-sm text-muted-foreground">
|
||||
<p>Host a standard game of Bisca de {{ selectedMode }}.</p>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<Button variant="outline" class="flex-1" @click="showHostModal = false" :disabled="hostLoading">
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button class="flex-1 transition-all duration-300" :variant="isReady ? 'outline' : 'default'"
|
||||
:class="!isReady ? 'bg-purple-600 hover:bg-purple-700' : ''" @click="toggleReady">
|
||||
{{ isReady ? 'Cancel Ready' : 'Ready up' }}
|
||||
<Button class="flex-1 bg-purple-600 hover:bg-purple-700" @click="hostGame" :disabled="hostLoading">
|
||||
<span v-if="hostLoading" class="animate-pulse">Creating...</span>
|
||||
<span v-else>{{ isBestOfThree ? 'Create Match' : 'Start Game' }}</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,7 @@ import TestAllAnimations from '@/pages/TestAllAnimations.vue'
|
||||
import TestGameBoard from '@/pages/TestGameBoard.vue'
|
||||
import SinglePlayerGamePage from '@/pages/game/SinglePlayerGamePage.vue'
|
||||
import MultiplayerGamePage from '@/pages/game/MultiplayerGamePage.vue'
|
||||
import MultiplayerMatchPage from '@/pages/game/MultiplayerMatchPage.vue'
|
||||
import RegisterPage from '@/pages/register/RegisterPage.vue'
|
||||
import CoinsPurchasePage from '@/pages/purchase/CoinPurchasePage.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
@@ -54,6 +55,12 @@ const router = createRouter({
|
||||
component: MultiplayerGamePage,
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/match/:id',
|
||||
name: 'multiplayer-match',
|
||||
component: MultiplayerMatchPage,
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/user',
|
||||
component: UserPage,
|
||||
|
||||
Generated
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "DADProject",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {}
|
||||
}
|
||||
Reference in New Issue
Block a user