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:
+41
@@ -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
|
||||||
@@ -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": {}
|
||||||
|
}
|
||||||
@@ -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 |
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
/* config options here */
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
Generated
+13821
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
export default function SettingsPage() {
|
||||||
|
redirect("/app/settings/profile");
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 |
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 />;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
|
];
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
Reference in New Issue
Block a user