@teincfood/core 0.9.5 → 0.9.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.
- package/dist/domain/orders/service.js +1 -1
- package/dist/hooks/business-hours.hooks.d.ts +2 -2
- package/dist/hooks/business-hours.hooks.js +3 -3
- package/dist/hooks/delivery.hooks.d.ts +24 -24
- package/dist/hooks/rider.hooks.d.ts +48 -48
- package/dist/hooks/use-infinite-list.d.ts +24 -24
- package/dist/hooks/wallet.hooks.d.ts +24 -24
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/local-node/service.js +11 -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 +186 -43
- package/dist/printer/service.d.ts +5 -1
- package/dist/printer/service.js +14 -4
- package/dist/reference/entitlement-guard.d.ts +31 -0
- package/dist/reference/entitlement-guard.js +83 -0
- package/dist/reference/menu-record.d.ts +2 -3
- package/dist/reference/menu-record.js +5 -6
- package/dist/sync/command-queue.service.js +1 -1
- package/dist/sync/engine.js +17 -0
- package/dist/sync/online-gates.js +2 -2
- package/dist/types/api/menu.api.d.ts +4 -2
- package/dist/types/api/seller.api.d.ts +15 -0
- package/dist/utils/business-hours.d.ts +8 -4
- package/dist/utils/business-hours.js +8 -6
- package/dist/utils/order-transitions.d.ts +13 -0
- package/dist/utils/order-transitions.js +14 -0
- package/dist/validation/offline.d.ts +2 -0
- package/dist/validation/offline.js +6 -0
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -98,6 +98,7 @@ export * from "./reference/menu-record";
|
|
|
98
98
|
export * from "./reference/menu-apply";
|
|
99
99
|
export * from "./reference/capabilities";
|
|
100
100
|
export * from "./reference/entitlements";
|
|
101
|
+
export * from "./reference/entitlement-guard";
|
|
101
102
|
export * from "./reference/entitlement-clock";
|
|
102
103
|
export { hmacSha256Hex, sha256Hex } from "./reference/entitlement-crypto";
|
|
103
104
|
export * from "./adapters/sqlite";
|
|
@@ -122,6 +123,7 @@ export { SELLER_ORDERS_KEYS, BUSINESS_DELIVERIES_KEYS, MENU_KEYS, REFERENCE_KEYS
|
|
|
122
123
|
export * from "./printer/types";
|
|
123
124
|
export * from "./printer/store";
|
|
124
125
|
export * from "./printer/receipt";
|
|
126
|
+
export * from "./printer/company";
|
|
125
127
|
export * from "./printer/service";
|
|
126
128
|
export * from "./printer/hooks";
|
|
127
129
|
export * from "./cart/store";
|
package/dist/index.js
CHANGED
|
@@ -100,6 +100,7 @@ export * from "./reference/menu-record";
|
|
|
100
100
|
export * from "./reference/menu-apply";
|
|
101
101
|
export * from "./reference/capabilities";
|
|
102
102
|
export * from "./reference/entitlements";
|
|
103
|
+
export * from "./reference/entitlement-guard";
|
|
103
104
|
export * from "./reference/entitlement-clock";
|
|
104
105
|
export { hmacSha256Hex, sha256Hex } from "./reference/entitlement-crypto";
|
|
105
106
|
// ── Adapters (for host apps to supply) ──
|
|
@@ -129,6 +130,7 @@ export { SELLER_ORDERS_KEYS, BUSINESS_DELIVERIES_KEYS, MENU_KEYS, REFERENCE_KEYS
|
|
|
129
130
|
export * from "./printer/types";
|
|
130
131
|
export * from "./printer/store";
|
|
131
132
|
export * from "./printer/receipt";
|
|
133
|
+
export * from "./printer/company";
|
|
132
134
|
export * from "./printer/service";
|
|
133
135
|
export * from "./printer/hooks";
|
|
134
136
|
// ── Cart (device-local) ──
|
|
@@ -731,6 +731,17 @@ class LocalNodeServiceCore {
|
|
|
731
731
|
event = events[0] ?? null;
|
|
732
732
|
}
|
|
733
733
|
catch (error) {
|
|
734
|
+
// A 4xx is a backend rejection of THIS command (validation, auth,
|
|
735
|
+
// state) — not evidence the cloud is down. Rethrow without touching
|
|
736
|
+
// global reachability (mirrors sync engine); only network/5xx marks
|
|
737
|
+
// unreachable. Otherwise one rejected sync poisons every later
|
|
738
|
+
// resolveStrategy() into queueing and gates unrelated API ops.
|
|
739
|
+
const rejectionStatus = error?.status;
|
|
740
|
+
if (typeof rejectionStatus === "number" &&
|
|
741
|
+
rejectionStatus >= 400 &&
|
|
742
|
+
rejectionStatus < 500) {
|
|
743
|
+
throw error;
|
|
744
|
+
}
|
|
734
745
|
syncLogger.warn("LocalNodeService", "Cloud forward failed, queueing", { error: String(error) });
|
|
735
746
|
try {
|
|
736
747
|
setConnectivityState({ isCloudReachable: "unreachable" });
|
|
@@ -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,61 @@ 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();
|
|
424
|
+
const contactLine = `${company.address} | ${company.phone}`;
|
|
299
425
|
push(ESC_ALIGN_CENTER);
|
|
300
426
|
pushT(paymentLineEscpos(order));
|
|
301
427
|
push(LF);
|
|
@@ -303,6 +429,12 @@ export function generateEscpos(order) {
|
|
|
303
429
|
pushT(doubleLineEscpos());
|
|
304
430
|
push("Thank you for your order!" + LF);
|
|
305
431
|
push("Come back soon!" + LF);
|
|
432
|
+
push(LF);
|
|
433
|
+
pushT(company.brand + LF);
|
|
434
|
+
pushT(contactLine + LF);
|
|
435
|
+
pushT(company.supportEmail + LF);
|
|
436
|
+
pushT(company.website + LF);
|
|
437
|
+
pushT(`Powered by ${company.name}` + LF);
|
|
306
438
|
push(ESC_FEED_3);
|
|
307
439
|
push(GS_CUT_FULL);
|
|
308
440
|
return parts.filter((p) => p != null).join("");
|
|
@@ -363,6 +495,7 @@ export function generateHtml(order, logoDataUri) {
|
|
|
363
495
|
const logoSrc = logoDataUri || business?.logo_url || "";
|
|
364
496
|
const hasLogo = !!logoSrc;
|
|
365
497
|
const businessName = escHtml(business?.name || "Teinc Food");
|
|
498
|
+
const company = getCompanyInfo();
|
|
366
499
|
const addr = businessAddress(business);
|
|
367
500
|
const bPhone = business?.phone;
|
|
368
501
|
const itemRows = items.map((item) => {
|
|
@@ -370,13 +503,17 @@ export function generateHtml(order, logoDataUri) {
|
|
|
370
503
|
// unit_price is already minor? backend format_money expects minor
|
|
371
504
|
const price = formatMoney((item.unit_price ?? 0) * qty, order);
|
|
372
505
|
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
|
|
506
|
+
const vName = variantName(item);
|
|
507
|
+
const variantRow = vName
|
|
508
|
+
? `<tr class="variant-row"><td class="item-name" colspan="2">Size: ${escHtml(vName)}</td><td class="item-price"></td></tr>`
|
|
509
|
+
: "";
|
|
510
|
+
const addons = item.order_item_addons ?? item.addons ?? [];
|
|
374
511
|
const addonRows = addons.map((addon) => {
|
|
375
512
|
const aName = escHtml(addonName(addon));
|
|
376
513
|
const aPriceMinor = addon.price_minor ?? addon.price ?? 0;
|
|
377
514
|
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
515
|
}).join("\n");
|
|
379
|
-
return [main, addonRows].filter(Boolean).join("\n");
|
|
516
|
+
return [main, variantRow, addonRows].filter(Boolean).join("\n");
|
|
380
517
|
}).join("\n");
|
|
381
518
|
const taxRowsHtml = taxLines.map((tax) => {
|
|
382
519
|
const rateStr = tax.rate_basis_points ? `(${Math.floor(tax.rate_basis_points / 100)}%)` : "";
|
|
@@ -389,23 +526,28 @@ export function generateHtml(order, logoDataUri) {
|
|
|
389
526
|
<style>
|
|
390
527
|
body{font-family:monospace;max-width:320px;margin:0 auto;padding:16px;background:#fff;color:#111;}
|
|
391
528
|
.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;
|
|
529
|
+
.header .logo{max-width:200px;max-height:64px;margin:4px auto;display:block;object-fit:contain;}
|
|
530
|
+
.header h2{margin:4px 0 0;}
|
|
531
|
+
.header h2.name-2x{font-size:28px;font-weight:bold;}
|
|
532
|
+
.header h2.name-1x-bold{font-size:16px;font-weight:bold;}
|
|
533
|
+
.header h2.name-1x{font-size:14px;}
|
|
394
534
|
.header .address{font-size:11px;color:#555;margin:2px 0;}
|
|
395
|
-
.callout{
|
|
535
|
+
.callout{text-align:center;margin:16px 0;}
|
|
396
536
|
.callout .number{font-size:36px;font-weight:bold;letter-spacing:4px;margin:0;}
|
|
397
537
|
.callout .label{font-size:10px;color:#555;margin:0;}
|
|
398
538
|
.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:
|
|
539
|
+
table{width:100%;font-size:12px;border-collapse:collapse;table-layout:fixed;}
|
|
540
|
+
td{padding:2px 0;vertical-align:top;overflow-wrap:anywhere;}
|
|
541
|
+
.item-name{width:17ch;}
|
|
542
|
+
.item-qty{width:3ch;text-align:right;}
|
|
543
|
+
.item-price{width:10ch;text-align:right;}
|
|
404
544
|
.addon-row td{font-size:11px;color:#666;padding-left:12px;}
|
|
405
545
|
.addon-row td.addon-price{text-align:right;}
|
|
546
|
+
.variant-row td{font-size:11px;color:#444;padding-left:8px;}
|
|
406
547
|
.totals{margin-top:8px;}
|
|
407
548
|
.totals td.label{text-align:left;width:70%;}
|
|
408
549
|
.totals td.amount{text-align:right;width:30%;}
|
|
550
|
+
.total-row td{padding-top:12px;}
|
|
409
551
|
.total-row{font-weight:bold;font-size:15px;border-top:2px solid #333;border-bottom:2px solid #333;padding:4px 0;}
|
|
410
552
|
.footer{text-align:center;margin-top:16px;padding-top:8px;font-size:12px;color:#555;}
|
|
411
553
|
@media print{body{max-width:80mm;padding:0;}}
|
|
@@ -413,7 +555,7 @@ export function generateHtml(order, logoDataUri) {
|
|
|
413
555
|
<body>
|
|
414
556
|
<div class="header">
|
|
415
557
|
${hasLogo ? `<img class="logo" src="${escHtml(logoSrc)}" alt="${businessName}" />` : ""}
|
|
416
|
-
<h2>${businessName}</h2>
|
|
558
|
+
<h2 class="${nameSizeClass(business?.name)}">${businessName}</h2>
|
|
417
559
|
<div class="address">${escHtml(addr || "")}</div>
|
|
418
560
|
${bPhone ? `<div class="address">Tel: ${escHtml(bPhone)}</div>` : ""}
|
|
419
561
|
</div>
|
|
@@ -443,6 +585,7 @@ export function generateHtml(order, logoDataUri) {
|
|
|
443
585
|
</div>
|
|
444
586
|
<div class="footer">
|
|
445
587
|
Thank you for your order!<br>Come back soon!
|
|
588
|
+
<br><br>${escHtml(company.brand)}<br>${escHtml(company.address)} | ${escHtml(company.phone)}<br>${escHtml(company.supportEmail)}<br>${escHtml(company.website)}<br>Powered by ${escHtml(company.name)}
|
|
446
589
|
</div>
|
|
447
590
|
</body></html>`;
|
|
448
591
|
}
|
|
@@ -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,
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Offline entitlement auto-guard — evaluates the cached signed
|
|
3
|
+
* `subscription:entitlements` envelope and enforces gated commands locally,
|
|
4
|
+
* before any transport is attempted. The backend remains the final authority
|
|
5
|
+
* (HTTP 402 SUBSCRIPTION_REQUIRED); this guard gives immediate feedback and
|
|
6
|
+
* keeps disallowed work out of the outbox.
|
|
7
|
+
*
|
|
8
|
+
* Fail-closed: missing envelope, missing secret, bad signature, expired, or
|
|
9
|
+
* stale (> max_offline_days) => gated commands throw SUBSCRIPTION_REQUIRED.
|
|
10
|
+
* The host app provisions the HMAC secret at boot via setEntitlementSecret
|
|
11
|
+
* and refreshes the envelope via reference sync; useEntitlements surfaces
|
|
12
|
+
* mustReconnect/paywall state for UI gates.
|
|
13
|
+
*/
|
|
14
|
+
import { type EntitlementEvaluation } from "./entitlements";
|
|
15
|
+
/** Provisioned once at app boot from the host app's env. */
|
|
16
|
+
export declare function setEntitlementSecret(secret: string | null): void;
|
|
17
|
+
export declare function getEntitlementSecret(): string | null;
|
|
18
|
+
export interface BusinessEntitlementState {
|
|
19
|
+
evaluation: EntitlementEvaluation;
|
|
20
|
+
features: Record<string, unknown> | null;
|
|
21
|
+
}
|
|
22
|
+
/** Evaluate the cached signed envelope for a business (no network). */
|
|
23
|
+
export declare function evaluateBusinessEntitlements(businessId: string): Promise<BusinessEntitlementState>;
|
|
24
|
+
export declare function isEntitlementGated(commandType: string): boolean;
|
|
25
|
+
/**
|
|
26
|
+
* Throw SUBSCRIPTION_REQUIRED unless the cached entitlement proof covers
|
|
27
|
+
* this command. No-ops for non-gated commands — and when no envelope was
|
|
28
|
+
* ever cached (fresh terminal that never synced): the backend remains the
|
|
29
|
+
* authority there. Once proof exists, invalid/expired/stale proof denies.
|
|
30
|
+
*/
|
|
31
|
+
export declare function assertEntitledForCommand(businessId: string, commandType: string): Promise<void>;
|