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:
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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('/')}`;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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");
|
||||
},
|
||||
});
|
||||
}}
|
||||
|
||||
@@ -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");
|
||||
},
|
||||
});
|
||||
}}
|
||||
|
||||
@@ -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");
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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
@@ -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
@@ -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">
|
||||
|
||||
@@ -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");
|
||||
},
|
||||
});
|
||||
}}
|
||||
|
||||
@@ -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");
|
||||
},
|
||||
});
|
||||
}}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
},
|
||||
});
|
||||
}}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
},
|
||||
},
|
||||
);
|
||||
}}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
@@ -0,0 +1,2 @@
|
||||
"use client";
|
||||
export { default } from "@/app/app/documents/[id]/page";
|
||||
@@ -0,0 +1,2 @@
|
||||
"use client";
|
||||
export { default } from "@/app/app/documents/new/page";
|
||||
@@ -0,0 +1,2 @@
|
||||
"use client";
|
||||
export { default } from "@/app/app/documents/page";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -0,0 +1,2 @@
|
||||
"use client";
|
||||
export { default } from "@/app/app/residences/new/page";
|
||||
@@ -0,0 +1,2 @@
|
||||
"use client";
|
||||
export { default } from "@/app/app/residences/page";
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
@@ -0,0 +1,2 @@
|
||||
"use client";
|
||||
export { default } from "@/app/app/tasks/[id]/page";
|
||||
@@ -0,0 +1,2 @@
|
||||
"use client";
|
||||
export { default } from "@/app/app/tasks/new/page";
|
||||
@@ -0,0 +1,2 @@
|
||||
"use client";
|
||||
export { default } from "@/app/app/tasks/page";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 — 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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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're looking for doesn'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,
|
||||
}));
|
||||
|
||||
@@ -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,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">
|
||||
|
||||
@@ -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're exploring Casera in demo mode. Changes aren'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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
},
|
||||
});
|
||||
}}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 }
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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' }),
|
||||
},
|
||||
};
|
||||
@@ -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
Reference in New Issue
Block a user