@teincfood/core 0.7.8 → 0.7.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.
@@ -11,3 +11,5 @@ export declare const useAuthStore: {
11
11
  user: AuthUser | null;
12
12
  };
13
13
  };
14
+ export declare function setAuthProvider(fn: () => string | null): void;
15
+ export declare function getAuthToken(): string | null;
@@ -8,3 +8,10 @@ export function setAuthUser(u) {
8
8
  export const useAuthStore = {
9
9
  getState: () => ({ user }),
10
10
  };
11
+ let getAccessToken = null;
12
+ export function setAuthProvider(fn) {
13
+ getAccessToken = fn;
14
+ }
15
+ export function getAuthToken() {
16
+ return getAccessToken ? getAccessToken() : null;
17
+ }
@@ -40,6 +40,9 @@ export declare const sellerService: {
40
40
  data: unknown;
41
41
  }>;
42
42
  confirmHandover: (_deliveryId: string) => Promise<unknown>;
43
+ confirmCashPayment: (_orderId: string) => Promise<{
44
+ data: unknown;
45
+ }>;
43
46
  };
44
47
  export declare const kioskService: {
45
48
  assignKioskOrderToRider: (_orderId: string, _riderId: string) => Promise<unknown>;
@@ -49,6 +49,9 @@ export const sellerService = {
49
49
  confirmHandover: async (_deliveryId) => {
50
50
  notImpl("sellerService.confirmHandover");
51
51
  },
52
+ confirmCashPayment: async (_orderId) => {
53
+ notImpl("sellerService.confirmCashPayment");
54
+ },
52
55
  };
53
56
  // kiosk.service
54
57
  export const kioskService = {
@@ -63,6 +63,7 @@ export declare function useAssignRiderMutation(): import("@tanstack/react-query"
63
63
  orderId: string;
64
64
  riderId: string;
65
65
  }, unknown>;
66
+ export declare function useConfirmCashPaymentMutation(): import("@tanstack/react-query").UseMutationResult<SellerOrder, Error, string, unknown>;
66
67
  export declare function useUpdateOrderStatusMutation(): {
67
68
  mutate: (orderId: string, status: string) => Promise<unknown>;
68
69
  isPending: boolean;
@@ -277,9 +277,13 @@ export function useSellerOrderQuery(orderId, enabled = true) {
277
277
  const businessId = useBusinessId();
278
278
  const connectivity = useConnectivity();
279
279
  const offline = isOfflineState(connectivity);
280
+ const pendingOrders = useCorePendingOrders(businessId);
280
281
  return useQuery({
281
282
  queryKey: SELLER_KEYS.order(orderId, businessId),
282
283
  queryFn: async () => {
284
+ const pendingHit = pendingOrders.find((o) => o.id === orderId || o.order_number === orderId);
285
+ if (pendingHit)
286
+ return { data: pendingHit };
283
287
  if (offline) {
284
288
  const list = businessId ? await orderSnapshotsService.getSellerList(businessId, businessId) : null;
285
289
  const found = list?.find((o) => o.id === orderId || o.order_number === orderId);
@@ -295,6 +299,9 @@ export function useSellerOrderQuery(orderId, enabled = true) {
295
299
  const found = list?.find((o) => o.id === orderId || o.order_number === orderId);
296
300
  if (found)
297
301
  return { data: found };
302
+ const pendingAgain = getPendingOrdersSnapshot().orders.find((o) => o.id === orderId || o.order_number === orderId);
303
+ if (pendingAgain)
304
+ return { data: pendingAgain };
298
305
  throw e;
299
306
  }
300
307
  },
@@ -369,6 +376,23 @@ export function useAssignRiderMutation() {
369
376
  },
370
377
  });
371
378
  }
379
+ export function useConfirmCashPaymentMutation() {
380
+ const businessId = useBusinessId();
381
+ const queryClient = useQueryClient();
382
+ return useMutation({
383
+ mutationFn: (orderId) => {
384
+ if (!businessId)
385
+ throw new Error("No active business");
386
+ return orderRepository.confirmCashPayment({ orderId, businessId });
387
+ },
388
+ onSettled: (_d, _e, orderId) => {
389
+ queryClient.invalidateQueries({ queryKey: SELLER_KEYS.all });
390
+ queryClient.invalidateQueries({ queryKey: POS_KEYS.all });
391
+ if (orderId)
392
+ queryClient.invalidateQueries({ queryKey: SELLER_KEYS.order(orderId) });
393
+ },
394
+ });
395
+ }
372
396
  export function useUpdateOrderStatusMutation() {
373
397
  const accept = useAcceptOrderMutation();
374
398
  const reject = useRejectOrderMutation();
package/dist/index.d.ts CHANGED
@@ -69,6 +69,7 @@ export * from "./adapters/http";
69
69
  export * from "./adapters/local-node";
70
70
  export * from "./adapters/services";
71
71
  export * from "./adapters/devices";
72
+ export { setAuthProvider, getAuthToken } from "./adapters/auth";
72
73
  export { imageCacheService } from "./adapters/image-cache";
73
74
  export * from "./adapters/business";
74
75
  export * from "./adapters/pending-orders";
@@ -83,6 +84,10 @@ export * from "./printer/hooks";
83
84
  export * from "./cart/store";
84
85
  export * from "./cart/hooks";
85
86
  export * from "./catalog/hooks";
87
+ export { phoenixSocket } from "./realtime/phoenix-socket";
88
+ export { eventsService } from "./realtime/events.service";
89
+ export { orderEventsService } from "./realtime/order-events.service";
90
+ export { useEventsConnection } from "./realtime/useEventsConnection";
86
91
  export { getCachedImagePath, ensureImageCacheDir, getCachedImageInfo, cacheRemoteImage, cacheImageBatch, clearImageCache, pruneImageCache, resolveImageSource, stableCacheUrl } from "./image-cache/service";
87
92
  import { type SqliteDriver } from "./adapters/sqlite";
88
93
  import { type KVDriver } from "./adapters/kv";
package/dist/index.js CHANGED
@@ -72,6 +72,7 @@ export * from "./adapters/http";
72
72
  export * from "./adapters/local-node";
73
73
  export * from "./adapters/services";
74
74
  export * from "./adapters/devices";
75
+ export { setAuthProvider, getAuthToken } from "./adapters/auth";
75
76
  export { imageCacheService } from "./adapters/image-cache";
76
77
  export * from "./adapters/business";
77
78
  export * from "./adapters/pending-orders";
@@ -90,6 +91,11 @@ export * from "./cart/store";
90
91
  export * from "./cart/hooks";
91
92
  // ── Catalog ──
92
93
  export * from "./catalog/hooks";
94
+ // ── Realtime (single source — PHX WebSocket + business:{id}) ──
95
+ export { phoenixSocket } from "./realtime/phoenix-socket";
96
+ export { eventsService } from "./realtime/events.service";
97
+ export { orderEventsService } from "./realtime/order-events.service";
98
+ export { useEventsConnection } from "./realtime/useEventsConnection";
93
99
  // ── Image cache (durable) ──
94
100
  export { getCachedImagePath, ensureImageCacheDir, getCachedImageInfo, cacheRemoteImage, cacheImageBatch, clearImageCache, pruneImageCache, resolveImageSource, stableCacheUrl } from "./image-cache/service";
95
101
  // ── Init helper ──
@@ -0,0 +1,18 @@
1
+ type EventCallback = (event: Record<string, unknown>) => void;
2
+ declare class EventsService {
3
+ private refCount;
4
+ private topic;
5
+ private joined;
6
+ private lastSequence;
7
+ private activeBusinessId;
8
+ private listeners;
9
+ private wildcardListeners;
10
+ private frameHandlerUnsub;
11
+ connect(userId: string, businessId?: string | null): () => void;
12
+ on(eventType: string, callback: EventCallback): () => void;
13
+ onAny(callback: EventCallback): () => void;
14
+ isConnected(): boolean;
15
+ resetSequence(): void;
16
+ }
17
+ export declare const eventsService: EventsService;
18
+ export {};
@@ -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,240 @@
1
+ import { WS_BASE_URL } from "../utils/constants";
2
+ import { getAuthToken } from "../adapters/auth";
3
+ const RECONNECT_BASE = 1000;
4
+ const RECONNECT_MAX = 10000;
5
+ const HEARTBEAT_MS = 30000;
6
+ class PhoenixSocket {
7
+ constructor() {
8
+ this.socket = null;
9
+ this.refCounter = 0;
10
+ this.reconnectAttempt = 0;
11
+ this.reconnectTimer = null;
12
+ this.heartbeatTimer = null;
13
+ this.heartbeatPend = false;
14
+ this.heartbeatMissed = 0;
15
+ this.intentionalClose = false;
16
+ this.connectGen = 0;
17
+ this.connectPromise = null;
18
+ this.connectionState = "disconnected";
19
+ this.connectionListeners = new Set();
20
+ this.pendingJoins = new Map();
21
+ this.pendingPushes = new Map();
22
+ this.joinedChannels = new Set();
23
+ this.pendingRejoin = new Set();
24
+ this.topicJoinRefs = new Map();
25
+ this.frameHandlers = new Map();
26
+ }
27
+ getRef() { return String(++this.refCounter); }
28
+ buildSocketUrl(token) {
29
+ const base = WS_BASE_URL.replace(/\/+$/, "");
30
+ const path = base.endsWith("/ws") ? "/websocket" : "/ws/websocket";
31
+ return `${base}${path}?token=${encodeURIComponent(token)}&vsn=2.0.0`;
32
+ }
33
+ setState(s) {
34
+ this.connectionState = s;
35
+ this.connectionListeners.forEach((fn) => fn(s));
36
+ }
37
+ onConnectionStateChange(fn) {
38
+ this.connectionListeners.add(fn);
39
+ return () => this.connectionListeners.delete(fn);
40
+ }
41
+ getConnectionState() { return this.connectionState; }
42
+ connect() {
43
+ if (this.socket?.readyState === WebSocket.OPEN)
44
+ return Promise.resolve();
45
+ if (this.connectPromise)
46
+ return this.connectPromise;
47
+ this.cancelReconnect();
48
+ this.intentionalClose = false;
49
+ this.setState("connecting");
50
+ this.connectPromise = new Promise((resolve, reject) => {
51
+ const token = getAuthToken();
52
+ if (!token) {
53
+ this.setState("disconnected");
54
+ this.connectPromise = null;
55
+ reject(new Error("Not authenticated"));
56
+ return;
57
+ }
58
+ const myGen = ++this.connectGen;
59
+ const url = this.buildSocketUrl(token);
60
+ this.socket = new WebSocket(url);
61
+ this.socket.onopen = () => {
62
+ if (myGen !== this.connectGen)
63
+ return;
64
+ this.reconnectAttempt = 0;
65
+ this.setState("connected");
66
+ this.startHeartbeat();
67
+ this.rejoinPending().catch(() => { });
68
+ this.connectPromise = null;
69
+ resolve();
70
+ };
71
+ this.socket.onerror = () => { if (myGen !== this.connectGen)
72
+ return; if (this.connectionState === "connecting") {
73
+ this.setState("disconnected");
74
+ this.connectPromise = null;
75
+ } };
76
+ this.socket.onmessage = (ev) => { try {
77
+ const frame = JSON.parse(ev.data);
78
+ this.handleFrame(frame);
79
+ }
80
+ catch { } };
81
+ this.socket.onclose = () => {
82
+ if (myGen !== this.connectGen)
83
+ return;
84
+ this.stopHeartbeat();
85
+ if (!this.intentionalClose) {
86
+ this.joinedChannels.forEach((t) => this.pendingRejoin.add(t));
87
+ this.joinedChannels.clear();
88
+ this.scheduleReconnect();
89
+ }
90
+ else {
91
+ this.joinedChannels.clear();
92
+ this.pendingRejoin.clear();
93
+ this.setState("disconnected");
94
+ }
95
+ this.connectPromise = null;
96
+ };
97
+ });
98
+ return this.connectPromise;
99
+ }
100
+ disconnect() {
101
+ this.intentionalClose = true;
102
+ this.cancelReconnect();
103
+ this.stopHeartbeat();
104
+ this.joinedChannels.clear();
105
+ this.pendingRejoin.clear();
106
+ this.pendingJoins.clear();
107
+ this.pendingPushes.clear();
108
+ this.topicJoinRefs.clear();
109
+ this.frameHandlers.clear();
110
+ if (this.socket) {
111
+ this.socket.close();
112
+ this.socket = null;
113
+ }
114
+ this.setState("disconnected");
115
+ this.connectPromise = null;
116
+ }
117
+ retryConnection() { this.cancelReconnect(); this.reconnectAttempt = 0; this.connect().catch(() => { }); }
118
+ scheduleReconnect() {
119
+ if (this.intentionalClose)
120
+ return;
121
+ this.setState("reconnecting");
122
+ const delay = Math.min(RECONNECT_BASE * Math.pow(2, this.reconnectAttempt), RECONNECT_MAX);
123
+ this.reconnectAttempt++;
124
+ this.reconnectTimer = setTimeout(() => this.connect().catch(() => { }), delay);
125
+ }
126
+ cancelReconnect() { if (this.reconnectTimer) {
127
+ clearTimeout(this.reconnectTimer);
128
+ this.reconnectTimer = null;
129
+ } }
130
+ startHeartbeat() {
131
+ this.heartbeatPend = false;
132
+ this.heartbeatMissed = 0;
133
+ this.heartbeatTimer = setInterval(() => {
134
+ if (this.heartbeatPend) {
135
+ this.heartbeatMissed++;
136
+ if (this.heartbeatMissed >= PhoenixSocket.MAX_MISSED)
137
+ this.socket?.close();
138
+ return;
139
+ }
140
+ this.heartbeatPend = true;
141
+ this.sendRaw({ topic: "phoenix", event: "heartbeat", payload: {} });
142
+ }, HEARTBEAT_MS);
143
+ }
144
+ stopHeartbeat() { if (this.heartbeatTimer) {
145
+ clearInterval(this.heartbeatTimer);
146
+ this.heartbeatTimer = null;
147
+ } this.heartbeatPend = false; this.heartbeatMissed = 0; }
148
+ sendRaw(msg) {
149
+ if (this.socket?.readyState === WebSocket.OPEN) {
150
+ const joinRef = msg.topic && msg.topic !== "phoenix" ? (msg.joinRef ?? this.topicJoinRefs.get(msg.topic) ?? null) : (msg.joinRef ?? null);
151
+ const frame = [joinRef, msg.ref ?? null, msg.topic, msg.event, msg.payload];
152
+ this.socket.send(JSON.stringify(frame));
153
+ }
154
+ }
155
+ push(topic, event, payload) { const ref = this.getRef(); this.sendRaw({ topic, event, payload, ref, joinRef: this.topicJoinRefs.get(topic) }); }
156
+ pushWithReply(topic, event, payload) {
157
+ const ref = this.getRef();
158
+ const msg = { topic, event, payload, ref, joinRef: this.topicJoinRefs.get(topic) };
159
+ return new Promise((resolve, reject) => {
160
+ this.pendingPushes.set(ref, { resolve, reject });
161
+ this.sendRaw(msg);
162
+ setTimeout(() => { if (this.pendingPushes.has(ref)) {
163
+ this.pendingPushes.delete(ref);
164
+ reject(new Error(`Request timed out: ${event} on ${topic}`));
165
+ } }, 5000);
166
+ });
167
+ }
168
+ handleFrame([joinRef, ref, topic, event, payload]) {
169
+ if (event === "phx_reply" && topic === "phoenix") {
170
+ this.heartbeatPend = false;
171
+ this.heartbeatMissed = 0;
172
+ return;
173
+ }
174
+ if (event === "phx_reply" && ref) {
175
+ const pending = this.pendingPushes.get(ref);
176
+ if (pending) {
177
+ const status = payload?.status;
178
+ const response = (payload?.response ?? {});
179
+ if (status === "ok")
180
+ pending.resolve(response);
181
+ else
182
+ pending.reject(new Error(String(response.reason ?? "Request failed")));
183
+ this.pendingPushes.delete(ref);
184
+ return;
185
+ }
186
+ }
187
+ if (event === "phx_reply" && payload?.status === "ok") {
188
+ const pending = this.pendingJoins.get(topic);
189
+ if (pending) {
190
+ const response = (payload?.response ?? {});
191
+ pending.resolve(response);
192
+ this.pendingJoins.delete(topic);
193
+ this.joinedChannels.add(topic);
194
+ if (joinRef)
195
+ this.topicJoinRefs.set(topic, joinRef);
196
+ }
197
+ return;
198
+ }
199
+ if (event === "phx_reply" && payload?.status === "error") {
200
+ const pending = this.pendingJoins.get(topic);
201
+ if (pending) {
202
+ const response = (payload?.response ?? {});
203
+ pending.reject(new Error(String(response.reason ?? "Join failed")));
204
+ this.pendingJoins.delete(topic);
205
+ }
206
+ return;
207
+ }
208
+ const handlers = this.frameHandlers.get(topic);
209
+ if (handlers)
210
+ handlers.forEach((cb) => cb([joinRef, ref, topic, event, payload]));
211
+ }
212
+ onFrame(topic, handler) {
213
+ if (!this.frameHandlers.has(topic))
214
+ this.frameHandlers.set(topic, new Set());
215
+ this.frameHandlers.get(topic).add(handler);
216
+ return () => { this.frameHandlers.get(topic)?.delete(handler); };
217
+ }
218
+ async joinChannel(topic, payload = {}) {
219
+ if (this.joinedChannels.has(topic))
220
+ return Promise.resolve({});
221
+ if (this.pendingJoins.has(topic))
222
+ return new Promise((resolve, reject) => { this.pendingJoins.set(topic, { resolve, reject }); });
223
+ const ref = this.getRef();
224
+ const msg = { topic, event: "phx_join", payload, ref };
225
+ return new Promise((resolve, reject) => {
226
+ this.pendingJoins.set(topic, { resolve, reject });
227
+ this.sendRaw(msg);
228
+ setTimeout(() => { if (this.pendingJoins.has(topic)) {
229
+ this.pendingJoins.delete(topic);
230
+ reject(new Error(`Failed to join channel ${topic} - timeout`));
231
+ } }, 5000);
232
+ });
233
+ }
234
+ leaveChannel(topic) { this.joinedChannels.delete(topic); this.pendingRejoin.delete(topic); this.topicJoinRefs.delete(topic); }
235
+ async rejoinPending() { const topics = Array.from(this.pendingRejoin); this.pendingRejoin.clear(); await Promise.allSettled(topics.map((t) => this.joinChannel(t))); }
236
+ isConnected() { return this.socket?.readyState === WebSocket.OPEN; }
237
+ hasJoined(topic) { return this.joinedChannels.has(topic); }
238
+ }
239
+ PhoenixSocket.MAX_MISSED = 3;
240
+ export const phoenixSocket = new PhoenixSocket();
@@ -0,0 +1 @@
1
+ export declare function useEventsConnection(): void;
@@ -0,0 +1,41 @@
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
+ function useBusinessId() {
7
+ return useSyncExternalStore(subscribeBusiness, () => getActiveBusiness()?.id ?? null, () => null);
8
+ }
9
+ function useIsAuthenticated() {
10
+ // poll auth token presence; host should setAuthProvider — if no provider, assume authenticated when token exists
11
+ return !!getAuthToken();
12
+ }
13
+ export function useEventsConnection() {
14
+ const activeBusinessId = useBusinessId();
15
+ // derive userId from auth token? Host must provide via setAuthProvider that encodes user, but we need userId for topic.
16
+ // For now, read from global if host set it via setAuthUserId adapter. Fallback: token payload decode.
17
+ // Minimal: require host to call eventsService.connect with userId directly via wrapper — this hook is convenience that reads business only.
18
+ // To keep zero-config, we attempt to read from window localStorage token decode — fallback no-op.
19
+ const userId = deriveUserId();
20
+ useEffect(() => {
21
+ if (!userId)
22
+ return;
23
+ const disconnect = eventsService.connect(userId, activeBusinessId);
24
+ return () => disconnect();
25
+ }, [userId, activeBusinessId]);
26
+ }
27
+ function deriveUserId() {
28
+ try {
29
+ const token = getAuthToken();
30
+ if (!token)
31
+ return null;
32
+ const parts = token.split(".");
33
+ if (parts.length < 2)
34
+ return null;
35
+ const payload = JSON.parse(atob(parts[1]));
36
+ return payload.sub || payload.user_id || payload.id || null;
37
+ }
38
+ catch {
39
+ return null;
40
+ }
41
+ }
@@ -93,6 +93,7 @@ export const CAPABILITY_BY_COMMAND = {
93
93
  "menu:update_item": "menu_manage",
94
94
  "menu:delete_item": "menu_manage",
95
95
  "menu:create_category": "menu_manage",
96
+ "payment:confirm_cash": "orders_accept",
96
97
  };
97
98
  export async function hasCapabilitySync(businessId, commandType) {
98
99
  const required = CAPABILITY_BY_COMMAND[commandType];
@@ -41,4 +41,5 @@ export declare const orderRepository: {
41
41
  assignRider(input: AssignRiderInput & {
42
42
  businessId: string;
43
43
  }): Promise<unknown>;
44
+ confirmCashPayment(input: OrderActionInput): Promise<SellerOrder>;
44
45
  };
@@ -126,4 +126,13 @@ export const orderRepository = {
126
126
  }
127
127
  return event.payload;
128
128
  },
129
+ async confirmCashPayment(input) {
130
+ const command = buildCommand({
131
+ commandType: "payment:confirm_cash",
132
+ businessId: input.businessId,
133
+ entityId: input.orderId,
134
+ payload: {},
135
+ });
136
+ return executeCommand(command);
137
+ },
129
138
  };
@@ -25,6 +25,10 @@ const QUEUED_STATUS_BY_COMMAND = {
25
25
  "delivery:assign": "assigned",
26
26
  "delivery:confirm_handover": "delivered",
27
27
  };
28
+ const PAYMENT_QUEUED_PAYLOAD = {
29
+ is_paid: true,
30
+ to_is_paid: true,
31
+ };
28
32
  export function getQueuedStatus(commandType) {
29
33
  return QUEUED_STATUS_BY_COMMAND[commandType] ?? null;
30
34
  }
@@ -42,6 +46,24 @@ function buildOptimisticEvent(command) {
42
46
  timestamp: new Date().toISOString(),
43
47
  };
44
48
  }
49
+ if (command.command_type === "payment:confirm_cash") {
50
+ return {
51
+ event_id: generateEventId(),
52
+ event_type: "payment.completed_queued",
53
+ event_version: "1.0",
54
+ entity_id: command.entity_id ?? command.command_id,
55
+ user_id: command.user_id,
56
+ device_id: command.device_id,
57
+ payload: {
58
+ ...payload,
59
+ command_id: command.command_id,
60
+ is_paid: true,
61
+ paid_at: new Date().toISOString(),
62
+ order_id: command.entity_id,
63
+ },
64
+ timestamp: new Date().toISOString(),
65
+ };
66
+ }
45
67
  const queuedStatus = getQueuedStatus(command.command_type);
46
68
  return {
47
69
  event_id: generateEventId(),
@@ -58,11 +80,13 @@ function buildOptimisticEvent(command) {
58
80
  to_status: queuedStatus,
59
81
  order_id: command.entity_id,
60
82
  delivery_id: command.entity_id,
83
+ ...(queuedStatus ? {} : {}),
61
84
  },
62
85
  timestamp: new Date().toISOString(),
63
86
  };
64
87
  }
65
88
  function buildOptimisticOrder(command, payload) {
89
+ const snapshots = payload.item_snapshots ?? null;
66
90
  const items = payload.items ?? [];
67
91
  const totalMinor = payload.total_minor ?? 0;
68
92
  const subtotalMinor = payload.subtotal_minor ?? totalMinor;
@@ -109,14 +133,22 @@ function buildOptimisticOrder(command, payload) {
109
133
  delivery_lat: null,
110
134
  delivery_lng: null,
111
135
  user: null,
112
- items: items.map((item, index) => ({
136
+ items: (snapshots ? snapshots.map((s, index) => ({
137
+ id: `tmp-${command.command_id}-${index}`,
138
+ menu_item: { id: s.menu_item_id, name: s.name },
139
+ quantity: s.quantity,
140
+ unit_price: s.unit_price_minor,
141
+ subtotal: s.unit_price_minor * s.quantity,
142
+ variant_name: s.variant_name,
143
+ addons: (s.addons ?? []).map((a) => ({ name: a.name, price_minor: a.price_minor })),
144
+ })) : items.map((item, index) => ({
113
145
  id: `tmp-${command.command_id}-${index}`,
114
146
  menu_item: { id: item.menu_item_id, name: "" },
115
147
  quantity: item.quantity,
116
148
  unit_price: 0,
117
149
  subtotal: 0,
118
150
  addons: [],
119
- })),
151
+ }))),
120
152
  inserted_at: now,
121
153
  };
122
154
  }
@@ -29,6 +29,7 @@ declare class OrderSnapshotsService {
29
29
  */
30
30
  applyQueuedStatusEvent(event: DomainEvent, origin?: "local" | "lan" | "cloud"): Promise<void>;
31
31
  private applyOrderStatusToReadModelInternal;
32
+ applyPaymentEvent(event: DomainEvent, origin?: "local" | "lan" | "cloud"): Promise<void>;
32
33
  /**
33
34
  * Patch the latest status of a known order inside every list snapshot for the
34
35
  * business, so offline devices see live status changes without a refetch.
@@ -97,6 +97,36 @@ class OrderSnapshotsService {
97
97
  });
98
98
  }
99
99
  }
100
+ async applyPaymentEvent(event, origin = "local") {
101
+ const payload = event.payload;
102
+ const orderId = payload?.order_id ?? event.entity_id;
103
+ if (!orderId)
104
+ return;
105
+ const paidAt = payload?.paid_at || new Date().toISOString();
106
+ try {
107
+ const db = await openSyncDatabase();
108
+ await insertEvent(db, { event, origin });
109
+ const lists = await getEntitySnapshotsByType(db, "seller_orders");
110
+ for (const list of lists) {
111
+ const data = list.payload.data ?? [];
112
+ const idx = data.findIndex((o) => o.id === orderId || o.order_number === payload?.order_number);
113
+ if (idx === -1)
114
+ continue;
115
+ const updated = data.map((o, i) => i === idx ? { ...o, is_paid: true, paid_at: paidAt, payment: { ...o.payment, is_paid: true, paid_at: paidAt } } : o);
116
+ await upsertEntitySnapshot(db, {
117
+ entityType: "seller_orders",
118
+ entityId: list.entity_id,
119
+ businessId: list.business_id,
120
+ lastEventId: event.event_id,
121
+ lastEventTimestamp: event.timestamp,
122
+ payload: { ...list.payload, data: updated },
123
+ });
124
+ }
125
+ }
126
+ catch (error) {
127
+ syncLogger.error("OrderSnapshots", "Failed to apply payment", { error: String(error) });
128
+ }
129
+ }
100
130
  /**
101
131
  * Patch the latest status of a known order inside every list snapshot for the
102
132
  * business, so offline devices see live status changes without a refetch.
@@ -36,6 +36,7 @@ const QUEUED_STATUS_EVENT_TYPES = new Set([
36
36
  "delivery.assign_queued",
37
37
  "delivery.confirm_handover_queued",
38
38
  ]);
39
+ const PAYMENT_QUEUED_TYPES = new Set(["payment.completed_queued"]);
39
40
  let invalidator = null;
40
41
  /**
41
42
  * Register a callback used to invalidate TanStack Query caches when a pending
@@ -99,8 +100,12 @@ export async function hydratePendingOrders() {
99
100
  export async function applyOrderStateChangeEvent(event, origin = "local") {
100
101
  try {
101
102
  const isQueued = QUEUED_STATUS_EVENT_TYPES.has(event.event_type);
102
- if (!ORDER_STATE_EVENT_TYPES.has(event.event_type) && !isQueued)
103
- return;
103
+ const isPaymentQueued = PAYMENT_QUEUED_TYPES.has(event.event_type);
104
+ if (!ORDER_STATE_EVENT_TYPES.has(event.event_type) && !isQueued && !isPaymentQueued) {
105
+ // Also handle cloud payment.completed for is_paid patch
106
+ if (event.event_type !== "payment.completed")
107
+ return;
108
+ }
104
109
  syncLogger.debug("OrderState", "Order state changed", {
105
110
  origin,
106
111
  event_type: event.event_type,
@@ -111,6 +116,19 @@ export async function applyOrderStateChangeEvent(event, origin = "local") {
111
116
  // Queued transitions carry to_status/order_id so they patch instantly offline.
112
117
  if (isQueued) {
113
118
  await orderSnapshotsService.applyQueuedStatusEvent(event, origin);
119
+ patchPendingStatus(event);
120
+ invalidator?.();
121
+ return;
122
+ }
123
+ if (isPaymentQueued) {
124
+ await orderSnapshotsService.applyPaymentEvent(event, origin);
125
+ patchPendingPaid(event);
126
+ invalidator?.();
127
+ return;
128
+ }
129
+ if (event.event_type === "payment.completed") {
130
+ await orderSnapshotsService.applyPaymentEvent(event, origin);
131
+ patchPendingPaid(event);
114
132
  invalidator?.();
115
133
  return;
116
134
  }
@@ -124,6 +142,46 @@ export async function applyOrderStateChangeEvent(event, origin = "local") {
124
142
  });
125
143
  }
126
144
  }
145
+ function patchPendingStatus(event) {
146
+ try {
147
+ const payload = event.payload;
148
+ const toStatus = payload.to_status || payload.status;
149
+ const orderId = payload.order_id || event.entity_id;
150
+ if (!toStatus || !orderId)
151
+ return;
152
+ const store = usePendingOrdersStore.getState();
153
+ const target = store.orders.find((o) => o.id === orderId || o.order_number === orderId);
154
+ if (!target)
155
+ return;
156
+ const patched = { ...target, status: toStatus };
157
+ store.addOrder(patched);
158
+ }
159
+ catch { }
160
+ }
161
+ function patchPendingPaid(event) {
162
+ try {
163
+ const payload = event.payload;
164
+ const orderId = payload.order_id || event.entity_id;
165
+ if (!orderId)
166
+ return;
167
+ const now = new Date().toISOString();
168
+ const store = usePendingOrdersStore.getState();
169
+ const target = store.orders.find((o) => o.id === orderId || o.order_number === payload.order_number || o.id === event.entity_id);
170
+ const altTarget = !target ? store.orders.find((o) => o.id === orderId) : null;
171
+ const t = target ?? altTarget;
172
+ if (!t)
173
+ return;
174
+ const paidAt = payload.paid_at || now;
175
+ const patched = {
176
+ ...t,
177
+ is_paid: true,
178
+ paid_at: paidAt,
179
+ payment: { ...t.payment, is_paid: true, paid_at: paidAt },
180
+ };
181
+ store.addOrder(patched);
182
+ }
183
+ catch { }
184
+ }
127
185
  export async function applyPendingOrderEvent(event, origin = "local") {
128
186
  try {
129
187
  if (event.event_type === PENDING_EVENT_TYPE) {
@@ -144,6 +202,27 @@ export async function applyPendingOrderEvent(event, origin = "local") {
144
202
  if (QUEUED_STATUS_EVENT_TYPES.has(event.event_type)) {
145
203
  // Queued transitions: patch the read model so listing reflects new status offline.
146
204
  await orderSnapshotsService.applyQueuedStatusEvent(event, origin);
205
+ // Also patch pending store so isPending overlay shows new status immediately.
206
+ try {
207
+ const payload = event.payload;
208
+ const toStatus = payload.to_status || payload.status;
209
+ const orderId = payload.order_id || event.entity_id;
210
+ if (toStatus && orderId) {
211
+ const store = usePendingOrdersStore.getState();
212
+ const target = store.orders.find((o) => o.id === orderId || o.order_number === orderId);
213
+ if (target)
214
+ store.addOrder({ ...target, status: toStatus });
215
+ }
216
+ }
217
+ catch { }
218
+ const db = await openSyncDatabase();
219
+ await insertEvent(db, { event, origin });
220
+ invalidator?.();
221
+ return;
222
+ }
223
+ if (PAYMENT_QUEUED_TYPES.has(event.event_type) || event.event_type === "payment.completed") {
224
+ await orderSnapshotsService.applyPaymentEvent(event, origin);
225
+ patchPendingPaid(event);
147
226
  const db = await openSyncDatabase();
148
227
  await insertEvent(db, { event, origin });
149
228
  invalidator?.();
@@ -50,11 +50,12 @@ export class RestTransport {
50
50
  case "order:create": {
51
51
  const posPayload = {
52
52
  ...payload,
53
+ id: command.command_id,
53
54
  command_id: command.command_id,
54
55
  local_order_number: payload.order_number,
55
56
  };
56
57
  const response = await posService.createPOSOrder(business_id, posPayload);
57
- return this.wrapResponse("order.created", response.data, entity_id, timestamp, command);
58
+ return this.wrapResponse("order.created", response.data, entity_id ?? command.command_id, timestamp, command);
58
59
  }
59
60
  case "order:accept": {
60
61
  const response = await sellerService.acceptOrder(entity_id);
@@ -89,8 +90,9 @@ export class RestTransport {
89
90
  return this.wrapResponse("delivery.status_changed", response, entity_id, timestamp, command);
90
91
  }
91
92
  case "menu:create_item": {
92
- const response = await menuService.createMenuItem(business_id, payload);
93
- return this.wrapResponse("menu.item_created", response, entity_id, timestamp, command);
93
+ const createPayload = { ...payload, id: payload.id ?? command.command_id, command_id: command.command_id };
94
+ const response = await menuService.createMenuItem(business_id, createPayload);
95
+ return this.wrapResponse("menu.item_created", response, command.command_id, timestamp, command);
94
96
  }
95
97
  case "menu:update_item": {
96
98
  const response = await menuService.updateMenuItem(entity_id, payload);
@@ -101,8 +103,13 @@ export class RestTransport {
101
103
  return this.wrapResponse("menu.item_deleted", response, entity_id, timestamp, command);
102
104
  }
103
105
  case "menu:create_category": {
104
- const response = await menuService.createCategory(business_id, payload);
105
- return this.wrapResponse("menu.category_created", response, entity_id, timestamp, command);
106
+ const createPayload = { ...payload, id: payload.id ?? command.command_id, command_id: command.command_id };
107
+ const response = await menuService.createCategory(business_id, createPayload);
108
+ return this.wrapResponse("menu.category_created", response, command.command_id, timestamp, command);
109
+ }
110
+ case "payment:confirm_cash": {
111
+ const response = await sellerService.confirmCashPayment(entity_id);
112
+ return this.wrapResponse("payment.completed", response, entity_id, timestamp, command);
106
113
  }
107
114
  default:
108
115
  throw new Error(`RestTransport: unsupported command type ${command_type}`);
@@ -10,6 +10,7 @@
10
10
  import { useEffect } from "react";
11
11
  import { useQueryClient } from "@tanstack/react-query";
12
12
  import { syncEngine } from "./engine";
13
+ import { getActiveBusiness } from "../adapters/business";
13
14
  import { applyPendingOrderEvent, applyOrderStateChangeEvent, registerPendingOrdersInvalidator, } from "./pending-orders.service";
14
15
  import { localNodeService } from "../adapters/local-node";
15
16
  import { getSyncDatabase } from "../db/connection";
@@ -24,16 +25,42 @@ function isLocalOrderEvent(event) {
24
25
  const localOrderNumber = payload.local_order_number;
25
26
  return typeof localOrderNumber === "string" && /^[0-9]{2}-[0-9]{3}-[0-9]{3}$/.test(localOrderNumber);
26
27
  }
28
+ const RECONCILE_DEBOUNCE_MS = 400;
27
29
  export function useSyncReconciliation() {
28
30
  const queryClient = useQueryClient();
29
31
  useEffect(() => {
32
+ let timer;
33
+ const pending = new Set();
34
+ const flush = () => {
35
+ timer = undefined;
36
+ if (pending.has("reference")) {
37
+ queryClient.invalidateQueries({ queryKey: MENU_KEYS.all });
38
+ queryClient.invalidateQueries({ queryKey: REFERENCE_KEYS.all });
39
+ queryClient.invalidateQueries({ queryKey: POS_KEYS.all });
40
+ queryClient.invalidateQueries({ queryKey: SELLER_KEYS.all });
41
+ }
42
+ if (pending.has("pending")) {
43
+ queryClient.invalidateQueries({ queryKey: POS_KEYS.all });
44
+ queryClient.invalidateQueries({ queryKey: SELLER_KEYS.all });
45
+ queryClient.invalidateQueries({ queryKey: KIOSK_KEYS.all });
46
+ queryClient.invalidateQueries({ queryKey: SELLER_ORDERS_KEYS.all });
47
+ queryClient.invalidateQueries({ queryKey: BUSINESS_DELIVERIES_KEYS.all });
48
+ }
49
+ if (pending.has("order")) {
50
+ // order.created with local number already covered by pending, but keep fine-grained
51
+ // POS.orders(businessId) is also covered by pending's broad POS.all, no extra needed.
52
+ }
53
+ pending.clear();
54
+ };
55
+ const schedule = (kind) => {
56
+ pending.add(kind);
57
+ if (!timer)
58
+ timer = setTimeout(flush, RECONCILE_DEBOUNCE_MS);
59
+ };
30
60
  // Reference changes re-sync offline data and refresh caches on this device
31
61
  // and (via the LAN relay) on every connected terminal.
32
62
  const unregisterReferenceInvalidator = registerReferenceInvalidator(() => {
33
- queryClient.invalidateQueries({ queryKey: MENU_KEYS.all });
34
- queryClient.invalidateQueries({ queryKey: REFERENCE_KEYS.all });
35
- queryClient.invalidateQueries({ queryKey: POS_KEYS.all });
36
- queryClient.invalidateQueries({ queryKey: SELLER_KEYS.all });
63
+ schedule("reference");
37
64
  });
38
65
  // Pending-order reconciliation invalidates POS + seller order lists so the
39
66
  // server-confirmed order replaces the optimistic one. Invalidate from the
@@ -42,17 +69,21 @@ export function useSyncReconciliation() {
42
69
  // keys. Also cover kiosk/seller-orders/business-deliveries so boards
43
70
  // refresh even when there is no Local Node (offline queue + Phoenix).
44
71
  const unregisterInvalidator = registerPendingOrdersInvalidator(() => {
45
- queryClient.invalidateQueries({ queryKey: POS_KEYS.all });
46
- queryClient.invalidateQueries({ queryKey: SELLER_KEYS.all });
47
- queryClient.invalidateQueries({ queryKey: KIOSK_KEYS.all });
48
- queryClient.invalidateQueries({ queryKey: SELLER_ORDERS_KEYS.all });
49
- queryClient.invalidateQueries({ queryKey: BUSINESS_DELIVERIES_KEYS.all });
72
+ schedule("pending");
50
73
  });
51
74
  // Reference-change events are published through the Sync Engine but are
52
75
  // not backed by a command, so the engine has no transport dependency on
53
76
  // the reference module. Register the singleton once.
54
77
  registerReferenceEventEngine(syncEngine);
55
78
  const unsubscribe = syncEngine.subscribe(async (event) => {
79
+ // LAN strict: ignore events for other business on same subnet
80
+ try {
81
+ const activeId = getActiveBusiness()?.id;
82
+ const payloadBid = event.payload?.business_id;
83
+ if (activeId && payloadBid && payloadBid !== activeId)
84
+ return;
85
+ }
86
+ catch { }
56
87
  syncLogger.event(event, "received");
57
88
  // The node relays engine events (e.g. order.created when a queued
58
89
  // command is replayed) to LAN clients so they reconcile their pending
@@ -90,15 +121,12 @@ export function useSyncReconciliation() {
90
121
  error: String(error),
91
122
  });
92
123
  }
93
- const payload = event.payload;
94
- const businessId = payload.business_id;
95
- if (businessId) {
96
- queryClient.invalidateQueries({ queryKey: POS_KEYS.orders(businessId) });
97
- queryClient.invalidateQueries({ queryKey: SELLER_KEYS.all });
98
- }
124
+ schedule("pending");
99
125
  }
100
126
  });
101
127
  return () => {
128
+ if (timer)
129
+ clearTimeout(timer);
102
130
  unsubscribe();
103
131
  unregisterInvalidator();
104
132
  unregisterReferenceInvalidator();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@teincfood/core",
3
- "version": "0.7.8",
3
+ "version": "0.7.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",