fix(offline): stop the sync queue badge getting stuck above zero
Two bugs made "N در صف" persist even when online: - The badge counted poisoned ops (failed after 5 retries, never removed), so it never returned to 0. Now the badge counts only retryable (active) ops; poisoned ops are tracked separately as failedCount and surfaced as a red "N failed — clear" chip the user can tap to discard them. - The manual-retry click drained the LEGACY order_queue, not the real outbox the app actually uses — so clicking did nothing for stuck items. It now drains the outbox (drainOutbox), invalidates queries on success, and recounts. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,61 +1,76 @@
|
||||
"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 { useSyncQueueStore } from "@/lib/stores/sync-queue.store";
|
||||
import { useLocale } from "next-intl";
|
||||
import { getQueueCount } from "@/lib/offline/offline-db";
|
||||
import {
|
||||
getAllQueueItems,
|
||||
getQueueCount,
|
||||
removeQueueItem,
|
||||
markQueueItemFailed,
|
||||
} from "@/lib/offline/offline-db";
|
||||
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());
|
||||
}
|
||||
}
|
||||
drainOutbox,
|
||||
getActiveOutboxCount,
|
||||
getFailedOutboxCount,
|
||||
discardFailedOps,
|
||||
} from "@/lib/offline/outbox";
|
||||
|
||||
export function SyncStatusIndicator() {
|
||||
const { queueCount, isSyncing, isOnline, setSyncing, setQueueCount } =
|
||||
useSyncQueueStore();
|
||||
const {
|
||||
queueCount,
|
||||
failedCount,
|
||||
isSyncing,
|
||||
isOnline,
|
||||
setSyncing,
|
||||
setQueueCount,
|
||||
setFailedCount,
|
||||
} = useSyncQueueStore();
|
||||
const queryClient = useQueryClient();
|
||||
const locale = useLocale();
|
||||
const isFa = locale !== "en";
|
||||
|
||||
const show = !isOnline || queueCount > 0 || isSyncing;
|
||||
if (!show) return null;
|
||||
const recount = async () => {
|
||||
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
|
||||
? !isOnline
|
||||
@@ -72,13 +87,9 @@ export function SyncStatusIndicator() {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void runManualSync(setSyncing, setQueueCount)}
|
||||
onClick={() => void retry()}
|
||||
disabled={isSyncing || !isOnline}
|
||||
title={
|
||||
isFa
|
||||
? "برای همگامسازی دستی کلیک کنید"
|
||||
: "Click to retry sync"
|
||||
}
|
||||
title={isFa ? "برای همگامسازی دستی کلیک کنید" : "Click to retry sync"}
|
||||
className={cn(
|
||||
"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",
|
||||
|
||||
Reference in New Issue
Block a user