@teincfood/core 0.7.7 → 0.7.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Image cache for offline reference — port of Business src/reference/image-cache.ts
3
+ * Durable documentDirectory cache (not cacheDirectory) with stable presigned URL keying.
4
+ * Host injects FileSystemAdapter; falls back to no-op on web.
5
+ */
6
+ import { getFileSystemAdapter } from "../adapters/filesystem";
7
+ import { syncLogger } from "../utils/sync-logger";
8
+ export function stableCacheUrl(url) {
9
+ try {
10
+ const p = new URL(url);
11
+ return `${p.origin}${p.pathname}`;
12
+ }
13
+ catch {
14
+ return url;
15
+ }
16
+ }
17
+ function cacheKeyFromUrl(url) {
18
+ let hash = 0;
19
+ const stable = stableCacheUrl(url);
20
+ for (let i = 0; i < stable.length; i++) {
21
+ hash = (hash << 5) - hash + stable.charCodeAt(i);
22
+ hash |= 0;
23
+ }
24
+ return `${Math.abs(hash).toString(16)}.img`;
25
+ }
26
+ function getImageCacheDir(fs) {
27
+ const base = fs?.getDocumentDirectory() ?? "";
28
+ return `${base}teincfood_images/`;
29
+ }
30
+ export function getCachedImagePath(url) {
31
+ const fs = getFileSystemAdapter();
32
+ return `${getImageCacheDir(fs)}${cacheKeyFromUrl(url)}`;
33
+ }
34
+ export async function ensureImageCacheDir() {
35
+ const fs = getFileSystemAdapter();
36
+ if (!fs)
37
+ return "";
38
+ const dir = getImageCacheDir(fs);
39
+ const info = await fs.getInfoAsync(dir);
40
+ if (!info.exists)
41
+ await fs.makeDirectoryAsync(dir, { intermediates: true });
42
+ return dir;
43
+ }
44
+ export async function getCachedImageInfo(url) {
45
+ const fs = getFileSystemAdapter();
46
+ if (!fs)
47
+ return { remoteUrl: url, localUri: url, etag: null, lastModified: null, cachedAt: new Date().toISOString() };
48
+ const localPath = getCachedImagePath(url);
49
+ const info = await fs.getInfoAsync(localPath);
50
+ return { remoteUrl: url, localUri: info.exists && !info.isDirectory ? localPath : null, etag: null, lastModified: null, cachedAt: info.exists ? new Date().toISOString() : null };
51
+ }
52
+ export async function cacheRemoteImage(url, opts = {}) {
53
+ if (!url)
54
+ return null;
55
+ const fs = getFileSystemAdapter();
56
+ if (!fs)
57
+ return { remoteUrl: url, localUri: url, etag: null, lastModified: null, cachedAt: new Date().toISOString() };
58
+ await ensureImageCacheDir();
59
+ const localPath = getCachedImagePath(url);
60
+ const existing = await fs.getInfoAsync(localPath);
61
+ if (opts.skipIfCached && existing.exists && !existing.isDirectory) {
62
+ return { remoteUrl: url, localUri: localPath, etag: null, lastModified: null, cachedAt: new Date().toISOString() };
63
+ }
64
+ try {
65
+ const res = await fs.downloadAsync(url, localPath);
66
+ if (res.uri)
67
+ return { remoteUrl: url, localUri: localPath, etag: null, lastModified: null, cachedAt: new Date().toISOString() };
68
+ }
69
+ catch (e) {
70
+ syncLogger.warn("ImageCache", "Failed to download", { url, error: String(e) });
71
+ }
72
+ if (opts.removeOnFailure)
73
+ await fs.deleteAsync(localPath, { idempotent: true }).catch(() => { });
74
+ if (existing.exists && !existing.isDirectory)
75
+ return { remoteUrl: url, localUri: localPath, etag: null, lastModified: null, cachedAt: new Date().toISOString() };
76
+ return { remoteUrl: url, localUri: null, etag: null, lastModified: null, cachedAt: null };
77
+ }
78
+ export async function cacheImageBatch(urls, opts = {}) {
79
+ const results = await Promise.all(urls.map((u) => cacheRemoteImage(u, opts)));
80
+ return results.filter((r) => r !== null);
81
+ }
82
+ export async function clearImageCache() {
83
+ const fs = getFileSystemAdapter();
84
+ if (!fs)
85
+ return;
86
+ await fs.deleteAsync(getImageCacheDir(fs), { idempotent: true });
87
+ }
88
+ export async function pruneImageCache(knownUrls, maxAgeDays = 30) {
89
+ try {
90
+ const fs = getFileSystemAdapter();
91
+ if (!fs)
92
+ return 0;
93
+ const known = new Set();
94
+ for (const u of knownUrls)
95
+ if (u)
96
+ known.add(u);
97
+ const dir = getImageCacheDir(fs);
98
+ const info = await fs.getInfoAsync(dir);
99
+ if (!info.exists || info.isDirectory !== true)
100
+ return 0;
101
+ // cutoff check skipped without modificationTime — just orphan check
102
+ const entries = await fs.readDirectoryAsync(dir);
103
+ let pruned = 0;
104
+ for (const f of entries) {
105
+ if (!f.endsWith(".img"))
106
+ continue;
107
+ if ([...known].some((url) => getCachedImagePath(url) === `${dir}${f}`))
108
+ continue;
109
+ await fs.deleteAsync(`${dir}${f}`, { idempotent: true });
110
+ pruned += 1;
111
+ }
112
+ if (pruned > 0)
113
+ syncLogger.info("ImageCache", "Pruned orphan images", { pruned });
114
+ return pruned;
115
+ }
116
+ catch {
117
+ return 0;
118
+ }
119
+ }
120
+ export async function resolveImageSource(url) {
121
+ if (!url)
122
+ return null;
123
+ const cached = await getCachedImageInfo(url);
124
+ return cached.localUri ? { uri: cached.localUri } : { uri: url };
125
+ }
package/dist/index.d.ts CHANGED
@@ -63,16 +63,27 @@ export * from "./reference/service";
63
63
  export { referenceSyncService } from "./reference/service";
