feat: redesign app UI — top nav, clean dashboard, warm branding
- Replace sidebar with top navigation bar (like Airbnb/Nextdoor) - Redesign dashboard: home cards, coming up tasks, quick action pills - Remove widget-heavy layout (charts, stats, activity feed) - Add landing page with hero, features, how-it-works, CTA sections - Update auth pages with split layout - Clean white theme with neutral grays, brand orange/teal accents - Friendly copy across all empty states and page headers - Add Bricolage Grotesque + Outfit fonts - Default to light mode Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 464 KiB |
@@ -1,10 +1,63 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
export default function AuthLayout({ children }: { children: React.ReactNode }) {
|
export default function AuthLayout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-background p-4">
|
<div className="min-h-screen flex bg-[#FAFAF7]">
|
||||||
<div className="w-full max-w-md">
|
{/* Left brand panel — hidden on mobile */}
|
||||||
{children}
|
<div className="hidden lg:flex lg:w-[480px] xl:w-[540px] relative flex-col justify-between bg-[#1C1917] p-10 overflow-hidden">
|
||||||
|
{/* Decorative blurs */}
|
||||||
|
<div className="absolute top-0 right-0 w-80 h-80 rounded-full bg-[#E07A3A]/15 blur-[100px] pointer-events-none" />
|
||||||
|
<div className="absolute bottom-0 left-0 w-64 h-64 rounded-full bg-[#0D7C66]/10 blur-[80px] pointer-events-none" />
|
||||||
|
|
||||||
|
{/* Subtle grid */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 opacity-[0.03]"
|
||||||
|
style={{
|
||||||
|
backgroundImage:
|
||||||
|
"linear-gradient(rgba(255,255,255,0.5) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,0.5) 1px, transparent 1px)",
|
||||||
|
backgroundSize: "48px 48px",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<Link href="/" className="flex items-center gap-2.5">
|
||||||
|
<Image
|
||||||
|
src="/logo.png"
|
||||||
|
alt="Casera"
|
||||||
|
width={36}
|
||||||
|
height={36}
|
||||||
|
className="rounded-lg"
|
||||||
|
/>
|
||||||
|
<span className="font-heading text-xl font-bold text-white">
|
||||||
|
Casera
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<h2 className="font-heading text-3xl font-bold text-white leading-snug mb-4">
|
||||||
|
Home maintenance,<br />
|
||||||
|
<span className="text-[#E07A3A]">simplified.</span>
|
||||||
|
</h2>
|
||||||
|
<p className="text-[#A8A29E] leading-relaxed max-w-sm">
|
||||||
|
Track tasks, organize contractors, and store important
|
||||||
|
documents — all in one place built for homeowners.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="relative text-xs text-[#78716C]">
|
||||||
|
© {new Date().getFullYear()} Casera
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right form area */}
|
||||||
|
<div className="flex-1 flex items-center justify-center p-6 sm:p-8">
|
||||||
|
<div className="w-full max-w-md">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -104,9 +104,9 @@ export default function ContractorsPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Contractors"
|
title="Your Pros"
|
||||||
description="Manage your trusted contractors and service providers"
|
description="The people you trust to get the job done"
|
||||||
actionLabel="Add Contractor"
|
actionLabel="Add a Pro"
|
||||||
onAction={() => router.push(`${basePath}/contractors/new`)}
|
onAction={() => router.push(`${basePath}/contractors/new`)}
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
@@ -145,10 +145,10 @@ export default function ContractorsPage() {
|
|||||||
{filtered.length === 0 ? (
|
{filtered.length === 0 ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={Wrench}
|
icon={Wrench}
|
||||||
title="No contractors found"
|
title="No pros saved yet"
|
||||||
description={
|
description={
|
||||||
(contractors?.length ?? 0) === 0
|
(contractors?.length ?? 0) === 0
|
||||||
? "Add your first contractor to keep track of service providers."
|
? "Save your go-to plumber, electrician, or handyman so you always know who to call."
|
||||||
: "Try adjusting your search or filters."
|
: "Try adjusting your search or filters."
|
||||||
}
|
}
|
||||||
actionLabel={contractors.length === 0 ? "Add Contractor" : undefined}
|
actionLabel={contractors.length === 0 ? "Add Contractor" : undefined}
|
||||||
|
|||||||
@@ -32,8 +32,8 @@ export default function DocumentsPage() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Documents"
|
title="Documents"
|
||||||
description="Manage your property documents and warranties"
|
description="Warranties, manuals, receipts — all in one place"
|
||||||
actionLabel="Add Document"
|
actionLabel="Save a Document"
|
||||||
onAction={() => router.push(`${basePath}/documents/new`)}
|
onAction={() => router.push(`${basePath}/documents/new`)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -59,9 +59,9 @@ export default function DocumentsPage() {
|
|||||||
documents.length === 0 && (
|
documents.length === 0 && (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={FileText}
|
icon={FileText}
|
||||||
title="No documents yet"
|
title="No documents saved yet"
|
||||||
description="Add your first document to start organizing your property records."
|
description="Store warranties, manuals, receipts, and more — so they're easy to find when you need them."
|
||||||
actionLabel="Add Document"
|
actionLabel="Save a Document"
|
||||||
onAction={() => router.push(`${basePath}/documents/new`)}
|
onAction={() => router.push(`${basePath}/documents/new`)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -94,8 +94,8 @@ export default function DocumentsPage() {
|
|||||||
warranties.length === 0 && (
|
warranties.length === 0 && (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={FileText}
|
icon={FileText}
|
||||||
title="No warranties yet"
|
title="No warranties saved yet"
|
||||||
description="Documents with type 'warranty' will appear here."
|
description="When you save a document as a warranty, it'll show up here for easy access."
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
+4
-11
@@ -1,6 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Sidebar } from '@/components/layout/sidebar';
|
|
||||||
import { TopBar } from '@/components/layout/top-bar';
|
import { TopBar } from '@/components/layout/top-bar';
|
||||||
import { MobileNav } from '@/components/layout/mobile-nav';
|
import { MobileNav } from '@/components/layout/mobile-nav';
|
||||||
import { DataProviderProvider } from '@/lib/demo/data-provider-context';
|
import { DataProviderProvider } from '@/lib/demo/data-provider-context';
|
||||||
@@ -10,18 +9,12 @@ export default function AppLayout({ children }: { children: React.ReactNode }) {
|
|||||||
return (
|
return (
|
||||||
<DataProviderProvider value={realProvider}>
|
<DataProviderProvider value={realProvider}>
|
||||||
<div className="min-h-screen bg-background">
|
<div className="min-h-screen bg-background">
|
||||||
{/* Sidebar - hidden on mobile */}
|
<TopBar />
|
||||||
<Sidebar />
|
|
||||||
|
|
||||||
{/* Main content area */}
|
<main className="max-w-7xl mx-auto px-6 py-8 lg:py-12 pb-28 md:pb-12">
|
||||||
<div className="md:ml-16 lg:ml-64 flex flex-col min-h-screen">
|
{children}
|
||||||
<TopBar />
|
</main>
|
||||||
<main className="flex-1 p-4 lg:p-6 pb-20 md:pb-6">
|
|
||||||
{children}
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Mobile bottom nav */}
|
|
||||||
<MobileNav />
|
<MobileNav />
|
||||||
</div>
|
</div>
|
||||||
</DataProviderProvider>
|
</DataProviderProvider>
|
||||||
|
|||||||
+371
-47
@@ -1,61 +1,385 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import dynamic from "next/dynamic";
|
import Link from "next/link";
|
||||||
|
import {
|
||||||
|
Home,
|
||||||
|
CheckSquare,
|
||||||
|
HardHat,
|
||||||
|
FileText,
|
||||||
|
MapPin,
|
||||||
|
ArrowRight,
|
||||||
|
Plus,
|
||||||
|
CalendarClock,
|
||||||
|
Sparkles,
|
||||||
|
CircleAlert,
|
||||||
|
} from "lucide-react";
|
||||||
import { useResidences } from "@/lib/hooks/use-residences";
|
import { useResidences } from "@/lib/hooks/use-residences";
|
||||||
|
import { useTasks } from "@/lib/hooks/use-tasks";
|
||||||
import { useAuthStore } from "@/stores/auth";
|
import { useAuthStore } from "@/stores/auth";
|
||||||
import { LoadingSkeleton } from "@/components/shared/loading-skeleton";
|
import { useDataProvider } from "@/lib/demo/data-provider-context";
|
||||||
import { StatsCards } from "@/components/dashboard/stats-cards";
|
import { Button } from "@/components/ui/button";
|
||||||
import { RecentActivity } from "@/components/dashboard/recent-activity";
|
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import type { MyResidenceResponse } from "@/lib/api/residences";
|
||||||
|
import type { TaskResponse } from "@/lib/api/tasks";
|
||||||
|
|
||||||
const TaskCompletionChart = dynamic(
|
/* ─── Helpers ─── */
|
||||||
() => 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() {
|
function getTimeGreeting() {
|
||||||
const { data: residences, isLoading } = useResidences();
|
const h = new Date().getHours();
|
||||||
const user = useAuthStore((s) => s.user);
|
if (h < 12) return "Good morning";
|
||||||
|
if (h < 17) return "Good afternoon";
|
||||||
|
return "Good evening";
|
||||||
|
}
|
||||||
|
|
||||||
const list = Array.isArray(residences) ? residences : [];
|
function getRelativeDate(dateStr: string) {
|
||||||
const totalOverdue =
|
const date = new Date(dateStr);
|
||||||
list.reduce((sum, r) => sum + (r.task_summary?.overdue ?? 0), 0);
|
const now = new Date();
|
||||||
const totalDueSoon =
|
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||||
list.reduce((sum, r) => sum + (r.task_summary?.due_soon ?? 0), 0);
|
const target = new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
||||||
const totalActive =
|
const diff = Math.round((target.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
|
||||||
list.reduce((sum, r) => sum + (r.task_summary?.in_progress ?? 0), 0);
|
|
||||||
const totalCompleted =
|
if (diff < 0) return "Overdue";
|
||||||
list.reduce((sum, r) => sum + (r.task_summary?.completed ?? 0), 0);
|
if (diff === 0) return "Today";
|
||||||
|
if (diff === 1) return "Tomorrow";
|
||||||
|
if (diff < 7) return date.toLocaleDateString("en-US", { weekday: "long" });
|
||||||
|
return date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Home Card ─── */
|
||||||
|
|
||||||
|
function HomeCard({
|
||||||
|
data,
|
||||||
|
basePath,
|
||||||
|
}: {
|
||||||
|
data: MyResidenceResponse;
|
||||||
|
basePath: string;
|
||||||
|
}) {
|
||||||
|
const r = data.residence;
|
||||||
|
const overdue = data.task_summary?.overdue ?? 0;
|
||||||
|
const dueSoon = data.task_summary?.due_soon ?? 0;
|
||||||
|
const total = data.task_summary?.total ?? 0;
|
||||||
|
const address = [r.street_address, r.city, r.state_province]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(", ");
|
||||||
|
const isGood = overdue === 0 && dueSoon === 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<Link
|
||||||
<h1 className="text-2xl font-bold tracking-tight">
|
href={`${basePath}/residences/${r.id}`}
|
||||||
{user?.first_name
|
className="group block"
|
||||||
? `Welcome back, ${user.first_name}`
|
>
|
||||||
: "Dashboard"}
|
<div className="rounded-2xl border border-border bg-card p-5 sm:p-6 transition-all duration-200 hover:shadow-lg hover:shadow-black/[0.04] hover:-translate-y-0.5 hover:border-border/80">
|
||||||
</h1>
|
{/* Status badge */}
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div className="size-11 rounded-xl bg-[#FFF3EB] flex items-center justify-center">
|
||||||
|
<Home className="size-5 text-[#E07A3A]" />
|
||||||
|
</div>
|
||||||
|
{overdue > 0 ? (
|
||||||
|
<span className="inline-flex items-center gap-1 text-xs font-medium text-red-600 bg-red-50 dark:text-red-400 dark:bg-red-500/10 px-2.5 py-1 rounded-full">
|
||||||
|
<CircleAlert className="size-3" />
|
||||||
|
{overdue} overdue
|
||||||
|
</span>
|
||||||
|
) : !isGood && dueSoon > 0 ? (
|
||||||
|
<span className="inline-flex items-center gap-1 text-xs font-medium text-amber-600 bg-amber-50 dark:text-amber-400 dark:bg-amber-500/10 px-2.5 py-1 rounded-full">
|
||||||
|
<CalendarClock className="size-3" />
|
||||||
|
{dueSoon} coming up
|
||||||
|
</span>
|
||||||
|
) : isGood && total > 0 ? (
|
||||||
|
<span className="inline-flex items-center gap-1 text-xs font-medium text-[#0D7C66] bg-emerald-50 dark:text-emerald-400 dark:bg-emerald-500/10 px-2.5 py-1 rounded-full">
|
||||||
|
<Sparkles className="size-3" />
|
||||||
|
All good
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
{isLoading ? (
|
{/* Name and address */}
|
||||||
<LoadingSkeleton variant="card-grid" count={4} />
|
<h3 className="font-heading text-lg font-semibold leading-tight group-hover:text-primary transition-colors">
|
||||||
) : (
|
{r.name}
|
||||||
<>
|
</h3>
|
||||||
<StatsCards
|
{address && (
|
||||||
overdue={totalOverdue}
|
<p className="text-sm text-muted-foreground mt-1 flex items-center gap-1.5">
|
||||||
dueSoon={totalDueSoon}
|
<MapPin className="size-3.5 shrink-0" />
|
||||||
active={totalActive}
|
<span className="truncate">{address}</span>
|
||||||
completed={totalCompleted}
|
</p>
|
||||||
/>
|
)}
|
||||||
<TaskCompletionChart data={[]} />
|
|
||||||
<RecentActivity />
|
{/* Quick stats */}
|
||||||
</>
|
<div className="flex items-center gap-4 mt-4 pt-4 border-t border-border/60">
|
||||||
)}
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{total} {total === 1 ? "task" : "tasks"}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground flex items-center gap-1">
|
||||||
|
View home
|
||||||
|
<ArrowRight className="size-3 transition-transform group-hover:translate-x-0.5" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Coming Up (task list) ─── */
|
||||||
|
|
||||||
|
function ComingUp({
|
||||||
|
tasks,
|
||||||
|
basePath,
|
||||||
|
}: {
|
||||||
|
tasks: TaskResponse[];
|
||||||
|
basePath: string;
|
||||||
|
}) {
|
||||||
|
if (tasks.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="font-heading text-lg font-semibold">Coming Up</h2>
|
||||||
|
<Link
|
||||||
|
href={`${basePath}/tasks`}
|
||||||
|
className="text-sm font-medium text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1"
|
||||||
|
>
|
||||||
|
All tasks
|
||||||
|
<ArrowRight className="size-3.5" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
{tasks.map((task) => {
|
||||||
|
const dateLabel = task.next_due_date || task.due_date;
|
||||||
|
const isOverdue =
|
||||||
|
dateLabel && new Date(dateLabel) < new Date() && getRelativeDate(dateLabel) === "Overdue";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={task.id}
|
||||||
|
href={`${basePath}/tasks/${task.id}`}
|
||||||
|
className="flex items-center gap-3 rounded-xl px-3 py-3 -mx-3 hover:bg-accent/50 transition-colors group"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`size-2 rounded-full shrink-0 ${
|
||||||
|
isOverdue
|
||||||
|
? "bg-red-500"
|
||||||
|
: task.in_progress
|
||||||
|
? "bg-[#0D7C66]"
|
||||||
|
: "bg-border"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium leading-snug truncate group-hover:text-primary transition-colors">
|
||||||
|
{task.title}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
{task.residence_name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{dateLabel && (
|
||||||
|
<span
|
||||||
|
className={`text-xs font-medium shrink-0 ${
|
||||||
|
isOverdue ? "text-red-500" : "text-muted-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{getRelativeDate(dateLabel)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Quick Actions (subtle, not prominent) ─── */
|
||||||
|
|
||||||
|
function QuickActions({ basePath }: { basePath: string }) {
|
||||||
|
const actions = [
|
||||||
|
{ label: "Add task", href: `${basePath}/tasks/new`, icon: CheckSquare },
|
||||||
|
{ label: "Add pro", href: `${basePath}/contractors/new`, icon: HardHat },
|
||||||
|
{ label: "Save doc", href: `${basePath}/documents/new`, icon: FileText },
|
||||||
|
{ label: "Add home", href: `${basePath}/residences/new`, icon: Home },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{actions.map((a) => (
|
||||||
|
<Link
|
||||||
|
key={a.href}
|
||||||
|
href={a.href}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-full border border-border bg-card px-3.5 py-2 text-xs font-medium text-muted-foreground hover:text-foreground hover:border-foreground/20 transition-colors"
|
||||||
|
>
|
||||||
|
<a.icon className="size-3.5" />
|
||||||
|
{a.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Loading State ─── */
|
||||||
|
|
||||||
|
function DashboardSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-10">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-8 w-64" />
|
||||||
|
<Skeleton className="h-5 w-48" />
|
||||||
|
</div>
|
||||||
|
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<div key={i} className="rounded-2xl border bg-card p-6 space-y-4">
|
||||||
|
<Skeleton className="size-11 rounded-xl" />
|
||||||
|
<Skeleton className="h-5 w-3/4" />
|
||||||
|
<Skeleton className="h-4 w-1/2" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Dashboard ─── */
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
const { data: residences, isLoading: homesLoading } = useResidences();
|
||||||
|
const { data: kanban, isLoading: tasksLoading } = useTasks();
|
||||||
|
const user = useAuthStore((s) => s.user);
|
||||||
|
const { basePath } = useDataProvider();
|
||||||
|
|
||||||
|
const homes = Array.isArray(residences) ? residences : [];
|
||||||
|
const name = user?.first_name || "";
|
||||||
|
const greeting = `${getTimeGreeting()}${name ? `, ${name}` : ""}`;
|
||||||
|
|
||||||
|
// Flatten all tasks from kanban columns, sort by due date, take upcoming ones
|
||||||
|
const allTasks: TaskResponse[] = kanban?.columns
|
||||||
|
?.flatMap((col) => col.tasks)
|
||||||
|
?.filter((t) => !t.is_cancelled && !t.is_archived) ?? [];
|
||||||
|
|
||||||
|
const upcomingTasks = allTasks
|
||||||
|
.filter((t) => t.next_due_date || t.due_date || t.in_progress)
|
||||||
|
.sort((a, b) => {
|
||||||
|
const dateA = a.next_due_date || a.due_date || "";
|
||||||
|
const dateB = b.next_due_date || b.due_date || "";
|
||||||
|
if (!dateA) return 1;
|
||||||
|
if (!dateB) return -1;
|
||||||
|
return new Date(dateA).getTime() - new Date(dateB).getTime();
|
||||||
|
})
|
||||||
|
.slice(0, 6);
|
||||||
|
|
||||||
|
const totalOverdue = homes.reduce(
|
||||||
|
(sum, r) => sum + (r.task_summary?.overdue ?? 0), 0
|
||||||
|
);
|
||||||
|
|
||||||
|
// Status line under greeting
|
||||||
|
const statusMsg = homes.length === 0
|
||||||
|
? ""
|
||||||
|
: totalOverdue > 0
|
||||||
|
? totalOverdue === 1
|
||||||
|
? "One thing needs your attention."
|
||||||
|
: `${totalOverdue} things need your attention.`
|
||||||
|
: allTasks.length > 0
|
||||||
|
? "Everything\u2019s looking good."
|
||||||
|
: "";
|
||||||
|
|
||||||
|
if (homesLoading || tasksLoading) {
|
||||||
|
return <DashboardSkeleton />;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Empty state: no homes yet ─── */
|
||||||
|
if (homes.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-[60vh] text-center">
|
||||||
|
<div className="size-20 rounded-3xl bg-gradient-to-br from-[#FFF3EB] to-[#ECFDF5] flex items-center justify-center mb-6">
|
||||||
|
<Home className="size-9 text-[#E07A3A]" />
|
||||||
|
</div>
|
||||||
|
<h1 className="font-heading text-3xl font-bold tracking-tight">
|
||||||
|
Welcome to Casera{name ? `, ${name}` : ""}
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground mt-3 max-w-md text-base leading-relaxed">
|
||||||
|
The easiest way to keep your home running smoothly.
|
||||||
|
Add your first home to get started.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
asChild
|
||||||
|
className="mt-8 rounded-full px-8 h-12 text-base"
|
||||||
|
>
|
||||||
|
<Link href={`${basePath}/residences/new`}>
|
||||||
|
<Plus className="size-5 mr-2" />
|
||||||
|
Add Your Home
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="grid sm:grid-cols-3 gap-5 mt-16 max-w-xl w-full">
|
||||||
|
{[
|
||||||
|
{
|
||||||
|
icon: CheckSquare,
|
||||||
|
title: "Track tasks",
|
||||||
|
body: "Repairs, maintenance, projects — all in one place.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: HardHat,
|
||||||
|
title: "Save your pros",
|
||||||
|
body: "Never lose a good contractor\u2019s number again.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: FileText,
|
||||||
|
title: "Store documents",
|
||||||
|
body: "Warranties, manuals, receipts — easy to find.",
|
||||||
|
},
|
||||||
|
].map((tip) => (
|
||||||
|
<div key={tip.title} className="text-center">
|
||||||
|
<tip.icon className="size-5 text-muted-foreground mx-auto mb-2" />
|
||||||
|
<p className="text-sm font-medium">{tip.title}</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
|
||||||
|
{tip.body}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Main dashboard ─── */
|
||||||
|
return (
|
||||||
|
<div className="space-y-10">
|
||||||
|
{/* Greeting */}
|
||||||
|
<div>
|
||||||
|
<h1 className="font-heading text-2xl sm:text-3xl font-bold tracking-tight">
|
||||||
|
{greeting}
|
||||||
|
</h1>
|
||||||
|
{statusMsg && (
|
||||||
|
<p className="text-muted-foreground mt-1.5 text-[15px]">{statusMsg}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Your Homes — the main content */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="font-heading text-lg font-semibold">Your Homes</h2>
|
||||||
|
<Link
|
||||||
|
href={`${basePath}/residences/new`}
|
||||||
|
className="inline-flex items-center gap-1 text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<Plus className="size-3.5" />
|
||||||
|
Add
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`grid gap-4 ${
|
||||||
|
homes.length === 1
|
||||||
|
? "grid-cols-1 max-w-lg"
|
||||||
|
: "sm:grid-cols-2 lg:grid-cols-3"
|
||||||
|
}`}>
|
||||||
|
{homes.map((home) => (
|
||||||
|
<HomeCard key={home.residence.id} data={home} basePath={basePath} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Coming Up — clean task list */}
|
||||||
|
<ComingUp tasks={upcomingTasks} basePath={basePath} />
|
||||||
|
|
||||||
|
{/* Quick actions — subtle pills at the bottom */}
|
||||||
|
<QuickActions basePath={basePath} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,9 +19,9 @@ export default function ResidencesPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Residences"
|
title="Your Homes"
|
||||||
description="Manage your properties"
|
description="All the places you look after"
|
||||||
actionLabel="Add Residence"
|
actionLabel="Add Home"
|
||||||
onAction={() => router.push(`${basePath}/residences/new`)}
|
onAction={() => router.push(`${basePath}/residences/new`)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -37,9 +37,9 @@ export default function ResidencesPage() {
|
|||||||
{!isLoading && !error && Array.isArray(residences) && residences.length === 0 && (
|
{!isLoading && !error && Array.isArray(residences) && residences.length === 0 && (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={Home}
|
icon={Home}
|
||||||
title="No residences yet"
|
title="No homes added yet"
|
||||||
description="Add your first property to start tracking tasks and maintenance."
|
description="Add your home to start keeping track of everything — tasks, documents, contractors, and more."
|
||||||
actionLabel="Add Residence"
|
actionLabel="Add Your Home"
|
||||||
onAction={() => router.push(`${basePath}/residences/new`)}
|
onAction={() => router.push(`${basePath}/residences/new`)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -21,17 +21,21 @@ export default function SettingsLayout({ children }: { children: React.ReactNode
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Settings</h1>
|
<div>
|
||||||
|
<h1 className="font-heading text-2xl font-bold tracking-tight">Settings</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">Manage your account preferences.</p>
|
||||||
|
</div>
|
||||||
<div className="flex flex-col sm:flex-row gap-6">
|
<div className="flex flex-col sm:flex-row gap-6">
|
||||||
<nav className="flex sm:flex-col gap-1 sm:w-48 shrink-0">
|
<nav className="flex sm:flex-col gap-1 sm:w-52 shrink-0">
|
||||||
{settingsNav.map((item) => (
|
{settingsNav.map((item) => (
|
||||||
<Link key={item.href} href={item.href}
|
<Link key={item.href} href={item.href}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors",
|
"flex items-center gap-2.5 rounded-xl px-3.5 py-2.5 text-sm font-medium transition-all duration-200",
|
||||||
"hover:bg-accent hover:text-accent-foreground",
|
pathname === item.href
|
||||||
pathname === item.href ? "bg-accent text-accent-foreground" : "text-muted-foreground"
|
? "bg-primary/10 text-primary shadow-sm"
|
||||||
|
: "text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||||
)}>
|
)}>
|
||||||
<item.icon className="size-4" />
|
<item.icon className={cn("size-4", pathname === item.href ? "text-primary" : "text-muted-foreground")} />
|
||||||
{item.label}
|
{item.label}
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export default function TasksPage() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Tasks"
|
title="Tasks"
|
||||||
description="Manage your home maintenance tasks"
|
description="Everything on your to-do list"
|
||||||
actionLabel="New Task"
|
actionLabel="New Task"
|
||||||
onAction={() => router.push(`${basePath}/tasks/new`)}
|
onAction={() => router.push(`${basePath}/tasks/new`)}
|
||||||
>
|
>
|
||||||
@@ -72,9 +72,9 @@ export default function TasksPage() {
|
|||||||
{!isLoading && !isError && isEmpty && (
|
{!isLoading && !isError && isEmpty && (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={ClipboardList}
|
icon={ClipboardList}
|
||||||
title="No tasks yet"
|
title="Nothing on the list yet"
|
||||||
description="Create your first task to start tracking home maintenance."
|
description="When something around the house needs attention, add it here and we'll help you stay on top of it."
|
||||||
actionLabel="New Task"
|
actionLabel="Add Your First Task"
|
||||||
onAction={() => router.push(`${basePath}/tasks/new`)}
|
onAction={() => router.push(`${basePath}/tasks/new`)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Sidebar } from '@/components/layout/sidebar';
|
|
||||||
import { TopBar } from '@/components/layout/top-bar';
|
import { TopBar } from '@/components/layout/top-bar';
|
||||||
import { MobileNav } from '@/components/layout/mobile-nav';
|
import { MobileNav } from '@/components/layout/mobile-nav';
|
||||||
import { DemoBanner } from '@/components/demo/demo-banner';
|
import { DemoBanner } from '@/components/demo/demo-banner';
|
||||||
@@ -12,19 +11,12 @@ export default function DemoAppLayout({ children }: { children: React.ReactNode
|
|||||||
<DataProviderProvider value={demoProvider}>
|
<DataProviderProvider value={demoProvider}>
|
||||||
<div className="min-h-screen bg-background">
|
<div className="min-h-screen bg-background">
|
||||||
<DemoBanner />
|
<DemoBanner />
|
||||||
|
<TopBar />
|
||||||
|
|
||||||
{/* Sidebar - hidden on mobile */}
|
<main className="max-w-7xl mx-auto px-6 py-8 lg:py-12 pb-28 md:pb-12">
|
||||||
<Sidebar />
|
{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 />
|
<MobileNav />
|
||||||
</div>
|
</div>
|
||||||
</DataProviderProvider>
|
</DataProviderProvider>
|
||||||
|
|||||||
+38
-19
@@ -1,38 +1,57 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Button } from "@/components/ui/button";
|
import Image from "next/image";
|
||||||
|
import { ArrowRight, Play } from "lucide-react";
|
||||||
|
|
||||||
export default function DemoLandingPage() {
|
export default function DemoLandingPage() {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col items-center justify-center bg-background px-4">
|
<div className="flex min-h-screen flex-col items-center justify-center bg-[#FAFAF7] px-6 relative overflow-hidden">
|
||||||
<div className="mx-auto max-w-md text-center">
|
{/* Decorative background */}
|
||||||
|
<div className="absolute top-20 right-[-10%] w-[500px] h-[500px] rounded-full bg-[#E07A3A]/[0.04] blur-3xl pointer-events-none" />
|
||||||
|
<div className="absolute bottom-0 left-[-5%] w-[300px] h-[300px] rounded-full bg-[#0D7C66]/[0.03] blur-3xl pointer-events-none" />
|
||||||
|
|
||||||
|
<div className="relative mx-auto max-w-lg text-center">
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<h1 className="mb-8 text-2xl font-bold tracking-tight text-primary">
|
<Link href="/" className="inline-flex items-center gap-2.5 mb-10">
|
||||||
Casera
|
<Image
|
||||||
</h1>
|
src="/logo.png"
|
||||||
|
alt="Casera"
|
||||||
|
width={36}
|
||||||
|
height={36}
|
||||||
|
className="rounded-lg"
|
||||||
|
/>
|
||||||
|
<span className="font-heading text-2xl font-bold tracking-tight text-[#1C1917]">
|
||||||
|
Casera
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
{/* Hero */}
|
{/* Hero */}
|
||||||
<h2 className="text-3xl font-bold tracking-tight">
|
<h1 className="font-heading text-4xl font-bold tracking-tight text-[#1C1917]">
|
||||||
Try Casera — No Account Needed
|
See Casera in action
|
||||||
</h2>
|
</h1>
|
||||||
<p className="mt-3 text-muted-foreground">
|
<p className="mt-4 text-lg text-[#78716C] leading-relaxed">
|
||||||
Manage your home maintenance, track tasks, organize contractors, and
|
Explore the full app with sample data. No account needed —
|
||||||
store documents.
|
just click and start exploring.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="mt-8 flex flex-col gap-3">
|
<div className="mt-10 flex flex-col sm:flex-row items-center justify-center gap-4">
|
||||||
<Button size="lg" asChild>
|
<Link
|
||||||
<Link href="/demo/app">Start Demo</Link>
|
href="/demo/app"
|
||||||
</Button>
|
className="group inline-flex items-center gap-2.5 rounded-full bg-[#E07A3A] px-8 py-4 text-base font-semibold text-white shadow-lg shadow-[#E07A3A]/20 hover:bg-[#C4632A] transition-all"
|
||||||
|
>
|
||||||
|
<Play className="size-4" />
|
||||||
|
Launch Demo
|
||||||
|
<ArrowRight className="size-4 transition-transform group-hover:translate-x-0.5" />
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Login link */}
|
{/* Login link */}
|
||||||
<p className="mt-6 text-sm text-muted-foreground">
|
<p className="mt-8 text-sm text-[#A8A29E]">
|
||||||
Already have an account?{" "}
|
Already have an account?{" "}
|
||||||
<Link href="/login" className="text-primary hover:underline">
|
<Link href="/login" className="text-[#E07A3A] font-medium hover:underline">
|
||||||
Log In
|
Sign In
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+89
-6
@@ -8,8 +8,9 @@
|
|||||||
@theme inline {
|
@theme inline {
|
||||||
--color-background: var(--background);
|
--color-background: var(--background);
|
||||||
--color-foreground: var(--foreground);
|
--color-foreground: var(--foreground);
|
||||||
--font-sans: var(--font-geist-sans);
|
--font-sans: var(--font-outfit);
|
||||||
--font-mono: var(--font-geist-mono);
|
--font-mono: var(--font-geist-mono);
|
||||||
|
--font-heading: var(--font-bricolage);
|
||||||
--color-sidebar-ring: var(--sidebar-ring);
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
--color-sidebar-border: var(--sidebar-border);
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
@@ -48,15 +49,30 @@
|
|||||||
--radius-4xl: calc(var(--radius) + 16px);
|
--radius-4xl: calc(var(--radius) + 16px);
|
||||||
|
|
||||||
/* App-specific theme-aware Tailwind utilities */
|
/* App-specific theme-aware Tailwind utilities */
|
||||||
--color-bg-primary: var(--color-bg-primary);
|
--color-bg-primary: #FFFFFF;
|
||||||
--color-bg-secondary: var(--color-bg-secondary);
|
--color-bg-secondary: #F7F7F7;
|
||||||
--color-text-primary: var(--color-text-primary);
|
--color-text-primary: #1C1917;
|
||||||
--color-text-secondary: var(--color-text-secondary);
|
--color-text-secondary: #78716C;
|
||||||
--color-text-on-primary: var(--color-text-on-primary);
|
--color-text-on-primary: #FFFFFF;
|
||||||
--color-app-primary: var(--color-primary);
|
--color-app-primary: var(--color-primary);
|
||||||
--color-app-secondary: var(--color-secondary);
|
--color-app-secondary: var(--color-secondary);
|
||||||
--color-app-accent: var(--color-accent);
|
--color-app-accent: var(--color-accent);
|
||||||
--color-app-error: var(--color-error);
|
--color-app-error: var(--color-error);
|
||||||
|
|
||||||
|
/* Landing page brand colors (theme-independent) */
|
||||||
|
--color-brand-orange: #E07A3A;
|
||||||
|
--color-brand-orange-dark: #C4632A;
|
||||||
|
--color-brand-orange-light: #FFF3EB;
|
||||||
|
--color-brand-teal: #0D7C66;
|
||||||
|
--color-brand-teal-light: #ECFDF5;
|
||||||
|
--color-brand-slate: #1C1917;
|
||||||
|
--color-brand-warm: #FFFFFF;
|
||||||
|
|
||||||
|
/* Animation tokens */
|
||||||
|
--animate-fade-up: fade-up 0.7s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||||
|
--animate-fade-in: fade-in 0.6s ease both;
|
||||||
|
--animate-slide-in-right: slide-in-right 0.7s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||||
|
--animate-scale-in: scale-in 0.5s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
@@ -71,3 +87,70 @@
|
|||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Keyframes */
|
||||||
|
@keyframes fade-up {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(24px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fade-in {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slide-in-right {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(40px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes scale-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.92);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes float {
|
||||||
|
0%, 100% { transform: translateY(0px); }
|
||||||
|
50% { transform: translateY(-12px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% { background-position: -200% 0; }
|
||||||
|
100% { background-position: 200% 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Utility classes for staggered animations */
|
||||||
|
.stagger-1 { animation-delay: 0ms; }
|
||||||
|
.stagger-2 { animation-delay: 100ms; }
|
||||||
|
.stagger-3 { animation-delay: 200ms; }
|
||||||
|
.stagger-4 { animation-delay: 300ms; }
|
||||||
|
.stagger-5 { animation-delay: 400ms; }
|
||||||
|
.stagger-6 { animation-delay: 500ms; }
|
||||||
|
|
||||||
|
/* Noise texture overlay for backgrounds */
|
||||||
|
.noise-overlay::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
opacity: 0.03;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E");
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|||||||
+11
-4
@@ -1,15 +1,22 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Bricolage_Grotesque, Outfit, Geist_Mono } from "next/font/google";
|
||||||
import { ThemeProvider } from "@/lib/themes/theme-provider";
|
import { ThemeProvider } from "@/lib/themes/theme-provider";
|
||||||
import { QueryProvider } from "@/lib/query/query-provider";
|
import { QueryProvider } from "@/lib/query/query-provider";
|
||||||
import { PostHogProvider } from "@/lib/analytics/posthog-provider";
|
import { PostHogProvider } from "@/lib/analytics/posthog-provider";
|
||||||
import { Toaster } from "@/components/ui/sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
const geistSans = Geist({
|
const bricolage = Bricolage_Grotesque({
|
||||||
variable: "--font-geist-sans",
|
variable: "--font-bricolage",
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
|
display: "swap",
|
||||||
|
});
|
||||||
|
|
||||||
|
const outfit = Outfit({
|
||||||
|
variable: "--font-outfit",
|
||||||
|
subsets: ["latin"],
|
||||||
|
display: "swap",
|
||||||
});
|
});
|
||||||
|
|
||||||
const geistMono = Geist_Mono({
|
const geistMono = Geist_Mono({
|
||||||
@@ -46,7 +53,7 @@ export default function RootLayout({
|
|||||||
return (
|
return (
|
||||||
<html lang="en" suppressHydrationWarning>
|
<html lang="en" suppressHydrationWarning>
|
||||||
<body
|
<body
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
className={`${bricolage.variable} ${outfit.variable} ${geistMono.variable} antialiased`}
|
||||||
>
|
>
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<PostHogProvider>
|
<PostHogProvider>
|
||||||
|
|||||||
+496
-3
@@ -1,5 +1,498 @@
|
|||||||
import { redirect } from 'next/navigation';
|
import Link from "next/link";
|
||||||
|
import Image from "next/image";
|
||||||
|
import {
|
||||||
|
CheckSquare,
|
||||||
|
HardHat,
|
||||||
|
FileText,
|
||||||
|
ArrowRight,
|
||||||
|
Shield,
|
||||||
|
Users,
|
||||||
|
Bell,
|
||||||
|
ChevronRight,
|
||||||
|
Home,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
export default function Home() {
|
const features = [
|
||||||
redirect('/app');
|
{
|
||||||
|
icon: CheckSquare,
|
||||||
|
title: "Smart Task Tracking",
|
||||||
|
description:
|
||||||
|
"Kanban boards, recurring schedules, and due date reminders keep every repair and project on track.",
|
||||||
|
color: "bg-[#FFF3EB] text-[#E07A3A]",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: HardHat,
|
||||||
|
title: "Contractor Rolodex",
|
||||||
|
description:
|
||||||
|
"Store contact details, specialties, and notes for every service provider. Never lose a plumber's number again.",
|
||||||
|
color: "bg-[#ECFDF5] text-[#0D7C66]",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: FileText,
|
||||||
|
title: "Document Vault",
|
||||||
|
description:
|
||||||
|
"Warranties, manuals, leases — organized by property and always at your fingertips when you need them.",
|
||||||
|
color: "bg-[#FEF3C7] text-[#92400E]",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const steps = [
|
||||||
|
{
|
||||||
|
number: "01",
|
||||||
|
title: "Add your home",
|
||||||
|
description:
|
||||||
|
"Create a residence and invite household members to collaborate on maintenance together.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
number: "02",
|
||||||
|
title: "Track everything",
|
||||||
|
description:
|
||||||
|
"Add tasks, log contractors, upload documents. Build your home's complete history over time.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
number: "03",
|
||||||
|
title: "Stay ahead",
|
||||||
|
description:
|
||||||
|
"Get reminders for upcoming maintenance. Never miss a filter change, inspection, or warranty deadline.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const highlights = [
|
||||||
|
{
|
||||||
|
icon: Shield,
|
||||||
|
title: "Private & Secure",
|
||||||
|
description: "Your data stays yours. We never share or sell your information.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Users,
|
||||||
|
title: "Household Sharing",
|
||||||
|
description: "Invite family members with simple invite codes. Everyone stays in sync.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Bell,
|
||||||
|
title: "Smart Reminders",
|
||||||
|
description: "Recurring tasks and due dates keep you ahead of every maintenance need.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Home,
|
||||||
|
title: "Multi-Property",
|
||||||
|
description: "Manage one home or many. Each property gets its own organized space.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function HomePage() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-white text-[#1C1917] font-sans selection:bg-[#E07A3A]/20">
|
||||||
|
{/* ─── Navigation ─── */}
|
||||||
|
<nav className="fixed top-0 w-full z-50 bg-white/80 backdrop-blur-xl border-b border-[#E7E5E4]/60">
|
||||||
|
<div className="max-w-7xl mx-auto px-6 flex items-center justify-between h-16">
|
||||||
|
<Link href="/" className="flex items-center gap-2.5">
|
||||||
|
<Image
|
||||||
|
src="/logo.png"
|
||||||
|
alt="Casera"
|
||||||
|
width={32}
|
||||||
|
height={32}
|
||||||
|
className="rounded-lg"
|
||||||
|
/>
|
||||||
|
<span className="font-heading text-xl font-bold tracking-tight text-[#1C1917]">
|
||||||
|
Casera
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="hidden md:flex items-center gap-8">
|
||||||
|
<a
|
||||||
|
href="#features"
|
||||||
|
className="text-sm font-medium text-[#78716C] hover:text-[#1C1917] transition-colors"
|
||||||
|
>
|
||||||
|
Features
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="#how-it-works"
|
||||||
|
className="text-sm font-medium text-[#78716C] hover:text-[#1C1917] transition-colors"
|
||||||
|
>
|
||||||
|
How It Works
|
||||||
|
</a>
|
||||||
|
<Link
|
||||||
|
href="/demo"
|
||||||
|
className="text-sm font-medium text-[#78716C] hover:text-[#1C1917] transition-colors"
|
||||||
|
>
|
||||||
|
Demo
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Link
|
||||||
|
href="/login"
|
||||||
|
className="text-sm font-medium text-[#78716C] hover:text-[#1C1917] transition-colors hidden sm:inline-flex"
|
||||||
|
>
|
||||||
|
Sign In
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/register"
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-full bg-[#1C1917] px-5 py-2 text-sm font-semibold text-white hover:bg-[#292524] transition-colors"
|
||||||
|
>
|
||||||
|
Get Started
|
||||||
|
<ArrowRight className="size-3.5" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* ─── Hero ─── */}
|
||||||
|
<section className="relative pt-32 pb-24 md:pt-40 md:pb-32 overflow-hidden">
|
||||||
|
{/* Decorative background */}
|
||||||
|
<div className="absolute inset-0 pointer-events-none">
|
||||||
|
<div className="absolute top-20 right-[-10%] w-[600px] h-[600px] rounded-full bg-[#E07A3A]/[0.04] blur-3xl" />
|
||||||
|
<div className="absolute bottom-0 left-[-5%] w-[400px] h-[400px] rounded-full bg-[#0D7C66]/[0.03] blur-3xl" />
|
||||||
|
{/* Subtle grid pattern */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 opacity-[0.03]"
|
||||||
|
style={{
|
||||||
|
backgroundImage:
|
||||||
|
"linear-gradient(#1C1917 1px, transparent 1px), linear-gradient(90deg, #1C1917 1px, transparent 1px)",
|
||||||
|
backgroundSize: "64px 64px",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-7xl mx-auto px-6 relative">
|
||||||
|
<div className="max-w-3xl">
|
||||||
|
{/* Badge */}
|
||||||
|
<div className="animate-fade-up stagger-1 inline-flex items-center gap-2 rounded-full border border-[#E7E5E4] bg-white px-4 py-1.5 text-sm text-[#78716C] mb-8 shadow-sm">
|
||||||
|
<span className="inline-block size-2 rounded-full bg-[#0D7C66] animate-pulse" />
|
||||||
|
Now available for homeowners
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="animate-fade-up stagger-2 font-heading text-5xl sm:text-6xl md:text-7xl font-bold tracking-tight leading-[1.08]">
|
||||||
|
Your home,{" "}
|
||||||
|
<span className="text-[#E07A3A]">perfectly maintained.</span>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="animate-fade-up stagger-3 mt-6 text-lg md:text-xl text-[#78716C] max-w-xl leading-relaxed">
|
||||||
|
Track tasks, organize contractors, and store important
|
||||||
|
documents. Everything you need to keep your home running
|
||||||
|
smoothly — in one place.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="animate-fade-up stagger-4 mt-10 flex flex-wrap gap-4">
|
||||||
|
<Link
|
||||||
|
href="/register"
|
||||||
|
className="group inline-flex items-center gap-2 rounded-full bg-[#E07A3A] px-7 py-3.5 text-base font-semibold text-white shadow-lg shadow-[#E07A3A]/20 hover:bg-[#C4632A] transition-all hover:shadow-xl hover:shadow-[#E07A3A]/25"
|
||||||
|
>
|
||||||
|
Get Started — Free
|
||||||
|
<ArrowRight className="size-4 transition-transform group-hover:translate-x-0.5" />
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/demo"
|
||||||
|
className="group inline-flex items-center gap-2 rounded-full border-2 border-[#E7E5E4] bg-white px-7 py-3.5 text-base font-semibold text-[#1C1917] hover:border-[#D6D3D1] hover:bg-[#F5F5F4] transition-all"
|
||||||
|
>
|
||||||
|
Try the Demo
|
||||||
|
<ChevronRight className="size-4 text-[#A8A29E] transition-transform group-hover:translate-x-0.5" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hero visual — abstract app preview */}
|
||||||
|
<div className="animate-fade-up stagger-5 hidden lg:block absolute top-12 right-6 w-[420px]">
|
||||||
|
<div className="relative">
|
||||||
|
{/* Main card */}
|
||||||
|
<div className="rounded-2xl bg-white border border-[#E7E5E4] shadow-xl shadow-black/[0.04] p-5">
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<div className="size-8 rounded-lg bg-[#FFF3EB] flex items-center justify-center">
|
||||||
|
<CheckSquare className="size-4 text-[#E07A3A]" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-semibold">Upcoming Tasks</div>
|
||||||
|
<div className="text-xs text-[#A8A29E]">3 due this week</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
{[
|
||||||
|
{ task: "Replace HVAC filter", due: "Tomorrow", status: "bg-[#FEF3C7] text-[#92400E]" },
|
||||||
|
{ task: "Schedule gutter cleaning", due: "Wed", status: "bg-[#ECFDF5] text-[#0D7C66]" },
|
||||||
|
{ task: "Check smoke detectors", due: "Fri", status: "bg-[#FFF3EB] text-[#E07A3A]" },
|
||||||
|
].map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.task}
|
||||||
|
className="flex items-center justify-between rounded-lg bg-white px-3.5 py-2.5"
|
||||||
|
>
|
||||||
|
<span className="text-sm font-medium">{item.task}</span>
|
||||||
|
<span
|
||||||
|
className={`text-xs font-medium px-2 py-0.5 rounded-full ${item.status}`}
|
||||||
|
>
|
||||||
|
{item.due}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Floating stat card */}
|
||||||
|
<div
|
||||||
|
className="absolute -bottom-8 -left-8 rounded-xl bg-white border border-[#E7E5E4] shadow-lg shadow-black/[0.04] p-4 w-48"
|
||||||
|
style={{ animation: "float 6s ease-in-out infinite" }}
|
||||||
|
>
|
||||||
|
<div className="text-xs text-[#A8A29E] mb-1">This Month</div>
|
||||||
|
<div className="text-2xl font-bold font-heading text-[#0D7C66]">12 Done</div>
|
||||||
|
<div className="mt-2 h-1.5 rounded-full bg-[#F7F7F7] overflow-hidden">
|
||||||
|
<div className="h-full w-3/4 rounded-full bg-[#0D7C66]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ─── Social proof strip ─── */}
|
||||||
|
<section className="border-y border-[#E7E5E4] bg-white/50">
|
||||||
|
<div className="max-w-7xl mx-auto px-6 py-8 flex flex-wrap items-center justify-center gap-x-12 gap-y-4 text-center">
|
||||||
|
{[
|
||||||
|
{ value: "Free", label: "to get started" },
|
||||||
|
{ value: "100%", label: "private & secure" },
|
||||||
|
{ value: "All-in-one", label: "home management" },
|
||||||
|
].map((stat) => (
|
||||||
|
<div key={stat.label} className="flex items-baseline gap-2">
|
||||||
|
<span className="text-lg font-bold font-heading text-[#1C1917]">
|
||||||
|
{stat.value}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-[#A8A29E]">{stat.label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ─── Features ─── */}
|
||||||
|
<section id="features" className="py-24 md:py-32">
|
||||||
|
<div className="max-w-7xl mx-auto px-6">
|
||||||
|
<div className="max-w-2xl mb-16">
|
||||||
|
<p className="text-sm font-semibold text-[#E07A3A] uppercase tracking-wider mb-3">
|
||||||
|
Features
|
||||||
|
</p>
|
||||||
|
<h2 className="font-heading text-3xl md:text-4xl font-bold tracking-tight">
|
||||||
|
Everything your home needs, nothing it doesn't.
|
||||||
|
</h2>
|
||||||
|
<p className="mt-4 text-lg text-[#78716C] leading-relaxed">
|
||||||
|
Casera brings all your home maintenance into one clear,
|
||||||
|
organized space. No bloat, no learning curve.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid md:grid-cols-3 gap-6">
|
||||||
|
{features.map((feature, i) => (
|
||||||
|
<div
|
||||||
|
key={feature.title}
|
||||||
|
className={`group relative rounded-2xl border border-[#E7E5E4] bg-white p-8 transition-all hover:shadow-lg hover:shadow-black/[0.04] hover:-translate-y-0.5 stagger-${i + 1}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`inline-flex items-center justify-center size-12 rounded-xl ${feature.color} mb-5`}
|
||||||
|
>
|
||||||
|
<feature.icon className="size-5" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-heading text-xl font-bold mb-2">
|
||||||
|
{feature.title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-[#78716C] leading-relaxed">
|
||||||
|
{feature.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ─── How It Works ─── */}
|
||||||
|
<section
|
||||||
|
id="how-it-works"
|
||||||
|
className="py-24 md:py-32 bg-[#F7F7F7] relative noise-overlay"
|
||||||
|
>
|
||||||
|
<div className="max-w-7xl mx-auto px-6 relative">
|
||||||
|
<div className="text-center max-w-2xl mx-auto mb-16">
|
||||||
|
<p className="text-sm font-semibold text-[#0D7C66] uppercase tracking-wider mb-3">
|
||||||
|
How It Works
|
||||||
|
</p>
|
||||||
|
<h2 className="font-heading text-3xl md:text-4xl font-bold tracking-tight">
|
||||||
|
Up and running in minutes
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid md:grid-cols-3 gap-8 md:gap-12">
|
||||||
|
{steps.map((step) => (
|
||||||
|
<div key={step.number} className="text-center md:text-left">
|
||||||
|
<div className="inline-flex items-center justify-center size-14 rounded-2xl bg-[#1C1917] text-white font-heading font-bold text-lg mb-5">
|
||||||
|
{step.number}
|
||||||
|
</div>
|
||||||
|
<h3 className="font-heading text-xl font-bold mb-2">
|
||||||
|
{step.title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-[#78716C] leading-relaxed">
|
||||||
|
{step.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ─── Highlights Grid ─── */}
|
||||||
|
<section className="py-24 md:py-32">
|
||||||
|
<div className="max-w-7xl mx-auto px-6">
|
||||||
|
<div className="text-center max-w-2xl mx-auto mb-16">
|
||||||
|
<h2 className="font-heading text-3xl md:text-4xl font-bold tracking-tight">
|
||||||
|
Built for real homeowners
|
||||||
|
</h2>
|
||||||
|
<p className="mt-4 text-lg text-[#78716C]">
|
||||||
|
Thoughtful details that make home maintenance feel manageable.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
|
{highlights.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.title}
|
||||||
|
className="rounded-2xl border border-[#E7E5E4] bg-white p-6 hover:shadow-md hover:shadow-black/[0.03] transition-all"
|
||||||
|
>
|
||||||
|
<item.icon className="size-6 text-[#E07A3A] mb-4" />
|
||||||
|
<h3 className="font-heading font-bold text-base mb-1.5">
|
||||||
|
{item.title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-[#78716C] leading-relaxed">
|
||||||
|
{item.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ─── CTA Section ─── */}
|
||||||
|
<section className="py-24 md:py-32 relative overflow-hidden">
|
||||||
|
<div className="absolute inset-0 bg-[#1C1917]" />
|
||||||
|
{/* Orange glow */}
|
||||||
|
<div className="absolute top-0 right-0 w-[500px] h-[500px] rounded-full bg-[#E07A3A]/10 blur-[120px] pointer-events-none" />
|
||||||
|
<div className="absolute bottom-0 left-0 w-[400px] h-[400px] rounded-full bg-[#0D7C66]/10 blur-[100px] pointer-events-none" />
|
||||||
|
|
||||||
|
<div className="max-w-3xl mx-auto px-6 text-center relative">
|
||||||
|
<h2 className="font-heading text-3xl md:text-5xl font-bold tracking-tight text-white">
|
||||||
|
Ready to take control of your home?
|
||||||
|
</h2>
|
||||||
|
<p className="mt-5 text-lg text-[#A8A29E] max-w-xl mx-auto leading-relaxed">
|
||||||
|
Join homeowners who've simplified their maintenance routine.
|
||||||
|
Free to start, no credit card required.
|
||||||
|
</p>
|
||||||
|
<div className="mt-10 flex flex-wrap justify-center gap-4">
|
||||||
|
<Link
|
||||||
|
href="/register"
|
||||||
|
className="group inline-flex items-center gap-2 rounded-full bg-[#E07A3A] px-8 py-4 text-base font-semibold text-white shadow-lg shadow-[#E07A3A]/20 hover:bg-[#C4632A] transition-all"
|
||||||
|
>
|
||||||
|
Get Started — Free
|
||||||
|
<ArrowRight className="size-4 transition-transform group-hover:translate-x-0.5" />
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/demo"
|
||||||
|
className="inline-flex items-center gap-2 rounded-full border-2 border-white/15 px-8 py-4 text-base font-semibold text-white hover:border-white/30 hover:bg-white/5 transition-all"
|
||||||
|
>
|
||||||
|
Try the Demo
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ─── Footer ─── */}
|
||||||
|
<footer className="bg-[#1C1917] border-t border-white/5 text-[#A8A29E]">
|
||||||
|
<div className="max-w-7xl mx-auto px-6 py-16">
|
||||||
|
<div className="grid md:grid-cols-4 gap-10">
|
||||||
|
{/* Brand */}
|
||||||
|
<div className="md:col-span-1">
|
||||||
|
<Link href="/" className="flex items-center gap-2.5 mb-4">
|
||||||
|
<Image
|
||||||
|
src="/logo.png"
|
||||||
|
alt="Casera"
|
||||||
|
width={28}
|
||||||
|
height={28}
|
||||||
|
className="rounded-md"
|
||||||
|
/>
|
||||||
|
<span className="font-heading text-lg font-bold text-white">
|
||||||
|
Casera
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
<p className="text-sm leading-relaxed">
|
||||||
|
Home maintenance made simple. Track tasks, organize
|
||||||
|
contractors, store documents.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Product */}
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-semibold text-white mb-4">
|
||||||
|
Product
|
||||||
|
</h4>
|
||||||
|
<ul className="space-y-2.5 text-sm">
|
||||||
|
<li>
|
||||||
|
<a href="#features" className="hover:text-white transition-colors">
|
||||||
|
Features
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link href="/demo" className="hover:text-white transition-colors">
|
||||||
|
Demo
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="#how-it-works" className="hover:text-white transition-colors">
|
||||||
|
How It Works
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Account */}
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-semibold text-white mb-4">
|
||||||
|
Account
|
||||||
|
</h4>
|
||||||
|
<ul className="space-y-2.5 text-sm">
|
||||||
|
<li>
|
||||||
|
<Link href="/login" className="hover:text-white transition-colors">
|
||||||
|
Sign In
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link href="/register" className="hover:text-white transition-colors">
|
||||||
|
Create Account
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link href="/forgot-password" className="hover:text-white transition-colors">
|
||||||
|
Reset Password
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Legal */}
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-semibold text-white mb-4">Legal</h4>
|
||||||
|
<ul className="space-y-2.5 text-sm">
|
||||||
|
<li>
|
||||||
|
<span className="cursor-default">Privacy Policy</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span className="cursor-default">Terms of Service</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-16 pt-8 border-t border-white/5 flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||||
|
<p className="text-xs">
|
||||||
|
© {new Date().getFullYear()} Casera. All rights reserved.
|
||||||
|
</p>
|
||||||
|
<p className="text-xs">
|
||||||
|
Made for homeowners, by homeowners.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Phone, Mail, Star } from "lucide-react";
|
import { Phone, Mail, Star } from "lucide-react";
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardAction } from "@/components/ui/card";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useDataProvider } from "@/lib/demo/data-provider-context";
|
import { useDataProvider } from "@/lib/demo/data-provider-context";
|
||||||
@@ -16,64 +15,67 @@ interface ContractorCardProps {
|
|||||||
export function ContractorCard({ contractor, onToggleFavorite }: ContractorCardProps) {
|
export function ContractorCard({ contractor, onToggleFavorite }: ContractorCardProps) {
|
||||||
const { basePath } = useDataProvider();
|
const { basePath } = useDataProvider();
|
||||||
return (
|
return (
|
||||||
<Card className="transition-shadow hover:shadow-md">
|
<div className="group rounded-2xl border border-border bg-card p-5 transition-all duration-200 hover:shadow-md hover:shadow-black/[0.04] hover:-translate-y-0.5">
|
||||||
<CardHeader>
|
<div className="flex items-start justify-between mb-3">
|
||||||
<Link href={`${basePath}/contractors/${contractor.id}`} className="hover:underline">
|
<div className="min-w-0 flex-1">
|
||||||
<CardTitle>{contractor.name}</CardTitle>
|
<Link
|
||||||
</Link>
|
href={`${basePath}/contractors/${contractor.id}`}
|
||||||
{contractor.company && (
|
className="font-heading font-bold text-base leading-tight hover:text-primary transition-colors line-clamp-1"
|
||||||
<CardDescription>{contractor.company}</CardDescription>
|
|
||||||
)}
|
|
||||||
<CardAction>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="size-8"
|
|
||||||
aria-label={contractor.is_favorite ? "Remove from favorites" : "Add to favorites"}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
onToggleFavorite(contractor.id);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Star
|
{contractor.name}
|
||||||
aria-hidden="true"
|
</Link>
|
||||||
className={
|
{contractor.company && (
|
||||||
contractor.is_favorite
|
<p className="text-sm text-muted-foreground mt-0.5 truncate">{contractor.company}</p>
|
||||||
? "size-4 fill-yellow-400 text-yellow-400"
|
|
||||||
: "size-4 text-muted-foreground"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
</CardAction>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{contractor.specialties.length > 0 && (
|
|
||||||
<div className="flex flex-wrap gap-1 mb-3">
|
|
||||||
{contractor.specialties.map((s) => (
|
|
||||||
<Badge key={s.id} variant="secondary">
|
|
||||||
{s.icon && <span className="mr-1">{s.icon}</span>}
|
|
||||||
{s.name}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{contractor.phone && (
|
|
||||||
<Button variant="outline" size="icon" className="size-8" asChild>
|
|
||||||
<a href={`tel:${contractor.phone}`} aria-label={`Call ${contractor.name}`} onClick={(e) => e.stopPropagation()}>
|
|
||||||
<Phone className="size-4" aria-hidden="true" />
|
|
||||||
</a>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{contractor.email && (
|
|
||||||
<Button variant="outline" size="icon" className="size-8" asChild>
|
|
||||||
<a href={`mailto:${contractor.email}`} aria-label={`Email ${contractor.name}`} onClick={(e) => e.stopPropagation()}>
|
|
||||||
<Mail className="size-4" aria-hidden="true" />
|
|
||||||
</a>
|
|
||||||
</Button>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
<Button
|
||||||
</Card>
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-8 shrink-0 -mr-1 -mt-1"
|
||||||
|
aria-label={contractor.is_favorite ? "Remove from favorites" : "Add to favorites"}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onToggleFavorite(contractor.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Star
|
||||||
|
aria-hidden="true"
|
||||||
|
className={
|
||||||
|
contractor.is_favorite
|
||||||
|
? "size-4 fill-amber-400 text-amber-400"
|
||||||
|
: "size-4 text-muted-foreground hover:text-amber-400 transition-colors"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{contractor.specialties.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1.5 mb-4">
|
||||||
|
{contractor.specialties.map((s) => (
|
||||||
|
<Badge key={s.id} variant="secondary" className="rounded-lg text-xs">
|
||||||
|
{s.icon && <span className="mr-1">{s.icon}</span>}
|
||||||
|
{s.name}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{contractor.phone && (
|
||||||
|
<Button variant="outline" size="icon" className="size-8 rounded-lg" asChild>
|
||||||
|
<a href={`tel:${contractor.phone}`} aria-label={`Call ${contractor.name}`} onClick={(e) => e.stopPropagation()}>
|
||||||
|
<Phone className="size-3.5" aria-hidden="true" />
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{contractor.email && (
|
||||||
|
<Button variant="outline" size="icon" className="size-8 rounded-lg" asChild>
|
||||||
|
<a href={`mailto:${contractor.email}`} aria-label={`Email ${contractor.name}`} onClick={(e) => e.stopPropagation()}>
|
||||||
|
<Mail className="size-3.5" aria-hidden="true" />
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Bell } from "lucide-react";
|
import { Bell, Coffee } from "lucide-react";
|
||||||
import { formatDistanceToNow } from "date-fns";
|
import { formatDistanceToNow } from "date-fns";
|
||||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
|
||||||
import { useNotifications } from "@/lib/hooks/use-notifications";
|
import { useNotifications } from "@/lib/hooks/use-notifications";
|
||||||
import { useDataProvider } from "@/lib/demo/data-provider-context";
|
import { useDataProvider } from "@/lib/demo/data-provider-context";
|
||||||
|
|
||||||
@@ -14,52 +13,60 @@ export function RecentActivity() {
|
|||||||
const notifications = data?.results ?? [];
|
const notifications = data?.results ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<div className="rounded-2xl border border-border bg-card overflow-hidden h-full">
|
||||||
<CardHeader>
|
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
|
||||||
<CardTitle className="flex items-center justify-between">
|
<h3 className="font-heading font-semibold text-base">What's Been Happening</h3>
|
||||||
<span>Recent Activity</span>
|
<Link
|
||||||
<Link
|
href={`${basePath}/settings/notifications`}
|
||||||
href={`${basePath}/settings/notifications`}
|
className="text-xs font-medium text-primary hover:text-primary/80 transition-colors"
|
||||||
className="text-sm font-normal text-primary hover:underline"
|
>
|
||||||
>
|
View all
|
||||||
View all
|
</Link>
|
||||||
</Link>
|
</div>
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
<div className="p-4">
|
||||||
<CardContent>
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4 p-2">
|
||||||
{Array.from({ length: 3 }).map((_, i) => (
|
{Array.from({ length: 3 }).map((_, i) => (
|
||||||
<div key={i} className="flex gap-3 animate-pulse">
|
<div key={i} className="flex gap-3 animate-pulse">
|
||||||
<div className="size-8 rounded-full bg-muted" />
|
<div className="size-9 rounded-xl bg-muted shrink-0" />
|
||||||
<div className="flex-1 space-y-2">
|
<div className="flex-1 space-y-2 py-0.5">
|
||||||
<div className="h-4 w-1/2 rounded bg-muted" />
|
<div className="h-3.5 w-3/5 rounded bg-muted" />
|
||||||
<div className="h-3 w-3/4 rounded bg-muted" />
|
<div className="h-3 w-4/5 rounded bg-muted" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : notifications.length === 0 ? (
|
) : notifications.length === 0 ? (
|
||||||
<div className="flex items-center justify-center py-8 text-muted-foreground">
|
<div className="flex flex-col items-center justify-center py-10 text-center">
|
||||||
No recent activity
|
<div className="size-11 rounded-xl bg-[#FFF3EB] flex items-center justify-center mb-3">
|
||||||
|
<Coffee className="size-5 text-[#E07A3A]" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-medium text-foreground">Nothing yet — all quiet!</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Activity will show up here as you manage your home.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-1">
|
||||||
{notifications.map((notification) => (
|
{notifications.map((notification) => (
|
||||||
<div key={notification.id} className="flex gap-3 items-start">
|
<div
|
||||||
<div className="flex size-8 shrink-0 items-center justify-center rounded-full bg-primary/10">
|
key={notification.id}
|
||||||
|
className="flex gap-3 items-start rounded-xl p-2.5 hover:bg-accent/50 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex size-9 shrink-0 items-center justify-center rounded-xl bg-primary/10">
|
||||||
<Bell className="size-4 text-primary" />
|
<Bell className="size-4 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0 py-0.5">
|
||||||
<p className="text-sm font-medium leading-tight">
|
<p className="text-sm font-medium leading-snug">
|
||||||
{notification.title}
|
{notification.title}
|
||||||
</p>
|
</p>
|
||||||
{notification.body && (
|
{notification.body && (
|
||||||
<p className="text-xs text-muted-foreground mt-0.5 truncate">
|
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-1">
|
||||||
{notification.body}
|
{notification.body}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
<p className="text-[11px] text-muted-foreground/70 mt-1">
|
||||||
{formatDistanceToNow(new Date(notification.created_at), {
|
{formatDistanceToNow(new Date(notification.created_at), {
|
||||||
addSuffix: true,
|
addSuffix: true,
|
||||||
})}
|
})}
|
||||||
@@ -69,7 +76,7 @@ export function RecentActivity() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { AlertTriangle, Clock, ClipboardList, CheckCircle2 } from "lucide-react";
|
import { AlertTriangle, Clock, ClipboardList, CheckCircle2 } from "lucide-react";
|
||||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
|
||||||
import { useDataProvider } from "@/lib/demo/data-provider-context";
|
import { useDataProvider } from "@/lib/demo/data-provider-context";
|
||||||
|
|
||||||
interface StatsCardsProps {
|
interface StatsCardsProps {
|
||||||
@@ -17,28 +16,32 @@ const stats = [
|
|||||||
key: "overdue",
|
key: "overdue",
|
||||||
label: "Overdue",
|
label: "Overdue",
|
||||||
icon: AlertTriangle,
|
icon: AlertTriangle,
|
||||||
color: "text-red-500",
|
iconColor: "text-red-500",
|
||||||
|
bgColor: "bg-red-50 dark:bg-red-500/10",
|
||||||
prop: "overdue" as const,
|
prop: "overdue" as const,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "dueSoon",
|
key: "dueSoon",
|
||||||
label: "Due Soon",
|
label: "Due Soon",
|
||||||
icon: Clock,
|
icon: Clock,
|
||||||
color: "text-orange-500",
|
iconColor: "text-amber-500",
|
||||||
|
bgColor: "bg-amber-50 dark:bg-amber-500/10",
|
||||||
prop: "dueSoon" as const,
|
prop: "dueSoon" as const,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "active",
|
key: "active",
|
||||||
label: "Active",
|
label: "Active",
|
||||||
icon: ClipboardList,
|
icon: ClipboardList,
|
||||||
color: "text-blue-500",
|
iconColor: "text-[#0D7C66]",
|
||||||
|
bgColor: "bg-emerald-50 dark:bg-emerald-500/10",
|
||||||
prop: "active" as const,
|
prop: "active" as const,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "completed",
|
key: "completed",
|
||||||
label: "Completed",
|
label: "Completed",
|
||||||
icon: CheckCircle2,
|
icon: CheckCircle2,
|
||||||
color: "text-green-500",
|
iconColor: "text-[#E07A3A]",
|
||||||
|
bgColor: "bg-orange-50 dark:bg-orange-500/10",
|
||||||
prop: "completed" as const,
|
prop: "completed" as const,
|
||||||
},
|
},
|
||||||
] as const;
|
] as const;
|
||||||
@@ -51,17 +54,17 @@ export function StatsCards({ overdue, dueSoon, active, completed }: StatsCardsPr
|
|||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
{stats.map((stat) => (
|
{stats.map((stat) => (
|
||||||
<Link key={stat.key} href={`${basePath}/tasks`}>
|
<Link key={stat.key} href={`${basePath}/tasks`}>
|
||||||
<Card className="hover:shadow-md transition-shadow cursor-pointer">
|
<div className="group rounded-2xl border border-border bg-card p-5 hover:shadow-md hover:shadow-black/[0.03] transition-all hover:-translate-y-0.5 cursor-pointer">
|
||||||
<CardHeader className="pb-2">
|
<div className="flex items-center gap-3 mb-3">
|
||||||
<CardTitle className="text-sm text-muted-foreground flex items-center gap-2">
|
<div className={`inline-flex items-center justify-center size-9 rounded-xl ${stat.bgColor}`}>
|
||||||
<stat.icon className={`size-4 ${stat.color}`} />
|
<stat.icon className={`size-4 ${stat.iconColor}`} />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium text-muted-foreground">
|
||||||
{stat.label}
|
{stat.label}
|
||||||
</CardTitle>
|
</span>
|
||||||
</CardHeader>
|
</div>
|
||||||
<CardContent>
|
<p className="text-3xl font-bold font-heading tracking-tight">{values[stat.prop]}</p>
|
||||||
<p className="text-2xl font-bold">{values[stat.prop]}</p>
|
</div>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
Tooltip,
|
Tooltip,
|
||||||
CartesianGrid,
|
CartesianGrid,
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
import { TrendingUp } from "lucide-react";
|
||||||
|
|
||||||
interface TaskCompletionChartProps {
|
interface TaskCompletionChartProps {
|
||||||
data: { date: string; count: number }[];
|
data: { date: string; count: number }[];
|
||||||
@@ -19,11 +19,12 @@ export function TaskCompletionChart({ data }: TaskCompletionChartProps) {
|
|||||||
const hasData = data && data.length > 0;
|
const hasData = data && data.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<div className="rounded-2xl border border-border bg-card overflow-hidden h-full">
|
||||||
<CardHeader>
|
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
|
||||||
<CardTitle>Task Completions</CardTitle>
|
<h3 className="font-heading font-semibold text-base">Your Progress</h3>
|
||||||
</CardHeader>
|
</div>
|
||||||
<CardContent>
|
|
||||||
|
<div className="p-6">
|
||||||
{hasData ? (
|
{hasData ? (
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
<AreaChart data={data}>
|
<AreaChart data={data}>
|
||||||
@@ -42,19 +43,25 @@ export function TaskCompletionChart({ data }: TaskCompletionChartProps) {
|
|||||||
<Area
|
<Area
|
||||||
type="monotone"
|
type="monotone"
|
||||||
dataKey="count"
|
dataKey="count"
|
||||||
stroke="hsl(var(--primary))"
|
stroke="#E07A3A"
|
||||||
fill="hsl(var(--primary))"
|
fill="#E07A3A"
|
||||||
fillOpacity={0.2}
|
fillOpacity={0.1}
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
/>
|
/>
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center justify-center h-[300px] text-muted-foreground">
|
<div className="flex flex-col items-center justify-center h-[300px] text-center">
|
||||||
No completion data yet
|
<div className="size-12 rounded-xl bg-[#ECFDF5] flex items-center justify-center mb-3">
|
||||||
|
<TrendingUp className="size-5 text-[#0D7C66]" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-medium text-foreground">Your progress chart lives here</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1 max-w-[260px] leading-relaxed">
|
||||||
|
As you complete tasks, you'll see a visual history of everything you've accomplished.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,17 +11,17 @@ export function DemoBanner() {
|
|||||||
if (dismissed) return null;
|
if (dismissed) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="sticky top-0 z-50 flex items-center justify-center gap-3 border-b bg-muted/60 px-4 py-2 text-sm text-muted-foreground backdrop-blur-sm">
|
<div className="sticky top-0 z-50 flex items-center justify-center gap-3 border-b border-[#E07A3A]/20 bg-[#FFF3EB]/80 px-4 py-2.5 text-sm text-[#92400E] backdrop-blur-xl">
|
||||||
<p>
|
<p className="font-medium">
|
||||||
You're exploring Casera in demo mode. Changes aren't saved.
|
You're exploring Casera in demo mode. Changes aren't saved.
|
||||||
</p>
|
</p>
|
||||||
<Button size="xs" asChild>
|
<Button size="xs" className="rounded-full" asChild>
|
||||||
<Link href="/register">Sign Up Free</Link>
|
<Link href="/register">Sign Up Free</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon-xs"
|
size="icon-xs"
|
||||||
className="absolute right-2"
|
className="absolute right-2 text-[#92400E] hover:bg-[#E07A3A]/10"
|
||||||
onClick={() => setDismissed(true)}
|
onClick={() => setDismissed(true)}
|
||||||
aria-label="Dismiss banner"
|
aria-label="Dismiss banner"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import Link from "next/link";
|
|||||||
import { FileText, FileImage, File, FileSpreadsheet } from "lucide-react";
|
import { FileText, FileImage, File, FileSpreadsheet } from "lucide-react";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { WarrantyStatus } from "@/components/documents/warranty-status";
|
import { WarrantyStatus } from "@/components/documents/warranty-status";
|
||||||
import { useDataProvider } from "@/lib/demo/data-provider-context";
|
import { useDataProvider } from "@/lib/demo/data-provider-context";
|
||||||
@@ -30,40 +29,49 @@ const typeLabels: Record<string, string> = {
|
|||||||
manual: "Manual",
|
manual: "Manual",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const typeColors: Record<string, string> = {
|
||||||
|
general: "bg-muted text-muted-foreground",
|
||||||
|
warranty: "bg-amber-50 text-amber-700 dark:bg-amber-500/10 dark:text-amber-400",
|
||||||
|
receipt: "bg-emerald-50 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-400",
|
||||||
|
contract: "bg-blue-50 text-blue-700 dark:bg-blue-500/10 dark:text-blue-400",
|
||||||
|
insurance: "bg-purple-50 text-purple-700 dark:bg-purple-500/10 dark:text-purple-400",
|
||||||
|
manual: "bg-orange-50 text-orange-700 dark:bg-orange-500/10 dark:text-orange-400",
|
||||||
|
};
|
||||||
|
|
||||||
export function DocumentCard({ document: doc }: DocumentCardProps) {
|
export function DocumentCard({ document: doc }: DocumentCardProps) {
|
||||||
const Icon = getFileIcon(doc.mime_type);
|
const Icon = getFileIcon(doc.mime_type);
|
||||||
const { basePath } = useDataProvider();
|
const { basePath } = useDataProvider();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link href={`${basePath}/documents/${doc.id}`} className="block">
|
<Link href={`${basePath}/documents/${doc.id}`} className="block group">
|
||||||
<Card className="transition-colors hover:border-primary/40">
|
<div className="rounded-2xl border border-border bg-card p-5 transition-all duration-200 hover:shadow-md hover:shadow-black/[0.04] hover:-translate-y-0.5 hover:border-primary/30">
|
||||||
<CardHeader>
|
<div className="flex items-start gap-3 mb-4">
|
||||||
<div className="flex items-start gap-3">
|
<div className={`rounded-xl p-2.5 shrink-0 ${typeColors[doc.document_type] ?? typeColors.general}`} aria-hidden="true">
|
||||||
<div className="rounded-md bg-muted p-2 shrink-0" aria-hidden="true">
|
<Icon className="size-5" />
|
||||||
<Icon className="size-5 text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<CardTitle className="text-base truncate">{doc.title}</CardTitle>
|
|
||||||
<p className="text-sm text-muted-foreground truncate mt-0.5">
|
|
||||||
{doc.residence_name}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
<div className="min-w-0 flex-1">
|
||||||
<CardContent>
|
<h3 className="font-heading font-bold text-base leading-tight truncate group-hover:text-primary transition-colors">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
{doc.title}
|
||||||
<Badge variant="outline">
|
</h3>
|
||||||
{typeLabels[doc.document_type] ?? doc.document_type}
|
<p className="text-sm text-muted-foreground truncate mt-0.5">
|
||||||
</Badge>
|
{doc.residence_name}
|
||||||
{doc.document_type === "warranty" && (
|
</p>
|
||||||
<WarrantyStatus expiry_date={doc.expiry_date} />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground mt-3">
|
</div>
|
||||||
Created {format(new Date(doc.created_at), "MMM d, yyyy")}
|
|
||||||
</p>
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
</CardContent>
|
<Badge variant="outline" className="rounded-lg">
|
||||||
</Card>
|
{typeLabels[doc.document_type] ?? doc.document_type}
|
||||||
|
</Badge>
|
||||||
|
{doc.document_type === "warranty" && (
|
||||||
|
<WarrantyStatus expiry_date={doc.expiry_date} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-muted-foreground/70 mt-3">
|
||||||
|
{format(new Date(doc.created_at), "MMM d, yyyy")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {
|
import Image from "next/image";
|
||||||
Card,
|
import Link from "next/link";
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
|
|
||||||
interface AuthFormWrapperProps {
|
interface AuthFormWrapperProps {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -22,19 +17,39 @@ export function AuthFormWrapper({
|
|||||||
footer,
|
footer,
|
||||||
}: AuthFormWrapperProps) {
|
}: AuthFormWrapperProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center gap-6">
|
<div className="flex flex-col gap-8">
|
||||||
<div className="text-center">
|
{/* Mobile logo — hidden on desktop since it's in the side panel */}
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Casera</h1>
|
<div className="lg:hidden flex items-center gap-2.5">
|
||||||
|
<Link href="/" className="flex items-center gap-2.5">
|
||||||
|
<Image
|
||||||
|
src="/logo.png"
|
||||||
|
alt="Casera"
|
||||||
|
width={32}
|
||||||
|
height={32}
|
||||||
|
className="rounded-lg"
|
||||||
|
/>
|
||||||
|
<span className="font-heading text-xl font-bold text-[#1C1917]">
|
||||||
|
Casera
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card className="w-full">
|
{/* Header */}
|
||||||
<CardHeader>
|
<div>
|
||||||
<CardTitle className="text-xl">{title}</CardTitle>
|
<h1 className="font-heading text-2xl font-bold tracking-tight text-foreground">
|
||||||
{subtitle && <CardDescription>{subtitle}</CardDescription>}
|
{title}
|
||||||
</CardHeader>
|
</h1>
|
||||||
<CardContent>{children}</CardContent>
|
{subtitle && (
|
||||||
</Card>
|
<p className="mt-1.5 text-sm text-muted-foreground">{subtitle}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form content */}
|
||||||
|
<div className="rounded-2xl border border-border bg-card p-6 shadow-sm">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
{footer && (
|
{footer && (
|
||||||
<div className="text-center text-sm text-muted-foreground">
|
<div className="text-center text-sm text-muted-foreground">
|
||||||
{footer}
|
{footer}
|
||||||
|
|||||||
@@ -9,18 +9,19 @@ import { useDataProvider } from '@/lib/demo/data-provider-context';
|
|||||||
export function MobileNav() {
|
export function MobileNav() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const { basePath } = useDataProvider();
|
const { basePath } = useDataProvider();
|
||||||
const navItems = getNavItems(basePath);
|
const navItems = getNavItems(basePath).filter((item) => item.label !== 'Settings');
|
||||||
|
|
||||||
// Show the first 5 nav items on mobile (exclude Settings)
|
|
||||||
const mobileNavItems = navItems.filter((item) => item.label !== 'Settings');
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav role="navigation" aria-label="Main navigation" className="md:hidden fixed bottom-0 left-0 right-0 z-30 bg-card border-t border-border">
|
<nav
|
||||||
<div className="flex items-center justify-around px-2 py-2">
|
role="navigation"
|
||||||
{mobileNavItems.map((item) => {
|
aria-label="Main navigation"
|
||||||
|
className="md:hidden fixed bottom-0 left-0 right-0 z-30 bg-background/80 backdrop-blur-xl border-t border-border/60"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-around px-1 py-1.5 pb-[calc(0.375rem+env(safe-area-inset-bottom))]">
|
||||||
|
{navItems.map((item) => {
|
||||||
const isActive =
|
const isActive =
|
||||||
item.href === basePath
|
item.href === basePath
|
||||||
? pathname === basePath
|
? pathname === basePath || pathname === basePath + '/'
|
||||||
: pathname.startsWith(item.href);
|
: pathname.startsWith(item.href);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -30,14 +31,14 @@ export function MobileNav() {
|
|||||||
aria-label={item.label}
|
aria-label={item.label}
|
||||||
aria-current={isActive ? 'page' : undefined}
|
aria-current={isActive ? 'page' : undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex flex-col items-center gap-1 px-2 py-1 rounded-md text-xs transition-colors',
|
'flex flex-col items-center gap-0.5 px-3 py-1.5 rounded-lg text-[10px] transition-colors',
|
||||||
isActive
|
isActive
|
||||||
? 'text-primary'
|
? 'text-foreground'
|
||||||
: 'text-muted-foreground hover:text-foreground'
|
: 'text-muted-foreground'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<item.icon className="size-5" aria-hidden="true" />
|
<item.icon className={cn("size-[22px]", isActive && "text-primary")} aria-hidden="true" />
|
||||||
<span>{item.label}</span>
|
<span className="font-medium">{item.label}</span>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Home, Building2, CheckSquare, HardHat, FileText, Settings } from 'lucide-react';
|
import { Home, CheckSquare, Building2, HardHat, FileText, Settings } from 'lucide-react';
|
||||||
|
|
||||||
export interface NavItem {
|
export interface NavItem {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -9,10 +9,10 @@ export interface NavItem {
|
|||||||
export function getNavItems(basePath: string): NavItem[] {
|
export function getNavItems(basePath: string): NavItem[] {
|
||||||
return [
|
return [
|
||||||
{ label: 'Home', href: basePath, icon: Home },
|
{ label: 'Home', href: basePath, icon: Home },
|
||||||
{ label: 'Residences', href: `${basePath}/residences`, icon: Building2 },
|
|
||||||
{ label: 'Tasks', href: `${basePath}/tasks`, icon: CheckSquare },
|
{ label: 'Tasks', href: `${basePath}/tasks`, icon: CheckSquare },
|
||||||
{ label: 'Contractors', href: `${basePath}/contractors`, icon: HardHat },
|
{ label: 'Homes', href: `${basePath}/residences`, icon: Building2 },
|
||||||
{ label: 'Documents', href: `${basePath}/documents`, icon: FileText },
|
{ label: 'Pros', href: `${basePath}/contractors`, icon: HardHat },
|
||||||
|
{ label: 'Docs', href: `${basePath}/documents`, icon: FileText },
|
||||||
{ label: 'Settings', href: `${basePath}/settings`, icon: Settings },
|
{ label: 'Settings', href: `${basePath}/settings`, icon: Settings },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import Image from 'next/image';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Separator } from '@/components/ui/separator';
|
|
||||||
import { getNavItems } from './nav-items';
|
import { getNavItems } from './nav-items';
|
||||||
import { useDataProvider } from '@/lib/demo/data-provider-context';
|
import { useDataProvider } from '@/lib/demo/data-provider-context';
|
||||||
|
|
||||||
@@ -12,23 +12,32 @@ export function Sidebar() {
|
|||||||
const { basePath } = useDataProvider();
|
const { basePath } = useDataProvider();
|
||||||
const navItems = getNavItems(basePath);
|
const navItems = getNavItems(basePath);
|
||||||
|
|
||||||
|
const mainNavItems = navItems.filter((item) => item.label !== 'Settings');
|
||||||
|
const settingsItem = navItems.find((item) => item.label === 'Settings');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="hidden md:flex md:flex-col md:fixed md:inset-y-0 md:left-0 md:z-30 w-16 lg:w-64 bg-card border-r border-border">
|
<aside className="hidden md:flex md:flex-col md:fixed md:inset-y-0 md:left-0 md:z-30 w-16 lg:w-64 bg-card border-r border-border">
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<div className="flex items-center h-16 px-4 lg:px-6">
|
<div className="flex items-center h-16 px-3 lg:px-5">
|
||||||
<Link href={basePath} className="flex items-center gap-2">
|
<Link href={basePath} className="flex items-center gap-2.5 group">
|
||||||
<span className="text-xl font-bold text-primary">C</span>
|
<Image
|
||||||
<span className="hidden lg:inline text-xl font-bold text-foreground">
|
src="/logo.png"
|
||||||
|
alt="Casera"
|
||||||
|
width={32}
|
||||||
|
height={32}
|
||||||
|
className="rounded-lg transition-transform group-hover:scale-105"
|
||||||
|
/>
|
||||||
|
<span className="hidden lg:inline font-heading text-lg font-bold text-foreground tracking-tight">
|
||||||
Casera
|
Casera
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Separator />
|
<div className="mx-3 lg:mx-4 h-px bg-border" />
|
||||||
|
|
||||||
{/* Navigation */}
|
{/* Main navigation */}
|
||||||
<nav role="navigation" aria-label="Main navigation" className="flex-1 flex flex-col gap-1 p-2 lg:p-3">
|
<nav role="navigation" aria-label="Main navigation" className="flex-1 flex flex-col gap-0.5 p-2 lg:p-3 mt-2">
|
||||||
{navItems.map((item) => {
|
{mainNavItems.map((item) => {
|
||||||
const isActive =
|
const isActive =
|
||||||
item.href === basePath
|
item.href === basePath
|
||||||
? pathname === basePath
|
? pathname === basePath
|
||||||
@@ -41,19 +50,55 @@ export function Sidebar() {
|
|||||||
aria-label={item.label}
|
aria-label={item.label}
|
||||||
aria-current={isActive ? 'page' : undefined}
|
aria-current={isActive ? 'page' : undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-3 rounded-md px-3 py-2.5 text-sm font-medium transition-colors',
|
'group flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium transition-all duration-200',
|
||||||
'hover:bg-accent hover:text-accent-foreground',
|
|
||||||
isActive
|
isActive
|
||||||
? 'bg-accent text-accent-foreground'
|
? 'bg-primary/10 text-primary shadow-sm'
|
||||||
: 'text-muted-foreground'
|
: 'text-muted-foreground hover:bg-accent hover:text-foreground'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<item.icon className="size-5 shrink-0" aria-hidden="true" />
|
<item.icon
|
||||||
|
className={cn(
|
||||||
|
'size-[18px] shrink-0 transition-colors',
|
||||||
|
isActive ? 'text-primary' : 'text-muted-foreground group-hover:text-foreground'
|
||||||
|
)}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
<span className="hidden lg:inline">{item.label}</span>
|
<span className="hidden lg:inline">{item.label}</span>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
{/* Settings at bottom */}
|
||||||
|
{settingsItem && (
|
||||||
|
<div className="p-2 lg:p-3 border-t border-border">
|
||||||
|
{(() => {
|
||||||
|
const isActive = pathname.startsWith(settingsItem.href);
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={settingsItem.href}
|
||||||
|
aria-label={settingsItem.label}
|
||||||
|
aria-current={isActive ? 'page' : undefined}
|
||||||
|
className={cn(
|
||||||
|
'group flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium transition-all duration-200',
|
||||||
|
isActive
|
||||||
|
? 'bg-primary/10 text-primary shadow-sm'
|
||||||
|
: 'text-muted-foreground hover:bg-accent hover:text-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<settingsItem.icon
|
||||||
|
className={cn(
|
||||||
|
'size-[18px] shrink-0 transition-colors',
|
||||||
|
isActive ? 'text-primary' : 'text-muted-foreground group-hover:text-foreground'
|
||||||
|
)}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span className="hidden lg:inline">{settingsItem.label}</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
import Image from 'next/image';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { usePathname, useRouter } from 'next/navigation';
|
||||||
import { LogOut, Settings, User } from 'lucide-react';
|
import { LogOut, Settings, User } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
import { NotificationBell } from '@/components/notifications/notification-bell';
|
import { NotificationBell } from '@/components/notifications/notification-bell';
|
||||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||||
import {
|
import {
|
||||||
@@ -12,10 +15,19 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { useDataProvider } from '@/lib/demo/data-provider-context';
|
import { useDataProvider } from '@/lib/demo/data-provider-context';
|
||||||
|
import { useAuthStore } from '@/stores/auth';
|
||||||
|
import { getNavItems } from './nav-items';
|
||||||
|
|
||||||
export function TopBar() {
|
export function TopBar() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
const { basePath } = useDataProvider();
|
const { basePath } = useDataProvider();
|
||||||
|
const user = useAuthStore((s) => s.user);
|
||||||
|
|
||||||
|
const navItems = getNavItems(basePath).filter((item) => item.label !== 'Settings');
|
||||||
|
const initials = user
|
||||||
|
? `${user.first_name?.[0] ?? ''}${user.last_name?.[0] ?? ''}`.toUpperCase() || 'U'
|
||||||
|
: 'U';
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -31,42 +43,84 @@ export function TopBar() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-20 flex items-center justify-between h-16 px-4 lg:px-6 bg-card border-b border-border">
|
<header className="sticky top-0 z-30 bg-background/80 backdrop-blur-xl border-b border-border/60">
|
||||||
{/* Mobile logo - hidden on desktop since sidebar has it */}
|
<div className="max-w-7xl mx-auto flex items-center justify-between h-16 px-6">
|
||||||
<div className="md:hidden">
|
{/* Logo */}
|
||||||
<span className="text-xl font-bold text-foreground">Casera</span>
|
<Link href={basePath} className="flex items-center gap-2.5 shrink-0 group">
|
||||||
</div>
|
<Image
|
||||||
|
src="/logo.png"
|
||||||
|
alt="Casera"
|
||||||
|
width={32}
|
||||||
|
height={32}
|
||||||
|
className="rounded-lg transition-transform group-hover:scale-105"
|
||||||
|
/>
|
||||||
|
<span className="font-heading text-xl font-bold text-foreground tracking-tight">
|
||||||
|
Casera
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
{/* Spacer for desktop (logo is in sidebar) */}
|
{/* Desktop nav links */}
|
||||||
<div className="hidden md:block" />
|
<nav className="hidden md:flex items-center gap-1" role="navigation" aria-label="Main navigation">
|
||||||
|
{navItems.map((item) => {
|
||||||
|
const isActive =
|
||||||
|
item.href === basePath
|
||||||
|
? pathname === basePath || pathname === basePath + '/'
|
||||||
|
: pathname.startsWith(item.href);
|
||||||
|
|
||||||
{/* Notifications + Profile */}
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<Link
|
||||||
<NotificationBell />
|
key={item.href}
|
||||||
<DropdownMenu>
|
href={item.href}
|
||||||
<DropdownMenuTrigger asChild>
|
aria-current={isActive ? 'page' : undefined}
|
||||||
<button aria-label="User menu" className="flex items-center gap-2 rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2">
|
className={cn(
|
||||||
<Avatar>
|
'relative px-3.5 py-1.5 text-sm font-medium rounded-lg transition-colors',
|
||||||
<AvatarFallback>U</AvatarFallback>
|
isActive
|
||||||
</Avatar>
|
? 'text-foreground'
|
||||||
</button>
|
: 'text-muted-foreground hover:text-foreground'
|
||||||
</DropdownMenuTrigger>
|
)}
|
||||||
<DropdownMenuContent align="end" className="w-48">
|
>
|
||||||
<DropdownMenuItem onClick={() => router.push(`${basePath}/settings`)}>
|
{item.label}
|
||||||
<User className="size-4" />
|
{isActive && (
|
||||||
Profile
|
<span className="absolute bottom-0 left-3.5 right-3.5 h-[2px] bg-foreground rounded-full" />
|
||||||
</DropdownMenuItem>
|
)}
|
||||||
<DropdownMenuItem onClick={() => router.push(`${basePath}/settings`)}>
|
</Link>
|
||||||
<Settings className="size-4" />
|
);
|
||||||
Settings
|
})}
|
||||||
</DropdownMenuItem>
|
</nav>
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem onClick={handleLogout} variant="destructive">
|
{/* Right section */}
|
||||||
<LogOut className="size-4" />
|
<div className="flex items-center gap-1.5">
|
||||||
Logout
|
<NotificationBell />
|
||||||
</DropdownMenuItem>
|
<DropdownMenu>
|
||||||
</DropdownMenuContent>
|
<DropdownMenuTrigger asChild>
|
||||||
</DropdownMenu>
|
<button
|
||||||
|
aria-label="User menu"
|
||||||
|
className="flex items-center gap-2 rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||||
|
>
|
||||||
|
<Avatar className="size-8 border border-border/60">
|
||||||
|
<AvatarFallback className="bg-primary/10 text-primary text-xs font-semibold">
|
||||||
|
{initials}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-48">
|
||||||
|
<DropdownMenuItem onClick={() => router.push(`${basePath}/settings/profile`)}>
|
||||||
|
<User className="size-4" />
|
||||||
|
Profile
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => router.push(`${basePath}/settings`)}>
|
||||||
|
<Settings className="size-4" />
|
||||||
|
Settings
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem onClick={handleLogout} variant="destructive">
|
||||||
|
<LogOut className="size-4" />
|
||||||
|
Sign out
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { MapPin } from "lucide-react";
|
import { MapPin } from "lucide-react";
|
||||||
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { useDataProvider } from "@/lib/demo/data-provider-context";
|
import { useDataProvider } from "@/lib/demo/data-provider-context";
|
||||||
import type { MyResidenceResponse } from "@/lib/api/residences";
|
import type { MyResidenceResponse } from "@/lib/api/residences";
|
||||||
@@ -21,36 +20,36 @@ export function ResidenceCard({ data }: ResidenceCardProps) {
|
|||||||
.join(", ");
|
.join(", ");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link href={`${basePath}/residences/${residence.id}`} className="block">
|
<Link href={`${basePath}/residences/${residence.id}`} className="block group">
|
||||||
<Card className="transition-colors hover:border-primary/40">
|
<div className="rounded-2xl border border-border bg-card p-5 transition-all duration-200 hover:shadow-md hover:shadow-black/[0.04] hover:-translate-y-0.5 hover:border-primary/30">
|
||||||
<CardHeader>
|
<div className="mb-3">
|
||||||
<CardTitle className="text-base">{residence.name}</CardTitle>
|
<h3 className="font-heading font-bold text-base leading-tight group-hover:text-primary transition-colors">
|
||||||
|
{residence.name}
|
||||||
|
</h3>
|
||||||
{address && (
|
{address && (
|
||||||
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
<div className="flex items-center gap-1.5 text-sm text-muted-foreground mt-1.5">
|
||||||
<MapPin className="size-3.5 shrink-0" aria-hidden="true" />
|
<MapPin className="size-3.5 shrink-0" aria-hidden="true" />
|
||||||
<span className="sr-only">Address:</span>
|
<span className="sr-only">Address:</span>
|
||||||
<span className="truncate">{address}</span>
|
<span className="truncate">{address}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardHeader>
|
</div>
|
||||||
<CardContent>
|
<div className="flex flex-wrap gap-2">
|
||||||
<div className="flex flex-wrap gap-2">
|
{task_summary.overdue > 0 && (
|
||||||
{task_summary.overdue > 0 && (
|
<Badge variant="destructive" className="rounded-lg">
|
||||||
<Badge variant="destructive">
|
{task_summary.overdue} overdue
|
||||||
{task_summary.overdue} overdue
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{task_summary.due_soon > 0 && (
|
|
||||||
<Badge variant="secondary">
|
|
||||||
{task_summary.due_soon} due soon
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
<Badge variant="outline">
|
|
||||||
{task_summary.total} {task_summary.total === 1 ? "task" : "tasks"}
|
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
)}
|
||||||
</CardContent>
|
{task_summary.due_soon > 0 && (
|
||||||
</Card>
|
<Badge variant="secondary" className="rounded-lg">
|
||||||
|
{task_summary.due_soon} due soon
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
<Badge variant="outline" className="rounded-lg">
|
||||||
|
{task_summary.total} {task_summary.total === 1 ? "task" : "tasks"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,12 +11,16 @@ interface EmptyStateProps {
|
|||||||
|
|
||||||
export function EmptyState({ icon: Icon, title, description, actionLabel, onAction }: EmptyStateProps) {
|
export function EmptyState({ icon: Icon, title, description, actionLabel, onAction }: EmptyStateProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
<div className="flex flex-col items-center justify-center py-20 text-center">
|
||||||
<div className="rounded-full bg-muted p-4 mb-4"><Icon className="size-8 text-muted-foreground" /></div>
|
<div className="size-16 rounded-2xl bg-[#FFF3EB] flex items-center justify-center mb-5">
|
||||||
<h3 className="text-lg font-semibold">{title}</h3>
|
<Icon className="size-7 text-[#E07A3A]" />
|
||||||
<p className="text-muted-foreground mt-1 max-w-sm">{description}</p>
|
</div>
|
||||||
|
<h3 className="font-heading text-lg font-bold">{title}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mt-2 max-w-sm leading-relaxed">{description}</p>
|
||||||
{actionLabel && onAction && (
|
{actionLabel && onAction && (
|
||||||
<Button onClick={onAction} className="mt-4"><Plus className="size-4 mr-2" />{actionLabel}</Button>
|
<Button onClick={onAction} className="mt-6 rounded-xl shadow-sm shadow-primary/20">
|
||||||
|
<Plus className="size-4 mr-2" />{actionLabel}
|
||||||
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,10 +9,12 @@ interface ErrorBannerProps {
|
|||||||
|
|
||||||
export function ErrorBanner({ message = "Something went wrong. Please try again.", onRetry }: ErrorBannerProps) {
|
export function ErrorBanner({ message = "Something went wrong. Please try again.", onRetry }: ErrorBannerProps) {
|
||||||
return (
|
return (
|
||||||
<div role="alert" aria-live="assertive" className="rounded-lg border border-destructive/50 bg-destructive/10 p-4 flex items-center gap-3">
|
<div role="alert" aria-live="assertive" className="rounded-2xl border border-destructive/30 bg-destructive/5 p-4 flex items-center gap-3">
|
||||||
<AlertTriangle className="size-5 text-destructive shrink-0" aria-hidden="true" />
|
<div className="size-9 rounded-xl bg-destructive/10 flex items-center justify-center shrink-0">
|
||||||
|
<AlertTriangle className="size-4 text-destructive" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
<p className="text-sm text-destructive flex-1">{message}</p>
|
<p className="text-sm text-destructive flex-1">{message}</p>
|
||||||
{onRetry && <Button variant="outline" size="sm" onClick={onRetry}>Retry</Button>}
|
{onRetry && <Button variant="outline" size="sm" onClick={onRetry} className="rounded-lg shrink-0">Retry</Button>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ export function LoadingSkeleton({ variant, count = 4 }: LoadingSkeletonProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{Array.from({ length: count }).map((_, i) => (
|
{Array.from({ length: count }).map((_, i) => (
|
||||||
<div key={i} className="rounded-lg border p-4 space-y-3">
|
<div key={i} className="rounded-2xl border bg-card p-5 space-y-3">
|
||||||
<Skeleton className="h-5 w-3/4" />
|
<Skeleton className="h-5 w-3/4 rounded-lg" />
|
||||||
<Skeleton className="h-4 w-1/2" />
|
<Skeleton className="h-4 w-1/2 rounded-lg" />
|
||||||
<div className="flex gap-2 pt-2"><Skeleton className="h-6 w-16 rounded-full" /><Skeleton className="h-6 w-16 rounded-full" /></div>
|
<div className="flex gap-2 pt-2"><Skeleton className="h-6 w-16 rounded-full" /><Skeleton className="h-6 w-16 rounded-full" /></div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -23,10 +23,10 @@ export function LoadingSkeleton({ variant, count = 4 }: LoadingSkeletonProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{Array.from({ length: count }).map((_, i) => (
|
{Array.from({ length: count }).map((_, i) => (
|
||||||
<div key={i} className="flex items-center gap-4 rounded-lg border p-4">
|
<div key={i} className="flex items-center gap-4 rounded-2xl border bg-card p-4">
|
||||||
<Skeleton className="size-10 rounded-full" />
|
<Skeleton className="size-10 rounded-xl" />
|
||||||
<div className="flex-1 space-y-2"><Skeleton className="h-4 w-1/3" /><Skeleton className="h-3 w-1/2" /></div>
|
<div className="flex-1 space-y-2"><Skeleton className="h-4 w-1/3 rounded-lg" /><Skeleton className="h-3 w-1/2 rounded-lg" /></div>
|
||||||
<Skeleton className="h-8 w-20" />
|
<Skeleton className="h-8 w-20 rounded-lg" />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -35,10 +35,10 @@ export function LoadingSkeleton({ variant, count = 4 }: LoadingSkeletonProps) {
|
|||||||
if (variant === "detail") {
|
if (variant === "detail") {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<Skeleton className="h-8 w-1/3" />
|
<Skeleton className="h-8 w-1/3 rounded-lg" />
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
{Array.from({ length: 6 }).map((_, i) => (
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
<div key={i} className="space-y-2"><Skeleton className="h-3 w-20" /><Skeleton className="h-5 w-full" /></div>
|
<div key={i} className="space-y-2"><Skeleton className="h-3 w-20 rounded-lg" /><Skeleton className="h-5 w-full rounded-lg" /></div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -47,8 +47,8 @@ export function LoadingSkeleton({ variant, count = 4 }: LoadingSkeletonProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="flex gap-4 overflow-x-auto pb-4">
|
<div className="flex gap-4 overflow-x-auto pb-4">
|
||||||
{Array.from({ length: count }).map((_, i) => (
|
{Array.from({ length: count }).map((_, i) => (
|
||||||
<div key={i} className="min-w-[280px] rounded-lg border p-4 space-y-3">
|
<div key={i} className="min-w-[280px] rounded-2xl border bg-card p-4 space-y-3">
|
||||||
<Skeleton className="h-5 w-24" /><Skeleton className="h-24 w-full rounded-md" /><Skeleton className="h-24 w-full rounded-md" />
|
<Skeleton className="h-5 w-24 rounded-lg" /><Skeleton className="h-24 w-full rounded-xl" /><Skeleton className="h-24 w-full rounded-xl" />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,13 +14,15 @@ export function PageHeader({ title, description, actionLabel, onAction, children
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight">{title}</h1>
|
<h1 className="font-heading text-2xl font-bold tracking-tight">{title}</h1>
|
||||||
{description && <p className="text-muted-foreground mt-1">{description}</p>}
|
{description && <p className="text-sm text-muted-foreground mt-1">{description}</p>}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{children}
|
{children}
|
||||||
{actionLabel && onAction && (
|
{actionLabel && onAction && (
|
||||||
<Button onClick={onAction}><Plus className="size-4 mr-2" />{actionLabel}</Button>
|
<Button onClick={onAction} className="rounded-xl shadow-sm">
|
||||||
|
<Plus className="size-4 mr-2" />{actionLabel}
|
||||||
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,30 +8,30 @@ import { TaskCard } from "./task-card";
|
|||||||
import type { KanbanColumn as KanbanColumnType } from "@/lib/api/tasks";
|
import type { KanbanColumn as KanbanColumnType } from "@/lib/api/tasks";
|
||||||
|
|
||||||
const COLUMN_COLORS: Record<string, string> = {
|
const COLUMN_COLORS: Record<string, string> = {
|
||||||
overdue_tasks: "border-red-500/50 bg-red-50/50 dark:bg-red-950/20",
|
overdue_tasks: "border-red-200 bg-red-50/40 dark:border-red-500/20 dark:bg-red-950/20",
|
||||||
due_soon_tasks: "border-yellow-500/50 bg-yellow-50/50 dark:bg-yellow-950/20",
|
due_soon_tasks: "border-amber-200 bg-amber-50/40 dark:border-amber-500/20 dark:bg-amber-950/20",
|
||||||
upcoming_tasks: "border-blue-500/50 bg-blue-50/50 dark:bg-blue-950/20",
|
upcoming_tasks: "border-blue-200 bg-blue-50/40 dark:border-blue-500/20 dark:bg-blue-950/20",
|
||||||
in_progress_tasks: "border-green-500/50 bg-green-50/50 dark:bg-green-950/20",
|
in_progress_tasks: "border-emerald-200 bg-emerald-50/40 dark:border-emerald-500/20 dark:bg-emerald-950/20",
|
||||||
completed_tasks: "border-gray-500/50 bg-gray-50/50 dark:bg-gray-950/20",
|
completed_tasks: "border-stone-200 bg-stone-50/40 dark:border-stone-500/20 dark:bg-stone-950/20",
|
||||||
cancelled_tasks: "border-slate-500/50 bg-slate-50/50 dark:bg-slate-950/20",
|
cancelled_tasks: "border-slate-200 bg-slate-50/40 dark:border-slate-500/20 dark:bg-slate-950/20",
|
||||||
};
|
};
|
||||||
|
|
||||||
const COLUMN_HEADER_COLORS: Record<string, string> = {
|
const COLUMN_HEADER_COLORS: Record<string, string> = {
|
||||||
overdue_tasks: "text-red-700 dark:text-red-400",
|
overdue_tasks: "text-red-700 dark:text-red-400",
|
||||||
due_soon_tasks: "text-yellow-700 dark:text-yellow-400",
|
due_soon_tasks: "text-amber-700 dark:text-amber-400",
|
||||||
upcoming_tasks: "text-blue-700 dark:text-blue-400",
|
upcoming_tasks: "text-blue-700 dark:text-blue-400",
|
||||||
in_progress_tasks: "text-green-700 dark:text-green-400",
|
in_progress_tasks: "text-emerald-700 dark:text-emerald-400",
|
||||||
completed_tasks: "text-gray-700 dark:text-gray-400",
|
completed_tasks: "text-stone-600 dark:text-stone-400",
|
||||||
cancelled_tasks: "text-slate-700 dark:text-slate-400",
|
cancelled_tasks: "text-slate-600 dark:text-slate-400",
|
||||||
};
|
};
|
||||||
|
|
||||||
const COUNT_BADGE_COLORS: Record<string, string> = {
|
const COUNT_BADGE_COLORS: Record<string, string> = {
|
||||||
overdue_tasks: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200",
|
overdue_tasks: "bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-300",
|
||||||
due_soon_tasks: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200",
|
due_soon_tasks: "bg-amber-100 text-amber-700 dark:bg-amber-900/50 dark:text-amber-300",
|
||||||
upcoming_tasks: "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200",
|
upcoming_tasks: "bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300",
|
||||||
in_progress_tasks: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200",
|
in_progress_tasks: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/50 dark:text-emerald-300",
|
||||||
completed_tasks: "bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200",
|
completed_tasks: "bg-stone-100 text-stone-700 dark:bg-stone-900/50 dark:text-stone-300",
|
||||||
cancelled_tasks: "bg-slate-100 text-slate-800 dark:bg-slate-900 dark:text-slate-200",
|
cancelled_tasks: "bg-slate-100 text-slate-700 dark:bg-slate-900/50 dark:text-slate-300",
|
||||||
};
|
};
|
||||||
|
|
||||||
interface KanbanColumnProps {
|
interface KanbanColumnProps {
|
||||||
@@ -94,15 +94,15 @@ export function KanbanColumn({ column }: KanbanColumnProps) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col min-w-[280px] sm:min-w-0 sm:flex-1 max-w-[320px] sm:max-w-none snap-center sm:snap-align-none rounded-lg border-2 p-3",
|
"flex flex-col min-w-[280px] sm:min-w-0 sm:flex-1 max-w-[320px] sm:max-w-none snap-center sm:snap-align-none rounded-2xl border p-3",
|
||||||
COLUMN_COLORS[column.name] ?? "border-border bg-muted/30",
|
COLUMN_COLORS[column.name] ?? "border-border bg-muted/30",
|
||||||
isOver && "ring-2 ring-primary"
|
isOver && "ring-2 ring-primary ring-offset-2"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<div className="flex items-center gap-2 mb-3 px-1">
|
||||||
<h3
|
<h3
|
||||||
className={cn(
|
className={cn(
|
||||||
"font-semibold text-sm",
|
"font-heading font-semibold text-sm",
|
||||||
COLUMN_HEADER_COLORS[column.name]
|
COLUMN_HEADER_COLORS[column.name]
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -110,7 +110,7 @@ export function KanbanColumn({ column }: KanbanColumnProps) {
|
|||||||
</h3>
|
</h3>
|
||||||
<Badge
|
<Badge
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className={cn("text-xs", COUNT_BADGE_COLORS[column.name])}
|
className={cn("text-xs rounded-md", COUNT_BADGE_COLORS[column.name])}
|
||||||
>
|
>
|
||||||
{column.count}
|
{column.count}
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -121,7 +121,7 @@ export function KanbanColumn({ column }: KanbanColumnProps) {
|
|||||||
<DraggableTask key={task.id} task={task} />
|
<DraggableTask key={task.id} task={task} />
|
||||||
))}
|
))}
|
||||||
{column.tasks.length === 0 && (
|
{column.tasks.length === 0 && (
|
||||||
<div className="flex items-center justify-center h-[60px] text-xs text-muted-foreground rounded-md border border-dashed">
|
<div className="flex items-center justify-center h-[60px] text-xs text-muted-foreground rounded-xl border border-dashed border-border/60">
|
||||||
No tasks
|
No tasks
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -18,23 +18,23 @@ export function TaskCard({ task, isDragging }: TaskCardProps) {
|
|||||||
<Link href={`${basePath}/tasks/${task.id}`}>
|
<Link href={`${basePath}/tasks/${task.id}`}>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-lg border bg-card p-3 space-y-2 transition-shadow hover:shadow-md cursor-grab",
|
"rounded-xl border bg-card p-3.5 space-y-2 transition-all duration-200 hover:shadow-md hover:shadow-black/[0.04] cursor-grab",
|
||||||
isDragging && "shadow-lg ring-2 ring-primary"
|
isDragging && "shadow-lg ring-2 ring-primary rotate-[1deg] scale-[1.02]"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="font-medium text-sm leading-tight line-clamp-2">
|
<div className="font-medium text-sm leading-snug line-clamp-2">
|
||||||
{task.title}
|
{task.title}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{task.residence_name && (
|
{task.residence_name && (
|
||||||
<p className="text-xs text-muted-foreground truncate">
|
<p className="text-xs text-muted-foreground/70 truncate">
|
||||||
{task.residence_name}
|
{task.residence_name}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{task.priority && (
|
{task.priority && (
|
||||||
<Badge variant="outline" className="text-xs px-1.5 py-0">
|
<Badge variant="outline" className="text-xs px-1.5 py-0 rounded-md">
|
||||||
{task.priority.icon && (
|
{task.priority.icon && (
|
||||||
<span className="mr-0.5">{task.priority.icon}</span>
|
<span className="mr-0.5">{task.priority.icon}</span>
|
||||||
)}
|
)}
|
||||||
@@ -42,7 +42,7 @@ export function TaskCard({ task, isDragging }: TaskCardProps) {
|
|||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{task.category && (
|
{task.category && (
|
||||||
<Badge variant="secondary" className="text-xs px-1.5 py-0">
|
<Badge variant="secondary" className="text-xs px-1.5 py-0 rounded-md">
|
||||||
{task.category.icon && (
|
{task.category.icon && (
|
||||||
<span className="mr-0.5">{task.category.icon}</span>
|
<span className="mr-0.5">{task.category.icon}</span>
|
||||||
)}
|
)}
|
||||||
@@ -51,7 +51,7 @@ export function TaskCard({ task, isDragging }: TaskCardProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
<div className="flex items-center gap-3 text-xs text-muted-foreground/70">
|
||||||
{task.due_date && (
|
{task.due_date && (
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<Calendar className="size-3" aria-hidden="true" />
|
<Calendar className="size-3" aria-hidden="true" />
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
<div
|
<div
|
||||||
data-slot="card"
|
data-slot="card"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm",
|
"flex flex-col gap-6 rounded-2xl border bg-card py-6 text-card-foreground shadow-sm transition-shadow",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -26,28 +26,28 @@ export const themes: ThemeDefinition[] = [
|
|||||||
{
|
{
|
||||||
id: "default",
|
id: "default",
|
||||||
name: "Default",
|
name: "Default",
|
||||||
description: "Vibrant iOS system colors",
|
description: "Warm orange with teal accents",
|
||||||
light: {
|
light: {
|
||||||
primary: "#0079FF",
|
primary: "#E07A3A",
|
||||||
secondary: "#5AC7F9",
|
secondary: "#0D7C66",
|
||||||
accent: "#FF9400",
|
accent: "#D4A574",
|
||||||
error: "#FF3A2F",
|
error: "#DC2626",
|
||||||
bgPrimary: "#FFFFFF",
|
bgPrimary: "#FFFFFF",
|
||||||
bgSecondary: "#F1F7F7",
|
bgSecondary: "#F7F7F7",
|
||||||
textPrimary: "#111111",
|
textPrimary: "#1C1917",
|
||||||
textSecondary: "#3C3C3C",
|
textSecondary: "#78716C",
|
||||||
textOnPrimary: "#FFFFFF",
|
textOnPrimary: "#FFFFFF",
|
||||||
},
|
},
|
||||||
dark: {
|
dark: {
|
||||||
primary: "#0984FF",
|
primary: "#F0A070",
|
||||||
secondary: "#63D2FF",
|
secondary: "#4FC9AF",
|
||||||
accent: "#FF9F09",
|
accent: "#DDB892",
|
||||||
error: "#FF4539",
|
error: "#EF4444",
|
||||||
bgPrimary: "#1C1C1C",
|
bgPrimary: "#0A0A0A",
|
||||||
bgSecondary: "#2C2C2C",
|
bgSecondary: "#161616",
|
||||||
textPrimary: "#FFFFFF",
|
textPrimary: "#FAFAFA",
|
||||||
textSecondary: "#EBEBEB",
|
textSecondary: "#A1A1A1",
|
||||||
textOnPrimary: "#FFFFFF",
|
textOnPrimary: "#0A0A0A",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -3,16 +3,6 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useThemeStore } from "@/stores/theme";
|
import { useThemeStore } from "@/stores/theme";
|
||||||
|
|
||||||
/**
|
|
||||||
* ThemeProvider syncs the Zustand theme store with the DOM.
|
|
||||||
*
|
|
||||||
* It sets:
|
|
||||||
* - `data-theme` attribute on <html> for CSS theme selection
|
|
||||||
* - `dark` class on <html> for dark mode (respects system preference when mode is "system")
|
|
||||||
*
|
|
||||||
* Render this component once, near the root of the app (e.g., in layout.tsx).
|
|
||||||
* It renders no visible DOM — it only produces side effects.
|
|
||||||
*/
|
|
||||||
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||||
const themeId = useThemeStore((s) => s.themeId);
|
const themeId = useThemeStore((s) => s.themeId);
|
||||||
const mode = useThemeStore((s) => s.mode);
|
const mode = useThemeStore((s) => s.mode);
|
||||||
@@ -51,5 +41,22 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
|||||||
return () => mql.removeEventListener("change", handler);
|
return () => mql.removeEventListener("change", handler);
|
||||||
}, [mode]);
|
}, [mode]);
|
||||||
|
|
||||||
|
// On mount, migrate any stale persisted state to light mode
|
||||||
|
useEffect(() => {
|
||||||
|
const stored = localStorage.getItem("casera-theme");
|
||||||
|
if (stored) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(stored);
|
||||||
|
if (parsed?.state?.mode === "system") {
|
||||||
|
parsed.state.mode = "light";
|
||||||
|
localStorage.setItem("casera-theme", JSON.stringify(parsed));
|
||||||
|
useThemeStore.getState().setMode("light");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -15,7 +15,7 @@ export const useThemeStore = create<ThemeState>()(
|
|||||||
persist(
|
persist(
|
||||||
(set) => ({
|
(set) => ({
|
||||||
themeId: DEFAULT_THEME_ID,
|
themeId: DEFAULT_THEME_ID,
|
||||||
mode: "system",
|
mode: "light",
|
||||||
setTheme: (themeId: string) => set({ themeId }),
|
setTheme: (themeId: string) => set({ themeId }),
|
||||||
setMode: (mode: ColorMode) => set({ mode }),
|
setMode: (mode: ColorMode) => set({ mode }),
|
||||||
}),
|
}),
|
||||||
|
|||||||
+30
-30
@@ -32,19 +32,19 @@
|
|||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
/* ================================================================== */
|
/* ================================================================== */
|
||||||
/* THEME: Default — Vibrant iOS system colors */
|
/* THEME: Default — Warm orange brand with teal accents */
|
||||||
/* ================================================================== */
|
/* ================================================================== */
|
||||||
[data-theme="default"],
|
[data-theme="default"],
|
||||||
:root {
|
:root {
|
||||||
/* App color tokens – light */
|
/* App color tokens – light */
|
||||||
--color-primary: #0079FF;
|
--color-primary: #E07A3A;
|
||||||
--color-secondary: #5AC7F9;
|
--color-secondary: #0D7C66;
|
||||||
--color-accent: #FF9400;
|
--color-accent: #D4A574;
|
||||||
--color-error: #FF3A2F;
|
--color-error: #DC2626;
|
||||||
--color-bg-primary: #FFFFFF;
|
--color-bg-primary: #FFFFFF;
|
||||||
--color-bg-secondary: #F1F7F7;
|
--color-bg-secondary: #F7F7F7;
|
||||||
--color-text-primary: #111111;
|
--color-text-primary: #1C1917;
|
||||||
--color-text-secondary: #3C3C3C;
|
--color-text-secondary: #78716C;
|
||||||
--color-text-on-primary: #FFFFFF;
|
--color-text-on-primary: #FFFFFF;
|
||||||
|
|
||||||
/* shadcn overrides */
|
/* shadcn overrides */
|
||||||
@@ -56,38 +56,38 @@
|
|||||||
--accent-foreground: var(--color-text-primary);
|
--accent-foreground: var(--color-text-primary);
|
||||||
--background: var(--color-bg-primary);
|
--background: var(--color-bg-primary);
|
||||||
--foreground: var(--color-text-primary);
|
--foreground: var(--color-text-primary);
|
||||||
--card: var(--color-bg-secondary);
|
--card: #FFFFFF;
|
||||||
--card-foreground: var(--color-text-primary);
|
--card-foreground: var(--color-text-primary);
|
||||||
--popover: var(--color-bg-primary);
|
--popover: #FFFFFF;
|
||||||
--popover-foreground: var(--color-text-primary);
|
--popover-foreground: var(--color-text-primary);
|
||||||
--muted: var(--color-bg-secondary);
|
--muted: var(--color-bg-secondary);
|
||||||
--muted-foreground: var(--color-text-secondary);
|
--muted-foreground: var(--color-text-secondary);
|
||||||
--destructive: var(--color-error);
|
--destructive: var(--color-error);
|
||||||
--border: color-mix(in srgb, var(--color-text-primary) 15%, transparent);
|
--border: #E8E8E8;
|
||||||
--input: color-mix(in srgb, var(--color-text-primary) 15%, transparent);
|
--input: #E8E8E8;
|
||||||
--ring: var(--color-primary);
|
--ring: var(--color-primary);
|
||||||
|
|
||||||
--sidebar: var(--color-bg-secondary);
|
--sidebar: #FFFFFF;
|
||||||
--sidebar-foreground: var(--color-text-primary);
|
--sidebar-foreground: var(--color-text-primary);
|
||||||
--sidebar-primary: var(--color-primary);
|
--sidebar-primary: var(--color-primary);
|
||||||
--sidebar-primary-foreground: var(--color-text-on-primary);
|
--sidebar-primary-foreground: var(--color-text-on-primary);
|
||||||
--sidebar-accent: var(--color-bg-primary);
|
--sidebar-accent: var(--color-bg-secondary);
|
||||||
--sidebar-accent-foreground: var(--color-text-primary);
|
--sidebar-accent-foreground: var(--color-text-primary);
|
||||||
--sidebar-border: color-mix(in srgb, var(--color-text-primary) 15%, transparent);
|
--sidebar-border: #E7E5E4;
|
||||||
--sidebar-ring: var(--color-primary);
|
--sidebar-ring: var(--color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="default"].dark,
|
[data-theme="default"].dark,
|
||||||
.dark[data-theme="default"] {
|
.dark[data-theme="default"] {
|
||||||
--color-primary: #0984FF;
|
--color-primary: #F0A070;
|
||||||
--color-secondary: #63D2FF;
|
--color-secondary: #4FC9AF;
|
||||||
--color-accent: #FF9F09;
|
--color-accent: #DDB892;
|
||||||
--color-error: #FF4539;
|
--color-error: #EF4444;
|
||||||
--color-bg-primary: #1C1C1C;
|
--color-bg-primary: #0A0A0A;
|
||||||
--color-bg-secondary: #2C2C2C;
|
--color-bg-secondary: #161616;
|
||||||
--color-text-primary: #FFFFFF;
|
--color-text-primary: #FAFAFA;
|
||||||
--color-text-secondary: #EBEBEB;
|
--color-text-secondary: #A1A1A1;
|
||||||
--color-text-on-primary: #FFFFFF;
|
--color-text-on-primary: #0A0A0A;
|
||||||
|
|
||||||
--primary: var(--color-primary);
|
--primary: var(--color-primary);
|
||||||
--primary-foreground: var(--color-text-on-primary);
|
--primary-foreground: var(--color-text-on-primary);
|
||||||
@@ -97,24 +97,24 @@
|
|||||||
--accent-foreground: var(--color-text-primary);
|
--accent-foreground: var(--color-text-primary);
|
||||||
--background: var(--color-bg-primary);
|
--background: var(--color-bg-primary);
|
||||||
--foreground: var(--color-text-primary);
|
--foreground: var(--color-text-primary);
|
||||||
--card: var(--color-bg-secondary);
|
--card: #141414;
|
||||||
--card-foreground: var(--color-text-primary);
|
--card-foreground: var(--color-text-primary);
|
||||||
--popover: var(--color-bg-secondary);
|
--popover: #141414;
|
||||||
--popover-foreground: var(--color-text-primary);
|
--popover-foreground: var(--color-text-primary);
|
||||||
--muted: var(--color-bg-secondary);
|
--muted: var(--color-bg-secondary);
|
||||||
--muted-foreground: var(--color-text-secondary);
|
--muted-foreground: var(--color-text-secondary);
|
||||||
--destructive: var(--color-error);
|
--destructive: var(--color-error);
|
||||||
--border: color-mix(in srgb, var(--color-text-primary) 12%, transparent);
|
--border: rgba(255, 255, 255, 0.1);
|
||||||
--input: color-mix(in srgb, var(--color-text-primary) 15%, transparent);
|
--input: rgba(255, 255, 255, 0.12);
|
||||||
--ring: var(--color-primary);
|
--ring: var(--color-primary);
|
||||||
|
|
||||||
--sidebar: var(--color-bg-secondary);
|
--sidebar: #141414;
|
||||||
--sidebar-foreground: var(--color-text-primary);
|
--sidebar-foreground: var(--color-text-primary);
|
||||||
--sidebar-primary: var(--color-primary);
|
--sidebar-primary: var(--color-primary);
|
||||||
--sidebar-primary-foreground: var(--color-text-on-primary);
|
--sidebar-primary-foreground: var(--color-text-on-primary);
|
||||||
--sidebar-accent: var(--color-bg-primary);
|
--sidebar-accent: var(--color-bg-primary);
|
||||||
--sidebar-accent-foreground: var(--color-text-primary);
|
--sidebar-accent-foreground: var(--color-text-primary);
|
||||||
--sidebar-border: color-mix(in srgb, var(--color-text-primary) 12%, transparent);
|
--sidebar-border: rgba(255, 255, 255, 0.1);
|
||||||
--sidebar-ring: var(--color-primary);
|
--sidebar-ring: var(--color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user