diff --git a/main.py b/main.py
new file mode 100644
index 0000000..e9a2205
--- /dev/null
+++ b/main.py
@@ -0,0 +1,454 @@
+"""
+ClubLedger - Store Credit Web App
+Admin configuration: edit the CONFIG dict below.
+"""
+
+import sqlite3
+import hashlib
+import time
+import os
+from contextlib import contextmanager
+from datetime import datetime, timezone
+from pathlib import Path
+
+import bcrypt
+from fastapi import FastAPI, HTTPException, Request
+from fastapi.responses import HTMLResponse, JSONResponse
+from fastapi.staticfiles import StaticFiles
+from pydantic import BaseModel, field_validator
+from typing import Optional
+
+# ---------------------------------------------------------------------------
+# Admin configuration
+# ---------------------------------------------------------------------------
+CONFIG = {
+ "club_name": "ClubLedger",
+ "currency_symbol": "£",
+ "currency_unit": "pence", # smallest unit stored as integer
+ "currency_divisor": 100, # divide stored int by this for display
+ "db_path": "clubledger.db",
+ "allow_negative_balance": False, # set True to allow overdraft at bar
+ "min_topup_amount": 100, # minimum top-up in pence (£1.00)
+ "max_topup_amount": 100_000, # maximum top-up in pence (£1000.00)
+ "max_charge_amount": 50_000, # maximum single charge in pence
+}
+
+DB_PATH = CONFIG["db_path"]
+
+# ---------------------------------------------------------------------------
+# Database
+# ---------------------------------------------------------------------------
+
+def get_db():
+ conn = sqlite3.connect(DB_PATH)
+ conn.row_factory = sqlite3.Row
+ conn.execute("PRAGMA journal_mode=WAL")
+ conn.execute("PRAGMA foreign_keys=ON")
+ return conn
+
+@contextmanager
+def db_conn():
+ conn = get_db()
+ try:
+ yield conn
+ conn.commit()
+ except Exception:
+ conn.rollback()
+ raise
+ finally:
+ conn.close()
+
+def init_db():
+ with db_conn() as conn:
+ conn.executescript("""
+ CREATE TABLE IF NOT EXISTS members (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ member_number TEXT UNIQUE NOT NULL,
+ name TEXT NOT NULL,
+ pin_hash TEXT NOT NULL,
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
+ );
+
+ CREATE TABLE IF NOT EXISTS ledger_entries (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ member_id INTEGER NOT NULL REFERENCES members(id),
+ amount INTEGER NOT NULL,
+ type TEXT NOT NULL CHECK(type IN ('topup','charge')),
+ venue TEXT NOT NULL CHECK(venue IN ('cashier','bar')),
+ note TEXT,
+ staff_name TEXT NOT NULL,
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
+ );
+
+ CREATE TABLE IF NOT EXISTS products (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL,
+ brand TEXT,
+ price INTEGER NOT NULL,
+ member_price INTEGER,
+ search_tags TEXT,
+ active INTEGER NOT NULL DEFAULT 1
+ );
+
+ CREATE INDEX IF NOT EXISTS idx_ledger_member
+ ON ledger_entries(member_id);
+ """)
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+def hash_pin(pin: str) -> str:
+ return bcrypt.hashpw(pin.encode(), bcrypt.gensalt()).decode()
+
+def verify_pin(pin: str, hashed: str) -> bool:
+ return bcrypt.checkpw(pin.encode(), hashed.encode())
+
+def member_balance(conn, member_id: int) -> int:
+ row = conn.execute("""
+ SELECT COALESCE(
+ SUM(CASE WHEN type='topup' THEN amount ELSE -amount END), 0
+ ) AS balance
+ FROM ledger_entries WHERE member_id=?
+ """, (member_id,)).fetchone()
+ return row["balance"] if row else 0
+
+def format_amount(pence: int) -> str:
+ sym = CONFIG["currency_symbol"]
+ div = CONFIG["currency_divisor"]
+ return f"{sym}{pence / div:.2f}"
+
+# ---------------------------------------------------------------------------
+# FastAPI app
+# ---------------------------------------------------------------------------
+
+app = FastAPI(title=CONFIG["club_name"])
+
+# Serve static files
+static_dir = Path(__file__).parent / "static"
+static_dir.mkdir(exist_ok=True)
+app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
+
+@app.on_event("startup")
+def on_startup():
+ init_db()
+
+# ---------------------------------------------------------------------------
+# Pydantic models
+# ---------------------------------------------------------------------------
+
+class MemberCreate(BaseModel):
+ member_number: str
+ name: str
+ pin: str
+
+ @field_validator("pin")
+ @classmethod
+ def pin_length(cls, v):
+ if len(v) < 4:
+ raise ValueError("PIN must be at least 4 characters")
+ return v
+
+ @field_validator("member_number")
+ @classmethod
+ def member_number_nonempty(cls, v):
+ v = v.strip()
+ if not v:
+ raise ValueError("member_number cannot be empty")
+ return v
+
+class TopupRequest(BaseModel):
+ member_id: int
+ amount: int # pence
+ staff_name: str
+ note: Optional[str] = None
+
+class ChargeRequest(BaseModel):
+ member_id: int
+ amount: int # pence
+ pin: str
+ staff_name: str
+ note: Optional[str] = None
+
+class ProductCreate(BaseModel):
+ name: str
+ brand: Optional[str] = None
+ price: int
+ member_price: Optional[int] = None
+ search_tags: Optional[str] = None
+
+# ---------------------------------------------------------------------------
+# Endpoints
+# ---------------------------------------------------------------------------
+
+@app.get("/", response_class=HTMLResponse)
+async def root():
+ return (static_dir / "index.html").read_text()
+
+@app.post("/members")
+def create_member(body: MemberCreate):
+ with db_conn() as conn:
+ existing = conn.execute(
+ "SELECT id FROM members WHERE member_number=?",
+ (body.member_number.strip(),)
+ ).fetchone()
+ if existing:
+ raise HTTPException(400, "Member number already exists")
+ pin_hash = hash_pin(body.pin)
+ cur = conn.execute(
+ "INSERT INTO members (member_number, name, pin_hash) VALUES (?,?,?)",
+ (body.member_number.strip(), body.name.strip(), pin_hash)
+ )
+ member_id = cur.lastrowid
+ with db_conn() as conn:
+ row = conn.execute("SELECT * FROM members WHERE id=?", (member_id,)).fetchone()
+ return {
+ "id": row["id"],
+ "member_number": row["member_number"],
+ "name": row["name"],
+ "created_at": row["created_at"],
+ }
+
+@app.get("/members")
+def list_members(q: Optional[str] = None):
+ with db_conn() as conn:
+ if q:
+ pattern = f"%{q}%"
+ rows = conn.execute(
+ "SELECT * FROM members WHERE name LIKE ? OR member_number LIKE ? ORDER BY name",
+ (pattern, pattern)
+ ).fetchall()
+ else:
+ rows = conn.execute("SELECT * FROM members ORDER BY name").fetchall()
+ result = []
+ for r in rows:
+ balance = member_balance(conn, r["id"])
+ result.append({
+ "id": r["id"],
+ "member_number": r["member_number"],
+ "name": r["name"],
+ "balance": balance,
+ "balance_display": format_amount(balance),
+ "created_at": r["created_at"],
+ })
+ return result
+
+@app.post("/topup")
+def topup(body: TopupRequest):
+ if body.amount < CONFIG["min_topup_amount"]:
+ raise HTTPException(400, f"Minimum top-up is {format_amount(CONFIG['min_topup_amount'])}")
+ if body.amount > CONFIG["max_topup_amount"]:
+ raise HTTPException(400, f"Maximum top-up is {format_amount(CONFIG['max_topup_amount'])}")
+ with db_conn() as conn:
+ member = conn.execute("SELECT * FROM members WHERE id=?", (body.member_id,)).fetchone()
+ if not member:
+ raise HTTPException(404, "Member not found")
+ conn.execute(
+ "INSERT INTO ledger_entries (member_id, amount, type, venue, note, staff_name) VALUES (?,?,?,?,?,?)",
+ (body.member_id, body.amount, "topup", "cashier", body.note, body.staff_name)
+ )
+ balance = member_balance(conn, body.member_id)
+ return {
+ "ok": True,
+ "new_balance": balance,
+ "new_balance_display": format_amount(balance),
+ }
+
+@app.post("/charge")
+def charge(body: ChargeRequest):
+ if body.amount <= 0:
+ raise HTTPException(400, "Amount must be positive")
+ if body.amount > CONFIG["max_charge_amount"]:
+ raise HTTPException(400, f"Maximum single charge is {format_amount(CONFIG['max_charge_amount'])}")
+ with db_conn() as conn:
+ member = conn.execute("SELECT * FROM members WHERE id=?", (body.member_id,)).fetchone()
+ if not member:
+ raise HTTPException(404, "Member not found")
+ if not verify_pin(body.pin, member["pin_hash"]):
+ raise HTTPException(403, "Incorrect PIN")
+ balance = member_balance(conn, body.member_id)
+ if not CONFIG["allow_negative_balance"] and balance < body.amount:
+ raise HTTPException(400, f"Insufficient balance ({format_amount(balance)})")
+ conn.execute(
+ "INSERT INTO ledger_entries (member_id, amount, type, venue, note, staff_name) VALUES (?,?,?,?,?,?)",
+ (body.member_id, body.amount, "charge", "bar", body.note, body.staff_name)
+ )
+ new_balance = member_balance(conn, body.member_id)
+ return {
+ "ok": True,
+ "new_balance": new_balance,
+ "new_balance_display": format_amount(new_balance),
+ }
+
+@app.get("/members/{member_id}/transactions")
+def transactions(member_id: int, limit: int = 50, offset: int = 0):
+ with db_conn() as conn:
+ member = conn.execute("SELECT * FROM members WHERE id=?", (member_id,)).fetchone()
+ if not member:
+ raise HTTPException(404, "Member not found")
+ rows = conn.execute("""
+ SELECT * FROM ledger_entries
+ WHERE member_id=?
+ ORDER BY created_at DESC
+ LIMIT ? OFFSET ?
+ """, (member_id, limit, offset)).fetchall()
+ balance = member_balance(conn, member_id)
+ return {
+ "member": {
+ "id": member["id"],
+ "member_number": member["member_number"],
+ "name": member["name"],
+ },
+ "balance": balance,
+ "balance_display": format_amount(balance),
+ "transactions": [
+ {
+ "id": r["id"],
+ "amount": r["amount"],
+ "amount_display": format_amount(r["amount"]),
+ "type": r["type"],
+ "venue": r["venue"],
+ "note": r["note"],
+ "staff_name": r["staff_name"],
+ "created_at": r["created_at"],
+ }
+ for r in rows
+ ],
+ }
+
+@app.get("/members/{member_id}/statement", response_class=HTMLResponse)
+def statement(member_id: int):
+ with db_conn() as conn:
+ member = conn.execute("SELECT * FROM members WHERE id=?", (member_id,)).fetchone()
+ if not member:
+ raise HTTPException(404, "Member not found")
+ rows = conn.execute("""
+ SELECT * FROM ledger_entries WHERE member_id=?
+ ORDER BY created_at ASC
+ """, (member_id,)).fetchall()
+ balance = member_balance(conn, member_id)
+
+ sym = CONFIG["currency_symbol"]
+ div = CONFIG["currency_divisor"]
+ club = CONFIG["club_name"]
+
+ def fmt(p):
+ return f"{sym}{p/div:.2f}"
+
+ rows_html = ""
+ running = 0
+ for r in rows:
+ if r["type"] == "topup":
+ running += r["amount"]
+ dr, cr = "", fmt(r["amount"])
+ else:
+ running -= r["amount"]
+ dr, cr = fmt(r["amount"]), ""
+ rows_html += f"""
+
+ | {r['created_at'][:16]} |
+ {r['type']} |
+ {r['venue']} |
+ {r['note'] or ''} |
+ {r['staff_name']} |
+ {dr} |
+ {cr} |
+ {fmt(running)} |
+
"""
+
+ return f"""
+
+
+
+Statement – {member['name']}
+
+
+
+
+
+
+{club} – Account Statement
+Member: {member['name']} | #{member['member_number']} | Generated: {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M')} UTC
+
+
+
+ | Date/Time | Type | Venue | Note |
+ Staff | Charge | Top-up | Balance |
+
+
+ {rows_html}
+
+Current Balance: {fmt(balance)}
+
+"""
+
+# ---------------------------------------------------------------------------
+# Products endpoints
+# ---------------------------------------------------------------------------
+
+@app.get("/products")
+def list_products(q: Optional[str] = None, active_only: bool = True):
+ with db_conn() as conn:
+ base = "SELECT * FROM products"
+ conds, params = [], []
+ if active_only:
+ conds.append("active=1")
+ if q:
+ conds.append("(name LIKE ? OR brand LIKE ? OR search_tags LIKE ?)")
+ p = f"%{q}%"
+ params += [p, p, p]
+ if conds:
+ base += " WHERE " + " AND ".join(conds)
+ base += " ORDER BY name"
+ rows = conn.execute(base, params).fetchall()
+ return [
+ {
+ "id": r["id"],
+ "name": r["name"],
+ "brand": r["brand"],
+ "price": r["price"],
+ "price_display": format_amount(r["price"]),
+ "member_price": r["member_price"],
+ "member_price_display": format_amount(r["member_price"]) if r["member_price"] else None,
+ "search_tags": r["search_tags"],
+ "active": bool(r["active"]),
+ }
+ for r in rows
+ ]
+
+@app.post("/products")
+def create_product(body: ProductCreate):
+ with db_conn() as conn:
+ cur = conn.execute(
+ "INSERT INTO products (name, brand, price, member_price, search_tags) VALUES (?,?,?,?,?)",
+ (body.name, body.brand, body.price, body.member_price, body.search_tags)
+ )
+ return {"id": cur.lastrowid, "ok": True}
+
+@app.get("/config")
+def get_config():
+ safe = {k: v for k, v in CONFIG.items() if k != "db_path"}
+ return safe
+
+if __name__ == "__main__":
+ import uvicorn
+ uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..d52f40e
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,3 @@
+fastapi
+uvicorn[standard]
+bcrypt
diff --git a/static/app.js b/static/app.js
new file mode 100644
index 0000000..7e83511
--- /dev/null
+++ b/static/app.js
@@ -0,0 +1,285 @@
+/* ClubLedger – frontend */
+
+let cfg = { currency_unit: 'pence', currency_symbol: '£', currency_divisor: 100, club_name: 'ClubLedger' };
+let cashierMember = null;
+let barMember = null;
+
+// ---------------------------------------------------------------------------
+// Boot
+// ---------------------------------------------------------------------------
+(async function init() {
+ try {
+ const r = await fetch('/config');
+ cfg = await r.json();
+ document.getElementById('navBrand').textContent = cfg.club_name;
+ document.title = cfg.club_name;
+ document.querySelectorAll('.currency-unit').forEach(el => {
+ el.textContent = cfg.currency_unit;
+ });
+ } catch (e) { /* use defaults */ }
+
+ // Nav
+ document.querySelectorAll('.nav-btn').forEach(btn => {
+ btn.addEventListener('click', () => {
+ document.querySelectorAll('.nav-btn').forEach(b => b.classList.remove('active'));
+ btn.classList.add('active');
+ document.querySelectorAll('.view').forEach(v => v.classList.add('hidden'));
+ document.getElementById('view-' + btn.dataset.view).classList.remove('hidden');
+ });
+ });
+
+ // Register form
+ document.getElementById('registerForm').addEventListener('submit', async e => {
+ e.preventDefault();
+ await registerMember();
+ });
+
+ // Enter key on search fields
+ document.getElementById('memberSearch').addEventListener('keydown', e => { if (e.key === 'Enter') searchMembers(); });
+ document.getElementById('cashierSearch').addEventListener('keydown', e => { if (e.key === 'Enter') cashierSearchMembers(); });
+ document.getElementById('barSearch').addEventListener('keydown', e => { if (e.key === 'Enter') barSearchMembers(); });
+
+ searchMembers();
+})();
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+function fmtAmount(pence) {
+ return cfg.currency_symbol + (pence / cfg.currency_divisor).toFixed(2);
+}
+
+function balanceClass(v) {
+ return v < 0 ? 'balance-neg' : 'balance-pos';
+}
+
+function setMsg(id, text, type) {
+ const el = document.getElementById(id);
+ el.textContent = text;
+ el.className = 'msg ' + (type || '');
+}
+
+async function apiFetch(url, opts) {
+ const r = await fetch(url, opts);
+ const json = await r.json();
+ if (!r.ok) throw new Error(json.detail || 'Server error');
+ return json;
+}
+
+// ---------------------------------------------------------------------------
+// Members view
+// ---------------------------------------------------------------------------
+async function registerMember() {
+ const number = document.getElementById('reg-number').value.trim();
+ const name = document.getElementById('reg-name').value.trim();
+ const pin = document.getElementById('reg-pin').value;
+ if (!number || !name || !pin) { setMsg('registerMsg', 'All fields required.', 'err'); return; }
+ try {
+ const m = await apiFetch('/members', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ member_number: number, name, pin })
+ });
+ setMsg('registerMsg', `Registered: ${m.name} (#${m.member_number})`, 'ok');
+ document.getElementById('registerForm').reset();
+ searchMembers();
+ } catch (e) {
+ setMsg('registerMsg', e.message, 'err');
+ }
+}
+
+async function searchMembers() {
+ const q = document.getElementById('memberSearch').value.trim();
+ const url = q ? `/members?q=${encodeURIComponent(q)}` : '/members';
+ try {
+ const members = await apiFetch(url);
+ renderMemberTable(members);
+ } catch (e) {
+ console.error(e);
+ }
+}
+
+function renderMemberTable(members) {
+ const tbody = document.querySelector('#memberTable tbody');
+ if (!members.length) { tbody.innerHTML = '| No members found |
'; return; }
+ tbody.innerHTML = members.map(m => `
+
+ | ${esc(m.member_number)} |
+ ${esc(m.name)} |
+ ${esc(m.balance_display)} |
+ ${m.created_at ? m.created_at.slice(0,10) : ''} |
+
+ Statement
+ |
+
`).join('');
+}
+
+// ---------------------------------------------------------------------------
+// Cashier view
+// ---------------------------------------------------------------------------
+async function cashierSearchMembers() {
+ const q = document.getElementById('cashierSearch').value.trim();
+ const url = q ? `/members?q=${encodeURIComponent(q)}` : '/members';
+ try {
+ const members = await apiFetch(url);
+ const list = document.getElementById('cashierMemberList');
+ list.innerHTML = members.map(m => `
+
+
+
${esc(m.name)}
+
#${esc(m.member_number)}
+
+
${esc(m.balance_display)}
+
`).join('');
+ } catch (e) { console.error(e); }
+}
+
+function selectCashierMember(id, name, number, balance, balanceDisplay) {
+ cashierMember = { id, name, number };
+ document.getElementById('cashierMemberList').innerHTML = '';
+ document.getElementById('cashierSelected').innerHTML =
+ `${esc(name)} #${esc(number)} Balance: ${esc(balanceDisplay)}`;
+ document.getElementById('cashierForm').classList.remove('hidden');
+ setMsg('cashierMsg', '', '');
+}
+
+function clearCashierSelection() {
+ cashierMember = null;
+ document.getElementById('cashierForm').classList.add('hidden');
+ document.getElementById('cashierAmount').value = '';
+ document.getElementById('cashierStaff').value = '';
+ document.getElementById('cashierNote').value = '';
+ setMsg('cashierMsg', '', '');
+}
+
+async function doTopup() {
+ if (!cashierMember) return;
+ const amount = parseInt(document.getElementById('cashierAmount').value, 10);
+ const staff = document.getElementById('cashierStaff').value.trim();
+ const note = document.getElementById('cashierNote').value.trim();
+ if (!amount || isNaN(amount) || amount <= 0) { setMsg('cashierMsg', 'Enter a valid amount.', 'err'); return; }
+ if (!staff) { setMsg('cashierMsg', 'Staff name required.', 'err'); return; }
+ try {
+ const r = await apiFetch('/topup', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ member_id: cashierMember.id, amount, staff_name: staff, note: note || null })
+ });
+ setMsg('cashierMsg', `Top-up complete. New balance: ${r.new_balance_display}`, 'ok');
+ clearCashierSelection();
+ searchMembers();
+ } catch (e) {
+ setMsg('cashierMsg', e.message, 'err');
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Bar view
+// ---------------------------------------------------------------------------
+async function barSearchMembers() {
+ const q = document.getElementById('barSearch').value.trim();
+ const url = q ? `/members?q=${encodeURIComponent(q)}` : '/members';
+ try {
+ const members = await apiFetch(url);
+ const list = document.getElementById('barMemberList');
+ list.innerHTML = members.map(m => `
+
+
+
${esc(m.name)}
+
#${esc(m.member_number)}
+
+
${esc(m.balance_display)}
+
`).join('');
+ } catch (e) { console.error(e); }
+}
+
+function selectBarMember(id, name, number, balance, balanceDisplay) {
+ barMember = { id, name, number };
+ document.getElementById('barMemberList').innerHTML = '';
+ document.getElementById('barSelected').innerHTML =
+ `${esc(name)} #${esc(number)} Balance: ${esc(balanceDisplay)}`;
+ document.getElementById('barForm').classList.remove('hidden');
+ document.getElementById('barProductSearch').value = '';
+ document.getElementById('barProductResults').innerHTML = '';
+ setMsg('barMsg', '', '');
+}
+
+function clearBarSelection() {
+ barMember = null;
+ document.getElementById('barForm').classList.add('hidden');
+ document.getElementById('barAmount').value = '';
+ document.getElementById('barPin').value = '';
+ document.getElementById('barStaff').value = '';
+ document.getElementById('barNote').value = '';
+ document.getElementById('barProductSearch').value = '';
+ document.getElementById('barProductResults').innerHTML = '';
+ setMsg('barMsg', '', '');
+}
+
+let productTimer = null;
+async function barProductLookup() {
+ clearTimeout(productTimer);
+ productTimer = setTimeout(async () => {
+ const q = document.getElementById('barProductSearch').value.trim();
+ if (!q) { document.getElementById('barProductResults').innerHTML = ''; return; }
+ try {
+ const products = await apiFetch(`/products?q=${encodeURIComponent(q)}`);
+ const div = document.getElementById('barProductResults');
+ if (!products.length) { div.innerHTML = 'No products found
'; return; }
+ div.innerHTML = products.map(p => `
+
+
+
${esc(p.name)}${p.brand ? `
– ${esc(p.brand)}` : ''}
+ ${p.search_tags ? `
${esc(p.search_tags)}
` : ''}
+
+
+ ${esc(p.price_display)}
+ ${p.member_price_display ? `mbr: ${esc(p.member_price_display)}` : ''}
+
+
`).join('');
+ } catch (e) { console.error(e); }
+ }, 250);
+}
+
+function selectProduct(price, memberPrice, label) {
+ document.getElementById('barAmount').value = memberPrice;
+ document.getElementById('barNote').value = label;
+ document.getElementById('barProductResults').innerHTML = '';
+ document.getElementById('barProductSearch').value = '';
+}
+
+async function doCharge() {
+ if (!barMember) return;
+ const amount = parseInt(document.getElementById('barAmount').value, 10);
+ const pin = document.getElementById('barPin').value;
+ const staff = document.getElementById('barStaff').value.trim();
+ const note = document.getElementById('barNote').value.trim();
+ if (!amount || isNaN(amount) || amount <= 0) { setMsg('barMsg', 'Enter a valid amount.', 'err'); return; }
+ if (!pin) { setMsg('barMsg', 'PIN required.', 'err'); return; }
+ if (!staff) { setMsg('barMsg', 'Staff name required.', 'err'); return; }
+ try {
+ const r = await apiFetch('/charge', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ member_id: barMember.id, amount, pin, staff_name: staff, note: note || null })
+ });
+ setMsg('barMsg', `Charge complete. New balance: ${r.new_balance_display}`, 'ok');
+ clearBarSelection();
+ searchMembers();
+ } catch (e) {
+ setMsg('barMsg', e.message, 'err');
+ }
+}
+
+// ---------------------------------------------------------------------------
+// XSS-safe escape
+// ---------------------------------------------------------------------------
+function esc(str) {
+ if (str == null) return '';
+ return String(str)
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+}
diff --git a/static/index.html b/static/index.html
new file mode 100644
index 0000000..1d2f117
--- /dev/null
+++ b/static/index.html
@@ -0,0 +1,129 @@
+
+
+
+
+
+ClubLedger
+
+
+
+
+
+
+
+
+
+
Register New Member
+
+
+
+
+
+
Member Search
+
+
+
+
+
+
+
+
+
+
+
+
Top Up Account
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Charge Account
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/style.css b/static/style.css
new file mode 100644
index 0000000..eec8276
--- /dev/null
+++ b/static/style.css
@@ -0,0 +1,148 @@
+/* ClubLedger – main styles */
+*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+
+:root {
+ --primary: #1a56db;
+ --primary-dark: #1140a6;
+ --danger: #d63b3b;
+ --danger-dark: #a82e2e;
+ --success: #1a7f3c;
+ --bg: #f4f6fb;
+ --panel-bg: #ffffff;
+ --border: #d1d5db;
+ --text: #111827;
+ --muted: #6b7280;
+ --nav-bg: #1a1a2e;
+ --nav-text: #e0e0e0;
+}
+
+body { font-family: 'Segoe UI', system-ui, sans-serif; background: var(--bg); color: var(--text); min-height: 100vh; }
+
+/* ---- Nav ---- */
+nav {
+ background: var(--nav-bg);
+ color: var(--nav-text);
+ display: flex;
+ align-items: center;
+ padding: 0 20px;
+ height: 52px;
+ gap: 8px;
+ position: sticky;
+ top: 0;
+ z-index: 100;
+}
+.brand { font-size: 1.2rem; font-weight: 700; letter-spacing: .5px; margin-right: 16px; color: #fff; }
+.nav-btn {
+ background: transparent;
+ border: 2px solid transparent;
+ color: var(--nav-text);
+ padding: 6px 16px;
+ border-radius: 6px;
+ cursor: pointer;
+ font-size: .95rem;
+ transition: background .15s, border-color .15s;
+}
+.nav-btn:hover { background: rgba(255,255,255,.1); }
+.nav-btn.active { border-color: #4a9eff; color: #fff; }
+
+/* ---- Views ---- */
+.view { max-width: 900px; margin: 28px auto; padding: 0 16px; display: flex; flex-direction: column; gap: 24px; }
+.hidden { display: none !important; }
+
+/* ---- Panel ---- */
+.panel { background: var(--panel-bg); border: 1px solid var(--border); border-radius: 10px; padding: 24px; }
+.panel h2 { font-size: 1.15rem; margin-bottom: 18px; color: var(--text); }
+
+/* ---- Forms ---- */
+.form-row { display: flex; flex-direction: column; gap: 4px; margin-bottom: 14px; }
+.form-row label { font-size: .85rem; font-weight: 600; color: var(--muted); }
+.form-row input {
+ padding: 9px 12px;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ font-size: 1rem;
+ outline: none;
+ transition: border-color .15s;
+}
+.form-row input:focus { border-color: var(--primary); }
+
+.search-row { display: flex; gap: 8px; margin-bottom: 14px; }
+.search-row input { flex: 1; padding: 9px 12px; border: 1px solid var(--border); border-radius: 6px; font-size: 1rem; outline: none; }
+.search-row input:focus { border-color: var(--primary); }
+
+/* ---- Buttons ---- */
+.btn {
+ padding: 9px 20px;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ background: #fff;
+ cursor: pointer;
+ font-size: .95rem;
+ transition: background .15s;
+ margin-right: 6px;
+}
+.btn:hover { background: #f0f0f0; }
+.btn-primary { background: var(--primary); color: #fff; border-color: var(--primary); }
+.btn-primary:hover { background: var(--primary-dark); }
+.btn-danger { background: var(--danger); color: #fff; border-color: var(--danger); }
+.btn-danger:hover { background: var(--danger-dark); }
+
+/* ---- Data table ---- */
+.data-table { width: 100%; border-collapse: collapse; font-size: .93rem; margin-top: 8px; }
+.data-table th { background: #f0f2f7; padding: 8px 10px; text-align: left; font-weight: 600; border-bottom: 2px solid var(--border); }
+.data-table td { padding: 8px 10px; border-bottom: 1px solid #eee; }
+.data-table tr:last-child td { border-bottom: none; }
+.data-table tr:hover td { background: #f9f9ff; }
+.num { text-align: right; font-variant-numeric: tabular-nums; }
+.balance-pos { color: var(--success); font-weight: 600; }
+.balance-neg { color: var(--danger); font-weight: 600; }
+
+/* ---- Member pick list ---- */
+.member-pick-list { margin-bottom: 14px; }
+.member-pick-item {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 10px 14px;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ margin-bottom: 6px;
+ cursor: pointer;
+ transition: background .12s, border-color .12s;
+}
+.member-pick-item:hover { background: #eef2ff; border-color: var(--primary); }
+.member-pick-name { font-weight: 600; }
+.member-pick-sub { font-size: .83rem; color: var(--muted); }
+
+/* ---- Selected member box ---- */
+.selected-member-box {
+ background: #eef2ff;
+ border: 1px solid #c7d2f7;
+ border-radius: 8px;
+ padding: 12px 16px;
+ margin-bottom: 16px;
+ font-size: .95rem;
+}
+.selected-member-box strong { font-size: 1.05rem; }
+
+/* ---- Product results ---- */
+.product-results { margin-bottom: 14px; }
+.product-item {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 8px 12px;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ margin-bottom: 5px;
+ cursor: pointer;
+ transition: background .12s;
+}
+.product-item:hover { background: #f0fff4; border-color: #34d399; }
+.product-price { font-weight: 700; color: var(--primary); }
+
+/* ---- Messages ---- */
+.msg { margin-top: 12px; padding: 10px 14px; border-radius: 6px; font-size: .93rem; }
+.msg:empty { display: none; }
+.msg.ok { background: #d1fae5; color: #065f46; border: 1px solid #6ee7b7; }
+.msg.err { background: #fee2e2; color: #991b1b; border: 1px solid #fca5a5; }