Compare commits

...

6 Commits

Author SHA1 Message Date
Trey t 42e7bedea4 Replace hand-rolled auth with Ory Kratos browser flows
The honeyDue Go API no longer owns identity — Ory Kratos at
NEXT_PUBLIC_KRATOS_URL does. Rewrite the web app's auth layer to use Kratos
browser self-service flows and the ory_kratos_session cookie.

- Kratos client (src/lib/kratos/): flow init/fetch/submit, whoami, logout,
  message helpers, and the useKratosFlow lifecycle hook.
- Generic flow renderer (src/components/auth/): KratosFlowForm renders
  ui.nodes (inputs, oidc social buttons, hidden csrf), KratosMessages
  surfaces flow-level messages, AuthGate guards /app via whoami.
- Auth pages (login/register/forgot-password/verify-email/reset-password)
  rewritten as Kratos login/registration/recovery/verification/settings
  flows. Password change in settings now uses the Kratos settings flow.
- Proxy + serverFetch forward the ory_kratos_session cookie to the Go API
  instead of "Authorization: Token". Deleted /api/auth/{login,logout,me}.
- Middleware does a cheap ory_kratos_session cookie pre-filter; AuthGate's
  whoami call is authoritative.
- auth store rewritten around whoami + GET /auth/me; removed dead auth API
  functions, types/auth, validations/auth, code-input.
- Added NEXT_PUBLIC_KRATOS_URL to config (.env.example) and CLAUDE.md.

npm run build passes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 18:16:49 -05:00
Trey t f77f913ee8 Add public help center with 10 topic pages and improve contractor/residence cards
- Create complete help center at /help with getting-started, residences, tasks,
  contractors, documents, sharing, dashboard, notifications, subscription, and
  account pages
- Add shared help components: sidebar, header, footer, article/section wrappers,
  screenshot placeholders, and tip/note/warning callouts