64
64
  export * from "./reference/capabilities";
65
65
  export * from "./adapters/sqlite";
66
- export * from "./adapters/kv";
66
+ export { KVDriver, setKVDriver, getKVDriver, kvGetItem, kvSetItem, kvRemoveItem, getItem, setItem, removeItem, getItemSync, setItemSync } from "./adapters/kv";
67
67
  export * from "./adapters/connectivity";
68
68
  export * from "./adapters/http";
69
69
  export * from "./adapters/local-node";
70
70
  export * from "./adapters/services";
71
71
  export * from "./adapters/devices";
72
- export * from "./adapters/image-cache";
72
+ export { imageCacheService } from "./adapters/image-cache";
73
73
  export * from "./adapters/business";
74
74
  export * from "./adapters/pending-orders";
75
+ export * from "./adapters/print";
76
+ export * from "./adapters/filesystem";
75
77
  export { KIOSK_KEYS, SELLER_ORDERS_KEYS, BUSINESS_DELIVERIES_KEYS, MENU_KEYS, REFERENCE_KEYS } from "./adapters/query-keys";
78
+ export * from "./printer/types";
79
+ export * from "./printer/store";
80
+ export * from "./printer/receipt";
81
+ export * from "./printer/service";
82
+ export * from "./printer/hooks";
83
+ export * from "./cart/store";
84
+ export * from "./cart/hooks";
85
+ export * from "./catalog/hooks";
86
+ export { getCachedImagePath, ensureImageCacheDir, getCachedImageInfo, cacheRemoteImage, cacheImageBatch, clearImageCache, pruneImageCache, resolveImageSource, stableCacheUrl } from "./image-cache/service";
76
87
  import { type SqliteDriver } from "./adapters/sqlite";
77
88
  import { type KVDriver } from "./adapters/kv";
78
89
  import { type HttpClient } from "./adapters/http";
@@ -88,6 +99,12 @@ export interface CoreConfig {
88
99
  isOnline?: boolean;
89
100
  isCloudReachable?: "reachable" | "unreachable" | "unknown";
90
101
  };
102
+ printAdapters?: {
103
+ tcp?: import("./adapters/print").TcpSocketAdapter;
104
+ printFile?: import("./adapters/print").PrintFileAdapter;
105
+ mdns?: import("./adapters/print").MdnsAdapter;
106
+ };
107
+ fileSystemAdapter?: import("./adapters/filesystem").FileSystemAdapter;
91
108
  }
