@teincfood/core 0.7.8 → 0.8.0

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.
Files changed (198) hide show
  1. package/README.md +26 -21
  2. package/dist/adapters/auth.d.ts +10 -0
  3. package/dist/adapters/auth.js +43 -0
  4. package/dist/adapters/devices.d.ts +3 -1
  5. package/dist/adapters/devices.js +187 -3
  6. package/dist/adapters/filesystem.d.ts +4 -0
  7. package/dist/adapters/kv.d.ts +10 -0
  8. package/dist/adapters/kv.js +23 -7
  9. package/dist/adapters/local-node.d.ts +4 -6
  10. package/dist/adapters/local-node.js +9 -32
  11. package/dist/adapters/notify.d.ts +12 -0
  12. package/dist/adapters/notify.js +33 -0
  13. package/dist/adapters/query-keys.d.ts +4 -0
  14. package/dist/adapters/query-keys.js +4 -0
  15. package/dist/adapters/services.d.ts +99 -15
  16. package/dist/adapters/services.js +244 -4
  17. package/dist/auth/session.d.ts +66 -0
  18. package/dist/auth/session.js +172 -0
  19. package/dist/catalog/hooks.d.ts +10 -0
  20. package/dist/catalog/hooks.js +82 -11
  21. package/dist/db/migrations.js +274 -0
  22. package/dist/db/operations.d.ts +53 -2
  23. package/dist/db/operations.js +177 -8
  24. package/dist/domain/business/index.d.ts +5 -0
  25. package/dist/domain/business/index.js +5 -0
  26. package/dist/domain/orders/lifecycle.d.ts +6 -0
  27. package/dist/domain/orders/lifecycle.js +6 -0
  28. package/dist/domain/orders/service.d.ts +24 -0
  29. package/dist/domain/orders/service.js +242 -0
  30. package/dist/domain/payments/index.d.ts +6 -0
  31. package/dist/domain/payments/index.js +5 -0
  32. package/dist/domain/reference/index.d.ts +8 -0
  33. package/dist/domain/reference/index.js +8 -0
  34. package/dist/e2e/harness.d.ts +1 -0
  35. package/dist/e2e/harness.js +61 -0
  36. package/dist/hooks/analytics.hooks.d.ts +25 -0
  37. package/dist/hooks/analytics.hooks.js +214 -0
  38. package/dist/hooks/business-hours.hooks.d.ts +8 -0
  39. package/dist/hooks/business-hours.hooks.js +45 -0
  40. package/dist/hooks/dashboard.hooks.d.ts +30 -0
  41. package/dist/hooks/dashboard.hooks.js +132 -0
  42. package/dist/hooks/delivery.hooks.d.ts +225 -0
  43. package/dist/hooks/delivery.hooks.js +80 -0
  44. package/dist/hooks/fleet.hooks.d.ts +37 -0
  45. package/dist/hooks/fleet.hooks.js +229 -0
  46. package/dist/hooks/image.hooks.d.ts +20 -0
  47. package/dist/hooks/image.hooks.js +62 -0
  48. package/dist/hooks/kiosk.hooks.d.ts +22 -5
  49. package/dist/hooks/kiosk.hooks.js +74 -5
  50. package/dist/hooks/members.hooks.d.ts +48 -0
  51. package/dist/hooks/members.hooks.js +302 -0
  52. package/dist/hooks/offline-orders.hooks.d.ts +9 -0
  53. package/dist/hooks/offline-orders.hooks.js +51 -0
  54. package/dist/hooks/orders.hooks.d.ts +19 -39
  55. package/dist/hooks/orders.hooks.js +126 -321
  56. package/dist/hooks/places.hooks.d.ts +21 -0
  57. package/dist/hooks/places.hooks.js +112 -0
  58. package/dist/hooks/realtime-stubs.d.ts +28 -0
  59. package/dist/hooks/realtime-stubs.js +85 -0
  60. package/dist/hooks/rider.hooks.d.ts +470 -0
  61. package/dist/hooks/rider.hooks.js +373 -0
  62. package/dist/hooks/use-infinite-list.d.ts +243 -0
  63. package/dist/hooks/use-infinite-list.js +58 -0
  64. package/dist/hooks/wallet.hooks.d.ts +219 -0
  65. package/dist/hooks/wallet.hooks.js +56 -0
  66. package/dist/image-cache/service.d.ts +8 -0
  67. package/dist/image-cache/service.js +15 -3
  68. package/dist/index.d.ts +60 -5
  69. package/dist/index.js +78 -5
  70. package/dist/local-node/adapters.d.ts +87 -0
  71. package/dist/local-node/adapters.js +4 -0
  72. package/dist/local-node/index.d.ts +5 -0
  73. package/dist/local-node/index.js +5 -0
  74. package/dist/local-node/security.d.ts +25 -0
  75. package/dist/local-node/security.js +149 -0
  76. package/dist/local-node/service.d.ts +83 -0
  77. package/dist/local-node/service.js +706 -0
  78. package/dist/local-node/store.d.ts +50 -0
  79. package/dist/local-node/store.js +284 -0
  80. package/dist/local-node/types.d.ts +92 -0
  81. package/dist/local-node/types.js +4 -0
  82. package/dist/printer/receipt.d.ts +4 -0
  83. package/dist/printer/receipt.js +75 -0
  84. package/dist/printer/service.js +1 -1
  85. package/dist/providers/query-client.d.ts +13 -0
  86. package/dist/providers/query-client.js +58 -0
  87. package/dist/realtime/events.service.d.ts +18 -0
  88. package/dist/realtime/events.service.js +76 -0
  89. package/dist/realtime/order-events.service.d.ts +6 -0
  90. package/dist/realtime/order-events.service.js +17 -0
  91. package/dist/realtime/phoenix-socket.d.ts +55 -0
  92. package/dist/realtime/phoenix-socket.js +269 -0
  93. package/dist/realtime/useEventsConnection.d.ts +1 -0
  94. package/dist/realtime/useEventsConnection.js +50 -0
  95. package/dist/reference/api.d.ts +2 -0
  96. package/dist/reference/api.js +2 -0
  97. package/dist/reference/capabilities.d.ts +12 -7
  98. package/dist/reference/capabilities.js +28 -13
  99. package/dist/reference/hooks.d.ts +34 -0
  100. package/dist/reference/hooks.js +124 -0
  101. package/dist/reference/menu-apply.d.ts +29 -0
  102. package/dist/reference/menu-apply.js +289 -0
  103. package/dist/reference/menu-record.d.ts +69 -0
  104. package/dist/reference/menu-record.js +163 -0
  105. package/dist/reference/operations.d.ts +3 -0
  106. package/dist/reference/operations.js +35 -1
  107. package/dist/reference/service.js +36 -9
  108. package/dist/reference/types.d.ts +1 -1
  109. package/dist/repositories/order.repository.d.ts +8 -0
  110. package/dist/repositories/order.repository.js +24 -2
  111. package/dist/repositories/pos.repository.d.ts +4 -0
  112. package/dist/repositories/pos.repository.js +4 -0
  113. package/dist/sync/backoff.d.ts +21 -0
  114. package/dist/sync/backoff.js +42 -0
  115. package/dist/sync/command-builder.js +10 -2
  116. package/dist/sync/command-queue.service.d.ts +12 -0
  117. package/dist/sync/command-queue.service.js +115 -18
  118. package/dist/sync/device-lease.service.d.ts +3 -3
  119. package/dist/sync/device-lease.service.js +80 -20
  120. package/dist/sync/engine.d.ts +3 -1
  121. package/dist/sync/engine.js +44 -4
  122. package/dist/sync/full-sync.d.ts +6 -0
  123. package/dist/sync/full-sync.js +72 -0
  124. package/dist/sync/health-probe.d.ts +24 -0
  125. package/dist/sync/health-probe.js +138 -0
  126. package/dist/sync/offline-capable.js +25 -0
  127. package/dist/sync/online-gates.d.ts +17 -0
  128. package/dist/sync/online-gates.js +55 -0
  129. package/dist/sync/order-number.d.ts +4 -4
  130. package/dist/sync/order-number.js +33 -13
  131. package/dist/sync/order-snapshots.service.d.ts +1 -0
  132. package/dist/sync/order-snapshots.service.js +47 -2
  133. package/dist/sync/pending-orders.service.d.ts +18 -0
  134. package/dist/sync/pending-orders.service.js +148 -3
  135. package/dist/sync/reconciliation/index.d.ts +5 -0
  136. package/dist/sync/reconciliation/index.js +5 -0
  137. package/dist/sync/snapshots/entity.d.ts +6 -0
  138. package/dist/sync/snapshots/entity.js +6 -0
  139. package/dist/sync/sync-jobs.service.d.ts +18 -0
  140. package/dist/sync/sync-jobs.service.js +539 -0
  141. package/dist/sync/transition-endpoints.d.ts +11 -0
  142. package/dist/sync/transition-endpoints.js +30 -0
  143. package/dist/sync/transport-local-node.js +3 -2
  144. package/dist/sync/transport-rest.d.ts +7 -0
  145. package/dist/sync/transport-rest.js +90 -25
  146. package/dist/sync/use-sync-reconciliation.js +58 -16
  147. package/dist/test-helpers/memory-driver.js +182 -4
  148. package/dist/test-helpers/seed-hours.d.ts +1 -0
  149. package/dist/test-helpers/seed-hours.js +23 -0
  150. package/dist/types/api/business.api.d.ts +42 -0
  151. package/dist/types/api/menu.api.d.ts +15 -0
  152. package/dist/types/api/seller.api.d.ts +20 -0
  153. package/dist/types/places.d.ts +19 -0
  154. package/dist/types/places.js +2 -0
  155. package/dist/types/rider.d.ts +67 -0
  156. package/dist/types/rider.js +2 -0
  157. package/dist/utils/attempt-stats.d.ts +23 -0
  158. package/dist/utils/attempt-stats.js +61 -0
  159. package/dist/utils/business-hours.d.ts +37 -0
  160. package/dist/utils/business-hours.js +96 -0
  161. package/dist/utils/debug-log.d.ts +23 -0
  162. package/dist/utils/debug-log.js +153 -0
  163. package/dist/utils/order-filter.d.ts +1 -1
  164. package/dist/utils/order-filter.js +1 -1
  165. package/dist/utils/order-transitions.d.ts +30 -0
  166. package/dist/utils/order-transitions.js +185 -0
  167. package/dist/utils/sync-logger.js +5 -4
  168. package/dist/validation/offline.d.ts +46 -0
  169. package/dist/validation/offline.js +79 -0
  170. package/package.json +6 -1
  171. package/dist/pricing/engine.test.d.ts +0 -1
  172. package/dist/pricing/engine.test.js +0 -142
  173. package/dist/reference/lww.test.d.ts +0 -1
  174. package/dist/reference/lww.test.js +0 -23
  175. package/dist/reference/operations.test.d.ts +0 -1
  176. package/dist/reference/operations.test.js +0 -66
  177. package/dist/standalone.integration.test.d.ts +0 -1
  178. package/dist/standalone.integration.test.js +0 -235
  179. package/dist/sync/device-lease.test.d.ts +0 -1
  180. package/dist/sync/device-lease.test.js +0 -38
  181. package/dist/sync/offline-integration.test.d.ts +0 -1
  182. package/dist/sync/offline-integration.test.js +0 -52
  183. package/dist/sync/order-number.test.d.ts +0 -1
  184. package/dist/sync/order-number.test.js +0 -51
  185. package/dist/sync/perms.test.d.ts +0 -1
  186. package/dist/sync/perms.test.js +0 -15
  187. package/dist/utils/currency.test.d.ts +0 -1
  188. package/dist/utils/currency.test.js +0 -17
  189. package/dist/utils/hlc.test.d.ts +0 -1
  190. package/dist/utils/hlc.test.js +0 -29
  191. package/dist/utils/phone.test.d.ts +0 -1
  192. package/dist/utils/phone.test.js +0 -19
  193. package/dist/utils/responsive.d.ts +0 -13
  194. package/dist/utils/responsive.js +0 -18
  195. package/dist/utils/uuid.test.d.ts +0 -1
  196. package/dist/utils/uuid.test.js +0 -13
  197. package/dist/utils/validation.test.d.ts +0 -1
  198. package/dist/utils/validation.test.js +0 -23
