create-tigra 3.0.3 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/README.md +8 -2
  2. package/bin/create-tigra.js +5 -19
  3. package/modules/email-verification/client/hooks/useVerification.ts +3 -3
  4. package/package.json +1 -1
  5. package/template/.agents/skills/security-audit/AI-AND-LLM.md +83 -0
  6. package/template/.agents/skills/security-audit/ATTACK-CLASSES.md +130 -0
  7. package/template/.agents/skills/security-audit/CLIENT-SIDE.md +83 -0
  8. package/template/.agents/skills/security-audit/CLOUD-AND-DEPLOYMENT.md +86 -0
  9. package/template/.agents/skills/security-audit/DATA-ISOLATION-AND-LIFECYCLE.md +84 -0
  10. package/template/.agents/skills/security-audit/DESKTOP-MOBILE-AND-LOCAL-IPC.md +89 -0
  11. package/template/.agents/skills/security-audit/HUNTING.md +251 -0
  12. package/template/.agents/skills/security-audit/LICENSE +21 -0
  13. package/template/.agents/skills/security-audit/MEMORY-SAFETY-AND-BINARY.md +101 -0
  14. package/template/.agents/skills/security-audit/PROTOCOLS-RPC-AND-MESSAGING.md +81 -0
  15. package/template/.agents/skills/security-audit/RECONNAISSANCE.md +156 -0
  16. package/template/.agents/skills/security-audit/RESOURCE-EXHAUSTION-AND-AVAILABILITY.md +78 -0
  17. package/template/.agents/skills/security-audit/SKILL.md +192 -0
  18. package/template/.agents/skills/security-audit/SOURCE.md +5 -0
  19. package/template/.agents/skills/security-audit/SUPPLY-CHAIN-AND-RELEASE.md +73 -0
  20. package/template/.agents/skills/security-audit/VALIDATION-AND-REPORTING.md +186 -0
  21. package/template/.agents/skills/security-audit/WEB-PROTOCOL-AND-AUTH.md +105 -0
  22. package/template/.agents/skills/security-audit/report-schema.json +461 -0
  23. package/template/.agents/skills/security-audit/validate-coverage-ledger.cjs +872 -0
  24. package/template/.agents/skills/security-audit/validate-coverage-ledger.test.cjs +740 -0
  25. package/template/.agents/skills/security-audit/validate-findings.cjs +773 -0
  26. package/template/.agents/skills/security-audit/validate-findings.test.cjs +652 -0
  27. package/template/AGENTS.md +45 -0
  28. package/template/client/AGENTS.md +22 -0
  29. package/template/client/Dockerfile +11 -1
  30. package/template/client/package-lock.json +410 -324
  31. package/template/client/package.json +4 -4
  32. package/template/client/scripts/next-dev.cjs +47 -0
  33. package/template/client/src/app/(auth)/layout.tsx +9 -0
  34. package/template/client/src/app/(auth)/loading.tsx +7 -0
  35. package/template/client/src/app/(main)/layout.tsx +11 -0
  36. package/template/client/src/app/(main)/loading.tsx +7 -0
  37. package/template/client/src/app/globals.css +4 -0
  38. package/template/client/src/app/loading.tsx +2 -6
  39. package/template/client/src/app/not-found.tsx +2 -3
  40. package/template/client/src/app/providers.tsx +6 -3
  41. package/template/client/src/components/common/AppLink.tsx +84 -0
  42. package/template/client/src/components/common/EmptyState.tsx +2 -2
  43. package/template/client/src/components/common/Pagination.tsx +3 -2
  44. package/template/client/src/components/common/RouteLoadingShell.tsx +21 -0
  45. package/template/client/src/components/common/SmoothNavigationProvider.tsx +151 -0
  46. package/template/client/src/components/layout/Header.tsx +12 -12
  47. package/template/client/src/features/admin/hooks/useAdminSessions.ts +2 -2
  48. package/template/client/src/features/admin/hooks/useAdminUsers.ts +3 -3
  49. package/template/client/src/features/auth/components/AuthInitializer.tsx +3 -2
  50. package/template/client/src/features/auth/components/LoginForm.tsx +3 -3
  51. package/template/client/src/features/auth/components/RegisterForm.tsx +3 -3
  52. package/template/client/src/features/auth/hooks/useAuth.ts +2 -2
  53. package/template/client/src/features/auth/hooks/usePasswordReset.ts +2 -2
  54. package/template/client/src/hooks/useAppRouter.ts +40 -0
  55. package/template/client/src/instrumentation-client.ts +29 -21
  56. package/template/client/src/instrumentation.ts +21 -17
  57. package/template/client/src/styles/themes/default.css +1 -1
  58. package/template/gitignore +0 -6
  59. package/template/server/AGENTS.md +28 -0
  60. package/template/server/Dockerfile +9 -3
  61. package/template/server/package-lock.json +671 -522
  62. package/template/server/package.json +8 -8
  63. package/template/server/src/modules/auth/__tests__/auth.service.test.ts +25 -41
  64. package/template/server/src/modules/auth/auth.repo.ts +0 -12
  65. package/template/server/src/modules/auth/auth.service.ts +29 -57
  66. package/template/_claude/QUICK_REFERENCE.md +0 -193
  67. package/template/_claude/README.md +0 -53
  68. package/template/_claude/commands/create-client.md +0 -878
  69. package/template/_claude/commands/create-server.md +0 -388
  70. package/template/_claude/hooks/restrict-paths.sh +0 -51
  71. package/template/_claude/rules/client/01-project-structure.md +0 -147
  72. package/template/_claude/rules/client/02-components-and-types.md +0 -146
  73. package/template/_claude/rules/client/03-data-and-state.md +0 -195
  74. package/template/_claude/rules/client/04-design-system.md +0 -408
  75. package/template/_claude/rules/client/05-security.md +0 -55
  76. package/template/_claude/rules/client/06-ux-checklist.md +0 -111
  77. package/template/_claude/rules/client/07-deployment.md +0 -99
  78. package/template/_claude/rules/client/08-lockfile-cross-platform.md +0 -79
  79. package/template/_claude/rules/client/core.md +0 -46
  80. package/template/_claude/rules/global/completion-reports.md +0 -178
  81. package/template/_claude/rules/global/core.md +0 -104
  82. package/template/_claude/rules/global/investigation-before-conclusions.md +0 -57
  83. package/template/_claude/rules/server/core.md +0 -52
  84. package/template/_claude/rules/server/database.md +0 -124
  85. package/template/_claude/rules/server/deployment.md +0 -78
  86. package/template/_claude/rules/server/project-conventions.md +0 -254
  87. package/template/_claude/rules/server/response-handling.md +0 -144
  88. package/template/_claude/settings.json +0 -15
  89. package/template/_claude/skills/clean-ui/SKILL.md +0 -63
  90. package/template/_claude/skills/role/SKILL.md +0 -39
  91. package/template/_claude/skills/theme/SKILL.md +0 -109
