feat(01-12): implement Players tab with buy-in, bust-out, rebuy, add-on flows

- 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>
This commit is contained in:
Mikkel Georgsen 2026-03-01 08:23:09 +01:00
parent e7da206d32
commit 44b555db10
8 changed files with 3017 additions and 23 deletions

View file

@ -0,0 +1,332 @@
<script lang="ts">
import { tournament } from '$lib/stores/tournament.svelte';
import type { Player } from '$lib/stores/tournament.svelte';
import { toast } from '$lib/stores/toast.svelte';
import { api } from '$lib/api';
import PlayerSearch from './PlayerSearch.svelte';
/**
* Add-On Flow — quick flow similar to Rebuy.
*
* Features:
* - Show only during add-on window
* - "Add-On All" option for break-time mass add-ons
* - Quick flow: select player + confirm
*/
interface Props {
/** Close callback. */
onclose: () => void;
/** Pre-selected player (from player detail). */
preselectedPlayer?: Player | null;
}
let { onclose, preselectedPlayer = null }: Props = $props();
type Step = 'select' | 'confirm';
let step = $state<Step>('select');
let selectedPlayer = $state<Player | null>(null);
// Initialize from preselectedPlayer prop
$effect(() => {
if (preselectedPlayer) {
selectedPlayer = preselectedPlayer;
step = 'confirm';
}
});
let submitting = $state(false);
// Add-on config (simplified — real config from tournament)
let addonAmount = 50;
let addonChips = 15000;
/** Eligible: active players only. */
function isEligible(player: Player): boolean {
return player.status === 'active';
}
function handleSelect(player: Player): void {
selectedPlayer = player;
step = 'confirm';
}
async function confirmAddon(): Promise<void> {
if (!selectedPlayer) return;
submitting = true;
try {
await api.post(`/tournaments/${tournament.id}/addon`, {
player_id: selectedPlayer.id
});
toast.success(`${selectedPlayer.name} add-on — +${addonChips.toLocaleString()} chips`);
onclose();
} catch (err) {
toast.error(`Add-on failed: ${err instanceof Error ? err.message : 'Unknown error'}`);
} finally {
submitting = false;
}
}
async function addonAll(): Promise<void> {
submitting = true;
try {
const activePlayers = tournament.players.filter((p) => p.status === 'active');
let count = 0;
for (const player of activePlayers) {
await api.post(`/tournaments/${tournament.id}/addon`, {
player_id: player.id
});
count++;
}
toast.success(`Add-on applied to ${count} players`);
onclose();
} catch (err) {
toast.error(`Mass add-on failed: ${err instanceof Error ? err.message : 'Unknown error'}`);
} finally {
submitting = false;
}
}
function goBack(): void {
if (step === 'confirm' && !preselectedPlayer) {
step = 'select';
selectedPlayer = null;
} else {
onclose();
}
}
</script>
<div class="addon-flow">
<!-- Header -->
<div class="flow-header">
<button class="back-btn touch-target" onclick={goBack} aria-label="Go back">
<span aria-hidden="true">&larr;</span>
</button>
<h2 class="flow-title">Add-On</h2>
<button class="close-btn touch-target" onclick={onclose} aria-label="Close">
&times;
</button>
</div>
<div class="flow-content">
{#if step === 'select'}
<h3 class="step-title">Select Player</h3>
<PlayerSearch
players={tournament.players}
onselect={handleSelect}
filter={isEligible}
placeholder="Search eligible players..."
autofocus={true}
/>
<!-- Add-On All button -->
<div class="mass-addon">
<button
class="btn-addon-all touch-target"
onclick={addonAll}
disabled={submitting}
>
{submitting ? 'Processing...' : 'Add-On All Active Players'}
</button>
<p class="mass-addon-hint">
Applies add-on to all {tournament.players.filter((p) => p.status === 'active').length} active players
</p>
</div>
{:else if step === 'confirm'}
<div class="confirm-card">
<h3 class="confirm-name">{selectedPlayer?.name}</h3>
<div class="confirm-details">
<div class="confirm-row">
<span class="confirm-label">Current Chips</span>
<span class="confirm-value chips">{selectedPlayer?.chips.toLocaleString()}</span>
</div>
<div class="confirm-row">
<span class="confirm-label">Add-On Amount</span>
<span class="confirm-value currency">{addonAmount.toLocaleString()}</span>
</div>
<div class="confirm-row">
<span class="confirm-label">Chips Added</span>
<span class="confirm-value chips">+{addonChips.toLocaleString()}</span>
</div>
<div class="confirm-row">
<span class="confirm-label">Add-Ons So Far</span>
<span class="confirm-value number">{selectedPlayer?.addons ?? 0}</span>
</div>
</div>
<button
class="btn-confirm touch-target"
onclick={confirmAddon}
disabled={submitting}
>
{submitting ? 'Processing...' : 'Confirm Add-On'}
</button>
</div>
{/if}
</div>
</div>
<style>
.addon-flow {
display: flex;
flex-direction: column;
height: 100%;
background-color: var(--color-bg);
}
.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-warning);
}
.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);
}
.flow-content {
flex: 1;
overflow-y: auto;
padding: var(--space-4);
display: flex;
flex-direction: column;
gap: var(--space-6);
}
.step-title {
font-size: var(--text-lg);
font-weight: 600;
color: var(--color-text);
}
/* Mass add-on */
.mass-addon {
display: flex;
flex-direction: column;
gap: var(--space-2);
align-items: center;
padding-top: var(--space-4);
border-top: 1px solid var(--color-border);
}
.btn-addon-all {
width: 100%;
padding: var(--space-3) var(--space-6);
font-size: var(--text-base);
font-weight: 600;
background-color: var(--color-warning);
color: var(--color-bg);
border: none;
border-radius: var(--radius-lg);
cursor: pointer;
transition: background-color var(--transition-fast), opacity var(--transition-fast);
}
.btn-addon-all:hover:not(:disabled) {
background-color: color-mix(in srgb, var(--color-warning) 85%, white);
}
.btn-addon-all:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.mass-addon-hint {
font-size: var(--text-xs);
color: var(--color-text-muted);
text-align: center;
}
/* Confirm card */
.confirm-card {
display: flex;
flex-direction: column;
gap: var(--space-4);
align-items: center;
padding-top: var(--space-4);
}
.confirm-name {
font-size: var(--text-2xl);
font-weight: 700;
color: var(--color-text);
}
.confirm-details {
width: 100%;
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);
}
.btn-confirm {
width: 100%;
padding: var(--space-4) var(--space-6);
font-size: var(--text-lg);
font-weight: 700;
background-color: var(--color-warning);
color: var(--color-bg);
border: none;
border-radius: var(--radius-lg);
cursor: pointer;
transition: background-color var(--transition-fast), opacity var(--transition-fast);
}
.btn-confirm:hover:not(:disabled) {
background-color: color-mix(in srgb, var(--color-warning) 85%, white);
}
.btn-confirm:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style>