@@ -0,0 +1,76 @@
1
+ import { phoenixSocket } from "./phoenix-socket";
2
+ class EventsService {
3
+ constructor() {
4
+ this.refCount = 0;
5
+ this.topic = null;
6
+ this.joined = false;
7
+ this.lastSequence = 0;
8
+ this.activeBusinessId = null;
9
+ this.listeners = new Map();
10
+ this.wildcardListeners = new Set();
11
+ this.frameHandlerUnsub = null;
12
+ }
13
+ connect(userId, businessId) {
14
+ this.refCount++;
15
+ const topic = `events:${userId}`;
16
+ if (businessId !== undefined)
17
+ this.activeBusinessId = businessId ?? null;
18
+ if (this.refCount === 1 && !this.frameHandlerUnsub) {
19
+ this.topic = topic;
20
+ this.frameHandlerUnsub = phoenixSocket.onFrame(topic, ([_joinRef, _ref, _topic, event, payload]) => {
21
+ const data = payload;
22
+ const payloadBid = data?.business_id;
23
+ if (this.activeBusinessId && payloadBid && payloadBid !== this.activeBusinessId)
24
+ return;
25
+ const sequence = data?._sequence;
26
+ if (typeof sequence === "number" && sequence <= this.lastSequence)
27
+ return;
28
+ if (typeof sequence === "number" && sequence > this.lastSequence)
29
+ this.lastSequence = sequence;
30
+ else if (typeof data?._sequence === "number")
31
+ this.lastSequence = data._sequence;
32
+ const typeListeners = this.listeners.get(event);
33
+ if (typeListeners)
34
+ typeListeners.forEach((cb) => cb(data));
35
+ this.wildcardListeners.forEach((cb) => cb(data));
36
+ });
37
+ const joinPayload = {};
38
+ if (this.lastSequence > 0)
39
+ joinPayload.last_sequence = this.lastSequence;
40
+ if (this.activeBusinessId)
41
+ joinPayload.business_id = this.activeBusinessId;
42
+ phoenixSocket.connect().then(() => phoenixSocket.joinChannel(topic, joinPayload)).then(() => { this.joined = true; }).catch(() => { });
43
+ }
44
+ else if (businessId !== undefined) {
45
+ // business switched while already joined — update filter without rejoin (next events filtered). If backend requires business:{id} subscription, re-join could be forced — not needed as fallback business_authorized check still passes.
46
+ this.activeBusinessId = businessId ?? null;
47
+ }
48
+ let disconnected = false;
49
+ return () => {
50
+ if (disconnected)
51
+ return;
52
+ disconnected = true;
53
+ this.refCount = Math.max(0, this.refCount - 1);
54
+ if (this.refCount === 0) {
55
+ if (this.topic && this.joined)
56
+ phoenixSocket.leaveChannel(this.topic);
57
+ this.frameHandlerUnsub?.();
58
+ this.frameHandlerUnsub = null;
59
+ this.joined = false;
60
+ this.topic = null;
61
+ // reset sequence on full disconnect so next user doesn't dedup? Keep until next connect with same user - if user changes, lastSequence should reset. Caller should ensure new userId -> new instance or reset.
62
+ // Keep for replay.
63
+ }
64
+ };
65
+ }
66
+ on(eventType, callback) {
67
+ if (!this.listeners.has(eventType))
68
+ this.listeners.set(eventType, new Set());
69
+ this.listeners.get(eventType).add(callback);
70
+ return () => { this.listeners.get(eventType)?.delete(callback); };
71
+ }
72
+ onAny(callback) { this.wildcardListeners.add(callback); return () => this.wildcardListeners.delete(callback); }
73
+ isConnected() { return this.joined && phoenixSocket.isConnected(); }
74
+ resetSequence() { this.lastSequence = 0; }
75
+ }
76
+ export const eventsService = new EventsService();
@@ -0,0 +1,6 @@
1
+ export declare const orderEventsService: {
2
+ onOrderUpdates(callback: (event: Record<string, unknown> & {
3
+ type: string;
4
+ order_id?: string;
5
+ }) => void): () => void;
6
+ };
@@ -0,0 +1,17 @@
1
+ import { eventsService } from "./events.service";
2
+ const ORDER_EVENT_TYPES = [
3
+ "order.created",
4
+ "order.status_changed",
5
+ "delivery.assigned",
6
+ "delivery.status_changed",
7
+ "delivery.pickup_confirmed",
8
+ "payment.completed",
9
+ "payment.failed",
10
+ "reference_data.changed",
11
+ ];
12
+ export const orderEventsService = {
13
+ onOrderUpdates(callback) {
14
+ const unsubs = ORDER_EVENT_TYPES.map((type) => eventsService.on(type, (data) => callback({ ...data, type })));
15
+ return () => unsubs.forEach((fn) => fn());
16
+ },
17
+ };
@@ -0,0 +1,55 @@
1
+ export type ConnectionState = "disconnected" | "connecting" | "connected" | "reconnecting";
2
+ export type PhoenixV2Frame = [string | null, string | null, string, string, Record<string, unknown>];
3
+ export type PhoenixOutbound = {
4
+ topic: string;
5
+ event: string;
6
+ payload: Record<string, unknown>;
7
+ ref?: string | null;
8
+ joinRef?: string | null;
9
+ };
10
+ type FrameHandler = (frame: PhoenixV2Frame) => void;
11
+ declare class PhoenixSocket {
12
+ socket: WebSocket | null;
13
+ private refCounter;
14
+ private reconnectAttempt;
15
+ private reconnectTimer;
16
+ private heartbeatTimer;
17
+ private heartbeatPend;
18
+ private heartbeatMissed;
19
+ private static readonly MAX_MISSED;
20
+ private intentionalClose;
21
+ private connectGen;
22
+ private connectPromise;
23
+ private connectionState;
24
+ private connectionListeners;
25
+ private pendingJoins;
26
+ private pendingPushes;
27
+ joinedChannels: Set<string>;
28
+ private pendingRejoin;
29
+ private topicJoinRefs;
30
+ private frameHandlers;
31
+ private getRef;
32
+ private buildSocketUrl;
33
+ private setState;
34
+ onConnectionStateChange(fn: (s: ConnectionState) => void): () => void;
35
+ getConnectionState(): ConnectionState;
36
+ connect(): Promise<void>;
37
+ disconnect(): void;
38
+ retryConnection(): void;
39
+ private scheduleReconnect;
40
+ private cancelReconnect;
41
+ private startHeartbeat;
42
+ private stopHeartbeat;
43
+ private sendRaw;
44
+ push(topic: string, event: string, payload: Record<string, unknown>): void;
45
+ pushWithReply(topic: string, event: string, payload: Record<string, unknown>): Promise<Record<string, unknown>>;
46
+ private handleFrame;
47
+ onFrame(topic: string, handler: FrameHandler): () => void;
48
+ joinChannel(topic: string, payload?: Record<string, unknown>): Promise<Record<string, unknown>>;
49
+ leaveChannel(topic: string): void;
50
+ private rejoinPending;
51
+ isConnected(): boolean;
52
+ hasJoined(topic: string): boolean;
53
+ }
54
+ export declare const phoenixSocket: PhoenixSocket;
55
+ export {};
@@ -0,0 +1,269 @@
1
+ import { WS_BASE_URL } from "../utils/constants";
2
+ import { getAuthToken } from "../adapters/auth";
3
+ import { getConnectivityState } from "../adapters/connectivity";
4
+ import { recordAttempt } from "../utils/attempt-stats";
5
+ import { syncLogger } from "../utils/sync-logger";
6
+ const RECONNECT_BASE = 1000;
7
+ const RECONNECT_MAX = 10000;
8
+ const HEARTBEAT_MS = 30000;
9
+ class PhoenixSocket {
10
+ constructor() {
11
+ this.socket = null;
12
+ this.refCounter = 0;
13
+ this.reconnectAttempt = 0;
14
+ this.reconnectTimer = null;
15
+ this.heartbeatTimer = null;
16
+ this.heartbeatPend = false;
17
+ this.heartbeatMissed = 0;
18
+ this.intentionalClose = false;
19
+ this.connectGen = 0;
20
+ this.connectPromise = null;
21
+ this.connectionState = "disconnected";
22
+ this.connectionListeners = new Set();
23
+ this.pendingJoins = new Map();
24
+ this.pendingPushes = new Map();
25
+ this.joinedChannels = new Set();
26
+ this.pendingRejoin = new Set();
27
+ this.topicJoinRefs = new Map();
28
+ this.frameHandlers = new Map();
29
+ }
30
+ getRef() { return String(++this.refCounter); }
31
+ buildSocketUrl(token) {
32
+ const base = WS_BASE_URL.replace(/\/+$/, "");
33
+ const path = base.endsWith("/ws") ? "/websocket" : "/ws/websocket";
34
+ return `${base}${path}?token=${encodeURIComponent(token)}&vsn=2.0.0`;
35
+ }
36
+ setState(s) {
37
+ this.connectionState = s;
38
+ this.connectionListeners.forEach((fn) => fn(s));
39
+ }
40
+ onConnectionStateChange(fn) {
41
+ this.connectionListeners.add(fn);
42
+ return () => this.connectionListeners.delete(fn);
43
+ }
44
+ getConnectionState() { return this.connectionState; }
45
+ connect() {
46
+ if (this.socket?.readyState === WebSocket.OPEN)
47
+ return Promise.resolve();
48
+ if (this.connectPromise)
49
+ return this.connectPromise;
50
+ // Offline-first: don't even open a socket when the cloud is known
51
+ // unreachable — the browser logs a WS error per attempt otherwise.
52
+ try {
53
+ if (getConnectivityState().isCloudReachable === "unreachable") {
54
+ this.scheduleReconnect();
55
+ return Promise.reject(new Error("Offline — cloud unreachable"));
56
+ }
57
+ }
58
+ catch { }
59
+ recordAttempt("ws_connect");
60
+ this.cancelReconnect();
61
+ this.intentionalClose = false;
62
+ this.setState("connecting");
63
+ this.connectPromise = new Promise((resolve, reject) => {
64
+ const token = getAuthToken();
65
+ if (!token) {
66
+ this.setState("disconnected");
67
+ this.connectPromise = null;
68
+ reject(new Error("Not authenticated"));
69
+ return;
70
+ }
71
+ const myGen = ++this.connectGen;
72
+ const url = this.buildSocketUrl(token);
73
+ this.socket = new WebSocket(url);
74
+ this.socket.onopen = () => {
75
+ if (myGen !== this.connectGen)
76
+ return;
77
+ this.reconnectAttempt = 0;
78
+ this.setState("connected");
79
+ this.startHeartbeat();
80
+ this.rejoinPending().catch(() => { });
81
+ this.connectPromise = null;
82
+ resolve();
83
+ };
84
+ this.socket.onerror = () => { if (myGen !== this.connectGen)
85
+ return; if (this.connectionState === "connecting") {
86
+ this.setState("disconnected");
87
+ this.connectPromise = null;
88
+ } };
89
+ this.socket.onmessage = (ev) => { try {
90
+ const frame = JSON.parse(ev.data);
91
+ this.handleFrame(frame);
92
+ }
93
+ catch { } };
94
+ this.socket.onclose = () => {
95
+ if (myGen !== this.connectGen)
96
+ return;
97
+ this.stopHeartbeat();
98
+ if (!this.intentionalClose) {
99
+ this.joinedChannels.forEach((t) => this.pendingRejoin.add(t));
100
+ this.joinedChannels.clear();
101
+ this.scheduleReconnect();
102
+ }
103
+ else {
104
+ this.joinedChannels.clear();
105
+ this.pendingRejoin.clear();
106
+ this.setState("disconnected");
107
+ }
108
+ this.connectPromise = null;
109
+ };
110
+ });
111
+ return this.connectPromise;
112
+ }
113
+ disconnect() {
114
+ this.intentionalClose = true;
115
+ this.cancelReconnect();
116
+ this.stopHeartbeat();
117
+ this.joinedChannels.clear();
118
+ this.pendingRejoin.clear();
119
+ this.pendingJoins.clear();
120
+ this.pendingPushes.clear();
121
+ this.topicJoinRefs.clear();
122
+ this.frameHandlers.clear();
123
+ if (this.socket) {
124
+ this.socket.close();
125
+ this.socket = null;
126
+ }
127
+ this.setState("disconnected");
128
+ this.connectPromise = null;
129
+ }
130
+ retryConnection() { this.cancelReconnect(); this.reconnectAttempt = 0; this.connect().catch(() => { }); }
131
+ scheduleReconnect() {
132
+ if (this.intentionalClose)
133
+ return;
134
+ this.setState("reconnecting");
135
+ // Offline-first: when the cloud is known unreachable (backend down),
136
+ // back off to a slow 60s poll instead of the 1s→10s hot loop — the
137
+ // useEventsConnection gate + probe regain path re-establishes fast.
138
+ let unreachable = false;
139
+ try {
140
+ unreachable = getConnectivityState().isCloudReachable === "unreachable";
141
+ }
142
+ catch { }
143
+ const delay = unreachable
144
+ ? 60000
145
+ : Math.min(RECONNECT_BASE * Math.pow(2, this.reconnectAttempt), RECONNECT_MAX);
146
+ this.reconnectAttempt++;
147
+ recordAttempt("ws_reconnect", `delay=${delay} attempt=${this.reconnectAttempt}`);
148
+ try {
149
+ syncLogger.debug("PhoenixSocket", "scheduleReconnect", { delay, attempt: this.reconnectAttempt });
150
+ }
151
+ catch { }
152
+ this.reconnectTimer = setTimeout(() => this.connect().catch(() => { }), delay);
153
+ }
154
+ cancelReconnect() { if (this.reconnectTimer) {
155
+ clearTimeout(this.reconnectTimer);
156
+ this.reconnectTimer = null;
157
+ } }
158
+ startHeartbeat() {
159
+ this.heartbeatPend = false;
160
+ this.heartbeatMissed = 0;
161
+ this.heartbeatTimer = setInterval(() => {
162
+ recordAttempt("ws_heartbeat", `missed=${this.heartbeatMissed}`);
163
+ if (this.heartbeatPend) {
164
+ this.heartbeatMissed++;
165
+ if (this.heartbeatMissed >= PhoenixSocket.MAX_MISSED)
166
+ this.socket?.close();
167
+ return;
168
+ }
169
+ this.heartbeatPend = true;
170
+ this.sendRaw({ topic: "phoenix", event: "heartbeat", payload: {} });
171
+ }, HEARTBEAT_MS);
172
+ }
173
+ stopHeartbeat() { if (this.heartbeatTimer) {
174
+ clearInterval(this.heartbeatTimer);
175
+ this.heartbeatTimer = null;
176
+ } this.heartbeatPend = false; this.heartbeatMissed = 0; }
177
+ sendRaw(msg) {
178
+ if (this.socket?.readyState === WebSocket.OPEN) {
179
+ const joinRef = msg.topic && msg.topic !== "phoenix" ? (msg.joinRef ?? this.topicJoinRefs.get(msg.topic) ?? null) : (msg.joinRef ?? null);
180
+ const frame = [joinRef, msg.ref ?? null, msg.topic, msg.event, msg.payload];
181
+ this.socket.send(JSON.stringify(frame));
182
+ }
183
+ }
184
+ push(topic, event, payload) { const ref = this.getRef(); this.sendRaw({ topic, event, payload, ref, joinRef: this.topicJoinRefs.get(topic) }); }
185
+ pushWithReply(topic, event, payload) {
186
+ const ref = this.getRef();
187
+ const msg = { topic, event, payload, ref, joinRef: this.topicJoinRefs.get(topic) };
188
+ return new Promise((resolve, reject) => {
189
+ this.pendingPushes.set(ref, { resolve, reject });
190
+ this.sendRaw(msg);
191
+ setTimeout(() => { if (this.pendingPushes.has(ref)) {
192
+ this.pendingPushes.delete(ref);
193
+ reject(new Error(`Request timed out: ${event} on ${topic}`));
194
+ } }, 5000);
195
+ });
196
+ }
197
+ handleFrame([joinRef, ref, topic, event, payload]) {
198
+ if (event === "phx_reply" && topic === "phoenix") {
199
+ this.heartbeatPend = false;
200
+ this.heartbeatMissed = 0;
201
+ return;
202
+ }
203
+ if (event === "phx_reply" && ref) {
204
+ const pending = this.pendingPushes.get(ref);
205
+ if (pending) {
206
+ const status = payload?.status;
207
+ const response = (payload?.response ?? {});
208
+ if (status === "ok")
209
+ pending.resolve(response);
210
+ else
211
+ pending.reject(new Error(String(response.reason ?? "Request failed")));
212
+ this.pendingPushes.delete(ref);
213
+ return;
214
+ }
215
+ }
216
+ if (event === "phx_reply" && payload?.status === "ok") {
217
+ const pending = this.pendingJoins.get(topic);
218
+ if (pending) {
219
+ const response = (payload?.response ?? {});
220
+ pending.resolve(response);
221
+ this.pendingJoins.delete(topic);
222
+ this.joinedChannels.add(topic);
223
+ if (joinRef)
224
+ this.topicJoinRefs.set(topic, joinRef);
225
+ }
226
+ return;
227
+ }
228
+ if (event === "phx_reply" && payload?.status === "error") {
229
+ const pending = this.pendingJoins.get(topic);
230
+ if (pending) {
231
+ const response = (payload?.response ?? {});
232
+ pending.reject(new Error(String(response.reason ?? "Join failed")));
233
+ this.pendingJoins.delete(topic);
234
+ }
235
+ return;
236
+ }
237
+ const handlers = this.frameHandlers.get(topic);
238
+ if (handlers)
239
+ handlers.forEach((cb) => cb([joinRef, ref, topic, event, payload]));
240
+ }
241
+ onFrame(topic, handler) {
242
+ if (!this.frameHandlers.has(topic))
243
+ this.frameHandlers.set(topic, new Set());
244
+ this.frameHandlers.get(topic).add(handler);
245
+ return () => { this.frameHandlers.get(topic)?.delete(handler); };
246
+ }
247
+ async joinChannel(topic, payload = {}) {
248
+ if (this.joinedChannels.has(topic))
249
+ return Promise.resolve({});
250
+ if (this.pendingJoins.has(topic))
251
+ return new Promise((resolve, reject) => { this.pendingJoins.set(topic, { resolve, reject }); });
252
+ const ref = this.getRef();
253
+ const msg = { topic, event: "phx_join", payload, ref };
254
+ return new Promise((resolve, reject) => {
255
+ this.pendingJoins.set(topic, { resolve, reject });
256
+ this.sendRaw(msg);
257
+ setTimeout(() => { if (this.pendingJoins.has(topic)) {
258
+ this.pendingJoins.delete(topic);
259
+ reject(new Error(`Failed to join channel ${topic} - timeout`));
260
+ } }, 5000);
261
+ });
262
+ }
263
+ leaveChannel(topic) { this.joinedChannels.delete(topic); this.pendingRejoin.delete(topic); this.topicJoinRefs.delete(topic); }
264
+ async rejoinPending() { const topics = Array.from(this.pendingRejoin); this.pendingRejoin.clear(); await Promise.allSettled(topics.map((t) => this.joinChannel(t))); }
265
+ isConnected() { return this.socket?.readyState === WebSocket.OPEN; }
266
+ hasJoined(topic) { return this.joinedChannels.has(topic); }
267
+ }
268
+ PhoenixSocket.MAX_MISSED = 3;
269
+ export const phoenixSocket = new PhoenixSocket();
@@ -0,0 +1 @@
1
+ export declare function useEventsConnection(): void;
@@ -0,0 +1,50 @@
1
+ import { useEffect } from "react";
2
+ import { eventsService } from "./events.service";
3
+ import { useSyncExternalStore } from "react";
4
+ import { getActiveBusiness, subscribeBusiness } from "../adapters/business";
5
+ import { getAuthToken } from "../adapters/auth";
6
+ import { getConnectivityState, subscribeConnectivity } from "../adapters/connectivity";
7
+ function useBusinessId() {
8
+ return useSyncExternalStore(subscribeBusiness, () => getActiveBusiness()?.id ?? null, () => null);
9
+ }
10
+ function useIsAuthenticated() {
11
+ // poll auth token presence; host should setAuthProvider — if no provider, assume authenticated when token exists
12
+ return !!getAuthToken();
13
+ }
14
+ function useIsEffectivelyOnline() {
15
+ const s = useSyncExternalStore(subscribeConnectivity, getConnectivityState, getConnectivityState);
16
+ return s.isOnline && s.isCloudReachable !== "unreachable";
17
+ }
18
+ export function useEventsConnection() {
19
+ const activeBusinessId = useBusinessId();
20
+ // derive userId from auth token? Host must provide via setAuthProvider that encodes user, but we need userId for topic.
21
+ // For now, read from global if host set it via setAuthUserId adapter. Fallback: token payload decode.
22
+ // Minimal: require host to call eventsService.connect with userId directly via wrapper — this hook is convenience that reads business only.
23
+ // To keep zero-config, we attempt to read from window localStorage token decode — fallback no-op.
24
+ const userId = deriveUserId();
25
+ // Offline-first: never open the Phoenix socket while the cloud is known
26
+ // unreachable (backend down). Connecting anyway just spins the 1s→10s
27
+ // reconnect loop and spams WS errors. Reconnect fires via effect dep.
28
+ const online = useIsEffectivelyOnline();
29
+ useEffect(() => {
30
+ if (!userId || !online)
31
+ return;
32
+ const disconnect = eventsService.connect(userId, activeBusinessId);
33
+ return () => disconnect();
34
+ }, [userId, activeBusinessId, online]);
35
+ }
36
+ function deriveUserId() {
37
+ try {
38
+ const token = getAuthToken();
39
+ if (!token)
40
+ return null;
41
+ const parts = token.split(".");
42
+ if (parts.length < 2)
43
+ return null;
44
+ const payload = JSON.parse(atob(parts[1]));
45
+ return payload.sub || payload.user_id || payload.id || null;
46
+ }
47
+ catch {
48
+ return null;
49
+ }
50
+ }
@@ -25,3 +25,5 @@ export declare function fetchReferenceVersions(businessId: string): Promise<Refe
25
25
  */
