Add public kiosk page with QR/pricing cycle and configurable slide timing
This commit is contained in:
parent
a25bf3a9c9
commit
c726525c8e
5 changed files with 351 additions and 1 deletions
92
app.py
92
app.py
|
|
@ -282,6 +282,32 @@ def get_refresh_seconds():
|
||||||
return val if val in (10, 20, 30) else 10
|
return val if val in (10, 20, 30) else 10
|
||||||
|
|
||||||
|
|
||||||
|
def get_kiosk_cycle_seconds():
|
||||||
|
"""Return the kiosk slide cycle interval in seconds. 0 = static price list; 5+ cycles QR and pricing."""
|
||||||
|
cfg = load_booth_settings()
|
||||||
|
try:
|
||||||
|
val = int(cfg.get('kiosk_cycle_seconds', 10))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
val = 10
|
||||||
|
if val == 0:
|
||||||
|
return 0
|
||||||
|
return max(5, val)
|
||||||
|
|
||||||
|
|
||||||
|
def get_kiosk_mode():
|
||||||
|
"""Return 'cycle', 'qr', or 'prices' based on kiosk_cycle_seconds setting."""
|
||||||
|
cfg = load_booth_settings()
|
||||||
|
try:
|
||||||
|
val = int(cfg.get('kiosk_cycle_seconds', 10))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
val = 10
|
||||||
|
if val == -1:
|
||||||
|
return 'qr'
|
||||||
|
if val == 0:
|
||||||
|
return 'prices'
|
||||||
|
return 'cycle'
|
||||||
|
|
||||||
|
|
||||||
def get_hermes_api_key():
|
def get_hermes_api_key():
|
||||||
"""
|
"""
|
||||||
Return the effective Hermes API key.
|
Return the effective Hermes API key.
|
||||||
|
|
@ -847,6 +873,49 @@ def admin_pricing():
|
||||||
return render_template('admin/pricing.html', fixed=fixed, customs=customs)
|
return render_template('admin/pricing.html', fixed=fixed, customs=customs)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/kiosk')
|
||||||
|
def kiosk():
|
||||||
|
"""
|
||||||
|
Public kiosk display page for the booth.
|
||||||
|
Shows open/closed banner, pricing, and optionally cycles with a QR code.
|
||||||
|
Auto-refreshes so pricing updates are picked up quickly.
|
||||||
|
"""
|
||||||
|
cfg = load_booth_settings()
|
||||||
|
pricing = cfg.get('pricing', {})
|
||||||
|
|
||||||
|
fixed_keys = {
|
||||||
|
'one_song': 'One Song',
|
||||||
|
'both_songs': 'Both Songs',
|
||||||
|
'wav_per_song': 'WAV files / song',
|
||||||
|
'stems_per_song': 'STEM files / song'
|
||||||
|
}
|
||||||
|
price_items = []
|
||||||
|
for key, label in fixed_keys.items():
|
||||||
|
val = pricing.get(key, '').strip()
|
||||||
|
if val:
|
||||||
|
price_items.append({'label': label, 'price': val})
|
||||||
|
for i in range(1, 6):
|
||||||
|
entry = pricing.get(f'custom_{i}')
|
||||||
|
if isinstance(entry, dict):
|
||||||
|
name = entry.get('name', '').strip()
|
||||||
|
price = entry.get('price', '').strip()
|
||||||
|
if name and price:
|
||||||
|
price_items.append({'label': name, 'price': price})
|
||||||
|
|
||||||
|
cycle_seconds = get_kiosk_cycle_seconds()
|
||||||
|
mode = get_kiosk_mode()
|
||||||
|
booth_open = get_booth_open()
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
'kiosk.html',
|
||||||
|
booth_open=booth_open,
|
||||||
|
price_items=price_items,
|
||||||
|
cycle_seconds=cycle_seconds,
|
||||||
|
mode=mode,
|
||||||
|
refresh_seconds=30
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.route('/admin/request/<int:rid>', methods=['GET', 'POST'])
|
@app.route('/admin/request/<int:rid>', methods=['GET', 'POST'])
|
||||||
def admin_request(rid):
|
def admin_request(rid):
|
||||||
"""
|
"""
|
||||||
|
|
@ -1248,6 +1317,28 @@ def admin_settings():
|
||||||
flash(f'Dashboard auto-refresh set to {val} seconds.', 'success')
|
flash(f'Dashboard auto-refresh set to {val} seconds.', 'success')
|
||||||
return redirect(url_for('admin_settings'))
|
return redirect(url_for('admin_settings'))
|
||||||
|
|
||||||
|
elif action == 'save_kiosk_cycle':
|
||||||
|
# Update kiosk slide cycle interval. Special values:
|
||||||
|
# -1 = show QR only, 0 = show pricing only, 5+ = cycle every N seconds.
|
||||||
|
raw = request.form.get('kiosk_cycle_seconds', '10').strip()
|
||||||
|
try:
|
||||||
|
val = int(raw)
|
||||||
|
except ValueError:
|
||||||
|
val = 10
|
||||||
|
if val not in (-1, 0) and val < 5:
|
||||||
|
val = 5
|
||||||
|
cfg = load_booth_settings()
|
||||||
|
cfg['kiosk_cycle_seconds'] = val
|
||||||
|
save_booth_settings(cfg)
|
||||||
|
if val == -1:
|
||||||
|
label = 'QR code only'
|
||||||
|
elif val == 0:
|
||||||
|
label = 'pricing only'
|
||||||
|
else:
|
||||||
|
label = f'cycle every {val} seconds'
|
||||||
|
flash(f'Kiosk mode set to {label}.', 'success')
|
||||||
|
return redirect(url_for('admin_settings'))
|
||||||
|
|
||||||
elif action == 'save_booth_open':
|
elif action == 'save_booth_open':
|
||||||
# Toggle whether the public request form is accepting submissions.
|
# Toggle whether the public request form is accepting submissions.
|
||||||
cfg = load_booth_settings()
|
cfg = load_booth_settings()
|
||||||
|
|
@ -1327,6 +1418,7 @@ def admin_settings():
|
||||||
upload_path=str(upload_root),
|
upload_path=str(upload_root),
|
||||||
current_max_revisions=current_max_revisions,
|
current_max_revisions=current_max_revisions,
|
||||||
current_refresh_seconds=current_refresh_seconds,
|
current_refresh_seconds=current_refresh_seconds,
|
||||||
|
current_kiosk_cycle_seconds=get_kiosk_cycle_seconds(),
|
||||||
booth_open=booth_open,
|
booth_open=booth_open,
|
||||||
email_form=email_form,
|
email_form=email_form,
|
||||||
metadata={
|
metadata={
|
||||||
|
|
|
||||||
BIN
static/qr-code.png
Executable file
BIN
static/qr-code.png
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 60 KiB |
|
|
@ -110,8 +110,9 @@
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<!-- Topbar: Pricing, Sales, Settings, Log out -->
|
<!-- Topbar: Kiosk, Pricing, Sales, Settings, Log out -->
|
||||||
<div class="topbar">
|
<div class="topbar">
|
||||||
|
<a href="{{ url_for('kiosk') }}" target="_blank" rel="noopener noreferrer">Kiosk</a>
|
||||||
<a href="{{ url_for('admin_pricing') }}">Pricing</a>
|
<a href="{{ url_for('admin_pricing') }}">Pricing</a>
|
||||||
<a href="{{ url_for('admin_sales') }}">Sales</a>
|
<a href="{{ url_for('admin_sales') }}">Sales</a>
|
||||||
<a href="{{ url_for('admin_settings') }}" class="settings">Settings</a>
|
<a href="{{ url_for('admin_settings') }}" class="settings">Settings</a>
|
||||||
|
|
|
||||||
|
|
@ -269,6 +269,19 @@
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Kiosk cycle -->
|
||||||
|
<div class="section">
|
||||||
|
<h2>Kiosk Display Mode</h2>
|
||||||
|
<p class="copy-hint">Set how the public <a href="{{ url_for('kiosk') }}" target="_blank" rel="noopener noreferrer">/kiosk</a> page cycles. Use -1 for QR only, 0 for pricing only, or 5+ seconds to cycle between them.</p>
|
||||||
|
<form method="POST">
|
||||||
|
<input type="hidden" name="action" value="save_kiosk_cycle">
|
||||||
|
<label for="kiosk_cycle_seconds">Kiosk cycle seconds</label>
|
||||||
|
<input type="number" id="kiosk_cycle_seconds" name="kiosk_cycle_seconds" value="{{ current_kiosk_cycle_seconds }}" min="-1">
|
||||||
|
<p class="copy-hint">Examples: -1 = QR only, 0 = pricing only, 10 = swap every 10 seconds.</p>
|
||||||
|
<button type="submit">Save Kiosk Mode</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Hermes API Key display (env var only; no regeneration) -->
|
<!-- Hermes API Key display (env var only; no regeneration) -->
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<h2>Hermes API Key</h2>
|
<h2>Hermes API Key</h2>
|
||||||
|
|
|
||||||
244
templates/kiosk.html
Normal file
244
templates/kiosk.html
Normal file
|
|
@ -0,0 +1,244 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Trollgorithm Booth — Kiosk</title>
|
||||||
|
{% if refresh_seconds and refresh_seconds > 0 %}
|
||||||
|
<meta http-equiv="refresh" content="{{ refresh_seconds }}">
|
||||||
|
{% endif %}
|
||||||
|
<style>
|
||||||
|
*{box-sizing:border-box;}
|
||||||
|
html,body{
|
||||||
|
margin:0;
|
||||||
|
padding:0;
|
||||||
|
height:100%;
|
||||||
|
font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
|
||||||
|
background:linear-gradient(135deg,#0f172a 0%,#1e3a8a 50%,#0f172a 100%);
|
||||||
|
color:#f3f4f6;
|
||||||
|
overflow:hidden;
|
||||||
|
}
|
||||||
|
.kiosk{
|
||||||
|
height:100%;
|
||||||
|
display:flex;
|
||||||
|
flex-direction:column;
|
||||||
|
align-items:center;
|
||||||
|
justify-content:space-between;
|
||||||
|
padding:2vh 3vw;
|
||||||
|
text-align:center;
|
||||||
|
}
|
||||||
|
.banner{
|
||||||
|
width:100%;
|
||||||
|
max-height:22vh;
|
||||||
|
object-fit:contain;
|
||||||
|
border-radius:1rem;
|
||||||
|
box-shadow:0 10px 40px rgba(0,0,0,.4);
|
||||||
|
}
|
||||||
|
.status-pill{
|
||||||
|
display:inline-block;
|
||||||
|
margin-top:1.5vh;
|
||||||
|
padding:.6rem 1.4rem;
|
||||||
|
border-radius:9999px;
|
||||||
|
font-weight:700;
|
||||||
|
font-size:1.2rem;
|
||||||
|
text-transform:uppercase;
|
||||||
|
letter-spacing:.05em;
|
||||||
|
}
|
||||||
|
.status-open{background:#10b981;color:#000;}
|
||||||
|
.status-closed{background:#ef4444;color:#000;}
|
||||||
|
.stage{
|
||||||
|
flex:1;
|
||||||
|
display:flex;
|
||||||
|
flex-direction:column;
|
||||||
|
align-items:center;
|
||||||
|
justify-content:center;
|
||||||
|
width:100%;
|
||||||
|
max-width:900px;
|
||||||
|
margin:2vh 0;
|
||||||
|
}
|
||||||
|
.slide{
|
||||||
|
width:100%;
|
||||||
|
animation:fadeIn .8s ease;
|
||||||
|
}
|
||||||
|
@keyframes fadeIn{
|
||||||
|
from{opacity:0;transform:translateY(20px);}
|
||||||
|
to{opacity:1;transform:translateY(0);}
|
||||||
|
}
|
||||||
|
.slide.hidden{display:none;}
|
||||||
|
h1{
|
||||||
|
margin:0 0 1.5vh 0;
|
||||||
|
font-size:clamp(2rem,5vw,3.5rem);
|
||||||
|
color:#60a5fa;
|
||||||
|
}
|
||||||
|
.qr-code{
|
||||||
|
max-height:44vh;
|
||||||
|
max-width:44vh;
|
||||||
|
width:auto;
|
||||||
|
border-radius:1rem;
|
||||||
|
box-shadow:0 10px 40px rgba(0,0,0,.4);
|
||||||
|
background:#fff;
|
||||||
|
padding:.6rem;
|
||||||
|
}
|
||||||
|
.qr-caption{
|
||||||
|
margin-top:1.5vh;
|
||||||
|
font-size:clamp(1.1rem,2.5vw,1.6rem);
|
||||||
|
color:#93c5fd;
|
||||||
|
}
|
||||||
|
.price-card{
|
||||||
|
background:rgba(31,41,55,.85);
|
||||||
|
border:1px solid rgba(96,165,250,.25);
|
||||||
|
border-radius:1.2rem;
|
||||||
|
padding:3vh 4vw;
|
||||||
|
box-shadow:0 15px 50px rgba(0,0,0,.35);
|
||||||
|
width:100%;
|
||||||
|
}
|
||||||
|
.price-card h2{
|
||||||
|
margin:0 0 2vh 0;
|
||||||
|
font-size:clamp(1.6rem,4vw,2.6rem);
|
||||||
|
color:#fbbf24;
|
||||||
|
}
|
||||||
|
.price-list{
|
||||||
|
list-style:none;
|
||||||
|
margin:0;
|
||||||
|
padding:0;
|
||||||
|
text-align:left;
|
||||||
|
}
|
||||||
|
.price-list li{
|
||||||
|
display:flex;
|
||||||
|
justify-content:space-between;
|
||||||
|
align-items:center;
|
||||||
|
border-bottom:1px solid rgba(147,197,253,.2);
|
||||||
|
padding:1.2vh 0;
|
||||||
|
font-size:clamp(1.2rem,3vw,1.8rem);
|
||||||
|
}
|
||||||
|
.price-list li:last-child{border-bottom:none;}
|
||||||
|
.price-list .label{color:#e5e7eb;}
|
||||||
|
.price-list .amount{font-weight:700;color:#fbbf24;}
|
||||||
|
.price-empty{
|
||||||
|
font-size:1.3rem;
|
||||||
|
color:#9ca3af;
|
||||||
|
}
|
||||||
|
.footer{
|
||||||
|
font-size:1rem;
|
||||||
|
color:#9ca3af;
|
||||||
|
}
|
||||||
|
.dots{
|
||||||
|
display:flex;
|
||||||
|
gap:.6rem;
|
||||||
|
justify-content:center;
|
||||||
|
margin-top:1vh;
|
||||||
|
}
|
||||||
|
.dot{
|
||||||
|
width:12px;
|
||||||
|
height:12px;
|
||||||
|
border-radius:50%;
|
||||||
|
background:#374151;
|
||||||
|
transition:background .3s;
|
||||||
|
}
|
||||||
|
.dot.active{background:#60a5fa;}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="kiosk">
|
||||||
|
<div class="header">
|
||||||
|
{% if booth_open %}
|
||||||
|
<img src="{{ url_for('static', filename='Trollgorithm_booth.jpg') }}" alt="Trollgorithm Theme Song Booth" class="banner">
|
||||||
|
<div class="status-pill status-open">Booth is Open ✅</div>
|
||||||
|
{% else %}
|
||||||
|
<img src="{{ url_for('static', filename='Booth_closed.png') }}" alt="Booth Closed" class="banner">
|
||||||
|
<div class="status-pill status-closed">Booth is Closed ❌</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stage" id="stage">
|
||||||
|
{% if mode == 'qr' %}
|
||||||
|
<div class="slide">
|
||||||
|
<h1>Scan to Order</h1>
|
||||||
|
<img src="{{ url_for('static', filename='qr-code.png') }}" alt="Scan to order" class="qr-code">
|
||||||
|
<p class="qr-caption">Point your camera at the code to start your request.</p>
|
||||||
|
</div>
|
||||||
|
{% elif mode == 'prices' %}
|
||||||
|
<div class="slide">
|
||||||
|
<div class="price-card">
|
||||||
|
<h2>Pricing</h2>
|
||||||
|
{% if price_items %}
|
||||||
|
<ul class="price-list">
|
||||||
|
{% for item in price_items %}
|
||||||
|
<li>
|
||||||
|
<span class="label">{{ item.label }}</span>
|
||||||
|
<span class="amount">{{ item.price }}</span>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
<p class="price-empty">Pricing coming soon. Ask the booth operator!</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="slide" id="slide-qr">
|
||||||
|
<h1>Scan to Order</h1>
|
||||||
|
<img src="{{ url_for('static', filename='qr-code.png') }}" alt="Scan to order" class="qr-code">
|
||||||
|
<p class="qr-caption">Point your camera at the code to start your request.</p>
|
||||||
|
</div>
|
||||||
|
<div class="slide hidden" id="slide-prices">
|
||||||
|
<div class="price-card">
|
||||||
|
<h2>Pricing</h2>
|
||||||
|
{% if price_items %}
|
||||||
|
<ul class="price-list">
|
||||||
|
{% for item in price_items %}
|
||||||
|
<li>
|
||||||
|
<span class="label">{{ item.label }}</span>
|
||||||
|
<span class="amount">{{ item.price }}</span>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
<p class="price-empty">Pricing coming soon. Ask the booth operator!</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if mode == 'cycle' %}
|
||||||
|
<div class="footer">
|
||||||
|
<div class="dots">
|
||||||
|
<div class="dot active" id="dot-qr"></div>
|
||||||
|
<div class="dot" id="dot-prices"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
const qr = document.getElementById('slide-qr');
|
||||||
|
const prices = document.getElementById('slide-prices');
|
||||||
|
const dotQr = document.getElementById('dot-qr');
|
||||||
|
const dotPrices = document.getElementById('dot-prices');
|
||||||
|
const seconds = {{ cycle_seconds }};
|
||||||
|
if (seconds > 0 && qr && prices) {
|
||||||
|
let showingQr = true;
|
||||||
|
setInterval(function(){
|
||||||
|
showingQr = !showingQr;
|
||||||
|
if (showingQr) {
|
||||||
|
qr.classList.remove('hidden');
|
||||||
|
prices.classList.add('hidden');
|
||||||
|
dotQr.classList.add('active');
|
||||||
|
dotPrices.classList.remove('active');
|
||||||
|
} else {
|
||||||
|
qr.classList.add('hidden');
|
||||||
|
prices.classList.remove('hidden');
|
||||||
|
dotQr.classList.remove('active');
|
||||||
|
dotPrices.classList.add('active');
|
||||||
|
}
|
||||||
|
}, seconds * 1000);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<p>{{ request.host_url }}request · Auto-refreshes every {{ refresh_seconds }}s</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in a new issue