베팅장 장수칸에서 금액 입력과 개별 즉시 베팅 지원

This commit is contained in:
2026-09-12 01:20:35 +00:00
parent a31c2711c7
commit 5041fc365e
5 changed files with 445 additions and 463 deletions
+151 -178
View File
@@ -2,25 +2,29 @@
import { useClockDisplay } from '../composables/useClockDisplay';
const { formatTime: formatGameTime } = useClockDisplay();
import { storeToRefs } from 'pinia';
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue';
import { computed, onMounted, onUnmounted, ref } from 'vue';
import TournamentBracket from '../components/tournament/TournamentBracket.vue';
import TournamentPageHeader from '../components/tournament/TournamentPageHeader.vue';
import GeneralIdentity from '../components/ui/GeneralIdentity.vue';
import { useGameFeedback } from '../composables/useGameFeedback';
import { useTournamentPagesStore } from '../stores/tournamentPages';
import type { TournamentBracketSlot } from '../utils/tournamentBracket';
import { trpc } from '../utils/trpc';
const tournamentPages = useTournamentPagesStore();
const { snapshot, betting: summary, rankings, loading, error } = storeToRefs(tournamentPages);
const selectedAmount = ref(10);
const selectedTarget = ref<TournamentBracketSlot | null>(null);
const betDialog = ref<HTMLDialogElement | null>(null);
const betAmountSelect = ref<HTMLSelectElement | null>(null);
const placingBet = ref(false);
const betError = ref<string | null>(null);
const defaultAmount = ref<number | string>(10);
const amounts = ref<Record<number, number | string>>({});
const pendingBets = ref<Record<number, number>>({});
const betMessages = ref<Record<number, { text: string; error: boolean }>>({});
const presetAmounts = [10, 20, 50, 100, 200, 500, 1000];
const activeRankingPrefix = ref('tt');
const { success: showSuccessToast } = useGameFeedback();
const amountFor = (id: number): number | string => amounts.value[id] ?? defaultAmount.value;
const remainingAmount = computed(() => Math.max(0, 1000 - myAmount.value));
const availableAmount = computed(() =>
Math.max(0, remainingAmount.value - Object.values(pendingBets.value).reduce((sum, amount) => sum + amount, 0))
);
const validAmount = (amount: number | string): boolean =>
typeof amount === 'number' && Number.isInteger(amount) && amount >= 10 && amount <= availableAmount.value;
const typeNames = ['전력전', '통솔전', '일기토', '설전'];
const stageNames = [
'경기 없음',
@@ -48,22 +52,9 @@ const totalAmount = computed(() => summary.value?.totalAmount ?? 0);
const myAmount = computed(() => summary.value?.myAmount ?? 0);
const betTotals = computed(() => summary.value?.totals as Record<number, number> | undefined);
const myBetTotals = computed(() => summary.value?.myTotals as Record<number, number> | undefined);
const ratio = (id: number) => {
const totals = summary.value?.totals as Record<number, number> | undefined;
const amount = totals?.[id] ?? 0;
return amount ? (totalAmount.value / amount).toFixed(2) : '0';
};
const openingTime = computed(() =>
formatGameTime(snapshot.value?.state?.nextAt, { format: 'hourMinute', fallback: '--:--' })
);
const selectedRatio = computed(() => {
const targetId = selectedTarget.value?.id;
return targetId === null || targetId === undefined ? '0' : ratio(targetId);
});
const selectedExpectedReturn = computed(() => {
const numericRatio = Number(selectedRatio.value);
return Number.isFinite(numericRatio) ? Math.round(selectedAmount.value * numericRatio) : 0;
});
const bettingOpen = computed(() => {
const state = snapshot.value?.state;
if (!state || state.stage !== 6) return false;
@@ -71,32 +62,21 @@ const bettingOpen = computed(() => {
return new Date(state.bettingCloseAt).getTime() > Date.now();
});
const openBetDialog = async (target: TournamentBracketSlot) => {
if (target.id === null || !bettingOpen.value) return;
selectedTarget.value = target;
betError.value = null;
await nextTick();
betDialog.value?.showModal();
betAmountSelect.value?.focus();
};
const closeBetDialog = () => {
betDialog.value?.close();
};
const placeBet = async () => {
const targetId = selectedTarget.value?.id;
if (targetId === null || targetId === undefined || placingBet.value) return;
const amount = selectedAmount.value;
betError.value = null;
placingBet.value = true;
const placeBet = async (target: TournamentBracketSlot) => {
const targetId = target.id;
if (targetId === null || pendingBets.value[targetId] || !bettingOpen.value) return;
const amount = amountFor(targetId);
if (!validAmount(amount) || typeof amount !== 'number') return;
delete betMessages.value[targetId];
pendingBets.value[targetId] = amount;
try {
await trpc.tournament.placeBet.mutate({ targetId, amount });
showSuccessToast('베팅이 등록되었습니다.');
betMessages.value[targetId] = { text: `${amount.toLocaleString('ko-KR')}금 베팅 완료`, error: false };
await load();
closeBetDialog();
} catch (value) {
betError.value = errorText(value);
betMessages.value[targetId] = { text: errorText(value), error: true };
} finally {
placingBet.value = false;
delete pendingBets.value[targetId];
}
};
</script>
@@ -125,6 +105,22 @@ const placeBet = async () => {
<small>(전체 금액 : {{ totalAmount }} / 투자 금액 : {{ myAmount }})</small>
</section>
<section class="bet-settings bg0" aria-label="베팅 금액 설정">
<label
>기본 금액
<input v-model.number="defaultAmount" type="number" min="10" max="1000" step="1" inputmode="numeric" />
</label>
<select v-model.number="defaultAmount" aria-label="기본 지정 금액">
<option v-if="!presetAmounts.includes(Number(defaultAmount))" :value="defaultAmount">직접 입력</option>
<option v-for="amount in presetAmounts" :key="amount" :value="amount">{{ amount }}</option>
</select>
<span>남은 한도 {{ remainingAmount.toLocaleString('ko-KR') }}</span>
<small
> 장수에게 추가할 금액입니다. 예상 환수금은 해당 장수 우승 금액이며, 최종 배당에 따라
달라집니다.</small
>
</section>
<TournamentBracket
class="bg0 betting-bracket"
:participants="snapshot?.participants ?? []"
@@ -137,61 +133,62 @@ const placeBet = async () => {
:show-legend="false"
:betting-open="bettingOpen"
:betting-mode="true"
@request-bet="openBetDialog"
/>
<dialog
ref="betDialog"
class="bet-dialog viewport-centered-dialog"
aria-labelledby="bet-dialog-title"
@close="selectedTarget = null"
>
<form v-if="selectedTarget" class="bet-dialog-content" @submit.prevent="placeBet">
<header>
<h2 id="bet-dialog-title">베팅하기</h2>
<button type="button" aria-label="베팅 닫기" :disabled="placingBet" @click="closeBetDialog">
×
</button>
</header>
<GeneralIdentity
:name="selectedTarget.name"
:picture="selectedTarget.picture"
:image-server="selectedTarget.imageServer"
:npc-state="selectedTarget.npcState"
/>
<label class="bet-amount-field">
<span>베팅 금액</span>
<select ref="betAmountSelect" v-model.number="selectedAmount" :disabled="placingBet">
<option :value="10">금10</option>
<option :value="20">금20</option>
<option :value="50">금50</option>
<option :value="100">금100</option>
<option :value="200">금200</option>
<option :value="500">금500</option>
<option :value="1000">최대 금1000</option>
</select>
</label>
<output class="bet-return-preview" aria-live="polite">
<span class="ratio-color">배당 {{ selectedRatio }}</span>
<span aria-hidden="true">×</span>
<span class="gold-color">{{ selectedAmount }}</span>
<span aria-hidden="true">=</span>
<strong class="return-color"
>예상 환수금 {{ selectedExpectedReturn.toLocaleString('ko-KR') }}</strong
<template #bet-controls="{ candidate: slot }">
<form v-if="slot.id !== null" class="inline-bet" @submit.prevent="placeBet(slot)">
<input
:value="amountFor(slot.id)"
type="number"
min="10"
:max="remainingAmount"
step="1"
inputmode="numeric"
:aria-label="`${slot.name} 베팅 금액`"
:disabled="Boolean(pendingBets[slot.id])"
@input="
amounts[slot.id] =
($event.target as HTMLInputElement).value === ''
? ''
: Number(($event.target as HTMLInputElement).value)
"
/>
<select
:value="presetAmounts.includes(Number(amountFor(slot.id))) ? amountFor(slot.id) : ''"
:aria-label="`${slot.name} 지정 금액`"
:disabled="Boolean(pendingBets[slot.id])"
@change="amounts[slot.id] = Number(($event.target as HTMLSelectElement).value)"
>
</output>
<p class="bet-preview-note">
현재 배당 기준 예상값이며, 베팅 상황에 따라 최종 배당은 달라질 있습니다.
</p>
<p v-if="betError" class="bet-dialog-error" role="alert">{{ betError }}</p>
<footer>
<button type="button" :disabled="placingBet" @click="closeBetDialog">취소</button>
<button type="submit" class="bet-submit" :disabled="placingBet">
{{ placingBet ? '등록 중...' : '베팅 등록' }}
<option value="" disabled>직접 입력</option>
<option
v-for="amount in presetAmounts"
:key="amount"
:value="amount"
:disabled="amount > availableAmount"
>
{{ amount }}
</option>
</select>
<button
type="submit"
class="bracket-bet-button"
:aria-label="`${slot.name}에게 베팅하기`"
:disabled="Boolean(pendingBets[slot.id]) || !validAmount(amountFor(slot.id))"
>
{{ pendingBets[slot.id] ? '등록 중' : '베팅' }}
</button>
</footer>
</form>
</dialog>
<small class="bet-message" :class="{ 'bet-error': betMessages[slot.id]?.error }" role="status">
{{
betMessages[slot.id]?.text ??
(!validAmount(amountFor(slot.id)) && !pendingBets[slot.id]
? availableAmount < 10
? '베팅 한도 부족'
: `10~${availableAmount}금 입력`
: '')
}}
</small>
</form>
</template>
</TournamentBracket>
<div class="legacy-table-signature" hidden>
<table v-for="tableIndex in 6" :key="tableIndex">
@@ -351,16 +348,6 @@ const placeBet = async () => {
color: orange;
font-size: 14px;
}
.ratio-color {
color: skyblue;
}
.expected,
.return-color {
color: cyan;
}
.gold-color {
color: orange;
}
select {
width: 100%;
min-height: 27px;
@@ -391,84 +378,76 @@ select:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.bet-dialog {
width: min(420px, calc(100vw - 24px));
max-width: none;
padding: 0;
border: 1px solid #8d713d;
border-radius: 8px;
color: #fff;
background: #3a2118 var(--sammo-texture-walnut);
box-shadow: 0 18px 56px rgb(0 0 0 / 75%);
.bet-settings {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
padding: 8px;
text-align: left;
}
.bet-dialog::backdrop {
background: rgb(0 0 0 / 72%);
}
.bet-dialog-content {
display: grid;
gap: 14px;
padding: 16px;
}
.bet-dialog-content header,
.bet-dialog-content footer {
.bet-settings label {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.bet-dialog-content h2 {
margin: 0;
color: #ffd25e;
font-size: 20px;
.bet-settings input,
.bet-settings select {
width: 90px;
}
.bet-dialog-content header button {
width: 36px;
height: 36px;
padding: 0;
font-size: 22px;
}
.bet-dialog-content :deep(.general-identity) {
justify-content: flex-start;
text-align: left;
}
.bet-amount-field {
display: grid;
grid-template-columns: 88px minmax(0, 1fr);
align-items: center;
gap: 10px;
text-align: left;
}
.bet-return-preview {
display: grid;
grid-template-columns: auto auto auto auto minmax(0, 1fr);
align-items: center;
gap: 7px;
padding: 12px;
border: 1px solid #66563c;
background: rgb(0 0 0 / 28%);
font-variant-numeric: tabular-nums;
}
.bet-preview-note,
.bet-dialog-error {
margin: 0;
text-align: left;
font-size: 12px;
}
.bet-preview-note {
.bet-settings small {
flex-basis: 100%;
color: #c9c1b2;
}
.bet-dialog-error {
color: #ff8080;
.inline-bet {
display: grid;
grid-template-columns: minmax(0, 1fr) 80px 64px;
gap: 4px;
width: 100%;
}
.bet-dialog-content footer {
justify-content: flex-end;
.inline-bet input,
.inline-bet select,
.inline-bet button,
.bet-settings input,
.bet-settings select {
box-sizing: border-box;
min-width: 0;
height: 44px;
margin: 0;
padding: 4px;
border: 1px solid #8d713d;
border-radius: 3px;
color: #fff;
background: #201610;
font-size: 16px;
}
.bet-dialog-content footer button {
min-width: 80px;
}
.bet-dialog-content .bet-submit {
border-color: #9a7632;
.inline-bet .bracket-bet-button {
background: #59400e;
font-weight: 700;
}
.inline-bet input:focus-visible,
.bet-settings input:focus-visible {
outline: 2px solid #f39c12;
outline-offset: 1px;
}
.bet-message {
grid-column: 1 / -1;
min-height: 16px;
font-size: 11px;
line-height: 16px;
color: #b8e6ac;
overflow-wrap: anywhere;
}
.bet-message.bet-error {
color: #ff9e9e;
}
@media (max-width: 1100px) {
.inline-bet {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
}
.inline-bet .bracket-bet-button {
grid-column: 1 / -1;
}
}
.ranking-title {
min-height: 50px;
@@ -547,12 +526,6 @@ select:disabled {
.ranking-title {
font-size: 20px;
}
.bet-return-preview {
grid-template-columns: auto auto auto;
}
.bet-return-preview .return-color {
grid-column: 1 / -1;
}
.ranking-placeholder {
display: none;
}