Merge pull request 'Quick Split: chop a pot from your phone without setting up a table' (#15) from feature/casino-holdem-quick-split into master
Build and Deploy / build-deploy (push) Successful in 1m14s
Build and Deploy / build-deploy (push) Successful in 1m14s
Reviewed-on: #15
This commit was merged in pull request #15.
This commit is contained in:
@@ -19,6 +19,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
<a href="/casino/texasholdem/quicksplit" class="game-card">
|
||||||
|
<div class="game-icon">➗</div>
|
||||||
|
<div class="game-title">Quick Split</div>
|
||||||
|
<div class="game-description">
|
||||||
|
Chop a pot in seconds on your phone — no table setup, just the amount,
|
||||||
|
how many players are splitting it, and the smallest chip in play.
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
<div class="game-card disabled">
|
<div class="game-card disabled">
|
||||||
<div class="game-icon">🎲</div>
|
<div class="game-icon">🎲</div>
|
||||||
<div class="game-title">Craps</div>
|
<div class="game-title">Craps</div>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
<span id="handCounterBadge" class="handCounterBadge">Hand #1</span>
|
<span id="handCounterBadge" class="handCounterBadge">Hand #1</span>
|
||||||
</div>
|
</div>
|
||||||
<div id="holdemHeaderActions">
|
<div id="holdemHeaderActions">
|
||||||
|
<a href="/casino/texasholdem/quicksplit" class="holdemBtn" title="Chop a pot without setting up a table">Quick Split</a>
|
||||||
<button id="nextHandBtn" class="holdemBtn" title="Move the button, small blind and big blind to the next occupied seat">Next Hand ⟳</button>
|
<button id="nextHandBtn" class="holdemBtn" title="Move the button, small blind and big blind to the next occupied seat">Next Hand ⟳</button>
|
||||||
<button id="resolvePotBtn" class="holdemBtn holdemBtnAccent">Resolve Pot</button>
|
<button id="resolvePotBtn" class="holdemBtn holdemBtnAccent">Resolve Pot</button>
|
||||||
<button id="resetTableBtn" class="holdemBtn holdemBtnDanger">Reset Table</button>
|
<button id="resetTableBtn" class="holdemBtn holdemBtnDanger">Reset Table</button>
|
||||||
@@ -37,7 +38,11 @@
|
|||||||
|
|
||||||
<aside id="holdemSidebar">
|
<aside id="holdemSidebar">
|
||||||
<section id="seatEditorPanel" class="sidebarPanel seatEditorPanel">
|
<section id="seatEditorPanel" class="sidebarPanel seatEditorPanel">
|
||||||
|
<div class="seatSheetGrip" aria-hidden="true"></div>
|
||||||
|
<div class="seatEditorHeader">
|
||||||
<h2 id="seatEditorTitle">Select a seat</h2>
|
<h2 id="seatEditorTitle">Select a seat</h2>
|
||||||
|
<button id="closeSeatEditorBtn" class="seatSheetClose" aria-label="Done editing this seat">Done</button>
|
||||||
|
</div>
|
||||||
<div id="seatEditorBody">
|
<div id="seatEditorBody">
|
||||||
<p class="fieldHint">Click a seat at the table to edit their name, cards, chips and anything else.</p>
|
<p class="fieldHint">Click a seat at the table to edit their name, cards, chips and anything else.</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -205,6 +210,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<div id="toastHost" aria-live="polite"></div>
|
||||||
|
|
||||||
|
<div id="confirmOverlay" hidden>
|
||||||
|
<div id="confirmModal" role="alertdialog" aria-labelledby="confirmTitle" aria-describedby="confirmMessage" tabindex="-1">
|
||||||
|
<h2 id="confirmTitle"></h2>
|
||||||
|
<p id="confirmMessage"></p>
|
||||||
|
<div class="confirmActions">
|
||||||
|
<button id="confirmCancelBtn" class="holdemBtn holdemBtnGhost" type="button">Cancel</button>
|
||||||
|
<button id="confirmAcceptBtn" class="holdemBtn holdemBtnDanger" type="button">Confirm</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="potWizardOverlay" hidden>
|
<div id="potWizardOverlay" hidden>
|
||||||
<div id="potWizardModal">
|
<div id="potWizardModal">
|
||||||
<div class="potWizardHeader">
|
<div class="potWizardHeader">
|
||||||
@@ -220,6 +238,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@section Scripts {
|
@section Scripts {
|
||||||
|
<script src="~/js/casino/holdem/money.js?v=@ViewData["cssVersion"]"></script>
|
||||||
|
<script src="~/js/casino/holdem/feedback.js?v=@ViewData["cssVersion"]"></script>
|
||||||
<script src="~/js/casino/holdem/potCalculator.js?v=@ViewData["cssVersion"]"></script>
|
<script src="~/js/casino/holdem/potCalculator.js?v=@ViewData["cssVersion"]"></script>
|
||||||
<script src="~/js/casino/holdem/state.js?v=@ViewData["cssVersion"]"></script>
|
<script src="~/js/casino/holdem/state.js?v=@ViewData["cssVersion"]"></script>
|
||||||
<script src="~/js/casino/holdem/render.js?v=@ViewData["cssVersion"]"></script>
|
<script src="~/js/casino/holdem/render.js?v=@ViewData["cssVersion"]"></script>
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
@page
|
||||||
|
@model JoshHeaps.Net.Pages.Casino.TexasHoldem.QuickSplitModel
|
||||||
|
@{
|
||||||
|
ViewData["Title"] = "Quick Split";
|
||||||
|
Layout = "_Layout";
|
||||||
|
}
|
||||||
|
|
||||||
|
<div id="splitApp">
|
||||||
|
<header id="splitHeader">
|
||||||
|
<h1>Quick Split</h1>
|
||||||
|
<p class="splitSubtitle">Chop a pot without setting up a table.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div id="splitResult" aria-live="polite">
|
||||||
|
<div id="splitResultLabel">Each player gets</div>
|
||||||
|
<div id="splitResultAmount">$0.00</div>
|
||||||
|
<div id="splitOddNote" hidden></div>
|
||||||
|
<div id="splitDustNote" hidden></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="splitField">
|
||||||
|
<label for="potInput" class="splitLabel">Pot</label>
|
||||||
|
<div class="potInputWrap">
|
||||||
|
<span class="potCurrency">$</span>
|
||||||
|
<input type="text" id="potInput" inputmode="decimal" autocomplete="off"
|
||||||
|
placeholder="0" aria-label="Pot amount" />
|
||||||
|
<button type="button" id="clearPotBtn" class="clearPotBtn" aria-label="Clear pot amount" hidden>✕</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="splitField">
|
||||||
|
<span class="splitLabel" id="waysLabel">Split between</span>
|
||||||
|
<div id="waysChoices" class="chipRow" role="group" aria-labelledby="waysLabel"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="splitField">
|
||||||
|
<span class="splitLabel" id="chipLabel">Smallest chip in play</span>
|
||||||
|
<div id="chipChoices" class="chipRow" role="group" aria-labelledby="chipLabel"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div id="splitActions">
|
||||||
|
<button type="button" id="resetSplitBtn" class="splitBtn splitBtnGhost">Clear</button>
|
||||||
|
<a href="/casino/texasholdem" class="splitBtn splitBtnGhost">Full dealer table →</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="splitFootnote">
|
||||||
|
Uneven pots — someone all-in for less — need the side-pot walkthrough on the
|
||||||
|
<a href="/casino/texasholdem">full table</a>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@section Styles {
|
||||||
|
<link href="/css/casino/quicksplit.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
|
||||||
|
}
|
||||||
|
|
||||||
|
@section Scripts {
|
||||||
|
<script src="~/js/casino/holdem/money.js?v=@ViewData["cssVersion"]"></script>
|
||||||
|
<script src="~/js/casino/holdem/splitCalculator.js?v=@ViewData["cssVersion"]"></script>
|
||||||
|
<script src="~/js/casino/holdem/quickSplit.js?v=@ViewData["cssVersion"]"></script>
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||||
|
|
||||||
|
namespace JoshHeaps.Net.Pages.Casino.TexasHoldem
|
||||||
|
{
|
||||||
|
public class QuickSplitModel : PageModel
|
||||||
|
{
|
||||||
|
public void OnGet()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -376,6 +376,27 @@ body {
|
|||||||
padding: 0.9rem;
|
padding: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.seatEditorHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatEditorHeader h2 {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Grip and Done button are the sheet's furniture — no reason for either on a
|
||||||
|
desktop sidebar, where the panel is just a panel. */
|
||||||
|
.seatSheetGrip,
|
||||||
|
.seatSheetClose {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.seatEditorPanel h2 {
|
.seatEditorPanel h2 {
|
||||||
margin: 0 0 0.6rem;
|
margin: 0 0 0.6rem;
|
||||||
padding-bottom: 0.5rem;
|
padding-bottom: 0.5rem;
|
||||||
@@ -732,6 +753,135 @@ body {
|
|||||||
|
|
||||||
/* ---------- Pot wizard ---------- */
|
/* ---------- Pot wizard ---------- */
|
||||||
|
|
||||||
|
/* ---------- Toasts ---------- */
|
||||||
|
|
||||||
|
#toastHost {
|
||||||
|
position: fixed;
|
||||||
|
left: 50%;
|
||||||
|
bottom: 1.25rem;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 80;
|
||||||
|
width: min(560px, calc(100vw - 1.5rem));
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
background: linear-gradient(180deg, #2b2b2b, #202020);
|
||||||
|
border: 1px solid #414141;
|
||||||
|
border-left: 3px solid #d8b46a;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0.7rem 0.8rem 0.7rem 0.9rem;
|
||||||
|
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.55);
|
||||||
|
pointer-events: auto;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(12px);
|
||||||
|
transition: opacity 0.18s ease, transform 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast.toastVisible {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toastText {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
color: #e9e9e9;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toastAction {
|
||||||
|
flex: none;
|
||||||
|
min-height: 38px;
|
||||||
|
padding: 0.35rem 0.9rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #d8b46a;
|
||||||
|
background: rgba(216, 180, 106, 0.12);
|
||||||
|
color: #f0d9a8;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toastAction:hover {
|
||||||
|
background: rgba(216, 180, 106, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toastClose {
|
||||||
|
flex: none;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: #888;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toastClose:hover {
|
||||||
|
background: #333;
|
||||||
|
color: #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Confirmation dialog ---------- */
|
||||||
|
|
||||||
|
#confirmOverlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.72);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 90;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#confirmOverlay[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#confirmModal {
|
||||||
|
background: #1e1e1e;
|
||||||
|
border: 1px solid #3a3a3a;
|
||||||
|
border-radius: 12px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 420px;
|
||||||
|
padding: 1.3rem 1.4rem 1.2rem;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.6);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#confirmTitle {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
#confirmMessage {
|
||||||
|
margin: 0 0 1.2rem;
|
||||||
|
color: #a5a5a5;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirmActions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirmActions .holdemBtn {
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
#potWizardOverlay {
|
#potWizardOverlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
@@ -843,3 +993,206 @@ body {
|
|||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- Phones ----------
|
||||||
|
Two things break on a narrow screen: seat pucks ring the table on a radius
|
||||||
|
measured from the wrap, so the left- and right-most ones hang off the edge of
|
||||||
|
the viewport; and the seat editor lives far enough down the page that tapping a
|
||||||
|
seat looks like it did nothing. Inset the ring by half a puck, and float the
|
||||||
|
editor up as a bottom sheet over the table. */
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
#holdemApp {
|
||||||
|
padding: 1rem 0.75rem 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#holdemHeader {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#holdemHeader h1 {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.holdemSubtitle {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#holdemHeaderActions {
|
||||||
|
width: 100%;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#holdemHeaderActions .holdemBtn {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 46px;
|
||||||
|
padding: 0.5rem 0.7rem;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Keeps every puck inside the viewport: the ring is drawn within this box,
|
||||||
|
so half a puck of padding is exactly enough to stop the overhang. */
|
||||||
|
#seatsLayer {
|
||||||
|
inset: 26px 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Narrower than the desktop puck: at a full ten-handed table the ring is
|
||||||
|
flattest at the top and bottom, and wide pucks are what made neighbours
|
||||||
|
overlap there. The puck is only a summary — the real form is the sheet. */
|
||||||
|
.seat {
|
||||||
|
width: 64px;
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 0.25rem 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#tableWatermark {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#potBadge {
|
||||||
|
padding: 0.5rem 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#potBadgeAmount {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableHint {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Seat editor as a bottom sheet ---- */
|
||||||
|
|
||||||
|
#seatEditorPanel {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 60;
|
||||||
|
margin: 0;
|
||||||
|
max-height: 74vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
border-radius: 16px 16px 0 0;
|
||||||
|
border-bottom: none;
|
||||||
|
padding: 0.35rem 1rem 1.25rem;
|
||||||
|
background: linear-gradient(180deg, #242424, #1a1a1a);
|
||||||
|
box-shadow: 0 -14px 40px rgba(0, 0, 0, 0.65);
|
||||||
|
transform: translateY(105%);
|
||||||
|
transition: transform 0.25s ease;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.seatSheetOpen #seatEditorPanel {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatSheetGrip {
|
||||||
|
display: block;
|
||||||
|
width: 38px;
|
||||||
|
height: 4px;
|
||||||
|
margin: 0.35rem auto 0.5rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #4a4a4a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatSheetClose {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
flex: none;
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 0.35rem 1.1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #3a3a3a;
|
||||||
|
background: linear-gradient(180deg, #2e2e2e, #232323);
|
||||||
|
color: #ddd;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The sheet covers the bottom of the page while it's open — give the sidebar
|
||||||
|
room so nothing is stranded underneath it. */
|
||||||
|
body.seatSheetOpen #holdemSidebar {
|
||||||
|
padding-bottom: 40vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The seat form was sized for a 320px desktop sidebar. In the sheet it has a
|
||||||
|
whole phone width to work with, so everything grows to thumb size — and
|
||||||
|
every field goes to 16px, below which iOS zooms the page on focus and the
|
||||||
|
dealer has to pinch back out mid-hand. */
|
||||||
|
.seatEditorForm .seatNameInput,
|
||||||
|
.seatEditorForm .amountOverrideInput,
|
||||||
|
.seatEditorForm .chipCountInput,
|
||||||
|
.seatEditorForm .customFieldLabel,
|
||||||
|
.seatEditorForm .customFieldValue {
|
||||||
|
font-size: 16px;
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatEditorForm .chipCountInput {
|
||||||
|
width: 56px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatEditorForm .chipCounts {
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatEditorForm .chipSwatch {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatEditorForm .amountOverrideInput {
|
||||||
|
width: 110px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatEditorForm .cardInput {
|
||||||
|
font-size: 1.35rem;
|
||||||
|
min-height: 58px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatEditorForm .seatCardToggles {
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatEditorForm .inHandToggle,
|
||||||
|
.seatEditorForm .foldToggle {
|
||||||
|
min-height: 44px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatEditorForm .inHandToggle input,
|
||||||
|
.seatEditorForm .foldToggle input {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatEditorForm .amountOverrideRow,
|
||||||
|
.seatEditorForm .seatTotal {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatEditorForm .addFieldBtn {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 44px;
|
||||||
|
margin-top: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toastAction {
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirmActions {
|
||||||
|
flex-direction: column-reverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirmActions .holdemBtn {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,301 @@
|
|||||||
|
/* Quick Split is phone-first: sized for one thumb at the table, then allowed to
|
||||||
|
breathe on a laptop. Palette matches the dealer table so the two read as one tool. */
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: radial-gradient(ellipse at 50% -10%, #1c1c1c 0%, #101010 55%, #0a0a0a 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
color: #e6e6e6;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
#splitApp {
|
||||||
|
max-width: 480px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1.25rem 1rem 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Header ---------- */
|
||||||
|
|
||||||
|
#splitHeader h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 300;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: #f5f5f5;
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
padding-bottom: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#splitHeader h1::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 36px;
|
||||||
|
height: 3px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: linear-gradient(90deg, #d8b46a, #a9782f);
|
||||||
|
}
|
||||||
|
|
||||||
|
.splitSubtitle {
|
||||||
|
margin: 0.35rem 0 1.25rem;
|
||||||
|
color: #8f8f8f;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- The answer ---------- */
|
||||||
|
|
||||||
|
#splitResult {
|
||||||
|
background: linear-gradient(160deg, #1f2a21, #161c17);
|
||||||
|
border: 1px solid #2c3a2f;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 1.4rem 1.1rem;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
#splitResult.hasAmount {
|
||||||
|
border-color: #3c6b46;
|
||||||
|
box-shadow: 0 0 0 1px rgba(78, 201, 122, 0.08), 0 6px 20px rgba(0, 0, 0, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
#splitResultLabel {
|
||||||
|
color: #8f9d92;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
#splitResultAmount {
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
font-size: clamp(2.6rem, 14vw, 3.4rem);
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.05;
|
||||||
|
color: #f2f2f2;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
#splitResult.hasAmount #splitResultAmount {
|
||||||
|
color: #7ee2a0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#splitOddNote {
|
||||||
|
margin-top: 0.7rem;
|
||||||
|
color: #d8b46a;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
#splitDustNote {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
color: #e0a1a1;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Inputs ---------- */
|
||||||
|
|
||||||
|
.splitField {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 1.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.splitLabel {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
color: #9a9a9a;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.potInputWrap {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
background: #141414;
|
||||||
|
border: 1px solid #3a3a3a;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 0.2rem 0.75rem;
|
||||||
|
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.potInputWrap:focus-within {
|
||||||
|
border-color: #d8b46a;
|
||||||
|
box-shadow: 0 0 0 3px rgba(216, 180, 106, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.potCurrency {
|
||||||
|
color: #777;
|
||||||
|
font-size: 1.6rem;
|
||||||
|
font-weight: 300;
|
||||||
|
}
|
||||||
|
|
||||||
|
#potInput {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
color: #f2f2f2;
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 500;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
padding: 0.55rem 0;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
#potInput::placeholder {
|
||||||
|
color: #4a4a4a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clearPotBtn {
|
||||||
|
flex: none;
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: none;
|
||||||
|
background: #262626;
|
||||||
|
color: #999;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clearPotBtn:hover {
|
||||||
|
background: #333;
|
||||||
|
color: #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Tap targets ---------- */
|
||||||
|
|
||||||
|
.chipRow {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.choiceChip {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 58px;
|
||||||
|
min-height: 52px;
|
||||||
|
padding: 0.5rem 0.7rem;
|
||||||
|
background: linear-gradient(180deg, #2e2e2e, #232323);
|
||||||
|
border: 1px solid #3a3a3a;
|
||||||
|
border-radius: 11px;
|
||||||
|
color: #ddd;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.choiceChip:hover {
|
||||||
|
border-color: #4a4a4a;
|
||||||
|
background: linear-gradient(180deg, #383838, #2a2a2a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.choiceChip.selected {
|
||||||
|
background: linear-gradient(180deg, #3a2f18, #241d0f);
|
||||||
|
border-color: #d8b46a;
|
||||||
|
color: #f0d9a8;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.waysStepper {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 150px;
|
||||||
|
background: #191919;
|
||||||
|
border: 1px solid #333;
|
||||||
|
border-radius: 11px;
|
||||||
|
padding: 0.2rem 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepBtn {
|
||||||
|
width: 40px;
|
||||||
|
height: 44px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #262626;
|
||||||
|
color: #ddd;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
line-height: 1;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepBtn:hover {
|
||||||
|
background: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.waysReadout {
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
|
color: #999;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Footer actions ---------- */
|
||||||
|
|
||||||
|
#splitActions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.6rem;
|
||||||
|
margin-top: 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.splitBtn {
|
||||||
|
flex: 1;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 48px;
|
||||||
|
padding: 0.6rem 0.9rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid #3a3a3a;
|
||||||
|
background: linear-gradient(180deg, #2e2e2e, #232323);
|
||||||
|
color: #ddd;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-family: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.splitBtn:hover {
|
||||||
|
border-color: #4a4a4a;
|
||||||
|
background: linear-gradient(180deg, #383838, #2a2a2a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.splitBtnGhost {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.splitFootnote {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
color: #6f6f6f;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.splitFootnote a {
|
||||||
|
color: #a9782f;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Roomier once there's a real screen to work with. */
|
||||||
|
@media (min-width: 700px) {
|
||||||
|
#splitApp {
|
||||||
|
padding-top: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#splitHeader h1 {
|
||||||
|
font-size: 1.8rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
// Toasts and confirmation dialogs, so the table never has to fall back on the
|
||||||
|
// browser's own alert boxes. A system confirm() steals focus, looks nothing like
|
||||||
|
// the rest of the page, and on a phone it's a full-screen interruption — all of
|
||||||
|
// which is a bad trade for a dealer trying to keep a game moving.
|
||||||
|
|
||||||
|
// A short-lived message at the bottom of the screen, optionally with one action
|
||||||
|
// (in practice: Undo). Only one is ever on screen; a second replaces the first.
|
||||||
|
const Toast = {
|
||||||
|
DEFAULT_DURATION_MS: 8000,
|
||||||
|
timer: null,
|
||||||
|
|
||||||
|
show(message, options = {}) {
|
||||||
|
const host = document.getElementById("toastHost");
|
||||||
|
if (!host) return;
|
||||||
|
|
||||||
|
this.dismiss();
|
||||||
|
|
||||||
|
const toast = document.createElement("div");
|
||||||
|
toast.className = "toast";
|
||||||
|
toast.setAttribute("role", "status");
|
||||||
|
|
||||||
|
const text = document.createElement("span");
|
||||||
|
text.className = "toastText";
|
||||||
|
text.textContent = message;
|
||||||
|
toast.appendChild(text);
|
||||||
|
|
||||||
|
if (options.actionLabel && options.onAction) {
|
||||||
|
const action = document.createElement("button");
|
||||||
|
action.type = "button";
|
||||||
|
action.className = "toastAction";
|
||||||
|
action.textContent = options.actionLabel;
|
||||||
|
action.addEventListener("click", () => {
|
||||||
|
this.dismiss();
|
||||||
|
options.onAction();
|
||||||
|
});
|
||||||
|
toast.appendChild(action);
|
||||||
|
}
|
||||||
|
|
||||||
|
const close = document.createElement("button");
|
||||||
|
close.type = "button";
|
||||||
|
close.className = "toastClose";
|
||||||
|
close.setAttribute("aria-label", "Dismiss");
|
||||||
|
close.textContent = "✕";
|
||||||
|
close.addEventListener("click", () => this.dismiss());
|
||||||
|
toast.appendChild(close);
|
||||||
|
|
||||||
|
host.appendChild(toast);
|
||||||
|
// Next frame, so the entrance transition actually runs.
|
||||||
|
requestAnimationFrame(() => toast.classList.add("toastVisible"));
|
||||||
|
|
||||||
|
this.timer = setTimeout(
|
||||||
|
() => this.dismiss(),
|
||||||
|
options.duration || this.DEFAULT_DURATION_MS
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
dismiss() {
|
||||||
|
if (this.timer) {
|
||||||
|
clearTimeout(this.timer);
|
||||||
|
this.timer = null;
|
||||||
|
}
|
||||||
|
const host = document.getElementById("toastHost");
|
||||||
|
if (host) host.innerHTML = "";
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Styled stand-in for confirm(). Resolves true if the dealer goes through with it.
|
||||||
|
const Dialog = {
|
||||||
|
confirm({ title, message, confirmLabel = "Confirm", cancelLabel = "Cancel", danger = false }) {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
const overlay = document.getElementById("confirmOverlay");
|
||||||
|
const modal = overlay.querySelector("#confirmModal");
|
||||||
|
overlay.querySelector("#confirmTitle").textContent = title;
|
||||||
|
overlay.querySelector("#confirmMessage").textContent = message;
|
||||||
|
|
||||||
|
const confirmBtn = overlay.querySelector("#confirmAcceptBtn");
|
||||||
|
const cancelBtn = overlay.querySelector("#confirmCancelBtn");
|
||||||
|
confirmBtn.textContent = confirmLabel;
|
||||||
|
cancelBtn.textContent = cancelLabel;
|
||||||
|
confirmBtn.classList.toggle("holdemBtnDanger", danger);
|
||||||
|
confirmBtn.classList.toggle("holdemBtnAccent", !danger);
|
||||||
|
|
||||||
|
const finish = result => {
|
||||||
|
overlay.hidden = true;
|
||||||
|
confirmBtn.removeEventListener("click", onAccept);
|
||||||
|
cancelBtn.removeEventListener("click", onCancel);
|
||||||
|
overlay.removeEventListener("click", onBackdrop);
|
||||||
|
document.removeEventListener("keydown", onKey);
|
||||||
|
resolve(result);
|
||||||
|
};
|
||||||
|
const onAccept = () => finish(true);
|
||||||
|
const onCancel = () => finish(false);
|
||||||
|
const onBackdrop = e => {
|
||||||
|
if (e.target === overlay) finish(false);
|
||||||
|
};
|
||||||
|
const onKey = e => {
|
||||||
|
if (e.key === "Escape") finish(false);
|
||||||
|
if (e.key === "Enter") finish(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
confirmBtn.addEventListener("click", onAccept);
|
||||||
|
cancelBtn.addEventListener("click", onCancel);
|
||||||
|
overlay.addEventListener("click", onBackdrop);
|
||||||
|
document.addEventListener("keydown", onKey);
|
||||||
|
|
||||||
|
overlay.hidden = false;
|
||||||
|
modal.focus();
|
||||||
|
confirmBtn.focus();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof module !== "undefined" && module.exports) {
|
||||||
|
module.exports = { Toast, Dialog };
|
||||||
|
}
|
||||||
@@ -8,6 +8,29 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
|
|
||||||
Render.mount(state, hooks);
|
Render.mount(state, hooks);
|
||||||
|
|
||||||
|
// Moving to the next hand used to ask "are you sure?" every single time —
|
||||||
|
// the most-pressed button in a session, gated behind a system dialog. It now
|
||||||
|
// just happens, and the toast offers the way back for the few seconds anyone
|
||||||
|
// would notice a misclick. The snapshot is deliberately in-memory only: an
|
||||||
|
// undo is for the mistake you just made, not one from last night's session.
|
||||||
|
const runUndoable = (message, mutate) => {
|
||||||
|
const snapshot = JSON.parse(JSON.stringify(state));
|
||||||
|
mutate();
|
||||||
|
hooks.save(state);
|
||||||
|
Render.renderAll();
|
||||||
|
|
||||||
|
Toast.show(message, {
|
||||||
|
actionLabel: "Undo",
|
||||||
|
onAction: () => {
|
||||||
|
Object.keys(state).forEach(key => delete state[key]);
|
||||||
|
Object.assign(state, snapshot);
|
||||||
|
hooks.save(state);
|
||||||
|
Render.renderAll();
|
||||||
|
Toast.show("Undone.", { duration: 2500 });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
document.getElementById("seatCountInput").addEventListener("change", e => {
|
document.getElementById("seatCountInput").addEventListener("change", e => {
|
||||||
HoldemState.resizeSeats(state, Number(e.target.value));
|
HoldemState.resizeSeats(state, Number(e.target.value));
|
||||||
hooks.save(state);
|
hooks.save(state);
|
||||||
@@ -49,38 +72,63 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById("nextHandBtn").addEventListener("click", () => {
|
document.getElementById("nextHandBtn").addEventListener("click", () => {
|
||||||
if (!confirm("Start the next hand? This moves the button, clears cards and chip counts for every seat.")) return;
|
// startNextHand moves the button on by one; say where it landed so the
|
||||||
HoldemState.startNextHand(state);
|
// dealer can confirm the rotation without hunting for the badge.
|
||||||
hooks.save(state);
|
const nextDealer = HoldemState.nextSeatIndex(state, state.dealerSeatIndex, 1);
|
||||||
Render.renderAll();
|
const where = state.dedicatedDealer
|
||||||
|
? `small blind on seat ${HoldemState.nextSeatIndex(state, nextDealer, 1) + 1}`
|
||||||
|
: `button on seat ${nextDealer + 1}`;
|
||||||
|
|
||||||
|
runUndoable(`Next hand — ${where}. Cards and chip counts cleared.`, () =>
|
||||||
|
HoldemState.startNextHand(state)
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById("resetTableBtn").addEventListener("click", () => {
|
document.getElementById("resetTableBtn").addEventListener("click", async () => {
|
||||||
if (!confirm("Reset the whole table? This clears every seat, chip legend and note.")) return;
|
const ok = await Dialog.confirm({
|
||||||
|
title: "Reset the whole table?",
|
||||||
|
message: "Every seat, chip colour and note goes back to defaults. The hand history and session standings are cleared too.",
|
||||||
|
confirmLabel: "Reset table",
|
||||||
|
danger: true,
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
|
||||||
HoldemState.clear();
|
HoldemState.clear();
|
||||||
Object.assign(state, HoldemState.createDefault());
|
Object.assign(state, HoldemState.createDefault());
|
||||||
Render.mount(state, hooks);
|
Render.mount(state, hooks);
|
||||||
|
Toast.show("Table reset.", { duration: 2500 });
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById("resolvePotBtn").addEventListener("click", () => {
|
document.getElementById("resolvePotBtn").addEventListener("click", () => {
|
||||||
PotWizard.open(state, payouts => {
|
PotWizard.open(state, payouts => {
|
||||||
|
const paid = payouts.map(p => `${p.label} ${Money.format(p.amount)}`).join(", ");
|
||||||
|
runUndoable(paid ? `Paid ${paid}.` : "Hand closed with nothing to pay out.", () => {
|
||||||
if (payouts.length > 0) HoldemState.recordHand(state, payouts);
|
if (payouts.length > 0) HoldemState.recordHand(state, payouts);
|
||||||
HoldemState.startNextHand(state);
|
HoldemState.startNextHand(state);
|
||||||
hooks.save(state);
|
|
||||||
Render.renderAll();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("closeSeatEditorBtn").addEventListener("click", () => Render.closeSeatEditor());
|
||||||
|
|
||||||
document.getElementById("closePotWizardBtn").addEventListener("click", () => PotWizard.close());
|
document.getElementById("closePotWizardBtn").addEventListener("click", () => PotWizard.close());
|
||||||
document.getElementById("potWizardOverlay").addEventListener("click", e => {
|
document.getElementById("potWizardOverlay").addEventListener("click", e => {
|
||||||
if (e.target.id === "potWizardOverlay") PotWizard.close();
|
if (e.target.id === "potWizardOverlay") PotWizard.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById("clearHistoryBtn").addEventListener("click", () => {
|
document.getElementById("clearHistoryBtn").addEventListener("click", async () => {
|
||||||
if (!confirm("Clear the hand history and reset the hand counter to 1? This can't be undone.")) return;
|
const ok = await Dialog.confirm({
|
||||||
|
title: "Clear the hand history?",
|
||||||
|
message: "This wipes every logged hand and the session standings, and puts the hand counter back to #1. Export first if anyone might want the numbers.",
|
||||||
|
confirmLabel: "Clear history",
|
||||||
|
danger: true,
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
|
||||||
HoldemState.clearHistory(state);
|
HoldemState.clearHistory(state);
|
||||||
hooks.save(state);
|
hooks.save(state);
|
||||||
Render.renderAll();
|
Render.renderAll();
|
||||||
|
Toast.show("Hand history cleared.", { duration: 2500 });
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById("exportHistoryBtn").addEventListener("click", () => {
|
document.getElementById("exportHistoryBtn").addEventListener("click", () => {
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
// Shared money formatting for every casino surface. Kept apart from the table's
|
||||||
|
// rendering so the quick-split calculator can pull in the formatter without dragging
|
||||||
|
// the whole seat/chip UI along with it.
|
||||||
|
const Money = {
|
||||||
|
format(amount) {
|
||||||
|
const value = Number(amount) || 0;
|
||||||
|
return `$${value.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||||
|
},
|
||||||
|
|
||||||
|
// "+$40.00" / "-$40.00" rather than "$-40.00" — a standings column is read at a
|
||||||
|
// glance, and the sign belongs out front where the eye lands first.
|
||||||
|
formatSigned(amount) {
|
||||||
|
const value = Number(amount) || 0;
|
||||||
|
if (value > 0) return `+${this.format(value)}`;
|
||||||
|
if (value < 0) return `-${this.format(Math.abs(value))}`;
|
||||||
|
return this.format(0);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof module !== "undefined" && module.exports) {
|
||||||
|
module.exports = Money;
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
// The quick-split screen: pot in, payout out, nothing to set up and nothing saved.
|
||||||
|
// A dealer opens this one-handed with players waiting, so every control is a tap and
|
||||||
|
// the answer updates as they type rather than behind a "calculate" button.
|
||||||
|
const QuickSplit = {
|
||||||
|
WAY_CHOICES: [2, 3, 4, 5],
|
||||||
|
CHIP_CHOICES: [
|
||||||
|
{ value: 0.01, label: "1¢" },
|
||||||
|
{ value: 0.25, label: "25¢" },
|
||||||
|
{ value: 1, label: "$1" },
|
||||||
|
{ value: 5, label: "$5" },
|
||||||
|
{ value: 25, label: "$25" },
|
||||||
|
],
|
||||||
|
MAX_WAYS: 12,
|
||||||
|
|
||||||
|
ways: 2,
|
||||||
|
chip: 1,
|
||||||
|
|
||||||
|
mount() {
|
||||||
|
this.potInput = document.getElementById("potInput");
|
||||||
|
this.clearPotBtn = document.getElementById("clearPotBtn");
|
||||||
|
|
||||||
|
this.renderWayChoices();
|
||||||
|
this.renderChipChoices();
|
||||||
|
|
||||||
|
this.potInput.addEventListener("input", () => {
|
||||||
|
this.sanitizePotInput();
|
||||||
|
this.update();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.clearPotBtn.addEventListener("click", () => {
|
||||||
|
this.potInput.value = "";
|
||||||
|
this.potInput.focus();
|
||||||
|
this.update();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("resetSplitBtn").addEventListener("click", () => {
|
||||||
|
this.potInput.value = "";
|
||||||
|
this.ways = 2;
|
||||||
|
this.chip = 1;
|
||||||
|
this.renderWayChoices();
|
||||||
|
this.renderChipChoices();
|
||||||
|
this.potInput.focus();
|
||||||
|
this.update();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.update();
|
||||||
|
},
|
||||||
|
|
||||||
|
// Keeps the field to something parseable without fighting the typist: digits and a
|
||||||
|
// single decimal point, at most two places.
|
||||||
|
sanitizePotInput() {
|
||||||
|
const cleaned = this.potInput.value
|
||||||
|
.replace(/[^0-9.]/g, "")
|
||||||
|
.replace(/(\..*)\./g, "$1")
|
||||||
|
.replace(/(\.\d\d)\d+/, "$1");
|
||||||
|
if (cleaned !== this.potInput.value) this.potInput.value = cleaned;
|
||||||
|
},
|
||||||
|
|
||||||
|
makeChoiceButton(label, isSelected, onPick) {
|
||||||
|
const btn = document.createElement("button");
|
||||||
|
btn.type = "button";
|
||||||
|
btn.className = "choiceChip";
|
||||||
|
btn.textContent = label;
|
||||||
|
btn.setAttribute("aria-pressed", String(isSelected));
|
||||||
|
btn.classList.toggle("selected", isSelected);
|
||||||
|
btn.addEventListener("click", onPick);
|
||||||
|
return btn;
|
||||||
|
},
|
||||||
|
|
||||||
|
renderWayChoices() {
|
||||||
|
const row = document.getElementById("waysChoices");
|
||||||
|
row.innerHTML = "";
|
||||||
|
|
||||||
|
this.WAY_CHOICES.forEach(count => {
|
||||||
|
row.appendChild(
|
||||||
|
this.makeChoiceButton(String(count), this.ways === count, () => {
|
||||||
|
this.ways = count;
|
||||||
|
this.renderWayChoices();
|
||||||
|
this.update();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Beyond a five-way chop it's rare enough to hide behind a stepper rather than
|
||||||
|
// spend a row of buttons on it.
|
||||||
|
const more = document.createElement("div");
|
||||||
|
more.className = "waysStepper";
|
||||||
|
|
||||||
|
const minus = this.makeStepButton("−", () => this.stepWays(-1));
|
||||||
|
const plus = this.makeStepButton("+", () => this.stepWays(1));
|
||||||
|
const readout = document.createElement("span");
|
||||||
|
readout.className = "waysReadout";
|
||||||
|
readout.textContent = `${this.ways} ways`;
|
||||||
|
|
||||||
|
more.appendChild(minus);
|
||||||
|
more.appendChild(readout);
|
||||||
|
more.appendChild(plus);
|
||||||
|
row.appendChild(more);
|
||||||
|
},
|
||||||
|
|
||||||
|
makeStepButton(label, onPick) {
|
||||||
|
const btn = document.createElement("button");
|
||||||
|
btn.type = "button";
|
||||||
|
btn.className = "stepBtn";
|
||||||
|
btn.textContent = label;
|
||||||
|
btn.addEventListener("click", onPick);
|
||||||
|
return btn;
|
||||||
|
},
|
||||||
|
|
||||||
|
stepWays(delta) {
|
||||||
|
this.ways = Math.max(2, Math.min(this.MAX_WAYS, this.ways + delta));
|
||||||
|
this.renderWayChoices();
|
||||||
|
this.update();
|
||||||
|
},
|
||||||
|
|
||||||
|
renderChipChoices() {
|
||||||
|
const row = document.getElementById("chipChoices");
|
||||||
|
row.innerHTML = "";
|
||||||
|
this.CHIP_CHOICES.forEach(choice => {
|
||||||
|
row.appendChild(
|
||||||
|
this.makeChoiceButton(choice.label, this.chip === choice.value, () => {
|
||||||
|
this.chip = choice.value;
|
||||||
|
this.renderChipChoices();
|
||||||
|
this.update();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
update() {
|
||||||
|
const potValue = Number(this.potInput.value);
|
||||||
|
const pot = Number.isNaN(potValue) ? 0 : potValue;
|
||||||
|
this.clearPotBtn.hidden = this.potInput.value === "";
|
||||||
|
|
||||||
|
const result = SplitCalculator.split(pot, this.ways, this.chip);
|
||||||
|
|
||||||
|
document.getElementById("splitResultLabel").textContent =
|
||||||
|
`Each of ${result.players} players gets`;
|
||||||
|
document.getElementById("splitResultAmount").textContent = Money.format(result.each);
|
||||||
|
|
||||||
|
const oddNote = document.getElementById("splitOddNote");
|
||||||
|
if (result.valid && result.oddChips > 0) {
|
||||||
|
oddNote.textContent = result.oddChips === 1
|
||||||
|
? `plus one odd ${Money.format(result.chip)} chip to the first player left of the button`
|
||||||
|
: `plus ${result.oddChips} odd ${Money.format(result.chip)} chips — one each to the ` +
|
||||||
|
`first ${result.oddChips} players left of the button`;
|
||||||
|
oddNote.hidden = false;
|
||||||
|
} else {
|
||||||
|
oddNote.hidden = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dustNote = document.getElementById("splitDustNote");
|
||||||
|
if (result.valid && result.dust > 0) {
|
||||||
|
dustNote.textContent =
|
||||||
|
`${Money.format(result.dust)} doesn't divide into ${Money.format(result.chip)} chips — ` +
|
||||||
|
`pick a smaller denomination.`;
|
||||||
|
dustNote.hidden = false;
|
||||||
|
} else {
|
||||||
|
dustNote.hidden = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("splitResult").classList.toggle("hasAmount", result.valid && result.each > 0);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", () => QuickSplit.mount());
|
||||||
@@ -106,7 +106,10 @@ const Render = {
|
|||||||
node.classList.toggle("seatSelected", index === this.selectedSeatIndex);
|
node.classList.toggle("seatSelected", index === this.selectedSeatIndex);
|
||||||
|
|
||||||
node.addEventListener("click", () => {
|
node.addEventListener("click", () => {
|
||||||
this.selectedSeatIndex = index;
|
// Tapping the open seat again closes it — on a phone the editor is a
|
||||||
|
// sheet over the table, and the seat you just tapped is the closest
|
||||||
|
// thing to hand for dismissing it.
|
||||||
|
this.selectedSeatIndex = this.selectedSeatIndex === index ? null : index;
|
||||||
this.renderSeats();
|
this.renderSeats();
|
||||||
this.renderSeatEditor();
|
this.renderSeatEditor();
|
||||||
});
|
});
|
||||||
@@ -175,10 +178,20 @@ const Render = {
|
|||||||
|
|
||||||
// ---- Seat editor panel (the full form for whichever seat is selected) ----
|
// ---- Seat editor panel (the full form for whichever seat is selected) ----
|
||||||
|
|
||||||
|
closeSeatEditor() {
|
||||||
|
this.selectedSeatIndex = null;
|
||||||
|
this.renderSeats();
|
||||||
|
this.renderSeatEditor();
|
||||||
|
},
|
||||||
|
|
||||||
renderSeatEditor() {
|
renderSeatEditor() {
|
||||||
const title = document.getElementById("seatEditorTitle");
|
const title = document.getElementById("seatEditorTitle");
|
||||||
const body = document.getElementById("seatEditorBody");
|
const body = document.getElementById("seatEditorBody");
|
||||||
|
|
||||||
|
// Drives the mobile bottom sheet: the panel only slides up over the table
|
||||||
|
// while a seat is actually being edited.
|
||||||
|
document.body.classList.toggle("seatSheetOpen", this.selectedSeatIndex !== null);
|
||||||
|
|
||||||
if (this.selectedSeatIndex === null) {
|
if (this.selectedSeatIndex === null) {
|
||||||
title.textContent = "Select a seat";
|
title.textContent = "Select a seat";
|
||||||
body.innerHTML = '<p class="fieldHint">Click a seat at the table to edit their name, cards, chips and anything else.</p>';
|
body.innerHTML = '<p class="fieldHint">Click a seat at the table to edit their name, cards, chips and anything else.</p>';
|
||||||
@@ -415,22 +428,6 @@ const Render = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const Money = {
|
|
||||||
format(amount) {
|
|
||||||
const value = Number(amount) || 0;
|
|
||||||
return `$${value.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
|
||||||
},
|
|
||||||
|
|
||||||
// "+$40.00" / "-$40.00" rather than "$-40.00" — a standings column is read at a
|
|
||||||
// glance, and the sign belongs out front where the eye lands first.
|
|
||||||
formatSigned(amount) {
|
|
||||||
const value = Number(amount) || 0;
|
|
||||||
if (value > 0) return `+${this.format(value)}`;
|
|
||||||
if (value < 0) return `-${this.format(Math.abs(value))}`;
|
|
||||||
return this.format(0);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
if (typeof module !== "undefined" && module.exports) {
|
if (typeof module !== "undefined" && module.exports) {
|
||||||
module.exports = { Render, Money };
|
module.exports = { Render };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
// Pure math for chopping one pot between tied players. No DOM.
|
||||||
|
//
|
||||||
|
// A dealer can't push half a chip across the felt, so this works in whole chips of
|
||||||
|
// whatever the smallest denomination in play is: everyone gets the same stack, and
|
||||||
|
// whatever won't divide is called out separately as odd chips. House rule for those
|
||||||
|
// is that they go to the first player left of the button, one at a time.
|
||||||
|
const SplitCalculator = {
|
||||||
|
// Everything is done in cents to keep 0.1 + 0.2 out of a payout.
|
||||||
|
toCents(amount) {
|
||||||
|
return Math.round((Number(amount) || 0) * 100);
|
||||||
|
},
|
||||||
|
|
||||||
|
split(potAmount, ways, chipAmount) {
|
||||||
|
const potCents = this.toCents(potAmount);
|
||||||
|
const chipCents = Math.max(1, this.toCents(chipAmount));
|
||||||
|
const players = Math.max(1, Math.floor(Number(ways) || 0));
|
||||||
|
|
||||||
|
if (potCents <= 0) {
|
||||||
|
return { valid: false, each: 0, oddChips: 0, chip: chipCents / 100, dust: 0, players };
|
||||||
|
}
|
||||||
|
|
||||||
|
const wholeChips = Math.floor(potCents / chipCents);
|
||||||
|
// Left over when the pot itself isn't a whole number of the smallest chip —
|
||||||
|
// usually means the dealer picked the wrong denomination, so it's surfaced
|
||||||
|
// rather than silently rolled into someone's stack.
|
||||||
|
const dustCents = potCents - wholeChips * chipCents;
|
||||||
|
|
||||||
|
const chipsEach = Math.floor(wholeChips / players);
|
||||||
|
const oddChips = wholeChips - chipsEach * players;
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: true,
|
||||||
|
players,
|
||||||
|
each: (chipsEach * chipCents) / 100,
|
||||||
|
oddChips,
|
||||||
|
chip: chipCents / 100,
|
||||||
|
dust: dustCents / 100,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// Sanity check for the UI: what the split actually hands out has to equal the pot.
|
||||||
|
accountedFor(result) {
|
||||||
|
return (
|
||||||
|
Math.round(result.each * 100) * result.players +
|
||||||
|
Math.round(result.chip * 100) * result.oddChips +
|
||||||
|
Math.round(result.dust * 100)
|
||||||
|
) / 100;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof module !== "undefined" && module.exports) {
|
||||||
|
module.exports = SplitCalculator;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user