Skip to content

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

  1. Kiosk Mode - Run in full-screen kiosk mode to prevent access to OS
  2. Network Security - Use VPN or private network for API communication
  3. PIN Management - PINs should be single-use and expire quickly
  4. Session Tokens - Rotate tokens regularly via heartbeat
  5. Hardware ID - Use TPM or secure element for device identity

Reliability

  1. Offline Mode - Cache menu data for offline operation
  2. Retry Logic - Implement exponential backoff for failed requests
  3. Health Monitoring - Alert on missed heartbeats
  4. Auto-Recovery - Restart on critical errors

Accessibility

  1. Screen Reader - Support for visually impaired users
  2. Large Touch Targets - Minimum 44x44px touch areas
  3. High Contrast - Option for high contrast mode
  4. Timeout Warnings - Audio/visual warnings before idle timeout

Changelog
DateChange
2026-01-15Initial publication.

ShopHero CommerceCore Platform