feat: complete Phase 3 — advanced features for Casera web app

Adds sharing (residence share codes, join, user management, .casera file
export/import), subscription status with feature comparison, notification
preferences with bell icon, profile settings (edit info, change password,
theme picker, delete account), onboarding wizard with create/join paths,
enhanced dashboard with stats cards, Recharts completion chart, recent
activity feed, and task report PDF download.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-03-03 09:31:29 -06:00
commit 5a50d77515
183 changed files with 34450 additions and 0 deletions
+94
View File
@@ -0,0 +1,94 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { AuthFormWrapper } from "@/components/forms/auth-form-wrapper";
import {
forgotPasswordSchema,
type ForgotPasswordFormData,
} from "@/lib/validations/auth";
import * as authApi from "@/lib/api/auth";
import { ApiError } from "@/lib/api/client";
export default function ForgotPasswordPage() {
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const {
register,
handleSubmit,
formState: { errors },
} = useForm<ForgotPasswordFormData>({
resolver: zodResolver(forgotPasswordSchema),
});
async function onSubmit(data: ForgotPasswordFormData) {
setIsLoading(true);
setError(null);
try {
await authApi.forgotPassword({ email: data.email });
router.push(
`/reset-password?email=${encodeURIComponent(data.email)}`
);
} catch (err) {
const message =
err instanceof ApiError
? err.message
: "Failed to send reset code. Please try again.";
setError(message);
} finally {
setIsLoading(false);
}
}
return (
<AuthFormWrapper
title="Forgot password?"
subtitle="Enter your email to receive a reset code"
footer={
<p>
<Link href="/login" className="text-primary hover:underline">
Back to login
</Link>
</p>
}
>
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
{error && (
<div className="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</div>
)}
<div className="flex flex-col gap-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="you@example.com"
autoComplete="email"
aria-invalid={!!errors.email}
{...register("email")}
/>
{errors.email && (
<p className="text-sm text-destructive">{errors.email.message}</p>
)}
</div>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading && <Loader2 className="animate-spin" />}
Send reset code
</Button>
</form>
</AuthFormWrapper>
);
}
+11
View File
@@ -0,0 +1,11 @@
"use client";
export default function AuthLayout({ children }: { children: React.ReactNode }) {
return (
<div className="min-h-screen flex items-center justify-center bg-background p-4">
<div className="w-full max-w-md">
{children}
</div>
</div>
);
}
+98
View File
@@ -0,0 +1,98 @@
"use client";
import Link from "next/link";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { AuthFormWrapper } from "@/components/forms/auth-form-wrapper";
import { PasswordInput } from "@/components/forms/password-input";
import { loginSchema, type LoginFormData } from "@/lib/validations/auth";
import { useAuthStore } from "@/stores/auth";
export default function LoginPage() {
const { login, isLoading, error, clearError } = useAuthStore();
const {
register,
handleSubmit,
formState: { errors },
} = useForm<LoginFormData>({
resolver: zodResolver(loginSchema),
});
async function onSubmit(data: LoginFormData) {
clearError();
await login({ username: data.username, password: data.password });
}
return (
<AuthFormWrapper
title="Welcome back"
subtitle="Sign in to your account"
footer={
<p>
Don&apos;t have an account?{" "}
<Link href="/register" className="text-primary hover:underline">
Sign up
</Link>
</p>
}
>
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
{error && (
<div className="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</div>
)}
<div className="flex flex-col gap-2">
<Label htmlFor="username">Username or email</Label>
<Input
id="username"
placeholder="you@example.com"
autoComplete="username"
aria-invalid={!!errors.username}
{...register("username")}
/>
{errors.username && (
<p className="text-sm text-destructive">
{errors.username.message}
</p>
)}
</div>
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<Label htmlFor="password">Password</Label>
<Link
href="/forgot-password"
className="text-xs text-muted-foreground hover:text-primary"
>
Forgot password?
</Link>
</div>
<PasswordInput
id="password"
autoComplete="current-password"
aria-invalid={!!errors.password}
{...register("password")}
/>
{errors.password && (
<p className="text-sm text-destructive">
{errors.password.message}
</p>
)}
</div>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading && <Loader2 className="animate-spin" />}
Sign in
</Button>
</form>
</AuthFormWrapper>
);
}
+166
View File
@@ -0,0 +1,166 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { AuthFormWrapper } from "@/components/forms/auth-form-wrapper";
import { PasswordInput } from "@/components/forms/password-input";
import { registerSchema, type RegisterFormData } from "@/lib/validations/auth";
import { useAuthStore } from "@/stores/auth";
export default function RegisterPage() {
const router = useRouter();
const { register: registerUser, isLoading, error, clearError } = useAuthStore();
const {
register,
handleSubmit,
formState: { errors },
} = useForm<RegisterFormData>({
resolver: zodResolver(registerSchema),
});
async function onSubmit(data: RegisterFormData) {
clearError();
try {
await registerUser({
first_name: data.first_name,
last_name: data.last_name,
username: data.username,
email: data.email,
password: data.password,
});
router.push(
`/verify-email?email=${encodeURIComponent(data.email)}`
);
} catch {
// Error is already set in the store
}
}
return (
<AuthFormWrapper
title="Create account"
subtitle="Get started with Casera"
footer={
<p>
Already have an account?{" "}
<Link href="/login" className="text-primary hover:underline">
Sign in
</Link>
</p>
}
>
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
{error && (
<div className="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="first_name">First name</Label>
<Input
id="first_name"
autoComplete="given-name"
aria-invalid={!!errors.first_name}
{...register("first_name")}
/>
{errors.first_name && (
<p className="text-sm text-destructive">
{errors.first_name.message}
</p>
)}
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="last_name">Last name</Label>
<Input
id="last_name"
autoComplete="family-name"
aria-invalid={!!errors.last_name}
{...register("last_name")}
/>
{errors.last_name && (
<p className="text-sm text-destructive">
{errors.last_name.message}
</p>
)}
</div>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="username">Username</Label>
<Input
id="username"
autoComplete="username"
aria-invalid={!!errors.username}
{...register("username")}
/>
{errors.username && (
<p className="text-sm text-destructive">
{errors.username.message}
</p>
)}
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="you@example.com"
autoComplete="email"
aria-invalid={!!errors.email}
{...register("email")}
/>
{errors.email && (
<p className="text-sm text-destructive">{errors.email.message}</p>
)}
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="password">Password</Label>
<PasswordInput
id="password"
autoComplete="new-password"
aria-invalid={!!errors.password}
{...register("password")}
/>
{errors.password && (
<p className="text-sm text-destructive">
{errors.password.message}
</p>
)}
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="confirm_password">Confirm password</Label>
<PasswordInput
id="confirm_password"
autoComplete="new-password"
aria-invalid={!!errors.confirm_password}
{...register("confirm_password")}
/>
{errors.confirm_password && (
<p className="text-sm text-destructive">
{errors.confirm_password.message}
</p>
)}
</div>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading && <Loader2 className="animate-spin" />}
Create account
</Button>
</form>
</AuthFormWrapper>
);
}
+208
View File
@@ -0,0 +1,208 @@
"use client";
import { Suspense, useState } from "react";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { AuthFormWrapper } from "@/components/forms/auth-form-wrapper";
import { PasswordInput } from "@/components/forms/password-input";
import { CodeInput } from "@/components/forms/code-input";
import { resetPasswordSchema, type ResetPasswordFormData } from "@/lib/validations/auth";
import * as authApi from "@/lib/api/auth";
import { ApiError } from "@/lib/api/client";
type Step = "code" | "password";
function ResetPasswordForm() {
const router = useRouter();
const searchParams = useSearchParams();
const email = searchParams.get("email") ?? "";
const [step, setStep] = useState<Step>("code");
const [code, setCode] = useState("");
const [resetToken, setResetToken] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const {
register,
handleSubmit,
formState: { errors },
} = useForm<ResetPasswordFormData>({
resolver: zodResolver(resetPasswordSchema),
values: {
email,
code,
new_password: "",
confirm_password: "",
},
});
// Step 1: Verify the 6-digit code
async function handleVerifyCode(submittedCode: string) {
if (submittedCode.length !== 6 || isLoading) return;
setIsLoading(true);
setError(null);
try {
const result = await authApi.verifyResetCode({
email,
code: submittedCode,
});
setResetToken(result.reset_token);
setStep("password");
} catch (err) {
const message =
err instanceof ApiError
? err.message
: "Invalid code. Please try again.";
setError(message);
} finally {
setIsLoading(false);
}
}
function handleCodeChange(newCode: string) {
setCode(newCode);
if (newCode.length === 6) {
handleVerifyCode(newCode);
}
}
// Step 2: Reset password with the token
async function onSubmitPassword(data: ResetPasswordFormData) {
setIsLoading(true);
setError(null);
try {
await authApi.resetPassword({
reset_token: resetToken,
new_password: data.new_password,
});
router.push("/login");
} catch (err) {
const message =
err instanceof ApiError
? err.message
: "Failed to reset password. Please try again.";
setError(message);
} finally {
setIsLoading(false);
}
}
if (step === "code") {
return (
<AuthFormWrapper
title="Enter reset code"
subtitle={
email
? `Enter the 6-digit code sent to ${email}`
: "Enter the 6-digit code sent to your email"
}
footer={
<p>
<Link href="/login" className="text-primary hover:underline">
Back to login
</Link>
</p>
}
>
<div className="flex flex-col gap-6">
{error && (
<div className="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</div>
)}
<CodeInput
value={code}
onChange={handleCodeChange}
disabled={isLoading}
/>
<Button
type="button"
className="w-full"
disabled={code.length !== 6 || isLoading}
onClick={() => handleVerifyCode(code)}
>
{isLoading && <Loader2 className="animate-spin" />}
Verify code
</Button>
</div>
</AuthFormWrapper>
);
}
return (
<AuthFormWrapper
title="Set new password"
subtitle="Enter your new password below"
footer={
<p>
<Link href="/login" className="text-primary hover:underline">
Back to login
</Link>
</p>
}
>
<form
onSubmit={handleSubmit(onSubmitPassword)}
className="flex flex-col gap-4"
>
{error && (
<div className="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</div>
)}
<div className="flex flex-col gap-2">
<Label htmlFor="new_password">New password</Label>
<PasswordInput
id="new_password"
autoComplete="new-password"
aria-invalid={!!errors.new_password}
{...register("new_password")}
/>
{errors.new_password && (
<p className="text-sm text-destructive">
{errors.new_password.message}
</p>
)}
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="confirm_password">Confirm password</Label>
<PasswordInput
id="confirm_password"
autoComplete="new-password"
aria-invalid={!!errors.confirm_password}
{...register("confirm_password")}
/>
{errors.confirm_password && (
<p className="text-sm text-destructive">
{errors.confirm_password.message}
</p>
)}
</div>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading && <Loader2 className="animate-spin" />}
Reset password
</Button>
</form>
</AuthFormWrapper>
);
}
export default function ResetPasswordPage() {
return (
<Suspense>
<ResetPasswordForm />
</Suspense>
);
}
+147
View File
@@ -0,0 +1,147 @@
"use client";
import { Suspense } from "react";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { useState, useEffect, useCallback } from "react";
import { Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { AuthFormWrapper } from "@/components/forms/auth-form-wrapper";
import { CodeInput } from "@/components/forms/code-input";
import * as authApi from "@/lib/api/auth";
import { ApiError } from "@/lib/api/client";
const RESEND_COOLDOWN_SECONDS = 60;
function VerifyEmailForm() {
const router = useRouter();
const searchParams = useSearchParams();
const email = searchParams.get("email") ?? "";
const [code, setCode] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [isResending, setIsResending] = useState(false);
const [error, setError] = useState<string | null>(null);
const [cooldown, setCooldown] = useState(0);
// Cooldown timer for resend button
useEffect(() => {
if (cooldown <= 0) return;
const timer = setInterval(() => {
setCooldown((c) => c - 1);
}, 1000);
return () => clearInterval(timer);
}, [cooldown]);
const handleSubmit = useCallback(
async (submittedCode: string) => {
if (submittedCode.length !== 6 || isSubmitting) return;
setError(null);
setIsSubmitting(true);
try {
await authApi.verifyEmail({ code: submittedCode });
router.push("/login");
} catch (err) {
const message =
err instanceof ApiError
? err.message
: "Verification failed. Please try again.";
setError(message);
} finally {
setIsSubmitting(false);
}
},
[isSubmitting, router]
);
function handleCodeChange(newCode: string) {
setCode(newCode);
// Auto-submit when all 6 digits are entered
if (newCode.length === 6) {
handleSubmit(newCode);
}
}
async function handleResend() {
setIsResending(true);
setError(null);
try {
await authApi.resendVerification();
setCooldown(RESEND_COOLDOWN_SECONDS);
} catch (err) {
const message =
err instanceof ApiError
? err.message
: "Failed to resend code. Please try again.";
setError(message);
} finally {
setIsResending(false);
}
}
return (
<AuthFormWrapper
title="Verify your email"
subtitle={
email
? `Enter the 6-digit code sent to ${email}`
: "Enter the 6-digit code sent to your email"
}
footer={
<p>
<Link href="/login" className="text-primary hover:underline">
Back to login
</Link>
</p>
}
>
<div className="flex flex-col gap-6">
{error && (
<div className="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</div>
)}
<CodeInput
value={code}
onChange={handleCodeChange}
disabled={isSubmitting}
/>
<Button
type="button"
className="w-full"
disabled={code.length !== 6 || isSubmitting}
onClick={() => handleSubmit(code)}
>
{isSubmitting && <Loader2 className="animate-spin" />}
Verify email
</Button>
<div className="text-center">
<Button
type="button"
variant="ghost"
size="sm"
disabled={isResending || cooldown > 0}
onClick={handleResend}
>
{isResending && <Loader2 className="animate-spin" />}
{cooldown > 0
? `Resend code (${cooldown}s)`
: "Resend code"}
</Button>
</div>
</div>
</AuthFormWrapper>
);
}
export default function VerifyEmailPage() {
return (
<Suspense>
<VerifyEmailForm />
</Suspense>
);
}
+76
View File
@@ -0,0 +1,76 @@
import { cookies } from 'next/headers';
import { NextRequest, NextResponse } from 'next/server';
// ---------------------------------------------------------------------------
// POST /api/auth/login
// ---------------------------------------------------------------------------
// Special route handler for login. On success, sets the auth token in an
// httpOnly cookie so it is never exposed to client-side JavaScript.
// ---------------------------------------------------------------------------
const API_BASE_URL =
process.env.API_URL ||
process.env.NEXT_PUBLIC_API_URL ||
'https://mycrib.treytartt.com/api';
const COOKIE_NAME = 'casera-token';
const COOKIE_MAX_AGE = 60 * 60 * 24 * 30; // 30 days
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const upstream = await fetch(`${API_BASE_URL}/auth/login/`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Timezone':
request.headers.get('x-timezone') ||
Intl.DateTimeFormat().resolvedOptions().timeZone,
},
cache: 'no-store',
body: JSON.stringify(body),
});
const data = await upstream.json().catch(() => null);
if (!upstream.ok) {
return NextResponse.json(
data || { error: 'Login failed' },
{ status: upstream.status },
);
}
// Extract token from Go API response
// The Go API returns { token: "...", user: { ... } }
const token: string | undefined = data?.token;
if (!token) {
return NextResponse.json(
{ error: 'No token in response' },
{ status: 500 },
);
}
// Set httpOnly cookie
const cookieStore = await cookies();
cookieStore.set(COOKIE_NAME, token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
path: '/',
maxAge: COOKIE_MAX_AGE,
});
// Return the full response (including user data) to the client,
// but strip the raw token since it is now in the cookie.
const { token: _stripped, ...safeData } = data;
return NextResponse.json(safeData, { status: 200 });
} catch (error) {
console.error('[auth/login] Error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 },
);
}
}
+50
View File
@@ -0,0 +1,50 @@
import { cookies } from 'next/headers';
import { NextRequest, NextResponse } from 'next/server';
// ---------------------------------------------------------------------------
// POST /api/auth/logout
// ---------------------------------------------------------------------------
// Clears the httpOnly auth cookie and optionally invalidates the token on
// the Go API side.
// ---------------------------------------------------------------------------
const API_BASE_URL =
process.env.API_URL ||
process.env.NEXT_PUBLIC_API_URL ||
'https://mycrib.treytartt.com/api';
const COOKIE_NAME = 'casera-token';
export async function POST(request: NextRequest) {
try {
const cookieStore = await cookies();
const token = cookieStore.get(COOKIE_NAME)?.value;
// Best-effort: tell the Go API to invalidate the token
if (token) {
try {
await fetch(`${API_BASE_URL}/auth/logout/`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Token ${token}`,
},
cache: 'no-store',
});
} catch {
// Don't block logout if the upstream call fails
}
}
// Delete the cookie
cookieStore.delete(COOKIE_NAME);
return NextResponse.json({ message: 'Logged out successfully' });
} catch (error) {
console.error('[auth/logout] Error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 },
);
}
}
+60
View File
@@ -0,0 +1,60 @@
import { cookies } from 'next/headers';
import { NextRequest, NextResponse } from 'next/server';
// ---------------------------------------------------------------------------
// GET /api/auth/me
// ---------------------------------------------------------------------------
// Returns the current authenticated user. Reads the token from the httpOnly
// cookie and proxies to the Go API.
// ---------------------------------------------------------------------------
const API_BASE_URL =
process.env.API_URL ||
process.env.NEXT_PUBLIC_API_URL ||
'https://mycrib.treytartt.com/api';
const COOKIE_NAME = 'casera-token';
export async function GET(_request: NextRequest) {
try {
const cookieStore = await cookies();
const token = cookieStore.get(COOKIE_NAME)?.value;
if (!token) {
return NextResponse.json(
{ error: 'Not authenticated' },
{ status: 401 },
);
}
const upstream = await fetch(`${API_BASE_URL}/auth/me/`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Token ${token}`,
},
cache: 'no-store',
});
const data = await upstream.json().catch(() => null);
if (!upstream.ok) {
// If the token is invalid/expired, clear the cookie
if (upstream.status === 401) {
cookieStore.delete(COOKIE_NAME);
}
return NextResponse.json(
data || { error: 'Failed to fetch user' },
{ status: upstream.status },
);
}
return NextResponse.json(data);
} catch (error) {
console.error('[auth/me] Error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 },
);
}
}
+188
View File
@@ -0,0 +1,188 @@
import { cookies } from 'next/headers';
import { NextRequest, NextResponse } from 'next/server';
// ---------------------------------------------------------------------------
// Catch-all proxy route handler
// ---------------------------------------------------------------------------
// Every authenticated client-side API call goes through this proxy.
// It reads the `casera-token` httpOnly cookie and forwards the request to the
// Go API with an Authorization header.
// ---------------------------------------------------------------------------
const API_BASE_URL =
process.env.API_URL ||
process.env.NEXT_PUBLIC_API_URL ||
'https://mycrib.treytartt.com/api';
/**
* Build the target URL from the catch-all path segments.
* e.g. /api/proxy/tasks/123/ -> https://mycrib.treytartt.com/api/tasks/123/
*/
function buildTargetUrl(request: NextRequest, pathSegments: string[]): string {
const path = `/${pathSegments.join('/')}`;
// Ensure trailing slash (Go API requires it)
const normalizedPath = path.endsWith('/') ? path : `${path}/`;
// Forward query string if present
const search = request.nextUrl.search;
return `${API_BASE_URL}${normalizedPath}${search}`;
}
/**
* Build headers to forward to Go API.
* Strips hop-by-hop headers and adds Authorization from cookie.
*/
async function buildHeaders(request: NextRequest): Promise<Headers> {
const headers = new Headers();
// Forward select request headers
const forwardHeaders = [
'content-type',
'accept',
'x-timezone',
'x-requested-with',
'if-none-match',
];
for (const name of forwardHeaders) {
const value = request.headers.get(name);
if (value) {
headers.set(name, value);
}
}
// Attach auth token from httpOnly cookie
const cookieStore = await cookies();
const token = cookieStore.get('casera-token')?.value;
if (token) {
headers.set('Authorization', `Token ${token}`);
}
return headers;
}
/**
* Proxy a request to the Go API and return the response.
*/
async function proxyRequest(
request: NextRequest,
pathSegments: string[],
): Promise<NextResponse> {
const targetUrl = buildTargetUrl(request, pathSegments);
const headers = await buildHeaders(request);
// Build fetch options
const fetchOptions: RequestInit = {
method: request.method,
headers,
// Do not let Next.js cache proxy requests
cache: 'no-store',
};
// Forward request body for methods that typically have one
if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(request.method)) {
const contentType = request.headers.get('content-type') || '';
if (contentType.includes('multipart/form-data')) {
// Stream the raw body for multipart uploads
fetchOptions.body = await request.arrayBuffer();
} else if (contentType.includes('application/json')) {
fetchOptions.body = await request.text();
} else {
// Fallback: forward raw body
fetchOptions.body = await request.arrayBuffer();
}
}
try {
const upstream = await fetch(targetUrl, fetchOptions);
// Build response headers to forward back to the client
const responseHeaders = new Headers();
const passHeaders = [
'content-type',
'etag',
'cache-control',
'content-disposition',
];
for (const name of passHeaders) {
const value = upstream.headers.get(name);
if (value) {
responseHeaders.set(name, value);
}
}
// Handle 304 Not Modified (no body)
if (upstream.status === 304) {
return new NextResponse(null, {
status: 304,
headers: responseHeaders,
});
}
// Handle 204 No Content
if (upstream.status === 204) {
return new NextResponse(null, {
status: 204,
headers: responseHeaders,
});
}
// Stream the upstream body back
const body = await upstream.arrayBuffer();
return new NextResponse(body, {
status: upstream.status,
headers: responseHeaders,
});
} catch (error) {
console.error('[proxy] Upstream request failed:', error);
return NextResponse.json(
{ error: 'Failed to reach API server' },
{ status: 502 },
);
}
}
// ---------------------------------------------------------------------------
// HTTP method handlers
// ---------------------------------------------------------------------------
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ path: string[] }> },
) {
const { path } = await params;
return proxyRequest(request, path);
}
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ path: string[] }> },
) {
const { path } = await params;
return proxyRequest(request, path);
}
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ path: string[] }> },
) {
const { path } = await params;
return proxyRequest(request, path);
}
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ path: string[] }> },
) {
const { path } = await params;
return proxyRequest(request, path);
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ path: string[] }> },
) {
const { path } = await params;
return proxyRequest(request, path);
}
@@ -0,0 +1,57 @@
"use client";
import { use } from "react";
import { useRouter } from "next/navigation";
import { PageHeader } from "@/components/shared/page-header";
import { LoadingSkeleton } from "@/components/shared/loading-skeleton";
import { ErrorBanner } from "@/components/shared/error-banner";
import { ContractorForm } from "@/components/contractors/contractor-form";
import { useContractor, useUpdateContractor } from "@/lib/hooks/use-contractors";
import type { ContractorFormValues } from "@/components/contractors/contractor-form";
export default function EditContractorPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id: idParam } = use(params);
const id = Number(idParam);
const router = useRouter();
const { data: contractor, isLoading, isError, error, refetch } = useContractor(id);
const updateContractor = useUpdateContractor(id);
function handleSubmit(data: ContractorFormValues) {
updateContractor.mutate(data, {
onSuccess: () => {
router.push(`/app/contractors/${id}`);
},
});
}
if (isLoading) {
return <LoadingSkeleton variant="detail" />;
}
if (isError) {
return (
<ErrorBanner
message={error instanceof Error ? error.message : "Failed to load contractor."}
onRetry={() => refetch()}
/>
);
}
if (!contractor) return null;
return (
<div className="space-y-6 max-w-2xl">
<PageHeader title={`Edit ${contractor.name}`} />
<ContractorForm
contractor={contractor}
onSubmit={handleSubmit}
loading={updateContractor.isPending}
/>
</div>
);
}
+252
View File
@@ -0,0 +1,252 @@
"use client";
import { use, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { Phone, Mail, Globe, Star, Pencil, Trash2, FileDown } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { PageHeader } from "@/components/shared/page-header";
import { LoadingSkeleton } from "@/components/shared/loading-skeleton";
import { ErrorBanner } from "@/components/shared/error-banner";
import { ConfirmDialog } from "@/components/shared/confirm-dialog";
import { StarRating } from "@/components/shared/star-rating";
import { downloadCaseraFile } from "@/components/sharing/casera-file-handler";
import {
useContractor,
useContractorTasks,
useDeleteContractor,
useToggleFavorite,
} from "@/lib/hooks/use-contractors";
export default function ContractorDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id: idParam } = use(params);
const id = Number(idParam);
const router = useRouter();
const { data: contractor, isLoading, isError, error, refetch } = useContractor(id);
const { data: tasks } = useContractorTasks(id);
const deleteContractor = useDeleteContractor();
const toggleFavorite = useToggleFavorite();
const [deleteOpen, setDeleteOpen] = useState(false);
function handleDelete() {
deleteContractor.mutate(id, {
onSuccess: () => {
router.push("/app/contractors");
},
});
}
if (isLoading) {
return <LoadingSkeleton variant="detail" />;
}
if (isError) {
return (
<ErrorBanner
message={error instanceof Error ? error.message : "Failed to load contractor."}
onRetry={() => refetch()}
/>
);
}
if (!contractor) return null;
return (
<div className="space-y-6 max-w-3xl">
<PageHeader title={contractor.name} description={contractor.company || undefined}>
<Button
variant="ghost"
size="icon"
onClick={() => toggleFavorite.mutate(contractor.id)}
>
<Star
className={
contractor.is_favorite
? "size-5 fill-yellow-400 text-yellow-400"
: "size-5 text-muted-foreground"
}
/>
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
const exportData = {
type: "casera_contractor_share",
version: 1,
contractor: {
name: contractor.name,
company: contractor.company,
phone: contractor.phone,
email: contractor.email,
website: contractor.website,
notes: contractor.notes,
street_address: contractor.street_address,
city: contractor.city,
state_province: contractor.state_province,
postal_code: contractor.postal_code,
specialty_ids: contractor.specialties.map((s) => s.id),
rating: contractor.rating,
},
exported_at: new Date().toISOString(),
};
const safeName = contractor.name.replace(/[^a-zA-Z0-9_-]/g, "_").toLowerCase();
downloadCaseraFile(exportData, `${safeName}-contractor`);
}}
>
<FileDown className="size-4 mr-2" />
Share
</Button>
<Button variant="outline" size="sm" asChild>
<Link href={`/app/contractors/${contractor.id}/edit`}>
<Pencil className="size-4 mr-2" />
Edit
</Link>
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => setDeleteOpen(true)}
>
<Trash2 className="size-4 mr-2" />
Delete
</Button>
</PageHeader>
{/* Contact info */}
<Card>
<CardHeader>
<CardTitle>Contact Information</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{contractor.phone && (
<div className="flex items-center gap-3">
<Phone className="size-4 text-muted-foreground" />
<a href={`tel:${contractor.phone}`} className="text-sm hover:underline">
{contractor.phone}
</a>
</div>
)}
{contractor.email && (
<div className="flex items-center gap-3">
<Mail className="size-4 text-muted-foreground" />
<a href={`mailto:${contractor.email}`} className="text-sm hover:underline">
{contractor.email}
</a>
</div>
)}
{contractor.website && (
<div className="flex items-center gap-3">
<Globe className="size-4 text-muted-foreground" />
<a
href={contractor.website.startsWith("http") ? contractor.website : `https://${contractor.website}`}
target="_blank"
rel="noopener noreferrer"
className="text-sm hover:underline"
>
{contractor.website}
</a>
</div>
)}
{!contractor.phone && !contractor.email && !contractor.website && (
<p className="text-sm text-muted-foreground">No contact information provided.</p>
)}
</CardContent>
</Card>
{/* Specialties */}
{contractor.specialties.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Specialties</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{contractor.specialties.map((s) => (
<Badge key={s.id} variant="secondary">
{s.icon && <span className="mr-1">{s.icon}</span>}
{s.name}
</Badge>
))}
</div>
</CardContent>
</Card>
)}
{/* Rating */}
{contractor.rating != null && contractor.rating > 0 && (
<Card>
<CardHeader>
<CardTitle>Rating</CardTitle>
</CardHeader>
<CardContent>
<StarRating value={contractor.rating} readonly />
</CardContent>
</Card>
)}
{/* Notes */}
{contractor.notes && (
<Card>
<CardHeader>
<CardTitle>Notes</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm whitespace-pre-wrap">{contractor.notes}</p>
</CardContent>
</Card>
)}
{/* Linked tasks */}
<Card>
<CardHeader>
<CardTitle>Linked Tasks</CardTitle>
</CardHeader>
<CardContent>
{!tasks || tasks.length === 0 ? (
<p className="text-sm text-muted-foreground">No tasks linked to this contractor.</p>
) : (
<div className="space-y-2">
{tasks.map((task) => (
<div key={task.id}>
<div className="flex items-center justify-between py-2">
<div>
<p className="text-sm font-medium">{task.title}</p>
<p className="text-xs text-muted-foreground">
{task.residence_name}
{task.due_date && ` - Due ${task.due_date}`}
</p>
</div>
<Badge variant="outline">{task.status}</Badge>
</div>
<Separator />
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Delete confirmation */}
<ConfirmDialog
open={deleteOpen}
onOpenChange={setDeleteOpen}
title="Delete Contractor"
description={`Are you sure you want to delete "${contractor.name}"? This action cannot be undone.`}
confirmLabel="Delete"
variant="destructive"
loading={deleteContractor.isPending}
onConfirm={handleDelete}
/>
</div>
);
}
+27
View File
@@ -0,0 +1,27 @@
"use client";
import { useRouter } from "next/navigation";
import { PageHeader } from "@/components/shared/page-header";
import { ContractorForm } from "@/components/contractors/contractor-form";
import { useCreateContractor } from "@/lib/hooks/use-contractors";
import type { ContractorFormValues } from "@/components/contractors/contractor-form";
export default function NewContractorPage() {
const router = useRouter();
const createContractor = useCreateContractor();
function handleSubmit(data: ContractorFormValues) {
createContractor.mutate(data, {
onSuccess: (res) => {
router.push(`/app/contractors/${res.id}`);
},
});
}
return (
<div className="space-y-6 max-w-2xl">
<PageHeader title="New Contractor" />
<ContractorForm onSubmit={handleSubmit} loading={createContractor.isPending} />
</div>
);
}
+189
View File
@@ -0,0 +1,189 @@
"use client";
import { useState, useMemo } from "react";
import { useRouter } from "next/navigation";
import { Upload, Wrench } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle,
} from "@/components/ui/dialog";
import { PageHeader } from "@/components/shared/page-header";
import { LoadingSkeleton } from "@/components/shared/loading-skeleton";
import { ErrorBanner } from "@/components/shared/error-banner";
import { EmptyState } from "@/components/shared/empty-state";
import { CaseraFileImport } from "@/components/sharing/casera-file-handler";
import { ContractorCard } from "@/components/contractors/contractor-card";
import { ContractorFilters } from "@/components/contractors/contractor-filters";
import { useContractors, useToggleFavorite, useCreateContractor } from "@/lib/hooks/use-contractors";
export default function ContractorsPage() {
const router = useRouter();
const { data: contractors, isLoading, isError, error, refetch } = useContractors();
const toggleFavorite = useToggleFavorite();
const createContractor = useCreateContractor();
const [search, setSearch] = useState("");
const [specialtyId, setSpecialtyId] = useState<number | undefined>(undefined);
const [favoritesOnly, setFavoritesOnly] = useState(false);
const [importOpen, setImportOpen] = useState(false);
const [importError, setImportError] = useState<string | null>(null);
const filtered = useMemo(() => {
if (!contractors) return [];
let list = contractors;
// Search filter (name or company)
if (search.trim()) {
const q = search.toLowerCase();
list = list.filter(
(c) =>
c.name.toLowerCase().includes(q) ||
c.company.toLowerCase().includes(q),
);
}
// Specialty filter
if (specialtyId != null) {
list = list.filter((c) =>
c.specialties.some((s) => s.id === specialtyId),
);
}
// Favorites filter
if (favoritesOnly) {
list = list.filter((c) => c.is_favorite);
}
return list;
}, [contractors, search, specialtyId, favoritesOnly]);
function handleContractorImport(data: unknown) {
setImportError(null);
if (
typeof data === "object" &&
data !== null &&
"type" in data &&
(data as Record<string, unknown>).type === "casera_contractor_share" &&
"contractor" in data
) {
const contractor = (data as Record<string, unknown>).contractor as Record<string, unknown>;
createContractor.mutate(
{
name: (contractor.name as string) ?? "",
company: contractor.company as string | undefined,
phone: contractor.phone as string | undefined,
email: contractor.email as string | undefined,
website: contractor.website as string | undefined,
notes: contractor.notes as string | undefined,
street_address: contractor.street_address as string | undefined,
city: contractor.city as string | undefined,
state_province: contractor.state_province as string | undefined,
postal_code: contractor.postal_code as string | undefined,
specialty_ids: contractor.specialty_ids as number[] | undefined,
rating: contractor.rating as number | undefined,
},
{
onSuccess: () => {
setImportOpen(false);
},
onError: (err) => {
setImportError(
err instanceof Error ? err.message : "Failed to import contractor.",
);
},
},
);
} else {
setImportError("Invalid .casera file. Expected a contractor share file.");
}
}
return (
<div className="space-y-6">
<PageHeader
title="Contractors"
description="Manage your trusted contractors and service providers"
actionLabel="Add Contractor"
onAction={() => router.push("/app/contractors/new")}
>
<Button
variant="outline"
size="sm"
onClick={() => {
setImportError(null);
setImportOpen(true);
}}
>
<Upload className="size-4 mr-2" />
Import .casera
</Button>
</PageHeader>
{isError && (
<ErrorBanner
message={error instanceof Error ? error.message : "Failed to load contractors."}
onRetry={() => refetch()}
/>
)}
{isLoading && <LoadingSkeleton variant="list" count={5} />}
{!isLoading && !isError && contractors && (
<>
<ContractorFilters
search={search}
onSearchChange={setSearch}
specialtyId={specialtyId}
onSpecialtyChange={setSpecialtyId}
favoritesOnly={favoritesOnly}
onFavoritesOnlyChange={setFavoritesOnly}
/>
{filtered.length === 0 ? (
<EmptyState
icon={Wrench}
title="No contractors found"
description={
contractors.length === 0
? "Add your first contractor to keep track of service providers."
: "Try adjusting your search or filters."
}
actionLabel={contractors.length === 0 ? "Add Contractor" : undefined}
onAction={contractors.length === 0 ? () => router.push("/app/contractors/new") : undefined}
/>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{filtered.map((c) => (
<ContractorCard
key={c.id}
contractor={c}
onToggleFavorite={(id) => toggleFavorite.mutate(id)}
/>
))}
</div>
)}
</>
)}
{/* Import .casera dialog */}
<Dialog open={importOpen} onOpenChange={setImportOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Import Contractor</DialogTitle>
<DialogDescription>
Import a contractor from a .casera file shared with you.
</DialogDescription>
</DialogHeader>
<CaseraFileImport onImport={handleContractorImport} />
{importError && (
<p className="text-sm text-destructive">{importError}</p>
)}
{createContractor.isPending && (
<p className="text-sm text-muted-foreground">Importing...</p>
)}
</DialogContent>
</Dialog>
</div>
);
}
+63
View File
@@ -0,0 +1,63 @@
"use client";
import { use } from "react";
import { useRouter } from "next/navigation";
import { PageHeader } from "@/components/shared/page-header";
import { LoadingSkeleton } from "@/components/shared/loading-skeleton";
import { ErrorBanner } from "@/components/shared/error-banner";
import { DocumentForm } from "@/components/documents/document-form";
import { useDocument, useUpdateDocument } from "@/lib/hooks/use-documents";
interface EditDocumentPageProps {
params: Promise<{ id: string }>;
}
export default function EditDocumentPage({ params }: EditDocumentPageProps) {
const { id: rawId } = use(params);
const id = Number(rawId);
const router = useRouter();
const { data: document, isLoading, error, refetch } = useDocument(id);
const updateDocument = useUpdateDocument(id);
if (isLoading) {
return (
<div className="space-y-6">
<LoadingSkeleton variant="detail" />
</div>
);
}
if (error || !document) {
return (
<div className="space-y-6">
<ErrorBanner
message="Failed to load document."
onRetry={() => refetch()}
/>
</div>
);
}
return (
<div className="space-y-6">
<PageHeader
title="Edit Document"
description={document.title}
/>
<DocumentForm
document={document}
loading={updateDocument.isPending}
onSubmit={(data) => {
updateDocument.mutate(data, {
onSuccess: () => {
router.push(`/app/documents/${id}`);
},
});
}}
/>
</div>
);
}
+239
View File
@@ -0,0 +1,239 @@
"use client";
import { use, useState } from "react";
import { useRouter } from "next/navigation";
import {
Pencil,
Trash2,
Download,
FileText,
} from "lucide-react";
import { format } from "date-fns";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { PageHeader } from "@/components/shared/page-header";
import { LoadingSkeleton } from "@/components/shared/loading-skeleton";
import { ErrorBanner } from "@/components/shared/error-banner";
import { ConfirmDialog } from "@/components/shared/confirm-dialog";
import { WarrantyStatus } from "@/components/documents/warranty-status";
import { ImageGallery } from "@/components/documents/image-gallery";
import { useDocument, useDeleteDocument } from "@/lib/hooks/use-documents";
const typeLabels: Record<string, string> = {
general: "General",
warranty: "Warranty",
receipt: "Receipt",
contract: "Contract",
insurance: "Insurance",
manual: "Manual",
};
interface DocumentDetailPageProps {
params: Promise<{ id: string }>;
}
export default function DocumentDetailPage({ params }: DocumentDetailPageProps) {
const { id: rawId } = use(params);
const id = Number(rawId);
const router = useRouter();
const { data: document, isLoading, error, refetch } = useDocument(id);
const deleteDocument = useDeleteDocument();
const [deleteOpen, setDeleteOpen] = useState(false);
if (isLoading) {
return (
<div className="space-y-6">
<LoadingSkeleton variant="detail" />
</div>
);
}
if (error || !document) {
return (
<div className="space-y-6">
<ErrorBanner
message="Failed to load document."
onRetry={() => refetch()}
/>
</div>
);
}
const isWarranty = document.document_type === "warranty";
const warrantyDetails = [
{ label: "Vendor", value: document.vendor },
{ label: "Serial Number", value: document.serial_number },
{ label: "Model Number", value: document.model_number },
{
label: "Purchase Date",
value: document.purchase_date
? format(new Date(document.purchase_date), "MMM d, yyyy")
: undefined,
},
{
label: "Expiry Date",
value: document.expiry_date
? format(new Date(document.expiry_date), "MMM d, yyyy")
: undefined,
},
{
label: "Purchase Price",
value:
document.purchase_price != null
? `$${document.purchase_price.toFixed(2)}`
: undefined,
},
].filter((d) => d.value);
return (
<div className="space-y-6">
<PageHeader title={document.title}>
<Button
variant="outline"
size="sm"
onClick={() => router.push(`/app/documents/${id}/edit`)}
>
<Pencil className="size-4 mr-2" />
Edit
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setDeleteOpen(true)}
>
<Trash2 className="size-4 mr-2" />
Delete
</Button>
</PageHeader>
{/* Type badge & residence */}
<div className="flex flex-wrap items-center gap-3">
<Badge variant="outline">
{typeLabels[document.document_type] ?? document.document_type}
</Badge>
<span className="text-sm text-muted-foreground">
{document.residence_name}
</span>
{isWarranty && <WarrantyStatus expiry_date={document.expiry_date} />}
</div>
{/* Description */}
{document.description && (
<Card>
<CardHeader>
<CardTitle className="text-sm text-muted-foreground">
Description
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm whitespace-pre-wrap">
{document.description}
</p>
</CardContent>
</Card>
)}
{/* Warranty Details */}
{isWarranty && warrantyDetails.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-sm text-muted-foreground">
Warranty Details
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3">
{warrantyDetails.map((d) => (
<div key={d.label}>
<p className="text-xs text-muted-foreground">{d.label}</p>
<p className="text-sm font-medium">{d.value}</p>
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* Images */}
{document.images && document.images.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-sm text-muted-foreground">
Images
</CardTitle>
</CardHeader>
<CardContent>
<ImageGallery images={document.images} />
</CardContent>
</Card>
)}
{/* File Download */}
{document.file_url && (
<Card>
<CardHeader>
<CardTitle className="text-sm text-muted-foreground">
Attached File
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center gap-3">
<div className="rounded-md bg-muted p-2">
<FileText className="size-5 text-muted-foreground" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">
{document.file_name || "Download file"}
</p>
{document.file_size != null && (
<p className="text-xs text-muted-foreground">
{(document.file_size / 1024).toFixed(0)} KB
</p>
)}
</div>
<Button variant="outline" size="sm" asChild>
<a
href={document.file_url}
target="_blank"
rel="noopener noreferrer"
>
<Download className="size-4 mr-2" />
Download
</a>
</Button>
</div>
</CardContent>
</Card>
)}
{/* Meta info */}
<div className="text-xs text-muted-foreground">
Created {format(new Date(document.created_at), "MMM d, yyyy")} by{" "}
{document.created_by.first_name} {document.created_by.last_name}
</div>
{/* Delete Dialog */}
<ConfirmDialog
open={deleteOpen}
onOpenChange={setDeleteOpen}
title="Delete Document"
description="Are you sure you want to delete this document? This action cannot be undone."
confirmLabel="Delete"
variant="destructive"
loading={deleteDocument.isPending}
onConfirm={() => {
deleteDocument.mutate(id, {
onSuccess: () => {
router.push("/app/documents");
},
});
}}
/>
</div>
);
}
+32
View File
@@ -0,0 +1,32 @@
"use client";
import { useRouter } from "next/navigation";
import { PageHeader } from "@/components/shared/page-header";
import { DocumentForm } from "@/components/documents/document-form";
import { useCreateDocument } from "@/lib/hooks/use-documents";
export default function NewDocumentPage() {
const router = useRouter();
const createDocument = useCreateDocument();
return (
<div className="space-y-6">
<PageHeader title="New Document" description="Add a new document" />
<DocumentForm
loading={createDocument.isPending}
onSubmit={(data, file) => {
createDocument.mutate(
{ data, file },
{
onSuccess: (res) => {
router.push(`/app/documents/${res.id}`);
},
},
);
}}
/>
</div>
);
}
+114
View File
@@ -0,0 +1,114 @@
"use client";
import { useRouter } from "next/navigation";
import { FileText } from "lucide-react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { PageHeader } from "@/components/shared/page-header";
import { LoadingSkeleton } from "@/components/shared/loading-skeleton";
import { ErrorBanner } from "@/components/shared/error-banner";
import { EmptyState } from "@/components/shared/empty-state";
import { DocumentCard } from "@/components/documents/document-card";
import { useDocuments, useWarranties } from "@/lib/hooks/use-documents";
export default function DocumentsPage() {
const router = useRouter();
const {
data: documents,
isLoading: documentsLoading,
error: documentsError,
refetch: refetchDocuments,
} = useDocuments();
const {
data: warranties,
isLoading: warrantiesLoading,
error: warrantiesError,
refetch: refetchWarranties,
} = useWarranties();
return (
<div className="space-y-6">
<PageHeader
title="Documents"
description="Manage your property documents and warranties"
actionLabel="Add Document"
onAction={() => router.push("/app/documents/new")}
/>
<Tabs defaultValue="documents">
<TabsList>
<TabsTrigger value="documents">Documents</TabsTrigger>
<TabsTrigger value="warranties">Warranties</TabsTrigger>
</TabsList>
<TabsContent value="documents" className="mt-4">
{documentsLoading && <LoadingSkeleton variant="card-grid" />}
{documentsError && (
<ErrorBanner
message="Failed to load documents."
onRetry={() => refetchDocuments()}
/>
)}
{!documentsLoading &&
!documentsError &&
documents &&
documents.length === 0 && (
<EmptyState
icon={FileText}
title="No documents yet"
description="Add your first document to start organizing your property records."
actionLabel="Add Document"
onAction={() => router.push("/app/documents/new")}
/>
)}
{!documentsLoading &&
!documentsError &&
documents &&
documents.length > 0 && (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{documents.map((doc) => (
<DocumentCard key={doc.id} document={doc} />
))}
</div>
)}
</TabsContent>
<TabsContent value="warranties" className="mt-4">
{warrantiesLoading && <LoadingSkeleton variant="card-grid" />}
{warrantiesError && (
<ErrorBanner
message="Failed to load warranties."
onRetry={() => refetchWarranties()}
/>
)}
{!warrantiesLoading &&
!warrantiesError &&
warranties &&
warranties.length === 0 && (
<EmptyState
icon={FileText}
title="No warranties yet"
description="Documents with type 'warranty' will appear here."
/>
)}
{!warrantiesLoading &&
!warrantiesError &&
warranties &&
warranties.length > 0 && (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{warranties.map((doc) => (
<DocumentCard key={doc.id} document={doc} />
))}
</div>
)}
</TabsContent>
</Tabs>
</div>
);
}
+25
View File
@@ -0,0 +1,25 @@
"use client";
import { Sidebar } from '@/components/layout/sidebar';
import { TopBar } from '@/components/layout/top-bar';
import { MobileNav } from '@/components/layout/mobile-nav';
export default function AppLayout({ children }: { children: React.ReactNode }) {
return (
<div className="min-h-screen bg-background">
{/* Sidebar - hidden on mobile */}
<Sidebar />
{/* Main content area */}
<div className="md:ml-16 lg:ml-64 flex flex-col min-h-screen">
<TopBar />
<main className="flex-1 p-4 lg:p-6 pb-20 md:pb-6">
{children}
</main>
</div>
{/* Mobile bottom nav */}
<MobileNav />
</div>
);
}
+47
View File
@@ -0,0 +1,47 @@
"use client";
import { useResidences } from "@/lib/hooks/use-residences";
import { useAuthStore } from "@/stores/auth";
import { LoadingSkeleton } from "@/components/shared/loading-skeleton";
import { StatsCards } from "@/components/dashboard/stats-cards";
import { TaskCompletionChart } from "@/components/dashboard/task-completion-chart";
import { RecentActivity } from "@/components/dashboard/recent-activity";
export default function DashboardPage() {
const { data: residences, isLoading } = useResidences();
const user = useAuthStore((s) => s.user);
const totalOverdue =
residences?.reduce((sum, r) => sum + r.task_summary.overdue, 0) ?? 0;
const totalDueSoon =
residences?.reduce((sum, r) => sum + r.task_summary.due_soon, 0) ?? 0;
const totalActive =
residences?.reduce((sum, r) => sum + r.task_summary.in_progress, 0) ?? 0;
const totalCompleted =
residences?.reduce((sum, r) => sum + r.task_summary.completed, 0) ?? 0;
return (
<div className="space-y-8">
<h1 className="text-2xl font-bold tracking-tight">
{user?.first_name
? `Welcome back, ${user.first_name}`
: "Dashboard"}
</h1>
{isLoading ? (
<LoadingSkeleton variant="card-grid" count={4} />
) : (
<>
<StatsCards
overdue={totalOverdue}
dueSoon={totalDueSoon}
active={totalActive}
completed={totalCompleted}
/>
<TaskCompletionChart data={[]} />
<RecentActivity />
</>
)}
</div>
);
}
+63
View File
@@ -0,0 +1,63 @@
"use client";
import { use } from "react";
import { useRouter } from "next/navigation";
import { PageHeader } from "@/components/shared/page-header";
import { LoadingSkeleton } from "@/components/shared/loading-skeleton";
import { ErrorBanner } from "@/components/shared/error-banner";
import { ResidenceForm } from "@/components/residences/residence-form";
import { useResidence, useUpdateResidence } from "@/lib/hooks/use-residences";
interface EditResidencePageProps {
params: Promise<{ id: string }>;
}
export default function EditResidencePage({ params }: EditResidencePageProps) {
const { id: rawId } = use(params);
const id = Number(rawId);
const router = useRouter();
const { data: residence, isLoading, error, refetch } = useResidence(id);
const updateResidence = useUpdateResidence(id);
if (isLoading) {
return (
<div className="space-y-6">
<LoadingSkeleton variant="detail" />
</div>
);
}
if (error || !residence) {
return (
<div className="space-y-6">
<ErrorBanner
message="Failed to load residence."
onRetry={() => refetch()}
/>
</div>
);
}
return (
<div className="space-y-6">
<PageHeader
title="Edit Residence"
description={residence.name}
/>
<ResidenceForm
residence={residence}
loading={updateResidence.isPending}
onSubmit={(data) => {
updateResidence.mutate(data, {
onSuccess: () => {
router.push(`/app/residences/${id}`);
},
});
}}
/>
</div>
);
}
+203
View File
@@ -0,0 +1,203 @@
"use client";
import { use, useState } from "react";
import { useRouter } from "next/navigation";
import { MapPin, Pencil, Share2, Trash2, FileDown } from "lucide-react";
import * as residencesApi from "@/lib/api/residences";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { PageHeader } from "@/components/shared/page-header";
import { LoadingSkeleton } from "@/components/shared/loading-skeleton";
import { ErrorBanner } from "@/components/shared/error-banner";
import { ConfirmDialog } from "@/components/shared/confirm-dialog";
import { ResidenceSummary } from "@/components/residences/residence-summary";
import { useResidence, useResidences, useDeleteResidence } from "@/lib/hooks/use-residences";
interface ResidenceDetailPageProps {
params: Promise<{ id: string }>;
}
export default function ResidenceDetailPage({ params }: ResidenceDetailPageProps) {
const { id: rawId } = use(params);
const id = Number(rawId);
const router = useRouter();
const { data: residence, isLoading, error, refetch } = useResidence(id);
const { data: residences } = useResidences();
const deleteResidence = useDeleteResidence();
const [deleteOpen, setDeleteOpen] = useState(false);
const [reportLoading, setReportLoading] = useState(false);
const [reportMessage, setReportMessage] = useState<string | null>(null);
const handleGenerateReport = async () => {
setReportLoading(true);
setReportMessage(null);
try {
const result = await residencesApi.generateTasksReport(id);
setReportMessage(result.message || "Report sent to your email!");
} catch {
setReportMessage("Failed to generate report.");
} finally {
setReportLoading(false);
}
};
// Find the task summary from the residences list
const myResidence = residences?.find((r) => r.residence.id === id);
const taskSummary = myResidence?.task_summary;
if (isLoading) {
return (
<div className="space-y-6">
<LoadingSkeleton variant="detail" />
</div>
);
}
if (error || !residence) {
return (
<div className="space-y-6">
<ErrorBanner
message="Failed to load residence."
onRetry={() => refetch()}
/>
</div>
);
}
const address = [
residence.street_address,
residence.apartment_unit,
residence.city,
residence.state_province,
residence.postal_code,
residence.country,
]
.filter(Boolean)
.join(", ");
const details = [
{ label: "Bedrooms", value: residence.bedrooms },
{ label: "Bathrooms", value: residence.bathrooms },
{ label: "Sq. Footage", value: residence.square_footage?.toLocaleString() },
{ label: "Year Built", value: residence.year_built },
{ label: "Property Type", value: residence.property_type?.name },
].filter((d) => d.value != null);
return (
<div className="space-y-6">
<PageHeader title={residence.name}>
{residence.is_owner && (
<Button
variant="outline"
size="sm"
onClick={() => router.push(`/app/residences/${id}/share`)}
>
<Share2 className="size-4 mr-2" />
Share
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={handleGenerateReport}
disabled={reportLoading}
>
<FileDown className="size-4 mr-2" />
{reportLoading ? "Generating..." : "Report"}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => router.push(`/app/residences/${id}/edit`)}
>
<Pencil className="size-4 mr-2" />
Edit
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setDeleteOpen(true)}
>
<Trash2 className="size-4 mr-2" />
Delete
</Button>
</PageHeader>
{/* Report Message */}
{reportMessage && (
<div className="rounded-md border px-4 py-3 text-sm">
{reportMessage}
</div>
)}
{/* Address */}
{address && (
<div className="flex items-center gap-2 text-muted-foreground">
<MapPin className="size-4 shrink-0" />
<span>{address}</span>
</div>
)}
{/* Task Summary */}
{taskSummary && (
<ResidenceSummary
totalTasks={taskSummary.total}
inProgress={taskSummary.in_progress}
userCount={residence.user_count}
/>
)}
{/* Description */}
{residence.description && (
<Card>
<CardHeader>
<CardTitle className="text-sm text-muted-foreground">Description</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm whitespace-pre-wrap">{residence.description}</p>
</CardContent>
</Card>
)}
{/* Property Details */}
{details.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-sm text-muted-foreground">Property Details</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
{details.map((d) => (
<div key={d.label}>
<p className="text-xs text-muted-foreground">{d.label}</p>
<p className="text-sm font-medium">{d.value}</p>
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* Delete Dialog */}
<ConfirmDialog
open={deleteOpen}
onOpenChange={setDeleteOpen}
title="Delete Residence"
description="Are you sure you want to delete this residence? This action cannot be undone."
confirmLabel="Delete"
variant="destructive"
loading={deleteResidence.isPending}
onConfirm={() => {
deleteResidence.mutate(id, {
onSuccess: () => {
router.push("/app/residences");
},
});
}}
/>
</div>
);
}
+106
View File
@@ -0,0 +1,106 @@
"use client";
import { use } from "react";
import { useRouter } from "next/navigation";
import { ArrowLeft } from "lucide-react";
import { Button } from "@/components/ui/button";
import { PageHeader } from "@/components/shared/page-header";
import { LoadingSkeleton } from "@/components/shared/loading-skeleton";
import { ErrorBanner } from "@/components/shared/error-banner";
import { ShareCodeDisplay } from "@/components/sharing/share-code-display";
import { UserManagement } from "@/components/sharing/user-management";
import { CaseraFileExport } from "@/components/sharing/casera-file-handler";
import { useResidence } from "@/lib/hooks/use-residences";
interface SharePageProps {
params: Promise<{ id: string }>;
}
export default function ResidenceSharePage({ params }: SharePageProps) {
const { id: rawId } = use(params);
const id = Number(rawId);
const router = useRouter();
const { data: residence, isLoading, error, refetch } = useResidence(id);
if (isLoading) {
return (
<div className="space-y-6">
<LoadingSkeleton variant="detail" />
</div>
);
}
if (error || !residence) {
return (
<div className="space-y-6">
<ErrorBanner
message="Failed to load residence."
onRetry={() => refetch()}
/>
</div>
);
}
// Build the exportable residence data
const exportData = {
type: "casera_residence_share",
version: 1,
residence: {
name: residence.name,
property_type_id: residence.property_type_id,
street_address: residence.street_address,
apartment_unit: residence.apartment_unit,
city: residence.city,
state_province: residence.state_province,
postal_code: residence.postal_code,
country: residence.country,
bedrooms: residence.bedrooms,
bathrooms: residence.bathrooms,
square_footage: residence.square_footage,
lot_size: residence.lot_size,
year_built: residence.year_built,
description: residence.description,
},
exported_at: new Date().toISOString(),
};
const safeFilename = residence.name.replace(/[^a-zA-Z0-9_-]/g, "_").toLowerCase();
return (
<div className="space-y-6 max-w-2xl">
<PageHeader title={`Share "${residence.name}"`}>
<Button
variant="ghost"
size="sm"
onClick={() => router.push(`/app/residences/${id}`)}
>
<ArrowLeft className="size-4 mr-2" />
Back
</Button>
</PageHeader>
{/* Share code section — only owner can manage */}
{residence.is_owner && (
<ShareCodeDisplay residenceId={id} />
)}
{/* Export .casera file */}
{residence.is_owner && (
<div className="flex items-center gap-3">
<CaseraFileExport
data={exportData}
filename={`${safeFilename}-residence`}
label="Export Residence (.casera)"
/>
<p className="text-sm text-muted-foreground">
Download residence data as a portable .casera file.
</p>
</div>
)}
{/* User / member management */}
<UserManagement residenceId={id} />
</div>
);
}
+132
View File
@@ -0,0 +1,132 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Home } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import { PageHeader } from "@/components/shared/page-header";
import { ErrorBanner } from "@/components/shared/error-banner";
import { CaseraFileImport } from "@/components/sharing/casera-file-handler";
import { useJoinResidence } from "@/lib/hooks/use-sharing";
export default function JoinResidencePage() {
const router = useRouter();
const joinResidence = useJoinResidence();
const [code, setCode] = useState("");
const [fileError, setFileError] = useState<string | null>(null);
function handleSubmitCode(e: React.FormEvent) {
e.preventDefault();
const trimmed = code.trim();
if (!trimmed) return;
joinResidence.mutate(trimmed, {
onSuccess: () => {
router.push("/app/residences");
},
});
}
function handleFileImport(data: unknown) {
setFileError(null);
// Validate that the imported data has a code field
if (
typeof data === "object" &&
data !== null &&
"code" in data &&
typeof (data as Record<string, unknown>).code === "string"
) {
const importedCode = (data as Record<string, unknown>).code as string;
joinResidence.mutate(importedCode, {
onSuccess: () => {
router.push("/app/residences");
},
});
} else {
setFileError(
"Invalid .casera file. Expected a share package with a code field.",
);
}
}
return (
<div className="space-y-6 max-w-lg mx-auto">
<PageHeader title="Join a Residence" />
{joinResidence.isError && (
<ErrorBanner
message={
joinResidence.error instanceof Error
? joinResidence.error.message
: "Failed to join residence. Please check the code and try again."
}
/>
)}
{/* Code entry */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Home className="size-5" />
Enter Share Code
</CardTitle>
<CardDescription>
Enter the 6-character code you received from the residence owner.
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmitCode} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="share-code">Share Code</Label>
<Input
id="share-code"
placeholder="ABC123"
value={code}
onChange={(e) => setCode(e.target.value.toUpperCase())}
maxLength={6}
className="text-center text-lg font-mono tracking-widest"
autoComplete="off"
/>
</div>
<Button
type="submit"
className="w-full"
disabled={code.trim().length === 0 || joinResidence.isPending}
>
{joinResidence.isPending ? "Joining..." : "Join Residence"}
</Button>
</form>
</CardContent>
</Card>
{/* Divider */}
<div className="flex items-center gap-4">
<Separator className="flex-1" />
<span className="text-xs text-muted-foreground uppercase">or</span>
<Separator className="flex-1" />
</div>
{/* File import */}
<Card>
<CardHeader>
<CardTitle>Import .casera File</CardTitle>
<CardDescription>
If you received a .casera share package file, import it here.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<CaseraFileImport onImport={handleFileImport} />
{fileError && (
<p className="text-sm text-destructive">{fileError}</p>
)}
</CardContent>
</Card>
</div>
);
}
+29
View File
@@ -0,0 +1,29 @@
"use client";
import { useRouter } from "next/navigation";
import { PageHeader } from "@/components/shared/page-header";
import { ResidenceForm } from "@/components/residences/residence-form";
import { useCreateResidence } from "@/lib/hooks/use-residences";
export default function NewResidencePage() {
const router = useRouter();
const createResidence = useCreateResidence();
return (
<div className="space-y-6">
<PageHeader title="New Residence" description="Add a new property" />
<ResidenceForm
loading={createResidence.isPending}
onSubmit={(data) => {
createResidence.mutate(data, {
onSuccess: (res) => {
router.push(`/app/residences/${res.id}`);
},
});
}}
/>
</div>
);
}
+54
View File
@@ -0,0 +1,54 @@
"use client";
import { useRouter } from "next/navigation";
import { Home } from "lucide-react";
import { PageHeader } from "@/components/shared/page-header";
import { LoadingSkeleton } from "@/components/shared/loading-skeleton";
import { ErrorBanner } from "@/components/shared/error-banner";
import { EmptyState } from "@/components/shared/empty-state";
import { ResidenceCard } from "@/components/residences/residence-card";
import { useResidences } from "@/lib/hooks/use-residences";
export default function ResidencesPage() {
const router = useRouter();
const { data: residences, isLoading, error, refetch } = useResidences();
return (
<div className="space-y-6">
<PageHeader
title="Residences"
description="Manage your properties"
actionLabel="Add Residence"
onAction={() => router.push("/app/residences/new")}
/>
{isLoading && <LoadingSkeleton variant="card-grid" />}
{error && (
<ErrorBanner
message="Failed to load residences."
onRetry={() => refetch()}
/>
)}
{!isLoading && !error && residences && residences.length === 0 && (
<EmptyState
icon={Home}
title="No residences yet"
description="Add your first property to start tracking tasks and maintenance."
actionLabel="Add Residence"
onAction={() => router.push("/app/residences/new")}
/>
)}
{!isLoading && !error && residences && residences.length > 0 && (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{residences.map((item) => (
<ResidenceCard key={item.residence.id} data={item} />
))}
</div>
)}
</div>
);
}
+38
View File
@@ -0,0 +1,38 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
import { User, Bell, CreditCard } from "lucide-react";
const settingsNav = [
{ label: "Profile", href: "/app/settings/profile", icon: User },
{ label: "Notifications", href: "/app/settings/notifications", icon: Bell },
{ label: "Subscription", href: "/app/settings/subscription", icon: CreditCard },
];
export default function SettingsLayout({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold tracking-tight">Settings</h1>
<div className="flex flex-col sm:flex-row gap-6">
<nav className="flex sm:flex-col gap-1 sm:w-48 shrink-0">
{settingsNav.map((item) => (
<Link key={item.href} href={item.href}
className={cn(
"flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors",
"hover:bg-accent hover:text-accent-foreground",
pathname === item.href ? "bg-accent text-accent-foreground" : "text-muted-foreground"
)}>
<item.icon className="size-4" />
{item.label}
</Link>
))}
</nav>
<div className="flex-1 min-w-0">{children}</div>
</div>
</div>
);
}
@@ -0,0 +1,7 @@
"use client";
import { NotificationPreferences } from "@/components/settings/notification-preferences";
export default function NotificationsSettingsPage() {
return <NotificationPreferences />;
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function SettingsPage() {
redirect("/app/settings/profile");
}
+17
View File
@@ -0,0 +1,17 @@
"use client";
import { ProfileForm } from "@/components/settings/profile-form";
import { ChangePasswordForm } from "@/components/settings/change-password-form";
import { ThemePicker } from "@/components/settings/theme-picker";
import { DeleteAccountSection } from "@/components/settings/delete-account-section";
export default function ProfileSettingsPage() {
return (
<div className="space-y-6">
<ProfileForm />
<ChangePasswordForm />
<ThemePicker />
<DeleteAccountSection />
</div>
);
}
@@ -0,0 +1,13 @@
"use client";
import { SubscriptionStatus } from "@/components/settings/subscription-status";
import { FeatureComparison } from "@/components/settings/feature-comparison";
export default function SubscriptionSettingsPage() {
return (
<div className="space-y-6">
<SubscriptionStatus />
<FeatureComparison />
</div>
);
}
+74
View File
@@ -0,0 +1,74 @@
"use client";
import { use } from "react";
import { useRouter } from "next/navigation";
import { PageHeader } from "@/components/shared/page-header";
import { LoadingSkeleton } from "@/components/shared/loading-skeleton";
import { ErrorBanner } from "@/components/shared/error-banner";
import { Card, CardContent } from "@/components/ui/card";
import { TaskCompletionForm } from "@/components/tasks/task-completion-form";
import { useTask, useCreateCompletion } from "@/lib/hooks/use-tasks";
export default function CompleteTaskPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = use(params);
const taskId = Number(id);
const router = useRouter();
const { data: task, isLoading, isError, error, refetch } = useTask(taskId);
const createCompletion = useCreateCompletion();
if (isLoading) {
return <LoadingSkeleton variant="detail" />;
}
if (isError) {
return (
<ErrorBanner
message={
error instanceof Error ? error.message : "Failed to load task"
}
onRetry={() => refetch()}
/>
);
}
if (!task) return null;
return (
<div className="space-y-6">
<PageHeader
title="Complete Task"
description={task.title}
/>
<Card>
<CardContent>
<TaskCompletionForm
onSubmit={(data, images) => {
createCompletion.mutate(
{
data: {
task_id: taskId,
completed_at: data.completed_at,
actual_cost: data.actual_cost,
notes: data.notes,
rating: data.rating,
},
images,
},
{
onSuccess: () => router.push(`/app/tasks/${taskId}`),
},
);
}}
isSubmitting={createCompletion.isPending}
/>
</CardContent>
</Card>
</div>
);
}
+60
View File
@@ -0,0 +1,60 @@
"use client";
import { use } from "react";
import { useRouter } from "next/navigation";
import { PageHeader } from "@/components/shared/page-header";
import { LoadingSkeleton } from "@/components/shared/loading-skeleton";
import { ErrorBanner } from "@/components/shared/error-banner";
import { Card, CardContent } from "@/components/ui/card";
import { TaskForm } from "@/components/tasks/task-form";
import { useTask, useUpdateTask } from "@/lib/hooks/use-tasks";
export default function EditTaskPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = use(params);
const taskId = Number(id);
const router = useRouter();
const { data: task, isLoading, isError, error, refetch } = useTask(taskId);
const updateTask = useUpdateTask(taskId);
if (isLoading) {
return <LoadingSkeleton variant="detail" />;
}
if (isError) {
return (
<ErrorBanner
message={
error instanceof Error ? error.message : "Failed to load task"
}
onRetry={() => refetch()}
/>
);
}
if (!task) return null;
return (
<div className="space-y-6">
<PageHeader title="Edit Task" />
<Card>
<CardContent>
<TaskForm
task={task}
onSubmit={(data) => {
updateTask.mutate(data, {
onSuccess: () => router.push(`/app/tasks/${taskId}`),
});
}}
isSubmitting={updateTask.isPending}
/>
</CardContent>
</Card>
</div>
);
}
+253
View File
@@ -0,0 +1,253 @@
"use client";
import { use } from "react";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { PageHeader } from "@/components/shared/page-header";
import { LoadingSkeleton } from "@/components/shared/loading-skeleton";
import { ErrorBanner } from "@/components/shared/error-banner";
import { StarRating } from "@/components/shared/star-rating";
import { TaskActionsMenu } from "@/components/tasks/task-actions-menu";
import { useTask, useTaskCompletions } from "@/lib/hooks/use-tasks";
import {
Calendar,
DollarSign,
Repeat,
User,
Wrench,
Home,
} from "lucide-react";
export default function TaskDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = use(params);
const taskId = Number(id);
const { data: task, isLoading, isError, error, refetch } = useTask(taskId);
const { data: completions } = useTaskCompletions(taskId);
if (isLoading) {
return <LoadingSkeleton variant="detail" />;
}
if (isError) {
return (
<ErrorBanner
message={error instanceof Error ? error.message : "Failed to load task"}
onRetry={() => refetch()}
/>
);
}
if (!task) return null;
const statusLabel = task.is_cancelled
? "Cancelled"
: task.is_archived
? "Archived"
: task.in_progress
? "In Progress"
: "Active";
const statusVariant = task.is_cancelled
? "destructive"
: task.is_archived
? "secondary"
: task.in_progress
? "default"
: "outline";
return (
<div className="space-y-6">
<PageHeader title={task.title}>
<TaskActionsMenu taskId={task.id} />
</PageHeader>
{/* Status & badges */}
<div className="flex flex-wrap gap-2">
<Badge variant={statusVariant}>{statusLabel}</Badge>
{task.priority && (
<Badge variant="outline">
{task.priority.icon && <span className="mr-1">{task.priority.icon}</span>}
{task.priority.name}
</Badge>
)}
{task.category && (
<Badge variant="secondary">
{task.category.icon && <span className="mr-1">{task.category.icon}</span>}
{task.category.name}
</Badge>
)}
{task.frequency && (
<Badge variant="outline">
<Repeat className="size-3 mr-1" />
{task.frequency.name}
</Badge>
)}
</div>
{/* Metadata */}
<Card>
<CardHeader>
<CardTitle>Details</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 text-sm">
<div className="flex items-center gap-2">
<Home className="size-4 text-muted-foreground shrink-0" />
<span className="text-muted-foreground">Residence:</span>
<span>{task.residence_name}</span>
</div>
{task.due_date && (
<div className="flex items-center gap-2">
<Calendar className="size-4 text-muted-foreground shrink-0" />
<span className="text-muted-foreground">Due Date:</span>
<span>{new Date(task.due_date).toLocaleDateString()}</span>
</div>
)}
{task.next_due_date && (
<div className="flex items-center gap-2">
<Calendar className="size-4 text-muted-foreground shrink-0" />
<span className="text-muted-foreground">Next Due:</span>
<span>
{new Date(task.next_due_date).toLocaleDateString()}
</span>
</div>
)}
{task.estimated_cost != null && task.estimated_cost > 0 && (
<div className="flex items-center gap-2">
<DollarSign className="size-4 text-muted-foreground shrink-0" />
<span className="text-muted-foreground">Estimated Cost:</span>
<span>${task.estimated_cost.toFixed(2)}</span>
</div>
)}
{task.actual_cost != null && task.actual_cost > 0 && (
<div className="flex items-center gap-2">
<DollarSign className="size-4 text-muted-foreground shrink-0" />
<span className="text-muted-foreground">Actual Cost:</span>
<span>${task.actual_cost.toFixed(2)}</span>
</div>
)}
{task.contractor && (
<div className="flex items-center gap-2">
<Wrench className="size-4 text-muted-foreground shrink-0" />
<span className="text-muted-foreground">Contractor:</span>
<span>
{task.contractor.name}
{task.contractor.company && ` (${task.contractor.company})`}
</span>
</div>
)}
{task.assigned_to && (
<div className="flex items-center gap-2">
<User className="size-4 text-muted-foreground shrink-0" />
<span className="text-muted-foreground">Assigned To:</span>
<span>
{task.assigned_to.first_name} {task.assigned_to.last_name}
</span>
</div>
)}
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Completions:</span>
<span>{task.completion_count}</span>
</div>
{task.last_completed_at && (
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Last Completed:</span>
<span>
{new Date(task.last_completed_at).toLocaleDateString()}
</span>
</div>
)}
</div>
</CardContent>
</Card>
{/* Description */}
{task.description && (
<Card>
<CardHeader>
<CardTitle>Description</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm whitespace-pre-wrap">{task.description}</p>
</CardContent>
</Card>
)}
{/* Completion history */}
{completions && completions.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Completion History</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{completions.map((completion) => (
<div
key={completion.id}
className="border rounded-lg p-3 space-y-2"
>
<div className="flex items-center justify-between">
<span className="text-sm font-medium">
{new Date(completion.completed_at).toLocaleDateString()}{" "}
{new Date(completion.completed_at).toLocaleTimeString()}
</span>
{completion.rating != null && completion.rating > 0 && (
<StarRating
value={completion.rating}
readonly
size="sm"
/>
)}
</div>
{completion.completed_by && (
<p className="text-xs text-muted-foreground">
Completed by {completion.completed_by.first_name}{" "}
{completion.completed_by.last_name}
</p>
)}
{completion.notes && (
<p className="text-sm">{completion.notes}</p>
)}
{completion.actual_cost != null &&
completion.actual_cost > 0 && (
<p className="text-sm text-muted-foreground">
Cost: ${completion.actual_cost.toFixed(2)}
</p>
)}
{completion.images.length > 0 && (
<div className="flex gap-2 flex-wrap">
{completion.images.map((img) => (
<img
key={img.id}
src={img.image_url}
alt={img.caption || "Completion photo"}
className="size-20 rounded-md object-cover border"
/>
))}
</div>
)}
</div>
))}
</div>
</CardContent>
</Card>
)}
</div>
);
}
+31
View File
@@ -0,0 +1,31 @@
"use client";
import { useRouter } from "next/navigation";
import { PageHeader } from "@/components/shared/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { TaskForm } from "@/components/tasks/task-form";
import { useCreateTask } from "@/lib/hooks/use-tasks";
export default function NewTaskPage() {
const router = useRouter();
const createTask = useCreateTask();
return (
<div className="space-y-6">
<PageHeader title="New Task" />
<Card>
<CardContent>
<TaskForm
onSubmit={(data) => {
createTask.mutate(data, {
onSuccess: () => router.push("/app/tasks"),
});
}}
isSubmitting={createTask.isPending}
/>
</CardContent>
</Card>
</div>
);
}
+80
View File
@@ -0,0 +1,80 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { ClipboardList } from "lucide-react";
import { PageHeader } from "@/components/shared/page-header";
import { LoadingSkeleton } from "@/components/shared/loading-skeleton";
import { ErrorBanner } from "@/components/shared/error-banner";
import { EmptyState } from "@/components/shared/empty-state";
import { LookupSelect } from "@/components/shared/lookup-select";
import { KanbanBoard } from "@/components/tasks/kanban-board";
import { useTasks, useTasksByResidence } from "@/lib/hooks/use-tasks";
import { useResidences } from "@/lib/hooks/use-residences";
export default function TasksPage() {
const router = useRouter();
const [selectedResidenceId, setSelectedResidenceId] = useState<
number | undefined
>();
const { data: residences } = useResidences();
const allTasks = useTasks();
const filteredTasks = useTasksByResidence(selectedResidenceId ?? 0);
const activeQuery = selectedResidenceId ? filteredTasks : allTasks;
const { data, isLoading, isError, error, refetch } = activeQuery;
const residenceItems = (residences ?? []).map((r) => ({
id: r.residence.id,
name: r.residence.name,
}));
const isEmpty =
data && data.columns.every((col) => col.tasks.length === 0);
return (
<div className="space-y-6">
<PageHeader
title="Tasks"
description="Manage your home maintenance tasks"
actionLabel="New Task"
onAction={() => router.push("/app/tasks/new")}
>
{residenceItems.length > 1 && (
<LookupSelect
items={[{ id: 0, name: "All Residences" }, ...residenceItems]}
value={selectedResidenceId ?? 0}
onValueChange={(v) =>
setSelectedResidenceId(v === 0 ? undefined : v)
}
placeholder="Filter by residence..."
/>
)}
</PageHeader>
{isLoading && <LoadingSkeleton variant="kanban" count={5} />}
{isError && (
<ErrorBanner
message={error instanceof Error ? error.message : "Failed to load tasks"}
onRetry={() => refetch()}
/>
)}
{!isLoading && !isError && isEmpty && (
<EmptyState
icon={ClipboardList}
title="No tasks yet"
description="Create your first task to start tracking home maintenance."
actionLabel="New Task"
onAction={() => router.push("/app/tasks/new")}
/>
)}
{!isLoading && !isError && data && !isEmpty && (
<KanbanBoard data={data} />
)}
</div>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+73
View File
@@ -0,0 +1,73 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@import "../styles/themes.css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
--radius-3xl: calc(var(--radius) + 12px);
--radius-4xl: calc(var(--radius) + 16px);
/* App-specific theme-aware Tailwind utilities */
--color-bg-primary: var(--color-bg-primary);
--color-bg-secondary: var(--color-bg-secondary);
--color-text-primary: var(--color-text-primary);
--color-text-secondary: var(--color-text-secondary);
--color-text-on-primary: var(--color-text-on-primary);
--color-app-primary: var(--color-primary);
--color-app-secondary: var(--color-secondary);
--color-app-accent: var(--color-accent);
--color-app-error: var(--color-error);
}
:root {
--radius: 0.625rem;
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
+38
View File
@@ -0,0 +1,38 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { ThemeProvider } from "@/lib/themes/theme-provider";
import { QueryProvider } from "@/lib/query/query-provider";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Casera",
description: "Property management platform",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" suppressHydrationWarning>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<QueryProvider>
<ThemeProvider>{children}</ThemeProvider>
</QueryProvider>
</body>
</html>
);
}
+57
View File
@@ -0,0 +1,57 @@
"use client";
import { useOnboardingStore } from "@/stores/onboarding";
import { cn } from "@/lib/utils";
function StepDots() {
const { currentStep, path } = useOnboardingStore();
// Total steps depends on path:
// Create path: Welcome (0) -> Choose (1) -> Create Residence (2) -> First Task (3) -> Complete (4)
// Join path: Welcome (0) -> Choose (1) -> Join Residence (2) -> Complete (3)
// Before path chosen: 4 dots (default)
const totalSteps = path === "join" ? 4 : 5;
return (
<div className="flex items-center justify-center gap-2">
{Array.from({ length: totalSteps }).map((_, i) => (
<div
key={i}
className={cn(
"h-2 rounded-full transition-all duration-300",
i === currentStep
? "w-6 bg-primary"
: i < currentStep
? "w-2 bg-primary/60"
: "w-2 bg-muted-foreground/30"
)}
/>
))}
</div>
);
}
export default function OnboardingLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="flex min-h-screen flex-col items-center bg-background px-4 py-12">
{/* Logo */}
<div className="mb-2">
<h1 className="text-2xl font-bold tracking-tight text-primary">
Casera
</h1>
</div>
{/* Progress dots */}
<div className="mb-8">
<StepDots />
</div>
{/* Content */}
<div className="w-full max-w-lg">{children}</div>
</div>
);
}
+54
View File
@@ -0,0 +1,54 @@
"use client";
import { useOnboardingStore } from "@/stores/onboarding";
import { WelcomeStep } from "@/components/onboarding/welcome-step";
import { ChoosePathStep } from "@/components/onboarding/choose-path-step";
import { CreateResidenceStep } from "@/components/onboarding/create-residence-step";
import { JoinResidenceStep } from "@/components/onboarding/join-residence-step";
import { FirstTaskStep } from "@/components/onboarding/first-task-step";
import { CompleteStep } from "@/components/onboarding/complete-step";
export default function OnboardingPage() {
const { currentStep, path } = useOnboardingStore();
// Step flow:
// 0: Welcome
// 1: Choose path (create or join)
// 2: Create Residence (path=create) or Join Residence (path=join)
// 3: First Task (path=create only) or Complete (path=join)
// 4: Complete (path=create)
if (currentStep === 0) {
return <WelcomeStep />;
}
if (currentStep === 1) {
return <ChoosePathStep />;
}
if (currentStep === 2) {
if (path === "create") {
return <CreateResidenceStep />;
}
if (path === "join") {
return <JoinResidenceStep />;
}
// Fallback if path not set (shouldn't happen)
return <ChoosePathStep />;
}
if (currentStep === 3) {
if (path === "create") {
return <FirstTaskStep />;
}
// path === "join" reaches complete at step 3
return <CompleteStep />;
}
if (currentStep === 4) {
return <CompleteStep />;
}
// Fallback
return <WelcomeStep />;
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation';
export default function Home() {
redirect('/app');
}
@@ -0,0 +1,75 @@
"use client";
import Link from "next/link";
import { Phone, Mail, Star } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardAction } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import type { ContractorResponse } from "@/lib/api/contractors";
interface ContractorCardProps {
contractor: ContractorResponse;
onToggleFavorite: (id: number) => void;
}
export function ContractorCard({ contractor, onToggleFavorite }: ContractorCardProps) {
return (
<Card className="transition-shadow hover:shadow-md">
<CardHeader>
<Link href={`/app/contractors/${contractor.id}`} className="hover:underline">
<CardTitle>{contractor.name}</CardTitle>
</Link>
{contractor.company && (
<CardDescription>{contractor.company}</CardDescription>
)}
<CardAction>
<Button
variant="ghost"
size="icon"
className="size-8"
onClick={(e) => {
e.preventDefault();
onToggleFavorite(contractor.id);
}}
>
<Star
className={
contractor.is_favorite
? "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}`} onClick={(e) => e.stopPropagation()}>
<Phone className="size-4" />
</a>
</Button>
)}
{contractor.email && (
<Button variant="outline" size="icon" className="size-8" asChild>
<a href={`mailto:${contractor.email}`} onClick={(e) => e.stopPropagation()}>
<Mail className="size-4" />
</a>
</Button>
)}
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,64 @@
"use client";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { LookupSelect } from "@/components/shared/lookup-select";
import { useContractorSpecialties } from "@/lib/hooks/use-lookups";
import { Search, Star } from "lucide-react";
import { cn } from "@/lib/utils";
interface ContractorFiltersProps {
search: string;
onSearchChange: (value: string) => void;
specialtyId: number | undefined;
onSpecialtyChange: (value: number | undefined) => void;
favoritesOnly: boolean;
onFavoritesOnlyChange: (value: boolean) => void;
}
export function ContractorFilters({
search,
onSearchChange,
specialtyId,
onSpecialtyChange,
favoritesOnly,
onFavoritesOnlyChange,
}: ContractorFiltersProps) {
const { data: specialties } = useContractorSpecialties();
return (
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
{/* Search */}
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
placeholder="Search by name or company..."
value={search}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-9"
/>
</div>
{/* Specialty filter */}
<div className="w-full sm:w-48">
<LookupSelect
items={specialties}
value={specialtyId}
onValueChange={onSpecialtyChange}
placeholder="All specialties"
/>
</div>
{/* Favorites toggle */}
<Button
variant={favoritesOnly ? "default" : "outline"}
size="sm"
onClick={() => onFavoritesOnlyChange(!favoritesOnly)}
className={cn("gap-1.5", favoritesOnly && "bg-yellow-500 hover:bg-yellow-600 text-white")}
>
<Star className={cn("size-4", favoritesOnly && "fill-white")} />
Favorites
</Button>
</div>
);
}
@@ -0,0 +1,168 @@
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { FormField } from "@/components/shared/form-field";
import { LookupSelect } from "@/components/shared/lookup-select";
import { StarRating } from "@/components/shared/star-rating";
import { useContractorSpecialties } from "@/lib/hooks/use-lookups";
import { useResidences } from "@/lib/hooks/use-residences";
import type { ContractorResponse } from "@/lib/api/contractors";
// ---------------------------------------------------------------------------
// Schema
// ---------------------------------------------------------------------------
const contractorSchema = z.object({
name: z.string().min(1, "Name is required"),
company: z.string().optional(),
phone: z.string().optional(),
email: z.string().email("Invalid email address").or(z.literal("")).optional(),
website: z.string().optional(),
notes: z.string().optional(),
residence_id: z.number().optional(),
specialty_ids: z.array(z.number()).optional(),
is_favorite: z.boolean().optional(),
rating: z.number().optional(),
});
export type ContractorFormValues = z.infer<typeof contractorSchema>;
// ---------------------------------------------------------------------------
// Props
// ---------------------------------------------------------------------------
interface ContractorFormProps {
contractor?: ContractorResponse;
onSubmit: (data: ContractorFormValues) => void;
loading?: boolean;
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export function ContractorForm({ contractor, onSubmit, loading }: ContractorFormProps) {
const { data: specialties } = useContractorSpecialties();
const { data: residencesData } = useResidences();
const residenceItems = (residencesData ?? []).map((r) => ({
id: r.residence.id,
name: r.residence.name,
}));
const {
register,
handleSubmit,
setValue,
watch,
formState: { errors },
} = useForm<ContractorFormValues>({
resolver: zodResolver(contractorSchema),
defaultValues: {
name: contractor?.name ?? "",
company: contractor?.company ?? "",
phone: contractor?.phone ?? "",
email: contractor?.email ?? "",
website: contractor?.website ?? "",
notes: contractor?.notes ?? "",
residence_id: contractor?.residence_id ?? undefined,
specialty_ids: contractor?.specialties.map((s) => s.id) ?? [],
is_favorite: contractor?.is_favorite ?? false,
rating: contractor?.rating ?? 0,
},
});
const selectedSpecialtyIds = watch("specialty_ids") ?? [];
const rating = watch("rating") ?? 0;
function toggleSpecialty(id: number) {
const current = selectedSpecialtyIds;
const next = current.includes(id)
? current.filter((sid) => sid !== id)
: [...current, id];
setValue("specialty_ids", next, { shouldDirty: true });
}
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
{/* Name (required) */}
<FormField label="Name" htmlFor="name" required error={errors.name?.message}>
<Input id="name" placeholder="e.g. John's Plumbing" {...register("name")} />
</FormField>
{/* Company */}
<FormField label="Company" htmlFor="company" error={errors.company?.message}>
<Input id="company" placeholder="Company name" {...register("company")} />
</FormField>
{/* Contact fields grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<FormField label="Phone" htmlFor="phone" error={errors.phone?.message}>
<Input id="phone" type="tel" placeholder="(555) 123-4567" {...register("phone")} />
</FormField>
<FormField label="Email" htmlFor="email" error={errors.email?.message}>
<Input id="email" type="email" placeholder="contractor@example.com" {...register("email")} />
</FormField>
<FormField label="Website" htmlFor="website" error={errors.website?.message} className="sm:col-span-2">
<Input id="website" placeholder="https://example.com" {...register("website")} />
</FormField>
</div>
{/* Residence */}
<FormField label="Residence" htmlFor="residence_id">
<LookupSelect
items={residenceItems}
value={watch("residence_id")}
onValueChange={(v) => setValue("residence_id", v, { shouldDirty: true })}
placeholder="Select a residence (optional)"
/>
</FormField>
{/* Specialties */}
<FormField label="Specialties" htmlFor="specialties">
<div className="flex flex-wrap gap-2">
{specialties.map((s) => {
const selected = selectedSpecialtyIds.includes(s.id);
return (
<Badge
key={s.id}
variant={selected ? "default" : "outline"}
className="cursor-pointer select-none"
onClick={() => toggleSpecialty(s.id)}
>
{s.icon && <span className="mr-1">{s.icon}</span>}
{s.name}
</Badge>
);
})}
</div>
</FormField>
{/* Rating */}
<FormField label="Rating" htmlFor="rating">
<StarRating
value={rating}
onChange={(v) => setValue("rating", v, { shouldDirty: true })}
/>
</FormField>
{/* Notes */}
<FormField label="Notes" htmlFor="notes" error={errors.notes?.message}>
<Textarea id="notes" placeholder="Any additional notes..." rows={3} {...register("notes")} />
</FormField>
{/* Submit */}
<div className="flex justify-end gap-2">
<Button type="submit" disabled={loading}>
{loading ? "Saving..." : contractor ? "Update Contractor" : "Create Contractor"}
</Button>
</div>
</form>
);
}
@@ -0,0 +1,73 @@
"use client";
import Link from "next/link";
import { Bell } from "lucide-react";
import { formatDistanceToNow } from "date-fns";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { useNotifications } from "@/lib/hooks/use-notifications";
export function RecentActivity() {
const { data, isLoading } = useNotifications(5);
const notifications = data?.results ?? [];
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span>Recent Activity</span>
<Link
href="/app/settings/notifications"
className="text-sm font-normal text-primary hover:underline"
>
View all
</Link>
</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="space-y-4">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="flex gap-3 animate-pulse">
<div className="size-8 rounded-full bg-muted" />
<div className="flex-1 space-y-2">
<div className="h-4 w-1/2 rounded bg-muted" />
<div className="h-3 w-3/4 rounded bg-muted" />
</div>
</div>
))}
</div>
) : notifications.length === 0 ? (
<div className="flex items-center justify-center py-8 text-muted-foreground">
No recent activity
</div>
) : (
<div className="space-y-4">
{notifications.map((notification) => (
<div key={notification.id} className="flex gap-3 items-start">
<div className="flex size-8 shrink-0 items-center justify-center rounded-full bg-primary/10">
<Bell className="size-4 text-primary" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium leading-tight">
{notification.title}
</p>
{notification.body && (
<p className="text-xs text-muted-foreground mt-0.5 truncate">
{notification.body}
</p>
)}
<p className="text-xs text-muted-foreground mt-1">
{formatDistanceToNow(new Date(notification.created_at), {
addSuffix: true,
})}
</p>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
);
}
+67
View File
@@ -0,0 +1,67 @@
"use client";
import Link from "next/link";
import { AlertTriangle, Clock, ClipboardList, CheckCircle2 } from "lucide-react";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
interface StatsCardsProps {
overdue: number;
dueSoon: number;
active: number;
completed: number;
}
const stats = [
{
key: "overdue",
label: "Overdue",
icon: AlertTriangle,
color: "text-red-500",
prop: "overdue" as const,
},
{
key: "dueSoon",
label: "Due Soon",
icon: Clock,
color: "text-orange-500",
prop: "dueSoon" as const,
},
{
key: "active",
label: "Active",
icon: ClipboardList,
color: "text-blue-500",
prop: "active" as const,
},
{
key: "completed",
label: "Completed",
icon: CheckCircle2,
color: "text-green-500",
prop: "completed" as const,
},
] as const;
export function StatsCards({ overdue, dueSoon, active, completed }: StatsCardsProps) {
const values: Record<string, number> = { overdue, dueSoon, active, completed };
return (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{stats.map((stat) => (
<Link key={stat.key} href="/app/tasks">
<Card className="hover:shadow-md transition-shadow cursor-pointer">
<CardHeader className="pb-2">
<CardTitle className="text-sm text-muted-foreground flex items-center gap-2">
<stat.icon className={`size-4 ${stat.color}`} />
{stat.label}
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{values[stat.prop]}</p>
</CardContent>
</Card>
</Link>
))}
</div>
);
}
@@ -0,0 +1,60 @@
"use client";
import {
ResponsiveContainer,
AreaChart,
Area,
XAxis,
YAxis,
Tooltip,
CartesianGrid,
} from "recharts";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
interface TaskCompletionChartProps {
data: { date: string; count: number }[];
}
export function TaskCompletionChart({ data }: TaskCompletionChartProps) {
const hasData = data && data.length > 0;
return (
<Card>
<CardHeader>
<CardTitle>Task Completions</CardTitle>
</CardHeader>
<CardContent>
{hasData ? (
<ResponsiveContainer width="100%" height={300}>
<AreaChart data={data}>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis
dataKey="date"
tick={{ fontSize: 12 }}
className="text-muted-foreground"
/>
<YAxis
allowDecimals={false}
tick={{ fontSize: 12 }}
className="text-muted-foreground"
/>
<Tooltip />
<Area
type="monotone"
dataKey="count"
stroke="hsl(var(--primary))"
fill="hsl(var(--primary))"
fillOpacity={0.2}
strokeWidth={2}
/>
</AreaChart>
</ResponsiveContainer>
) : (
<div className="flex items-center justify-center h-[300px] text-muted-foreground">
No completion data yet
</div>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,65 @@
import Link from "next/link";
import { FileText, FileImage, File, FileSpreadsheet } from "lucide-react";
import { format } from "date-fns";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { WarrantyStatus } from "@/components/documents/warranty-status";
import type { DocumentResponse } from "@/lib/api/documents";
interface DocumentCardProps {
document: DocumentResponse;
}
function getFileIcon(mimeType: string) {
if (mimeType.startsWith("image/")) return FileImage;
if (mimeType.includes("spreadsheet") || mimeType.includes("csv") || mimeType.includes("excel")) return FileSpreadsheet;
if (mimeType.includes("pdf") || mimeType.includes("text")) return FileText;
return File;
}
const typeLabels: Record<string, string> = {
general: "General",
warranty: "Warranty",
receipt: "Receipt",
contract: "Contract",
insurance: "Insurance",
manual: "Manual",
};
export function DocumentCard({ document: doc }: DocumentCardProps) {
const Icon = getFileIcon(doc.mime_type);
return (
<Link href={`/app/documents/${doc.id}`} className="block">
<Card className="transition-colors hover:border-primary/40">
<CardHeader>
<div className="flex items-start gap-3">
<div className="rounded-md bg-muted p-2 shrink-0">
<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>
</CardHeader>
<CardContent>
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline">
{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 mt-3">
Created {format(new Date(doc.created_at), "MMM d, yyyy")}
</p>
</CardContent>
</Card>
</Link>
);
}
+268
View File
@@ -0,0 +1,268 @@
"use client";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { FormField } from "@/components/shared/form-field";
import { LookupSelect } from "@/components/shared/lookup-select";
import { FileUpload } from "@/components/shared/file-upload";
import { CurrencyInput } from "@/components/shared/currency-input";
import { useResidences } from "@/lib/hooks/use-residences";
import type { DocumentResponse, DocumentType } from "@/lib/api/documents";
// ---------------------------------------------------------------------------
// Schema
// ---------------------------------------------------------------------------
const documentSchema = z.object({
title: z.string().min(1, "Title is required"),
residence_id: z.number({ error: "Residence is required" }),
description: z.string().optional(),
document_type: z
.enum(["general", "warranty", "receipt", "contract", "insurance", "manual"])
.optional(),
vendor: z.string().optional(),
serial_number: z.string().optional(),
model_number: z.string().optional(),
purchase_date: z.string().optional(),
expiry_date: z.string().optional(),
purchase_price: z.number().optional(),
});
type DocumentFormData = z.infer<typeof documentSchema>;
// ---------------------------------------------------------------------------
// Props
// ---------------------------------------------------------------------------
interface DocumentFormProps {
document?: DocumentResponse;
onSubmit: (data: DocumentFormData, file?: File) => void;
loading?: boolean;
}
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const documentTypes: { value: DocumentType; label: string }[] = [
{ value: "general", label: "General" },
{ value: "warranty", label: "Warranty" },
{ value: "receipt", label: "Receipt" },
{ value: "contract", label: "Contract" },
{ value: "insurance", label: "Insurance" },
{ value: "manual", label: "Manual" },
];
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export function DocumentForm({
document,
onSubmit,
loading = false,
}: DocumentFormProps) {
const { data: residences } = useResidences();
const [files, setFiles] = useState<File[]>([]);
const residenceItems = (residences ?? []).map((r) => ({
id: r.residence.id,
name: r.residence.name,
}));
const {
register,
handleSubmit,
setValue,
watch,
formState: { errors },
} = useForm<DocumentFormData>({
resolver: zodResolver(documentSchema),
defaultValues: {
title: document?.title ?? "",
residence_id: document?.residence_id ?? undefined,
description: document?.description ?? "",
document_type: document?.document_type ?? undefined,
vendor: document?.vendor ?? "",
serial_number: document?.serial_number ?? "",
model_number: document?.model_number ?? "",
purchase_date: document?.purchase_date ?? "",
expiry_date: document?.expiry_date ?? "",
purchase_price: document?.purchase_price ?? undefined,
},
});
const residenceId = watch("residence_id");
const documentType = watch("document_type");
const purchasePrice = watch("purchase_price");
const isWarranty = documentType === "warranty";
const handleFormSubmit = (data: DocumentFormData) => {
onSubmit(data, files[0]);
};
return (
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-8">
{/* Title & Residence */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField
label="Title"
htmlFor="title"
error={errors.title?.message}
required
>
<Input
id="title"
placeholder="Document title"
aria-invalid={!!errors.title}
{...register("title")}
/>
</FormField>
<FormField
label="Residence"
htmlFor="residence_id"
error={errors.residence_id?.message}
required
>
<LookupSelect
items={residenceItems}
value={residenceId}
onValueChange={(v) => setValue("residence_id", v as number)}
placeholder="Select residence..."
/>
</FormField>
</div>
{/* Document Type */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField label="Document Type" htmlFor="document_type">
<Select
value={documentType ?? ""}
onValueChange={(v) =>
setValue("document_type", (v || undefined) as DocumentType | undefined)
}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select type..." />
</SelectTrigger>
<SelectContent>
{documentTypes.map((dt) => (
<SelectItem key={dt.value} value={dt.value}>
{dt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</FormField>
</div>
{/* Description */}
<FormField label="Description" htmlFor="description">
<Textarea
id="description"
placeholder="Notes about this document..."
rows={3}
{...register("description")}
/>
</FormField>
{/* Warranty Fields (conditional) */}
{isWarranty && (
<div className="space-y-4">
<h3 className="text-sm font-medium text-muted-foreground">
Warranty Details
</h3>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField label="Vendor" htmlFor="vendor">
<Input
id="vendor"
placeholder="e.g. Samsung"
{...register("vendor")}
/>
</FormField>
<FormField label="Serial Number" htmlFor="serial_number">
<Input
id="serial_number"
placeholder="SN-12345"
{...register("serial_number")}
/>
</FormField>
<FormField label="Model Number" htmlFor="model_number">
<Input
id="model_number"
placeholder="ABC-100"
{...register("model_number")}
/>
</FormField>
<FormField label="Purchase Price" htmlFor="purchase_price">
<CurrencyInput
id="purchase_price"
value={purchasePrice}
onChange={(v) => setValue("purchase_price", v)}
placeholder="0.00"
/>
</FormField>
<FormField label="Purchase Date" htmlFor="purchase_date">
<Input
id="purchase_date"
type="date"
{...register("purchase_date")}
/>
</FormField>
<FormField label="Expiry Date" htmlFor="expiry_date">
<Input
id="expiry_date"
type="date"
{...register("expiry_date")}
/>
</FormField>
</div>
</div>
)}
{/* File Upload (create mode only) */}
{!document && (
<div className="space-y-2">
<h3 className="text-sm font-medium text-muted-foreground">
Attachment
</h3>
<FileUpload
accept="*"
multiple={false}
files={files}
onChange={setFiles}
label="Upload a document file"
/>
</div>
)}
{/* Submit */}
<div className="flex justify-end">
<Button type="submit" disabled={loading}>
{loading && <Loader2 className="size-4 mr-2 animate-spin" />}
{document ? "Save Changes" : "Create Document"}
</Button>
</div>
</form>
);
}
@@ -0,0 +1,63 @@
"use client";
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import type { DocumentImageResponse } from "@/lib/api/documents";
interface ImageGalleryProps {
images: DocumentImageResponse[];
}
export function ImageGallery({ images }: ImageGalleryProps) {
const [selectedImage, setSelectedImage] = useState<DocumentImageResponse | null>(null);
if (images.length === 0) return null;
return (
<>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
{images.map((image) => (
<button
key={image.id}
type="button"
className="group relative aspect-square overflow-hidden rounded-lg border bg-muted focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 outline-none"
onClick={() => setSelectedImage(image)}
>
<img
src={image.image_url}
alt={image.caption || "Document image"}
className="size-full object-cover transition-transform group-hover:scale-105"
/>
{image.caption && (
<div className="absolute inset-x-0 bottom-0 bg-black/60 px-2 py-1">
<p className="text-xs text-white truncate">{image.caption}</p>
</div>
)}
</button>
))}
</div>
<Dialog open={!!selectedImage} onOpenChange={() => setSelectedImage(null)}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{selectedImage?.caption || "Image"}</DialogTitle>
</DialogHeader>
{selectedImage && (
<div className="flex justify-center">
<img
src={selectedImage.image_url}
alt={selectedImage.caption || "Document image"}
className="max-h-[70vh] w-auto rounded-md object-contain"
/>
</div>
)}
</DialogContent>
</Dialog>
</>
);
}
@@ -0,0 +1,42 @@
import { Badge } from "@/components/ui/badge";
import { differenceInDays } from "date-fns";
interface WarrantyStatusProps {
expiry_date?: string;
}
export function WarrantyStatus({ expiry_date }: WarrantyStatusProps) {
if (!expiry_date) {
return <Badge variant="secondary">No expiry</Badge>;
}
const today = new Date();
today.setHours(0, 0, 0, 0);
const expiry = new Date(expiry_date);
expiry.setHours(0, 0, 0, 0);
const daysRemaining = differenceInDays(expiry, today);
if (daysRemaining < 0) {
return (
<Badge variant="destructive">
Expired {Math.abs(daysRemaining)} {Math.abs(daysRemaining) === 1 ? "day" : "days"} ago
</Badge>
);
}
if (daysRemaining <= 30) {
return (
<Badge className="bg-yellow-100 text-yellow-800 border-yellow-300 dark:bg-yellow-900/30 dark:text-yellow-400 dark:border-yellow-700">
Expiring soon ({daysRemaining} {daysRemaining === 1 ? "day" : "days"})
</Badge>
);
}
return (
<Badge className="bg-green-100 text-green-800 border-green-300 dark:bg-green-900/30 dark:text-green-400 dark:border-green-700">
Active ({daysRemaining} {daysRemaining === 1 ? "day" : "days"} left)
</Badge>
);
}
@@ -0,0 +1,45 @@
"use client";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
interface AuthFormWrapperProps {
title: string;
subtitle?: string;
children: React.ReactNode;
footer?: React.ReactNode;
}
export function AuthFormWrapper({
title,
subtitle,
children,
footer,
}: AuthFormWrapperProps) {
return (
<div className="flex flex-col items-center gap-6">
<div className="text-center">
<h1 className="text-2xl font-bold tracking-tight">Casera</h1>
</div>
<Card className="w-full">
<CardHeader>
<CardTitle className="text-xl">{title}</CardTitle>
{subtitle && <CardDescription>{subtitle}</CardDescription>}
</CardHeader>
<CardContent>{children}</CardContent>
</Card>
{footer && (
<div className="text-center text-sm text-muted-foreground">
{footer}
</div>
)}
</div>
);
}
+107
View File
@@ -0,0 +1,107 @@
"use client";
import * as React from "react";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
interface CodeInputProps {
value: string;
onChange: (code: string) => void;
disabled?: boolean;
className?: string;
}
export function CodeInput({
value,
onChange,
disabled = false,
className,
}: CodeInputProps) {
const inputRefs = React.useRef<(HTMLInputElement | null)[]>([]);
const digits = value.padEnd(6, "").slice(0, 6).split("");
function updateCode(newDigits: string[]) {
onChange(newDigits.join(""));
}
function handleChange(index: number, char: string) {
// Accept only single digits
if (char && !/^\d$/.test(char)) return;
const next = [...digits];
next[index] = char;
updateCode(next);
// Auto-advance to next input
if (char && index < 5) {
inputRefs.current[index + 1]?.focus();
}
}
function handleKeyDown(
index: number,
e: React.KeyboardEvent<HTMLInputElement>
) {
if (e.key === "Backspace") {
e.preventDefault();
if (digits[index]) {
// Clear current digit
const next = [...digits];
next[index] = "";
updateCode(next);
} else if (index > 0) {
// Move to previous and clear it
const next = [...digits];
next[index - 1] = "";
updateCode(next);
inputRefs.current[index - 1]?.focus();
}
} else if (e.key === "ArrowLeft" && index > 0) {
inputRefs.current[index - 1]?.focus();
} else if (e.key === "ArrowRight" && index < 5) {
inputRefs.current[index + 1]?.focus();
}
}
function handlePaste(e: React.ClipboardEvent) {
e.preventDefault();
const pasted = e.clipboardData
.getData("text")
.replace(/\D/g, "")
.slice(0, 6);
if (!pasted) return;
const next = [...digits];
for (let i = 0; i < pasted.length && i < 6; i++) {
next[i] = pasted[i];
}
updateCode(next);
// Focus the input after the last pasted digit
const focusIndex = Math.min(pasted.length, 5);
inputRefs.current[focusIndex]?.focus();
}
return (
<div className={cn("flex gap-2 justify-center", className)}>
{digits.map((digit, i) => (
<Input
key={i}
ref={(el) => {
inputRefs.current[i] = el;
}}
type="text"
inputMode="numeric"
maxLength={1}
value={digit}
disabled={disabled}
className="h-12 w-12 text-center text-lg font-semibold"
onChange={(e) => handleChange(i, e.target.value.slice(-1))}
onKeyDown={(e) => handleKeyDown(i, e)}
onPaste={handlePaste}
autoComplete="one-time-code"
/>
))}
</div>
);
}
+40
View File
@@ -0,0 +1,40 @@
"use client";
import * as React from "react";
import { Eye, EyeOff } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
const PasswordInput = React.forwardRef<
HTMLInputElement,
React.ComponentProps<"input">
>(({ className, ...props }, ref) => {
const [visible, setVisible] = React.useState(false);
return (
<div className="relative">
<Input
ref={ref}
type={visible ? "text" : "password"}
className={cn("pr-10", className)}
{...props}
/>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => setVisible((v) => !v)}
tabIndex={-1}
aria-label={visible ? "Hide password" : "Show password"}
>
{visible ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
</Button>
</div>
);
});
PasswordInput.displayName = "PasswordInput";
export { PasswordInput };
+42
View File
@@ -0,0 +1,42 @@
"use client";
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { cn } from '@/lib/utils';
import { navItems } from './nav-items';
// Show the first 5 nav items on mobile (exclude Settings)
const mobileNavItems = navItems.filter((item) => item.label !== 'Settings');
export function MobileNav() {
const pathname = usePathname();
return (
<nav className="md:hidden fixed bottom-0 left-0 right-0 z-30 bg-card border-t border-border">
<div className="flex items-center justify-around px-2 py-2">
{mobileNavItems.map((item) => {
const isActive =
item.href === '/app'
? pathname === '/app'
: pathname.startsWith(item.href);
return (
<Link
key={item.href}
href={item.href}
className={cn(
'flex flex-col items-center gap-1 px-2 py-1 rounded-md text-xs transition-colors',
isActive
? 'text-primary'
: 'text-muted-foreground hover:text-foreground'
)}
>
<item.icon className="size-5" />
<span>{item.label}</span>
</Link>
);
})}
</div>
</nav>
);
}
+16
View File
@@ -0,0 +1,16 @@
import { Home, Building2, CheckSquare, HardHat, FileText, Settings } from 'lucide-react';
export interface NavItem {
label: string;
href: string;
icon: React.ComponentType<{ className?: string }>;
}
export const navItems: NavItem[] = [
{ label: 'Home', href: '/app', icon: Home },
{ label: 'Residences', href: '/app/residences', icon: Building2 },
{ label: 'Tasks', href: '/app/tasks', icon: CheckSquare },
{ label: 'Contractors', href: '/app/contractors', icon: HardHat },
{ label: 'Documents', href: '/app/documents', icon: FileText },
{ label: 'Settings', href: '/app/settings', icon: Settings },
];
+54
View File
@@ -0,0 +1,54 @@
"use client";
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { cn } from '@/lib/utils';
import { Separator } from '@/components/ui/separator';
import { navItems } from './nav-items';
export function Sidebar() {
const pathname = usePathname();
return (
<aside className="hidden md:flex md:flex-col md:fixed md:inset-y-0 md:left-0 md:z-30 w-16 lg:w-64 bg-card border-r border-border">
{/* Logo */}
<div className="flex items-center h-16 px-4 lg:px-6">
<Link href="/app" className="flex items-center gap-2">
<span className="text-xl font-bold text-primary">C</span>
<span className="hidden lg:inline text-xl font-bold text-foreground">
Casera
</span>
</Link>
</div>
<Separator />
{/* Navigation */}
<nav className="flex-1 flex flex-col gap-1 p-2 lg:p-3">
{navItems.map((item) => {
const isActive =
item.href === '/app'
? pathname === '/app'
: pathname.startsWith(item.href);
return (
<Link
key={item.href}
href={item.href}
className={cn(
'flex items-center gap-3 rounded-md px-3 py-2.5 text-sm font-medium transition-colors',
'hover:bg-accent hover:text-accent-foreground',
isActive
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground'
)}
>
<item.icon className="size-5 shrink-0" />
<span className="hidden lg:inline">{item.label}</span>
</Link>
);
})}
</nav>
</aside>
);
}
+67
View File
@@ -0,0 +1,67 @@
"use client";
import { useRouter } from 'next/navigation';
import { LogOut, Settings, User } from 'lucide-react';
import { NotificationBell } from '@/components/notifications/notification-bell';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
export function TopBar() {
const router = useRouter();
const handleLogout = async () => {
try {
await fetch('/api/auth/logout', { method: 'POST' });
} catch {
// Continue with redirect even if the API call fails
}
router.push('/login');
};
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">
{/* Mobile logo - hidden on desktop since sidebar has it */}
<div className="md:hidden">
<span className="text-xl font-bold text-foreground">Casera</span>
</div>
{/* Spacer for desktop (logo is in sidebar) */}
<div className="hidden md:block" />
{/* Notifications + Profile */}
<div className="flex items-center gap-2">
<NotificationBell />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="flex items-center gap-2 rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2">
<Avatar>
<AvatarFallback>U</AvatarFallback>
</Avatar>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem onClick={() => router.push('/app/settings')}>
<User className="size-4" />
Profile
</DropdownMenuItem>
<DropdownMenuItem onClick={() => router.push('/app/settings')}>
<Settings className="size-4" />
Settings
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleLogout} variant="destructive">
<LogOut className="size-4" />
Logout
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</header>
);
}
@@ -0,0 +1,58 @@
"use client";
import { Bell } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useNotifications, useUnreadCount, useMarkAsRead, useMarkAllAsRead } from "@/lib/hooks/use-notifications";
export function NotificationBell() {
const { data: unreadData } = useUnreadCount();
const { data: notifData } = useNotifications(10);
const markAsRead = useMarkAsRead();
const markAllAsRead = useMarkAllAsRead();
const unreadCount = unreadData?.unread_count ?? 0;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="relative">
<Bell className="size-5" />
{unreadCount > 0 && (
<span className="absolute -top-1 -right-1 flex size-5 items-center justify-center rounded-full bg-destructive text-[10px] font-bold text-destructive-foreground">
{unreadCount > 9 ? "9+" : unreadCount}
</span>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-80 max-h-96 overflow-y-auto">
<div className="flex items-center justify-between px-3 py-2">
<p className="text-sm font-semibold">Notifications</p>
{unreadCount > 0 && (
<Button variant="ghost" size="sm" className="text-xs h-auto py-1" onClick={() => markAllAsRead.mutate()}>
Mark all read
</Button>
)}
</div>
<DropdownMenuSeparator />
{(!notifData || notifData.results.length === 0) ? (
<div className="px-3 py-6 text-center text-sm text-muted-foreground">No notifications</div>
) : (
notifData.results.map((n) => (
<DropdownMenuItem key={n.id} className="flex-col items-start gap-1 py-2"
onClick={() => { if (!n.is_read) markAsRead.mutate(n.id); }}>
<p className={`text-sm ${n.is_read ? "text-muted-foreground" : "font-medium"}`}>{n.title}</p>
<p className="text-xs text-muted-foreground">{n.body}</p>
<p className="text-xs text-muted-foreground">{new Date(n.created_at).toLocaleDateString()}</p>
</DropdownMenuItem>
))
)}
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -0,0 +1,71 @@
"use client";
import { Home, Users } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { useOnboardingStore } from "@/stores/onboarding";
import { cn } from "@/lib/utils";
interface PathCardProps {
icon: React.ReactNode;
title: string;
description: string;
onClick: () => void;
}
function PathCard({ icon, title, description, onClick }: PathCardProps) {
return (
<Card
className={cn(
"cursor-pointer transition-all hover:border-primary hover:shadow-md"
)}
onClick={onClick}
>
<CardContent className="flex flex-col items-center text-center space-y-3 py-8">
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-primary/10 text-primary">
{icon}
</div>
<h3 className="text-lg font-semibold">{title}</h3>
<p className="text-sm text-muted-foreground">{description}</p>
</CardContent>
</Card>
);
}
export function ChoosePathStep() {
const { setPath, prevStep } = useOnboardingStore();
return (
<div className="space-y-6">
<div className="text-center space-y-2">
<h2 className="text-2xl font-bold tracking-tight">
How would you like to start?
</h2>
<p className="text-muted-foreground">
You can always do both later.
</p>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<PathCard
icon={<Home className="size-7" />}
title="Create a residence"
description="Set up your first property to start tracking maintenance."
onClick={() => setPath("create")}
/>
<PathCard
icon={<Users className="size-7" />}
title="Join a residence"
description="Join an existing property with a share code from the owner."
onClick={() => setPath("join")}
/>
</div>
<div className="flex justify-center">
<Button variant="ghost" onClick={prevStep}>
Back
</Button>
</div>
</div>
);
}
@@ -0,0 +1,51 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { CheckCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useOnboardingStore } from "@/stores/onboarding";
export function CompleteStep() {
const router = useRouter();
const { path, residenceId, complete } = useOnboardingStore();
useEffect(() => {
localStorage.setItem("onboarding_complete", "true");
complete();
}, [complete]);
const isCreatePath = path === "create";
const handleNavigate = () => {
if (isCreatePath && residenceId) {
router.push(`/app/residences/${residenceId}`);
} else {
router.push("/app/residences");
}
};
return (
<div className="flex flex-col items-center text-center space-y-6 py-12">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-green-100 text-green-600 dark:bg-green-900/30 dark:text-green-400">
<CheckCircle className="size-9" />
</div>
<div className="space-y-2">
<h2 className="text-3xl font-bold tracking-tight">
{isCreatePath ? "You're all set!" : "Welcome to the residence!"}
</h2>
<p className="text-muted-foreground text-lg max-w-sm mx-auto">
{isCreatePath
? "Your residence is ready. Start managing your property like a pro."
: "You've successfully joined the residence. Time to get organized."}
</p>
</div>
<Button size="lg" onClick={handleNavigate} className="mt-4">
{isCreatePath ? "Go to your residence" : "View residences"}
</Button>
</div>
);
}
@@ -0,0 +1,123 @@
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { FormField } from "@/components/shared/form-field";
import { useCreateResidence } from "@/lib/hooks/use-residences";
import { useOnboardingStore } from "@/stores/onboarding";
// ---------------------------------------------------------------------------
// Schema (simplified for onboarding)
// ---------------------------------------------------------------------------
const createResidenceSchema = z.object({
name: z.string().min(1, "Name is required"),
street_address: z.string().optional(),
city: z.string().optional(),
});
type CreateResidenceFormData = z.infer<typeof createResidenceSchema>;
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export function CreateResidenceStep() {
const { nextStep, prevStep, setResidenceId } = useOnboardingStore();
const createResidence = useCreateResidence();
const {
register,
handleSubmit,
formState: { errors },
} = useForm<CreateResidenceFormData>({
resolver: zodResolver(createResidenceSchema),
defaultValues: {
name: "",
street_address: "",
city: "",
},
});
const onSubmit = (data: CreateResidenceFormData) => {
createResidence.mutate(
{
name: data.name,
street_address: data.street_address || undefined,
city: data.city || undefined,
},
{
onSuccess: (residence) => {
setResidenceId(residence.id);
nextStep();
},
}
);
};
return (
<div className="space-y-6">
<div className="text-center space-y-2">
<h2 className="text-2xl font-bold tracking-tight">
Create your residence
</h2>
<p className="text-muted-foreground">
You can add more details later. Just give it a name to get started.
</p>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<FormField
label="Name"
htmlFor="name"
error={errors.name?.message}
required
>
<Input
id="name"
placeholder="My Home"
aria-invalid={!!errors.name}
{...register("name")}
/>
</FormField>
<FormField label="Street Address" htmlFor="street_address">
<Input
id="street_address"
placeholder="123 Main St"
{...register("street_address")}
/>
</FormField>
<FormField label="City" htmlFor="city">
<Input id="city" placeholder="Austin" {...register("city")} />
</FormField>
{createResidence.error && (
<p className="text-sm text-destructive">
{createResidence.error instanceof Error
? createResidence.error.message
: "Failed to create residence. Please try again."}
</p>
)}
<div className="flex items-center justify-between pt-2">
<Button type="button" variant="ghost" onClick={prevStep}>
Back
</Button>
<Button type="submit" disabled={createResidence.isPending}>
{createResidence.isPending && (
<Loader2 className="size-4 mr-2 animate-spin" />
)}
Create Residence
</Button>
</div>
</form>
</div>
);
}
@@ -0,0 +1,155 @@
"use client";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { format } from "date-fns";
import { CalendarIcon, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { FormField } from "@/components/shared/form-field";
import { Calendar } from "@/components/ui/calendar";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { useCreateTask } from "@/lib/hooks/use-tasks";
import { useOnboardingStore } from "@/stores/onboarding";
import { cn } from "@/lib/utils";
// ---------------------------------------------------------------------------
// Schema
// ---------------------------------------------------------------------------
const firstTaskSchema = z.object({
title: z.string().min(1, "Task title is required"),
});
type FirstTaskFormData = z.infer<typeof firstTaskSchema>;
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export function FirstTaskStep() {
const { nextStep, prevStep, residenceId } = useOnboardingStore();
const createTask = useCreateTask();
const [dueDate, setDueDate] = useState<Date | undefined>(undefined);
const {
register,
handleSubmit,
formState: { errors },
} = useForm<FirstTaskFormData>({
resolver: zodResolver(firstTaskSchema),
defaultValues: {
title: "",
},
});
const onSubmit = (data: FirstTaskFormData) => {
if (!residenceId) return;
createTask.mutate(
{
residence_id: residenceId,
title: data.title,
due_date: dueDate ? format(dueDate, "yyyy-MM-dd") : undefined,
},
{
onSuccess: () => {
nextStep();
},
}
);
};
const handleSkip = () => {
nextStep();
};
return (
<div className="space-y-6">
<div className="text-center space-y-2">
<h2 className="text-2xl font-bold tracking-tight">
Add your first task
</h2>
<p className="text-muted-foreground">
Create a maintenance task to keep your property in shape. You can skip
this for now.
</p>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<FormField
label="Task Title"
htmlFor="title"
error={errors.title?.message}
required
>
<Input
id="title"
placeholder="e.g., Change HVAC filter"
aria-invalid={!!errors.title}
{...register("title")}
/>
</FormField>
<FormField label="Due Date" htmlFor="due_date">
<Popover>
<PopoverTrigger asChild>
<Button
id="due_date"
type="button"
variant="outline"
className={cn(
"w-full justify-start text-left font-normal",
!dueDate && "text-muted-foreground"
)}
>
<CalendarIcon className="mr-2 size-4" />
{dueDate ? format(dueDate, "PPP") : "Pick a date (optional)"}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={dueDate}
onSelect={setDueDate}
disabled={(date) => date < new Date(new Date().setHours(0, 0, 0, 0))}
/>
</PopoverContent>
</Popover>
</FormField>
{createTask.error && (
<p className="text-sm text-destructive">
{createTask.error instanceof Error
? createTask.error.message
: "Failed to create task. Please try again."}
</p>
)}
<div className="flex items-center justify-between pt-2">
<Button type="button" variant="ghost" onClick={prevStep}>
Back
</Button>
<div className="flex items-center gap-2">
<Button type="button" variant="outline" onClick={handleSkip}>
Skip
</Button>
<Button type="submit" disabled={createTask.isPending}>
{createTask.isPending && (
<Loader2 className="size-4 mr-2 animate-spin" />
)}
Create Task
</Button>
</div>
</div>
</form>
</div>
);
}
@@ -0,0 +1,105 @@
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { FormField } from "@/components/shared/form-field";
import { useJoinResidence } from "@/lib/hooks/use-sharing";
import { useOnboardingStore } from "@/stores/onboarding";
// ---------------------------------------------------------------------------
// Schema
// ---------------------------------------------------------------------------
const joinResidenceSchema = z.object({
code: z
.string()
.length(6, "Share code must be 6 characters")
.regex(/^[A-Za-z0-9]+$/, "Share code must be alphanumeric"),
});
type JoinResidenceFormData = z.infer<typeof joinResidenceSchema>;
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export function JoinResidenceStep() {
const { nextStep, prevStep, setResidenceId } = useOnboardingStore();
const joinResidence = useJoinResidence();
const {
register,
handleSubmit,
formState: { errors },
} = useForm<JoinResidenceFormData>({
resolver: zodResolver(joinResidenceSchema),
defaultValues: {
code: "",
},
});
const onSubmit = (data: JoinResidenceFormData) => {
joinResidence.mutate(data.code, {
onSuccess: (residence) => {
setResidenceId(residence.id);
nextStep();
},
});
};
return (
<div className="space-y-6">
<div className="text-center space-y-2">
<h2 className="text-2xl font-bold tracking-tight">
Join a residence
</h2>
<p className="text-muted-foreground">
Enter the 6-character share code you received from the property owner.
</p>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<FormField
label="Share Code"
htmlFor="code"
error={errors.code?.message}
required
>
<Input
id="code"
placeholder="ABC123"
maxLength={6}
className="text-center text-lg tracking-widest uppercase"
aria-invalid={!!errors.code}
{...register("code")}
/>
</FormField>
{joinResidence.error && (
<p className="text-sm text-destructive">
{joinResidence.error instanceof Error
? joinResidence.error.message
: "Invalid or expired share code. Please check and try again."}
</p>
)}
<div className="flex items-center justify-between pt-2">
<Button type="button" variant="ghost" onClick={prevStep}>
Back
</Button>
<Button type="submit" disabled={joinResidence.isPending}>
{joinResidence.isPending && (
<Loader2 className="size-4 mr-2 animate-spin" />
)}
Join Residence
</Button>
</div>
</form>
</div>
);
}
@@ -0,0 +1,30 @@
"use client";
import { Button } from "@/components/ui/button";
import { useOnboardingStore } from "@/stores/onboarding";
import { useAuthStore } from "@/stores/auth";
export function WelcomeStep() {
const nextStep = useOnboardingStore((s) => s.nextStep);
const user = useAuthStore((s) => s.user);
const greeting = user?.first_name
? `Welcome to Casera, ${user.first_name}!`
: "Welcome to Casera!";
return (
<div className="flex flex-col items-center text-center space-y-6 py-12">
<div className="space-y-3">
<h2 className="text-3xl font-bold tracking-tight">{greeting}</h2>
<p className="text-muted-foreground text-lg max-w-sm mx-auto">
Your property management companion. Let&apos;s get you set up in just
a few steps.
</p>
</div>
<Button size="lg" onClick={nextStep} className="mt-4">
Get Started
</Button>
</div>
);
}
@@ -0,0 +1,51 @@
import Link from "next/link";
import { MapPin } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import type { MyResidenceResponse } from "@/lib/api/residences";
interface ResidenceCardProps {
data: MyResidenceResponse;
}
export function ResidenceCard({ data }: ResidenceCardProps) {
const { residence, task_summary } = data;
const address = [residence.street_address, residence.city, residence.state_province]
.filter(Boolean)
.join(", ");
return (
<Link href={`/app/residences/${residence.id}`} className="block">
<Card className="transition-colors hover:border-primary/40">
<CardHeader>
<CardTitle className="text-base">{residence.name}</CardTitle>
{address && (
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
<MapPin className="size-3.5 shrink-0" />
<span className="truncate">{address}</span>
</div>
)}
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{task_summary.overdue > 0 && (
<Badge variant="destructive">
{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>
</div>
</CardContent>
</Card>
</Link>
);
}
@@ -0,0 +1,221 @@
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { FormField } from "@/components/shared/form-field";
import { LookupSelect } from "@/components/shared/lookup-select";
import { useResidenceTypes } from "@/lib/hooks/use-lookups";
import type { ResidenceResponse } from "@/lib/api/residences";
import { Loader2 } from "lucide-react";
// ---------------------------------------------------------------------------
// Schema
// ---------------------------------------------------------------------------
const residenceSchema = z.object({
name: z.string().min(1, "Name is required"),
property_type_id: z.number().optional(),
street_address: z.string().optional(),
apartment_unit: z.string().optional(),
city: z.string().optional(),
state_province: z.string().optional(),
postal_code: z.string().optional(),
country: z.string().optional(),
bedrooms: z.number().optional(),
bathrooms: z.number().optional(),
square_footage: z.number().optional(),
year_built: z.number().min(1800).max(2100).optional(),
description: z.string().optional(),
});
type ResidenceFormData = z.infer<typeof residenceSchema>;
// ---------------------------------------------------------------------------
// Props
// ---------------------------------------------------------------------------
interface ResidenceFormProps {
residence?: ResidenceResponse;
onSubmit: (data: ResidenceFormData) => void;
loading?: boolean;
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export function ResidenceForm({ residence, onSubmit, loading = false }: ResidenceFormProps) {
const { data: residenceTypes } = useResidenceTypes();
const {
register,
handleSubmit,
setValue,
watch,
formState: { errors },
} = useForm<ResidenceFormData>({
resolver: zodResolver(residenceSchema),
defaultValues: {
name: residence?.name ?? "",
property_type_id: residence?.property_type_id ?? undefined,
street_address: residence?.street_address ?? "",
apartment_unit: residence?.apartment_unit ?? "",
city: residence?.city ?? "",
state_province: residence?.state_province ?? "",
postal_code: residence?.postal_code ?? "",
country: residence?.country ?? "",
bedrooms: residence?.bedrooms ?? undefined,
bathrooms: residence?.bathrooms ?? undefined,
square_footage: residence?.square_footage ?? undefined,
year_built: residence?.year_built ?? undefined,
description: residence?.description ?? "",
},
});
const propertyTypeId = watch("property_type_id");
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-8">
{/* Name & Property Type */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField label="Name" htmlFor="name" error={errors.name?.message} required>
<Input
id="name"
placeholder="My Home"
aria-invalid={!!errors.name}
{...register("name")}
/>
</FormField>
<FormField label="Property Type" htmlFor="property_type_id">
<LookupSelect
items={residenceTypes}
value={propertyTypeId}
onValueChange={(v) => setValue("property_type_id", v)}
placeholder="Select type..."
/>
</FormField>
</div>
{/* Address */}
<div className="space-y-4">
<h3 className="text-sm font-medium text-muted-foreground">Address</h3>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField label="Street Address" htmlFor="street_address">
<Input
id="street_address"
placeholder="123 Main St"
{...register("street_address")}
/>
</FormField>
<FormField label="Apartment / Unit" htmlFor="apartment_unit">
<Input
id="apartment_unit"
placeholder="Apt 4B"
{...register("apartment_unit")}
/>
</FormField>
<FormField label="City" htmlFor="city">
<Input id="city" placeholder="Austin" {...register("city")} />
</FormField>
<FormField label="State / Province" htmlFor="state_province">
<Input
id="state_province"
placeholder="TX"
{...register("state_province")}
/>
</FormField>
<FormField label="Postal Code" htmlFor="postal_code">
<Input
id="postal_code"
placeholder="78701"
{...register("postal_code")}
/>
</FormField>
<FormField label="Country" htmlFor="country">
<Input
id="country"
placeholder="US"
{...register("country")}
/>
</FormField>
</div>
</div>
{/* Property Details */}
<div className="space-y-4">
<h3 className="text-sm font-medium text-muted-foreground">Property Details</h3>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<FormField label="Bedrooms" htmlFor="bedrooms">
<Input
id="bedrooms"
type="number"
placeholder="3"
{...register("bedrooms", { valueAsNumber: true })}
/>
</FormField>
<FormField label="Bathrooms" htmlFor="bathrooms">
<Input
id="bathrooms"
type="number"
placeholder="2"
{...register("bathrooms", { valueAsNumber: true })}
/>
</FormField>
<FormField label="Sq. Footage" htmlFor="square_footage">
<Input
id="square_footage"
type="number"
placeholder="1500"
{...register("square_footage", { valueAsNumber: true })}
/>
</FormField>
<FormField
label="Year Built"
htmlFor="year_built"
error={errors.year_built?.message}
>
<Input
id="year_built"
type="number"
placeholder="2000"
aria-invalid={!!errors.year_built}
{...register("year_built", { valueAsNumber: true })}
/>
</FormField>
</div>
</div>
{/* Description */}
<FormField label="Description" htmlFor="description">
<Textarea
id="description"
placeholder="Notes about this property..."
rows={3}
{...register("description")}
/>
</FormField>
{/* Submit */}
<div className="flex justify-end">
<Button type="submit" disabled={loading}>
{loading && <Loader2 className="size-4 mr-2 animate-spin" />}
{residence ? "Save Changes" : "Create Residence"}
</Button>
</div>
</form>
);
}
@@ -0,0 +1,40 @@
import { ClipboardList, Wrench, Users } from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
interface ResidenceSummaryProps {
totalTasks: number;
inProgress: number;
userCount: number;
}
interface StatCardProps {
icon: React.ElementType;
label: string;
value: number;
}
function StatCard({ icon: Icon, label, value }: StatCardProps) {
return (
<Card>
<CardContent className="flex items-center gap-4">
<div className="rounded-full bg-muted p-2.5">
<Icon className="size-5 text-muted-foreground" />
</div>
<div>
<p className="text-2xl font-bold">{value}</p>
<p className="text-sm text-muted-foreground">{label}</p>
</div>
</CardContent>
</Card>
);
}
export function ResidenceSummary({ totalTasks, inProgress, userCount }: ResidenceSummaryProps) {
return (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<StatCard icon={ClipboardList} label="Total Tasks" value={totalTasks} />
<StatCard icon={Wrench} label="In Progress" value={inProgress} />
<StatCard icon={Users} label="Users" value={userCount} />
</div>
);
}
@@ -0,0 +1,115 @@
"use client";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Loader2, Check } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { FormField } from "@/components/shared/form-field";
import { PasswordInput } from "@/components/forms/password-input";
import * as authApi from "@/lib/api/auth";
const changePasswordSchema = z
.object({
current_password: z.string().min(8, "Password must be at least 8 characters"),
new_password: z.string().min(8, "Password must be at least 8 characters"),
confirm_password: z.string(),
})
.refine((data) => data.new_password === data.confirm_password, {
message: "Passwords don't match",
path: ["confirm_password"],
});
type ChangePasswordFormData = z.infer<typeof changePasswordSchema>;
export function ChangePasswordForm() {
const [success, setSuccess] = useState(false);
const [apiError, setApiError] = useState<string | null>(null);
const {
register,
handleSubmit,
reset,
formState: { errors, isSubmitting },
} = useForm<ChangePasswordFormData>({
resolver: zodResolver(changePasswordSchema),
});
async function onSubmit(data: ChangePasswordFormData) {
setSuccess(false);
setApiError(null);
try {
await authApi.changePassword({
current_password: data.current_password,
new_password: data.new_password,
});
reset();
setSuccess(true);
} catch (err) {
const message =
err instanceof Error ? err.message : "Failed to change password.";
setApiError(message);
}
}
return (
<Card>
<CardHeader>
<CardTitle>Change Password</CardTitle>
<CardDescription>Update your password to keep your account secure.</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
{apiError && (
<div className="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">
{apiError}
</div>
)}
{success && (
<div className="rounded-md bg-green-500/10 px-3 py-2 text-sm text-green-700 dark:text-green-400 flex items-center gap-2">
<Check className="size-4" />
Password changed successfully.
</div>
)}
<FormField label="Current password" htmlFor="current_password" error={errors.current_password?.message} required>
<PasswordInput
id="current_password"
autoComplete="current-password"
aria-invalid={!!errors.current_password}
{...register("current_password")}
/>
</FormField>
<FormField label="New password" htmlFor="new_password" error={errors.new_password?.message} required>
<PasswordInput
id="new_password"
autoComplete="new-password"
aria-invalid={!!errors.new_password}
{...register("new_password")}
/>
</FormField>
<FormField label="Confirm new password" htmlFor="confirm_password" error={errors.confirm_password?.message} required>
<PasswordInput
id="confirm_password"
autoComplete="new-password"
aria-invalid={!!errors.confirm_password}
{...register("confirm_password")}
/>
</FormField>
<div className="flex justify-end">
<Button type="submit" disabled={isSubmitting}>
{isSubmitting && <Loader2 className="animate-spin" />}
Update Password
</Button>
</div>
</form>
</CardContent>
</Card>
);
}
@@ -0,0 +1,121 @@
"use client";
import { useState } from "react";
import { AlertTriangle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useAuthStore } from "@/stores/auth";
import * as authApi from "@/lib/api/auth";
export function DeleteAccountSection() {
const { logout } = useAuthStore();
const [dialogOpen, setDialogOpen] = useState(false);
const [confirmText, setConfirmText] = useState("");
const [isDeleting, setIsDeleting] = useState(false);
const [apiError, setApiError] = useState<string | null>(null);
const isConfirmed = confirmText === "DELETE";
async function handleDelete() {
setApiError(null);
setIsDeleting(true);
try {
await authApi.deleteAccount();
await logout();
} catch (err) {
const message =
err instanceof Error ? err.message : "Failed to delete account.";
setApiError(message);
setIsDeleting(false);
}
}
function handleOpenChange(open: boolean) {
setDialogOpen(open);
if (!open) {
setConfirmText("");
setApiError(null);
}
}
return (
<>
<Card className="border-destructive/50">
<CardHeader>
<CardTitle className="text-destructive">Danger Zone</CardTitle>
<CardDescription>
Permanently delete your account and all associated data. This action cannot be undone.
</CardDescription>
</CardHeader>
<CardContent>
<Button variant="destructive" onClick={() => setDialogOpen(true)}>
Delete Account
</Button>
</CardContent>
</Card>
<Dialog open={dialogOpen} onOpenChange={handleOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertTriangle className="size-5 text-destructive" />
Delete Account
</DialogTitle>
<DialogDescription>
This will permanently delete your account, all your residences, tasks,
documents, and associated data. This action is irreversible.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
{apiError && (
<div className="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">
{apiError}
</div>
)}
<div className="space-y-2">
<Label htmlFor="confirm-delete">
Type <span className="font-mono font-semibold">DELETE</span> to confirm
</Label>
<Input
id="confirm-delete"
placeholder="DELETE"
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
autoComplete="off"
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => handleOpenChange(false)}
disabled={isDeleting}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleDelete}
disabled={!isConfirmed || isDeleting}
>
{isDeleting ? "Deleting..." : "Delete My Account"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
@@ -0,0 +1,85 @@
"use client";
import { useFeatureBenefits } from "@/lib/hooks/use-subscription";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { Check, X } from "lucide-react";
export function FeatureComparison() {
const { data: features, isLoading } = useFeatureBenefits();
if (isLoading) {
return (
<Card>
<CardHeader>
<CardTitle>Plan Comparison</CardTitle>
<CardDescription>See what each plan includes.</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="flex items-center justify-between">
<Skeleton className="h-4 w-40" />
<div className="flex gap-8">
<Skeleton className="size-5 rounded-full" />
<Skeleton className="size-5 rounded-full" />
</div>
</div>
))}
</div>
</CardContent>
</Card>
);
}
if (!features || features.length === 0) return null;
// Sort by sort_order
const sortedFeatures = [...features].sort((a, b) => a.sort_order - b.sort_order);
return (
<Card>
<CardHeader>
<CardTitle>Plan Comparison</CardTitle>
<CardDescription>See what each plan includes.</CardDescription>
</CardHeader>
<CardContent>
{/* Table header */}
<div className="flex items-center justify-between border-b pb-3 mb-3">
<span className="text-sm font-medium text-muted-foreground">Feature</span>
<div className="flex gap-8 text-sm font-medium text-muted-foreground">
<span className="w-12 text-center">Free</span>
<span className="w-12 text-center">Premium</span>
</div>
</div>
{/* Feature rows */}
<div className="space-y-3">
{sortedFeatures.map((feature) => {
const isFreeFeature = feature.tier === "free";
return (
<div key={feature.id} className="flex items-center justify-between py-1">
<div className="space-y-0.5">
<p className="text-sm font-medium">{feature.title}</p>
<p className="text-xs text-muted-foreground">{feature.description}</p>
</div>
<div className="flex gap-8 shrink-0">
<div className="w-12 flex justify-center">
{isFreeFeature ? (
<Check className="size-4 text-green-500" />
) : (
<X className="size-4 text-muted-foreground/40" />
)}
</div>
<div className="w-12 flex justify-center">
<Check className="size-4 text-green-500" />
</div>
</div>
</div>
);
})}
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,102 @@
"use client";
import { useNotificationPreferences, useUpdatePreferences } from "@/lib/hooks/use-notifications";
import { Switch } from "@/components/ui/switch";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { Label } from "@/components/ui/label";
import type { NotificationPreferencesResponse, UpdatePreferencesRequest } from "@/lib/api/notifications";
const preferenceItems: {
key: keyof NotificationPreferencesResponse;
label: string;
description: string;
}[] = [
{
key: "task_reminders",
label: "Task Reminders",
description: "Get notified about upcoming and overdue tasks",
},
{
key: "task_completions",
label: "Task Completions",
description: "Get notified when tasks are completed",
},
{
key: "residence_updates",
label: "Residence Updates",
description: "Get notified about residence changes",
},
{
key: "share_notifications",
label: "Share Notifications",
description: "Get notified about sharing activity",
},
{
key: "marketing",
label: "Marketing",
description: "Receive product updates and tips",
},
];
export function NotificationPreferences() {
const { data: preferences, isLoading } = useNotificationPreferences();
const updatePreferences = useUpdatePreferences();
function handleToggle(key: keyof NotificationPreferencesResponse, checked: boolean) {
const update: UpdatePreferencesRequest = { [key]: checked };
updatePreferences.mutate(update);
}
if (isLoading) {
return (
<Card>
<CardHeader>
<CardTitle>Notification Preferences</CardTitle>
<CardDescription>Control which notifications you receive.</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center justify-between gap-4">
<div className="space-y-1.5">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-3 w-56" />
</div>
<Skeleton className="h-5 w-9 rounded-full" />
</div>
))}
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<CardTitle>Notification Preferences</CardTitle>
<CardDescription>Control which notifications you receive.</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{preferenceItems.map((item) => {
const isChecked = preferences?.[item.key] ?? false;
return (
<div key={item.key} className="flex items-center justify-between gap-4">
<div className="space-y-0.5">
<Label htmlFor={item.key} className="text-sm font-medium">
{item.label}
</Label>
<p className="text-sm text-muted-foreground">{item.description}</p>
</div>
<Switch
id={item.key}
checked={isChecked}
onCheckedChange={(checked) => handleToggle(item.key, checked as boolean)}
disabled={updatePreferences.isPending}
/>
</div>
);
})}
</CardContent>
</Card>
);
}
+116
View File
@@ -0,0 +1,116 @@
"use client";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Loader2, Check } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { FormField } from "@/components/shared/form-field";
import { useAuthStore } from "@/stores/auth";
import * as authApi from "@/lib/api/auth";
const profileSchema = z.object({
first_name: z.string().min(1, "First name is required"),
last_name: z.string().min(1, "Last name is required"),
email: z.string().email("Invalid email address"),
});
type ProfileFormData = z.infer<typeof profileSchema>;
export function ProfileForm() {
const { user, fetchUser } = useAuthStore();
const [success, setSuccess] = useState(false);
const [apiError, setApiError] = useState<string | null>(null);
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<ProfileFormData>({
resolver: zodResolver(profileSchema),
defaultValues: {
first_name: user?.first_name ?? "",
last_name: user?.last_name ?? "",
email: user?.email ?? "",
},
});
async function onSubmit(data: ProfileFormData) {
setSuccess(false);
setApiError(null);
try {
await authApi.updateProfile(data);
await fetchUser();
setSuccess(true);
} catch (err) {
const message =
err instanceof Error ? err.message : "Failed to update profile.";
setApiError(message);
}
}
return (
<Card>
<CardHeader>
<CardTitle>Personal Information</CardTitle>
<CardDescription>Update your name and email address.</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
{apiError && (
<div className="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">
{apiError}
</div>
)}
{success && (
<div className="rounded-md bg-green-500/10 px-3 py-2 text-sm text-green-700 dark:text-green-400 flex items-center gap-2">
<Check className="size-4" />
Profile updated successfully.
</div>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<FormField label="First name" htmlFor="first_name" error={errors.first_name?.message} required>
<Input
id="first_name"
autoComplete="given-name"
aria-invalid={!!errors.first_name}
{...register("first_name")}
/>
</FormField>
<FormField label="Last name" htmlFor="last_name" error={errors.last_name?.message} required>
<Input
id="last_name"
autoComplete="family-name"
aria-invalid={!!errors.last_name}
{...register("last_name")}
/>
</FormField>
</div>
<FormField label="Email" htmlFor="email" error={errors.email?.message} required>
<Input
id="email"
type="email"
autoComplete="email"
aria-invalid={!!errors.email}
{...register("email")}
/>
</FormField>
<div className="flex justify-end">
<Button type="submit" disabled={isSubmitting}>
{isSubmitting && <Loader2 className="animate-spin" />}
Save Changes
</Button>
</div>
</form>
</CardContent>
</Card>
);
}
@@ -0,0 +1,147 @@
"use client";
import { useSubscriptionStatus } from "@/lib/hooks/use-subscription";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { Crown, Sparkles } from "lucide-react";
interface LimitBarProps {
label: string;
current?: number;
max: number;
}
function LimitBar({ label, max }: LimitBarProps) {
// The API returns limits but not current usage counts. We show the max
// allowed value for now. When usage data is available from the API, we can
// display a real progress bar.
return (
<div className="space-y-1.5">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">{label}</span>
<span className="font-medium">{max === -1 ? "Unlimited" : max}</span>
</div>
<div className="h-2 w-full rounded-full bg-muted">
<div
className="h-full rounded-full bg-primary transition-all"
style={{ width: max === -1 ? "100%" : "0%" }}
/>
</div>
</div>
);
}
export function SubscriptionStatus() {
const { data: status, isLoading } = useSubscriptionStatus();
if (isLoading) {
return (
<Card>
<CardHeader>
<CardTitle>Subscription</CardTitle>
<CardDescription>Your current plan and usage limits.</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex items-center gap-3">
<Skeleton className="h-6 w-20 rounded-full" />
<Skeleton className="h-4 w-16" />
</div>
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="space-y-1.5">
<div className="flex justify-between">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-12" />
</div>
<Skeleton className="h-2 w-full rounded-full" />
</div>
))}
</CardContent>
</Card>
);
}
if (!status) return null;
const isFree = status.tier === "free";
const isPremium = status.tier === "premium";
return (
<Card>
<CardHeader>
<CardTitle>Subscription</CardTitle>
<CardDescription>Your current plan and usage limits.</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Tier badge and status */}
<div className="flex items-center gap-3">
<Badge variant={isPremium ? "default" : "secondary"} className="gap-1">
{isPremium ? <Crown className="size-3" /> : null}
{status.tier.charAt(0).toUpperCase() + status.tier.slice(1)}
</Badge>
<span className="text-sm text-muted-foreground">
{status.is_active ? "Active" : "Inactive"}
</span>
</div>
{/* Expiry date for premium */}
{isPremium && status.expires_at && (
<p className="text-sm text-muted-foreground">
Renews on{" "}
<span className="font-medium text-foreground">
{new Date(status.expires_at).toLocaleDateString(undefined, {
year: "numeric",
month: "long",
day: "numeric",
})}
</span>
</p>
)}
{/* Limits */}
<div className="space-y-4">
<h3 className="text-sm font-medium">Plan Limits</h3>
<LimitBar label="Residences" max={status.limits.max_residences} />
<LimitBar label="Tasks per Residence" max={status.limits.max_tasks_per_residence} />
<LimitBar label="Contractors" max={status.limits.max_contractors} />
<LimitBar label="Documents" max={status.limits.max_documents} />
<div className="grid grid-cols-2 gap-4 pt-2">
<div className="flex items-center gap-2 text-sm">
<div
className={`size-2 rounded-full ${status.limits.can_share ? "bg-green-500" : "bg-muted-foreground"}`}
/>
<span className="text-muted-foreground">Sharing</span>
<span className="font-medium">{status.limits.can_share ? "Enabled" : "Disabled"}</span>
</div>
<div className="flex items-center gap-2 text-sm">
<div
className={`size-2 rounded-full ${status.limits.can_export ? "bg-green-500" : "bg-muted-foreground"}`}
/>
<span className="text-muted-foreground">Export</span>
<span className="font-medium">{status.limits.can_export ? "Enabled" : "Disabled"}</span>
</div>
</div>
</div>
{/* Upgrade CTA for free tier */}
{isFree && (
<div className="rounded-lg border border-primary/20 bg-primary/5 p-4">
<div className="flex items-start gap-3">
<Sparkles className="size-5 text-primary mt-0.5" />
<div className="space-y-1">
<p className="text-sm font-medium">Upgrade to Premium</p>
<p className="text-sm text-muted-foreground">
Unlock unlimited residences, tasks, and more features.
</p>
<p className="text-xs text-muted-foreground pt-1">
Available through the Casera iOS or Android app.
</p>
</div>
</div>
</div>
)}
</CardContent>
</Card>
);
}
+81
View File
@@ -0,0 +1,81 @@
"use client";
import { Monitor, Moon, Sun } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { cn } from "@/lib/utils";
import { themes } from "@/lib/themes/theme-config";
import { useThemeStore, type ColorMode } from "@/stores/theme";
const modeOptions: { value: ColorMode; label: string; icon: React.ElementType }[] = [
{ value: "light", label: "Light", icon: Sun },
{ value: "dark", label: "Dark", icon: Moon },
{ value: "system", label: "System", icon: Monitor },
];
export function ThemePicker() {
const { themeId, mode, setTheme, setMode } = useThemeStore();
return (
<Card>
<CardHeader>
<CardTitle>Appearance</CardTitle>
<CardDescription>Choose a theme and color mode for the app.</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Theme swatches */}
<div>
<p className="text-sm font-medium mb-3">Theme</p>
<div className="flex flex-wrap gap-3">
{themes.map((theme) => (
<button
key={theme.id}
type="button"
onClick={() => setTheme(theme.id)}
className={cn(
"group relative flex flex-col items-center gap-1.5 rounded-lg p-2 transition-colors hover:bg-accent",
themeId === theme.id && "bg-accent"
)}
title={theme.name}
>
<span
className={cn(
"size-8 rounded-full border-2 transition-all",
themeId === theme.id
? "border-foreground ring-2 ring-foreground ring-offset-2 ring-offset-background"
: "border-transparent"
)}
style={{ backgroundColor: theme.light.primary }}
/>
<span className="text-xs text-muted-foreground group-hover:text-foreground">
{theme.name}
</span>
</button>
))}
</div>
</div>
{/* Mode toggle */}
<div>
<p className="text-sm font-medium mb-3">Mode</p>
<div className="inline-flex items-center rounded-lg border bg-muted p-1 gap-1">
{modeOptions.map((opt) => (
<Button
key={opt.value}
type="button"
variant={mode === opt.value ? "default" : "ghost"}
size="sm"
onClick={() => setMode(opt.value)}
className="gap-1.5"
>
<opt.icon className="size-4" />
{opt.label}
</Button>
))}
</div>
</div>
</CardContent>
</Card>
);
}
+30
View File
@@ -0,0 +1,30 @@
"use client";
import {
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
interface ConfirmDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
description: string;
confirmLabel?: string;
variant?: "default" | "destructive";
loading?: boolean;
onConfirm: () => void;
}
export function ConfirmDialog({ open, onOpenChange, title, description, confirmLabel = "Confirm", variant = "default", loading = false, onConfirm }: ConfirmDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader><DialogTitle>{title}</DialogTitle><DialogDescription>{description}</DialogDescription></DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={loading}>Cancel</Button>
<Button variant={variant} onClick={onConfirm} disabled={loading}>{loading ? "..." : confirmLabel}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+20
View File
@@ -0,0 +1,20 @@
"use client";
import { Input } from "@/components/ui/input";
import { forwardRef } from "react";
interface CurrencyInputProps extends Omit<React.ComponentProps<typeof Input>, "value" | "onChange"> {
value?: number;
onChange: (value: number | undefined) => void;
}
export const CurrencyInput = forwardRef<HTMLInputElement, CurrencyInputProps>(
function CurrencyInput({ value, onChange, ...props }, ref) {
return (
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground text-sm">$</span>
<Input ref={ref} type="number" step="0.01" min="0" className="pl-7"
value={value ?? ""} onChange={(e) => { const v = e.target.value; onChange(v === "" ? undefined : Number(v)); }} {...props} />
</div>
);
}
);
+23
View File
@@ -0,0 +1,23 @@
import { Button } from "@/components/ui/button";
import { LucideIcon, Plus } from "lucide-react";
interface EmptyStateProps {
icon: LucideIcon;
title: string;
description: string;
actionLabel?: string;
onAction?: () => void;
}
export function EmptyState({ icon: Icon, title, description, actionLabel, onAction }: EmptyStateProps) {
return (
<div className="flex flex-col items-center justify-center py-16 text-center">
<div className="rounded-full bg-muted p-4 mb-4"><Icon className="size-8 text-muted-foreground" /></div>
<h3 className="text-lg font-semibold">{title}</h3>
<p className="text-muted-foreground mt-1 max-w-sm">{description}</p>
{actionLabel && onAction && (
<Button onClick={onAction} className="mt-4"><Plus className="size-4 mr-2" />{actionLabel}</Button>
)}
</div>
);
}
+18
View File
@@ -0,0 +1,18 @@
"use client";
import { Button } from "@/components/ui/button";
import { AlertTriangle } from "lucide-react";
interface ErrorBannerProps {
message?: string;
onRetry?: () => void;
}
export function ErrorBanner({ message = "Something went wrong. Please try again.", onRetry }: ErrorBannerProps) {
return (
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-4 flex items-center gap-3">
<AlertTriangle className="size-5 text-destructive shrink-0" />
<p className="text-sm text-destructive flex-1">{message}</p>
{onRetry && <Button variant="outline" size="sm" onClick={onRetry}>Retry</Button>}
</div>
);
}
+58
View File
@@ -0,0 +1,58 @@
"use client";
import { useCallback, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Upload, X, FileIcon } from "lucide-react";
interface FileUploadProps {
accept?: string;
multiple?: boolean;
maxSizeMB?: number;
files: File[];
onChange: (files: File[]) => void;
label?: string;
}
export function FileUpload({ accept = "image/*", multiple = false, maxSizeMB = 10, files, onChange, label = "Upload files" }: FileUploadProps) {
const inputRef = useRef<HTMLInputElement>(null);
const [error, setError] = useState<string>();
const handleFiles = useCallback((fileList: FileList) => {
setError(undefined);
const newFiles = Array.from(fileList);
const oversized = newFiles.find((f) => f.size > maxSizeMB * 1024 * 1024);
if (oversized) { setError(`File "${oversized.name}" exceeds ${maxSizeMB}MB limit`); return; }
onChange(multiple ? [...files, ...newFiles] : newFiles.slice(0, 1));
}, [files, maxSizeMB, multiple, onChange]);
const removeFile = (index: number) => { onChange(files.filter((_, i) => i !== index)); };
return (
<div className="space-y-2">
<div className="border-2 border-dashed rounded-lg p-6 text-center cursor-pointer hover:border-primary/50 transition-colors"
onClick={() => inputRef.current?.click()}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => { e.preventDefault(); if (e.dataTransfer.files.length) handleFiles(e.dataTransfer.files); }}>
<Upload className="size-8 mx-auto text-muted-foreground mb-2" />
<p className="text-sm text-muted-foreground">{label}</p>
<p className="text-xs text-muted-foreground mt-1">Drag & drop or click to browse (max {maxSizeMB}MB)</p>
</div>
<input ref={inputRef} type="file" accept={accept} multiple={multiple} className="hidden"
onChange={(e) => { if (e.target.files?.length) handleFiles(e.target.files); e.target.value = ""; }} />
{error && <p className="text-sm text-destructive">{error}</p>}
{files.length > 0 && (
<ul className="space-y-1">
{files.map((file, i) => (
<li key={i} className="flex items-center gap-2 text-sm rounded border p-2">
<FileIcon className="size-4 text-muted-foreground shrink-0" />
<span className="flex-1 truncate">{file.name}</span>
<span className="text-muted-foreground text-xs">{(file.size / 1024).toFixed(0)}KB</span>
<Button variant="ghost" size="icon" className="size-6" onClick={(e) => { e.stopPropagation(); removeFile(i); }}>
<X className="size-3" />
</Button>
</li>
))}
</ul>
)}
</div>
);
}
+21
View File
@@ -0,0 +1,21 @@
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
interface FormFieldProps {
label: string;
htmlFor: string;
error?: string;
required?: boolean;
className?: string;
children: React.ReactNode;
}
export function FormField({ label, htmlFor, error, required, className, children }: FormFieldProps) {
return (
<div className={cn("space-y-2", className)}>
<Label htmlFor={htmlFor}>{label}{required && <span className="text-destructive ml-1">*</span>}</Label>
{children}
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
);
}
@@ -0,0 +1,56 @@
import { Skeleton } from "@/components/ui/skeleton";
interface LoadingSkeletonProps {
variant: "card-grid" | "list" | "detail" | "kanban";
count?: number;
}
export function LoadingSkeleton({ variant, count = 4 }: LoadingSkeletonProps) {
if (variant === "card-grid") {
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{Array.from({ length: count }).map((_, i) => (
<div key={i} className="rounded-lg border p-4 space-y-3">
<Skeleton className="h-5 w-3/4" />
<Skeleton className="h-4 w-1/2" />
<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>
);
}
if (variant === "list") {
return (
<div className="space-y-3">
{Array.from({ length: count }).map((_, i) => (
<div key={i} className="flex items-center gap-4 rounded-lg border p-4">
<Skeleton className="size-10 rounded-full" />
<div className="flex-1 space-y-2"><Skeleton className="h-4 w-1/3" /><Skeleton className="h-3 w-1/2" /></div>
<Skeleton className="h-8 w-20" />
</div>
))}
</div>
);
}
if (variant === "detail") {
return (
<div className="space-y-6">
<Skeleton className="h-8 w-1/3" />
<div className="grid grid-cols-2 gap-4">
{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>
</div>
);
}
return (
<div className="flex gap-4 overflow-x-auto pb-4">
{Array.from({ length: count }).map((_, i) => (
<div key={i} className="min-w-[280px] rounded-lg border 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" />
</div>
))}
</div>
);
}
+27
View File
@@ -0,0 +1,27 @@
"use client";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
interface LookupItem { id: number; name: string; icon?: string; }
interface LookupSelectProps {
items: LookupItem[];
value?: number;
onValueChange: (value: number | undefined) => void;
placeholder?: string;
disabled?: boolean;
}
export function LookupSelect({ items, value, onValueChange, placeholder = "Select...", disabled }: LookupSelectProps) {
return (
<Select value={value != null ? String(value) : undefined} onValueChange={(v) => onValueChange(v ? Number(v) : undefined)} disabled={disabled}>
<SelectTrigger><SelectValue placeholder={placeholder} /></SelectTrigger>
<SelectContent>
{items.map((item) => (
<SelectItem key={item.id} value={String(item.id)}>
{item.icon && <span className="mr-2">{item.icon}</span>}{item.name}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
+28
View File
@@ -0,0 +1,28 @@
"use client";
import { Button } from "@/components/ui/button";
import { Plus } from "lucide-react";
interface PageHeaderProps {
title: string;
description?: string;
actionLabel?: string;
onAction?: () => void;
children?: React.ReactNode;
}
export function PageHeader({ title, description, actionLabel, onAction, children }: PageHeaderProps) {
return (
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">{title}</h1>
{description && <p className="text-muted-foreground mt-1">{description}</p>}
</div>
<div className="flex items-center gap-2">
{children}
{actionLabel && onAction && (
<Button onClick={onAction}><Plus className="size-4 mr-2" />{actionLabel}</Button>
)}
</div>
</div>
);
}
+25
View File
@@ -0,0 +1,25 @@
"use client";
import { Star } from "lucide-react";
import { cn } from "@/lib/utils";
interface StarRatingProps {
value: number;
onChange?: (value: number) => void;
readonly?: boolean;
size?: "sm" | "md";
}
export function StarRating({ value, onChange, readonly = false, size = "md" }: StarRatingProps) {
const starSize = size === "sm" ? "size-4" : "size-5";
return (
<div className="flex gap-1">
{[1, 2, 3, 4, 5].map((star) => (
<button key={star} type="button" disabled={readonly}
className={cn("transition-colors", !readonly && "cursor-pointer hover:text-yellow-400")}
onClick={() => onChange?.(star === value ? 0 : star)}>
<Star className={cn(starSize, star <= value ? "fill-yellow-400 text-yellow-400" : "text-muted-foreground")} />
</button>
))}
</div>
);
}
+63
View File
@@ -0,0 +1,63 @@
"use client";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Crown } from "lucide-react";
interface UpgradePromptProps {
open: boolean;
onOpenChange: (open: boolean) => void;
feature: string;
limitInfo?: string;
}
export function UpgradePrompt({ open, onOpenChange, feature, limitInfo }: UpgradePromptProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<div className="mx-auto mb-2 flex size-12 items-center justify-center rounded-full bg-primary/10">
<Crown className="size-6 text-primary" />
</div>
<DialogTitle className="text-center">Upgrade to Premium</DialogTitle>
<DialogDescription className="text-center">
{feature}
</DialogDescription>
</DialogHeader>
{limitInfo && (
<div className="rounded-lg border bg-muted/50 p-3 text-center">
<p className="text-sm text-muted-foreground">{limitInfo}</p>
</div>
)}
<div className="space-y-2 text-sm text-muted-foreground">
<p>
Premium unlocks unlimited residences, tasks, contractors, documents, sharing, and export
features.
</p>
<p>
Subscriptions are managed through the Casera iOS or Android app.
</p>
</div>
<DialogFooter className="flex flex-col gap-2 sm:flex-col">
<Button className="w-full" onClick={() => onOpenChange(false)}>
<Crown className="size-4" />
Upgrade on App Store
</Button>
<Button variant="outline" className="w-full" onClick={() => onOpenChange(false)}>
Maybe Later
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,156 @@
"use client";
import { useCallback, useRef, useState } from "react";
import { Upload, FileDown } from "lucide-react";
import { Button } from "@/components/ui/button";
// ---------------------------------------------------------------------------
// Export helper (non-component function)
// ---------------------------------------------------------------------------
/**
* Generates a `.casera` file from the given data object and triggers a download.
*/
export function downloadCaseraFile(data: object, filename: string) {
const json = JSON.stringify(data, null, 2);
const blob = new Blob([json], { type: "application/json" });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename.endsWith(".casera") ? filename : `${filename}.casera`;
document.body.appendChild(anchor);
anchor.click();
// Cleanup
document.body.removeChild(anchor);
URL.revokeObjectURL(url);
}
// ---------------------------------------------------------------------------
// Import component
// ---------------------------------------------------------------------------
interface CaseraFileImportProps {
onImport: (data: unknown) => void;
accept?: string;
}
export function CaseraFileImport({ onImport, accept = ".casera" }: CaseraFileImportProps) {
const inputRef = useRef<HTMLInputElement>(null);
const [dragActive, setDragActive] = useState(false);
const [error, setError] = useState<string | null>(null);
const [fileName, setFileName] = useState<string | null>(null);
const processFile = useCallback(
(file: File) => {
setError(null);
setFileName(file.name);
const reader = new FileReader();
reader.onload = (e) => {
try {
const text = e.target?.result;
if (typeof text !== "string") {
setError("Failed to read file.");
return;
}
const parsed = JSON.parse(text);
onImport(parsed);
} catch {
setError("Invalid .casera file. Could not parse contents.");
setFileName(null);
}
};
reader.onerror = () => {
setError("Failed to read file.");
setFileName(null);
};
reader.readAsText(file);
},
[onImport],
);
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (file) processFile(file);
}
function handleDrop(e: React.DragEvent) {
e.preventDefault();
setDragActive(false);
const file = e.dataTransfer.files[0];
if (file) processFile(file);
}
function handleDragOver(e: React.DragEvent) {
e.preventDefault();
setDragActive(true);
}
function handleDragLeave(e: React.DragEvent) {
e.preventDefault();
setDragActive(false);
}
return (
<div className="space-y-2">
<div
className={`flex flex-col items-center justify-center gap-3 rounded-lg border-2 border-dashed p-8 transition-colors cursor-pointer ${
dragActive
? "border-primary bg-primary/5"
: "border-muted-foreground/25 hover:border-muted-foreground/50"
}`}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onClick={() => inputRef.current?.click()}
>
<Upload className="size-8 text-muted-foreground" />
<div className="text-center">
<p className="text-sm font-medium">
Drop a .casera file here or click to browse
</p>
{fileName && (
<p className="text-xs text-muted-foreground mt-1">
Selected: {fileName}
</p>
)}
</div>
<input
ref={inputRef}
type="file"
accept={accept}
onChange={handleFileChange}
className="hidden"
/>
</div>
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Export button component (convenience wrapper)
// ---------------------------------------------------------------------------
interface CaseraFileExportProps {
data: object;
filename: string;
label?: string;
}
export function CaseraFileExport({ data, filename, label = "Export .casera" }: CaseraFileExportProps) {
return (
<Button
variant="outline"
size="sm"
onClick={() => downloadCaseraFile(data, filename)}
>
<FileDown className="size-4 mr-2" />
{label}
</Button>
);
}
@@ -0,0 +1,106 @@
"use client";
import { useState } from "react";
import { Copy, Check, RefreshCw } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { useShareCode, useGenerateShareCode } from "@/lib/hooks/use-sharing";
interface ShareCodeDisplayProps {
residenceId: number;
}
export function ShareCodeDisplay({ residenceId }: ShareCodeDisplayProps) {
const { data, isLoading } = useShareCode(residenceId);
const generateCode = useGenerateShareCode(residenceId);
const [copied, setCopied] = useState(false);
const shareCode = data?.share_code;
async function handleCopy() {
if (!shareCode) return;
await navigator.clipboard.writeText(shareCode.code);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
function handleGenerate() {
generateCode.mutate();
}
if (isLoading) {
return (
<Card>
<CardHeader>
<CardTitle>Share Code</CardTitle>
<CardDescription>Loading...</CardDescription>
</CardHeader>
</Card>
);
}
return (
<Card>
<CardHeader>
<CardTitle>Share Code</CardTitle>
<CardDescription>
Share this code with others to invite them to your residence.
</CardDescription>
</CardHeader>
<CardContent>
{shareCode ? (
<div className="space-y-4">
<div className="flex items-center gap-3">
<code className="flex-1 rounded-lg border bg-muted px-4 py-3 text-center text-2xl font-mono font-bold tracking-widest">
{shareCode.code}
</code>
<Button
variant="outline"
size="icon"
onClick={handleCopy}
title="Copy to clipboard"
>
{copied ? (
<Check className="size-4 text-green-600" />
) : (
<Copy className="size-4" />
)}
</Button>
</div>
<p className="text-sm text-muted-foreground">
Expires{" "}
{new Date(shareCode.expires_at).toLocaleDateString(undefined, {
year: "numeric",
month: "long",
day: "numeric",
hour: "numeric",
minute: "2-digit",
})}
</p>
<Button
variant="outline"
size="sm"
onClick={handleGenerate}
disabled={generateCode.isPending}
>
<RefreshCw className="size-4 mr-2" />
{generateCode.isPending ? "Generating..." : "Generate New Code"}
</Button>
</div>
) : (
<div className="text-center py-4">
<p className="text-sm text-muted-foreground mb-4">
No active share code. Generate one to invite others.
</p>
<Button
onClick={handleGenerate}
disabled={generateCode.isPending}
>
{generateCode.isPending ? "Generating..." : "Generate Share Code"}
</Button>
</div>
)}
</CardContent>
</Card>
);
}
+128
View File
@@ -0,0 +1,128 @@
"use client";
import { useState } from "react";
import { UserMinus } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { ConfirmDialog } from "@/components/shared/confirm-dialog";
import { LoadingSkeleton } from "@/components/shared/loading-skeleton";
import { ErrorBanner } from "@/components/shared/error-banner";
import { useResidenceUsers, useRemoveResidenceUser } from "@/lib/hooks/use-sharing";
interface UserManagementProps {
residenceId: number;
}
export function UserManagement({ residenceId }: UserManagementProps) {
const { data: users, isLoading, isError, error, refetch } = useResidenceUsers(residenceId);
const removeUser = useRemoveResidenceUser(residenceId);
const [removeTarget, setRemoveTarget] = useState<{ id: number; name: string } | null>(null);
function handleRemove() {
if (!removeTarget) return;
removeUser.mutate(removeTarget.id, {
onSuccess: () => {
setRemoveTarget(null);
},
});
}
if (isLoading) {
return (
<Card>
<CardHeader>
<CardTitle>Members</CardTitle>
</CardHeader>
<CardContent>
<LoadingSkeleton variant="list" count={3} />
</CardContent>
</Card>
);
}
if (isError) {
return (
<Card>
<CardHeader>
<CardTitle>Members</CardTitle>
</CardHeader>
<CardContent>
<ErrorBanner
message={error instanceof Error ? error.message : "Failed to load members."}
onRetry={() => refetch()}
/>
</CardContent>
</Card>
);
}
return (
<>
<Card>
<CardHeader>
<CardTitle>Members</CardTitle>
<CardDescription>
People who have access to this residence.
</CardDescription>
</CardHeader>
<CardContent>
{!users || users.length === 0 ? (
<p className="text-sm text-muted-foreground">No members found.</p>
) : (
<div className="space-y-3">
{users.map((user) => {
const displayName = [user.first_name, user.last_name].filter(Boolean).join(" ") || user.username;
return (
<div
key={user.id}
className="flex items-center justify-between rounded-lg border p-3"
>
<div className="flex items-center gap-3 min-w-0">
<div className="flex size-10 items-center justify-center rounded-full bg-muted text-sm font-medium shrink-0">
{(user.first_name?.[0] || user.username[0] || "?").toUpperCase()}
</div>
<div className="min-w-0">
<p className="text-sm font-medium truncate">{displayName}</p>
<p className="text-xs text-muted-foreground truncate">{user.email}</p>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<Badge variant={user.is_owner ? "default" : "secondary"}>
{user.is_owner ? "Owner" : "Member"}
</Badge>
{!user.is_owner && (
<Button
variant="ghost"
size="icon"
onClick={() => setRemoveTarget({ id: user.id, name: displayName })}
title="Remove member"
>
<UserMinus className="size-4 text-destructive" />
</Button>
)}
</div>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
<ConfirmDialog
open={removeTarget !== null}
onOpenChange={(open) => {
if (!open) setRemoveTarget(null);
}}
title="Remove Member"
description={`Are you sure you want to remove ${removeTarget?.name ?? "this member"} from this residence? They will lose access to all shared data.`}
confirmLabel="Remove"
variant="destructive"
loading={removeUser.isPending}
onConfirm={handleRemove}
/>
</>
);
}
+56
View File
@@ -0,0 +1,56 @@
"use client";
import { useCallback } from "react";
import { useRouter } from "next/navigation";
import {
DndContext,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core";
import { useMarkInProgress } from "@/lib/hooks/use-tasks";
import { KanbanColumn } from "./kanban-column";
import type { KanbanResponse } from "@/lib/api/tasks";
interface KanbanBoardProps {
data: KanbanResponse;
}
export function KanbanBoard({ data }: KanbanBoardProps) {
const router = useRouter();
const markInProgress = useMarkInProgress();
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 8 },
})
);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event;
if (!over) return;
const taskId = active.id as number;
const targetColumn = over.id as string;
if (targetColumn === "in_progress") {
markInProgress.mutate(taskId);
} else if (targetColumn === "completed") {
router.push(`/app/tasks/${taskId}/complete`);
}
},
[markInProgress, router]
);
return (
<DndContext sensors={sensors} onDragEnd={handleDragEnd}>
<div className="flex gap-4 overflow-x-auto pb-4">
{data.columns.map((column) => (
<KanbanColumn key={column.name} column={column} />
))}
</div>
</DndContext>
);
}
+111
View File
@@ -0,0 +1,111 @@
"use client";
import { useDroppable } from "@dnd-kit/core";
import { SortableContext, verticalListSortingStrategy, useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import { TaskCard } from "./task-card";
import type { KanbanColumn as KanbanColumnType } from "@/lib/api/tasks";
const COLUMN_COLORS: Record<string, string> = {
overdue: "border-red-500/50 bg-red-50/50 dark:bg-red-950/20",
due_today: "border-orange-500/50 bg-orange-50/50 dark:bg-orange-950/20",
due_soon: "border-yellow-500/50 bg-yellow-50/50 dark:bg-yellow-950/20",
upcoming: "border-blue-500/50 bg-blue-50/50 dark:bg-blue-950/20",
in_progress: "border-green-500/50 bg-green-50/50 dark:bg-green-950/20",
completed: "border-gray-500/50 bg-gray-50/50 dark:bg-gray-950/20",
};
const COLUMN_HEADER_COLORS: Record<string, string> = {
overdue: "text-red-700 dark:text-red-400",
due_today: "text-orange-700 dark:text-orange-400",
due_soon: "text-yellow-700 dark:text-yellow-400",
upcoming: "text-blue-700 dark:text-blue-400",
in_progress: "text-green-700 dark:text-green-400",
completed: "text-gray-700 dark:text-gray-400",
};
const COUNT_BADGE_COLORS: Record<string, string> = {
overdue: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200",
due_today: "bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200",
due_soon: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200",
upcoming: "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200",
in_progress: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200",
completed: "bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200",
};
interface KanbanColumnProps {
column: KanbanColumnType;
}
function SortableTask({ task }: { task: import("@/lib/api/tasks").TaskResponse }) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: task.id });
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
return (
<div ref={setNodeRef} style={style} {...attributes} {...listeners}>
<TaskCard task={task} isDragging={isDragging} />
</div>
);
}
export function KanbanColumn({ column }: KanbanColumnProps) {
const { setNodeRef, isOver } = useDroppable({
id: column.name,
});
const taskIds = column.tasks.map((t) => t.id);
return (
<div
className={cn(
"flex flex-col min-w-[280px] max-w-[320px] rounded-lg border-2 p-3",
COLUMN_COLORS[column.name] ?? "border-border bg-muted/30",
isOver && "ring-2 ring-primary"
)}
>
<div className="flex items-center gap-2 mb-3">
<h3
className={cn(
"font-semibold text-sm",
COLUMN_HEADER_COLORS[column.name]
)}
>
{column.display_name}
</h3>
<Badge
variant="secondary"
className={cn("text-xs", COUNT_BADGE_COLORS[column.name])}
>
{column.count}
</Badge>
</div>
<div ref={setNodeRef} className="flex-1 space-y-2 min-h-[60px]">
<SortableContext items={taskIds} strategy={verticalListSortingStrategy}>
{column.tasks.map((task) => (
<SortableTask key={task.id} task={task} />
))}
</SortableContext>
{column.tasks.length === 0 && (
<div className="flex items-center justify-center h-[60px] text-xs text-muted-foreground rounded-md border border-dashed">
No tasks
</div>
)}
</div>
</div>
);
}
+109
View File
@@ -0,0 +1,109 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { ConfirmDialog } from "@/components/shared/confirm-dialog";
import {
MoreVertical,
CheckCircle,
Play,
Pencil,
XCircle,
Archive,
Trash2,
} from "lucide-react";
import {
useMarkInProgress,
useCancelTask,
useArchiveTask,
useDeleteTask,
} from "@/lib/hooks/use-tasks";
interface TaskActionsMenuProps {
taskId: number;
}
export function TaskActionsMenu({ taskId }: TaskActionsMenuProps) {
const router = useRouter();
const [deleteOpen, setDeleteOpen] = useState(false);
const markInProgress = useMarkInProgress();
const cancelTask = useCancelTask();
const archiveTask = useArchiveTask();
const deleteTask = useDeleteTask();
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon">
<MoreVertical className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => router.push(`/app/tasks/${taskId}/complete`)}
>
<CheckCircle className="size-4" />
Complete
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => markInProgress.mutate(taskId)}
>
<Play className="size-4" />
Mark In Progress
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => router.push(`/app/tasks/${taskId}/edit`)}
>
<Pencil className="size-4" />
Edit
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => cancelTask.mutate(taskId)}>
<XCircle className="size-4" />
Cancel
</DropdownMenuItem>
<DropdownMenuItem onClick={() => archiveTask.mutate(taskId)}>
<Archive className="size-4" />
Archive
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onClick={() => setDeleteOpen(true)}
>
<Trash2 className="size-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<ConfirmDialog
open={deleteOpen}
onOpenChange={setDeleteOpen}
title="Delete Task"
description="Are you sure you want to delete this task? This action cannot be undone."
confirmLabel="Delete"
variant="destructive"
loading={deleteTask.isPending}
onConfirm={() => {
deleteTask.mutate(taskId, {
onSuccess: () => {
setDeleteOpen(false);
router.push("/app/tasks");
},
});
}}
/>
</>
);
}
+69
View File
@@ -0,0 +1,69 @@
"use client";
import Link from "next/link";
import { Badge } from "@/components/ui/badge";
import { Calendar, DollarSign } from "lucide-react";
import { cn } from "@/lib/utils";
import type { TaskResponse } from "@/lib/api/tasks";
interface TaskCardProps {
task: TaskResponse;
isDragging?: boolean;
}
export function TaskCard({ task, isDragging }: TaskCardProps) {
return (
<Link href={`/app/tasks/${task.id}`}>
<div
className={cn(
"rounded-lg border bg-card p-3 space-y-2 transition-shadow hover:shadow-md cursor-grab",
isDragging && "shadow-lg ring-2 ring-primary"
)}
>
<div className="font-medium text-sm leading-tight line-clamp-2">
{task.title}
</div>
{task.residence_name && (
<p className="text-xs text-muted-foreground truncate">
{task.residence_name}
</p>
)}
<div className="flex flex-wrap gap-1.5">
{task.priority && (
<Badge variant="outline" className="text-xs px-1.5 py-0">
{task.priority.icon && (
<span className="mr-0.5">{task.priority.icon}</span>
)}
{task.priority.name}
</Badge>
)}
{task.category && (
<Badge variant="secondary" className="text-xs px-1.5 py-0">
{task.category.icon && (
<span className="mr-0.5">{task.category.icon}</span>
)}
{task.category.name}
</Badge>
)}
</div>
<div className="flex items-center gap-3 text-xs text-muted-foreground">
{task.due_date && (
<span className="flex items-center gap-1">
<Calendar className="size-3" />
{new Date(task.due_date).toLocaleDateString()}
</span>
)}
{task.estimated_cost != null && task.estimated_cost > 0 && (
<span className="flex items-center gap-1">
<DollarSign className="size-3" />
{task.estimated_cost.toFixed(2)}
</span>
)}
</div>
</div>
</Link>
);
}
@@ -0,0 +1,124 @@
"use client";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { FormField } from "@/components/shared/form-field";
import { CurrencyInput } from "@/components/shared/currency-input";
import { StarRating } from "@/components/shared/star-rating";
import { FileUpload } from "@/components/shared/file-upload";
const completionSchema = z.object({
completed_at: z.string().optional(),
actual_cost: z.number().optional(),
notes: z.string().optional(),
rating: z.number().min(0).max(5).optional(),
});
type CompletionFormValues = z.infer<typeof completionSchema>;
interface TaskCompletionFormProps {
onSubmit: (data: CompletionFormValues, images: File[]) => void;
isSubmitting?: boolean;
}
function toDateTimeLocalValue(date: Date): string {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, "0");
const d = String(date.getDate()).padStart(2, "0");
const h = String(date.getHours()).padStart(2, "0");
const min = String(date.getMinutes()).padStart(2, "0");
return `${y}-${m}-${d}T${h}:${min}`;
}
export function TaskCompletionForm({
onSubmit,
isSubmitting,
}: TaskCompletionFormProps) {
const [images, setImages] = useState<File[]>([]);
const {
register,
handleSubmit,
setValue,
watch,
formState: { errors },
} = useForm<CompletionFormValues>({
resolver: zodResolver(completionSchema),
defaultValues: {
completed_at: toDateTimeLocalValue(new Date()),
rating: 0,
},
});
const handleFormSubmit = (data: CompletionFormValues) => {
onSubmit(data, images);
};
return (
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-6">
<FormField
label="Completed At"
htmlFor="completed_at"
error={errors.completed_at?.message}
>
<Input
id="completed_at"
type="datetime-local"
{...register("completed_at")}
/>
</FormField>
<FormField
label="Actual Cost"
htmlFor="actual_cost"
error={errors.actual_cost?.message}
>
<CurrencyInput
value={watch("actual_cost")}
onChange={(v) => setValue("actual_cost", v)}
/>
</FormField>
<FormField label="Notes" htmlFor="notes" error={errors.notes?.message}>
<Textarea
id="notes"
rows={3}
placeholder="Add notes about this completion..."
{...register("notes")}
/>
</FormField>
<FormField
label="Rating"
htmlFor="rating"
error={errors.rating?.message}
>
<StarRating
value={watch("rating") ?? 0}
onChange={(v) => setValue("rating", v)}
/>
</FormField>
<FormField label="Photos" htmlFor="photos">
<FileUpload
accept="image/*"
multiple
files={images}
onChange={setImages}
label="Upload completion photos"
/>
</FormField>
<div className="flex justify-end gap-2">
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Saving..." : "Complete Task"}
</Button>
</div>
</form>
);
}
+199
View File
@@ -0,0 +1,199 @@
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { FormField } from "@/components/shared/form-field";
import { LookupSelect } from "@/components/shared/lookup-select";
import { CurrencyInput } from "@/components/shared/currency-input";
import { TemplateSearch } from "./template-search";
import { useResidences } from "@/lib/hooks/use-residences";
import { useContractors } from "@/lib/hooks/use-contractors";
import {
useTaskCategories,
useTaskPriorities,
useTaskFrequencies,
} from "@/lib/hooks/use-lookups";
import type { TaskResponse } from "@/lib/api/tasks";
import type { TaskTemplateResponse } from "@/lib/api/lookups";
const taskSchema = z.object({
title: z.string().min(1, "Title is required"),
residence_id: z.number({ error: "Residence is required" }),
description: z.string().optional(),
category_id: z.number().optional(),
priority_id: z.number().optional(),
frequency_id: z.number().optional(),
due_date: z.string().optional(),
estimated_cost: z.number().optional(),
contractor_id: z.number().optional(),
});
type TaskFormValues = z.infer<typeof taskSchema>;
interface TaskFormProps {
task?: TaskResponse;
onSubmit: (data: TaskFormValues) => void;
isSubmitting?: boolean;
}
export function TaskForm({ task, onSubmit, isSubmitting }: TaskFormProps) {
const isEdit = !!task;
const { data: residences } = useResidences();
const { data: contractors } = useContractors();
const { data: categories } = useTaskCategories();
const { data: priorities } = useTaskPriorities();
const { data: frequencies } = useTaskFrequencies();
const residenceItems = (residences ?? []).map((r) => ({
id: r.residence.id,
name: r.residence.name,
}));
const contractorItems = (contractors ?? []).map((c) => ({
id: c.id,
name: c.company ? `${c.name} (${c.company})` : c.name,
}));
const categoryItems = categories.map((c) => ({
id: c.id,
name: c.name,
icon: c.icon,
}));
const priorityItems = priorities.map((p) => ({
id: p.id,
name: p.name,
icon: p.icon,
}));
const frequencyItems = frequencies.map((f) => ({
id: f.id,
name: f.name,
}));
const {
register,
handleSubmit,
setValue,
watch,
formState: { errors },
} = useForm<TaskFormValues>({
resolver: zodResolver(taskSchema),
defaultValues: {
title: task?.title ?? "",
residence_id: task?.residence_id,
description: task?.description ?? "",
category_id: task?.category_id,
priority_id: task?.priority_id,
frequency_id: task?.frequency_id,
due_date: task?.due_date ? task.due_date.split("T")[0] : undefined,
estimated_cost: task?.estimated_cost,
contractor_id: task?.contractor_id,
},
});
const handleTemplateSelect = (template: TaskTemplateResponse) => {
if (template.category_id) setValue("category_id", template.category_id);
if (template.priority_id) setValue("priority_id", template.priority_id);
if (template.frequency_id) setValue("frequency_id", template.frequency_id);
if (template.estimated_cost) setValue("estimated_cost", template.estimated_cost);
};
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
<FormField
label="Title"
htmlFor="title"
error={errors.title?.message}
required
>
{isEdit ? (
<Input id="title" {...register("title")} />
) : (
<TemplateSearch
onTitleChange={(value) => setValue("title", value, { shouldValidate: true })}
onSelect={handleTemplateSelect}
/>
)}
</FormField>
<FormField
label="Residence"
htmlFor="residence_id"
error={errors.residence_id?.message}
required
>
<LookupSelect
items={residenceItems}
value={watch("residence_id")}
onValueChange={(v) => setValue("residence_id", v as number, { shouldValidate: true })}
placeholder="Select residence..."
/>
</FormField>
<FormField label="Description" htmlFor="description">
<Textarea id="description" rows={3} {...register("description")} />
</FormField>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<FormField label="Category" htmlFor="category_id">
<LookupSelect
items={categoryItems}
value={watch("category_id")}
onValueChange={(v) => setValue("category_id", v)}
placeholder="Select category..."
/>
</FormField>
<FormField label="Priority" htmlFor="priority_id">
<LookupSelect
items={priorityItems}
value={watch("priority_id")}
onValueChange={(v) => setValue("priority_id", v)}
placeholder="Select priority..."
/>
</FormField>
<FormField label="Frequency" htmlFor="frequency_id">
<LookupSelect
items={frequencyItems}
value={watch("frequency_id")}
onValueChange={(v) => setValue("frequency_id", v)}
placeholder="Select frequency..."
/>
</FormField>
<FormField label="Due Date" htmlFor="due_date">
<Input id="due_date" type="date" {...register("due_date")} />
</FormField>
<FormField label="Estimated Cost" htmlFor="estimated_cost">
<CurrencyInput
value={watch("estimated_cost")}
onChange={(v) => setValue("estimated_cost", v)}
/>
</FormField>
<FormField label="Contractor" htmlFor="contractor_id">
<LookupSelect
items={contractorItems}
value={watch("contractor_id")}
onValueChange={(v) => setValue("contractor_id", v)}
placeholder="Select contractor..."
/>
</FormField>
</div>
<div className="flex justify-end gap-2">
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Saving..." : isEdit ? "Update Task" : "Create Task"}
</Button>
</div>
</form>
);
}
+95
View File
@@ -0,0 +1,95 @@
"use client";
import { useState, useRef, useCallback } from "react";
import { useQuery } from "@tanstack/react-query";
import { Input } from "@/components/ui/input";
import { Search } from "lucide-react";
import { searchTaskTemplates } from "@/lib/api/lookups";
import type { TaskTemplateResponse } from "@/lib/api/lookups";
interface TemplateSearchProps {
onSelect: (template: TaskTemplateResponse) => void;
onTitleChange: (title: string) => void;
}
export function TemplateSearch({ onSelect, onTitleChange }: TemplateSearchProps) {
const [query, setQuery] = useState("");
const [isFocused, setIsFocused] = useState(false);
const blurTimeoutRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const { data: results = [] } = useQuery({
queryKey: ["task-templates", "search", query],
queryFn: () => searchTaskTemplates(query),
enabled: query.length >= 2,
});
const showDropdown = isFocused && query.length >= 2 && results.length > 0;
const handleChange = useCallback(
(value: string) => {
setQuery(value);
onTitleChange(value);
},
[onTitleChange]
);
const handleSelect = useCallback(
(template: TaskTemplateResponse) => {
setQuery(template.title);
onTitleChange(template.title);
onSelect(template);
setIsFocused(false);
},
[onSelect, onTitleChange]
);
const handleFocus = useCallback(() => {
if (blurTimeoutRef.current) {
clearTimeout(blurTimeoutRef.current);
}
setIsFocused(true);
}, []);
const handleBlur = useCallback(() => {
blurTimeoutRef.current = setTimeout(() => {
setIsFocused(false);
}, 200);
}, []);
return (
<div className="relative">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
value={query}
onChange={(e) => handleChange(e.target.value)}
onFocus={handleFocus}
onBlur={handleBlur}
placeholder="Search templates or type a title..."
className="pl-9"
/>
</div>
{showDropdown && (
<div className="absolute z-50 w-full mt-1 rounded-md border bg-popover shadow-lg max-h-60 overflow-y-auto">
{results.map((template) => (
<button
key={template.id}
type="button"
className="w-full text-left px-3 py-2 text-sm hover:bg-accent hover:text-accent-foreground transition-colors"
onMouseDown={(e) => e.preventDefault()}
onClick={() => handleSelect(template)}
>
<div className="font-medium">{template.title}</div>
{template.description && (
<div className="text-xs text-muted-foreground truncate">
{template.description}
</div>
)}
</button>
))}
</div>
)}
</div>
);
}
+109
View File
@@ -0,0 +1,109 @@
"use client"
import * as React from "react"
import { Avatar as AvatarPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Avatar({
className,
size = "default",
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root> & {
size?: "default" | "sm" | "lg"
}) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
data-size={size}
className={cn(
"group/avatar relative flex size-8 shrink-0 overflow-hidden rounded-full select-none data-[size=lg]:size-10 data-[size=sm]:size-6",
className
)}
{...props}
/>
)
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn("aspect-square size-full", className)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
className
)}
{...props}
/>
)
}
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="avatar-badge"
className={cn(
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground ring-2 ring-background select-none",
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
className
)}
{...props}
/>
)
}
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group"
className={cn(
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
className
)}
{...props}
/>
)
}
function AvatarGroupCount({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group-count"
className={cn(
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
className
)}
{...props}
/>
)
}
export {
Avatar,
AvatarImage,
AvatarFallback,
AvatarBadge,
AvatarGroup,
AvatarGroupCount,
}
+48
View File
@@ -0,0 +1,48 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90",
outline:
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
link: "text-primary underline-offset-4 [a&]:hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span"
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }
+64
View File
@@ -0,0 +1,64 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

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