From 92d71cf1f4c4cb34444ed7c2690b649c58bc380c Mon Sep 17 00:00:00 2001 From: Darkdruce Date: Sat, 29 Aug 2026 12:48:06 +0000 Subject: [PATCH] fix: resolve issues #167, #168, #169, #170 #167 - Remove conflicting block class from scope checkbox labels - src/app/dashboard/settings/page.tsx: strip redundant `block` from the scope-selection label; `flex` is the correct display value. #168 - Add per-status icons so state is not color-only (WCAG 1.4.1) - src/lib/utils.ts: export STATUS_ICONS (LucideIcon map) alongside STATUS_COLORS with a unique icon per status value. - src/app/dashboard/settlements/page.tsx: render icon inside every status badge (mobile card + table row). - src/app/dashboard/admin/settlements/page.tsx: consolidate local statusIcons onto the shared STATUS_ICONS from utils.ts. #169 - Prevent blank flash / premature logout on hard refresh - src/lib/store.ts: add hasHydrated flag, set to true via persist onRehydrateStorage callback. - src/app/dashboard/layout.tsx: show a loading spinner until hasHydrated is true; only evaluate the auth guard afterwards. #170 - Remove duplicate localStorage write path for auth token - src/lib/store.ts: drop the manual localStorage.setItem / removeItem calls; Zustand persist's single write path via the dupdub-auth key is sufficient and avoids transient two-key disagreement on logout. --- src/app/dashboard/admin/settlements/page.tsx | 10 ++------ src/app/dashboard/layout.tsx | 18 +++++++++++--- src/app/dashboard/settings/page.tsx | 2 +- src/app/dashboard/settlements/page.tsx | 20 +++++++++------ src/lib/store.ts | 20 ++++++++++++--- src/lib/utils.ts | 26 ++++++++++++++++++++ 6 files changed, 74 insertions(+), 22 deletions(-) diff --git a/src/app/dashboard/admin/settlements/page.tsx b/src/app/dashboard/admin/settlements/page.tsx index 82d7e40c..cb314d34 100644 --- a/src/app/dashboard/admin/settlements/page.tsx +++ b/src/app/dashboard/admin/settlements/page.tsx @@ -11,7 +11,7 @@ import { } from 'lucide-react'; import { useAuthStore } from '@/lib/store'; import api from '@/lib/api'; -import { STATUS_COLORS } from '@/lib/utils'; +import { STATUS_COLORS, STATUS_ICONS } from '@/lib/utils'; interface Settlement { id: string; @@ -44,13 +44,7 @@ interface SettlementsResponse { totalPages: number; } -const statusIcons = { - pending: Clock, - pending_approval: AlertCircle, - processing: RefreshCw, - completed: CheckCircle, - failed: AlertCircle, -}; +const statusIcons = STATUS_ICONS; export default function AdminSettlementsPage() { const { token } = useAuthStore(); diff --git a/src/app/dashboard/layout.tsx b/src/app/dashboard/layout.tsx index 2b983bd0..dc87f853 100644 --- a/src/app/dashboard/layout.tsx +++ b/src/app/dashboard/layout.tsx @@ -31,19 +31,31 @@ const navItems = [ ]; export default function DashboardLayout({ children }: { children: React.ReactNode }) { - const { merchant, token, logout } = useAuthStore(); + const { merchant, token, logout, hasHydrated } = useAuthStore(); const router = useRouter(); const pathname = usePathname(); const [mobileNavOpen, setMobileNavOpen] = useState(false); useEffect(() => { - if (!token) router.push('/auth/login'); - }, [token, router]); + // Only redirect once the persist middleware has finished reading localStorage. + // Redirecting before hydration would log out a valid session on every hard refresh. + if (hasHydrated && !token) router.push('/auth/login'); + }, [hasHydrated, token, router]); useEffect(() => { setMobileNavOpen(false); }, [pathname]); + // Show a neutral loading state while Zustand rehydrates from localStorage. + // This prevents both the blank-page flash and the premature redirect. + if (!hasHydrated) { + return ( +
+
+
+ ); + } + if (!merchant) return null; const sidebarContent = ( diff --git a/src/app/dashboard/settings/page.tsx b/src/app/dashboard/settings/page.tsx index f3990c88..cb3acdba 100644 --- a/src/app/dashboard/settings/page.tsx +++ b/src/app/dashboard/settings/page.tsx @@ -139,7 +139,7 @@ export default function SettingsPage() { { value: 'settlements:read', label: 'Settlements: read' }, { value: 'webhooks:manage', label: 'Webhooks: manage' }, ].map(({ value, label }) => ( -
{formatUsd(s.netAmountUsd)}
@@ -88,9 +91,12 @@ export default function SettlementsPage() { -{formatUsd(s.feeAmountUsd)} {formatUsd(s.netAmountUsd)} - - {s.status} - + {(() => { const Icon = STATUS_ICONS[s.status]; return ( + + {Icon && + ); })()} {formatDate(s.createdAt)} diff --git a/src/lib/store.ts b/src/lib/store.ts index 962a5e8c..ae19d7a5 100644 --- a/src/lib/store.ts +++ b/src/lib/store.ts @@ -11,8 +11,11 @@ interface Merchant { interface AuthState { token: string | null; merchant: Merchant | null; + /** True once Zustand's persist middleware has finished rehydrating from localStorage. */ + hasHydrated: boolean; setAuth: (token: string, merchant: Merchant) => void; logout: () => void; + _setHasHydrated: (value: boolean) => void; } export const useAuthStore = create()( @@ -20,15 +23,26 @@ export const useAuthStore = create()( (set) => ({ token: null, merchant: null, + hasHydrated: false, setAuth: (token, merchant) => { - localStorage.setItem('access_token', token); + // Single write path: persist middleware will sync the 'dupdub-auth' key. + // No duplicate localStorage.setItem — consumers that need the raw token + // should read it from the store, not from a separate 'access_token' key. set({ token, merchant }); }, logout: () => { - localStorage.removeItem('access_token'); + // Single write path: clearing state triggers persist to overwrite the key. set({ token: null, merchant: null }); }, + _setHasHydrated: (value) => set({ hasHydrated: value }), }), - { name: 'dupdub-auth' }, + { + name: 'dupdub-auth', + onRehydrateStorage: () => (state) => { + // Mark hydration complete so the dashboard layout can safely evaluate + // the auth guard without racing against the async localStorage read. + state?._setHasHydrated(true); + }, + }, ), ); diff --git a/src/lib/utils.ts b/src/lib/utils.ts index aa7fe47a..e953ef09 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -27,6 +27,16 @@ export function formatDate(date: string | Date): string { }).format(new Date(date)); } +import { + Clock, + AlertCircle, + CheckCircle, + RefreshCw, + XCircle, + HelpCircle, + type LucideIcon, +} from 'lucide-react'; + export const STATUS_COLORS: Record = { pending: 'bg-yellow-100 text-yellow-800', pending_approval: 'bg-orange-100 text-orange-800', @@ -39,6 +49,22 @@ export const STATUS_COLORS: Record = { expired: 'bg-gray-100 text-gray-800', }; +/** + * Distinguishing icon per status so that state is never conveyed by color alone. + * Use alongside STATUS_COLORS in badge/pill elements to meet WCAG 1.4.1 (Use of Color). + */ +export const STATUS_ICONS: Record = { + pending: Clock, + pending_approval: AlertCircle, + confirmed: CheckCircle, + processing: RefreshCw, + settling: RefreshCw, + settled: CheckCircle, + completed: CheckCircle, + failed: XCircle, + expired: HelpCircle, +}; + /** @deprecated use STATUS_COLORS */ export const PAYMENT_STATUS_COLORS = STATUS_COLORS;