26
26
  export declare function fetchReferenceData(businessId: string, keys: ReferenceDataKey[]): Promise<Record<ReferenceDataKey, unknown>>;
27
27
  export declare function fetchReferenceChanges<T>(businessId: string, key: ReferenceDataKey, fromVersion: string): Promise<ReferenceChangeSet<T>>;
28
+ export { setReferenceData, mergeReferenceData } from "./operations";
29
+ export { setOperatingHoursLocal, setBusinessProfileLocal } from "./operations";
@@ -40,3 +40,5 @@ export async function fetchReferenceChanges(businessId, key, fromVersion) {
40
40
  const response = await api.get(`/businesses/${businessId}/reference-changes`, { params: { key, from_version: fromVersion } });
41
41
  return response.data.data;
42
42
  }
43
+ export { setReferenceData, mergeReferenceData } from "./operations";
44
+ export { setOperatingHoursLocal, setBusinessProfileLocal } from "./operations";
@@ -8,16 +8,21 @@
8
8
  *
9
9
  * Host apps wire `businessService.fetchMyBusinessMembership` via adapters/services.
10
10
  */
11
+ import { useQuery } from "@tanstack/react-query";
11
12
  /**
12
13
  * Standard hook for both apps — businessId comes from the host's active business store.
13
- * Returns the same shape as the previous app-level useMyBusinessCapabilities.
14
+ * Offline-first: when offline, only the SQLite cache (`capabilities`) is used so
15
+ * nav filtering (menu/pos/orders/deliveries hidden) does not break when backend is down.
16
+ * When online, network refreshes the cache.
14
17
  */