92
109
  export declare function createTeincCore(config?: CoreConfig): {
93
110
  engine: SyncEngine;
package/dist/index.js CHANGED
@@ -66,22 +66,39 @@ export { referenceSyncService } from "./reference/service";
66
66
  export * from "./reference/capabilities";
67
67
  // ── Adapters (for host apps to supply) ──
68
68
  export * from "./adapters/sqlite";
69
- export * from "./adapters/kv";
69
+ export { setKVDriver, getKVDriver, kvGetItem, kvSetItem, kvRemoveItem, getItem, setItem, removeItem, getItemSync, setItemSync } from "./adapters/kv";
70
70
  export * from "./adapters/connectivity";
71
71
  export * from "./adapters/http";
72
72
  export * from "./adapters/local-node";
73
73
  export * from "./adapters/services";
74
74
  export * from "./adapters/devices";
75
- export * from "./adapters/image-cache";
75
+ export { imageCacheService } from "./adapters/image-cache";
76
76
  export * from "./adapters/business";
77
77
  export * from "./adapters/pending-orders";
78
+ export * from "./adapters/print";
79
+ export * from "./adapters/filesystem";
78
80
  export { KIOSK_KEYS, SELLER_ORDERS_KEYS, BUSINESS_DELIVERIES_KEYS, MENU_KEYS, REFERENCE_KEYS } from "./adapters/query-keys";
79
81
  // POS_KEYS / SELLER_KEYS are exported from hooks/orders.hooks (offline-first source of truth)
82
+ // ── Printer (backend-identical template) ──
83
+ export * from "./printer/types";
84
+ export * from "./printer/store";
85
+ export * from "./printer/receipt";
86
+ export * from "./printer/service";
87
+ export * from "./printer/hooks";
88
+ // ── Cart (device-local) ──
89
+ export * from "./cart/store";
90
+ export * from "./cart/hooks";
91
+ // ── Catalog ──
92
+ export * from "./catalog/hooks";
93
+ // ── Image cache (durable) ──
94
+ export { getCachedImagePath, ensureImageCacheDir, getCachedImageInfo, cacheRemoteImage, cacheImageBatch, clearImageCache, pruneImageCache, resolveImageSource, stableCacheUrl } from "./image-cache/service";
80
95
  // ── Init helper ──
81
96
  import { setSqliteDriver } from "./adapters/sqlite";
82
97
  import { setKVDriver } from "./adapters/kv";
83
98
  import { setConnectivityState } from "./adapters/connectivity";
84
99
  import { setHttpClient } from "./adapters/http";
100
+ import { setTcpAdapter, setPrintFileAdapter, setMdnsAdapter } from "./adapters/print";
101
+ import { setFileSystemAdapter } from "./adapters/filesystem";
85
102
  import { API_BASE_URL, WS_BASE_URL, setApiBaseUrl, setWsBaseUrl } from "./utils/constants";
86
103
  import { SyncEngine } from "./sync/engine";
87
104
  import { restTransport } from "./sync/transport-rest";
@@ -105,6 +122,14 @@ export function createTeincCore(config = {}) {
105
122
  setKVDriver(config.kvDriver);
106
123
  if (config.httpClient)
107
124
  setHttpClient(config.httpClient);
125
+ if (config.printAdapters?.tcp)
126
+ setTcpAdapter(config.printAdapters.tcp);
127
+ if (config.printAdapters?.printFile)
128
+ setPrintFileAdapter(config.printAdapters.printFile);
129
+ if (config.printAdapters?.mdns)
130
+ setMdnsAdapter(config.printAdapters.mdns);
131
+ if (config.fileSystemAdapter)
132
+ setFileSystemAdapter(config.fileSystemAdapter);
108
133
  if (config.apiBaseUrl)
109
134
  setApiBaseUrl(config.apiBaseUrl);
110
135
  else {
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Printer hooks for UI.
3
+ */
4
+ import { setDefaultPrinter, setAutoPrint, addPrinter, updatePrinter, removePrinter } from "./store";
5
+ import type { SellerOrder } from "../types/api/seller.api";
6
+ export declare function usePrinter(): {
7
+ defaultPrinterId: string | null;
8
+ printers: import("./types").PrinterConfig[];
9
+ autoPrint: boolean;
10
+ setDefaultPrinter: typeof setDefaultPrinter;
11
+ setAutoPrint: typeof setAutoPrint;
12
+ addPrinter: typeof addPrinter;
13
+ updatePrinter: typeof updatePrinter;
14
+ removePrinter: typeof removePrinter;
15
+ };
16
+ export declare function usePrintOrder(): {
17
+ print: (order: SellerOrder) => Promise<void>;
18
+ printing: boolean;
19
+ };
20
+ export declare function useDiscoverPrinters(): {
21
+ printers: {
22
+ name: string;
23
+ ip: string;
24
+ port: number;
25
+ }[];
26
+ discovering: boolean;
27
+ discover: (timeoutMs?: number) => Promise<{
28
+ name: string;
29
+ ip: string;
30
+ port: number;
31
+ }[]>;
32
+ };
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Printer hooks for UI.
3
+ */
4
+ import { useSyncExternalStore, useCallback } from "react";
5
+ import { subscribePrinter, getPrinterSnapshot, setDefaultPrinter, setAutoPrint, addPrinter, updatePrinter, removePrinter, hydratePrinterStore } from "./store";
6
+ import { getMdnsAdapter } from "../adapters/print";
7
+ import { printerService } from "./service";
8
+ import { useEffect, useState } from "react";
9
+ export function usePrinter() {
10
+ const state = useSyncExternalStore(subscribePrinter, getPrinterSnapshot, getPrinterSnapshot);
11
+ useEffect(() => { void hydratePrinterStore(); }, []);
12
+ return {
13
+ ...state,
14
+ setDefaultPrinter,
15
+ setAutoPrint,
16
+ addPrinter,
17
+ updatePrinter,
18
+ removePrinter,
19
+ };
20
+ }
21
+ export function usePrintOrder() {
22
+ const [printing, setPrinting] = useState(false);
23
+ const print = useCallback(async (order) => {
24
+ setPrinting(true);
25
+ try {
26
+ await printerService.printOrderReceipt(order);
27
+ }
28
+ finally {
29
+ setPrinting(false);
30
+ }
31
+ }, []);
32
+ return { print, printing };
33
+ }
34
+ export function useDiscoverPrinters() {
35
+ const [discovering, setDiscovering] = useState(false);
36
+ const [printers, setPrinters] = useState([]);
37
+ const discover = useCallback(async (timeoutMs = 8000) => {
38
+ setDiscovering(true);
39
+ try {
40
+ const mdns = getMdnsAdapter();
41
+ if (mdns?.discoverPrinters) {
42
+ const found = await mdns.discoverPrinters(timeoutMs);
43
+ setPrinters(found);
44
+ return found;
45
+ }
46
+ return [];
47
+ }
48
+ finally {
49
+ setDiscovering(false);
50
+ }
51
+ }, []);
52
+ return { printers, discovering, discover };
53
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Local receipt generation — 1:1 port of TeincFoodBackend.Orders.Receipt
3
+ * Generates ESC/POS (80mm, 32 chars) and HTML from a SellerOrder snapshot.
4
+ * Works fully offline for queued DD-RRR-CCC orders without backend fetch.
5
+ *
6
+ * Backend source: lib/teinc_food_backend/orders/receipt.ex
7
+ * Keep this file in sync with backend when template changes.
8
+ */
9
+ import type { SellerOrder } from "../types/api/seller.api";
10
+ export declare function calloutNumber(order: SellerOrder): string;
11
+ export declare function generateEscpos(order: SellerOrder): string;
12
+ export declare function generateHtml(order: SellerOrder, logoDataUri?: string | null): string;
@@ -0,0 +1,315 @@
1
+ /**
2
+ * Local receipt generation — 1:1 port of TeincFoodBackend.Orders.Receipt
3
+ * Generates ESC/POS (80mm, 32 chars) and HTML from a SellerOrder snapshot.
4
+ * Works fully offline for queued DD-RRR-CCC orders without backend fetch.
5
+ *
6
+ * Backend source: lib/teinc_food_backend/orders/receipt.ex
7
+ * Keep this file in sync with backend when template changes.
8
+ */
9
+ const LINE_WIDTH = 32;
10
+ const ESC_INIT = "\x1B\x40";
11
+ const ESC_ALIGN_LEFT = "\x1B\x61\x00";
12
+ const ESC_ALIGN_CENTER = "\x1B\x61\x01";
13
+ const ESC_BOLD_ON = "\x1B\x45\x01";
14
+ const ESC_BOLD_OFF = "\x1B\x45\x00";
15
+ const GS_SIZE_NORMAL = "\x1D\x21\x00";
16
+ const GS_SIZE_DOUBLE_W = "\x1D\x21\x11";
17
+ const GS_SIZE_DOUBLE_WH = "\x1D\x21\x33";
18
+ const LF = "\x0A";
19
+ const ESC_FEED_3 = "\x1B\x64\x03";
20
+ const GS_CUT_FULL = "\x1D\x56\x00";
21
+ function padR(text, width) {
22
+ if (text.length >= width)
23
+ return text.slice(0, width);
24
+ return text.padEnd(width, " ");
25
+ }
26
+ function currencySymbol(currency) {
27
+ switch (currency) {
28
+ case "GHS": return "₵";
29
+ case "USD": return "$";
30
+ case "EUR": return "€";
31
+ case "GBP": return "£";
32
+ default: return currency ? currency + " " : "";
33
+ }
34
+ }
35
+ function formatMoney(amountMinor, order) {
36
+ if (amountMinor == null)
37
+ return "---";
38
+ const sym = currencySymbol(order.currency_code || order.currency);
39
+ return `${sym}${(amountMinor / 100).toFixed(2)}`;
40
+ }
41
+ function formatDatetime(iso) {
42
+ if (!iso)
43
+ return "---";
44
+ try {
45
+ const d = new Date(iso);
46
+ // Backend: Calendar.strftime(dt, "%d %b %Y %H:%M") e.g. "03 Sep 2026 14:05"
47
+ const day = String(d.getDate()).padStart(2, "0");
48
+ const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
49
+ const mon = months[d.getMonth()];
50
+ const year = d.getFullYear();
51
+ const hh = String(d.getHours()).padStart(2, "0");
52
+ const mm = String(d.getMinutes()).padStart(2, "0");
53
+ return `${day} ${mon} ${year} ${hh}:${mm}`;
54
+ }
55
+ catch {
56
+ return iso;
57
+ }
58
+ }
59
+ export function calloutNumber(order) {
60
+ const num = order.order_number || "";
61
+ if (!num)
62
+ return "----";
63
+ const last4 = num.slice(-4);
64
+ const n = parseInt(last4, 10);
65
+ if (Number.isNaN(n))
66
+ return last4;
67
+ return String(n);
68
+ }
69
+ function itemName(item) {
70
+ return item.menu_item?.name || "(deleted item)";
71
+ }
72
+ function addonName(addon) {
73
+ const a = addon;
74
+ if (a.addon?.name)
75
+ return a.addon.name;
76
+ if (a.name)
77
+ return a.name;
78
+ return "Add-on";
79
+ }
80
+ function orderTypeText(order) {
81
+ const t = order.order_type;
82
+ if (t === "takeout")
83
+ return "Takeout";
84
+ if (t === "dine_in")
85
+ return "Dine-in";
86
+ if (t === "delivery")
87
+ return "Delivery";
88
+ if (order.payment_mode === "pay_on_delivery")
89
+ return "Pay on Delivery";
90
+ if (order.payment_mode === "prepaid")
91
+ return "Prepaid";
92
+ return "Walk-in";
93
+ }
94
+ function paymentLineText(order) {
95
+ const m = order.pos_payment_method;
96
+ if (m)
97
+ return `Payment: ${m.charAt(0).toUpperCase() + m.slice(1)}`;
98
+ if (order.payment_mode === "pay_on_delivery")
99
+ return "Payment: Cash / Pay on Delivery";
100
+ if (order.payment_mode === "prepaid")
101
+ return "Payment: Prepaid (Online)";
102
+ return "Payment: Walk-in";
103
+ }
104
+ function escHtml(s) {
105
+ if (!s)
106
+ return "";
107
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
108
+ }
109
+ function businessAddress(business) {
110
+ const b = business;
111
+ if (!b)
112
+ return null;
113
+ if (b.location?.address)
114
+ return b.location.address;
115
+ if (b.address)
116
+ return b.address;
117
+ return null;
118
+ }
119
+ // ── ESC/POS ────────────────────────────────────────────────────────────
120
+ function doubleLineEscpos() { return "═".repeat(LINE_WIDTH) + LF; }
121
+ function singleLineEscpos() { return "─".repeat(LINE_WIDTH); }
122
+ function customerLineEscpos(order) {
123
+ if (order.customer_name)
124
+ return `Customer: ${order.customer_name}${LF}`;
125
+ if (order.customer_phone)
126
+ return `Phone: ${order.customer_phone}${LF}`;
127
+ if (order.user?.full_name)
128
+ return `Customer: ${order.user.full_name}${LF}`;
129
+ return null;
130
+ }
131
+ function orderTypeEscpos(order) { return orderTypeText(order); }
132
+ function paymentLineEscpos(order) { return paymentLineText(order); }
133
+ export function generateEscpos(order) {
134
+ const business = order.business;
135
+ const items = order.items || [];
136
+ const taxLines = (order.order_tax_lines ?? order.tax_lines ?? []);
137
+ const parts = [];
138
+ const push = (s) => { if (s != null)
139
+ parts.push(s); };
140
+ // Header — matches receipt.ex to_escpos/1
141
+ push(ESC_INIT);
142
+ push(ESC_ALIGN_CENTER);
143
+ push(GS_SIZE_DOUBLE_WH);
144
+ push(business?.name || "Teinc Food");
145
+ push(LF);
146
+ push(GS_SIZE_NORMAL);
147
+ const addr = businessAddress(business);
148
+ if (addr)
149
+ push(addr + LF);
150
+ const bPhone = business?.phone;
151
+ if (bPhone)
152
+ push(`Tel: ${bPhone}${LF}`);
153
+ push(GS_SIZE_NORMAL);
154
+ push(doubleLineEscpos());
155
+ // Order info
156
+ push(ESC_ALIGN_LEFT);
157
+ push(`Order: ${order.order_number}${LF}`);
158
+ // callout_box_escpos
159
+ push(ESC_ALIGN_CENTER);
160
+ push(GS_SIZE_DOUBLE_W);
161
+ push(`Callout: ${calloutNumber(order)}${LF}`);
162
+ push(GS_SIZE_NORMAL);
163
+ push(LF);
164
+ push(ESC_ALIGN_LEFT);
165
+ push(`Date: ${formatDatetime(order.inserted_at)}${LF}`);
166
+ push(`Type: ${orderTypeEscpos(order)}${LF}`);
167
+ const custLine = customerLineEscpos(order);
168
+ if (custLine)
169
+ push(custLine);
170
+ push(LF);
171
+ // Barcode — CODE128 GS k 73
172
+ push(ESC_ALIGN_CENTER);
173
+ const data = order.order_number || "";
174
+ push(String.fromCharCode(0x1d, 0x6b, 0x49, data.length) + data);
175
+ push(LF);
176
+ // Items
177
+ push(ESC_ALIGN_LEFT);
178
+ push(`${padR("Item", 16)} Qty Price`);
179
+ push(LF);
180
+ push(singleLineEscpos());
181
+ push(LF);
182
+ for (const item of items) {
183
+ const qty = item.quantity || 1;
184
+ const price = formatMoney((item.unit_price ?? 0) * qty, order);
185
+ push(` ${padR(itemName(item), 16)} ${padR(String(qty), 4)} ${padR(price, 9)}${LF}`);
186
+ const addons = item.order_item_addons ?? item.addons ?? [];
187
+ for (const addon of addons) {
188
+ const aName = addonName(addon);
189
+ const aPriceMinor = addon.price_minor ?? addon.price ?? 0;
190
+ const addonPrice = formatMoney(aPriceMinor, order);
191
+ push(` + ${padR(aName, 12)} ${padR(addonPrice, 9)}${LF}`);
192
+ }
193
+ }
194
+ push(singleLineEscpos());
195
+ push(LF);
196
+ // Subtotal section — mirrors subtotal_section_escpos/2
197
+ push(`${padR("Subtotal:", 20)} ${formatMoney(order.subtotal_minor, order)}${LF}`);
198
+ if (order.delivery_fee_minor && order.delivery_fee_minor > 0) {
199
+ push(`${padR("Delivery Fee:", 20)} ${formatMoney(order.delivery_fee_minor, order)}${LF}`);
200
+ }
201
+ for (const tax of taxLines) {
202
+ const rateStr = tax.rate_basis_points ? `(${Math.floor(tax.rate_basis_points / 100)}%)` : "";
203
+ const label = `${tax.name || ""} ${rateStr}`.trim().slice(0, 19);
204
+ push(`${padR(label, 20)} ${formatMoney(tax.tax_amount_minor, order)}${LF}`);
205
+ }
206
+ if (order.discount_minor && order.discount_minor > 0) {
207
+ push(`${padR("Discount:", 20)} -${formatMoney(order.discount_minor, order)}${LF}`);
208
+ }
209
+ push(ESC_BOLD_ON);
210
+ push(`${padR("TOTAL:", 20)} ${formatMoney(order.total_minor, order)}${LF}`);
211
+ push(ESC_BOLD_OFF);
212
+ push(LF);
213
+ push(ESC_ALIGN_CENTER);
214
+ push(paymentLineEscpos(order));
215
+ push(LF);
216
+ // Footer
217
+ push(doubleLineEscpos());
218
+ push("Thank you for your order!" + LF);
219
+ push("Come back soon!" + LF);
220
+ push(ESC_FEED_3);
221
+ push(GS_CUT_FULL);
222
+ return parts.filter((p) => p != null).join("");
223
+ }
224
+ // ── HTML ───────────────────────────────────────────────────────────────
225
+ export function generateHtml(order, logoDataUri) {
226
+ const business = order.business;
227
+ const items = order.items || [];
228
+ const taxLines = ((order.order_tax_lines) ?? order.tax_lines ?? []);
229
+ const callout = calloutNumber(order);
230
+ const logoSrc = logoDataUri || business?.logo_url || "";
231
+ const hasLogo = !!logoSrc;
232
+ const businessName = escHtml(business?.name || "Teinc Food");
233
+ const addr = businessAddress(business);
234
+ const bPhone = business?.phone;
235
+ const itemRows = items.map((item) => {
236
+ const qty = item.quantity || 1;
237
+ // unit_price is already minor? backend format_money expects minor
238
+ const price = formatMoney((item.unit_price ?? 0) * qty, order);
239
+ const main = `<tr><td class="item-name">${escHtml(itemName(item))}</td><td class="item-qty">${qty}</td><td class="item-price">${price}</td></tr>`;
240
+ const addons = (item.order_item_addons ?? item.addons ?? []);
241
+ const addonRows = addons.map((addon) => {
242
+ const aName = escHtml(addonName(addon));
243
+ const aPriceMinor = addon.price_minor ?? addon.price ?? 0;
244
+ return `<tr class="addon-row"><td class="item-name">+ ${aName}</td><td class="item-qty"></td><td class="addon-price">${formatMoney(aPriceMinor, order)}</td></tr>`;
245
+ }).join("\n");
246
+ return [main, addonRows].filter(Boolean).join("\n");
247
+ }).join("\n");
248
+ const taxRowsHtml = taxLines.map((tax) => {
249
+ const rateStr = tax.rate_basis_points ? `(${Math.floor(tax.rate_basis_points / 100)}%)` : "";
250
+ return `<tr><td class="label">${escHtml(tax.name || "")} ${escHtml(rateStr)}</td><td class="amount">${formatMoney(tax.tax_amount_minor, order)}</td></tr>`;
251
+ }).join("\n");
252
+ // Exact style from receipt.ex to_html/2
253
+ return `<!DOCTYPE html>
254
+ <html>
255
+ <head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
256
+ <style>
257
+ body{font-family:monospace;max-width:320px;margin:0 auto;padding:16px;background:#fff;color:#111;}
258
+ .header{text-align:center;border-top:2px solid #333;border-bottom:2px solid #333;padding:8px 0;margin-bottom:12px;}
259
+ .header .logo{max-width:180px;max-height:60px;margin:4px auto;display:block;object-fit:contain;}
260
+ .header h2{margin:4px 0 0;font-size:14px;}
261
+ .header .address{font-size:11px;color:#555;margin:2px 0;}
262
+ .callout{border:3px double #333;text-align:center;padding:10px;margin:10px 0;}
263
+ .callout .number{font-size:36px;font-weight:bold;letter-spacing:4px;margin:0;}
264
+ .callout .label{font-size:10px;color:#555;margin:0;}
265
+ .order-info{font-size:12px;margin:8px 0;}
266
+ table{width:100%;font-size:12px;border-collapse:collapse;}
267
+ td{padding:2px 0;vertical-align:top;}
268
+ .item-name{width:50%;}
269
+ .item-qty{width:15%;text-align:center;}
270
+ .item-price{width:35%;text-align:right;}
271
+ .addon-row td{font-size:11px;color:#666;padding-left:12px;}
272
+ .addon-row td.addon-price{text-align:right;}
273
+ .totals{margin-top:8px;}
274
+ .totals td.label{text-align:left;width:70%;}
275
+ .totals td.amount{text-align:right;width:30%;}
276
+ .total-row{font-weight:bold;font-size:15px;border-top:2px solid #333;border-bottom:2px solid #333;padding:4px 0;}
277
+ .footer{text-align:center;margin-top:16px;padding-top:8px;font-size:12px;color:#555;}
278
+ @media print{body{max-width:80mm;padding:0;}}
279
+ </style></head>
280
+ <body>
281
+ <div class="header">
282
+ ${hasLogo ? `<img class="logo" src="${escHtml(logoSrc)}" alt="${businessName}" />` : ""}
283
+ <h2>${businessName}</h2>
284
+ <div class="address">${escHtml(addr || "")}</div>
285
+ ${bPhone ? `<div class="address">Tel: ${escHtml(bPhone)}</div>` : ""}
286
+ </div>
287
+ <div class="order-info">
288
+ <strong>Order:</strong> ${escHtml(order.order_number)}<br>
289
+ <strong>Date:</strong> ${escHtml(formatDatetime(order.inserted_at))}<br>
290
+ <strong>Type:</strong> ${escHtml(orderTypeText(order))}<br>
291
+ ${order.customer_name ? `<strong>Customer:</strong> ${escHtml(order.customer_name)}<br>` : order.user?.full_name ? `<strong>Customer:</strong> ${escHtml(order.user.full_name)}<br>` : ""}
292
+ </div>
293
+ <div class="callout">
294
+ <div class="number">${escHtml(callout)}</div>
295
+ <div class="label">CALL OUT NUMBER</div>
296
+ </div>
297
+ <table>
298
+ <tr style="border-bottom:1px solid #ccc;"><td class="item-name"><strong>Item</strong></td><td class="item-qty"><strong>Qty</strong></td><td class="item-price"><strong>Price</strong></td></tr>
299
+ ${itemRows}
300
+ </table>
301
+ <div class="totals">
302
+ <table>
303
+ <tr><td class="label">Subtotal</td><td class="amount">${formatMoney(order.subtotal_minor, order)}</td></tr>
304
+ ${order.delivery_fee_minor ? `<tr><td class="label">Delivery Fee</td><td class="amount">${formatMoney(order.delivery_fee_minor, order)}</td></tr>` : ""}
305
+ ${taxRowsHtml}
306
+ ${order.discount_minor ? `<tr><td class="label">Discount</td><td class="amount">-${formatMoney(order.discount_minor, order)}</td></tr>` : ""}
307
+ <tr class="total-row"><td class="label">TOTAL</td><td class="amount">${formatMoney(order.total_minor, order)}</td></tr>
308
+ <tr><td colspan="2" style="text-align:center;padding-top:8px;">${escHtml(paymentLineText(order))}</td></tr>
309
+ </table>
310
+ </div>
311
+ <div class="footer">
312
+ Thank you for your order!<br>Come back soon!
313
+ </div>
314
+ </body></html>`;
315
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Printer service — fetch receipt from backend when online,
3
+ * otherwise generate locally from SellerOrder snapshot (backend-identical template).
4
+ * Sends ESC/POS via TcpSocketAdapter, falls back to PrintFileAdapter.
5
+ */
6
+ import { generateEscpos, generateHtml } from "./receipt";
7
+ import type { SellerOrder } from "../types/api/seller.api";
8
+ declare function fetchReceipt(orderId: string, format?: "html" | "escpos"): Promise<string>;
9
+ /**
10
+ * Generate receipt locally without network — used for offline queued DD-RRR-CCC orders.
11
+ */
12
+ export declare function generateLocalReceipt(order: SellerOrder, format?: "html" | "escpos", logoDataUri?: string | null): string;
13
+ /**
14
+ * Print an order — tries TCP ESC/POS if a default printer is configured, otherwise
15
+ * generates HTML and uses the PrintFileAdapter (expo-print / Tauri print).
16
+ * `order` must be a full SellerOrder snapshot; for online orders we attempt to
17
+ * fetch the authoritative receipt first so the business logo/address are server-rendered.
18
+ */
19
+ export declare function printOrderReceipt(order: SellerOrder, opts?: {
20
+ logoDataUri?: string | null;
21
+ }): Promise<void>;
22
+ export declare function sendToTcpPrinter(ip: string, port: number, text: string): Promise<void>;
23
+ export declare const printerService: {
24
+ fetchReceipt: typeof fetchReceipt;
25
+ generateLocalReceipt: typeof generateLocalReceipt;
26
+ printOrderReceipt: typeof printOrderReceipt;
27
+ sendToTcpPrinter: typeof sendToTcpPrinter;
28
+ generateEscpos: typeof generateEscpos;
29
+ generateHtml: typeof generateHtml;
30
+ };
31
+ export {};