diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..25e9099 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,137 @@ +# Casera Web (`myCribAPI-Web`) + +Next.js web client for the Casera property management platform. Talks to the Go REST API backend. + +## Build & Run + +```bash +npm run dev # Dev server (localhost:3000) +npm run build # Production build (catches type errors) +npm run lint # ESLint +npm run test:e2e # Playwright end-to-end tests +npm run test:e2e:ui # Playwright with UI +npm run analyze # Bundle analysis +``` + +## Stack + +- **Framework**: Next.js 16 (App Router, React 19) +- **Styling**: Tailwind CSS v4 + shadcn/ui (Radix primitives) +- **State**: Zustand (auth, onboarding, theme), TanStack React Query (server state) +- **Forms**: React Hook Form + Zod validation +- **Drag & Drop**: @dnd-kit (kanban boards) +- **Charts**: Recharts +- **Icons**: Lucide React +- **Analytics**: PostHog +- **Testing**: Playwright (E2E), Vitest (unit) + +## Architecture + +### Request Flow + +``` +Browser → Next.js page (client component) + → apiFetch("/tasks/") → /api/proxy/tasks (Next.js route handler) + → Go API (reads casera-token httpOnly cookie, forwards as Authorization header) +``` + +Auth tokens are stored as httpOnly cookies (`casera-token`), never exposed to JS. The Next.js `/api/proxy/[...path]` catch-all route forwards requests to the Go API. + +### Directory Structure + +``` +src/ +├── app/ +│ ├── (auth)/ # Login, register, forgot-password (public) +│ ├── api/proxy/ # Catch-all proxy to Go API +│ ├── app/ # Authenticated app pages +│ │ ├── contractors/ # Contractor CRUD +│ │ ├── documents/ # Document management +│ │ ├── residences/ # Residence CRUD + detail with kanban +│ │ ├── settings/ # User settings, theme +│ │ └── tasks/ # Task CRUD, kanban board +│ ├── demo/ # Demo mode (fake data, no auth) +│ └── page.tsx # Landing page +├── components/ +│ ├── contractors/ # Contractor cards, forms +│ ├── dashboard/ # Stats, activity, template suggestions +│ ├── demo/ # Demo banner +│ ├── documents/ # Document cards, forms +│ ├── forms/ # Auth form wrapper +│ ├── layout/ # TopBar, MobileNav, Sidebar +│ ├── notifications/ # Notification components +│ ├── onboarding/ # Onboarding flow +│ ├── residences/ # Residence cards, forms, summary, share +│ ├── settings/ # Theme picker +│ ├── shared/ # PageHeader, EmptyState, ErrorBanner, etc. +│ ├── sharing/ # Share code UI +│ ├── tasks/ # TaskCard, TaskForm, KanbanBoard, KanbanColumn +│ └── ui/ # shadcn primitives (button, card, dialog, etc.) +├── lib/ +│ ├── api/ # API client functions per domain +│ │ ├── client.ts # apiFetch (client-side) + serverFetch (server-side) +│ │ ├── tasks.ts # Task API (CRUD, kanban, completions) +│ │ ├── residences.ts # Residence API (CRUD, sharing, reports) +│ │ ├── contractors.ts +│ │ ├── documents.ts +│ │ └── ... +│ ├── hooks/ # React Query hooks per domain +│ │ ├── use-tasks.ts # useTasks, useTask, useCreateTask, etc. +│ │ ├── use-residences.ts +│ │ └── ... +│ ├── demo/ # Demo mode: DataProvider abstraction +│ │ ├── data-provider-context.tsx # React context +│ │ ├── real-provider.ts # Real API calls +│ │ ├── demo-provider.ts # Fake data +│ │ └── mock-data/ # Static demo data +│ ├── themes/ # Theme system (Warm Sage palette) +│ ├── validations/ # Zod schemas +│ └── utils.ts # cn() helper +├── stores/ +│ ├── auth.ts # Zustand: user, token, login/logout +│ ├── onboarding.ts # Zustand: onboarding state +│ └── theme.ts # Zustand: theme preferences +└── styles/ + └── themes.css # CSS custom properties for theme +``` + +### Key Patterns + +**Data fetching**: All server data uses React Query hooks (`use-*.ts`). Hooks call API functions from `lib/api/`. API functions call `apiFetch()` which proxies through Next.js. + +**Demo mode**: The app supports a `/demo` route with fake data. Components use `useDataProvider()` to get API functions and `basePath` — this returns either real or demo implementations depending on the route. + +**Kanban boards**: Tasks display in kanban columns (overdue, due_soon, in_progress, not_started, completed). Uses `@dnd-kit` for drag-and-drop. Column names match Go API: `overdue_tasks`, `due_soon_tasks`, `in_progress_tasks`, `not_started_tasks`, `completed_tasks`. + +**Middleware** (`src/middleware.ts`): Checks `casera-token` cookie. Redirects unauthenticated users to `/login` for protected routes. Skips API routes, static files, and public paths. + +## Conventions + +- All pages under `/app/` are `"use client"` components +- Use `apiFetch()` for client-side API calls, `serverFetch()` for server components/route handlers +- React Query hooks go in `src/lib/hooks/use-{domain}.ts` +- API client functions go in `src/lib/api/{domain}.ts` +- shadcn components in `src/components/ui/` — don't modify these directly +- Custom components use Tailwind classes, warm shadow CSS variables (`--shadow-warm-sm`, `--shadow-warm-md`) +- Brand colors: `primary` (sage green), `brand-clay`, `brand-sage` variants defined in CSS custom properties +- Font: `font-heading` for headings (tracking-tight), system font for body +- Toast notifications via `sonner` — use `toast.success()`, `toast.error()` +- Form validation with Zod schemas in `src/lib/validations/` +- Icons from `lucide-react` — keep consistent sizes (size-4 for inline, size-5 for cards) + +## Environment Variables + +| Variable | Description | Default | +|----------|-------------|---------| +| `NEXT_PUBLIC_API_URL` | Go API URL (client-side) | `https://casera.treytartt.com/api` | +| `API_URL` | Go API URL (server-side, no proxy) | Falls back to `NEXT_PUBLIC_API_URL` | +| `NEXT_PUBLIC_POSTHOG_KEY` | PostHog analytics key | — | +| `NEXT_PUBLIC_POSTHOG_HOST` | PostHog host | — | + +## Common Tasks + +**Add a new page**: Create `src/app/app/{route}/page.tsx` (client component). Add nav item to `src/components/layout/nav-items.ts`. + +**Add a new API domain**: Create `src/lib/api/{domain}.ts` with typed functions using `apiFetch()`. Create `src/lib/hooks/use-{domain}.ts` with React Query hooks. Add demo provider methods if needed. + +**Add a shadcn component**: `npx shadcn@latest add {component}` — installs to `src/components/ui/`. diff --git a/src/components/tasks/kanban-board.tsx b/src/components/tasks/kanban-board.tsx index 92c2248..1fb6218 100644 --- a/src/components/tasks/kanban-board.tsx +++ b/src/components/tasks/kanban-board.tsx @@ -12,7 +12,7 @@ import { type DragEndEvent, } from "@dnd-kit/core"; import { useMarkInProgress } from "@/lib/hooks/use-tasks"; -import { useDataProvider } from "@/lib/demo/data-provider-context"; +import { useDataProvider, useQueryKeyPrefix } from "@/lib/demo/data-provider-context"; import { KanbanColumn } from "./kanban-column"; import type { KanbanResponse, KanbanColumn as KanbanColumnType } from "@/lib/api/tasks"; @@ -24,6 +24,7 @@ export function KanbanBoard({ data }: KanbanBoardProps) { const router = useRouter(); const queryClient = useQueryClient(); const { basePath } = useDataProvider(); + const qk = useQueryKeyPrefix(); const markInProgress = useMarkInProgress(); // Local columns state for instant optimistic updates @@ -88,7 +89,7 @@ export function KanbanBoard({ data }: KanbanBoardProps) { markInProgress.mutate(taskId, { onError: () => setColumns(data.columns), onSettled: () => { - queryClient.invalidateQueries({ queryKey: ["tasks"] }); + queryClient.invalidateQueries({ queryKey: qk("tasks") }); }, }); return; @@ -98,9 +99,9 @@ export function KanbanBoard({ data }: KanbanBoardProps) { // (no API endpoint for moving to overdue/due_soon/upcoming — those are computed server-side) moveTask(taskId, sourceCol.name, targetColumn); // Refetch to get correct server state - queryClient.invalidateQueries({ queryKey: ["tasks"] }); + queryClient.invalidateQueries({ queryKey: qk("tasks") }); }, - [columns, data.columns, moveTask, markInProgress, queryClient, router, basePath] + [columns, data.columns, moveTask, markInProgress, queryClient, qk, router, basePath] ); return ( diff --git a/src/lib/demo/data-provider-context.tsx b/src/lib/demo/data-provider-context.tsx index f069653..8f7f422 100644 --- a/src/lib/demo/data-provider-context.tsx +++ b/src/lib/demo/data-provider-context.tsx @@ -1,6 +1,6 @@ "use client"; -import { createContext, useContext } from 'react'; +import { createContext, useContext, useMemo } from 'react'; import type { DataProvider } from './data-provider'; const DataProviderContext = createContext(null); @@ -13,6 +13,12 @@ export function useDataProvider(): DataProvider { return ctx; } +/** Returns a function that prefixes query keys with the current provider's basePath. */ +export function useQueryKeyPrefix(): (...key: unknown[]) => unknown[] { + const { basePath } = useDataProvider(); + return useMemo(() => (...key: unknown[]) => [basePath, ...key], [basePath]); +} + export function DataProviderProvider({ value, children, diff --git a/src/lib/hooks/use-auth.ts b/src/lib/hooks/use-auth.ts index faef9a1..4123c33 100644 --- a/src/lib/hooks/use-auth.ts +++ b/src/lib/hooks/use-auth.ts @@ -1,13 +1,14 @@ "use client"; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { useDataProvider } from '@/lib/demo/data-provider-context'; +import { useDataProvider, useQueryKeyPrefix } from '@/lib/demo/data-provider-context'; import { useRouter } from 'next/navigation'; export function useCurrentUser() { const { auth } = useDataProvider(); + const qk = useQueryKeyPrefix(); return useQuery({ - queryKey: ['auth', 'user'], + queryKey: qk('auth', 'user'), queryFn: () => auth.getCurrentUser(), retry: false, staleTime: 5 * 60 * 1000, // 5 minutes diff --git a/src/lib/hooks/use-contractors.ts b/src/lib/hooks/use-contractors.ts index dc1a0e5..701666e 100644 --- a/src/lib/hooks/use-contractors.ts +++ b/src/lib/hooks/use-contractors.ts @@ -1,7 +1,7 @@ "use client"; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { useDataProvider } from '@/lib/demo/data-provider-context'; +import { useDataProvider, useQueryKeyPrefix } from '@/lib/demo/data-provider-context'; import type { CreateContractorRequest, UpdateContractorRequest } from '@/lib/api/contractors'; // --------------------------------------------------------------------------- @@ -10,8 +10,9 @@ import type { CreateContractorRequest, UpdateContractorRequest } from '@/lib/api export function useContractors() { const { contractors } = useDataProvider(); + const qk = useQueryKeyPrefix(); return useQuery({ - queryKey: ['contractors'], + queryKey: qk('contractors'), queryFn: () => contractors.list(), select: (data) => (Array.isArray(data) ? data : []), }); @@ -19,8 +20,9 @@ export function useContractors() { export function useContractor(id: number) { const { contractors } = useDataProvider(); + const qk = useQueryKeyPrefix(); return useQuery({ - queryKey: ['contractors', id], + queryKey: qk('contractors', id), queryFn: () => contractors.get(id), enabled: !!id, }); @@ -28,8 +30,9 @@ export function useContractor(id: number) { export function useContractorTasks(id: number) { const { contractors } = useDataProvider(); + const qk = useQueryKeyPrefix(); return useQuery({ - queryKey: ['contractors', id, 'tasks'], + queryKey: qk('contractors', id, 'tasks'), queryFn: () => contractors.getTasks(id), enabled: !!id, select: (data) => (Array.isArray(data) ? data : []), @@ -43,11 +46,12 @@ export function useContractorTasks(id: number) { export function useCreateContractor() { const queryClient = useQueryClient(); const { contractors } = useDataProvider(); + const qk = useQueryKeyPrefix(); return useMutation({ mutationFn: (data: CreateContractorRequest) => contractors.create(data), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['contractors'] }); + queryClient.invalidateQueries({ queryKey: qk('contractors') }); }, }); } @@ -55,12 +59,13 @@ export function useCreateContractor() { export function useUpdateContractor(id: number) { const queryClient = useQueryClient(); const { contractors } = useDataProvider(); + const qk = useQueryKeyPrefix(); return useMutation({ mutationFn: (data: UpdateContractorRequest) => contractors.update(id, data), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['contractors'] }); - queryClient.invalidateQueries({ queryKey: ['contractors', id] }); + queryClient.invalidateQueries({ queryKey: qk('contractors') }); + queryClient.invalidateQueries({ queryKey: qk('contractors', id) }); }, }); } @@ -68,10 +73,11 @@ export function useUpdateContractor(id: number) { export function useDeleteContractor() { const queryClient = useQueryClient(); const { contractors } = useDataProvider(); + const qk = useQueryKeyPrefix(); return useMutation({ mutationFn: (id: number) => contractors.delete(id), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['contractors'] }); + queryClient.invalidateQueries({ queryKey: qk('contractors') }); }, }); } @@ -79,10 +85,11 @@ export function useDeleteContractor() { export function useToggleFavorite() { const queryClient = useQueryClient(); const { contractors } = useDataProvider(); + const qk = useQueryKeyPrefix(); return useMutation({ mutationFn: (id: number) => contractors.toggleFavorite(id), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['contractors'] }); + queryClient.invalidateQueries({ queryKey: qk('contractors') }); }, }); } diff --git a/src/lib/hooks/use-documents.ts b/src/lib/hooks/use-documents.ts index aade868..b55908c 100644 --- a/src/lib/hooks/use-documents.ts +++ b/src/lib/hooks/use-documents.ts @@ -1,7 +1,7 @@ "use client"; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { useDataProvider } from '@/lib/demo/data-provider-context'; +import { useDataProvider, useQueryKeyPrefix } from '@/lib/demo/data-provider-context'; import type { DocumentListParams, CreateDocumentRequest, UpdateDocumentRequest } from '@/lib/api/documents'; // --------------------------------------------------------------------------- @@ -10,8 +10,9 @@ import type { DocumentListParams, CreateDocumentRequest, UpdateDocumentRequest } export function useDocuments(params?: DocumentListParams) { const { documents } = useDataProvider(); + const qk = useQueryKeyPrefix(); return useQuery({ - queryKey: ['documents', params], + queryKey: qk('documents', params), queryFn: () => documents.list(params), select: (data) => (Array.isArray(data) ? data : []), }); @@ -19,8 +20,9 @@ export function useDocuments(params?: DocumentListParams) { export function useWarranties() { const { documents } = useDataProvider(); + const qk = useQueryKeyPrefix(); return useQuery({ - queryKey: ['documents', 'warranties'], + queryKey: qk('documents', 'warranties'), queryFn: () => documents.listWarranties(), select: (data) => (Array.isArray(data) ? data : []), }); @@ -28,7 +30,8 @@ export function useWarranties() { export function useDocument(id: number) { const { documents } = useDataProvider(); - return useQuery({ queryKey: ['documents', id], queryFn: () => documents.get(id), enabled: !!id }); + const qk = useQueryKeyPrefix(); + return useQuery({ queryKey: qk('documents', id), queryFn: () => documents.get(id), enabled: !!id }); } // --------------------------------------------------------------------------- @@ -38,6 +41,7 @@ export function useDocument(id: number) { export function useCreateDocument() { const queryClient = useQueryClient(); const { documents } = useDataProvider(); + const qk = useQueryKeyPrefix(); return useMutation({ mutationFn: ({ data, file }: { data: CreateDocumentRequest; file?: File }) => { if (file) { @@ -46,7 +50,7 @@ export function useCreateDocument() { return documents.create(data); }, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['documents'] }); + queryClient.invalidateQueries({ queryKey: qk('documents') }); }, }); } @@ -54,12 +58,13 @@ export function useCreateDocument() { export function useUpdateDocument(id: number) { const queryClient = useQueryClient(); const { documents } = useDataProvider(); + const qk = useQueryKeyPrefix(); return useMutation({ mutationFn: (data: UpdateDocumentRequest) => documents.update(id, data), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['documents'] }); - queryClient.invalidateQueries({ queryKey: ['documents', id] }); + queryClient.invalidateQueries({ queryKey: qk('documents') }); + queryClient.invalidateQueries({ queryKey: qk('documents', id) }); }, }); } @@ -67,10 +72,11 @@ export function useUpdateDocument(id: number) { export function useDeleteDocument() { const queryClient = useQueryClient(); const { documents } = useDataProvider(); + const qk = useQueryKeyPrefix(); return useMutation({ mutationFn: (id: number) => documents.delete(id), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['documents'] }); + queryClient.invalidateQueries({ queryKey: qk('documents') }); }, }); } diff --git a/src/lib/hooks/use-lookups.ts b/src/lib/hooks/use-lookups.ts index 02bcc55..4693ed6 100644 --- a/src/lib/hooks/use-lookups.ts +++ b/src/lib/hooks/use-lookups.ts @@ -1,7 +1,7 @@ "use client"; import { useQuery } from '@tanstack/react-query'; -import { useDataProvider } from '@/lib/demo/data-provider-context'; +import { useDataProvider, useQueryKeyPrefix } from '@/lib/demo/data-provider-context'; // --------------------------------------------------------------------------- // Main hook — fetches all static data in a single request @@ -9,8 +9,9 @@ import { useDataProvider } from '@/lib/demo/data-provider-context'; export function useLookups() { const { lookups } = useDataProvider(); + const qk = useQueryKeyPrefix(); return useQuery({ - queryKey: ['lookups', 'static-data'], + queryKey: qk('lookups', 'static-data'), queryFn: () => lookups.getStaticData(), staleTime: Infinity, // ETag-based; never auto-refetch }); diff --git a/src/lib/hooks/use-notifications.ts b/src/lib/hooks/use-notifications.ts index 5fa219a..8406756 100644 --- a/src/lib/hooks/use-notifications.ts +++ b/src/lib/hooks/use-notifications.ts @@ -1,21 +1,23 @@ "use client"; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { useDataProvider } from '@/lib/demo/data-provider-context'; +import { useDataProvider, useQueryKeyPrefix } from '@/lib/demo/data-provider-context'; import type { UpdatePreferencesRequest } from '@/lib/api/notifications'; export function useNotifications(limit?: number) { const { notifications } = useDataProvider(); + const qk = useQueryKeyPrefix(); return useQuery({ - queryKey: ['notifications', limit], + queryKey: qk('notifications', limit), queryFn: () => notifications.list(limit), }); } export function useUnreadCount() { const { notifications } = useDataProvider(); + const qk = useQueryKeyPrefix(); return useQuery({ - queryKey: ['notifications', 'unread-count'], + queryKey: qk('notifications', 'unread-count'), queryFn: () => notifications.getUnreadCount(), refetchInterval: 60_000, }); @@ -23,8 +25,9 @@ export function useUnreadCount() { export function useNotificationPreferences() { const { notifications } = useDataProvider(); + const qk = useQueryKeyPrefix(); return useQuery({ - queryKey: ['notifications', 'preferences'], + queryKey: qk('notifications', 'preferences'), queryFn: () => notifications.getPreferences(), }); } @@ -32,10 +35,11 @@ export function useNotificationPreferences() { export function useUpdatePreferences() { const queryClient = useQueryClient(); const { notifications } = useDataProvider(); + const qk = useQueryKeyPrefix(); return useMutation({ mutationFn: (data: UpdatePreferencesRequest) => notifications.updatePreferences(data), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['notifications', 'preferences'] }); + queryClient.invalidateQueries({ queryKey: qk('notifications', 'preferences') }); }, }); } @@ -43,11 +47,12 @@ export function useUpdatePreferences() { export function useMarkAsRead() { const queryClient = useQueryClient(); const { notifications } = useDataProvider(); + const qk = useQueryKeyPrefix(); return useMutation({ mutationFn: (id: number) => notifications.markAsRead(id), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['notifications'] }); - queryClient.invalidateQueries({ queryKey: ['notifications', 'unread-count'] }); + queryClient.invalidateQueries({ queryKey: qk('notifications') }); + queryClient.invalidateQueries({ queryKey: qk('notifications', 'unread-count') }); }, }); } @@ -55,11 +60,12 @@ export function useMarkAsRead() { export function useMarkAllAsRead() { const queryClient = useQueryClient(); const { notifications } = useDataProvider(); + const qk = useQueryKeyPrefix(); return useMutation({ mutationFn: () => notifications.markAllAsRead(), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['notifications'] }); - queryClient.invalidateQueries({ queryKey: ['notifications', 'unread-count'] }); + queryClient.invalidateQueries({ queryKey: qk('notifications') }); + queryClient.invalidateQueries({ queryKey: qk('notifications', 'unread-count') }); }, }); } diff --git a/src/lib/hooks/use-residences.ts b/src/lib/hooks/use-residences.ts index cd6d6d0..8408acc 100644 --- a/src/lib/hooks/use-residences.ts +++ b/src/lib/hooks/use-residences.ts @@ -1,7 +1,7 @@ "use client"; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { useDataProvider } from '@/lib/demo/data-provider-context'; +import { useDataProvider, useQueryKeyPrefix } from '@/lib/demo/data-provider-context'; import type { CreateResidenceRequest, UpdateResidenceRequest } from '@/lib/api/residences'; // --------------------------------------------------------------------------- @@ -10,8 +10,9 @@ import type { CreateResidenceRequest, UpdateResidenceRequest } from '@/lib/api/r export function useResidences() { const { residences } = useDataProvider(); + const qk = useQueryKeyPrefix(); return useQuery({ - queryKey: ['residences'], + queryKey: qk('residences'), queryFn: () => residences.getMyResidences(), select: (data) => (Array.isArray(data) ? data : []), }); @@ -19,8 +20,9 @@ export function useResidences() { export function useResidence(id: number) { const { residences } = useDataProvider(); + const qk = useQueryKeyPrefix(); return useQuery({ - queryKey: ['residences', id], + queryKey: qk('residences', id), queryFn: () => residences.get(id), enabled: !!id, }); @@ -33,11 +35,12 @@ export function useResidence(id: number) { export function useCreateResidence() { const queryClient = useQueryClient(); const { residences } = useDataProvider(); + const qk = useQueryKeyPrefix(); return useMutation({ mutationFn: (data: CreateResidenceRequest) => residences.create(data), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['residences'] }); + queryClient.invalidateQueries({ queryKey: qk('residences') }); }, }); } @@ -45,12 +48,13 @@ export function useCreateResidence() { export function useUpdateResidence(id: number) { const queryClient = useQueryClient(); const { residences } = useDataProvider(); + const qk = useQueryKeyPrefix(); return useMutation({ mutationFn: (data: UpdateResidenceRequest) => residences.update(id, data), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['residences'] }); - queryClient.invalidateQueries({ queryKey: ['residences', id] }); + queryClient.invalidateQueries({ queryKey: qk('residences') }); + queryClient.invalidateQueries({ queryKey: qk('residences', id) }); }, }); } @@ -58,10 +62,11 @@ export function useUpdateResidence(id: number) { export function useDeleteResidence() { const queryClient = useQueryClient(); const { residences } = useDataProvider(); + const qk = useQueryKeyPrefix(); return useMutation({ mutationFn: (id: number) => residences.delete(id), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['residences'] }); + queryClient.invalidateQueries({ queryKey: qk('residences') }); }, }); } diff --git a/src/lib/hooks/use-sharing.ts b/src/lib/hooks/use-sharing.ts index ab9d5bf..d0c5a1f 100644 --- a/src/lib/hooks/use-sharing.ts +++ b/src/lib/hooks/use-sharing.ts @@ -1,7 +1,7 @@ "use client"; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { useDataProvider } from '@/lib/demo/data-provider-context'; +import { useDataProvider, useQueryKeyPrefix } from '@/lib/demo/data-provider-context'; // --------------------------------------------------------------------------- // Query hooks @@ -9,8 +9,9 @@ import { useDataProvider } from '@/lib/demo/data-provider-context'; export function useShareCode(residenceId: number) { const { sharing } = useDataProvider(); + const qk = useQueryKeyPrefix(); return useQuery({ - queryKey: ['residences', residenceId, 'share-code'], + queryKey: qk('residences', residenceId, 'share-code'), queryFn: () => sharing.getShareCode(residenceId), enabled: !!residenceId, }); @@ -18,8 +19,9 @@ export function useShareCode(residenceId: number) { export function useResidenceUsers(residenceId: number) { const { sharing } = useDataProvider(); + const qk = useQueryKeyPrefix(); return useQuery({ - queryKey: ['residences', residenceId, 'users'], + queryKey: qk('residences', residenceId, 'users'), queryFn: () => sharing.getResidenceUsers(residenceId), enabled: !!residenceId, }); @@ -32,10 +34,11 @@ export function useResidenceUsers(residenceId: number) { export function useGenerateShareCode(residenceId: number) { const queryClient = useQueryClient(); const { sharing } = useDataProvider(); + const qk = useQueryKeyPrefix(); return useMutation({ mutationFn: () => sharing.generateShareCode(residenceId), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['residences', residenceId, 'share-code'] }); + queryClient.invalidateQueries({ queryKey: qk('residences', residenceId, 'share-code') }); }, }); } @@ -43,10 +46,11 @@ export function useGenerateShareCode(residenceId: number) { export function useRemoveResidenceUser(residenceId: number) { const queryClient = useQueryClient(); const { sharing } = useDataProvider(); + const qk = useQueryKeyPrefix(); return useMutation({ mutationFn: (userId: number) => sharing.removeUser(residenceId, userId), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['residences', residenceId, 'users'] }); + queryClient.invalidateQueries({ queryKey: qk('residences', residenceId, 'users') }); }, }); } @@ -54,10 +58,11 @@ export function useRemoveResidenceUser(residenceId: number) { export function useJoinResidence() { const queryClient = useQueryClient(); const { sharing } = useDataProvider(); + const qk = useQueryKeyPrefix(); return useMutation({ mutationFn: (code: string) => sharing.joinWithCode({ code }), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['residences'] }); + queryClient.invalidateQueries({ queryKey: qk('residences') }); }, }); } diff --git a/src/lib/hooks/use-subscription.ts b/src/lib/hooks/use-subscription.ts index b817851..ddba546 100644 --- a/src/lib/hooks/use-subscription.ts +++ b/src/lib/hooks/use-subscription.ts @@ -1,20 +1,22 @@ "use client"; import { useQuery } from '@tanstack/react-query'; -import { useDataProvider } from '@/lib/demo/data-provider-context'; +import { useDataProvider, useQueryKeyPrefix } from '@/lib/demo/data-provider-context'; export function useSubscriptionStatus() { const { subscription } = useDataProvider(); + const qk = useQueryKeyPrefix(); return useQuery({ - queryKey: ['subscription', 'status'], + queryKey: qk('subscription', 'status'), queryFn: () => subscription.getStatus(), }); } export function useFeatureBenefits() { const { subscription } = useDataProvider(); + const qk = useQueryKeyPrefix(); return useQuery({ - queryKey: ['subscription', 'features'], + queryKey: qk('subscription', 'features'), queryFn: () => subscription.getFeatureBenefits(), staleTime: Infinity, }); @@ -22,8 +24,9 @@ export function useFeatureBenefits() { export function useUpgradeTriggers() { const { subscription } = useDataProvider(); + const qk = useQueryKeyPrefix(); return useQuery({ - queryKey: ['subscription', 'upgrade-triggers'], + queryKey: qk('subscription', 'upgrade-triggers'), queryFn: () => subscription.getUpgradeTriggers(), staleTime: Infinity, }); diff --git a/src/lib/hooks/use-tasks.ts b/src/lib/hooks/use-tasks.ts index 8a83b4f..0fccf23 100644 --- a/src/lib/hooks/use-tasks.ts +++ b/src/lib/hooks/use-tasks.ts @@ -1,7 +1,7 @@ "use client"; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { useDataProvider } from '@/lib/demo/data-provider-context'; +import { useDataProvider, useQueryKeyPrefix } from '@/lib/demo/data-provider-context'; import type { CreateTaskRequest, UpdateTaskRequest, CreateCompletionRequest } from '@/lib/api/tasks'; // --------------------------------------------------------------------------- @@ -10,16 +10,18 @@ import type { CreateTaskRequest, UpdateTaskRequest, CreateCompletionRequest } fr export function useTasks() { const { tasks } = useDataProvider(); + const qk = useQueryKeyPrefix(); return useQuery({ - queryKey: ['tasks'], + queryKey: qk('tasks'), queryFn: () => tasks.list(), }); } export function useTasksByResidence(residenceId: number) { const { tasks } = useDataProvider(); + const qk = useQueryKeyPrefix(); return useQuery({ - queryKey: ['tasks', 'by-residence', residenceId], + queryKey: qk('tasks', 'by-residence', residenceId), queryFn: () => tasks.getByResidence(residenceId), enabled: !!residenceId, }); @@ -27,8 +29,9 @@ export function useTasksByResidence(residenceId: number) { export function useTask(id: number) { const { tasks } = useDataProvider(); + const qk = useQueryKeyPrefix(); return useQuery({ - queryKey: ['tasks', id], + queryKey: qk('tasks', id), queryFn: () => tasks.get(id), enabled: !!id, }); @@ -36,8 +39,9 @@ export function useTask(id: number) { export function useKanbanBoard(residenceId: number) { const { tasks } = useDataProvider(); + const qk = useQueryKeyPrefix(); return useQuery({ - queryKey: ['tasks', 'kanban', residenceId], + queryKey: qk('tasks', 'kanban', residenceId), queryFn: () => tasks.getByResidence(residenceId), enabled: !!residenceId, }); @@ -45,8 +49,9 @@ export function useKanbanBoard(residenceId: number) { export function useTaskCompletions(taskId: number) { const { tasks } = useDataProvider(); + const qk = useQueryKeyPrefix(); return useQuery({ - queryKey: ['tasks', taskId, 'completions'], + queryKey: qk('tasks', taskId, 'completions'), queryFn: () => tasks.getCompletions(taskId), enabled: !!taskId, }); @@ -59,11 +64,12 @@ export function useTaskCompletions(taskId: number) { export function useCreateTask() { const queryClient = useQueryClient(); const { tasks } = useDataProvider(); + const qk = useQueryKeyPrefix(); return useMutation({ mutationFn: (data: CreateTaskRequest) => tasks.create(data), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['tasks'] }); - queryClient.invalidateQueries({ queryKey: ['residences'] }); + queryClient.invalidateQueries({ queryKey: qk('tasks') }); + queryClient.invalidateQueries({ queryKey: qk('residences') }); }, }); } @@ -71,12 +77,13 @@ export function useCreateTask() { export function useUpdateTask(id: number) { const queryClient = useQueryClient(); const { tasks } = useDataProvider(); + const qk = useQueryKeyPrefix(); return useMutation({ mutationFn: (data: UpdateTaskRequest) => tasks.update(id, data), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['tasks'] }); - queryClient.invalidateQueries({ queryKey: ['tasks', id] }); - queryClient.invalidateQueries({ queryKey: ['residences'] }); + queryClient.invalidateQueries({ queryKey: qk('tasks') }); + queryClient.invalidateQueries({ queryKey: qk('tasks', id) }); + queryClient.invalidateQueries({ queryKey: qk('residences') }); }, }); } @@ -84,11 +91,12 @@ export function useUpdateTask(id: number) { export function useDeleteTask() { const queryClient = useQueryClient(); const { tasks } = useDataProvider(); + const qk = useQueryKeyPrefix(); return useMutation({ mutationFn: (id: number) => tasks.delete(id), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['tasks'] }); - queryClient.invalidateQueries({ queryKey: ['residences'] }); + queryClient.invalidateQueries({ queryKey: qk('tasks') }); + queryClient.invalidateQueries({ queryKey: qk('residences') }); }, }); } @@ -96,10 +104,11 @@ export function useDeleteTask() { export function useMarkInProgress() { const queryClient = useQueryClient(); const { tasks } = useDataProvider(); + const qk = useQueryKeyPrefix(); return useMutation({ mutationFn: (id: number) => tasks.markInProgress(id), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['tasks'] }); + queryClient.invalidateQueries({ queryKey: qk('tasks') }); }, }); } @@ -107,10 +116,11 @@ export function useMarkInProgress() { export function useCancelTask() { const queryClient = useQueryClient(); const { tasks } = useDataProvider(); + const qk = useQueryKeyPrefix(); return useMutation({ mutationFn: (id: number) => tasks.cancel(id), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['tasks'] }); + queryClient.invalidateQueries({ queryKey: qk('tasks') }); }, }); } @@ -118,10 +128,11 @@ export function useCancelTask() { export function useArchiveTask() { const queryClient = useQueryClient(); const { tasks } = useDataProvider(); + const qk = useQueryKeyPrefix(); return useMutation({ mutationFn: (id: number) => tasks.archive(id), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['tasks'] }); + queryClient.invalidateQueries({ queryKey: qk('tasks') }); }, }); } @@ -129,6 +140,7 @@ export function useArchiveTask() { export function useCreateCompletion() { const queryClient = useQueryClient(); const { tasks } = useDataProvider(); + const qk = useQueryKeyPrefix(); return useMutation({ mutationFn: ({ data, @@ -151,8 +163,8 @@ export function useCreateCompletion() { return tasks.createCompletion(data); }, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['tasks'] }); - queryClient.invalidateQueries({ queryKey: ['residences'] }); + queryClient.invalidateQueries({ queryKey: qk('tasks') }); + queryClient.invalidateQueries({ queryKey: qk('residences') }); }, }); } diff --git a/src/stores/auth.ts b/src/stores/auth.ts index d592304..7ecd903 100644 --- a/src/stores/auth.ts +++ b/src/stores/auth.ts @@ -1,5 +1,6 @@ import { create } from 'zustand'; import * as authApi from '@/lib/api/auth'; +import { getQueryClient } from '@/lib/query/query-client'; import type { UserResponse } from '@/lib/api/auth'; interface AuthState { @@ -62,6 +63,8 @@ export const useAuthStore = create()((set) => ({ } catch { // Even if logout fails server-side, clear local state } finally { + // Clear React Query cache to prevent stale data leaking into demo mode + getQueryClient().clear(); set({ user: null, isAuthenticated: false,