Files
polylan-submitter/polylan_submitter/src/components/MarketCard.vue
T
2026-10-07 21:54:24 +02:00

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>