15
- export declare function useBusinessCapabilities(businessId?: string | null): import("@tanstack/react-query").UseQueryResult<{
16
- role: string;
17
- source: string;
18
- capabilities: string[];
19
- status: string;
20
- }, Error>;
18
+ export declare function useBusinessCapabilities(businessId?: string | null): ReturnType<typeof useQuery> & {
19
+ data: {
20
+ role: string;
21
+ source: string;
22
+ capabilities: string[];
23
+ status: string;
24
+ } | undefined;
25
+ };
21
26
  export declare function useHasCapabilityFor(businessId: string | undefined, capability: string): boolean;
22
27
  export declare const CAPABILITY_BY_COMMAND: Record<string, string>;
23
28
  export declare function hasCapabilitySync(businessId: string, commandType: string): Promise<boolean>;
@@ -9,10 +9,11 @@
9
9
  * Host apps wire `businessService.fetchMyBusinessMembership` via adapters/services.
10
10
  */
11
11
  import { useEffect, useState } from "react";
12
- import { useQuery, useQueryClient } from "@tanstack/react-query";
12
+ import { useQuery } from "@tanstack/react-query";
13
13
  import { openSyncDatabase } from "../db/connection";
14
14
  import { getReferenceData, setReferenceData } from "./operations";
15
15
  import { businessService } from "../adapters/services";
