diff --git a/resources/js/app.tsx b/resources/js/app.tsx index 22c62b6..8bb1ba8 100644 --- a/resources/js/app.tsx +++ b/resources/js/app.tsx @@ -8,6 +8,8 @@ import SettingsLayout from '@/layouts/settings/layout'; const appName = import.meta.env.VITE_APP_NAME || 'Laravel'; +import { PrinterProvider } from '@/contexts/printer-context'; + createInertiaApp({ title: (title) => (title ? `${title} - ${appName}` : appName), layout: (name) => { @@ -26,7 +28,9 @@ createInertiaApp({ withApp(app) { return ( - {app} + + {app} + ); diff --git a/resources/js/contexts/printer-context.tsx b/resources/js/contexts/printer-context.tsx new file mode 100644 index 0000000..cb3de39 --- /dev/null +++ b/resources/js/contexts/printer-context.tsx @@ -0,0 +1,371 @@ +import React, { createContext, useContext, useState, useCallback, ReactNode, useEffect } from 'react'; +import { toast } from 'sonner'; + +export type PrinterType = 'bluetooth' | 'usb' | null; +export type PaperSize = '58' | '80'; + +interface PrinterState { + device: any; + type: PrinterType; + isConnected: boolean; + name: string | null; + paperSize: PaperSize; +} + +interface PrinterContextType extends PrinterState { + connectBluetooth: () => Promise; + connectUsb: () => Promise; + disconnect: () => Promise; + sendData: (data: Uint8Array) => Promise; + setPaperSize: (size: PaperSize) => void; +} + +const PrinterContext = createContext(undefined); + +async function findWriteCharacteristic(services: any[]) { + for (const service of services) { + const characteristics = await service.getCharacteristics(); + + for (const char of characteristics) { + if (char.properties.write || char.properties.writeWithoutResponse) { + return char; + } + } + } + + return null; +} + +export function PrinterProvider({ children }: { children: ReactNode }) { + const [state, setState] = useState({ + device: null, + type: null, + isConnected: false, + name: null, + paperSize: (localStorage.getItem('printer_paper_size') as PaperSize) || '58', + }); + + const [characteristic, setCharacteristic] = useState(null); + + // Save paper size to local storage + useEffect(() => { + localStorage.setItem('printer_paper_size', state.paperSize); + }, [state.paperSize]); + + // USB disconnect listener + useEffect(() => { + const nav = navigator as any; + if (!nav.usb) return; + + const handleUsbDisconnect = (event: any) => { + // Check if the disconnected device matches our current device + // We use vendorId and productId as a fallback if reference check fails + const currentDevice = state.device; + if (state.type === 'usb' && currentDevice && + (event.device === currentDevice || + (event.device.vendorId === currentDevice.vendorId && + event.device.productId === currentDevice.productId))) { + + setState(prev => ({ ...prev, device: null, type: null, isConnected: false, name: null })); + toast.error('Printer USB terputus'); + } + }; + + nav.usb.addEventListener('disconnect', handleUsbDisconnect); + return () => nav.usb.removeEventListener('disconnect', handleUsbDisconnect); + }, [state.type, state.device]); + + // Bluetooth disconnect listener + useEffect(() => { + if (state.type === 'bluetooth' && state.device) { + const device = state.device; + const handleDisconnect = () => { + setState(prev => ({ ...prev, device: null, type: null, isConnected: false, name: null })); + setCharacteristic(null); + toast.error('Printer Bluetooth terputus'); + }; + + device.addEventListener('gattserverdisconnected', handleDisconnect); + return () => device.removeEventListener('gattserverdisconnected', handleDisconnect); + } + }, [state.type, state.device]); + + // Auto-reconnect on mount + useEffect(() => { + const autoReconnect = async () => { + const nav = navigator as any; + const lastType = localStorage.getItem('last_printer_type'); + + if (lastType === 'bluetooth' && nav.bluetooth?.getDevices) { + try { + const devices = await nav.bluetooth.getDevices(); + if (devices.length > 0) { + const device = devices[0]; + const server = await device.gatt?.connect(); + const services = await server.getPrimaryServices(); + const writeChar = await findWriteCharacteristic(services); + if (writeChar) { + setCharacteristic(writeChar); + setState(prev => ({ + ...prev, + device, + type: 'bluetooth', + isConnected: true, + name: device.name || 'Bluetooth Printer', + })); + } + } + } catch (e) { + console.error('Auto-reconnect bluetooth failed:', e); + } + } else if (lastType === 'usb' && nav.usb?.getDevices) { + try { + const devices = await nav.usb.getDevices(); + if (devices.length > 0) { + const device = devices[0]; + await device.open(); + if (device.configuration === null) await device.selectConfiguration(1); + const iface = device.configuration.interfaces[0]; + await device.claimInterface(iface.interfaceNumber); + + setState(prev => ({ + ...prev, + device, + type: 'usb', + isConnected: true, + name: device.productName || 'USB Printer', + })); + } + } catch (e) { + console.error('Auto-reconnect usb failed:', e); + } + } + }; + + autoReconnect(); + }, []); + + const connectBluetooth = useCallback(async () => { + try { + const nav = navigator as any; + + if (!nav.bluetooth) { + toast.error('Bluetooth tidak didukung di browser ini'); + + return; + } + + const device = await nav.bluetooth.requestDevice({ + acceptAllDevices: true, + optionalServices: [ + '000018f0-0000-1000-8000-00805f9b34fb', + '49535343-fe7d-41aa-83b1-d10935904914', + '0000ff00-0000-1000-8000-00805f9b34fb', + '00001101-0000-1000-8000-00805f9b34fb' + ] + }); + + const server = await device.gatt?.connect(); + const services = await server.getPrimaryServices(); + + const writeChar = await findWriteCharacteristic(services); + + if (!writeChar) { + throw new Error('Tidak dapat menemukan characteristic untuk menulis data'); + } + + setCharacteristic(writeChar); + setState(prev => ({ + ...prev, + device, + type: 'bluetooth', + isConnected: true, + name: device.name || 'Bluetooth Printer', + })); + + localStorage.setItem('last_printer_type', 'bluetooth'); + toast.success(`Terhubung ke ${device.name || 'Printer Bluetooth'}`); + + } catch (error: any) { + console.error('Bluetooth connection error:', error); + + if (error.name !== 'NotFoundError') { + toast.error(error.message || 'Gagal menghubungkan printer bluetooth'); + } + } + }, []); + + const connectUsb = useCallback(async () => { + try { + const nav = navigator as any; + + if (!nav.usb) { + toast.error('USB tidak didukung di browser ini'); + + return; + } + + const device = await nav.usb.requestDevice({ + filters: [] + }); + + await device.open(); + + if (device.configuration === null) { + await device.selectConfiguration(1); + } + + const iface = device.configuration.interfaces[0]; + await device.claimInterface(iface.interfaceNumber); + + setState(prev => ({ + ...prev, + device, + type: 'usb', + isConnected: true, + name: device.productName || 'USB Printer', + })); + + localStorage.setItem('last_printer_type', 'usb'); + toast.success(`Terhubung ke ${device.productName || 'Printer USB'}`); + + } catch (error: any) { + console.error('USB connection error:', error); + + if (error.name !== 'NotFoundError') { + toast.error(error.message || 'Gagal menghubungkan printer USB'); + } + } + }, []); + + const setPaperSize = useCallback((size: PaperSize) => { + setState(prev => ({ ...prev, paperSize: size })); + }, []); + + const sendData = useCallback(async (data: Uint8Array) => { + if (!state.isConnected || !state.device) { + toast.error('Printer belum terhubung'); + + return; + } + + try { + if (state.type === 'bluetooth') { + const device = state.device; + let activeChar = characteristic; + + // Try to reconnect if GATT is disconnected + if (!device.gatt?.connected) { + toast.loading('Menghubungkan kembali...', { id: 'printer-reconnect' }); + try { + const server = await device.gatt?.connect(); + const services = await server.getPrimaryServices(); + const writeChar = await findWriteCharacteristic(services); + + if (writeChar) { + setCharacteristic(writeChar); + activeChar = writeChar; + toast.success('Printer terhubung kembali', { id: 'printer-reconnect' }); + } else { + throw new Error('Gagal menemukan characteristic'); + } + } catch (e) { + toast.error('Printer terputus', { id: 'printer-reconnect' }); + setState(prev => ({ ...prev, device: null, type: null, isConnected: false, name: null })); + setCharacteristic(null); + + return; + } + } + + if (!activeChar) { + throw new Error('Characteristic tidak ditemukan'); + } + + // Bluetooth printers often have small buffers and limited MTU. + // 20 bytes is the standard BLE MTU. + const chunkSize = 20; + + for (let i = 0; i < data.length; i += chunkSize) { + const chunk = data.slice(i, i + chunkSize); + + // Prefer writeValue (with response) for better reliability + // it acts as a primitive flow control + await activeChar.writeValue(chunk); + + // Add a tiny delay between chunks if the data is large to avoid buffer overflow + // especially for cheaper printers + if (i % 100 === 0 && i > 0) { + await new Promise(resolve => setTimeout(resolve, 50)); + } + } + } else if (state.type === 'usb') { + const device = state.device as any; + + try { + // Check if device is still open + if (!device.opened) { + await device.open(); + } + + const endpoint = device.configuration.interfaces[0].alternates[0].endpoints.find((e: any) => e.direction === 'out' && e.type === 'bulk'); + + if (!endpoint) { + throw new Error('Tidak dapat menemukan USB endpoint'); + } + + await device.transferOut(endpoint.endpointNumber, data); + } catch (e) { + console.error('USB print error:', e); + setState(prev => ({ ...prev, device: null, type: null, isConnected: false, name: null })); + toast.error('Printer USB terputus'); + + return; + } + } + } catch (error: any) { + console.error('Print error:', error); + toast.error('Gagal mengirim data ke printer: ' + (error.message || 'Error tidak diketahui')); + } + }, [state.isConnected, state.device, state.type, characteristic]); + + const disconnect = useCallback(async () => { + if (state.type === 'bluetooth' && state.device) { + if (state.device.gatt?.connected) { + state.device.gatt.disconnect(); + } + } else if (state.type === 'usb' && state.device) { + try { + await state.device.close(); + } catch (e) { + console.error('Error closing USB device:', e); + } + } + + localStorage.removeItem('last_printer_type'); + setState(prev => ({ ...prev, device: null, type: null, isConnected: false, name: null })); + setCharacteristic(null); + toast.info('Printer terputus'); + }, [state.device, state.type]); + + return ( + + {children} + + ); +} + +export function usePrinterContext() { + const context = useContext(PrinterContext); + if (context === undefined) { + throw new Error('usePrinterContext must be used within a PrinterProvider'); + } + return context; +} diff --git a/resources/js/hooks/use-printer.ts b/resources/js/hooks/use-printer.ts index a6444cd..2bac833 100644 --- a/resources/js/hooks/use-printer.ts +++ b/resources/js/hooks/use-printer.ts @@ -1,224 +1,7 @@ -import { useState, useCallback } from 'react'; -import { toast } from 'sonner'; +import { usePrinterContext } from '@/contexts/printer-context'; -// Type definitions for Web Bluetooth and Web USB -// This is to satisfy TypeScript if the global types are missing - - -export type PrinterType = 'bluetooth' | 'usb' | null; -export type PaperSize = '58' | '80'; - -interface PrinterState { - device: any; // Using any to avoid complex type issues with experimental APIs - type: PrinterType; - isConnected: boolean; - name: string | null; - paperSize: PaperSize; -} +export type { PrinterType, PaperSize } from '@/contexts/printer-context'; export function usePrinter() { - const [state, setState] = useState(() => { - return { - device: null, - type: null, - isConnected: false, - name: null, - paperSize: '58', - }; - }); - - const [characteristic, setCharacteristic] = useState(null); - - const connectBluetooth = useCallback(async () => { - try { - const nav = navigator as any; - - if (!nav.bluetooth) { - toast.error('Bluetooth tidak didukung di browser ini'); - - return; - } - - const device = await nav.bluetooth.requestDevice({ - acceptAllDevices: true, - optionalServices: ['000018f0-0000-1000-8000-00805f9b34fb', '49535343-fe7d-41aa-83b1-d10935904914', '0000ff00-0000-1000-8000-00805f9b34fb'] - }); - - const server = await device.gatt?.connect(); - const services = await server.getPrimaryServices(); - - const writeChar = await findWriteCharacteristic(services); - - if (!writeChar) { - throw new Error('Tidak dapat menemukan characteristic untuk menulis data'); - } - - setCharacteristic(writeChar); - setState(prev => ({ - ...prev, - device, - type: 'bluetooth', - isConnected: true, - name: device.name || 'Bluetooth Printer', - })); - - toast.success(`Terhubung ke ${device.name || 'Printer Bluetooth'}`); - - device.addEventListener('gattserverdisconnected', () => { - setState(prev => ({ ...prev, device: null, type: null, isConnected: false, name: null })); - setCharacteristic(null); - toast.error('Printer Bluetooth terputus'); - }); - - } catch (error: any) { - console.error('Bluetooth connection error:', error); - - if (error.name !== 'NotFoundError') { - toast.error(error.message || 'Gagal menghubungkan printer bluetooth'); - } - } - }, []); - - const connectUsb = useCallback(async () => { - try { - const nav = navigator as any; - - if (!nav.usb) { - toast.error('USB tidak didukung di browser ini'); - - return; - } - - const device = await nav.usb.requestDevice({ - filters: [] - }); - - await device.open(); - - if (device.configuration === null) { - await device.selectConfiguration(1); - } - - const iface = device.configuration.interfaces[0]; - await device.claimInterface(iface.interfaceNumber); - - setState(prev => ({ - ...prev, - device, - type: 'usb', - isConnected: true, - name: device.productName || 'USB Printer', - })); - - toast.success(`Terhubung ke ${device.productName || 'Printer USB'}`); - - } catch (error: any) { - console.error('USB connection error:', error); - - if (error.name !== 'NotFoundError') { - toast.error(error.message || 'Gagal menghubungkan printer USB'); - } - } - }, []); - - const setPaperSize = useCallback((size: PaperSize) => { - setState(prev => ({ ...prev, paperSize: size })); - }, []); - - const sendData = useCallback(async (data: Uint8Array) => { - if (!state.isConnected || !state.device) { - toast.error('Printer belum terhubung'); - - return; - } - - try { - if (state.type === 'bluetooth') { - const device = state.device; - - // Try to reconnect if GATT is disconnected - if (!device.gatt?.connected) { - toast.loading('Menghubungkan kembali...', { id: 'printer-reconnect' }); - const server = await device.gatt?.connect(); - const services = await server.getPrimaryServices(); - const writeChar = await findWriteCharacteristic(services); - - if (writeChar) { - setCharacteristic(writeChar); - toast.success('Printer terhubung kembali', { id: 'printer-reconnect' }); - } else { - toast.error('Gagal menghubungkan kembali printer', { id: 'printer-reconnect' }); - - return; - } - } - - if (!characteristic) { -throw new Error('Characteristic tidak ditemukan'); -} - - const chunkSize = 20; - - for (let i = 0; i < data.length; i += chunkSize) { - const chunk = data.slice(i, i + chunkSize); - - // Use writeValueWithoutResponse for better stability if supported - if (characteristic.writeValueWithoutResponse) { - await characteristic.writeValueWithoutResponse(chunk); - } else { - await characteristic.writeValue(chunk); - } - } - } else if (state.type === 'usb') { - const device = state.device as any; - const endpoint = device.configuration.interfaces[0].alternates[0].endpoints.find((e: any) => e.direction === 'out' && e.type === 'bulk'); - - if (!endpoint) { -throw new Error('Tidak dapat menemukan USB endpoint'); -} - - await device.transferOut(endpoint.endpointNumber, data); - } - } catch (error: any) { - console.error('Print error:', error); - toast.error('Gagal mengirim data ke printer: ' + (error.message || 'Error tidak diketahui')); - } - }, [state.isConnected, state.device, state.type, characteristic]); - - const disconnect = useCallback(async () => { - if (state.type === 'bluetooth' && state.device) { - if (state.device.gatt?.connected) { - state.device.gatt.disconnect(); - } - } else if (state.type === 'usb' && state.device) { - await state.device.close(); - } - - setState(prev => ({ ...prev, device: null, type: null, isConnected: false, name: null })); - setCharacteristic(null); - toast.info('Printer terputus'); - }, [state.device, state.type]); - - return { - ...state, - connectBluetooth, - connectUsb, - disconnect, - sendData, - setPaperSize, - }; -} - -async function findWriteCharacteristic(services: any[]) { - for (const service of services) { - const characteristics = await service.getCharacteristics(); - - for (const char of characteristics) { - if (char.properties.write || char.properties.writeWithoutResponse) { - return char; - } - } - } - - return null; + return usePrinterContext(); } diff --git a/resources/js/pages/admin/manage/order/hooks/use-order-print.ts b/resources/js/pages/admin/manage/order/hooks/use-order-print.ts index 963c7a4..595ecdf 100644 --- a/resources/js/pages/admin/manage/order/hooks/use-order-print.ts +++ b/resources/js/pages/admin/manage/order/hooks/use-order-print.ts @@ -84,7 +84,7 @@ export function useOrderPrint() { .feed(1) .line('Terima Kasih') .line('Selamat Belanja Kembali') - .feed(1) + .feed(3) .cut(); await sendData(result.encode());