@@ -3,7 +3,7 @@
3
3
  "version": "0.1.0",
4
4
  "private": true,
5
5
  "scripts": {
6
- "dev": "next dev",
6
+ "dev": "node ./scripts/next-dev.cjs",
7
7
  "build": "next build",
8
8
  "start": "next start",
9
9
  "lint": "eslint src/",
@@ -16,11 +16,11 @@
16
16
  "@reduxjs/toolkit": "^2.11.2",
17
17
  "@sentry/nextjs": "^10.62.0",
18
18
  "@tanstack/react-query": "^5.90.21",
19
- "axios": "^1.13.5",
19
+ "axios": "^1.20.0",
20
20
  "class-variance-authority": "^0.7.1",
21
21
  "clsx": "^2.1.1",
22
22
  "lucide-react": "^0.563.0",
23
- "next": "16.2.9",
23
+ "next": "16.3.5",
24
24
  "next-themes": "^0.4.6",
25
25
  "radix-ui": "^1.4.3",
26
26
  "react": "19.2.3",
@@ -41,7 +41,7 @@
41
41
  "@types/react": "^19",
42
42
  "@types/react-dom": "^19",
43
43
  "eslint": "^9",
44
- "eslint-config-next": "16.2.9",
44
+ "eslint-config-next": "16.3.5",
45
45
  "shadcn": "^3.8.4",
46
46
  "tailwindcss": "^4",
47
47
  "tw-animate-css": "^1.4.0",
@@ -0,0 +1,47 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ const { spawn } = require("node:child_process");
5
+
6
+ const DEFAULT_MAX_OLD_SPACE_SIZE = "768";
7
+ const nextBin = require.resolve("next/dist/bin/next");
8
+
9
+ function applyOldSpaceLimit(nodeOptions, sizeMb) {
10
+ if (/(^|\s)--max[-_]old[-_]space[-_]size(=|\s|$)/.test(nodeOptions)) {
11
+ return nodeOptions;
12
+ }
13
+
14
+ return [nodeOptions, `--max-old-space-size=${sizeMb}`].filter(Boolean).join(" ").trim();
15
+ }
16
+
17
+ const env = { ...process.env };
18
+ env.NEXT_DISABLE_MEM_OVERRIDE = "1";
19
+ env.NODE_OPTIONS = applyOldSpaceLimit(
20
+ env.NODE_OPTIONS || "",
21
+ env.NEXT_DEV_MAX_OLD_SPACE_SIZE || DEFAULT_MAX_OLD_SPACE_SIZE,
22
+ );
23
+
24
+ const args = [
25
+ nextBin,
26
+ "dev",
27
+ "--webpack",
28
+ "--disable-source-maps",
29
+ "--no-server-fast-refresh",
30
+ ...process.argv.slice(2),
31
+ ];
32
+
33
+ const child = spawn(process.execPath, args, { stdio: "inherit", env });
34
+
35
+ child.on("error", (error) => {
36
+ console.error(error);
37
+ process.exit(1);
38
+ });
39
+
40
+ child.on("exit", (code, signal) => {
41
+ if (signal) {
42
+ process.kill(process.pid, signal);
43
+ return;
44
+ }
45
+
46
+ process.exit(code ?? 0);
47
+ });
@@ -0,0 +1,9 @@
1
+ import type React from 'react';
2
+
3
+ export default function AuthRouteLayout({
4
+ children,
5
+ }: Readonly<{
6
+ children: React.ReactNode;
7
+ }>): React.ReactElement {
8
+ return <main className="flex min-h-dvh items-center justify-center p-4">{children}</main>;
9
+ }
@@ -0,0 +1,7 @@
1
+ import type React from 'react';
2
+
3
+ import { RouteLoadingShell } from '@/components/common/RouteLoadingShell';
4
+
5
+ export default function AuthRouteLoading(): React.ReactElement {
6
+ return <RouteLoadingShell className="min-h-dvh" />;
7
+ }
@@ -0,0 +1,11 @@
1
+ import type React from 'react';
2
+
3
+ import { MainLayout } from '@/components/layout/MainLayout';
4
+
5
+ export default function MainRouteLayout({
6
+ children,
7
+ }: Readonly<{
8
+ children: React.ReactNode;
9
+ }>): React.ReactElement {
10
+ return <MainLayout>{children}</MainLayout>;
11
+ }
@@ -0,0 +1,7 @@
1
+ import type React from 'react';
2
+
3
+ import { RouteLoadingShell } from '@/components/common/RouteLoadingShell';
4
+
5
+ export default function MainRouteLoading(): React.ReactElement {
6
+ return <RouteLoadingShell />;
7
+ }
@@ -91,6 +91,7 @@
91
91
  html {
92
92
  @apply bg-background;
93
93
  overflow-x: hidden;
94
+ scroll-behavior: smooth;
94
95
  -webkit-text-size-adjust: 100%;
95
96
  text-size-adjust: 100%;
96
97
  }
@@ -100,4 +101,7 @@
100
101
  overflow-x: hidden;
101
102
  touch-action: manipulation;
102
103
  }
