Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ae5c750d34 | |||
| f985deb233 |
@@ -1,61 +1,76 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { WifiOff, CloudUpload, RefreshCw } from "lucide-react";
|
import { WifiOff, CloudUpload, RefreshCw, AlertTriangle } from "lucide-react";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useLocale } from "next-intl";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useSyncQueueStore } from "@/lib/stores/sync-queue.store";
|
import { useSyncQueueStore } from "@/lib/stores/sync-queue.store";
|
||||||
import { useLocale } from "next-intl";
|
import { getQueueCount } from "@/lib/offline/offline-db";
|
||||||
import {
|
import {
|
||||||
getAllQueueItems,
|
drainOutbox,
|
||||||
getQueueCount,
|
getActiveOutboxCount,
|
||||||
removeQueueItem,
|
getFailedOutboxCount,
|
||||||
markQueueItemFailed,
|
discardFailedOps,
|
||||||
} from "@/lib/offline/offline-db";
|
} from "@/lib/offline/outbox";
|
||||||
import { apiPost } from "@/lib/api/client";
|
|
||||||
|
|
||||||
/** Manual retry — fires one sync pass immediately (used as onClick). */
|
|
||||||
async function runManualSync(
|
|
||||||
setSyncing: (v: boolean) => void,
|
|
||||||
setQueueCount: (n: number) => void
|
|
||||||
) {
|
|
||||||
if (!navigator.onLine) return;
|
|
||||||
setSyncing(true);
|
|
||||||
try {
|
|
||||||
const items = await getAllQueueItems();
|
|
||||||
for (const item of items) {
|
|
||||||
try {
|
|
||||||
if (item.type === "create_order") {
|
|
||||||
const { cafeId, body } = item.payload as { cafeId: string; body: unknown };
|
|
||||||
await apiPost(`/api/cafes/${cafeId}/orders`, body as Record<string, unknown>);
|
|
||||||
} else if (item.type === "add_items") {
|
|
||||||
const { cafeId, orderId, body } = item.payload as {
|
|
||||||
cafeId: string;
|
|
||||||
orderId: string;
|
|
||||||
body: unknown;
|
|
||||||
};
|
|
||||||
await apiPost(
|
|
||||||
`/api/cafes/${cafeId}/orders/${orderId}/items`,
|
|
||||||
body as Record<string, unknown>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
await removeQueueItem(item.id);
|
|
||||||
} catch {
|
|
||||||
await markQueueItemFailed(item.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setSyncing(false);
|
|
||||||
setQueueCount(await getQueueCount());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SyncStatusIndicator() {
|
export function SyncStatusIndicator() {
|
||||||
const { queueCount, isSyncing, isOnline, setSyncing, setQueueCount } =
|
const {
|
||||||
useSyncQueueStore();
|
queueCount,
|
||||||
|
failedCount,
|
||||||
|
isSyncing,
|
||||||
|
isOnline,
|
||||||
|
setSyncing,
|
||||||
|
setQueueCount,
|
||||||
|
setFailedCount,
|
||||||
|
} = useSyncQueueStore();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
const isFa = locale !== "en";
|
const isFa = locale !== "en";
|
||||||
|
|
||||||
const show = !isOnline || queueCount > 0 || isSyncing;
|
const recount = async () => {
|
||||||
if (!show) return null;
|
setQueueCount((await getActiveOutboxCount()) + (await getQueueCount()));
|
||||||
|
setFailedCount(await getFailedOutboxCount());
|
||||||
|
};
|
||||||
|
|
||||||
|
// Manual retry — drains the REAL outbox (the engine the app actually uses),
|
||||||
|
// then refreshes server data and the counts.
|
||||||
|
const retry = async () => {
|
||||||
|
if (typeof navigator !== "undefined" && !navigator.onLine) return;
|
||||||
|
if (isSyncing) return;
|
||||||
|
setSyncing(true);
|
||||||
|
try {
|
||||||
|
const res = await drainOutbox();
|
||||||
|
if (res.sent > 0) await queryClient.invalidateQueries();
|
||||||
|
} finally {
|
||||||
|
setSyncing(false);
|
||||||
|
await recount();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Poisoned ops can never sync (permanent 4xx) — let the user clear them so the
|
||||||
|
// badge doesn't sit stuck forever.
|
||||||
|
const clearFailed = async () => {
|
||||||
|
await discardFailedOps();
|
||||||
|
await recount();
|
||||||
|
};
|
||||||
|
|
||||||
|
const showPending = !isOnline || queueCount > 0 || isSyncing;
|
||||||
|
const showFailed = !showPending && failedCount > 0;
|
||||||
|
if (!showPending && !showFailed) return null;
|
||||||
|
|
||||||
|
if (showFailed) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void clearFailed()}
|
||||||
|
title={isFa ? "حذف موارد ناموفق همگامسازی" : "Clear failed sync items"}
|
||||||
|
className="flex cursor-pointer items-center gap-1.5 rounded-full bg-red-100 px-2.5 py-1 text-[11px] font-medium text-red-800 transition-colors hover:bg-red-200 dark:bg-red-900/30 dark:text-red-300"
|
||||||
|
>
|
||||||
|
<AlertTriangle className="h-3 w-3 shrink-0" aria-hidden />
|
||||||
|
<span>{isFa ? `${failedCount} ناموفق — پاک کردن` : `${failedCount} failed — clear`}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const label = isFa
|
const label = isFa
|
||||||
? !isOnline
|
? !isOnline
|
||||||
@@ -72,13 +87,9 @@ export function SyncStatusIndicator() {
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void runManualSync(setSyncing, setQueueCount)}
|
onClick={() => void retry()}
|
||||||
disabled={isSyncing || !isOnline}
|
disabled={isSyncing || !isOnline}
|
||||||
title={
|
title={isFa ? "برای همگامسازی دستی کلیک کنید" : "Click to retry sync"}
|
||||||
isFa
|
|
||||||
? "برای همگامسازی دستی کلیک کنید"
|
|
||||||
: "Click to retry sync"
|
|
||||||
}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex cursor-pointer items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-medium transition-colors",
|
"flex cursor-pointer items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-medium transition-colors",
|
||||||
"disabled:cursor-not-allowed",
|
"disabled:cursor-not-allowed",
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ export function useNotificationsFeed(options: UseNotificationsFeedOptions = {})
|
|||||||
queryFn: () => fetchNotifications(cafeId!, unreadOnly, limit),
|
queryFn: () => fetchNotifications(cafeId!, unreadOnly, limit),
|
||||||
enabled: !!cafeId,
|
enabled: !!cafeId,
|
||||||
refetchInterval: 60_000,
|
refetchInterval: 60_000,
|
||||||
|
// Keep polling even when the tab is in the background, so the unread count
|
||||||
|
// stays current if the live socket missed something — no refresh needed.
|
||||||
|
refetchIntervalInBackground: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const refresh = useCallback(() => {
|
const refresh = useCallback(() => {
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ export function useTabBadge() {
|
|||||||
queryFn: () => fetchNotifications(cafeId!, false, 50),
|
queryFn: () => fetchNotifications(cafeId!, false, 50),
|
||||||
enabled: !!cafeId,
|
enabled: !!cafeId,
|
||||||
refetchInterval: 60_000,
|
refetchInterval: 60_000,
|
||||||
|
refetchIntervalInBackground: true, // update the tab badge even when unfocused
|
||||||
});
|
});
|
||||||
|
|
||||||
const unread = data?.unreadCount ?? 0;
|
const unread = data?.unreadCount ?? 0;
|
||||||
|
|||||||
@@ -165,3 +165,24 @@ export async function getPoisonedOps(): Promise<OutboxOp[]> {
|
|||||||
const ops = await getOutboxOps();
|
const ops = await getOutboxOps();
|
||||||
return ops.filter((o) => o.status === "failed" && o.attempts >= MAX_ATTEMPTS);
|
return ops.filter((o) => o.status === "failed" && o.attempts >= MAX_ATTEMPTS);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isPoisoned(o: OutboxOp): boolean {
|
||||||
|
return o.status === "failed" && o.attempts >= MAX_ATTEMPTS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Count of ops still worth retrying (excludes poisoned) — drives the "pending" badge. */
|
||||||
|
export async function getActiveOutboxCount(): Promise<number> {
|
||||||
|
return (await getOutboxOps()).filter((o) => !isPoisoned(o)).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Count of poisoned ops — surfaced separately so they don't inflate "pending". */
|
||||||
|
export async function getFailedOutboxCount(): Promise<number> {
|
||||||
|
return (await getOutboxOps()).filter(isPoisoned).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Permanently drop poisoned ops (user-initiated "clear failed"). Returns how many. */
|
||||||
|
export async function discardFailedOps(): Promise<number> {
|
||||||
|
const poisoned = (await getOutboxOps()).filter(isPoisoned);
|
||||||
|
for (const o of poisoned) await removeOutboxOp(o.id);
|
||||||
|
return poisoned.length;
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,11 +6,14 @@ import { useSyncQueueStore } from "@/lib/stores/sync-queue.store";
|
|||||||
import {
|
import {
|
||||||
enqueueOutboxOp,
|
enqueueOutboxOp,
|
||||||
getAllQueueItems,
|
getAllQueueItems,
|
||||||
getOutboxCount,
|
|
||||||
getQueueCount,
|
getQueueCount,
|
||||||
removeQueueItem,
|
removeQueueItem,
|
||||||
} from "@/lib/offline/offline-db";
|
} from "@/lib/offline/offline-db";
|
||||||
import { drainOutbox } from "@/lib/offline/outbox";
|
import {
|
||||||
|
drainOutbox,
|
||||||
|
getActiveOutboxCount,
|
||||||
|
getFailedOutboxCount,
|
||||||
|
} from "@/lib/offline/outbox";
|
||||||
|
|
||||||
function newId(prefix: string): string {
|
function newId(prefix: string): string {
|
||||||
if (prefix === "idem" && typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
if (prefix === "idem" && typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
||||||
@@ -75,15 +78,18 @@ async function migrateLegacyQueue(): Promise<void> {
|
|||||||
* - refresh server data once writes have synced.
|
* - refresh server data once writes have synced.
|
||||||
*/
|
*/
|
||||||
export function useOfflineSync() {
|
export function useOfflineSync() {
|
||||||
const { setQueueCount, setSyncing, setOnline } = useSyncQueueStore();
|
const { setQueueCount, setFailedCount, setSyncing, setOnline } = useSyncQueueStore();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const syncLock = useRef(false);
|
const syncLock = useRef(false);
|
||||||
|
|
||||||
const refreshCount = useCallback(async () => {
|
const refreshCount = useCallback(async () => {
|
||||||
const n = (await getOutboxCount()) + (await getQueueCount());
|
// Pending = retryable ops only; poisoned (failed after max retries) are shown
|
||||||
|
// separately so they never inflate the badge or keep it stuck above zero.
|
||||||
|
const n = (await getActiveOutboxCount()) + (await getQueueCount());
|
||||||
setQueueCount(n);
|
setQueueCount(n);
|
||||||
|
setFailedCount(await getFailedOutboxCount());
|
||||||
return n;
|
return n;
|
||||||
}, [setQueueCount]);
|
}, [setQueueCount, setFailedCount]);
|
||||||
|
|
||||||
const syncQueue = useCallback(async () => {
|
const syncQueue = useCallback(async () => {
|
||||||
if (syncLock.current) return;
|
if (syncLock.current) return;
|
||||||
|
|||||||
@@ -54,10 +54,23 @@ export function useOrderAlerts() {
|
|||||||
accessTokenFactory: () =>
|
accessTokenFactory: () =>
|
||||||
(typeof window !== "undefined" ? localStorage.getItem("meezi_access_token") : null) ?? "",
|
(typeof window !== "undefined" ? localStorage.getItem("meezi_access_token") : null) ?? "",
|
||||||
})
|
})
|
||||||
.withAutomaticReconnect()
|
// Retry FOREVER (capped backoff). The default policy gives up after ~30s,
|
||||||
|
// leaving the connection dead until a page refresh → missed notifications.
|
||||||
|
.withAutomaticReconnect({
|
||||||
|
nextRetryDelayInMilliseconds: (ctx) =>
|
||||||
|
Math.min(30000, 1000 * 2 ** Math.min(ctx.previousRetryCount, 5)),
|
||||||
|
})
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
let stopped = false;
|
let stopped = false;
|
||||||
|
const joinCafe = () => connection.invoke("JoinCafe", cafeId).catch(() => {});
|
||||||
|
|
||||||
|
// On reconnect the server group membership is gone — re-join or we silently
|
||||||
|
// stop receiving notifications. Also catch up anything missed while down.
|
||||||
|
connection.onreconnected(() => {
|
||||||
|
void joinCafe();
|
||||||
|
void qc.invalidateQueries({ queryKey: ["notifications", cafeId] });
|
||||||
|
});
|
||||||
|
|
||||||
const severityFor = (type: string) => {
|
const severityFor = (type: string) => {
|
||||||
if (type === "table_call_waiter") return notify.warning;
|
if (type === "table_call_waiter") return notify.warning;
|
||||||
@@ -104,14 +117,36 @@ export function useOrderAlerts() {
|
|||||||
void connection
|
void connection
|
||||||
.start()
|
.start()
|
||||||
.then(() => {
|
.then(() => {
|
||||||
if (!stopped) return connection.invoke("JoinCafe", cafeId);
|
if (!stopped) return joinCafe();
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
// connection/auth failed — alerts simply won't fire; no UI breakage
|
// connection/auth failed — alerts simply won't fire; no UI breakage
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// If the connection fully dropped (gave up, or the device slept), bring it
|
||||||
|
// back when the network returns or the tab is focused again.
|
||||||
|
const ensureConnected = () => {
|
||||||
|
if (stopped) return;
|
||||||
|
if (connection.state === signalR.HubConnectionState.Disconnected) {
|
||||||
|
void connection
|
||||||
|
.start()
|
||||||
|
.then(() => {
|
||||||
|
if (!stopped) return joinCafe();
|
||||||
|
})
|
||||||
|
.then(() => qc.invalidateQueries({ queryKey: ["notifications", cafeId] }))
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onVisible = () => {
|
||||||
|
if (document.visibilityState === "visible") ensureConnected();
|
||||||
|
};
|
||||||
|
window.addEventListener("online", ensureConnected);
|
||||||
|
document.addEventListener("visibilitychange", onVisible);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
stopped = true;
|
stopped = true;
|
||||||
|
window.removeEventListener("online", ensureConnected);
|
||||||
|
document.removeEventListener("visibilitychange", onVisible);
|
||||||
void connection.stop();
|
void connection.stop();
|
||||||
};
|
};
|
||||||
}, [cafeId, locale, qc]);
|
}, [cafeId, locale, qc]);
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
|
|
||||||
interface SyncQueueState {
|
interface SyncQueueState {
|
||||||
/** Number of items waiting to be synced */
|
/** Number of items waiting to be synced (retryable; excludes poisoned) */
|
||||||
queueCount: number;
|
queueCount: number;
|
||||||
|
/** Ops that exhausted retries and need attention (won't auto-sync) */
|
||||||
|
failedCount: number;
|
||||||
/** True while a sync pass is running */
|
/** True while a sync pass is running */
|
||||||
isSyncing: boolean;
|
isSyncing: boolean;
|
||||||
/** Mirrors navigator.onLine (updated client-side) */
|
/** Mirrors navigator.onLine (updated client-side) */
|
||||||
isOnline: boolean;
|
isOnline: boolean;
|
||||||
|
|
||||||
setQueueCount: (n: number) => void;
|
setQueueCount: (n: number) => void;
|
||||||
|
setFailedCount: (n: number) => void;
|
||||||
setSyncing: (v: boolean) => void;
|
setSyncing: (v: boolean) => void;
|
||||||
setOnline: (v: boolean) => void;
|
setOnline: (v: boolean) => void;
|
||||||
incrementQueue: () => void;
|
incrementQueue: () => void;
|
||||||
@@ -17,10 +20,12 @@ interface SyncQueueState {
|
|||||||
|
|
||||||
export const useSyncQueueStore = create<SyncQueueState>((set) => ({
|
export const useSyncQueueStore = create<SyncQueueState>((set) => ({
|
||||||
queueCount: 0,
|
queueCount: 0,
|
||||||
|
failedCount: 0,
|
||||||
isSyncing: false,
|
isSyncing: false,
|
||||||
isOnline: true, // assume online until client hydrates
|
isOnline: true, // assume online until client hydrates
|
||||||
|
|
||||||
setQueueCount: (n) => set({ queueCount: Math.max(0, n) }),
|
setQueueCount: (n) => set({ queueCount: Math.max(0, n) }),
|
||||||
|
setFailedCount: (n) => set({ failedCount: Math.max(0, n) }),
|
||||||
setSyncing: (v) => set({ isSyncing: v }),
|
setSyncing: (v) => set({ isSyncing: v }),
|
||||||
setOnline: (v) => set({ isOnline: v }),
|
setOnline: (v) => set({ isOnline: v }),
|
||||||
incrementQueue: () => set((s) => ({ queueCount: s.queueCount + 1 })),
|
incrementQueue: () => set((s) => ({ queueCount: s.queueCount + 1 })),
|
||||||
|
|||||||
Reference in New Issue
Block a user