Salon — POS Checkout
A luxe, editorial point-of-sale checkout for a boutique salon. Tab between Services and Retail, tap to add items, and adjust quantities with inline steppers while an itemized ticket tallies live. Set an adjustable tip from percent presets or a custom amount, apply a promo code, and pick a payment method — Card, Cash, or Gift card. The Charge button confirms with an approval overlay, change due for cash, and a toast. Vanilla HTML, CSS and JS, fully responsive and keyboard accessible.
MCP
Kod
:root {
--serif: "Cormorant Garamond", Georgia, serif;
--sans: "Inter", system-ui, -apple-system, sans-serif;
--gold: #b08d57;
--gold-d: #8c6d3f;
--gold-soft: #efe2cf;
--rose: #c9a78f;
--rose-soft: #f3e6dc;
--ink: #1c1814;
--ink-2: #3d362f;
--muted: #8a7d70;
--cream: #f7f1e8;
--bg: #faf6ef;
--white: #ffffff;
--line: rgba(28, 24, 20, 0.1);
--line-2: rgba(28, 24, 20, 0.18);
--ok: #5f8a6b;
--warn: #c08a3e;
--danger: #b3503e;
--r-sm: 8px;
--r-md: 14px;
--r-lg: 22px;
--sh-sm: 0 1px 2px rgba(28, 24, 20, 0.05);
--sh-md: 0 10px 28px -16px rgba(28, 24, 20, 0.4);
--sh-lg: 0 30px 70px -30px rgba(28, 24, 20, 0.45);
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
body {
margin: 0;
font-family: var(--sans);
line-height: 1.5;
color: var(--ink);
background: var(--bg);
background-image:
radial-gradient(120% 90% at 0% 0%, var(--cream) 0%, transparent 55%),
radial-gradient(120% 90% at 100% 100%, var(--rose-soft) 0%, transparent 50%);
min-height: 100vh;
padding: clamp(16px, 3vw, 40px);
}
h1, h2, h3 {
font-family: var(--serif);
font-weight: 600;
margin: 0;
letter-spacing: 0.01em;
}
.kicker {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--gold-d);
margin: 0;
}
.field__label {
display: block;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--muted);
margin-bottom: 8px;
}
/* ============ LAYOUT ============ */
.pos {
max-width: 1140px;
margin: 0 auto;
display: grid;
grid-template-columns: 1.45fr 1fr;
gap: clamp(16px, 2.2vw, 28px);
align-items: start;
}
.pos__picker,
.pos__till {
background: var(--white);
border: 1px solid var(--line);
border-radius: var(--r-lg);
box-shadow: var(--sh-md);
}
.pos__picker {
padding: clamp(20px, 2.4vw, 32px);
}
.pos__till {
padding: clamp(20px, 2.2vw, 28px);
position: sticky;
top: 24px;
display: flex;
flex-direction: column;
}
/* ============ PICKER HEAD ============ */
.picker__title {
font-size: clamp(30px, 4.4vw, 40px);
line-height: 1.05;
margin: 6px 0 8px;
}
.picker__meta {
margin: 0;
font-size: 13px;
color: var(--muted);
}
.picker__meta strong {
color: var(--ink-2);
font-weight: 600;
}
/* ============ TABS ============ */
.tabs {
display: flex;
align-items: center;
gap: 8px;
margin: 22px 0 16px;
flex-wrap: wrap;
}
.tab {
font-family: var(--sans);
font-size: 14px;
font-weight: 600;
color: var(--muted);
background: var(--cream);
border: 1px solid var(--line);
border-radius: 999px;
padding: 9px 20px;
cursor: pointer;
transition: all 0.18s ease;
}
.tab:hover {
color: var(--ink);
border-color: var(--line-2);
}
.tab.is-active {
color: var(--white);
background: linear-gradient(135deg, var(--gold), var(--gold-d));
border-color: transparent;
box-shadow: var(--sh-sm);
}
.tabs__hint {
font-size: 12px;
color: var(--muted);
margin-left: auto;
}
/* ============ SEARCH ============ */
.search {
position: relative;
margin-bottom: 18px;
}
.search__ico {
position: absolute;
left: 16px;
top: 50%;
transform: translateY(-50%);
width: 18px;
height: 18px;
fill: none;
stroke: var(--muted);
stroke-width: 2;
stroke-linecap: round;
}
.search input {
width: 100%;
font-family: var(--sans);
font-size: 15px;
color: var(--ink);
background: var(--bg);
border: 1px solid var(--line);
border-radius: var(--r-md);
padding: 13px 16px 13px 44px;
}
.search input::placeholder { color: var(--muted); }
.search input:focus-visible {
outline: none;
border-color: var(--gold);
box-shadow: 0 0 0 3px var(--gold-soft);
}
/* ============ ITEM GRID ============ */
.grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
.item {
text-align: left;
background: var(--white);
border: 1px solid var(--line);
border-radius: var(--r-md);
padding: 16px 16px 14px;
cursor: pointer;
font-family: var(--sans);
display: flex;
flex-direction: column;
gap: 6px;
transition: transform 0.16s ease, border-color 0.16s ease, box-shadow 0.16s ease;
position: relative;
overflow: hidden;
}
.item::after {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
background: linear-gradient(135deg, var(--gold-soft), transparent 60%);
opacity: 0;
transition: opacity 0.18s ease;
pointer-events: none;
}
.item:hover {
transform: translateY(-2px);
border-color: var(--rose);
box-shadow: var(--sh-md);
}
.item:hover::after { opacity: 0.5; }
.item:focus-visible {
outline: none;
border-color: var(--gold);
box-shadow: 0 0 0 3px var(--gold-soft);
}
.item.is-flash {
animation: flash 0.4s ease;
}
@keyframes flash {
0% { box-shadow: 0 0 0 0 var(--gold); }
100% { box-shadow: 0 0 0 8px transparent; }
}
.item__top {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
}
.item__name {
font-size: 15px;
font-weight: 600;
color: var(--ink);
position: relative;
z-index: 1;
}
.item__price {
font-family: var(--serif);
font-size: 18px;
font-weight: 600;
color: var(--gold-d);
white-space: nowrap;
position: relative;
z-index: 1;
}
.item__desc {
font-size: 12.5px;
color: var(--muted);
position: relative;
z-index: 1;
}
.item__tag {
align-self: flex-start;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--gold-d);
background: var(--gold-soft);
border-radius: 999px;
padding: 3px 9px;
position: relative;
z-index: 1;
}
.item__inCart {
position: absolute;
top: 12px;
right: 12px;
z-index: 2;
font-size: 11px;
font-weight: 700;
color: var(--white);
background: var(--gold);
width: 22px;
height: 22px;
border-radius: 50%;
display: none;
align-items: center;
justify-content: center;
box-shadow: var(--sh-sm);
}
.item.is-in .item__inCart { display: flex; }
.grid__empty {
text-align: center;
color: var(--muted);
font-style: italic;
padding: 40px 0;
font-family: var(--serif);
font-size: 18px;
}
/* ============ TILL HEAD ============ */
.till__head { margin-bottom: 16px; }
.till__title {
font-size: 24px;
margin-top: 4px;
}
/* ============ CART ============ */
.cart {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
}
.cart:not(:empty) {
border-top: 1px solid var(--line);
margin-bottom: 4px;
}
.line {
display: grid;
grid-template-columns: 1fr auto;
gap: 4px 12px;
padding: 14px 0;
border-bottom: 1px solid var(--line);
animation: lineIn 0.28s ease;
}
@keyframes lineIn {
from { opacity: 0; transform: translateY(-6px); }
to { opacity: 1; transform: none; }
}
.line__name {
font-size: 14px;
font-weight: 600;
color: var(--ink);
}
.line__unit {
font-size: 12px;
color: var(--muted);
}
.line__total {
font-family: var(--serif);
font-size: 17px;
font-weight: 600;
color: var(--ink);
text-align: right;
align-self: center;
}
.line__ctrl {
grid-column: 1 / -1;
display: flex;
align-items: center;
gap: 10px;
margin-top: 4px;
}
.stepper {
display: inline-flex;
align-items: center;
border: 1px solid var(--line-2);
border-radius: 999px;
overflow: hidden;
}
.stepper button {
width: 28px;
height: 28px;
border: none;
background: var(--white);
color: var(--ink);
font-size: 16px;
cursor: pointer;
line-height: 1;
transition: background 0.15s ease;
}
.stepper button:hover { background: var(--cream); }
.stepper button:focus-visible {
outline: none;
background: var(--gold-soft);
}
.stepper__qty {
min-width: 28px;
text-align: center;
font-size: 13px;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.line__remove {
margin-left: auto;
border: none;
background: none;
color: var(--muted);
font-size: 12px;
cursor: pointer;
padding: 4px 6px;
border-radius: var(--r-sm);
transition: color 0.15s ease;
}
.line__remove:hover { color: var(--danger); }
.line__remove:focus-visible {
outline: none;
color: var(--danger);
box-shadow: 0 0 0 2px rgba(179, 80, 62, 0.3);
}
/* ============ EMPTY STATE ============ */
.cart__empty {
text-align: center;
padding: 40px 12px;
color: var(--muted);
}
.cart__emptyMark {
display: block;
font-size: 26px;
color: var(--gold);
margin-bottom: 8px;
}
.cart__empty p { margin: 2px 0; font-size: 14px; }
.cart__emptySub { font-size: 12.5px; color: var(--muted); }
/* ============ PROMO ============ */
.till__body { margin-top: 16px; }
.promo { margin-bottom: 18px; }
.promo__row { display: flex; gap: 8px; }
.promo input {
flex: 1;
font-family: var(--sans);
font-size: 14px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--ink);
background: var(--bg);
border: 1px solid var(--line);
border-radius: var(--r-md);
padding: 11px 14px;
}
.promo input::placeholder { text-transform: none; letter-spacing: normal; color: var(--muted); }
.promo input:focus-visible {
outline: none;
border-color: var(--gold);
box-shadow: 0 0 0 3px var(--gold-soft);
}
.promo__msg {
margin: 8px 0 0;
font-size: 12.5px;
min-height: 16px;
}
.promo__msg.is-ok { color: var(--ok); }
.promo__msg.is-err { color: var(--danger); }
.btn-ghost {
font-family: var(--sans);
font-size: 13px;
font-weight: 600;
color: var(--gold-d);
background: var(--white);
border: 1px solid var(--line-2);
border-radius: var(--r-md);
padding: 11px 16px;
cursor: pointer;
white-space: nowrap;
transition: all 0.15s ease;
}
.btn-ghost:hover {
background: var(--gold-soft);
border-color: var(--gold);
}
.btn-ghost:focus-visible {
outline: none;
box-shadow: 0 0 0 3px var(--gold-soft);
}
/* ============ TIP ============ */
.tip { margin-bottom: 18px; }
.tip__top {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.tip__top .field__label { margin-bottom: 0; }
.tip__amt {
font-family: var(--serif);
font-size: 17px;
font-weight: 600;
color: var(--gold-d);
}
.tip__presets {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.tip__chip { flex: 1 0 auto; min-width: 58px; justify-content: center; }
.tip__custom {
display: flex;
align-items: center;
gap: 2px;
flex: 1 1 90px;
background: var(--bg);
border: 1px solid var(--line);
border-radius: 999px;
padding: 0 12px;
color: var(--muted);
}
.tip__custom input {
width: 100%;
border: none;
background: none;
font-family: var(--sans);
font-size: 14px;
color: var(--ink);
padding: 9px 0;
}
.tip__custom input:focus-visible { outline: none; }
.tip__custom:focus-within {
border-color: var(--gold);
box-shadow: 0 0 0 3px var(--gold-soft);
}
/* ============ CHIPS ============ */
.chip {
font-family: var(--sans);
font-size: 13px;
font-weight: 600;
color: var(--ink-2);
background: var(--cream);
border: 1px solid var(--line);
border-radius: 999px;
padding: 9px 14px;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 7px;
transition: all 0.16s ease;
}
.chip:hover { border-color: var(--line-2); }
.chip:focus-visible {
outline: none;
box-shadow: 0 0 0 3px var(--gold-soft);
}
.chip.is-active {
color: var(--white);
background: linear-gradient(135deg, var(--gold), var(--gold-d));
border-color: transparent;
}
.chip svg {
width: 16px;
height: 16px;
fill: none;
stroke: currentColor;
stroke-width: 1.7;
stroke-linecap: round;
stroke-linejoin: round;
}
/* ============ TOTALS ============ */
.totals {
margin: 0 0 18px;
padding: 16px 0;
border-top: 1px solid var(--line);
border-bottom: 1px solid var(--line);
}
.totals__row {
display: flex;
align-items: baseline;
justify-content: space-between;
margin: 0;
padding: 5px 0;
}
.totals__row dt {
font-size: 14px;
color: var(--ink-2);
}
.totals__row dd {
margin: 0;
font-size: 14px;
font-weight: 600;
color: var(--ink);
font-variant-numeric: tabular-nums;
}
.totals__hint { color: var(--muted); font-size: 12px; }
.totals__row--disc dd,
.totals__row--disc dt span { color: var(--ok); }
.totals__row--disc dt span {
font-size: 12px;
font-weight: 600;
letter-spacing: 0.06em;
}
.totals__row--grand {
padding-top: 12px;
margin-top: 6px;
border-top: 1px solid var(--line);
}
.totals__row--grand dt {
font-family: var(--serif);
font-size: 19px;
font-weight: 600;
color: var(--ink);
}
.totals__row--grand dd {
font-family: var(--serif);
font-size: 26px;
font-weight: 700;
color: var(--gold-d);
}
/* ============ PAYMENT ============ */
.pay { margin-bottom: 20px; }
.pay__chips {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
margin-bottom: 14px;
}
.pay__chip { justify-content: center; }
.cash {
background: var(--cream);
border: 1px solid var(--line);
border-radius: var(--r-md);
padding: 14px;
animation: lineIn 0.24s ease;
}
.cash__row { display: flex; flex-direction: column; gap: 10px; }
.cash__input {
display: flex;
align-items: center;
gap: 4px;
background: var(--white);
border: 1px solid var(--line-2);
border-radius: var(--r-md);
padding: 0 14px;
color: var(--muted);
font-size: 15px;
}
.cash__input input {
width: 100%;
border: none;
background: none;
font-family: var(--sans);
font-size: 16px;
color: var(--ink);
padding: 11px 0;
}
.cash__input input:focus-visible { outline: none; }
.cash__input:focus-within {
border-color: var(--gold);
box-shadow: 0 0 0 3px var(--gold-soft);
}
.cash__quick { display: flex; gap: 8px; }
.btn-quick { flex: 1; padding: 9px 0; text-align: center; }
/* ============ CHARGE BUTTON ============ */
.btn-charge {
width: 100%;
font-family: var(--sans);
font-size: 15px;
font-weight: 600;
letter-spacing: 0.02em;
color: var(--white);
background: linear-gradient(135deg, var(--ink-2), var(--ink));
border: 1px solid var(--ink);
border-radius: var(--r-md);
padding: 16px;
cursor: pointer;
transition: transform 0.14s ease, box-shadow 0.18s ease, opacity 0.18s ease;
box-shadow: var(--sh-md);
}
.btn-charge:hover {
transform: translateY(-1px);
box-shadow: var(--sh-lg);
}
.btn-charge:active { transform: translateY(0); }
.btn-charge:focus-visible {
outline: none;
box-shadow: 0 0 0 3px var(--gold-soft), var(--sh-md);
}
.btn-charge:disabled {
opacity: 0.55;
cursor: not-allowed;
transform: none;
}
.btn-charge.is-loading { pointer-events: none; }
.btn-charge.is-loading .btn-charge__label { opacity: 0.4; }
.btn-charge__label #chargeAmt {
font-family: var(--serif);
font-weight: 700;
font-size: 17px;
}
.btn-charge--ghost {
background: var(--white);
color: var(--ink);
border-color: var(--line-2);
box-shadow: none;
margin-top: 18px;
}
.btn-charge--ghost:hover {
background: var(--cream);
box-shadow: var(--sh-sm);
}
/* ============ SUCCESS OVERLAY ============ */
.paid {
position: fixed;
inset: 0;
z-index: 60;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background: rgba(28, 24, 20, 0.45);
backdrop-filter: blur(4px);
animation: fade 0.2s ease;
}
@keyframes fade { from { opacity: 0; } to { opacity: 1; } }
.paid__card {
background: var(--white);
border: 1px solid var(--line);
border-radius: var(--r-lg);
box-shadow: var(--sh-lg);
padding: 36px 32px 30px;
width: min(380px, 100%);
text-align: center;
animation: pop 0.3s cubic-bezier(0.2, 0.9, 0.3, 1.2);
}
@keyframes pop {
from { opacity: 0; transform: translateY(10px) scale(0.96); }
to { opacity: 1; transform: none; }
}
.paid__check {
display: inline-flex;
align-items: center;
justify-content: center;
width: 64px;
height: 64px;
border-radius: 50%;
background: linear-gradient(135deg, var(--gold), var(--gold-d));
margin-bottom: 16px;
box-shadow: 0 8px 22px -8px var(--gold-d);
}
.paid__check svg {
width: 30px;
height: 30px;
fill: none;
stroke: var(--white);
stroke-width: 2.6;
stroke-linecap: round;
stroke-linejoin: round;
stroke-dasharray: 30;
stroke-dashoffset: 30;
animation: draw 0.5s 0.15s ease forwards;
}
@keyframes draw { to { stroke-dashoffset: 0; } }
.paid__total {
font-size: 40px;
margin: 8px 0 4px;
color: var(--ink);
}
.paid__method {
margin: 0;
color: var(--muted);
font-size: 13.5px;
}
.paid__change {
margin-top: 18px;
padding: 14px 16px;
background: var(--gold-soft);
border-radius: var(--r-md);
display: flex;
align-items: center;
justify-content: space-between;
}
.paid__change span { font-size: 13px; color: var(--gold-d); font-weight: 600; }
.paid__change strong {
font-family: var(--serif);
font-size: 22px;
color: var(--gold-d);
}
/* ============ TOAST ============ */
.toast {
position: fixed;
left: 50%;
bottom: 28px;
transform: translate(-50%, 24px);
z-index: 80;
background: var(--ink);
color: var(--cream);
font-size: 13.5px;
font-weight: 500;
padding: 12px 20px;
border-radius: 999px;
box-shadow: var(--sh-lg);
opacity: 0;
pointer-events: none;
transition: opacity 0.25s ease, transform 0.25s ease;
max-width: calc(100% - 40px);
text-align: center;
}
.toast.is-show {
opacity: 1;
transform: translate(-50%, 0);
}
.toast::before {
content: "✦";
color: var(--gold);
margin-right: 8px;
}
/* ============ RESPONSIVE ============ */
@media (max-width: 880px) {
.pos { grid-template-columns: 1fr; }
.pos__till { position: static; }
}
@media (max-width: 520px) {
body { padding: 12px; }
.pos__picker, .pos__till { border-radius: var(--r-md); padding: 18px; }
.picker__title { font-size: 30px; }
.grid { grid-template-columns: 1fr; }
.tabs__hint { display: none; }
.pay__chips { grid-template-columns: 1fr; }
.tip__chip { min-width: 50px; padding: 9px 10px; }
.totals__row--grand dd { font-size: 23px; }
.paid__card { padding: 28px 22px; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
/* Visibility guard: honor the [hidden] attribute over base display */
.totals__row[hidden],
.paid[hidden],
.paid__change[hidden] {
display: none;
}(function () {
"use strict";
/* ---------------- DATA ---------------- */
var CATALOG = {
services: [
{ id: "s1", name: "Signature Cut & Style", price: 95, tag: "Hair", desc: "Consultation, cut, blow-dry finish." },
{ id: "s2", name: "Lumière Balayage", price: 220, tag: "Color", desc: "Hand-painted, freehand highlights." },
{ id: "s3", name: "Gloss & Tone", price: 65, tag: "Color", desc: "Shine-boosting demi-permanent glaze." },
{ id: "s4", name: "Keratin Smoothing", price: 180, tag: "Treatment", desc: "Frizz-taming, 12-week result." },
{ id: "s5", name: "Bridal Updo", price: 140, tag: "Styling", desc: "Pinned, lacquered, photo-ready." },
{ id: "s6", name: "Scalp Ritual Facial", price: 78, tag: "Spa", desc: "Detox massage & serum infusion." },
{ id: "s7", name: "Gel Manicure", price: 52, tag: "Nails", desc: "Shaped, cuticle care, gel polish." },
{ id: "s8", name: "Brow Architecture", price: 38, tag: "Brows", desc: "Map, wax, tint & set." }
],
retail: [
{ id: "r1", name: "Ritual Repair Mask", price: 46, tag: "Hair", desc: "Bond-building weekly treatment." },
{ id: "r2", name: "Argan Gloss Oil", price: 34, tag: "Hair", desc: "Weightless finishing shine." },
{ id: "r3", name: "Velvet Hold Spray", price: 28, tag: "Styling", desc: "Flexible, brushable hold." },
{ id: "r4", name: "Rose Quartz Comb", price: 22, tag: "Tools", desc: "Static-free wide-tooth comb." },
{ id: "r5", name: "Cuticle Elixir", price: 19, tag: "Nails", desc: "Nourishing nail & cuticle oil." },
{ id: "r6", name: "Silk Pillow Wrap", price: 58, tag: "Home", desc: "Mulberry silk, frizz-saving." }
]
};
var PROMOS = {
LUMIERE10: { type: "pct", value: 0.1, label: "10% off" },
WELCOME20: { type: "pct", value: 0.2, label: "20% off" },
ROSE15: { type: "flat", value: 15, label: "$15 off" }
};
var TAX_RATE = 0.085;
/* ---------------- STATE ---------------- */
var state = {
tab: "services",
query: "",
cart: {}, // id -> { item, qty }
tipMode: "pct", // "pct" | "custom"
tipPct: 18,
tipCustom: 0,
promo: null, // { code, ...promoDef }
pay: "card",
tendered: 0
};
/* ---------------- HELPERS ---------------- */
function $(sel, ctx) { return (ctx || document).querySelector(sel); }
function $all(sel, ctx) { return Array.prototype.slice.call((ctx || document).querySelectorAll(sel)); }
function money(n) {
var neg = n < 0;
var v = Math.abs(n).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
return (neg ? "−$" : "$") + v;
}
var toastTimer;
function toast(msg) {
var el = $("#toast");
el.textContent = msg;
el.classList.add("is-show");
clearTimeout(toastTimer);
toastTimer = setTimeout(function () { el.classList.remove("is-show"); }, 2600);
}
function findItem(id) {
var all = CATALOG.services.concat(CATALOG.retail);
for (var i = 0; i < all.length; i++) if (all[i].id === id) return all[i];
return null;
}
/* ---------------- RENDER GRID ---------------- */
function renderGrid() {
var grid = $("#grid");
var q = state.query.trim().toLowerCase();
var list = CATALOG[state.tab].filter(function (it) {
if (!q) return true;
return (it.name + " " + it.tag + " " + it.desc).toLowerCase().indexOf(q) !== -1;
});
$("#gridEmpty").hidden = list.length !== 0;
grid.innerHTML = list.map(function (it) {
var inCart = state.cart[it.id];
return (
'<button class="item' + (inCart ? " is-in" : "") + '" type="button" data-id="' + it.id + '" aria-label="Add ' + it.name + ', ' + money(it.price) + '">' +
'<span class="item__inCart" aria-hidden="true">' + (inCart ? inCart.qty : "") + "</span>" +
'<span class="item__top">' +
'<span class="item__name">' + it.name + "</span>" +
'<span class="item__price">' + money(it.price) + "</span>" +
"</span>" +
'<span class="item__desc">' + it.desc + "</span>" +
'<span class="item__tag">' + it.tag + "</span>" +
"</button>"
);
}).join("");
}
/* ---------------- CART OPS ---------------- */
function addToCart(id) {
var item = findItem(id);
if (!item) return;
if (state.cart[id]) state.cart[id].qty += 1;
else state.cart[id] = { item: item, qty: 1 };
renderCart();
renderGrid();
flashItem(id);
}
function setQty(id, delta) {
var entry = state.cart[id];
if (!entry) return;
entry.qty += delta;
if (entry.qty <= 0) delete state.cart[id];
renderCart();
renderGrid();
}
function removeFromCart(id) {
delete state.cart[id];
renderCart();
renderGrid();
}
function flashItem(id) {
var el = $('.item[data-id="' + id + '"]');
if (!el) return;
el.classList.remove("is-flash");
void el.offsetWidth;
el.classList.add("is-flash");
}
/* ---------------- TOTALS ---------------- */
function calc() {
var subtotal = 0;
Object.keys(state.cart).forEach(function (id) {
var e = state.cart[id];
subtotal += e.item.price * e.qty;
});
var discount = 0;
if (state.promo) {
discount = state.promo.type === "pct" ? subtotal * state.promo.value : Math.min(state.promo.value, subtotal);
}
var taxable = Math.max(0, subtotal - discount);
var tip = 0;
if (state.tipMode === "custom") tip = state.tipCustom;
else tip = taxable * (state.tipPct / 100);
var tax = taxable * TAX_RATE;
var grand = taxable + tax + tip;
return {
subtotal: subtotal,
discount: discount,
tip: tip,
tax: tax,
grand: grand
};
}
/* ---------------- RENDER CART ---------------- */
function renderCart() {
var cartEl = $("#cart");
var ids = Object.keys(state.cart);
var empty = ids.length === 0;
$("#cartEmpty").hidden = !empty;
$("#tillBody").hidden = empty;
cartEl.innerHTML = ids.map(function (id) {
var e = state.cart[id];
var lineTotal = e.item.price * e.qty;
return (
'<li class="line">' +
'<span class="line__name">' + e.item.name + "</span>" +
'<span class="line__total">' + money(lineTotal) + "</span>" +
'<span class="line__unit">' + money(e.item.price) + " each</span>" +
'<span></span>' +
'<span class="line__ctrl">' +
'<span class="stepper">' +
'<button type="button" data-act="dec" data-id="' + id + '" aria-label="Decrease quantity of ' + e.item.name + '">−</button>' +
'<span class="stepper__qty" aria-live="polite">' + e.qty + "</span>" +
'<button type="button" data-act="inc" data-id="' + id + '" aria-label="Increase quantity of ' + e.item.name + '">+</button>' +
"</span>" +
'<button class="line__remove" type="button" data-act="rm" data-id="' + id + '">Remove</button>' +
"</span>" +
"</li>"
);
}).join("");
renderTotals();
}
function renderTotals() {
var t = calc();
$("#subtotal").textContent = money(t.subtotal);
var discRow = $("#discRow");
if (t.discount > 0) {
discRow.hidden = false;
$("#discount").textContent = money(-t.discount);
$("#discLabel").textContent = state.promo ? "· " + state.promo.code : "";
} else {
discRow.hidden = true;
}
$("#tax").textContent = money(t.tax);
$("#tipLine").textContent = money(t.tip);
$("#tipAmt").textContent = money(t.tip);
$("#grand").textContent = money(t.grand);
$("#chargeAmt").textContent = money(t.grand);
var charge = $("#charge");
charge.disabled = t.grand <= 0;
}
/* ---------------- TIP ---------------- */
function setTipPct(pct) {
state.tipMode = "pct";
state.tipPct = pct;
state.tipCustom = 0;
$("#tipCustom").value = "";
$all(".tip__chip").forEach(function (c) {
var on = Number(c.dataset.tip) === pct;
c.classList.toggle("is-active", on);
c.setAttribute("aria-pressed", String(on));
});
renderTotals();
}
function setTipCustom(val) {
var n = parseFloat(val);
state.tipMode = "custom";
state.tipCustom = isNaN(n) || n < 0 ? 0 : n;
$all(".tip__chip").forEach(function (c) {
c.classList.remove("is-active");
c.setAttribute("aria-pressed", "false");
});
renderTotals();
}
/* ---------------- PROMO ---------------- */
function applyPromo() {
var input = $("#promo");
var msg = $("#promoMsg");
var code = input.value.trim().toUpperCase();
msg.className = "promo__msg";
if (!code) {
msg.textContent = "Enter a promo code to apply.";
msg.classList.add("is-err");
return;
}
if (state.promo && state.promo.code === code) {
msg.textContent = "That code is already applied.";
msg.classList.add("is-ok");
return;
}
var def = PROMOS[code];
if (!def) {
state.promo = null;
msg.textContent = "Code “" + code + "” is not valid.";
msg.classList.add("is-err");
renderTotals();
return;
}
state.promo = { code: code, type: def.type, value: def.value, label: def.label };
msg.textContent = def.label + " applied.";
msg.classList.add("is-ok");
renderTotals();
toast(def.label + " applied");
}
/* ---------------- PAYMENT ---------------- */
function setPay(method) {
state.pay = method;
$all(".pay__chip").forEach(function (c) {
var on = c.dataset.pay === method;
c.classList.toggle("is-active", on);
c.setAttribute("aria-pressed", String(on));
});
$("#cashPanel").hidden = method !== "cash";
}
/* ---------------- CHARGE ---------------- */
function charge() {
var t = calc();
if (t.grand <= 0) return;
if (state.pay === "cash") {
var tendered = parseFloat($("#tendered").value);
if (isNaN(tendered) || tendered < t.grand) {
toast("Cash tendered must cover " + money(t.grand));
$("#tendered").focus();
return;
}
}
var btn = $("#charge");
btn.classList.add("is-loading");
btn.disabled = true;
setTimeout(function () {
btn.classList.remove("is-loading");
showSuccess(t);
}, 750);
}
function showSuccess(t) {
var methodLabel = { card: "Visa •••• 4471", cash: "Cash", gift: "Gift card •••• 8820" }[state.pay];
$("#paidTitle").textContent = money(t.grand);
$("#paidMethod").textContent = "Paid with " + methodLabel + " · Ticket #ML-2048";
var changeWrap = $("#paidChange");
if (state.pay === "cash") {
var tendered = parseFloat($("#tendered").value) || 0;
$("#changeDue").textContent = money(tendered - t.grand);
changeWrap.hidden = false;
} else {
changeWrap.hidden = true;
}
$("#paid").hidden = false;
toast("Payment approved · " + money(t.grand));
}
function newSale() {
state.cart = {};
state.promo = null;
state.tendered = 0;
$("#promo").value = "";
$("#promoMsg").textContent = "";
$("#promoMsg").className = "promo__msg";
$("#tendered").value = "";
setTipPct(18);
setPay("card");
$("#paid").hidden = true;
$("#charge").disabled = false;
renderCart();
renderGrid();
toast("Ready for the next guest");
}
/* ---------------- EVENTS ---------------- */
function bind() {
// tabs
$all(".tab").forEach(function (tab) {
tab.addEventListener("click", function () {
state.tab = tab.dataset.tab;
$all(".tab").forEach(function (t) {
var on = t === tab;
t.classList.toggle("is-active", on);
t.setAttribute("aria-selected", String(on));
});
$("#grid").setAttribute("aria-labelledby", tab.id);
renderGrid();
});
});
// search
$("#search").addEventListener("input", function (e) {
state.query = e.target.value;
renderGrid();
});
// grid delegation
$("#grid").addEventListener("click", function (e) {
var card = e.target.closest(".item");
if (card) addToCart(card.dataset.id);
});
// cart delegation
$("#cart").addEventListener("click", function (e) {
var btn = e.target.closest("button[data-act]");
if (!btn) return;
var id = btn.dataset.id;
if (btn.dataset.act === "inc") setQty(id, 1);
else if (btn.dataset.act === "dec") setQty(id, -1);
else if (btn.dataset.act === "rm") removeFromCart(id);
});
// tip presets
$all(".tip__chip").forEach(function (chip) {
chip.addEventListener("click", function () { setTipPct(Number(chip.dataset.tip)); });
});
$("#tipCustom").addEventListener("input", function (e) { setTipCustom(e.target.value); });
// promo
$("#applyPromo").addEventListener("click", applyPromo);
$("#promo").addEventListener("keydown", function (e) {
if (e.key === "Enter") { e.preventDefault(); applyPromo(); }
});
// payment chips
$all(".pay__chip").forEach(function (chip) {
chip.addEventListener("click", function () { setPay(chip.dataset.pay); });
});
// cash quick
$all(".btn-quick").forEach(function (b) {
b.addEventListener("click", function () {
var t = calc();
var val = b.dataset.cash === "exact" ? t.grand : Number(b.dataset.cash);
$("#tendered").value = val.toFixed(2);
$("#tendered").focus();
});
});
// charge + new sale
$("#charge").addEventListener("click", charge);
$("#newSale").addEventListener("click", newSale);
// close overlay on backdrop / escape
$("#paid").addEventListener("click", function (e) {
if (e.target === $("#paid")) newSale();
});
document.addEventListener("keydown", function (e) {
if (e.key === "Escape" && !$("#paid").hidden) newSale();
});
}
/* ---------------- INIT ---------------- */
function init() {
bind();
setTipPct(18);
setPay("card");
renderGrid();
renderCart();
}
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", init);
else init();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@500;600;700&family=Inter:wght@400;500;600;700&display=swap"
/>
<link rel="stylesheet" href="style.css" />
<title>Checkout · Maison Lumière Salon</title>
</head>
<body>
<main class="pos" aria-label="Point of sale checkout">
<!-- ================= LEFT : ITEM PICKER ================= -->
<section class="pos__picker" aria-label="Add items">
<header class="picker__head">
<p class="kicker">Maison Lumière Salon</p>
<h1 class="picker__title">Checkout</h1>
<p class="picker__meta">
Ticket <strong>#ML-2048</strong> · Stylist <strong>Aria Vance</strong> ·
Client <strong>Noor Halabi</strong>
</p>
</header>
<div class="tabs" role="tablist" aria-label="Item categories">
<button class="tab is-active" role="tab" aria-selected="true" data-tab="services" id="tab-services">
Services
</button>
<button class="tab" role="tab" aria-selected="false" data-tab="retail" id="tab-retail">
Retail
</button>
<span class="tabs__hint">Tap an item to add it to the ticket</span>
</div>
<div class="search">
<svg viewBox="0 0 24 24" aria-hidden="true" class="search__ico">
<circle cx="11" cy="11" r="7" /><line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
<input type="search" id="search" placeholder="Search the menu…" aria-label="Search items" autocomplete="off" />
</div>
<div class="grid" id="grid" role="tabpanel" aria-labelledby="tab-services" aria-live="polite">
<!-- item cards injected here -->
</div>
<p class="grid__empty" id="gridEmpty" hidden>No items match your search.</p>
</section>
<!-- ================= RIGHT : CART / TILL ================= -->
<aside class="pos__till" aria-label="Current ticket">
<header class="till__head">
<p class="kicker">Current ticket</p>
<h2 class="till__title">Order summary</h2>
</header>
<ul class="cart" id="cart" aria-label="Items in ticket" aria-live="polite"></ul>
<div class="cart__empty" id="cartEmpty">
<span class="cart__emptyMark" aria-hidden="true">✦</span>
<p>Your ticket is empty.</p>
<p class="cart__emptySub">Add services or retail to begin.</p>
</div>
<div class="till__body" id="tillBody" hidden>
<!-- Promo -->
<div class="promo">
<label class="field__label" for="promo">Promo code</label>
<div class="promo__row">
<input type="text" id="promo" placeholder="e.g. LUMIERE10" autocomplete="off" />
<button class="btn-ghost" id="applyPromo" type="button">Apply</button>
</div>
<p class="promo__msg" id="promoMsg" role="status" aria-live="polite"></p>
</div>
<!-- Tip -->
<div class="tip">
<div class="tip__top">
<span class="field__label">Add a tip for Aria</span>
<span class="tip__amt" id="tipAmt">$0.00</span>
</div>
<div class="tip__presets" role="group" aria-label="Tip percentage">
<button class="chip tip__chip" data-tip="0" type="button" aria-pressed="true">None</button>
<button class="chip tip__chip" data-tip="15" type="button" aria-pressed="false">15%</button>
<button class="chip tip__chip is-active" data-tip="18" type="button" aria-pressed="true">18%</button>
<button class="chip tip__chip" data-tip="20" type="button" aria-pressed="false">20%</button>
<button class="chip tip__chip" data-tip="25" type="button" aria-pressed="false">25%</button>
<div class="tip__custom">
<span aria-hidden="true">$</span>
<input type="number" id="tipCustom" min="0" step="0.50" inputmode="decimal" placeholder="Custom" aria-label="Custom tip amount" />
</div>
</div>
</div>
<!-- Totals -->
<dl class="totals">
<div class="totals__row"><dt>Subtotal</dt><dd id="subtotal">$0.00</dd></div>
<div class="totals__row totals__row--disc" id="discRow" hidden>
<dt>Discount <span id="discLabel"></span></dt><dd id="discount">−$0.00</dd>
</div>
<div class="totals__row"><dt>Tip</dt><dd id="tipLine">$0.00</dd></div>
<div class="totals__row"><dt>Tax <span class="totals__hint">(8.5%)</span></dt><dd id="tax">$0.00</dd></div>
<div class="totals__row totals__row--grand"><dt>Total due</dt><dd id="grand">$0.00</dd></div>
</dl>
<!-- Payment -->
<div class="pay">
<span class="field__label">Payment method</span>
<div class="pay__chips" role="group" aria-label="Payment method">
<button class="chip pay__chip is-active" data-pay="card" type="button" aria-pressed="true">
<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="2" y="5" width="20" height="14" rx="2"/><line x1="2" y1="10" x2="22" y2="10"/></svg>
Card
</button>
<button class="chip pay__chip" data-pay="cash" type="button" aria-pressed="false">
<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="2" y="6" width="20" height="12" rx="2"/><circle cx="12" cy="12" r="2.5"/></svg>
Cash
</button>
<button class="chip pay__chip" data-pay="gift" type="button" aria-pressed="false">
<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="8" width="18" height="13" rx="2"/><path d="M3 12h18M12 8v13M12 8s-3-5-5-3 1 3 5 3zm0 0s3-5 5-3-1 3-5 3z"/></svg>
Gift card
</button>
</div>
<div class="cash" id="cashPanel" hidden>
<label class="field__label" for="tendered">Cash tendered</label>
<div class="cash__row">
<div class="cash__input"><span aria-hidden="true">$</span>
<input type="number" id="tendered" min="0" step="1" inputmode="decimal" placeholder="0.00" />
</div>
<div class="cash__quick">
<button class="btn-ghost btn-quick" data-cash="exact" type="button">Exact</button>
<button class="btn-ghost btn-quick" data-cash="100" type="button">$100</button>
<button class="btn-ghost btn-quick" data-cash="200" type="button">$200</button>
</div>
</div>
</div>
</div>
<button class="btn-charge" id="charge" type="button">
<span class="btn-charge__label">Charge <span id="chargeAmt">$0.00</span></span>
</button>
</div>
</aside>
</main>
<!-- Success overlay -->
<div class="paid" id="paid" hidden>
<div class="paid__card" role="alertdialog" aria-modal="true" aria-labelledby="paidTitle">
<span class="paid__check" aria-hidden="true">
<svg viewBox="0 0 24 24"><path d="M5 13l4 4L19 7"/></svg>
</span>
<p class="kicker">Payment approved</p>
<h3 class="paid__total" id="paidTitle">$0.00</h3>
<p class="paid__method" id="paidMethod"></p>
<div class="paid__change" id="paidChange" hidden>
<span>Change due</span><strong id="changeDue">$0.00</strong>
</div>
<button class="btn-charge btn-charge--ghost" id="newSale" type="button">New sale</button>
</div>
</div>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script src="script.js"></script>
</body>
</html>POS Checkout
A full point-of-sale checkout for Maison Lumière Salon, built to feel like a real boutique till rather than a placeholder. The left panel splits the menu into Services and Retail tabs with a live search; tap any card and it lands in the ticket, the in-cart badge counts up, and the card flashes gold. The right panel is the till — an itemized ticket with quantity steppers, per-line removal, and totals that recompute on every interaction.
Tipping is generous and effortless: choose 15 / 18 / 20 / 25% presets calculated on the discounted subtotal, or type a custom dollar amount. A promo field validates seeded codes — try LUMIERE10, WELCOME20, or ROSE15 — and folds the discount into the running totals with tax applied at 8.5%. Payment method chips switch between Card, Cash, and Gift card; selecting Cash reveals a tendered field with Exact, $100 and $200 quick-fills.
Pressing Charge validates the tender, shows a brief processing state, then unfurls an approval overlay with the final total, the masked payment method, and — for cash — the precise change due, all confirmed by a toast. Gold hairlines, Cormorant Garamond display type and calm whitespace carry the maison aesthetic. Interactions are keyboard-friendly with aria-live feedback and proper roles, contrast meets WCAG AA, and the layout reflows cleanly down to 360px. No frameworks, no build step — drop in three files and it works.