felt/frontend/src/lib/components/BuyInFlow.svelte
Mikkel Georgsen 44b555db10 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>
2026-03-01 08:23:09 +01:00

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">&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>