@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 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 — 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.
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
- export declare function generateEscpos(order: SellerOrder): string;
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;
@@ -1,11 +1,10 @@
1
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.
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
- const a = addon;
116
- if (a.addon?.name)
117
- return a.addon.name;
118
- if (a.name)
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 — matches receipt.ex to_escpos/1
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
- push(GS_SIZE_DOUBLE_WH);
228
- pushT(business?.name || "Teinc Food");
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", 16)} Qty Price`);
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
- pushT(` ${padR(itemName(item), 16)} ${padR(String(qty), 4)} ${padR(price, 9)}${LF}`);
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
- pushT(` + ${padR(aName, 12)} ${padR(addonPrice, 9)}${LF}`);
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 — mirrors subtotal_section_escpos/2
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)} -${formatMoney(order.discount_minor, order)}${LF}`);
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 addons = (item.order_item_addons ?? item.addons ?? []);
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:180px;max-height:60px;margin:4px auto;display:block;object-fit:contain;}
393
- .header h2{margin:4px 0 0;font-size:14px;}
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{border:3px double #333;text-align:center;padding:10px;margin:10px 0;}
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:50%;}
402
- .item-qty{width:15%;text-align:center;}
403
- .item-price{width:35%;text-align:right;}
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;
@@ -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" ? generateEscpos(order) : generateHtml(order, logoDataUri);
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 = generateLocalReceipt(order, "escpos", opts?.logoDataUri ?? null);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@teincfood/core",
3
- "version": "0.9.7",
3
+ "version": "0.9.9",
4
4
  "description": "TeincFood shared offline-first core — types, sync engine, local DB, reference data, and repositories for mobile + desktop",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",