feat: 토너먼트 카드에서 직접 베팅한다
This commit is contained in:
@@ -90,6 +90,20 @@ const matches = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const response = (data: unknown) => ({ result: { data } });
|
const response = (data: unknown) => ({ result: { data } });
|
||||||
|
const asRecord = (value: unknown): Record<string, unknown> | null =>
|
||||||
|
typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : null;
|
||||||
|
const findBetInput = (value: unknown): { targetId: number; amount: number } | null => {
|
||||||
|
const record = asRecord(value);
|
||||||
|
if (!record) return null;
|
||||||
|
if (typeof record.targetId === 'number' && typeof record.amount === 'number') {
|
||||||
|
return { targetId: record.targetId, amount: record.amount };
|
||||||
|
}
|
||||||
|
for (const child of Object.values(record)) {
|
||||||
|
const result = findBetInput(child);
|
||||||
|
if (result) return result;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
const operationNames = (route: Route): string[] => {
|
const operationNames = (route: Route): string[] => {
|
||||||
const url = new URL(route.request().url());
|
const url = new URL(route.request().url());
|
||||||
return decodeURIComponent(url.pathname.slice(url.pathname.lastIndexOf('/trpc/') + 6)).split(',');
|
return decodeURIComponent(url.pathname.slice(url.pathname.lastIndexOf('/trpc/') + 6)).split(',');
|
||||||
@@ -126,8 +140,12 @@ const persistScreenshot = async (page: Page, name: string, fallbackPath: string)
|
|||||||
await page.screenshot({ path: resolve(responsiveArtifactDir, `${name}.webp`), fullPage: true });
|
await page.screenshot({ path: resolve(responsiveArtifactDir, `${name}.webp`), fullPage: true });
|
||||||
};
|
};
|
||||||
|
|
||||||
const installFixture = async (page: Page, options: { applicationOpen?: boolean; tournamentType?: number } = {}) => {
|
const installFixture = async (
|
||||||
|
page: Page,
|
||||||
|
options: { applicationOpen?: boolean; tournamentType?: number; tournamentStage?: number } = {}
|
||||||
|
) => {
|
||||||
let joined = false;
|
let joined = false;
|
||||||
|
const placedBets: Array<{ targetId: number; amount: number }> = [];
|
||||||
await page.addInitScript((profile) => {
|
await page.addInitScript((profile) => {
|
||||||
window.localStorage.setItem('sammo-game-token', 'ga_tournament_bracket_playwright');
|
window.localStorage.setItem('sammo-game-token', 'ga_tournament_bracket_playwright');
|
||||||
window.localStorage.setItem('sammo-game-profile', profile);
|
window.localStorage.setItem('sammo-game-profile', profile);
|
||||||
@@ -150,7 +168,7 @@ const installFixture = async (page: Page, options: { applicationOpen?: boolean;
|
|||||||
if (operation === 'tournament.getSnapshot') {
|
if (operation === 'tournament.getSnapshot') {
|
||||||
return response({
|
return response({
|
||||||
state: {
|
state: {
|
||||||
stage: options.applicationOpen ? 1 : 0,
|
stage: options.tournamentStage ?? (options.applicationOpen ? 1 : 0),
|
||||||
phase: 0,
|
phase: 0,
|
||||||
type: options.tournamentType ?? 0,
|
type: options.tournamentType ?? 0,
|
||||||
auto: false,
|
auto: false,
|
||||||
@@ -196,6 +214,12 @@ const installFixture = async (page: Page, options: { applicationOpen?: boolean;
|
|||||||
myAmount: 160,
|
myAmount: 160,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (operation === 'tournament.placeBet') {
|
||||||
|
const input = findBetInput(route.request().postDataJSON());
|
||||||
|
if (!input) throw new Error('베팅 요청에서 targetId와 amount를 찾을 수 없습니다.');
|
||||||
|
placedBets.push(input);
|
||||||
|
return response({ ok: true });
|
||||||
|
}
|
||||||
if (operation === 'tournament.getRankings') {
|
if (operation === 'tournament.getRankings') {
|
||||||
return response(
|
return response(
|
||||||
[
|
[
|
||||||
@@ -229,6 +253,7 @@ const installFixture = async (page: Page, options: { applicationOpen?: boolean;
|
|||||||
});
|
});
|
||||||
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(results) });
|
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(results) });
|
||||||
});
|
});
|
||||||
|
return { placedBets };
|
||||||
};
|
};
|
||||||
|
|
||||||
const openTournament = async (page: Page) => {
|
const openTournament = async (page: Page) => {
|
||||||
@@ -480,12 +505,49 @@ test('tournament and betting pages expose same-row navigation tabs beside close'
|
|||||||
|
|
||||||
test('mobile betting rankings use tabs and keep dedicated icons beside general names', async ({ page }, testInfo) => {
|
test('mobile betting rankings use tabs and keep dedicated icons beside general names', async ({ page }, testInfo) => {
|
||||||
await page.setViewportSize({ width: 390, height: 844 });
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
await installFixture(page);
|
const { placedBets } = await installFixture(page, { tournamentStage: 6 });
|
||||||
await page.goto('betting');
|
await page.goto('betting');
|
||||||
|
|
||||||
await expect(page.locator('.candidate-card')).toHaveCount(16);
|
await expect(page.locator('.candidate-table')).toHaveCount(0);
|
||||||
|
const betButtons = page.locator('.mobile-bracket .bracket-bet-button:visible');
|
||||||
|
await expect(betButtons).toHaveCount(16);
|
||||||
await expect(page.locator('.betting-bracket .bracket-core-stat').first()).toHaveText('종합 240');
|
await expect(page.locator('.betting-bracket .bracket-core-stat').first()).toHaveText('종합 240');
|
||||||
await expect(page.locator('.betting-bracket .bracket-my-bet').first()).toHaveText('내 투자 금120');
|
await expect(page.locator('.betting-bracket .bracket-my-bet').first()).toHaveText('내 투자 금120');
|
||||||
|
|
||||||
|
const firstCard = page.locator('.mobile-bracket-name[data-general-id="1"]');
|
||||||
|
const firstBetButton = page.getByRole('button', { name: '관우에게 베팅하기' });
|
||||||
|
const corner = await firstCard.evaluate((card) => {
|
||||||
|
const own = card.getBoundingClientRect();
|
||||||
|
const button = card.querySelector<HTMLElement>('.bracket-bet-button')!.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
topOffset: button.top - own.top,
|
||||||
|
rightOffset: own.right - button.right,
|
||||||
|
contained: button.top >= own.top && button.right <= own.right && button.bottom <= own.bottom,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(corner.topOffset).toBeGreaterThanOrEqual(2);
|
||||||
|
expect(corner.topOffset).toBeLessThanOrEqual(4);
|
||||||
|
expect(corner.rightOffset).toBeGreaterThanOrEqual(2);
|
||||||
|
expect(corner.rightOffset).toBeLessThanOrEqual(4);
|
||||||
|
expect(corner.contained).toBe(true);
|
||||||
|
|
||||||
|
await firstBetButton.hover();
|
||||||
|
await expect(firstBetButton).toHaveCSS('filter', 'brightness(1.25)');
|
||||||
|
await firstBetButton.focus();
|
||||||
|
await expect(firstBetButton).toBeFocused();
|
||||||
|
await firstBetButton.click();
|
||||||
|
const dialog = page.getByRole('dialog', { name: '베팅하기' });
|
||||||
|
await expect(dialog).toBeVisible();
|
||||||
|
await expect(dialog.getByText('배당 28.00')).toBeVisible();
|
||||||
|
await expect(dialog.getByText('예상 환수금 280')).toBeVisible();
|
||||||
|
await dialog.getByLabel('베팅 금액').selectOption('50');
|
||||||
|
await expect(dialog.getByText('예상 환수금 1,400')).toBeVisible();
|
||||||
|
await persistScreenshot(page, 'tournament-betting-dialog-mobile', testInfo.outputPath('betting-dialog-mobile.webp'));
|
||||||
|
await dialog.getByRole('button', { name: '베팅 등록' }).click();
|
||||||
|
await expect(dialog).not.toBeVisible();
|
||||||
|
await expect(page.getByRole('status')).toHaveText('베팅이 등록되었습니다.');
|
||||||
|
expect(placedBets).toEqual([{ targetId: 1, amount: 50 }]);
|
||||||
|
|
||||||
await expect(page.getByRole('tablist', { name: '토너먼트 랭킹 종목 선택' })).toBeVisible();
|
await expect(page.getByRole('tablist', { name: '토너먼트 랭킹 종목 선택' })).toBeVisible();
|
||||||
await expect(page.locator('.ranking-table:visible')).toHaveCount(1);
|
await expect(page.locator('.ranking-table:visible')).toHaveCount(1);
|
||||||
await page.getByRole('tab', { name: '통솔전' }).click();
|
await page.getByRole('tab', { name: '통솔전' }).click();
|
||||||
@@ -509,7 +571,7 @@ test('mobile betting rankings use tabs and keep dedicated icons beside general n
|
|||||||
|
|
||||||
test('betting bracket shows intelligence for debate tournament candidates', async ({ page }) => {
|
test('betting bracket shows intelligence for debate tournament candidates', async ({ page }) => {
|
||||||
await page.setViewportSize({ width: 390, height: 844 });
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
await installFixture(page, { tournamentType: 3 });
|
await installFixture(page, { tournamentType: 3, tournamentStage: 6 });
|
||||||
await page.goto('betting');
|
await page.goto('betting');
|
||||||
|
|
||||||
await expect(page.locator('.betting-bracket .bracket-core-stat').first()).toHaveText('지력 80');
|
await expect(page.locator('.betting-bracket .bracket-core-stat').first()).toHaveText('지력 80');
|
||||||
@@ -522,17 +584,30 @@ test('desktop betting presents icon-and-name cards and all four rankings without
|
|||||||
page,
|
page,
|
||||||
}, testInfo) => {
|
}, testInfo) => {
|
||||||
await page.setViewportSize({ width: 1365, height: 900 });
|
await page.setViewportSize({ width: 1365, height: 900 });
|
||||||
await installFixture(page);
|
await installFixture(page, { tournamentStage: 6 });
|
||||||
await page.goto('betting');
|
await page.goto('betting');
|
||||||
|
|
||||||
await expect(page.locator('.candidate-card')).toHaveCount(16);
|
await expect(page.locator('.candidate-table')).toHaveCount(0);
|
||||||
|
await expect(page.locator('.desktop-bracket .bracket-bet-button:visible')).toHaveCount(16);
|
||||||
await expect(page.locator('.ranking-table:visible')).toHaveCount(4);
|
await expect(page.locator('.ranking-table:visible')).toHaveCount(4);
|
||||||
await expect(page.locator('.general-identity-icon').first()).toHaveCSS('width', '64px');
|
await expect(page.locator('.general-identity-icon').first()).toHaveCSS('width', '64px');
|
||||||
await expect(page.locator('.general-identity-icon').first()).toHaveCSS('height', '64px');
|
await expect(page.locator('.general-identity-icon').first()).toHaveCSS('height', '64px');
|
||||||
const columns = await page
|
const firstCardCorner = await page
|
||||||
.locator('.candidate-grid')
|
.locator('.desktop-bracket-name.betting-target[data-general-id="1"]')
|
||||||
.evaluate((element) => getComputedStyle(element).gridTemplateColumns.split(' ').length);
|
.evaluate((card) => {
|
||||||
expect(columns).toBe(4);
|
const own = card.getBoundingClientRect();
|
||||||
|
const button = card.querySelector<HTMLElement>('.bracket-bet-button')!.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
topOffset: button.top - own.top,
|
||||||
|
rightOffset: own.right - button.right,
|
||||||
|
contained: button.top >= own.top && button.right <= own.right && button.bottom <= own.bottom,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(firstCardCorner.topOffset).toBeGreaterThanOrEqual(2);
|
||||||
|
expect(firstCardCorner.topOffset).toBeLessThanOrEqual(4);
|
||||||
|
expect(firstCardCorner.rightOffset).toBeGreaterThanOrEqual(2);
|
||||||
|
expect(firstCardCorner.rightOffset).toBeLessThanOrEqual(4);
|
||||||
|
expect(firstCardCorner.contained).toBe(true);
|
||||||
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(1365);
|
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(1365);
|
||||||
await persistScreenshot(page, 'tournament-ranking-desktop', testInfo.outputPath('tournament-ranking-desktop.webp'));
|
await persistScreenshot(page, 'tournament-ranking-desktop', testInfo.outputPath('tournament-ranking-desktop.webp'));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
resolveTournamentCoreStat,
|
resolveTournamentCoreStat,
|
||||||
type TournamentBracketMatch,
|
type TournamentBracketMatch,
|
||||||
type TournamentBracketParticipant,
|
type TournamentBracketParticipant,
|
||||||
|
type TournamentBracketSlot,
|
||||||
} from '../../utils/tournamentBracket';
|
} from '../../utils/tournamentBracket';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -17,6 +18,11 @@ const props = defineProps<{
|
|||||||
totalBet: number;
|
totalBet: number;
|
||||||
tournamentType?: number;
|
tournamentType?: number;
|
||||||
showLegend?: boolean;
|
showLegend?: boolean;
|
||||||
|
bettingOpen?: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
requestBet: [slot: TournamentBracketSlot];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const bracket = computed(() => buildTournamentBracket(props.participants, props.matches, props.winnerId));
|
const bracket = computed(() => buildTournamentBracket(props.participants, props.matches, props.winnerId));
|
||||||
@@ -72,6 +78,10 @@ const odds = (id: number | null) => {
|
|||||||
const myBet = (id: number | null) => (id === null ? 0 : (props.myBetTotals?.[id] ?? 0));
|
const myBet = (id: number | null) => (id === null ? 0 : (props.myBetTotals?.[id] ?? 0));
|
||||||
const coreStat = (slot: (typeof bracket.value.top16.slots)[number]) =>
|
const coreStat = (slot: (typeof bracket.value.top16.slots)[number]) =>
|
||||||
resolveTournamentCoreStat(slot, props.tournamentType ?? 0);
|
resolveTournamentCoreStat(slot, props.tournamentType ?? 0);
|
||||||
|
const requestBet = (slot: TournamentBracketSlot) => {
|
||||||
|
if (!props.bettingOpen || slot.id === null) return;
|
||||||
|
emit('requestBet', slot);
|
||||||
|
};
|
||||||
const mobilePairs = computed(() => {
|
const mobilePairs = computed(() => {
|
||||||
const column = roundColumns.value[activeMobileRound.value] ?? [];
|
const column = roundColumns.value[activeMobileRound.value] ?? [];
|
||||||
if (activeMobileRound.value === roundColumns.value.length - 1) return column.map((slot) => [slot]);
|
if (activeMobileRound.value === roundColumns.value.length - 1) return column.map((slot) => [slot]);
|
||||||
@@ -114,7 +124,10 @@ const mobilePairs = computed(() => {
|
|||||||
v-for="(slot, slotIndex) in column"
|
v-for="(slot, slotIndex) in column"
|
||||||
:key="`${columnIndex}-${slot.id ?? 'empty'}-${slotIndex}`"
|
:key="`${columnIndex}-${slot.id ?? 'empty'}-${slotIndex}`"
|
||||||
class="desktop-bracket-name"
|
class="desktop-bracket-name"
|
||||||
:class="{ advanced: slot.advanced }"
|
:class="{
|
||||||
|
advanced: slot.advanced,
|
||||||
|
'betting-target': columnIndex === 0 && bettingOpen && slot.id !== null,
|
||||||
|
}"
|
||||||
:data-general-id="slot.id ?? undefined"
|
:data-general-id="slot.id ?? undefined"
|
||||||
:style="{
|
:style="{
|
||||||
left: `${(desktopX[columnIndex]! / 1200) * 100}%`,
|
left: `${(desktopX[columnIndex]! / 1200) * 100}%`,
|
||||||
@@ -122,6 +135,15 @@ const mobilePairs = computed(() => {
|
|||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<GeneralIdentity :name="slot.name" :picture="slot.picture" :image-server="slot.imageServer" />
|
<GeneralIdentity :name="slot.name" :picture="slot.picture" :image-server="slot.imageServer" />
|
||||||
|
<button
|
||||||
|
v-if="columnIndex === 0 && bettingOpen && slot.id !== null"
|
||||||
|
type="button"
|
||||||
|
class="bracket-bet-button"
|
||||||
|
:aria-label="`${slot.name}에게 베팅하기`"
|
||||||
|
@click="requestBet(slot)"
|
||||||
|
>
|
||||||
|
베팅하기
|
||||||
|
</button>
|
||||||
<div v-if="columnIndex === 0" class="bracket-bet-summary">
|
<div v-if="columnIndex === 0" class="bracket-bet-summary">
|
||||||
<small v-if="coreStat(slot)" class="bracket-core-stat">
|
<small v-if="coreStat(slot)" class="bracket-core-stat">
|
||||||
{{ coreStat(slot)?.label }} {{ coreStat(slot)?.value }}
|
{{ coreStat(slot)?.label }} {{ coreStat(slot)?.value }}
|
||||||
@@ -154,10 +176,22 @@ const mobilePairs = computed(() => {
|
|||||||
v-for="(slot, slotIndex) in pair"
|
v-for="(slot, slotIndex) in pair"
|
||||||
:key="`${slot.id ?? 'empty'}-${slotIndex}`"
|
:key="`${slot.id ?? 'empty'}-${slotIndex}`"
|
||||||
class="mobile-bracket-name"
|
class="mobile-bracket-name"
|
||||||
:class="{ advanced: slot.advanced }"
|
:class="{
|
||||||
|
advanced: slot.advanced,
|
||||||
|
'betting-target': activeMobileRound === 0 && bettingOpen && slot.id !== null,
|
||||||
|
}"
|
||||||
:data-general-id="slot.id ?? undefined"
|
:data-general-id="slot.id ?? undefined"
|
||||||
>
|
>
|
||||||
<GeneralIdentity :name="slot.name" :picture="slot.picture" :image-server="slot.imageServer" />
|
<GeneralIdentity :name="slot.name" :picture="slot.picture" :image-server="slot.imageServer" />
|
||||||
|
<button
|
||||||
|
v-if="activeMobileRound === 0 && bettingOpen && slot.id !== null"
|
||||||
|
type="button"
|
||||||
|
class="bracket-bet-button"
|
||||||
|
:aria-label="`${slot.name}에게 베팅하기`"
|
||||||
|
@click="requestBet(slot)"
|
||||||
|
>
|
||||||
|
베팅하기
|
||||||
|
</button>
|
||||||
<div v-if="activeMobileRound === 0" class="bracket-bet-summary">
|
<div v-if="activeMobileRound === 0" class="bracket-bet-summary">
|
||||||
<small v-if="coreStat(slot)" class="bracket-core-stat">
|
<small v-if="coreStat(slot)" class="bracket-core-stat">
|
||||||
{{ coreStat(slot)?.label }} {{ coreStat(slot)?.value }}
|
{{ coreStat(slot)?.label }} {{ coreStat(slot)?.value }}
|
||||||
@@ -228,6 +262,36 @@ const mobilePairs = computed(() => {
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
padding: 1px 3px;
|
padding: 1px 3px;
|
||||||
}
|
}
|
||||||
|
.betting-target {
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
.mobile-bracket-name.betting-target {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.bracket-bet-button {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 2;
|
||||||
|
top: 3px;
|
||||||
|
right: 3px;
|
||||||
|
min-width: 58px;
|
||||||
|
height: 24px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 2px 5px;
|
||||||
|
border: 1px solid #9a7632;
|
||||||
|
border-radius: 3px;
|
||||||
|
color: #fff3cd;
|
||||||
|
background: #59400e;
|
||||||
|
font: 700 11px/1 var(--sammo-font-sans);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.bracket-bet-button:hover,
|
||||||
|
.bracket-bet-button:focus {
|
||||||
|
filter: brightness(1.25);
|
||||||
|
}
|
||||||
|
.bracket-bet-button:focus-visible {
|
||||||
|
outline: 2px solid #f39c12;
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
.desktop-bracket-name.advanced,
|
.desktop-bracket-name.advanced,
|
||||||
.mobile-bracket-name.advanced {
|
.mobile-bracket-name.advanced {
|
||||||
border-color: #ff4b4b;
|
border-color: #ff4b4b;
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime';
|
import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime';
|
||||||
import { computed, onMounted, ref } from 'vue';
|
import { computed, nextTick, onMounted, ref } from 'vue';
|
||||||
import TournamentBracket from '../components/tournament/TournamentBracket.vue';
|
import TournamentBracket from '../components/tournament/TournamentBracket.vue';
|
||||||
import TournamentPageHeader from '../components/tournament/TournamentPageHeader.vue';
|
import TournamentPageHeader from '../components/tournament/TournamentPageHeader.vue';
|
||||||
import GeneralIdentity from '../components/ui/GeneralIdentity.vue';
|
import GeneralIdentity from '../components/ui/GeneralIdentity.vue';
|
||||||
|
import type { TournamentBracketSlot } from '../utils/tournamentBracket';
|
||||||
import { trpc } from '../utils/trpc';
|
import { trpc } from '../utils/trpc';
|
||||||
|
|
||||||
type Snapshot = Awaited<ReturnType<typeof trpc.tournament.getSnapshot.query>>;
|
type Snapshot = Awaited<ReturnType<typeof trpc.tournament.getSnapshot.query>>;
|
||||||
@@ -15,6 +16,11 @@ const loading = ref(false);
|
|||||||
const error = ref<string | null>(null);
|
const error = ref<string | null>(null);
|
||||||
const message = ref<string | null>(null);
|
const message = ref<string | null>(null);
|
||||||
const amounts = ref<Record<number, number>>({});
|
const amounts = ref<Record<number, number>>({});
|
||||||
|
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 activeRankingPrefix = ref('tt');
|
const activeRankingPrefix = ref('tt');
|
||||||
const typeNames = ['전력전', '통솔전', '일기토', '설전'];
|
const typeNames = ['전력전', '통솔전', '일기토', '설전'];
|
||||||
const stageNames = [
|
const stageNames = [
|
||||||
@@ -49,27 +55,6 @@ const load = async () => {
|
|||||||
};
|
};
|
||||||
onMounted(() => void load());
|
onMounted(() => void load());
|
||||||
|
|
||||||
const participantMap = computed(
|
|
||||||
() => new Map((snapshot.value?.participants ?? []).map((participant) => [participant.id, participant]))
|
|
||||||
);
|
|
||||||
const final16Ids = computed(() =>
|
|
||||||
(snapshot.value?.matches ?? [])
|
|
||||||
.filter((match) => match.stage === 7)
|
|
||||||
.sort((a, b) => a.roundIndex - b.roundIndex)
|
|
||||||
.flatMap((match) => [match.attackerId, match.defenderId])
|
|
||||||
);
|
|
||||||
const candidates = computed(() =>
|
|
||||||
Array.from({ length: 16 }, (_, index) => {
|
|
||||||
const id = final16Ids.value[index] ?? 0;
|
|
||||||
const participant = id ? participantMap.value.get(id) : null;
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
name: id ? (participant?.name ?? `#${id}`) : '-',
|
|
||||||
picture: participant?.picture ?? null,
|
|
||||||
imageServer: participant?.imageServer ?? 0,
|
|
||||||
};
|
|
||||||
})
|
|
||||||
);
|
|
||||||
const totalAmount = computed(() => summary.value?.totalAmount ?? 0);
|
const totalAmount = computed(() => summary.value?.totalAmount ?? 0);
|
||||||
const myAmount = computed(() => summary.value?.myAmount ?? 0);
|
const myAmount = computed(() => summary.value?.myAmount ?? 0);
|
||||||
const betTotals = computed(() => summary.value?.totals as Record<number, number> | undefined);
|
const betTotals = computed(() => summary.value?.totals as Record<number, number> | undefined);
|
||||||
@@ -82,12 +67,25 @@ const ratio = (id: number) => {
|
|||||||
const openingTime = computed(() =>
|
const openingTime = computed(() =>
|
||||||
formatServerDateTime(snapshot.value?.state?.nextAt, { format: 'hourMinute', fallback: '--:--' })
|
formatServerDateTime(snapshot.value?.state?.nextAt, { format: 'hourMinute', fallback: '--:--' })
|
||||||
);
|
);
|
||||||
const expected = (id: number) => {
|
const selectedAmount = computed({
|
||||||
const myTotals = summary.value?.myTotals as Record<number, number> | undefined;
|
get: () => {
|
||||||
const current = myTotals?.[id] ?? 0;
|
const targetId = selectedTarget.value?.id;
|
||||||
const numericRatio = Number(ratio(id));
|
return targetId === null || targetId === undefined ? 10 : (amounts.value[targetId] ?? 10);
|
||||||
return Number.isFinite(numericRatio) ? Math.floor(current * numericRatio) : 0;
|
},
|
||||||
};
|
set: (amount: number) => {
|
||||||
|
const targetId = selectedTarget.value?.id;
|
||||||
|
if (targetId === null || targetId === undefined) return;
|
||||||
|
amounts.value[targetId] = amount;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
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 bettingOpen = computed(() => {
|
||||||
const state = snapshot.value?.state;
|
const state = snapshot.value?.state;
|
||||||
if (!state || state.stage !== 6) return false;
|
if (!state || state.stage !== 6) return false;
|
||||||
@@ -95,17 +93,35 @@ const bettingOpen = computed(() => {
|
|||||||
return new Date(state.bettingCloseAt).getTime() > Date.now();
|
return new Date(state.bettingCloseAt).getTime() > Date.now();
|
||||||
});
|
});
|
||||||
|
|
||||||
const placeBet = async (targetId: number) => {
|
const openBetDialog = async (target: TournamentBracketSlot) => {
|
||||||
if (!targetId) return;
|
if (target.id === null || !bettingOpen.value) return;
|
||||||
const amount = amounts.value[targetId] ?? 10;
|
selectedTarget.value = target;
|
||||||
|
betError.value = null;
|
||||||
|
if (amounts.value[target.id] === undefined) amounts.value[target.id] = 10;
|
||||||
|
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;
|
||||||
message.value = null;
|
message.value = null;
|
||||||
|
betError.value = null;
|
||||||
|
placingBet.value = true;
|
||||||
try {
|
try {
|
||||||
await trpc.tournament.placeBet.mutate({ targetId, amount });
|
await trpc.tournament.placeBet.mutate({ targetId, amount });
|
||||||
message.value = '베팅이 등록되었습니다.';
|
message.value = '베팅이 등록되었습니다.';
|
||||||
} catch (value) {
|
|
||||||
message.value = errorText(value);
|
|
||||||
} finally {
|
|
||||||
await load();
|
await load();
|
||||||
|
closeBetDialog();
|
||||||
|
} catch (value) {
|
||||||
|
betError.value = errorText(value);
|
||||||
|
message.value = betError.value;
|
||||||
|
} finally {
|
||||||
|
placingBet.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
@@ -139,47 +155,57 @@ const placeBet = async (targetId: number) => {
|
|||||||
:total-bet="totalAmount"
|
:total-bet="totalAmount"
|
||||||
:tournament-type="snapshot?.state?.type ?? 0"
|
:tournament-type="snapshot?.state?.type ?? 0"
|
||||||
:show-legend="false"
|
:show-legend="false"
|
||||||
|
:betting-open="bettingOpen"
|
||||||
|
@request-bet="openBetDialog"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<section class="candidate-table bg0">
|
<dialog
|
||||||
<div class="candidate-grid">
|
ref="betDialog"
|
||||||
<article v-for="candidate in candidates" :key="candidate.id || candidate.name" class="candidate-card">
|
class="bet-dialog"
|
||||||
<GeneralIdentity
|
aria-labelledby="bet-dialog-title"
|
||||||
:name="candidate.name"
|
@close="selectedTarget = null"
|
||||||
:picture="candidate.picture"
|
|
||||||
:image-server="candidate.imageServer"
|
|
||||||
/>
|
|
||||||
<div class="candidate-return">
|
|
||||||
<span class="ratio-color">{{ ratio(candidate.id) }}</span>
|
|
||||||
<span aria-hidden="true">×</span>
|
|
||||||
<span class="gold-color">{{ amounts[candidate.id] ?? 10 }}</span>
|
|
||||||
<span aria-hidden="true">=</span>
|
|
||||||
<strong class="return-color">{{ expected(candidate.id) }}</strong>
|
|
||||||
</div>
|
|
||||||
<div v-if="bettingOpen" class="candidate-actions">
|
|
||||||
<select
|
|
||||||
v-model.number="amounts[candidate.id]"
|
|
||||||
:aria-label="`${candidate.name} 베팅 금액`"
|
|
||||||
:disabled="!candidate.id"
|
|
||||||
>
|
>
|
||||||
|
<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"
|
||||||
|
/>
|
||||||
|
<label class="bet-amount-field">
|
||||||
|
<span>베팅 금액</span>
|
||||||
|
<select ref="betAmountSelect" v-model.number="selectedAmount" :disabled="placingBet">
|
||||||
<option :value="10">금10</option>
|
<option :value="10">금10</option>
|
||||||
<option :value="20">금20</option>
|
<option :value="20">금20</option>
|
||||||
<option :value="50">금50</option>
|
<option :value="50">금50</option>
|
||||||
<option :value="100">금100</option>
|
<option :value="100">금100</option>
|
||||||
<option :value="200">금200</option>
|
<option :value="200">금200</option>
|
||||||
<option :value="500">금500</option>
|
<option :value="500">금500</option>
|
||||||
<option :value="1000">최대</option>
|
<option :value="1000">최대 금1000</option>
|
||||||
</select>
|
</select>
|
||||||
<button type="button" :disabled="!candidate.id" @click="placeBet(candidate.id)">베팅</button>
|
</label>
|
||||||
</div>
|
<output class="bet-return-preview" aria-live="polite">
|
||||||
</article>
|
<span class="ratio-color">배당 {{ selectedRatio }}</span>
|
||||||
</div>
|
<span aria-hidden="true">×</span>
|
||||||
<p class="candidate-help">
|
<span class="gold-color">금{{ selectedAmount }}</span>
|
||||||
<span class="ratio-color">배당률</span> × <span class="gold-color">베팅금</span> =
|
<span aria-hidden="true">=</span>
|
||||||
<span class="return-color">적중시 환수금</span><br />
|
<strong class="return-color">예상 환수금 {{ selectedExpectedReturn.toLocaleString('ko-KR') }}</strong>
|
||||||
<span class="ratio-color">( 베팅후 500원 이하일땐 베팅이 불가능합니다. )</span>
|
</output>
|
||||||
</p>
|
<p class="bet-preview-note">현재 배당 기준 예상값이며, 베팅 상황에 따라 최종 배당은 달라질 수 있습니다.</p>
|
||||||
</section>
|
<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 ? '등록 중...' : '베팅 등록' }}
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
<div class="legacy-table-signature" hidden>
|
<div class="legacy-table-signature" hidden>
|
||||||
<table v-for="tableIndex in 6" :key="tableIndex">
|
<table v-for="tableIndex in 6" :key="tableIndex">
|
||||||
@@ -336,36 +362,6 @@ const placeBet = async (targetId: number) => {
|
|||||||
color: orange;
|
color: orange;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
.candidate-table {
|
|
||||||
border: 1px solid gray;
|
|
||||||
padding: 10px;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
.candidate-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
.candidate-card {
|
|
||||||
min-width: 0;
|
|
||||||
padding: 8px;
|
|
||||||
border: 1px solid #5b504b;
|
|
||||||
background: rgb(0 0 0 / 26%);
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
.candidate-return {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr auto 1fr auto 1fr;
|
|
||||||
gap: 4px;
|
|
||||||
margin: 8px 0;
|
|
||||||
text-align: center;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
.candidate-actions {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(0, 1fr) 64px;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
.ratio-color {
|
.ratio-color {
|
||||||
color: skyblue;
|
color: skyblue;
|
||||||
}
|
}
|
||||||
@@ -376,8 +372,7 @@ const placeBet = async (targetId: number) => {
|
|||||||
.gold-color {
|
.gold-color {
|
||||||
color: orange;
|
color: orange;
|
||||||
}
|
}
|
||||||
select,
|
select {
|
||||||
.candidate-actions button {
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 27px;
|
min-height: 27px;
|
||||||
padding: 2px 1px;
|
padding: 2px 1px;
|
||||||
@@ -407,11 +402,84 @@ select:disabled {
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
.candidate-help {
|
.bet-dialog {
|
||||||
min-height: 20px;
|
width: min(420px, calc(100vw - 24px));
|
||||||
margin: 8px 0 0;
|
max-width: none;
|
||||||
font-size: 18px;
|
padding: 0;
|
||||||
line-height: 14px;
|
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-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 {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.bet-dialog-content h2 {
|
||||||
|
margin: 0;
|
||||||
|
color: #ffd25e;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
.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 {
|
||||||
|
color: #c9c1b2;
|
||||||
|
}
|
||||||
|
.bet-dialog-error {
|
||||||
|
color: #ff8080;
|
||||||
|
}
|
||||||
|
.bet-dialog-content footer {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.bet-dialog-content footer button {
|
||||||
|
min-width: 80px;
|
||||||
|
}
|
||||||
|
.bet-dialog-content .bet-submit {
|
||||||
|
border-color: #9a7632;
|
||||||
|
background: #59400e;
|
||||||
}
|
}
|
||||||
.ranking-title {
|
.ranking-title {
|
||||||
min-height: 50px;
|
min-height: 50px;
|
||||||
@@ -490,25 +558,12 @@ select:disabled {
|
|||||||
.ranking-title {
|
.ranking-title {
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
}
|
}
|
||||||
.candidate-grid {
|
.bet-return-preview {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: auto auto auto;
|
||||||
}
|
}
|
||||||
.candidate-card {
|
.bet-return-preview .return-color {
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(0, 1fr) 112px;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px 12px;
|
|
||||||
}
|
|
||||||
.candidate-return {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
.candidate-actions {
|
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
.candidate-help {
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 18px;
|
|
||||||
}
|
|
||||||
.ranking-placeholder {
|
.ranking-placeholder {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user