From be8340a98575f445d5e5751d0b04d0bc7454a21a Mon Sep 17 00:00:00 2001 From: hided62 Date: Fri, 21 Aug 2026 15:54:26 +0000 Subject: [PATCH 1/3] =?UTF-8?q?fix:=20=ED=86=A0=EB=84=88=EB=A8=BC=ED=8A=B8?= =?UTF-8?q?=20=EB=8B=A8=EA=B3=84=EB=A5=BC=20=EC=88=9C=EC=B0=A8=20=EB=85=B8?= =?UTF-8?q?=EC=B6=9C=ED=95=98=EA=B3=A0=20=EC=B0=B8=EA=B0=80=20=EC=A1=B0?= =?UTF-8?q?=EB=A5=BC=20=ED=91=9C=EC=8B=9C=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 예선, 본선, 16강 영역을 실제 진행 단계부터 누적해서 보여준다. 참가 신청이 성공하면 배정된 예선 조를 선택하고 해당 표로 이동한다. --- .../e2e/tournamentBracket.spec.ts | 79 ++++- .../src/utils/tournamentStatus.ts | 15 + .../src/views/TournamentView.vue | 320 ++++++++++-------- .../test/tournamentStatus.test.ts | 30 +- 4 files changed, 292 insertions(+), 152 deletions(-) diff --git a/app/game-frontend/e2e/tournamentBracket.spec.ts b/app/game-frontend/e2e/tournamentBracket.spec.ts index 84782d31..b0db345f 100644 --- a/app/game-frontend/e2e/tournamentBracket.spec.ts +++ b/app/game-frontend/e2e/tournamentBracket.spec.ts @@ -126,7 +126,15 @@ const persistScreenshot = async (page: Page, name: string, fallbackPath: string) 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; + joinedGroupId?: number; + } = {} +) => { let joined = false; await page.addInitScript((profile) => { window.localStorage.setItem('sammo-game-token', 'ga_tournament_bracket_playwright'); @@ -148,9 +156,11 @@ const installFixture = async (page: Page, options: { applicationOpen?: boolean; if (operation === 'general.me') return response({ general: { id: 1, name: names[0] } }); if (operation === 'tournament.getAdminStatus') return response({ ok: false }); if (operation === 'tournament.getSnapshot') { + const tournamentStage = options.tournamentStage ?? (options.applicationOpen ? 1 : 0); + const joinedGroupId = options.joinedGroupId ?? 0; return response({ state: { - stage: options.applicationOpen ? 1 : 0, + stage: tournamentStage, phase: 0, type: options.tournamentType ?? 0, auto: false, @@ -158,7 +168,7 @@ const installFixture = async (page: Page, options: { applicationOpen?: boolean; openMonth: 1, termSeconds: 60, nextAt: '2026-08-02T00:00:00.000Z', - winnerId: 1, + winnerId: tournamentStage === 0 ? 1 : undefined, }, participants: options.applicationOpen && !joined @@ -167,8 +177,10 @@ const installFixture = async (page: Page, options: { applicationOpen?: boolean; ? [ { ...participants[0], - groupId: 0, + groupId: joinedGroupId, groupNo: 0, + preliminaryGroupId: joinedGroupId, + preliminaryGroupNo: 0, win: 0, draw: 0, lose: 0, @@ -322,20 +334,36 @@ test('desktop bracket connects every real general slot to the next round', async await persistScreenshot(page, 'tournament-desktop', testInfo.outputPath('tournament-bracket-desktop.webp')); }); -test('join refresh shows the assigned preliminary group immediately with accessible controls', async ({ page }) => { +test('join refresh shows the assigned preliminary group immediately with accessible controls', async ({ + page, +}, testInfo) => { await page.setViewportSize({ width: 390, height: 844 }); - await installFixture(page, { applicationOpen: true }); + await installFixture(page, { applicationOpen: true, joinedGroupId: 5 }); await page.goto('tournament'); const refresh = page.getByRole('button', { name: '갱신' }); const join = page.getByRole('button', { name: '참가' }); const close = page.getByRole('button', { name: '창 닫기' }).first(); await expect(join).toBeEnabled(); + await expect(page.getByText('조별 예선 순위')).toBeVisible(); + await expect(page.getByText('조별 본선 순위')).toHaveCount(0); + await expect(page.getByLabel('토너먼트 대진표')).toHaveCount(0); await join.click(); - await expect(page.getByRole('status')).toHaveText('참가 신청이 반영되었습니다.'); + await expect(page.getByRole('status')).toHaveText('참가 신청이 반영되었습니다. 六조에 배정되었습니다.'); await expect(join).toBeDisabled(); - await expect(page.locator('.preliminary-grid .general-identity', { hasText: names[0] })).toBeVisible(); + const preliminaryTabs = page.getByRole('tablist', { name: '예선 조 선택' }); + await expect(preliminaryTabs.getByRole('tab').nth(5)).toHaveAttribute('aria-selected', 'true'); + const assignedGroup = page.locator('[data-preliminary-group="5"]'); + await expect(assignedGroup.locator('.general-identity', { hasText: names[0] })).toBeVisible(); + const assignedGroupBounds = await assignedGroup.boundingBox(); + expect(assignedGroupBounds?.y).toBeLessThan(844); + expect((assignedGroupBounds?.y ?? 0) + (assignedGroupBounds?.height ?? 0)).toBeGreaterThan(0); + await persistScreenshot( + page, + 'tournament-joined-group-mobile', + testInfo.outputPath('tournament-joined-group.webp') + ); for (const control of [refresh, join, close]) { const box = await control.boundingBox(); @@ -349,6 +377,41 @@ test('join refresh shows the assigned preliminary group immediately with accessi expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390); }); +test('desktop join scrolls the assigned preliminary group into view without future sections', async ({ + page, +}, testInfo) => { + await page.setViewportSize({ width: 1365, height: 900 }); + await installFixture(page, { applicationOpen: true, joinedGroupId: 7 }); + await page.goto('tournament'); + + await expect(page.getByText('조별 본선 순위')).toHaveCount(0); + await expect(page.getByLabel('토너먼트 대진표')).toHaveCount(0); + await page.getByRole('button', { name: '참가' }).click(); + + await expect(page.getByRole('status')).toHaveText('참가 신청이 반영되었습니다. 八조에 배정되었습니다.'); + const assignedGroup = page.locator('[data-preliminary-group="7"]'); + await expect(assignedGroup.locator('.general-identity', { hasText: names[0] })).toBeVisible(); + const bounds = await assignedGroup.boundingBox(); + expect(bounds?.y).toBeLessThan(900); + expect((bounds?.y ?? 0) + (bounds?.height ?? 0)).toBeGreaterThan(0); + await persistScreenshot( + page, + 'tournament-joined-group-desktop', + testInfo.outputPath('tournament-joined-group.webp') + ); +}); + +test('final group section appears before the later knockout section', async ({ page }, testInfo) => { + await page.setViewportSize({ width: 390, height: 844 }); + await installFixture(page, { tournamentStage: 3 }); + await page.goto('tournament'); + + await expect(page.getByText('조별 예선 순위')).toBeVisible(); + await expect(page.getByText('조별 본선 순위')).toBeVisible(); + await expect(page.getByLabel('토너먼트 대진표')).toHaveCount(0); + await persistScreenshot(page, 'tournament-final-stage-mobile', testInfo.outputPath('tournament-final-stage.webp')); +}); + test('mobile bracket exposes every round through tabs with standard horizontal identities', async ({ page, }, testInfo) => { diff --git a/app/game-frontend/src/utils/tournamentStatus.ts b/app/game-frontend/src/utils/tournamentStatus.ts index 138501e7..529e8941 100644 --- a/app/game-frontend/src/utils/tournamentStatus.ts +++ b/app/game-frontend/src/utils/tournamentStatus.ts @@ -13,3 +13,18 @@ export const tournamentStageNames = [ ] as const; export const resolveTournamentStageName = (stage: number): string => tournamentStageNames[stage] ?? '상태 확인 중'; + +export interface TournamentSectionVisibility { + preliminary: boolean; + final: boolean; + knockout: boolean; +} + +export const resolveTournamentSectionVisibility = (stage: number, winnerId?: number): TournamentSectionVisibility => { + const completed = stage === 0 && winnerId !== undefined; + return { + preliminary: stage >= 1 || completed, + final: stage >= 3 || completed, + knockout: stage >= 5 || completed, + }; +}; diff --git a/app/game-frontend/src/views/TournamentView.vue b/app/game-frontend/src/views/TournamentView.vue index e21b9b16..c1ea2f71 100644 --- a/app/game-frontend/src/views/TournamentView.vue +++ b/app/game-frontend/src/views/TournamentView.vue @@ -1,11 +1,11 @@ - 명마{{ itemNames.horse ?? '-' }} - 무기{{ itemNames.weapon ?? '-' }} - 서적{{ itemNames.book ?? '-' }} + 명마 + + + {{ itemNames.horse ?? '-' }} + + + 무기 + + + {{ itemNames.weapon ?? '-' }} + + + 서적 + + + {{ itemNames.book ?? '-' }} + + { /> 자금{{ props.general.gold.toLocaleString('ko-KR') }} 군량{{ props.general.rice.toLocaleString('ko-KR') }} - 도구{{ itemNames.item ?? '-' }} + 도구 + + + {{ itemNames.item ?? '-' }} + + - 병종{{ props.general.crewTypeName ?? '-' }} + 병종 + + + {{ props.general.crewTypeName ?? '-' }} + + + 병사{{ props.general.crew.toLocaleString('ko-KR') }} - 성격{{ props.general.traits?.personal ?? '-' }} + 성격 + + + {{ props.general.traits?.personal ?? '-' }} + + 훈련{{ props.general.train }} 사기{{ props.general.atmos }} - 특기{{ specialText }} + 특기 + + + {{ props.general.traits?.specialDomestic ?? '-' }} + + / + + {{ props.general.traits?.specialWar ?? '-' }} + + Lv {{ props.general.progression?.experienceLevel ?? 0 }} diff --git a/app/game-frontend/src/components/main/NationBasicCard.vue b/app/game-frontend/src/components/main/NationBasicCard.vue index ba738b29..478df508 100644 --- a/app/game-frontend/src/components/main/NationBasicCard.vue +++ b/app/game-frontend/src/components/main/NationBasicCard.vue @@ -1,5 +1,6 @@ + + + + diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3d89f226..3400c45f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -207,6 +207,9 @@ importers: pinia: specifier: ^3.0.4 version: 3.0.4(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3)) + tippy.js: + specifier: 6.3.7 + version: 6.3.7 vue: specifier: ^3.5.26 version: 3.5.41(typescript@6.0.3) @@ -1347,6 +1350,9 @@ packages: '@pm2/pm2-version-check@1.0.4': resolution: {integrity: sha512-SXsM27SGH3yTWKc2fKR4SYNxsmnvuBQ9dd6QHtEWmiZ/VqaOYPAIlS8+vMcn27YLtAEBGvNRSh3TPNvtjZgfqA==} + '@popperjs/core@2.11.8': + resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==} + '@prisma/adapter-pg@7.9.1': resolution: {integrity: sha512-Ho2RK1KanQxLNSC0sR5bpiiVep10sWPLXCcxK+KXfI/Q69TMRbiafSvLPv3V9snimX72rMCqGlyJ4sBO4lKTAw==} @@ -4489,6 +4495,9 @@ packages: resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==} engines: {node: '>=14.0.0'} + tippy.js@6.3.7: + resolution: {integrity: sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==} + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -5496,6 +5505,8 @@ snapshots: transitivePeerDependencies: - supports-color + '@popperjs/core@2.11.8': {} + '@prisma/adapter-pg@7.9.1': dependencies: '@prisma/driver-adapter-utils': 7.9.1 @@ -8517,6 +8528,10 @@ snapshots: tinyrainbow@3.1.1: {} + tippy.js@6.3.7: + dependencies: + '@popperjs/core': 2.11.8 + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 From 75c5a933e09f5302975903e2faed46c38643aef6 Mon Sep 17 00:00:00 2001 From: hided62 Date: Fri, 21 Aug 2026 15:57:49 +0000 Subject: [PATCH 3/3] =?UTF-8?q?feat:=20=ED=86=A0=EB=84=88=EB=A8=BC?= =?UTF-8?q?=ED=8A=B8=20=EC=B9=B4=EB=93=9C=EC=97=90=EC=84=9C=20=EC=A7=81?= =?UTF-8?q?=EC=A0=91=20=EB=B2=A0=ED=8C=85=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../e2e/tournamentBracket.spec.ts | 97 +++++- .../tournament/TournamentBracket.vue | 68 +++- app/game-frontend/src/views/BettingView.vue | 307 +++++++++++------- 3 files changed, 333 insertions(+), 139 deletions(-) diff --git a/app/game-frontend/e2e/tournamentBracket.spec.ts b/app/game-frontend/e2e/tournamentBracket.spec.ts index 84782d31..4cab47b0 100644 --- a/app/game-frontend/e2e/tournamentBracket.spec.ts +++ b/app/game-frontend/e2e/tournamentBracket.spec.ts @@ -90,6 +90,20 @@ const matches = [ ]; const response = (data: unknown) => ({ result: { data } }); +const asRecord = (value: unknown): Record | null => + typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record) : 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 url = new URL(route.request().url()); 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 }); }; -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; + const placedBets: Array<{ targetId: number; amount: number }> = []; await page.addInitScript((profile) => { window.localStorage.setItem('sammo-game-token', 'ga_tournament_bracket_playwright'); window.localStorage.setItem('sammo-game-profile', profile); @@ -150,7 +168,7 @@ const installFixture = async (page: Page, options: { applicationOpen?: boolean; if (operation === 'tournament.getSnapshot') { return response({ state: { - stage: options.applicationOpen ? 1 : 0, + stage: options.tournamentStage ?? (options.applicationOpen ? 1 : 0), phase: 0, type: options.tournamentType ?? 0, auto: false, @@ -196,6 +214,12 @@ const installFixture = async (page: Page, options: { applicationOpen?: boolean; 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') { 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) }); }); + return { placedBets }; }; 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) => { await page.setViewportSize({ width: 390, height: 844 }); - await installFixture(page); + const { placedBets } = await installFixture(page, { tournamentStage: 6 }); 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-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('.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.locator('.ranking-table:visible')).toHaveCount(1); 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 }) => { await page.setViewportSize({ width: 390, height: 844 }); - await installFixture(page, { tournamentType: 3 }); + await installFixture(page, { tournamentType: 3, tournamentStage: 6 }); await page.goto('betting'); 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, }, testInfo) => { await page.setViewportSize({ width: 1365, height: 900 }); - await installFixture(page); + await installFixture(page, { tournamentStage: 6 }); 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('.general-identity-icon').first()).toHaveCSS('width', '64px'); await expect(page.locator('.general-identity-icon').first()).toHaveCSS('height', '64px'); - const columns = await page - .locator('.candidate-grid') - .evaluate((element) => getComputedStyle(element).gridTemplateColumns.split(' ').length); - expect(columns).toBe(4); + const firstCardCorner = await page + .locator('.desktop-bracket-name.betting-target[data-general-id="1"]') + .evaluate((card) => { + const own = card.getBoundingClientRect(); + const button = card.querySelector('.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); await persistScreenshot(page, 'tournament-ranking-desktop', testInfo.outputPath('tournament-ranking-desktop.webp')); }); diff --git a/app/game-frontend/src/components/tournament/TournamentBracket.vue b/app/game-frontend/src/components/tournament/TournamentBracket.vue index 6146404b..3d494fe3 100644 --- a/app/game-frontend/src/components/tournament/TournamentBracket.vue +++ b/app/game-frontend/src/components/tournament/TournamentBracket.vue @@ -6,6 +6,7 @@ import { resolveTournamentCoreStat, type TournamentBracketMatch, type TournamentBracketParticipant, + type TournamentBracketSlot, } from '../../utils/tournamentBracket'; const props = defineProps<{ @@ -17,6 +18,11 @@ const props = defineProps<{ totalBet: number; tournamentType?: number; showLegend?: boolean; + bettingOpen?: boolean; +}>(); + +const emit = defineEmits<{ + requestBet: [slot: TournamentBracketSlot]; }>(); 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 coreStat = (slot: (typeof bracket.value.top16.slots)[number]) => resolveTournamentCoreStat(slot, props.tournamentType ?? 0); +const requestBet = (slot: TournamentBracketSlot) => { + if (!props.bettingOpen || slot.id === null) return; + emit('requestBet', slot); +}; const mobilePairs = computed(() => { const column = roundColumns.value[activeMobileRound.value] ?? []; 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" :key="`${columnIndex}-${slot.id ?? 'empty'}-${slotIndex}`" 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" :style="{ left: `${(desktopX[columnIndex]! / 1200) * 100}%`, @@ -122,6 +135,15 @@ const mobilePairs = computed(() => { }" > +
{{ coreStat(slot)?.label }} {{ coreStat(slot)?.value }} @@ -154,10 +176,22 @@ const mobilePairs = computed(() => { v-for="(slot, slotIndex) in pair" :key="`${slot.id ?? 'empty'}-${slotIndex}`" 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" > +
{{ coreStat(slot)?.label }} {{ coreStat(slot)?.value }} @@ -228,6 +262,36 @@ const mobilePairs = computed(() => { color: #fff; 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, .mobile-bracket-name.advanced { border-color: #ff4b4b; diff --git a/app/game-frontend/src/views/BettingView.vue b/app/game-frontend/src/views/BettingView.vue index 96605bd4..0d9626ab 100644 --- a/app/game-frontend/src/views/BettingView.vue +++ b/app/game-frontend/src/views/BettingView.vue @@ -1,9 +1,10 @@ @@ -139,47 +155,57 @@ const placeBet = async (targetId: number) => { :total-bet="totalAmount" :tournament-type="snapshot?.state?.type ?? 0" :show-legend="false" + :betting-open="bettingOpen" + @request-bet="openBetDialog" /> -
-
-
- -
- {{ ratio(candidate.id) }} - - {{ amounts[candidate.id] ?? 10 }} - - {{ expected(candidate.id) }} -
-
- - -
-
-
-

- 배당률 × 베팅금 = - 적중시 환수금
- ( 베팅후 500원 이하일땐 베팅이 불가능합니다. ) -

-
+ +
+
+

베팅하기

+ +
+ + + + 배당 {{ selectedRatio }} + + 금{{ selectedAmount }} + + 예상 환수금 {{ selectedExpectedReturn.toLocaleString('ko-KR') }} + +

현재 배당 기준 예상값이며, 베팅 상황에 따라 최종 배당은 달라질 수 있습니다.

+ +
+ + +
+ +