16
+ import { useIsEffectivelyOnline } from "../hooks/use-infinite-list";
16
17
  const MEMBERSHIP_KEYS = {
17
18
  all: ["my-membership"],
18
19
  byBusiness: (id) => [...MEMBERSHIP_KEYS.all, id],
@@ -42,13 +43,14 @@ function useCapabilitiesPlaceholder(businessId) {
42
43
  }
43
44
  /**
44
45
  * Standard hook for both apps — businessId comes from the host's active business store.
45
- * Returns the same shape as the previous app-level useMyBusinessCapabilities.
46
+ * Offline-first: when offline, only the SQLite cache (`capabilities`) is used so
47
+ * nav filtering (menu/pos/orders/deliveries hidden) does not break when backend is down.
48
+ * When online, network refreshes the cache.
46
49
  */
47
50
  export function useBusinessCapabilities(businessId) {
48
- const queryClient = useQueryClient();
49
- void queryClient;
51
+ const isOnline = useIsEffectivelyOnline();
50
52
  const cached = useCapabilitiesPlaceholder(businessId ?? null);
51
- return useQuery({
53
+ const query = useQuery({
52
54
  queryKey: MEMBERSHIP_KEYS.byBusiness(businessId ?? ""),
53
55
  queryFn: async () => {
54
56
  const res = (await businessService.fetchMyBusinessMembership(businessId));
@@ -64,16 +66,26 @@ export function useBusinessCapabilities(businessId) {
64
66
  catch { }
65
67
  return res;
66
68
  },
67
- enabled: !!businessId,
68
- staleTime: Infinity,
69
+ enabled: !!businessId && isOnline,
70
+ staleTime: 30 * 1000,
71
+ retry: false,
72
+ networkMode: "offlineFirst",
69
73
  placeholderData: cached ?? undefined,
70
- select: (data) => ({
71
- role: data.data.role,
72
- source: data.data.source,
73
- capabilities: data.data.capabilities,
74
- status: data.data.status,
75
- }),
76
74
  });
75
+ // When offline, use the SQLite-cached membership directly so nav stays visible.
76
+ const raw = query.data ?? cached ?? null;
77
+ const selected = raw
78
+ ? {
79
+ role: raw.data.role,
80
+ source: raw.data.source,
81
+ capabilities: raw.data.capabilities,
82
+ status: raw.data.status,
83
+ }
84
+ : undefined;
85
+ return {
86
+ ...query,
87
+ data: selected,
88
+ };
77
89
  }
78
90
  export function useHasCapabilityFor(businessId, capability) {
79
91
  const { data } = useBusinessCapabilities(businessId);
@@ -93,6 +105,9 @@ export const CAPABILITY_BY_COMMAND = {
93
105
  "menu:update_item": "menu_manage",
94
106
  "menu:delete_item": "menu_manage",
95
107
  "menu:create_category": "menu_manage",
108
+ "menu:update_category": "menu_manage",
109
+ "menu:delete_category": "menu_manage",
110
+ "payment:confirm_cash": "orders_accept",
96
111
  };
97
112
  export async function hasCapabilitySync(businessId, commandType) {
98
113
  const required = CAPABILITY_BY_COMMAND[commandType];
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Reference-data React hooks — single source for both apps.
3
+ * Sync hooks use core connectivity/business adapters; cache reads are pure
4
+ * SQLite (never network). Image-query invalidation uses the shared
5
+ * ["cachedImage"] prefix both apps use.
6
+ */
7
+ import type { ReferenceDataKey, ReferenceSyncOptions, ReferenceSyncResult } from "./types";
8
+ export declare const REFERENCE_KEYS: {
9
+ all: readonly ["reference"];
10
+ sync: readonly ["reference", "sync"];
11
+ data: (key: ReferenceDataKey) => readonly ["reference", "data", ReferenceDataKey];
12
+ };
13
+ /**
14
+ * Trigger reference data synchronization when the active business changes.
15
+ * Does not return data; fires a background sync. Set `enabled` to false to
16
+ * disable automatic sync.
17
+ */
18
+ export declare function useReferenceDataSync(businessId: string | undefined, options?: Omit<ReferenceSyncOptions, "businessId"> & {
19
+ enabled?: boolean;
20
+ }): void;
21
+ /** Manually trigger reference data sync. */
22
+ export declare function useSyncReferenceData(): (options: ReferenceSyncOptions) => Promise<ReferenceSyncResult>;
23
+ /**
24
+ * App-level reference sync: downloads every reference-backed dataset into
25
+ * the SQLite cache so the business operates fully offline. Call once from
26
+ * the app root/layouts. Strict reachable gate — `unknown` (cold start)
27
+ * must not fire sync, otherwise airplane launch hangs on versions fetch.
28
+ */
29
+ export declare function useBusinessReferenceDataSync(): void;
30
+ /**
31
+ * Read a reference data record from the local SQLite cache for the active
32
+ * business. Never hits the network.
33
+ */
34
+ export declare function useReferenceData<T>(key: ReferenceDataKey): import("@tanstack/react-query").UseQueryResult<import("./types").ReferenceDataRecord<T> | null, Error>;