@teincfood/core 0.9.7 → 0.9.9
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.
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/printer/company.d.ts +21 -0
- package/dist/printer/company.js +30 -0
- package/dist/printer/receipt.d.ts +13 -7
- package/dist/printer/receipt.js +182 -43
- package/dist/printer/service.d.ts +5 -1
- package/dist/printer/service.js +14 -4
- package/dist/types/api/seller.api.d.ts +15 -0
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -123,6 +123,7 @@ export { SELLER_ORDERS_KEYS, BUSINESS_DELIVERIES_KEYS, MENU_KEYS, REFERENCE_KEYS
|
|
|
123
123
|
export * from "./printer/types";
|
|
124
124
|
export * from "./printer/store";
|
|
125
125
|
export * from "./printer/receipt";
|
|
126
|
+
export * from "./printer/company";
|
|
126
127
|
export * from "./printer/service";
|
|
127
128
|
export * from "./printer/hooks";
|
|
128
129
|
export * from "./cart/store";
|
package/dist/index.js
CHANGED
|
@@ -130,6 +130,7 @@ export { SELLER_ORDERS_KEYS, BUSINESS_DELIVERIES_KEYS, MENU_KEYS, REFERENCE_KEYS
|
|
|
130
130
|
export * from "./printer/types";
|
|
131
131
|
export * from "./printer/store";
|
|
132
132
|
export * from "./printer/receipt";
|
|
133
|
+
export * from "./printer/company";
|
|
133
134
|
export * from "./printer/service";
|
|
134
135
|
export * from "./printer/hooks";
|
|
135
136
|
// ── Cart (device-local) ──
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Company footer info for receipts — platform/brand block printed under
|
|
3
|
+
* the receipt footer ("Powered by" section).
|
|
4
|
+
*
|
|
5
|
+
* Defaults match docs/receipt-template.md §4. Host apps override at boot
|
|
6
|
+
* from env (desktop: VITE_COMPANY_*, mobile: EXPO_PUBLIC_COMPANY_*) via
|
|
7
|
+
* setCompanyInfo; unset fields fall back to defaults. The backend reads
|
|
8
|
+
* COMPANY_* OS env with the same defaults.
|
|
9
|
+
*/
|
|
10
|
+
export interface CompanyInfo {
|
|
11
|
+
/** Company name, e.g. "TeincFood". */
|
|
12
|
+
name: string;
|
|
13
|
+
/** Consumer brand line, e.g. "TeincFood Business". */
|
|
14
|
+
brand: string;
|
|
15
|
+
address: string;
|
|
16
|
+
website: string;
|
|
17
|
+
supportEmail: string;
|
|
18
|
+
phone: string;
|
|
19
|
+
}
|
|
20
|
+
export declare function setCompanyInfo(partial: Partial<CompanyInfo>): void;
|
|
21
|
+
export declare function getCompanyInfo(): CompanyInfo;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Company footer info for receipts — platform/brand block printed under
|
|
3
|
+
* the receipt footer ("Powered by" section).
|
|
4
|
+
*
|
|
5
|
+
* Defaults match docs/receipt-template.md §4. Host apps override at boot
|
|
6
|
+
* from env (desktop: VITE_COMPANY_*, mobile: EXPO_PUBLIC_COMPANY_*) via
|
|
7
|
+
* setCompanyInfo; unset fields fall back to defaults. The backend reads
|
|
8
|
+
* COMPANY_* OS env with the same defaults.
|
|
9
|
+
*/
|
|
10
|
+
const DEFAULTS = {
|
|
11
|
+
name: "TeincFood",
|
|
12
|
+
brand: "TeincFood Business",
|
|
13
|
+
address: "Accra, Ghana",
|
|
14
|
+
website: "https://teincfood.com",
|
|
15
|
+
supportEmail: "support@teincfood.com",
|
|
16
|
+
phone: "+233 50 398 1913",
|
|
17
|
+
};
|
|
18
|
+
let override = {};
|
|
19
|
+
export function setCompanyInfo(partial) {
|
|
20
|
+
const clean = {};
|
|
21
|
+
for (const [k, v] of Object.entries(partial)) {
|
|
22
|
+
if (typeof v === "string" && v.length > 0) {
|
|
23
|
+
clean[k] = v;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
override = { ...override, ...clean };
|
|
27
|
+
}
|
|
28
|
+
export function getCompanyInfo() {
|
|
29
|
+
return { ...DEFAULTS, ...override };
|
|
30
|
+
}
|
|
@@ -1,15 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Local receipt generation —
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* Backend source: lib/teinc_food_backend/orders/receipt.ex
|
|
7
|
-
* Keep this file in sync with backend when template changes.
|
|
2
|
+
* Local receipt generation — implements docs/receipt-template.md
|
|
3
|
+
* (single source of design; backend TeincFoodBackend.Orders.Receipt
|
|
4
|
+
* implements the same spec). Generates ESC/POS (80mm, 32 chars) and HTML
|
|
5
|
+
* (visual twin) from a SellerOrder snapshot. Works fully offline.
|
|
8
6
|
*/
|
|
9
7
|
import type { SellerOrder } from "../types/api/seller.api";
|
|
10
8
|
export declare function escposText(s: string | null | undefined): string;
|
|
9
|
+
/** Word-wrap sanitized text to width. Long words hard-split. Never drops content. */
|
|
10
|
+
export declare function wrapText(text: string, width: number): string[];
|
|
11
11
|
export declare function calloutNumber(order: SellerOrder): string;
|
|
12
|
-
|
|
12
|
+
/**
|
|
13
|
+
* Resolve cached logo raster (base64 `GS v 0` block) for an order: prefers
|
|
14
|
+
* `order.business.logo_escpos`, falls back to the synced `business:profile`
|
|
15
|
+
* reference. Null when none — fully offline, no network.
|
|
16
|
+
*/
|
|
17
|
+
export declare function resolveLogoRaster(order: SellerOrder): Promise<string | null>;
|
|
18
|
+
export declare function generateEscpos(order: SellerOrder, logoRasterB64?: string | null): string;
|
|
13
19
|
export declare function generateHtmlAsync(order: SellerOrder, opts?: {
|
|
14
20
|
logoDataUri?: string | null;
|
|
15
21
|
useCache?: boolean;
|
package/dist/printer/receipt.js
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Local receipt generation —
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* Backend source: lib/teinc_food_backend/orders/receipt.ex
|
|
7
|
-
* Keep this file in sync with backend when template changes.
|
|
2
|
+
* Local receipt generation — implements docs/receipt-template.md
|
|
3
|
+
* (single source of design; backend TeincFoodBackend.Orders.Receipt
|
|
4
|
+
* implements the same spec). Generates ESC/POS (80mm, 32 chars) and HTML
|
|
5
|
+
* (visual twin) from a SellerOrder snapshot. Works fully offline.
|
|
8
6
|
*/
|
|
7
|
+
import { getCompanyInfo } from "./company";
|
|
9
8
|
import { openSyncDatabase } from "../db/connection";
|
|
10
9
|
import { getReferenceData } from "../reference/operations";
|
|
11
10
|
import { cacheRemoteImage, resolveImageSource } from "../image-cache/service";
|
|
@@ -17,7 +16,6 @@ const ESC_BOLD_ON = "\x1B\x45\x01";
|
|
|
17
16
|
const ESC_BOLD_OFF = "\x1B\x45\x00";
|
|
18
17
|
const GS_SIZE_NORMAL = "\x1D\x21\x00";
|
|
19
18
|
const GS_SIZE_DOUBLE_W = "\x1D\x21\x11";
|
|
20
|
-
const GS_SIZE_DOUBLE_WH = "\x1D\x21\x33";
|
|
21
19
|
const GS_BAR_HEIGHT = "\x1D\x68\x3C";
|
|
22
20
|
const GS_BAR_WIDTH = "\x1D\x77\x02";
|
|
23
21
|
const LF = "\x0A";
|
|
@@ -60,6 +58,60 @@ function padR(text, width) {
|
|
|
60
58
|
return text.slice(0, width);
|
|
61
59
|
return text.padEnd(width, " ");
|
|
62
60
|
}
|
|
61
|
+
function padL(text, width) {
|
|
62
|
+
const s = String(text);
|
|
63
|
+
if (s.length >= width)
|
|
64
|
+
return s.slice(0, width);
|
|
65
|
+
return s.padStart(width, " ");
|
|
66
|
+
}
|
|
67
|
+
/** Word-wrap sanitized text to width. Long words hard-split. Never drops content. */
|
|
68
|
+
export function wrapText(text, width) {
|
|
69
|
+
const words = [];
|
|
70
|
+
for (const word of text.split(" ").filter(Boolean)) {
|
|
71
|
+
if (word.length <= width)
|
|
72
|
+
words.push(word);
|
|
73
|
+
else
|
|
74
|
+
for (let i = 0; i < word.length; i += width)
|
|
75
|
+
words.push(word.slice(i, i + width));
|
|
76
|
+
}
|
|
77
|
+
const lines = [];
|
|
78
|
+
let cur = "";
|
|
79
|
+
for (const word of words) {
|
|
80
|
+
if (!cur)
|
|
81
|
+
cur = word;
|
|
82
|
+
else if (cur.length + 1 + word.length <= width)
|
|
83
|
+
cur += " " + word;
|
|
84
|
+
else {
|
|
85
|
+
lines.push(cur);
|
|
86
|
+
cur = word;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (cur)
|
|
90
|
+
lines.push(cur);
|
|
91
|
+
return lines;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Business-name fit ladder (centered, never truncated):
|
|
95
|
+
* ≤16 chars → 2x, 17–64 → 1x bold (≤2 lines), longer → 1x (≤3 lines,
|
|
96
|
+
* keeps wrapping beyond that).
|
|
97
|
+
*/
|
|
98
|
+
function nameBlockEscpos(name) {
|
|
99
|
+
const text = escposText(name);
|
|
100
|
+
if (text.length <= 16)
|
|
101
|
+
return [GS_SIZE_DOUBLE_W, text, LF, GS_SIZE_NORMAL];
|
|
102
|
+
if (text.length <= 64) {
|
|
103
|
+
return [ESC_BOLD_ON, ...wrapText(text, 32).flatMap((l) => [escposText(l), LF]), ESC_BOLD_OFF];
|
|
104
|
+
}
|
|
105
|
+
return wrapText(text, 32).flatMap((l) => [escposText(l), LF]);
|
|
106
|
+
}
|
|
107
|
+
function nameSizeClass(name) {
|
|
108
|
+
const len = (name || "").length;
|
|
109
|
+
if (len <= 16)
|
|
110
|
+
return "name-2x";
|
|
111
|
+
if (len <= 64)
|
|
112
|
+
return "name-1x-bold";
|
|
113
|
+
return "name-1x";
|
|
114
|
+
}
|
|
63
115
|
function currencySymbol(currency) {
|
|
64
116
|
switch (currency) {
|
|
65
117
|
case "GHS": return "₵";
|
|
@@ -112,13 +164,16 @@ function itemName(item) {
|
|
|
112
164
|
return item.menu_item?.name || "(deleted item)";
|
|
113
165
|
}
|
|
114
166
|
function addonName(addon) {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
return a.name;
|
|
167
|
+
if (addon.addon?.name)
|
|
168
|
+
return addon.addon.name;
|
|
169
|
+
if (addon.name)
|
|
170
|
+
return addon.name;
|
|
120
171
|
return "Add-on";
|
|
121
172
|
}
|
|
173
|
+
function variantName(item) {
|
|
174
|
+
const n = item.variant?.name;
|
|
175
|
+
return n ? n : null;
|
|
176
|
+
}
|
|
122
177
|
function orderTypeText(order) {
|
|
123
178
|
const t = order.order_type;
|
|
124
179
|
if (t === "takeout")
|
|
@@ -194,6 +249,54 @@ async function fetchAsDataUri(url) {
|
|
|
194
249
|
return null;
|
|
195
250
|
}
|
|
196
251
|
}
|
|
252
|
+
/** Decode base64 raster bytes (from `business:profile` reference) to a byte string. */
|
|
253
|
+
function binaryFromBase64(b64) {
|
|
254
|
+
const clean = b64.replace(/\s/g, "");
|
|
255
|
+
if (typeof atob === "function")
|
|
256
|
+
return atob(clean);
|
|
257
|
+
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
258
|
+
let out = "";
|
|
259
|
+
let buf = 0;
|
|
260
|
+
let bits = 0;
|
|
261
|
+
for (const ch of clean) {
|
|
262
|
+
if (ch === "=")
|
|
263
|
+
break;
|
|
264
|
+
const v = chars.indexOf(ch);
|
|
265
|
+
if (v < 0)
|
|
266
|
+
continue;
|
|
267
|
+
buf = (buf << 6) | v;
|
|
268
|
+
bits += 6;
|
|
269
|
+
if (bits >= 8) {
|
|
270
|
+
bits -= 8;
|
|
271
|
+
out += String.fromCharCode((buf >> bits) & 0xff);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
if (!out)
|
|
275
|
+
throw new Error("empty raster");
|
|
276
|
+
return out;
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Resolve cached logo raster (base64 `GS v 0` block) for an order: prefers
|
|
280
|
+
* `order.business.logo_escpos`, falls back to the synced `business:profile`
|
|
281
|
+
* reference. Null when none — fully offline, no network.
|
|
282
|
+
*/
|
|
283
|
+
export async function resolveLogoRaster(order) {
|
|
284
|
+
const direct = order.business?.logo_escpos;
|
|
285
|
+
if (typeof direct === "string" && direct.length > 0)
|
|
286
|
+
return direct;
|
|
287
|
+
try {
|
|
288
|
+
const bId = order.business_id;
|
|
289
|
+
if (!bId)
|
|
290
|
+
return null;
|
|
291
|
+
const db = await openSyncDatabase();
|
|
292
|
+
const ref = await getReferenceData(db, bId, "business:profile");
|
|
293
|
+
const raster = ref?.payload?.data?.logo_escpos;
|
|
294
|
+
return typeof raster === "string" && raster.length > 0 ? raster : null;
|
|
295
|
+
}
|
|
296
|
+
catch {
|
|
297
|
+
return null;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
197
300
|
// ── ESC/POS ────────────────────────────────────────────────────────────
|
|
198
301
|
function doubleLineEscpos() { return "═".repeat(LINE_WIDTH) + LF; }
|
|
199
302
|
function singleLineEscpos() { return "─".repeat(LINE_WIDTH); }
|
|
@@ -208,7 +311,7 @@ function customerLineEscpos(order) {
|
|
|
208
311
|
}
|
|
209
312
|
function orderTypeEscpos(order) { return orderTypeText(order); }
|
|
210
313
|
function paymentLineEscpos(order) { return paymentLineText(order); }
|
|
211
|
-
export function generateEscpos(order) {
|
|
314
|
+
export function generateEscpos(order, logoRasterB64) {
|
|
212
315
|
const business = order.business;
|
|
213
316
|
const items = order.items || [];
|
|
214
317
|
const taxLines = (order.order_tax_lines ?? order.tax_lines ?? []);
|
|
@@ -219,15 +322,19 @@ export function generateEscpos(order) {
|
|
|
219
322
|
// (control codes and barcode data use raw push).
|
|
220
323
|
const pushT = (s) => { if (s != null)
|
|
221
324
|
parts.push(escposText(s)); };
|
|
222
|
-
// Header —
|
|
325
|
+
// Header — implements docs/receipt-template.md §1
|
|
223
326
|
// NOTE: no logo in ESC/POS text mode — a logo URL cannot be rendered without
|
|
224
327
|
// a raster pipeline (GS v 0). HTML receipts embed the logo instead.
|
|
225
328
|
push(ESC_INIT);
|
|
329
|
+
if (logoRasterB64) {
|
|
330
|
+
try {
|
|
331
|
+
push(binaryFromBase64(logoRasterB64));
|
|
332
|
+
}
|
|
333
|
+
catch { /* logo is decorative — never fail the receipt */ }
|
|
334
|
+
}
|
|
226
335
|
push(ESC_ALIGN_CENTER);
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
push(LF);
|
|
230
|
-
push(GS_SIZE_NORMAL);
|
|
336
|
+
for (const part of nameBlockEscpos(business?.name || "Teinc Food"))
|
|
337
|
+
push(part);
|
|
231
338
|
const addr = businessAddress(business);
|
|
232
339
|
if (addr)
|
|
233
340
|
pushT(addr + LF);
|
|
@@ -239,7 +346,8 @@ export function generateEscpos(order) {
|
|
|
239
346
|
// Order info
|
|
240
347
|
push(ESC_ALIGN_LEFT);
|
|
241
348
|
pushT(`Order: ${order.order_number}${LF}`);
|
|
242
|
-
// callout_box_escpos
|
|
349
|
+
// callout_box_escpos — symmetric blank lines, no border rules around it
|
|
350
|
+
push(LF);
|
|
243
351
|
push(ESC_ALIGN_CENTER);
|
|
244
352
|
push(GS_SIZE_DOUBLE_W);
|
|
245
353
|
pushT(`Callout: ${calloutNumber(order)}${LF}`);
|
|
@@ -259,43 +367,60 @@ export function generateEscpos(order) {
|
|
|
259
367
|
const data = order.order_number || "";
|
|
260
368
|
push(String.fromCharCode(0x1d, 0x6b, 0x49, data.length) + data);
|
|
261
369
|
push(LF);
|
|
262
|
-
// Items
|
|
370
|
+
// Items — template §2: name 17 | qty 3 (right) | price 10 (right) = 32
|
|
263
371
|
push(ESC_ALIGN_LEFT);
|
|
264
|
-
push(`${padR("Item",
|
|
372
|
+
push(`${padR("Item", 17)} ${padL("Qty", 3)} ${padL("Price", 10)}`);
|
|
265
373
|
push(LF);
|
|
266
374
|
pushT(singleLineEscpos());
|
|
267
375
|
push(LF);
|
|
268
376
|
for (const item of items) {
|
|
269
377
|
const qty = item.quantity || 1;
|
|
270
|
-
const price = formatMoney((item.unit_price ?? 0) * qty, order);
|
|
271
|
-
|
|
378
|
+
const price = escposText(formatMoney((item.unit_price ?? 0) * qty, order));
|
|
379
|
+
const nameLines = wrapText(escposText(itemName(item)), 17);
|
|
380
|
+
const first = nameLines[0] ?? "(deleted item)";
|
|
381
|
+
const rest = nameLines.slice(1, 2);
|
|
382
|
+
pushT(`${padR(first, 17)} ${padL(qty, 3)} ${padL(price, 10)}${LF}`);
|
|
383
|
+
for (const cont of rest)
|
|
384
|
+
pushT(`${padR(cont, 17)}${LF}`);
|
|
385
|
+
const vName = variantName(item);
|
|
386
|
+
if (vName) {
|
|
387
|
+
for (const vl of wrapText(escposText(`Size: ${vName}`), 30))
|
|
388
|
+
pushT(` ${vl}${LF}`);
|
|
389
|
+
}
|
|
272
390
|
const addons = item.order_item_addons ?? item.addons ?? [];
|
|
273
391
|
for (const addon of addons) {
|
|
274
|
-
const aName = addonName(addon);
|
|
275
392
|
const aPriceMinor = addon.price_minor ?? addon.price ?? 0;
|
|
276
|
-
const addonPrice = formatMoney(aPriceMinor, order);
|
|
277
|
-
|
|
393
|
+
const addonPrice = escposText(formatMoney(aPriceMinor, order));
|
|
394
|
+
const aLines = wrapText(escposText(addonName(addon)), 17);
|
|
395
|
+
if (!aLines.length)
|
|
396
|
+
continue;
|
|
397
|
+
pushT(` + ${padR(aLines[0], 17)} ${padL(addonPrice, 10)}${LF}`);
|
|
398
|
+
for (const aCont of aLines.slice(1, 2))
|
|
399
|
+
pushT(` ${padR(aCont, 17)}${LF}`);
|
|
278
400
|
}
|
|
279
401
|
}
|
|
280
402
|
pushT(singleLineEscpos());
|
|
281
403
|
push(LF);
|
|
282
|
-
// Subtotal section —
|
|
283
|
-
pushT(`${padR("Subtotal:", 20)} ${formatMoney(order.subtotal_minor, order)}${LF}`);
|
|
404
|
+
// Subtotal section — template §3: label 20 + amount 11 (right)
|
|
405
|
+
pushT(`${padR("Subtotal:", 20)} ${padL(escposText(formatMoney(order.subtotal_minor, order)), 11)}${LF}`);
|
|
284
406
|
if (order.delivery_fee_minor && order.delivery_fee_minor > 0) {
|
|
285
|
-
pushT(`${padR("Delivery Fee:", 20)} ${formatMoney(order.delivery_fee_minor, order)}${LF}`);
|
|
407
|
+
pushT(`${padR("Delivery Fee:", 20)} ${padL(escposText(formatMoney(order.delivery_fee_minor, order)), 11)}${LF}`);
|
|
286
408
|
}
|
|
287
409
|
for (const tax of taxLines) {
|
|
288
410
|
const rateStr = tax.rate_basis_points ? `(${Math.floor(tax.rate_basis_points / 100)}%)` : "";
|
|
289
411
|
const label = `${tax.name || ""} ${rateStr}`.trim().slice(0, 19);
|
|
290
|
-
pushT(`${padR(label, 20)} ${formatMoney(tax.tax_amount_minor, order)}${LF}`);
|
|
412
|
+
pushT(`${padR(label, 20)} ${padL(escposText(formatMoney(tax.tax_amount_minor, order)), 11)}${LF}`);
|
|
291
413
|
}
|
|
292
414
|
if (order.discount_minor && order.discount_minor > 0) {
|
|
293
|
-
pushT(`${padR("Discount:", 20)}
|
|
415
|
+
pushT(`${padR("Discount:", 20)} ${padL("-" + escposText(formatMoney(order.discount_minor, order)), 11)}${LF}`);
|
|
294
416
|
}
|
|
417
|
+
push(LF);
|
|
295
418
|
push(ESC_BOLD_ON);
|
|
296
|
-
pushT(`${padR("TOTAL:", 20)} ${formatMoney(order.total_minor, order)}${LF}`);
|
|
419
|
+
pushT(`${padR("TOTAL:", 20)} ${padL(escposText(formatMoney(order.total_minor, order)), 11)}${LF}`);
|
|
297
420
|
push(ESC_BOLD_OFF);
|
|
298
421
|
push(LF);
|
|
422
|
+
// Footer — template §4 company block (centered, never truncated)
|
|
423
|
+
const company = getCompanyInfo();
|
|
299
424
|
push(ESC_ALIGN_CENTER);
|
|
300
425
|
pushT(paymentLineEscpos(order));
|
|
301
426
|
push(LF);
|
|
@@ -303,6 +428,9 @@ export function generateEscpos(order) {
|
|
|
303
428
|
pushT(doubleLineEscpos());
|
|
304
429
|
push("Thank you for your order!" + LF);
|
|
305
430
|
push("Come back soon!" + LF);
|
|
431
|
+
push(LF);
|
|
432
|
+
pushT(company.website + LF);
|
|
433
|
+
pushT(`Powered by ${company.name}` + LF);
|
|
306
434
|
push(ESC_FEED_3);
|
|
307
435
|
push(GS_CUT_FULL);
|
|
308
436
|
return parts.filter((p) => p != null).join("");
|
|
@@ -363,6 +491,7 @@ export function generateHtml(order, logoDataUri) {
|
|
|
363
491
|
const logoSrc = logoDataUri || business?.logo_url || "";
|
|
364
492
|
const hasLogo = !!logoSrc;
|
|
365
493
|
const businessName = escHtml(business?.name || "Teinc Food");
|
|
494
|
+
const company = getCompanyInfo();
|
|
366
495
|
const addr = businessAddress(business);
|
|
367
496
|
const bPhone = business?.phone;
|
|
368
497
|
const itemRows = items.map((item) => {
|
|
@@ -370,13 +499,17 @@ export function generateHtml(order, logoDataUri) {
|
|
|
370
499
|
// unit_price is already minor? backend format_money expects minor
|
|
371
500
|
const price = formatMoney((item.unit_price ?? 0) * qty, order);
|
|
372
501
|
const main = `<tr><td class="item-name">${escHtml(itemName(item))}</td><td class="item-qty">${qty}</td><td class="item-price">${price}</td></tr>`;
|
|
373
|
-
const
|
|
502
|
+
const vName = variantName(item);
|
|
503
|
+
const variantRow = vName
|
|
504
|
+
? `<tr class="variant-row"><td class="item-name" colspan="2">Size: ${escHtml(vName)}</td><td class="item-price"></td></tr>`
|
|
505
|
+
: "";
|
|
506
|
+
const addons = item.order_item_addons ?? item.addons ?? [];
|
|
374
507
|
const addonRows = addons.map((addon) => {
|
|
375
508
|
const aName = escHtml(addonName(addon));
|
|
376
509
|
const aPriceMinor = addon.price_minor ?? addon.price ?? 0;
|
|
377
510
|
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>`;
|
|
378
511
|
}).join("\n");
|
|
379
|
-
return [main, addonRows].filter(Boolean).join("\n");
|
|
512
|
+
return [main, variantRow, addonRows].filter(Boolean).join("\n");
|
|
380
513
|
}).join("\n");
|
|
381
514
|
const taxRowsHtml = taxLines.map((tax) => {
|
|
382
515
|
const rateStr = tax.rate_basis_points ? `(${Math.floor(tax.rate_basis_points / 100)}%)` : "";
|
|
@@ -389,23 +522,28 @@ export function generateHtml(order, logoDataUri) {
|
|
|
389
522
|
<style>
|
|
390
523
|
body{font-family:monospace;max-width:320px;margin:0 auto;padding:16px;background:#fff;color:#111;}
|
|
391
524
|
.header{text-align:center;border-top:2px solid #333;border-bottom:2px solid #333;padding:8px 0;margin-bottom:12px;}
|
|
392
|
-
.header .logo{max-width:
|
|
393
|
-
.header h2{margin:4px 0 0;
|
|
525
|
+
.header .logo{max-width:200px;max-height:64px;margin:4px auto;display:block;object-fit:contain;}
|
|
526
|
+
.header h2{margin:4px 0 0;}
|
|
527
|
+
.header h2.name-2x{font-size:28px;font-weight:bold;}
|
|
528
|
+
.header h2.name-1x-bold{font-size:16px;font-weight:bold;}
|
|
529
|
+
.header h2.name-1x{font-size:14px;}
|
|
394
530
|
.header .address{font-size:11px;color:#555;margin:2px 0;}
|
|
395
|
-
.callout{
|
|
531
|
+
.callout{text-align:center;margin:16px 0;}
|
|
396
532
|
.callout .number{font-size:36px;font-weight:bold;letter-spacing:4px;margin:0;}
|
|
397
533
|
.callout .label{font-size:10px;color:#555;margin:0;}
|
|
398
534
|
.order-info{font-size:12px;margin:8px 0;}
|
|
399
|
-
table{width:100%;font-size:12px;border-collapse:collapse;}
|
|
400
|
-
td{padding:2px 0;vertical-align:top;}
|
|
401
|
-
.item-name{width:
|
|
402
|
-
.item-qty{width:
|
|
403
|
-
.item-price{width:
|
|
535
|
+
table{width:100%;font-size:12px;border-collapse:collapse;table-layout:fixed;}
|
|
536
|
+
td{padding:2px 0;vertical-align:top;overflow-wrap:anywhere;}
|
|
537
|
+
.item-name{width:17ch;}
|
|
538
|
+
.item-qty{width:3ch;text-align:right;}
|
|
539
|
+
.item-price{width:10ch;text-align:right;}
|
|
404
540
|
.addon-row td{font-size:11px;color:#666;padding-left:12px;}
|
|
405
541
|
.addon-row td.addon-price{text-align:right;}
|
|
542
|
+
.variant-row td{font-size:11px;color:#444;padding-left:8px;}
|
|
406
543
|
.totals{margin-top:8px;}
|
|
407
544
|
.totals td.label{text-align:left;width:70%;}
|
|
408
545
|
.totals td.amount{text-align:right;width:30%;}
|
|
546
|
+
.total-row td{padding-top:12px;}
|
|
409
547
|
.total-row{font-weight:bold;font-size:15px;border-top:2px solid #333;border-bottom:2px solid #333;padding:4px 0;}
|
|
410
548
|
.footer{text-align:center;margin-top:16px;padding-top:8px;font-size:12px;color:#555;}
|
|
411
549
|
@media print{body{max-width:80mm;padding:0;}}
|
|
@@ -413,7 +551,7 @@ export function generateHtml(order, logoDataUri) {
|
|
|
413
551
|
<body>
|
|
414
552
|
<div class="header">
|
|
415
553
|
${hasLogo ? `<img class="logo" src="${escHtml(logoSrc)}" alt="${businessName}" />` : ""}
|
|
416
|
-
<h2>${businessName}</h2>
|
|
554
|
+
<h2 class="${nameSizeClass(business?.name)}">${businessName}</h2>
|
|
417
555
|
<div class="address">${escHtml(addr || "")}</div>
|
|
418
556
|
${bPhone ? `<div class="address">Tel: ${escHtml(bPhone)}</div>` : ""}
|
|
419
557
|
</div>
|
|
@@ -443,6 +581,7 @@ export function generateHtml(order, logoDataUri) {
|
|
|
443
581
|
</div>
|
|
444
582
|
<div class="footer">
|
|
445
583
|
Thank you for your order!<br>Come back soon!
|
|
584
|
+
<br><br>${escHtml(company.website)}<br>Powered by ${escHtml(company.name)}
|
|
446
585
|
</div>
|
|
447
586
|
</body></html>`;
|
|
448
587
|
}
|
|
@@ -8,8 +8,11 @@ import type { SellerOrder } from "../types/api/seller.api";
|
|
|
8
8
|
declare function fetchReceipt(orderId: string, format?: "html" | "escpos"): Promise<string>;
|
|
9
9
|
/**
|
|
10
10
|
* Generate receipt locally without network — used for offline queued DD-RRR-CCC orders.
|
|
11
|
+
* Pass a base64 logo raster (see resolveLogoRaster) to include the thermal header logo.
|
|
11
12
|
*/
|
|
12
|
-
export declare function generateLocalReceipt(order: SellerOrder, format?: "html" | "escpos", logoDataUri?: string | null): string;
|
|
13
|
+
export declare function generateLocalReceipt(order: SellerOrder, format?: "html" | "escpos", logoDataUri?: string | null, logoRasterB64?: string | null): string;
|
|
14
|
+
/** Async variant: resolves the cached logo raster before generating. */
|
|
15
|
+
export declare function generateLocalReceiptAsync(order: SellerOrder, format?: "html" | "escpos", logoDataUri?: string | null): Promise<string>;
|
|
13
16
|
/**
|
|
14
17
|
* Print an order — tries TCP ESC/POS if a default printer is configured, otherwise
|
|
15
18
|
* generates HTML and uses the PrintFileAdapter (expo-print / Tauri print).
|
|
@@ -23,6 +26,7 @@ export declare function sendToTcpPrinter(ip: string, port: number, text: string)
|
|
|
23
26
|
export declare const printerService: {
|
|
24
27
|
fetchReceipt: typeof fetchReceipt;
|
|
25
28
|
generateLocalReceipt: typeof generateLocalReceipt;
|
|
29
|
+
generateLocalReceiptAsync: typeof generateLocalReceiptAsync;
|
|
26
30
|
printOrderReceipt: typeof printOrderReceipt;
|
|
27
31
|
sendToTcpPrinter: typeof sendToTcpPrinter;
|
|
28
32
|
generateEscpos: typeof generateEscpos;
|
package/dist/printer/service.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import { getHttpClient } from "../adapters/http";
|
|
7
7
|
import { getTcpAdapter, getPrintFileAdapter } from "../adapters/print";
|
|
8
8
|
import { getPrinterState } from "./store";
|
|
9
|
-
import { generateEscpos, generateHtml } from "./receipt";
|
|
9
|
+
import { generateEscpos, generateHtml, resolveLogoRaster } from "./receipt";
|
|
10
10
|
import { API_BASE_URL } from "../utils/constants";
|
|
11
11
|
async function fetchReceipt(orderId, format = "html") {
|
|
12
12
|
const http = getHttpClient();
|
|
@@ -32,9 +32,18 @@ async function fetchReceipt(orderId, format = "html") {
|
|
|
32
32
|
}
|
|
33
33
|
/**
|
|
34
34
|
* Generate receipt locally without network — used for offline queued DD-RRR-CCC orders.
|
|
35
|
+
* Pass a base64 logo raster (see resolveLogoRaster) to include the thermal header logo.
|
|
35
36
|
*/
|
|
36
|
-
export function generateLocalReceipt(order, format = "escpos", logoDataUri) {
|
|
37
|
-
return format === "escpos"
|
|
37
|
+
export function generateLocalReceipt(order, format = "escpos", logoDataUri, logoRasterB64) {
|
|
38
|
+
return format === "escpos"
|
|
39
|
+
? generateEscpos(order, logoRasterB64 ?? null)
|
|
40
|
+
: generateHtml(order, logoDataUri);
|
|
41
|
+
}
|
|
42
|
+
/** Async variant: resolves the cached logo raster before generating. */
|
|
43
|
+
export async function generateLocalReceiptAsync(order, format = "escpos", logoDataUri) {
|
|
44
|
+
if (format !== "escpos")
|
|
45
|
+
return generateHtml(order, logoDataUri);
|
|
46
|
+
return generateEscpos(order, await resolveLogoRaster(order));
|
|
38
47
|
}
|
|
39
48
|
/**
|
|
40
49
|
* Print an order — tries TCP ESC/POS if a default printer is configured, otherwise
|
|
@@ -64,7 +73,7 @@ export async function printOrderReceipt(order, opts) {
|
|
|
64
73
|
}
|
|
65
74
|
}
|
|
66
75
|
if (!escpos)
|
|
67
|
-
escpos =
|
|
76
|
+
escpos = generateEscpos(order, await resolveLogoRaster(order));
|
|
68
77
|
const tcp = getTcpAdapter();
|
|
69
78
|
if (!tcp)
|
|
70
79
|
throw new Error("TCP adapter not configured");
|
|
@@ -112,6 +121,7 @@ export async function sendToTcpPrinter(ip, port, text) {
|
|
|
112
121
|
export const printerService = {
|
|
113
122
|
fetchReceipt,
|
|
114
123
|
generateLocalReceipt,
|
|
124
|
+
generateLocalReceiptAsync,
|
|
115
125
|
printOrderReceipt,
|
|
116
126
|
sendToTcpPrinter,
|
|
117
127
|
generateEscpos,
|
|
@@ -20,6 +20,12 @@ export interface OrderMenuItem {
|
|
|
20
20
|
export interface OrderItemAddon {
|
|
21
21
|
id: string;
|
|
22
22
|
price: number;
|
|
23
|
+
/** Minor-unit price (backend order_item_addons carry price_minor). */
|
|
24
|
+
price_minor?: number;
|
|
25
|
+
name?: string;
|
|
26
|
+
addon?: {
|
|
27
|
+
name?: string;
|
|
28
|
+
} | null;
|
|
23
29
|
}
|
|
24
30
|
export interface OrderItem {
|
|
25
31
|
id: string;
|
|
@@ -28,11 +34,20 @@ export interface OrderItem {
|
|
|
28
34
|
unit_price: number;
|
|
29
35
|
subtotal: number;
|
|
30
36
|
addons?: OrderItemAddon[];
|
|
37
|
+
/** Backend order_items preload :variant (absolute size, has name). */
|
|
38
|
+
variant?: {
|
|
39
|
+
id?: string;
|
|
40
|
+
name?: string;
|
|
41
|
+
} | null;
|
|
42
|
+
/** Backend shape for addon lines. */
|
|
43
|
+
order_item_addons?: OrderItemAddon[];
|
|
31
44
|
}
|
|
32
45
|
export interface OrderBusiness {
|
|
33
46
|
id: string;
|
|
34
47
|
name: string;
|
|
35
48
|
phone?: string;
|
|
49
|
+
/** Base64 `GS v 0` raster block for the thermal header (synced via business:profile). */
|
|
50
|
+
logo_escpos?: string | null;
|
|
36
51
|
latitude?: number;
|
|
37
52
|
longitude?: number;
|
|
38
53
|
estimated_delivery_time?: number;
|
package/package.json
CHANGED