@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* 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;
|
||||
});
|
||||
Reference in New Issue
Block a user