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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user