View file

@ -0,0 +1,629 @@
<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';
/**
* Bust-Out Flow — optimized for minimum taps.
*
* Steps:
* 1. Pick Table: grid of active tables (large tap targets)
* 2. Pick Seat: oval table view, tap busted player's seat
* 3. Verify: confirmation "Bust [Name]?"
* 4. Select Hitman: players at same table, or search other
* 5. Done: toast with placement
*
* Triggered from: FAB "Bust" action.
* PLYR-05
*/
interface Props {
/** Close callback. */
onclose: () => void;
}
let { onclose }: Props = $props();
// Flow state
type Step = 'table' | 'seat' | 'verify' | 'hitman';
let step = $state<Step>('table');
let selectedTable = $state<Table | null>(null);
let selectedPlayer = $state<Player | null>(null);
let selectedHitman = $state<Player | null>(null);
let showAllPlayers = $state(false);
let submitting = $state(false);
// Active tables with at least 1 player
let activeTables = $derived(
tournament.tables
.filter((t) => !t.is_break_table && t.players.length > 0)
.sort((a, b) => a.number - b.number)
);
// Players at selected table
let tablePlayers = $derived.by(() => {
if (!selectedTable) return [];
return tournament.players.filter(
(p) => p.table_id === selectedTable!.id && p.status === 'active'
);
});
// Hitman candidates: players at same table (excluding busted player)
let hitmanCandidates = $derived.by(() => {
if (!selectedTable || !selectedPlayer) return [];
if (showAllPlayers) {
return tournament.players
.filter((p) => p.status === 'active' && p.id !== selectedPlayer!.id)
.sort((a, b) => a.name.localeCompare(b.name));
}
return tablePlayers.filter((p) => p.id !== selectedPlayer!.id);
});
function selectTable(table: Table): void {
selectedTable = table;
step = 'seat';
}
function selectSeat(player: Player): void {
selectedPlayer = player;
step = 'verify';
}
function confirmBust(): void {
step = 'hitman';
}
function selectHitman(hitman: Player): void {
selectedHitman = hitman;
submitBust();
}
function skipHitman(): void {
selectedHitman = null;
submitBust();
}
async function submitBust(): Promise<void> {
if (!selectedPlayer) return;
submitting = true;
try {
await api.post(`/tournaments/${tournament.id}/bust`, {
player_id: selectedPlayer.id,
hitman_id: selectedHitman?.id ?? null
});
// Calculate approximate position
const remainingCount = tournament.remainingPlayers;
const position = remainingCount; // Will be refined by backend
toast.success(
`${selectedPlayer.name} busted out — ${ordinal(position)} place`
);
onclose();
} catch (err) {
toast.error(`Bust failed: ${err instanceof Error ? err.message : 'Unknown error'}`);
} finally {
submitting = false;
}
}
function goBack(): void {
if (step === 'hitman') {
step = 'verify';
} else if (step === 'verify') {
step = 'seat';
selectedPlayer = null;
} else if (step === 'seat') {
step = 'table';
selectedTable = null;
} else {
onclose();
}
}
/** Get seat layout for the table view. */
function getSeatLayout(table: Table): { seat: number; player: Player | null }[] {
const seats: { seat: number; player: Player | null }[] = [];
for (let s = 1; s <= table.seats; s++) {
const player = tournament.players.find(
(p) => p.table_id === table.id && p.seat === s && p.status === 'active'
);
seats.push({ seat: s, player: player ?? null });
}
return seats;
}
function ordinal(n: number): string {
const s = ['th', 'st', 'nd', 'rd'];
const v = n % 100;
return n + (s[(v - 20) % 10] || s[v] || s[0]);
}
function playerInitials(name: string): string {
return name
.split(' ')
.map((w) => w.charAt(0))
.join('')
.toUpperCase()
.slice(0, 2);
}
</script>
<div class="bustout-flow">
<!-- Header -->
<div class="flow-header">
<button class="back-btn touch-target" onclick={goBack} aria-label="Go back">
<span aria-hidden="true">&larr;</span>
</button>
<h2 class="flow-title">Bust Out</h2>
<button class="close-btn touch-target" onclick={onclose} aria-label="Close">
&times;
</button>
</div>
<!-- Content -->
<div class="flow-content">
{#if step === 'table'}
<!-- Step 1: Pick Table -->
<h3 class="step-title">Select Table</h3>
<div class="table-grid">
{#each activeTables as table (table.id)}
<button
class="table-card touch-target"
onclick={() => selectTable(table)}
>
<span class="table-number">Table {table.number}</span>
<span class="table-count">{table.players.length} players</span>
</button>
{/each}
</div>
{#if activeTables.length === 0}
<p class="empty-msg">No active tables with players.</p>
{/if}
{:else if step === 'seat'}
<!-- Step 2: Pick Seat (oval table view) -->
<h3 class="step-title">Table {selectedTable?.number} — Tap the busted player</h3>
{#if selectedTable}
{@const seatLayout = getSeatLayout(selectedTable)}
<div class="oval-table-view">
<div class="felt-table">
<span class="table-label">Table {selectedTable.number}</span>
</div>
<div class="seats-ring">
{#each seatLayout as { seat, player }}
{#if player}
<button
class="seat-btn occupied touch-target"
style="--seat-angle: {(seat - 1) * (360 / selectedTable.seats)}deg"
onclick={() => selectSeat(player)}
aria-label="Bust {player.name} at seat {seat}"
>
<span class="seat-number">{seat}</span>
<span class="seat-name">{player.name.split(' ')[0]}</span>
</button>
{:else}
<div
class="seat-btn empty"
style="--seat-angle: {(seat - 1) * (360 / selectedTable.seats)}deg"
>
<span class="seat-number">{seat}</span>
</div>
{/if}
{/each}
</div>
</div>
{/if}
{:else if step === 'verify'}
<!-- Step 3: Verify -->
<div class="verify-card">
<div class="verify-avatar">
{playerInitials(selectedPlayer?.name ?? '?')}
</div>
<h3 class="verify-question">Bust {selectedPlayer?.name}?</h3>
<div class="verify-info">
<span>Table {selectedTable?.number}, Seat {selectedPlayer?.seat}</span>
{#if selectedPlayer?.chips}
<span class="chips">{selectedPlayer.chips.toLocaleString()} chips</span>
{/if}
</div>
<button
class="btn btn-bust touch-target"
onclick={confirmBust}
>
Confirm Bust
</button>
</div>
{:else if step === 'hitman'}
<!-- Step 4: Select Hitman -->
<h3 class="step-title">Who busted {selectedPlayer?.name}?</h3>
<div class="hitman-list">
{#each hitmanCandidates as candidate (candidate.id)}
<button
class="hitman-option touch-target"
onclick={() => selectHitman(candidate)}
disabled={submitting}
>
<div class="hitman-avatar">{playerInitials(candidate.name)}</div>
<div class="hitman-info">
<span class="hitman-name">{candidate.name}</span>
{#if !showAllPlayers}
<span class="hitman-seat">Seat {candidate.seat}</span>
{:else}
<span class="hitman-seat">
Table {tournament.tables.find((t) => t.id === candidate.table_id)?.number}, Seat {candidate.seat}
</span>
{/if}
</div>
</button>
{/each}
</div>
<div class="hitman-actions">
{#if !showAllPlayers}
<button
class="btn btn-outline touch-target"
onclick={() => { showAllPlayers = true; }}
>
Other Player...
</button>
{/if}
<button
class="btn btn-outline btn-skip touch-target"
onclick={skipHitman}
disabled={submitting}
>
Skip
</button>
</div>
{/if}
</div>
</div>
<style>
.bustout-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-error);
}
.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);
}
/* Content */
.flow-content {
flex: 1;
overflow-y: auto;
padding: var(--space-4);
}
.step-title {
font-size: var(--text-lg);
font-weight: 600;
color: var(--color-text);
margin-bottom: var(--space-4);
}
/* Table grid */
.table-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--space-3);
}
@media (min-width: 768px) {
.table-grid {
grid-template-columns: repeat(3, 1fr);
}
}
.table-card {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-2);
padding: var(--space-6) var(--space-4);
background-color: var(--color-surface);
border: 2px solid var(--color-border);
border-radius: var(--radius-lg);
cursor: pointer;
transition: border-color var(--transition-fast), background-color var(--transition-fast);
}
.table-card:hover {
border-color: var(--color-primary);
background-color: var(--color-surface-hover);
}
.table-number {
font-size: var(--text-lg);
font-weight: 700;
color: var(--color-text);
}
.table-count {
font-size: var(--text-sm);
color: var(--color-text-muted);
}
.empty-msg {
text-align: center;
color: var(--color-text-muted);
padding: var(--space-8);
font-style: italic;
}
/* Oval table view */
.oval-table-view {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
padding: var(--space-8) var(--space-4);
}
.felt-table {
width: 160px;
height: 100px;
border-radius: 50%;
background-color: color-mix(in srgb, var(--color-felt) 20%, transparent);
border: 3px solid var(--color-felt);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: var(--space-6);
}
.table-label {
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-felt);
}
.seats-ring {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: var(--space-3);
max-width: 320px;
}
.seat-btn {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 64px;
height: 64px;
border-radius: var(--radius-lg);
border: 2px solid var(--color-border);
background: none;
cursor: default;
gap: 2px;
}
.seat-btn.occupied {
background-color: var(--color-surface);
border-color: var(--color-error);
cursor: pointer;
transition: background-color var(--transition-fast), transform var(--transition-fast);
}
.seat-btn.occupied:hover {
background-color: color-mix(in srgb, var(--color-error) 15%, transparent);
transform: scale(1.05);
}
.seat-btn.empty {
opacity: 0.3;
}
.seat-number {
font-size: var(--text-xs);
font-weight: 700;
color: var(--color-text-muted);
}
.seat-name {
font-size: var(--text-xs);
color: var(--color-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 56px;
}
/* Verify card */
.verify-card {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-4);
padding: var(--space-8) var(--space-4);
text-align: center;
}
.verify-avatar {
display: flex;
align-items: center;
justify-content: center;
width: 80px;
height: 80px;
border-radius: var(--radius-full);
background-color: var(--color-surface);
border: 3px solid var(--color-error);
font-size: var(--text-2xl);
font-weight: 700;
color: var(--color-text);
}
.verify-question {
font-size: var(--text-2xl);
font-weight: 700;
color: var(--color-text);
}
.verify-info {
display: flex;
flex-direction: column;
gap: var(--space-1);
color: var(--color-text-muted);
font-size: var(--text-sm);
}
/* Hitman list */
.hitman-list {
display: flex;
flex-direction: column;
gap: var(--space-2);
margin-bottom: var(--space-4);
}
.hitman-option {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
background-color: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
cursor: pointer;
color: var(--color-text);
transition: border-color var(--transition-fast), background-color var(--transition-fast);
}
.hitman-option:hover:not(:disabled) {
border-color: var(--color-primary);
background-color: var(--color-surface-hover);
}
.hitman-option:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.hitman-avatar {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: var(--radius-full);
background-color: var(--color-surface-active);
font-size: var(--text-sm);
font-weight: 700;
color: var(--color-text);
flex-shrink: 0;
}
.hitman-info {
display: flex;
flex-direction: column;
gap: 2px;
text-align: left;
}
.hitman-name {
font-weight: 600;
font-size: var(--text-base);
}
.hitman-seat {
font-size: var(--text-xs);
color: var(--color-text-muted);
}
/* Hitman action buttons */
.hitman-actions {
display: flex;
gap: var(--space-3);
}
/* 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-bust {
background-color: var(--color-error);
color: white;
font-size: var(--text-lg);
padding: var(--space-4) var(--space-6);
width: 100%;
max-width: 300px;
}
.btn-bust:hover:not(:disabled) {
background-color: color-mix(in srgb, var(--color-error) 85%, white);
}
.btn-outline {
background: none;
border: 1px solid var(--color-border);
color: var(--color-text);
}
.btn-outline:hover:not(:disabled) {
background-color: var(--color-surface);
}
.btn-skip {
color: var(--color-text-muted);
}
</style>

View file

@ -0,0 +1,643 @@
<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">&larr;</span>
</button>
<h2 class="flow-title">Buy In</h2>
<button class="close-btn touch-target" onclick={onclose} aria-label="Close">
&times;
</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>

View file

@ -0,0 +1,443 @@
<script lang="ts">
import { tournament } from '$lib/stores/tournament.svelte';
import type { Player } from '$lib/stores/tournament.svelte';
import { toast } from '$lib/stores/toast.svelte';
import { api } from '$lib/api';
/**
* Player Detail — full per-player tracking within tournament.
*
* Shows: status, seat, chips, playing time, buy-in time,
* rebuys, add-ons, bounties, prize, points, net take,
* action history, undo buttons.
*
* PLYR-07
*/
interface Props {
/** The player to display. */
player: Player;
/** Close callback. */
onclose: () => void;
/** Callback to trigger rebuy flow. */
onrebuy?: (player: Player) => void;
/** Callback to trigger add-on flow. */
onaddon?: (player: Player) => void;
}
let { player, onclose, onrebuy, onaddon }: Props = $props();
let undoing = $state<string | null>(null);
// Derived data
let tableName = $derived.by(() => {
if (!player.table_id) return 'Not seated';
const table = tournament.tables.find((t) => t.id === player.table_id);
return table ? `Table ${table.number}` : 'Unknown';
});
let seatDisplay = $derived(
player.seat ? `Seat ${player.seat}` : 'N/A'
);
let totalInvestment = $derived(
100 + (player.rebuys * 100) + (player.addons * 50)
);
let isActive = $derived(player.status === 'active');
// Action history from activity feed (filtered for this player)
let playerHistory = $derived(
tournament.activity
.filter((a) => a.player_id === player.id)
.sort((a, b) => b.timestamp - a.timestamp)
);
async function undoAction(actionType: string): Promise<void> {
undoing = actionType;
try {
await api.post(`/tournaments/${tournament.id}/undo`, {
player_id: player.id,
action_type: actionType
});
toast.success(`Undid ${actionType} for ${player.name}`);
} catch (err) {
toast.error(`Undo failed: ${err instanceof Error ? err.message : 'Unknown error'}`);
} finally {
undoing = null;
}
}
function formatTime(seconds: number): string {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
}
function formatTimestamp(ts: number): string {
const date = new Date(ts * 1000);
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
function ordinal(n: number): string {
const s = ['th', 'st', 'nd', 'rd'];
const v = n % 100;
return n + (s[(v - 20) % 10] || s[v] || s[0]);
}
function statusColor(status: string): string {
switch (status) {
case 'active': return 'var(--color-success)';
case 'eliminated': return 'var(--color-error)';
case 'registered': return 'var(--color-primary)';
default: return 'var(--color-text-muted)';
}
}
</script>
<div class="player-detail">
<!-- Header -->
<div class="detail-header">
<button class="back-btn touch-target" onclick={onclose} aria-label="Close player detail">
<span aria-hidden="true">&larr;</span>
</button>
<h2 class="detail-title">{player.name}</h2>
<div class="status-badge" style="color: {statusColor(player.status)}">
{player.status}
</div>
</div>
<div class="detail-content">
<!-- Status & Location -->
<section class="detail-section">
<div class="info-grid">
<div class="info-item">
<span class="info-label">Status</span>
<span class="info-value" style="color: {statusColor(player.status)}">{player.status}</span>
</div>
<div class="info-item">
<span class="info-label">Location</span>
<span class="info-value">{tableName}, {seatDisplay}</span>
</div>
<div class="info-item">
<span class="info-label">Chips</span>
<span class="info-value chips">{player.chips.toLocaleString()}</span>
</div>
{#if player.finish_position}
<div class="info-item">
<span class="info-label">Finish</span>
<span class="info-value">{ordinal(player.finish_position)} place</span>
</div>
{/if}
</div>
</section>
<!-- Financial Details -->
<section class="detail-section">
<h3 class="section-title">Financial</h3>
<div class="stats-list">
<div class="stat-row">
<span class="stat-label">Rebuys</span>
<span class="stat-value number">{player.rebuys}</span>
</div>
<div class="stat-row">
<span class="stat-label">Add-ons</span>
<span class="stat-value number">{player.addons}</span>
</div>
<div class="stat-row">
<span class="stat-label">Total Investment</span>
<span class="stat-value currency">{totalInvestment.toLocaleString()}</span>
</div>
<div class="stat-row">
<span class="stat-label">Bounties Collected</span>
<span class="stat-value number">{player.bounty}</span>
</div>
</div>
</section>
<!-- Quick Actions -->
{#if isActive}
<section class="detail-section">
<h3 class="section-title">Actions</h3>
<div class="action-grid">
{#if onrebuy}
<button
class="action-btn touch-target"
style="--action-color: var(--color-primary)"
onclick={() => onrebuy?.(player)}
>
Rebuy
</button>
{/if}
{#if onaddon}
<button
class="action-btn touch-target"
style="--action-color: var(--color-warning)"
onclick={() => onaddon?.(player)}
>
Add-On
</button>
{/if}
<button
class="action-btn touch-target"
style="--action-color: var(--color-error)"
onclick={() => undoAction('buyin')}
disabled={undoing !== null}
>
{undoing === 'buyin' ? 'Undoing...' : 'Undo Buy-In'}
</button>
</div>
</section>
{:else if player.status === 'eliminated'}
<section class="detail-section">
<h3 class="section-title">Actions</h3>
<div class="action-grid">
<button
class="action-btn touch-target"
style="--action-color: var(--color-success)"
onclick={() => undoAction('bust')}
disabled={undoing !== null}
>
{undoing === 'bust' ? 'Undoing...' : 'Undo Bust'}
</button>
</div>
</section>
{/if}
<!-- Action History -->
<section class="detail-section">
<h3 class="section-title">History</h3>
{#if playerHistory.length === 0}
<p class="empty-history">No actions recorded yet.</p>
{:else}
<div class="history-list">
{#each playerHistory as entry (entry.id)}
<div class="history-item">
<span class="history-time">{formatTimestamp(entry.timestamp)}</span>
<span class="history-message">{entry.message}</span>
</div>
{/each}
</div>
{/if}
</section>
</div>
</div>
<style>
.player-detail {
display: flex;
flex-direction: column;
height: 100%;
background-color: var(--color-bg);
}
/* Header */
.detail-header {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--color-border);
background-color: var(--color-bg-elevated);
}
.back-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);
flex-shrink: 0;
}
.back-btn:hover {
background-color: var(--color-surface);
}
.detail-title {
flex: 1;
font-size: var(--text-lg);
font-weight: 700;
color: var(--color-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.status-badge {
font-size: var(--text-xs);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
flex-shrink: 0;
}
/* Content */
.detail-content {
flex: 1;
overflow-y: auto;
padding: var(--space-4);
display: flex;
flex-direction: column;
gap: var(--space-6);
}
.detail-section {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.section-title {
font-size: var(--text-sm);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-muted);
}
/* Info grid */
.info-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--space-3);
}
.info-item {
display: flex;
flex-direction: column;
gap: var(--space-1);
padding: var(--space-3);
background-color: var(--color-surface);
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
}
.info-label {
font-size: var(--text-xs);
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-muted);
}
.info-value {
font-size: var(--text-lg);
font-weight: 700;
color: var(--color-text);
}
/* Stats list */
.stats-list {
display: flex;
flex-direction: column;
background-color: var(--color-surface);
border-radius: var(--radius-lg);
border: 1px solid var(--color-border);
overflow: hidden;
}
.stat-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--color-border);
}
.stat-row:last-child {
border-bottom: none;
}
.stat-label {
font-size: var(--text-sm);
color: var(--color-text-muted);
}
.stat-value {
font-weight: 600;
color: var(--color-text);
}
/* Action grid */
.action-grid {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.action-btn {
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-3) var(--space-4);
font-size: var(--text-sm);
font-weight: 600;
color: var(--action-color);
background: none;
border: 1px solid var(--action-color);
border-radius: var(--radius-lg);
cursor: pointer;
transition: background-color var(--transition-fast);
}
.action-btn:hover:not(:disabled) {
background-color: color-mix(in srgb, var(--action-color) 10%, transparent);
}
.action-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* History */
.empty-history {
font-size: var(--text-sm);
color: var(--color-text-muted);
font-style: italic;
padding: var(--space-4);
text-align: center;
}
.history-list {
display: flex;
flex-direction: column;
background-color: var(--color-surface);
border-radius: var(--radius-lg);
border: 1px solid var(--color-border);
overflow: hidden;
max-height: 300px;
overflow-y: auto;
}
.history-item {
display: flex;
gap: var(--space-3);
padding: var(--space-2) var(--space-4);
border-bottom: 1px solid var(--color-border);
align-items: baseline;
}
.history-item:last-child {
border-bottom: none;
}
.history-time {
font-size: var(--text-xs);
font-family: var(--font-mono);
color: var(--color-text-muted);
white-space: nowrap;
flex-shrink: 0;
}
.history-message {
font-size: var(--text-sm);
color: var(--color-text);
}
</style>

View file

@ -0,0 +1,301 @@
<script lang="ts">
import type { Player } from '$lib/stores/tournament.svelte';
/**
* Typeahead player search for tournament operations.
*
* Features:
* - Debounced 200ms search as TD types
* - Results: name, nickname, last tournament date
* - 48px rows for touch targets
* - "Add New Player" at bottom if no match
* - Recently active players when empty
*/
interface Props {
/** All players to search from. */
players: Player[];
/** Callback when a player is selected. */
onselect: (player: Player) => void;
/** Callback when "Add New" is tapped. */
onnewplayer?: () => void;
/** Optional filter: only show matching players. */
filter?: (player: Player) => boolean;
/** Placeholder text. */
placeholder?: string;
/** Auto-focus on mount. */
autofocus?: boolean;
}
let {
players,
onselect,
onnewplayer,
filter,
placeholder = 'Search players...',
autofocus = false
}: Props = $props();
let query = $state('');
let debouncedQuery = $state('');
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
let isFocused = $state(false);
// Debounce the search query (200ms)
$effect(() => {
const q = query;
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
debouncedQuery = q;
}, 200);
return () => clearTimeout(debounceTimer);
});
/** Filtered results based on debounced query. */
let results = $derived.by(() => {
let pool = filter ? players.filter(filter) : players;
if (!debouncedQuery.trim()) {
// Show recently active players (sorted by status: active first)
return pool
.filter((p) => p.status === 'active')
.slice(0, 8);
}
const q = debouncedQuery.toLowerCase();
return pool.filter((p) => {
return p.name.toLowerCase().includes(q);
}).slice(0, 20);
});
let showResults = $derived(isFocused && (results.length > 0 || debouncedQuery.trim().length > 0));
function selectPlayer(player: Player): void {
onselect(player);
query = '';
debouncedQuery = '';
isFocused = false;
}
function handleFocus(): void {
isFocused = true;
}
function handleBlur(): void {
// Delay to allow click on results
setTimeout(() => {
isFocused = false;
}, 200);
}
function statusBadgeClass(status: string): string {
switch (status) {
case 'active': return 'badge-active';
case 'eliminated': return 'badge-eliminated';
case 'registered': return 'badge-registered';
default: return '';
}
}
</script>
<div class="player-search">
<!-- svelte-ignore a11y_autofocus -->
<input
type="search"
class="search-input"
{placeholder}
bind:value={query}
onfocus={handleFocus}
onblur={handleBlur}
aria-label="Search players"
aria-autocomplete="list"
autofocus={autofocus ? true : undefined}
/>
{#if showResults}
<div class="search-results" role="listbox" aria-label="Player search results">
{#if results.length === 0}
<div class="no-results">
No players found for "{debouncedQuery}"
</div>
{/if}
{#each results as player (player.id)}
<button
class="result-row touch-target"
role="option"
aria-selected="false"
onclick={() => selectPlayer(player)}
>
<div class="result-info">
<span class="result-name">{player.name}</span>
<span class="result-meta">
{#if player.table_id}
Table {player.table_id} / Seat {player.seat}
{:else}
{player.status}
{/if}
</span>
</div>
<span class="status-badge {statusBadgeClass(player.status)}">
{player.status}
</span>
</button>
{/each}
{#if onnewplayer}
<button
class="result-row add-new touch-target"
onclick={onnewplayer}
>
<span class="add-new-icon">+</span>
<span class="add-new-label">Add New Player</span>
</button>
{/if}
</div>
{/if}
</div>
<style>
.player-search {
position: relative;
width: 100%;
}
.search-input {
width: 100%;
padding: var(--space-3) var(--space-4);
font-size: var(--text-base);
color: var(--color-text);
background-color: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
outline: none;
min-height: var(--touch-target);
transition: border-color var(--transition-fast);
}
.search-input:focus {
border-color: var(--color-primary);
}
.search-input::placeholder {
color: var(--color-text-muted);
}
.search-results {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 50;
background-color: var(--color-surface);
border: 1px solid var(--color-border);
border-top: none;
border-radius: 0 0 var(--radius-lg) var(--radius-lg);
box-shadow: var(--shadow-lg);
max-height: 400px;
overflow-y: auto;
}
.no-results {
padding: var(--space-4);
color: var(--color-text-muted);
font-size: var(--text-sm);
text-align: center;
font-style: italic;
}
.result-row {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: var(--space-3) var(--space-4);
background: none;
border: none;
border-bottom: 1px solid var(--color-border);
cursor: pointer;
text-align: left;
color: var(--color-text);
transition: background-color var(--transition-fast);
}
.result-row:last-child {
border-bottom: none;
}
.result-row:hover {
background-color: var(--color-surface-hover);
}
.result-row:active {
background-color: var(--color-surface-active);
}
.result-info {
display: flex;
flex-direction: column;
gap: 2px;
}
.result-name {
font-size: var(--text-base);
font-weight: 600;
}
.result-meta {
font-size: var(--text-xs);
color: var(--color-text-muted);
}
.status-badge {
font-size: var(--text-xs);
font-weight: 600;
padding: 2px var(--space-2);
border-radius: var(--radius-full);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.badge-active {
color: var(--color-success);
background-color: color-mix(in srgb, var(--color-success) 15%, transparent);
}
.badge-eliminated {
color: var(--color-error);
background-color: color-mix(in srgb, var(--color-error) 15%, transparent);
}
.badge-registered {
color: var(--color-primary);
background-color: color-mix(in srgb, var(--color-primary) 15%, transparent);
}
/* Add New row */
.add-new {
border-top: 2px solid var(--color-border);
gap: var(--space-3);
justify-content: flex-start;
}
.add-new-icon {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: var(--radius-full);
background-color: var(--color-primary);
color: var(--color-bg);
font-size: var(--text-lg);
font-weight: 700;
}
.add-new-label {
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-primary);
}
</style>

View file

@ -0,0 +1,258 @@
<script lang="ts">
import { tournament } from '$lib/stores/tournament.svelte';
import type { Player } from '$lib/stores/tournament.svelte';
import { toast } from '$lib/stores/toast.svelte';
import { api } from '$lib/api';
import PlayerSearch from './PlayerSearch.svelte';
/**
* Rebuy Flow — quick 2-tap flow.
*
* 1. Select player (only eligible: active, under chip threshold, within cutoff)
* 2. Confirm rebuy amount and chips
*
* Triggered from: FAB "Rebuy", player detail.
*/
interface Props {
/** Close callback. */
onclose: () => void;
/** Pre-selected player (from player detail). */
preselectedPlayer?: Player | null;
}
let { onclose, preselectedPlayer = null }: Props = $props();
type Step = 'select' | 'confirm';
let step = $state<Step>('select');
let selectedPlayer = $state<Player | null>(null);
// Initialize from preselectedPlayer prop
$effect(() => {
if (preselectedPlayer) {
selectedPlayer = preselectedPlayer;
step = 'confirm';
}
});
let submitting = $state(false);
// Rebuy amount (simplified — real amount comes from tournament config)
let rebuyAmount = 100;
let rebuyChips = 10000;
/** Only show active players eligible for rebuy. */
function isEligible(player: Player): boolean {
return player.status === 'active';
}
function handleSelect(player: Player): void {
selectedPlayer = player;
step = 'confirm';
}
async function confirmRebuy(): Promise<void> {
if (!selectedPlayer) return;
submitting = true;
try {
await api.post(`/tournaments/${tournament.id}/rebuy`, {
player_id: selectedPlayer.id
});
toast.success(`${selectedPlayer.name} rebought — +${rebuyChips.toLocaleString()} chips`);
onclose();
} catch (err) {
toast.error(`Rebuy failed: ${err instanceof Error ? err.message : 'Unknown error'}`);
} finally {
submitting = false;
}
}
function goBack(): void {
if (step === 'confirm' && !preselectedPlayer) {
step = 'select';
selectedPlayer = null;
} else {
onclose();
}
}
</script>
<div class="rebuy-flow">
<!-- Header -->
<div class="flow-header">
<button class="back-btn touch-target" onclick={goBack} aria-label="Go back">
<span aria-hidden="true">&larr;</span>
</button>
<h2 class="flow-title">Rebuy</h2>
<button class="close-btn touch-target" onclick={onclose} aria-label="Close">
&times;
</button>
</div>
<div class="flow-content">
{#if step === 'select'}
<h3 class="step-title">Select Player</h3>
<PlayerSearch
players={tournament.players}
onselect={handleSelect}
filter={isEligible}
placeholder="Search eligible players..."
autofocus={true}
/>
{:else if step === 'confirm'}
<div class="confirm-card">
<h3 class="confirm-name">{selectedPlayer?.name}</h3>
<div class="confirm-details">
<div class="confirm-row">
<span class="confirm-label">Current Chips</span>
<span class="confirm-value chips">{selectedPlayer?.chips.toLocaleString()}</span>
</div>
<div class="confirm-row">
<span class="confirm-label">Rebuy Amount</span>
<span class="confirm-value currency">{rebuyAmount.toLocaleString()}</span>
</div>
<div class="confirm-row">
<span class="confirm-label">Chips Added</span>
<span class="confirm-value chips">+{rebuyChips.toLocaleString()}</span>
</div>
<div class="confirm-row">
<span class="confirm-label">Rebuys So Far</span>
<span class="confirm-value number">{selectedPlayer?.rebuys ?? 0}</span>
</div>
</div>
<button
class="btn-confirm touch-target"
onclick={confirmRebuy}
disabled={submitting}
>
{submitting ? 'Processing...' : 'Confirm Rebuy'}
</button>
</div>
{/if}
</div>
</div>
<style>
.rebuy-flow {
display: flex;
flex-direction: column;
height: 100%;
background-color: var(--color-bg);
}
.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-primary);
}
.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);
}
.flow-content {
flex: 1;
overflow-y: auto;
padding: var(--space-4);
}
.step-title {
font-size: var(--text-lg);
font-weight: 600;
color: var(--color-text);
margin-bottom: var(--space-4);
}
/* Confirm card */
.confirm-card {
display: flex;
flex-direction: column;
gap: var(--space-4);
align-items: center;
padding-top: var(--space-4);
}
.confirm-name {
font-size: var(--text-2xl);
font-weight: 700;
color: var(--color-text);
}
.confirm-details {
width: 100%;
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);
}
.btn-confirm {
width: 100%;
padding: var(--space-4) var(--space-6);
font-size: var(--text-lg);
font-weight: 700;
background-color: var(--color-primary);
color: var(--color-bg);
border: none;
border-radius: var(--radius-lg);
cursor: pointer;
transition: background-color var(--transition-fast), opacity var(--transition-fast);
}
.btn-confirm:hover:not(:disabled) {
background-color: color-mix(in srgb, var(--color-primary) 85%, white);
}
.btn-confirm:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style>

View file

@ -10,7 +10,13 @@
import FAB from '$lib/components/FAB.svelte';
import Toast from '$lib/components/Toast.svelte';
import TournamentTabs from '$lib/components/TournamentTabs.svelte';
import BuyInFlow from '$lib/components/BuyInFlow.svelte';
import BustOutFlow from '$lib/components/BustOutFlow.svelte';
import RebuyFlow from '$lib/components/RebuyFlow.svelte';
import AddOnFlow from '$lib/components/AddOnFlow.svelte';
import { toast } from '$lib/stores/toast.svelte';
import { tournament } from '$lib/stores/tournament.svelte';
import { api } from '$lib/api';
let { children } = $props();
@ -24,28 +30,50 @@
}
});
// Flow overlay state
let showBuyIn = $state(false);
let showBustOut = $state(false);
let showRebuy = $state(false);
let showAddon = $state(false);
/** Handle FAB action dispatches. */
function handleFABAction(actionId: string): void {
switch (actionId) {
case 'bust':
toast.info('Bust flow: coming in Plan N');
showBustOut = true;
break;
case 'buyin':
toast.info('Buy-in flow: coming in Plan N');
showBuyIn = true;
break;
case 'rebuy':
toast.info('Rebuy flow: coming in Plan N');
showRebuy = true;
break;
case 'addon':
toast.info('Add-on flow: coming in Plan N');
showAddon = true;
break;
case 'pause-resume':
toast.info('Pause/Resume: coming in Plan N');
togglePauseResume();
break;
default:
console.warn(`Unknown FAB action: ${actionId}`);
}
}
/** Toggle clock pause/resume. */
async function togglePauseResume(): Promise<void> {
if (!tournament.id || !tournament.clock) return;
try {
if (tournament.clock.is_paused) {
await api.post(`/tournaments/${tournament.id}/clock/resume`);
toast.success('Clock resumed');
} else {
await api.post(`/tournaments/${tournament.id}/clock/pause`);
toast.success('Clock paused');
}
} catch (err) {
toast.error(`Clock action failed: ${err instanceof Error ? err.message : 'Unknown error'}`);
}
}
</script>
{#if isLoginPage}
@ -72,6 +100,31 @@
</div>
{/if}
<!-- Flow overlays (triggered by FAB) -->
{#if showBuyIn}
<div class="flow-overlay">
<BuyInFlow onclose={() => { showBuyIn = false; }} />
</div>
{/if}
{#if showBustOut}
<div class="flow-overlay">
<BustOutFlow onclose={() => { showBustOut = false; }} />
</div>
{/if}
{#if showRebuy}
<div class="flow-overlay">
<RebuyFlow onclose={() => { showRebuy = false; }} />
</div>
{/if}
{#if showAddon}
<div class="flow-overlay">
<AddOnFlow onclose={() => { showAddon = false; }} />
</div>
{/if}
<!-- Toast notifications always visible -->
<Toast />
@ -107,4 +160,11 @@
min-height: 100dvh;
color: var(--color-text-muted);
}
.flow-overlay {
position: fixed;
inset: 0;
z-index: 100;
background-color: var(--color-bg);
}
</style>

View file

@ -1,51 +1,379 @@
<script lang="ts">
import { tournament } from '$lib/stores/tournament.svelte';
import type { Player } from '$lib/stores/tournament.svelte';
import DataTable from '$lib/components/DataTable.svelte';
import PlayerDetail from '$lib/components/PlayerDetail.svelte';
import BuyInFlow from '$lib/components/BuyInFlow.svelte';
import BustOutFlow from '$lib/components/BustOutFlow.svelte';
import RebuyFlow from '$lib/components/RebuyFlow.svelte';
import AddOnFlow from '$lib/components/AddOnFlow.svelte';
const columns = [
/**
* Players page — Active/Busted/All tabs with search,
* buy-in button, player detail, and swipe actions.
*/
// Tab state
type Tab = 'active' | 'busted' | 'all';
let activeTab = $state<Tab>('active');
// Overlay state
let showBuyIn = $state(false);
let showBustOut = $state(false);
let showRebuy = $state(false);
let showAddon = $state(false);
let selectedPlayer = $state<Player | null>(null);
let rebuyPlayer = $state<Player | null>(null);
let addonPlayer = $state<Player | null>(null);
// Columns per tab
const activeColumns = [
{ key: 'name', label: 'Name', sortable: true },
{ key: 'status', label: 'Status', sortable: true },
{ key: 'chips', label: 'Chips', sortable: true, align: 'right' as const, render: (p: Record<string, unknown>) => (p['chips'] as number).toLocaleString() },
{ key: 'table_id', label: 'Table', hideMobile: true, sortable: true },
{ key: 'seat', label: 'Seat', hideMobile: true, sortable: true, align: 'center' as const },
{ key: 'rebuys', label: 'Rebuys', hideMobile: true, sortable: true, align: 'center' as const }
{
key: 'table_seat', label: 'Table/Seat', sortable: true,
render: (p: Record<string, unknown>) => {
const tableId = p['table_id'] as string | null;
const seat = p['seat'] as number | null;
if (!tableId) return '-';
const table = tournament.tables.find((t) => t.id === tableId);
return `T${table?.number ?? '?'}/S${seat ?? '?'}`;
}
},
{
key: 'chips', label: 'Chips', sortable: true, align: 'right' as const,
render: (p: Record<string, unknown>) => (p['chips'] as number).toLocaleString()
},
{
key: 'rebuys', label: 'Rebuys', hideMobile: true, sortable: true, align: 'center' as const
},
{
key: 'status', label: 'Status', sortable: true,
render: (p: Record<string, unknown>) => {
const s = p['status'] as string;
return s.charAt(0).toUpperCase() + s.slice(1);
}
}
];
const bustedColumns = [
{ key: 'name', label: 'Name', sortable: true },
{
key: 'finish_position', label: 'Position', sortable: true, align: 'center' as const,
render: (p: Record<string, unknown>) => {
const pos = p['finish_position'] as number | null;
return pos ? ordinal(pos) : '-';
}
},
{
key: 'eliminated_by', label: 'Hitman', hideMobile: true, sortable: true,
render: (p: Record<string, unknown>) => {
const hitmanId = p['eliminated_by'] as string | null;
if (!hitmanId) return '-';
const hitman = tournament.players.find((pl) => pl.id === hitmanId);
return hitman?.name ?? '-';
}
}
];
const allColumns = [
{ key: 'name', label: 'Name', sortable: true },
{
key: 'status', label: 'Status', sortable: true,
render: (p: Record<string, unknown>) => {
const s = p['status'] as string;
return s.charAt(0).toUpperCase() + s.slice(1);
}
},
{
key: 'chips_or_position', label: 'Chips/Pos', sortable: true, align: 'right' as const,
render: (p: Record<string, unknown>) => {
if (p['status'] === 'active') return (p['chips'] as number).toLocaleString();
const pos = p['finish_position'] as number | null;
return pos ? ordinal(pos) : '-';
}
}
];
// Tab data
let tabData = $derived.by(() => {
switch (activeTab) {
case 'active':
return tournament.players.filter((p) => p.status === 'active');
case 'busted':
return tournament.players.filter((p) => p.status === 'eliminated');
case 'all':
return tournament.players;
}
});
let tabColumns = $derived.by(() => {
switch (activeTab) {
case 'active': return activeColumns;
case 'busted': return bustedColumns;
case 'all': return allColumns;
}
});
let emptyMessage = $derived.by(() => {
switch (activeTab) {
case 'active': return 'No active players';
case 'busted': return 'No eliminated players';
case 'all': return 'No players registered yet';
}
});
// Swipe actions per tab
let swipeActions = $derived.by(() => {
if (activeTab === 'active') {
return [
{
id: 'bust',
label: 'Bust',
color: 'var(--color-error)',
handler: (_item: Record<string, unknown>) => {
showBustOut = true;
}
},
{
id: 'rebuy',
label: 'Rebuy',
color: 'var(--color-primary)',
handler: (item: Record<string, unknown>) => {
const player = tournament.players.find((p) => p.id === item['id']);
if (player) {
rebuyPlayer = player;
showRebuy = true;
}
}
}
];
}
if (activeTab === 'busted') {
return [
{
id: 'undo-bust',
label: 'Undo Bust',
color: 'var(--color-success)',
handler: (_item: Record<string, unknown>) => {
// Undo bust is handled in player detail for now
}
}
];
}
return [];
});
// Tab counts
let activeCt = $derived(tournament.players.filter((p) => p.status === 'active').length);
let bustedCt = $derived(tournament.players.filter((p) => p.status === 'eliminated').length);
let allCt = $derived(tournament.players.length);
function handleRowClick(item: Record<string, unknown>): void {
const player = tournament.players.find((p) => p.id === item['id']);
if (player) selectedPlayer = player;
}
function handleRebuy(player: Player): void {
rebuyPlayer = player;
selectedPlayer = null;
showRebuy = true;
}
function handleAddon(player: Player): void {
addonPlayer = player;
selectedPlayer = null;
showAddon = true;
}
function ordinal(n: number): string {
const s = ['th', 'st', 'nd', 'rd'];
const v = n % 100;
return n + (s[(v - 20) % 10] || s[v] || s[0]);
}
</script>
<!-- Main players page -->
<div class="page-content">
<div class="page-header">
<h2>Players</h2>
<p class="text-secondary">Registered players and chip counts.</p>
<button class="buyin-btn touch-target" onclick={() => { showBuyIn = true; }}>
+ Buy In
</button>
</div>
<!-- Tab bar -->
<div class="tab-bar" role="tablist" aria-label="Player filter tabs">
<button
class="tab touch-target"
class:active={activeTab === 'active'}
role="tab"
aria-selected={activeTab === 'active'}
onclick={() => { activeTab = 'active'; }}
>
Active <span class="tab-count">{activeCt}</span>
</button>
<button
class="tab touch-target"
class:active={activeTab === 'busted'}
role="tab"
aria-selected={activeTab === 'busted'}
onclick={() => { activeTab = 'busted'; }}
>
Busted <span class="tab-count">{bustedCt}</span>
</button>
<button
class="tab touch-target"
class:active={activeTab === 'all'}
role="tab"
aria-selected={activeTab === 'all'}
onclick={() => { activeTab = 'all'; }}
>
All <span class="tab-count">{allCt}</span>
</button>
</div>
<!-- Data table -->
<DataTable
{columns}
data={tournament.players}
columns={tabColumns}
data={tabData as unknown as Record<string, unknown>[]}
sortable={true}
searchable={true}
loading={false}
emptyMessage="No players registered yet"
{emptyMessage}
rowKey={(item) => String(item['id'])}
swipeActions={[
{ id: 'bust', label: 'Bust', color: 'var(--color-error)', handler: () => {} },
{ id: 'rebuy', label: 'Rebuy', color: 'var(--color-primary)', handler: () => {} }
]}
onrowclick={handleRowClick}
{swipeActions}
/>
</div>
<!-- Overlays -->
{#if showBuyIn}
<div class="flow-overlay">
<BuyInFlow onclose={() => { showBuyIn = false; }} />
</div>
{/if}
{#if showBustOut}
<div class="flow-overlay">
<BustOutFlow onclose={() => { showBustOut = false; }} />
</div>
{/if}
{#if showRebuy}
<div class="flow-overlay">
<RebuyFlow
onclose={() => { showRebuy = false; rebuyPlayer = null; }}
preselectedPlayer={rebuyPlayer}
/>
</div>
{/if}
{#if showAddon}
<div class="flow-overlay">
<AddOnFlow
onclose={() => { showAddon = false; addonPlayer = null; }}
preselectedPlayer={addonPlayer}
/>
</div>
{/if}
{#if selectedPlayer}
<div class="flow-overlay">
<PlayerDetail
player={selectedPlayer}
onclose={() => { selectedPlayer = null; }}
onrebuy={handleRebuy}
onaddon={handleAddon}
/>
</div>
{/if}
<style>
.page-content {
padding: var(--space-4);
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-4);
}
h2 {
font-size: var(--text-2xl);
font-weight: 700;
color: var(--color-text);
margin-bottom: var(--space-2);
}
.text-secondary {
color: var(--color-text-secondary);
.buyin-btn {
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-2) var(--space-4);
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-bg);
background-color: var(--color-success);
border: none;
border-radius: var(--radius-lg);
cursor: pointer;
transition: background-color var(--transition-fast);
}
.buyin-btn:hover {
background-color: color-mix(in srgb, var(--color-success) 85%, white);
}
/* Tab bar */
.tab-bar {
display: flex;
gap: var(--space-1);
margin-bottom: var(--space-4);
background-color: var(--color-surface);
border-radius: var(--radius-lg);
padding: var(--space-1);
}
.tab {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-1);
padding: var(--space-2) var(--space-3);
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-text-muted);
background: none;
border: none;
border-radius: var(--radius-md);
cursor: pointer;
transition: color var(--transition-fast), background-color var(--transition-fast);
}
.tab:hover {
color: var(--color-text);
}
.tab.active {
color: var(--color-text);
background-color: var(--color-bg);
box-shadow: var(--shadow-sm);
}
.tab-count {
font-size: var(--text-xs);
font-weight: 700;
color: inherit;
opacity: 0.7;
}
/* Flow overlay */
.flow-overlay {
position: fixed;
inset: 0;
z-index: 100;
background-color: var(--color-bg);
}
</style>