374 lines
14 KiB
Vue
374 lines
14 KiB
Vue
<script setup lang="ts">
|
|
import { ref, computed, onMounted } from "vue";
|
|
import { storeToRefs } from "pinia";
|
|
import { useI18n } from "vue-i18n";
|
|
import { marketApiCreateBet, marketApiCloseMarket, marketApiResolveMarket } from "../api";
|
|
import { useMarketStore } from "../stores/market";
|
|
import { useEventPath } from "../composables/useEventPath";
|
|
import type { Market, MarketOption } from "../types";
|
|
import type { UserBetSchema } from "../api/types.gen";
|
|
import PayoutChart from "./charts/PayoutChart.vue";
|
|
import BetHistoryChart from "./charts/BetHistoryChart.vue";
|
|
|
|
const props = defineProps<{
|
|
market: Market;
|
|
}>();
|
|
|
|
const emit = defineEmits<{
|
|
refresh: [];
|
|
}>();
|
|
|
|
const { t } = useI18n();
|
|
const eventPath = useEventPath();
|
|
const marketStore = useMarketStore();
|
|
const { userInfo, userBets, userPoints } = storeToRefs(marketStore);
|
|
|
|
const selectedOption = ref<string | null>(null);
|
|
const betAmount = ref<number>(0);
|
|
const loading = ref(false);
|
|
const error = ref<string>("");
|
|
const existingBet = ref<UserBetSchema | null>(null);
|
|
const showResolveModal = ref(false);
|
|
const selectedWinningOption = ref<string | null>(null);
|
|
const resolveLoading = ref(false);
|
|
const showCharts = ref(true);
|
|
|
|
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 t("components.marketCard.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 "draft":
|
|
return "badge-secondary";
|
|
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 * props.market.multiplier);
|
|
};
|
|
|
|
const selectedMarketOption = computed(() =>
|
|
props.market.options.find((opt) => opt.uuid === selectedOption.value) ?? null,
|
|
);
|
|
|
|
// Payout chart only makes sense while the user can still bet on the selected option
|
|
const showPayoutChart = computed(
|
|
() => props.market.status === "open" && !!userInfo.value?.is_authenticated && !!selectedMarketOption.value,
|
|
);
|
|
|
|
const existingAmountOnSelected = computed(() =>
|
|
existingBet.value && existingBet.value.option.uuid === selectedOption.value ? existingBet.value.amount : 0,
|
|
);
|
|
|
|
const closeMarket = async () => {
|
|
loading.value = true;
|
|
error.value = "";
|
|
|
|
try {
|
|
await marketApiCloseMarket({
|
|
path: { event_path: eventPath, market_uuid: props.market.uuid },
|
|
});
|
|
emit("refresh");
|
|
} catch (e) {
|
|
error.value = t("components.marketCard.errorClosing");
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
};
|
|
|
|
const resolveMarket = async () => {
|
|
if (!selectedWinningOption.value) {
|
|
error.value = t("components.marketCard.selectWinnerRequired");
|
|
return;
|
|
}
|
|
|
|
resolveLoading.value = true;
|
|
error.value = "";
|
|
|
|
try {
|
|
await marketApiResolveMarket({
|
|
path: { event_path: eventPath, 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 = t("components.marketCard.errorResolving");
|
|
}
|
|
} finally {
|
|
resolveLoading.value = false;
|
|
}
|
|
};
|
|
|
|
const placeBet = async () => {
|
|
if (!selectedOption.value || !betAmount.value) return;
|
|
|
|
loading.value = true;
|
|
error.value = "";
|
|
|
|
try {
|
|
const response = await marketApiCreateBet({
|
|
path: {
|
|
event_path: eventPath,
|
|
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 Promise.all([marketStore.loadUserBets(eventPath), marketStore.loadUserPoints(eventPath)]);
|
|
updateExistingBet();
|
|
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 = t("components.marketCard.failedToPlaceBet");
|
|
}
|
|
}
|
|
} catch (e) {
|
|
error.value = t("components.marketCard.errorPlacingBet");
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
};
|
|
|
|
onMounted(() => {
|
|
// Update existing bet when component mounts or userBets changes
|
|
updateExistingBet();
|
|
});
|
|
|
|
const updateExistingBet = () => {
|
|
if (userBets.value) {
|
|
existingBet.value = userBets.value.find(bet => bet.market?.uuid === props.market.uuid) || null;
|
|
if (existingBet.value) {
|
|
selectedOption.value = existingBet.value.option.uuid;
|
|
betAmount.value = existingBet.value.amount;
|
|
}
|
|
}
|
|
};
|
|
</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="flex gap-2 items-center">
|
|
<div :class="['badge', statusColor, 'text-white']">
|
|
{{ t(`common.marketStatus.${market.status}`) }}
|
|
</div>
|
|
<div v-if="market.multiplier > 1" class="badge badge-accent text-white font-bold">
|
|
{{ market.multiplier }}x
|
|
</div>
|
|
</div>
|
|
<div v-if="market.status === 'open' || market.status === 'closed'" class="text-sm text-base-content/60 text-right">
|
|
<div>{{ timeRemaining }}</div>
|
|
<div class="text-xs">{{ t("components.marketCard.untilClose") }}</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>{{ t("components.marketCard.closeMarket") }}</span>
|
|
</button>
|
|
<button v-if="market.status === 'closed'" @click="showResolveModal = true" class="btn btn-sm btn-success">
|
|
{{ t("components.marketCard.resolveMarketButton") }}
|
|
</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">{{ t("components.marketCard.resolveModalTitle") }}</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">
|
|
{{ t("common.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>{{ t("components.marketCard.resolveAndDistribute") }}</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>{{ t("components.marketCard.pool", { n: option.total_bets }) }}</div>
|
|
<div v-if="option.total_bets > 0">{{ t("components.marketCard.multiplierLabel", { x: getMultiplier(option).toFixed(2) }) }}</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">
|
|
{{ t("components.marketCard.yourBet", { n: existingBet.amount }) }}
|
|
</span>
|
|
<span v-if="option.total_bets > 0" class="badge badge-success badge-sm w-fit">
|
|
{{ t("components.marketCard.potential", { n: getPotentialGain(option) }) }}
|
|
</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>
|
|
|
|
<!-- Charts -->
|
|
<div class="px-8 pb-2">
|
|
<button @click="showCharts = !showCharts" class="btn btn-ghost btn-xs">
|
|
<i :class="['mdi', showCharts ? 'mdi-chevron-up' : 'mdi-chart-line']"></i>
|
|
{{ showCharts ? t("components.marketCharts.hideCharts") : t("components.marketCharts.showCharts") }}
|
|
</button>
|
|
</div>
|
|
<div v-if="showCharts" class="card-body py-4 gap-6">
|
|
<PayoutChart v-if="showPayoutChart && selectedMarketOption" :market="market" :option="selectedMarketOption"
|
|
:existing-amount="existingAmountOnSelected" :stake="betAmount || 0"
|
|
:max-stake="existingAmountOnSelected + userPoints" />
|
|
<BetHistoryChart :market="market" />
|
|
</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">{{ t("components.marketCard.increaseBetLabel", { n: existingBet.amount }) }}</span>
|
|
<span v-else>{{ t("components.marketCard.pointsToBet") }}</span>
|
|
</span>
|
|
</label>
|
|
<input v-model.number="betAmount" type="number"
|
|
:placeholder="existingBet ? t('components.marketCard.increaseByPlaceholder') : t('components.marketCard.enterPointsPlaceholder')" 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">{{ t("components.marketCard.increaseBet") }}</span>
|
|
<span v-else>{{ t("components.marketCard.placeBet") }}</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">{{ t("components.marketCard.yourBetTitle") }}</div>
|
|
<div class="text-base-content/70 mt-1">
|
|
{{ t("components.marketCard.optionLabel", { text: existingBet.option.text }) }}
|
|
</div>
|
|
<div class="text-base-content/70">
|
|
{{ t("components.marketCard.amountLabel", { n: existingBet.amount }) }}
|
|
</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">{{ t("components.marketCard.won") }}</span>
|
|
<span v-else-if="existingBet">{{ t("components.marketCard.lost") }}</span>
|
|
<span v-else>{{ t("components.marketCard.resolved") }}</span>
|
|
</div>
|
|
<div class="text-base-content/70 mt-1">
|
|
{{ t("components.marketCard.winnerLabel", { text: market.winning_option.text }) }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|