create-tigra 2.7.1 → 2.8.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-tigra",
3
- "version": "2.7.1",
3
+ "version": "2.8.0",
4
4
  "type": "module",
5
5
  "description": "Create a production-ready full-stack app with Next.js 16 + Fastify 5 + Prisma + Redis",
6
6
  "bin": {
@@ -25,12 +25,15 @@
25
25
  Defaults in `app/providers.tsx`:
26
26
 
27
27
  ```typescript
28
- staleTime: 5 * 60 * 1000 // 5 min
29
- gcTime: 10 * 60 * 1000 // 10 min
30
- refetchOnWindowFocus: false
28
+ staleTime: 30 * 1000 // 30s — short enough that back-navigation refetches
29
+ gcTime: 5 * 60 * 1000 // 5 min
30
+ refetchOnWindowFocus: true // catch cross-tab edits
31
+ refetchOnMount: true // always refetch stale data on mount
31
32
  retry: 1
32
33
  ```
33
34
 
35
+ **Why these values matter**: With a long `staleTime` (e.g. 5 min) and `refetchOnWindowFocus: false`, navigating back to a list page after editing a record on another page will show stale data until the user hard-refreshes. Keep `staleTime` short and let invalidation + remount refetch do their job.
36
+
34
37
  ### Query Key Factory Pattern
35
38
 
36
39
  ```typescript
@@ -45,9 +48,35 @@ export const itemKeys = {
45
48
 
46
49
  ### Mutations
47
50
 
48
- On success: invalidate related queries, show toast, navigate if needed.
51
+ On success:
52
+ 1. **`queryClient.invalidateQueries({ queryKey: ... })`** — refreshes any client-fetched data (React Query).
53
+ 2. **`router.refresh()`** — refreshes any Server-Component-rendered data on the next route the user navigates to. Without this, the Next.js Router Cache will serve stale RSC payloads on back-navigation, and your edit will not appear until a hard refresh.
54
+ 3. Show a toast.
55
+ 4. Navigate if needed.
56
+
49
57
  On error: `toast.error(getErrorMessage(error))`.
50
58
 
59
+ **Always call both `invalidateQueries` AND `router.refresh()`** unless you are 100% certain no Server Component on any reachable route reads the mutated data. The two caches are independent — invalidating one does not touch the other.
60
+
61
+ ```typescript
62
+ const router = useRouter();
63
+ const queryClient = useQueryClient();
64
+
65
+ const mutation = useMutation({
66
+ mutationFn: (data) => itemService.updateItem(id, data),
67
+ onSuccess: () => {
68
+ queryClient.invalidateQueries({ queryKey: itemKeys.all });
69
+ router.refresh();
70
+ toast.success('Item updated');
71
+ },
72
+ onError: (error) => toast.error(getErrorMessage(error)),
73
+ });
74
+ ```
75
+
76
+ ### Next.js Router Cache
77
+
78
+ `next.config.ts` sets `experimental.staleTimes: { dynamic: 0, static: 0 }` to disable client-side Router Cache reuse. **Never raise these values** — doing so reintroduces the back-navigation stale-data bug across every page that uses Server Components for data fetching.
79
+
51
80
  ---
52
81
 
53
82
  ## Redux
@@ -61,7 +90,17 @@ State shape:
61
90
  { user: IUser | null; isAuthenticated: boolean; isInitializing: boolean; isLoggingOut: boolean }
62
91
  ```
63
92
 
64
- **Not persisted to localStorage** — auth state is hydrated on page load by `AuthInitializer` calling `getMe()`. Tokens are stored in httpOnly cookies (not accessible from JS).
93
+ **Not persisted to localStorage** — auth state is hydrated by `AuthInitializer`, which calls the `useCurrentUser()` hook. `useCurrentUser()` is a React Query wrapper around `authService.getMe()` that syncs the result into Redux via a side effect. Tokens are stored in httpOnly cookies (not accessible from JS).
94
+
95
+ **Refreshing the current user**: any mutation that changes the logged-in user's own data (profile update, role change, avatar upload, email verification, subscription change, etc.) MUST invalidate the auth query so Redux picks up the new values:
96
+
97
+ ```typescript
98
+ import { authKeys } from '@/features/auth/hooks/useCurrentUser';
99
+
100
+ queryClient.invalidateQueries({ queryKey: authKeys.me() });
101
+ ```
102
+
103
+ Without this, Redux will hold the stale snapshot from initial page load until the next window-focus refetch (30s staleTime), or until logout/hard refresh. Never write directly to the auth slice from outside the auth feature — always go through invalidation.
65
104
 
66
105
  ---
67
106
 
@@ -37,7 +37,7 @@ X-XSS-Protection: 1; mode=block
37
37
  default-src 'self';
38
38
  script-src 'self' 'unsafe-eval' 'unsafe-inline';
39
39
  style-src 'self' 'unsafe-inline';
40
- img-src 'self' blob: data: https:;
40
+ img-src 'self' blob: data: https: ${apiOrigin};
41
41
  font-src 'self';
42
42
  object-src 'none';
43
43
  base-uri 'self';
@@ -0,0 +1,63 @@
1
+ ---
2
+ name: clean-ui
3
+ description: Remove the starter welcome page UI and replace with a blank canvas, preserving all client-side functionality (auth, hooks, services, store, middleware, utils)
4
+ ---
5
+
6
+ The user wants to remove the starter/welcome UI from the scaffolded client and start with a blank page.
7
+
8
+ ## What this skill does
9
+
10
+ Replaces the demo welcome page (`src/app/page.tsx`) with the absolute bare minimum — just a centered "Ready to build." text. **Nothing else is touched** — all functional infrastructure remains intact.
11
+
12
+ ## What gets replaced
13
+
14
+ | File | Action | Reason |
15
+ |------|--------|--------|
16
+ | `src/app/page.tsx` | **Replace** with blank canvas | Remove ALL demo content — hero, ambient glow, GitHub links, buttons, everything |
17
+
18
+ ## What is NOT touched (preserved as-is)
19
+
20
+ - `src/components/layout/` — Header, Footer, MainLayout
21
+ - `src/app/layout.tsx` — root layout with providers
22
+ - `src/app/providers.tsx` — Redux, React Query, themes, AuthInitializer
23
+ - `src/app/error.tsx`, `src/app/not-found.tsx`, `src/app/loading.tsx`
24
+ - `src/middleware.ts` — route protection
25
+ - `src/features/auth/**` — entire auth system
26
+ - `src/components/common/**` — ThemeToggle, EmptyState, LoadingSpinner, etc.
27
+ - `src/components/ui/**` — all shadcn/ui components
28
+ - `src/hooks/**`, `src/store/**`, `src/lib/**`, `src/styles/**`
29
+ - All config files
30
+
31
+ ## Steps
32
+
33
+ 1. Read `src/app/page.tsx` to confirm it exists.
34
+ 2. Replace its contents with the clean page below. Use the **exact** template — do not add anything.
35
+ 3. Confirm to the user what was done.
36
+
37
+ ## Clean page template
38
+
39
+ Replace `src/app/page.tsx` with **exactly** this — no additions, no modifications:
40
+
41
+ ```tsx
42
+ import type React from 'react';
43
+
44
+ export default function HomePage(): React.ReactElement {
45
+ return (
46
+ <main className="flex min-h-dvh items-center justify-center">
47
+ <p className="text-sm text-muted-foreground">Ready to build.</p>
48
+ </main>
49
+ );
50
+ }
51
+ ```
52
+
53
+ **CRITICAL**: Do NOT add anything beyond what is in the template above. No heading, no links, no buttons, no metadata, no imports beyond React. The entire point is a blank canvas.
54
+
55
+ ## Response format
56
+
57
+ After completing, respond with:
58
+
59
+ ```
60
+ Starter UI cleaned. `src/app/page.tsx` is now a blank canvas.
61
+
62
+ Everything else is untouched — auth, hooks, services, store, middleware, components, and design system are all intact.
63
+ ```
@@ -0,0 +1,109 @@
1
+ ---
2
+ name: theme
3
+ description: Change the color palette in the client theme file. Only swaps HEX values — never touches variable names, file structure, or conventions.
4
+ argument-hint: "[color description or specific HEX values]"
5
+ ---
6
+
7
+ # Theme Palette Changer
8
+
9
+ The user wants to change the color palette. Their input: **$ARGUMENTS**
10
+
11
+ ## Your ONLY job
12
+
13
+ Replace HEX color values in `src/styles/themes/default.css` (or the equivalent theme file in the active project's client directory). That's it.
14
+
15
+ ## What you MUST NOT do
16
+
17
+ - **DO NOT** rename, add, or remove any CSS variable names (e.g., `--primary`, `--background`, `--muted`)
18
+ - **DO NOT** change the file structure, move files, or create new files
19
+ - **DO NOT** touch `globals.css`, components, or any other file
20
+ - **DO NOT** change `--radius` or any non-color value
21
+ - **DO NOT** convert HEX to OKLCH, HSL, or any other format — stay in HEX
22
+ - **DO NOT** remove the `.dark` selector or the `:root` selector
23
+ - **DO NOT** remove or rewrite comments unless updating the palette description at the top
24
+ - **DO NOT** change `rgba()` values to HEX — keep `rgba()` where it already exists (e.g., dark mode `--border`, `--input`)
25
+ - **DO NOT** touch any code outside the theme file — no components, no Tailwind config, no globals
26
+
27
+ ## Before making changes — ask these questions if not answered
28
+
29
+ You MUST gather enough information before editing. If the user's input doesn't cover these, ask BEFORE making any changes:
30
+
31
+ ### Required information
32
+
33
+ 1. **Primary/brand color** — What is the main accent color? (at minimum, you need this)
34
+
35
+ ### Clarifying questions (ask if not addressed)
36
+
37
+ 2. **Dark mode** — one of:
38
+ - "Will you provide dark mode colors yourself, or should I generate them from your palette?"
39
+ - Skip if user explicitly says "light mode only" or provides both sets
40
+
41
+ 3. **Palette vibe/mood** — if the user only gave a single color or vague description:
42
+ - "What's the mood? (e.g., warm, cool, corporate, playful, minimal, luxury)"
43
+ - This helps you pick complementary background, muted, secondary, and accent colors
44
+
45
+ 4. **Background preference** — if not obvious from context:
46
+ - "Do you want a light cream/warm background, a cool/gray background, or pure white?"
47
+
48
+ 5. **Destructive/success/warning/info** — if the user provides a full custom palette but skips these:
49
+ - "Should I keep the current red/green/amber/blue for status colors, or adjust them to match your new palette?"
50
+
51
+ ### When you have enough
52
+
53
+ - User gives a full palette with explicit HEX values for most tokens → just apply them, generate any missing ones
54
+ - User gives a brand color + mood → generate a cohesive palette and present it for approval before applying
55
+ - User gives a full set of colors for both light and dark → apply directly
56
+
57
+ ## How to apply changes
58
+
59
+ 1. **Read** the current `default.css` theme file first
60
+ 2. **Locate** the correct theme file:
61
+ - In a scaffolded project: `client/src/styles/themes/default.css`
62
+ - In the template: `template/client/src/styles/themes/default.css`
63
+ - Check which one exists in the current working directory
64
+ 3. **Present** your proposed palette to the user in a readable table BEFORE editing:
65
+
66
+ | Token | Current | New (Light) | New (Dark) |
67
+ |-------|---------|-------------|------------|
68
+ | --background | #f4f3ee | #... | #... |
69
+ | --primary | #c15f3c | #... | #... |
70
+ | ... | ... | ... | ... |
71
+
72
+ 4. **Wait for user approval** — do not edit until they confirm
73
+ 5. **Edit** the file using the Edit tool — only change HEX values
74
+ 6. **Update** the comment block at the top to reflect the new palette name/vibe (e.g., "Ocean blue palette" instead of "Claude-inspired warm palette")
75
+ 7. **Confirm** what was changed in a brief summary
76
+
77
+ ## Palette generation guidelines
78
+
79
+ When generating colors from a brand color, follow these principles:
80
+
81
+ - **Background**: Very desaturated, light tint of the brand hue (light mode) / very dark shade (dark mode)
82
+ - **Foreground**: Near-black with a hint of the brand hue (light mode) / near-white (dark mode)
83
+ - **Primary**: The brand color itself (light) / slightly lighter/more vibrant version (dark)
84
+ - **Primary-foreground**: White or near-white for contrast against primary
85
+ - **Secondary/muted/accent**: Desaturated, low-contrast versions of the brand palette
86
+ - **Card/popover**: White or very slight tint (light) / slightly elevated dark shade (dark)
87
+ - **Border/input**: Very subtle, low-contrast separator colors
88
+ - **Ring**: Same as primary (focus ring should match brand)
89
+ - **Destructive**: Red family (#e7000b light / #ff6467 dark) — adjust warmth/coolness to match palette
90
+ - **Success**: Green family — adjust to match palette temperature
91
+ - **Warning**: Amber/yellow family — adjust to match palette temperature
92
+ - **Info**: Blue family — adjust to match palette temperature
93
+ - **Chart colors**: 5 distinct, harmonious colors for data visualization
94
+ - **Sidebar**: Slightly different shade than main background for visual separation
95
+
96
+ ### Dark mode rules
97
+
98
+ - Increase brightness of the primary color (not just invert)
99
+ - Background should be very dark (not pure black) with a hint of the brand hue
100
+ - Borders use `rgba()` for subtle transparency — keep this pattern
101
+ - Foreground colors should be off-white, not pure #ffffff
102
+ - Reduce contrast slightly compared to light mode to reduce eye strain
103
+
104
+ ## Edge cases
105
+
106
+ - If user says "make it blue" → ask for a specific shade or suggest 3 options (e.g., ocean #0066cc, royal #4169e1, navy #1a237e)
107
+ - If user provides only RGB or HSL → convert to HEX yourself, don't ask them to convert
108
+ - If user wants to keep some colors and change others → only change the ones they specified
109
+ - If the theme file doesn't exist → tell the user to scaffold the project first, don't create the file
@@ -11,6 +11,9 @@ const apiOrigin = (() => {
11
11
 
12
12
  const nextConfig: NextConfig = {
13
13
  output: "standalone",
14
+ experimental: {
15
+ staleTimes: { dynamic: 0, static: 0 },
16
+ },
14
17
  async headers() {
15
18
  return [
16
19
  {
@@ -26,7 +29,7 @@ const nextConfig: NextConfig = {
26
29
  "default-src 'self'",
27
30
  "script-src 'self' 'unsafe-eval' 'unsafe-inline'",
28
31
  "style-src 'self' 'unsafe-inline'",
29
- "img-src 'self' blob: data: https:",
32
+ `img-src 'self' blob: data: https: ${apiOrigin}`,
30
33
  "font-src 'self'",
31
34
  "object-src 'none'",
32
35
  "base-uri 'self'",
@@ -1,47 +1,47 @@
1
- {
2
- "name": "{{PROJECT_NAME}}-client",
3
- "version": "0.1.0",
4
- "private": true,
5
- "scripts": {
6
- "dev": "next dev",
7
- "build": "next build",
8
- "start": "next start",
9
- "lint": "eslint src/",
10
- "generate:env": "node -e \"import('fs').then(f=>{if(f.existsSync('.env'))console.log('.env already exists, skipping');else{f.copyFileSync('.env.example','.env');console.log('.env created from .env.example')}})\""
11
- },
12
- "dependencies": {
13
- "@hookform/resolvers": "^5.2.2",
14
- "@reduxjs/toolkit": "^2.11.2",
15
- "@tanstack/react-query": "^5.90.21",
16
- "axios": "^1.13.5",
17
- "class-variance-authority": "^0.7.1",
18
- "clsx": "^2.1.1",
19
- "lucide-react": "^0.563.0",
20
- "next": "16.1.6",
21
- "next-themes": "^0.4.6",
22
- "radix-ui": "^1.4.3",
23
- "react": "19.2.3",
24
- "react-dom": "19.2.3",
25
- "react-hook-form": "^7.71.1",
26
- "react-redux": "^9.2.0",
27
- "sonner": "^2.0.7",
28
- "tailwind-merge": "^3.4.0",
29
- "tailwindcss-animate": "^1.0.7",
30
- "zod": "^4.3.6"
31
- },
32
- "overrides": {
33
- "minimatch": ">=10.2.1"
34
- },
35
- "devDependencies": {
36
- "@tailwindcss/postcss": "^4",
37
- "@types/node": "^20",
38
- "@types/react": "^19",
39
- "@types/react-dom": "^19",
40
- "eslint": "^9",
41
- "eslint-config-next": "16.1.6",
42
- "shadcn": "^3.8.4",
43
- "tailwindcss": "^4",
44
- "tw-animate-css": "^1.4.0",
45
- "typescript": "^5"
46
- }
47
- }
1
+ {
2
+ "name": "{{PROJECT_NAME}}-client",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "scripts": {
6
+ "dev": "next dev",
7
+ "build": "next build",
8
+ "start": "next start",
9
+ "lint": "eslint src/",
10
+ "generate:env": "node -e \"import('fs').then(f=>{if(f.existsSync('.env'))console.log('.env already exists, skipping');else{f.copyFileSync('.env.example','.env');console.log('.env created from .env.example')}})\""
11
+ },
12
+ "dependencies": {
13
+ "@hookform/resolvers": "^5.2.2",
14
+ "@reduxjs/toolkit": "^2.11.2",
15
+ "@tanstack/react-query": "^5.90.21",
16
+ "axios": "^1.13.5",
17
+ "class-variance-authority": "^0.7.1",
18
+ "clsx": "^2.1.1",
19
+ "lucide-react": "^0.563.0",
20
+ "next": "16.1.6",
21
+ "next-themes": "^0.4.6",
22
+ "radix-ui": "^1.4.3",
23
+ "react": "19.2.3",
24
+ "react-dom": "19.2.3",
25
+ "react-hook-form": "^7.71.1",
26
+ "react-redux": "^9.2.0",
27
+ "sonner": "^2.0.7",
28
+ "tailwind-merge": "^3.4.0",
29
+ "tailwindcss-animate": "^1.0.7",
30
+ "zod": "^4.3.6"
31
+ },
32
+ "overrides": {
33
+ "minimatch": ">=10.2.1"
34
+ },
35
+ "devDependencies": {
36
+ "@tailwindcss/postcss": "^4",
37
+ "@types/node": "^20",
38
+ "@types/react": "^19",
39
+ "@types/react-dom": "^19",
40
+ "eslint": "^9",
41
+ "eslint-config-next": "16.1.6",
42
+ "shadcn": "^3.8.4",
43
+ "tailwindcss": "^4",
44
+ "tw-animate-css": "^1.4.0",
45
+ "typescript": "^5"
46
+ }
47
+ }
@@ -88,8 +88,16 @@
88
88
  transition-duration: 200ms;
89
89
  transition-timing-function: ease-out;
90
90
  }
91
+ html {
92
+ @apply bg-background;
93
+ overflow-x: hidden;
94
+ -webkit-text-size-adjust: 100%;
95
+ text-size-adjust: 100%;
96
+ }
91
97
  body {
92
98
  @apply bg-background text-foreground;
93
99
  font-feature-settings: "rlig" 1, "calt" 1;
100
+ overflow-x: hidden;
101
+ touch-action: manipulation;
94
102
  }
95
103
  }
@@ -1,4 +1,4 @@
1
- import type { Metadata } from 'next';
1
+ import type { Metadata, Viewport } from 'next';
2
2
  import type React from 'react';
3
3
  import { Inter, JetBrains_Mono } from 'next/font/google';
4
4
 
@@ -21,6 +21,12 @@ export const metadata: Metadata = {
21
21
  description: 'A full-stack application built with Next.js and Fastify',
22
22
  };
23
23
 
24
+ export const viewport: Viewport = {
25
+ width: 'device-width',
26
+ initialScale: 1,
27
+ viewportFit: 'cover',
28
+ };
29
+
24
30
  export default function RootLayout({
25
31
  children,
26
32
  }: Readonly<{
@@ -17,9 +17,10 @@ export function Providers({ children }: { children: React.ReactNode }): React.Re
17
17
  new QueryClient({
18
18
  defaultOptions: {
19
19
  queries: {
20
- staleTime: 5 * 60 * 1000,
21
- gcTime: 10 * 60 * 1000,
22
- refetchOnWindowFocus: false,
20
+ staleTime: 30 * 1000,
21
+ gcTime: 5 * 60 * 1000,
22
+ refetchOnWindowFocus: true,
23
+ refetchOnMount: true,
23
24
  retry: 1,
24
25
  },
25
26
  },
@@ -29,7 +30,7 @@ export function Providers({ children }: { children: React.ReactNode }): React.Re
29
30
  return (
30
31
  <ReduxProvider store={store}>
31
32
  <QueryClientProvider client={queryClient}>
32
- <ThemeProvider attribute="class" defaultTheme="light">
33
+ <ThemeProvider attribute="class" defaultTheme="light" enableColorScheme={false}>
33
34
  <AuthInitializer>
34
35
  {children}
35
36
  </AuthInitializer>
@@ -1,7 +1,7 @@
1
1
  'use client';
2
2
 
3
3
  import type React from 'react';
4
- import { useCallback } from 'react';
4
+ import { Suspense, useCallback } from 'react';
5
5
 
6
6
  import { useRouter, useSearchParams, usePathname } from 'next/navigation';
7
7
  import { ChevronLeft, ChevronRight } from 'lucide-react';
@@ -13,7 +13,7 @@ interface PaginationProps {
13
13
  totalPages: number;
14
14
  }
15
15
 
16
- export const Pagination = ({ page, totalPages }: PaginationProps): React.ReactElement => {
16
+ const PaginationInner = ({ page, totalPages }: PaginationProps): React.ReactElement => {
17
17
  const router = useRouter();
18
18
  const pathname = usePathname();
19
19
  const searchParams = useSearchParams();
@@ -53,3 +53,11 @@ export const Pagination = ({ page, totalPages }: PaginationProps): React.ReactEl
53
53
  </div>
54
54
  );
55
55
  };
56
+
57
+ export const Pagination = ({ page, totalPages }: PaginationProps): React.ReactElement => {
58
+ return (
59
+ <Suspense>
60
+ <PaginationInner page={page} totalPages={totalPages} />
61
+ </Suspense>
62
+ );
63
+ };
@@ -1,6 +1,7 @@
1
1
  'use client';
2
2
 
3
3
  import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
4
+ import { useRouter } from 'next/navigation';
4
5
  import { toast } from 'sonner';
5
6
 
6
7
  import { getErrorMessage } from '@/lib/utils/error';
@@ -48,6 +49,7 @@ interface UseForceExpireSessionReturn {
48
49
 
49
50
  export const useForceExpireSession = (): UseForceExpireSessionReturn => {
50
51
  const queryClient = useQueryClient();
52
+ const router = useRouter();
51
53
 
52
54
  const mutation = useMutation({
53
55
  mutationFn: (sessionId: string) => adminService.deleteSession(sessionId),
@@ -55,6 +57,7 @@ export const useForceExpireSession = (): UseForceExpireSessionReturn => {
55
57
  toast.success('Session expired successfully');
56
58
  queryClient.invalidateQueries({ queryKey: adminKeys.sessions() });
57
59
  queryClient.invalidateQueries({ queryKey: adminKeys.stats() });
60
+ router.refresh();
58
61
  },
59
62
  onError: (error) => {
60
63
  toast.error(getErrorMessage(error));
@@ -1,6 +1,7 @@
1
1
  'use client';
2
2
 
3
3
  import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
4
+ import { useRouter } from 'next/navigation';
4
5
  import { toast } from 'sonner';
5
6
 
6
7
  import { getErrorMessage } from '@/lib/utils/error';
@@ -81,6 +82,7 @@ interface UseUpdateUserStatusReturn {
81
82
 
82
83
  export const useUpdateUserStatus = (): UseUpdateUserStatusReturn => {
83
84
  const queryClient = useQueryClient();
85
+ const router = useRouter();
84
86
 
85
87
  const mutation = useMutation({
86
88
  mutationFn: ({ userId, isActive }: { userId: string; isActive: boolean }) =>
@@ -90,6 +92,7 @@ export const useUpdateUserStatus = (): UseUpdateUserStatusReturn => {
90
92
  toast.success(`User ${action} successfully`);
91
93
  queryClient.invalidateQueries({ queryKey: adminKeys.users() });
92
94
  queryClient.invalidateQueries({ queryKey: adminKeys.stats() });
95
+ router.refresh();
93
96
  },
94
97
  onError: (error) => {
95
98
  toast.error(getErrorMessage(error));
@@ -111,6 +114,7 @@ interface UseUpdateUserRoleReturn {
111
114
 
112
115
  export const useUpdateUserRole = (): UseUpdateUserRoleReturn => {
113
116
  const queryClient = useQueryClient();
117
+ const router = useRouter();
114
118
 
115
119
  const mutation = useMutation({
116
120
  mutationFn: ({ userId, role }: { userId: string; role: 'USER' | 'ADMIN' }) =>
@@ -119,6 +123,7 @@ export const useUpdateUserRole = (): UseUpdateUserRoleReturn => {
119
123
  toast.success('User role updated successfully');
120
124
  queryClient.invalidateQueries({ queryKey: adminKeys.users() });
121
125
  queryClient.invalidateQueries({ queryKey: adminKeys.stats() });
126
+ router.refresh();
122
127
  },
123
128
  onError: (error) => {
124
129
  toast.error(getErrorMessage(error));
@@ -7,11 +7,10 @@ import { usePathname, useRouter } from 'next/navigation';
7
7
 
8
8
  import { toast } from 'sonner';
9
9
 
10
- import { useAppDispatch, useAppSelector } from '@/store/hooks';
10
+ import { useAppSelector } from '@/store/hooks';
11
11
  import { ROUTES } from '@/lib/constants/routes';
12
12
  import { isErrorCode, ERROR_CODES } from '@/lib/utils/error';
13
- import { authService } from '../services/auth.service';
14
- import { setUser, setInitialized } from '../store/authSlice';
13
+ import { useCurrentUser } from '../hooks/useCurrentUser';
15
14
 
16
15
  const PROTECTED_PATHS: string[] = [ROUTES.DASHBOARD, ROUTES.PROFILE, '/admin'];
17
16
 
@@ -32,53 +31,37 @@ function isAuthPage(pathname: string): boolean {
32
31
  return AUTH_PATHS.some((path) => pathname.startsWith(path));
33
32
  }
34
33
 
34
+ interface HttpLikeError {
35
+ response?: { status?: number };
36
+ }
37
+
35
38
  export const AuthInitializer = ({ children }: AuthInitializerProps): React.ReactElement => {
36
- const dispatch = useAppDispatch();
37
39
  const pathname = usePathname();
38
40
  const router = useRouter();
39
- const { isAuthenticated, isLoggingOut } = useAppSelector((state) => state.auth);
41
+ const { isLoggingOut } = useAppSelector((state) => state.auth);
40
42
 
41
- useEffect(() => {
42
- // On auth pages (login, register, etc.), never call getMe().
43
- // There is no session to hydrate, and a 401 here would trigger
44
- // the refresh → fail → redirect chain, causing an infinite loop.
45
- if (isAuthPage(pathname)) {
46
- dispatch(setInitialized());
47
- return;
48
- }
43
+ // Skip getMe() on auth pages and during logout.
44
+ // React Query handles deduping, refetch-on-focus, and invalidation
45
+ // any mutation that touches the current user (profile update, role change,
46
+ // avatar upload, email verification) should call:
47
+ // queryClient.invalidateQueries({ queryKey: authKeys.me() })
48
+ // to refresh Redux state automatically.
49
+ const enabled = !isAuthPage(pathname) && !isLoggingOut;
49
50
 
50
- // On other public pages, skip auth hydration — just mark as initialized
51
- if (!isProtectedPath(pathname)) {
52
- dispatch(setInitialized());
53
- return;
54
- }
51
+ const { error } = useCurrentUser({ enabled });
55
52
 
56
- if (isAuthenticated || isLoggingOut) return;
57
-
58
- let cancelled = false;
59
-
60
- authService
61
- .getMe()
62
- .then((user) => {
63
- if (!cancelled) dispatch(setUser(user));
64
- })
65
- .catch((error) => {
66
- if (cancelled) return;
67
- dispatch(setInitialized());
68
- // Only redirect on auth errors (401/403), not network failures
69
- const status = error?.response?.status;
70
- if (status === 401 || status === 403) {
71
- if (isErrorCode(error, ERROR_CODES.ACCOUNT_NOT_ACTIVE)) {
72
- toast.error('Your account is not yet activated. Please verify your account.');
73
- }
74
- router.push(ROUTES.LOGIN);
75
- }
76
- });
77
-
78
- return (): void => {
79
- cancelled = true;
80
- };
81
- }, [dispatch, pathname, isAuthenticated, isLoggingOut, router]);
53
+ useEffect(() => {
54
+ if (!error) return;
55
+ if (!isProtectedPath(pathname)) return;
56
+
57
+ const status = (error as HttpLikeError)?.response?.status;
58
+ if (status === 401 || status === 403) {
59
+ if (isErrorCode(error, ERROR_CODES.ACCOUNT_NOT_ACTIVE)) {
60
+ toast.error('Your account is not yet activated. Please verify your account.');
61
+ }
62
+ router.push(ROUTES.LOGIN);
63
+ }
64
+ }, [error, pathname, router]);
82
65
 
83
66
  return <>{children}</>;
84
67
  };