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
+41
View File
@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
+23
View File
@@ -0,0 +1,23 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}
+115
View File
@@ -0,0 +1,115 @@
# Casera Web App — Build Plan Overview
## What We're Building
Full parity web app for Casera: **46 screens**, **104 API operations**, **4 domains** (Residences, Tasks, Contractors, Documents), plus auth, onboarding, subscriptions, settings, and a **sandboxed demo mode** with mock data.
## Tech Stack
| Layer | Choice |
|---|---|
| Framework | Next.js 15 (App Router) |
| Language | TypeScript |
| Styling | Tailwind CSS 4 |
| Components | shadcn/ui + Radix primitives |
| State (server) | TanStack Query v5 |
| State (client) | Zustand |
| Forms | React Hook Form + Zod validation |
| Auth | httpOnly cookie storing API token, Next.js middleware for route protection |
| Kanban | dnd-kit (drag-and-drop) for task board |
| Charts/metrics | Recharts (for summary dashboard) |
| Icons | Lucide (matches shadcn/ui) |
| Testing | Vitest + Playwright |
| Deployment | Docker → Dokku (separate app) |
## Why Next.js + shadcn/ui
- SSR for the marketing landing + demo preview (SEO, social sharing)
- shadcn/ui gives the exact component primitives this app needs (kanban boards, forms, dialogs, data tables)
- TanStack Query maps perfectly to the existing `APILayer``DataManager` cache pattern
- Separate Dokku app keeps deployment independent
- Biggest ecosystem for finding solutions
## Project Structure
```
myCribAPI-Web/
├── src/
│ ├── app/ # Next.js App Router
│ │ ├── (marketing)/ # Public pages (landing, pricing)
│ │ ├── (auth)/ # Login, Register, Forgot Password, Reset
│ │ ├── (onboarding)/ # Multi-step onboarding flow
│ │ ├── (demo)/ # Demo mode (sandboxed)
│ │ │ └── layout.tsx # Wraps demo in DemoProvider context
│ │ ├── (app)/ # Authenticated app
│ │ │ ├── residences/ # List, [id] detail, new, [id]/edit
│ │ │ ├── tasks/ # Kanban board, new, [id]/edit, [id]/complete
│ │ │ ├── contractors/ # List, [id] detail, new, [id]/edit
│ │ │ ├── documents/ # Tabs (warranties/documents), [id] detail
│ │ │ └── settings/ # Profile, notifications, themes, subscription
│ │ └── layout.tsx # Root layout
│ ├── components/
│ │ ├── ui/ # shadcn/ui primitives
│ │ ├── forms/ # Reusable form components
│ │ ├── cards/ # ResidenceCard, TaskCard, ContractorCard, etc.
│ │ ├── kanban/ # KanbanBoard, KanbanColumn, TaskActions
│ │ └── shared/ # EmptyState, LoadingOverlay, ErrorBanner, etc.
│ ├── lib/
│ │ ├── api/ # API client (typed fetch wrappers per domain)
│ │ ├── demo/ # DemoDataProvider + mock data seeds
│ │ ├── auth/ # Token management, middleware helpers
│ │ ├── hooks/ # useResidences, useTasks, etc. (TanStack Query)
│ │ └── types/ # TypeScript types matching API DTOs
│ ├── stores/ # Zustand stores (auth, theme, demo state)
│ └── styles/ # Tailwind theme config, design tokens
├── public/ # Static assets
├── Dockerfile
├── next.config.ts
├── tailwind.config.ts
├── package.json
└── CLAUDE.md
```
## Build Phases
| Phase | Focus | Details |
|-------|-------|---------|
| [Phase 1](./01-foundation.md) | Foundation | Scaffold, auth, layout, design system |
| [Phase 2](./02-core-crud.md) | Core CRUD | 4 domains: residences, tasks, contractors, documents |
| [Phase 3](./03-advanced-features.md) | Advanced Features | Sharing, subscription, notifications, onboarding |
| [Phase 4](./04-demo-mode.md) | Demo Mode | Mock data, in-memory store, session isolation |
| [Phase 5](./05-polish-deploy.md) | Polish & Deploy | Responsive, error handling, Dockerfile, tests |
## Web-Only Adaptations
| Mobile Feature | Web Equivalent |
|---|---|
| Push notifications | Not needed (no push API for web in scope) |
| Widgets | Not applicable |
| .casera file import/export | File download (export) + drag-and-drop or file picker (import) |
| Apple/Google Sign In | OAuth redirect flow (same backend endpoints) |
| StoreKit subscription | Stripe Checkout or link to App Store (TBD) |
| Camera capture | File upload input (no camera API needed) |
| Haptics | N/A |
| Background refresh | TanStack Query `refetchInterval` + window focus refetch |
## Risks & Mitigations
| Risk | Mitigation |
|---|---|
| Kanban board performance with many tasks | Virtualize columns with `react-window`, limit initial render to 50 tasks per column |
| Demo mode state leaking between sessions | Session ID in httpOnly cookie, Zustand store scoped to session, auto-clear on expiry |
| File upload size limits | Match backend 10MB limit, client-side validation before upload |
| OAuth redirect for Apple/Google | Use backend's existing social auth endpoints, redirect flow instead of native SDK |
| Subscription without StoreKit | Phase 1: link to App Store. Phase 2: add Stripe for web-only purchases (requires backend work) |
| 46 screens is a lot of UI | shadcn/ui + consistent patterns reduce per-screen effort to ~1-2 hours each after foundation is built |
## Security Considerations
| Concern | Approach |
|---|---|
| Auth token in browser | httpOnly cookie, not accessible to JS, CSRF protection via SameSite=Strict |
| Demo mode data isolation | No backend calls, purely client-side, session-scoped store, no persistence |
| API compatibility | 100% same endpoints, same token format (`Token <hex>`), same request/response shapes |
| Mobile feature gaps | Push notifications and widgets explicitly excluded. Everything else covered. |
| Deployment independence | Separate Dokku app, own domain (e.g., `app.casera.treytartt.com`), no coupling to Go API deployment |
+275
View File
@@ -0,0 +1,275 @@
# Phase 1 — Foundation
Scaffold the project, wire up auth, build the app shell, and establish the design system.
## Checklist
- [ ] Next.js 15 project init with App Router + TypeScript
- [ ] Tailwind CSS 4 configuration
- [ ] shadcn/ui setup (install CLI, add base components)
- [ ] TypeScript types from API DTOs (all request/response shapes)
- [ ] API client layer (typed fetch wrappers, token injection, error handling)
- [ ] Auth flows: Login, Register, Verify Email, Forgot/Reset Password
- [ ] Auth middleware (route protection, token cookie)
- [ ] App shell: sidebar nav + top bar
- [ ] Design system: color tokens matching mobile app
- [ ] Theme switcher (11 themes, persisted to localStorage)
## 1. Project Scaffold
```bash
npx create-next-app@latest myCribAPI-Web --typescript --tailwind --eslint --app --src-dir
cd myCribAPI-Web
npx shadcn@latest init
```
Install core dependencies:
```bash
npm install @tanstack/react-query zustand react-hook-form @hookform/resolvers zod
npm install lucide-react recharts @dnd-kit/core @dnd-kit/sortable
npm install -D vitest @playwright/test
```
## 2. TypeScript Types
Generate TypeScript types matching the Go API DTOs. Source from:
- `myCribAPI-go/internal/dto/requests/` — request shapes
- `myCribAPI-go/internal/dto/responses/` — response shapes
- `myCribAPI-go/internal/models/` — entity shapes
Key types to define in `src/lib/types/`:
```
types/
├── api.ts # ApiResult<T>, PaginatedResponse, ErrorResponse
├── auth.ts # LoginRequest, RegisterRequest, UserResponse, TokenResponse
├── residence.ts # Residence, ResidenceDetail, MyResidencesResponse, CreateResidenceRequest
├── task.ts # Task, TaskDetail, TaskColumnsResponse, CreateTaskRequest, TaskCompletionRequest
├── contractor.ts # Contractor, ContractorDetail, CreateContractorRequest
├── document.ts # Document, DocumentDetail, CreateDocumentRequest
├── subscription.ts # SubscriptionStatus, FeatureBenefit, UpgradeTrigger
├── lookups.ts # TaskCategory, TaskPriority, TaskFrequency, ContractorSpecialty
└── notification.ts # NotificationPreference, Notification
```
## 3. API Client Layer
Create typed fetch wrappers in `src/lib/api/`:
```
api/
├── client.ts # Base fetch wrapper (token injection, error handling, base URL)
├── auth.ts # login(), register(), forgotPassword(), resetPassword()
├── residences.ts # getResidences(), getResidence(), createResidence(), updateResidence(), deleteResidence()
├── tasks.ts # getTasks(), getTasksByResidence(), createTask(), updateTask(), completeTask(), etc.
├── contractors.ts # getContractors(), getContractor(), createContractor(), etc.
├── documents.ts # getDocuments(), getDocument(), createDocument(), uploadFile(), etc.
├── lookups.ts # getStaticData(), getUpgradeTriggers()
├── subscription.ts # getSubscriptionStatus(), etc.
└── notifications.ts # getPreferences(), updatePreference(), etc.
```
**Base client pattern:**
```typescript
// src/lib/api/client.ts
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'https://mycrib.treytartt.com/api';
async function apiFetch<T>(
path: string,
options: RequestInit = {}
): Promise<T> {
const res = await fetch(`${API_BASE_URL}${path}`, {
...options,
credentials: 'include', // Send httpOnly cookie
headers: {
'Content-Type': 'application/json',
...options.headers,
},
});
if (!res.ok) {
const error = await res.json().catch(() => ({ detail: 'Unknown error' }));
throw new ApiError(res.status, error.detail || error.message);
}
return res.json();
}
```
## 4. Auth Flows
### Pages
| Route | Screen | Components |
|-------|--------|------------|
| `/login` | Login form | Email, password, "Forgot password?" link, social sign-in buttons |
| `/register` | Registration form | First name, last name, email, password, confirm password |
| `/verify-email` | Email verification | Code input, resend button |
| `/forgot-password` | Forgot password | Email input, submit |
| `/reset-password` | Reset password | New password, confirm, token from URL |
### Auth Token Strategy
```
Login → Server returns token → API route stores token in httpOnly cookie → Client redirects to /app
All subsequent requests include cookie
Next.js middleware reads cookie for route protection
```
- Token format: `Token <hex>` (Django-compatible, same as mobile)
- Cookie: `httpOnly`, `SameSite=Strict`, `Secure` in production
- Next.js middleware in `src/middleware.ts` checks cookie existence for protected routes
### Auth Store (Zustand)
```typescript
// src/stores/auth.ts
interface AuthState {
user: UserResponse | null;
isAuthenticated: boolean;
isLoading: boolean;
login: (email: string, password: string) => Promise<void>;
register: (data: RegisterRequest) => Promise<void>;
logout: () => Promise<void>;
fetchUser: () => Promise<void>;
}
```
## 5. App Shell
### Layout Structure
```
┌─────────────────────────────────────────────────────┐
│ Top Bar: Logo | Search (optional) | Profile Menu │
├──────────┬──────────────────────────────────────────┤
│ │ │
│ Sidebar │ Main Content │
│ │ │
│ 🏠 Home │ │
│ 🏘 Resid │ │
│ ✅ Tasks │ │
│ 👷 Contr │ │
│ 📄 Docs │ │
│ │ │
│ ──────── │ │
│ ⚙ Setti │ │
│ │ │
├──────────┴──────────────────────────────────────────┤
│ (mobile: bottom tab bar instead of sidebar) │
└─────────────────────────────────────────────────────┘
```
- **Desktop (≥1024px)**: Collapsible sidebar + top bar
- **Tablet (768-1023px)**: Collapsed sidebar (icons only) + top bar
- **Mobile (<768px)**: Bottom tab bar (matches iOS app), no sidebar
### Navigation Items
| Icon | Label | Route |
|------|-------|-------|
| Home | Home | `/app` |
| Building | Residences | `/app/residences` |
| CheckSquare | Tasks | `/app/tasks` |
| HardHat | Contractors | `/app/contractors` |
| FileText | Documents | `/app/documents` |
| Settings | Settings | `/app/settings` |
## 6. Design System
### Color Tokens
Map the mobile app's color palette to CSS custom properties and Tailwind config:
```css
/* src/styles/theme.css */
:root {
/* Default theme (matches iOS appPrimary, etc.) */
--color-primary: #07A0C3; /* BlueGreen */
--color-secondary: #0055A5; /* Cerulean */
--color-accent: #F5A623; /* BrightAmber */
--color-error: #DD1C1A; /* PrimaryScarlet */
--color-bg-primary: #FFF1D0; /* Cream */
--color-bg-secondary: #F0F4F8; /* Blue-gray */
--color-text-primary: #1A1A1A;
--color-text-secondary: #6B7280;
--color-text-on-primary: #FFFFFF;
}
[data-theme="dark"] {
--color-bg-primary: #0A1929;
--color-bg-secondary: #1A2A3A;
--color-text-primary: #F0F0F0;
--color-text-secondary: #9CA3AF;
}
```
### 11 Themes
Match the mobile app's theme system:
| Theme | Primary | Secondary |
|-------|---------|-----------|
| Default | #07A0C3 | #0055A5 |
| Teal | TBD | TBD |
| Ocean | TBD | TBD |
| Forest | TBD | TBD |
| Sunset | TBD | TBD |
| Monochrome | TBD | TBD |
| Lavender | TBD | TBD |
| Crimson | TBD | TBD |
| Midnight | TBD | TBD |
| Desert | TBD | TBD |
| Mint | TBD | TBD |
Theme values sourced from `MyCribKMM/composeApp/src/commonMain/.../ui/theme/ThemeColors.kt`.
### Spacing
```css
--spacing-xs: 4px;
--spacing-sm: 8px;
--spacing-md: 12px;
--spacing-lg: 16px;
--spacing-xl: 24px;
```
## 7. TanStack Query Setup
```typescript
// src/lib/hooks/useResidences.ts
export function useResidences() {
return useQuery({
queryKey: ['residences'],
queryFn: () => api.residences.getResidences(),
staleTime: 60 * 60 * 1000, // 1 hour (matches mobile cache timeout)
refetchOnWindowFocus: true,
});
}
```
## 8. Lookups Initialization
Match the mobile pattern — after login, fetch `/api/static_data/` and `/api/upgrade-triggers/`:
```typescript
// After successful login
await queryClient.prefetchQuery({
queryKey: ['lookups'],
queryFn: () => api.lookups.getStaticData(),
staleTime: Infinity, // ETag-based, manually invalidate
});
```
## Deliverables
At the end of Phase 1, you should have:
1. A working Next.js app with Tailwind + shadcn/ui
2. Login and registration flows that authenticate against the Go API
3. A protected app shell with sidebar navigation
4. A design system with color tokens matching the mobile app
5. Typed API client with error handling
6. Theme switching (light/dark + 11 color themes)
+355
View File
@@ -0,0 +1,355 @@
# Phase 2 — Core CRUD
Build the 4 primary domains: Residences, Tasks, Contractors, and Documents.
## Checklist
- [ ] Residences: list (with summary card), detail, create/edit form, delete
- [ ] Tasks: kanban board (drag-and-drop columns), create/edit form, task actions (complete, cancel, archive, in-progress)
- [ ] Task completion: form with file upload (photos), completion history with image viewer
- [ ] Task templates: autocomplete search + template browser
- [ ] Contractors: list (search, filter by favorite/specialty), detail (quick actions), create/edit form, delete
- [ ] Documents: tabbed view (warranties/documents), detail, create/edit form (type-specific fields), file upload/download, image gallery
---
## 1. Residences
### Screens
| Route | Screen | Description |
|-------|--------|-------------|
| `/app/residences` | Residence List | Grid of residence cards with summary data |
| `/app/residences/new` | Create Residence | Form: name, address, type, photo |
| `/app/residences/[id]` | Residence Detail | Summary dashboard, task/contractor/document counts, users |
| `/app/residences/[id]/edit` | Edit Residence | Same form as create, pre-filled |
### API Endpoints Used
| Method | Endpoint | Purpose |
|--------|----------|---------|
| GET | `/api/residences/my/` | User's residences with summaries |
| GET | `/api/residences/:id/` | Residence detail |
| POST | `/api/residences/` | Create residence |
| PUT | `/api/residences/:id/` | Update residence |
| DELETE | `/api/residences/:id/` | Delete residence |
### Components
- **ResidenceCard**: Summary card showing name, address, type icon, task counts (overdue, due soon, total)
- **ResidenceForm**: React Hook Form + Zod. Fields: name (required), address, residence type (dropdown from lookups), photo upload
- **ResidenceSummary**: Dashboard showing task breakdown, contractor count, document count
### TanStack Query Hooks
```typescript
useResidences() // GET /residences/my/
useResidence(id) // GET /residences/:id/
useCreateResidence() // POST /residences/
useUpdateResidence(id) // PUT /residences/:id/
useDeleteResidence(id) // DELETE /residences/:id/
```
### Cache Invalidation
After create/update/delete → invalidate `['residences']` and `['residence', id]` queries.
---
## 2. Tasks
### Screens
| Route | Screen | Description |
|-------|--------|-------------|
| `/app/tasks` | Kanban Board | All tasks across residences, grouped by column |
| `/app/residences/[id]/tasks` | Residence Kanban | Tasks for a specific residence |
| `/app/tasks/new` | Create Task | Full task form |
| `/app/tasks/[id]` | Task Detail | Full task info, completion history, actions |
| `/app/tasks/[id]/edit` | Edit Task | Same form as create, pre-filled |
| `/app/tasks/[id]/complete` | Complete Task | Completion form with photo upload |
### Kanban Board
Columns (from backend `TaskColumnsResponse`):
1. **Overdue** (red indicator)
2. **Due Today** (orange indicator)
3. **Due Soon** (yellow indicator, within 30 days)
4. **Upcoming** (blue indicator)
5. **In Progress** (green indicator)
6. **Completed** (gray indicator)
**Drag-and-drop**: Use `@dnd-kit/core` + `@dnd-kit/sortable` for dragging tasks between columns. Dropping a task triggers the appropriate API action:
- Drop on "In Progress" → `POST /tasks/:id/in-progress/`
- Drop on "Completed" → Opens completion form
- Other moves may just reorder (no API call)
**Board controls**:
- Filter by residence (dropdown)
- Filter by category, priority
- Search by title
### Task Form Fields
| Field | Type | Required | Source |
|-------|------|----------|--------|
| Title | Text / Autocomplete | Yes | Free text or template |
| Residence | Select | Yes | User's residences |
| Category | Select | No | Lookups (categories) |
| Priority | Select | No | Lookups (priorities) |
| Due Date | Date picker | No | Calendar |
| Frequency | Select | No | Lookups (frequencies) — for recurring tasks |
| Estimated Cost | Number | No | Currency input |
| Notes | Textarea | No | Free text |
| Assigned Contractor | Select | No | User's contractors |
### Task Actions
| Action | Endpoint | UI Trigger |
|--------|----------|------------|
| Complete | `POST /tasks/:id/complete/` | Button + completion form |
| Mark In Progress | `POST /tasks/:id/in-progress/` | Button or drag |
| Cancel | `POST /tasks/:id/cancel/` | Menu action |
| Archive | `POST /tasks/:id/archive/` | Menu action |
| Uncancel | `POST /tasks/:id/uncancel/` | Menu action |
| Unarchive | `POST /tasks/:id/unarchive/` | Menu action |
### Task Completion Form
| Field | Type | Required |
|-------|------|----------|
| Completed At | DateTime | Yes (default: now) |
| Actual Cost | Number | No |
| Notes | Textarea | No |
| Rating | Star rating (1-5) | No |
| Photos | File upload (multiple) | No |
File upload uses `FormData` with multipart, same as mobile's `submitFormWithBinaryData`.
### Task Templates
- **Autocomplete search**: As user types in title field, search `/api/task-templates/search/?q=...`
- **Template browser**: Modal with categorized templates, click to prefill form
- Template data populates: title, category, priority, estimated cost, frequency
### API Endpoints Used
| Method | Endpoint | Purpose |
|--------|----------|---------|
| GET | `/api/tasks/` | All user's tasks (kanban columns) |
| GET | `/api/tasks/by-residence/:id/` | Tasks for one residence |
| POST | `/api/tasks/` | Create task |
| PUT | `/api/tasks/:id/` | Update task |
| DELETE | `/api/tasks/:id/` | Delete task |
| POST | `/api/tasks/:id/complete/` | Complete task |
| POST | `/api/tasks/:id/in-progress/` | Mark in progress |
| POST | `/api/tasks/:id/cancel/` | Cancel task |
| POST | `/api/tasks/:id/archive/` | Archive task |
| POST | `/api/task-completions/` | Create completion (with images) |
| GET | `/api/task-completions/` | List completions |
| GET | `/api/task-templates/search/` | Search templates |
### TanStack Query Hooks
```typescript
useTasks() // GET /tasks/
useTasksByResidence(residenceId) // GET /tasks/by-residence/:id/
useTask(id) // GET /tasks/:id/
useCreateTask() // POST /tasks/
useUpdateTask(id) // PUT /tasks/:id/
useDeleteTask(id) // DELETE /tasks/:id/
useCompleteTask(id) // POST /tasks/:id/complete/
useTaskTemplateSearch(query) // GET /task-templates/search/?q=...
useCreateCompletion() // POST /task-completions/
```
---
## 3. Contractors
### Screens
| Route | Screen | Description |
|-------|--------|-------------|
| `/app/contractors` | Contractor List | Filterable list with search |
| `/app/contractors/new` | Create Contractor | Form with contact details |
| `/app/contractors/[id]` | Contractor Detail | Full info, quick actions, linked tasks |
| `/app/contractors/[id]/edit` | Edit Contractor | Same form as create, pre-filled |
### List Features
- **Search**: Filter by name, company
- **Filter**: By specialty (from lookups), by favorite
- **Sort**: By name, recently added
- **Quick actions**: Call, email, favorite toggle
### Contractor Form Fields
| Field | Type | Required | Source |
|-------|------|----------|--------|
| Name | Text | Yes | Free text |
| Company | Text | No | Free text |
| Phone | Phone | No | Tel input |
| Email | Email | No | Email input |
| Specialty | Select | No | Lookups (specialties) |
| Notes | Textarea | No | Free text |
| Is Favorite | Toggle | No | Boolean |
| Residence | Select | Yes | User's residences |
### Contractor Detail
- Contact info with click-to-call (`tel:`) and click-to-email (`mailto:`)
- Favorite toggle
- Linked tasks (tasks assigned to this contractor)
- Edit / delete actions
### API Endpoints Used
| Method | Endpoint | Purpose |
|--------|----------|---------|
| GET | `/api/contractors/` | User's contractors |
| GET | `/api/contractors/:id/` | Contractor detail |
| POST | `/api/contractors/` | Create contractor |
| PUT | `/api/contractors/:id/` | Update contractor |
| DELETE | `/api/contractors/:id/` | Delete contractor |
| POST | `/api/contractors/:id/favorite/` | Toggle favorite |
### TanStack Query Hooks
```typescript
useContractors() // GET /contractors/
useContractor(id) // GET /contractors/:id/
useCreateContractor() // POST /contractors/
useUpdateContractor(id) // PUT /contractors/:id/
useDeleteContractor(id) // DELETE /contractors/:id/
useToggleFavorite(id) // POST /contractors/:id/favorite/
```
---
## 4. Documents
### Screens
| Route | Screen | Description |
|-------|--------|-------------|
| `/app/documents` | Document List | Tabbed view: Warranties / Documents |
| `/app/documents/new` | Create Document | Form with type-specific fields |
| `/app/documents/[id]` | Document Detail | Full info, file viewer, image gallery |
| `/app/documents/[id]/edit` | Edit Document | Same form as create, pre-filled |
### Tabbed View
- **Warranties tab**: Documents with `is_warranty = true`. Shows expiry dates, status (active/expired/expiring soon)
- **Documents tab**: All other documents. Shows type, date added, file preview
### Document Form Fields
| Field | Type | Required | Source |
|-------|------|----------|--------|
| Title | Text | Yes | Free text |
| Residence | Select | Yes | User's residences |
| Type | Select | No | Document type options |
| Notes | Textarea | No | Free text |
| File | File upload | No | File picker |
| Is Warranty | Toggle | No | Boolean |
| Purchase Date | Date | Conditional | If warranty |
| Expiry Date | Date | Conditional | If warranty |
| Purchase Price | Number | Conditional | If warranty |
### Document Detail
- File preview (PDF viewer, image gallery)
- Download button
- Warranty status indicator (if warranty)
- Edit / delete actions
### API Endpoints Used
| Method | Endpoint | Purpose |
|--------|----------|---------|
| GET | `/api/documents/` | User's documents |
| GET | `/api/documents/:id/` | Document detail |
| POST | `/api/documents/` | Create document |
| PUT | `/api/documents/:id/` | Update document |
| DELETE | `/api/documents/:id/` | Delete document |
| POST | `/api/documents/:id/activate/` | Activate document |
| POST | `/api/documents/:id/deactivate/` | Deactivate document |
### TanStack Query Hooks
```typescript
useDocuments() // GET /documents/
useDocument(id) // GET /documents/:id/
useCreateDocument() // POST /documents/
useUpdateDocument(id) // PUT /documents/:id/
useDeleteDocument(id) // DELETE /documents/:id/
```
---
## Shared Patterns
### Form Pattern
Every CRUD form follows the same structure:
```typescript
// src/components/forms/TaskForm.tsx
const taskSchema = z.object({
title: z.string().min(1, 'Title is required'),
residenceId: z.number().min(1, 'Residence is required'),
categoryId: z.number().optional(),
// ...
});
type TaskFormData = z.infer<typeof taskSchema>;
export function TaskForm({ task, onSubmit }: Props) {
const form = useForm<TaskFormData>({
resolver: zodResolver(taskSchema),
defaultValues: task ? mapTaskToFormData(task) : defaults,
});
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
{/* Fields */}
</form>
</Form>
);
}
```
### Mutation Pattern
```typescript
export function useCreateTask() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateTaskRequest) => api.tasks.createTask(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tasks'] });
queryClient.invalidateQueries({ queryKey: ['residences'] }); // Summary counts change
},
});
}
```
### Loading / Error / Empty States
Every list and detail page handles three states:
1. **Loading**: Skeleton loader (shadcn Skeleton component)
2. **Error**: Error banner with retry button
3. **Empty**: Empty state illustration with CTA to create first item
## Deliverables
At the end of Phase 2, you should have:
1. Full CRUD for all 4 domains
2. Kanban board with drag-and-drop
3. Task completion with photo upload
4. Template search and autocomplete
5. All forms validated with Zod
6. Proper cache invalidation after mutations
+309
View File
@@ -0,0 +1,309 @@
# Phase 3 — Advanced Features
Build sharing, subscriptions, notifications, profile, onboarding, and summary metrics.
## Checklist
- [ ] Residence sharing: generate/display share code, join residence, manage users, .casera file export/import
- [ ] Contractor sharing: .casera file export/import
- [ ] Subscription: status display, feature comparison, upgrade prompt, usage tracking
- [ ] Notification preferences: toggle + time picker per notification type
- [ ] Profile: edit name/email, change password, delete account
- [ ] Onboarding: multi-step wizard (fresh vs join paths)
- [ ] Summary metrics: dashboard cards with Recharts
- [ ] Task report: PDF generation + download
---
## 1. Residence Sharing
### Screens
| Route | Screen | Description |
|-------|--------|-------------|
| `/app/residences/[id]/share` | Share Residence | Generate/display share code, manage users |
| `/app/residences/join` | Join Residence | Enter share code or import .casera file |
### Share Code Flow
```
Owner opens Share screen
→ If no active share code: "Generate Share Code" button
→ POST /api/residences/:id/share/ → returns { code: "ABC123", expires_at: "..." }
→ Display code with copy button + expiry countdown
→ "Revoke" button to disable code
Invitee opens Join screen
→ Enter share code manually, OR
→ Upload .casera file (drag-and-drop zone)
→ POST /api/residences/join/ { code: "ABC123" }
→ On success: redirect to residence detail
```
### .casera File Handling
**Export** (owner):
- Button on residence detail: "Export as .casera"
- Generates JSON file with `{ type: "residence", code: "ABC123", ... }`
- Browser downloads the file
**Import** (invitee):
- Drag-and-drop zone on join page
- Or file picker button
- Read JSON, extract code, auto-submit join request
### Manage Users
| Action | Endpoint | Description |
|--------|----------|-------------|
| List users | `GET /api/residences/:id/users/` | Show all users with roles |
| Remove user | `DELETE /api/residences/:id/users/:userId/` | Owner removes a member |
### API Endpoints
| Method | Endpoint | Purpose |
|--------|----------|---------|
| POST | `/api/residences/:id/share/` | Generate share code |
| DELETE | `/api/residences/:id/share/` | Revoke share code |
| POST | `/api/residences/join/` | Join with share code |
| GET | `/api/residences/:id/users/` | List users |
| DELETE | `/api/residences/:id/users/:userId/` | Remove user |
---
## 2. Contractor Sharing
### .casera File Export/Import
**Export**:
- Button on contractor detail: "Share Contractor"
- Generates JSON file with `{ type: "contractor", name: "...", phone: "...", ... }`
- Browser downloads the file
**Import**:
- Button on contractor list: "Import Contractor"
- File picker or drag-and-drop for .casera file
- Read JSON, show confirmation dialog, create contractor
- `POST /api/contractors/import/`
---
## 3. Subscription
### Screens
| Route | Screen | Description |
|-------|--------|-------------|
| `/app/settings/subscription` | Subscription Status | Current plan, usage, upgrade CTA |
### Status Display
```
┌─────────────────────────────────────────┐
│ Current Plan: Free │
│ Residences: 1/1 Tasks: 5/5 │
│ │
│ ┌─────────────────────────────────┐ │
│ │ Upgrade to Premium │ │
│ │ • Unlimited residences │ │
│ │ • Unlimited tasks │ │
│ │ • Document storage │ │
│ │ • Priority support │ │
│ │ [Upgrade on App Store] │ │
│ └─────────────────────────────────┘ │
└─────────────────────────────────────────┘
```
### Feature Gating
Check tier limits before allowing creation:
- If at limit → show upgrade prompt dialog instead of create form
- Limits come from `GET /api/subscription/status/` response
### Upgrade Path (Phase 1)
Web users are directed to the mobile app for purchases:
- "Download Casera on the App Store to upgrade"
- Link to App Store listing
### Upgrade Path (Future — Phase 2)
Add Stripe Checkout for web-only subscription purchases. Requires backend work to support Stripe webhooks alongside StoreKit/Play Store.
### API Endpoints
| Method | Endpoint | Purpose |
|--------|----------|---------|
| GET | `/api/subscription/status/` | Current subscription + limits |
| GET | `/api/upgrade-triggers/` | Feature comparison data |
---
## 4. Notification Preferences
### Screen
| Route | Screen | Description |
|-------|--------|-------------|
| `/app/settings/notifications` | Notification Preferences | Toggle + time picker per type |
### Preference Types
| Preference | Description | Controls |
|------------|-------------|----------|
| Task Reminders | Reminders for upcoming due dates | Toggle on/off + time of day |
| Overdue Alerts | Alerts when tasks become overdue | Toggle on/off |
| Completion Confirmations | Notifications when tasks are completed | Toggle on/off |
| Residence Updates | When someone else modifies your residence | Toggle on/off |
### API Endpoints
| Method | Endpoint | Purpose |
|--------|----------|---------|
| GET | `/api/notifications/preferences/` | Get all preferences |
| PUT | `/api/notifications/preferences/:id/` | Update a preference |
| GET | `/api/notifications/` | List notifications (in-app inbox) |
| POST | `/api/notifications/:id/read/` | Mark notification as read |
### In-App Notification Inbox
- Bell icon in top bar with unread count badge
- Dropdown panel showing recent notifications
- Click to navigate to related item
- "Mark all as read" action
---
## 5. Profile
### Screen
| Route | Screen | Description |
|-------|--------|-------------|
| `/app/settings/profile` | Profile Settings | Edit name, email, password, delete account |
### Sections
**Personal Info:**
- First name, Last name, Email
- `PUT /api/auth/profile/`
**Change Password:**
- Current password, New password, Confirm new password
- `POST /api/auth/change-password/`
**Danger Zone:**
- Delete account button with confirmation dialog
- `DELETE /api/auth/account/`
**Theme:**
- Theme picker (11 options, preview swatches)
- Dark/light mode toggle
- Persisted to localStorage
---
## 6. Onboarding
### Screen
| Route | Screen | Description |
|-------|--------|-------------|
| `/onboarding` | Onboarding Wizard | Multi-step flow after first registration |
### Flow
```
Step 1: Welcome
→ "Welcome to Casera! Let's set up your first property."
Step 2: Choose Path
→ "Create a new residence" OR "Join an existing residence"
Path A: Create Residence
Step 3a: Residence form (name, address, type)
Step 4a: "Add your first task?" (optional quick-add)
Step 5a: Done → redirect to residence detail
Path B: Join Residence
Step 3b: Enter share code
Step 4b: Success → redirect to residence detail
```
### State Management
Use Zustand store to track onboarding progress:
```typescript
interface OnboardingState {
currentStep: number;
path: 'create' | 'join' | null;
residenceData: Partial<CreateResidenceRequest>;
isComplete: boolean;
nextStep: () => void;
prevStep: () => void;
setPath: (path: 'create' | 'join') => void;
complete: () => void;
}
```
After completion, set a flag so onboarding isn't shown again:
- `POST /api/auth/profile/` with `onboarding_complete: true`
- Or store in localStorage as fallback
---
## 7. Summary Metrics
### Dashboard (Home Page)
```
┌──────────────────────────────────────────────────────┐
│ Welcome back, Trey │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌─────────┐│
│ │ 3 │ │ 2 │ │ 12 │ │ 5 ││
│ │ Overdue │ │ Due Today│ │ Active │ │ Done ││
│ │ 🔴 │ │ 🟠 │ │ 🔵 │ │ ✅ ││
│ └──────────┘ └──────────┘ └──────────┘ └─────────┘│
│ │
│ ┌─────────────────────────────────────────────────┐│
│ │ Task Completion Trend (last 30 days) ││
│ │ [Area chart via Recharts] ││
│ └─────────────────────────────────────────────────┘│
│ │
│ Recent Activity │
│ • Task "Fix leak" completed 2h ago │
│ • New contractor "Bob's Plumbing" added yesterday │
│ • Residence "Main House" updated 3d ago │
│ │
└──────────────────────────────────────────────────────┘
```
Data sources:
- Task counts from `GET /api/tasks/` response (column counts)
- Summary calculated client-side from kanban data (same as mobile)
- Charts via Recharts library
---
## 8. Task Reports
### Feature
- Button on residence detail: "Download Task Report"
- `GET /api/residences/:id/report/` → returns PDF
- Browser triggers download
---
## Deliverables
At the end of Phase 3, you should have:
1. Residence sharing with code generation, join flow, and user management
2. Contractor sharing via .casera files
3. Subscription status with tier limits and upgrade prompts
4. Notification preferences with toggles
5. Profile editing (name, email, password, delete account)
6. Onboarding wizard for new users
7. Home dashboard with summary metrics and charts
8. Task report PDF download
+360
View File
@@ -0,0 +1,360 @@
# Phase 4 — Demo Mode
Build a fully sandboxed demo experience with realistic mock data and no backend dependency.
## Checklist
- [ ] Mock data seeds (2 residences, 15 tasks across columns, 5 contractors, 8 documents)
- [ ] DemoDataProvider implementing same interface as real API client
- [ ] Session-scoped Zustand store (resets on close)
- [ ] All CRUD operations work against in-memory store
- [ ] Demo banner + "Sign Up" CTA
- [ ] Demo landing page with feature preview
---
## Architecture
```
User visits /demo
→ Server creates a session ID (cookie, no account needed)
→ App loads with pre-built mock data (realistic residences, tasks, contractors, docs)
→ All CRUD operations work against an in-memory store scoped to that session
→ No backend API calls — everything is client-side
→ Store resets on session expiry (or browser close)
→ Banner: "You're in demo mode — Sign up to save your data"
→ "Sign Up" converts session → real account (data NOT migrated, clean start)
```
### Context Switch
A `DataProvider` context determines whether the app uses real API calls or demo data:
```typescript
// src/lib/demo/demo-context.tsx
interface DataProvider {
residences: {
list: () => Promise<Residence[]>;
get: (id: number) => Promise<ResidenceDetail>;
create: (data: CreateResidenceRequest) => Promise<Residence>;
update: (id: number, data: UpdateResidenceRequest) => Promise<Residence>;
delete: (id: number) => Promise<void>;
};
tasks: { /* same pattern */ };
contractors: { /* same pattern */ };
documents: { /* same pattern */ };
}
const DataProviderContext = createContext<DataProvider>(realApiProvider);
export function DemoProvider({ children }: { children: ReactNode }) {
const demoStore = useDemoStore();
const demoProvider = createDemoProvider(demoStore);
return (
<DataProviderContext.Provider value={demoProvider}>
<DemoBanner />
{children}
</DataProviderContext.Provider>
);
}
export function useDataProvider() {
return useContext(DataProviderContext);
}
```
### Layout Integration
```
src/app/(demo)/layout.tsx → wraps children in <DemoProvider>
src/app/(app)/layout.tsx → wraps children in <RealProvider> (or just uses default)
```
Both route groups share the same page components — the data provider abstraction means zero code duplication.
---
## 1. Mock Data Seeds
### Residences (2)
```typescript
const mockResidences = [
{
id: 1,
name: "Maple Street House",
address: "142 Maple Street, Austin, TX",
residenceType: "House",
taskSummary: { overdue: 2, dueToday: 1, dueSoon: 3, upcoming: 5, completed: 8 },
contractorCount: 3,
documentCount: 5,
},
{
id: 2,
name: "Downtown Apartment",
address: "800 Congress Ave #412, Austin, TX",
residenceType: "Apartment",
taskSummary: { overdue: 0, dueToday: 1, dueSoon: 2, upcoming: 3, completed: 4 },
contractorCount: 2,
documentCount: 3,
},
];
```
### Tasks (15 across columns)
Realistic tasks distributed across kanban columns:
| Column | Count | Examples |
|--------|-------|---------|
| Overdue | 2 | "Replace smoke detector batteries" (due 2 weeks ago), "Clean gutters" (due 1 month ago) |
| Due Today | 2 | "HVAC filter replacement", "Test garage door opener" |
| Due Soon | 3 | "Power wash driveway" (5 days), "Inspect roof" (2 weeks), "Service water heater" (3 weeks) |
| Upcoming | 4 | Various maintenance tasks due 1-3 months out |
| In Progress | 1 | "Paint master bedroom" (in progress since yesterday) |
| Completed | 3 | "Fix kitchen faucet leak", "Install new doorbell", "Reseal bathroom grout" |
Each task has realistic:
- Categories (Maintenance, Cleaning, Repair, etc.)
- Priorities (High, Medium, Low)
- Estimated costs ($25 - $500)
- Some assigned to contractors
- Completed tasks have completion records with notes
### Contractors (5)
```typescript
const mockContractors = [
{ id: 1, name: "Bob Martinez", company: "Bob's Plumbing", phone: "(512) 555-0101", email: "bob@bobsplumbing.com", specialty: "Plumbing", isFavorite: true },
{ id: 2, name: "Sarah Chen", company: "Chen Electric", phone: "(512) 555-0202", email: "sarah@chenelectric.com", specialty: "Electrical", isFavorite: true },
{ id: 3, name: "Mike Johnson", company: null, phone: "(512) 555-0303", email: null, specialty: "General Handyman", isFavorite: false },
{ id: 4, name: "Lisa Park", company: "Park Landscaping", phone: "(512) 555-0404", email: "lisa@parklandscaping.com", specialty: "Landscaping", isFavorite: false },
{ id: 5, name: "James Wilson", company: "Wilson HVAC", phone: "(512) 555-0505", email: "james@wilsonhvac.com", specialty: "HVAC", isFavorite: true },
];
```
### Documents (8)
Mix of warranties and general documents:
| Type | Count | Examples |
|------|-------|---------|
| Warranty | 4 | "Samsung Refrigerator Warranty" (expires 2027), "Roof Warranty" (expires 2035), "HVAC System Warranty" (expired 2024), "Dishwasher Warranty" (expires 2026) |
| Document | 4 | "Home Insurance Policy", "Property Tax Statement 2025", "HOA Agreement", "Electrical Inspection Certificate" |
### Lookups
Pre-loaded categories, priorities, frequencies, specialties — same as production static data.
---
## 2. DemoDataProvider
```typescript
// src/lib/demo/demo-provider.ts
export function createDemoProvider(store: DemoStore): DataProvider {
return {
residences: {
list: async () => store.getResidences(),
get: async (id) => store.getResidence(id),
create: async (data) => store.createResidence(data),
update: async (id, data) => store.updateResidence(id, data),
delete: async (id) => store.deleteResidence(id),
},
tasks: {
list: async () => store.getTasks(),
create: async (data) => store.createTask(data),
complete: async (id, data) => store.completeTask(id, data),
// ... all task actions
},
contractors: { /* ... */ },
documents: { /* ... */ },
};
}
```
All operations are synchronous under the hood (wrapped in async for interface compatibility). They:
1. Validate input (same Zod schemas as real forms)
2. Generate auto-increment IDs
3. Update the Zustand store
4. Return the created/updated object
---
## 3. Session-Scoped Zustand Store
```typescript
// src/lib/demo/demo-store.ts
interface DemoStore {
// Data
residences: Residence[];
tasks: Task[];
contractors: Contractor[];
documents: Document[];
completions: TaskCompletion[];
lookups: StaticData;
// Auto-increment IDs
nextIds: { residence: number; task: number; contractor: number; document: number };
// CRUD operations
createResidence: (data: CreateResidenceRequest) => Residence;
updateResidence: (id: number, data: UpdateResidenceRequest) => Residence;
deleteResidence: (id: number) => void;
// ... same for all domains
// Task-specific
completeTask: (id: number, data: TaskCompletionRequest) => Task;
markInProgress: (id: number) => Task;
cancelTask: (id: number) => Task;
archiveTask: (id: number) => Task;
// Reset
reset: () => void;
}
export const useDemoStore = create<DemoStore>((set, get) => ({
residences: mockResidences,
tasks: mockTasks,
contractors: mockContractors,
documents: mockDocuments,
completions: mockCompletions,
lookups: mockLookups,
nextIds: { residence: 3, task: 16, contractor: 6, document: 9 },
createResidence: (data) => {
const id = get().nextIds.residence;
const residence = { id, ...data, createdAt: new Date().toISOString() };
set((state) => ({
residences: [...state.residences, residence],
nextIds: { ...state.nextIds, residence: id + 1 },
}));
return residence;
},
// ... other operations
reset: () => set({
residences: mockResidences,
tasks: mockTasks,
contractors: mockContractors,
documents: mockDocuments,
completions: mockCompletions,
nextIds: { residence: 3, task: 16, contractor: 6, document: 9 },
}),
}));
```
### Session Isolation
- Store is created fresh when `/demo` is accessed
- No `persist` middleware — store is pure in-memory
- Closing the browser tab or navigating away clears the store
- Each visitor gets their own Zustand store instance
---
## 4. Demo Banner
Persistent banner at the top of the demo app:
```
┌─────────────────────────────────────────────────────────────────┐
│ 🎯 You're exploring Casera in demo mode. [Sign Up Free] │
│ Changes aren't saved. Create an account to get started! │
└─────────────────────────────────────────────────────────────────┘
```
- Fixed at top, doesn't scroll
- "Sign Up Free" links to `/register`
- Subtle, non-intrusive (light background, small text)
- Can be temporarily dismissed (per session only)
---
## 5. Demo Landing Page
### Route: `/demo`
Before entering the demo, show a brief preview page:
```
┌─────────────────────────────────────────────────────┐
│ │
│ 🏠 Try Casera — No Account Needed │
│ │
│ Manage your home maintenance, track tasks, │
│ organize contractors, and store documents. │
│ │
│ [Start Demo →] │
│ │
│ ──────────────────────────────────────── │
│ │
│ Preview: │
│ [Screenshot of kanban board] │
│ [Screenshot of residence detail] │
│ │
│ Already have an account? [Log In] │
│ │
└─────────────────────────────────────────────────────┘
```
Clicking "Start Demo" initializes the demo store and redirects to `/demo/residences`.
---
## 6. TanStack Query Integration
In demo mode, TanStack Query hooks still work — they just call the demo provider instead of real API:
```typescript
// src/lib/hooks/useResidences.ts
export function useResidences() {
const provider = useDataProvider();
return useQuery({
queryKey: ['residences'],
queryFn: () => provider.residences.list(),
staleTime: Infinity, // Demo data never goes stale
});
}
```
Mutations also work through the provider:
```typescript
export function useCreateResidence() {
const provider = useDataProvider();
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateResidenceRequest) => provider.residences.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['residences'] });
},
});
}
```
---
## Key Design Decisions
| Decision | Rationale |
|----------|-----------|
| No backend calls in demo | Zero infrastructure cost, no rate limiting, instant responses, no auth needed |
| Session-scoped (no persistence) | Prevents demo data from accumulating, ensures fresh experience each visit |
| Same components as real app | Zero code duplication, demo accurately represents the product |
| Provider pattern (not if/else) | Clean separation, easy to test, no demo logic leaking into components |
| Data NOT migrated on signup | Avoids edge cases with ID conflicts, mock data quality issues |
## Deliverables
At the end of Phase 4, you should have:
1. Realistic mock data covering all 4 domains
2. Fully functional CRUD in demo mode (create, edit, delete tasks/residences/etc.)
3. Demo banner with sign-up CTA
4. Landing page with preview
5. Zero backend dependency for the demo experience
6. Same UI components used for both demo and real app
+399
View File
@@ -0,0 +1,399 @@
# Phase 5 — Polish & Deploy
Responsive design, error handling, deployment, testing, and performance optimization.
## Checklist
- [ ] Responsive design (mobile-first, works on phone browsers too)
- [ ] Loading states, empty states, error handling for every screen
- [ ] Dockerfile + Dokku deployment config
- [ ] E2E tests (Playwright) for critical paths
- [ ] Performance optimization (code splitting, image optimization)
- [ ] SEO + Open Graph meta tags for marketing/demo pages
- [ ] Accessibility audit (keyboard navigation, screen readers, ARIA labels)
- [ ] Analytics integration (PostHog)
---
## 1. Responsive Design
### Breakpoint Strategy
| Breakpoint | Target | Layout |
|------------|--------|--------|
| `<640px` | Mobile phones | Bottom tab bar, stacked cards, full-width forms |
| `640-1023px` | Tablets | Collapsed sidebar (icons only), 2-column grids |
| `≥1024px` | Desktop | Full sidebar, 3-column grids, side panels |
### Mobile Adaptations
- **Navigation**: Bottom tab bar replaces sidebar (matches iOS app experience)
- **Kanban board**: Horizontal scroll with swipeable columns (single column visible at a time on small screens)
- **Cards**: Full-width stacked layout
- **Forms**: Single column, full-width inputs
- **Tables**: Convert to card-based layout on mobile
- **Dialogs**: Full-screen on mobile, centered modal on desktop
### Implementation
Use Tailwind responsive prefixes consistently:
```tsx
// Grid that adapts from 1 → 2 → 3 columns
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{residences.map(r => <ResidenceCard key={r.id} residence={r} />)}
</div>
// Sidebar visibility
<aside className="hidden lg:flex lg:w-64 ..."> {/* Desktop sidebar */}
<nav className="lg:hidden fixed bottom-0 ..."> {/* Mobile bottom bar */}
```
---
## 2. Loading, Empty, and Error States
### Loading States
Every data-dependent component must show a loading skeleton:
```tsx
function ResidenceList() {
const { data, isLoading, error } = useResidences();
if (isLoading) return <ResidenceListSkeleton />;
if (error) return <ErrorBanner error={error} onRetry={() => refetch()} />;
if (!data?.length) return <EmptyState icon="building" title="No residences" cta="Add Residence" />;
return <div>{data.map(r => <ResidenceCard key={r.id} residence={r} />)}</div>;
}
```
### Skeleton Components
Use shadcn/ui `Skeleton` for consistent loading states:
- Card skeletons for list pages
- Form skeletons for detail pages
- Kanban column skeletons for task board
### Empty States
Every list has a meaningful empty state:
| Screen | Icon | Title | Subtitle | CTA |
|--------|------|-------|----------|-----|
| Residences | Building | No residences yet | Add your first property to get started | Add Residence |
| Tasks | CheckSquare | No tasks | Create a task to start tracking maintenance | Add Task |
| Contractors | HardHat | No contractors | Save your trusted service providers | Add Contractor |
| Documents | FileText | No documents | Store important home documents | Add Document |
### Error Handling
```typescript
// Global error boundary
// src/app/error.tsx
export default function Error({ error, reset }: { error: Error; reset: () => void }) {
return (
<div className="flex flex-col items-center justify-center min-h-[400px]">
<AlertCircle className="h-12 w-12 text-destructive" />
<h2 className="mt-4 text-lg font-semibold">Something went wrong</h2>
<p className="mt-2 text-muted-foreground">{error.message}</p>
<Button onClick={reset} className="mt-4">Try Again</Button>
</div>
);
}
```
### Toast Notifications
Use shadcn/ui `Toaster` for success/error feedback:
- "Residence created successfully" (green)
- "Task completed" (green)
- "Failed to save changes" (red)
- "Connection lost — retrying..." (yellow)
---
## 3. Dockerfile + Deployment
### Dockerfile
```dockerfile
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Production stage
FROM node:20-alpine AS production
WORKDIR /app
# Create non-root user
RUN addgroup -g 1001 nodejs && adduser -u 1001 -G nodejs -s /bin/sh -D nextjs
# Copy standalone build
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
CMD curl -f http://localhost:3000/ || exit 1
CMD ["node", "server.js"]
```
### next.config.ts
```typescript
const nextConfig = {
output: 'standalone', // Required for Docker
images: {
remotePatterns: [
{ protocol: 'https', hostname: 'mycrib.treytartt.com' }, // API media
],
},
};
```
### Dokku Deployment
```bash
# On mycribDev server
dokku apps:create casera-web
dokku domains:add casera-web app.casera.treytartt.com
dokku config:set casera-web NEXT_PUBLIC_API_URL=https://mycrib.treytartt.com/api
dokku letsencrypt:enable casera-web
# Deploy
git remote add dokku-web dokku@mycribDev:casera-web
git push dokku-web main
```
### Environment Variables
| Variable | Description | Required |
|----------|-------------|----------|
| `NEXT_PUBLIC_API_URL` | Go API base URL | Yes |
| `NEXT_PUBLIC_POSTHOG_KEY` | PostHog project key | No |
| `NEXT_PUBLIC_POSTHOG_HOST` | PostHog host URL | No |
| `PORT` | Server port (default: 3000) | No |
---
## 4. E2E Tests (Playwright)
### Critical Path Tests
```
tests/
├── auth.spec.ts # Login, register, logout
├── residences.spec.ts # Create, view, edit, delete residence
├── tasks.spec.ts # Create task, kanban drag, complete task
├── contractors.spec.ts # Create, view, edit, delete contractor
├── documents.spec.ts # Create, upload, view, delete document
├── demo.spec.ts # Demo mode flows (no backend)
└── responsive.spec.ts # Mobile/tablet viewport tests
```
### Test Structure
```typescript
// tests/auth.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Authentication', () => {
test('login with valid credentials', async ({ page }) => {
await page.goto('/login');
await page.fill('[name="email"]', 'test@example.com');
await page.fill('[name="password"]', 'password123');
await page.click('button[type="submit"]');
await expect(page).toHaveURL('/app/residences');
});
test('shows error for invalid credentials', async ({ page }) => {
await page.goto('/login');
await page.fill('[name="email"]', 'bad@example.com');
await page.fill('[name="password"]', 'wrong');
await page.click('button[type="submit"]');
await expect(page.locator('[role="alert"]')).toBeVisible();
});
});
```
### Demo Mode Tests (No Backend)
```typescript
// tests/demo.spec.ts
test.describe('Demo Mode', () => {
test('loads with mock data', async ({ page }) => {
await page.goto('/demo');
await page.click('text=Start Demo');
await expect(page.locator('text=Maple Street House')).toBeVisible();
});
test('can create a task in demo mode', async ({ page }) => {
await page.goto('/demo/tasks');
await page.click('text=Add Task');
await page.fill('[name="title"]', 'Test Demo Task');
await page.click('button[type="submit"]');
await expect(page.locator('text=Test Demo Task')).toBeVisible();
});
});
```
### Playwright Config
```typescript
// playwright.config.ts
export default defineConfig({
testDir: './tests',
use: {
baseURL: 'http://localhost:3000',
screenshot: 'only-on-failure',
trace: 'on-first-retry',
},
projects: [
{ name: 'Desktop Chrome', use: { ...devices['Desktop Chrome'] } },
{ name: 'Mobile Safari', use: { ...devices['iPhone 14'] } },
{ name: 'Tablet', use: { viewport: { width: 768, height: 1024 } } },
],
});
```
---
## 5. Performance Optimization
### Code Splitting
- Dynamic imports for heavy components:
```typescript
const KanbanBoard = dynamic(() => import('@/components/kanban/KanbanBoard'), {
loading: () => <KanbanSkeleton />,
});
const RechartsDashboard = dynamic(() => import('@/components/dashboard/Charts'));
```
### Image Optimization
- Use Next.js `<Image>` component for all images
- API media served through Next.js image optimization proxy
- Lazy load images below the fold
### Bundle Analysis
```bash
npm install -D @next/bundle-analyzer
# Analyze with: ANALYZE=true npm run build
```
### TanStack Query Optimization
- `staleTime: 60 * 60 * 1000` (1 hour) for most queries
- `refetchOnWindowFocus: true` for fresh data when user returns
- `keepPreviousData: true` for smooth pagination/filtering transitions
- Prefetch on hover for navigation links
---
## 6. SEO + Open Graph
Marketing and demo pages need proper meta tags:
```typescript
// src/app/(marketing)/page.tsx
export const metadata: Metadata = {
title: 'Casera — Home Maintenance Made Simple',
description: 'Track tasks, organize contractors, store documents. Manage your home maintenance in one place.',
openGraph: {
title: 'Casera — Home Maintenance Made Simple',
description: 'Track tasks, organize contractors, store documents.',
images: ['/og-image.png'],
type: 'website',
},
twitter: {
card: 'summary_large_image',
title: 'Casera',
description: 'Home Maintenance Made Simple',
images: ['/og-image.png'],
},
};
```
---
## 7. Accessibility
### Requirements
- All interactive elements keyboard accessible
- Proper ARIA labels on icons and buttons
- Focus management on modals and route changes
- Color contrast meets WCAG 2.1 AA
- Screen reader compatible forms with error announcements
### Testing
```bash
# axe-core integration
npm install -D @axe-core/playwright
# In tests:
import AxeBuilder from '@axe-core/playwright';
const results = await new AxeBuilder({ page }).analyze();
expect(results.violations).toEqual([]);
```
---
## 8. Analytics (PostHog)
```typescript
// src/lib/analytics.ts
import posthog from 'posthog-js';
export function initAnalytics() {
if (typeof window !== 'undefined' && process.env.NEXT_PUBLIC_POSTHOG_KEY) {
posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY, {
api_host: process.env.NEXT_PUBLIC_POSTHOG_HOST || 'https://analytics.88oakapps.com',
});
}
}
export function trackEvent(event: string, properties?: Record<string, any>) {
posthog.capture(event, properties);
}
export function trackScreen(screenName: string) {
posthog.capture('$pageview', { $current_url: screenName });
}
```
Track same events as mobile app for consistent analytics.
---
## Deliverables
At the end of Phase 5, you should have:
1. Fully responsive web app (mobile, tablet, desktop)
2. Consistent loading, empty, and error states everywhere
3. Deployed on Dokku at `app.casera.treytartt.com`
4. E2E tests covering auth, CRUD, demo mode, and responsive viewports
5. Optimized bundle with code splitting and image optimization
6. SEO-ready marketing and demo pages
7. Accessible UI meeting WCAG 2.1 AA
8. PostHog analytics tracking
File diff suppressed because it is too large Load Diff
+781
View File
@@ -0,0 +1,781 @@
# Phase 3 — Advanced Features Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Build sharing, subscriptions, notifications, profile settings, onboarding wizard, summary metrics with charts, and task report PDF download.
**Architecture:** A shared foundation layer (hooks, settings hub, notification bell) is built first, then 5 domain agents run concurrently on independent feature areas. Each agent produces pages and components that compile independently.
**Tech Stack:** Next.js 16 (App Router), TanStack Query v5, React Hook Form + Zod 4, shadcn/ui + Radix, Tailwind CSS 4, Recharts (new), Zustand, Lucide icons.
---
## Execution Strategy: 7 Tasks, 5 Run in Parallel
```
Task 1: Shared Foundation (sequential — all features depend on this)
Task 2: Sharing (residence + contractor) ─┐
Task 3: Profile Settings ─┤
Task 4: Notifications + Subscription ─┤ ← 5 parallel agents
Task 5: Onboarding Wizard ─┤
Task 6: Dashboard + Task Reports ─┘
Task 7: Integration + Verification (sequential — cross-feature wiring)
```
**Verification gate:** Each task ends with `npm run build`. No task is complete until it compiles clean.
---
## Task 1: Shared Foundation
**Files:**
- Create: `src/lib/hooks/use-notifications.ts`
- Create: `src/lib/hooks/use-subscription.ts`
- Create: `src/components/notifications/notification-bell.tsx`
- Create: `src/app/app/settings/layout.tsx`
- Modify: `src/app/app/settings/page.tsx` (overwrite stub → settings hub)
- Modify: `src/components/layout/top-bar.tsx` (add notification bell)
- Modify: `src/lib/hooks/index.ts` (add new hook exports)
### Step 1: Install Recharts
```bash
cd /Users/treyt/Desktop/code/MyCrib/myCribAPI-Web
npm install recharts
```
### Step 2: Create notification hooks
```typescript
// src/lib/hooks/use-notifications.ts
"use client";
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import * as notificationsApi from '@/lib/api/notifications';
import type { UpdatePreferencesRequest } from '@/lib/api/notifications';
export function useNotifications(limit?: number) {
return useQuery({
queryKey: ['notifications', limit],
queryFn: () => notificationsApi.listNotifications(limit),
});
}
export function useUnreadCount() {
return useQuery({
queryKey: ['notifications', 'unread-count'],
queryFn: () => notificationsApi.getUnreadCount(),
refetchInterval: 60_000, // Poll every minute
});
}
export function useNotificationPreferences() {
return useQuery({
queryKey: ['notifications', 'preferences'],
queryFn: () => notificationsApi.getPreferences(),
});
}
export function useUpdatePreferences() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: UpdatePreferencesRequest) => notificationsApi.updatePreferences(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['notifications', 'preferences'] });
},
});
}
export function useMarkAsRead() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => notificationsApi.markAsRead(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['notifications'] });
queryClient.invalidateQueries({ queryKey: ['notifications', 'unread-count'] });
},
});
}
export function useMarkAllAsRead() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => notificationsApi.markAllAsRead(),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['notifications'] });
queryClient.invalidateQueries({ queryKey: ['notifications', 'unread-count'] });
},
});
}
```
### Step 3: Create subscription hooks
```typescript
// src/lib/hooks/use-subscription.ts
"use client";
import { useQuery } from '@tanstack/react-query';
import * as subscriptionApi from '@/lib/api/subscription';
export function useSubscriptionStatus() {
return useQuery({
queryKey: ['subscription', 'status'],
queryFn: () => subscriptionApi.getSubscriptionStatus(),
});
}
export function useFeatureBenefits() {
return useQuery({
queryKey: ['subscription', 'features'],
queryFn: () => subscriptionApi.getFeatureBenefits(),
staleTime: Infinity,
});
}
export function useUpgradeTriggers() {
return useQuery({
queryKey: ['subscription', 'upgrade-triggers'],
queryFn: () => subscriptionApi.getAllUpgradeTriggers(),
staleTime: Infinity,
});
}
```
### Step 4: Create NotificationBell component
```tsx
// src/components/notifications/notification-bell.tsx
"use client";
import { useState } from "react";
import { Bell } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Badge } from "@/components/ui/badge";
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>
);
}
```
### Step 5: Modify TopBar to include notification bell
In `src/components/layout/top-bar.tsx`, add `<NotificationBell />` before the profile dropdown. Import from `@/components/notifications/notification-bell`.
Add a `<div className="flex items-center gap-2">` wrapper around the bell and avatar dropdown.
### Step 6: Create settings layout with sidebar navigation
```tsx
// src/app/app/settings/layout.tsx
"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>
);
}
```
### Step 7: Overwrite settings page stub → redirect to profile
```tsx
// src/app/app/settings/page.tsx
import { redirect } from "next/navigation";
export default function SettingsPage() {
redirect("/app/settings/profile");
}
```
### Step 8: Update hooks barrel export
Add to `src/lib/hooks/index.ts`:
```typescript
export * from './use-notifications';
export * from './use-subscription';
```
### Step 9: Verify build
```bash
cd /Users/treyt/Desktop/code/MyCrib/myCribAPI-Web && npm run build
```
### Step 10: Commit
```bash
git add -A && git commit -m "feat: add Phase 3 foundation — notification hooks, subscription hooks, settings layout, notification bell"
```
---
## Task 2: Sharing (Residence + Contractor)
**Depends on:** Task 1
**Files:**
- Create: `src/app/app/residences/[id]/share/page.tsx`
- Create: `src/app/app/residences/join/page.tsx`
- Create: `src/components/sharing/share-code-display.tsx`
- Create: `src/components/sharing/user-management.tsx`
- Create: `src/components/sharing/casera-file-handler.tsx`
- Create: `src/lib/hooks/use-sharing.ts`
- Modify: `src/app/app/residences/[id]/page.tsx` (add Share button)
- Modify: `src/app/app/contractors/[id]/page.tsx` (add Share/Export button)
- Modify: `src/app/app/contractors/page.tsx` (add Import button)
### Residence Sharing Components
**share-code-display.tsx** — Displays share code with copy button and expiry countdown:
- If no active code: "Generate Share Code" button → calls `POST /residences/:id/generate-share-code/`
- If active code: displays code in large monospace text, copy-to-clipboard button, expiry timer
- Uses existing `residencesApi.generateShareCode()` and `residencesApi.getShareCode()`
**user-management.tsx** — Lists residence users with remove action:
- Uses `residencesApi.getResidenceUsers(id)` to fetch user list
- Each user row: name, email, role badge (Owner/Member)
- Owner can click "Remove" → ConfirmDialog → `residencesApi.removeResidenceUser(residenceId, userId)`
### .casera File Handler
**casera-file-handler.tsx** — Reusable component for both residence and contractor sharing:
- **Export mode**: Takes data object, generates JSON `{ type: "residence"|"contractor", ... }`, triggers browser download as `.casera` file
- **Import mode**: FileUpload drop zone accepting `.casera` files, reads JSON, validates type, calls callback with parsed data
- Uses `URL.createObjectURL` + anchor click for download
- Uses `FileReader.readAsText` for import
### Pages
**Residence Share page** (`/app/residences/[id]/share`):
- ShareCodeDisplay for generating/displaying share codes
- .casera export button → downloads file with share code embedded
- UserManagement table
- Only accessible to residence owner
**Residence Join page** (`/app/residences/join`):
- Text input for manual code entry
- .casera file import drop zone
- On submit: calls `residencesApi.joinWithCode()` → redirect to residence detail
### Contractor Sharing
On contractor detail page, add "Share" button → generates .casera file with contractor data for download.
On contractor list page, add "Import" button → opens import dialog → reads .casera file → creates contractor via API.
### Query hooks
```typescript
// src/lib/hooks/use-sharing.ts
"use client";
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import * as residencesApi from '@/lib/api/residences';
export function useShareCode(residenceId: number) {
return useQuery({
queryKey: ['residences', residenceId, 'share-code'],
queryFn: () => residencesApi.getShareCode(residenceId),
enabled: !!residenceId,
});
}
export function useGenerateShareCode(residenceId: number) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => residencesApi.generateShareCode(residenceId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['residences', residenceId, 'share-code'] });
},
});
}
export function useResidenceUsers(residenceId: number) {
return useQuery({
queryKey: ['residences', residenceId, 'users'],
queryFn: () => residencesApi.getResidenceUsers(residenceId),
enabled: !!residenceId,
});
}
export function useRemoveResidenceUser(residenceId: number) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (userId: number) => residencesApi.removeResidenceUser(residenceId, userId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['residences', residenceId, 'users'] });
},
});
}
export function useJoinResidence() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (code: string) => residencesApi.joinWithCode({ code }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['residences'] });
},
});
}
```
### Verify build
```bash
cd /Users/treyt/Desktop/code/MyCrib/myCribAPI-Web && npm run build
```
### Commit
```bash
git add -A && git commit -m "feat: add residence sharing (share code, join, user management) and contractor .casera export/import"
```
---
## Task 3: Profile Settings
**Depends on:** Task 1
**Files:**
- Create: `src/app/app/settings/profile/page.tsx`
- Create: `src/components/settings/profile-form.tsx`
- Create: `src/components/settings/change-password-form.tsx`
- Create: `src/components/settings/delete-account-section.tsx`
- Create: `src/components/settings/theme-picker.tsx`
- Modify: `src/lib/api/auth.ts` (add changePassword and deleteAccount functions if missing)
### API additions needed in auth.ts
Check if these exist, add if missing:
```typescript
export async function changePassword(data: { current_password: string; new_password: string }): Promise<MessageResponse> {
const res = await fetch('/api/proxy/auth/change-password/', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Timezone': timezone() },
body: JSON.stringify(data),
});
return handleResponse<MessageResponse>(res);
}
export async function deleteAccount(): Promise<MessageResponse> {
const res = await fetch('/api/proxy/auth/delete-account/', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json', 'X-Timezone': timezone() },
});
return handleResponse<MessageResponse>(res);
}
```
### Components
**profile-form.tsx** — Edit first name, last name, email:
- Zod schema: first_name (required), last_name (required), email (required, valid email)
- Pre-fills from current user (from auth store)
- On submit: calls `authApi.updateProfile()` → updates auth store user
- Success toast / inline success message
**change-password-form.tsx** — Change password:
- Zod schema: current_password (required, min 8), new_password (required, min 8), confirm_password (must match new_password)
- On submit: calls `authApi.changePassword()` → clear form on success
**delete-account-section.tsx** — Danger zone:
- Red bordered card with warning text
- "Delete Account" button → ConfirmDialog with "Type DELETE to confirm" pattern
- On confirm: calls `authApi.deleteAccount()` → logout → redirect to login
**theme-picker.tsx** — Visual theme selector:
- Grid of 11 theme swatches (colored circles/squares from theme-config)
- Click to select → applies theme via `useTheme()` hook from existing theme store
- Dark/light toggle using existing theme store
- Read theme config from `src/lib/themes/theme-config.ts`
### Profile page
```tsx
// src/app/app/settings/profile/page.tsx
"use client";
// 4 sections stacked vertically:
// 1. ProfileForm (personal info)
// 2. ChangePasswordForm
// 3. ThemePicker
// 4. DeleteAccountSection (danger zone at bottom)
```
### Verify build
```bash
cd /Users/treyt/Desktop/code/MyCrib/myCribAPI-Web && npm run build
```
### Commit
```bash
git add -A && git commit -m "feat: add profile settings — edit info, change password, theme picker, delete account"
```
---
## Task 4: Notifications + Subscription
**Depends on:** Task 1
**Files:**
- Create: `src/app/app/settings/notifications/page.tsx`
- Create: `src/app/app/settings/subscription/page.tsx`
- Create: `src/components/settings/notification-preferences.tsx`
- Create: `src/components/settings/subscription-status.tsx`
- Create: `src/components/settings/feature-comparison.tsx`
- Create: `src/components/shared/upgrade-prompt.tsx`
### Notification Preferences
**notification-preferences.tsx** — Toggle switches per notification type:
- Uses `useNotificationPreferences()` to load current state
- Each preference row: label, description, toggle switch (shadcn Switch component — install via shadcn if not present)
- On toggle: calls `useUpdatePreferences()` with changed field
- Preference types: Task Reminders, Task Completions, Residence Updates, Share Notifications, Marketing
**Notifications page** (`/app/settings/notifications`):
- NotificationPreferences component
- Section header "Notification Preferences"
- Each toggle saves immediately (optimistic update)
### Subscription
**subscription-status.tsx** — Current plan display:
- Uses `useSubscriptionStatus()` to load tier, limits, usage
- Shows current tier name (Free/Premium)
- Progress bars for usage: residences (X/max), tasks per residence (X/max), contractors (X/max), documents (X/max)
- If free tier: show upgrade CTA section
**feature-comparison.tsx** — Free vs Premium comparison:
- Uses `useFeatureBenefits()` to load feature list
- Two-column comparison table: Free vs Premium
- Check/cross icons per feature
- "Upgrade on App Store" button linking to App Store
**upgrade-prompt.tsx** — Reusable upgrade prompt dialog:
- Shown when user hits a tier limit (e.g., max residences reached)
- Takes `feature` prop (what they're trying to do)
- Shows limit info + upgrade CTA
- Uses shadcn Dialog
- Can be imported by any domain page that needs feature gating
**Subscription page** (`/app/settings/subscription`):
- SubscriptionStatus card at top
- FeatureComparison below
- If premium: show expiry date, management info
### Install Switch component
```bash
npx shadcn@latest add switch
```
### Verify build
```bash
cd /Users/treyt/Desktop/code/MyCrib/myCribAPI-Web && npm run build
```
### Commit
```bash
git add -A && git commit -m "feat: add notification preferences and subscription status with feature comparison"
```
---
## Task 5: Onboarding Wizard
**Depends on:** Task 1
**Files:**
- Create: `src/app/onboarding/page.tsx`
- Create: `src/app/onboarding/layout.tsx`
- Create: `src/components/onboarding/welcome-step.tsx`
- Create: `src/components/onboarding/choose-path-step.tsx`
- Create: `src/components/onboarding/create-residence-step.tsx`
- Create: `src/components/onboarding/first-task-step.tsx`
- Create: `src/components/onboarding/join-residence-step.tsx`
- Create: `src/components/onboarding/complete-step.tsx`
- Create: `src/stores/onboarding.ts`
### Onboarding Zustand Store
```typescript
// src/stores/onboarding.ts
import { create } from "zustand";
interface OnboardingState {
currentStep: number;
path: "create" | "join" | null;
residenceId: number | null;
isComplete: boolean;
nextStep: () => void;
prevStep: () => void;
setPath: (path: "create" | "join") => void;
setResidenceId: (id: number) => void;
complete: () => void;
reset: () => void;
}
export const useOnboardingStore = create<OnboardingState>()((set) => ({
currentStep: 0,
path: null,
residenceId: null,
isComplete: false,
nextStep: () => set((s) => ({ currentStep: s.currentStep + 1 })),
prevStep: () => set((s) => ({ currentStep: Math.max(0, s.currentStep - 1) })),
setPath: (path) => set({ path, currentStep: 2 }),
setResidenceId: (id) => set({ residenceId: id }),
complete: () => set({ isComplete: true }),
reset: () => set({ currentStep: 0, path: null, residenceId: null, isComplete: false }),
}));
```
### Onboarding Layout
- Clean, centered layout (no sidebar or app shell)
- Progress indicator (step dots or progress bar)
- Casera logo at top
### Steps
**Step 0: Welcome** — "Welcome to Casera!" message, illustration, "Get Started" button
**Step 1: Choose Path** — Two cards: "Create a new residence" (House icon) and "Join an existing residence" (Users icon). Clicking sets path and advances.
**Path A — Create:**
- **Step 2a: Create Residence** — Simplified ResidenceForm (name, address, type only). On submit: creates residence via `useCreateResidence()`, stores ID, advances.
- **Step 3a: First Task (optional)** — "Add your first task?" Quick task form (title + due date only) or "Skip" button. On submit: creates task, advances. On skip: advances.
- **Step 4a: Complete** — "You're all set!" Redirect to `/app/residences/${id}`
**Path B — Join:**
- **Step 2b: Join Residence** — Code input (6-char) or .casera file import. On submit: joins via API, advances.
- **Step 3b: Complete** — "Welcome to the residence!" Redirect to residence detail.
### Onboarding trigger
After login/register, check if user has 0 residences → redirect to `/onboarding`.
This is done via middleware or the auth store's login flow.
For now, just mark `localStorage.setItem('onboarding_complete', 'true')` after completion, and check in the app layout.
### Verify build
```bash
cd /Users/treyt/Desktop/code/MyCrib/myCribAPI-Web && npm run build
```
### Commit
```bash
git add -A && git commit -m "feat: add onboarding wizard with create/join paths"
```
---
## Task 6: Enhanced Dashboard + Task Reports
**Depends on:** Task 1
**Files:**
- Modify: `src/app/app/page.tsx` (enhance existing dashboard)
- Create: `src/components/dashboard/task-completion-chart.tsx`
- Create: `src/components/dashboard/recent-activity.tsx`
- Create: `src/components/dashboard/stats-cards.tsx`
- Modify: `src/app/app/residences/[id]/page.tsx` (add "Download Report" button)
### Enhanced Dashboard
Replace the current simple dashboard with the spec's design:
**stats-cards.tsx** — Top row of stat cards:
- 4 cards: Overdue (red), Due Today (orange), Active (blue), Completed (green)
- Data from `useTasks()` kanban response `total_summary` field
- Each card: count, label, colored icon
**task-completion-chart.tsx** — Area chart using Recharts:
- Uses task completion data (from completions API or derived from kanban data)
- Shows last 30 days of completions as an area chart
- `<ResponsiveContainer>` + `<AreaChart>` + `<Area>` from recharts
- Styled with theme colors via CSS variables
- If no completion data available, show "No completion data yet" empty state
**recent-activity.tsx** — Recent activity feed:
- Uses `useNotifications(5)` to show last 5 notifications as activity items
- Each item: icon, title, time ago (relative)
- "View all" link to settings/notifications
- If no notifications: "No recent activity"
### Dashboard page structure
```tsx
// src/app/app/page.tsx (enhanced)
// 1. Welcome message with user name (from auth store)
// 2. StatsCards (4-column grid)
// 3. TaskCompletionChart (full width card)
// 4. RecentActivity (list)
```
### Task Report PDF
On residence detail page (`src/app/app/residences/[id]/page.tsx`), add a "Download Report" button:
- Calls `residencesApi.generateTasksReport(residenceId)`
- The API generates a PDF and emails it (existing behavior)
- Show success message "Report sent to your email"
- Alternative: if API returns a PDF URL, trigger browser download
### Verify build
```bash
cd /Users/treyt/Desktop/code/MyCrib/myCribAPI-Web && npm run build
```
### Commit
```bash
git add -A && git commit -m "feat: add enhanced dashboard with charts and task report PDF download"
```
---
## Task 7: Integration + Verification
**Depends on:** Tasks 2-6
### Step 1: Full build verification
```bash
cd /Users/treyt/Desktop/code/MyCrib/myCribAPI-Web && npm run build
```
### Step 2: Route verification
Confirm all new routes compile:
- `/app/settings/profile`
- `/app/settings/notifications`
- `/app/settings/subscription`
- `/app/residences/[id]/share`
- `/app/residences/join`
- `/onboarding`
### Step 3: Cross-feature integration checks
- TopBar notification bell renders and shows unread count
- Settings hub sidebar navigates between profile/notifications/subscription
- Residence detail has Share and Download Report buttons
- Contractor detail has Share button, list has Import button
- Dashboard shows stats, chart, and recent activity
### Step 4: Commit
```bash
git add -A && git commit -m "feat: complete Phase 3 — advanced features integration verified"
```
---
## Deliverables Checklist
At the end of Phase 3, verify:
- [ ] **Residence sharing**: generate share code, copy, join with code, manage users (list/remove)
- [ ] **Contractor sharing**: .casera file export from detail, import on list page
- [ ] **.casera files**: download as JSON, import via drag-and-drop
- [ ] **Subscription**: status display with usage bars, feature comparison table, upgrade CTA
- [ ] **Feature gating**: upgrade prompt dialog available for tier limit enforcement
- [ ] **Notification preferences**: toggle switches per notification type, saves immediately
- [ ] **In-app notifications**: bell icon in TopBar with unread count badge, dropdown with recent notifications
- [ ] **Profile**: edit name/email form, change password form, delete account with confirmation
- [ ] **Theme picker**: 11 theme swatches + dark/light toggle in profile settings
- [ ] **Onboarding**: multi-step wizard with create/join paths, stores completion state
- [ ] **Dashboard**: enhanced with stats cards, completion trend chart (Recharts), recent activity feed
- [ ] **Task reports**: "Download Report" button on residence detail, triggers PDF generation
- [ ] **Settings hub**: sidebar navigation between profile, notifications, subscription
- [ ] **Build passes**: `npm run build` exits 0
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;
+7
View File
@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;
+13821
View File
File diff suppressed because it is too large Load Diff
+47
View File
@@ -0,0 +1,47 @@
{
"name": "casera-web",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^5.2.2",
"@tanstack/react-query": "^5.90.21",
"@tanstack/react-query-devtools": "^5.91.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"lucide-react": "^0.576.0",
"next": "16.1.6",
"radix-ui": "^1.4.3",
"react": "19.2.3",
"react-day-picker": "^9.14.0",
"react-dom": "19.2.3",
"react-hook-form": "^7.71.2",
"recharts": "^3.7.0",
"tailwind-merge": "^3.5.0",
"zod": "^4.3.6",
"zustand": "^5.0.11"
},
"devDependencies": {
"@playwright/test": "^1.58.2",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.1.6",
"shadcn": "^3.8.5",
"tailwindcss": "^4",
"tw-animate-css": "^1.4.0",
"typescript": "^5",
"vitest": "^4.0.18"
}
}
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
+1
View File
@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

+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>
);
}

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