Appearance
Kiosk Integration
Complete guide for building self-service ordering kiosks with the KitchenClick API.
Architecture Overview
┌─────────────────────────────────────────────────────────────────┐
│ KIOSK APPLICATION │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────────┐ │
│ │ Attract │ │ Menu │ │ Order & Payment │ │
│ │ Screen │──│ Browser │──│ Flow │ │
│ └─────────────┘ └─────────────┘ └─────────────────────────┘ │
│ │ │ │
│ └──────────────────┬───────────────────┘ │
│ │ │
│ ┌─────────────────────────┴─────────────────────────────────┐ │
│ │ Kiosk Manager Service │ │
│ │ - Bootstrap & Authentication │ │
│ │ - Heartbeat Management │ │
│ │ - Configuration Sync │ │
│ │ - Hardware Integration │ │
│ └─────────────────────────┬─────────────────────────────────┘ │
│ │ │
└────────────────────────────┼────────────────────────────────────┘
│
┌──────────────┴──────────────┐
│ KitchenClick API │
│ - Menu Data │
│ - Order Processing │
│ - Payment Integration │
└─────────────────────────────┘Kiosk Manager Service
Core Implementation
javascript
// services/KioskManager.js
class KioskManager extends EventEmitter {
constructor(apiClient) {
super();
this.apiClient = apiClient;
this.config = null;
this.sessionToken = null;
this.kioskHashkey = null;
this.heartbeatInterval = null;
this.metrics = {
ordersProcessed: 0,
averageOrderTime: 0,
errors: 0,
startTime: Date.now(),
};
}
// Bootstrap the kiosk with PIN
async bootstrap(pin) {
const deviceInfo = await this.collectDeviceInfo();
const response = await this.apiClient.post('/v1/ecommerce/kiosk/bootstrap', {
pin,
device_info: deviceInfo,
});
if (response.status !== 'success') {
throw new Error(response.message || 'Bootstrap failed');
}
const { data } = response;
this.config = data.config;
this.sessionToken = data.session_token;
this.kioskHashkey = data.kiosk.hashkey;
// Store for recovery after restart
await this.persistSession({
sessionToken: this.sessionToken,
kioskHashkey: this.kioskHashkey,
bootstrappedAt: Date.now(),
});
// Start heartbeat
this.startHeartbeat(data.heartbeat_interval_seconds);
// Emit ready event
this.emit('ready', {
kiosk: data.kiosk,
location: data.location,
concepts: data.concepts,
config: data.config,
});
return data;
}
// Heartbeat loop
startHeartbeat(intervalSeconds) {
if (this.heartbeatInterval) {
clearInterval(this.heartbeatInterval);
}
// Send immediately, then on interval
this.sendHeartbeat();
this.heartbeatInterval = setInterval(
() => this.sendHeartbeat(),
intervalSeconds * 1000
);
}
async sendHeartbeat() {
try {
const hardwareStatus = await this.checkHardwareStatus();
const response = await this.apiClient.post(
`/v1/ecommerce/kiosk/${this.kioskHashkey}/heartbeat`,
{
session_token: this.sessionToken,
status: this.determineOperationalStatus(hardwareStatus),
metrics: {
orders_since_last_heartbeat: this.metrics.ordersProcessed,
average_order_time_seconds: Math.round(this.metrics.averageOrderTime),
error_count: this.metrics.errors,
uptime_seconds: Math.round((Date.now() - this.metrics.startTime) / 1000),
},
hardware_status: hardwareStatus,
}
);
// Reset metrics after successful heartbeat
this.metrics.ordersProcessed = 0;
// Process server commands
if (response.data.commands?.length > 0) {
await this.processCommands(response.data.commands);
}
// Check for config updates
if (response.data.config_updated) {
await this.refreshConfig();
}
this.emit('heartbeat', { success: true });
} catch (error) {
console.error('Heartbeat failed:', error);
this.emit('heartbeat', { success: false, error });
if (error.status === 401) {
this.emit('session_expired');
}
}
}
async processCommands(commands) {
for (const command of commands) {
this.emit('command', command);
switch (command.type) {
case 'reload_menus':
this.emit('reload_menus');
break;
case 'update_config':
this.config = { ...this.config, ...command.config };
this.emit('config_updated', this.config);
break;
case 'restart':
if (command.scheduled_at) {
this.scheduleRestart(new Date(command.scheduled_at));
} else {
this.emit('restart_requested');
}
break;
case 'display_message':
this.emit('display_message', {
title: command.title,
message: command.message,
duration: command.duration_seconds,
});
break;
case 'shutdown':
this.emit('shutdown_requested');
break;
}
}
}
// Hardware status check
async checkHardwareStatus() {
const status = {};
// Check receipt printer
try {
status.printer = await this.printerService?.getStatus() || 'disconnected';
} catch {
status.printer = 'error';
}
// Check card reader
try {
status.card_reader = await this.cardReaderService?.getStatus() || 'disconnected';
} catch {
status.card_reader = 'error';
}
// Touchscreen is always ready if app is running
status.touchscreen = 'ready';
return status;
}
determineOperationalStatus(hardwareStatus) {
const statuses = Object.values(hardwareStatus);
if (statuses.some(s => s === 'error')) {
return 'error';
}
if (statuses.some(s => s === 'warning' || s === 'disconnected')) {
return 'degraded';
}
return 'operational';
}
// Track order for metrics
recordOrder(orderTimeMs) {
this.metrics.ordersProcessed++;
// Rolling average
const prevTotal = this.metrics.averageOrderTime * (this.metrics.ordersProcessed - 1);
this.metrics.averageOrderTime = (prevTotal + orderTimeMs / 1000) / this.metrics.ordersProcessed;
}
recordError() {
this.metrics.errors++;
}
// Collect device information
async collectDeviceInfo() {
return {
hardware_id: await this.getHardwareId(),
model: 'KitchenClick Kiosk',
os_version: navigator.userAgent,
app_version: APP_VERSION,
screen_resolution: `${window.screen.width}x${window.screen.height}`,
};
}
async getHardwareId() {
// In production, use secure hardware identifier
// For development, use localStorage
let id = localStorage.getItem('kiosk_hardware_id');
if (!id) {
id = 'kiosk-' + Math.random().toString(36).substring(2, 15);
localStorage.setItem('kiosk_hardware_id', id);
}
return id;
}
async persistSession(session) {
localStorage.setItem('kiosk_session', JSON.stringify(session));
}
async restoreSession() {
const stored = localStorage.getItem('kiosk_session');
if (stored) {
return JSON.parse(stored);
}
return null;
}
shutdown() {
if (this.heartbeatInterval) {
clearInterval(this.heartbeatInterval);
}
this.emit('shutdown');
}
}
export default KioskManager;Kiosk Application
Main App Component
javascript
// App.js
import React, { useState, useEffect, useCallback } from 'react';
import KioskManager from './services/KioskManager';
import ApiClient from './services/ApiClient';
import AttractScreen from './screens/AttractScreen';
import BootstrapScreen from './screens/BootstrapScreen';
import MenuScreen from './screens/MenuScreen';
import CheckoutScreen from './screens/CheckoutScreen';
import OrderCompleteScreen from './screens/OrderCompleteScreen';
import ErrorOverlay from './components/ErrorOverlay';
import MessageOverlay from './components/MessageOverlay';
const apiClient = new ApiClient();
const kioskManager = new KioskManager(apiClient);
export default function App() {
const [screen, setScreen] = useState('bootstrap');
const [kioskData, setKioskData] = useState(null);
const [error, setError] = useState(null);
const [message, setMessage] = useState(null);
// Initialize kiosk
useEffect(() => {
setupKioskListeners();
checkExistingSession();
return () => kioskManager.shutdown();
}, []);
function setupKioskListeners() {
kioskManager.on('ready', (data) => {
setKioskData(data);
setScreen('attract');
});
kioskManager.on('session_expired', () => {
setScreen('bootstrap');
setError({ title: 'Session Expired', message: 'Please enter PIN to continue' });
});
kioskManager.on('reload_menus', () => {
// Force menu refresh
setKioskData(prev => ({ ...prev, refreshKey: Date.now() }));
});
kioskManager.on('display_message', (msg) => {
setMessage(msg);
if (msg.duration) {
setTimeout(() => setMessage(null), msg.duration * 1000);
}
});
kioskManager.on('config_updated', (config) => {
setKioskData(prev => ({ ...prev, config }));
});
kioskManager.on('restart_requested', () => {
window.location.reload();
});
}
async function checkExistingSession() {
const session = await kioskManager.restoreSession();
if (session && Date.now() - session.bootstrappedAt < 24 * 60 * 60 * 1000) {
// Try to resume session
try {
kioskManager.sessionToken = session.sessionToken;
kioskManager.kioskHashkey = session.kioskHashkey;
// Verify by sending heartbeat
await kioskManager.sendHeartbeat();
// Session valid, load kiosk data
// In real app, fetch fresh data from API
setScreen('attract');
} catch {
// Session invalid, need new bootstrap
setScreen('bootstrap');
}
}
}
async function handleBootstrap(pin) {
try {
setError(null);
await kioskManager.bootstrap(pin);
} catch (err) {
setError({ title: 'Bootstrap Failed', message: err.message });
}
}
// Idle timeout - return to attract screen
const resetIdleTimer = useCallback(() => {
if (window.idleTimer) {
clearTimeout(window.idleTimer);
}
const timeout = kioskData?.config?.idle_timeout_seconds || 120;
window.idleTimer = setTimeout(() => {
if (!['attract', 'bootstrap'].includes(screen)) {
setScreen('attract');
}
}, timeout * 1000);
}, [screen, kioskData]);
useEffect(() => {
const events = ['touchstart', 'mousedown', 'keydown'];
events.forEach(e => window.addEventListener(e, resetIdleTimer));
resetIdleTimer();
return () => {
events.forEach(e => window.removeEventListener(e, resetIdleTimer));
if (window.idleTimer) clearTimeout(window.idleTimer);
};
}, [resetIdleTimer]);
// Render current screen
function renderScreen() {
switch (screen) {
case 'bootstrap':
return <BootstrapScreen onSubmit={handleBootstrap} />;
case 'attract':
return (
<AttractScreen
concepts={kioskData?.concepts}
onStart={() => setScreen('menu')}
/>
);
case 'menu':
return (
<MenuScreen
location={kioskData?.location}
concepts={kioskData?.concepts}
config={kioskData?.config}
onCheckout={() => setScreen('checkout')}
onCancel={() => setScreen('attract')}
/>
);
case 'checkout':
return (
<CheckoutScreen
kioskHashkey={kioskManager.kioskHashkey}
location={kioskData?.location}
config={kioskData?.config}
onComplete={(order) => {
kioskManager.recordOrder(order.orderTime);
setScreen('complete');
}}
onCancel={() => setScreen('menu')}
onError={(err) => {
kioskManager.recordError();
setError({ title: 'Order Failed', message: err.message });
}}
/>
);
case 'complete':
return (
<OrderCompleteScreen
onDone={() => setScreen('attract')}
/>
);
default:
return null;
}
}
return (
<div className="kiosk-app">
{renderScreen()}
{error && (
<ErrorOverlay
title={error.title}
message={error.message}
onDismiss={() => setError(null)}
/>
)}
{message && (
<MessageOverlay
title={message.title}
message={message.message}
/>
)}
</div>
);
}Bootstrap Screen
javascript
// screens/BootstrapScreen.js
import React, { useState } from 'react';
import './BootstrapScreen.css';
export default function BootstrapScreen({ onSubmit }) {
const [pin, setPin] = useState('');
const [loading, setLoading] = useState(false);
async function handleSubmit() {
if (pin.length !== 6) return;
setLoading(true);
try {
await onSubmit(pin);
} finally {
setLoading(false);
}
}
function handleKeyPress(key) {
if (key === 'clear') {
setPin('');
} else if (key === 'back') {
setPin(pin.slice(0, -1));
} else if (pin.length < 6) {
setPin(pin + key);
}
}
return (
<div className="bootstrap-screen">
<div className="bootstrap-content">
<h1>Kiosk Setup</h1>
<p>Enter your 6-digit PIN to activate this kiosk</p>
<div className="pin-display">
{[...Array(6)].map((_, i) => (
<div key={i} className={`pin-dot ${i < pin.length ? 'filled' : ''}`} />
))}
</div>
<div className="keypad">
{['1', '2', '3', '4', '5', '6', '7', '8', '9', 'clear', '0', 'back'].map(key => (
<button
key={key}
className={`keypad-button ${key === 'clear' || key === 'back' ? 'action' : ''}`}
onClick={() => handleKeyPress(key)}
disabled={loading}
>
{key === 'back' ? '←' : key.toUpperCase()}
</button>
))}
</div>
<button
className="submit-button"
onClick={handleSubmit}
disabled={pin.length !== 6 || loading}
>
{loading ? 'Activating...' : 'Activate Kiosk'}
</button>
</div>
</div>
);
}Attract Screen
javascript
// screens/AttractScreen.js
import React from 'react';
import './AttractScreen.css';
export default function AttractScreen({ concepts, onStart }) {
return (
<div className="attract-screen" onClick={onStart}>
<div className="attract-content">
<h1 className="attract-title">Touch to Order</h1>
<div className="concept-logos">
{concepts?.map(concept => (
<img
key={concept.hashkey}
src={concept.logo_url}
alt={concept.name}
className="concept-logo"
/>
))}
</div>
<div className="attract-prompt">
<span className="pulse-ring" />
<span className="tap-icon">👆</span>
</div>
</div>
<div className="attract-footer">
<p>Self-Service Ordering</p>
</div>
</div>
);
}Hardware Integration
Receipt Printer Service
javascript
// services/PrinterService.js
class PrinterService {
constructor() {
this.printer = null;
this.connected = false;
}
async connect() {
// Connect to printer via WebUSB or TCP
// Implementation depends on printer model
try {
// Example: Epson TM series via WebUSB
const device = await navigator.usb.requestDevice({
filters: [{ vendorId: 0x04b8 }] // Epson
});
await device.open();
this.printer = device;
this.connected = true;
return true;
} catch (error) {
console.error('Printer connection failed:', error);
return false;
}
}
async getStatus() {
if (!this.connected) return 'disconnected';
try {
// Query printer status
// Return 'ready', 'warning' (low paper), or 'error'
return 'ready';
} catch {
return 'error';
}
}
async printReceipt(order) {
if (!this.connected) {
throw new Error('Printer not connected');
}
const receipt = this.formatReceipt(order);
// Send to printer
// Implementation depends on printer protocol
}
formatReceipt(order) {
const lines = [];
// Header
lines.push({ text: 'ORDER RECEIPT', align: 'center', bold: true });
lines.push({ text: `Order #${order.order_number}`, align: 'center' });
lines.push({ text: new Date().toLocaleString(), align: 'center' });
lines.push({ text: '─'.repeat(32) });
// Items
for (const item of order.items) {
lines.push({ text: `${item.quantity}x ${item.name}` });
if (item.customizations?.length > 0) {
for (const mod of item.customizations) {
lines.push({ text: ` + ${mod}`, indent: 2 });
}
}
lines.push({ text: `$${item.line_total.toFixed(2)}`, align: 'right' });
}
// Totals
lines.push({ text: '─'.repeat(32) });
lines.push({ text: `Subtotal: $${order.totals.subtotal.toFixed(2)}`, align: 'right' });
lines.push({ text: `Tax: $${order.totals.tax.toFixed(2)}`, align: 'right' });
lines.push({ text: `TOTAL: $${order.totals.total.toFixed(2)}`, align: 'right', bold: true });
// Footer
lines.push({ text: '─'.repeat(32) });
lines.push({ text: 'Thank you for your order!', align: 'center' });
return lines;
}
}
export default PrinterService;Deployment Considerations
Security
- Kiosk Mode - Run in full-screen kiosk mode to prevent access to OS
- Network Security - Use VPN or private network for API communication
- PIN Management - PINs should be single-use and expire quickly
- Session Tokens - Rotate tokens regularly via heartbeat
- Hardware ID - Use TPM or secure element for device identity
Reliability
- Offline Mode - Cache menu data for offline operation
- Retry Logic - Implement exponential backoff for failed requests
- Health Monitoring - Alert on missed heartbeats
- Auto-Recovery - Restart on critical errors
Accessibility
- Screen Reader - Support for visually impaired users
- Large Touch Targets - Minimum 44x44px touch areas
- High Contrast - Option for high contrast mode
- Timeout Warnings - Audio/visual warnings before idle timeout
Changelog
| Date | Change |
|---|---|
| 2026-01-15 | Initial publication. |