340 lines
12 KiB
Vue
340 lines
12 KiB
Vue
<script setup lang="ts">
|
|
import { ref, computed, onMounted } from "vue";
|
|
import { polylanSubmitterApiGetUserInfo, marketApiCreateBet, marketApiListUserBets, marketApiCloseMarket, marketApiResolveMarket } from "../api";
|
|
import type { Market, MarketOption } from "../types";
|
|
import type { UserInfoOut, UserBetSchema } from "../api/types.gen";
|
|
|
|
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<UserInfoOut | undefined>();
|
|
const userBets = ref<UserBetSchema[]>([]);
|
|
const existingBet = ref<UserBetSchema | null>(null);
|
|
const showResolveModal = ref(false);
|
|
const selectedWinningOption = ref<string | null>(null);
|
|
const resolveLoading = ref(false);
|
|
|
|
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 totalPot = computed(() => {
|
|
return props.market.options.reduce((sum, opt) => sum + opt.total_bets, 0);
|
|
});
|
|
|
|
const getMultiplier = (option: MarketOption) => {
|
|
if (option.total_bets === 0) return 0;
|
|
return totalPot.value / option.total_bets;
|
|
};
|
|
|
|
const getPotentialGain = (option: MarketOption) => {
|
|
if (!existingBet.value || existingBet.value.option.uuid !== option.uuid) return 0;
|
|
const multiplier = getMultiplier(option);
|
|
return Math.round(existingBet.value.amount * multiplier);
|
|
};
|
|
|
|
const closeMarket = async () => {
|
|
loading.value = true;
|
|
error.value = "";
|
|
|
|
try {
|
|
await marketApiCloseMarket({
|
|
path: { market_uuid: props.market.uuid },
|
|
});
|
|
emit("refresh");
|
|
} catch (e) {
|
|
error.value = "Error closing market";
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
};
|
|
|
|
const resolveMarket = async () => {
|
|
if (!selectedWinningOption.value) {
|
|
error.value = "Please select a winning option";
|
|
return;
|
|
}
|
|
|
|
resolveLoading.value = true;
|
|
error.value = "";
|
|
|
|
try {
|
|
await marketApiResolveMarket({
|
|
path: { market_uuid: props.market.uuid },
|
|
body: { winning_option_uuid: selectedWinningOption.value },
|
|
});
|
|
emit("refresh");
|
|
showResolveModal.value = false;
|
|
selectedWinningOption.value = null;
|
|
} catch (e) {
|
|
const err = e as any;
|
|
if (typeof err === 'object' && err?.detail) {
|
|
error.value = err.detail;
|
|
} else if (typeof err === 'string') {
|
|
error.value = err;
|
|
} else {
|
|
error.value = "Error resolving market";
|
|
}
|
|
} finally {
|
|
resolveLoading.value = false;
|
|
}
|
|
};
|
|
|
|
const placeBet = async () => {
|
|
if (!selectedOption.value || !betAmount.value) return;
|
|
|
|
loading.value = true;
|
|
error.value = "";
|
|
|
|
try {
|
|
const response = await marketApiCreateBet({
|
|
path: {
|
|
market_uuid: props.market.uuid
|
|
},
|
|
body: {
|
|
option_uuid: selectedOption.value,
|
|
amount: betAmount.value,
|
|
},
|
|
});
|
|
|
|
if (!response.error) {
|
|
// Reload user bets to reflect the new bet
|
|
await loadUserBets();
|
|
betAmount.value = 0;
|
|
} else {
|
|
const err = response.error as any;
|
|
if (typeof err === 'object' && err?.detail) {
|
|
error.value = err.detail;
|
|
} else if (typeof err === 'string') {
|
|
error.value = err;
|
|
} else {
|
|
error.value = "Failed to place bet";
|
|
}
|
|
}
|
|
} catch (e) {
|
|
error.value = "Error placing bet";
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
};
|
|
|
|
const initializeUserInfo = async () => {
|
|
const response = await polylanSubmitterApiGetUserInfo();
|
|
if (response.data) {
|
|
userInfo.value = response.data;
|
|
}
|
|
};
|
|
|
|
const loadUserBets = async () => {
|
|
const response = await marketApiListUserBets();
|
|
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 v-if="market.status === 'open'" 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>
|
|
|
|
<!-- Admin Actions -->
|
|
<div v-if="userInfo?.is_superuser" class="card-body py-4 bg-base-100">
|
|
<div class="flex gap-2">
|
|
<button v-if="market.status === 'open'" @click="closeMarket" :disabled="loading" class="btn btn-sm btn-warning">
|
|
<span v-if="loading" class="loading loading-spinner loading-sm"></span>
|
|
<span v-else>Close Market</span>
|
|
</button>
|
|
<button v-if="market.status === 'closed'" @click="showResolveModal = true" class="btn btn-sm btn-success">
|
|
Resolve Market
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Resolve Modal -->
|
|
<dialog v-if="showResolveModal" class="modal modal-open">
|
|
<div class="modal-box">
|
|
<h3 class="font-bold text-lg mb-4">Resolve Market - Select Winner</h3>
|
|
|
|
<div class="space-y-2 mb-4">
|
|
<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 w-full flex justify-between">
|
|
<span class="label-text font-medium">{{ option.text }}</span>
|
|
<input type="radio" :value="option.uuid" v-model="selectedWinningOption" class="radio radio-primary" />
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="error" class="alert alert-error mb-4">
|
|
<i class="mdi mdi-alert-circle"></i>
|
|
<span>{{ error }}</span>
|
|
</div>
|
|
|
|
<div class="modal-action">
|
|
<button @click="showResolveModal = false" class="btn" :disabled="resolveLoading">
|
|
Cancel
|
|
</button>
|
|
<button @click="resolveMarket" :disabled="!selectedWinningOption || resolveLoading" class="btn btn-primary">
|
|
<span v-if="resolveLoading" class="loading loading-spinner loading-sm"></span>
|
|
<span v-else>Resolve & Distribute Points</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="modal-backdrop" @click="showResolveModal = false"></div>
|
|
</dialog>
|
|
|
|
<!-- Options -->
|
|
<div class="card-body py-4">
|
|
<div class="flex flex-col gap-3">
|
|
<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 w-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>
|
|
<div class="text-xs text-base-content/60">
|
|
<div>Pool: {{ option.total_bets }} pts</div>
|
|
<div v-if="option.total_bets > 0">Multiplier: {{ getMultiplier(option).toFixed(2) }}x</div>
|
|
</div>
|
|
<div v-if="existingBet && existingBet.option.uuid === option.uuid" class="flex flex-col gap-1">
|
|
<span class="badge badge-primary badge-sm w-fit">
|
|
Your bet: {{ existingBet.amount }} pts
|
|
</span>
|
|
<span v-if="option.total_bets > 0" class="badge badge-success badge-sm w-fit">
|
|
Potential: {{ getPotentialGain(option) }} pts
|
|
</span>
|
|
</div>
|
|
</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>
|