Commit 0a82b230 authored by Administrator's avatar Administrator

Update 12 files via Son of Anton

parent 553c8c4a
...@@ -6,8 +6,11 @@ import { useAuthStore } from '@/stores/auth.store'; ...@@ -6,8 +6,11 @@ import { useAuthStore } from '@/stores/auth.store';
import { Sidebar } from '@/components/layout/sidebar'; import { Sidebar } from '@/components/layout/sidebar';
import { Topbar } from '@/components/layout/topbar'; import { Topbar } from '@/components/layout/topbar';
import { BlockingOverlay } from '@/components/notifications/blocking-overlay'; import { BlockingOverlay } from '@/components/notifications/blocking-overlay';
import { OfflineBanner } from '@/components/shared/offline-banner';
import { KeyboardShortcutsHelp } from '@/components/shared/keyboard-shortcuts-help';
import { useHud } from '@/hooks/use-hud'; import { useHud } from '@/hooks/use-hud';
import { useNotifications } from '@/hooks/use-notifications'; import { useNotifications } from '@/hooks/use-notifications';
import { useNavigationShortcuts } from '@/hooks/use-navigation-shortcuts';
import { useThemeStore } from '@/stores/theme.store'; import { useThemeStore } from '@/stores/theme.store';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
...@@ -35,9 +38,28 @@ export default function DashboardLayout({ ...@@ -35,9 +38,28 @@ export default function DashboardLayout({
} }
}, [isAuthenticated, router]); }, [isAuthenticated, router]);
// Redirect onboarding users
useEffect(() => {
if (user && user.status === 'ONBOARDING') {
router.push('/onboarding');
}
}, [user, router]);
// Initialize HUD and notifications // Initialize HUD and notifications
useHud(); useHud();
useNotifications(); useNotifications();
useNavigationShortcuts();
// Theme toggle listener
useEffect(() => {
const handler = () => {
const html = document.documentElement;
const isDark = html.classList.contains('dark');
html.classList.toggle('dark', !isDark);
};
document.addEventListener('toggle-theme', handler);
return () => document.removeEventListener('toggle-theme', handler);
}, []);
if (!user) { if (!user) {
return ( return (
...@@ -52,6 +74,12 @@ export default function DashboardLayout({ ...@@ -52,6 +74,12 @@ export default function DashboardLayout({
{/* Blocking notification overlay */} {/* Blocking notification overlay */}
<BlockingOverlay /> <BlockingOverlay />
{/* Offline banner */}
<OfflineBanner />
{/* Keyboard shortcuts help */}
<KeyboardShortcutsHelp />
{/* Sidebar */} {/* Sidebar */}
<Sidebar /> <Sidebar />
......
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useAuthStore } from '@/stores/auth.store';
export default function OnboardingLayout({
children,
}: {
children: React.ReactNode;
}) {
const { user, isAuthenticated, loadUser } = useAuthStore();
const router = useRouter();
useEffect(() => {
loadUser();
}, [loadUser]);
useEffect(() => {
if (!isAuthenticated) {
const token = localStorage.getItem('accessToken');
if (!token) {
router.push('/login');
}
}
}, [isAuthenticated, router]);
useEffect(() => {
if (user && user.status !== 'ONBOARDING') {
router.push('/');
}
}, [user, router]);
if (!user) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
</div>
);
}
return (
<div className="min-h-screen bg-gradient-to-br from-background to-muted">
<div className="max-w-3xl mx-auto p-6">
<div className="text-center mb-8">
<h1 className="text-3xl font-black tracking-tighter">THE GRIND</h1>
<p className="text-sm text-muted-foreground mt-1">
Welcome, {user.firstName}! Complete your onboarding to get started.
</p>
</div>
{children}
</div>
</div>
);
}
\ No newline at end of file
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { apiGet } from '@/lib/api';
import { useAuthStore } from '@/stores/auth.store';
import { CheckCircle2, Clock, AlertCircle, Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
interface ChecklistItem {
id: string;
name: string;
status: 'COMPLETE' | 'PENDING' | 'NOT_STARTED';
verifiedBy: string;
completedAt?: string;
}
export default function OnboardingChecklistPage() {
const user = useAuthStore((s) => s.user);
const router = useRouter();
const [items, setItems] = useState<ChecklistItem[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
loadChecklist();
}, []);
const loadChecklist = async () => {
try {
const res = await apiGet(`/onboarding/checklist/${user?.id}`);
setItems(res.data || getDefaultChecklist());
} catch {
setItems(getDefaultChecklist());
} finally {
setIsLoading(false);
}
};
const getDefaultChecklist = (): ChecklistItem[] => [
{ id: '1', name: 'Profile photo uploaded', status: user?.avatar ? 'COMPLETE' : 'NOT_STARTED', verifiedBy: 'System' },
{ id: '2', name: 'Bank details provided', status: user?.bankName ? 'COMPLETE' : 'NOT_STARTED', verifiedBy: 'System' },
{ id: '3', name: 'Contract signed', status: user?.contractSignedAt ? 'COMPLETE' : 'NOT_STARTED', verifiedBy: 'System' },
{ id: '4', name: 'All policies acknowledged', status: 'PENDING', verifiedBy: 'System' },
{ id: '5', name: 'Competency self-assessment completed', status: 'PENDING', verifiedBy: 'System' },
{ id: '6', name: 'Device setup confirmed', status: 'NOT_STARTED', verifiedBy: 'Contractor' },
{ id: '7', name: 'Source control access configured', status: 'NOT_STARTED', verifiedBy: 'Project Leader' },
{ id: '8', name: 'First board assigned', status: 'NOT_STARTED', verifiedBy: 'Admin / PL' },
{ id: '9', name: 'Introduction meeting completed', status: 'NOT_STARTED', verifiedBy: 'Admin' },
];
const completedCount = items.filter((i) => i.status === 'COMPLETE').length;
const totalCount = items.length;
const progressPercent = totalCount > 0 ? Math.round((completedCount / totalCount) * 100) : 0;
if (isLoading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="animate-spin" size={32} />
</div>
);
}
return (
<div className="space-y-6">
{/* Progress */}
<div className="bg-card rounded-xl border p-6">
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-bold">Onboarding Checklist</h2>
<span className="text-sm font-medium">
{completedCount}/{totalCount} complete ({progressPercent}%)
</span>
</div>
<div className="h-3 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-emerald-500 rounded-full transition-all duration-500"
style={{ width: `${progressPercent}%` }}
/>
</div>
{progressPercent === 100 && (
<p className="text-sm text-emerald-600 mt-3 font-medium">
🎉 All items complete! An admin will activate your account shortly.
</p>
)}
</div>
{/* Checklist items */}
<div className="bg-card rounded-xl border divide-y">
{items.map((item) => {
const statusConfig = {
COMPLETE: { icon: CheckCircle2, color: 'text-emerald-500', bg: 'bg-emerald-500/10' },
PENDING: { icon: Clock, color: 'text-yellow-500', bg: 'bg-yellow-500/10' },
NOT_STARTED: { icon: AlertCircle, color: 'text-muted-foreground', bg: 'bg-muted' },
};
const config = statusConfig[item.status];
const Icon = config.icon;
return (
<div key={item.id} className="p-4 flex items-center gap-4">
<div className={cn('p-2 rounded-full', config.bg)}>
<Icon size={18} className={config.color} />
</div>
<div className="flex-1">
<p
className={cn(
'text-sm font-medium',
item.status === 'COMPLETE' && 'line-through text-muted-foreground',
)}
>
{item.name}
</p>
<p className="text-[10px] text-muted-foreground mt-0.5">
Verified by: {item.verifiedBy}
{item.completedAt && ` · Completed ${new Date(item.completedAt).toLocaleDateString()}`}
</p>
</div>
<span
className={cn(
'text-[10px] font-medium px-2 py-0.5 rounded-full',
item.status === 'COMPLETE'
? 'bg-emerald-500/10 text-emerald-600'
: item.status === 'PENDING'
? 'bg-yellow-500/10 text-yellow-600'
: 'bg-muted text-muted-foreground',
)}
>
{item.status === 'COMPLETE'
? '✅ Complete'
: item.status === 'PENDING'
? '⏳ Pending'
: '❌ Not Started'}
</span>
</div>
);
})}
</div>
{/* Info */}
<div className="bg-accent/50 rounded-xl p-4 text-sm text-muted-foreground">
<p>
<strong>Note:</strong> Some items are verified automatically by the system. Others
require your Project Leader or Admin to confirm. If you have questions, use the
messaging system to contact your admin.
</p>
</div>
</div>
);
}
\ No newline at end of file
'use client';
import { formatEgp } from '@/lib/utils';
import { formatDate } from '@/lib/date';
import { TrendingUp, TrendingDown, DollarSign, Coins, AlertTriangle } from 'lucide-react';
import Link from 'next/link';
interface HudLineItem {
id: string;
type: 'salary' | 'bounty' | 'deduction' | 'adjustment';
label: string;
amountPiasters: number;
link?: string;
date?: string;
}
interface HudExpandedProps {
actualSalaryPiasters: number;
liveSalaryPiasters: number;
items: HudLineItem[];
onClose: () => void;
}
export function HudExpanded({
actualSalaryPiasters,
liveSalaryPiasters,
items,
onClose,
}: HudExpandedProps) {
const sortedItems = [...items].sort(
(a, b) => new Date(a.date || 0).getTime() - new Date(b.date || 0).getTime(),
);
return (
<div className="absolute top-full left-0 right-0 mt-1 z-50 animate-fade-in">
<div className="bg-card rounded-xl border shadow-xl p-3 max-h-[60vh] overflow-y-auto">
{/* Header */}
<div className="flex items-center justify-between mb-3 pb-2 border-b">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Salary Breakdown
</span>
<button
onClick={onClose}
className="text-xs text-muted-foreground hover:text-foreground"
>
Close
</button>
</div>
{/* Base salary */}
<div className="flex items-center justify-between py-1.5 text-sm">
<div className="flex items-center gap-2">
<DollarSign size={14} className="text-muted-foreground" />
<span>Actual Salary</span>
</div>
<span className="font-mono font-medium">{formatEgp(actualSalaryPiasters)}</span>
</div>
{/* Line items */}
{sortedItems.map((item) => {
const isPositive = item.amountPiasters > 0;
const Icon = isPositive ? TrendingUp : TrendingDown;
const color = isPositive ? 'text-emerald-500' : 'text-red-500';
const content = (
<div className="flex items-center justify-between py-1.5 text-sm hover:bg-accent/50 rounded px-1 -mx-1 transition-colors">
<div className="flex items-center gap-2 min-w-0">
{item.type === 'bounty' && <Coins size={14} className="text-amber-500 shrink-0" />}
{item.type === 'deduction' && (
<AlertTriangle size={14} className="text-red-500 shrink-0" />
)}
{item.type === 'adjustment' && (
<Icon size={14} className={`${color} shrink-0`} />
)}
<span className="truncate text-muted-foreground">{item.label}</span>
</div>
<span className={`font-mono font-medium shrink-0 ml-2 ${color}`}>
{isPositive ? '+' : ''}
{formatEgp(item.amountPiasters)}
</span>
</div>
);
if (item.link) {
return (
<Link key={item.id} href={item.link}>
{content}
</Link>
);
}
return <div key={item.id}>{content}</div>;
})}
{sortedItems.length === 0 && (
<p className="text-xs text-muted-foreground text-center py-4">
No bounties, deductions, or adjustments this month
</p>
)}
{/* Total */}
<div className="flex items-center justify-between pt-2 mt-2 border-t text-sm font-bold">
<span>= Live Salary</span>
<span className="font-mono">{formatEgp(liveSalaryPiasters)}</span>
</div>
</div>
</div>
);
}
\ No newline at end of file
'use client';
import { useEffect, useRef, useState } from 'react';
import { cn } from '@/lib/utils';
interface SalaryAnimationProps {
value: number;
prefix?: string;
className?: string;
duration?: number;
formatFn?: (n: number) => string;
}
export function SalaryAnimation({
value,
prefix = '',
className,
duration = 600,
formatFn,
}: SalaryAnimationProps) {
const [displayValue, setDisplayValue] = useState(value);
const [isAnimating, setIsAnimating] = useState(false);
const [direction, setDirection] = useState<'up' | 'down' | null>(null);
const prevValueRef = useRef(value);
const animationRef = useRef<number | null>(null);
useEffect(() => {
const prevValue = prevValueRef.current;
if (prevValue === value) return;
setDirection(value > prevValue ? 'up' : 'down');
setIsAnimating(true);
const startTime = performance.now();
const startValue = prevValue;
const diff = value - prevValue;
const animate = (currentTime: number) => {
const elapsed = currentTime - startTime;
const progress = Math.min(elapsed / duration, 1);
const eased = 1 - Math.pow(1 - progress, 3); // ease-out cubic
const current = Math.round(startValue + diff * eased);
setDisplayValue(current);
if (progress < 1) {
animationRef.current = requestAnimationFrame(animate);
} else {
setDisplayValue(value);
setTimeout(() => {
setIsAnimating(false);
setDirection(null);
}, 300);
}
};
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
}
animationRef.current = requestAnimationFrame(animate);
prevValueRef.current = value;
return () => {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
}
};
}, [value, duration]);
const formatted = formatFn
? formatFn(displayValue)
: `${prefix}${(displayValue / 100).toLocaleString('en-EG', {
minimumFractionDigits: 0,
maximumFractionDigits: 0,
})}`;
return (
<span
className={cn(
'tabular-nums transition-colors duration-300',
isAnimating && direction === 'up' && 'text-emerald-500',
isAnimating && direction === 'down' && 'text-red-500',
className,
)}
>
{formatted}
</span>
);
}
\ No newline at end of file
This diff is collapsed.
'use client';
import { useState } from 'react';
import { Plus, MoreHorizontal, GripVertical } from 'lucide-react';
import { cn } from '@/lib/utils';
interface ColumnHeaderProps {
column: {
id: string;
name: string;
type: string;
wipLimit?: number;
icon?: string;
};
cardCount: number;
onAddCard?: () => void;
canAddCard: boolean;
}
const COLUMN_ICONS: Record<string, string> = {
BACKLOG: '📋',
TODO: '📌',
DOING: '🔨',
FROZEN: '🧊',
IN_REVIEW: '🔍',
DONE: '✅',
CUSTOM: '📁',
};
export function ColumnHeader({ column, cardCount, onAddCard, canAddCard }: ColumnHeaderProps) {
const icon = column.icon || COLUMN_ICONS[column.type] || '📁';
const isAtWipLimit = column.wipLimit && cardCount >= column.wipLimit;
return (
<div className="flex items-center justify-between px-2 py-2 mb-2">
<div className="flex items-center gap-2 min-w-0">
<span className="text-base">{icon}</span>
<h3 className="text-sm font-semibold truncate">{column.name}</h3>
<span
className={cn(
'text-[10px] font-mono px-1.5 py-0.5 rounded-full min-w-[20px] text-center',
isAtWipLimit
? 'bg-red-500/10 text-red-500 font-bold'
: 'bg-muted text-muted-foreground',
)}
>
{cardCount}
{column.wipLimit ? `/${column.wipLimit}` : ''}
</span>
</div>
<div className="flex items-center gap-0.5">
{canAddCard && (
<button
onClick={onAddCard}
disabled={!!isAtWipLimit}
className={cn(
'p-1 rounded-md transition-colors',
isAtWipLimit
? 'opacity-30 cursor-not-allowed'
: 'hover:bg-accent text-muted-foreground hover:text-foreground',
)}
title={isAtWipLimit ? 'WIP limit reached' : 'Add card'}
>
<Plus size={14} />
</button>
)}
</div>
</div>
);
}
\ No newline at end of file
This diff is collapsed.
'use client';
import { useState, useEffect } from 'react';
import { X, Keyboard } from 'lucide-react';
import { cn } from '@/lib/utils';
const SHORTCUTS = [
{ category: 'Navigation', shortcuts: [
{ keys: ['Ctrl', 'K'], description: 'Open command palette / search' },
{ keys: ['G', 'B'], description: 'Go to Boards' },
{ keys: ['G', 'T'], description: 'Go to My Tasks' },
{ keys: ['G', 'R'], description: 'Go to Reports' },
{ keys: ['G', 'M'], description: 'Go to Messages' },
{ keys: ['G', 'S'], description: 'Go to Salary' },
{ keys: ['G', 'P'], description: 'Go to Profile' },
{ keys: ['G', 'N'], description: 'Go to Notifications' },
]},
{ category: 'Board', shortcuts: [
{ keys: ['N'], description: 'New card (when on board)' },
{ keys: ['F'], description: 'Toggle filters' },
{ keys: ['1-6'], description: 'Switch to column view' },
{ keys: ['Esc'], description: 'Close card detail / dialog' },
]},
{ category: 'General', shortcuts: [
{ keys: ['?'], description: 'Show keyboard shortcuts' },
{ keys: ['Ctrl', '/'], description: 'Toggle dark mode' },
]},
];
export function KeyboardShortcutsHelp() {
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === '?' && !e.ctrlKey && !e.metaKey) {
const target = e.target as HTMLElement;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) return;
e.preventDefault();
setIsOpen((prev) => !prev);
}
if (e.key === 'Escape' && isOpen) {
setIsOpen(false);
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [isOpen]);
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[100] bg-black/50 flex items-center justify-center p-4" onClick={() => setIsOpen(false)}>
<div className="bg-card rounded-xl border shadow-2xl w-full max-w-lg max-h-[80vh] overflow-y-auto animate-fade-in" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between p-4 border-b sticky top-0 bg-card z-10">
<div className="flex items-center gap-2">
<Keyboard size={18} />
<h2 className="text-lg font-semibold">Keyboard Shortcuts</h2>
</div>
<button onClick={() => setIsOpen(false)} className="p-1 rounded-md hover:bg-accent">
<X size={18} />
</button>
</div>
<div className="p-4 space-y-6">
{SHORTCUTS.map((group) => (
<div key={group.category}>
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">
{group.category}
</h3>
<div className="space-y-1.5">
{group.shortcuts.map((shortcut, i) => (
<div key={i} className="flex items-center justify-between py-1">
<span className="text-sm text-muted-foreground">{shortcut.description}</span>
<div className="flex items-center gap-1">
{shortcut.keys.map((key, j) => (
<span key={j}>
{j > 0 && <span className="text-[10px] text-muted-foreground mx-0.5">+</span>}
<kbd className="px-2 py-0.5 bg-muted rounded text-xs font-mono font-medium border border-border/50">
{key}
</kbd>
</span>
))}
</div>
</div>
))}
</div>
</div>
))}
</div>
<div className="p-4 border-t text-center">
<p className="text-xs text-muted-foreground">
Press <kbd className="px-1.5 py-0.5 bg-muted rounded text-[10px] font-mono">?</kbd> to toggle this panel
</p>
</div>
</div>
</div>
);
}
\ No newline at end of file
'use client';
import { useOnlineStatus } from '@/hooks/use-online-status';
import { WifiOff, Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
export function OfflineBanner() {
const isOnline = useOnlineStatus();
if (isOnline) return null;
return (
<div className="fixed bottom-4 left-1/2 -translate-x-1/2 z-[200] animate-slide-in-right">
<div className="flex items-center gap-2 bg-destructive text-destructive-foreground px-4 py-2.5 rounded-xl shadow-lg">
<WifiOff size={16} />
<span className="text-sm font-medium">
You're offline — changes will sync when reconnected
</span>
<Loader2 size={14} className="animate-spin ml-1" />
</div>
</div>
);
}
\ No newline at end of file
'use client';
import { useEffect, useRef } from 'react';
import { useRouter } from 'next/navigation';
export function useNavigationShortcuts() {
const router = useRouter();
const pendingKeyRef = useRef<string | null>(null);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
const target = e.target as HTMLElement;
if (
target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.tagName === 'SELECT' ||
target.isContentEditable
) {
return;
}
// Ctrl+K for search
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
e.preventDefault();
document.dispatchEvent(new CustomEvent('open-command-palette'));
return;
}
// Ctrl+/ for dark mode toggle
if ((e.ctrlKey || e.metaKey) && e.key === '/') {
e.preventDefault();
document.dispatchEvent(new CustomEvent('toggle-theme'));
return;
}
// Two-key navigation: G then letter
if (pendingKeyRef.current === 'g') {
pendingKeyRef.current = null;
if (timeoutRef.current) clearTimeout(timeoutRef.current);
switch (e.key.toLowerCase()) {
case 'b':
e.preventDefault();
router.push('/boards');
break;
case 't':
e.preventDefault();
router.push('/my-tasks');
break;
case 'r':
e.preventDefault();
router.push('/reports');
break;
case 'm':
e.preventDefault();
router.push('/messages');
break;
case 's':
e.preventDefault();
router.push('/salary');
break;
case 'p':
e.preventDefault();
router.push('/profile');
break;
case 'n':
e.preventDefault();
router.push('/notifications');
break;
case 'd':
e.preventDefault();
router.push('/');
break;
}
return;
}
if (e.key === 'g' && !e.ctrlKey && !e.metaKey && !e.altKey) {
pendingKeyRef.current = 'g';
if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => {
pendingKeyRef.current = null;
}, 500);
}
};
window.addEventListener('keydown', handler);
return () => {
window.removeEventListener('keydown', handler);
if (timeoutRef.current) clearTimeout(timeoutRef.current);
};
}, [router]);
}
\ No newline at end of file
'use client'; 'use client';
import { useState, useEffect, useCallback, useRef } from 'react'; import { useState, useEffect } from 'react';
import { toast } from 'sonner';
interface QueuedAction { export function useOnlineStatus(): boolean {
id: string;
type: string;
url: string;
method: string;
body?: any;
timestamp: number;
}
const QUEUE_KEY = 'thegrind_offline_queue';
function getQueue(): QueuedAction[] {
if (typeof window === 'undefined') return [];
try {
const raw = localStorage.getItem(QUEUE_KEY);
return raw ? JSON.parse(raw) : [];
} catch {
return [];
}
}
function saveQueue(queue: QueuedAction[]): void {
if (typeof window === 'undefined') return;
localStorage.setItem(QUEUE_KEY, JSON.stringify(queue));
}
export function useOnlineStatus() {
const [isOnline, setIsOnline] = useState(true); const [isOnline, setIsOnline] = useState(true);
const [queueLength, setQueueLength] = useState(0);
const isSyncing = useRef(false);
useEffect(() => { useEffect(() => {
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
setIsOnline(navigator.onLine); setIsOnline(navigator.onLine);
setQueueLength(getQueue().length);
const handleOnline = () => {
setIsOnline(true);
toast.success('Back online! Syncing queued actions...');
syncQueue();
};
const handleOffline = () => { const handleOnline = () => setIsOnline(true);
setIsOnline(false); const handleOffline = () => setIsOnline(false);
toast.warning('You are offline. Changes will be queued and synced when reconnected.');
};
window.addEventListener('online', handleOnline); window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline); window.addEventListener('offline', handleOffline);
...@@ -60,72 +22,5 @@ export function useOnlineStatus() { ...@@ -60,72 +22,5 @@ export function useOnlineStatus() {
}; };
}, []); }, []);
const enqueue = useCallback((action: Omit<QueuedAction, 'id' | 'timestamp'>) => { return isOnline;
const queue = getQueue();
const newAction: QueuedAction = {
...action,
id: `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
timestamp: Date.now(),
};
queue.push(newAction);
saveQueue(queue);
setQueueLength(queue.length);
return newAction.id;
}, []);
const syncQueue = useCallback(async () => {
if (isSyncing.current) return;
isSyncing.current = true;
const queue = getQueue();
if (queue.length === 0) {
isSyncing.current = false;
return;
}
const failed: QueuedAction[] = [];
let successCount = 0;
for (const action of queue) {
try {
const token = typeof window !== 'undefined' ? localStorage.getItem('accessToken') : null;
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (token) headers['Authorization'] = `Bearer ${token}`;
const response = await fetch(action.url, {
method: action.method,
headers,
body: action.body ? JSON.stringify(action.body) : undefined,
});
if (response.ok) {
successCount++;
} else if (response.status >= 500) {
failed.push(action);
}
// 4xx errors are dropped (client error, no point retrying)
} catch {
failed.push(action);
}
}
saveQueue(failed);
setQueueLength(failed.length);
if (successCount > 0) {
toast.success(`Synced ${successCount} queued action${successCount > 1 ? 's' : ''}`);
}
if (failed.length > 0) {
toast.error(`${failed.length} action${failed.length > 1 ? 's' : ''} failed to sync. Will retry later.`);
}
isSyncing.current = false;
}, []);
const clearQueue = useCallback(() => {
saveQueue([]);
setQueueLength(0);
}, []);
return { isOnline, queueLength, enqueue, syncQueue, clearQueue };
} }
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment