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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-03-03 11:37:41 -06:00
parent 5a50d77515
commit 7884ebbfd4
133 changed files with 3904 additions and 300 deletions
+3 -2
View File
@@ -64,7 +64,7 @@ export default function ForgotPasswordPage() {
>
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
{error && (
<div className="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">
<div role="alert" aria-live="assertive" className="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</div>
)}
@@ -77,10 +77,11 @@ export default function ForgotPasswordPage() {
placeholder="you@example.com"
autoComplete="email"
aria-invalid={!!errors.email}
aria-describedby={errors.email ? "email-error" : undefined}
{...register("email")}
/>
{errors.email && (
<p className="text-sm text-destructive">{errors.email.message}</p>
<p id="email-error" role="alert" className="text-sm text-destructive">{errors.email.message}</p>
)}
</div>
+5 -3
View File
@@ -44,7 +44,7 @@ export default function LoginPage() {
>
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
{error && (
<div className="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">
<div role="alert" aria-live="assertive" className="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</div>
)}
@@ -56,10 +56,11 @@ export default function LoginPage() {
placeholder="you@example.com"
autoComplete="username"
aria-invalid={!!errors.username}
aria-describedby={errors.username ? "username-error" : undefined}
{...register("username")}
/>
{errors.username && (
<p className="text-sm text-destructive">
<p id="username-error" role="alert" className="text-sm text-destructive">
{errors.username.message}
</p>
)}
@@ -79,10 +80,11 @@ export default function LoginPage() {
id="password"
autoComplete="current-password"
aria-invalid={!!errors.password}
aria-describedby={errors.password ? "password-error" : undefined}
{...register("password")}
/>
{errors.password && (
<p className="text-sm text-destructive">
<p id="password-error" role="alert" className="text-sm text-destructive">
{errors.password.message}
</p>
)}
+13 -7
View File
@@ -59,7 +59,7 @@ export default function RegisterPage() {
>
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
{error && (
<div className="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">
<div role="alert" aria-live="assertive" className="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</div>
)}
@@ -71,10 +71,11 @@ export default function RegisterPage() {
id="first_name"
autoComplete="given-name"
aria-invalid={!!errors.first_name}
aria-describedby={errors.first_name ? "first-name-error" : undefined}
{...register("first_name")}
/>
{errors.first_name && (
<p className="text-sm text-destructive">
<p id="first-name-error" role="alert" className="text-sm text-destructive">
{errors.first_name.message}
</p>
)}
@@ -86,10 +87,11 @@ export default function RegisterPage() {
id="last_name"
autoComplete="family-name"
aria-invalid={!!errors.last_name}
aria-describedby={errors.last_name ? "last-name-error" : undefined}
{...register("last_name")}
/>
{errors.last_name && (
<p className="text-sm text-destructive">
<p id="last-name-error" role="alert" className="text-sm text-destructive">
{errors.last_name.message}
</p>
)}
@@ -102,10 +104,11 @@ export default function RegisterPage() {
id="username"
autoComplete="username"
aria-invalid={!!errors.username}
aria-describedby={errors.username ? "username-error" : undefined}
{...register("username")}
/>
{errors.username && (
<p className="text-sm text-destructive">
<p id="username-error" role="alert" className="text-sm text-destructive">
{errors.username.message}
</p>
)}
@@ -119,10 +122,11 @@ export default function RegisterPage() {
placeholder="you@example.com"
autoComplete="email"
aria-invalid={!!errors.email}
aria-describedby={errors.email ? "email-error" : undefined}
{...register("email")}
/>
{errors.email && (
<p className="text-sm text-destructive">{errors.email.message}</p>
<p id="email-error" role="alert" className="text-sm text-destructive">{errors.email.message}</p>
)}
</div>
@@ -132,10 +136,11 @@ export default function RegisterPage() {
id="password"
autoComplete="new-password"
aria-invalid={!!errors.password}
aria-describedby={errors.password ? "password-error" : undefined}
{...register("password")}
/>
{errors.password && (
<p className="text-sm text-destructive">
<p id="password-error" role="alert" className="text-sm text-destructive">
{errors.password.message}
</p>
)}
@@ -147,10 +152,11 @@ export default function RegisterPage() {
id="confirm_password"
autoComplete="new-password"
aria-invalid={!!errors.confirm_password}
aria-describedby={errors.confirm_password ? "confirm-password-error" : undefined}
{...register("confirm_password")}
/>
{errors.confirm_password && (
<p className="text-sm text-destructive">
<p id="confirm-password-error" role="alert" className="text-sm text-destructive">
{errors.confirm_password.message}
</p>
)}
+1 -1
View File
@@ -11,7 +11,7 @@ import { NextRequest, NextResponse } from 'next/server';
const API_BASE_URL =
process.env.API_URL ||
process.env.NEXT_PUBLIC_API_URL ||
'https://mycrib.treytartt.com/api';
'https://casera.treytartt.com/api';
const COOKIE_NAME = 'casera-token';
const COOKIE_MAX_AGE = 60 * 60 * 24 * 30; // 30 days
+1 -1
View File
@@ -11,7 +11,7 @@ import { NextRequest, NextResponse } from 'next/server';
const API_BASE_URL =
process.env.API_URL ||
process.env.NEXT_PUBLIC_API_URL ||
'https://mycrib.treytartt.com/api';
'https://casera.treytartt.com/api';
const COOKIE_NAME = 'casera-token';
+1 -1
View File
@@ -11,7 +11,7 @@ import { NextRequest, NextResponse } from 'next/server';
const API_BASE_URL =
process.env.API_URL ||
process.env.NEXT_PUBLIC_API_URL ||
'https://mycrib.treytartt.com/api';
'https://casera.treytartt.com/api';
const COOKIE_NAME = 'casera-token';
+2 -2
View File
@@ -12,11 +12,11 @@ import { NextRequest, NextResponse } from 'next/server';
const API_BASE_URL =
process.env.API_URL ||
process.env.NEXT_PUBLIC_API_URL ||
'https://mycrib.treytartt.com/api';
'https://casera.treytartt.com/api';
/**
* Build the target URL from the catch-all path segments.
* e.g. /api/proxy/tasks/123/ -> https://mycrib.treytartt.com/api/tasks/123/
* e.g. /api/proxy/tasks/123/ -> https://casera.treytartt.com/api/tasks/123/
*/
function buildTargetUrl(request: NextRequest, pathSegments: string[]): string {
const path = `/${pathSegments.join('/')}`;
+9 -2
View File
@@ -2,11 +2,13 @@
import { use } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { PageHeader } from "@/components/shared/page-header";
import { LoadingSkeleton } from "@/components/shared/loading-skeleton";
import { ErrorBanner } from "@/components/shared/error-banner";
import { ContractorForm } from "@/components/contractors/contractor-form";
import { useContractor, useUpdateContractor } from "@/lib/hooks/use-contractors";
import { useDataProvider } from "@/lib/demo/data-provider-context";
import type { ContractorFormValues } from "@/components/contractors/contractor-form";
export default function EditContractorPage({
@@ -17,6 +19,7 @@ export default function EditContractorPage({
const { id: idParam } = use(params);
const id = Number(idParam);
const router = useRouter();
const { basePath } = useDataProvider();
const { data: contractor, isLoading, isError, error, refetch } = useContractor(id);
const updateContractor = useUpdateContractor(id);
@@ -24,7 +27,11 @@ export default function EditContractorPage({
function handleSubmit(data: ContractorFormValues) {
updateContractor.mutate(data, {
onSuccess: () => {
router.push(`/app/contractors/${id}`);
toast.success("Contractor updated");
router.push(`${basePath}/contractors/${id}`);
},
onError: () => {
toast.error("Failed to update contractor");
},
});
}
@@ -45,7 +52,7 @@ export default function EditContractorPage({
if (!contractor) return null;
return (
<div className="space-y-6 max-w-2xl">
<div className="space-y-6 w-full max-w-2xl mx-auto">
<PageHeader title={`Edit ${contractor.name}`} />
<ContractorForm
contractor={contractor}
+8 -1
View File
@@ -3,6 +3,7 @@
import { use, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { toast } from "sonner";
import { Phone, Mail, Globe, Star, Pencil, Trash2, FileDown } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -20,6 +21,7 @@ import {
useDeleteContractor,
useToggleFavorite,
} from "@/lib/hooks/use-contractors";
import { useDataProvider } from "@/lib/demo/data-provider-context";
export default function ContractorDetailPage({
params,
@@ -29,6 +31,7 @@ export default function ContractorDetailPage({
const { id: idParam } = use(params);
const id = Number(idParam);
const router = useRouter();
const { basePath } = useDataProvider();
const { data: contractor, isLoading, isError, error, refetch } = useContractor(id);
const { data: tasks } = useContractorTasks(id);
@@ -40,7 +43,11 @@ export default function ContractorDetailPage({
function handleDelete() {
deleteContractor.mutate(id, {
onSuccess: () => {
router.push("/app/contractors");
toast.success("Contractor deleted");
router.push(`${basePath}/contractors`);
},
onError: () => {
toast.error("Failed to delete contractor");
},
});
}
+9 -2
View File
@@ -1,25 +1,32 @@
"use client";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { PageHeader } from "@/components/shared/page-header";
import { ContractorForm } from "@/components/contractors/contractor-form";
import { useCreateContractor } from "@/lib/hooks/use-contractors";
import { useDataProvider } from "@/lib/demo/data-provider-context";
import type { ContractorFormValues } from "@/components/contractors/contractor-form";
export default function NewContractorPage() {
const router = useRouter();
const { basePath } = useDataProvider();
const createContractor = useCreateContractor();
function handleSubmit(data: ContractorFormValues) {
createContractor.mutate(data, {
onSuccess: (res) => {
router.push(`/app/contractors/${res.id}`);
toast.success("Contractor created");
router.push(`${basePath}/contractors/${res.id}`);
},
onError: () => {
toast.error("Failed to create contractor");
},
});
}
return (
<div className="space-y-6 max-w-2xl">
<div className="space-y-6 w-full max-w-2xl mx-auto">
<PageHeader title="New Contractor" />
<ContractorForm onSubmit={handleSubmit} loading={createContractor.isPending} />
</div>
+7 -5
View File
@@ -15,9 +15,11 @@ import { CaseraFileImport } from "@/components/sharing/casera-file-handler";
import { ContractorCard } from "@/components/contractors/contractor-card";
import { ContractorFilters } from "@/components/contractors/contractor-filters";
import { useContractors, useToggleFavorite, useCreateContractor } from "@/lib/hooks/use-contractors";
import { useDataProvider } from "@/lib/demo/data-provider-context";
export default function ContractorsPage() {
const router = useRouter();
const { basePath } = useDataProvider();
const { data: contractors, isLoading, isError, error, refetch } = useContractors();
const toggleFavorite = useToggleFavorite();
const createContractor = useCreateContractor();
@@ -29,7 +31,7 @@ export default function ContractorsPage() {
const [importError, setImportError] = useState<string | null>(null);
const filtered = useMemo(() => {
if (!contractors) return [];
if (!Array.isArray(contractors)) return [];
let list = contractors;
// Search filter (name or company)
@@ -105,7 +107,7 @@ export default function ContractorsPage() {
title="Contractors"
description="Manage your trusted contractors and service providers"
actionLabel="Add Contractor"
onAction={() => router.push("/app/contractors/new")}
onAction={() => router.push(`${basePath}/contractors/new`)}
>
<Button
variant="outline"
@@ -129,7 +131,7 @@ export default function ContractorsPage() {
{isLoading && <LoadingSkeleton variant="list" count={5} />}
{!isLoading && !isError && contractors && (
{!isLoading && !isError && Array.isArray(contractors) && (
<>
<ContractorFilters
search={search}
@@ -145,12 +147,12 @@ export default function ContractorsPage() {
icon={Wrench}
title="No contractors found"
description={
contractors.length === 0
(contractors?.length ?? 0) === 0
? "Add your first contractor to keep track of service providers."
: "Try adjusting your search or filters."
}
actionLabel={contractors.length === 0 ? "Add Contractor" : undefined}
onAction={contractors.length === 0 ? () => router.push("/app/contractors/new") : undefined}
onAction={contractors.length === 0 ? () => router.push(`${basePath}/contractors/new`) : undefined}
/>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
+9 -2
View File
@@ -2,12 +2,14 @@
import { use } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { PageHeader } from "@/components/shared/page-header";
import { LoadingSkeleton } from "@/components/shared/loading-skeleton";
import { ErrorBanner } from "@/components/shared/error-banner";
import { DocumentForm } from "@/components/documents/document-form";
import { useDocument, useUpdateDocument } from "@/lib/hooks/use-documents";
import { useDataProvider } from "@/lib/demo/data-provider-context";
interface EditDocumentPageProps {
params: Promise<{ id: string }>;
@@ -17,6 +19,7 @@ export default function EditDocumentPage({ params }: EditDocumentPageProps) {
const { id: rawId } = use(params);
const id = Number(rawId);
const router = useRouter();
const { basePath } = useDataProvider();
const { data: document, isLoading, error, refetch } = useDocument(id);
const updateDocument = useUpdateDocument(id);
@@ -41,7 +44,7 @@ export default function EditDocumentPage({ params }: EditDocumentPageProps) {
}
return (
<div className="space-y-6">
<div className="space-y-6 w-full max-w-2xl mx-auto">
<PageHeader
title="Edit Document"
description={document.title}
@@ -53,7 +56,11 @@ export default function EditDocumentPage({ params }: EditDocumentPageProps) {
onSubmit={(data) => {
updateDocument.mutate(data, {
onSuccess: () => {
router.push(`/app/documents/${id}`);
toast.success("Document updated");
router.push(`${basePath}/documents/${id}`);
},
onError: () => {
toast.error("Failed to update document");
},
});
}}
+9 -2
View File
@@ -2,6 +2,7 @@
import { use, useState } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import {
Pencil,
Trash2,
@@ -20,6 +21,7 @@ import { ConfirmDialog } from "@/components/shared/confirm-dialog";
import { WarrantyStatus } from "@/components/documents/warranty-status";
import { ImageGallery } from "@/components/documents/image-gallery";
import { useDocument, useDeleteDocument } from "@/lib/hooks/use-documents";
import { useDataProvider } from "@/lib/demo/data-provider-context";
const typeLabels: Record<string, string> = {
general: "General",
@@ -38,6 +40,7 @@ export default function DocumentDetailPage({ params }: DocumentDetailPageProps)
const { id: rawId } = use(params);
const id = Number(rawId);
const router = useRouter();
const { basePath } = useDataProvider();
const { data: document, isLoading, error, refetch } = useDocument(id);
const deleteDocument = useDeleteDocument();
@@ -96,7 +99,7 @@ export default function DocumentDetailPage({ params }: DocumentDetailPageProps)
<Button
variant="outline"
size="sm"
onClick={() => router.push(`/app/documents/${id}/edit`)}
onClick={() => router.push(`${basePath}/documents/${id}/edit`)}
>
<Pencil className="size-4 mr-2" />
Edit
@@ -229,7 +232,11 @@ export default function DocumentDetailPage({ params }: DocumentDetailPageProps)
onConfirm={() => {
deleteDocument.mutate(id, {
onSuccess: () => {
router.push("/app/documents");
toast.success("Document deleted");
router.push(`${basePath}/documents`);
},
onError: () => {
toast.error("Failed to delete document");
},
});
}}
+9 -2
View File
@@ -1,17 +1,20 @@
"use client";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { PageHeader } from "@/components/shared/page-header";
import { DocumentForm } from "@/components/documents/document-form";
import { useCreateDocument } from "@/lib/hooks/use-documents";
import { useDataProvider } from "@/lib/demo/data-provider-context";
export default function NewDocumentPage() {
const router = useRouter();
const { basePath } = useDataProvider();
const createDocument = useCreateDocument();
return (
<div className="space-y-6">
<div className="space-y-6 w-full max-w-2xl mx-auto">
<PageHeader title="New Document" description="Add a new document" />
<DocumentForm
@@ -21,7 +24,11 @@ export default function NewDocumentPage() {
{ data, file },
{
onSuccess: (res) => {
router.push(`/app/documents/${res.id}`);
toast.success("Document created");
router.push(`${basePath}/documents/${res.id}`);
},
onError: () => {
toast.error("Failed to create document");
},
},
);
+8 -6
View File
@@ -10,9 +10,11 @@ import { ErrorBanner } from "@/components/shared/error-banner";
import { EmptyState } from "@/components/shared/empty-state";
import { DocumentCard } from "@/components/documents/document-card";
import { useDocuments, useWarranties } from "@/lib/hooks/use-documents";
import { useDataProvider } from "@/lib/demo/data-provider-context";
export default function DocumentsPage() {
const router = useRouter();
const { basePath } = useDataProvider();
const {
data: documents,
isLoading: documentsLoading,
@@ -32,7 +34,7 @@ export default function DocumentsPage() {
title="Documents"
description="Manage your property documents and warranties"
actionLabel="Add Document"
onAction={() => router.push("/app/documents/new")}
onAction={() => router.push(`${basePath}/documents/new`)}
/>
<Tabs defaultValue="documents">
@@ -53,20 +55,20 @@ export default function DocumentsPage() {
{!documentsLoading &&
!documentsError &&
documents &&
Array.isArray(documents) &&
documents.length === 0 && (
<EmptyState
icon={FileText}
title="No documents yet"
description="Add your first document to start organizing your property records."
actionLabel="Add Document"
onAction={() => router.push("/app/documents/new")}
onAction={() => router.push(`${basePath}/documents/new`)}
/>
)}
{!documentsLoading &&
!documentsError &&
documents &&
Array.isArray(documents) &&
documents.length > 0 && (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{documents.map((doc) => (
@@ -88,7 +90,7 @@ export default function DocumentsPage() {
{!warrantiesLoading &&
!warrantiesError &&
warranties &&
Array.isArray(warranties) &&
warranties.length === 0 && (
<EmptyState
icon={FileText}
@@ -99,7 +101,7 @@ export default function DocumentsPage() {
{!warrantiesLoading &&
!warrantiesError &&
warranties &&
Array.isArray(warranties) &&
warranties.length > 0 && (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{warranties.map((doc) => (
+17 -13
View File
@@ -3,23 +3,27 @@
import { Sidebar } from '@/components/layout/sidebar';
import { TopBar } from '@/components/layout/top-bar';
import { MobileNav } from '@/components/layout/mobile-nav';
import { DataProviderProvider } from '@/lib/demo/data-provider-context';
import { realProvider } from '@/lib/demo/real-provider';
export default function AppLayout({ children }: { children: React.ReactNode }) {
return (
<div className="min-h-screen bg-background">
{/* Sidebar - hidden on mobile */}
<Sidebar />
<DataProviderProvider value={realProvider}>
<div className="min-h-screen bg-background">
{/* Sidebar - hidden on mobile */}
<Sidebar />
{/* Main content area */}
<div className="md:ml-16 lg:ml-64 flex flex-col min-h-screen">
<TopBar />
<main className="flex-1 p-4 lg:p-6 pb-20 md:pb-6">
{children}
</main>
{/* Main content area */}
<div className="md:ml-16 lg:ml-64 flex flex-col min-h-screen">
<TopBar />
<main className="flex-1 p-4 lg:p-6 pb-20 md:pb-6">
{children}
</main>
</div>
{/* Mobile bottom nav */}
<MobileNav />
</div>
{/* Mobile bottom nav */}
<MobileNav />
</div>
</DataProviderProvider>
);
}
+19 -5
View File
@@ -1,24 +1,38 @@
"use client";
import dynamic from "next/dynamic";
import { useResidences } from "@/lib/hooks/use-residences";
import { useAuthStore } from "@/stores/auth";
import { LoadingSkeleton } from "@/components/shared/loading-skeleton";
import { StatsCards } from "@/components/dashboard/stats-cards";
import { TaskCompletionChart } from "@/components/dashboard/task-completion-chart";
import { RecentActivity } from "@/components/dashboard/recent-activity";
import { Skeleton } from "@/components/ui/skeleton";
const TaskCompletionChart = dynamic(
() => import("@/components/dashboard/task-completion-chart").then((mod) => ({ default: mod.TaskCompletionChart })),
{
loading: () => (
<div className="rounded-lg border p-6 space-y-4">
<Skeleton className="h-5 w-40" />
<Skeleton className="h-[300px] w-full" />
</div>
),
}
);
export default function DashboardPage() {
const { data: residences, isLoading } = useResidences();
const user = useAuthStore((s) => s.user);
const list = Array.isArray(residences) ? residences : [];
const totalOverdue =
residences?.reduce((sum, r) => sum + r.task_summary.overdue, 0) ?? 0;
list.reduce((sum, r) => sum + (r.task_summary?.overdue ?? 0), 0);
const totalDueSoon =
residences?.reduce((sum, r) => sum + r.task_summary.due_soon, 0) ?? 0;
list.reduce((sum, r) => sum + (r.task_summary?.due_soon ?? 0), 0);
const totalActive =
residences?.reduce((sum, r) => sum + r.task_summary.in_progress, 0) ?? 0;
list.reduce((sum, r) => sum + (r.task_summary?.in_progress ?? 0), 0);
const totalCompleted =
residences?.reduce((sum, r) => sum + r.task_summary.completed, 0) ?? 0;
list.reduce((sum, r) => sum + (r.task_summary?.completed ?? 0), 0);
return (
<div className="space-y-8">
+9 -2
View File
@@ -2,12 +2,14 @@
import { use } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { PageHeader } from "@/components/shared/page-header";
import { LoadingSkeleton } from "@/components/shared/loading-skeleton";
import { ErrorBanner } from "@/components/shared/error-banner";
import { ResidenceForm } from "@/components/residences/residence-form";
import { useResidence, useUpdateResidence } from "@/lib/hooks/use-residences";
import { useDataProvider } from "@/lib/demo/data-provider-context";
interface EditResidencePageProps {
params: Promise<{ id: string }>;
@@ -17,6 +19,7 @@ export default function EditResidencePage({ params }: EditResidencePageProps) {
const { id: rawId } = use(params);
const id = Number(rawId);
const router = useRouter();
const { basePath } = useDataProvider();
const { data: residence, isLoading, error, refetch } = useResidence(id);
const updateResidence = useUpdateResidence(id);
@@ -41,7 +44,7 @@ export default function EditResidencePage({ params }: EditResidencePageProps) {
}
return (
<div className="space-y-6">
<div className="space-y-6 w-full max-w-2xl mx-auto">
<PageHeader
title="Edit Residence"
description={residence.name}
@@ -53,7 +56,11 @@ export default function EditResidencePage({ params }: EditResidencePageProps) {
onSubmit={(data) => {
updateResidence.mutate(data, {
onSuccess: () => {
router.push(`/app/residences/${id}`);
toast.success("Residence updated");
router.push(`${basePath}/residences/${id}`);
},
onError: () => {
toast.error("Failed to update residence");
},
});
}}
+17 -7
View File
@@ -3,7 +3,8 @@
import { use, useState } from "react";
import { useRouter } from "next/navigation";
import { MapPin, Pencil, Share2, Trash2, FileDown } from "lucide-react";
import * as residencesApi from "@/lib/api/residences";
import { toast } from "sonner";
import { useDataProvider } from "@/lib/demo/data-provider-context";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -22,6 +23,7 @@ export default function ResidenceDetailPage({ params }: ResidenceDetailPageProps
const { id: rawId } = use(params);
const id = Number(rawId);
const router = useRouter();
const { basePath, sharing } = useDataProvider();
const { data: residence, isLoading, error, refetch } = useResidence(id);
const { data: residences } = useResidences();
@@ -35,17 +37,21 @@ export default function ResidenceDetailPage({ params }: ResidenceDetailPageProps
setReportLoading(true);
setReportMessage(null);
try {
const result = await residencesApi.generateTasksReport(id);
setReportMessage(result.message || "Report sent to your email!");
const result = await sharing.generateTasksReport(id);
const msg = result.message || "Report sent to your email!";
setReportMessage(msg);
toast.success(msg);
} catch {
setReportMessage("Failed to generate report.");
toast.error("Failed to generate report");
} finally {
setReportLoading(false);
}
};
// Find the task summary from the residences list
const myResidence = residences?.find((r) => r.residence.id === id);
const resList = Array.isArray(residences) ? residences : [];
const myResidence = resList.find((r) => r.residence.id === id);
const taskSummary = myResidence?.task_summary;
if (isLoading) {
@@ -93,7 +99,7 @@ export default function ResidenceDetailPage({ params }: ResidenceDetailPageProps
<Button
variant="outline"
size="sm"
onClick={() => router.push(`/app/residences/${id}/share`)}
onClick={() => router.push(`${basePath}/residences/${id}/share`)}
>
<Share2 className="size-4 mr-2" />
Share
@@ -111,7 +117,7 @@ export default function ResidenceDetailPage({ params }: ResidenceDetailPageProps
<Button
variant="outline"
size="sm"
onClick={() => router.push(`/app/residences/${id}/edit`)}
onClick={() => router.push(`${basePath}/residences/${id}/edit`)}
>
<Pencil className="size-4 mr-2" />
Edit
@@ -193,7 +199,11 @@ export default function ResidenceDetailPage({ params }: ResidenceDetailPageProps
onConfirm={() => {
deleteResidence.mutate(id, {
onSuccess: () => {
router.push("/app/residences");
toast.success("Residence deleted");
router.push(`${basePath}/residences`);
},
onError: () => {
toast.error("Failed to delete residence");
},
});
}}
+3 -1
View File
@@ -11,6 +11,7 @@ import { ShareCodeDisplay } from "@/components/sharing/share-code-display";
import { UserManagement } from "@/components/sharing/user-management";
import { CaseraFileExport } from "@/components/sharing/casera-file-handler";
import { useResidence } from "@/lib/hooks/use-residences";
import { useDataProvider } from "@/lib/demo/data-provider-context";
interface SharePageProps {
params: Promise<{ id: string }>;
@@ -20,6 +21,7 @@ export default function ResidenceSharePage({ params }: SharePageProps) {
const { id: rawId } = use(params);
const id = Number(rawId);
const router = useRouter();
const { basePath } = useDataProvider();
const { data: residence, isLoading, error, refetch } = useResidence(id);
@@ -73,7 +75,7 @@ export default function ResidenceSharePage({ params }: SharePageProps) {
<Button
variant="ghost"
size="sm"
onClick={() => router.push(`/app/residences/${id}`)}
onClick={() => router.push(`${basePath}/residences/${id}`)}
>
<ArrowLeft className="size-4 mr-2" />
Back
+13 -2
View File
@@ -2,6 +2,7 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { Home } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
@@ -12,9 +13,11 @@ import { PageHeader } from "@/components/shared/page-header";
import { ErrorBanner } from "@/components/shared/error-banner";
import { CaseraFileImport } from "@/components/sharing/casera-file-handler";
import { useJoinResidence } from "@/lib/hooks/use-sharing";
import { useDataProvider } from "@/lib/demo/data-provider-context";
export default function JoinResidencePage() {
const router = useRouter();
const { basePath } = useDataProvider();
const joinResidence = useJoinResidence();
const [code, setCode] = useState("");
@@ -27,7 +30,11 @@ export default function JoinResidencePage() {
joinResidence.mutate(trimmed, {
onSuccess: () => {
router.push("/app/residences");
toast.success("Joined residence");
router.push(`${basePath}/residences`);
},
onError: () => {
toast.error("Failed to join residence");
},
});
}
@@ -45,7 +52,11 @@ export default function JoinResidencePage() {
const importedCode = (data as Record<string, unknown>).code as string;
joinResidence.mutate(importedCode, {
onSuccess: () => {
router.push("/app/residences");
toast.success("Joined residence");
router.push(`${basePath}/residences`);
},
onError: () => {
toast.error("Failed to join residence");
},
});
} else {
+9 -2
View File
@@ -1,17 +1,20 @@
"use client";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { PageHeader } from "@/components/shared/page-header";
import { ResidenceForm } from "@/components/residences/residence-form";
import { useCreateResidence } from "@/lib/hooks/use-residences";
import { useDataProvider } from "@/lib/demo/data-provider-context";
export default function NewResidencePage() {
const router = useRouter();
const { basePath } = useDataProvider();
const createResidence = useCreateResidence();
return (
<div className="space-y-6">
<div className="space-y-6 w-full max-w-2xl mx-auto">
<PageHeader title="New Residence" description="Add a new property" />
<ResidenceForm
@@ -19,7 +22,11 @@ export default function NewResidencePage() {
onSubmit={(data) => {
createResidence.mutate(data, {
onSuccess: (res) => {
router.push(`/app/residences/${res.id}`);
toast.success("Residence created");
router.push(`${basePath}/residences/${res.id}`);
},
onError: () => {
toast.error("Failed to create residence");
},
});
}}
+6 -4
View File
@@ -9,9 +9,11 @@ import { ErrorBanner } from "@/components/shared/error-banner";
import { EmptyState } from "@/components/shared/empty-state";
import { ResidenceCard } from "@/components/residences/residence-card";
import { useResidences } from "@/lib/hooks/use-residences";
import { useDataProvider } from "@/lib/demo/data-provider-context";
export default function ResidencesPage() {
const router = useRouter();
const { basePath } = useDataProvider();
const { data: residences, isLoading, error, refetch } = useResidences();
return (
@@ -20,7 +22,7 @@ export default function ResidencesPage() {
title="Residences"
description="Manage your properties"
actionLabel="Add Residence"
onAction={() => router.push("/app/residences/new")}
onAction={() => router.push(`${basePath}/residences/new`)}
/>
{isLoading && <LoadingSkeleton variant="card-grid" />}
@@ -32,17 +34,17 @@ export default function ResidencesPage() {
/>
)}
{!isLoading && !error && residences && residences.length === 0 && (
{!isLoading && !error && Array.isArray(residences) && residences.length === 0 && (
<EmptyState
icon={Home}
title="No residences yet"
description="Add your first property to start tracking tasks and maintenance."
actionLabel="Add Residence"
onAction={() => router.push("/app/residences/new")}
onAction={() => router.push(`${basePath}/residences/new`)}
/>
)}
{!isLoading && !error && residences && residences.length > 0 && (
{!isLoading && !error && Array.isArray(residences) && residences.length > 0 && (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{residences.map((item) => (
<ResidenceCard key={item.residence.id} data={item} />
+10 -5
View File
@@ -4,15 +4,20 @@ import Link from "next/link";
import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
import { User, Bell, CreditCard } from "lucide-react";
import { useDataProvider } from "@/lib/demo/data-provider-context";
const settingsNav = [
{ label: "Profile", href: "/app/settings/profile", icon: User },
{ label: "Notifications", href: "/app/settings/notifications", icon: Bell },
{ label: "Subscription", href: "/app/settings/subscription", icon: CreditCard },
];
function getSettingsNav(basePath: string) {
return [
{ label: "Profile", href: `${basePath}/settings/profile`, icon: User },
{ label: "Notifications", href: `${basePath}/settings/notifications`, icon: Bell },
{ label: "Subscription", href: `${basePath}/settings/subscription`, icon: CreditCard },
];
}
export default function SettingsLayout({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const { basePath } = useDataProvider();
const settingsNav = getSettingsNav(basePath);
return (
<div className="space-y-6">
+13 -2
View File
@@ -1,5 +1,16 @@
import { redirect } from "next/navigation";
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { useDataProvider } from "@/lib/demo/data-provider-context";
export default function SettingsPage() {
redirect("/app/settings/profile");
const router = useRouter();
const { basePath } = useDataProvider();
useEffect(() => {
router.replace(`${basePath}/settings/profile`);
}, [router, basePath]);
return null;
}
+10 -1
View File
@@ -2,12 +2,14 @@
import { use } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { PageHeader } from "@/components/shared/page-header";
import { LoadingSkeleton } from "@/components/shared/loading-skeleton";
import { ErrorBanner } from "@/components/shared/error-banner";
import { Card, CardContent } from "@/components/ui/card";
import { TaskCompletionForm } from "@/components/tasks/task-completion-form";
import { useTask, useCreateCompletion } from "@/lib/hooks/use-tasks";
import { useDataProvider } from "@/lib/demo/data-provider-context";
export default function CompleteTaskPage({
params,
@@ -17,6 +19,7 @@ export default function CompleteTaskPage({
const { id } = use(params);
const taskId = Number(id);
const router = useRouter();
const { basePath } = useDataProvider();
const { data: task, isLoading, isError, error, refetch } = useTask(taskId);
const createCompletion = useCreateCompletion();
@@ -61,7 +64,13 @@ export default function CompleteTaskPage({
images,
},
{
onSuccess: () => router.push(`/app/tasks/${taskId}`),
onSuccess: () => {
toast.success("Task completed");
router.push(`${basePath}/tasks/${taskId}`);
},
onError: () => {
toast.error("Failed to complete task");
},
},
);
}}
+11 -2
View File
@@ -2,12 +2,14 @@
import { use } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { PageHeader } from "@/components/shared/page-header";
import { LoadingSkeleton } from "@/components/shared/loading-skeleton";
import { ErrorBanner } from "@/components/shared/error-banner";
import { Card, CardContent } from "@/components/ui/card";
import { TaskForm } from "@/components/tasks/task-form";
import { useTask, useUpdateTask } from "@/lib/hooks/use-tasks";
import { useDataProvider } from "@/lib/demo/data-provider-context";
export default function EditTaskPage({
params,
@@ -17,6 +19,7 @@ export default function EditTaskPage({
const { id } = use(params);
const taskId = Number(id);
const router = useRouter();
const { basePath } = useDataProvider();
const { data: task, isLoading, isError, error, refetch } = useTask(taskId);
const updateTask = useUpdateTask(taskId);
@@ -39,7 +42,7 @@ export default function EditTaskPage({
if (!task) return null;
return (
<div className="space-y-6">
<div className="space-y-6 w-full max-w-2xl mx-auto">
<PageHeader title="Edit Task" />
<Card>
@@ -48,7 +51,13 @@ export default function EditTaskPage({
task={task}
onSubmit={(data) => {
updateTask.mutate(data, {
onSuccess: () => router.push(`/app/tasks/${taskId}`),
onSuccess: () => {
toast.success("Task updated");
router.push(`${basePath}/tasks/${taskId}`);
},
onError: () => {
toast.error("Failed to update task");
},
});
}}
isSubmitting={updateTask.isPending}
+4 -1
View File
@@ -1,6 +1,7 @@
"use client";
import { use } from "react";
import Image from "next/image";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { PageHeader } from "@/components/shared/page-header";
@@ -233,10 +234,12 @@ export default function TaskDetailPage({
{completion.images.length > 0 && (
<div className="flex gap-2 flex-wrap">
{completion.images.map((img) => (
<img
<Image
key={img.id}
src={img.image_url}
alt={img.caption || "Completion photo"}
width={80}
height={80}
className="size-20 rounded-md object-cover border"
/>
))}
+11 -2
View File
@@ -1,17 +1,20 @@
"use client";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { PageHeader } from "@/components/shared/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { TaskForm } from "@/components/tasks/task-form";
import { useCreateTask } from "@/lib/hooks/use-tasks";
import { useDataProvider } from "@/lib/demo/data-provider-context";
export default function NewTaskPage() {
const router = useRouter();
const { basePath } = useDataProvider();
const createTask = useCreateTask();
return (
<div className="space-y-6">
<div className="space-y-6 w-full max-w-2xl mx-auto">
<PageHeader title="New Task" />
<Card>
@@ -19,7 +22,13 @@ export default function NewTaskPage() {
<TaskForm
onSubmit={(data) => {
createTask.mutate(data, {
onSuccess: () => router.push("/app/tasks"),
onSuccess: () => {
toast.success("Task created");
router.push(`${basePath}/tasks`);
},
onError: () => {
toast.error("Failed to create task");
},
});
}}
isSubmitting={createTask.isPending}
+11 -4
View File
@@ -2,18 +2,25 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import dynamic from "next/dynamic";
import { ClipboardList } from "lucide-react";
import { PageHeader } from "@/components/shared/page-header";
import { LoadingSkeleton } from "@/components/shared/loading-skeleton";
import { ErrorBanner } from "@/components/shared/error-banner";
import { EmptyState } from "@/components/shared/empty-state";
import { LookupSelect } from "@/components/shared/lookup-select";
import { KanbanBoard } from "@/components/tasks/kanban-board";
import { useTasks, useTasksByResidence } from "@/lib/hooks/use-tasks";
const KanbanBoard = dynamic(
() => import("@/components/tasks/kanban-board").then((mod) => ({ default: mod.KanbanBoard })),
{ loading: () => <LoadingSkeleton variant="kanban" /> }
);
import { useResidences } from "@/lib/hooks/use-residences";
import { useDataProvider } from "@/lib/demo/data-provider-context";
export default function TasksPage() {
const router = useRouter();
const { basePath } = useDataProvider();
const [selectedResidenceId, setSelectedResidenceId] = useState<
number | undefined
>();
@@ -25,7 +32,7 @@ export default function TasksPage() {
const activeQuery = selectedResidenceId ? filteredTasks : allTasks;
const { data, isLoading, isError, error, refetch } = activeQuery;
const residenceItems = (residences ?? []).map((r) => ({
const residenceItems = (Array.isArray(residences) ? residences : []).map((r) => ({
id: r.residence.id,
name: r.residence.name,
}));
@@ -39,7 +46,7 @@ export default function TasksPage() {
title="Tasks"
description="Manage your home maintenance tasks"
actionLabel="New Task"
onAction={() => router.push("/app/tasks/new")}
onAction={() => router.push(`${basePath}/tasks/new`)}
>
{residenceItems.length > 1 && (
<LookupSelect
@@ -68,7 +75,7 @@ export default function TasksPage() {
title="No tasks yet"
description="Create your first task to start tracking home maintenance."
actionLabel="New Task"
onAction={() => router.push("/app/tasks/new")}
onAction={() => router.push(`${basePath}/tasks/new`)}
/>
)}
@@ -0,0 +1,2 @@
"use client";
export { default } from "@/app/app/contractors/[id]/edit/page";
@@ -0,0 +1,2 @@
"use client";
export { default } from "@/app/app/contractors/[id]/page";
@@ -0,0 +1,2 @@
"use client";
export { default } from "@/app/app/contractors/new/page";
+2
View File
@@ -0,0 +1,2 @@
"use client";
export { default } from "@/app/app/contractors/page";
@@ -0,0 +1,2 @@
"use client";
export { default } from "@/app/app/documents/[id]/edit/page";
+2
View File
@@ -0,0 +1,2 @@
"use client";
export { default } from "@/app/app/documents/[id]/page";
+2
View File
@@ -0,0 +1,2 @@
"use client";
export { default } from "@/app/app/documents/new/page";
+2
View File
@@ -0,0 +1,2 @@
"use client";
export { default } from "@/app/app/documents/page";
+32
View File
@@ -0,0 +1,32 @@
"use client";
import { Sidebar } from '@/components/layout/sidebar';
import { TopBar } from '@/components/layout/top-bar';
import { MobileNav } from '@/components/layout/mobile-nav';
import { DemoBanner } from '@/components/demo/demo-banner';
import { DataProviderProvider } from '@/lib/demo/data-provider-context';
import { demoProvider } from '@/lib/demo/demo-provider';
export default function DemoAppLayout({ children }: { children: React.ReactNode }) {
return (
<DataProviderProvider value={demoProvider}>
<div className="min-h-screen bg-background">
<DemoBanner />
{/* Sidebar - hidden on mobile */}
<Sidebar />
{/* Main content area */}
<div className="md:ml-16 lg:ml-64 flex flex-col min-h-screen">
<TopBar />
<main className="flex-1 p-4 lg:p-6 pb-20 md:pb-6">
{children}
</main>
</div>
{/* Mobile bottom nav */}
<MobileNav />
</div>
</DataProviderProvider>
);
}
+2
View File
@@ -0,0 +1,2 @@
"use client";
export { default } from "@/app/app/page";
@@ -0,0 +1,2 @@
"use client";
export { default } from "@/app/app/residences/[id]/edit/page";
@@ -0,0 +1,2 @@
"use client";
export { default } from "@/app/app/residences/[id]/page";
@@ -0,0 +1,2 @@
"use client";
export { default } from "@/app/app/residences/[id]/share/page";
@@ -0,0 +1,2 @@
"use client";
export { default } from "@/app/app/residences/join/page";
+2
View File
@@ -0,0 +1,2 @@
"use client";
export { default } from "@/app/app/residences/new/page";
+2
View File
@@ -0,0 +1,2 @@
"use client";
export { default } from "@/app/app/residences/page";
+2
View File
@@ -0,0 +1,2 @@
"use client";
export { default } from "@/app/app/settings/layout";
@@ -0,0 +1,2 @@
"use client";
export { default } from "@/app/app/settings/notifications/page";
+2
View File
@@ -0,0 +1,2 @@
"use client";
export { default } from "@/app/app/settings/page";
@@ -0,0 +1,2 @@
"use client";
export { default } from "@/app/app/settings/profile/page";
@@ -0,0 +1,2 @@
"use client";
export { default } from "@/app/app/settings/subscription/page";
@@ -0,0 +1,2 @@
"use client";
export { default } from "@/app/app/tasks/[id]/complete/page";
@@ -0,0 +1,2 @@
"use client";
export { default } from "@/app/app/tasks/[id]/edit/page";
+2
View File
@@ -0,0 +1,2 @@
"use client";
export { default } from "@/app/app/tasks/[id]/page";
+2
View File
@@ -0,0 +1,2 @@
"use client";
export { default } from "@/app/app/tasks/new/page";
+2
View File
@@ -0,0 +1,2 @@
"use client";
export { default } from "@/app/app/tasks/page";
+21
View File
@@ -0,0 +1,21 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Try Casera — Free Demo",
description:
"Try Casera without an account. Manage tasks, contractors, and documents in a live demo.",
openGraph: {
title: "Try Casera — Free Demo",
description:
"Try Casera without an account. Manage tasks, contractors, and documents in a live demo.",
type: "website",
},
};
export default function DemoLayout({
children,
}: {
children: React.ReactNode;
}) {
return children;
}
+41
View File
@@ -0,0 +1,41 @@
"use client";
import Link from "next/link";
import { Button } from "@/components/ui/button";
export default function DemoLandingPage() {
return (
<div className="flex min-h-screen flex-col items-center justify-center bg-background px-4">
<div className="mx-auto max-w-md text-center">
{/* Logo */}
<h1 className="mb-8 text-2xl font-bold tracking-tight text-primary">
Casera
</h1>
{/* Hero */}
<h2 className="text-3xl font-bold tracking-tight">
Try Casera &mdash; No Account Needed
</h2>
<p className="mt-3 text-muted-foreground">
Manage your home maintenance, track tasks, organize contractors, and
store documents.
</p>
{/* Actions */}
<div className="mt-8 flex flex-col gap-3">
<Button size="lg" asChild>
<Link href="/demo/app">Start Demo</Link>
</Button>
</div>
{/* Login link */}
<p className="mt-6 text-sm text-muted-foreground">
Already have an account?{" "}
<Link href="/login" className="text-primary hover:underline">
Log In
</Link>
</p>
</div>
</div>
);
}
+27
View File
@@ -0,0 +1,27 @@
"use client";
import { Button } from "@/components/ui/button";
import { AlertTriangle } from "lucide-react";
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<div className="flex flex-col items-center justify-center min-h-[400px] px-4">
<div className="rounded-full bg-destructive/10 p-4 mb-4">
<AlertTriangle className="size-10 text-destructive" />
</div>
<h2 className="text-lg font-semibold">Something went wrong</h2>
<p className="mt-2 text-muted-foreground text-center max-w-md">
{error.message || "An unexpected error occurred. Please try again."}
</p>
<Button onClick={reset} className="mt-6">
Try Again
</Button>
</div>
);
}
+31 -5
View File
@@ -1,7 +1,10 @@
import type { Metadata } from "next";
import { Suspense } from "react";
import { Geist, Geist_Mono } from "next/font/google";
import { ThemeProvider } from "@/lib/themes/theme-provider";
import { QueryProvider } from "@/lib/query/query-provider";
import { PostHogProvider } from "@/lib/analytics/posthog-provider";
import { Toaster } from "@/components/ui/sonner";
import "./globals.css";
const geistSans = Geist({
@@ -15,8 +18,24 @@ const geistMono = Geist_Mono({
});
export const metadata: Metadata = {
title: "Casera",
description: "Property management platform",
title: {
default: "Casera — Home Maintenance Made Simple",
template: "%s | Casera",
},
description:
"Track tasks, organize contractors, store documents. Manage your home maintenance in one place.",
openGraph: {
title: "Casera — Home Maintenance Made Simple",
description:
"Track tasks, organize contractors, store documents. Manage your home maintenance in one place.",
type: "website",
siteName: "Casera",
},
twitter: {
card: "summary_large_image",
title: "Casera — Home Maintenance Made Simple",
description: "Home Maintenance Made Simple",
},
};
export default function RootLayout({
@@ -29,9 +48,16 @@ export default function RootLayout({
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<QueryProvider>
<ThemeProvider>{children}</ThemeProvider>
</QueryProvider>
<Suspense fallback={null}>
<PostHogProvider>
<QueryProvider>
<ThemeProvider>
{children}
<Toaster richColors closeButton />
</ThemeProvider>
</QueryProvider>
</PostHogProvider>
</Suspense>
</body>
</html>
);
+20
View File
@@ -0,0 +1,20 @@
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { FileQuestion } from "lucide-react";
export default function NotFound() {
return (
<div className="flex flex-col items-center justify-center min-h-screen px-4">
<div className="rounded-full bg-muted p-4 mb-4">
<FileQuestion className="size-10 text-muted-foreground" />
</div>
<h2 className="text-lg font-semibold">Page not found</h2>
<p className="mt-2 text-muted-foreground text-center max-w-md">
The page you&apos;re looking for doesn&apos;t exist or has been moved.
</p>
<Button asChild className="mt-6">
<Link href="/app/residences">Go Home</Link>
</Button>
</div>
);
}