feat(market): basic page + submit

This commit is contained in:
2026-05-23 18:30:50 +02:00
parent ce30539808
commit f7c7eba4da
17 changed files with 866 additions and 9 deletions
@@ -0,0 +1,225 @@
<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
import { apiService } from "../services/apiService";
import type { Market, UserBet } from "../types";
const props = defineProps<{
market: Market;
}>();
const emit = defineEmits<{
refresh: [];
}>();
const selectedOption = ref<string | null>(null);
const betAmount = ref<number>(0);
const loading = ref(false);
const error = ref<string>("");
const userInfo = ref<any>(null);
const userBets = ref<UserBet[]>([]);
const existingBet = ref<UserBet | null>(null);
const timeRemaining = computed(() => {
const endDate = new Date(props.market.end_date).getTime();
const now = new Date().getTime();
const diff = endDate - now;
if (diff <= 0) return "Ended";
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
if (days > 0) return `${days}d ${hours}h`;
if (hours > 0) return `${hours}h ${minutes}m`;
return `${minutes}m`;
});
const statusColor = computed(() => {
switch (props.market.status) {
case "open":
return "badge-success";
case "closed":
return "badge-warning";
case "resolved":
return "badge-info";
default:
return "badge-ghost";
}
});
const canBet = computed(() => {
return props.market.status === "open" && userInfo.value?.is_authenticated && selectedOption.value && betAmount.value > 0;
});
const placeBet = async () => {
if (!selectedOption.value || !betAmount.value) return;
loading.value = true;
error.value = "";
try {
const response = await apiService.placeBet(props.market.uuid, {
option_uuid: selectedOption.value,
amount: betAmount.value,
});
if (response.status === 200 || response.status === 201) {
// Reload user bets to reflect the new bet
await loadUserBets();
betAmount.value = 0;
} else {
error.value = response.error || "Failed to place bet";
}
} catch (e) {
error.value = "Error placing bet";
} finally {
loading.value = false;
}
};
const initializeUserInfo = async () => {
const response = await apiService.getUserInfo();
if (response.data) {
userInfo.value = response.data;
}
};
const loadUserBets = async () => {
const response = await apiService.getUserBets();
if (response.data) {
userBets.value = response.data;
// Find if user has a bet on this market
existingBet.value = response.data.find(bet => bet.market.uuid === props.market.uuid) || null;
if (existingBet.value) {
selectedOption.value = existingBet.value.option.uuid;
betAmount.value = existingBet.value.amount;
}
}
};
onMounted(async () => {
await initializeUserInfo();
if (userInfo.value?.is_authenticated) {
await loadUserBets();
}
});
</script>
<template>
<div class="card bg-base-100 shadow-xl">
<!-- Header -->
<div class="card-body pb-3">
<div class="flex justify-between items-start gap-4">
<div class="flex-1">
<h2 class="card-title text-2xl mb-2">{{ market.title }}</h2>
<p class="text-sm text-base-content/70">{{ market.description }}</p>
</div>
<div class="flex flex-col items-end gap-2">
<div :class="['badge', statusColor, 'text-white']">
{{ market.status }}
</div>
<div class="text-sm text-base-content/60 text-right">
<div>{{ timeRemaining }}</div>
<div class="text-xs">until close</div>
</div>
</div>
</div>
</div>
<div class="divider my-0"></div>
<!-- Options -->
<div class="card-body py-4">
<div class="grid gap-3" :class="market.options.length > 2 ? 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3' : 'grid-cols-1 md:grid-cols-2'">
<div v-for="option in market.options" :key="option.uuid" class="form-control">
<label
:class="[
'label cursor-pointer border rounded-lg p-3 hover:bg-base-200 transition h-full',
existingBet && existingBet.option.uuid === option.uuid ? 'border-primary border-2 bg-primary/5' : ''
]"
>
<div class="flex flex-col gap-2 flex-1">
<span class="label-text font-medium">{{ option.text }}</span>
<span
v-if="existingBet && existingBet.option.uuid === option.uuid"
class="badge badge-primary badge-sm w-fit"
>
Your bet: {{ existingBet.amount }} pts
</span>
</div>
<input
type="radio"
:value="option.uuid"
v-model="selectedOption"
class="radio radio-primary"
:disabled="market.status !== 'open' || !!(existingBet && existingBet.option.uuid !== option.uuid)"
/>
</label>
</div>
</div>
</div>
<!-- Bet Input -->
<div v-if="market.status === 'open' && userInfo?.is_authenticated && selectedOption" class="card-body py-4">
<div class="form-control gap-3">
<label class="label">
<span class="label-text">
<span v-if="existingBet">Increase bet (current: {{ existingBet.amount }} pts)</span>
<span v-else>Points to bet</span>
</span>
</label>
<input
v-model.number="betAmount"
type="number"
:placeholder="existingBet ? `Enter amount to increase by` : 'Enter points'"
class="input input-bordered"
min="1"
:disabled="loading"
/>
<div v-if="error" class="alert alert-error">
<i class="mdi mdi-alert-circle"></i>
<span>{{ error }}</span>
</div>
<button
@click="placeBet"
:disabled="!canBet || loading"
class="btn btn-primary"
>
<span v-if="loading" class="loading loading-spinner loading-sm"></span>
<span v-else-if="existingBet">Increase Bet</span>
<span v-else>Place Bet</span>
</button>
</div>
</div>
<!-- Show existing bet (market closed/resolved) -->
<div v-else-if="market.status !== 'open' && existingBet" class="card-body py-4 bg-base-200">
<div class="text-sm">
<div class="font-semibold">Your Bet</div>
<div class="text-base-content/70 mt-1">
Option: <span class="font-semibold">{{ existingBet.option.text }}</span>
</div>
<div class="text-base-content/70">
Amount: <span class="font-semibold">{{ existingBet.amount }} pts</span>
</div>
</div>
</div>
<!-- Result -->
<div v-else-if="market.status === 'resolved' && market.winning_option" class="card-body py-4 bg-base-200">
<div class="text-sm">
<div class="font-semibold" :class="existingBet && existingBet.option.uuid === market.winning_option.uuid ? 'text-success' : 'text-error'">
<span v-if="existingBet && existingBet.option.uuid === market.winning_option.uuid"> You Won!</span>
<span v-else-if="existingBet"> You Lost</span>
<span v-else>Resolved</span>
</div>
<div class="text-base-content/70 mt-1">
Winner: <span class="font-semibold">{{ market.winning_option.text }}</span>
</div>
</div>
</div>
</div>
</template>
@@ -0,0 +1,187 @@
<script setup lang="ts">
import { ref, onMounted, computed } from "vue";
import { apiService } from "../services/apiService";
import type { UserBet } from "../types";
defineEmits<{
refresh: [];
}>();
const userBets = ref<UserBet[]>([]);
const loading = ref(true);
const totalBetAmount = computed(() => {
return userBets.value.reduce((sum, bet) => sum + bet.amount, 0);
});
const winningBets = computed(() => {
return userBets.value.filter(bet => {
const market = bet.market;
return market.status === "resolved" && market.winning_option?.uuid === bet.option.uuid;
});
});
const losingBets = computed(() => {
return userBets.value.filter(bet => {
const market = bet.market;
return market.status === "resolved" && market.winning_option?.uuid !== bet.option.uuid;
});
});
const openBets = computed(() => {
return userBets.value.filter(bet => bet.market.status === "open");
});
const totalWinnings = computed(() => {
return winningBets.value.reduce((sum, bet) => sum + bet.amount, 0);
});
onMounted(async () => {
const response = await apiService.getUserBets();
if (response.data) {
userBets.value = response.data;
}
loading.value = false;
});
</script>
<template>
<div class="space-y-6">
<!-- Loading -->
<div v-if="loading" class="flex justify-center py-20">
<span class="loading loading-spinner loading-lg"></span>
</div>
<!-- Empty State -->
<div v-else-if="userBets.length === 0" class="alert">
<i class="mdi mdi-information mr-2"></i>
<span>You haven't placed any bets yet</span>
</div>
<!-- Content -->
<template v-else>
<!-- Stats -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div class="stat bg-base-100 rounded-lg shadow">
<div class="stat-title text-sm">Total Bets</div>
<div class="stat-value text-2xl">{{ userBets.length }}</div>
<div class="stat-desc text-xs">{{ totalBetAmount }} points</div>
</div>
<div class="stat bg-base-100 rounded-lg shadow">
<div class="stat-title text-sm">Active</div>
<div class="stat-value text-2xl text-info">{{ openBets.length }}</div>
<div class="stat-desc text-xs">Waiting for result</div>
</div>
<div class="stat bg-base-100 rounded-lg shadow">
<div class="stat-title text-sm">Won</div>
<div class="stat-value text-2xl text-success">{{ winningBets.length }}</div>
<div class="stat-desc text-xs text-success">+{{ totalWinnings }} points</div>
</div>
<div class="stat bg-base-100 rounded-lg shadow">
<div class="stat-title text-sm">Lost</div>
<div class="stat-value text-2xl text-error">{{ losingBets.length }}</div>
<div class="stat-desc text-xs">Better luck next time</div>
</div>
</div>
<!-- Bets Sections -->
<div class="space-y-6">
<!-- Open Bets -->
<div v-if="openBets.length > 0">
<h3 class="text-xl font-bold mb-4 flex items-center gap-2">
<i class="mdi mdi-progress-clock text-info"></i>
Active Bets ({{ openBets.length }})
</h3>
<div class="space-y-4">
<div
v-for="bet in openBets"
:key="bet.uuid"
class="card bg-base-100 shadow hover:shadow-lg transition-shadow"
>
<div class="card-body py-4">
<div class="flex justify-between items-start">
<div class="flex-1">
<h4 class="font-semibold text-lg">{{ bet.market.title }}</h4>
<p class="text-sm text-base-content/70">
Bet on: <span class="font-medium">{{ bet.option.text }}</span>
</p>
</div>
<div class="text-right">
<div class="text-lg font-bold">{{ bet.amount }} pts</div>
<div class="text-xs text-base-content/60 mt-1">
Status: <span class="badge badge-info badge-sm">{{ bet.market.status }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Winning Bets -->
<div v-if="winningBets.length > 0">
<h3 class="text-xl font-bold mb-4 flex items-center gap-2">
<i class="mdi mdi-check-circle text-success"></i>
Won Bets ({{ winningBets.length }})
</h3>
<div class="space-y-4">
<div
v-for="bet in winningBets"
:key="bet.uuid"
class="card bg-success/10 border border-success shadow"
>
<div class="card-body py-4">
<div class="flex justify-between items-start">
<div class="flex-1">
<h4 class="font-semibold text-lg">{{ bet.market.title }}</h4>
<p class="text-sm text-base-content/70">
Correct! You bet on: <span class="font-medium text-success">{{ bet.option.text }}</span>
</p>
</div>
<div class="text-right">
<div class="text-lg font-bold text-success">+{{ bet.amount }} pts</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Losing Bets -->
<div v-if="losingBets.length > 0">
<h3 class="text-xl font-bold mb-4 flex items-center gap-2">
<i class="mdi mdi-close-circle text-error"></i>
Lost Bets ({{ losingBets.length }})
</h3>
<div class="space-y-4">
<div
v-for="bet in losingBets"
:key="bet.uuid"
class="card bg-error/10 border border-error shadow"
>
<div class="card-body py-4">
<div class="flex justify-between items-start">
<div class="flex-1">
<h4 class="font-semibold text-lg">{{ bet.market.title }}</h4>
<p class="text-sm text-base-content/70">
You bet on: <span class="font-medium">{{ bet.option.text }}</span>
</p>
<p class="text-sm text-base-content/60 mt-1">
Winner: <span class="font-medium">{{ bet.market.winning_option?.text }}</span>
</p>
</div>
<div class="text-right">
<div class="text-lg font-bold text-error">-{{ bet.amount }} pts</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
</div>
</template>