Compare commits
6 Commits
4b8c10d768
...
42e7bedea4
| Author | SHA1 | Date | |
|---|---|---|---|
| 42e7bedea4 | |||
| f77f913ee8 | |||
| c4d35b3d68 | |||
| cf9a1de385 | |||
| 9fecb7b4d8 | |||
| e2172c20f2 |
@@ -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,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`.
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -11,7 +11,7 @@ const nextConfig: NextConfig = {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "casera.treytartt.com",
|
||||
hostname: "honeyDue.treytartt.com",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "casera-web",
|
||||
"name": "honeydue-web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 464 KiB After Width: | Height: | Size: 1.2 MiB |
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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]">
|
||||
© {new Date().getFullYear()} Casera
|
||||
© {new Date().getFullYear()} honeyDue
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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 |
@@ -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'll
|
||||
need to enter your current password for verification.
|
||||
</p>
|
||||
<HelpCallout type="note">
|
||||
If you signed up with Apple Sign In, you don'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's most comfortable for you.
|
||||
</p>
|
||||
<ScreenshotPlaceholder alt="Theme picker showing light, dark, and system options" />
|
||||
<p>
|
||||
The theme affects the app's appearance across all screens. Your
|
||||
preference syncs across devices when you'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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'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'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 "Needs Attention" 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>
|
||||
);
|
||||
}
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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'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'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., "Our House") 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 "Change HVAC
|
||||
filter" to "Schedule annual inspection."
|
||||
</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'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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'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't open. On the mobile app, you'll be prompted to allow
|
||||
notifications on first launch.
|
||||
</p>
|
||||
<HelpCallout type="tip">
|
||||
Make sure notifications are enabled in your device's system settings.
|
||||
If you'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's disabled everywhere.
|
||||
</p>
|
||||
<HelpCallout type="note">
|
||||
Check your spam folder if you're not seeing honeyDue emails. Adding
|
||||
our email to your contacts helps ensure delivery.
|
||||
</HelpCallout>
|
||||
</HelpSection>
|
||||
</HelpArticle>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'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'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>
|
||||
);
|
||||
}
|
||||
@@ -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'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'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'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'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>
|
||||
);
|
||||
}
|
||||
@@ -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'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'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>
|
||||
);
|
||||
}
|
||||
@@ -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., "Replace HVAC filter")</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'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
@@ -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",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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
@@ -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'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">
|
||||
© {new Date().getFullYear()} Casera. All rights reserved.
|
||||
© {new Date().getFullYear()} honeyDue. All rights reserved.
|
||||
</p>
|
||||
<p className="text-xs">
|
||||
Made for homeowners, by homeowners.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}</>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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're exploring Casera in demo mode. Changes aren't saved.
|
||||
You're exploring honeyDue in demo mode. Changes aren't saved.
|
||||
</p>
|
||||
<Button size="xs" className="rounded-full" asChild>
|
||||
<Link href="/register">Sign Up Free</Link>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
© {new Date().getFullYear()} honeyDue. All rights reserved.
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -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]"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div onClick={() => setMobileOpen(false)}>
|
||||
<HelpSidebar />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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.",
|
||||
},
|
||||
];
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
+10
-10
@@ -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
@@ -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
@@ -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,5 +1,5 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// Casera API Client - barrel export
|
||||
// honeyDue API Client - barrel export
|
||||
// ---------------------------------------------------------------------------
|
||||
// Usage:
|
||||
// import { auth, residences, tasks } from '@/lib/api';
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 () => {},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,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
@@ -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)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
// ============================================================================
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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)
|
||||
// ============================================================================
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
// ============================================================================
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
@@ -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
Reference in New Issue
Block a user