- PlayerSearch: typeahead with 200ms debounce, 48px touch targets, recently active when empty - BuyInFlow: 3-step wizard (search -> auto-seat preview -> confirm) with mini table diagram - BustOutFlow: minimal-tap flow (table grid -> seat tap -> verify -> hitman select) - PlayerDetail: full per-player tracking (status, chips, financials, history, undo buttons) - RebuyFlow: quick 2-tap flow with pre-selected player support - AddOnFlow: quick flow with mass add-on all option - Players page: Active/Busted/All tabs, DataTable with search, swipe actions, overlay flows - Layout: FAB actions wired to actual flows, clock pause/resume via API Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
643 lines
15 KiB
Svelte
643 lines
15 KiB
Svelte
<script lang="ts">
|
|
import { tournament } from '$lib/stores/tournament.svelte';
|
|
import type { Player, Table } from '$lib/stores/tournament.svelte';
|
|
import { toast } from '$lib/stores/toast.svelte';
|
|
import { api } from '$lib/api';
|
|
import PlayerSearch from './PlayerSearch.svelte';
|
|
|
|
/**
|
|
* Buy-In Flow — step-by-step wizard.
|
|
*
|
|
* Steps:
|
|
* 1. Search/Select Player
|
|
* 2. Auto-Seat Preview (system suggests table + seat, TD can override)
|
|
* 3. Confirm (summary card with big confirm button)
|
|
* 4. Receipt (toast + done)
|
|
*
|
|
* Triggered from: FAB "Buy In" action, Players tab "+" button.
|
|
* PLYR-04
|
|
*/
|
|
|
|
interface SeatSuggestion {
|
|
table_id: string;
|
|
table_number: number;
|
|
seat: number;
|
|
}
|
|
|
|
interface Props {
|
|
/** Close callback. */
|
|
onclose: () => void;
|
|
}
|
|
|
|
let { onclose }: Props = $props();
|
|
|
|
// Flow state
|
|
type Step = 'search' | 'seat' | 'confirm';
|
|
let step = $state<Step>('search');
|
|
let selectedPlayer = $state<Player | null>(null);
|
|
let suggestedSeat = $state<SeatSuggestion | null>(null);
|
|
let selectedSeat = $state<SeatSuggestion | null>(null);
|
|
let overrideMode = $state(false);
|
|
let submitting = $state(false);
|
|
|
|
// Buy-in amount from tournament financials
|
|
let buyinAmount = $derived(tournament.financials?.total_buyin ? 100 : 100);
|
|
|
|
// Available seats for override mode
|
|
let availableSeats = $derived.by(() => {
|
|
const seats: SeatSuggestion[] = [];
|
|
for (const table of tournament.tables) {
|
|
if (table.is_break_table) continue;
|
|
for (let s = 1; s <= table.seats; s++) {
|
|
const occupied = tournament.players.some(
|
|
(p) => p.table_id === table.id && p.seat === s && p.status === 'active'
|
|
);
|
|
if (!occupied) {
|
|
seats.push({
|
|
table_id: table.id,
|
|
table_number: table.number,
|
|
seat: s
|
|
});
|
|
}
|
|
}
|
|
}
|
|
return seats;
|
|
});
|
|
|
|
function handlePlayerSelect(player: Player): void {
|
|
selectedPlayer = player;
|
|
// Generate auto-seat suggestion: pick table with fewest players
|
|
const bestTable = findBestTable();
|
|
if (bestTable) {
|
|
suggestedSeat = bestTable;
|
|
selectedSeat = bestTable;
|
|
}
|
|
step = 'seat';
|
|
}
|
|
|
|
function findBestTable(): SeatSuggestion | null {
|
|
let bestTable: Table | null = null;
|
|
let minPlayers = Infinity;
|
|
|
|
for (const table of tournament.tables) {
|
|
if (table.is_break_table) continue;
|
|
const playerCount = table.players.length;
|
|
if (playerCount < table.seats && playerCount < minPlayers) {
|
|
minPlayers = playerCount;
|
|
bestTable = table;
|
|
}
|
|
}
|
|
|
|
if (!bestTable) return null;
|
|
|
|
// Find first empty seat
|
|
for (let s = 1; s <= bestTable.seats; s++) {
|
|
const occupied = tournament.players.some(
|
|
(p) => p.table_id === bestTable!.id && p.seat === s && p.status === 'active'
|
|
);
|
|
if (!occupied) {
|
|
return {
|
|
table_id: bestTable.id,
|
|
table_number: bestTable.number,
|
|
seat: s
|
|
};
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function acceptSuggestion(): void {
|
|
step = 'confirm';
|
|
}
|
|
|
|
function toggleOverride(): void {
|
|
overrideMode = !overrideMode;
|
|
}
|
|
|
|
function selectOverrideSeat(seat: SeatSuggestion): void {
|
|
selectedSeat = seat;
|
|
overrideMode = false;
|
|
step = 'confirm';
|
|
}
|
|
|
|
async function confirmBuyIn(): Promise<void> {
|
|
if (!selectedPlayer || !selectedSeat) return;
|
|
submitting = true;
|
|
|
|
try {
|
|
await api.post(`/tournaments/${tournament.id}/buyin`, {
|
|
player_id: selectedPlayer.id,
|
|
table_id: selectedSeat.table_id,
|
|
seat: selectedSeat.seat
|
|
});
|
|
|
|
toast.success(
|
|
`${selectedPlayer.name} bought in — Table ${selectedSeat.table_number}, Seat ${selectedSeat.seat}`
|
|
);
|
|
onclose();
|
|
} catch (err) {
|
|
toast.error(`Buy-in failed: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
|
} finally {
|
|
submitting = false;
|
|
}
|
|
}
|
|
|
|
function goBack(): void {
|
|
if (step === 'confirm') {
|
|
step = 'seat';
|
|
} else if (step === 'seat') {
|
|
step = 'search';
|
|
selectedPlayer = null;
|
|
suggestedSeat = null;
|
|
selectedSeat = null;
|
|
overrideMode = false;
|
|
} else {
|
|
onclose();
|
|
}
|
|
}
|
|
|
|
/** Get table players for mini diagram. */
|
|
function getTablePlayers(tableId: string, totalSeats: number): { seat: number; name: string | null }[] {
|
|
const seats: { seat: number; name: string | null }[] = [];
|
|
for (let s = 1; s <= totalSeats; s++) {
|
|
const player = tournament.players.find(
|
|
(p) => p.table_id === tableId && p.seat === s && p.status === 'active'
|
|
);
|
|
seats.push({ seat: s, name: player?.name ?? null });
|
|
}
|
|
return seats;
|
|
}
|
|
</script>
|
|
|
|
<div class="buyin-flow">
|
|
<!-- Header -->
|
|
<div class="flow-header">
|
|
<button class="back-btn touch-target" onclick={goBack} aria-label="Go back">
|
|
<span aria-hidden="true">←</span>
|
|
</button>
|
|
<h2 class="flow-title">Buy In</h2>
|
|
<button class="close-btn touch-target" onclick={onclose} aria-label="Close">
|
|
×
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Step indicator -->
|
|
<div class="step-indicator" role="progressbar" aria-valuenow={step === 'search' ? 1 : step === 'seat' ? 2 : 3} aria-valuemin={1} aria-valuemax={3}>
|
|
<div class="step" class:active={step === 'search'} class:done={step !== 'search'}>1</div>
|
|
<div class="step-line" class:done={step !== 'search'}></div>
|
|
<div class="step" class:active={step === 'seat'} class:done={step === 'confirm'}>2</div>
|
|
<div class="step-line" class:done={step === 'confirm'}></div>
|
|
<div class="step" class:active={step === 'confirm'}>3</div>
|
|
</div>
|
|
|
|
<!-- Step content -->
|
|
<div class="flow-content">
|
|
{#if step === 'search'}
|
|
<!-- Step 1: Search/Select Player -->
|
|
<div class="step-content">
|
|
<h3 class="step-title">Select Player</h3>
|
|
<PlayerSearch
|
|
players={tournament.players}
|
|
onselect={handlePlayerSelect}
|
|
filter={(p) => p.status !== 'active'}
|
|
placeholder="Search by name..."
|
|
autofocus={true}
|
|
/>
|
|
</div>
|
|
|
|
{:else if step === 'seat'}
|
|
<!-- Step 2: Auto-Seat Preview -->
|
|
<div class="step-content">
|
|
<h3 class="step-title">Seat Assignment</h3>
|
|
|
|
{#if suggestedSeat && !overrideMode}
|
|
<div class="suggestion-card">
|
|
<span class="suggestion-label">Suggested Seat</span>
|
|
<div class="suggestion-detail">
|
|
<span class="seat-info">Table {suggestedSeat.table_number}, Seat {suggestedSeat.seat}</span>
|
|
</div>
|
|
|
|
<!-- Mini table diagram -->
|
|
{#if suggestedSeat}
|
|
{@const tableData = tournament.tables.find((t) => t.id === suggestedSeat!.table_id)}
|
|
{#if tableData}
|
|
<div class="mini-table">
|
|
<div class="table-oval">
|
|
{#each getTablePlayers(tableData.id, tableData.seats) as seatData}
|
|
<div
|
|
class="mini-seat"
|
|
class:occupied={seatData.name !== null}
|
|
class:suggested={seatData.seat === suggestedSeat.seat}
|
|
title={seatData.name ?? `Seat ${seatData.seat} (empty)`}
|
|
>
|
|
{seatData.seat}
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
{/if}
|
|
|
|
<div class="suggestion-actions">
|
|
<button class="btn btn-primary btn-lg touch-target" onclick={acceptSuggestion}>
|
|
Accept Seat
|
|
</button>
|
|
<button class="btn btn-outline touch-target" onclick={toggleOverride}>
|
|
Override
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{:else if overrideMode}
|
|
<div class="override-list">
|
|
<p class="override-hint">Select a different seat:</p>
|
|
{#if availableSeats.length === 0}
|
|
<p class="no-seats">No available seats</p>
|
|
{:else}
|
|
{#each availableSeats as seat}
|
|
<button
|
|
class="seat-option touch-target"
|
|
onclick={() => selectOverrideSeat(seat)}
|
|
>
|
|
<span class="seat-option-table">Table {seat.table_number}</span>
|
|
<span class="seat-option-seat">Seat {seat.seat}</span>
|
|
</button>
|
|
{/each}
|
|
{/if}
|
|
<button class="btn btn-outline touch-target" onclick={toggleOverride}>
|
|
Cancel Override
|
|
</button>
|
|
</div>
|
|
{:else}
|
|
<div class="no-tables">
|
|
<p>No tables available. Create a table first.</p>
|
|
<button class="btn btn-outline touch-target" onclick={goBack}>
|
|
Go Back
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
{:else if step === 'confirm'}
|
|
<!-- Step 3: Confirm -->
|
|
<div class="step-content">
|
|
<h3 class="step-title">Confirm Buy-In</h3>
|
|
|
|
<div class="confirm-card">
|
|
<div class="confirm-row">
|
|
<span class="confirm-label">Player</span>
|
|
<span class="confirm-value">{selectedPlayer?.name}</span>
|
|
</div>
|
|
<div class="confirm-row">
|
|
<span class="confirm-label">Table</span>
|
|
<span class="confirm-value">Table {selectedSeat?.table_number}</span>
|
|
</div>
|
|
<div class="confirm-row">
|
|
<span class="confirm-label">Seat</span>
|
|
<span class="confirm-value">Seat {selectedSeat?.seat}</span>
|
|
</div>
|
|
<div class="confirm-row">
|
|
<span class="confirm-label">Buy-In</span>
|
|
<span class="confirm-value currency">{buyinAmount.toLocaleString()}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<button
|
|
class="btn btn-confirm touch-target"
|
|
onclick={confirmBuyIn}
|
|
disabled={submitting}
|
|
>
|
|
{#if submitting}
|
|
Processing...
|
|
{:else}
|
|
Confirm Buy-In
|
|
{/if}
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
<style>
|
|
.buyin-flow {
|
|
display: flex;
|
|
flex-direction: column;
|
|
height: 100%;
|
|
background-color: var(--color-bg);
|
|
}
|
|
|
|
/* Header */
|
|
.flow-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: var(--space-3) var(--space-4);
|
|
border-bottom: 1px solid var(--color-border);
|
|
background-color: var(--color-bg-elevated);
|
|
}
|
|
|
|
.flow-title {
|
|
font-size: var(--text-lg);
|
|
font-weight: 700;
|
|
color: var(--color-text);
|
|
}
|
|
|
|
.back-btn, .close-btn {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 40px;
|
|
height: 40px;
|
|
background: none;
|
|
border: none;
|
|
color: var(--color-text);
|
|
font-size: var(--text-xl);
|
|
cursor: pointer;
|
|
border-radius: var(--radius-md);
|
|
}
|
|
|
|
.back-btn:hover, .close-btn:hover {
|
|
background-color: var(--color-surface);
|
|
}
|
|
|
|
/* Step indicator */
|
|
.step-indicator {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: var(--space-4);
|
|
gap: 0;
|
|
}
|
|
|
|
.step {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 32px;
|
|
height: 32px;
|
|
border-radius: var(--radius-full);
|
|
font-size: var(--text-sm);
|
|
font-weight: 700;
|
|
border: 2px solid var(--color-border);
|
|
color: var(--color-text-muted);
|
|
background: none;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.step.active {
|
|
border-color: var(--color-primary);
|
|
color: var(--color-primary);
|
|
background-color: color-mix(in srgb, var(--color-primary) 10%, transparent);
|
|
}
|
|
|
|
.step.done {
|
|
border-color: var(--color-success);
|
|
color: var(--color-bg);
|
|
background-color: var(--color-success);
|
|
}
|
|
|
|
.step-line {
|
|
width: 40px;
|
|
height: 2px;
|
|
background-color: var(--color-border);
|
|
}
|
|
|
|
.step-line.done {
|
|
background-color: var(--color-success);
|
|
}
|
|
|
|
/* Content */
|
|
.flow-content {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
padding: var(--space-4);
|
|
}
|
|
|
|
.step-content {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--space-4);
|
|
}
|
|
|
|
.step-title {
|
|
font-size: var(--text-lg);
|
|
font-weight: 600;
|
|
color: var(--color-text);
|
|
}
|
|
|
|
/* Suggestion card */
|
|
.suggestion-card {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--space-4);
|
|
padding: var(--space-6);
|
|
background-color: var(--color-surface);
|
|
border-radius: var(--radius-lg);
|
|
border: 1px solid var(--color-border);
|
|
text-align: center;
|
|
}
|
|
|
|
.suggestion-label {
|
|
font-size: var(--text-xs);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
color: var(--color-text-muted);
|
|
}
|
|
|
|
.seat-info {
|
|
font-size: var(--text-2xl);
|
|
font-weight: 700;
|
|
color: var(--color-text);
|
|
font-family: var(--font-mono);
|
|
}
|
|
|
|
.suggestion-actions {
|
|
display: flex;
|
|
gap: var(--space-3);
|
|
}
|
|
|
|
/* Mini table diagram */
|
|
.mini-table {
|
|
display: flex;
|
|
justify-content: center;
|
|
padding: var(--space-4) 0;
|
|
}
|
|
|
|
.table-oval {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
justify-content: center;
|
|
gap: var(--space-2);
|
|
max-width: 280px;
|
|
}
|
|
|
|
.mini-seat {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 36px;
|
|
height: 36px;
|
|
border-radius: var(--radius-full);
|
|
font-size: var(--text-xs);
|
|
font-weight: 600;
|
|
border: 2px solid var(--color-border);
|
|
color: var(--color-text-muted);
|
|
background: none;
|
|
}
|
|
|
|
.mini-seat.occupied {
|
|
background-color: var(--color-surface-active);
|
|
color: var(--color-text);
|
|
border-color: var(--color-text-muted);
|
|
}
|
|
|
|
.mini-seat.suggested {
|
|
border-color: var(--color-success);
|
|
color: var(--color-success);
|
|
background-color: color-mix(in srgb, var(--color-success) 15%, transparent);
|
|
animation: pulse-seat 1.5s ease-in-out infinite;
|
|
}
|
|
|
|
@keyframes pulse-seat {
|
|
0%, 100% { transform: scale(1); }
|
|
50% { transform: scale(1.1); }
|
|
}
|
|
|
|
/* Override */
|
|
.override-list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--space-2);
|
|
}
|
|
|
|
.override-hint {
|
|
font-size: var(--text-sm);
|
|
color: var(--color-text-muted);
|
|
margin-bottom: var(--space-2);
|
|
}
|
|
|
|
.seat-option {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: var(--space-3) var(--space-4);
|
|
background-color: var(--color-surface);
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius-md);
|
|
cursor: pointer;
|
|
color: var(--color-text);
|
|
transition: background-color var(--transition-fast);
|
|
}
|
|
|
|
.seat-option:hover {
|
|
background-color: var(--color-surface-hover);
|
|
}
|
|
|
|
.seat-option-table {
|
|
font-weight: 600;
|
|
}
|
|
|
|
.seat-option-seat {
|
|
color: var(--color-text-muted);
|
|
font-family: var(--font-mono);
|
|
}
|
|
|
|
.no-seats, .no-tables {
|
|
text-align: center;
|
|
color: var(--color-text-muted);
|
|
padding: var(--space-8);
|
|
}
|
|
|
|
/* Confirm card */
|
|
.confirm-card {
|
|
display: flex;
|
|
flex-direction: column;
|
|
background-color: var(--color-surface);
|
|
border-radius: var(--radius-lg);
|
|
border: 1px solid var(--color-border);
|
|
overflow: hidden;
|
|
}
|
|
|
|
.confirm-row {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: var(--space-3) var(--space-4);
|
|
border-bottom: 1px solid var(--color-border);
|
|
}
|
|
|
|
.confirm-row:last-child {
|
|
border-bottom: none;
|
|
}
|
|
|
|
.confirm-label {
|
|
font-size: var(--text-sm);
|
|
color: var(--color-text-muted);
|
|
}
|
|
|
|
.confirm-value {
|
|
font-weight: 600;
|
|
color: var(--color-text);
|
|
}
|
|
|
|
/* Buttons */
|
|
.btn {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: var(--space-3) var(--space-6);
|
|
font-size: var(--text-base);
|
|
font-weight: 600;
|
|
border-radius: var(--radius-lg);
|
|
border: none;
|
|
cursor: pointer;
|
|
transition: background-color var(--transition-fast), opacity var(--transition-fast);
|
|
flex: 1;
|
|
}
|
|
|
|
.btn:disabled {
|
|
opacity: 0.6;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.btn-primary {
|
|
background-color: var(--color-primary);
|
|
color: var(--color-bg);
|
|
}
|
|
|
|
.btn-primary:hover:not(:disabled) {
|
|
background-color: color-mix(in srgb, var(--color-primary) 85%, white);
|
|
}
|
|
|
|
.btn-outline {
|
|
background: none;
|
|
border: 1px solid var(--color-border);
|
|
color: var(--color-text);
|
|
}
|
|
|
|
.btn-outline:hover {
|
|
background-color: var(--color-surface);
|
|
}
|
|
|
|
.btn-confirm {
|
|
background-color: var(--color-success);
|
|
color: var(--color-bg);
|
|
font-size: var(--text-lg);
|
|
padding: var(--space-4) var(--space-6);
|
|
width: 100%;
|
|
}
|
|
|
|
.btn-confirm:hover:not(:disabled) {
|
|
background-color: color-mix(in srgb, var(--color-success) 85%, white);
|
|
}
|
|
|
|
.btn-lg {
|
|
padding: var(--space-4) var(--space-6);
|
|
}
|
|
|
|
.suggestion-detail {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
}
|
|
</style>
|