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
+3 -2
View File
@@ -64,7 +64,7 @@ export default function ForgotPasswordPage() {
>
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
{error && (
<div className="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">
<div role="alert" aria-live="assertive" className="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</div>
)}
@@ -77,10 +77,11 @@ export default function ForgotPasswordPage() {
placeholder="you@example.com"
autoComplete="email"
aria-invalid={!!errors.email}
aria-describedby={errors.email ? "email-error" : undefined}
{...register("email")}
/>
{errors.email && (
<p className="text-sm text-destructive">{errors.email.message}</p>
<p id="email-error" role="alert" className="text-sm text-destructive">{errors.email.message}</p>
)}
</div>
+5 -3
View File
@@ -44,7 +44,7 @@ export default function LoginPage() {
>
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
{error && (
<div className="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">
<div role="alert" aria-live="assertive" className="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</div>
)}
@@ -56,10 +56,11 @@ export default function LoginPage() {
placeholder="you@example.com"
autoComplete="username"
aria-invalid={!!errors.username}
aria-describedby={errors.username ? "username-error" : undefined}
{...register("username")}
/>
{errors.username && (
<p className="text-sm text-destructive">
<p id="username-error" role="alert" className="text-sm text-destructive">
{errors.username.message}
</p>
)}
@@ -79,10 +80,11 @@ export default function LoginPage() {
id="password"
autoComplete="current-password"
aria-invalid={!!errors.password}
aria-describedby={errors.password ? "password-error" : undefined}
{...register("password")}
/>
{errors.password && (
<p className="text-sm text-destructive">
<p id="password-error" role="alert" className="text-sm text-destructive">
{errors.password.message}
</p>
)}
+13 -7
View File
@@ -59,7 +59,7 @@ export default function RegisterPage() {
>
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
{error && (
<div className="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">
<div role="alert" aria-live="assertive" className="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</div>
)}
@@ -71,10 +71,11 @@ export default function RegisterPage() {
id="first_name"
autoComplete="given-name"
aria-invalid={!!errors.first_name}
aria-describedby={errors.first_name ? "first-name-error" : undefined}
{...register("first_name")}
/>
{errors.first_name && (
<p className="text-sm text-destructive">
<p id="first-name-error" role="alert" className="text-sm text-destructive">
{errors.first_name.message}
</p>
)}
@@ -86,10 +87,11 @@ export default function RegisterPage() {
id="last_name"
autoComplete="family-name"
aria-invalid={!!errors.last_name}
aria-describedby={errors.last_name ? "last-name-error" : undefined}
{...register("last_name")}
/>
{errors.last_name && (
<p className="text-sm text-destructive">
<p id="last-name-error" role="alert" className="text-sm text-destructive">
{errors.last_name.message}
</p>
)}
@@ -102,10 +104,11 @@ export default function RegisterPage() {
id="username"
autoComplete="username"
aria-invalid={!!errors.username}
aria-describedby={errors.username ? "username-error" : undefined}
{...register("username")}
/>
{errors.username && (
<p className="text-sm text-destructive">
<p id="username-error" role="alert" className="text-sm text-destructive">
{errors.username.message}
</p>
)}
@@ -119,10 +122,11 @@ export default function RegisterPage() {
placeholder="you@example.com"
autoComplete="email"
aria-invalid={!!errors.email}
aria-describedby={errors.email ? "email-error" : undefined}
{...register("email")}
/>
{errors.email && (
<p className="text-sm text-destructive">{errors.email.message}</p>
<p id="email-error" role="alert" className="text-sm text-destructive">{errors.email.message}</p>
)}
</div>
@@ -132,10 +136,11 @@ export default function RegisterPage() {
id="password"
autoComplete="new-password"
aria-invalid={!!errors.password}
aria-describedby={errors.password ? "password-error" : undefined}
{...register("password")}
/>
{errors.password && (
<p className="text-sm text-destructive">
<p id="password-error" role="alert" className="text-sm text-destructive">
{errors.password.message}
</p>
)}
@@ -147,10 +152,11 @@ export default function RegisterPage() {
id="confirm_password"
autoComplete="new-password"
aria-invalid={!!errors.confirm_password}
aria-describedby={errors.confirm_password ? "confirm-password-error" : undefined}
{...register("confirm_password")}
/>
{errors.confirm_password && (
<p className="text-sm text-destructive">
<p id="confirm-password-error" role="alert" className="text-sm text-destructive">
{errors.confirm_password.message}
</p>
)}
+1 -1
View File
@@ -11,7 +11,7 @@ import { NextRequest, NextResponse } from 'next/server';
const API_BASE_URL =
process.env.API_URL ||
process.env.NEXT_PUBLIC_API_URL ||
'https://mycrib.treytartt.com/api';
'https://casera.treytartt.com/api';
const COOKIE_NAME = 'casera-token';
const COOKIE_MAX_AGE = 60 * 60 * 24 * 30; // 30 days
+1 -1
View File
@@ -11,7 +11,7 @@ import { NextRequest, NextResponse } from 'next/server';
const API_BASE_URL =
process.env.API_URL ||
process.env.NEXT_PUBLIC_API_URL ||
'https://mycrib.treytartt.com/api';
'https://casera.treytartt.com/api';
const COOKIE_NAME = 'casera-token';
+1 -1
View File
@@ -11,7 +11,7 @@ import { NextRequest, NextResponse } from 'next/server';
const API_BASE_URL =
process.env.API_URL ||
process.env.NEXT_PUBLIC_API_URL ||
'https://mycrib.treytartt.com/api';
'https://casera.treytartt.com/api';
const COOKIE_NAME = 'casera-token';
+2 -2
View File
@@ -12,11 +12,11 @@ import { NextRequest, NextResponse } from 'next/server';
const API_BASE_URL =
process.env.API_URL ||
process.env.NEXT_PUBLIC_API_URL ||
'https://mycrib.treytartt.com/api';
'https://casera.treytartt.com/api';
/**
* Build the target URL from the catch-all path segments.
* e.g. /api/proxy/tasks/123/ -> https://mycrib.treytartt.com/api/tasks/123/
* e.g. /api/proxy/tasks/123/ -> https://casera.treytartt.com/api/tasks/123/
*/
function buildTargetUrl(request: NextRequest, pathSegments: string[]): string {
const path = `/${pathSegments.join('/')}`;
+9 -2
View File
@@ -2,11 +2,13 @@
import { use } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { PageHeader } from "@/components/shared/page-header";
import { LoadingSkeleton } from "@/components/shared/loading-skeleton";
import { ErrorBanner } from "@/components/shared/error-banner";
import { ContractorForm } from "@/components/contractors/contractor-form";
import { useContractor, useUpdateContractor } from "@/lib/hooks/use-contractors";
import { useDataProvider } from "@/lib/demo/data-provider-context";
import type { ContractorFormValues } from "@/components/contractors/contractor-form";
export default function EditContractorPage({
@@ -17,6 +19,7 @@ export default function EditContractorPage({
const { id: idParam } = use(params);
const id = Number(idParam);
const router = useRouter();
const { basePath } = useDataProvider();
const { data: contractor, isLoading, isError, error, refetch } = useContractor(id);
const updateContractor = useUpdateContractor(id);
@@ -24,7 +27,11 @@ export default function EditContractorPage({
function handleSubmit(data: ContractorFormValues) {
updateContractor.mutate(data, {
onSuccess: () => {
router.push(`/app/contractors/${id}`);
toast.success("Contractor updated");
router.push(`${basePath}/contractors/${id}`);
},
onError: () => {
toast.error("Failed to update contractor");
},
});
}
@@ -45,7 +52,7 @@ export default function EditContractorPage({
if (!contractor) return null;
return (
<div className="space-y-6 max-w-2xl">
<div className="space-y-6 w-full max-w-2xl mx-auto">
<PageHeader title={`Edit ${contractor.name}`} />
<ContractorForm
contractor={contractor}
+8 -1
View File
@@ -3,6 +3,7 @@
import { use, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { toast } from "sonner";
import { Phone, Mail, Globe, Star, Pencil, Trash2, FileDown } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -20,6 +21,7 @@ import {
useDeleteContractor,
useToggleFavorite,
} from "@/lib/hooks/use-contractors";
import { useDataProvider } from "@/lib/demo/data-provider-context";
export default function ContractorDetailPage({
params,
@@ -29,6 +31,7 @@ export default function ContractorDetailPage({
const { id: idParam } = use(params);
const id = Number(idParam);
const router = useRouter();
const { basePath } = useDataProvider();
const { data: contractor, isLoading, isError, error, refetch } = useContractor(id);
const { data: tasks } = useContractorTasks(id);
@@ -40,7 +43,11 @@ export default function ContractorDetailPage({
function handleDelete() {
deleteContractor.mutate(id, {
onSuccess: () => {
router.push("/app/contractors");
toast.success("Contractor deleted");
router.push(`${basePath}/contractors`);
},
onError: () => {
toast.error("Failed to delete contractor");
},
});
}
+9 -2
View File
@@ -1,25 +1,32 @@
"use client";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { PageHeader } from "@/components/shared/page-header";
import { ContractorForm } from "@/components/contractors/contractor-form";
import { useCreateContractor } from "@/lib/hooks/use-contractors";
import { useDataProvider } from "@/lib/demo/data-provider-context";
import type { ContractorFormValues } from "@/components/contractors/contractor-form";
export default function NewContractorPage() {
const router = useRouter();
const { basePath } = useDataProvider();
const createContractor = useCreateContractor();
function handleSubmit(data: ContractorFormValues) {
createContractor.mutate(data, {
onSuccess: (res) => {
router.push(`/app/contractors/${res.id}`);
toast.success("Contractor created");
router.push(`${basePath}/contractors/${res.id}`);
},
onError: () => {
toast.error("Failed to create contractor");
},
});
}
return (
<div className="space-y-6 max-w-2xl">
<div className="space-y-6 w-full max-w-2xl mx-auto">
<PageHeader title="New Contractor" />
<ContractorForm onSubmit={handleSubmit} loading={createContractor.isPending} />
</div>
+7 -5
View File
@@ -15,9 +15,11 @@ import { CaseraFileImport } from "@/components/sharing/casera-file-handler";
import { ContractorCard } from "@/components/contractors/contractor-card";
import { ContractorFilters } from "@/components/contractors/contractor-filters";
import { useContractors, useToggleFavorite, useCreateContractor } from "@/lib/hooks/use-contractors";
import { useDataProvider } from "@/lib/demo/data-provider-context";
export default function ContractorsPage() {
const router = useRouter();
const { basePath } = useDataProvider();
const { data: contractors, isLoading, isError, error, refetch } = useContractors();
const toggleFavorite = useToggleFavorite();
const createContractor = useCreateContractor();
@@ -29,7 +31,7 @@ export default function ContractorsPage() {
const [importError, setImportError] = useState<string | null>(null);
const filtered = useMemo(() => {
if (!contractors) return [];
if (!Array.isArray(contractors)) return [];
let list = contractors;
// Search filter (name or company)
@@ -105,7 +107,7 @@ export default function ContractorsPage() {
title="Contractors"
description="Manage your trusted contractors and service providers"
actionLabel="Add Contractor"
onAction={() => router.push("/app/contractors/new")}
onAction={() => router.push(`${basePath}/contractors/new`)}
>
<Button
variant="outline"
@@ -129,7 +131,7 @@ export default function ContractorsPage() {
{isLoading && <LoadingSkeleton variant="list" count={5} />}
{!isLoading && !isError && contractors && (
{!isLoading && !isError && Array.isArray(contractors) && (
<>
<ContractorFilters
search={search}
@@ -145,12 +147,12 @@ export default function ContractorsPage() {
icon={Wrench}
title="No contractors found"
description={
contractors.length === 0
(contractors?.length ?? 0) === 0
? "Add your first contractor to keep track of service providers."
: "Try adjusting your search or filters."
}
actionLabel={contractors.length === 0 ? "Add Contractor" : undefined}
onAction={contractors.length === 0 ? () => router.push("/app/contractors/new") : undefined}
onAction={contractors.length === 0 ? () => router.push(`${basePath}/contractors/new`) : undefined}
/>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
+9 -2
View File
@@ -2,12 +2,14 @@
import { use } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { PageHeader } from "@/components/shared/page-header";
import { LoadingSkeleton } from "@/components/shared/loading-skeleton";
import { ErrorBanner } from "@/components/shared/error-banner";
import { DocumentForm } from "@/components/documents/document-form";
import { useDocument, useUpdateDocument } from "@/lib/hooks/use-documents";
import { useDataProvider } from "@/lib/demo/data-provider-context";
interface EditDocumentPageProps {
params: Promise<{ id: string }>;
@@ -17,6 +19,7 @@ export default function EditDocumentPage({ params }: EditDocumentPageProps) {
const { id: rawId } = use(params);
const id = Number(rawId);
const router = useRouter();
const { basePath } = useDataProvider();
const { data: document, isLoading, error, refetch } = useDocument(id);
const updateDocument = useUpdateDocument(id);
@@ -41,7 +44,7 @@ export default function EditDocumentPage({ params }: EditDocumentPageProps) {
}
return (
<div className="space-y-6">
<div className="space-y-6 w-full max-w-2xl mx-auto">
<PageHeader
title="Edit Document"
description={document.title}
@@ -53,7 +56,11 @@ export default function EditDocumentPage({ params }: EditDocumentPageProps) {
onSubmit={(data) => {
updateDocument.mutate(data, {
onSuccess: () => {
router.push(`/app/documents/${id}`);
toast.success("Document updated");
router.push(`${basePath}/documents/${id}`);
},
onError: () => {
toast.error("Failed to update document");
},
});
}}
+9 -2
View File
@@ -2,6 +2,7 @@
import { use, useState } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import {
Pencil,
Trash2,
@@ -20,6 +21,7 @@ import { ConfirmDialog } from "@/components/shared/confirm-dialog";
import { WarrantyStatus } from "@/components/documents/warranty-status";
import { ImageGallery } from "@/components/documents/image-gallery";
import { useDocument, useDeleteDocument } from "@/lib/hooks/use-documents";
import { useDataProvider } from "@/lib/demo/data-provider-context";
const typeLabels: Record<string, string> = {
general: "General",
@@ -38,6 +40,7 @@ export default function DocumentDetailPage({ params }: DocumentDetailPageProps)
const { id: rawId } = use(params);
const id = Number(rawId);
const router = useRouter();
const { basePath } = useDataProvider();
const { data: document, isLoading, error, refetch } = useDocument(id);
const deleteDocument = useDeleteDocument();
@@ -96,7 +99,7 @@ export default function DocumentDetailPage({ params }: DocumentDetailPageProps)
<Button
variant="outline"
size="sm"
onClick={() => router.push(`/app/documents/${id}/edit`)}
onClick={() => router.push(`${basePath}/documents/${id}/edit`)}
>
<Pencil className="size-4 mr-2" />
Edit
@@ -229,7 +232,11 @@ export default function DocumentDetailPage({ params }: DocumentDetailPageProps)
onConfirm={() => {
deleteDocument.mutate(id, {
onSuccess: () => {
router.push("/app/documents");
toast.success("Document deleted");
router.push(`${basePath}/documents`);
},
onError: () => {
toast.error("Failed to delete document");
},
});
}}
+9 -2
View File
@@ -1,17 +1,20 @@
"use client";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { PageHeader } from "@/components/shared/page-header";
import { DocumentForm } from "@/components/documents/document-form";
import { useCreateDocument } from "@/lib/hooks/use-documents";
import { useDataProvider } from "@/lib/demo/data-provider-context";
export default function NewDocumentPage() {
const router = useRouter();
const { basePath } = useDataProvider();
const createDocument = useCreateDocument();
return (
<div className="space-y-6">
<div className="space-y-6 w-full max-w-2xl mx-auto">
<PageHeader title="New Document" description="Add a new document" />
<DocumentForm
@@ -21,7 +24,11 @@ export default function NewDocumentPage() {
{ data, file },
{
onSuccess: (res) => {
router.push(`/app/documents/${res.id}`);
toast.success("Document created");
router.push(`${basePath}/documents/${res.id}`);
},
onError: () => {
toast.error("Failed to create document");
},
},
);
+8 -6
View File
@@ -10,9 +10,11 @@ import { ErrorBanner } from "@/components/shared/error-banner";
import { EmptyState } from "@/components/shared/empty-state";
import { DocumentCard } from "@/components/documents/document-card";
import { useDocuments, useWarranties } from "@/lib/hooks/use-documents";
import { useDataProvider } from "@/lib/demo/data-provider-context";
export default function DocumentsPage() {
const router = useRouter();
const { basePath } = useDataProvider();
const {
data: documents,
isLoading: documentsLoading,
@@ -32,7 +34,7 @@ export default function DocumentsPage() {
title="Documents"
description="Manage your property documents and warranties"
actionLabel="Add Document"
onAction={() => router.push("/app/documents/new")}
onAction={() => router.push(`${basePath}/documents/new`)}
/>
<Tabs defaultValue="documents">
@@ -53,20 +55,20 @@ export default function DocumentsPage() {
{!documentsLoading &&
!documentsError &&
documents &&
Array.isArray(documents) &&
documents.length === 0 && (
<EmptyState
icon={FileText}
title="No documents yet"
description="Add your first document to start organizing your property records."
actionLabel="Add Document"
onAction={() => router.push("/app/documents/new")}
onAction={() => router.push(`${basePath}/documents/new`)}
/>
)}
{!documentsLoading &&
!documentsError &&
documents &&
Array.isArray(documents) &&
documents.length > 0 && (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{documents.map((doc) => (
@@ -88,7 +90,7 @@ export default function DocumentsPage() {
{!warrantiesLoading &&
!warrantiesError &&
warranties &&
Array.isArray(warranties) &&
warranties.length === 0 && (
<EmptyState
icon={FileText}
@@ -99,7 +101,7 @@ export default function DocumentsPage() {
{!warrantiesLoading &&
!warrantiesError &&
warranties &&
Array.isArray(warranties) &&
warranties.length > 0 && (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{warranties.map((doc) => (
+17 -13
View File
@@ -3,23 +3,27 @@
import { Sidebar } from '@/components/layout/sidebar';
import { TopBar } from '@/components/layout/top-bar';
import { MobileNav } from '@/components/layout/mobile-nav';
import { DataProviderProvider } from '@/lib/demo/data-provider-context';
import { realProvider } from '@/lib/demo/real-provider';
export default function AppLayout({ children }: { children: React.ReactNode }) {
return (
<div className="min-h-screen bg-background">
{/* Sidebar - hidden on mobile */}
<Sidebar />
<DataProviderProvider value={realProvider}>
<div className="min-h-screen bg-background">
{/* Sidebar - hidden on mobile */}
<Sidebar />
{/* Main content area */}
<div className="md:ml-16 lg:ml-64 flex flex-col min-h-screen">
<TopBar />
<main className="flex-1 p-4 lg:p-6 pb-20 md:pb-6">
{children}
</main>
{/* Main content area */}
<div className="md:ml-16 lg:ml-64 flex flex-col min-h-screen">
<TopBar />
<main className="flex-1 p-4 lg:p-6 pb-20 md:pb-6">
{children}
</main>
</div>
{/* Mobile bottom nav */}
<MobileNav />
</div>
{/* Mobile bottom nav */}
<MobileNav />
</div>
</DataProviderProvider>
);
}
+19 -5
View File
@@ -1,24 +1,38 @@
"use client";
import dynamic from "next/dynamic";
import { useResidences } from "@/lib/hooks/use-residences";
import { useAuthStore } from "@/stores/auth";
import { LoadingSkeleton } from "@/components/shared/loading-skeleton";
import { StatsCards } from "@/components/dashboard/stats-cards";
import { TaskCompletionChart } from "@/components/dashboard/task-completion-chart";
import { RecentActivity } from "@/components/dashboard/recent-activity";
import { Skeleton } from "@/components/ui/skeleton";
const TaskCompletionChart = dynamic(
() => import("@/components/dashboard/task-completion-chart").then((mod) => ({ default: mod.TaskCompletionChart })),
{
loading: () => (
<div className="rounded-lg border p-6 space-y-4">
<Skeleton className="h-5 w-40" />
<Skeleton className="h-[300px] w-full" />
</div>
),
}
);
export default function DashboardPage() {
const { data: residences, isLoading } = useResidences();
const user = useAuthStore((s) => s.user);
const list = Array.isArray(residences) ? residences : [];
const totalOverdue =
residences?.reduce((sum, r) => sum + r.task_summary.overdue, 0) ?? 0;
list.reduce((sum, r) => sum + (r.task_summary?.overdue ?? 0), 0);
const totalDueSoon =
residences?.reduce((sum, r) => sum + r.task_summary.due_soon, 0) ?? 0;
list.reduce((sum, r) => sum + (r.task_summary?.due_soon ?? 0), 0);
const totalActive =
residences?.reduce((sum, r) => sum + r.task_summary.in_progress, 0) ?? 0;
list.reduce((sum, r) => sum + (r.task_summary?.in_progress ?? 0), 0);
const totalCompleted =
residences?.reduce((sum, r) => sum + r.task_summary.completed, 0) ?? 0;
list.reduce((sum, r) => sum + (r.task_summary?.completed ?? 0), 0);
return (
<div className="space-y-8">
+9 -2
View File
@@ -2,12 +2,14 @@
import { use } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { PageHeader } from "@/components/shared/page-header";
import { LoadingSkeleton } from "@/components/shared/loading-skeleton";
import { ErrorBanner } from "@/components/shared/error-banner";
import { ResidenceForm } from "@/components/residences/residence-form";
import { useResidence, useUpdateResidence } from "@/lib/hooks/use-residences";
import { useDataProvider } from "@/lib/demo/data-provider-context";
interface EditResidencePageProps {
params: Promise<{ id: string }>;
@@ -17,6 +19,7 @@ export default function EditResidencePage({ params }: EditResidencePageProps) {
const { id: rawId } = use(params);
const id = Number(rawId);
const router = useRouter();
const { basePath } = useDataProvider();
const { data: residence, isLoading, error, refetch } = useResidence(id);
const updateResidence = useUpdateResidence(id);
@@ -41,7 +44,7 @@ export default function EditResidencePage({ params }: EditResidencePageProps) {
}
return (
<div className="space-y-6">
<div className="space-y-6 w-full max-w-2xl mx-auto">
<PageHeader
title="Edit Residence"
description={residence.name}
@@ -53,7 +56,11 @@ export default function EditResidencePage({ params }: EditResidencePageProps) {
onSubmit={(data) => {
updateResidence.mutate(data, {
onSuccess: () => {
router.push(`/app/residences/${id}`);
toast.success("Residence updated");
router.push(`${basePath}/residences/${id}`);
},
onError: () => {
toast.error("Failed to update residence");
},
});
}}
+17 -7
View File
@@ -3,7 +3,8 @@
import { use, useState } from "react";
import { useRouter } from "next/navigation";
import { MapPin, Pencil, Share2, Trash2, FileDown } from "lucide-react";
import * as residencesApi from "@/lib/api/residences";
import { toast } from "sonner";
import { useDataProvider } from "@/lib/demo/data-provider-context";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -22,6 +23,7 @@ export default function ResidenceDetailPage({ params }: ResidenceDetailPageProps
const { id: rawId } = use(params);
const id = Number(rawId);
const router = useRouter();
const { basePath, sharing } = useDataProvider();
const { data: residence, isLoading, error, refetch } = useResidence(id);
const { data: residences } = useResidences();
@@ -35,17 +37,21 @@ export default function ResidenceDetailPage({ params }: ResidenceDetailPageProps
setReportLoading(true);
setReportMessage(null);
try {
const result = await residencesApi.generateTasksReport(id);
setReportMessage(result.message || "Report sent to your email!");
const result = await sharing.generateTasksReport(id);
const msg = result.message || "Report sent to your email!";
setReportMessage(msg);
toast.success(msg);
} catch {
setReportMessage("Failed to generate report.");
toast.error("Failed to generate report");
} finally {
setReportLoading(false);
}
};
// Find the task summary from the residences list
const myResidence = residences?.find((r) => r.residence.id === id);
const resList = Array.isArray(residences) ? residences : [];
const myResidence = resList.find((r) => r.residence.id === id);
const taskSummary = myResidence?.task_summary;
if (isLoading) {
@@ -93,7 +99,7 @@ export default function ResidenceDetailPage({ params }: ResidenceDetailPageProps
<Button
variant="outline"
size="sm"
onClick={() => router.push(`/app/residences/${id}/share`)}
onClick={() => router.push(`${basePath}/residences/${id}/share`)}
>
<Share2 className="size-4 mr-2" />
Share
@@ -111,7 +117,7 @@ export default function ResidenceDetailPage({ params }: ResidenceDetailPageProps
<Button
variant="outline"
size="sm"
onClick={() => router.push(`/app/residences/${id}/edit`)}
onClick={() => router.push(`${basePath}/residences/${id}/edit`)}
>
<Pencil className="size-4 mr-2" />
Edit
@@ -193,7 +199,11 @@ export default function ResidenceDetailPage({ params }: ResidenceDetailPageProps
onConfirm={() => {
deleteResidence.mutate(id, {
onSuccess: () => {
router.push("/app/residences");
toast.success("Residence deleted");
router.push(`${basePath}/residences`);
},
onError: () => {
toast.error("Failed to delete residence");
},
});
}}
+3 -1
View File
@@ -11,6 +11,7 @@ import { ShareCodeDisplay } from "@/components/sharing/share-code-display";
import { UserManagement } from "@/components/sharing/user-management";
import { CaseraFileExport } from "@/components/sharing/casera-file-handler";
import { useResidence } from "@/lib/hooks/use-residences";
import { useDataProvider } from "@/lib/demo/data-provider-context";
interface SharePageProps {
params: Promise<{ id: string }>;
@@ -20,6 +21,7 @@ export default function ResidenceSharePage({ params }: SharePageProps) {
const { id: rawId } = use(params);
const id = Number(rawId);
const router = useRouter();
const { basePath } = useDataProvider();
const { data: residence, isLoading, error, refetch } = useResidence(id);
@@ -73,7 +75,7 @@ export default function ResidenceSharePage({ params }: SharePageProps) {
<Button
variant="ghost"
size="sm"
onClick={() => router.push(`/app/residences/${id}`)}
onClick={() => router.push(`${basePath}/residences/${id}`)}
>
<ArrowLeft className="size-4 mr-2" />
Back
+13 -2
View File
@@ -2,6 +2,7 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { Home } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
@@ -12,9 +13,11 @@ import { PageHeader } from "@/components/shared/page-header";
import { ErrorBanner } from "@/components/shared/error-banner";
import { CaseraFileImport } from "@/components/sharing/casera-file-handler";
import { useJoinResidence } from "@/lib/hooks/use-sharing";
import { useDataProvider } from "@/lib/demo/data-provider-context";
export default function JoinResidencePage() {
const router = useRouter();
const { basePath } = useDataProvider();
const joinResidence = useJoinResidence();
const [code, setCode] = useState("");
@@ -27,7 +30,11 @@ export default function JoinResidencePage() {
joinResidence.mutate(trimmed, {
onSuccess: () => {
router.push("/app/residences");
toast.success("Joined residence");
router.push(`${basePath}/residences`);
},
onError: () => {
toast.error("Failed to join residence");
},
});
}
@@ -45,7 +52,11 @@ export default function JoinResidencePage() {
const importedCode = (data as Record<string, unknown>).code as string;
joinResidence.mutate(importedCode, {
onSuccess: () => {
router.push("/app/residences");
toast.success("Joined residence");
router.push(`${basePath}/residences`);
},
onError: () => {
toast.error("Failed to join residence");
},
});
} else {
+9 -2
View File
@@ -1,17 +1,20 @@
"use client";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { PageHeader } from "@/components/shared/page-header";
import { ResidenceForm } from "@/components/residences/residence-form";
import { useCreateResidence } from "@/lib/hooks/use-residences";
import { useDataProvider } from "@/lib/demo/data-provider-context";
export default function NewResidencePage() {
const router = useRouter();
const { basePath } = useDataProvider();
const createResidence = useCreateResidence();
return (
<div className="space-y-6">
<div className="space-y-6 w-full max-w-2xl mx-auto">
<PageHeader title="New Residence" description="Add a new property" />
<ResidenceForm
@@ -19,7 +22,11 @@ export default function NewResidencePage() {
onSubmit={(data) => {
createResidence.mutate(data, {
onSuccess: (res) => {
router.push(`/app/residences/${res.id}`);
toast.success("Residence created");
router.push(`${basePath}/residences/${res.id}`);
},
onError: () => {
toast.error("Failed to create residence");
},
});
}}
+6 -4
View File
@@ -9,9 +9,11 @@ import { ErrorBanner } from "@/components/shared/error-banner";
import { EmptyState } from "@/components/shared/empty-state";
import { ResidenceCard } from "@/components/residences/residence-card";
import { useResidences } from "@/lib/hooks/use-residences";
import { useDataProvider } from "@/lib/demo/data-provider-context";
export default function ResidencesPage() {
const router = useRouter();
const { basePath } = useDataProvider();
const { data: residences, isLoading, error, refetch } = useResidences();
return (
@@ -20,7 +22,7 @@ export default function ResidencesPage() {
title="Residences"
description="Manage your properties"
actionLabel="Add Residence"
onAction={() => router.push("/app/residences/new")}
onAction={() => router.push(`${basePath}/residences/new`)}
/>
{isLoading && <LoadingSkeleton variant="card-grid" />}
@@ -32,17 +34,17 @@ export default function ResidencesPage() {
/>
)}
{!isLoading && !error && residences && residences.length === 0 && (
{!isLoading && !error && Array.isArray(residences) && residences.length === 0 && (
<EmptyState
icon={Home}
title="No residences yet"
description="Add your first property to start tracking tasks and maintenance."
actionLabel="Add Residence"
onAction={() => router.push("/app/residences/new")}
onAction={() => router.push(`${basePath}/residences/new`)}
/>
)}
{!isLoading && !error && residences && residences.length > 0 && (
{!isLoading && !error && Array.isArray(residences) && residences.length > 0 && (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{residences.map((item) => (
<ResidenceCard key={item.residence.id} data={item} />
+10 -5
View File
@@ -4,15 +4,20 @@ import Link from "next/link";
import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
import { User, Bell, CreditCard } from "lucide-react";
import { useDataProvider } from "@/lib/demo/data-provider-context";
const settingsNav = [
{ label: "Profile", href: "/app/settings/profile", icon: User },
{ label: "Notifications", href: "/app/settings/notifications", icon: Bell },
{ label: "Subscription", href: "/app/settings/subscription", icon: CreditCard },
];
function getSettingsNav(basePath: string) {
return [
{ label: "Profile", href: `${basePath}/settings/profile`, icon: User },
{ label: "Notifications", href: `${basePath}/settings/notifications`, icon: Bell },
{ label: "Subscription", href: `${basePath}/settings/subscription`, icon: CreditCard },
];
}
export default function SettingsLayout({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const { basePath } = useDataProvider();
const settingsNav = getSettingsNav(basePath);
return (
<div className="space-y-6">
+13 -2
View File
@@ -1,5 +1,16 @@
import { redirect } from "next/navigation";
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { useDataProvider } from "@/lib/demo/data-provider-context";
export default function SettingsPage() {
redirect("/app/settings/profile");
const router = useRouter();
const { basePath } = useDataProvider();
useEffect(() => {
router.replace(`${basePath}/settings/profile`);
}, [router, basePath]);
return null;
}
+10 -1
View File
@@ -2,12 +2,14 @@
import { use } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { PageHeader } from "@/components/shared/page-header";
import { LoadingSkeleton } from "@/components/shared/loading-skeleton";
import { ErrorBanner } from "@/components/shared/error-banner";
import { Card, CardContent } from "@/components/ui/card";
import { TaskCompletionForm } from "@/components/tasks/task-completion-form";
import { useTask, useCreateCompletion } from "@/lib/hooks/use-tasks";
import { useDataProvider } from "@/lib/demo/data-provider-context";
export default function CompleteTaskPage({
params,
@@ -17,6 +19,7 @@ export default function CompleteTaskPage({
const { id } = use(params);
const taskId = Number(id);
const router = useRouter();
const { basePath } = useDataProvider();
const { data: task, isLoading, isError, error, refetch } = useTask(taskId);
const createCompletion = useCreateCompletion();
@@ -61,7 +64,13 @@ export default function CompleteTaskPage({
images,
},
{
onSuccess: () => router.push(`/app/tasks/${taskId}`),
onSuccess: () => {
toast.success("Task completed");
router.push(`${basePath}/tasks/${taskId}`);
},
onError: () => {
toast.error("Failed to complete task");
},
},
);
}}
+11 -2
View File
@@ -2,12 +2,14 @@
import { use } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { PageHeader } from "@/components/shared/page-header";
import { LoadingSkeleton } from "@/components/shared/loading-skeleton";
import { ErrorBanner } from "@/components/shared/error-banner";
import { Card, CardContent } from "@/components/ui/card";
import { TaskForm } from "@/components/tasks/task-form";
import { useTask, useUpdateTask } from "@/lib/hooks/use-tasks";
import { useDataProvider } from "@/lib/demo/data-provider-context";
export default function EditTaskPage({
params,
@@ -17,6 +19,7 @@ export default function EditTaskPage({
const { id } = use(params);
const taskId = Number(id);
const router = useRouter();
const { basePath } = useDataProvider();
const { data: task, isLoading, isError, error, refetch } = useTask(taskId);
const updateTask = useUpdateTask(taskId);
@@ -39,7 +42,7 @@ export default function EditTaskPage({
if (!task) return null;
return (
<div className="space-y-6">
<div className="space-y-6 w-full max-w-2xl mx-auto">
<PageHeader title="Edit Task" />
<Card>
@@ -48,7 +51,13 @@ export default function EditTaskPage({
task={task}
onSubmit={(data) => {
updateTask.mutate(data, {
onSuccess: () => router.push(`/app/tasks/${taskId}`),
onSuccess: () => {
toast.success("Task updated");
router.push(`${basePath}/tasks/${taskId}`);
},
onError: () => {
toast.error("Failed to update task");
},
});
}}
isSubmitting={updateTask.isPending}
+4 -1
View File
@@ -1,6 +1,7 @@
"use client";
import { use } from "react";
import Image from "next/image";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { PageHeader } from "@/components/shared/page-header";
@@ -233,10 +234,12 @@ export default function TaskDetailPage({
{completion.images.length > 0 && (
<div className="flex gap-2 flex-wrap">
{completion.images.map((img) => (
<img
<Image
key={img.id}
src={img.image_url}
alt={img.caption || "Completion photo"}
width={80}
height={80}
className="size-20 rounded-md object-cover border"
/>
))}
+11 -2
View File
@@ -1,17 +1,20 @@
"use client";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { PageHeader } from "@/components/shared/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { TaskForm } from "@/components/tasks/task-form";
import { useCreateTask } from "@/lib/hooks/use-tasks";
import { useDataProvider } from "@/lib/demo/data-provider-context";
export default function NewTaskPage() {
const router = useRouter();
const { basePath } = useDataProvider();
const createTask = useCreateTask();
return (
<div className="space-y-6">
<div className="space-y-6 w-full max-w-2xl mx-auto">
<PageHeader title="New Task" />
<Card>
@@ -19,7 +22,13 @@ export default function NewTaskPage() {
<TaskForm
onSubmit={(data) => {
createTask.mutate(data, {
onSuccess: () => router.push("/app/tasks"),
onSuccess: () => {
toast.success("Task created");
router.push(`${basePath}/tasks`);
},
onError: () => {
toast.error("Failed to create task");
},
});
}}
isSubmitting={createTask.isPending}
+11 -4
View File
@@ -2,18 +2,25 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import dynamic from "next/dynamic";
import { ClipboardList } from "lucide-react";
import { PageHeader } from "@/components/shared/page-header";
import { LoadingSkeleton } from "@/components/shared/loading-skeleton";
import { ErrorBanner } from "@/components/shared/error-banner";
import { EmptyState } from "@/components/shared/empty-state";
import { LookupSelect } from "@/components/shared/lookup-select";
import { KanbanBoard } from "@/components/tasks/kanban-board";
import { useTasks, useTasksByResidence } from "@/lib/hooks/use-tasks";
const KanbanBoard = dynamic(
() => import("@/components/tasks/kanban-board").then((mod) => ({ default: mod.KanbanBoard })),
{ loading: () => <LoadingSkeleton variant="kanban" /> }
);
import { useResidences } from "@/lib/hooks/use-residences";
import { useDataProvider } from "@/lib/demo/data-provider-context";
export default function TasksPage() {
const router = useRouter();
const { basePath } = useDataProvider();
const [selectedResidenceId, setSelectedResidenceId] = useState<
number | undefined
>();
@@ -25,7 +32,7 @@ export default function TasksPage() {
const activeQuery = selectedResidenceId ? filteredTasks : allTasks;
const { data, isLoading, isError, error, refetch } = activeQuery;
const residenceItems = (residences ?? []).map((r) => ({
const residenceItems = (Array.isArray(residences) ? residences : []).map((r) => ({
id: r.residence.id,
name: r.residence.name,
}));
@@ -39,7 +46,7 @@ export default function TasksPage() {
title="Tasks"
description="Manage your home maintenance tasks"
actionLabel="New Task"
onAction={() => router.push("/app/tasks/new")}
onAction={() => router.push(`${basePath}/tasks/new`)}
>
{residenceItems.length > 1 && (
<LookupSelect
@@ -68,7 +75,7 @@ export default function TasksPage() {
title="No tasks yet"
description="Create your first task to start tracking home maintenance."
actionLabel="New Task"
onAction={() => router.push("/app/tasks/new")}
onAction={() => router.push(`${basePath}/tasks/new`)}
/>
)}
@@ -0,0 +1,2 @@
"use client";
export { default } from "@/app/app/contractors/[id]/edit/page";
@@ -0,0 +1,2 @@
"use client";
export { default } from "@/app/app/contractors/[id]/page";
@@ -0,0 +1,2 @@
"use client";
export { default } from "@/app/app/contractors/new/page";
+2
View File
@@ -0,0 +1,2 @@
"use client";
export { default } from "@/app/app/contractors/page";
@@ -0,0 +1,2 @@
"use client";
export { default } from "@/app/app/documents/[id]/edit/page";
+2
View File
@@ -0,0 +1,2 @@
"use client";
export { default } from "@/app/app/documents/[id]/page";
+2
View File
@@ -0,0 +1,2 @@
"use client";
export { default } from "@/app/app/documents/new/page";
+2
View File
@@ -0,0 +1,2 @@
"use client";
export { default } from "@/app/app/documents/page";
+32
View File
@@ -0,0 +1,32 @@
"use client";
import { Sidebar } from '@/components/layout/sidebar';
import { TopBar } from '@/components/layout/top-bar';
import { MobileNav } from '@/components/layout/mobile-nav';
import { DemoBanner } from '@/components/demo/demo-banner';
import { DataProviderProvider } from '@/lib/demo/data-provider-context';
import { demoProvider } from '@/lib/demo/demo-provider';
export default function DemoAppLayout({ children }: { children: React.ReactNode }) {
return (
<DataProviderProvider value={demoProvider}>
<div className="min-h-screen bg-background">
<DemoBanner />
{/* Sidebar - hidden on mobile */}
<Sidebar />
{/* Main content area */}
<div className="md:ml-16 lg:ml-64 flex flex-col min-h-screen">
<TopBar />
<main className="flex-1 p-4 lg:p-6 pb-20 md:pb-6">
{children}
</main>
</div>
{/* Mobile bottom nav */}
<MobileNav />
</div>
</DataProviderProvider>
);
}
+2
View File
@@ -0,0 +1,2 @@
"use client";
export { default } from "@/app/app/page";
@@ -0,0 +1,2 @@
"use client";
export { default } from "@/app/app/residences/[id]/edit/page";
@@ -0,0 +1,2 @@
"use client";
export { default } from "@/app/app/residences/[id]/page";
@@ -0,0 +1,2 @@
"use client";
export { default } from "@/app/app/residences/[id]/share/page";
@@ -0,0 +1,2 @@
"use client";
export { default } from "@/app/app/residences/join/page";
+2
View File
@@ -0,0 +1,2 @@
"use client";
export { default } from "@/app/app/residences/new/page";
+2
View File
@@ -0,0 +1,2 @@
"use client";
export { default } from "@/app/app/residences/page";
+2
View File
@@ -0,0 +1,2 @@
"use client";
export { default } from "@/app/app/settings/layout";
@@ -0,0 +1,2 @@
"use client";
export { default } from "@/app/app/settings/notifications/page";
+2
View File
@@ -0,0 +1,2 @@
"use client";
export { default } from "@/app/app/settings/page";
@@ -0,0 +1,2 @@
"use client";
export { default } from "@/app/app/settings/profile/page";
@@ -0,0 +1,2 @@
"use client";
export { default } from "@/app/app/settings/subscription/page";
@@ -0,0 +1,2 @@
"use client";
export { default } from "@/app/app/tasks/[id]/complete/page";
@@ -0,0 +1,2 @@
"use client";
export { default } from "@/app/app/tasks/[id]/edit/page";
+2
View File
@@ -0,0 +1,2 @@
"use client";
export { default } from "@/app/app/tasks/[id]/page";
+2
View File
@@ -0,0 +1,2 @@
"use client";
export { default } from "@/app/app/tasks/new/page";
+2
View File
@@ -0,0 +1,2 @@
"use client";
export { default } from "@/app/app/tasks/page";
+21
View File
@@ -0,0 +1,21 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Try Casera — Free Demo",
description:
"Try Casera without an account. Manage tasks, contractors, and documents in a live demo.",
openGraph: {
title: "Try Casera — Free Demo",
description:
"Try Casera without an account. Manage tasks, contractors, and documents in a live demo.",
type: "website",
},
};
export default function DemoLayout({
children,
}: {
children: React.ReactNode;
}) {
return children;
}
+41
View File
@@ -0,0 +1,41 @@
"use client";
import Link from "next/link";
import { Button } from "@/components/ui/button";
export default function DemoLandingPage() {
return (
<div className="flex min-h-screen flex-col items-center justify-center bg-background px-4">
<div className="mx-auto max-w-md text-center">
{/* Logo */}
<h1 className="mb-8 text-2xl font-bold tracking-tight text-primary">
Casera
</h1>
{/* Hero */}
<h2 className="text-3xl font-bold tracking-tight">
Try Casera &mdash; No Account Needed
</h2>
<p className="mt-3 text-muted-foreground">
Manage your home maintenance, track tasks, organize contractors, and
store documents.
</p>
{/* Actions */}
<div className="mt-8 flex flex-col gap-3">
<Button size="lg" asChild>
<Link href="/demo/app">Start Demo</Link>
</Button>
</div>
{/* Login link */}
<p className="mt-6 text-sm text-muted-foreground">
Already have an account?{" "}
<Link href="/login" className="text-primary hover:underline">
Log In
</Link>
</p>
</div>
</div>
);
}
+27
View File
@@ -0,0 +1,27 @@
"use client";
import { Button } from "@/components/ui/button";
import { AlertTriangle } from "lucide-react";
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<div className="flex flex-col items-center justify-center min-h-[400px] px-4">
<div className="rounded-full bg-destructive/10 p-4 mb-4">
<AlertTriangle className="size-10 text-destructive" />
</div>
<h2 className="text-lg font-semibold">Something went wrong</h2>
<p className="mt-2 text-muted-foreground text-center max-w-md">
{error.message || "An unexpected error occurred. Please try again."}
</p>
<Button onClick={reset} className="mt-6">
Try Again
</Button>
</div>
);
}
+31 -5
View File
@@ -1,7 +1,10 @@
import type { Metadata } from "next";
import { Suspense } from "react";
import { Geist, Geist_Mono } from "next/font/google";
import { ThemeProvider } from "@/lib/themes/theme-provider";
import { QueryProvider } from "@/lib/query/query-provider";
import { PostHogProvider } from "@/lib/analytics/posthog-provider";
import { Toaster } from "@/components/ui/sonner";
import "./globals.css";
const geistSans = Geist({
@@ -15,8 +18,24 @@ const geistMono = Geist_Mono({
});
export const metadata: Metadata = {
title: "Casera",
description: "Property management platform",
title: {
default: "Casera — Home Maintenance Made Simple",
template: "%s | Casera",
},
description:
"Track tasks, organize contractors, store documents. Manage your home maintenance in one place.",
openGraph: {
title: "Casera — Home Maintenance Made Simple",
description:
"Track tasks, organize contractors, store documents. Manage your home maintenance in one place.",
type: "website",
siteName: "Casera",
},
twitter: {
card: "summary_large_image",
title: "Casera — Home Maintenance Made Simple",
description: "Home Maintenance Made Simple",
},
};
export default function RootLayout({
@@ -29,9 +48,16 @@ export default function RootLayout({
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<QueryProvider>
<ThemeProvider>{children}</ThemeProvider>
</QueryProvider>
<Suspense fallback={null}>
<PostHogProvider>
<QueryProvider>
<ThemeProvider>
{children}
<Toaster richColors closeButton />
</ThemeProvider>
</QueryProvider>
</PostHogProvider>
</Suspense>
</body>
</html>
);
+20
View File
@@ -0,0 +1,20 @@
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { FileQuestion } from "lucide-react";
export default function NotFound() {
return (
<div className="flex flex-col items-center justify-center min-h-screen px-4">
<div className="rounded-full bg-muted p-4 mb-4">
<FileQuestion className="size-10 text-muted-foreground" />
</div>
<h2 className="text-lg font-semibold">Page not found</h2>
<p className="mt-2 text-muted-foreground text-center max-w-md">
The page you&apos;re looking for doesn&apos;t exist or has been moved.
</p>
<Button asChild className="mt-6">
<Link href="/app/residences">Go Home</Link>
</Button>
</div>
);
}
@@ -5,6 +5,7 @@ import { Phone, Mail, Star } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardAction } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { useDataProvider } from "@/lib/demo/data-provider-context";
import type { ContractorResponse } from "@/lib/api/contractors";
interface ContractorCardProps {
@@ -13,10 +14,11 @@ interface ContractorCardProps {
}
export function ContractorCard({ contractor, onToggleFavorite }: ContractorCardProps) {
const { basePath } = useDataProvider();
return (
<Card className="transition-shadow hover:shadow-md">
<CardHeader>
<Link href={`/app/contractors/${contractor.id}`} className="hover:underline">
<Link href={`${basePath}/contractors/${contractor.id}`} className="hover:underline">
<CardTitle>{contractor.name}</CardTitle>
</Link>
{contractor.company && (
@@ -27,12 +29,14 @@ export function ContractorCard({ contractor, onToggleFavorite }: ContractorCardP
variant="ghost"
size="icon"
className="size-8"
aria-label={contractor.is_favorite ? "Remove from favorites" : "Add to favorites"}
onClick={(e) => {
e.preventDefault();
onToggleFavorite(contractor.id);
}}
>
<Star
aria-hidden="true"
className={
contractor.is_favorite
? "size-4 fill-yellow-400 text-yellow-400"
@@ -56,15 +60,15 @@ export function ContractorCard({ contractor, onToggleFavorite }: ContractorCardP
<div className="flex items-center gap-2">
{contractor.phone && (
<Button variant="outline" size="icon" className="size-8" asChild>
<a href={`tel:${contractor.phone}`} onClick={(e) => e.stopPropagation()}>
<Phone className="size-4" />
<a href={`tel:${contractor.phone}`} aria-label={`Call ${contractor.name}`} onClick={(e) => e.stopPropagation()}>
<Phone className="size-4" aria-hidden="true" />
</a>
</Button>
)}
{contractor.email && (
<Button variant="outline" size="icon" className="size-8" asChild>
<a href={`mailto:${contractor.email}`} onClick={(e) => e.stopPropagation()}>
<Mail className="size-4" />
<a href={`mailto:${contractor.email}`} aria-label={`Email ${contractor.name}`} onClick={(e) => e.stopPropagation()}>
<Mail className="size-4" aria-hidden="true" />
</a>
</Button>
)}
@@ -51,7 +51,7 @@ export function ContractorForm({ contractor, onSubmit, loading }: ContractorForm
const { data: specialties } = useContractorSpecialties();
const { data: residencesData } = useResidences();
const residenceItems = (residencesData ?? []).map((r) => ({
const residenceItems = (Array.isArray(residencesData) ? residencesData : []).map((r) => ({
id: r.residence.id,
name: r.residence.name,
}));
+3 -1
View File
@@ -5,9 +5,11 @@ import { Bell } from "lucide-react";
import { formatDistanceToNow } from "date-fns";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { useNotifications } from "@/lib/hooks/use-notifications";
import { useDataProvider } from "@/lib/demo/data-provider-context";
export function RecentActivity() {
const { data, isLoading } = useNotifications(5);
const { basePath } = useDataProvider();
const notifications = data?.results ?? [];
@@ -17,7 +19,7 @@ export function RecentActivity() {
<CardTitle className="flex items-center justify-between">
<span>Recent Activity</span>
<Link
href="/app/settings/notifications"
href={`${basePath}/settings/notifications`}
className="text-sm font-normal text-primary hover:underline"
>
View all
+3 -1
View File
@@ -3,6 +3,7 @@
import Link from "next/link";
import { AlertTriangle, Clock, ClipboardList, CheckCircle2 } from "lucide-react";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { useDataProvider } from "@/lib/demo/data-provider-context";
interface StatsCardsProps {
overdue: number;
@@ -44,11 +45,12 @@ const stats = [
export function StatsCards({ overdue, dueSoon, active, completed }: StatsCardsProps) {
const values: Record<string, number> = { overdue, dueSoon, active, completed };
const { basePath } = useDataProvider();
return (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{stats.map((stat) => (
<Link key={stat.key} href="/app/tasks">
<Link key={stat.key} href={`${basePath}/tasks`}>
<Card className="hover:shadow-md transition-shadow cursor-pointer">
<CardHeader className="pb-2">
<CardTitle className="text-sm text-muted-foreground flex items-center gap-2">
+32
View File
@@ -0,0 +1,32 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { X } from "lucide-react";
import { Button } from "@/components/ui/button";
export function DemoBanner() {
const [dismissed, setDismissed] = useState(false);
if (dismissed) return null;
return (
<div className="sticky top-0 z-50 flex items-center justify-center gap-3 border-b bg-muted/60 px-4 py-2 text-sm text-muted-foreground backdrop-blur-sm">
<p>
You&apos;re exploring Casera in demo mode. Changes aren&apos;t saved.
</p>
<Button size="xs" asChild>
<Link href="/register">Sign Up Free</Link>
</Button>
<Button
variant="ghost"
size="icon-xs"
className="absolute right-2"
onClick={() => setDismissed(true)}
aria-label="Dismiss banner"
>
<X />
</Button>
</div>
);
}
+6 -2
View File
@@ -1,3 +1,5 @@
"use client";
import Link from "next/link";
import { FileText, FileImage, File, FileSpreadsheet } from "lucide-react";
import { format } from "date-fns";
@@ -5,6 +7,7 @@ import { format } from "date-fns";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { WarrantyStatus } from "@/components/documents/warranty-status";
import { useDataProvider } from "@/lib/demo/data-provider-context";
import type { DocumentResponse } from "@/lib/api/documents";
interface DocumentCardProps {
@@ -29,13 +32,14 @@ const typeLabels: Record<string, string> = {
export function DocumentCard({ document: doc }: DocumentCardProps) {
const Icon = getFileIcon(doc.mime_type);
const { basePath } = useDataProvider();
return (
<Link href={`/app/documents/${doc.id}`} className="block">
<Link href={`${basePath}/documents/${doc.id}`} className="block">
<Card className="transition-colors hover:border-primary/40">
<CardHeader>
<div className="flex items-start gap-3">
<div className="rounded-md bg-muted p-2 shrink-0">
<div className="rounded-md bg-muted p-2 shrink-0" aria-hidden="true">
<Icon className="size-5 text-muted-foreground" />
</div>
<div className="min-w-0 flex-1">
+1 -1
View File
@@ -79,7 +79,7 @@ export function DocumentForm({
const { data: residences } = useResidences();
const [files, setFiles] = useState<File[]>([]);
const residenceItems = (residences ?? []).map((r) => ({
const residenceItems = (Array.isArray(residences) ? residences : []).map((r) => ({
id: r.residence.id,
name: r.residence.name,
}));
+8 -3
View File
@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import Image from "next/image";
import {
Dialog,
DialogContent,
@@ -28,10 +29,12 @@ export function ImageGallery({ images }: ImageGalleryProps) {
className="group relative aspect-square overflow-hidden rounded-lg border bg-muted focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 outline-none"
onClick={() => setSelectedImage(image)}
>
<img
<Image
src={image.image_url}
alt={image.caption || "Document image"}
className="size-full object-cover transition-transform group-hover:scale-105"
fill
sizes="(max-width: 640px) 50vw, 33vw"
className="object-cover transition-transform group-hover:scale-105"
/>
{image.caption && (
<div className="absolute inset-x-0 bottom-0 bg-black/60 px-2 py-1">
@@ -49,9 +52,11 @@ export function ImageGallery({ images }: ImageGalleryProps) {
</DialogHeader>
{selectedImage && (
<div className="flex justify-center">
<img
<Image
src={selectedImage.image_url}
alt={selectedImage.caption || "Document image"}
width={800}
height={600}
className="max-h-[70vh] w-auto rounded-md object-contain"
/>
</div>
+13 -8
View File
@@ -3,27 +3,32 @@
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { cn } from '@/lib/utils';
import { navItems } from './nav-items';
// Show the first 5 nav items on mobile (exclude Settings)
const mobileNavItems = navItems.filter((item) => item.label !== 'Settings');
import { getNavItems } from './nav-items';
import { useDataProvider } from '@/lib/demo/data-provider-context';
export function MobileNav() {
const pathname = usePathname();
const { basePath } = useDataProvider();
const navItems = getNavItems(basePath);
// Show the first 5 nav items on mobile (exclude Settings)
const mobileNavItems = navItems.filter((item) => item.label !== 'Settings');
return (
<nav className="md:hidden fixed bottom-0 left-0 right-0 z-30 bg-card border-t border-border">
<nav role="navigation" aria-label="Main navigation" className="md:hidden fixed bottom-0 left-0 right-0 z-30 bg-card border-t border-border">
<div className="flex items-center justify-around px-2 py-2">
{mobileNavItems.map((item) => {
const isActive =
item.href === '/app'
? pathname === '/app'
item.href === basePath
? pathname === basePath
: pathname.startsWith(item.href);
return (
<Link
key={item.href}
href={item.href}
aria-label={item.label}
aria-current={isActive ? 'page' : undefined}
className={cn(
'flex flex-col items-center gap-1 px-2 py-1 rounded-md text-xs transition-colors',
isActive
@@ -31,7 +36,7 @@ export function MobileNav() {
: 'text-muted-foreground hover:text-foreground'
)}
>
<item.icon className="size-5" />
<item.icon className="size-5" aria-hidden="true" />
<span>{item.label}</span>
</Link>
);
+13 -8
View File
@@ -6,11 +6,16 @@ export interface NavItem {
icon: React.ComponentType<{ className?: string }>;
}
export const navItems: NavItem[] = [
{ label: 'Home', href: '/app', icon: Home },
{ label: 'Residences', href: '/app/residences', icon: Building2 },
{ label: 'Tasks', href: '/app/tasks', icon: CheckSquare },
{ label: 'Contractors', href: '/app/contractors', icon: HardHat },
{ label: 'Documents', href: '/app/documents', icon: FileText },
{ label: 'Settings', href: '/app/settings', icon: Settings },
];
export function getNavItems(basePath: string): NavItem[] {
return [
{ label: 'Home', href: basePath, icon: Home },
{ label: 'Residences', href: `${basePath}/residences`, icon: Building2 },
{ label: 'Tasks', href: `${basePath}/tasks`, icon: CheckSquare },
{ label: 'Contractors', href: `${basePath}/contractors`, icon: HardHat },
{ label: 'Documents', href: `${basePath}/documents`, icon: FileText },
{ label: 'Settings', href: `${basePath}/settings`, icon: Settings },
];
}
// Default export for backward compatibility
export const navItems: NavItem[] = getNavItems('/app');
+11 -6
View File
@@ -4,16 +4,19 @@ import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { cn } from '@/lib/utils';
import { Separator } from '@/components/ui/separator';
import { navItems } from './nav-items';
import { getNavItems } from './nav-items';
import { useDataProvider } from '@/lib/demo/data-provider-context';
export function Sidebar() {
const pathname = usePathname();
const { basePath } = useDataProvider();
const navItems = getNavItems(basePath);
return (
<aside className="hidden md:flex md:flex-col md:fixed md:inset-y-0 md:left-0 md:z-30 w-16 lg:w-64 bg-card border-r border-border">
{/* Logo */}
<div className="flex items-center h-16 px-4 lg:px-6">
<Link href="/app" className="flex items-center gap-2">
<Link href={basePath} className="flex items-center gap-2">
<span className="text-xl font-bold text-primary">C</span>
<span className="hidden lg:inline text-xl font-bold text-foreground">
Casera
@@ -24,17 +27,19 @@ export function Sidebar() {
<Separator />
{/* Navigation */}
<nav className="flex-1 flex flex-col gap-1 p-2 lg:p-3">
<nav role="navigation" aria-label="Main navigation" className="flex-1 flex flex-col gap-1 p-2 lg:p-3">
{navItems.map((item) => {
const isActive =
item.href === '/app'
? pathname === '/app'
item.href === basePath
? pathname === basePath
: pathname.startsWith(item.href);
return (
<Link
key={item.href}
href={item.href}
aria-label={item.label}
aria-current={isActive ? 'page' : undefined}
className={cn(
'flex items-center gap-3 rounded-md px-3 py-2.5 text-sm font-medium transition-colors',
'hover:bg-accent hover:text-accent-foreground',
@@ -43,7 +48,7 @@ export function Sidebar() {
: 'text-muted-foreground'
)}
>
<item.icon className="size-5 shrink-0" />
<item.icon className="size-5 shrink-0" aria-hidden="true" />
<span className="hidden lg:inline">{item.label}</span>
</Link>
);
+10 -4
View File
@@ -11,9 +11,11 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useDataProvider } from '@/lib/demo/data-provider-context';
export function TopBar() {
const router = useRouter();
const { basePath } = useDataProvider();
const handleLogout = async () => {
try {
@@ -21,7 +23,11 @@ export function TopBar() {
} catch {
// Continue with redirect even if the API call fails
}
router.push('/login');
if (basePath.startsWith('/demo')) {
router.push('/demo');
} else {
router.push('/login');
}
};
return (
@@ -39,18 +45,18 @@ export function TopBar() {
<NotificationBell />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="flex items-center gap-2 rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2">
<button aria-label="User menu" className="flex items-center gap-2 rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2">
<Avatar>
<AvatarFallback>U</AvatarFallback>
</Avatar>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem onClick={() => router.push('/app/settings')}>
<DropdownMenuItem onClick={() => router.push(`${basePath}/settings`)}>
<User className="size-4" />
Profile
</DropdownMenuItem>
<DropdownMenuItem onClick={() => router.push('/app/settings')}>
<DropdownMenuItem onClick={() => router.push(`${basePath}/settings`)}>
<Settings className="size-4" />
Settings
</DropdownMenuItem>
@@ -21,10 +21,10 @@ export function NotificationBell() {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="relative">
<Bell className="size-5" />
<Button variant="ghost" size="icon" className="relative" aria-label={unreadCount > 0 ? `Notifications (${unreadCount} unread)` : "Notifications"}>
<Bell className="size-5" aria-hidden="true" />
{unreadCount > 0 && (
<span className="absolute -top-1 -right-1 flex size-5 items-center justify-center rounded-full bg-destructive text-[10px] font-bold text-destructive-foreground">
<span aria-hidden="true" className="absolute -top-1 -right-1 flex size-5 items-center justify-center rounded-full bg-destructive text-[10px] font-bold text-destructive-foreground">
{unreadCount > 9 ? "9+" : unreadCount}
</span>
)}
+4 -2
View File
@@ -6,9 +6,11 @@ import { CheckCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useOnboardingStore } from "@/stores/onboarding";
import { useDataProvider } from "@/lib/demo/data-provider-context";
export function CompleteStep() {
const router = useRouter();
const { basePath } = useDataProvider();
const { path, residenceId, complete } = useOnboardingStore();
useEffect(() => {
@@ -20,9 +22,9 @@ export function CompleteStep() {
const handleNavigate = () => {
if (isCreatePath && residenceId) {
router.push(`/app/residences/${residenceId}`);
router.push(`${basePath}/residences/${residenceId}`);
} else {
router.push("/app/residences");
router.push(`${basePath}/residences`);
}
};
+7 -2
View File
@@ -1,8 +1,11 @@
"use client";
import Link from "next/link";
import { MapPin } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { useDataProvider } from "@/lib/demo/data-provider-context";
import type { MyResidenceResponse } from "@/lib/api/residences";
interface ResidenceCardProps {
@@ -11,19 +14,21 @@ interface ResidenceCardProps {
export function ResidenceCard({ data }: ResidenceCardProps) {
const { residence, task_summary } = data;
const { basePath } = useDataProvider();
const address = [residence.street_address, residence.city, residence.state_province]
.filter(Boolean)
.join(", ");
return (
<Link href={`/app/residences/${residence.id}`} className="block">
<Link href={`${basePath}/residences/${residence.id}`} className="block">
<Card className="transition-colors hover:border-primary/40">
<CardHeader>
<CardTitle className="text-base">{residence.name}</CardTitle>
{address && (
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
<MapPin className="size-3.5 shrink-0" />
<MapPin className="size-3.5 shrink-0" aria-hidden="true" />
<span className="sr-only">Address:</span>
<span className="truncate">{address}</span>
</div>
)}
@@ -4,6 +4,7 @@ import { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { toast } from "sonner";
import { Loader2, Check } from "lucide-react";
import { Button } from "@/components/ui/button";
@@ -48,10 +49,12 @@ export function ChangePasswordForm() {
});
reset();
setSuccess(true);
toast.success("Password changed");
} catch (err) {
const message =
err instanceof Error ? err.message : "Failed to change password.";
setApiError(message);
toast.error("Failed to change password");
}
}
@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { toast } from "sonner";
import { AlertTriangle } from "lucide-react";
import { Button } from "@/components/ui/button";
@@ -37,6 +38,7 @@ export function DeleteAccountSection() {
const message =
err instanceof Error ? err.message : "Failed to delete account.";
setApiError(message);
toast.error("Failed to delete account");
setIsDeleting(false);
}
}
@@ -1,5 +1,6 @@
"use client";
import { toast } from "sonner";
import { useNotificationPreferences, useUpdatePreferences } from "@/lib/hooks/use-notifications";
import { Switch } from "@/components/ui/switch";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
@@ -45,7 +46,14 @@ export function NotificationPreferences() {
function handleToggle(key: keyof NotificationPreferencesResponse, checked: boolean) {
const update: UpdatePreferencesRequest = { [key]: checked };
updatePreferences.mutate(update);
updatePreferences.mutate(update, {
onSuccess: () => {
toast.success("Preferences updated");
},
onError: () => {
toast.error("Failed to update preferences");
},
});
}
if (isLoading) {
+3
View File
@@ -4,6 +4,7 @@ import { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { toast } from "sonner";
import { Loader2, Check } from "lucide-react";
import { Button } from "@/components/ui/button";
@@ -46,10 +47,12 @@ export function ProfileForm() {
await authApi.updateProfile(data);
await fetchUser();
setSuccess(true);
toast.success("Profile updated");
} catch (err) {
const message =
err instanceof Error ? err.message : "Failed to update profile.";
setApiError(message);
toast.error("Failed to update profile");
}
}
+2 -2
View File
@@ -9,8 +9,8 @@ interface ErrorBannerProps {
export function ErrorBanner({ message = "Something went wrong. Please try again.", onRetry }: ErrorBannerProps) {
return (
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-4 flex items-center gap-3">
<AlertTriangle className="size-5 text-destructive shrink-0" />
<div role="alert" aria-live="assertive" className="rounded-lg border border-destructive/50 bg-destructive/10 p-4 flex items-center gap-3">
<AlertTriangle className="size-5 text-destructive shrink-0" aria-hidden="true" />
<p className="text-sm text-destructive flex-1">{message}</p>
{onRetry && <Button variant="outline" size="sm" onClick={onRetry}>Retry</Button>}
</div>
+3 -2
View File
@@ -11,11 +11,12 @@ interface FormFieldProps {
}
export function FormField({ label, htmlFor, error, required, className, children }: FormFieldProps) {
const errorId = error ? `${htmlFor}-error` : undefined;
return (
<div className={cn("space-y-2", className)}>
<Label htmlFor={htmlFor}>{label}{required && <span className="text-destructive ml-1">*</span>}</Label>
<Label htmlFor={htmlFor}>{label}{required && <span className="text-destructive ml-1" aria-hidden="true">*</span>}{required && <span className="sr-only">(required)</span>}</Label>
{children}
{error && <p className="text-sm text-destructive">{error}</p>}
{error && <p id={errorId} role="alert" className="text-sm text-destructive">{error}</p>}
</div>
);
}
+10 -1
View File
@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { toast } from "sonner";
import { Copy, Check, RefreshCw } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
@@ -21,11 +22,19 @@ export function ShareCodeDisplay({ residenceId }: ShareCodeDisplayProps) {
if (!shareCode) return;
await navigator.clipboard.writeText(shareCode.code);
setCopied(true);
toast.success("Code copied to clipboard");
setTimeout(() => setCopied(false), 2000);
}
function handleGenerate() {
generateCode.mutate();
generateCode.mutate(undefined, {
onSuccess: () => {
toast.success("Share code generated");
},
onError: () => {
toast.error("Failed to generate share code");
},
});
}
if (isLoading) {
@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { toast } from "sonner";
import { UserMinus } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -24,8 +25,12 @@ export function UserManagement({ residenceId }: UserManagementProps) {
if (!removeTarget) return;
removeUser.mutate(removeTarget.id, {
onSuccess: () => {
toast.success("Member removed");
setRemoveTarget(null);
},
onError: () => {
toast.error("Failed to remove member");
},
});
}
+69 -10
View File
@@ -1,17 +1,20 @@
"use client";
import { useCallback } from "react";
import { useState, useCallback, useEffect } from "react";
import { useRouter } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import {
DndContext,
PointerSensor,
pointerWithin,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core";
import { useMarkInProgress } from "@/lib/hooks/use-tasks";
import { useDataProvider } from "@/lib/demo/data-provider-context";
import { KanbanColumn } from "./kanban-column";
import type { KanbanResponse } from "@/lib/api/tasks";
import type { KanbanResponse, KanbanColumn as KanbanColumnType } from "@/lib/api/tasks";
interface KanbanBoardProps {
data: KanbanResponse;
@@ -19,14 +22,47 @@ interface KanbanBoardProps {
export function KanbanBoard({ data }: KanbanBoardProps) {
const router = useRouter();
const queryClient = useQueryClient();
const { basePath } = useDataProvider();
const markInProgress = useMarkInProgress();
// Local columns state for instant optimistic updates
const [columns, setColumns] = useState<KanbanColumnType[]>(data.columns);
// Sync with prop changes (e.g. after server refetch)
useEffect(() => {
setColumns(data.columns);
}, [data.columns]);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 8 },
})
);
const moveTask = useCallback(
(taskId: number, sourceColName: string, targetColName: string) => {
const sourceCol = columns.find((c) => c.name === sourceColName);
const task = sourceCol?.tasks.find((t) => t.id === taskId);
if (!task) return;
setColumns((prev) =>
prev.map((col) => {
if (col.name === sourceColName) {
const tasks = col.tasks.filter((t) => t.id !== taskId);
return { ...col, tasks, count: tasks.length };
}
if (col.name === targetColName) {
const tasks = [...col.tasks, task];
return { ...col, tasks, count: tasks.length };
}
return col;
})
);
},
[columns]
);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event;
@@ -35,19 +71,42 @@ export function KanbanBoard({ data }: KanbanBoardProps) {
const taskId = active.id as number;
const targetColumn = over.id as string;
if (targetColumn === "in_progress") {
markInProgress.mutate(taskId);
} else if (targetColumn === "completed") {
router.push(`/app/tasks/${taskId}/complete`);
// Find source column
const sourceCol = columns.find((col) =>
col.tasks.some((t) => t.id === taskId)
);
if (!sourceCol || sourceCol.name === targetColumn) return;
if (targetColumn === "completed_tasks") {
router.push(`${basePath}/tasks/${taskId}/complete`);
return;
}
if (targetColumn === "in_progress_tasks") {
// Optimistic move + API call
moveTask(taskId, sourceCol.name, targetColumn);
markInProgress.mutate(taskId, {
onError: () => setColumns(data.columns),
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["tasks"] });
},
});
return;
}
// For any other column, just do an optimistic visual move
// (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"] });
},
[markInProgress, router]
[columns, data.columns, moveTask, markInProgress, queryClient, router, basePath]
);
return (
<DndContext sensors={sensors} onDragEnd={handleDragEnd}>
<div className="flex gap-4 overflow-x-auto pb-4">
{data.columns.map((column) => (
<DndContext sensors={sensors} collisionDetection={pointerWithin} onDragEnd={handleDragEnd}>
<div className="flex gap-4 overflow-x-auto pb-4 snap-x snap-mandatory sm:snap-none -mx-4 px-4 sm:mx-0 sm:px-0">
{columns.map((column) => (
<KanbanColumn key={column.name} column={column} />
))}
</div>
+57 -37
View File
@@ -1,62 +1,86 @@
"use client";
import { useDroppable } from "@dnd-kit/core";
import { SortableContext, verticalListSortingStrategy, useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { useRef, useEffect } from "react";
import { useDraggable, useDroppable } from "@dnd-kit/core";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import { TaskCard } from "./task-card";
import type { KanbanColumn as KanbanColumnType } from "@/lib/api/tasks";
const COLUMN_COLORS: Record<string, string> = {
overdue: "border-red-500/50 bg-red-50/50 dark:bg-red-950/20",
due_today: "border-orange-500/50 bg-orange-50/50 dark:bg-orange-950/20",
due_soon: "border-yellow-500/50 bg-yellow-50/50 dark:bg-yellow-950/20",
upcoming: "border-blue-500/50 bg-blue-50/50 dark:bg-blue-950/20",
in_progress: "border-green-500/50 bg-green-50/50 dark:bg-green-950/20",
completed: "border-gray-500/50 bg-gray-50/50 dark:bg-gray-950/20",
overdue_tasks: "border-red-500/50 bg-red-50/50 dark:bg-red-950/20",
due_soon_tasks: "border-yellow-500/50 bg-yellow-50/50 dark:bg-yellow-950/20",
upcoming_tasks: "border-blue-500/50 bg-blue-50/50 dark:bg-blue-950/20",
in_progress_tasks: "border-green-500/50 bg-green-50/50 dark:bg-green-950/20",
completed_tasks: "border-gray-500/50 bg-gray-50/50 dark:bg-gray-950/20",
cancelled_tasks: "border-slate-500/50 bg-slate-50/50 dark:bg-slate-950/20",
};
const COLUMN_HEADER_COLORS: Record<string, string> = {
overdue: "text-red-700 dark:text-red-400",
due_today: "text-orange-700 dark:text-orange-400",
due_soon: "text-yellow-700 dark:text-yellow-400",
upcoming: "text-blue-700 dark:text-blue-400",
in_progress: "text-green-700 dark:text-green-400",
completed: "text-gray-700 dark:text-gray-400",
overdue_tasks: "text-red-700 dark:text-red-400",
due_soon_tasks: "text-yellow-700 dark:text-yellow-400",
upcoming_tasks: "text-blue-700 dark:text-blue-400",
in_progress_tasks: "text-green-700 dark:text-green-400",
completed_tasks: "text-gray-700 dark:text-gray-400",
cancelled_tasks: "text-slate-700 dark:text-slate-400",
};
const COUNT_BADGE_COLORS: Record<string, string> = {
overdue: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200",
due_today: "bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200",
due_soon: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200",
upcoming: "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200",
in_progress: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200",
completed: "bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200",
overdue_tasks: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200",
due_soon_tasks: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200",
upcoming_tasks: "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200",
in_progress_tasks: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200",
completed_tasks: "bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200",
cancelled_tasks: "bg-slate-100 text-slate-800 dark:bg-slate-900 dark:text-slate-200",
};
interface KanbanColumnProps {
column: KanbanColumnType;
}
function SortableTask({ task }: { task: import("@/lib/api/tasks").TaskResponse }) {
function DraggableTask({ task }: { task: import("@/lib/api/tasks").TaskResponse }) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: task.id });
} = useDraggable({ id: task.id });
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
const wasDragging = useRef(false);
useEffect(() => {
if (isDragging) {
wasDragging.current = true;
}
}, [isDragging]);
const style: React.CSSProperties = transform
? {
transform: `translate(${transform.x}px, ${transform.y}px)`,
opacity: isDragging ? 0.5 : 1,
zIndex: isDragging ? 50 : undefined,
position: isDragging ? "relative" : undefined,
}
: undefined as unknown as React.CSSProperties;
// Block the click that fires after a drag ends so the Link doesn't navigate
const handleClick = (e: React.MouseEvent) => {
if (wasDragging.current) {
e.preventDefault();
e.stopPropagation();
wasDragging.current = false;
}
};
return (
<div ref={setNodeRef} style={style} {...attributes} {...listeners}>
<div
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
onClickCapture={handleClick}
>
<TaskCard task={task} isDragging={isDragging} />
</div>
);
@@ -67,12 +91,10 @@ export function KanbanColumn({ column }: KanbanColumnProps) {
id: column.name,
});
const taskIds = column.tasks.map((t) => t.id);
return (
<div
className={cn(
"flex flex-col min-w-[280px] max-w-[320px] rounded-lg border-2 p-3",
"flex flex-col min-w-[280px] sm:min-w-0 sm:flex-1 max-w-[320px] sm:max-w-none snap-center sm:snap-align-none rounded-lg border-2 p-3",
COLUMN_COLORS[column.name] ?? "border-border bg-muted/30",
isOver && "ring-2 ring-primary"
)}
@@ -95,11 +117,9 @@ export function KanbanColumn({ column }: KanbanColumnProps) {
</div>
<div ref={setNodeRef} className="flex-1 space-y-2 min-h-[60px]">
<SortableContext items={taskIds} strategy={verticalListSortingStrategy}>
{column.tasks.map((task) => (
<SortableTask key={task.id} task={task} />
))}
</SortableContext>
{column.tasks.map((task) => (
<DraggableTask key={task.id} task={task} />
))}
{column.tasks.length === 0 && (
<div className="flex items-center justify-center h-[60px] text-xs text-muted-foreground rounded-md border border-dashed">
No tasks
+34 -8
View File
@@ -2,6 +2,7 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
@@ -26,6 +27,7 @@ import {
useArchiveTask,
useDeleteTask,
} from "@/lib/hooks/use-tasks";
import { useDataProvider } from "@/lib/demo/data-provider-context";
interface TaskActionsMenuProps {
taskId: number;
@@ -33,6 +35,7 @@ interface TaskActionsMenuProps {
export function TaskActionsMenu({ taskId }: TaskActionsMenuProps) {
const router = useRouter();
const { basePath } = useDataProvider();
const [deleteOpen, setDeleteOpen] = useState(false);
const markInProgress = useMarkInProgress();
@@ -44,35 +47,54 @@ export function TaskActionsMenu({ taskId }: TaskActionsMenuProps) {
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon">
<MoreVertical className="size-4" />
<Button variant="outline" size="icon" aria-label="Task actions">
<MoreVertical className="size-4" aria-hidden="true" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => router.push(`/app/tasks/${taskId}/complete`)}
onClick={() => router.push(`${basePath}/tasks/${taskId}/complete`)}
>
<CheckCircle className="size-4" />
Complete
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => markInProgress.mutate(taskId)}
onClick={() =>
markInProgress.mutate(taskId, {
onSuccess: () => toast.success("Task marked in progress"),
onError: () => toast.error("Failed to update task"),
})
}
>
<Play className="size-4" />
Mark In Progress
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => router.push(`/app/tasks/${taskId}/edit`)}
onClick={() => router.push(`${basePath}/tasks/${taskId}/edit`)}
>
<Pencil className="size-4" />
Edit
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => cancelTask.mutate(taskId)}>
<DropdownMenuItem
onClick={() =>
cancelTask.mutate(taskId, {
onSuccess: () => toast.success("Task cancelled"),
onError: () => toast.error("Failed to cancel task"),
})
}
>
<XCircle className="size-4" />
Cancel
</DropdownMenuItem>
<DropdownMenuItem onClick={() => archiveTask.mutate(taskId)}>
<DropdownMenuItem
onClick={() =>
archiveTask.mutate(taskId, {
onSuccess: () => toast.success("Task archived"),
onError: () => toast.error("Failed to archive task"),
})
}
>
<Archive className="size-4" />
Archive
</DropdownMenuItem>
@@ -98,8 +120,12 @@ export function TaskActionsMenu({ taskId }: TaskActionsMenuProps) {
onConfirm={() => {
deleteTask.mutate(taskId, {
onSuccess: () => {
toast.success("Task deleted");
setDeleteOpen(false);
router.push("/app/tasks");
router.push(`${basePath}/tasks`);
},
onError: () => {
toast.error("Failed to delete task");
},
});
}}
+7 -3
View File
@@ -4,6 +4,7 @@ import Link from "next/link";
import { Badge } from "@/components/ui/badge";
import { Calendar, DollarSign } from "lucide-react";
import { cn } from "@/lib/utils";
import { useDataProvider } from "@/lib/demo/data-provider-context";
import type { TaskResponse } from "@/lib/api/tasks";
interface TaskCardProps {
@@ -12,8 +13,9 @@ interface TaskCardProps {
}
export function TaskCard({ task, isDragging }: TaskCardProps) {
const { basePath } = useDataProvider();
return (
<Link href={`/app/tasks/${task.id}`}>
<Link href={`${basePath}/tasks/${task.id}`}>
<div
className={cn(
"rounded-lg border bg-card p-3 space-y-2 transition-shadow hover:shadow-md cursor-grab",
@@ -52,13 +54,15 @@ export function TaskCard({ task, isDragging }: TaskCardProps) {
<div className="flex items-center gap-3 text-xs text-muted-foreground">
{task.due_date && (
<span className="flex items-center gap-1">
<Calendar className="size-3" />
<Calendar className="size-3" aria-hidden="true" />
<span className="sr-only">Due date:</span>
{new Date(task.due_date).toLocaleDateString()}
</span>
)}
{task.estimated_cost != null && task.estimated_cost > 0 && (
<span className="flex items-center gap-1">
<DollarSign className="size-3" />
<DollarSign className="size-3" aria-hidden="true" />
<span className="sr-only">Estimated cost:</span>
{task.estimated_cost.toFixed(2)}
</span>
)}
+2 -2
View File
@@ -49,12 +49,12 @@ export function TaskForm({ task, onSubmit, isSubmitting }: TaskFormProps) {
const { data: priorities } = useTaskPriorities();
const { data: frequencies } = useTaskFrequencies();
const residenceItems = (residences ?? []).map((r) => ({
const residenceItems = (Array.isArray(residences) ? residences : []).map((r) => ({
id: r.residence.id,
name: r.residence.name,
}));
const contractorItems = (contractors ?? []).map((c) => ({
const contractorItems = (Array.isArray(contractors) ? contractors : []).map((c) => ({
id: c.id,
name: c.company ? `${c.name} (${c.company})` : c.name,
}));
+1 -1
View File
@@ -61,7 +61,7 @@ function DialogContent({
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
"fixed z-50 grid w-full gap-4 border bg-background p-6 shadow-lg duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 inset-0 rounded-none max-h-screen overflow-y-auto sm:inset-auto sm:top-[50%] sm:left-[50%] sm:translate-x-[-50%] sm:translate-y-[-50%] sm:rounded-lg sm:max-w-lg sm:max-h-[85vh]",
className
)}
{...props}
+25
View File
@@ -0,0 +1,25 @@
"use client"
import { useThemeStore } from "@/stores/theme"
import { Toaster as Sonner, type ToasterProps } from "sonner"
const Toaster = ({ ...props }: ToasterProps) => {
const mode = useThemeStore((s) => s.mode)
return (
<Sonner
theme={mode as ToasterProps["theme"]}
className="toaster group"
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
} as React.CSSProperties
}
{...props}
/>
)
}
export { Toaster }
+47
View File
@@ -0,0 +1,47 @@
import posthog from "posthog-js";
let initialized = false;
export function initAnalytics() {
if (
typeof window !== "undefined" &&
process.env.NEXT_PUBLIC_POSTHOG_KEY &&
!initialized
) {
posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY, {
api_host:
process.env.NEXT_PUBLIC_POSTHOG_HOST ||
"https://analytics.88oakapps.com",
capture_pageview: true,
capture_pageleave: true,
});
initialized = true;
}
}
export function trackEvent(
event: string,
properties?: Record<string, unknown>
) {
if (initialized) {
posthog.capture(event, properties);
}
}
export function trackScreen(screenName: string) {
if (initialized) {
posthog.capture("$pageview", { $current_url: screenName });
}
}
export function identifyUser(userId: string, traits?: Record<string, unknown>) {
if (initialized) {
posthog.identify(userId, traits);
}
}
export function resetAnalytics() {
if (initialized) {
posthog.reset();
}
}
+22
View File
@@ -0,0 +1,22 @@
"use client";
import { useEffect } from "react";
import { usePathname, useSearchParams } from "next/navigation";
import { initAnalytics, trackScreen } from "@/lib/analytics";
export function PostHogProvider({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const searchParams = useSearchParams();
useEffect(() => {
initAnalytics();
}, []);
useEffect(() => {
if (pathname) {
trackScreen(pathname);
}
}, [pathname, searchParams]);
return <>{children}</>;
}
+9 -9
View File
@@ -134,7 +134,7 @@ export async function login(credentials: LoginRequest): Promise<LoginResponse> {
export async function register(
data: RegisterRequest,
): Promise<RegisterResponse> {
const res = await fetch('/api/proxy/auth/register/', {
const res = await fetch('/api/proxy/auth/register', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -172,7 +172,7 @@ export async function getCurrentUser(): Promise<UserResponse> {
export async function updateProfile(
data: UpdateProfileRequest,
): Promise<UserResponse> {
const res = await fetch('/api/proxy/auth/profile/', {
const res = await fetch('/api/proxy/auth/profile', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
@@ -189,7 +189,7 @@ export async function updateProfile(
export async function verifyEmail(
data: VerifyEmailRequest,
): Promise<VerifyEmailResponse> {
const res = await fetch('/api/proxy/auth/verify-email/', {
const res = await fetch('/api/proxy/auth/verify-email', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -204,7 +204,7 @@ export async function verifyEmail(
* Resend the email verification code.
*/
export async function resendVerification(): Promise<MessageResponse> {
const res = await fetch('/api/proxy/auth/resend-verification/', {
const res = await fetch('/api/proxy/auth/resend-verification', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -220,7 +220,7 @@ export async function resendVerification(): Promise<MessageResponse> {
export async function forgotPassword(
data: ForgotPasswordRequest,
): Promise<MessageResponse> {
const res = await fetch('/api/proxy/auth/forgot-password/', {
const res = await fetch('/api/proxy/auth/forgot-password', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -237,7 +237,7 @@ export async function forgotPassword(
export async function verifyResetCode(
data: VerifyResetCodeRequest,
): Promise<VerifyResetCodeResponse> {
const res = await fetch('/api/proxy/auth/verify-reset-code/', {
const res = await fetch('/api/proxy/auth/verify-reset-code', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -254,7 +254,7 @@ export async function verifyResetCode(
export async function resetPassword(
data: ResetPasswordRequest,
): Promise<MessageResponse> {
const res = await fetch('/api/proxy/auth/reset-password/', {
const res = await fetch('/api/proxy/auth/reset-password', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -271,7 +271,7 @@ export async function resetPassword(
export async function changePassword(
data: { current_password: string; new_password: string },
): Promise<MessageResponse> {
const res = await fetch('/api/proxy/auth/change-password/', {
const res = await fetch('/api/proxy/auth/change-password', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -286,7 +286,7 @@ export async function changePassword(
* Delete the authenticated user's account permanently.
*/
export async function deleteAccount(): Promise<MessageResponse> {
const res = await fetch('/api/proxy/auth/delete-account/', {
const res = await fetch('/api/proxy/auth/delete-account', {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
+5 -3
View File
@@ -7,7 +7,7 @@
// ---------------------------------------------------------------------------
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_URL || 'https://mycrib.treytartt.com/api';
process.env.NEXT_PUBLIC_API_URL || 'https://casera.treytartt.com/api';
/**
* Server-only base URL. Falls back to the public one so that server
@@ -45,8 +45,10 @@ export async function apiFetch<T>(
path: string,
options: RequestInit = {},
): Promise<T> {
// Ensure trailing slash (Go API requires it)
const normalized = path.endsWith('/') ? path : `${path}/`;
// Strip trailing slash for the Next.js proxy URL (Next.js 308-redirects
// trailing slashes away by default). The proxy route handler re-adds the
// trailing slash when forwarding to the Go API.
const normalized = path.endsWith('/') ? path.slice(0, -1) : path;
const url = `/api/proxy${normalized}`;
const headers: Record<string, string> = {
+17 -2
View File
@@ -72,6 +72,7 @@ export interface ResidenceResponse {
is_owner: boolean;
owner_id: number;
user_count: number;
overdue_count?: number;
created_at: string;
updated_at: string;
}
@@ -156,8 +157,22 @@ export function listResidences(): Promise<ResidenceResponse[]> {
}
/** Get the user's residences with task summaries. */
export function getMyResidences(): Promise<MyResidenceResponse[]> {
return apiFetch<MyResidenceResponse[]>('/residences/my-residences/');
export async function getMyResidences(): Promise<MyResidenceResponse[]> {
// Go API returns { "residences": [ResidenceResponse, ...] }
// Each ResidenceResponse has overdue_count but no task_summary.
// We transform into MyResidenceResponse shape for compatibility.
const data = await apiFetch<{ residences: ResidenceResponse[] }>('/residences/my-residences/');
const residences = data.residences ?? [];
return residences.map((r) => ({
residence: r,
task_summary: {
total: 0,
overdue: r.overdue_count ?? 0,
due_soon: 0,
in_progress: 0,
completed: 0,
},
}));
}
/** Get aggregated task summary across all residences. */
+28
View File
@@ -0,0 +1,28 @@
"use client";
import { createContext, useContext } from 'react';
import type { DataProvider } from './data-provider';
const DataProviderContext = createContext<DataProvider | null>(null);
export function useDataProvider(): DataProvider {
const ctx = useContext(DataProviderContext);
if (!ctx) {
throw new Error('useDataProvider must be used within a DataProviderProvider');
}
return ctx;
}
export function DataProviderProvider({
value,
children,
}: {
value: DataProvider;
children: React.ReactNode;
}) {
return (
<DataProviderContext.Provider value={value}>
{children}
</DataProviderContext.Provider>
);
}
+173
View File
@@ -0,0 +1,173 @@
// ---------------------------------------------------------------------------
// DataProvider interface — abstraction over real API vs. in-memory demo store
// ---------------------------------------------------------------------------
// Types are imported from the API modules (the actual source of truth for hooks).
// ---------------------------------------------------------------------------
import type {
CreateResidenceRequest,
UpdateResidenceRequest,
ResidenceResponse,
MyResidenceResponse,
ResidenceSummaryResponse,
ShareCodeResponse,
SharePackageResponse,
GenerateShareCodeRequest,
ResidenceUserResponse,
TasksReportResponse,
MessageResponse as ResidenceMessageResponse,
} from '@/lib/api/residences';
import type {
CreateTaskRequest,
UpdateTaskRequest,
TaskResponse,
KanbanResponse,
CompletionResponse,
CreateCompletionRequest,
MessageResponse as TaskMessageResponse,
} from '@/lib/api/tasks';
import type {
CreateContractorRequest,
UpdateContractorRequest,
ContractorResponse,
ToggleFavoriteResponse,
ContractorTaskResponse,
} from '@/lib/api/contractors';
import type {
DocumentListParams,
CreateDocumentRequest,
UpdateDocumentRequest,
DocumentResponse,
MessageResponse as DocMessageResponse,
} from '@/lib/api/documents';
import type { StaticDataResponse } from '@/lib/api/lookups';
import type {
NotificationListResponse,
UnreadCountResponse,
NotificationPreferencesResponse,
UpdatePreferencesRequest,
} from '@/lib/api/notifications';
import type {
SubscriptionStatusResponse,
FeatureBenefitResponse,
UpgradeTriggerResponse,
} from '@/lib/api/subscription';
import type { UserResponse } from '@/lib/api/auth';
// Unified MessageResponse (all API modules define the same shape)
type MessageResponse = ResidenceMessageResponse | TaskMessageResponse | DocMessageResponse;
// ---------------------------------------------------------------------------
// Domain-split interface
// ---------------------------------------------------------------------------
export interface DataProvider {
basePath: string;
residences: {
list(): Promise<ResidenceResponse[]>;
get(id: number): Promise<ResidenceResponse>;
create(data: CreateResidenceRequest): Promise<ResidenceResponse>;
update(id: number, data: UpdateResidenceRequest): Promise<ResidenceResponse>;
delete(id: number): Promise<MessageResponse>;
getMyResidences(): Promise<MyResidenceResponse[]>;
getSummary(): Promise<ResidenceSummaryResponse>;
};
tasks: {
list(days?: number): Promise<KanbanResponse>;
get(id: number): Promise<TaskResponse>;
create(data: CreateTaskRequest): Promise<TaskResponse>;
update(id: number, data: UpdateTaskRequest): Promise<TaskResponse>;
delete(id: number): Promise<MessageResponse>;
getByResidence(residenceId: number, days?: number): Promise<KanbanResponse>;
getCompletions(taskId: number): Promise<CompletionResponse[]>;
createCompletion(data: CreateCompletionRequest): Promise<CompletionResponse>;
createCompletionWithImages(
data: { task_id: number; notes?: string; actual_cost?: number; completed_at?: string },
images: File[],
): Promise<CompletionResponse>;
markInProgress(id: number): Promise<TaskResponse>;
cancel(id: number): Promise<TaskResponse>;
uncancel(id: number): Promise<TaskResponse>;
archive(id: number): Promise<TaskResponse>;
unarchive(id: number): Promise<TaskResponse>;
quickComplete(id: number): Promise<void>;
};
contractors: {
list(): Promise<ContractorResponse[]>;
get(id: number): Promise<ContractorResponse>;
create(data: CreateContractorRequest): Promise<ContractorResponse>;
update(id: number, data: UpdateContractorRequest): Promise<ContractorResponse>;
delete(id: number): Promise<MessageResponse>;
toggleFavorite(id: number): Promise<ToggleFavoriteResponse>;
getTasks(id: number): Promise<ContractorTaskResponse[]>;
};
documents: {
list(params?: DocumentListParams): Promise<DocumentResponse[]>;
listWarranties(): Promise<DocumentResponse[]>;
get(id: number): Promise<DocumentResponse>;
create(data: CreateDocumentRequest): Promise<DocumentResponse>;
createWithFile(data: CreateDocumentRequest, file: File): Promise<DocumentResponse>;
update(id: number, data: UpdateDocumentRequest): Promise<DocumentResponse>;
delete(id: number): Promise<MessageResponse>;
};
lookups: {
getStaticData(): Promise<StaticDataResponse>;
};
sharing: {
getShareCode(residenceId: number): Promise<{ share_code: ShareCodeResponse | null }>;
generateShareCode(residenceId: number, data?: GenerateShareCodeRequest): Promise<ShareCodeResponse>;
generateSharePackage(residenceId: number, data?: GenerateShareCodeRequest): Promise<SharePackageResponse>;
getResidenceUsers(residenceId: number): Promise<ResidenceUserResponse[]>;
removeUser(residenceId: number, userId: number): Promise<MessageResponse>;
joinWithCode(data: { code: string }): Promise<ResidenceResponse>;
generateTasksReport(residenceId: number, email?: string): Promise<TasksReportResponse>;
};
notifications: {
list(limit?: number, offset?: number): Promise<NotificationListResponse>;
getUnreadCount(): Promise<UnreadCountResponse>;
getPreferences(): Promise<NotificationPreferencesResponse>;
updatePreferences(data: UpdatePreferencesRequest): Promise<NotificationPreferencesResponse>;
markAsRead(id: number): Promise<MessageResponse>;
markAllAsRead(): Promise<MessageResponse>;
};
subscription: {
getStatus(): Promise<SubscriptionStatusResponse>;
getFeatureBenefits(): Promise<FeatureBenefitResponse[]>;
getUpgradeTriggers(): Promise<UpgradeTriggerResponse[]>;
};
auth: {
getCurrentUser(): Promise<UserResponse>;
logout(): Promise<MessageResponse>;
};
}
// Re-export types that hooks/consumers will need
export type {
CreateResidenceRequest,
UpdateResidenceRequest,
CreateTaskRequest,
UpdateTaskRequest,
CreateCompletionRequest,
CreateContractorRequest,
UpdateContractorRequest,
CreateDocumentRequest,
UpdateDocumentRequest,
DocumentListParams,
UpdatePreferencesRequest,
};
+358
View File
@@ -0,0 +1,358 @@
// ---------------------------------------------------------------------------
// DemoProvider — DataProvider implementation backed by in-memory Zustand store
// ---------------------------------------------------------------------------
import type { DataProvider } from './data-provider';
import type { DocumentListParams } from '@/lib/api/documents';
import { useDemoStore } from './demo-store';
import { buildKanbanResponse } from './mock-data/tasks';
import { demoStaticData } from './mock-data/lookups';
import { demoUser } from './mock-data/user';
// ---------------------------------------------------------------------------
// Helper: read current store state (snapshot) outside React
// ---------------------------------------------------------------------------
function snap() {
return useDemoStore.getState();
}
// ---------------------------------------------------------------------------
// Provider
// ---------------------------------------------------------------------------
export const demoProvider: DataProvider = {
basePath: '/demo/app',
// -----------------------------------------------------------------------
// Residences
// -----------------------------------------------------------------------
residences: {
list: async () => snap().residences,
get: async (id) => {
const r = snap().residences.find(r => r.id === id);
if (!r) throw new Error(`Residence ${id} not found`);
return r;
},
create: async (data) => {
const staticData = demoStaticData;
const propertyType = data.property_type_id
? staticData.residence_types.find(t => t.id === data.property_type_id)
: undefined;
return snap().addResidence({ ...data, property_type: propertyType });
},
update: async (id, data) => snap().updateResidence(id, data),
delete: async (id) => {
snap().deleteResidence(id);
return { message: 'Residence deleted' };
},
getMyResidences: async () => snap().myResidences,
getSummary: async () => {
const { residences, myResidences } = snap();
const totalSummary = myResidences.reduce(
(acc, mr) => ({
total: acc.total + mr.task_summary.total,
overdue: acc.overdue + mr.task_summary.overdue,
due_soon: acc.due_soon + mr.task_summary.due_soon,
in_progress: acc.in_progress + mr.task_summary.in_progress,
completed: acc.completed + mr.task_summary.completed,
}),
{ total: 0, overdue: 0, due_soon: 0, in_progress: 0, completed: 0 },
);
return {
total_residences: residences.length,
total_summary: totalSummary,
residences: myResidences,
};
},
},
// -----------------------------------------------------------------------
// Tasks
// -----------------------------------------------------------------------
tasks: {
list: async () => buildKanbanResponse(snap().tasks),
get: async (id) => {
const t = snap().tasks.find(t => t.id === id);
if (!t) throw new Error(`Task ${id} not found`);
return t;
},
create: async (data) => {
const staticData = demoStaticData;
const category = data.category_id
? staticData.task_categories.find(c => c.id === data.category_id)
: undefined;
const priority = data.priority_id
? staticData.task_priorities.find(p => p.id === data.priority_id)
: undefined;
const contractor = data.contractor_id
? snap().contractors.find(c => c.id === data.contractor_id)
: undefined;
return snap().addTask({
...data,
category: category ? { ...category } : undefined,
priority: priority ? { ...priority } : undefined,
contractor: contractor
? { id: contractor.id, name: contractor.name, company: contractor.company }
: undefined,
});
},
update: async (id, data) => {
const staticData = demoStaticData;
const extra: Record<string, unknown> = {};
if (data.category_id != null) {
extra.category = staticData.task_categories.find(c => c.id === data.category_id);
}
if (data.priority_id != null) {
extra.priority = staticData.task_priorities.find(p => p.id === data.priority_id);
}
if (data.contractor_id != null) {
const c = snap().contractors.find(c => c.id === data.contractor_id);
extra.contractor = c ? { id: c.id, name: c.name, company: c.company } : undefined;
}
return snap().updateTask(id, { ...data, ...extra });
},
delete: async (id) => {
snap().deleteTask(id);
return { message: 'Task deleted' };
},
getByResidence: async (residenceId) => {
const tasks = snap().tasks.filter(t => t.residence_id === residenceId);
return buildKanbanResponse(tasks);
},
getCompletions: async (taskId) =>
snap().completions.filter(c => c.task_id === taskId),
createCompletion: async (data) =>
snap().completeTask(data.task_id, data),
createCompletionWithImages: async (data) =>
snap().completeTask(data.task_id, data),
markInProgress: async (id) => snap().markTaskInProgress(id),
cancel: async (id) => snap().cancelTask(id),
uncancel: async (id) => snap().uncancelTask(id),
archive: async (id) => snap().archiveTask(id),
unarchive: async (id) => snap().unarchiveTask(id),
quickComplete: async (id) => {
snap().completeTask(id, {});
},
},
// -----------------------------------------------------------------------
// Contractors
// -----------------------------------------------------------------------
contractors: {
list: async () => snap().contractors,
get: async (id) => {
const c = snap().contractors.find(c => c.id === id);
if (!c) throw new Error(`Contractor ${id} not found`);
return c;
},
create: async (data) => {
const specialties = (data.specialty_ids ?? [])
.map(sid => demoStaticData.contractor_specialties.find(s => s.id === sid))
.filter(Boolean) as { id: number; name: string; icon: string }[];
return snap().addContractor({ ...data, specialties });
},
update: async (id, data) => {
const extra: Record<string, unknown> = {};
if (data.specialty_ids) {
extra.specialties = data.specialty_ids
.map(sid => demoStaticData.contractor_specialties.find(s => s.id === sid))
.filter(Boolean);
}
return snap().updateContractor(id, { ...data, ...extra });
},
delete: async (id) => {
snap().deleteContractor(id);
return { message: 'Contractor deleted' };
},
toggleFavorite: async (id) => {
const updated = snap().toggleContractorFavorite(id);
return { id: updated.id, is_favorite: updated.is_favorite };
},
getTasks: async (id) => {
const tasks = snap().tasks.filter(t => t.contractor_id === id);
return tasks.map(t => ({
id: t.id,
title: t.title,
residence_name: t.residence_name,
status: t.in_progress ? 'in_progress' : t.is_cancelled ? 'cancelled' : 'pending',
due_date: t.due_date,
priority: t.priority?.name,
}));
},
},
// -----------------------------------------------------------------------
// Documents
// -----------------------------------------------------------------------
documents: {
list: async (params?: DocumentListParams) => {
let docs = snap().documents;
if (params?.residence) docs = docs.filter(d => d.residence_id === params.residence);
if (params?.document_type) docs = docs.filter(d => d.document_type === params.document_type);
if (params?.is_active != null) docs = docs.filter(d => d.is_active === params.is_active);
if (params?.search) {
const q = params.search.toLowerCase();
docs = docs.filter(d =>
d.title.toLowerCase().includes(q) || d.description.toLowerCase().includes(q),
);
}
return docs;
},
listWarranties: async () =>
snap().documents.filter(d => d.document_type === 'warranty'),
get: async (id) => {
const d = snap().documents.find(d => d.id === id);
if (!d) throw new Error(`Document ${id} not found`);
return d;
},
create: async (data) => {
const residence = snap().residences.find(r => r.id === data.residence_id);
return snap().addDocument({
...data,
residence_name: residence?.name,
});
},
createWithFile: async (data) => {
const residence = snap().residences.find(r => r.id === data.residence_id);
return snap().addDocument({
...data,
residence_name: residence?.name,
});
},
update: async (id, data) => snap().updateDocument(id, data),
delete: async (id) => {
snap().deleteDocument(id);
return { message: 'Document deleted' };
},
},
// -----------------------------------------------------------------------
// Lookups
// -----------------------------------------------------------------------
lookups: {
getStaticData: async () => demoStaticData,
},
// -----------------------------------------------------------------------
// Sharing (demo stubs — mostly no-ops or simple responses)
// -----------------------------------------------------------------------
sharing: {
getShareCode: async () => ({ share_code: null }),
generateShareCode: async (residenceId) => ({
code: 'DEMO42',
residence_id: residenceId,
expires_at: new Date(Date.now() + 24 * 3600000).toISOString(),
created_at: new Date().toISOString(),
}),
generateSharePackage: async (residenceId) => {
const r = snap().residences.find(r => r.id === residenceId);
return {
code: 'DEMO42',
residence_id: residenceId,
residence_name: r?.name ?? 'Demo Residence',
owner_name: 'Demo User',
expires_at: new Date(Date.now() + 24 * 3600000).toISOString(),
};
},
getResidenceUsers: async () => [
{
id: 1,
username: 'demo_user',
email: 'demo@casera.app',
first_name: 'Demo',
last_name: 'User',
is_owner: true,
},
],
removeUser: async () => ({ message: 'User removed' }),
joinWithCode: async () => {
throw new Error('Sharing is not available in demo mode');
},
generateTasksReport: async (residenceId) => {
const r = snap().residences.find(r => r.id === residenceId);
return {
message: 'Report generated (demo)',
residence_name: r?.name ?? 'Demo Residence',
recipient_email: 'demo@casera.app',
pdf_generated: true,
email_sent: false,
report: {},
};
},
},
// -----------------------------------------------------------------------
// Notifications
// -----------------------------------------------------------------------
notifications: {
list: async (limit, offset) => {
let items = snap().notifications;
const start = offset ?? 0;
const end = limit ? start + limit : undefined;
items = items.slice(start, end);
return { count: snap().notifications.length, results: items };
},
getUnreadCount: async () => ({
unread_count: snap().notifications.filter(n => !n.is_read).length,
}),
getPreferences: async () => ({
task_reminders: true,
task_completions: true,
residence_updates: true,
share_notifications: true,
marketing: false,
}),
updatePreferences: async (data) => ({
task_reminders: data.task_reminders ?? true,
task_completions: data.task_completions ?? true,
residence_updates: data.residence_updates ?? true,
share_notifications: data.share_notifications ?? true,
marketing: data.marketing ?? false,
}),
markAsRead: async (id) => {
snap().markNotificationRead(id);
return { message: 'Marked as read' };
},
markAllAsRead: async () => {
snap().markAllNotificationsRead();
return { message: 'All marked as read' };
},
},
// -----------------------------------------------------------------------
// Subscription (mock free tier)
// -----------------------------------------------------------------------
subscription: {
getStatus: async () => ({
tier: 'free',
status: 'active',
is_active: true,
limits: {
max_residences: 2,
max_tasks_per_residence: 25,
max_contractors: 10,
max_documents: 20,
can_share: false,
can_export: false,
},
}),
getFeatureBenefits: async () => [
{ id: 1, title: 'Unlimited Residences', description: 'Track all your properties', icon: '🏠', tier: 'pro', sort_order: 1 },
{ id: 2, title: 'Unlimited Tasks', description: 'Never hit a task limit', icon: '✅', tier: 'pro', sort_order: 2 },
{ id: 3, title: 'Sharing', description: 'Share residences with family', icon: '👥', tier: 'pro', sort_order: 3 },
{ id: 4, title: 'PDF Reports', description: 'Export task reports', icon: '📄', tier: 'pro', sort_order: 4 },
],
getUpgradeTriggers: async () => [
{ id: 1, key: 'residence_limit', title: 'Need more properties?', description: 'Upgrade to track unlimited residences.', action_text: 'Upgrade', icon: '🏠' },
{ id: 2, key: 'sharing', title: 'Want to share?', description: 'Upgrade to share residences with family members.', action_text: 'Upgrade', icon: '👥' },
],
},
// -----------------------------------------------------------------------
// Auth (demo user)
// -----------------------------------------------------------------------
auth: {
getCurrentUser: async () => demoUser,
logout: async () => ({ message: 'Logged out' }),
},
};
+489
View File
@@ -0,0 +1,489 @@
// ---------------------------------------------------------------------------
// Zustand demo store — in-memory CRUD for sandboxed demo experience
// No persist middleware: data resets on page reload.
// ---------------------------------------------------------------------------
import { create } from 'zustand';
import type { ResidenceResponse, MyResidenceResponse, TaskSummary } from '@/lib/api/residences';
import type { TaskResponse, CompletionResponse } from '@/lib/api/tasks';
import type { ContractorResponse } from '@/lib/api/contractors';
import type { DocumentResponse } from '@/lib/api/documents';
import type { NotificationResponse } from '@/lib/api/notifications';
import {
demoResidences,
demoMyResidences,
demoTasks,
demoCompletions,
demoContractors,
demoDocuments,
demoNotifications,
} from './mock-data';
// ---------------------------------------------------------------------------
// State shape
// ---------------------------------------------------------------------------
interface DemoState {
residences: ResidenceResponse[];
myResidences: MyResidenceResponse[];
tasks: TaskResponse[];
completions: CompletionResponse[];
contractors: ContractorResponse[];
documents: DocumentResponse[];
notifications: NotificationResponse[];
nextIds: {
residence: number;
task: number;
completion: number;
contractor: number;
document: number;
notification: number;
};
// Residences
addResidence: (data: Partial<ResidenceResponse>) => ResidenceResponse;
updateResidence: (id: number, data: Partial<ResidenceResponse>) => ResidenceResponse;
deleteResidence: (id: number) => void;
// Tasks
addTask: (data: Partial<TaskResponse>) => TaskResponse;
updateTask: (id: number, data: Partial<TaskResponse>) => TaskResponse;
deleteTask: (id: number) => void;
markTaskInProgress: (id: number) => TaskResponse;
cancelTask: (id: number) => TaskResponse;
uncancelTask: (id: number) => TaskResponse;
archiveTask: (id: number) => TaskResponse;
unarchiveTask: (id: number) => TaskResponse;
completeTask: (taskId: number, data: Partial<CompletionResponse>) => CompletionResponse;
// Contractors
addContractor: (data: Partial<ContractorResponse>) => ContractorResponse;
updateContractor: (id: number, data: Partial<ContractorResponse>) => ContractorResponse;
deleteContractor: (id: number) => void;
toggleContractorFavorite: (id: number) => ContractorResponse;
// Documents
addDocument: (data: Partial<DocumentResponse>) => DocumentResponse;
updateDocument: (id: number, data: Partial<DocumentResponse>) => DocumentResponse;
deleteDocument: (id: number) => void;
// Notifications
markNotificationRead: (id: number) => void;
markAllNotificationsRead: () => void;
// Reset
reset: () => void;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const now = () => new Date().toISOString();
function buildTaskSummary(tasks: TaskResponse[], residenceId: number): TaskSummary {
const today = new Date();
today.setHours(0, 0, 0, 0);
const dayMs = 86400000;
const resTasks = tasks.filter(t => t.residence_id === residenceId && !t.is_cancelled && !t.is_archived);
let overdue = 0, dueSoon = 0, inProgress = 0, completed = 0;
for (const task of resTasks) {
if (task.in_progress) { inProgress++; continue; }
const isCompleted = task.completion_count > 0 && !task.next_due_date;
const recentlyCompleted = task.last_completed_at && task.next_due_date &&
new Date(task.next_due_date) > new Date(today.getTime() + 14 * dayMs);
if (isCompleted || recentlyCompleted) { completed++; continue; }
const dd = task.next_due_date || task.due_date;
if (dd) {
const dueDate = new Date(dd);
dueDate.setHours(0, 0, 0, 0);
const diff = Math.floor((dueDate.getTime() - today.getTime()) / dayMs);
if (diff < 0) overdue++;
else if (diff <= 7) dueSoon++;
}
}
return { total: resTasks.length, overdue, due_soon: dueSoon, in_progress: inProgress, completed };
}
function rebuildMyResidences(residences: ResidenceResponse[], tasks: TaskResponse[]): MyResidenceResponse[] {
return residences.map(residence => ({
residence,
task_summary: buildTaskSummary(tasks, residence.id),
}));
}
// ---------------------------------------------------------------------------
// Initial state factory (for reset)
// ---------------------------------------------------------------------------
function initialState() {
return {
residences: structuredClone(demoResidences),
myResidences: structuredClone(demoMyResidences),
tasks: structuredClone(demoTasks),
completions: structuredClone(demoCompletions),
contractors: structuredClone(demoContractors),
documents: structuredClone(demoDocuments),
notifications: structuredClone(demoNotifications),
nextIds: {
residence: 100,
task: 100,
completion: 100,
contractor: 100,
document: 100,
notification: 100,
},
};
}
// ---------------------------------------------------------------------------
// Store
// ---------------------------------------------------------------------------
export const useDemoStore = create<DemoState>()((set, get) => ({
...initialState(),
// -------------------------------------------------------------------------
// Residences
// -------------------------------------------------------------------------
addResidence: (data) => {
const state = get();
const id = state.nextIds.residence;
const residence: ResidenceResponse = {
id,
name: data.name ?? 'New Residence',
property_type_id: data.property_type_id,
property_type: data.property_type,
street_address: data.street_address ?? '',
apartment_unit: data.apartment_unit ?? '',
city: data.city ?? '',
state_province: data.state_province ?? '',
postal_code: data.postal_code ?? '',
country: data.country ?? 'US',
bedrooms: data.bedrooms,
bathrooms: data.bathrooms,
square_footage: data.square_footage,
lot_size: data.lot_size,
year_built: data.year_built,
description: data.description ?? '',
purchase_date: data.purchase_date,
purchase_price: data.purchase_price,
is_primary: data.is_primary ?? false,
is_owner: true,
owner_id: 1,
user_count: 1,
created_at: now(),
updated_at: now(),
};
const newResidences = [...state.residences, residence];
set({
residences: newResidences,
myResidences: rebuildMyResidences(newResidences, state.tasks),
nextIds: { ...state.nextIds, residence: id + 1 },
});
return residence;
},
updateResidence: (id, data) => {
const state = get();
const newResidences = state.residences.map(r =>
r.id === id ? { ...r, ...data, id, updated_at: now() } : r,
);
const updated = newResidences.find(r => r.id === id)!;
set({
residences: newResidences,
myResidences: rebuildMyResidences(newResidences, state.tasks),
});
return updated;
},
deleteResidence: (id) => {
const state = get();
const newResidences = state.residences.filter(r => r.id !== id);
const newTasks = state.tasks.filter(t => t.residence_id !== id);
const newDocuments = state.documents.filter(d => d.residence_id !== id);
set({
residences: newResidences,
tasks: newTasks,
documents: newDocuments,
myResidences: rebuildMyResidences(newResidences, newTasks),
});
},
// -------------------------------------------------------------------------
// Tasks
// -------------------------------------------------------------------------
addTask: (data) => {
const state = get();
const id = state.nextIds.task;
const residence = state.residences.find(r => r.id === data.residence_id);
const task: TaskResponse = {
id,
residence_id: data.residence_id ?? 1,
residence_name: residence?.name ?? 'Unknown',
title: data.title ?? 'New Task',
description: data.description ?? '',
category_id: data.category_id,
category: data.category,
priority_id: data.priority_id,
priority: data.priority,
frequency_id: data.frequency_id,
in_progress: data.in_progress ?? false,
is_cancelled: false,
is_archived: false,
due_date: data.due_date,
next_due_date: data.due_date,
estimated_cost: data.estimated_cost,
contractor_id: data.contractor_id,
contractor: data.contractor,
completion_count: 0,
created_by_id: 1,
created_at: now(),
updated_at: now(),
};
const newTasks = [...state.tasks, task];
set({
tasks: newTasks,
myResidences: rebuildMyResidences(state.residences, newTasks),
nextIds: { ...state.nextIds, task: id + 1 },
});
return task;
},
updateTask: (id, data) => {
const state = get();
const newTasks = state.tasks.map(t =>
t.id === id ? { ...t, ...data, id, updated_at: now() } : t,
);
const updated = newTasks.find(t => t.id === id)!;
set({
tasks: newTasks,
myResidences: rebuildMyResidences(state.residences, newTasks),
});
return updated;
},
deleteTask: (id) => {
const state = get();
const newTasks = state.tasks.filter(t => t.id !== id);
set({
tasks: newTasks,
completions: state.completions.filter(c => c.task_id !== id),
myResidences: rebuildMyResidences(state.residences, newTasks),
});
},
markTaskInProgress: (id) => {
return get().updateTask(id, { in_progress: true });
},
cancelTask: (id) => {
return get().updateTask(id, { is_cancelled: true, in_progress: false });
},
uncancelTask: (id) => {
return get().updateTask(id, { is_cancelled: false });
},
archiveTask: (id) => {
return get().updateTask(id, { is_archived: true });
},
unarchiveTask: (id) => {
return get().updateTask(id, { is_archived: false });
},
completeTask: (taskId, data) => {
const state = get();
const completionId = state.nextIds.completion;
const task = state.tasks.find(t => t.id === taskId);
const completion: CompletionResponse = {
id: completionId,
task_id: taskId,
task_title: task?.title ?? '',
completed_at: data.completed_at ?? now(),
completed_by_id: 1,
completed_by: { id: 1, username: 'demo_user', first_name: 'Demo', last_name: 'User' },
notes: data.notes ?? '',
actual_cost: data.actual_cost,
rating: data.rating,
images: data.images ?? [],
created_at: now(),
};
// Update task: increment completion_count, set last_completed_at, advance next_due_date for recurring
const taskUpdates: Partial<TaskResponse> = {
completion_count: (task?.completion_count ?? 0) + 1,
last_completed_at: completion.completed_at,
in_progress: false,
actual_cost: data.actual_cost,
};
// Advance next_due_date for recurring tasks
if (task?.frequency_id) {
const frequencyDays: Record<number, number> = { 1: 7, 2: 14, 3: 30, 4: 90, 5: 180, 6: 365 };
const days = frequencyDays[task.frequency_id] ?? 30;
const nextDate = new Date(Date.now() + days * 86400000);
taskUpdates.next_due_date = nextDate.toISOString().split('T')[0];
} else {
// One-time task: clear next_due_date to mark as completed
taskUpdates.next_due_date = undefined;
}
const newTasks = state.tasks.map(t =>
t.id === taskId ? { ...t, ...taskUpdates, updated_at: now() } : t,
);
set({
tasks: newTasks,
completions: [...state.completions, completion],
myResidences: rebuildMyResidences(state.residences, newTasks),
nextIds: { ...state.nextIds, completion: completionId + 1 },
});
return completion;
},
// -------------------------------------------------------------------------
// Contractors
// -------------------------------------------------------------------------
addContractor: (data) => {
const state = get();
const id = state.nextIds.contractor;
const contractor: ContractorResponse = {
id,
name: data.name ?? 'New Contractor',
company: data.company ?? '',
phone: data.phone ?? '',
email: data.email ?? '',
website: data.website ?? '',
notes: data.notes ?? '',
street_address: data.street_address ?? '',
city: data.city ?? '',
state_province: data.state_province ?? '',
postal_code: data.postal_code ?? '',
specialties: data.specialties ?? [],
rating: data.rating,
is_favorite: data.is_favorite ?? false,
task_count: 0,
created_by_id: 1,
created_at: now(),
updated_at: now(),
};
set({
contractors: [...state.contractors, contractor],
nextIds: { ...state.nextIds, contractor: id + 1 },
});
return contractor;
},
updateContractor: (id, data) => {
const state = get();
const newContractors = state.contractors.map(c =>
c.id === id ? { ...c, ...data, id, updated_at: now() } : c,
);
set({ contractors: newContractors });
return newContractors.find(c => c.id === id)!;
},
deleteContractor: (id) => {
set(state => ({ contractors: state.contractors.filter(c => c.id !== id) }));
},
toggleContractorFavorite: (id) => {
const state = get();
const contractor = state.contractors.find(c => c.id === id);
if (!contractor) throw new Error(`Contractor ${id} not found`);
return get().updateContractor(id, { is_favorite: !contractor.is_favorite });
},
// -------------------------------------------------------------------------
// Documents
// -------------------------------------------------------------------------
addDocument: (data) => {
const state = get();
const id = state.nextIds.document;
const residence = state.residences.find(r => r.id === data.residence_id);
const document: DocumentResponse = {
id,
residence_id: data.residence_id ?? 1,
residence_name: residence?.name ?? 'Unknown',
title: data.title ?? 'New Document',
description: data.description ?? '',
document_type: data.document_type ?? 'general',
file_url: data.file_url ?? '',
file_name: data.file_name ?? '',
file_size: data.file_size,
mime_type: data.mime_type ?? 'application/pdf',
purchase_date: data.purchase_date,
expiry_date: data.expiry_date,
purchase_price: data.purchase_price,
vendor: data.vendor ?? '',
serial_number: data.serial_number ?? '',
model_number: data.model_number ?? '',
is_active: data.is_active ?? true,
images: data.images ?? [],
created_by: data.created_by ?? { id: 1, username: 'demo_user', first_name: 'Demo', last_name: 'User' },
created_at: now(),
updated_at: now(),
};
set({
documents: [...state.documents, document],
nextIds: { ...state.nextIds, document: id + 1 },
});
return document;
},
updateDocument: (id, data) => {
const state = get();
const newDocuments = state.documents.map(d =>
d.id === id ? { ...d, ...data, id, updated_at: now() } : d,
);
set({ documents: newDocuments });
return newDocuments.find(d => d.id === id)!;
},
deleteDocument: (id) => {
set(state => ({ documents: state.documents.filter(d => d.id !== id) }));
},
// -------------------------------------------------------------------------
// Notifications
// -------------------------------------------------------------------------
markNotificationRead: (id) => {
set(state => ({
notifications: state.notifications.map(n =>
n.id === id ? { ...n, is_read: true } : n,
),
}));
},
markAllNotificationsRead: () => {
set(state => ({
notifications: state.notifications.map(n => ({ ...n, is_read: true })),
}));
},
// -------------------------------------------------------------------------
// Reset
// -------------------------------------------------------------------------
reset: () => {
set(initialState());
},
}));

Some files were not shown because too many files have changed in this diff Show More