feat: Phase 4-5 — demo mode, polish, deploy, and bug fixes

Add demo mode with mock data provider, Docker deployment, Playwright
tests, PostHog analytics, error boundaries, and SEO metadata. Fix
residences API response unwrapping, kanban drag-and-drop with optimistic
updates, trailing slash proxy redirects, and column name mismatches with
Go API.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-03-03 11:37:41 -06:00
parent 5a50d77515
commit 7884ebbfd4
133 changed files with 3904 additions and 300 deletions
+11 -4
View File
@@ -1,13 +1,14 @@
"use client";
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import * as authApi from '@/lib/api/auth';
import { useDataProvider } from '@/lib/demo/data-provider-context';
import { useRouter } from 'next/navigation';
export function useCurrentUser() {
const { auth } = useDataProvider();
return useQuery({
queryKey: ['auth', 'user'],
queryFn: () => authApi.getCurrentUser(),
queryFn: () => auth.getCurrentUser(),
retry: false,
staleTime: 5 * 60 * 1000, // 5 minutes
});
@@ -16,12 +17,18 @@ export function useCurrentUser() {
export function useLogout() {
const queryClient = useQueryClient();
const router = useRouter();
const { auth, basePath } = useDataProvider();
return useMutation({
mutationFn: () => authApi.logout(),
mutationFn: () => auth.logout(),
onSuccess: () => {
queryClient.clear();
router.push('/login');
// In demo mode, redirect to /demo; in real mode, redirect to /login
if (basePath.startsWith('/demo')) {
router.push('/demo');
} else {
router.push('/login');
}
},
});
}
+17 -8
View File
@@ -1,7 +1,7 @@
"use client";
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import * as contractorsApi from '@/lib/api/contractors';
import { useDataProvider } from '@/lib/demo/data-provider-context';
import type { CreateContractorRequest, UpdateContractorRequest } from '@/lib/api/contractors';
// ---------------------------------------------------------------------------
@@ -9,25 +9,30 @@ import type { CreateContractorRequest, UpdateContractorRequest } from '@/lib/api
// ---------------------------------------------------------------------------
export function useContractors() {
const { contractors } = useDataProvider();
return useQuery({
queryKey: ['contractors'],
queryFn: () => contractorsApi.listContractors(),
queryFn: () => contractors.list(),
select: (data) => (Array.isArray(data) ? data : []),
});
}
export function useContractor(id: number) {
const { contractors } = useDataProvider();
return useQuery({
queryKey: ['contractors', id],
queryFn: () => contractorsApi.getContractor(id),
queryFn: () => contractors.get(id),
enabled: !!id,
});
}
export function useContractorTasks(id: number) {
const { contractors } = useDataProvider();
return useQuery({
queryKey: ['contractors', id, 'tasks'],
queryFn: () => contractorsApi.getContractorTasks(id),
queryFn: () => contractors.getTasks(id),
enabled: !!id,
select: (data) => (Array.isArray(data) ? data : []),
});
}
@@ -37,9 +42,10 @@ export function useContractorTasks(id: number) {
export function useCreateContractor() {
const queryClient = useQueryClient();
const { contractors } = useDataProvider();
return useMutation({
mutationFn: (data: CreateContractorRequest) =>
contractorsApi.createContractor(data),
contractors.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['contractors'] });
},
@@ -48,9 +54,10 @@ export function useCreateContractor() {
export function useUpdateContractor(id: number) {
const queryClient = useQueryClient();
const { contractors } = useDataProvider();
return useMutation({
mutationFn: (data: UpdateContractorRequest) =>
contractorsApi.updateContractor(id, data),
contractors.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['contractors'] });
queryClient.invalidateQueries({ queryKey: ['contractors', id] });
@@ -60,8 +67,9 @@ export function useUpdateContractor(id: number) {
export function useDeleteContractor() {
const queryClient = useQueryClient();
const { contractors } = useDataProvider();
return useMutation({
mutationFn: (id: number) => contractorsApi.deleteContractor(id),
mutationFn: (id: number) => contractors.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['contractors'] });
},
@@ -70,8 +78,9 @@ export function useDeleteContractor() {
export function useToggleFavorite() {
const queryClient = useQueryClient();
const { contractors } = useDataProvider();
return useMutation({
mutationFn: (id: number) => contractorsApi.toggleFavorite(id),
mutationFn: (id: number) => contractors.toggleFavorite(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['contractors'] });
},
+22 -8
View File
@@ -1,7 +1,7 @@
"use client";
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import * as documentsApi from '@/lib/api/documents';
import { useDataProvider } from '@/lib/demo/data-provider-context';
import type { DocumentListParams, CreateDocumentRequest, UpdateDocumentRequest } from '@/lib/api/documents';
// ---------------------------------------------------------------------------
@@ -9,15 +9,26 @@ import type { DocumentListParams, CreateDocumentRequest, UpdateDocumentRequest }
// ---------------------------------------------------------------------------
export function useDocuments(params?: DocumentListParams) {
return useQuery({ queryKey: ['documents', params], queryFn: () => documentsApi.listDocuments(params) });
const { documents } = useDataProvider();
return useQuery({
queryKey: ['documents', params],
queryFn: () => documents.list(params),
select: (data) => (Array.isArray(data) ? data : []),
});
}
export function useWarranties() {
return useQuery({ queryKey: ['documents', 'warranties'], queryFn: () => documentsApi.listWarranties() });
const { documents } = useDataProvider();
return useQuery({
queryKey: ['documents', 'warranties'],
queryFn: () => documents.listWarranties(),
select: (data) => (Array.isArray(data) ? data : []),
});
}
export function useDocument(id: number) {
return useQuery({ queryKey: ['documents', id], queryFn: () => documentsApi.getDocument(id), enabled: !!id });
const { documents } = useDataProvider();
return useQuery({ queryKey: ['documents', id], queryFn: () => documents.get(id), enabled: !!id });
}
// ---------------------------------------------------------------------------
@@ -26,12 +37,13 @@ export function useDocument(id: number) {
export function useCreateDocument() {
const queryClient = useQueryClient();
const { documents } = useDataProvider();
return useMutation({
mutationFn: ({ data, file }: { data: CreateDocumentRequest; file?: File }) => {
if (file) {
return documentsApi.createDocumentWithFile(data, file);
return documents.createWithFile(data, file);
}
return documentsApi.createDocument(data);
return documents.create(data);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['documents'] });
@@ -41,9 +53,10 @@ export function useCreateDocument() {
export function useUpdateDocument(id: number) {
const queryClient = useQueryClient();
const { documents } = useDataProvider();
return useMutation({
mutationFn: (data: UpdateDocumentRequest) =>
documentsApi.updateDocument(id, data),
documents.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['documents'] });
queryClient.invalidateQueries({ queryKey: ['documents', id] });
@@ -53,8 +66,9 @@ export function useUpdateDocument(id: number) {
export function useDeleteDocument() {
const queryClient = useQueryClient();
const { documents } = useDataProvider();
return useMutation({
mutationFn: (id: number) => documentsApi.deleteDocument(id),
mutationFn: (id: number) => documents.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['documents'] });
},
+13 -7
View File
@@ -1,16 +1,17 @@
"use client";
import { useQuery } from '@tanstack/react-query';
import * as lookupsApi from '@/lib/api/lookups';
import { useDataProvider } from '@/lib/demo/data-provider-context';
// ---------------------------------------------------------------------------
// Main hook — fetches all static data in a single request
// ---------------------------------------------------------------------------
export function useLookups() {
const { lookups } = useDataProvider();
return useQuery({
queryKey: ['lookups', 'static-data'],
queryFn: () => lookupsApi.getStaticData(),
queryFn: () => lookups.getStaticData(),
staleTime: Infinity, // ETag-based; never auto-refetch
});
}
@@ -21,27 +22,32 @@ export function useLookups() {
export function useTaskCategories() {
const { data, ...rest } = useLookups();
return { data: data?.task_categories ?? [], ...rest };
const arr = data?.task_categories;
return { data: Array.isArray(arr) ? arr : [], ...rest };
}
export function useTaskPriorities() {
const { data, ...rest } = useLookups();
return { data: data?.task_priorities ?? [], ...rest };
const arr = data?.task_priorities;
return { data: Array.isArray(arr) ? arr : [], ...rest };
}
export function useTaskFrequencies() {
const { data, ...rest } = useLookups();
return { data: data?.task_frequencies ?? [], ...rest };
const arr = data?.task_frequencies;
return { data: Array.isArray(arr) ? arr : [], ...rest };
}
export function useContractorSpecialties() {
const { data, ...rest } = useLookups();
return { data: data?.contractor_specialties ?? [], ...rest };
const arr = data?.contractor_specialties;
return { data: Array.isArray(arr) ? arr : [], ...rest };
}
export function useResidenceTypes() {
const { data, ...rest } = useLookups();
return { data: data?.residence_types ?? [], ...rest };
const arr = data?.residence_types;
return { data: Array.isArray(arr) ? arr : [], ...rest };
}
// ---------------------------------------------------------------------------
+13 -7
View File
@@ -1,35 +1,39 @@
"use client";
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import * as notificationsApi from '@/lib/api/notifications';
import { useDataProvider } from '@/lib/demo/data-provider-context';
import type { UpdatePreferencesRequest } from '@/lib/api/notifications';
export function useNotifications(limit?: number) {
const { notifications } = useDataProvider();
return useQuery({
queryKey: ['notifications', limit],
queryFn: () => notificationsApi.listNotifications(limit),
queryFn: () => notifications.list(limit),
});
}
export function useUnreadCount() {
const { notifications } = useDataProvider();
return useQuery({
queryKey: ['notifications', 'unread-count'],
queryFn: () => notificationsApi.getUnreadCount(),
queryFn: () => notifications.getUnreadCount(),
refetchInterval: 60_000,
});
}
export function useNotificationPreferences() {
const { notifications } = useDataProvider();
return useQuery({
queryKey: ['notifications', 'preferences'],
queryFn: () => notificationsApi.getPreferences(),
queryFn: () => notifications.getPreferences(),
});
}
export function useUpdatePreferences() {
const queryClient = useQueryClient();
const { notifications } = useDataProvider();
return useMutation({
mutationFn: (data: UpdatePreferencesRequest) => notificationsApi.updatePreferences(data),
mutationFn: (data: UpdatePreferencesRequest) => notifications.updatePreferences(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['notifications', 'preferences'] });
},
@@ -38,8 +42,9 @@ export function useUpdatePreferences() {
export function useMarkAsRead() {
const queryClient = useQueryClient();
const { notifications } = useDataProvider();
return useMutation({
mutationFn: (id: number) => notificationsApi.markAsRead(id),
mutationFn: (id: number) => notifications.markAsRead(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['notifications'] });
queryClient.invalidateQueries({ queryKey: ['notifications', 'unread-count'] });
@@ -49,8 +54,9 @@ export function useMarkAsRead() {
export function useMarkAllAsRead() {
const queryClient = useQueryClient();
const { notifications } = useDataProvider();
return useMutation({
mutationFn: () => notificationsApi.markAllAsRead(),
mutationFn: () => notifications.markAllAsRead(),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['notifications'] });
queryClient.invalidateQueries({ queryKey: ['notifications', 'unread-count'] });
+12 -6
View File
@@ -1,7 +1,7 @@
"use client";
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import * as residencesApi from '@/lib/api/residences';
import { useDataProvider } from '@/lib/demo/data-provider-context';
import type { CreateResidenceRequest, UpdateResidenceRequest } from '@/lib/api/residences';
// ---------------------------------------------------------------------------
@@ -9,16 +9,19 @@ import type { CreateResidenceRequest, UpdateResidenceRequest } from '@/lib/api/r
// ---------------------------------------------------------------------------
export function useResidences() {
const { residences } = useDataProvider();
return useQuery({
queryKey: ['residences'],
queryFn: () => residencesApi.getMyResidences(),
queryFn: () => residences.getMyResidences(),
select: (data) => (Array.isArray(data) ? data : []),
});
}
export function useResidence(id: number) {
const { residences } = useDataProvider();
return useQuery({
queryKey: ['residences', id],
queryFn: () => residencesApi.getResidence(id),
queryFn: () => residences.get(id),
enabled: !!id,
});
}
@@ -29,9 +32,10 @@ export function useResidence(id: number) {
export function useCreateResidence() {
const queryClient = useQueryClient();
const { residences } = useDataProvider();
return useMutation({
mutationFn: (data: CreateResidenceRequest) =>
residencesApi.createResidence(data),
residences.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['residences'] });
},
@@ -40,9 +44,10 @@ export function useCreateResidence() {
export function useUpdateResidence(id: number) {
const queryClient = useQueryClient();
const { residences } = useDataProvider();
return useMutation({
mutationFn: (data: UpdateResidenceRequest) =>
residencesApi.updateResidence(id, data),
residences.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['residences'] });
queryClient.invalidateQueries({ queryKey: ['residences', id] });
@@ -52,8 +57,9 @@ export function useUpdateResidence(id: number) {
export function useDeleteResidence() {
const queryClient = useQueryClient();
const { residences } = useDataProvider();
return useMutation({
mutationFn: (id: number) => residencesApi.deleteResidence(id),
mutationFn: (id: number) => residences.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['residences'] });
},
+11 -6
View File
@@ -1,24 +1,26 @@
"use client";
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import * as residencesApi from '@/lib/api/residences';
import { useDataProvider } from '@/lib/demo/data-provider-context';
// ---------------------------------------------------------------------------
// Query hooks
// ---------------------------------------------------------------------------
export function useShareCode(residenceId: number) {
const { sharing } = useDataProvider();
return useQuery({
queryKey: ['residences', residenceId, 'share-code'],
queryFn: () => residencesApi.getShareCode(residenceId),
queryFn: () => sharing.getShareCode(residenceId),
enabled: !!residenceId,
});
}
export function useResidenceUsers(residenceId: number) {
const { sharing } = useDataProvider();
return useQuery({
queryKey: ['residences', residenceId, 'users'],
queryFn: () => residencesApi.getResidenceUsers(residenceId),
queryFn: () => sharing.getResidenceUsers(residenceId),
enabled: !!residenceId,
});
}
@@ -29,8 +31,9 @@ export function useResidenceUsers(residenceId: number) {
export function useGenerateShareCode(residenceId: number) {
const queryClient = useQueryClient();
const { sharing } = useDataProvider();
return useMutation({
mutationFn: () => residencesApi.generateShareCode(residenceId),
mutationFn: () => sharing.generateShareCode(residenceId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['residences', residenceId, 'share-code'] });
},
@@ -39,8 +42,9 @@ export function useGenerateShareCode(residenceId: number) {
export function useRemoveResidenceUser(residenceId: number) {
const queryClient = useQueryClient();
const { sharing } = useDataProvider();
return useMutation({
mutationFn: (userId: number) => residencesApi.removeResidenceUser(residenceId, userId),
mutationFn: (userId: number) => sharing.removeUser(residenceId, userId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['residences', residenceId, 'users'] });
},
@@ -49,8 +53,9 @@ export function useRemoveResidenceUser(residenceId: number) {
export function useJoinResidence() {
const queryClient = useQueryClient();
const { sharing } = useDataProvider();
return useMutation({
mutationFn: (code: string) => residencesApi.joinWithCode({ code }),
mutationFn: (code: string) => sharing.joinWithCode({ code }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['residences'] });
},
+7 -4
View File
@@ -1,27 +1,30 @@
"use client";
import { useQuery } from '@tanstack/react-query';
import * as subscriptionApi from '@/lib/api/subscription';
import { useDataProvider } from '@/lib/demo/data-provider-context';
export function useSubscriptionStatus() {
const { subscription } = useDataProvider();
return useQuery({
queryKey: ['subscription', 'status'],
queryFn: () => subscriptionApi.getSubscriptionStatus(),
queryFn: () => subscription.getStatus(),
});
}
export function useFeatureBenefits() {
const { subscription } = useDataProvider();
return useQuery({
queryKey: ['subscription', 'features'],
queryFn: () => subscriptionApi.getFeatureBenefits(),
queryFn: () => subscription.getFeatureBenefits(),
staleTime: Infinity,
});
}
export function useUpgradeTriggers() {
const { subscription } = useDataProvider();
return useQuery({
queryKey: ['subscription', 'upgrade-triggers'],
queryFn: () => subscriptionApi.getAllUpgradeTriggers(),
queryFn: () => subscription.getUpgradeTriggers(),
staleTime: Infinity,
});
}
+26 -14
View File
@@ -1,7 +1,7 @@
"use client";
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import * as tasksApi from '@/lib/api/tasks';
import { useDataProvider } from '@/lib/demo/data-provider-context';
import type { CreateTaskRequest, UpdateTaskRequest, CreateCompletionRequest } from '@/lib/api/tasks';
// ---------------------------------------------------------------------------
@@ -9,40 +9,45 @@ import type { CreateTaskRequest, UpdateTaskRequest, CreateCompletionRequest } fr
// ---------------------------------------------------------------------------
export function useTasks() {
const { tasks } = useDataProvider();
return useQuery({
queryKey: ['tasks'],
queryFn: () => tasksApi.listTasks(),
queryFn: () => tasks.list(),
});
}
export function useTasksByResidence(residenceId: number) {
const { tasks } = useDataProvider();
return useQuery({
queryKey: ['tasks', 'by-residence', residenceId],
queryFn: () => tasksApi.getTasksByResidence(residenceId),
queryFn: () => tasks.getByResidence(residenceId),
enabled: !!residenceId,
});
}
export function useTask(id: number) {
const { tasks } = useDataProvider();
return useQuery({
queryKey: ['tasks', id],
queryFn: () => tasksApi.getTask(id),
queryFn: () => tasks.get(id),
enabled: !!id,
});
}
export function useKanbanBoard(residenceId: number) {
const { tasks } = useDataProvider();
return useQuery({
queryKey: ['tasks', 'kanban', residenceId],
queryFn: () => tasksApi.getTasksByResidence(residenceId),
queryFn: () => tasks.getByResidence(residenceId),
enabled: !!residenceId,
});
}
export function useTaskCompletions(taskId: number) {
const { tasks } = useDataProvider();
return useQuery({
queryKey: ['tasks', taskId, 'completions'],
queryFn: () => tasksApi.getTaskCompletions(taskId),
queryFn: () => tasks.getCompletions(taskId),
enabled: !!taskId,
});
}
@@ -53,8 +58,9 @@ export function useTaskCompletions(taskId: number) {
export function useCreateTask() {
const queryClient = useQueryClient();
const { tasks } = useDataProvider();
return useMutation({
mutationFn: (data: CreateTaskRequest) => tasksApi.createTask(data),
mutationFn: (data: CreateTaskRequest) => tasks.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tasks'] });
queryClient.invalidateQueries({ queryKey: ['residences'] });
@@ -64,8 +70,9 @@ export function useCreateTask() {
export function useUpdateTask(id: number) {
const queryClient = useQueryClient();
const { tasks } = useDataProvider();
return useMutation({
mutationFn: (data: UpdateTaskRequest) => tasksApi.updateTask(id, data),
mutationFn: (data: UpdateTaskRequest) => tasks.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tasks'] });
queryClient.invalidateQueries({ queryKey: ['tasks', id] });
@@ -76,8 +83,9 @@ export function useUpdateTask(id: number) {
export function useDeleteTask() {
const queryClient = useQueryClient();
const { tasks } = useDataProvider();
return useMutation({
mutationFn: (id: number) => tasksApi.deleteTask(id),
mutationFn: (id: number) => tasks.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tasks'] });
queryClient.invalidateQueries({ queryKey: ['residences'] });
@@ -87,8 +95,9 @@ export function useDeleteTask() {
export function useMarkInProgress() {
const queryClient = useQueryClient();
const { tasks } = useDataProvider();
return useMutation({
mutationFn: (id: number) => tasksApi.markInProgress(id),
mutationFn: (id: number) => tasks.markInProgress(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tasks'] });
},
@@ -97,8 +106,9 @@ export function useMarkInProgress() {
export function useCancelTask() {
const queryClient = useQueryClient();
const { tasks } = useDataProvider();
return useMutation({
mutationFn: (id: number) => tasksApi.cancelTask(id),
mutationFn: (id: number) => tasks.cancel(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tasks'] });
},
@@ -107,8 +117,9 @@ export function useCancelTask() {
export function useArchiveTask() {
const queryClient = useQueryClient();
const { tasks } = useDataProvider();
return useMutation({
mutationFn: (id: number) => tasksApi.archiveTask(id),
mutationFn: (id: number) => tasks.archive(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tasks'] });
},
@@ -117,6 +128,7 @@ export function useArchiveTask() {
export function useCreateCompletion() {
const queryClient = useQueryClient();
const { tasks } = useDataProvider();
return useMutation({
mutationFn: ({
data,
@@ -126,7 +138,7 @@ export function useCreateCompletion() {
images: File[];
}) => {
if (images.length > 0) {
return tasksApi.createCompletionWithImages(
return tasks.createCompletionWithImages(
{
task_id: data.task_id,
notes: data.notes,
@@ -136,7 +148,7 @@ export function useCreateCompletion() {
images,
);
}
return tasksApi.createCompletion(data);
return tasks.createCompletion(data);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tasks'] });