- Add /help to middleware public paths so it loads without auth
- Add Help Center link to landing page footer
- Link contractor import dialog to /help/sharing
- Make contractor cards fully clickable with consistent height
- Add clear button to contractor specialty filter
- Fix residence card height consistency in grid

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 00:10:43 -06:00
Trey t c4d35b3d68 Fix honeycomb grid pattern tiling geometry
Replaces broken overlapping hexagons with proper flat-top honeycomb
tiling using correct polygon coordinates and tile dimensions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 10:33:52 -06:00
Trey t cf9a1de385 Replace square grid with honeycomb pattern to match app icon
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 10:25:38 -06:00
Trey t 9fecb7b4d8 Update logo to new honeycomb icon
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 10:20:06 -06:00
Trey t e2172c20f2 Rebrand from Casera/MyCrib to honeyDue
Total rebrand across Web project:
- Package name: casera-web -> honeydue-web
- Cookie: casera-token -> honeydue-token
- Theme store: casera-theme -> honeydue-theme
- File sharing: .casera -> .honeydue, component/function renames
- casera-file-handler.tsx -> honeydue-file-handler.tsx
- All UI text, metadata, OG tags updated
- Domains: casera.treytartt.com -> honeyDue.treytartt.com
- Demo data emails updated
- All documentation updated

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 06:33:59 -06:00
103 changed files with 3030 additions and 1774 deletions
+19
View File
@@ -0,0 +1,19 @@
# ---------------------------------------------------------------------------
# honeyDue Web — environment variables
# ---------------------------------------------------------------------------
# Copy to `.env.local` and fill in. `.env*` is gitignored.
# honeyDue Go API base URL (client-side, used by the Next.js proxy).
NEXT_PUBLIC_API_URL=https://honeyDue.treytartt.com/api
# honeyDue Go API base URL (server-side; falls back to NEXT_PUBLIC_API_URL).
# API_URL=https://honeyDue.treytartt.com/api
# Ory Kratos public API base URL. Identity (login, registration, recovery,
# verification, settings, social sign-in) is owned by Kratos. The browser
# talks to Kratos self-service flows directly.
NEXT_PUBLIC_KRATOS_URL=https://auth.myhoneydue.com
# PostHog analytics (optional).
# NEXT_PUBLIC_POSTHOG_KEY=
# NEXT_PUBLIC_POSTHOG_HOST=https://analytics.88oakapps.com
+1 -1
View File
@@ -1,3 +1,3 @@
WAIT=10
ATTEMPTS=6
/ Casera
/ honeyDue
+20 -7
View File
@@ -1,6 +1,6 @@
# Casera Web (`myCribAPI-Web`)
# honeyDue Web (`honeyDueAPI-Web`)
Next.js web client for the Casera property management platform. Talks to the Go REST API backend.
Next.js web client for the honeyDue property management platform. Talks to the Go REST API backend.
## Build & Run
@@ -32,17 +32,27 @@ npm run analyze # Bundle analysis
```
Browser → Next.js page (client component)
→ apiFetch("/tasks/") → /api/proxy/tasks (Next.js route handler)
→ Go API (reads casera-token httpOnly cookie, forwards as Authorization header)
→ Go API (reads ory_kratos_session cookie, forwards it as a Cookie header)
```
Auth tokens are stored as httpOnly cookies (`casera-token`), never exposed to JS. The Next.js `/api/proxy/[...path]` catch-all route forwards requests to the Go API.
Identity is owned by **Ory Kratos** (`NEXT_PUBLIC_KRATOS_URL`). The browser holds an `ory_kratos_session` cookie set by Kratos. The Next.js `/api/proxy/[...path]` catch-all route forwards that cookie to the Go API, which validates the session against Kratos. The Go API no longer does auth (no `Authorization: Token`).
### Auth (Ory Kratos)
Login / registration / recovery (forgot-password) / email verification / password changes use **Kratos browser self-service flows**:
- Auth pages (`src/app/(auth)/...`) initialize a flow by hard-navigating the browser to `{kratos}/self-service/{type}/browser`; Kratos sets a flow cookie and redirects back with `?flow=<id>`.
- The page reads `?flow=<id>`, fetches the flow definition (`ui.nodes` / `ui.action` / `ui.method`), and renders it generically via `<KratosFlowForm>`.
- Social sign-in (Apple/Google) = the `oidc` nodes in the flow.
- `src/lib/kratos/` holds the client (`getFlow`, `submitFlow`, `whoami`, `logout`, ...) and the `useKratosFlow` hook.
- Route protection: `<AuthGate>` (in `src/app/app/layout.tsx`) calls `{kratos}/sessions/whoami`; the middleware does a cheap `ory_kratos_session` cookie pre-filter.
### Directory Structure
```
src/
├── app/
│ ├── (auth)/ # Login, register, forgot-password (public)
│ ├── (auth)/ # Kratos self-service flow pages (login/register/recovery/verify)
│ ├── api/proxy/ # Catch-all proxy to Go API
│ ├── app/ # Authenticated app pages
│ │ ├── contractors/ # Contractor CRUD
@@ -103,7 +113,7 @@ src/
**Kanban boards**: Tasks display in kanban columns (overdue, due_soon, in_progress, not_started, completed). Uses `@dnd-kit` for drag-and-drop. Column names match Go API: `overdue_tasks`, `due_soon_tasks`, `in_progress_tasks`, `not_started_tasks`, `completed_tasks`.
**Middleware** (`src/middleware.ts`): Checks `casera-token` cookie. Redirects unauthenticated users to `/login` for protected routes. Skips API routes, static files, and public paths.
**Middleware** (`src/middleware.ts`): Cheap pre-filter on the `ory_kratos_session` cookie. Redirects users without the cookie to `/login` for protected routes. Skips API routes, static files, and public paths. The authoritative session check is `<AuthGate>` (`whoami`).
## Conventions
@@ -123,11 +133,14 @@ src/
| Variable | Description | Default |
|----------|-------------|---------|
| `NEXT_PUBLIC_API_URL` | Go API URL (client-side) | `https://casera.treytartt.com/api` |
| `NEXT_PUBLIC_API_URL` | Go API URL (client-side) | `https://honeyDue.treytartt.com/api` |
| `API_URL` | Go API URL (server-side, no proxy) | Falls back to `NEXT_PUBLIC_API_URL` |
| `NEXT_PUBLIC_KRATOS_URL` | Ory Kratos public API URL (identity) | `https://auth.myhoneydue.com` |
| `NEXT_PUBLIC_POSTHOG_KEY` | PostHog analytics key | — |
| `NEXT_PUBLIC_POSTHOG_HOST` | PostHog host | — |
See `.env.example` for the full list.
## Common Tasks
**Add a new page**: Create `src/app/app/{route}/page.tsx` (client component). Add nav item to `src/components/layout/nav-items.ts`.
+3 -3
View File
@@ -1,6 +1,6 @@
# Casera Web App
# honeyDue Web App
Next.js web client for the Casera property management platform. Connects to the [Go REST API](https://github.com/akatreyt/casera-api) backend.
Next.js web client for the honeyDue property management platform. Connects to the [Go REST API](https://github.com/akatreyt/honeydue-api) backend.
## Features
@@ -50,7 +50,7 @@ Open [http://localhost:3000](http://localhost:3000).
| Variable | Description | Required |
|----------|-------------|----------|
| `NEXT_PUBLIC_API_URL` | Go API base URL (e.g. `https://casera.treytartt.com/api`) | Yes |
| `NEXT_PUBLIC_API_URL` | Go API base URL (e.g. `https://honeyDue.treytartt.com/api`) | Yes |
| `API_URL` | Server-side API URL (defaults to `NEXT_PUBLIC_API_URL`) | No |
| `NEXT_PUBLIC_POSTHOG_KEY` | PostHog project API key | No |
| `NEXT_PUBLIC_POSTHOG_HOST` | PostHog instance URL | No |
+5 -5
View File
@@ -1,8 +1,8 @@
# Casera Web App — Build Plan Overview
# honeyDue 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.
Full parity web app for honeyDue: **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
@@ -33,7 +33,7 @@ Full parity web app for Casera: **46 screens**, **104 API operations**, **4 doma
## Project Structure
```
myCribAPI-Web/
honeyDueAPI-Web/
├── src/
│ ├── app/ # Next.js App Router
│ │ ├── (marketing)/ # Public pages (landing, pricing)
@@ -86,7 +86,7 @@ myCribAPI-Web/
|---|---|
| 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) |
| .honeydue 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) |
@@ -112,4 +112,4 @@ myCribAPI-Web/
| 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 |
| Deployment independence | Separate Dokku app, own domain (e.g., `app.honeydue.treytartt.com`), no coupling to Go API deployment |
+7 -7
View File
@@ -18,8 +18,8 @@ Scaffold the project, wire up auth, build the app shell, and establish the desig
## 1. Project Scaffold
```bash
npx create-next-app@latest myCribAPI-Web --typescript --tailwind --eslint --app --src-dir
cd myCribAPI-Web
npx create-next-app@latest honeyDueAPI-Web --typescript --tailwind --eslint --app --src-dir
cd honeyDueAPI-Web
npx shadcn@latest init
```
@@ -33,9 +33,9 @@ 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
- `honeyDueAPI-go/internal/dto/requests/` — request shapes
- `honeyDueAPI-go/internal/dto/responses/` — response shapes
- `honeyDueAPI-go/internal/models/` — entity shapes
Key types to define in `src/lib/types/`:
@@ -73,7 +73,7 @@ api/
```typescript
// src/lib/api/client.ts
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'https://mycrib.treytartt.com/api';
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'https://honeyDue.treytartt.com/api';
async function apiFetch<T>(
path: string,
@@ -225,7 +225,7 @@ Match the mobile app's theme system:
| Desert | TBD | TBD |
| Mint | TBD | TBD |
Theme values sourced from `MyCribKMM/composeApp/src/commonMain/.../ui/theme/ThemeColors.kt`.
Theme values sourced from `HoneyDueKMM/composeApp/src/commonMain/.../ui/theme/ThemeColors.kt`.
### Spacing
+11 -11
View File
@@ -4,8 +4,8 @@ Build sharing, subscriptions, notifications, profile, onboarding, and summary me
## Checklist
- [ ] Residence sharing: generate/display share code, join residence, manage users, .casera file export/import
- [ ] Contractor sharing: .casera file export/import
- [ ] Residence sharing: generate/display share code, join residence, manage users, .honeydue file export/import
- [ ] Contractor sharing: .honeydue 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
@@ -22,7 +22,7 @@ Build sharing, subscriptions, notifications, profile, onboarding, and summary me
| 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 |
| `/app/residences/join` | Join Residence | Enter share code or import .honeydue file |
### Share Code Flow
@@ -35,15 +35,15 @@ Owner opens Share screen
Invitee opens Join screen
→ Enter share code manually, OR
→ Upload .casera file (drag-and-drop zone)
→ Upload .honeydue file (drag-and-drop zone)
→ POST /api/residences/join/ { code: "ABC123" }
→ On success: redirect to residence detail
```
### .casera File Handling
### .honeydue File Handling
**Export** (owner):
- Button on residence detail: "Export as .casera"
- Button on residence detail: "Export as .honeydue"
- Generates JSON file with `{ type: "residence", code: "ABC123", ... }`
- Browser downloads the file
@@ -73,7 +73,7 @@ Invitee opens Join screen
## 2. Contractor Sharing
### .casera File Export/Import
### .honeydue File Export/Import
**Export**:
- Button on contractor detail: "Share Contractor"
@@ -82,7 +82,7 @@ Invitee opens Join screen
**Import**:
- Button on contractor list: "Import Contractor"
- File picker or drag-and-drop for .casera file
- File picker or drag-and-drop for .honeydue file
- Read JSON, show confirmation dialog, create contractor
- `POST /api/contractors/import/`
@@ -123,7 +123,7 @@ Check tier limits before allowing creation:
### Upgrade Path (Phase 1)
Web users are directed to the mobile app for purchases:
- "Download Casera on the App Store to upgrade"
- "Download honeyDue on the App Store to upgrade"
- Link to App Store listing
### Upgrade Path (Future — Phase 2)
@@ -215,7 +215,7 @@ Add Stripe Checkout for web-only subscription purchases. Requires backend work t
```
Step 1: Welcome
→ "Welcome to Casera! Let's set up your first property."
→ "Welcome to honeyDue! Let's set up your first property."
Step 2: Choose Path
→ "Create a new residence" OR "Join an existing residence"
@@ -300,7 +300,7 @@ Data sources:
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
2. Contractor sharing via .honeydue files
3. Subscription status with tier limits and upgrade prompts
4. Notification preferences with toggles
5. Profile editing (name, email, password, delete account)
+2 -2
View File
@@ -261,7 +261,7 @@ Persistent banner at the top of the demo app:
```
┌─────────────────────────────────────────────────────────────────┐
│ 🎯 You're exploring Casera in demo mode. [Sign Up Free] │
│ 🎯 You're exploring honeyDue in demo mode. [Sign Up Free] │
│ Changes aren't saved. Create an account to get started! │
└─────────────────────────────────────────────────────────────────┘
```
@@ -282,7 +282,7 @@ Before entering the demo, show a brief preview page:
```
┌─────────────────────────────────────────────────────┐
│ │
│ 🏠 Try Casera — No Account Needed │
│ 🏠 Try honeyDue — No Account Needed │
│ │
│ Manage your home maintenance, track tasks, │
│ organize contractors, and store documents. │
+11 -11
View File
@@ -162,7 +162,7 @@ const nextConfig = {
output: 'standalone', // Required for Docker
images: {
remotePatterns: [
{ protocol: 'https', hostname: 'mycrib.treytartt.com' }, // API media
{ protocol: 'https', hostname: 'honeyDue.treytartt.com' }, // API media
],
},
};
@@ -171,14 +171,14 @@ const nextConfig = {
### 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
# On honeyDueDev server
dokku apps:create honeydue-web
dokku domains:add honeydue-web app.honeyDue.treytartt.com
dokku config:set honeydue-web NEXT_PUBLIC_API_URL=https://honeyDue.treytartt.com/api
dokku letsencrypt:enable honeydue-web
# Deploy
git remote add dokku-web dokku@mycribDev:casera-web
git remote add dokku-web dokku@honeyDueDev:honeydue-web
git push dokku-web main
```
@@ -316,17 +316,17 @@ Marketing and demo pages need proper meta tags:
```typescript
// src/app/(marketing)/page.tsx
export const metadata: Metadata = {
title: 'Casera — Home Maintenance Made Simple',
title: 'honeyDue — 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',
title: 'honeyDue — Home Maintenance Made Simple',
description: 'Track tasks, organize contractors, store documents.',
images: ['/og-image.png'],
type: 'website',
},
twitter: {
card: 'summary_large_image',
title: 'Casera',
title: 'honeyDue',
description: 'Home Maintenance Made Simple',
images: ['/og-image.png'],
},
@@ -391,7 +391,7 @@ Track same events as mobile app for consistent analytics.
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`
3. Deployed on Dokku at `app.honeyDue.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 -7
View File
@@ -56,7 +56,7 @@ Task 6: Integration + Verification (sequential — cross-domain wiring)
### Step 1: Install missing shadcn components and @dnd-kit
```bash
cd myCribAPI-Web
cd honeyDueAPI-Web
npx shadcn@latest add dialog select textarea tabs skeleton tooltip popover calendar
npm install @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities date-fns
```
@@ -688,7 +688,7 @@ export * from './use-documents';
### Step 14: Verify build
```bash
cd myCribAPI-Web && npm run build
cd honeyDueAPI-Web && npm run build
```
Expected: Build succeeds with no type errors.
@@ -1253,7 +1253,7 @@ export default function EditResidencePage({
### Step 9: Verify build
```bash
cd myCribAPI-Web && npm run build
cd honeyDueAPI-Web && npm run build
```
Expected: Build succeeds. All 4 residence routes render.
@@ -2198,7 +2198,7 @@ export default function TaskDetailPage({
### Step 11: Verify build
```bash
cd myCribAPI-Web && npm run build
cd honeyDueAPI-Web && npm run build
```
### Step 12: Commit
@@ -2282,7 +2282,7 @@ Follow the same patterns as Residences (Task 2). Key differences:
### Step 9: Verify build
```bash
cd myCribAPI-Web && npm run build
cd honeyDueAPI-Web && npm run build
```
### Step 10: Commit
@@ -2363,7 +2363,7 @@ Key differences from other domains:
### Step 9: Verify build
```bash
cd myCribAPI-Web && npm run build
cd honeyDueAPI-Web && npm run build
```
### Step 10: Commit
@@ -2442,7 +2442,7 @@ export default function DashboardPage() {
### Step 2: Full build verification
```bash
cd myCribAPI-Web && npm run build
cd honeyDueAPI-Web && npm run build
```
Expected: Build succeeds. All routes compile. No type errors.
+23 -23
View File
@@ -42,7 +42,7 @@ Task 7: Integration + Verification (sequential — cross-feature wiring)
### Step 1: Install Recharts
```bash
cd /Users/treyt/Desktop/code/MyCrib/myCribAPI-Web
cd /Users/treyt/Desktop/code/HoneyDue/honeyDueAPI-Web
npm install recharts
```
@@ -282,7 +282,7 @@ export * from './use-subscription';
### Step 9: Verify build
```bash
cd /Users/treyt/Desktop/code/MyCrib/myCribAPI-Web && npm run build
cd /Users/treyt/Desktop/code/HoneyDue/honeyDueAPI-Web && npm run build
```
### Step 10: Commit
@@ -302,7 +302,7 @@ git add -A && git commit -m "feat: add Phase 3 foundation — notification hooks
- 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/components/sharing/honeydue-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)
@@ -320,11 +320,11 @@ git add -A && git commit -m "feat: add Phase 3 foundation — notification hooks
- Each user row: name, email, role badge (Owner/Member)
- Owner can click "Remove" → ConfirmDialog → `residencesApi.removeResidenceUser(residenceId, userId)`
### .casera File Handler
### .honeydue 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
**honeydue-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 `.honeydue` file
- **Import mode**: FileUpload drop zone accepting `.honeydue` files, reads JSON, validates type, calls callback with parsed data
- Uses `URL.createObjectURL` + anchor click for download
- Uses `FileReader.readAsText` for import
@@ -332,19 +332,19 @@ git add -A && git commit -m "feat: add Phase 3 foundation — notification hooks
**Residence Share page** (`/app/residences/[id]/share`):
- ShareCodeDisplay for generating/displaying share codes
- .casera export button → downloads file with share code embedded
- .honeydue 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
- .honeydue 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.
On contractor detail page, add "Share" button → generates .honeydue file with contractor data for download.
On contractor list page, add "Import" button → opens import dialog → reads .honeydue file → creates contractor via API.
### Query hooks
@@ -405,13 +405,13 @@ export function useJoinResidence() {
### Verify build
```bash
cd /Users/treyt/Desktop/code/MyCrib/myCribAPI-Web && npm run build
cd /Users/treyt/Desktop/code/HoneyDue/honeyDueAPI-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"
git add -A && git commit -m "feat: add residence sharing (share code, join, user management) and contractor .honeydue export/import"
```
---
@@ -489,7 +489,7 @@ export async function deleteAccount(): Promise<MessageResponse> {
### Verify build
```bash
cd /Users/treyt/Desktop/code/MyCrib/myCribAPI-Web && npm run build
cd /Users/treyt/Desktop/code/HoneyDue/honeyDueAPI-Web && npm run build
```
### Commit
@@ -560,7 +560,7 @@ npx shadcn@latest add switch
### Verify build
```bash
cd /Users/treyt/Desktop/code/MyCrib/myCribAPI-Web && npm run build
cd /Users/treyt/Desktop/code/HoneyDue/honeyDueAPI-Web && npm run build
```
### Commit
@@ -623,11 +623,11 @@ export const useOnboardingStore = create<OnboardingState>()((set) => ({
- Clean, centered layout (no sidebar or app shell)
- Progress indicator (step dots or progress bar)
- Casera logo at top
- honeyDue logo at top
### Steps
**Step 0: Welcome** — "Welcome to Casera!" message, illustration, "Get Started" button
**Step 0: Welcome** — "Welcome to honeyDue!" 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.
@@ -637,7 +637,7 @@ export const useOnboardingStore = create<OnboardingState>()((set) => ({
- **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 2b: Join Residence** — Code input (6-char) or .honeydue file import. On submit: joins via API, advances.
- **Step 3b: Complete** — "Welcome to the residence!" Redirect to residence detail.
### Onboarding trigger
@@ -649,7 +649,7 @@ For now, just mark `localStorage.setItem('onboarding_complete', 'true')` after c
### Verify build
```bash
cd /Users/treyt/Desktop/code/MyCrib/myCribAPI-Web && npm run build
cd /Users/treyt/Desktop/code/HoneyDue/honeyDueAPI-Web && npm run build
```
### Commit
@@ -714,7 +714,7 @@ On residence detail page (`src/app/app/residences/[id]/page.tsx`), add a "Downlo
### Verify build
```bash
cd /Users/treyt/Desktop/code/MyCrib/myCribAPI-Web && npm run build
cd /Users/treyt/Desktop/code/HoneyDue/honeyDueAPI-Web && npm run build
```
### Commit
@@ -732,7 +732,7 @@ git add -A && git commit -m "feat: add enhanced dashboard with charts and task r
### Step 1: Full build verification
```bash
cd /Users/treyt/Desktop/code/MyCrib/myCribAPI-Web && npm run build
cd /Users/treyt/Desktop/code/HoneyDue/honeyDueAPI-Web && npm run build
```
### Step 2: Route verification
@@ -766,8 +766,8 @@ git add -A && git commit -m "feat: complete Phase 3 — advanced features integr
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
- [ ] **Contractor sharing**: .honeydue file export from detail, import on list page
- [ ] **.honeydue 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
+1 -1
View File
@@ -11,7 +11,7 @@ const nextConfig: NextConfig = {
remotePatterns: [
{
protocol: "https",
hostname: "casera.treytartt.com",
hostname: "honeyDue.treytartt.com",
},
],
},
+2 -2
View File
@@ -1,11 +1,11 @@
{
"name": "casera-web",
"name": "honeydue-web",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "casera-web",
"name": "honeydue-web",
"version": "0.1.0",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
+1 -1
View File
@@ -1,5 +1,5 @@
{
"name": "casera-web",
"name": "honeydue-web",
"version": "0.1.0",
"private": true,
"scripts": {
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 464 KiB

After

Width:  |  Height:  |  Size: 1.2 MiB

+47 -66
View File
@@ -1,59 +1,49 @@
"use client";
import { useState } from "react";
import { Suspense } 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";
import { KratosFlowForm } from "@/components/auth/kratos-flow-form";
import { KratosMessages } from "@/components/auth/kratos-messages";
import { useKratosFlow } from "@/lib/kratos/use-kratos-flow";
import type { KratosFlow } from "@/lib/kratos";
export default function ForgotPasswordPage() {
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// ---------------------------------------------------------------------------
// Forgot password — Ory Kratos `recovery` browser self-service flow
// ---------------------------------------------------------------------------
// The recovery flow is multi-step but single-page: the user first submits
// their email, Kratos emails a code and returns the SAME flow re-rendered with
// a "code" input. Submitting a valid code creates a privileged session and
// Kratos redirects the browser to the `settings` flow to set a new password.
//
// We just keep rendering `flow.ui.nodes` — Kratos drives the step transitions.
// ---------------------------------------------------------------------------
const {
register,
handleSubmit,
formState: { errors },
} = useForm<ForgotPasswordFormData>({
resolver: zodResolver(forgotPasswordSchema),
});
function ForgotPasswordForm() {
const { flow, loading, error, setFlow } = useKratosFlow("recovery");
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);
function handleResult(result: { status: number; ok: boolean; data: unknown }) {
// A hard success (2xx with no body) means Kratos established a recovery
// session and is redirecting the browser to the settings flow. With
// `redirect: 'manual'` the redirect surfaces as ok=true / data=null —
// navigate to settings so the user can pick a new password.
if (result.ok && !result.data) {
window.location.href = "/settings";
return;
}
// Otherwise Kratos returned a flow body: either the same flow advanced to
// the "code" step, or the same step re-rendered with validation messages.
if (result.data && typeof result.data === "object" && "ui" in result.data) {
setFlow(result.data as KratosFlow);
}
}
return (
<AuthFormWrapper
title="Forgot password?"
subtitle="Enter your email to receive a reset code"
subtitle="Enter your email to receive a recovery code"
footer={
<p>
<Link href="/login" className="text-primary hover:underline">
@@ -62,34 +52,25 @@ export default function ForgotPasswordPage() {
</p>
}
>
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
{error && (
<div role="alert" aria-live="assertive" className="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
<div className="flex flex-col gap-4">
<KratosMessages flow={flow} error={error} />
{loading && !flow && (
<div className="flex items-center justify-center py-6 text-muted-foreground">
<Loader2 className="animate-spin" />
</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}
aria-describedby={errors.email ? "email-error" : undefined}
{...register("email")}
/>
{errors.email && (
<p id="email-error" role="alert" 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>
{flow && <KratosFlowForm flow={flow} onResult={handleResult} />}
</div>
</AuthFormWrapper>
);
}
export default function ForgotPasswordPage() {
return (
<Suspense>
<ForgotPasswordForm />
</Suspense>
);
}
+7 -8
View File
@@ -12,13 +12,12 @@ export default function AuthLayout({ children }: { children: React.ReactNode })
<div className="absolute top-0 right-0 w-80 h-80 rounded-full bg-[#6B8F71]/15 blur-[100px] pointer-events-none" />
<div className="absolute bottom-0 left-0 w-64 h-64 rounded-full bg-[#C4856A]/10 blur-[80px] pointer-events-none" />
{/* Subtle grid */}
{/* Subtle honeycomb pattern */}
<div
className="absolute inset-0 opacity-[0.03]"
className="absolute inset-0 opacity-[0.08]"
style={{
backgroundImage:
"linear-gradient(rgba(255,255,255,0.5) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,0.5) 1px, transparent 1px)",
backgroundSize: "48px 48px",
backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='60' height='103.92'%3E%3Cpolygon points='30,0 60,17.32 60,51.96 30,69.28 0,51.96 0,17.32' fill='none' stroke='%23C4856A' stroke-width='0.8'/%3E%3Cpolygon points='60,51.96 90,69.28 90,103.92 60,121.24 30,103.92 30,69.28' fill='none' stroke='%23C4856A' stroke-width='0.8'/%3E%3C/svg%3E")`,
backgroundSize: "60px 103.92px",
}}
/>
@@ -26,13 +25,13 @@ export default function AuthLayout({ children }: { children: React.ReactNode })
<Link href="/" className="flex items-center gap-2.5">
<Image
src="/logo.png"
alt="Casera"
alt="honeyDue"
width={36}
height={36}
className="rounded-lg"
/>
<span className="font-heading text-xl font-bold text-white">
Casera
honeyDue
</span>
</Link>
</div>
@@ -49,7 +48,7 @@ export default function AuthLayout({ children }: { children: React.ReactNode })
</div>
<p className="relative text-xs text-[#8A8F87]">
&copy; {new Date().getFullYear()} Casera
&copy; {new Date().getFullYear()} honeyDue
</p>
</div>
+54 -70
View File
@@ -1,32 +1,38 @@
"use client";
import { Suspense } from "react";
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";
import { KratosFlowForm } from "@/components/auth/kratos-flow-form";
import { KratosMessages } from "@/components/auth/kratos-messages";
import { useKratosFlow } from "@/lib/kratos/use-kratos-flow";
import { trackEvent, AnalyticsEvents } from "@/lib/analytics";
import type { KratosFlow } from "@/lib/kratos";
export default function LoginPage() {
const { login, isLoading, error, clearError } = useAuthStore();
// ---------------------------------------------------------------------------
// Login — Ory Kratos `login` browser self-service flow
// ---------------------------------------------------------------------------
const {
register,
handleSubmit,
formState: { errors },
} = useForm<LoginFormData>({
resolver: zodResolver(loginSchema),
});
function LoginForm() {
const { flow, loading, error, setFlow } = useKratosFlow("login");
async function onSubmit(data: LoginFormData) {
clearError();
await login({ username: data.username, password: data.password });
function handleResult(result: { status: number; ok: boolean; data: unknown }) {
if (result.ok) {
// Kratos completed the login (set the `ory_kratos_session` cookie).
trackEvent(AnalyticsEvents.USER_SIGNED_IN, {
method: "kratos",
platform: "web",
});
window.location.href = "/app";
return;
}
// 400 = validation errors; Kratos returns the same flow re-rendered with
// messages. Re-render whatever Kratos returned.
if (result.data && typeof result.data === "object" && "ui" in result.data) {
setFlow(result.data as KratosFlow);
}
}
return (
@@ -42,59 +48,37 @@ export default function LoginPage() {
</p>
}
>
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
{error && (
<div role="alert" aria-live="assertive" className="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
<div className="flex flex-col gap-4">
<KratosMessages flow={flow} error={error} />
{loading && !flow && (
<div className="flex items-center justify-center py-6 text-muted-foreground">
<Loader2 className="animate-spin" />
</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}
aria-describedby={errors.username ? "username-error" : undefined}
{...register("username")}
/>
{errors.username && (
<p id="username-error" role="alert" 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}
aria-describedby={errors.password ? "password-error" : undefined}
{...register("password")}
/>
{errors.password && (
<p id="password-error" role="alert" 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>
{flow && (
<>
<KratosFlowForm flow={flow} onResult={handleResult} submitLabel="Sign in" />
<div className="text-center">
<Link
href="/forgot-password"
className="text-xs text-muted-foreground hover:text-primary"
>
Forgot password?
</Link>
</div>
</>
)}
</div>
</AuthFormWrapper>
);
}
export default function LoginPage() {
return (
<Suspense>
<LoginForm />
</Suspense>
);
}
+51 -140
View File
@@ -1,53 +1,49 @@
"use client";
import { Suspense } 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 { PasswordInput } from "@/components/forms/password-input";
import { registerSchema, type RegisterFormData } from "@/lib/validations/auth";
import { useAuthStore } from "@/stores/auth";
import { KratosFlowForm } from "@/components/auth/kratos-flow-form";
import { KratosMessages } from "@/components/auth/kratos-messages";
import { useKratosFlow } from "@/lib/kratos/use-kratos-flow";
import { trackEvent, AnalyticsEvents } from "@/lib/analytics";
import type { KratosFlow } from "@/lib/kratos";
export default function RegisterPage() {
const router = useRouter();
const { register: registerUser, isLoading, error, clearError } = useAuthStore();
// ---------------------------------------------------------------------------
// Registration — Ory Kratos `registration` browser self-service flow
// ---------------------------------------------------------------------------
// Kratos owns the schema (traits.email, traits.name.first, ...). The form is
// rendered entirely from `flow.ui.nodes`, so whatever the identity schema
// defines is what the user sees.
// ---------------------------------------------------------------------------
const {
register,
handleSubmit,
formState: { errors },
} = useForm<RegisterFormData>({
resolver: zodResolver(registerSchema),
});
function RegisterForm() {
const { flow, loading, error, setFlow } = useKratosFlow("registration");
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,
function handleResult(result: { status: number; ok: boolean; data: unknown }) {
if (result.ok) {
trackEvent(AnalyticsEvents.USER_REGISTERED, {
method: "kratos",
platform: "web",
});
router.push(
`/verify-email?email=${encodeURIComponent(data.email)}`
);
} catch {
// Error is already set in the store
// Depending on Kratos config, registration may either log the user in
// immediately (session cookie set) or require email verification first.
// `/app` works in both cases — middleware / whoami will route the user
// to verification if a session is not yet active.
window.location.href = "/app";
return;
}
if (result.data && typeof result.data === "object" && "ui" in result.data) {
setFlow(result.data as KratosFlow);
}
}
return (
<AuthFormWrapper
title="Create account"
subtitle="Get started with Casera"
subtitle="Get started with honeyDue"
footer={
<p>
Already have an account?{" "}
@@ -57,116 +53,31 @@ export default function RegisterPage() {
</p>
}
>
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
{error && (
<div role="alert" aria-live="assertive" className="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
<div className="flex flex-col gap-4">
<KratosMessages flow={flow} error={error} />
{loading && !flow && (
<div className="flex items-center justify-center py-6 text-muted-foreground">
<Loader2 className="animate-spin" />
</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}
aria-describedby={errors.first_name ? "first-name-error" : undefined}
{...register("first_name")}
/>
{errors.first_name && (
<p id="first-name-error" role="alert" 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}
aria-describedby={errors.last_name ? "last-name-error" : undefined}
{...register("last_name")}
/>
{errors.last_name && (
<p id="last-name-error" role="alert" 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}
aria-describedby={errors.username ? "username-error" : undefined}
{...register("username")}
{flow && (
<KratosFlowForm
flow={flow}
onResult={handleResult}
submitLabel="Create account"
/>
{errors.username && (
<p id="username-error" role="alert" 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}
aria-describedby={errors.email ? "email-error" : undefined}
{...register("email")}
/>
{errors.email && (
<p id="email-error" role="alert" 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}
aria-describedby={errors.password ? "password-error" : undefined}
{...register("password")}
/>
{errors.password && (
<p id="password-error" role="alert" 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}
aria-describedby={errors.confirm_password ? "confirm-password-error" : undefined}
{...register("confirm_password")}
/>
{errors.confirm_password && (
<p id="confirm-password-error" role="alert" 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>
)}
</div>
</AuthFormWrapper>
);
}
export default function RegisterPage() {
return (
<Suspense>
<RegisterForm />
</Suspense>
);
}
+59 -168
View File
@@ -1,147 +1,66 @@
"use client";
import { Suspense, useState } from "react";
import { Suspense } 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";
import { KratosFlowForm } from "@/components/auth/kratos-flow-form";
import { KratosMessages } from "@/components/auth/kratos-messages";
import { useKratosFlow } from "@/lib/kratos/use-kratos-flow";
import type { KratosFlow, KratosUiNode } from "@/lib/kratos";
type Step = "code" | "password";
// ---------------------------------------------------------------------------
// Reset password — Ory Kratos `settings` browser self-service flow
// ---------------------------------------------------------------------------
// Kratos does not have a standalone "reset password" flow. After completing
// the `recovery` flow, Kratos issues a privileged (recovery) session and
// redirects the browser here. The `settings` flow then lets the user set a
// new password.
//
// This page only renders the `password` group of the settings flow so it
// reads as a focused "set new password" screen. The full settings flow
// (profile, etc.) lives under /app/settings.
// ---------------------------------------------------------------------------
function ResetPasswordForm() {
const router = useRouter();
const searchParams = useSearchParams();
const email = searchParams.get("email") ?? "";
const { flow, loading, error, setFlow } = useKratosFlow("settings");
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 handleResult(result: { status: number; ok: boolean; data: unknown }) {
if (result.data && typeof result.data === "object" && "ui" in result.data) {
const next = result.data as KratosFlow;
setFlow(next);
// Kratos sets the flow state to "success" once the password is updated.
if (next.state === "success") {
// Give the success message a beat, then send the user into the app.
setTimeout(() => {
window.location.href = "/app";
}, 1200);
}
return;
}
if (result.ok) {
window.location.href = "/app";
}
}
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>
);
}
// Only render password-related nodes (csrf/method markers included).
const passwordOnlyFlow: KratosFlow | null = flow
? {
...flow,
ui: {
...flow.ui,
nodes: flow.ui.nodes.filter(
(n: KratosUiNode) => n.group === "password" || n.group === "default",
),
},
}
: null;
return (
<AuthFormWrapper
title="Set new password"
subtitle="Enter your new password below"
subtitle="Choose a new password for your account"
footer={
<p>
<Link href="/login" className="text-primary hover:underline">
@@ -150,51 +69,23 @@ function ResetPasswordForm() {
</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 className="flex flex-col gap-4">
<KratosMessages flow={flow} error={error} />
{loading && !flow && (
<div className="flex items-center justify-center py-6 text-muted-foreground">
<Loader2 className="animate-spin" />
</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")}
{passwordOnlyFlow && (
<KratosFlowForm
flow={passwordOnlyFlow}
onResult={handleResult}
submitLabel="Update 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>
)}
</div>
</AuthFormWrapper>
);
}
+32 -104
View File
@@ -2,91 +2,44 @@
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";
import { KratosFlowForm } from "@/components/auth/kratos-flow-form";
import { KratosMessages } from "@/components/auth/kratos-messages";
import { useKratosFlow } from "@/lib/kratos/use-kratos-flow";
import type { KratosFlow } from "@/lib/kratos";
const RESEND_COOLDOWN_SECONDS = 60;
// ---------------------------------------------------------------------------
// Verify email — Ory Kratos `verification` browser self-service flow
// ---------------------------------------------------------------------------
// Like recovery, the verification flow is single-page and multi-step: the user
// submits their email, Kratos sends a code and re-renders the flow with a
// "code" input. A valid code marks the address verified. Kratos keeps the flow
// in `state: "passed_challenge"` and shows a success message — there is no
// session change, so we leave the user on the page.
// ---------------------------------------------------------------------------
function VerifyEmailForm() {
const router = useRouter();
const searchParams = useSearchParams();
const email = searchParams.get("email") ?? "";
const { flow, loading, error, setFlow } = useKratosFlow("verification");
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);
function handleResult(result: { status: number; ok: boolean; data: unknown }) {
// Kratos returns the same flow re-rendered for every step (email -> code,
// and the final "passed_challenge" success). Just re-render it.
if (result.data && typeof result.data === "object" && "ui" in result.data) {
setFlow(result.data as KratosFlow);
}
}
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);
}
}
const verified = flow?.state === "passed_challenge";
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"
verified
? "Your email has been verified."
: "Enter your email, then the code we send you"
}
footer={
<p>
@@ -96,43 +49,18 @@ function VerifyEmailForm() {
</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 className="flex flex-col gap-4">
<KratosMessages flow={flow} error={error} />
{loading && !flow && (
<div className="flex items-center justify-center py-6 text-muted-foreground">
<Loader2 className="animate-spin" />
</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>
{flow && !verified && (
<KratosFlowForm flow={flow} onResult={handleResult} />
)}
</div>
</AuthFormWrapper>
);
-76
View File
@@ -1,76 +0,0 @@
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://casera.treytartt.com/api';
const COOKIE_NAME = 'casera-token';
const COOKIE_MAX_AGE = 60 * 60 * 24 * 30; // 30 days
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const upstream = await fetch(`${API_BASE_URL}/auth/login/`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Timezone':
request.headers.get('x-timezone') ||
Intl.DateTimeFormat().resolvedOptions().timeZone,
},
cache: 'no-store',
body: JSON.stringify(body),
});
const data = await upstream.json().catch(() => null);
if (!upstream.ok) {
return NextResponse.json(
data || { error: 'Login failed' },
{ status: upstream.status },
);
}
// Extract token from Go API response
// The Go API returns { token: "...", user: { ... } }
const token: string | undefined = data?.token;
if (!token) {
return NextResponse.json(
{ error: 'No token in response' },
{ status: 500 },
);
}
// Set httpOnly cookie
const cookieStore = await cookies();
cookieStore.set(COOKIE_NAME, token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
path: '/',
maxAge: COOKIE_MAX_AGE,
});
// Return the full response (including user data) to the client,
// but strip the raw token since it is now in the cookie.
const { token: _stripped, ...safeData } = data;
return NextResponse.json(safeData, { status: 200 });
} catch (error) {
console.error('[auth/login] Error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 },
);
}
}
-50
View File
@@ -1,50 +0,0 @@
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://casera.treytartt.com/api';
const COOKIE_NAME = 'casera-token';
export async function POST(request: NextRequest) {
try {
const cookieStore = await cookies();
const token = cookieStore.get(COOKIE_NAME)?.value;
// Best-effort: tell the Go API to invalidate the token
if (token) {
try {
await fetch(`${API_BASE_URL}/auth/logout/`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Token ${token}`,
},
cache: 'no-store',
});
} catch {
// Don't block logout if the upstream call fails
}
}
// Delete the cookie
cookieStore.delete(COOKIE_NAME);
return NextResponse.json({ message: 'Logged out successfully' });
} catch (error) {
console.error('[auth/logout] Error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 },
);
}
}
-60
View File
@@ -1,60 +0,0 @@
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://casera.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 },
);
}
}
+13 -9
View File
@@ -5,18 +5,21 @@ 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.
// Identity is owned by Ory Kratos: the browser holds an `ory_kratos_session`
// cookie. This proxy reads that cookie and forwards it to the Go API as a
// Cookie header, so the Go API can validate the session against Kratos.
// ---------------------------------------------------------------------------
const API_BASE_URL =
process.env.API_URL ||
process.env.NEXT_PUBLIC_API_URL ||
'https://casera.treytartt.com/api';
'https://honeyDue.treytartt.com/api';
const KRATOS_SESSION_COOKIE = 'ory_kratos_session';
/**
* Build the target URL from the catch-all path segments.
* e.g. /api/proxy/tasks/123/ -> https://casera.treytartt.com/api/tasks/123/
* e.g. /api/proxy/tasks/123/ -> https://honeyDue.treytartt.com/api/tasks/123/
*/
function buildTargetUrl(request: NextRequest, pathSegments: string[]): string {
const path = `/${pathSegments.join('/')}`;
@@ -30,7 +33,7 @@ function buildTargetUrl(request: NextRequest, pathSegments: string[]): string {
/**
* Build headers to forward to Go API.
* Strips hop-by-hop headers and adds Authorization from cookie.
* Strips hop-by-hop headers and forwards the Kratos session cookie.
*/
async function buildHeaders(request: NextRequest): Promise<Headers> {
const headers = new Headers();
@@ -51,11 +54,12 @@ async function buildHeaders(request: NextRequest): Promise<Headers> {
}
}
// Attach auth token from httpOnly cookie
// Forward the Ory Kratos session cookie so the Go API can authenticate the
// request against Kratos. The Go API no longer accepts `Authorization: Token`.
const cookieStore = await cookies();
const token = cookieStore.get('casera-token')?.value;
if (token) {
headers.set('Authorization', `Token ${token}`);
const session = cookieStore.get(KRATOS_SESSION_COOKIE)?.value;
if (session) {
headers.set('Cookie', `${KRATOS_SESSION_COOKIE}=${session}`);
}
return headers;
+3 -3
View File
@@ -14,7 +14,7 @@ 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 { downloadHoneyDueFile } from "@/components/sharing/honeydue-file-handler";
import {
useContractor,
useContractorTasks,
@@ -88,7 +88,7 @@ export default function ContractorDetailPage({
size="sm"
onClick={() => {
const exportData = {
type: "casera_contractor_share",
type: "honeydue_contractor_share",
version: 1,
contractor: {
name: contractor.name,
@@ -107,7 +107,7 @@ export default function ContractorDetailPage({
exported_at: new Date().toISOString(),
};
const safeName = contractor.name.replace(/[^a-zA-Z0-9_-]/g, "_").toLowerCase();
downloadCaseraFile(exportData, `${safeName}-contractor`);
downloadHoneyDueFile(exportData, `${safeName}-contractor`);
}}
>
<FileDown className="size-4 mr-2" />
+18 -9
View File
@@ -2,7 +2,8 @@
import { useState, useMemo } from "react";
import { useRouter } from "next/navigation";
import { Upload, Wrench } from "lucide-react";
import Link from "next/link";
import { Upload, Wrench, ExternalLink } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle,
@@ -11,7 +12,7 @@ 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 { HoneyDueFileImport } from "@/components/sharing/honeydue-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";
@@ -66,7 +67,7 @@ export default function ContractorsPage() {
typeof data === "object" &&
data !== null &&
"type" in data &&
(data as Record<string, unknown>).type === "casera_contractor_share" &&
(data as Record<string, unknown>).type === "honeydue_contractor_share" &&
"contractor" in data
) {
const contractor = (data as Record<string, unknown>).contractor as Record<string, unknown>;
@@ -97,7 +98,7 @@ export default function ContractorsPage() {
},
);
} else {
setImportError("Invalid .casera file. Expected a contractor share file.");
setImportError("Invalid .honeydue file. Expected a contractor share file.");
}
}
@@ -111,14 +112,14 @@ export default function ContractorsPage() {
>
<Button
variant="outline"
size="sm"
className="h-11 px-6 text-base rounded-xl"
onClick={() => {
setImportError(null);
setImportOpen(true);
}}
>
<Upload className="size-4 mr-2" />
Import .casera
Import
</Button>
</PageHeader>
@@ -168,22 +169,30 @@ export default function ContractorsPage() {
</>
)}
{/* Import .casera dialog */}
{/* Import .honeydue dialog */}
<Dialog open={importOpen} onOpenChange={setImportOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Import Contractor</DialogTitle>
<DialogDescription>
Import a contractor from a .casera file shared with you.
Someone shared a contractor with you? Drop the <code className="text-xs bg-muted px-1 py-0.5 rounded">.honeydue</code> file below to add them to your list.
</DialogDescription>
</DialogHeader>
<CaseraFileImport onImport={handleContractorImport} />
<HoneyDueFileImport onImport={handleContractorImport} />
{importError && (
<p className="text-sm text-destructive">{importError}</p>
)}
{createContractor.isPending && (
<p className="text-sm text-muted-foreground">Importing...</p>
)}
<Link
href="/help/sharing"
target="_blank"
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-primary transition-colors"
>
Learn how to share and import contractors
<ExternalLink className="size-3" />
</Link>
</DialogContent>
</Dialog>
</div>
+13 -10
View File
@@ -2,22 +2,25 @@
import { TopBar } from '@/components/layout/top-bar';
import { MobileNav } from '@/components/layout/mobile-nav';
import { AuthGate } from '@/components/auth/auth-gate';
import { DataProviderProvider } from '@/lib/demo/data-provider-context';
import { realProvider } from '@/lib/demo/real-provider';
export default function AppLayout({ children }: { children: React.ReactNode }) {
return (
<DataProviderProvider value={realProvider}>
<div className="min-h-screen bg-background">
<div className="pointer-events-none fixed inset-0 bg-gradient-to-br from-primary/[0.02] via-transparent to-brand-clay/[0.02]" />
<TopBar />
<AuthGate>
<DataProviderProvider value={realProvider}>
<div className="min-h-screen bg-background">
<div className="pointer-events-none fixed inset-0 bg-gradient-to-br from-primary/[0.02] via-transparent to-brand-clay/[0.02]" />
<TopBar />
<main className="max-w-6xl mx-auto px-4 sm:px-8 py-6 lg:py-10 pb-28 md:pb-12">
{children}
</main>
<main className="max-w-6xl mx-auto px-4 sm:px-8 py-6 lg:py-10 pb-28 md:pb-12">
{children}
</main>
<MobileNav />
</div>
</DataProviderProvider>
<MobileNav />
</div>
</DataProviderProvider>
</AuthGate>
);
}
+1 -1
View File
@@ -281,7 +281,7 @@ export default function DashboardPage() {
<Home className="size-9 text-primary" />
</div>
<h1 className="font-heading text-3xl font-bold tracking-tight">
Welcome to Casera{name ? `, ${name}` : ""}
Welcome to honeyDue{name ? `, ${name}` : ""}
</h1>
<p className="text-muted-foreground mt-3 max-w-md text-base leading-relaxed">
The easiest way to keep your home running smoothly.
+6 -6
View File
@@ -9,7 +9,7 @@ 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 { HoneyDueFileExport } from "@/components/sharing/honeydue-file-handler";
import { useResidence } from "@/lib/hooks/use-residences";
import { useDataProvider } from "@/lib/demo/data-provider-context";
@@ -46,7 +46,7 @@ export default function ResidenceSharePage({ params }: SharePageProps) {
// Build the exportable residence data
const exportData = {
type: "casera_residence_share",
type: "honeydue_residence_share",
version: 1,
residence: {
name: residence.name,
@@ -87,16 +87,16 @@ export default function ResidenceSharePage({ params }: SharePageProps) {
<ShareCodeDisplay residenceId={id} />
)}
{/* Export .casera file */}
{/* Export .honeydue file */}
{residence.is_owner && (
<div className="flex items-center gap-3">
<CaseraFileExport
<HoneyDueFileExport
data={exportData}
filename={`${safeFilename}-residence`}
label="Export Residence (.casera)"
label="Export Residence (.honeydue)"
/>
<p className="text-sm text-muted-foreground">
Download residence data as a portable .casera file.
Download residence data as a portable .honeydue file.
</p>
</div>
)}
+5 -5
View File
@@ -11,7 +11,7 @@ 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 { HoneyDueFileImport } from "@/components/sharing/honeydue-file-handler";
import { useJoinResidence } from "@/lib/hooks/use-sharing";
import { useDataProvider } from "@/lib/demo/data-provider-context";
@@ -61,7 +61,7 @@ export default function JoinResidencePage() {
});
} else {
setFileError(
"Invalid .casera file. Expected a share package with a code field.",
"Invalid .honeydue file. Expected a share package with a code field.",
);
}
}
@@ -126,13 +126,13 @@ export default function JoinResidencePage() {
{/* File import */}
<Card>
<CardHeader>
<CardTitle>Import .casera File</CardTitle>
<CardTitle>Import .honeydue File</CardTitle>
<CardDescription>
If you received a .casera share package file, import it here.
If you received a .honeydue share package file, import it here.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<CaseraFileImport onImport={handleFileImport} />
<HoneyDueFileImport onImport={handleFileImport} />
{fileError && (
<p className="text-sm text-destructive">{fileError}</p>
)}
+4 -4
View File
@@ -1,13 +1,13 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Try Casera — Free Demo",
title: "Try honeyDue — Free Demo",
description:
"Try Casera without an account. Manage tasks, contractors, and documents in a live demo.",
"Try honeyDue without an account. Manage tasks, contractors, and documents in a live demo.",
openGraph: {
title: "Try Casera — Free Demo",
title: "Try honeyDue — Free Demo",
description:
"Try Casera without an account. Manage tasks, contractors, and documents in a live demo.",
"Try honeyDue without an account. Manage tasks, contractors, and documents in a live demo.",
type: "website",
},
};
+3 -3
View File
@@ -16,19 +16,19 @@ export default function DemoLandingPage() {
<Link href="/" className="inline-flex items-center gap-2.5 mb-10">
<Image
src="/logo.png"
alt="Casera"
alt="honeyDue"
width={36}
height={36}
className="rounded-lg"
/>
<span className="font-heading text-2xl font-bold tracking-tight text-[#2D3436]">
Casera
honeyDue
</span>
</Link>
{/* Hero */}
<h1 className="font-heading text-4xl font-bold tracking-tight text-[#2D3436]">
See Casera in action
See honeyDue in action
</h1>
<p className="mt-4 text-lg text-[#8A8F87] leading-relaxed">
Explore the full app with sample data. No account needed
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

+69
View File
@@ -0,0 +1,69 @@
import { HelpArticle } from "@/components/help/help-article";
import { HelpSection } from "@/components/help/help-section";
import { HelpCallout } from "@/components/help/help-callout";
import { ScreenshotPlaceholder } from "@/components/help/screenshot-placeholder";
export default function AccountHelpPage() {
return (
<HelpArticle
title="Account Settings"
description="Manage your profile, password, theme preferences, and account."
>
<HelpSection id="profile" title="Profile">
<p>
Update your display name and email address from your profile settings.
Your name appears to other household members when collaborating on
shared residences.
</p>
<ScreenshotPlaceholder alt="Profile settings page with name and email fields" />
<ol className="list-decimal list-inside space-y-2 ml-1">
<li>Go to <strong>Settings</strong> <strong>Profile</strong>.</li>
<li>Edit your name or email.</li>
<li>Tap <strong>Save</strong> to apply changes.</li>
</ol>
</HelpSection>
<HelpSection id="password" title="Changing Your Password">
<p>
Change your password at any time from the profile settings. You&apos;ll
need to enter your current password for verification.
</p>
<HelpCallout type="note">
If you signed up with Apple Sign In, you don&apos;t have a password to
change authentication is handled by Apple.
</HelpCallout>
</HelpSection>
<HelpSection id="theme" title="Theme Preferences">
<p>
honeyDue supports light, dark, and system-matched themes. Choose the
one that&apos;s most comfortable for you.
</p>
<ScreenshotPlaceholder alt="Theme picker showing light, dark, and system options" />
<p>
The theme affects the app&apos;s appearance across all screens. Your
preference syncs across devices when you&apos;re logged in.
</p>
</HelpSection>
<HelpSection id="delete" title="Deleting Your Account">
<p>
You can permanently delete your account and all associated data from
the profile settings.
</p>
<HelpCallout type="warning">
Account deletion is permanent and cannot be undone. All your
residences, tasks, contractors, documents, and subscription data will
be permanently removed. Household members will lose access to any
residences you own.
</HelpCallout>
<ol className="list-decimal list-inside space-y-2 ml-1">
<li>Go to <strong>Settings</strong> <strong>Profile</strong>.</li>
<li>Scroll to the bottom and tap <strong>Delete Account</strong>.</li>
<li>Confirm the deletion by typing your email address.</li>
<li>Your account and all data will be permanently removed.</li>
</ol>
</HelpSection>
</HelpArticle>
);
}
+88
View File
@@ -0,0 +1,88 @@
import { HelpArticle } from "@/components/help/help-article";
import { HelpSection } from "@/components/help/help-section";
import { HelpCallout } from "@/components/help/help-callout";
import { ScreenshotPlaceholder } from "@/components/help/screenshot-placeholder";
export default function ContractorsHelpPage() {
return (
<HelpArticle
title="Contractors"
description="Build your rolodex of trusted service providers. Store contact details, specialties, and share them with household members."
>
<HelpSection id="adding" title="Adding a Contractor">
<p>
Keep track of every plumber, electrician, landscaper, and handyman
you work with. Each contractor record stores their contact details and
specialty.
</p>
<ScreenshotPlaceholder alt="Add contractor form with name, phone, email, specialty, and notes" />
<ol className="list-decimal list-inside space-y-2 ml-1">
<li>Go to your <strong>Contractors</strong> list.</li>
<li>Tap <strong>Add Contractor</strong>.</li>
<li>Enter their name, phone number, and optionally email.</li>
<li>Select their specialty (plumbing, electrical, HVAC, etc.).</li>
<li>Add any notes about your experience with them.</li>
</ol>
</HelpSection>
<HelpSection id="specialties" title="Specialties">
<p>
Specialties help you quickly filter and find the right contractor for a
job. honeyDue comes with common home service categories:
</p>
<ul className="list-disc list-inside space-y-1 ml-1">
<li>Plumbing</li>
<li>Electrical</li>
<li>HVAC</li>
<li>Roofing</li>
<li>Landscaping</li>
<li>General Handyman</li>
<li>Pest Control</li>
<li>Cleaning</li>
<li>And more...</li>
</ul>
<ScreenshotPlaceholder alt="Contractor list filtered by specialty" />
</HelpSection>
<HelpSection id="favorites" title="Favorites">
<p>
Mark your go-to contractors as favorites for quick access. Favorited
contractors appear at the top of your list and are highlighted when
linking contractors to tasks.
</p>
<HelpCallout type="tip">
Tap the star icon on any contractor card to toggle their favorite status.
</HelpCallout>
</HelpSection>
<HelpSection id="sharing" title="Sharing & Importing">
<p>
Share your trusted contractors with friends, family, or new household
members using the <strong>.honeydue file format</strong>.
</p>
<ScreenshotPlaceholder alt="Contractor import dialog with file picker and preview" />
<p>
Export your contractors as a .honeydue file and send it to anyone.
They can import it into their own honeyDue account to instantly add
your recommended pros.
</p>
<p>
See the <a href="/help/sharing" className="text-[#6B8F71] underline underline-offset-2 hover:text-[#5A7A60]">Sharing guide</a> for
detailed export and import instructions.
</p>
</HelpSection>
<HelpSection id="linking" title="Linking to Tasks">
<p>
When creating or editing a task, you can link a contractor to it. This
creates a handy reference so you know who to call for that specific
maintenance item.
</p>
<p>
Contractors linked to tasks also appear in residence reports, giving
you a complete maintenance history with contact information.
</p>
</HelpSection>
</HelpArticle>
);
}
+76
View File
@@ -0,0 +1,76 @@
import { HelpArticle } from "@/components/help/help-article";
import { HelpSection } from "@/components/help/help-section";
import { HelpCallout } from "@/components/help/help-callout";
import { ScreenshotPlaceholder } from "@/components/help/screenshot-placeholder";
export default function DashboardHelpPage() {
return (
<HelpArticle
title="Dashboard"
description="Get a quick overview of your home maintenance status, upcoming tasks, and recent activity."
>
<HelpSection id="overview" title="Dashboard Overview">
<p>
The dashboard is your home screen after logging in. It gives you a
bird&apos;s-eye view of everything that needs your attention across all
your residences.
</p>
<ScreenshotPlaceholder alt="Dashboard showing task summary cards, activity feed, and quick actions" />
</HelpSection>
<HelpSection id="task-summary" title="Task Summary">
<p>
The task summary section shows counts for each status category:
</p>
<ul className="list-disc list-inside space-y-2 ml-1">
<li><strong>Overdue</strong> Tasks that have passed their due date</li>
<li><strong>Due Soon</strong> Tasks coming up in the next 30 days</li>
<li><strong>In Progress</strong> Tasks you&apos;re actively working on</li>
<li><strong>Completed</strong> Tasks finished this month</li>
</ul>
<p>
Tap any summary card to jump directly to those tasks on the kanban
board.
</p>
</HelpSection>
<HelpSection id="needs-attention" title="Needs Attention">
<p>
The &quot;Needs Attention&quot; section highlights items that require
immediate action overdue tasks, expiring warranties, and tasks
without due dates that may have been forgotten.
</p>
<ScreenshotPlaceholder alt="Needs attention section highlighting overdue and urgent items" />
<HelpCallout type="tip">
Check the dashboard daily it takes just a few seconds to see if
anything needs your attention.
</HelpCallout>
</HelpSection>
<HelpSection id="quick-actions" title="Quick Actions">
<p>
Quick action buttons let you jump to common tasks without navigating
through menus:
</p>
<ul className="list-disc list-inside space-y-2 ml-1">
<li>Add a new task</li>
<li>Add a new contractor</li>
<li>Upload a document</li>
<li>View your residences</li>
</ul>
</HelpSection>
<HelpSection id="template-suggestions" title="Task Suggestions">
<p>
The dashboard may suggest common maintenance tasks based on the time
of year and your property type. These suggestions come from our
template library and can be added to your residence with one tap.
</p>
<HelpCallout type="note">
Suggestions are just that suggestions. You can dismiss them or add
them to your task list as you see fit.
</HelpCallout>
</HelpSection>
</HelpArticle>
);
}
+71
View File
@@ -0,0 +1,71 @@
import { HelpArticle } from "@/components/help/help-article";
import { HelpSection } from "@/components/help/help-section";
import { HelpCallout } from "@/components/help/help-callout";
import { ScreenshotPlaceholder } from "@/components/help/screenshot-placeholder";
export default function DocumentsHelpPage() {
return (
<HelpArticle
title="Documents & Warranties"
description="Store warranties, manuals, leases, and other important home documents organized by property."
>
<HelpSection id="adding" title="Adding Documents">
<p>
Upload any document related to your home warranties, appliance
manuals, insurance papers, inspection reports, or receipts.
</p>
<ScreenshotPlaceholder alt="Add document form with title, file upload, and document type" />
<ol className="list-decimal list-inside space-y-2 ml-1">
<li>Open a residence and go to the <strong>Documents</strong> tab.</li>
<li>Tap <strong>Add Document</strong>.</li>
<li>Enter a title and select the document type.</li>
<li>Upload the file (PDF, image, or other supported formats).</li>
<li>Optionally add notes or tags for easier searching.</li>
</ol>
</HelpSection>
<HelpSection id="warranties" title="Warranty Tracking">
<p>
Mark documents as warranties and set expiration dates. honeyDue will
help you track when warranties are about to expire so you can take
action before coverage runs out.
</p>
<ScreenshotPlaceholder alt="Document card showing warranty with expiration date highlighted" />
<HelpCallout type="tip">
Upload a photo of the warranty card or receipt right when you purchase
an appliance. You&apos;ll thank yourself later when you need to make a
claim.
</HelpCallout>
</HelpSection>
<HelpSection id="viewing" title="Viewing Documents">
<p>
All documents for a residence are listed in one place. Tap any
document to view its details, download the file, or see associated
notes.
</p>
<ScreenshotPlaceholder alt="Documents list showing various document types with icons" />
</HelpSection>
<HelpSection id="activating" title="Activating & Deactivating">
<p>
Documents can be toggled between active and inactive states. Use this
to archive old documents without deleting them like an expired
warranty you might still need for reference.
</p>
<HelpCallout type="note">
Inactive documents are hidden from the default view but can be shown
with a filter toggle.
</HelpCallout>
</HelpSection>
<HelpSection id="editing" title="Editing Documents">
<p>
Update document titles, notes, or warranty dates at any time. You can
also replace the uploaded file if you have a better scan or updated
version.
</p>
</HelpSection>
</HelpArticle>
);
}
+88
View File
@@ -0,0 +1,88 @@
import { HelpArticle } from "@/components/help/help-article";
import { HelpSection } from "@/components/help/help-section";
import { HelpCallout } from "@/components/help/help-callout";
import { ScreenshotPlaceholder } from "@/components/help/screenshot-placeholder";
export default function GettingStartedHelpPage() {
return (
<HelpArticle
title="Getting Started"
description="Create your account, add your first home, and start tracking maintenance in minutes."
>
<HelpSection id="create-account" title="Create Your Account">
<p>
Sign up for honeyDue with your email address or use Sign in with Apple
for a faster setup. Your account is free to create and includes
everything you need to manage one property.
</p>
<ScreenshotPlaceholder alt="Registration page with email and Apple Sign In options" />
<ol className="list-decimal list-inside space-y-2 ml-1">
<li>Visit the <strong>Create Account</strong> page.</li>
<li>Enter your name, email, and a password (or use Apple Sign In).</li>
<li>Verify your email address via the confirmation link.</li>
<li>You&apos;re in! The onboarding flow will guide you through next steps.</li>
</ol>
</HelpSection>
<HelpSection id="add-home" title="Add Your First Home">
<p>
After signing up, you&apos;ll be prompted to add your first residence.
This is the property you want to track maintenance for.
</p>
<ScreenshotPlaceholder alt="Add residence form with name, address, and property type" />
<ol className="list-decimal list-inside space-y-2 ml-1">
<li>Tap <strong>Add Residence</strong>.</li>
<li>Enter a name (e.g., &quot;Our House&quot;) and optionally an address.</li>
<li>Select the property type (house, apartment, condo, etc.).</li>
<li>Your residence is created and ready for tasks.</li>
</ol>
<HelpCallout type="tip">
You can manage multiple properties by upgrading to Pro. The free plan
includes one residence.
</HelpCallout>
</HelpSection>
<HelpSection id="first-task" title="Create Your First Task">
<p>
Tasks are the heart of honeyDue. Add anything from &quot;Change HVAC
filter&quot; to &quot;Schedule annual inspection.&quot;
</p>
<ScreenshotPlaceholder alt="Create task form with title, category, priority, and due date" />
<ol className="list-decimal list-inside space-y-2 ml-1">
<li>Open your residence and tap <strong>Add Task</strong>.</li>
<li>Give it a title and optionally set a category, priority, and due date.</li>
<li>For recurring tasks, set a frequency (weekly, monthly, etc.).</li>
<li>The task appears on your kanban board, organized by status.</li>
</ol>
</HelpSection>
<HelpSection id="invite-household" title="Invite Household Members">
<p>
Home maintenance is a team effort. Invite your partner, roommates, or
family members so everyone can see and manage tasks together.
</p>
<ScreenshotPlaceholder alt="Share code dialog for inviting household members" />
<ol className="list-decimal list-inside space-y-2 ml-1">
<li>Open your residence settings.</li>
<li>Generate a <strong>share code</strong>.</li>
<li>Send the code to your household member.</li>
<li>They enter the code on their device to join your residence.</li>
</ol>
<HelpCallout type="note">
See the <a href="/help/sharing" className="text-[#6B8F71] underline underline-offset-2 hover:text-[#5A7A60]">Sharing guide</a> for
more details on household sharing and contractor exports.
</HelpCallout>
</HelpSection>
<HelpSection id="explore" title="What to Explore Next">
<p>Now that you&apos;re set up, here are some features to try:</p>
<ul className="list-disc list-inside space-y-2 ml-1">
<li><a href="/help/tasks" className="text-[#6B8F71] underline underline-offset-2 hover:text-[#5A7A60]">Kanban boards</a> Visualize tasks by status (overdue, due soon, in progress, etc.)</li>
<li><a href="/help/contractors" className="text-[#6B8F71] underline underline-offset-2 hover:text-[#5A7A60]">Contractors</a> Build your rolodex of trusted service providers</li>
<li><a href="/help/documents" className="text-[#6B8F71] underline underline-offset-2 hover:text-[#5A7A60]">Documents</a> Upload warranties, manuals, and important files</li>
<li><a href="/help/notifications" className="text-[#6B8F71] underline underline-offset-2 hover:text-[#5A7A60]">Notifications</a> Set up push reminders so you never miss a due date</li>
</ul>
</HelpSection>
</HelpArticle>
);
}
+29
View File
@@ -0,0 +1,29 @@
import type { Metadata } from "next";
import { HelpHeader } from "@/components/help/help-header";
import { HelpSidebar } from "@/components/help/help-sidebar";
import { HelpFooter } from "@/components/help/help-footer";
export const metadata: Metadata = {
title: "Help Center",
description:
"Learn how to use honeyDue to manage your home maintenance, tasks, contractors, and documents.",
};
export default function HelpLayout({ children }: { children: React.ReactNode }) {
return (
<div className="min-h-screen bg-[#FAFAF7] text-[#2D3436] font-sans selection:bg-[#6B8F71]/20 flex flex-col">
<HelpHeader />
<div className="flex-1 max-w-7xl mx-auto w-full px-6 py-10">
<div className="flex gap-10">
<aside className="hidden lg:block w-56 shrink-0">
<div className="sticky top-24">
<HelpSidebar />
</div>
</aside>
<main className="flex-1 min-w-0">{children}</main>
</div>
</div>
<HelpFooter />
</div>
);
}
+62
View File
@@ -0,0 +1,62 @@
import { HelpArticle } from "@/components/help/help-article";
import { HelpSection } from "@/components/help/help-section";
import { HelpCallout } from "@/components/help/help-callout";
import { ScreenshotPlaceholder } from "@/components/help/screenshot-placeholder";
export default function NotificationsHelpPage() {
return (
<HelpArticle
title="Notifications"
description="Configure push notifications and reminders to stay on top of your home maintenance schedule."
>
<HelpSection id="types" title="Notification Types">
<p>honeyDue sends notifications for important events:</p>
<ul className="list-disc list-inside space-y-2 ml-1">
<li><strong>Task due reminders</strong> Before a task&apos;s due date</li>
<li><strong>Overdue alerts</strong> When a task passes its due date</li>
<li><strong>Household activity</strong> When a member completes a task or joins your residence</li>
<li><strong>Warranty expiration</strong> Before a tracked warranty expires</li>
</ul>
<ScreenshotPlaceholder alt="Notification list showing various notification types" />
</HelpSection>
<HelpSection id="preferences" title="Notification Preferences">
<p>
Customize which notifications you receive from your account settings.
You can enable or disable each notification type independently.
</p>
<ScreenshotPlaceholder alt="Notification preferences toggles for each notification type" />
<ol className="list-decimal list-inside space-y-2 ml-1">
<li>Go to <strong>Settings</strong> <strong>Notifications</strong>.</li>
<li>Toggle each notification type on or off.</li>
<li>Changes take effect immediately.</li>
</ol>
</HelpSection>
<HelpSection id="push" title="Push Notifications">
<p>
Push notifications are delivered directly to your device even when
the app isn&apos;t open. On the mobile app, you&apos;ll be prompted to allow
notifications on first launch.
</p>
<HelpCallout type="tip">
Make sure notifications are enabled in your device&apos;s system settings.
If you&apos;re not receiving notifications, check Settings honeyDue
Notifications on your phone.
</HelpCallout>
</HelpSection>
<HelpSection id="email" title="Email Notifications">
<p>
Important notifications (like overdue tasks) can also be sent to your
registered email address. Email notifications use the same preferences
disable a category and it&apos;s disabled everywhere.
</p>
<HelpCallout type="note">
Check your spam folder if you&apos;re not seeing honeyDue emails. Adding
our email to your contacts helps ensure delivery.
</HelpCallout>
</HelpSection>
</HelpArticle>
);
}
+41
View File
@@ -0,0 +1,41 @@
import Link from "next/link";
import { helpNavItems } from "@/components/help/help-nav-data";
export default function HelpIndexPage() {
return (
<div className="max-w-3xl">
<header className="mb-10">
<h1 className="font-heading text-3xl md:text-4xl font-bold tracking-tight text-[#2D3436]">
Help Center
</h1>
<p className="mt-3 text-lg text-[#8A8F87] leading-relaxed">
Everything you need to know about managing your home with honeyDue.
</p>
</header>
<div className="grid sm:grid-cols-2 gap-4">
{helpNavItems.map((item) => (
<Link
key={item.href}
href={item.href}
className="group rounded-2xl border border-[#E8E3DC] bg-white p-5 transition-all hover:shadow-lg hover:shadow-[#2D3436]/[0.04] hover:-translate-y-0.5"
>
<div className="flex items-start gap-3.5">
<div className="inline-flex items-center justify-center size-10 rounded-xl bg-[#EDF2ED] text-[#6B8F71] shrink-0">
<item.icon className="size-5" />
</div>
<div>
<h2 className="font-heading text-base font-bold text-[#2D3436] group-hover:text-[#6B8F71] transition-colors">
{item.label}
</h2>
<p className="text-sm text-[#8A8F87] mt-0.5 leading-relaxed">
{item.description}
</p>
</div>
</div>
</Link>
))}
</div>
</div>
);
}
+79
View File
@@ -0,0 +1,79 @@
import { HelpArticle } from "@/components/help/help-article";
import { HelpSection } from "@/components/help/help-section";
import { HelpCallout } from "@/components/help/help-callout";
import { ScreenshotPlaceholder } from "@/components/help/screenshot-placeholder";
export default function ResidencesHelpPage() {
return (
<HelpArticle
title="Residences"
description="Add and manage your properties, invite household members, and keep each home organized."
>
<HelpSection id="adding" title="Adding a Residence">
<p>
A residence represents a property you want to manage. It could be your
house, apartment, rental property, or vacation home.
</p>
<ScreenshotPlaceholder alt="Add residence form with property name, address, and type fields" />
<ol className="list-decimal list-inside space-y-2 ml-1">
<li>Go to your <strong>Residences</strong> list.</li>
<li>Tap <strong>Add Residence</strong>.</li>
<li>Enter a name and optionally an address.</li>
<li>Select the property type.</li>
</ol>
<HelpCallout type="note">
The free plan includes one residence. Upgrade to Pro for unlimited
properties.
</HelpCallout>
</HelpSection>
<HelpSection id="editing" title="Editing a Residence">
<p>
Update your residence details at any time change the name, address,
or property type from the residence settings.
</p>
<ScreenshotPlaceholder alt="Residence edit form with updated fields" />
</HelpSection>
<HelpSection id="details" title="Residence Details">
<p>
Each residence has its own dashboard showing tasks, documents, and
contractors associated with that property. The kanban board gives you a
visual overview of all task statuses.
</p>
<ScreenshotPlaceholder alt="Residence detail page with kanban board and task summary" />
</HelpSection>
<HelpSection id="sharing" title="Sharing a Residence">
<p>
Invite household members by generating a share code. Anyone with the
code can join and collaborate on that property&apos;s maintenance.
</p>
<p>
See the <a href="/help/sharing" className="text-[#6B8F71] underline underline-offset-2 hover:text-[#5A7A60]">Sharing guide</a> for
step-by-step instructions on inviting members and managing access.
</p>
</HelpSection>
<HelpSection id="reports" title="Residence Reports">
<p>
Generate a PDF report summarizing your residence&apos;s maintenance
history completed tasks, upcoming items, and contractor information.
Great for insurance claims or when selling your home.
</p>
<ScreenshotPlaceholder alt="Download residence report button and PDF preview" />
</HelpSection>
<HelpSection id="deleting" title="Deleting a Residence">
<p>
Only the residence owner can delete a property. Deleting a residence
removes all associated tasks, documents, and contractor links.
</p>
<HelpCallout type="warning">
Deleting a residence is permanent and cannot be undone. Make sure to
download any reports or documents you need before deleting.
</HelpCallout>
</HelpSection>
</HelpArticle>
);
}
+111
View File
@@ -0,0 +1,111 @@
import { HelpArticle } from "@/components/help/help-article";
import { HelpSection } from "@/components/help/help-section";
import { HelpCallout } from "@/components/help/help-callout";
import { ScreenshotPlaceholder } from "@/components/help/screenshot-placeholder";
export default function SharingHelpPage() {
return (
<HelpArticle
title="Sharing & Household Members"
description="Invite family members to your residences, share contractor contacts, and collaborate on home maintenance together."
>
<HelpSection id="share-codes" title="Invite with Share Codes">
<p>
Every residence has a unique share code that lets you invite household
members. When someone joins with your code, they get full access to that
residence&apos;s tasks, documents, and contractors.
</p>
<ScreenshotPlaceholder alt="Share code dialog showing the invite code and copy button" />
<p>To generate a share code:</p>
<ol className="list-decimal list-inside space-y-2 ml-1">
<li>Open the residence you want to share.</li>
<li>Tap the <strong>Share</strong> button or go to residence settings.</li>
<li>Copy the generated code and send it to your household member.</li>
</ol>
<HelpCallout type="tip">
Share codes are single-use for security. Generate a new one for each person you invite.
</HelpCallout>
</HelpSection>
<HelpSection id="joining" title="Joining a Residence">
<p>
If someone shared a code with you, you can join their residence from
the residences screen.
</p>
<ol className="list-decimal list-inside space-y-2 ml-1">
<li>Go to your <strong>Residences</strong> list.</li>
<li>Tap <strong>Join Residence</strong>.</li>
<li>Enter the share code you received.</li>
<li>The residence will appear in your list immediately.</li>
</ol>
<ScreenshotPlaceholder alt="Join residence screen with share code input field" />
</HelpSection>
<HelpSection id="household-members" title="Managing Household Members">
<p>
Once someone joins your residence, they can view and manage tasks,
contractors, and documents for that property. All changes sync
automatically for everyone.
</p>
<ScreenshotPlaceholder alt="Residence members list showing household users" />
<HelpCallout type="note">
The residence owner can remove members at any time from the residence
settings page.
</HelpCallout>
</HelpSection>
<HelpSection id="contractor-sharing" title="Sharing Contractors">
<p>
Contractors can be shared between household members who are part of the
same residence. When you add a contractor to a residence, everyone in
that household can see and use them.
</p>
<p>
You can also export your contractors as a <strong>.honeydue file</strong> to
share with anyone even people who aren&apos;t in your household yet.
</p>
</HelpSection>
<HelpSection id="honeydue-files" title="The .honeydue File Format">
<p>
A <code className="text-sm bg-[#F2EFE9] px-1.5 py-0.5 rounded">.honeydue</code> file
is a portable way to share contractor contacts. It contains names,
specialties, phone numbers, and notes everything someone needs to
import your trusted pros.
</p>
<ScreenshotPlaceholder alt="Export contractors dialog with .honeydue file download" />
<p><strong>To export:</strong></p>
<ol className="list-decimal list-inside space-y-2 ml-1">
<li>Go to your <strong>Contractors</strong> list.</li>
<li>Tap the <strong>Export</strong> button.</li>
<li>Choose which contractors to include.</li>
<li>Download or share the .honeydue file.</li>
</ol>
<p className="mt-4"><strong>To import:</strong></p>
<ol className="list-decimal list-inside space-y-2 ml-1">
<li>Go to your <strong>Contractors</strong> list.</li>
<li>Tap <strong>Import</strong>.</li>
<li>Select a .honeydue file from your device.</li>
<li>Review the contractors and confirm the import.</li>
</ol>
<HelpCallout type="tip">
Importing won&apos;t create duplicates if a contractor with the same name
and phone number already exists, it will be skipped.
</HelpCallout>
</HelpSection>
<HelpSection id="privacy" title="Privacy & Permissions">
<p>
Your data stays private. Sharing a residence only gives access to that
specific property not your other homes or personal account details.
</p>
<ul className="list-disc list-inside space-y-2 ml-1">
<li>Household members can only see residences they&apos;ve been invited to.</li>
<li>Removing a member revokes their access immediately.</li>
<li>Exported .honeydue files contain only contractor data, never personal information.</li>
<li>Share codes expire and cannot be reused.</li>
</ul>
</HelpSection>
</HelpArticle>
);
}
+86
View File
@@ -0,0 +1,86 @@
import { HelpArticle } from "@/components/help/help-article";
import { HelpSection } from "@/components/help/help-section";
import { HelpCallout } from "@/components/help/help-callout";
import { ScreenshotPlaceholder } from "@/components/help/screenshot-placeholder";
export default function SubscriptionHelpPage() {
return (
<HelpArticle
title="Subscription & Plans"
description="Understand the free plan, Pro features, and how to manage your subscription."
>
<HelpSection id="free-plan" title="Free Plan">
<p>
honeyDue is free to use with generous limits for individual homeowners:
</p>
<ul className="list-disc list-inside space-y-2 ml-1">
<li><strong>1 residence</strong></li>
<li><strong>Up to 25 tasks</strong> per residence</li>
<li><strong>Up to 10 contractors</strong></li>
<li><strong>Up to 10 documents</strong> per residence</li>
<li>Full kanban board, recurring tasks, and sharing</li>
</ul>
<HelpCallout type="tip">
The free plan is fully functional not a trial. It&apos;s designed for
homeowners managing a single property.
</HelpCallout>
</HelpSection>
<HelpSection id="pro" title="Pro Features">
<p>
Upgrade to Pro for unlimited everything perfect for landlords,
property managers, or households with multiple properties.
</p>
<ScreenshotPlaceholder alt="Pro plan feature comparison showing free vs pro limits" />
<ul className="list-disc list-inside space-y-2 ml-1">
<li><strong>Unlimited residences</strong></li>
<li><strong>Unlimited tasks</strong> per residence</li>
<li><strong>Unlimited contractors</strong></li>
<li><strong>Unlimited documents</strong> per residence</li>
<li><strong>Residence reports</strong> PDF exports of maintenance history</li>
<li><strong>Priority support</strong></li>
</ul>
</HelpSection>
<HelpSection id="upgrading" title="Upgrading to Pro">
<p>
Upgrade from your account settings or when you hit a free plan limit.
Subscriptions are managed through the App Store (iOS) or Google Play
(Android).
</p>
<ScreenshotPlaceholder alt="Subscription settings page with upgrade button" />
<ol className="list-decimal list-inside space-y-2 ml-1">
<li>Go to <strong>Settings</strong> <strong>Subscription</strong>.</li>
<li>Tap <strong>Upgrade to Pro</strong>.</li>
<li>Choose monthly or annual billing.</li>
<li>Confirm the purchase through your app store.</li>
</ol>
</HelpSection>
<HelpSection id="managing" title="Managing Your Subscription">
<p>
View your current plan, billing period, and renewal date from the
subscription settings. You can cancel at any time your Pro features
remain active until the end of the current billing period.
</p>
<HelpCallout type="note">
Subscriptions are managed by Apple or Google to cancel, go to your
device&apos;s subscription settings. Your data is never deleted when you
downgrade.
</HelpCallout>
</HelpSection>
<HelpSection id="restoring" title="Restoring Purchases">
<p>
If you reinstall the app or switch devices, you can restore your
existing subscription:
</p>
<ol className="list-decimal list-inside space-y-2 ml-1">
<li>Go to <strong>Settings</strong> <strong>Subscription</strong>.</li>
<li>Tap <strong>Restore Purchases</strong>.</li>
<li>Your Pro status will be restored if an active subscription is found.</li>
</ol>
</HelpSection>
</HelpArticle>
);
}
+101
View File
@@ -0,0 +1,101 @@
import { HelpArticle } from "@/components/help/help-article";
import { HelpSection } from "@/components/help/help-section";
import { HelpCallout } from "@/components/help/help-callout";
import { ScreenshotPlaceholder } from "@/components/help/screenshot-placeholder";
export default function TasksHelpPage() {
return (
<HelpArticle
title="Tasks"
description="Create, organize, and complete home maintenance tasks with kanban boards, recurring schedules, and priority tracking."
>
<HelpSection id="creating" title="Creating Tasks">
<p>
Tasks represent anything you need to do for your home from replacing
a filter to scheduling an annual inspection.
</p>
<ScreenshotPlaceholder alt="Create task form with title, category, priority, due date, and frequency fields" />
<p>When creating a task, you can set:</p>
<ul className="list-disc list-inside space-y-2 ml-1">
<li><strong>Title</strong> A short description (e.g., &quot;Replace HVAC filter&quot;)</li>
<li><strong>Category</strong> Group tasks by area (plumbing, electrical, exterior, etc.)</li>
<li><strong>Priority</strong> Low, medium, or high</li>
<li><strong>Due date</strong> When the task should be completed</li>
<li><strong>Frequency</strong> For recurring tasks (weekly, monthly, quarterly, etc.)</li>
<li><strong>Notes</strong> Additional details or instructions</li>
<li><strong>Contractor</strong> Link a service provider to the task</li>
</ul>
<HelpCallout type="tip">
Use task templates for common maintenance items. Start typing and
suggestions will appear based on popular home maintenance tasks.
</HelpCallout>
</HelpSection>
<HelpSection id="kanban" title="Kanban Board">
<p>
Tasks are organized on a kanban board with columns that automatically
sort based on due dates and completion status:
</p>
<ScreenshotPlaceholder alt="Kanban board showing overdue, due soon, in progress, not started, and completed columns" />
<ul className="list-disc list-inside space-y-2 ml-1">
<li><strong>Overdue</strong> Tasks past their due date</li>
<li><strong>Due Soon</strong> Tasks due within the next 30 days</li>
<li><strong>In Progress</strong> Tasks you&apos;ve started working on</li>
<li><strong>Not Started</strong> Future tasks with no due date pressure</li>
<li><strong>Completed</strong> Finished tasks</li>
</ul>
<p>
Drag and drop tasks between columns to update their status, or tap a
task to edit its details.
</p>
</HelpSection>
<HelpSection id="recurring" title="Recurring Tasks">
<p>
Many home maintenance items happen on a schedule. Set a frequency when
creating a task, and honeyDue will automatically create the next
occurrence after you complete the current one.
</p>
<ScreenshotPlaceholder alt="Task with recurring frequency set to monthly" />
<p>Available frequencies include:</p>
<ul className="list-disc list-inside space-y-2 ml-1">
<li>Weekly</li>
<li>Bi-weekly</li>
<li>Monthly</li>
<li>Quarterly</li>
<li>Semi-annually</li>
<li>Annually</li>
</ul>
<HelpCallout type="note">
When you complete a recurring task, the next due date is automatically
calculated based on the frequency. The task moves back to the
appropriate kanban column.
</HelpCallout>
</HelpSection>
<HelpSection id="completing" title="Completing Tasks">
<p>
Mark a task as complete by tapping the completion button. You can
optionally add a note and attach photos documenting the work.
</p>
<ScreenshotPlaceholder alt="Task completion dialog with notes field and photo upload" />
<p>
Completion records are saved as a history useful for tracking when
maintenance was last performed and what was done.
</p>
</HelpSection>
<HelpSection id="contractors" title="Linking Contractors">
<p>
Associate a contractor with a task to remember who did the work. This
is especially helpful for recurring tasks where you want to call the
same plumber or electrician each time.
</p>
<HelpCallout type="tip">
Linked contractors appear on the task card for quick access to their
contact information.
</HelpCallout>
</HelpSection>
</HelpArticle>
);
}
+5 -5
View File
@@ -26,21 +26,21 @@ const geistMono = Geist_Mono({
export const metadata: Metadata = {
title: {
default: "Casera — Home Maintenance Made Simple",
template: "%s | Casera",
default: "honeyDue — Home Maintenance Made Simple",
template: "%s | honeyDue",
},
description:
"Track tasks, organize contractors, store documents. Manage your home maintenance in one place.",
openGraph: {
title: "Casera — Home Maintenance Made Simple",
title: "honeyDue — Home Maintenance Made Simple",
description:
"Track tasks, organize contractors, store documents. Manage your home maintenance in one place.",
type: "website",
siteName: "Casera",
siteName: "honeyDue",
},
twitter: {
card: "summary_large_image",
title: "Casera — Home Maintenance Made Simple",
title: "honeyDue — Home Maintenance Made Simple",
description: "Home Maintenance Made Simple",
},
};
+1 -1
View File
@@ -41,7 +41,7 @@ export default function OnboardingLayout({
{/* Logo */}
<div className="mb-2">
<h1 className="text-2xl font-bold tracking-tight text-primary">
Casera
honeyDue
</h1>
</div>
+15 -11
View File
@@ -89,13 +89,13 @@ export default function HomePage() {
<Link href="/" className="flex items-center gap-2.5">
<Image
src="/logo.png"
alt="Casera"
alt="honeyDue"
width={32}
height={32}
className="rounded-lg"
/>
<span className="font-heading text-xl font-bold tracking-tight text-[#2D3436]">
Casera
honeyDue
</span>
</Link>
@@ -144,13 +144,12 @@ export default function HomePage() {
<div className="absolute inset-0 pointer-events-none">
<div className="absolute top-20 right-[-10%] w-[600px] h-[600px] rounded-full bg-[#6B8F71]/[0.04] blur-3xl" />
<div className="absolute bottom-0 left-[-5%] w-[400px] h-[400px] rounded-full bg-[#C4856A]/[0.03] blur-3xl" />
{/* Subtle grid pattern */}
{/* Subtle honeycomb pattern */}
<div
className="absolute inset-0 opacity-[0.03]"
className="absolute inset-0 opacity-[0.06]"
style={{
backgroundImage:
"linear-gradient(#2D3436 1px, transparent 1px), linear-gradient(90deg, #2D3436 1px, transparent 1px)",
backgroundSize: "64px 64px",
backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='60' height='103.92'%3E%3Cpolygon points='30,0 60,17.32 60,51.96 30,69.28 0,51.96 0,17.32' fill='none' stroke='%23C4856A' stroke-width='0.8'/%3E%3Cpolygon points='60,51.96 90,69.28 90,103.92 60,121.24 30,103.92 30,69.28' fill='none' stroke='%23C4856A' stroke-width='0.8'/%3E%3C/svg%3E")`,
backgroundSize: "60px 103.92px",
}}
/>
</div>
@@ -272,7 +271,7 @@ export default function HomePage() {
Everything your home needs, nothing it doesn&apos;t.
</h2>
<p className="mt-4 text-lg text-[#8A8F87] leading-relaxed">
Casera brings all your home maintenance into one clear,
honeyDue brings all your home maintenance into one clear,
organized space. No bloat, no learning curve.
</p>
</div>
@@ -406,13 +405,13 @@ export default function HomePage() {
<Link href="/" className="flex items-center gap-2.5 mb-4">
<Image
src="/logo.png"
alt="Casera"
alt="honeyDue"
width={28}
height={28}
className="rounded-md"
/>
<span className="font-heading text-lg font-bold text-white">
Casera
honeyDue
</span>
</Link>
<p className="text-sm leading-relaxed">
@@ -442,6 +441,11 @@ export default function HomePage() {
How It Works
</a>
</li>
<li>
<Link href="/help" className="hover:text-white transition-colors">
Help Center
</Link>
</li>
</ul>
</div>
@@ -485,7 +489,7 @@ export default function HomePage() {
<div className="mt-16 pt-8 border-t border-white/5 flex flex-col sm:flex-row items-center justify-between gap-4">
<p className="text-xs">
&copy; {new Date().getFullYear()} Casera. All rights reserved.
&copy; {new Date().getFullYear()} honeyDue. All rights reserved.
</p>
<p className="text-xs">
Made for homeowners, by homeowners.
+1 -1
View File
@@ -11,7 +11,7 @@ export default function SubscriptionSuccessPage() {
<h2 className="text-lg font-semibold">Thank you!</h2>
<p className="mt-2 text-muted-foreground text-center max-w-md">
Your Pro subscription is now active. Enjoy unlimited access to all
Casera features.
honeyDue features.
</p>
<Button asChild className="mt-6">
<Link href="/app/settings/subscription">Back to Settings</Link>
+57
View File
@@ -0,0 +1,57 @@
"use client";
// ---------------------------------------------------------------------------
// AuthGate — client-side route protection for authenticated app routes
// ---------------------------------------------------------------------------
// Identity is owned by Ory Kratos. Route protection is done by checking the
// live Kratos session via `whoami()`:
// - 200 / active session -> render the protected content
// - no session -> redirect to /login
//
// The Next.js middleware does a cheap cookie-presence pre-filter, but only a
// `whoami` call can confirm the session is actually valid (not expired). This
// gate is the authoritative check for the browser.
// ---------------------------------------------------------------------------
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { Loader2 } from "lucide-react";
import { useAuthStore } from "@/stores/auth";
export function AuthGate({ children }: { children: React.ReactNode }) {
const router = useRouter();
const hydrate = useAuthStore((s) => s.hydrate);
const [checked, setChecked] = useState(false);
const [allowed, setAllowed] = useState(false);
useEffect(() => {
let cancelled = false;
hydrate().then(() => {
if (cancelled) return;
const { isAuthenticated } = useAuthStore.getState();
if (isAuthenticated) {
setAllowed(true);
setChecked(true);
} else {
// No valid Kratos session — bounce to login.
router.replace("/login");
}
});
return () => {
cancelled = true;
};
}, [hydrate, router]);
if (!checked || !allowed) {
return (
<div className="flex min-h-screen items-center justify-center bg-background">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
);
}
return <>{children}</>;
}
+276
View File
@@ -0,0 +1,276 @@
"use client";
// ---------------------------------------------------------------------------
// KratosFlowForm — generic renderer for an Ory Kratos self-service flow
// ---------------------------------------------------------------------------
// Renders `flow.ui.nodes` into a form, submits to `flow.ui.action`, and shows
// the validation messages Kratos attaches to nodes / the flow.
//
// Node groups handled:
// - default / password / code / profile -> rendered as inputs & the submit
// - oidc -> rendered as social sign-in buttons
// - hidden inputs (csrf_token, etc.) -> rendered as <input type="hidden">
// ---------------------------------------------------------------------------
import { useMemo, useState } from "react";
import { Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { PasswordInput } from "@/components/forms/password-input";
import { submitFlow, type KratosFlow, type KratosUiNode } from "@/lib/kratos";
// ---------------------------------------------------------------------------
// Node classification helpers
// ---------------------------------------------------------------------------
function isHidden(node: KratosUiNode): boolean {
return node.attributes.node_type === "input" && node.attributes.type === "hidden";
}
function isSubmit(node: KratosUiNode): boolean {
return (
node.attributes.node_type === "input" &&
(node.attributes.type === "submit" || node.attributes.type === "button")
);
}
function isOidc(node: KratosUiNode): boolean {
return node.group === "oidc";
}
/** Human label for a node, falling back to the field name. */
function nodeLabel(node: KratosUiNode): string {
return (
node.meta?.label?.text ||
node.attributes.label?.text ||
node.attributes.name ||
""
);
}
/** Pretty provider name for an oidc button ("apple" -> "Apple"). */
function providerName(node: KratosUiNode): string {
const raw =
String(node.attributes.value ?? "") ||
nodeLabel(node).replace(/^sign in with /i, "");
return raw.charAt(0).toUpperCase() + raw.slice(1);
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
interface KratosFlowFormProps {
flow: KratosFlow;
/**
* Called after a submit. Receives the HTTP status, the parsed body, and
* whether the request was a 2xx. The page decides what to do next
* (redirect, advance a step, re-render the returned flow, ...).
*/
onResult: (result: { status: number; ok: boolean; data: unknown }) => void;
/** Optional override for the primary submit button label. */
submitLabel?: string;
/** Field names to omit from rendering (e.g. already-known email). */
hideFields?: string[];
}
export function KratosFlowForm({
flow,
onResult,
submitLabel,
hideFields = [],
}: KratosFlowFormProps) {
const [submitting, setSubmitting] = useState(false);
const [activeSubmit, setActiveSubmit] = useState<string | null>(null);
const { hiddenNodes, oidcNodes, fieldNodes, submitNodes } = useMemo(() => {
const hidden: KratosUiNode[] = [];
const oidc: KratosUiNode[] = [];
const fields: KratosUiNode[] = [];
const submits: KratosUiNode[] = [];
for (const node of flow.ui.nodes) {
if (isHidden(node)) hidden.push(node);
else if (isOidc(node)) oidc.push(node);
else if (isSubmit(node)) submits.push(node);
else if (node.attributes.node_type === "input") fields.push(node);
}
return {
hiddenNodes: hidden,
oidcNodes: oidc,
fieldNodes: fields.filter(
(n) => !hideFields.includes(n.attributes.name ?? ""),
),
submitNodes: submits,
};
}, [flow, hideFields]);
/** The primary (non-oidc) submit button, if any. */
const primarySubmit = submitNodes.find((n) => n.group !== "oidc");
async function runSubmit(
formEl: HTMLFormElement,
submitName?: string,
submitValue?: string,
) {
if (submitting) return;
setSubmitting(true);
setActiveSubmit(submitValue ?? submitName ?? null);
// Collect every input value from the form.
const body: Record<string, string> = {};
const data = new FormData(formEl);
for (const [key, value] of data.entries()) {
if (typeof value === "string") body[key] = value;
}
// The clicked submit button's name/value must be included so Kratos knows
// which method was used (e.g. method=password, or provider=google).
if (submitName && submitValue !== undefined) {
body[submitName] = submitValue;
}
try {
const result = await submitFlow(flow.ui.action, flow.ui.method, body);
onResult(result);
} finally {
setSubmitting(false);
setActiveSubmit(null);
}
}
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const form = e.currentTarget;
if (primarySubmit?.attributes.name) {
runSubmit(
form,
primarySubmit.attributes.name,
String(primarySubmit.attributes.value ?? ""),
);
} else {
runSubmit(form);
}
}
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
{/* Hidden inputs (csrf_token, flow id, method markers, ...) */}
{hiddenNodes.map((node, i) => (
<input
key={`${node.attributes.name}-${i}`}
type="hidden"
name={node.attributes.name}
defaultValue={String(node.attributes.value ?? "")}
/>
))}
{/* Visible input fields */}
{fieldNodes.map((node) => (
<FieldNode key={node.attributes.name} node={node} />
))}
{/* Primary submit */}
{primarySubmit && (
<Button type="submit" className="w-full" disabled={submitting}>
{submitting && activeSubmit === String(primarySubmit.attributes.value ?? "") && (
<Loader2 className="animate-spin" />
)}
{submitLabel ||
nodeLabel(primarySubmit) ||
"Continue"}
</Button>
)}
{/* Social sign-in (oidc) */}
{oidcNodes.length > 0 && (
<div className="flex flex-col gap-2">
{(fieldNodes.length > 0 || primarySubmit) && (
<div className="relative my-1 text-center">
<span className="relative z-10 bg-card px-2 text-xs text-muted-foreground">
or continue with
</span>
<span className="absolute left-0 top-1/2 h-px w-full -translate-y-1/2 bg-border" />
</div>
)}
{oidcNodes.map((node) => (
<Button
key={String(node.attributes.value)}
type="submit"
variant="outline"
className="w-full"
disabled={submitting}
formNoValidate
onClick={(e) => {
e.preventDefault();
runSubmit(
e.currentTarget.form as HTMLFormElement,
node.attributes.name,
String(node.attributes.value ?? ""),
);
}}
>
{submitting && activeSubmit === String(node.attributes.value ?? "") && (
<Loader2 className="animate-spin" />
)}
{nodeLabel(node) || `Continue with ${providerName(node)}`}
</Button>
))}
</div>
)}
</form>
);
}
// ---------------------------------------------------------------------------
// FieldNode — a single visible input + its label + node-level messages
// ---------------------------------------------------------------------------
function FieldNode({ node }: { node: KratosUiNode }) {
const name = node.attributes.name ?? "";
const type = node.attributes.type ?? "text";
const label = nodeLabel(node);
const isPassword = type === "password";
const errors = node.messages?.filter((m) => m.type === "error") ?? [];
const hasError = errors.length > 0;
const fieldId = `kratos-field-${name}`;
return (
<div className="flex flex-col gap-2">
{label && <Label htmlFor={fieldId}>{label}</Label>}
{isPassword ? (
<PasswordInput
id={fieldId}
name={name}
required={node.attributes.required}
disabled={node.attributes.disabled}
autoComplete={node.attributes.autocomplete ?? "current-password"}
aria-invalid={hasError}
aria-describedby={hasError ? `${fieldId}-error` : undefined}
/>
) : (
<Input
id={fieldId}
name={name}
type={type}
required={node.attributes.required}
disabled={node.attributes.disabled}
autoComplete={node.attributes.autocomplete}
defaultValue={String(node.attributes.value ?? "")}
aria-invalid={hasError}
aria-describedby={hasError ? `${fieldId}-error` : undefined}
/>
)}
{errors.map((m, i) => (
<p
key={m.id ?? i}
id={`${fieldId}-error`}
role="alert"
className="text-sm text-destructive"
>
{m.text}
</p>
))}
</div>
);
}
+70
View File
@@ -0,0 +1,70 @@
"use client";
// ---------------------------------------------------------------------------
// KratosMessages — renders flow-level messages (ui.messages) as banners
// ---------------------------------------------------------------------------
// Node-level (per-field) messages are rendered inline by KratosFlowForm.
// This component surfaces the flow-wide messages: "An email containing a
// recovery code has been sent", "The recovery code is invalid or has already
// been used", etc.
// ---------------------------------------------------------------------------
import { CheckCircle2, CircleAlert, Info } from "lucide-react";
import type { KratosFlow, KratosMessage } from "@/lib/kratos";
function variantClasses(type: string): string {
switch (type) {
case "error":
return "bg-destructive/10 text-destructive";
case "success":
return "bg-green-500/10 text-green-700 dark:text-green-400";
default:
return "bg-primary/10 text-primary";
}
}
function Icon({ type }: { type: string }) {
if (type === "error") return <CircleAlert className="size-4 shrink-0" />;
if (type === "success") return <CheckCircle2 className="size-4 shrink-0" />;
return <Info className="size-4 shrink-0" />;
}
interface KratosMessagesProps {
/** Either the whole flow (ui.messages used) or an explicit message list. */
flow?: KratosFlow | null;
messages?: KratosMessage[];
/** Extra ad-hoc error string (e.g. a network failure outside Kratos). */
error?: string | null;
}
export function KratosMessages({ flow, messages, error }: KratosMessagesProps) {
const list: KratosMessage[] = messages ?? flow?.ui.messages ?? [];
if (list.length === 0 && !error) return null;
return (
<div className="flex flex-col gap-2">
{error && (
<div
role="alert"
aria-live="assertive"
className="flex items-center gap-2 rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive"
>
<CircleAlert className="size-4 shrink-0" />
{error}
</div>
)}
{list.map((m, i) => (
<div
key={m.id ?? i}
role={m.type === "error" ? "alert" : "status"}
aria-live={m.type === "error" ? "assertive" : "polite"}
className={`flex items-center gap-2 rounded-md px-3 py-2 text-sm ${variantClasses(m.type)}`}
>
<Icon type={m.type} />
{m.text}
</div>
))}
</div>
);
}
+71 -71
View File
@@ -16,80 +16,80 @@ interface ContractorCardProps {
export function ContractorCard({ contractor, onToggleFavorite }: ContractorCardProps) {
const { basePath } = useDataProvider();
return (
<div className="group rounded-2xl border border-border bg-card p-5 transition-all duration-200 hover:shadow-[var(--shadow-warm-md)] hover:-translate-y-0.5">
<div className="flex items-start justify-between mb-3">
<div className="flex items-start gap-3 min-w-0 flex-1">
<div className="size-10 rounded-full bg-primary/10 flex items-center justify-center shrink-0 text-primary font-bold text-sm">
{contractor.name[0]?.toUpperCase()}
<Link href={`${basePath}/contractors/${contractor.id}`} className="block h-full group">
<div className="h-full rounded-2xl border border-border bg-card p-5 transition-all duration-200 hover:shadow-[var(--shadow-warm-md)] hover:-translate-y-0.5 hover:border-primary/30 flex flex-col">
<div className="flex items-start justify-between mb-3">
<div className="flex items-start gap-3 min-w-0 flex-1">
<div className="size-10 rounded-full bg-primary/10 flex items-center justify-center shrink-0 text-primary font-bold text-sm">
{contractor.name[0]?.toUpperCase()}
</div>
<div className="min-w-0">
<span className="font-heading font-bold text-base leading-tight group-hover:text-primary transition-colors line-clamp-1">
{contractor.name}
</span>
{contractor.company && (
<p className="text-sm text-muted-foreground mt-0.5 truncate">{contractor.company}</p>
)}
</div>
</div>
<div className="min-w-0">
<Link
href={`${basePath}/contractors/${contractor.id}`}
className="font-heading font-bold text-base leading-tight hover:text-primary transition-colors line-clamp-1"
<Button
variant="ghost"
size="icon"
className="size-8 shrink-0 -mr-1 -mt-1"
aria-label={contractor.is_favorite ? "Remove from favorites" : "Add to favorites"}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onToggleFavorite(contractor.id);
}}
>
<Star
aria-hidden="true"
className={
contractor.is_favorite
? "size-4 fill-amber-400 text-amber-400"
: "size-4 text-muted-foreground hover:text-amber-400 transition-colors"
}
/>
</Button>
</div>
{contractor.specialties.length > 0 && (
<div className="flex flex-wrap gap-1.5 mb-4">
{contractor.specialties.map((s) => (
<Badge key={s.id} variant="secondary" className="rounded-lg text-xs">
{s.icon && <span className="mr-1">{s.icon}</span>}
{s.name}
</Badge>
))}
</div>
)}
<div className="border-t border-border/60 pt-3 mt-auto flex items-center gap-4">
{contractor.phone && (
<a
href={`tel:${contractor.phone}`}
aria-label={`Call ${contractor.name}`}
onClick={(e) => e.stopPropagation()}
className="inline-flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-primary transition-colors"
>
{contractor.name}
</Link>
{contractor.company && (
<p className="text-sm text-muted-foreground mt-0.5 truncate">{contractor.company}</p>
)}
</div>
<Phone className="size-3" aria-hidden="true" />
Call
</a>
)}
{contractor.email && (
<a
href={`mailto:${contractor.email}`}
aria-label={`Email ${contractor.name}`}
onClick={(e) => e.stopPropagation()}
className="inline-flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-primary transition-colors"
>
<Mail className="size-3" aria-hidden="true" />
Email
</a>
)}
</div>
<Button
variant="ghost"
size="icon"
className="size-8 shrink-0 -mr-1 -mt-1"
aria-label={contractor.is_favorite ? "Remove from favorites" : "Add to favorites"}
onClick={(e) => {
e.preventDefault();
onToggleFavorite(contractor.id);
}}
>
<Star
aria-hidden="true"
className={
contractor.is_favorite
? "size-4 fill-amber-400 text-amber-400"
: "size-4 text-muted-foreground hover:text-amber-400 transition-colors"
}
/>
</Button>
</div>
{contractor.specialties.length > 0 && (
<div className="flex flex-wrap gap-1.5 mb-4">
{contractor.specialties.map((s) => (
<Badge key={s.id} variant="secondary" className="rounded-lg text-xs">
{s.icon && <span className="mr-1">{s.icon}</span>}
{s.name}
</Badge>
))}
</div>
)}
<div className="border-t border-border/60 pt-3 mt-3 flex items-center gap-4">
{contractor.phone && (
<a
href={`tel:${contractor.phone}`}
aria-label={`Call ${contractor.name}`}
onClick={(e) => e.stopPropagation()}
className="inline-flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-primary transition-colors"
>
<Phone className="size-3" aria-hidden="true" />
Call
</a>
)}
{contractor.email && (
<a
href={`mailto:${contractor.email}`}
aria-label={`Email ${contractor.name}`}
onClick={(e) => e.stopPropagation()}
className="inline-flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-primary transition-colors"
>
<Mail className="size-3" aria-hidden="true" />
Email
</a>
)}
</div>
</div>
</Link>
);
}
@@ -4,7 +4,7 @@ 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 { Search, Star, X } from "lucide-react";
import { cn } from "@/lib/utils";
interface ContractorFiltersProps {
@@ -40,13 +40,26 @@ export function ContractorFilters({
</div>
{/* Specialty filter */}
<div className="w-full sm:w-48">
<LookupSelect
items={specialties}
value={specialtyId}
onValueChange={onSpecialtyChange}
placeholder="All specialties"
/>
<div className="flex items-center gap-1.5">
<div className="w-full sm:w-48">
<LookupSelect
items={specialties}
value={specialtyId}
onValueChange={onSpecialtyChange}
placeholder="All specialties"
/>
</div>
{specialtyId != null && (
<Button
variant="ghost"
size="icon"
className="size-8 shrink-0 text-muted-foreground hover:text-foreground"
onClick={() => onSpecialtyChange(undefined)}
>
<X className="size-4" />
<span className="sr-only">Clear specialty filter</span>
</Button>
)}
</div>
{/* Favorites toggle */}
+1 -1
View File
@@ -13,7 +13,7 @@ export function DemoBanner() {
return (
<div className="sticky top-0 z-50 flex items-center justify-center gap-3 border-b border-brand-clay/20 bg-brand-clay-light/80 px-4 py-2.5 text-sm text-[#92400E] backdrop-blur-xl">
<p className="font-medium">
You&apos;re exploring Casera in demo mode. Changes aren&apos;t saved.
You&apos;re exploring honeyDue in demo mode. Changes aren&apos;t saved.
</p>
<Button size="xs" className="rounded-full" asChild>
<Link href="/register">Sign Up Free</Link>
+2 -2
View File
@@ -23,13 +23,13 @@ export function AuthFormWrapper({
<Link href="/" className="flex items-center gap-2.5">
<Image
src="/logo.png"
alt="Casera"
alt="honeyDue"
width={32}
height={32}
className="rounded-lg"
/>
<span className="font-heading text-xl font-bold text-foreground">
Casera
honeyDue
</span>
</Link>
</div>
-107
View File
@@ -1,107 +0,0 @@
"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>
);
}
+21
View File
@@ -0,0 +1,21 @@
interface HelpArticleProps {
title: string;
description: string;
children: React.ReactNode;
}
export function HelpArticle({ title, description, children }: HelpArticleProps) {
return (
<article className="max-w-3xl">
<header className="mb-10">
<h1 className="font-heading text-3xl md:text-4xl font-bold tracking-tight text-[#2D3436]">
{title}
</h1>
<p className="mt-3 text-lg text-[#8A8F87] leading-relaxed">
{description}
</p>
</header>
<div className="space-y-12">{children}</div>
</article>
);
}
+41
View File
@@ -0,0 +1,41 @@
import { Lightbulb, Info, AlertTriangle } from "lucide-react";
interface HelpCalloutProps {
type?: "tip" | "note" | "warning";
children: React.ReactNode;
}
const calloutConfig = {
tip: {
icon: Lightbulb,
label: "Tip",
classes: "bg-[#EDF2ED] border-[#6B8F71]/20 text-[#2D3436]",
iconClass: "text-[#6B8F71]",
},
note: {
icon: Info,
label: "Note",
classes: "bg-[#F2EFE9] border-[#C4856A]/20 text-[#2D3436]",
iconClass: "text-[#8A8F87]",
},
warning: {
icon: AlertTriangle,
label: "Warning",
classes: "bg-[#FDF3EE] border-[#C4856A]/30 text-[#2D3436]",
iconClass: "text-[#C4856A]",
},
};
export function HelpCallout({ type = "tip", children }: HelpCalloutProps) {
const config = calloutConfig[type];
const Icon = config.icon;
return (
<div
className={`my-6 flex gap-3 rounded-xl border p-4 ${config.classes}`}
>
<Icon className={`size-5 shrink-0 mt-0.5 ${config.iconClass}`} />
<div className="text-sm leading-relaxed">{children}</div>
</div>
);
}
+39
View File
@@ -0,0 +1,39 @@
import Link from "next/link";
import Image from "next/image";
export function HelpFooter() {
return (
<footer className="bg-[#2D3436] text-[#9A9E97]">
<div className="max-w-7xl mx-auto px-6 py-12">
<div className="flex flex-col sm:flex-row items-center justify-between gap-4">
<Link href="/" className="flex items-center gap-2.5">
<Image
src="/logo.png"
alt="honeyDue"
width={24}
height={24}
className="rounded-md"
/>
<span className="font-heading text-base font-bold text-white">
honeyDue
</span>
</Link>
<div className="flex items-center gap-6 text-sm">
<Link href="/" className="hover:text-white transition-colors">
Home
</Link>
<Link href="/demo" className="hover:text-white transition-colors">
Demo
</Link>
<Link href="/register" className="hover:text-white transition-colors">
Get Started
</Link>
</div>
</div>
<div className="mt-8 pt-6 border-t border-white/5 text-center text-xs">
&copy; {new Date().getFullYear()} honeyDue. All rights reserved.
</div>
</div>
</footer>
);
}
+95
View File
@@ -0,0 +1,95 @@
"use client";
import Link from "next/link";
import Image from "next/image";
import { Menu } from "lucide-react";
import { useState } from "react";
import { HelpSidebar } from "./help-sidebar";
export function HelpHeader() {
const [mobileOpen, setMobileOpen] = useState(false);
return (
<>
<header className="sticky top-0 z-50 bg-[#FAFAF7]/80 backdrop-blur-xl border-b border-[#E8E3DC]/60">
<div className="max-w-7xl mx-auto px-6 flex items-center justify-between h-16">
<div className="flex items-center gap-3">
<button
onClick={() => setMobileOpen(true)}
className="lg:hidden p-2 -ml-2 rounded-lg hover:bg-[#F2EFE9] transition-colors"
aria-label="Open navigation"
>
<Menu className="size-5 text-[#2D3436]" />
</button>
<Link href="/" className="flex items-center gap-2.5">
<Image
src="/logo.png"
alt="honeyDue"
width={32}
height={32}
className="rounded-lg"
/>
<span className="font-heading text-xl font-bold tracking-tight text-[#2D3436]">
honeyDue
</span>
</Link>
<span className="text-[#E8E3DC] mx-1 hidden sm:inline">/</span>
<Link
href="/help"
className="font-heading text-sm font-semibold text-[#6B8F71] hidden sm:inline"
>
Help Center
</Link>
</div>
<div className="hidden md:flex items-center gap-6">
<Link
href="/#features"
className="text-sm font-medium text-[#8A8F87] hover:text-[#2D3436] transition-colors"
>
Features
</Link>
<Link
href="/demo"
className="text-sm font-medium text-[#8A8F87] hover:text-[#2D3436] transition-colors"
>
Demo
</Link>
<Link
href="/login"
className="text-sm font-medium text-[#8A8F87] hover:text-[#2D3436] transition-colors"
>
Sign In
</Link>
</div>
</div>
</header>
{/* Mobile sidebar overlay */}
{mobileOpen && (
<div className="fixed inset-0 z-50 lg:hidden">
<div
className="absolute inset-0 bg-black/30"
onClick={() => setMobileOpen(false)}
/>
<div className="absolute left-0 top-0 bottom-0 w-72 bg-[#FAFAF7] border-r border-[#E8E3DC] p-6 overflow-y-auto">
<div className="flex items-center justify-between mb-6">
<span className="font-heading text-sm font-semibold text-[#6B8F71]">
Help Center
</span>
<button
onClick={() => setMobileOpen(false)}
className="p-1 rounded-lg hover:bg-[#F2EFE9] text-[#8A8F87]"
>
&times;
</button>
</div>
<div onClick={() => setMobileOpen(false)}>
<HelpSidebar />
</div>
</div>
</div>
)}
</>
);
}
+75
View File
@@ -0,0 +1,75 @@
import {
BookOpen,
Home,
CheckSquare,
HardHat,
FileText,
Users,
LayoutDashboard,
Bell,
CreditCard,
UserCircle,
} from "lucide-react";
export const helpNavItems = [
{
label: "Getting Started",
href: "/help/getting-started",
icon: BookOpen,
description: "Set up your account and add your first home.",
},
{
label: "Residences",
href: "/help/residences",
icon: Home,
description: "Manage your properties and household members.",
},
{
label: "Tasks",
href: "/help/tasks",
icon: CheckSquare,
description: "Track maintenance with kanban boards and schedules.",
},
{
label: "Contractors",
href: "/help/contractors",
icon: HardHat,
description: "Organize your service providers and share contacts.",
},
{
label: "Documents",
href: "/help/documents",
icon: FileText,
description: "Store warranties, manuals, and important files.",
},
{
label: "Sharing",
href: "/help/sharing",
icon: Users,
description: "Invite household members and share contractors.",
},
{
label: "Dashboard",
href: "/help/dashboard",
icon: LayoutDashboard,
description: "Understand your dashboard overview and metrics.",
},
{
label: "Notifications",
href: "/help/notifications",
icon: Bell,
description: "Configure push notifications and reminders.",
},
{
label: "Subscription",
href: "/help/subscription",
icon: CreditCard,
description: "Free vs Pro plans and managing your subscription.",
},
{
label: "Account",
href: "/help/account",
icon: UserCircle,
description: "Profile, password, theme, and account settings.",
},
];
+18
View File
@@ -0,0 +1,18 @@
interface HelpSectionProps {
id: string;
title: string;
children: React.ReactNode;
}
export function HelpSection({ id, title, children }: HelpSectionProps) {
return (
<section id={id}>
<h2 className="font-heading text-xl md:text-2xl font-bold tracking-tight text-[#2D3436] mb-4">
{title}
</h2>
<div className="space-y-4 text-[#2D3436]/80 leading-relaxed">
{children}
</div>
</section>
);
}
+33
View File
@@ -0,0 +1,33 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { helpNavItems } from "./help-nav-data";
export function HelpSidebar() {
const pathname = usePathname();
return (
<nav className="flex flex-col gap-1">
{helpNavItems.map((item) => {
const isActive = pathname === item.href;
return (
<Link
key={item.href}
href={item.href}
className={`flex items-center gap-2.5 rounded-xl px-3.5 py-2.5 text-sm font-medium transition-all duration-200 ${
isActive
? "bg-[#6B8F71]/10 text-[#6B8F71] shadow-sm"
: "text-[#8A8F87] hover:bg-[#F2EFE9] hover:text-[#2D3436]"
}`}
>
<item.icon
className={`size-4 ${isActive ? "text-[#6B8F71]" : "text-[#8A8F87]"}`}
/>
{item.label}
</Link>
);
})}
</nav>
);
}
@@ -0,0 +1,38 @@
import { ImageIcon } from "lucide-react";
interface ScreenshotPlaceholderProps {
alt: string;
caption?: string;
aspectRatio?: "video" | "square" | "wide" | "tall";
}
const aspectClasses = {
video: "aspect-video",
square: "aspect-square",
wide: "aspect-[21/9]",
tall: "aspect-[9/16]",
};
export function ScreenshotPlaceholder({
alt,
caption,
aspectRatio = "video",
}: ScreenshotPlaceholderProps) {
return (
<figure className="my-6">
<div
className={`${aspectClasses[aspectRatio]} w-full rounded-xl border-2 border-dashed border-[#E8E3DC] bg-[#FAFAF7] flex flex-col items-center justify-center gap-3`}
>
<ImageIcon className="size-8 text-[#8A8F87]/50" />
<span className="text-sm text-[#8A8F87]/70 text-center px-4">
{alt}
</span>
</div>
{caption && (
<figcaption className="mt-2 text-center text-sm text-[#8A8F87]">
{caption}
</figcaption>
)}
</figure>
);
}
+2 -2
View File
@@ -22,13 +22,13 @@ export function Sidebar() {
<Link href={basePath} className="flex items-center gap-2.5 group">
<Image
src="/logo.png"
alt="Casera"
alt="honeyDue"
width={32}
height={32}
className="rounded-lg transition-transform group-hover:scale-105"
/>
<span className="hidden lg:inline font-heading text-lg font-bold text-foreground tracking-tight">
Casera
honeyDue
</span>
</Link>
</div>
+8 -9
View File
@@ -23,6 +23,7 @@ export function TopBar() {
const pathname = usePathname();
const { basePath } = useDataProvider();
const user = useAuthStore((s) => s.user);
const logout = useAuthStore((s) => s.logout);
const navItems = getNavItems(basePath).filter((item) => item.label !== 'Settings');
const initials = user
@@ -30,16 +31,14 @@ export function TopBar() {
: 'U';
const handleLogout = async () => {
try {
await fetch('/api/auth/logout', { method: 'POST' });
} catch {
// Continue with redirect even if the API call fails
}
if (basePath.startsWith('/demo')) {
// Demo mode has no real session — just leave demo.
router.push('/demo');
} else {
router.push('/login');
return;
}
// Real mode: drive the Kratos browser logout flow. This clears the
// `ory_kratos_session` cookie and navigates the browser itself.
await logout();
};
return (
@@ -49,13 +48,13 @@ export function TopBar() {
<Link href={basePath} className="flex items-center gap-2.5 shrink-0 group">
<Image
src="/logo.png"
alt="Casera"
alt="honeyDue"
width={32}
height={32}
className="rounded-lg transition-transform group-hover:scale-105"
/>
<span className="font-heading text-xl font-bold text-foreground tracking-tight">
Casera
honeyDue
</span>
</Link>
+2 -2
View File
@@ -9,8 +9,8 @@ export function WelcomeStep() {
const user = useAuthStore((s) => s.user);
const greeting = user?.first_name
? `Welcome to Casera, ${user.first_name}!`
: "Welcome to Casera!";
? `Welcome to honeyDue, ${user.first_name}!`
: "Welcome to honeyDue!";
return (
<div className="flex flex-col items-center text-center space-y-6 py-12">
+4 -4
View File
@@ -20,11 +20,11 @@ export function ResidenceCard({ data }: ResidenceCardProps) {
.join(", ");
return (
<Link href={`${basePath}/residences/${residence.id}`} className="block group">
<div className="rounded-2xl border border-border bg-card overflow-hidden transition-all duration-200 hover:shadow-[var(--shadow-warm-md)] hover:-translate-y-0.5 hover:border-primary/30">
<Link href={`${basePath}/residences/${residence.id}`} className="block group h-full">
<div className="h-full rounded-2xl border border-border bg-card overflow-hidden transition-all duration-200 hover:shadow-[var(--shadow-warm-md)] hover:-translate-y-0.5 hover:border-primary/30 flex flex-col">
<div className="h-1 bg-gradient-to-r from-primary/60 to-primary/20" />
<div className="p-5">
<div className="flex items-start gap-3 mb-3">
<div className="p-5 flex flex-col flex-1">
<div className="flex items-start gap-3 mb-3 flex-1">
<div className="size-10 rounded-xl bg-primary/10 flex items-center justify-center shrink-0">
<Home className="size-5 text-primary" />
</div>
@@ -1,117 +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 { toast } from "sonner";
import { Loader2, Check } from "lucide-react";
// ---------------------------------------------------------------------------
// ChangePasswordForm — password changes are owned by Ory Kratos
// ---------------------------------------------------------------------------
// The honeyDue Go API no longer handles passwords. Changing a password is a
// Kratos `settings` browser self-service flow. This card renders the password
// group of that flow inline so the user never leaves the settings page.
// ---------------------------------------------------------------------------
import { useEffect, useState } from "react";
import { Loader2 } 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>;
import { KratosFlowForm } from "@/components/auth/kratos-flow-form";
import { KratosMessages } from "@/components/auth/kratos-messages";
import {
browserFlowUrl,
getFlow,
type KratosFlow,
type KratosUiNode,
} from "@/lib/kratos";
export function ChangePasswordForm() {
const [success, setSuccess] = useState(false);
const [apiError, setApiError] = useState<string | null>(null);
const [flow, setFlow] = useState<KratosFlow | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const {
register,
handleSubmit,
reset,
formState: { errors, isSubmitting },
} = useForm<ChangePasswordFormData>({
resolver: zodResolver(changePasswordSchema),
});
// Initialize a Kratos settings flow on mount. We fetch it directly (the user
// is already logged in, so the settings flow can be created via the browser
// endpoint, which responds with the flow JSON for a live session).
useEffect(() => {
let cancelled = false;
async function onSubmit(data: ChangePasswordFormData) {
setSuccess(false);
setApiError(null);
try {
await authApi.changePassword({
current_password: data.current_password,
new_password: data.new_password,
// State is only mutated from the async callbacks — the initial
// `loading: true` covers the in-flight window.
fetch(browserFlowUrl("settings"), {
credentials: "include",
headers: { Accept: "application/json" },
redirect: "follow",
})
.then(async (res) => {
if (cancelled) return;
if (res.ok) {
const data = (await res.json()) as KratosFlow;
setFlow(data);
setError(null);
} else {
setError("Could not load the password form.");
}
})
.catch(() => {
if (!cancelled) setError("Could not load the password form.");
})
.finally(() => {
if (!cancelled) setLoading(false);
});
reset();
setSuccess(true);
toast.success("Password changed");
} catch (err) {
const message =
err instanceof Error ? err.message : "Failed to change password.";
setApiError(message);
toast.error("Failed to change password");
return () => {
cancelled = true;
};
}, []);
function handleResult(result: { status: number; ok: boolean; data: unknown }) {
if (result.data && typeof result.data === "object" && "ui" in result.data) {
setFlow(result.data as KratosFlow);
} else if (result.ok && flow) {
// Re-fetch the flow so the form (and any success message) is fresh.
getFlow("settings", flow.id)
.then(setFlow)
.catch(() => {});
}
}
// Render only the password-related nodes of the settings flow.
const passwordFlow: KratosFlow | null = flow
? {
...flow,
ui: {
...flow.ui,
nodes: flow.ui.nodes.filter(
(n: KratosUiNode) => n.group === "password" || n.group === "default",
),
},
}
: null;
return (
<Card>
<CardHeader>
<CardTitle>Change Password</CardTitle>
<CardDescription>Update your password to keep your account secure.</CardDescription>
<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 className="flex flex-col gap-4">
<KratosMessages flow={flow} error={error} />
{loading && !flow && (
<div className="flex items-center justify-center py-4 text-muted-foreground">
<Loader2 className="animate-spin" />
</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")}
{passwordFlow && (
<KratosFlowForm
flow={passwordFlow}
onResult={handleResult}
submitLabel="Update 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>
)}
</div>
</CardContent>
</Card>
);
+1 -1
View File
@@ -44,7 +44,7 @@ export function UpgradePrompt({ open, onOpenChange, feature, limitInfo }: Upgrad
features.
</p>
<p>
Subscriptions are managed through the Casera iOS or Android app.
Subscriptions are managed through the honeyDue iOS or Android app.
</p>
</div>
@@ -9,16 +9,16 @@ import { Button } from "@/components/ui/button";
// ---------------------------------------------------------------------------
/**
* Generates a `.casera` file from the given data object and triggers a download.
* Generates a `.honeydue` file from the given data object and triggers a download.
*/
export function downloadCaseraFile(data: object, filename: string) {
export function downloadHoneyDueFile(data: object, filename: string) {
const json = JSON.stringify(data, null, 2);
const blob = new Blob([json], { type: "application/json" });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename.endsWith(".casera") ? filename : `${filename}.casera`;
anchor.download = filename.endsWith(".honeydue") ? filename : `${filename}.honeydue`;
document.body.appendChild(anchor);
anchor.click();
@@ -31,12 +31,12 @@ export function downloadCaseraFile(data: object, filename: string) {
// Import component
// ---------------------------------------------------------------------------
interface CaseraFileImportProps {
interface HoneyDueFileImportProps {
onImport: (data: unknown) => void;
accept?: string;
}
export function CaseraFileImport({ onImport, accept = ".casera" }: CaseraFileImportProps) {
export function HoneyDueFileImport({ onImport, accept = ".honeydue" }: HoneyDueFileImportProps) {
const inputRef = useRef<HTMLInputElement>(null);
const [dragActive, setDragActive] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -58,7 +58,7 @@ export function CaseraFileImport({ onImport, accept = ".casera" }: CaseraFileImp
const parsed = JSON.parse(text);
onImport(parsed);
} catch {
setError("Invalid .casera file. Could not parse contents.");
setError("Invalid .honeydue file. Could not parse contents.");
setFileName(null);
}
};
@@ -109,7 +109,7 @@ export function CaseraFileImport({ onImport, accept = ".casera" }: CaseraFileImp
<Upload className="size-8 text-muted-foreground" />
<div className="text-center">
<p className="text-sm font-medium">
Drop a .casera file here or click to browse
Drop a .honeydue file here or click to browse
</p>
{fileName && (
<p className="text-xs text-muted-foreground mt-1">
@@ -136,18 +136,18 @@ export function CaseraFileImport({ onImport, accept = ".casera" }: CaseraFileImp
// Export button component (convenience wrapper)
// ---------------------------------------------------------------------------
interface CaseraFileExportProps {
interface HoneyDueFileExportProps {
data: object;
filename: string;
label?: string;
}
export function CaseraFileExport({ data, filename, label = "Export .casera" }: CaseraFileExportProps) {
export function HoneyDueFileExport({ data, filename, label = "Export .honeydue" }: HoneyDueFileExportProps) {
return (
<Button
variant="outline"
size="sm"
onClick={() => downloadCaseraFile(data, filename)}
onClick={() => downloadHoneyDueFile(data, filename)}
>
<FileDown className="size-4 mr-2" />
{label}
+50 -259
View File
@@ -1,41 +1,27 @@
// ---------------------------------------------------------------------------
// Auth API client (client-side)
// Auth / profile API client (client-side)
// ---------------------------------------------------------------------------
// Login & logout go through dedicated Next.js route handlers that manage the
// httpOnly cookie. All other auth routes use the catch-all proxy.
// Identity (login, registration, recovery, verification, password changes,
// account deletion, social sign-in) is owned by Ory Kratos — see
// `src/lib/kratos/`. The honeyDue Go API no longer does auth.
//
// What remains here is the honeyDue *profile* surface, which still lives on
// the Go API and is authenticated by the `ory_kratos_session` cookie:
// - GET /auth/me -> the current user's honeyDue profile
// - PUT /auth/profile -> update honeyDue-side profile fields
// ---------------------------------------------------------------------------
import { ApiError } from './client';
import { apiFetch } from './client';
// ---------------------------------------------------------------------------
// Request / response shapes (inline; will unify with @/lib/types later)
// TODO: import from @/lib/types once the shared types package is finalised
// Response shapes
// ---------------------------------------------------------------------------
export interface LoginRequest {
username?: string;
email?: string;
password: string;
}
/** Login response after the route handler strips the raw token. */
export interface LoginResponse {
user: UserResponse;
}
export interface RegisterRequest {
username: string;
email: string;
password: string;
first_name?: string;
last_name?: string;
}
export interface RegisterResponse {
token: string;
user: UserResponse;
}
/**
* The current user as returned by the honeyDue Go API `GET /auth/me`.
* The canonical identity (email, verification status) is owned by Kratos;
* this is the honeyDue-side projection used to render the app UI.
*/
export interface UserResponse {
id: number;
username: string;
@@ -53,245 +39,50 @@ export interface UpdateProfileRequest {
last_name?: string;
}
export interface ForgotPasswordRequest {
email: string;
// ---------------------------------------------------------------------------
// API functions
// ---------------------------------------------------------------------------
/**
* Get the currently authenticated user's honeyDue profile.
* Requires a valid Kratos session (`ory_kratos_session` cookie), which the
* proxy forwards to the Go API.
*/
export function getCurrentUser(): Promise<UserResponse> {
return apiFetch<UserResponse>('/auth/me/');
}
export interface VerifyResetCodeRequest {
email: string;
code: string;
}
export interface VerifyResetCodeResponse {
message: string;
reset_token: string;
}
export interface ResetPasswordRequest {
reset_token: string;
new_password: string;
}
export interface VerifyEmailRequest {
code: string;
}
export interface VerifyEmailResponse {
message: string;
verified: boolean;
/**
* Update the authenticated user's honeyDue-side profile.
* Note: changing the email/password of the *identity* itself is done through
* the Kratos `settings` flow, not here.
*/
export function updateProfile(
data: UpdateProfileRequest,
): Promise<UserResponse> {
return apiFetch<UserResponse>('/auth/profile/', {
method: 'PUT',
body: JSON.stringify(data),
});
}
export interface MessageResponse {
message: string;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
async function handleResponse<T>(res: Response): Promise<T> {
const body = await res.json().catch(() => ({ error: 'Unknown error' }));
if (!res.ok) {
throw new ApiError(
res.status,
body.message || body.error || 'Request failed',
body.details,
);
}
return body as T;
}
function timezone(): string {
return Intl.DateTimeFormat().resolvedOptions().timeZone;
}
// ---------------------------------------------------------------------------
// Public API functions
// ---------------------------------------------------------------------------
/**
* Log in with username/email + password.
* Uses the dedicated `/api/auth/login` route handler which sets the httpOnly
* cookie and strips the token from the response.
* Delete the authenticated user's honeyDue account data.
*
* TODO(kratos): This deletes the honeyDue-side data via the Go API. The Kratos
* *identity* itself must also be removed for a full account deletion. Kratos
* does not expose a self-service "delete identity" browser flow — that has to
* be done either by the Go API server-side (admin API) as part of this call,
* or via a separate admin/back-office process. Verify the Go API's
* `DELETE /auth/account` also tears down the Kratos identity; if it does not,
* a follow-up is needed.
*/
export async function login(credentials: LoginRequest): Promise<LoginResponse> {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Timezone': timezone(),
},
body: JSON.stringify(credentials),
});
return handleResponse<LoginResponse>(res);
}
/**
* Register a new account.
* Goes through the proxy; the token is returned in the response body
* (caller should follow up with `login` to set the cookie).
*/
export async function register(
data: RegisterRequest,
): Promise<RegisterResponse> {
const res = await fetch('/api/proxy/auth/register', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Timezone': timezone(),
},
body: JSON.stringify(data),
});
return handleResponse<RegisterResponse>(res);
}
/**
* Log out. Clears the httpOnly cookie and invalidates the token server-side.
*/
export async function logout(): Promise<MessageResponse> {
const res = await fetch('/api/auth/logout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
return handleResponse<MessageResponse>(res);
}
/**
* Get the currently authenticated user.
*/
export async function getCurrentUser(): Promise<UserResponse> {
const res = await fetch('/api/auth/me', {
headers: { 'Content-Type': 'application/json' },
});
return handleResponse<UserResponse>(res);
}
/**
* Update the authenticated user's profile.
*/
export async function updateProfile(
data: UpdateProfileRequest,
): Promise<UserResponse> {
const res = await fetch('/api/proxy/auth/profile', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-Timezone': timezone(),
},
body: JSON.stringify(data),
});
return handleResponse<UserResponse>(res);
}
/**
* Verify the user's email with a 6-digit code.
*/
export async function verifyEmail(
data: VerifyEmailRequest,
): Promise<VerifyEmailResponse> {
const res = await fetch('/api/proxy/auth/verify-email', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Timezone': timezone(),
},
body: JSON.stringify(data),
});
return handleResponse<VerifyEmailResponse>(res);
}
/**
* Resend the email verification code.
*/
export async function resendVerification(): Promise<MessageResponse> {
const res = await fetch('/api/proxy/auth/resend-verification', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Timezone': timezone(),
},
});
return handleResponse<MessageResponse>(res);
}
/**
* Request a password reset email.
*/
export async function forgotPassword(
data: ForgotPasswordRequest,
): Promise<MessageResponse> {
const res = await fetch('/api/proxy/auth/forgot-password', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Timezone': timezone(),
},
body: JSON.stringify(data),
});
return handleResponse<MessageResponse>(res);
}
/**
* Verify the 6-digit reset code; returns a reset token.
*/
export async function verifyResetCode(
data: VerifyResetCodeRequest,
): Promise<VerifyResetCodeResponse> {
const res = await fetch('/api/proxy/auth/verify-reset-code', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Timezone': timezone(),
},
body: JSON.stringify(data),
});
return handleResponse<VerifyResetCodeResponse>(res);
}
/**
* Reset password using the token from `verifyResetCode`.
*/
export async function resetPassword(
data: ResetPasswordRequest,
): Promise<MessageResponse> {
const res = await fetch('/api/proxy/auth/reset-password', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Timezone': timezone(),
},
body: JSON.stringify(data),
});
return handleResponse<MessageResponse>(res);
}
/**
* Change the authenticated user's password.
*/
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);
}
/**
* Delete the authenticated user's account permanently.
*/
export async function deleteAccount(): Promise<MessageResponse> {
const res = await fetch('/api/proxy/auth/delete-account', {
export function deleteAccount(): Promise<MessageResponse> {
return apiFetch<MessageResponse>('/auth/account/', {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'X-Timezone': timezone(),
},
});
return handleResponse<MessageResponse>(res);
}
+21 -12
View File
@@ -1,13 +1,16 @@
// ---------------------------------------------------------------------------
// Base API client for Casera web app
// Base API client for honeyDue web app
// ---------------------------------------------------------------------------
// All client-side requests go through Next.js API route handlers (proxy).
// The proxy reads the httpOnly `casera-token` cookie and forwards it to the
// Go API as an Authorization header. This avoids exposing the token to JS.
// Identity is owned by Ory Kratos. Authenticated requests to the honeyDue Go
// API carry the Kratos session via the `ory_kratos_session` cookie.
//
// All client-side requests go through Next.js API route handlers (the proxy).
// The proxy reads the `ory_kratos_session` httpOnly cookie and forwards it as
// a Cookie header to the Go API (which validates the session against Kratos).
// ---------------------------------------------------------------------------
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_URL || 'https://casera.treytartt.com/api';
process.env.NEXT_PUBLIC_API_URL || 'https://honeyDue.treytartt.com/api';
/**
* Server-only base URL. Falls back to the public one so that server
@@ -15,6 +18,9 @@ const API_BASE_URL =
*/
const SERVER_API_URL = process.env.API_URL || API_BASE_URL;
/** Name of the Kratos browser session cookie. */
export const KRATOS_SESSION_COOKIE = 'ory_kratos_session';
// ---------------------------------------------------------------------------
// Error class
// ---------------------------------------------------------------------------
@@ -36,7 +42,7 @@ export class ApiError extends Error {
/**
* Client-side authenticated fetch. Calls the Next.js catch-all proxy which
* attaches the auth token from the httpOnly cookie before forwarding to Go.
* forwards the `ory_kratos_session` cookie to the Go API.
*
* @param path API path *without* the `/api` prefix, e.g. `/tasks/`
* @param options Standard RequestInit overrides
@@ -64,7 +70,8 @@ export async function apiFetch<T>(
delete headers['Content-Type'];
}
const res = await fetch(url, { ...options, headers });
// Include cookies so the proxy (same-origin) receives the Kratos session.
const res = await fetch(url, { ...options, headers, credentials: 'include' });
if (!res.ok) {
const body = await res.json().catch(() => ({ error: 'Unknown error' }));
@@ -86,8 +93,9 @@ export async function apiFetch<T>(
// ---------------------------------------------------------------------------
/**
* Server-side fetch that reads the auth token from the `casera-token` cookie
* and calls the Go API directly (no proxy hop).
* Server-side fetch that reads the Kratos session cookie and calls the Go API
* directly (no proxy hop). The `ory_kratos_session` cookie is forwarded as a
* Cookie header so the Go API can validate the session against Kratos.
*
* Only use this inside:
* - `app/api/.../route.ts` handlers
@@ -102,7 +110,7 @@ export async function serverFetch<T>(
// (the function itself should only be *called* on the server).
const { cookies } = await import('next/headers');
const cookieStore = await cookies();
const token = cookieStore.get('casera-token')?.value;
const session = cookieStore.get(KRATOS_SESSION_COOKIE)?.value;
const normalized = path.endsWith('/') ? path : `${path}/`;
const url = `${SERVER_API_URL}${normalized}`;
@@ -113,8 +121,9 @@ export async function serverFetch<T>(
...(options.headers as Record<string, string>),
};
if (token) {
headers['Authorization'] = `Token ${token}`;
// Forward the Kratos session cookie to the Go API.
if (session) {
headers['Cookie'] = `${KRATOS_SESSION_COOKIE}=${session}`;
}
if (options.body instanceof FormData) {
+1 -1
View File
@@ -1,5 +1,5 @@
// ---------------------------------------------------------------------------
// Casera API Client - barrel export
// honeyDue API Client - barrel export
// ---------------------------------------------------------------------------
// Usage:
// import { auth, residences, tasks } from '@/lib/api';
+5 -1
View File
@@ -153,7 +153,11 @@ export interface DataProvider {
auth: {
getCurrentUser(): Promise<UserResponse>;
logout(): Promise<MessageResponse>;
/**
* Real mode: drives the Ory Kratos browser logout flow (clears the
* session cookie and navigates the browser). Demo mode: a no-op.
*/
logout(): Promise<void>;
};
}
+5 -3
View File
@@ -258,7 +258,7 @@ export const demoProvider: DataProvider = {
{
id: 1,
username: 'demo_user',
email: 'demo@casera.app',
email: 'demo@honeyDue.treytartt.com',
first_name: 'Demo',
last_name: 'User',
is_owner: true,
@@ -273,7 +273,7 @@ export const demoProvider: DataProvider = {
return {
message: 'Report generated (demo)',
residence_name: r?.name ?? 'Demo Residence',
recipient_email: 'demo@casera.app',
recipient_email: 'demo@honeyDue.treytartt.com',
pdf_generated: true,
email_sent: false,
report: {},
@@ -350,6 +350,8 @@ export const demoProvider: DataProvider = {
// -----------------------------------------------------------------------
auth: {
getCurrentUser: async () => demoUser,
logout: async () => ({ message: 'Logged out' }),
// Demo mode has no real session — logout is a no-op (useLogout handles
// the /demo redirect).
logout: async () => {},
},
};
+1 -1
View File
@@ -86,7 +86,7 @@ export const demoNotifications: NotificationResponse[] = [
},
{
id: 9,
title: 'Welcome to Casera!',
title: 'Welcome to honeyDue!',
body: 'Start by adding your first residence to track home maintenance.',
notification_type: 'system',
is_read: true,
+1 -1
View File
@@ -3,7 +3,7 @@ import type { UserResponse } from '@/lib/api/auth';
export const demoUser: UserResponse = {
id: 1,
username: 'demo_user',
email: 'demo@casera.app',
email: 'demo@honeyDue.treytartt.com',
first_name: 'Demo',
last_name: 'User',
is_email_verified: true,
+3 -1
View File
@@ -11,6 +11,7 @@ import * as lookupsApi from '@/lib/api/lookups';
import * as notificationsApi from '@/lib/api/notifications';
import * as subscriptionApi from '@/lib/api/subscription';
import * as authApi from '@/lib/api/auth';
import { logout as kratosLogout } from '@/lib/kratos';
export const realProvider: DataProvider = {
basePath: '/app',
@@ -94,6 +95,7 @@ export const realProvider: DataProvider = {
auth: {
getCurrentUser: () => authApi.getCurrentUser(),
logout: () => authApi.logout(),
// Hands the browser off to the Kratos logout flow.
logout: () => kratosLogout(),
},
};
+12 -4
View File
@@ -1,8 +1,16 @@
"use client";
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useDataProvider, useQueryKeyPrefix } from '@/lib/demo/data-provider-context';
import { useRouter } from 'next/navigation';
import { useDataProvider, useQueryKeyPrefix } from '@/lib/demo/data-provider-context';
// ---------------------------------------------------------------------------
// Auth hooks
// ---------------------------------------------------------------------------
// Identity is owned by Ory Kratos. `getCurrentUser` reads the honeyDue-side
// profile from the Go API (authenticated via the Kratos session cookie);
// `logout` drives the Kratos browser logout flow.
// ---------------------------------------------------------------------------
export function useCurrentUser() {
const { auth } = useDataProvider();
@@ -24,11 +32,11 @@ export function useLogout() {
mutationFn: () => auth.logout(),
onSuccess: () => {
queryClient.clear();
// In demo mode, redirect to /demo; in real mode, redirect to /login
// In demo mode there is no Kratos session — just route back to /demo.
// In real mode, auth.logout() already hands the browser off to the
// Kratos logout flow, so this router.push is only a fallback.
if (basePath.startsWith('/demo')) {
router.push('/demo');
} else {
router.push('/login');
}
},
});
+240
View File
@@ -0,0 +1,240 @@
// ---------------------------------------------------------------------------
// Ory Kratos browser client
// ---------------------------------------------------------------------------
// Identity is owned by Ory Kratos. The browser talks to the Kratos public API
// directly using its self-service browser flows. Every request must include
// credentials so the flow + `ory_kratos_session` cookies are sent.
//
// Flow lifecycle (browser):
// 1. Navigate the browser to {kratos}/self-service/{type}/browser
// -> Kratos sets a flow cookie and 303-redirects to the configured
// `ui_url` with `?flow=<id>`.
// 2. The UI page reads `?flow=<id>` and fetches the flow definition via
// getFlow() to obtain `ui.nodes` / `ui.action` / `ui.method`.
// 3. The UI renders the form and submits it to `ui.action`.
// ---------------------------------------------------------------------------
import type {
FlowType,
KratosFlow,
KratosLogoutFlow,
KratosMessage,
KratosSession,
} from './types';
/** Base URL of the Kratos public API, e.g. https://auth.myhoneydue.com */
export const KRATOS_URL =
process.env.NEXT_PUBLIC_KRATOS_URL || 'https://auth.myhoneydue.com';
/** Always send credentials so flow + session cookies flow with the request. */
const CREDENTIALS: RequestCredentials = 'include';
/** Maps a flow type to its self-service flow path segment. */
const FLOW_PATH: Record<FlowType, string> = {
login: 'login',
registration: 'registration',
recovery: 'recovery',
verification: 'verification',
settings: 'settings',
};
// ---------------------------------------------------------------------------
// Error
// ---------------------------------------------------------------------------
export class KratosError extends Error {
constructor(
public status: number,
message: string,
/** Parsed flow body if Kratos returned a 4xx with a fresh flow. */
public flow?: KratosFlow,
) {
super(message);
this.name = 'KratosError';
}
}
// ---------------------------------------------------------------------------
// Flow initialization
// ---------------------------------------------------------------------------
/**
* Absolute URL that starts a browser self-service flow.
* Navigating the browser here (full page load, NOT fetch) lets Kratos set the
* flow cookie and redirect back to the configured `ui_url`.
*
* @param type Flow category.
* @param returnTo Optional post-success redirect target (absolute URL).
*/
export function browserFlowUrl(type: FlowType, returnTo?: string): string {
const url = new URL(`${KRATOS_URL}/self-service/${FLOW_PATH[type]}/browser`);
if (returnTo) url.searchParams.set('return_to', returnTo);
return url.toString();
}
/**
* Starts a self-service flow by navigating the current browser window to
* Kratos. This is a hard navigation — Kratos needs to set the flow cookie and
* issue a redirect, which a `fetch` cannot do for a browser client.
*/
export function startFlow(type: FlowType, returnTo?: string): void {
if (typeof window !== 'undefined') {
window.location.href = browserFlowUrl(type, returnTo);
}
}
// ---------------------------------------------------------------------------
// Flow fetching
// ---------------------------------------------------------------------------
/**
* Fetches the definition of an already-initialized flow by id.
* Called by the UI page after Kratos has redirected back with `?flow=<id>`.
*/
export async function getFlow(type: FlowType, id: string): Promise<KratosFlow> {
const res = await fetch(
`${KRATOS_URL}/self-service/${FLOW_PATH[type]}/flows?id=${encodeURIComponent(id)}`,
{
method: 'GET',
credentials: CREDENTIALS,
headers: { Accept: 'application/json' },
},
);
if (!res.ok) {
// 403/410 mean the flow expired or the cookie is missing — the caller
// should re-initialize the flow.
throw new KratosError(res.status, 'Flow expired or not found');
}
return (await res.json()) as KratosFlow;
}
// ---------------------------------------------------------------------------
// Session
// ---------------------------------------------------------------------------
/**
* Returns the current Kratos session, or null if the browser is not logged in.
* Used for route protection and to hydrate the user store.
*/
export async function whoami(): Promise<KratosSession | null> {
try {
const res = await fetch(`${KRATOS_URL}/sessions/whoami`, {
method: 'GET',
credentials: CREDENTIALS,
headers: { Accept: 'application/json' },
});
if (res.status === 200) {
return (await res.json()) as KratosSession;
}
// 401 = no/invalid session.
return null;
} catch {
return null;
}
}
/**
* Initiates a browser logout flow and navigates the browser to the returned
* `logout_url`, which clears the `ory_kratos_session` cookie. Kratos then
* redirects to the configured `default_browser_return_url`.
*/
export async function logout(): Promise<void> {
try {
const res = await fetch(`${KRATOS_URL}/self-service/logout/browser`, {
method: 'GET',
credentials: CREDENTIALS,
headers: { Accept: 'application/json' },
});
if (res.ok) {
const data = (await res.json()) as KratosLogoutFlow;
if (typeof window !== 'undefined' && data.logout_url) {
window.location.href = data.logout_url;
return;
}
}
} catch {
// fall through — best effort
}
// Fallback: if the logout flow could not be created (already logged out,
// network error), just send the user to the login page.
if (typeof window !== 'undefined') {
window.location.href = '/login';
}
}
// ---------------------------------------------------------------------------
// Form submission
// ---------------------------------------------------------------------------
/**
* Submits a Kratos flow form to its `ui.action` URL.
*
* On success Kratos either:
* - returns 200 with a session payload (login / registration that completes
* immediately), or
* - returns a fresh flow body (e.g. recovery moved to the "code" step), or
* - returns 4xx with the same flow re-rendered with validation `messages`.
*
* Kratos browser flows redirect on hard success; with `Accept: application/json`
* it instead returns JSON, which we hand back to the caller to inspect.
*
* @returns The parsed JSON body and the HTTP status.
*/
export async function submitFlow(
action: string,
method: string,
body: Record<string, string>,
): Promise<{ status: number; ok: boolean; data: unknown }> {
const res = await fetch(action, {
method: method.toUpperCase() || 'POST',
credentials: CREDENTIALS,
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify(body),
// Kratos may 303 to the `ui_url`; we want the JSON body, not the redirect.
redirect: 'manual',
});
// `redirect: 'manual'` surfaces a redirect as an opaque response (status 0).
// For our flows Kratos returns JSON for both success and validation errors
// when Accept: application/json is set, so a redirect here means a hard
// success — treat it as such.
if (res.status === 0 || res.type === 'opaqueredirect') {
return { status: 200, ok: true, data: null };
}
const data = await res.json().catch(() => null);
return { status: res.status, ok: res.ok, data };
}
// ---------------------------------------------------------------------------
// Message helpers
// ---------------------------------------------------------------------------
/** Collects all flow-level + node-level messages of the given types. */
export function collectMessages(
flow: KratosFlow | null | undefined,
types: string[] = ['error', 'info', 'success'],
): KratosMessage[] {
if (!flow) return [];
const out: KratosMessage[] = [];
for (const m of flow.ui.messages ?? []) {
if (types.includes(m.type)) out.push(m);
}
for (const node of flow.ui.nodes) {
for (const m of node.messages ?? []) {
if (types.includes(m.type)) out.push(m);
}
}
return out;
}
/** First error message across the whole flow, or null. */
export function firstError(flow: KratosFlow | null | undefined): string | null {
const errors = collectMessages(flow, ['error']);
return errors.length > 0 ? errors[0].text : null;
}
+29
View File
@@ -0,0 +1,29 @@
// ---------------------------------------------------------------------------
// Ory Kratos client — public surface
// ---------------------------------------------------------------------------
export {
KRATOS_URL,
KratosError,
browserFlowUrl,
startFlow,
getFlow,
whoami,
logout,
submitFlow,
collectMessages,
firstError,
} from './client';
export type {
FlowType,
KratosFlow,
KratosUiContainer,
KratosUiNode,
KratosNodeAttributes,
KratosMessage,
KratosIdentity,
KratosSession,
KratosLogoutFlow,
KratosGenericError,
} from './types';
+114
View File
@@ -0,0 +1,114 @@
// ---------------------------------------------------------------------------
// Ory Kratos browser self-service flow types
// ---------------------------------------------------------------------------
// Subset of the Kratos API schema covering the fields the honeyDue web client
// actually consumes. See https://www.ory.sh/docs/kratos/reference/api
// ---------------------------------------------------------------------------
/** Self-service flow categories supported by the UI. */
export type FlowType = 'login' | 'registration' | 'recovery' | 'verification' | 'settings';
/** A localized message attached to a node or to the flow as a whole. */
export interface KratosMessage {
id: number;
/** "info" | "error" | "success" */
type: string;
text: string;
context?: Record<string, unknown>;
}
/** Attributes of a UI node — Kratos only emits the "input" group for our flows,
* but anchor/text/img/script groups are typed loosely for completeness. */
export interface KratosNodeAttributes {
/** input node */
name?: string;
type?: string;
value?: string | number | boolean;
required?: boolean;
disabled?: boolean;
autocomplete?: string;
label?: KratosMessage;
/** "text" | "anchor" | "image" | "input" | "script" */
node_type: string;
// anchor / image
href?: string;
title?: KratosMessage;
src?: string;
// script
id?: string;
}
/** A single renderable element of a Kratos flow form. */
export interface KratosUiNode {
/** "default" | "password" | "oidc" | "code" | "totp" | "lookup_secret" | ... */
group: string;
type: string;
attributes: KratosNodeAttributes;
messages: KratosMessage[];
meta: {
label?: KratosMessage;
};
}
/** The renderable form definition of a flow. */
export interface KratosUiContainer {
/** Absolute URL to POST the form to. */
action: string;
/** "POST" (always, for these flows). */
method: string;
nodes: KratosUiNode[];
/** Flow-level messages (e.g. "An email containing a code has been sent"). */
messages?: KratosMessage[];
}
/** Common shape shared by all self-service flow responses. */
export interface KratosFlow {
id: string;
type: string;
expires_at?: string;
issued_at?: string;
request_url?: string;
/** Present on recovery / verification flows. */
state?: string;
ui: KratosUiContainer;
}
/** A Kratos identity (subset of fields used by the client). */
export interface KratosIdentity {
id: string;
schema_id: string;
traits: Record<string, unknown>;
verifiable_addresses?: Array<{
value: string;
verified: boolean;
via: string;
status: string;
}>;
metadata_public?: Record<string, unknown> | null;
}
/** Response of GET {kratos}/sessions/whoami. */
export interface KratosSession {
id: string;
active: boolean;
expires_at?: string;
authenticated_at?: string;
identity: KratosIdentity;
}
/** Response of GET {kratos}/self-service/logout/browser. */
export interface KratosLogoutFlow {
logout_url: string;
logout_token: string;
}
/** Generic Kratos error envelope (e.g. from a 4xx with a JSON body). */
export interface KratosGenericError {
error: {
id?: string;
code?: number;
status?: string;
reason?: string;
message?: string;
};
}
+88
View File
@@ -0,0 +1,88 @@
"use client";
// ---------------------------------------------------------------------------
// useKratosFlow — drives the browser self-service flow lifecycle for a page
// ---------------------------------------------------------------------------
// 1. Reads `?flow=<id>` from the URL.
// 2. If absent, hard-navigates the browser to Kratos to initialize the flow
// (Kratos sets the flow cookie and redirects back with `?flow=<id>`).
// 3. If present, fetches the flow definition; on 403/410 (expired/missing
// cookie) it re-initializes.
// ---------------------------------------------------------------------------
import { useCallback, useEffect, useState } from "react";
import { useSearchParams } from "next/navigation";
import { getFlow, startFlow } from "./client";
import type { FlowType, KratosFlow } from "./types";
interface UseKratosFlowResult {
flow: KratosFlow | null;
/** True while initializing / fetching the flow. */
loading: boolean;
/** Non-Kratos error (e.g. network failure). */
error: string | null;
/** Replace the current flow object (after a submit returns a fresh flow). */
setFlow: (flow: KratosFlow) => void;
/** Re-initialize the flow from scratch. */
reinit: () => void;
}
export function useKratosFlow(
type: FlowType,
returnTo?: string,
): UseKratosFlowResult {
const searchParams = useSearchParams();
const flowId = searchParams.get("flow");
const [flow, setFlow] = useState<KratosFlow | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const reinit = useCallback(() => {
startFlow(type, returnTo);
}, [type, returnTo]);
useEffect(() => {
let cancelled = false;
// No flow id in the URL -> kick off a fresh flow (hard navigation).
if (!flowId) {
startFlow(type, returnTo);
return;
}
// Fetch the flow definition. State is only mutated from the async
// callbacks (never synchronously in the effect body) so the initial
// `loading: true` covers the in-flight window.
getFlow(type, flowId)
.then((f) => {
if (!cancelled) {
setFlow(f);
setError(null);
setLoading(false);
}
})
.catch((err) => {
if (cancelled) return;
// 403/410: the flow expired or the flow cookie is gone — re-init.
if (
typeof err === "object" &&
err !== null &&
"status" in err &&
(err.status === 403 || err.status === 410 || err.status === 404)
) {
startFlow(type, returnTo);
return;
}
setError("Could not load the form. Please try again.");
setLoading(false);
});
return () => {
cancelled = true;
};
}, [flowId, type, returnTo]);
return { flow, loading, error, setFlow, reinit };
}
+1 -1
View File
@@ -19,7 +19,7 @@ export interface ThemeDefinition {
}
/**
* Single "Warm Sage" theme — the Casera brand palette.
* Single "Warm Sage" theme — the honeyDue brand palette.
*/
export const themes: ThemeDefinition[] = [
{
+1 -1
View File
@@ -1,6 +1,6 @@
// ============================================================================
// API-level types: errors, pagination, common response wrappers
// Generated from myCribAPI-go/internal/dto/responses/auth.go (ErrorResponse, MessageResponse)
// Generated from honeyDueAPI-go/internal/dto/responses/auth.go (ErrorResponse, MessageResponse)
// ============================================================================
/**
+8 -93
View File
@@ -1,68 +1,24 @@
// ============================================================================
// Auth request / response types
// Generated from:
// myCribAPI-go/internal/dto/requests/auth.go
// myCribAPI-go/internal/dto/responses/auth.go
// Auth / profile types
// ============================================================================
// Identity (login, registration, recovery, verification, password changes,
// social sign-in) is owned by Ory Kratos — those flow types live in
// `src/lib/kratos/types.ts`, not here.
//
// What remains are the honeyDue-side *profile* types served by the Go API
// (`GET /auth/me`, `PUT /auth/profile`), authenticated by the Kratos session.
// ============================================================================
// ---------------------------------------------------------------------------
// Requests
// ---------------------------------------------------------------------------
export interface LoginRequest {
username?: string;
email?: string;
password: string;
}
export interface RegisterRequest {
username: string;
email: string;
password: string;
first_name?: string;
last_name?: string;
}
export interface VerifyEmailRequest {
code: string;
}
export interface ForgotPasswordRequest {
email: string;
}
export interface VerifyResetCodeRequest {
email: string;
code: string;
}
export interface ResetPasswordRequest {
reset_token: string;
new_password: string;
}
export interface UpdateProfileRequest {
email?: string | null;
first_name?: string | null;
last_name?: string | null;
}
export interface ResendVerificationRequest {
// No body needed - uses authenticated user's email
}
export interface AppleSignInRequest {
id_token: string;
user_id: string;
email?: string | null;
first_name?: string | null;
last_name?: string | null;
}
export interface GoogleSignInRequest {
id_token: string;
}
// ---------------------------------------------------------------------------
// Responses
// ---------------------------------------------------------------------------
@@ -89,17 +45,6 @@ export interface UserProfileResponse {
profile_picture: string;
}
export interface LoginResponse {
token: string;
user: UserResponse;
}
export interface RegisterResponse {
token: string;
user: UserResponse;
message: string;
}
export interface CurrentUserResponse {
id: number;
username: string;
@@ -112,36 +57,6 @@ export interface CurrentUserResponse {
profile?: UserProfileResponse | null;
}
export interface VerifyEmailResponse {
message: string;
verified: boolean;
}
export interface ForgotPasswordResponse {
message: string;
}
export interface VerifyResetCodeResponse {
message: string;
reset_token: string;
}
export interface ResetPasswordResponse {
message: string;
}
export interface AppleSignInResponse {
token: string;
user: UserResponse;
is_new_user: boolean;
}
export interface GoogleSignInResponse {
token: string;
user: UserResponse;
is_new_user: boolean;
}
// ---------------------------------------------------------------------------
// User summary types (from responses/user.go)
// ---------------------------------------------------------------------------
+2 -2
View File
@@ -1,8 +1,8 @@
// ============================================================================
// Contractor request / response types
// Generated from:
// myCribAPI-go/internal/dto/requests/contractor.go
// myCribAPI-go/internal/dto/responses/contractor.go
// honeyDueAPI-go/internal/dto/requests/contractor.go
// honeyDueAPI-go/internal/dto/responses/contractor.go
// ============================================================================
// ---------------------------------------------------------------------------
+3 -3
View File
@@ -1,9 +1,9 @@
// ============================================================================
// Document request / response types
// Generated from:
// myCribAPI-go/internal/dto/requests/document.go
// myCribAPI-go/internal/dto/responses/document.go
// myCribAPI-go/internal/models/document.go (DocumentType enum)
// honeyDueAPI-go/internal/dto/requests/document.go
// honeyDueAPI-go/internal/dto/responses/document.go
// honeyDueAPI-go/internal/models/document.go (DocumentType enum)
// ============================================================================
// ---------------------------------------------------------------------------
+3 -18
View File
@@ -6,29 +6,14 @@
export { ApiError } from "./api";
export type { ErrorResponse, MessageResponse, PaginatedResponse } from "./api";
// Auth
// Auth / profile
// Identity flows (login, registration, recovery, ...) are owned by Ory Kratos
// — see `src/lib/kratos/types.ts`. Only honeyDue-side profile types remain.
export type {
LoginRequest,
RegisterRequest,
VerifyEmailRequest,
ForgotPasswordRequest,
VerifyResetCodeRequest,
ResetPasswordRequest,
UpdateProfileRequest,
ResendVerificationRequest,
AppleSignInRequest,
GoogleSignInRequest,
UserResponse,
UserProfileResponse,
LoginResponse,
RegisterResponse,
CurrentUserResponse,
VerifyEmailResponse,
ForgotPasswordResponse,
VerifyResetCodeResponse,
ResetPasswordResponse,
AppleSignInResponse,
GoogleSignInResponse,
UserSummary,
UserProfileSummary,
} from "./auth";
+5 -5
View File
@@ -1,11 +1,11 @@
// ============================================================================
// Static / lookup data types
// Generated from:
// myCribAPI-go/internal/handlers/static_data_handler.go (SeededDataResponse)
// myCribAPI-go/internal/dto/responses/residence.go (ResidenceTypeResponse)
// myCribAPI-go/internal/dto/responses/task.go (TaskCategory/Priority/Frequency)
// myCribAPI-go/internal/dto/responses/contractor.go (ContractorSpecialtyResponse)
// myCribAPI-go/internal/dto/responses/task_template.go (TaskTemplatesGroupedResponse)
// honeyDueAPI-go/internal/handlers/static_data_handler.go (SeededDataResponse)
// honeyDueAPI-go/internal/dto/responses/residence.go (ResidenceTypeResponse)
// honeyDueAPI-go/internal/dto/responses/task.go (TaskCategory/Priority/Frequency)
// honeyDueAPI-go/internal/dto/responses/contractor.go (ContractorSpecialtyResponse)
// honeyDueAPI-go/internal/dto/responses/task_template.go (TaskTemplatesGroupedResponse)
// ============================================================================
import type { ResidenceTypeResponse } from "./residence";
+2 -2
View File
@@ -1,8 +1,8 @@
// ============================================================================
// Notification request / response types
// Generated from:
// myCribAPI-go/internal/services/notification_service.go
// myCribAPI-go/internal/models/notification.go (NotificationType enum)
// honeyDueAPI-go/internal/services/notification_service.go
// honeyDueAPI-go/internal/models/notification.go (NotificationType enum)
// ============================================================================
// ---------------------------------------------------------------------------
+2 -2
View File
@@ -1,8 +1,8 @@
// ============================================================================
// Residence request / response types
// Generated from:
// myCribAPI-go/internal/dto/requests/residence.go
// myCribAPI-go/internal/dto/responses/residence.go
// honeyDueAPI-go/internal/dto/requests/residence.go
// honeyDueAPI-go/internal/dto/responses/residence.go
// ============================================================================
// ---------------------------------------------------------------------------
+2 -2
View File
@@ -1,8 +1,8 @@
// ============================================================================
// Subscription request / response types
// Generated from:
// myCribAPI-go/internal/services/subscription_service.go
// myCribAPI-go/internal/models/subscription.go (SubscriptionTier enum)
// honeyDueAPI-go/internal/services/subscription_service.go
// honeyDueAPI-go/internal/models/subscription.go (SubscriptionTier enum)
// ============================================================================
// ---------------------------------------------------------------------------
+1 -1
View File
@@ -1,7 +1,7 @@
// ============================================================================
// Task template response types
// Generated from:
// myCribAPI-go/internal/dto/responses/task_template.go
// honeyDueAPI-go/internal/dto/responses/task_template.go
// ============================================================================
import type { TaskCategoryResponse, TaskFrequencyResponse } from "./task";
+2 -2
View File
@@ -1,8 +1,8 @@
// ============================================================================
// Task request / response types
// Generated from:
// myCribAPI-go/internal/dto/requests/task.go
// myCribAPI-go/internal/dto/responses/task.go
// honeyDueAPI-go/internal/dto/requests/task.go
// honeyDueAPI-go/internal/dto/responses/task.go
// ============================================================================
import type { TotalSummary } from "./residence";
-84
View File
@@ -1,84 +0,0 @@
import { z } from 'zod';
// ---------------------------------------------------------------------------
// Login
// ---------------------------------------------------------------------------
export const loginSchema = z.object({
username: z.string().min(1, 'Username or email is required'),
password: z.string().min(1, 'Password is required'),
});
export type LoginFormData = z.infer<typeof loginSchema>;
// ---------------------------------------------------------------------------
// Register
// ---------------------------------------------------------------------------
export const registerSchema = z
.object({
first_name: z.string().min(1, 'First name is required').max(150),
last_name: z.string().min(1, 'Last name is required').max(150),
username: z
.string()
.min(3, 'Username must be at least 3 characters')
.max(150),
email: z.string().email('Invalid email address').max(254),
password: z.string().min(8, 'Password must be at least 8 characters'),
confirm_password: z.string(),
})
.refine((data) => data.password === data.confirm_password, {
message: "Passwords don't match",
path: ['confirm_password'],
});
export type RegisterFormData = z.infer<typeof registerSchema>;
// ---------------------------------------------------------------------------
// Verify email
// ---------------------------------------------------------------------------
export const verifyEmailSchema = z.object({
code: z.string().length(6, 'Code must be 6 digits'),
});
export type VerifyEmailFormData = z.infer<typeof verifyEmailSchema>;
// ---------------------------------------------------------------------------
// Forgot password
// ---------------------------------------------------------------------------
export const forgotPasswordSchema = z.object({
email: z.string().email('Invalid email address'),
});
export type ForgotPasswordFormData = z.infer<typeof forgotPasswordSchema>;
// ---------------------------------------------------------------------------
// Verify reset code
// ---------------------------------------------------------------------------
export const verifyResetCodeSchema = z.object({
email: z.string().email(),
code: z.string().length(6, 'Code must be 6 digits'),
});
export type VerifyResetCodeFormData = z.infer<typeof verifyResetCodeSchema>;
// ---------------------------------------------------------------------------
// Reset password
// ---------------------------------------------------------------------------
export const resetPasswordSchema = z
.object({
email: z.string().email(),
code: z.string(),
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'],
});
export type ResetPasswordFormData = z.infer<typeof resetPasswordSchema>;
+40 -10
View File
@@ -1,26 +1,56 @@
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
// ---------------------------------------------------------------------------
// Middleware — cheap cookie-presence route pre-filter
// ---------------------------------------------------------------------------
// Identity is owned by Ory Kratos. The authoritative session check is the
// `whoami` call done client-side by <AuthGate> (and server-side by the Go API
// for every API request). Middleware only does a cheap pre-filter on the
// presence of the `ory_kratos_session` cookie so that obviously-logged-out
// users are bounced to /login without a flash of the app shell.
//
// The cookie's mere presence does NOT guarantee a valid session (it may be
// expired) — that's why <AuthGate> still re-verifies via `whoami`.
// ---------------------------------------------------------------------------
const SESSION_COOKIE = 'ory_kratos_session';
export function middleware(request: NextRequest) {
const token = request.cookies.get('casera-token')?.value;
const hasSession = Boolean(request.cookies.get(SESSION_COOKIE)?.value);
const { pathname } = request.nextUrl;
// Public paths that don't require auth
const publicPaths = ['/', '/login', '/register', '/forgot-password', '/reset-password', '/verify-email', '/demo'];
const isPublicPath = publicPaths.some(p => pathname === p || pathname.startsWith(p + '/'));
// Public paths that don't require auth.
const publicPaths = [
'/',
'/login',
'/register',
'/forgot-password',
'/reset-password',
'/verify-email',
'/demo',
'/help',
];
const isPublicPath = publicPaths.some(
(p) => pathname === p || pathname.startsWith(p + '/'),
);
const isApiPath = pathname.startsWith('/api/');
const isStaticPath = pathname.startsWith('/_next/') || pathname.startsWith('/favicon') || pathname.match(/\.(png|jpg|jpeg|gif|svg|ico|webp|woff2?|ttf|css|js)$/);
const isStaticPath =
pathname.startsWith('/_next/') ||
pathname.startsWith('/favicon') ||
pathname.match(/\.(png|jpg|jpeg|gif|svg|ico|webp|woff2?|ttf|css|js)$/);
// Skip middleware for API routes and static files
// Skip middleware for API routes and static files.
if (isApiPath || isStaticPath) return NextResponse.next();
// No token + protected path redirect to login
if (!token && !isPublicPath) {
// No session cookie + protected path -> redirect to login.
if (!hasSession && !isPublicPath) {
return NextResponse.redirect(new URL('/login', request.url));
}
// Has token + auth page → redirect to app
if (token && (pathname === '/login' || pathname === '/register')) {
// Has a session cookie + on the login/register pages -> send to the app.
// (AuthGate / whoami will catch the case where the cookie is stale.)
if (hasSession && (pathname === '/login' || pathname === '/register')) {
return NextResponse.redirect(new URL('/app', request.url));
}

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