import ReceiptPrinterEncoder from '@point-of-sale/receipt-printer-encoder'; import { useCallback, useRef, useState } from 'react'; export type PrinterConnectionType = 'bluetooth' | 'usb'; export type PaperWidth = 58 | 80; interface PrinterState { connected: boolean; connectionType: PrinterConnectionType | null; printerName: string | null; } /* ── Receipt Builder (persis seperti contoh Vue) ── */ export function getPaperColumns(paperWidth: PaperWidth): number { return paperWidth === 58 ? 32 : 48; } function dashedLine(columns: number): string { return '-'.repeat(columns); } export type OrderReceiptItem = { product_name: string; variant_name: string; quantity: string; unit_price: string; subtotal: string; }; export type OrderReceiptData = { order_number: string; created_at: string; customer_name: string | null; cashier_name: string; items: OrderReceiptItem[]; subtotal: string; discount: string; nego_price: string | null; total_amount: string; notes: string | null; }; export type EncodeOrderReceiptOptions = { storeName: string; storeAddress: string; paperWidth: PaperWidth; }; export function encodeOrderReceipt( order: OrderReceiptData, options: EncodeOrderReceiptOptions, ): Uint8Array { const columns = getPaperColumns(options.paperWidth); const amountColumnWidth = 14; const labelColumnWidth = columns - amountColumnWidth; const encoder = new ReceiptPrinterEncoder({ columns }); encoder .initialize() .align('center') .bold(true) .text('') .newline() .text(options.storeName) .newline() .bold(false) .newline(); for (const addressLine of options.storeAddress.split('\n')) { const trimmed = addressLine.trim(); if (trimmed) { encoder.align('center').text(trimmed).newline(); } } encoder.align('center').line(dashedLine(columns)); encoder .align('left') .line(`No : ${order.order_number}`) .line(`Tgl : ${order.created_at}`); if (order.customer_name) { encoder.line(`Cust : ${order.customer_name}`); } encoder .line(`Kasir: ${order.cashier_name}`) .line(dashedLine(columns)); for (const item of order.items) { encoder .line(item.product_name) .line(` ${item.variant_name}`) .table( [ { width: labelColumnWidth, align: 'left' }, { width: amountColumnWidth, align: 'right' }, ], [ [ `${item.quantity} x ${item.unit_price}`, (rowEncoder: any) => rowEncoder .bold() .text(item.subtotal) .bold(false), ], ], ) .newline(); } encoder.line(dashedLine(columns)); const summaryRows = [ ['Subtotal', order.subtotal], ['Diskon', order.discount], ]; if (order.nego_price) { summaryRows.push(['Harga Nego', order.nego_price]); } summaryRows.push(['Total', order.total_amount]); encoder.table( [ { width: labelColumnWidth, align: 'left' }, { width: amountColumnWidth, align: 'right' }, ], summaryRows, ); encoder .newline() .line(dashedLine(columns)) .align('center') .line('Terima kasih atas kunjungannya') .line('Silakan berbelanja kembali') .newline(3) .cut(); return encoder.encode(); } /* ── Printer Connection Hook ── */ export function useThermalPrinter() { const [state, setState] = useState({ connected: false, connectionType: null, printerName: null, }); const bluetoothDeviceRef = useRef(null); const bluetoothCharacteristicRef = useRef(null); const usbDeviceRef = useRef(null); const disconnect = useCallback(async () => { try { if ( state.connectionType === 'bluetooth' && bluetoothDeviceRef.current ) { bluetoothDeviceRef.current.gatt?.disconnect(); bluetoothDeviceRef.current = null; bluetoothCharacteristicRef.current = null; } if (state.connectionType === 'usb' && usbDeviceRef.current) { usbDeviceRef.current.close(); usbDeviceRef.current = null; } } catch { // silent } setState({ connected: false, connectionType: null, printerName: null, }); }, [state.connectionType]); const connectBluetooth = useCallback(async () => { if (!navigator.bluetooth) { throw new Error( 'Web Bluetooth tidak didukung di browser ini. Gunakan Chrome atau Edge.', ); } const device = await navigator.bluetooth.requestDevice({ acceptAllDevices: true, optionalServices: [ '000018f0-0000-1000-8000-00805f9b34fb', '00001800-0000-1000-8000-00805f9b34fb', '00001801-0000-1000-8000-00805f9b34fb', '00001101-0000-1000-8000-00805f9b34fb', '0000ffe0-0000-1000-8000-00805f9b34fb', '0000fee7-0000-1000-8000-00805f9b34fb', ], }); const server = await device.gatt?.connect(); if (!server) { throw new Error('Gagal terhubung ke perangkat Bluetooth.'); } let characteristic: BluetoothCharacteristic | null = null; const services = await server.getPrimaryServices(); for (const svc of services) { try { const chars = await svc.getCharacteristics(); const writable = chars.find( (c) => c.properties.writeWithoutResponse || c.properties.write, ); if (writable) { characteristic = writable; break; } } catch { // skip } } if (!characteristic) { throw new Error( 'Tidak ditemukan characteristic yang bisa ditulis di printer ini.', ); } bluetoothDeviceRef.current = device; bluetoothCharacteristicRef.current = characteristic; setState({ connected: true, connectionType: 'bluetooth', printerName: device.name ?? 'Printer Bluetooth', }); return device.name ?? 'Printer Bluetooth'; }, []); const connectUSB = useCallback(async () => { if (!navigator.usb) { throw new Error( 'Web USB tidak didukung di browser ini. Gunakan Chrome atau Edge.', ); } const device = await navigator.usb.requestDevice({ filters: [] }); await device.open(); if (device.configuration === null) { await device.selectConfiguration(1); } const iface = device.configuration?.interfaces.find((i) => i.alternate.endpoints.some((e) => e.direction === 'out'), ) ?? device.configuration?.interfaces[0]; if (iface) { await device.claimInterface(iface.interfaceNumber); } usbDeviceRef.current = device; setState({ connected: true, connectionType: 'usb', printerName: device.productName ?? 'Printer USB', }); return device.productName ?? 'Printer USB'; }, []); const sendToBluetooth = useCallback(async (data: Uint8Array) => { const characteristic = bluetoothCharacteristicRef.current; if (!characteristic) { throw new Error( 'Printer Bluetooth tidak terhubung.', ); } const useWriteWithoutResponse = characteristic.properties.writeWithoutResponse; const chunkSize = 128; for (let i = 0; i < data.length; i += chunkSize) { const chunk = data.slice(i, i + chunkSize); if (useWriteWithoutResponse) { await characteristic.writeValueWithoutResponse(chunk); } else { await characteristic.writeValueWithResponse(chunk); } await new Promise((r) => setTimeout(r, 10)); } }, []); const sendToUSB = useCallback(async (data: Uint8Array) => { const device = usbDeviceRef.current; if (!device) { throw new Error('Printer USB tidak terhubung.'); } const iface = device.configuration?.interfaces.find((i) => i.alternate.endpoints.some((e) => e.direction === 'out'), ); const endpoint = iface?.alternate.endpoints.find( (e) => e.direction === 'out', )?.endpointNumber ?? 1; const chunkSize = 512; for (let i = 0; i < data.length; i += chunkSize) { const chunk = data.slice(i, i + chunkSize); await device.transferOut(endpoint, chunk); } }, []); const print = useCallback( async (data: Uint8Array) => { if (!state.connected) { throw new Error('Tidak ada printer yang terhubung.'); } if (state.connectionType === 'bluetooth') { await sendToBluetooth(data); } else if (state.connectionType === 'usb') { await sendToUSB(data); } }, [state.connected, state.connectionType, sendToBluetooth, sendToUSB], ); const connect = useCallback( async (type: PrinterConnectionType) => { if (state.connected) { await disconnect(); } if (type === 'bluetooth') { return await connectBluetooth(); } return await connectUSB(); }, [state.connected, disconnect, connectBluetooth, connectUSB], ); return { connected: state.connected, connectionType: state.connectionType, printerName: state.printerName, connect, disconnect, print, }; }