Speed up accounting sidebar navigation with shared session cache and prefetch.

Stop refetching /me on every page mount, warm shared accounting queries, prefetch nav routes, and keep the shell visible during soft SPA transitions.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Mortezakoohjani 2026-07-26 18:31:24 +03:30
parent 8caed2cfd2
commit 86b37c1de9
6 changed files with 221 additions and 47 deletions

View File

@ -2,28 +2,65 @@
import { AuthGuard } from "@/components/AuthGuard";
import { AccountingShell } from "@/components/accounting/AccountingShell";
import { RouteProgress } from "@/components/RouteProgress";
import { useMe } from "@/hooks/useMe";
import { accountingApi } from "@/lib/accounting-api";
import { useQuery } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { LoadingState, ErrorState } from "@/components/ds";
/** Prefetch shared accounting lookups so sidebar hops skip cold API waits. */
function useWarmAccountingCache(tenantId: string | null) {
useQuery({
queryKey: ["accounting", tenantId, "accounts"],
queryFn: () => accountingApi.accounts.list(tenantId!),
enabled: !!tenantId,
staleTime: 60_000,
});
useQuery({
queryKey: ["accounting", tenantId, "fiscal-periods"],
queryFn: () => accountingApi.fiscal.listPeriods(tenantId!),
enabled: !!tenantId,
staleTime: 60_000,
});
useQuery({
queryKey: ["accounting", tenantId, "posting-policy"],
queryFn: () => accountingApi.setup.getPostingPolicy(tenantId!),
enabled: !!tenantId,
staleTime: 60_000,
});
}
function AccountingGate({ children }: { children: React.ReactNode }) {
const { me, loading, error, reload } = useMe();
const router = useRouter();
const tenantId = me?.current_tenant_id ?? null;
useWarmAccountingCache(tenantId);
useEffect(() => {
if (loading) return;
if (me?.onboarding_required) router.replace("/onboarding");
}, [me, loading, router]);
if (loading) return <LoadingState label="در حال آماده‌سازی ماژول حسابداری…" />;
if (error) return <ErrorState message={error} onRetry={reload} />;
if (!me?.current_tenant_id) {
// Keep shell mounted once session is known — never blank the sidebar on soft nav.
if (loading && !me) return <LoadingState label="در حال آماده‌سازی ماژول حسابداری…" />;
if (error && !me) return <ErrorState message={error} onRetry={reload} />;
if (!loading && !me?.current_tenant_id) {
return (
<ErrorState message="برای استفاده از حسابداری باید یک workspace فعال داشته باشید." />
);
}
return <AccountingShell>{children}</AccountingShell>;
if (!me?.current_tenant_id) {
return <LoadingState label="در حال آماده‌سازی ماژول حسابداری…" />;
}
return (
<>
<RouteProgress />
<AccountingShell>{children}</AccountingShell>
</>
);
}
export default function AccountingLayout({ children }: { children: React.ReactNode }) {

View File

@ -0,0 +1,60 @@
"use client";
import { Suspense, useEffect, useState } from "react";
import { usePathname, useSearchParams } from "next/navigation";
function RouteProgressInner() {
const pathname = usePathname();
const searchParams = useSearchParams();
const [active, setActive] = useState(false);
useEffect(() => {
const onClick = (e: MouseEvent) => {
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) {
return;
}
const el = (e.target as HTMLElement | null)?.closest?.("a[href]");
if (!el) return;
const href = el.getAttribute("href");
if (!href || href.startsWith("http") || href.startsWith("mailto:") || href.startsWith("#")) {
return;
}
if (!href.startsWith("/")) return;
const nextPath = href.split("?")[0];
if (nextPath === window.location.pathname && href === `${window.location.pathname}${window.location.search}`) {
return;
}
setActive(true);
};
document.addEventListener("click", onClick, true);
return () => document.removeEventListener("click", onClick, true);
}, []);
useEffect(() => {
if (!active) return;
const t = window.setTimeout(() => setActive(false), 350);
return () => window.clearTimeout(t);
}, [pathname, searchParams, active]);
if (!active) return null;
return (
<div className="pointer-events-none fixed inset-x-0 top-0 z-[200] h-[3px] overflow-hidden" aria-hidden>
<div
className="h-full w-full bg-primary"
style={{
animation: "ty-route-progress 0.85s ease-in-out infinite",
transformOrigin: "right center",
}}
/>
</div>
);
}
export function RouteProgress() {
return (
<Suspense fallback={null}>
<RouteProgressInner />
</Suspense>
);
}

View File

@ -2,7 +2,7 @@
import { useMemo, useState } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { usePathname, useRouter } from "next/navigation";
import { ChevronDown, Moon, Sun, ChevronRight, Plus, Lock } from "lucide-react";
import { cn } from "@/lib/utils";
import { useColorMode } from "@/components/providers/ColorModeProvider";
@ -15,6 +15,34 @@ function isActive(pathname: string, href: string) {
return pathname === base || pathname.startsWith(`${base}/`);
}
function NavLink({
href,
className,
children,
}: {
href: string;
className?: string;
children: React.ReactNode;
}) {
const router = useRouter();
return (
<Link
href={href}
prefetch
className={className}
onMouseEnter={() => {
try {
router.prefetch(href);
} catch {
/* ignore */
}
}}
>
{children}
</Link>
);
}
export function AccountingShell({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const { mode, toggle } = useColorMode();
@ -58,7 +86,7 @@ export function AccountingShell({ children }: { children: React.ReactNode }) {
if (!hasChildren && group.href) {
return (
<Link
<NavLink
key={group.id}
href={group.href}
className={cn(
@ -70,7 +98,7 @@ export function AccountingShell({ children }: { children: React.ReactNode }) {
>
<Icon className="h-4 w-4 shrink-0" />
{group.label}
</Link>
</NavLink>
);
}
@ -100,7 +128,7 @@ export function AccountingShell({ children }: { children: React.ReactNode }) {
const active = isActive(pathname, item.href);
const blocked = item.blocked || group.blocked;
return (
<Link
<NavLink
key={item.href + item.label}
href={blocked ? "/accounting/ai" : item.href}
className={cn(
@ -113,7 +141,7 @@ export function AccountingShell({ children }: { children: React.ReactNode }) {
>
{blocked ? <Lock className="h-3 w-3" /> : null}
{item.label}
</Link>
</NavLink>
);
})}
</div>
@ -123,13 +151,13 @@ export function AccountingShell({ children }: { children: React.ReactNode }) {
})}
</nav>
<Link
<NavLink
href="/dashboard"
className="mt-2 flex items-center gap-2 px-3 text-xs text-[var(--muted)] hover:text-secondary"
>
<ChevronRight className="h-3.5 w-3.5" />
بازگشت به workspace
</Link>
</NavLink>
</aside>
<div className="relative min-w-0 flex-1 pb-28 lg:pb-20">
@ -139,7 +167,7 @@ export function AccountingShell({ children }: { children: React.ReactNode }) {
? [{ href: g.href, label: g.label }]
: (g.items ?? []).slice(0, 1).map((i) => ({ href: i.href, label: g.label }))
).map((item) => (
<Link
<NavLink
key={item.href + item.label}
href={item.href}
className={cn(
@ -150,17 +178,16 @@ export function AccountingShell({ children }: { children: React.ReactNode }) {
)}
>
{item.label}
</Link>
</NavLink>
))}
</div>
<div className="mx-auto max-w-7xl px-4 py-6 sm:px-6">{children}</div>
{/* Quick access bar */}
<div className="fixed inset-x-0 bottom-0 z-40 border-t border-[var(--border)] bg-[var(--surface)]/95 backdrop-blur">
<div className="mx-auto flex max-w-5xl items-stretch justify-around gap-1 px-2 py-2 pe-16">
{ACCOUNTING_QUICK_BAR.map((item) => (
<Link
<NavLink
key={item.href}
href={item.href}
className={cn(
@ -169,16 +196,15 @@ export function AccountingShell({ children }: { children: React.ReactNode }) {
)}
>
<span className="truncate">{item.label}</span>
</Link>
</NavLink>
))}
</div>
<Link
<NavLink
href="/accounting/vouchers/new"
className="absolute bottom-3 left-3 flex h-12 w-12 items-center justify-center rounded-full bg-primary text-white shadow-lg"
aria-label="سند جدید"
>
<Plus className="h-6 w-6" />
</Link>
<Plus className="h-6 w-6" aria-label="سند جدید" />
</NavLink>
</div>
</div>
</div>

View File

@ -10,9 +10,11 @@ export function AppProviders({ children }: { children: ReactNode }) {
new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
staleTime: 60_000,
gcTime: 10 * 60_000,
retry: 1,
refetchOnWindowFocus: false,
refetchOnMount: false,
},
},
})

View File

@ -1,37 +1,41 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useCallback } from "react";
import { api, type MeResponse } from "@/lib/api";
import { getStoredToken } from "@/lib/auth";
/** بارگذاری وضعیت کاربر جاری (عضویت‌ها و نیاز به onboarding) از `/api/v1/me`. */
export function useMe() {
const [me, setMe] = useState<MeResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
export const ME_QUERY_KEY = ["me"] as const;
const load = useCallback(async () => {
/** Shared user/tenant session — cached so sidebar navigations do not refetch /me. */
export function useMe() {
const qc = useQueryClient();
const hasToken = typeof window !== "undefined" ? !!getStoredToken() : false;
const q = useQuery({
queryKey: ME_QUERY_KEY,
queryFn: () => api.me.get(),
enabled: hasToken,
staleTime: 5 * 60_000,
gcTime: 30 * 60_000,
refetchOnMount: false,
refetchOnWindowFocus: false,
retry: 1,
});
const reload = useCallback(async () => {
if (!getStoredToken()) {
setMe(null);
setLoading(false);
qc.setQueryData<MeResponse | null>(ME_QUERY_KEY, null);
return;
}
setLoading(true);
setError("");
try {
const data = await api.me.get();
setMe(data);
} catch (e) {
setError(e instanceof Error ? e.message : "خطا در دریافت اطلاعات کاربر");
setMe(null);
} finally {
setLoading(false);
}
}, []);
await q.refetch();
}, [qc, q]);
useEffect(() => {
load();
}, [load]);
return { me, loading, error, reload: load };
return {
me: q.data ?? null,
/** True only on the first network load (cached navigations stay instant). */
loading: hasToken ? q.isLoading : false,
error: q.error instanceof Error ? q.error.message : q.error ? String(q.error) : "",
reload,
};
}

View File

@ -24,6 +24,23 @@
--radius-sm: 0.5rem;
--radius-md: 0.75rem;
--radius-lg: 1rem;
/* Healthcare module accents */
--health-accent: #0d9488;
--health-accent-soft: rgba(13, 148, 136, 0.12);
--health-calm: #14b8a6;
--health-success: #059669;
--health-warning: #d97706;
--health-danger: #dc2626;
/* Beauty module accents — warm luxury palette */
--beauty-accent: #b76e79;
--beauty-accent-soft: rgba(183, 110, 121, 0.14);
--beauty-warm: #c9a87c;
--beauty-calm: #d4a5a5;
--beauty-success: #059669;
--beauty-warning: #d97706;
--beauty-danger: #dc2626;
}
.dark {
@ -41,6 +58,21 @@
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.35);
--shadow-md: 0 8px 24px rgba(0, 0, 0, 0.45);
--shadow-lg: 0 16px 40px rgba(0, 0, 0, 0.55);
--health-accent: #2dd4bf;
--health-accent-soft: rgba(45, 212, 191, 0.15);
--health-calm: #5eead4;
--health-success: #34d399;
--health-warning: #fbbf24;
--health-danger: #f87171;
--beauty-accent: #e8b4bc;
--beauty-accent-soft: rgba(232, 180, 188, 0.18);
--beauty-warm: #d4b896;
--beauty-calm: #e8c4c4;
--beauty-success: #34d399;
--beauty-warning: #fbbf24;
--beauty-danger: #f87171;
}
body {
@ -56,3 +88,16 @@ body {
overflow: hidden;
}
}
@keyframes ty-route-progress {
0% {
transform: translateX(100%);
}
50% {
transform: translateX(0%);
}
100% {
transform: translateX(-100%);
}
}