123 lines
5.8 KiB
TypeScript
123 lines
5.8 KiB
TypeScript
/**
|
||
* A bar terminal on the command line, speaking the terminal protocol (docs/terminal-protocol.md): open tabs, ring up
|
||
* drinks, settle, and watch the dashboard follow. Keeps its own outbox like the app does.
|
||
*
|
||
* pnpm simulate # as the seeded demo terminal
|
||
* pnpm simulate --pair <code> # pairs as a new terminal first
|
||
*
|
||
* API_URL (default http://localhost:3001/api/v1) and TERMINAL_TOKEN override the defaults.
|
||
*/
|
||
import { randomUUID } from 'crypto';
|
||
import * as readline from 'readline/promises';
|
||
import { stdin, stdout } from 'process';
|
||
import type { SyncResponse, TerminalEvent } from '@kooltap/shared';
|
||
|
||
const API_URL = process.env.API_URL ?? 'http://localhost:3001/api/v1';
|
||
const HEADERS = { 'Content-Type': 'application/json', 'x-app-version': '2.0.0' };
|
||
|
||
let token = process.env.TERMINAL_TOKEN ?? 'kt_demo-terminal-token';
|
||
let state: SyncResponse | null = null;
|
||
let outbox: TerminalEvent[] = [];
|
||
|
||
async function call<T>(path: string, body: unknown, auth = true): Promise<T> {
|
||
const res = await fetch(`${API_URL}${path}`, {
|
||
method: 'POST',
|
||
headers: { ...HEADERS, ...(auth ? { Authorization: `Bearer ${token}` } : {}) },
|
||
body: JSON.stringify(body),
|
||
});
|
||
const json = await res.json();
|
||
if (!res.ok) throw new Error(`${res.status} ${json.error?.code}: ${json.error?.message}`);
|
||
return json;
|
||
}
|
||
|
||
/** Sends the outbox and takes over the venue's state, as the app does on every sync. */
|
||
async function sync() {
|
||
const sent = outbox;
|
||
state = await call<SyncResponse>('/terminal/sync', {
|
||
events: sent,
|
||
settlementsCursor: state?.settlementsCursor,
|
||
catalogVersion: state?.catalogVersion,
|
||
}).then((next) => ({ ...next, catalog: next.catalog ?? state?.catalog ?? [] }));
|
||
const acknowledged = new Set(state.acknowledgedEventIds);
|
||
outbox = outbox.filter((event) => !acknowledged.has(event.id));
|
||
for (const rejected of state.rejectedEvents) console.log(` rejected ${rejected.id}: ${rejected.reason}`);
|
||
for (const settlement of state.settlements) console.log(` paid: ${settlement.customerName} € ${(settlement.amountInCents / 100).toFixed(2)} (${settlement.paymentMethod})`);
|
||
show();
|
||
}
|
||
|
||
function show() {
|
||
const tabs = state?.tabs ?? [];
|
||
console.log(`\n${tabs.length} open tabs${outbox.length ? `, ${outbox.length} events unsent` : ''}`);
|
||
tabs.forEach((tab, i) => {
|
||
const total = tab.lines.reduce((sum, line) => sum + line.quantity * line.unitPriceInCents, 0);
|
||
const drinks = tab.lines.filter((l) => l.quantity > 0).map((l) => `${l.quantity}× ${l.productName}`).join(', ');
|
||
console.log(` ${i + 1}. ${tab.customerName.padEnd(16)} € ${(total / 100).toFixed(2).padStart(7)} ${drinks}`);
|
||
});
|
||
}
|
||
|
||
const event = <T extends TerminalEvent['type']>(type: T, fields: object) =>
|
||
({ id: randomUUID(), type, occurredAt: new Date().toISOString(), ...fields }) as Extract<TerminalEvent, { type: T }>;
|
||
|
||
async function main() {
|
||
const rl = readline.createInterface({ input: stdin, output: stdout });
|
||
const pairAt = process.argv.indexOf('--pair');
|
||
if (pairAt >= 0) {
|
||
const paired = await call<{ token: string; deviceId: string }>('/terminal/register', { registrationCode: process.argv[pairAt + 1], name: 'Simulator' }, false);
|
||
token = paired.token;
|
||
console.log(`Paired as ${paired.deviceId}`);
|
||
}
|
||
await sync();
|
||
|
||
for (;;) {
|
||
// Input that ends (a pipe) quits
|
||
const choice = (await rl.question('\n[s]ync [o]pen tab [a]dd drinks [r]emove drink [p]ay [q]uit > ').catch(() => 'q')).trim().toLowerCase();
|
||
try {
|
||
const tabs = state!.tabs;
|
||
const pickTab = async () => tabs[Number(await rl.question('Tab number: ')) - 1];
|
||
if (choice === 'q') break;
|
||
if (choice === 's') await sync();
|
||
if (choice === 'o') {
|
||
outbox.push(event('tab_opened', { tabId: randomUUID(), customerName: (await rl.question('Customer name: ')).trim() }));
|
||
await sync();
|
||
}
|
||
if (choice === 'a') {
|
||
const tab = await pickTab();
|
||
const products = state!.catalog!.filter((p) => !p.archived);
|
||
products.forEach((p, i) => console.log(` ${i + 1}. ${p.name} € ${(p.priceInCents / 100).toFixed(2)}`));
|
||
const product = products[Number(await rl.question('Product number: ')) - 1];
|
||
const quantity = Number(await rl.question('How many: ')) || 1;
|
||
const line = tab.lines.find((l) => l.productId === product.id);
|
||
outbox.push(event('line_added', {
|
||
tabId: tab.id, customerName: tab.customerName, lineId: line?.id ?? randomUUID(), orderId: randomUUID(),
|
||
productId: product.id, productName: product.name, unitPriceInCents: product.priceInCents, quantity,
|
||
}));
|
||
await sync();
|
||
}
|
||
if (choice === 'r') {
|
||
const tab = await pickTab();
|
||
const line = tab.lines.find((l) => l.quantity > 0);
|
||
const unit = line?.units.filter((u) => !u.removedAt).at(-1);
|
||
if (line && unit?.orderId) outbox.push(event('line_removed', { tabId: tab.id, lineId: line.id, orderId: unit.orderId, quantity: 1 }));
|
||
await sync();
|
||
}
|
||
if (choice === 'p') {
|
||
const tab = await pickTab();
|
||
const method = (await rl.question('cash / payconiq / payment_pending / other: ')).trim() || 'cash';
|
||
const items = tab.lines.filter((l) => l.quantity > 0)
|
||
.map((l) => ({ lineId: l.id, productId: l.productId, productName: l.productName, unitPriceInCents: l.unitPriceInCents, quantity: l.quantity }));
|
||
const amountInCents = items.reduce((sum, item) => sum + item.quantity * item.unitPriceInCents, 0);
|
||
outbox.push(event('settled', { tabId: tab.id, customerName: tab.customerName, amountInCents, paymentMethod: method, items }));
|
||
await sync();
|
||
}
|
||
} catch (error) {
|
||
console.log(` ${(error as Error).message}`);
|
||
}
|
||
}
|
||
rl.close();
|
||
}
|
||
|
||
main().catch((error) => {
|
||
console.error(error.message ?? error);
|
||
process.exitCode = 1;
|
||
});
|