104
+ html[data-smooth-navigation="pending"] body {
105
+ min-height: var(--smooth-navigation-min-height);
106
+ }
103
107
  }
@@ -1,11 +1,7 @@
1
1
  import type React from 'react';
2
2
 
3
- import { LoadingSpinner } from '@/components/common/LoadingSpinner';
3
+ import { RouteLoadingShell } from '@/components/common/RouteLoadingShell';
4
4
 
5
5
  export default function Loading(): React.ReactElement {
6
- return (
7
- <div className="flex min-h-dvh items-center justify-center">
8
- <LoadingSpinner size="lg" />
9
- </div>
10
- );
6
+ return <RouteLoadingShell className="min-h-dvh" />;
11
7
  }
@@ -1,10 +1,9 @@
1
1
  import type React from 'react';
2
2
 
3
- import Link from 'next/link';
4
-
5
3
  import { FileQuestion } from 'lucide-react';
6
4
 
7
5
  import { Button } from '@/components/ui/button';
6
+ import { AppLink } from '@/components/common/AppLink';
8
7
  import { ROUTES } from '@/lib/constants/routes';
9
8
 
10
9
  export default function NotFound(): React.ReactElement {
@@ -16,7 +15,7 @@ export default function NotFound(): React.ReactElement {
16
15
  The page you&apos;re looking for doesn&apos;t exist or has been moved.
17
16
  </p>
18
17
  <Button asChild>
19
- <Link href={ROUTES.HOME}>Go home</Link>
18
+ <AppLink href={ROUTES.HOME}>Go home</AppLink>
20
19
  </Button>
21
20
  </div>
22
21
  );
@@ -8,6 +8,7 @@ import { Provider as ReduxProvider } from 'react-redux';
8
8
  import { ThemeProvider } from 'next-themes';
9
9
  import { Toaster } from 'sonner';
10
10
 
11
+ import { SmoothNavigationProvider } from '@/components/common/SmoothNavigationProvider';
11
12
  import { store } from '@/store';
12
13
  import { AuthInitializer } from '@/features/auth/components/AuthInitializer';
13
14
 
@@ -44,9 +45,11 @@ export function Providers({
44
45
  enableColorScheme={false}
45
46
  nonce={nonce}
46
47
  >
47
- <AuthInitializer>
48
- {children}
49
- </AuthInitializer>
48
+ <SmoothNavigationProvider>
49
+ <AuthInitializer>
50
+ {children}
51
+ </AuthInitializer>
52
+ </SmoothNavigationProvider>
50
53
  <Toaster position="top-right" richColors />
51
54
  </ThemeProvider>
52
55
  </QueryClientProvider>
@@ -0,0 +1,84 @@
1
+ 'use client';
2
+
3
+ import type React from 'react';
4
+ import { forwardRef, useRef } from 'react';
5
+
6
+ import NextLink, { type LinkProps as NextLinkProps } from 'next/link';
7
+
8
+ import { useSmoothNavigation } from './SmoothNavigationProvider';
9
+
10
+ type AnchorProps = Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, keyof NextLinkProps>;
11
+
12
+ export type AppLinkProps = Omit<NextLinkProps, 'scroll'> & AnchorProps;
13
+
14
+ export const AppLink = forwardRef<HTMLAnchorElement, AppLinkProps>(
15
+ ({ onClick, onNavigate, ...props }, ref): React.ReactElement => {
16
+ const { startNavigation } = useSmoothNavigation();
17
+ const pendingDestinationRef = useRef<string | null>(null);
18
+
19
+ return (
20
+ <NextLink
21
+ {...props}
22
+ ref={ref}
23
+ scroll={false}
24
+ onClick={(event) => {
25
+ onClick?.(event);
26
+ pendingDestinationRef.current = null;
27
+ if (
28
+ event.defaultPrevented ||
29
+ event.button !== 0 ||
30
+ event.metaKey ||
31
+ event.ctrlKey ||
32
+ event.shiftKey ||
33
+ event.altKey
34
+ ) {
35
+ return;
36
+ }
37
+
38
+ const anchor = event.currentTarget;
39
+ const destination = new URL(anchor.href);
40
+ const current = new URL(window.location.href);
41
+ const opensElsewhere = Boolean(anchor.target && anchor.target !== '_self');
42
+ const isHashOnly =
43
+ destination.pathname === current.pathname &&
44
+ destination.search === current.search &&
45
+ Boolean(destination.hash);
46
+
47
+ if (
48
+ opensElsewhere ||
49
+ anchor.hasAttribute('download') ||
50
+ destination.origin !== current.origin ||
51
+ isHashOnly
52
+ ) {
53
+ return;
54
+ }
55
+
56
+ // onNavigate runs after onClick only for client-side navigations. Wait
57
+ // for it so a consumer can still prevent navigation without briefly
58
+ // showing a loader or starting the scroll animation.
59
+ pendingDestinationRef.current = destination.href;
60
+ }}
61
+ onNavigate={(event) => {
62
+ let navigationPrevented = false;
63
+ onNavigate?.({
64
+ preventDefault: () => {
65
+ navigationPrevented = true;
66
+ event.preventDefault();
67
+ },
68
+ });
69
+ if (navigationPrevented) {
70
+ pendingDestinationRef.current = null;
71
+ return;
72
+ }
73
+
74
+ if (pendingDestinationRef.current) {
75
+ startNavigation(pendingDestinationRef.current);
76
+ pendingDestinationRef.current = null;
77
+ }
78
+ }}
79
+ />
80
+ );
81
+ }
82
+ );
83
+
84
+ AppLink.displayName = 'AppLink';
@@ -1,9 +1,9 @@
1
1
  import type React from 'react';
2
2
 
3
- import Link from 'next/link';
4
3
  import { FileQuestion } from 'lucide-react';
5
4
 
6
5
  import { Button } from '@/components/ui/button';
6
+ import { AppLink } from './AppLink';
7
7
 
8
8
  interface EmptyStateProps {
9
9
  title: string;
@@ -24,7 +24,7 @@ export const EmptyState = ({
24
24
  <p className="mb-4 text-muted-foreground">{description}</p>
25
25
  {actionLabel && actionHref && (
26
26
  <Button asChild>
27
- <Link href={actionHref}>{actionLabel}</Link>
27
+ <AppLink href={actionHref}>{actionLabel}</AppLink>
28
28
  </Button>
29
29
  )}
30
30
  </div>
@@ -3,10 +3,11 @@
3
3
  import type React from 'react';
4
4
  import { Suspense, useCallback } from 'react';
5
5
 
6
- import { useRouter, useSearchParams, usePathname } from 'next/navigation';
6
+ import { useSearchParams, usePathname } from 'next/navigation';
7
7
  import { ChevronLeft, ChevronRight } from 'lucide-react';
8
8
 
9
9
  import { Button } from '@/components/ui/button';
10
+ import { useAppRouter } from '@/hooks/useAppRouter';
10
11
 
11
12
  interface PaginationProps {
12
13
  page: number;
@@ -14,7 +15,7 @@ interface PaginationProps {
14
15
  }
15
16
 
16
17
  const PaginationInner = ({ page, totalPages }: PaginationProps): React.ReactElement => {
17
- const router = useRouter();
18
+ const router = useAppRouter();
18
19
  const pathname = usePathname();
19
20
  const searchParams = useSearchParams();
20
21
 
@@ -0,0 +1,21 @@
1
+ import type React from 'react';
2
+
3
+ import { LoadingSpinner } from './LoadingSpinner';
4
+ import { cn } from '@/lib/utils';
5
+
6
+ /**
7
+ * Shared fallback for route transitions that are waiting on server data.
8
+ * Keep this intentionally generic so feature pages can later replace it with
9
+ * a layout-matching skeleton without changing the navigation contract.
10
+ */
11
+ interface RouteLoadingShellProps {
12
+ className?: string;
13
+ }
14
+
15
+ export const RouteLoadingShell = ({ className }: RouteLoadingShellProps): React.ReactElement => {
16
+ return (
17
+ <div className={cn('flex min-h-[calc(100dvh-8rem)] items-center justify-center p-6', className)}>
18
+ <LoadingSpinner size="lg" />
19
+ </div>
20
+ );
21
+ };
@@ -0,0 +1,151 @@
1
+ 'use client';
2
+
3
+ import type React from 'react';
4
+ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
5
+
6
+ import { usePathname, useSearchParams } from 'next/navigation';
7
+
8
+ import { RouteLoadingShell } from './RouteLoadingShell';
9
+
10
+ const MAX_SMOOTH_SCROLL_MS = 1500;
11
+ const NAVIGATION_FAILSAFE_MS = 30_000;
12
+
13
+ interface SmoothNavigationContextValue {
14
+ startNavigation: (destination: string) => void;
15
+ }
16
+
17
+ const SmoothNavigationContext = createContext<SmoothNavigationContextValue | null>(null);
18
+
19
+ interface SmoothNavigationProviderProps {
20
+ children: React.ReactNode;
21
+ }
22
+
23
+ export const SmoothNavigationProvider = ({
24
+ children,
25
+ }: SmoothNavigationProviderProps): React.ReactElement => {
26
+ const pathname = usePathname();
27
+ const searchParams = useSearchParams();
28
+ const [isNavigating, setIsNavigating] = useState(false);
29
+ const animationFrameRef = useRef<number | null>(null);
30
+ const destinationRef = useRef<string | null>(null);
31
+ const originRef = useRef<string | null>(null);
32
+ const navigationTimeoutRef = useRef<number | null>(null);
33
+ const timeoutRef = useRef<number | null>(null);
34
+
35
+ const clearTransitionState = useCallback((): void => {
36
+ if (animationFrameRef.current !== null) {
37
+ window.cancelAnimationFrame(animationFrameRef.current);
38
+ animationFrameRef.current = null;
39
+ }
40
+ if (timeoutRef.current !== null) {
41
+ window.clearTimeout(timeoutRef.current);
42
+ timeoutRef.current = null;
43
+ }
44
+
45
+ const root = document.documentElement;
46
+ delete root.dataset.smoothNavigation;
47
+ root.style.removeProperty('--smooth-navigation-min-height');
48
+ }, []);
49
+
50
+ const finishNavigation = useCallback((): void => {
51
+ if (navigationTimeoutRef.current !== null) {
52
+ window.clearTimeout(navigationTimeoutRef.current);
53
+ navigationTimeoutRef.current = null;
54
+ }
55
+ destinationRef.current = null;
56
+ originRef.current = null;
57
+ setIsNavigating(false);
58
+ }, []);
59
+
60
+ const cancelNavigation = useCallback((): void => {
61
+ finishNavigation();
62
+ clearTransitionState();
63
+ }, [clearTransitionState, finishNavigation]);
64
+
65
+ const startNavigation = useCallback((destination: string): void => {
66
+ clearTransitionState();
67
+
68
+ const root = document.documentElement;
69
+ const documentHeight = Math.max(root.scrollHeight, document.body.scrollHeight, root.clientHeight);
70
+
71
+ // The route fallback can be shorter than the outgoing page. Preserve its
72
+ // height until scrolling reaches the top so the browser cannot clamp the
73
+ // scroll position and turn a smooth transition into an instant jump.
74
+ root.dataset.smoothNavigation = 'pending';
75
+ root.style.setProperty('--smooth-navigation-min-height', `${documentHeight}px`);
76
+
77
+ window.scrollTo({ top: 0, left: 0, behavior: 'smooth' });
78
+
79
+ const startedAt = window.performance.now();
80
+ const monitorScroll = (): void => {
81
+ const timedOut = window.performance.now() - startedAt >= MAX_SMOOTH_SCROLL_MS;
82
+ if (Math.abs(window.scrollY) <= 1 || timedOut) {
83
+ clearTransitionState();
84
+ return;
85
+ }
86
+ animationFrameRef.current = window.requestAnimationFrame(monitorScroll);
87
+ };
88
+
89
+ animationFrameRef.current = window.requestAnimationFrame(monitorScroll);
90
+ timeoutRef.current = window.setTimeout(clearTransitionState, MAX_SMOOTH_SCROLL_MS + 100);
91
+
92
+ const destinationUrl = new URL(destination, window.location.href);
93
+ const destinationKey = `${destinationUrl.pathname}${destinationUrl.search}`;
94
+ const currentKey = `${window.location.pathname}${window.location.search}`;
95
+ if (destinationKey === currentKey) {
96
+ return;
97
+ }
98
+
99
+ destinationRef.current = destinationKey;
100
+ originRef.current = currentKey;
101
+ setIsNavigating(true);
102
+ if (navigationTimeoutRef.current !== null) {
103
+ window.clearTimeout(navigationTimeoutRef.current);
104
+ }
105
+ navigationTimeoutRef.current = window.setTimeout(cancelNavigation, NAVIGATION_FAILSAFE_MS);
106
+ }, [cancelNavigation, clearTransitionState]);
107
+
108
+ useEffect(() => {
109
+ const currentKey = `${pathname}${searchParams.size > 0 ? `?${searchParams.toString()}` : ''}`;
110
+ if (
111
+ destinationRef.current === currentKey ||
112
+ (originRef.current !== null && originRef.current !== currentKey)
113
+ ) {
114
+ // The new route can commit before the smooth scroll reaches the top.
115
+ // Hide the overlay without releasing the preserved document height. A
116
+ // changed key also handles redirects whose URL differs from the request.
117
+ finishNavigation();
118
+ }
119
+ }, [finishNavigation, pathname, searchParams]);
120
+
121
+ useEffect(
122
+ () => () => {
123
+ if (navigationTimeoutRef.current !== null) {
124
+ window.clearTimeout(navigationTimeoutRef.current);
125
+ }
126
+ clearTransitionState();
127
+ },
128
+ [clearTransitionState]
129
+ );
130
+
131
+ const value = useMemo(() => ({ startNavigation }), [startNavigation]);
132
+
133
+ return (
134
+ <SmoothNavigationContext.Provider value={value}>
135
+ {children}
136
+ {isNavigating && (
137
+ <div className="fixed inset-0 z-40 overflow-hidden bg-background" aria-busy="true">
138
+ <RouteLoadingShell className="min-h-dvh" />
139
+ </div>
140
+ )}
141
+ </SmoothNavigationContext.Provider>
142
+ );
143
+ };
144
+
145
+ export const useSmoothNavigation = (): SmoothNavigationContextValue => {
146
+ const context = useContext(SmoothNavigationContext);
147
+ if (!context) {
148
+ throw new Error('useSmoothNavigation must be used within SmoothNavigationProvider');
149
+ }
150
+ return context;
151
+ };
@@ -3,11 +3,11 @@
3
3
  import type React from 'react';
4
4
  import { useState, useCallback, useEffect, useRef } from 'react';
5
5
 
6
- import Link from 'next/link';
7
6
  import { useTheme } from 'next-themes';
8
7
  import { Sun, Moon, Menu, X, LogOut } from 'lucide-react';
9
8
 
10
9
  import { Button } from '@/components/ui/button';
10
+ import { AppLink } from '@/components/common/AppLink';
11
11
  import { useAppSelector } from '@/store/hooks';
12
12
  import { useAuth } from '@/features/auth/hooks/useAuth';
13
13
  import { APP_NAME } from '@/lib/constants/app.constants';
@@ -50,18 +50,18 @@ export const Header = (): React.ReactElement => {
50
50
  return (
51
51
  <header className="sticky top-0 z-50 border-b border-border/50 bg-background/80 backdrop-blur-md">
52
52
  <div className="container mx-auto flex h-16 items-center justify-between px-4 md:px-6 lg:px-8">
53
- <Link
53
+ <AppLink
54
54
  href={ROUTES.HOME}
55
55
  className="text-xl font-bold tracking-tight transition-colors duration-150 active:opacity-70 md:hover:text-primary"
56
56
  >
57
57
  {APP_NAME}
58
- </Link>
58
+ </AppLink>
59
59
 
60
60
  <nav className="hidden items-center gap-2 md:flex">
61
61
  {isAuthenticated ? (
62
62
  <>
63
63
  <Button variant="ghost" size="sm" asChild>
64
- <Link href={ROUTES.DASHBOARD}>Dashboard</Link>
64
+ <AppLink href={ROUTES.DASHBOARD}>Dashboard</AppLink>
65
65
  </Button>
66
66
  <span className="text-sm text-muted-foreground">
67
67
  {user?.firstName}
@@ -79,10 +79,10 @@ export const Header = (): React.ReactElement => {
79
79
  ) : (
80
80
  <>
81
81
  <Button variant="ghost" size="sm" asChild>
82
- <Link href={ROUTES.LOGIN}>Sign in</Link>
82
+ <AppLink href={ROUTES.LOGIN}>Sign in</AppLink>
83
83
  </Button>
84
84
  <Button size="sm" asChild>
85
- <Link href={ROUTES.REGISTER}>Get started</Link>
85
+ <AppLink href={ROUTES.REGISTER}>Get started</AppLink>
86
86
  </Button>
87
87
  </>
88
88
  )}
@@ -134,9 +134,9 @@ export const Header = (): React.ReactElement => {
134
134
  {isAuthenticated ? (
135
135
  <>
136
136
  <Button variant="ghost" className="w-full justify-start" asChild>
137
- <Link href={ROUTES.DASHBOARD} onClick={closeMobileMenu}>
137
+ <AppLink href={ROUTES.DASHBOARD} onClick={closeMobileMenu}>
138
138
  Dashboard
139
- </Link>
139
+ </AppLink>
140
140
  </Button>
141
141
  <Button
142
142
  variant="ghost"
@@ -154,14 +154,14 @@ export const Header = (): React.ReactElement => {
154
154
  ) : (
155
155
  <>
156
156
  <Button variant="ghost" className="w-full justify-start" asChild>
157
- <Link href={ROUTES.LOGIN} onClick={closeMobileMenu}>
157
+ <AppLink href={ROUTES.LOGIN} onClick={closeMobileMenu}>
158
158
  Sign in
159
- </Link>
159
+ </AppLink>
160
160
  </Button>
161
161
  <Button className="w-full" asChild>
162
- <Link href={ROUTES.REGISTER} onClick={closeMobileMenu}>
162
+ <AppLink href={ROUTES.REGISTER} onClick={closeMobileMenu}>
163
163
  Get started
164
- </Link>
164
+ </AppLink>
165
165
  </Button>
166
166
  </>
167
167
  )}
@@ -1,9 +1,9 @@
1
1
  'use client';
2
2
 
3
3
  import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
4
- import { useRouter } from 'next/navigation';
5
4
  import { toast } from 'sonner';
6
5
 
6
+ import { useAppRouter } from '@/hooks/useAppRouter';
7
7
  import { getErrorMessage } from '@/lib/utils/error';
8
8
  import { adminService } from '../services/admin.service';
9
9
  import { adminKeys } from './useAdminUsers';
@@ -49,7 +49,7 @@ interface UseForceExpireSessionReturn {
49
49
 
50
50
  export const useForceExpireSession = (): UseForceExpireSessionReturn => {
51
51
  const queryClient = useQueryClient();
52
- const router = useRouter();
52
+ const router = useAppRouter();
53
53
 
54
54
  const mutation = useMutation({
55
55
  mutationFn: (sessionId: string) => adminService.deleteSession(sessionId),
@@ -1,9 +1,9 @@
1
1
  'use client';
2
2
 
3
3
  import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
4
- import { useRouter } from 'next/navigation';
5
4
  import { toast } from 'sonner';
6
5
 
6
+ import { useAppRouter } from '@/hooks/useAppRouter';
7
7
  import { getErrorMessage } from '@/lib/utils/error';
8
8
  import { adminService } from '../services/admin.service';
9
9
 
@@ -82,7 +82,7 @@ interface UseUpdateUserStatusReturn {
82
82
 
83
83
  export const useUpdateUserStatus = (): UseUpdateUserStatusReturn => {
84
84
  const queryClient = useQueryClient();
85
- const router = useRouter();
85
+ const router = useAppRouter();
86
86
 
87
87
  const mutation = useMutation({
88
88
  mutationFn: ({ userId, isActive }: { userId: string; isActive: boolean }) =>
@@ -114,7 +114,7 @@ interface UseUpdateUserRoleReturn {
114
114
 
115
115
  export const useUpdateUserRole = (): UseUpdateUserRoleReturn => {
116
116
  const queryClient = useQueryClient();
117
- const router = useRouter();
117
+ const router = useAppRouter();
118
118
 
119
119
  const mutation = useMutation({
120
120
  mutationFn: ({ userId, role }: { userId: string; role: 'USER' | 'ADMIN' }) =>
@@ -3,11 +3,12 @@
3
3
  import type React from 'react';
4
4
  import { useEffect } from 'react';
5
5
 
6
- import { usePathname, useRouter } from 'next/navigation';
6
+ import { usePathname } from 'next/navigation';
7
7
 
8
8
  import { toast } from 'sonner';
9
9
 
10
10
  import { useAppSelector } from '@/store/hooks';
11
+ import { useAppRouter } from '@/hooks/useAppRouter';
11
12
  import { ROUTES } from '@/lib/constants/routes';
12
13
  import { isErrorCode, ERROR_CODES } from '@/lib/utils/error';
13
14
  import { useCurrentUser } from '../hooks/useCurrentUser';
@@ -37,7 +38,7 @@ interface HttpLikeError {
37
38
 
38
39
  export const AuthInitializer = ({ children }: AuthInitializerProps): React.ReactElement => {
39
40
  const pathname = usePathname();
40
- const router = useRouter();
41
+ const router = useAppRouter();
41
42
  const { isLoggingOut } = useAppSelector((state) => state.auth);
42
43
 
43
44
  // Skip getMe() on auth pages and during logout.