diff --git a/.gitignore b/.gitignore index c39d7e2..d8dceb4 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,28 @@ tsconfig.tsbuildinfo .playwright-mcp/ docker-compose.override.yml .remember/ +.DS_Store +# Root-only ad-hoc EOI scratch dir; routes under src/app/.../eoi/ must NOT match. +/eoi/ + +# Brainstorming companion mockup files +.superpowers/ + +# Ad-hoc screenshots / scratch artifacts at repo root +/*.png +/*.jpg + +# Legacy Nuxt portal — kept on disk for reference, not tracked here +/client-portal/ + +# Sister marketing site — separate Nuxt project, not part of CRM tracking +/website/ + +# Mobile audit screenshots — generated locally, regenerable +/.audit/ +/.audit-screenshots/ + +# Tool caches / runtime state +/.claude/ +/.serena/ +/ruvector.db diff --git a/07-DATABASE-SCHEMA.md b/07-DATABASE-SCHEMA.md index 624061e..44a814a 100644 --- a/07-DATABASE-SCHEMA.md +++ b/07-DATABASE-SCHEMA.md @@ -20,16 +20,42 @@ ### Client Domain -- `clients` — Anchor records for people/entities +- `clients` — Anchor records for people/entities. Yacht and company details + are no longer stored here — see the Yacht and Company domains. - `client_contacts` — Multi-channel contact entries per client +- `client_addresses` — Physical addresses per client (primary + others) - `client_relationships` — Relationships between clients (referrals, broker, family) - `client_notes` — Timestamped notes on clients - `client_tags` — Tags assigned to clients - `client_merge_log` — Audit trail of client merges +### Yacht Domain + +- `yachts` — First-class yacht records. Polymorphic ownership via + `current_owner_type` (`'client' | 'company'`) + `current_owner_id`. +- `yacht_ownership_history` — Append-only log of every transfer; partial + unique index `idx_yoh_active` enforces a single active owner per yacht. +- `yacht_notes`, `yacht_tags` — Notes / tags on yachts. + +### Company Domain + +- `companies` — Legal entities that may own yachts or be billed. +- `company_addresses` — Addresses per company. +- `company_memberships` — Active client ↔ company links with role + (director / shareholder / beneficial_owner / authorised_signatory), + start/end dates. + +### Reservation Domain + +- `berth_reservations` — Concrete client + yacht + berth holds with + start/end dates and status. Partial unique index `idx_br_active` + enforces one active reservation per berth. + ### Interest Domain -- `interests` — Per-berth pipeline records, each belonging to a client (milestone dates are inline columns) +- `interests` — Per-berth pipeline records. Each row references a + `client_id`, `yacht_id` (the yacht in scope for the inquiry), and + optional `berth_id`. Milestone dates are inline columns. - `interest_notes` — Timestamped notes on interests - `interest_tags` — Tags assigned to interests diff --git a/CLAUDE.md b/CLAUDE.md index 3923419..af30dfd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,6 +13,19 @@ pnpm db:generate # Generate Drizzle migrations pnpm db:push # Push schema to DB pnpm db:studio # Drizzle Studio GUI pnpm db:seed # Seed database (tsx src/lib/db/seed.ts) + +# Tests +pnpm exec vitest run # Unit + integration (~3s) +pnpm exec playwright test --project=smoke # Click-through smoke (~10min) +pnpm exec playwright test --project=exhaustive # Full UI exhaustive +pnpm exec playwright test --project=destructive # Archive/delete flows +pnpm exec playwright test --project=realapi # Real Documenso/IMAP (opt-in) +pnpm exec playwright test --project=visual # Pixel-diff baselines +pnpm exec playwright test --project=visual --update-snapshots # Regenerate baselines + +# Dev helpers +pnpm tsx scripts/dev-trigger-portal-invite.ts # Send a portal activation email +pnpm tsx scripts/dev-imap-probe.ts # Dump recent IMAP inbox messages ``` ## Tech stack @@ -70,15 +83,47 @@ src/ - **Formatting:** Prettier - single quotes, semicolons, trailing commas, 2-space indent, 100 char line width. - **Lint:** ESLint flat config extending `next/core-web-vitals`, `next/typescript`, `prettier`. Unused vars prefixed with `_` are allowed. - **Imports:** Use `@/*` path alias (maps to `src/*`). -- **Components:** shadcn/ui pattern - base components in `src/components/ui/`, domain components in `src/components/[domain]/`. -- **DB schema:** One file per domain in `src/lib/db/schema/`, re-exported from `index.ts`. Relations in `relations.ts`. +- **Components:** shadcn/ui pattern - base components in `src/components/ui/`, domain components in `src/components/[domain]/`. Yacht / company / reservation domains live in `components/yachts`, `components/companies`, `components/reservations` respectively. +- **DB schema:** One file per domain in `src/lib/db/schema/`, re-exported from `index.ts`. Relations in `relations.ts`. Domain files include `clients.ts`, `yachts.ts`, `companies.ts`, `reservations.ts`, `interests.ts`, `berths.ts`, `documents.ts`, `invoices.ts`, etc. +- **Polymorphic ownership:** Yachts and invoice billing-entities use `_type` + `_id` column pairs (`'client' | 'company'`). Resolve owner identity through `src/lib/services/yachts.service.ts` / `eoi-context.ts` rather than reading the columns ad hoc — those services apply the type discriminator. +- **EOI generation:** Two pathways share the same `EoiContext` (`src/lib/services/eoi-context.ts`). Documenso pathway calls the template-generate endpoint via `documenso-payload.ts`; in-app pathway fills the same source PDF (`assets/eoi-template.pdf`) via `src/lib/pdf/fill-eoi-form.ts` (pdf-lib AcroForm). Routed through `generateAndSign(...)` in `src/lib/services/document-templates.ts` with a `pathway` parameter. +- **Merge fields:** Token catalog lives in `src/lib/templates/merge-fields.ts`; the `createTemplateSchema` validator uses `VALID_MERGE_TOKENS` as an allow-list, so unknown tokens are rejected at template creation time. +- **Documenso webhooks:** Documenso (both v1.13 and 2.x) authenticates outbound webhooks by sending the configured secret in plaintext via the `X-Documenso-Secret` header — there is no HMAC. The receiver at `src/app/api/webhooks/documenso/route.ts` does a timing-safe equality check via `verifyDocumensoSecret`. Event names arrive as the uppercase Prisma enum on the wire (`DOCUMENT_SIGNED`, `DOCUMENT_COMPLETED`, etc.) even though the UI displays them as lowercase-dotted. The route also normalizes lowercase-dotted variants for forward-compat. +- **Documenso API responses:** 2.x renamed `id` → `documentId` and recipient `id` → `recipientId`; v1.13 still uses `id`. `src/lib/services/documenso-client.ts` runs every response through `normalizeDocument()` which reads either field name and surfaces the legacy `id` form to downstream consumers. +- **Email templates:** Branded HTML lives in `src/lib/email/templates/`. The portal-auth flow uses `portal-auth.ts` (activation + reset). All templates use the legacy table-based layout with the Port Nimara logo + blurred overhead background, max-width 600px and `width:100%` for responsive shrink. The `` URLs reference `s3.portnimara.com` directly (will move to `/public` later). +- **Portal auth pages:** `/portal/login`, `/portal/activate`, `/portal/reset-password` and the CRM `/login`, `/reset-password`, `/set-password` all wrap their content in `` (`src/components/shared/branded-auth-shell.tsx`) which renders the same blurred background + logo + white card the email templates use, so the in-app and email surfaces look unified. +- **Inline editing pattern:** detail pages (clients, yachts, companies, interests, residential clients/interests) use `` (`src/components/shared/inline-editable-field.tsx`) for click-to-edit text/select/textarea fields and `` (`src/components/shared/inline-tag-editor.tsx`) for tag chips. Each entity exposes a `PUT /api/v1//[id]/tags` endpoint backed by a `setTags` service helper that wipes-and-rewrites the join table inside a single transaction. There are no separate "Edit" modal forms on detail pages — the entire overview tab is editable in place. +- **Notes (polymorphic across entity types):** `notes.service.ts` dispatches across `clientNotes`, `interestNotes`, `yachtNotes`, `companyNotes` based on an `entityType` discriminator. `` works for all four. `companyNotes` lacks an `updatedAt` column — the service substitutes `createdAt` so callers get a uniform shape. +- **Route handler exports:** Next.js App Router `route.ts` files only allow specific named exports (`GET|POST|…`). Service-tested handler functions live in sibling `handlers.ts` files (e.g. `src/app/api/v1/yachts/[id]/handlers.ts`) and are imported by the colocated `route.ts` for `withAuth(withPermission(...))` wrapping. Integration tests import from `handlers.ts` directly to bypass auth/permission middleware. - **Routes:** Multi-tenant via `[portSlug]` dynamic segment. Typed routes enabled. -- **Pre-commit:** Husky + lint-staged runs ESLint fix + Prettier on staged `.ts`/`.tsx` files. +- **Pre-commit:** Husky + lint-staged runs ESLint fix + Prettier on staged `.ts`/`.tsx` files. The hook also blocks `.env*` files (including `.env.example`) from being committed; pass them via a separate workflow if needed. + +## Schema migrations during dev + +When you run a `db:push` or apply a migration via `psql` against a running dev server, **restart the dev server afterwards**. Drizzle/postgres.js keeps connection-level prepared statements that can hold stale column lists; a stale pool causes `column X does not exist` errors on pages that touch the migrated table even though the column is present in the DB. Symptom: pages return 500 with `errorMissingColumn`/`42703` after a successful migration. Fix: kill `next dev` and restart it. ## Environment Copy `.env.example` to `.env` for local dev. See `src/lib/env.ts` for the full schema. Set `SKIP_ENV_VALIDATION=1` to bypass validation (used in Docker build). +Optional dev/test-only env vars (not in `.env.example`): + +- `EMAIL_REDIRECT_TO=
` — when set, every outbound email is rerouted to this address regardless of the requested recipient and the subject is prefixed with `[redirected from ]`. Dev safety net so seeded fake-client emails don't escape; **must be unset in production**. +- `IMAP_HOST` / `IMAP_PORT` / `IMAP_USER` / `IMAP_PASS` — read by `tests/e2e/realapi/portal-imap-activation.spec.ts` to fetch the activation email from a real mailbox during the IMAP round-trip test. The spec skips when any are missing. + +## Testing + +Five Playwright projects, defined in `playwright.config.ts`: + +- `setup` — global setup (seeds users, port, berths, system settings). +- `smoke` — fast click-through over every major flow. Run on every change (~10 min, 125 specs). +- `exhaustive` — deeper UI coverage that takes longer. +- `destructive` — archive/delete/cancel paths against throwaway entities. +- `realapi` — opt-in suite that hits real external services (Documenso send-side + IMAP round-trip). Requires `DOCUMENSO_API_*`, `SMTP_*`, `IMAP_*` env. Cloudflared tunnel needs to be running so Documenso can call the local webhook receiver. +- `visual` — pixel-diff baselines for stable list/landing pages. Snapshots committed under `tests/e2e/visual/snapshots.spec.ts-snapshots/`. Regenerate with `--update-snapshots` after intentional UI changes. + +Vitest covers unit + integration with mocked external services (`tests/unit/`, `tests/integration/`). + ## Docker - `Dockerfile` - Production multi-stage build (deps -> build -> runner) @@ -89,3 +134,11 @@ Copy `.env.example` to `.env` for local dev. See `src/lib/env.ts` for the full s ## Architecture docs Numbered spec files in repo root (`01-CONSOLIDATED-SYSTEM-SPEC.md` through `15-DESIGN-TOKENS.md`) contain detailed architecture decisions, feature specs, DB schema docs, API catalog, and implementation sequence. + +Domain-specific references: + +- `docs/eoi-documenso-field-mapping.md` — canonical mapping from `EoiContext` + paths to the Documenso template's `formValues` keys, with the matching + AcroForm field names used by the in-app pathway. +- `assets/README.md` — what the in-app EOI source PDF must contain and how + to override its path in dev/test. diff --git a/assets/README.md b/assets/README.md new file mode 100644 index 0000000..6364be7 --- /dev/null +++ b/assets/README.md @@ -0,0 +1,48 @@ +# `assets/` + +Server-side runtime assets bundled by Next.js (via `outputFileTracingIncludes` +in `next.config.ts`). These files are read with `fs.readFile` from +`process.cwd()` at runtime, so they are NOT served as public URLs — use +`public/` for that. + +## `eoi-template.pdf` + +The source PDF used by the in-app EOI generation pathway +(`src/lib/pdf/fill-eoi-form.ts`). It must be the **same** PDF that the +Documenso EOI template uploads, so both pathways produce equivalent +documents. + +The PDF must contain AcroForm fields with these exact names (mirroring the +Documenso template's `formValues` keys — see +`docs/eoi-documenso-field-mapping.md`): + +| Field name | Type | Filled with | +| -------------- | -------- | ----------------------------------------------------- | +| `Name` | Text | `EoiContext.client.fullName` | +| `Email` | Text | `EoiContext.client.primaryEmail` | +| `Address` | Text | `street, city, country` | +| `Yacht Name` | Text | `EoiContext.yacht.name` | +| `Length` | Text | `EoiContext.yacht.lengthFt` | +| `Width` | Text | `EoiContext.yacht.widthFt` | +| `Draft` | Text | `EoiContext.yacht.draftFt` | +| `Berth Number` | Text | `EoiContext.berth.mooringNumber` | +| `Lease_10` | Checkbox | always `false` (legacy default — Purchase, not Lease) | +| `Purchase` | Checkbox | always `true` | + +Form fields stay interactive after generation (not flattened), so the +recipient can still tweak values before signing if the in-app pathway is +followed by a Documenso send. + +### Override path + +In dev/test, set `EOI_TEMPLATE_PDF_PATH=/abs/path/to/your/template.pdf` to +point at a different file (e.g. a fixture). + +### How to extract this PDF + +The legacy flow uploads this PDF to Documenso template ID 8. To get the +exact bytes: + +1. In Documenso, open the EOI template. +2. Download the source PDF. +3. Drop it here as `eoi-template.pdf`. diff --git a/assets/eoi-template.pdf b/assets/eoi-template.pdf new file mode 100644 index 0000000..bb74b3c Binary files /dev/null and b/assets/eoi-template.pdf differ diff --git a/client-portal b/client-portal deleted file mode 160000 index e2d3181..0000000 --- a/client-portal +++ /dev/null @@ -1 +0,0 @@ -Subproject commit e2d31815cf45fdcca9d36a5eb83c9038ba7d6057 diff --git a/docs/eoi-documenso-field-mapping.md b/docs/eoi-documenso-field-mapping.md new file mode 100644 index 0000000..02c5ce7 --- /dev/null +++ b/docs/eoi-documenso-field-mapping.md @@ -0,0 +1,76 @@ +# Documenso EOI Template — Field Mapping + +**Purpose:** This doc is the canonical reference for mapping the Documenso EOI template's `formValues` keys to the new data model's `EoiContext` shape. It drives `buildDocumensoPayload()` (Task 11.2), the in-app Standard EOI HTML tokens (Task 11.3), and the Spec 2 importer's yacht/company hydration. + +## Source + +The legacy field list comes from `client-portal/server/api/eoi/generate-quick-eoi.ts`, specifically the POST body sent to `POST /api/v1/templates/{templateId}/generate-document` (Documenso template 8). The relevant lines in that file are around the `createDocumentPayload.formValues` object. + +## Documenso template `formValues` keys + +Documenso template IDs and recipient IDs are configured via env vars: + +- `NUXT_DOCUMENSO_TEMPLATE_ID` (default: `8`) +- `NUXT_DOCUMENSO_CLIENT_RECIPIENT_ID` (default: `192`) — signing order 1 +- `NUXT_DOCUMENSO_DEVELOPER_RECIPIENT_ID` (default: `193`) — signing order 2 +- `NUXT_DOCUMENSO_APPROVAL_RECIPIENT_ID` (default: `194`) — APPROVER, signing order 3 + +The template exposes eight text fields (`formValues` keys) and two boolean checkboxes. + +## Field mapping + +| Documenso key | Type | Legacy source | New `EoiContext` path | Notes | +| -------------- | ------- | --------------------------- | ----------------------------------------------------- | ------------------------------------------------------------------------- | +| `Name` | text | `interest['Full Name']` | `context.client.fullName` | The interest's point-of-contact client (billing signer). | +| `Email` | text | `interest['Email Address']` | `context.client.primaryEmail` | Primary email contact from `client_contacts`. | +| `Address` | text | `interest['Address']` | concat `context.client.address.{street,city,country}` | Concatenate street, city, country with `', '`. Empty if address is null. | +| `Yacht Name` | text | `interest['Yacht Name']` | `context.yacht.name` | Yacht is now a first-class row; pulled via `interest.yachtId`. | +| `Length` | text | `interest['Length']` | `context.yacht.lengthFt` | Send as string. Documenso doesn't enforce numeric format. | +| `Width` | text | `interest['Width']` | `context.yacht.widthFt` | Same. | +| `Draft` | text | `interest['Depth']` | `context.yacht.draftFt` | Legacy field was named "Depth" in NocoDB; Documenso key is "Draft". | +| `Berth Number` | text | `berthNumbers` (joined) | `context.berth.mooringNumber` | One berth per reservation. Multi-berth case was multi-interest in legacy. | +| `Lease_10` | boolean | hardcoded `false` | `false` | Hardcoded — legacy flow defaults to Purchase (not Lease). | +| `Purchase` | boolean | hardcoded `true` | `true` | Hardcoded — legacy flow defaults to Purchase. | + +## Document `meta` fields (non-`formValues`) + +| Documenso key | Type | Legacy source | New source | +| ------------------------- | ---- | ---------------------------------------- | ----------------------------------------------------------------- | +| `meta.message` | text | `Dear ${interest['Full Name']}...` | `Dear ${context.client.fullName}, ...port name interpolated` | +| `meta.subject` | text | `"Your LOI is ready to be signed"` | Same — constant. | +| `meta.redirectUrl` | text | `"https://portnimara.com"` | `context.port.redirectUrl` if per-port; otherwise global app URL. | +| `meta.distributionMethod` | text | `"NONE"` | Same — constant. We use manual send flow (Documenso webhook). | +| `title` | text | `` `${interest['Full Name']}-EOI-NDA` `` | `` `${context.client.fullName}-EOI-NDA` `` | +| `externalId` | text | `` `loi-${interestId}` `` | Same. | + +## Recipients (non-`formValues`) + +| Recipient | Role | Name | Email | Signing order | +| ------------------- | -------- | ------------------------- | ----------------------------- | ------------- | +| Client (signer) | SIGNER | `context.client.fullName` | `context.client.primaryEmail` | 1 | +| Developer (signer) | SIGNER | `"David Mizrahi"` | `"dm@portnimara.com"` | 2 | +| Approval (approver) | APPROVER | `"Abbie May"` | `"sales@portnimara.com"` | 3 | + +The Developer and Approval recipients are currently hardcoded in the legacy flow. In the new system these should eventually come from port-level settings (e.g., `ports.settings.eoi.developerName` + email). For Task 11.2, keep them hardcoded as the legacy system does — tracking as TODO: "Replace hardcoded Developer/Approval recipients with port-level configuration." + +## Company-owned yacht handling + +The legacy flow has no concept of company ownership — the signer is always the interest's client. In the new system: + +- If `context.yacht.ownerType === 'client'`: behavior unchanged. +- If `context.yacht.ownerType === 'company'`: the interest's point-of-contact client still signs (they're the representative of the yacht's owning company), but an extra block should appear in the message body: `"On behalf of ${context.company.legalName ?? context.company.name} (representing the yacht's owner)."`. This isn't a separate Documenso field — it's woven into `meta.message`. + +Tracking this in the mapping doc rather than as a hard TODO because company-owned EOIs were rare in the legacy system and need product input before committing to the final wording. + +## Deprecated fields (no longer sourced from `clients`) + +The legacy system read these fields from the client row. They are now sourced elsewhere: + +| Legacy source | New source | +| ------------------------- | --------------------------------------------------- | +| `client.yachtName` | `yachts.name` via `interest.yachtId` | +| `client.yachtLengthFt` | `yachts.lengthFt` via `interest.yachtId` | +| `client.yachtWidthFt` | `yachts.widthFt` via `interest.yachtId` | +| `client.yachtDraftFt` | `yachts.draftFt` via `interest.yachtId` | +| `client.companyName` | `companies.name` via polymorphic owner resolution | +| `client.berthSizeDesired` | Removed. Berth is picked via reservation, not text. | diff --git a/docs/runbooks/backup-and-restore.md b/docs/runbooks/backup-and-restore.md new file mode 100644 index 0000000..8e04000 --- /dev/null +++ b/docs/runbooks/backup-and-restore.md @@ -0,0 +1,199 @@ +# Backup and restore runbook + +This runbook documents what gets backed up, how often, where it lands, and +the exact commands to restore the system from a cold start. The goal is +that any operator who has the off-site backup credentials can bring the +CRM back up on a clean host without help. + +## Scope of a "full backup" + +The CRM has three stateful surfaces. All three must be captured for a +restore to be useful. + +| Surface | Holds | Risk if missing | +| ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------- | +| **PostgreSQL** (`port_nimara_crm`) | Every relational record: clients, yachts, companies, interests, reservations, invoices, audit log, GDPR exports, AI usage ledger, Documenso webhook receipts, etc. | Total data loss — site is unrecoverable. | +| **MinIO bucket** (`MINIO_BUCKET`, default `crm-files`) | Receipts, signed contracts, EOI PDFs, GDPR export ZIPs, document attachments. | Files reachable by row references in Postgres become 404s. | +| **`.env` + secrets** | DB password, MinIO keys, Documenso webhook secret, SMTP creds, encryption key (`ENCRYPTION_KEY`). | OCR API keys re-resolve from `system_settings` (encrypted at rest), but **without the original `ENCRYPTION_KEY` they're unreadable**. | + +The Redis instance is not backed up. It only holds queue state, rate-limit +counters, and Socket.IO presence — all reconstructable. Stop the workers +during a restore so the queue starts clean. + +## Backup schedule + +Defaults are tuned for a single-port deployment with O(10k) clients. Bump +on the producing side as scale demands. + +| Job | Frequency | Retention | Where | +| ---------------------------------- | -------------------- | ----------------------------- | -------------------------------------------------------------------- | +| `pg_dump` (custom format, gzipped) | Hourly | 7 days hourly + 30 days daily | `${BACKUP_BUCKET}/pg///.dump.gz` | +| MinIO mirror | Hourly (incremental) | 30 days versions | `${BACKUP_BUCKET}/minio/` | +| `.env` snapshot (encrypted) | On change (manual) | Forever | Password manager / secrets vault — **never the same bucket as data** | + +The hourly cadence is the right answer for this workload — invoices and +contracts cluster around business hours, and an hour of lost work is the +worst-case data loss window most clients will tolerate. Promote to 15-min +WAL streaming if a customer demands tighter RPO. + +## Required environment variables + +The scripts below read these. Store them in a CI secret store, not the +host's bash profile. + +``` +# Source (the running CRM database) +DATABASE_URL=postgresql://crm:@:/port_nimara_crm + +# MinIO (source bucket — the live one) +MINIO_ENDPOINT=minio.letsbe.solutions +MINIO_PORT=443 +MINIO_USE_SSL=true +MINIO_ACCESS_KEY= +MINIO_SECRET_KEY= +MINIO_BUCKET=crm-files + +# Backup destination (a *separate* MinIO/S3 endpoint or a different bucket +# with no IAM overlap with the live keys) +BACKUP_S3_ENDPOINT=https://s3.eu-west-1.amazonaws.com +BACKUP_S3_REGION=eu-west-1 +BACKUP_S3_BUCKET=portnimara-backups-prod +BACKUP_S3_ACCESS_KEY= +BACKUP_S3_SECRET_KEY=<...> + +# Optional: encrypts dumps at rest with a passphrase. Cuts a wider blast +# radius if the backup bucket itself is compromised. +BACKUP_GPG_RECIPIENT=ops@portnimara.com +``` + +## Provisioning the backup destination + +1. Create a dedicated S3-compatible bucket in a **different account** from + the live infra. AWS S3, Backblaze B2, or a separately-credentialed + MinIO instance all work. +2. Apply object-lock or versioning so an attacker who steals the backup + write key still can't permanently delete history. +3. Generate IAM credentials scoped to `s3:PutObject`, `s3:GetObject`, + `s3:ListBucket` on this bucket only. Inject them as + `BACKUP_S3_*` above. Do not reuse the live `MINIO_*` keys. +4. Set a 90-day lifecycle rule that transitions objects older than 30 + days to cold storage and deletes them at 90 days. Past 90 days it's + cheaper to restart from a snapshot taken outside the system. + +## The scripts + +Three scripts in `scripts/backup/`: + +- `pg-backup.sh` — runs `pg_dump`, gzips, optionally GPG-encrypts, uploads +- `minio-mirror.sh` — `mc mirror` of the live bucket → backup bucket +- `restore.sh` — interactive restore (DB + MinIO) given a snapshot path + +Make them executable and wire them into cron / GitHub Actions / your +scheduler of choice. Sample crontab on the worker host: + +```cron +# Hourly DB dump at minute 7 +7 * * * * /opt/pncrm/scripts/backup/pg-backup.sh >> /var/log/pncrm-backup.log 2>&1 + +# Hourly MinIO mirror at minute 17 (offset so the two don't fight for I/O) +17 * * * * /opt/pncrm/scripts/backup/minio-mirror.sh >> /var/log/pncrm-backup.log 2>&1 + +# Weekly restore drill (smoke-test to a throwaway DB on Sunday at 03:00) +0 3 * * 0 /opt/pncrm/scripts/backup/restore.sh --drill >> /var/log/pncrm-restore-drill.log 2>&1 +``` + +## Restoring from cold + +These steps have been rehearsed against the dev environment; expect them +to take 15–30 minutes for a typical port. **The drill (last cron line +above) ensures the runbook stays correct — if the drill fails, the +real restore will too.** + +### 0. Stop everything that writes + +```bash +docker compose -f docker-compose.prod.yml stop web worker scheduler +# Leave postgres + minio + redis up; we'll point them at restored data. +``` + +### 1. Restore PostgreSQL + +```bash +# Find the dump you want. Prefer the most recent successful hour. +mc ls "$BACKUP_S3_BUCKET/pg/$(hostname)/" | tail +SNAPSHOT="2026-04-28/14.dump.gz" + +# Pull it. +mc cp "$BACKUP_S3_BUCKET/pg/$(hostname)/$SNAPSHOT" /tmp/ + +# Decrypt if BACKUP_GPG_RECIPIENT was set on the producer side. +gpg --decrypt /tmp/14.dump.gz.gpg > /tmp/14.dump.gz + +# Drop & recreate the database. The 'restrict' FK from gdpr_exports.requested_by +# to user means we restore in the right order — pg_restore handles this. +psql "$DATABASE_URL" -c 'DROP DATABASE IF EXISTS port_nimara_crm WITH (FORCE);' +psql "$DATABASE_URL" -c 'CREATE DATABASE port_nimara_crm;' +gunzip -c /tmp/14.dump.gz | pg_restore --no-owner --no-privileges \ + --dbname "$DATABASE_URL" +``` + +### 2. Restore MinIO + +```bash +# Sync the backup bucket back over the live one. --overwrite handles +# files that were modified between snapshots. +mc mirror --overwrite \ + "$BACKUP_S3_BUCKET/minio/" \ + "live/$MINIO_BUCKET/" +``` + +### 3. Restore secrets + +The `.env` file is **not** in object storage. Pull it from the password +manager / secrets vault. Verify `ENCRYPTION_KEY` matches the value used +when the database was last running — if it doesn't, rows in +`system_settings` (OCR API keys, etc.) decrypt to garbage and the OCR +"Test connection" button will return an opaque error. There is no +recovery path; the keys must be re-entered through the admin UI. + +### 4. Bring services back up + +```bash +docker compose -f docker-compose.prod.yml up -d +# Watch the worker logs; expect a flurry of socket reconnections, then quiet. +docker compose -f docker-compose.prod.yml logs -f worker +``` + +### 5. Verify + +Tail through the smoke checklist, in order: + +1. **DB up** — `psql "$DATABASE_URL" -c 'SELECT count(*) FROM clients;'` + matches the producer-side count from the snapshot's hour. +2. **MinIO up** — open any client with attachments in the CRM, click a + receipt thumbnail; verify the signed URL serves the file. +3. **Documenso webhooks** — re-trigger one in the Documenso admin and + confirm `audit_logs` records the receipt. +4. **Email** — send a portal invite to a real address. +5. **Realtime** — open two browser windows, edit a client in one, watch + the other update via Socket.IO. +6. **AI usage ledger** — `SELECT count(*) FROM ai_usage_ledger;` + non-empty if AI was being used. Old rows survive but the budget gates + reset alongside the period boundary at month rollover. + +## Drill schedule + +The weekly drill (cron line above) runs `restore.sh --drill` against a +throwaway database and a sandbox MinIO bucket. It must produce zero diff +between the restored row counts and the live row counts (modulo the +hour-or-so the drill takes to run). + +Failure modes the drill catches before they bite production: + +- New tables added without inclusion in `pg_dump`'s `--schema=public` (we + use the default, which captures everything in `public` — but a future + developer adding a `tenant_X` schema will silently lose it). +- MinIO bucket-policy changes that block the backup-side `s3:GetObject` + on certain prefixes. +- GPG passphrase rotation that wasn't propagated to the restore host. +- A `pg_restore` version skew with the producer-side `pg_dump`. diff --git a/docs/runbooks/email-deliverability.md b/docs/runbooks/email-deliverability.md new file mode 100644 index 0000000..debabec --- /dev/null +++ b/docs/runbooks/email-deliverability.md @@ -0,0 +1,186 @@ +# Email deliverability runbook + +The CRM sends transactional email through three different surfaces. Each +has a different failure mode when it lands in spam. This runbook covers +how to diagnose, fix, and verify each path. + +## What email the CRM sends + +| Surface | Trigger | Template | Default `from` | +| ----------------------------------------- | -------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | ----------------------------------------------------- | +| Portal activation / password-reset | Admin invites a client to the portal | `src/lib/email/templates/portal-auth.ts` | per-port `email_settings.from_address` or `SMTP_FROM` | +| Inquiry confirmation + sales notification | Public website POSTs to `/api/public/interests` or `/api/public/residential-inquiries` | `inquiry-client-confirmation.ts`, `inquiry-sales-notification.ts` | same | +| GDPR export ready | Staff requests an export with `emailToClient=true` | inline in `gdpr-export.service.ts` | same | +| Documenso reminders | Cadence job fires for an unsigned signer | `documenso/reminders/*` | same | + +Documenso _itself_ sends signing requests with its own `from` address — +those don't flow through this codebase. SPF/DKIM for the Documenso +sender is the Documenso operator's problem, not yours. + +## DNS records + +For every domain that appears in a `from:` header you must publish: + +### 1. SPF + +A single TXT record at the apex authorizing whichever provider is +sending. Multiple SPF records on the same name **break SPF entirely** — +combine into one. + +``` +v=spf1 include:_spf.google.com include:amazonses.com -all +``` + +The `-all` (hardfail) is correct for transactional mail. Switch to `~all` +(softfail) only as a temporary diagnostic when migrating providers. + +### 2. DKIM + +Each provider publishes its own selector. Common shapes: + +- Google Workspace: `google._domainkey` → 2048-bit RSA pubkey (rotate every 12 months). +- Amazon SES: `xxxx._domainkey`, `yyyy._domainkey`, `zzzz._domainkey` (three CNAMEs SES gives you). +- Postmark / Resend / Mailgun: one CNAME per selector. + +Verify alignment — the `d=` value in the DKIM signature must match the +`From:` domain (relaxed alignment is fine, strict is overkill). + +### 3. DMARC + +Start at `p=none` while you build deliverability data, then upgrade. + +``` +_dmarc 14400 IN TXT "v=DMARC1; p=quarantine; rua=mailto:dmarc@portnimara.com; ruf=mailto:dmarc@portnimara.com; fo=1; adkim=r; aspf=r; pct=100" +``` + +`rua` (aggregate reports) is the diagnostic feed — set it before the +first send so the first weekly report has data. + +### 4. MX (only if you also receive) + +The CRM's IMAP probe (`scripts/dev-imap-probe.ts`) and the inbound thread +sync rely on a real mailbox. Whoever runs that mailbox publishes the MX +records — typically Google Workspace or a dedicated provider. Don't add +an MX pointing at the CRM host; it doesn't accept SMTP IN. + +## Per-port overrides + +Each port can override `from_address`, `from_name`, and SMTP creds via +the admin email-settings page. When set, `getPortEmailConfig()` returns +those values and `sendEmail()` uses them in preference to the global +`SMTP_*` env. **The override domain still needs SPF / DKIM / DMARC** on +its own DNS — without them, every send from that port lands in spam. + +When a customer reports "our portal invite didn't arrive": + +1. Pull the port's email settings from the admin UI. Check `from_address`. +2. Run `dig TXT ` and `dig TXT _dmarc.`. + Confirm SPF includes the SMTP provider's domain and DMARC exists. +3. Send a probe through `mail-tester.com`: paste the address into a + test send, click the score breakdown. +4. Score < 8/10 → fix whatever's flagged before doing anything else in + this runbook. + +## Diagnosing a "didn't arrive" report + +Order matters — go top-down, stop when one of these is the answer. + +### Step 1: Was the send attempted? + +```bash +# Tail the worker logs for the recipient address. +docker compose logs worker | grep '' +``` + +You'll see one of three patterns: + +- **Nothing**: The job didn't run. Check that BullMQ actually queued it. + `redis-cli LLEN bull:email:waiting` — if non-zero, the worker is dead. + `docker compose logs scheduler | tail` to see why. +- **`Email sent`** with a message-id: The provider accepted it. Move to + Step 2. +- **`SendError`**: Provider rejected. The error string says why + (auth, rate limit, blocked recipient). + +### Step 2: Is `EMAIL_REDIRECT_TO` set? + +In dev/test we set `EMAIL_REDIRECT_TO=ops@portnimara.com` so seeded fake +clients don't get real email. **It must be unset in production.** + +```bash +# On the production host: +docker exec pncrm-web printenv EMAIL_REDIRECT_TO +# Should print nothing. +``` + +If it's set, every email is going to the redirect target with the +original recipient prefixed in the subject — the customer never sees it. + +### Step 3: Did it land but get filtered? + +Ask the recipient to check: + +- Spam / Junk folder +- Gmail "Promotions" tab +- Outlook "Other" folder (vs Focused) +- The Quarantine console if they're on M365 with anti-spam enabled + +If found in a spam folder: the email arrived; the recipient's filter +classified it. SPF/DKIM/DMARC alignment is suspect — re-run the +mail-tester probe from above. + +### Step 4: Was the recipient on a suppression list? + +Some providers (SES, Postmark) maintain a suppression list — once a +domain bounces from an address, future sends are dropped silently. + +```bash +# SES example: +aws ses list-suppressed-destinations --region eu-west-1 +``` + +If the recipient is suppressed, remove them and ask them to retry. The +CRM doesn't track suppression locally; that's the provider's job. + +## When migrating SMTP providers + +1. Add the new provider's DKIM CNAMEs alongside the old ones. +2. Add the new provider's `include:` to the existing SPF record. +3. Wait 48 hours for DNS to propagate and DMARC reports to confirm both + providers align. +4. Switch `SMTP_*` env to the new provider on a single staging host. +5. Send through the staging host for a week. Watch DMARC reports. +6. Cut production over. +7. Wait two weeks before removing the old provider's DNS — undelivered + bounce reports keep arriving for a while. + +## Testing a deliverability fix + +There's no automated test for "did this email reach the inbox" — that's a +property of the recipient's filter, which we don't control. The closest +proxy is the realapi suite: + +```bash +pnpm exec playwright test --project=realapi +``` + +It runs `tests/e2e/realapi/portal-imap-activation.spec.ts` which sends a +real portal-invite email through SMTP, then polls the configured IMAP +mailbox for the activation link. If it appears within 30 seconds, the +SMTP→DKIM→DMARC chain is alive end-to-end. If the test times out, work +backwards through this runbook. + +The realapi suite needs `SMTP_*` and `IMAP_*` env vars — see the +"Optional dev/test-only env vars" block in `CLAUDE.md`. + +## Bounce handling + +The CRM doesn't currently process bounces. If you start seeing volume: + +- Set up the provider's webhook (SES → SNS → Lambda; Postmark → webhook + URL) to POST bounce events to a new `/api/webhooks/email-bounce` route. +- Persist the bounced address into a `email_suppressions` table. +- Have `sendEmail()` consult that table before each send. + +That work isn't in scope yet; this runbook just flags it as the next +deliverability gap. diff --git a/docs/runbooks/permission-audit.md b/docs/runbooks/permission-audit.md new file mode 100644 index 0000000..5e29c75 --- /dev/null +++ b/docs/runbooks/permission-audit.md @@ -0,0 +1,56 @@ +# Permission Matrix Audit + +Scanned 182 route files under `src/app/api/v1/`. + +**No violations.** Every internal v1 handler is permission-gated. + +**Allow-listed:** 46 handler(s) intentionally skip `withPermission`. + +| File | Method | Reason | +| ---------------------------------------------------------------- | ------ | --------------------------------------------------------------------------------- | +| `src/app/api/v1/admin/alerts/run-engine/route.ts` | POST | Admin-only — gated by isSuperAdmin inside handler. | +| `src/app/api/v1/admin/connections/route.ts` | GET | Admin-only — gated by isSuperAdmin inside handler. | +| `src/app/api/v1/admin/errors/route.ts` | GET | Admin-only — gated by isSuperAdmin inside handler. | +| `src/app/api/v1/admin/health/route.ts` | GET | Admin-only — gated by isSuperAdmin inside handler. | +| `src/app/api/v1/admin/ocr-settings/route.ts` | GET | Admin-only — gated by isSuperAdmin inside handler. | +| `src/app/api/v1/admin/ocr-settings/route.ts` | PUT | Admin-only — gated by isSuperAdmin inside handler. | +| `src/app/api/v1/admin/ocr-settings/test/route.ts` | POST | Admin-only — gated by isSuperAdmin inside handler. | +| `src/app/api/v1/admin/queues/[queueName]/[jobId]/retry/route.ts` | POST | Admin-only — gated by isSuperAdmin inside handler. | +| `src/app/api/v1/admin/queues/[queueName]/[jobId]/route.ts` | DELETE | Admin-only — gated by isSuperAdmin inside handler. | +| `src/app/api/v1/admin/queues/[queueName]/route.ts` | GET | Admin-only — gated by isSuperAdmin inside handler. | +| `src/app/api/v1/admin/queues/route.ts` | GET | Admin-only — gated by isSuperAdmin inside handler. | +| `src/app/api/v1/admin/users/options/route.ts` | GET | Admin-only — gated by isSuperAdmin inside handler. | +| `src/app/api/v1/ai/email-draft/[jobId]/route.ts` | GET | TODO: needs ai:\* permission catalog entry. Currently allow-listed. | +| `src/app/api/v1/ai/email-draft/route.ts` | POST | TODO: needs ai:\* permission catalog entry. Currently allow-listed. | +| `src/app/api/v1/ai/interest-score/bulk/route.ts` | GET | TODO: needs ai:\* permission catalog entry. Currently allow-listed. | +| `src/app/api/v1/ai/interest-score/route.ts` | GET | TODO: needs ai:\* permission catalog entry. Currently allow-listed. | +| `src/app/api/v1/alerts/[id]/acknowledge/route.ts` | POST | Alerts are user-scoped; port-filtered via auth context. | +| `src/app/api/v1/alerts/[id]/dismiss/route.ts` | POST | Alerts are user-scoped; port-filtered via auth context. | +| `src/app/api/v1/alerts/count/route.ts` | GET | Alerts are user-scoped; port-filtered via auth context. | +| `src/app/api/v1/alerts/route.ts` | GET | Alerts are user-scoped; port-filtered via auth context. | +| `src/app/api/v1/berth-reservations/[id]/route.ts` | PATCH | TODO: PATCH should map to reservations:edit (not currently in catalog). | +| `src/app/api/v1/currency/convert/route.ts` | POST | Currency reference data; port-scoped, no PII. | +| `src/app/api/v1/currency/rates/refresh/route.ts` | POST | TODO: gate with admin:manage_settings — currently allow-listed. | +| `src/app/api/v1/currency/rates/route.ts` | GET | Currency reference data; port-scoped, no PII. | +| `src/app/api/v1/custom-fields/[entityId]/route.ts` | GET | TODO: needs custom_fields:\* permission. PUT path internally validated. | +| `src/app/api/v1/custom-fields/[entityId]/route.ts` | PUT | TODO: needs custom_fields:\* permission. PUT path internally validated. | +| `src/app/api/v1/expenses/export/parent-company/route.ts` | POST | Internally gated by isSuperAdmin inside the handler. | +| `src/app/api/v1/me/route.ts` | GET | Self-endpoint — auth is sufficient. | +| `src/app/api/v1/me/route.ts` | PATCH | Self-endpoint — auth is sufficient. | +| `src/app/api/v1/notifications/[notificationId]/route.ts` | PATCH | User-scoped notifications — caller is the resource owner. | +| `src/app/api/v1/notifications/preferences/route.ts` | GET | User-scoped notifications — caller is the resource owner. | +| `src/app/api/v1/notifications/preferences/route.ts` | PUT | User-scoped notifications — caller is the resource owner. | +| `src/app/api/v1/notifications/read-all/route.ts` | POST | User-scoped notifications — caller is the resource owner. | +| `src/app/api/v1/notifications/route.ts` | GET | User-scoped notifications — caller is the resource owner. | +| `src/app/api/v1/notifications/unread-count/route.ts` | GET | User-scoped notifications — caller is the resource owner. | +| `src/app/api/v1/saved-views/[id]/route.ts` | PATCH | User-self saved views — caller is the resource owner. | +| `src/app/api/v1/saved-views/[id]/route.ts` | DELETE | User-self saved views — caller is the resource owner. | +| `src/app/api/v1/saved-views/route.ts` | GET | User-self saved views — caller is the resource owner. | +| `src/app/api/v1/saved-views/route.ts` | POST | User-self saved views — caller is the resource owner. | +| `src/app/api/v1/search/recent/route.ts` | GET | Port-scoped search — results filtered by auth context (resources have own perms). | +| `src/app/api/v1/search/route.ts` | GET | Port-scoped search — results filtered by auth context (resources have own perms). | +| `src/app/api/v1/settings/feature-flag/route.ts` | GET | Public read of feature-flag bool — no PII; auth is sufficient. | +| `src/app/api/v1/tags/options/route.ts` | GET | Tags are cross-cutting reference data; port-scoped via auth. | +| `src/app/api/v1/tags/route.ts` | GET | Tags are cross-cutting reference data; port-scoped via auth. | +| `src/app/api/v1/users/me/preferences/route.ts` | GET | User-self preferences — caller is the resource owner. | +| `src/app/api/v1/users/me/preferences/route.ts` | PATCH | User-self preferences — caller is the resource owner. | diff --git a/docs/superpowers/plans/2026-04-29-mobile-foundation.md b/docs/superpowers/plans/2026-04-29-mobile-foundation.md new file mode 100644 index 0000000..d02732a --- /dev/null +++ b/docs/superpowers/plans/2026-04-29-mobile-foundation.md @@ -0,0 +1,1918 @@ +# Mobile Foundation PR Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Land the infrastructure, mobile shell, and mobile-aware primitives that §3 of the design spec requires, so subsequent per-page migrations are wrap-and-tweak. After this PR merges, every authenticated page already gains: viewport meta, no clipped topbar, bottom-tab navigation, safe-area handling, and 44px touch targets — without any per-page edits. + +**Architecture:** Adaptive shell via a `data-form-factor` body attribute set server-side from the User-Agent (no middleware), with a CSS media-query fallback. Both desktop and mobile shells render to the DOM; CSS reveals one. Mobile-aware primitives (``, ``, ``, ``, ``, ``) live in `src/components/shared/` and switch presentation at the `lg` Tailwind breakpoint. + +**Tech Stack:** Next.js 15 App Router, React 19, TypeScript strict, Tailwind 3, Radix/shadcn, vaul (new — for native-feel bottom sheets), Lucide icons, vitest (unit), Playwright (visual + audit). + +**Spec reference:** `docs/superpowers/specs/2026-04-29-mobile-optimization-design.md` §3. + +**Spec deviation:** spec calls the vaul-wrapper primitive `` but `src/components/ui/sheet.tsx` already exists (shadcn slide-from-side using Radix Dialog). The plan uses `` instead — matches shadcn's official vaul-wrapper naming convention and avoids collision. + +**Out of scope** (separate follow-up plans, see spec §5): + +- Per-page migration (quick-win sweep, list pages, detail pages, heavy pages, forms, portal, tablet pass). +- Adopting `` everywhere existing `` is used. Foundation only ships the primitive; per-page work swaps imports. +- Final PWA icon designs — placeholders only. + +--- + +## File structure + +**New files:** + +- `src/hooks/use-is-mobile.ts` — viewport-driven mobile detection hook +- `src/lib/form-factor.ts` — pure UA-classification function (unit-testable) +- `src/components/layout/mobile/mobile-layout.tsx` +- `src/components/layout/mobile/mobile-topbar.tsx` +- `src/components/layout/mobile/mobile-bottom-tabs.tsx` +- `src/components/layout/mobile/more-sheet.tsx` +- `src/components/layout/mobile/mobile-layout-provider.tsx` +- `src/components/shared/drawer.tsx` — vaul wrapper (was `` in spec) +- `src/components/shared/data-view.tsx` +- `src/components/shared/page-header.tsx` +- `src/components/shared/action-row.tsx` +- `src/components/shared/detail-page-shell.tsx` +- `src/components/shared/filter-chips.tsx` +- `tests/unit/lib/form-factor.test.ts` — vitest +- `tests/unit/hooks/use-is-mobile.test.ts` — vitest +- `tests/e2e/fixtures/devices.ts` — anchor device descriptors +- `tests/e2e/visual/mobile-shell.spec.ts` — playwright visual snapshot for the mobile shell +- `public/icon-192.png` — placeholder PWA asset (solid blue 192×192) +- `public/icon-512.png` — placeholder PWA asset (solid blue 512×512) +- `public/icon-512-maskable.png` — placeholder PWA asset (solid blue 512×512 with safe zone padding) +- `public/apple-touch-icon.png` — placeholder PWA asset (solid blue 180×180) + +**Modified files:** + +- `src/app/layout.tsx` — add `viewport` export, theme-color, body data-form-factor, apple-mobile-web-app metas +- `src/app/(dashboard)/layout.tsx` — render `` alongside the existing ``/``; CSS hides the inactive shell +- `src/app/globals.css` — add `[data-form-factor]` reveal/hide rules + media-query fallback +- `tailwind.config.ts` — add `safe` spacing utilities (`pt-safe`/`pb-safe`/etc.) +- `src/components/ui/button.tsx` — bump `size: default` from `h-9` to `h-11` (and `sm`/`lg`/`icon` proportionally) +- `src/components/ui/input.tsx` — bump from `h-9` to `h-11`, drop `md:text-sm` (keep 16px to prevent iOS zoom) +- `src/components/ui/textarea.tsx` — drop `md:text-sm` (keep 16px) +- `src/components/ui/dialog.tsx` — adjust `DialogContent` to render full-screen on mobile (`inset-0 max-w-full sm:inset-auto sm:max-w-lg`) +- `package.json` — add `vaul` dependency + +--- + +## Task 1: Add `viewport` export, theme-color, and PWA metas to root layout + +**Files:** + +- Modify: `src/app/layout.tsx` + +- [ ] **Step 1: Add the `viewport` export and PWA-related metadata to the root layout** + +```ts +// src/app/layout.tsx +import type { Metadata, Viewport } from 'next'; +import { Inter, JetBrains_Mono } from 'next/font/google'; +import { Toaster } from 'sonner'; +import './globals.css'; + +const inter = Inter({ + subsets: ['latin'], + variable: '--font-sans', + display: 'swap', +}); + +const jetbrainsMono = JetBrains_Mono({ + subsets: ['latin'], + variable: '--font-mono', + display: 'swap', +}); + +export const viewport: Viewport = { + width: 'device-width', + initialScale: 1, + viewportFit: 'cover', + themeColor: '#1e2844', +}; + +export const metadata: Metadata = { + title: { + default: 'Port Nimara CRM', + template: '%s | Port Nimara CRM', + }, + description: 'Marina management system for Port Nimara', + appleWebApp: { + capable: true, + statusBarStyle: 'black-translucent', + title: 'Port Nimara', + }, + icons: { + icon: [ + { url: '/icon-192.png', sizes: '192x192', type: 'image/png' }, + { url: '/icon-512.png', sizes: '512x512', type: 'image/png' }, + ], + apple: '/apple-touch-icon.png', + }, +}; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + + {children} + + + + ); +} +``` + +(The body `data-form-factor` attribute lands in Task 3.) + +- [ ] **Step 2: Verify the root layout still typechecks** + +Run: `pnpm exec tsc --noEmit` +Expected: no errors. + +- [ ] **Step 3: Verify the dev server still serves the page** + +Open `http://localhost:3000/login` in a browser, view source. Expected: `` and `` are present in ``. + +- [ ] **Step 4: Commit** + +```bash +git add src/app/layout.tsx +git commit -m "feat(mobile): add viewport meta, theme-color, and PWA metadata to root layout" +``` + +--- + +## Task 2: Add safe-area Tailwind utilities + +**Files:** + +- Modify: `tailwind.config.ts` + +- [ ] **Step 1: Add safe-area spacing utilities to the theme extension** + +Find the `extend:` block in `tailwind.config.ts`. Add these keys (place `spacing` before `keyframes`): + +```ts +spacing: { + safe: 'env(safe-area-inset-bottom)', + 'safe-top': 'env(safe-area-inset-top)', + 'safe-bottom': 'env(safe-area-inset-bottom)', + 'safe-left': 'env(safe-area-inset-left)', + 'safe-right': 'env(safe-area-inset-right)', +}, +``` + +This makes `pt-safe-top`, `pb-safe-bottom`, `pl-safe-left`, `pr-safe-right` (and `pt-safe`/`pb-safe` shorthand) available as Tailwind utilities. + +- [ ] **Step 2: Verify the config still parses** + +Run: `pnpm exec tsc --noEmit` +Expected: no errors. + +- [ ] **Step 3: Smoke-test the utility actually emits** + +Add a temporary `pb-safe-bottom` class to a test page (e.g., `src/app/(auth)/login/page.tsx`). Reload, inspect — element should have `padding-bottom: env(safe-area-inset-bottom)`. Remove the test class. + +- [ ] **Step 4: Commit** + +```bash +git add tailwind.config.ts +git commit -m "feat(mobile): add safe-area spacing utilities (pt-safe-top, pb-safe-bottom, etc.)" +``` + +--- + +## Task 3: Create UA-derived form-factor classifier (TDD) + +**Files:** + +- Create: `src/lib/form-factor.ts` +- Create: `tests/unit/lib/form-factor.test.ts` + +- [ ] **Step 1: Write the failing test** + +```ts +// tests/unit/lib/form-factor.test.ts +import { describe, it, expect } from 'vitest'; +import { classifyFormFactor } from '@/lib/form-factor'; + +describe('classifyFormFactor', () => { + it('returns "mobile" for an iPhone UA', () => { + expect( + classifyFormFactor( + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148', + ), + ).toBe('mobile'); + }); + + it('returns "mobile" for an iPad UA', () => { + expect( + classifyFormFactor( + 'Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148', + ), + ).toBe('mobile'); + }); + + it('returns "mobile" for an Android UA', () => { + expect( + classifyFormFactor( + 'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 Mobile Safari/537.36', + ), + ).toBe('mobile'); + }); + + it('returns "desktop" for a Mac Safari UA', () => { + expect( + classifyFormFactor( + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) AppleWebKit/605.1.15 Version/17.0 Safari/605.1.15', + ), + ).toBe('desktop'); + }); + + it('returns "desktop" for a Linux Chrome UA', () => { + expect( + classifyFormFactor( + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/120.0 Safari/537.36', + ), + ).toBe('desktop'); + }); + + it('returns "desktop" for missing UA', () => { + expect(classifyFormFactor(null)).toBe('desktop'); + expect(classifyFormFactor(undefined)).toBe('desktop'); + expect(classifyFormFactor('')).toBe('desktop'); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `pnpm exec vitest run tests/unit/lib/form-factor.test.ts` +Expected: FAIL with `Cannot find module '@/lib/form-factor'`. + +- [ ] **Step 3: Write the minimal implementation** + +```ts +// src/lib/form-factor.ts +export type FormFactor = 'mobile' | 'desktop'; + +const MOBILE_TOKENS = ['Mobile', 'iPhone', 'iPad', 'Android'] as const; + +/** + * Classify a User-Agent string as 'mobile' or 'desktop'. + * Defaults to 'desktop' when the UA is missing or unrecognized — the CSS + * media-query fallback in globals.css handles desktop browsers resized below + * the lg breakpoint, so a wrong-but-defaultish classification never breaks UX. + */ +export function classifyFormFactor(userAgent: string | null | undefined): FormFactor { + if (!userAgent) return 'desktop'; + return MOBILE_TOKENS.some((token) => userAgent.includes(token)) ? 'mobile' : 'desktop'; +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `pnpm exec vitest run tests/unit/lib/form-factor.test.ts` +Expected: 6 tests passing. + +- [ ] **Step 5: Wire it into the root layout** + +Modify `src/app/layout.tsx` — at the top, add the import: + +```ts +import { headers } from 'next/headers'; +import { classifyFormFactor } from '@/lib/form-factor'; +``` + +Change the `RootLayout` to async and read the form factor: + +```ts +export default async function RootLayout({ children }: { children: React.ReactNode }) { + const headerList = await headers(); + const formFactor = classifyFormFactor(headerList.get('user-agent')); + + return ( + + + {children} + + + + ); +} +``` + +- [ ] **Step 6: Verify it still builds** + +Run: `pnpm exec tsc --noEmit` +Expected: no errors. + +- [ ] **Step 7: Verify the body attribute renders** + +Open `http://localhost:3000/login` in a browser, inspect the `` element. Expected: `` (since this Mac Chrome UA is desktop). Open in a mobile-emulated tab (Chrome devtools → toggle device toolbar → iPhone) and reload — expected: `data-form-factor="mobile"`. + +- [ ] **Step 8: Commit** + +```bash +git add src/lib/form-factor.ts tests/unit/lib/form-factor.test.ts src/app/layout.tsx +git commit -m "feat(mobile): set data-form-factor body attr from User-Agent in root layout" +``` + +--- + +## Task 4: Add CSS rules that reveal mobile/desktop shells based on form factor + +**Files:** + +- Modify: `src/app/globals.css` + +- [ ] **Step 1: Append the form-factor reveal rules to globals.css** + +Add at the end of `src/app/globals.css`: + +```css +/* ─── Form-factor shell visibility ────────────────────────────────────────── + * Two shells (desktop + mobile) render to the DOM on every page; CSS reveals + * one and hides the other. The data-form-factor body attribute is set + * server-side from User-Agent (see src/lib/form-factor.ts). The media-query + * fallback handles desktop browsers resized below lg (1024px), or stripped UAs. + */ +[data-shell='desktop'] { + display: block; +} +[data-shell='mobile'] { + display: none; +} + +@media (max-width: 1023.98px) { + [data-shell='desktop'] { + display: none; + } + [data-shell='mobile'] { + display: block; + } +} + +body[data-form-factor='mobile'] [data-shell='desktop'] { + display: none; +} +body[data-form-factor='mobile'] [data-shell='mobile'] { + display: block; +} +``` + +The shell components themselves will set `data-shell="desktop"` or `data-shell="mobile"` on their root element (Tasks 13, 14). + +- [ ] **Step 2: Verify globals.css still parses** + +Reload `http://localhost:3000/login` — page should render normally with no console CSS errors. + +- [ ] **Step 3: Commit** + +```bash +git add src/app/globals.css +git commit -m "feat(mobile): add CSS rules to switch shells based on data-form-factor + viewport" +``` + +--- + +## Task 5: Create `useIsMobile()` hook (TDD) + +**Files:** + +- Create: `src/hooks/use-is-mobile.ts` +- Create: `tests/unit/hooks/use-is-mobile.test.ts` + +- [ ] **Step 1: Write the failing test** + +```ts +// tests/unit/hooks/use-is-mobile.test.ts +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useIsMobile } from '@/hooks/use-is-mobile'; + +type Listener = (e: { matches: boolean }) => void; + +describe('useIsMobile', () => { + let mediaListeners: Listener[]; + let currentMatches: boolean; + + beforeEach(() => { + mediaListeners = []; + currentMatches = false; + vi.stubGlobal( + 'matchMedia', + vi.fn((query: string) => ({ + matches: currentMatches, + media: query, + onchange: null, + addEventListener: (_: string, l: Listener) => mediaListeners.push(l), + removeEventListener: (_: string, l: Listener) => { + mediaListeners = mediaListeners.filter((x) => x !== l); + }, + addListener: () => {}, + removeListener: () => {}, + dispatchEvent: () => true, + })), + ); + Object.defineProperty(window, 'matchMedia', { + configurable: true, + writable: true, + value: globalThis.matchMedia, + }); + }); + + it('returns false for desktop viewport', () => { + currentMatches = false; + const { result } = renderHook(() => useIsMobile()); + expect(result.current).toBe(false); + }); + + it('returns true for mobile viewport', () => { + currentMatches = true; + const { result } = renderHook(() => useIsMobile()); + expect(result.current).toBe(true); + }); + + it('updates when the media query changes', () => { + currentMatches = false; + const { result } = renderHook(() => useIsMobile()); + expect(result.current).toBe(false); + + act(() => { + mediaListeners.forEach((l) => l({ matches: true })); + }); + expect(result.current).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `pnpm exec vitest run tests/unit/hooks/use-is-mobile.test.ts` +Expected: FAIL with `Cannot find module '@/hooks/use-is-mobile'`. + +- [ ] **Step 3: Write the implementation** + +```ts +// src/hooks/use-is-mobile.ts +'use client'; + +import { useEffect, useState } from 'react'; + +const MOBILE_QUERY = '(max-width: 1023.98px)'; + +/** + * Returns true when the viewport is below the `lg` Tailwind breakpoint. + * Backed by a media-query listener; safe to call from any client component. + * Server renders return `false` (desktop default) — clients hydrate to the + * true viewport state on mount. + */ +export function useIsMobile(): boolean { + const [isMobile, setIsMobile] = useState(false); + + useEffect(() => { + const mq = window.matchMedia(MOBILE_QUERY); + const update = (e: { matches: boolean }) => setIsMobile(e.matches); + setIsMobile(mq.matches); + mq.addEventListener('change', update); + return () => mq.removeEventListener('change', update); + }, []); + + return isMobile; +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `pnpm exec vitest run tests/unit/hooks/use-is-mobile.test.ts` +Expected: 3 tests passing. + +- [ ] **Step 5: Commit** + +```bash +git add src/hooks/use-is-mobile.ts tests/unit/hooks/use-is-mobile.test.ts +git commit -m "feat(mobile): add useIsMobile() hook backed by matchMedia" +``` + +--- + +## Task 6: Add vaul dependency + +**Files:** + +- Modify: `package.json` (via `pnpm add`) + +- [ ] **Step 1: Install vaul** + +Run: `pnpm add vaul@^1.1.2` +Expected: `vaul` appears in `package.json` dependencies. + +- [ ] **Step 2: Verify install** + +Run: `pnpm exec tsc --noEmit` +Expected: no errors. + +- [ ] **Step 3: Commit** + +```bash +git add package.json pnpm-lock.yaml +git commit -m "chore(deps): add vaul for native-feel bottom sheets" +``` + +--- + +## Task 7: Bump touch-target defaults on Button, Input, Textarea + +**Files:** + +- Modify: `src/components/ui/button.tsx` +- Modify: `src/components/ui/input.tsx` +- Modify: `src/components/ui/textarea.tsx` + +- [ ] **Step 1: Update Button size variants** + +In `src/components/ui/button.tsx`, change the `size` variants: + +```ts +size: { + default: "h-11 px-4 py-2", + sm: "h-9 rounded-md px-3 text-xs", + lg: "h-12 rounded-md px-8", + icon: "h-11 w-11", +}, +``` + +Rationale: 44px (h-11) hits the Apple HIG touch-target on default and icon. `sm` stays at 36px for dense desktop contexts (table inline actions); per-page work can opt into the larger size on mobile. + +- [ ] **Step 2: Update Input height + drop md:text-sm** + +In `src/components/ui/input.tsx`, change the className: + +```ts +className={cn( + "flex h-11 w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50", + className +)} +``` + +Removed: `md:text-sm`. Kept: `text-base` everywhere so iOS Safari doesn't zoom on focus (iOS zooms when focused input has a font-size below 16px). + +- [ ] **Step 3: Update Textarea — drop md:text-sm** + +In `src/components/ui/textarea.tsx`: + +```ts +className={cn( + "flex min-h-[80px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50", + className +)} +``` + +Removed: `md:text-sm`. Bumped `min-h-[60px]` to `min-h-[80px]` — single textarea is easier to use larger. + +- [ ] **Step 4: Verify typecheck + visual smoke** + +Run: `pnpm exec tsc --noEmit` +Expected: no errors. + +Open `http://localhost:3000/port-nimara/invoices/new` in the browser. Buttons + inputs should be visibly taller. Existing pages should not look broken — desktop layouts that depended on the 36px button height may need follow-up tweaks tracked in spec §7. + +- [ ] **Step 5: Commit** + +```bash +git add src/components/ui/button.tsx src/components/ui/input.tsx src/components/ui/textarea.tsx +git commit -m "feat(mobile): bump touch-target heights on Button/Input/Textarea, keep 16px to prevent iOS zoom" +``` + +--- + +## Task 8: Make Dialog full-screen on mobile + +**Files:** + +- Modify: `src/components/ui/dialog.tsx` + +- [ ] **Step 1: Update DialogContent positioning** + +In `src/components/ui/dialog.tsx`, change `DialogContent`'s className: + +```ts +className={cn( + "fixed inset-0 z-50 grid w-full gap-4 border-0 bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 sm:left-[50%] sm:top-[50%] sm:inset-auto sm:max-w-lg sm:translate-x-[-50%] sm:translate-y-[-50%] sm:border sm:rounded-lg sm:data-[state=closed]:zoom-out-95 sm:data-[state=open]:zoom-in-95 sm:data-[state=closed]:slide-out-to-left-1/2 sm:data-[state=closed]:slide-out-to-top-[48%] sm:data-[state=open]:slide-in-from-left-1/2 sm:data-[state=open]:slide-in-from-top-[48%]", + className +)} +``` + +Below the `sm` breakpoint (640px) the Dialog renders full-screen (`inset-0 max-w-full`); at and above `sm` it keeps the centered modal behavior. + +- [ ] **Step 2: Verify a Dialog still works at desktop** + +Reload `http://localhost:3000/port-nimara/clients` in a desktop viewport and open any dialog (e.g., create new client). Expected: centered modal, looks unchanged. + +Resize the browser to 400px wide. Same dialog should now fill the viewport. + +- [ ] **Step 3: Commit** + +```bash +git add src/components/ui/dialog.tsx +git commit -m "feat(mobile): render Dialog full-screen below sm, centered modal at sm+" +``` + +--- + +## Task 9: Add placeholder PWA assets + +**Files:** + +- Create: `public/icon-192.png` +- Create: `public/icon-512.png` +- Create: `public/icon-512-maskable.png` +- Create: `public/apple-touch-icon.png` + +- [ ] **Step 1: Generate solid-color placeholder PNGs** + +Use ImageMagick's `convert` (already on most macOS dev machines via Homebrew) to write four solid `#1e2844` (Port Nimara navy) PNGs at the right sizes. The maskable variant has a 20% transparent border on each side per the PWA maskable spec safe zone. + +```bash +convert -size 192x192 xc:'#1e2844' public/icon-192.png +convert -size 512x512 xc:'#1e2844' public/icon-512.png +convert -size 180x180 xc:'#1e2844' public/apple-touch-icon.png + +# Maskable: 410×410 navy centered on a 512×512 navy canvas (no transparent border; +# we want fully-bleeding navy so safe zone is purely a layout convention). +convert -size 512x512 xc:'#1e2844' public/icon-512-maskable.png +``` + +If `convert` is missing, install with `brew install imagemagick` first. + +- [ ] **Step 2: Verify the files exist and have correct dimensions** + +Run: `file public/icon-192.png public/icon-512.png public/apple-touch-icon.png public/icon-512-maskable.png` +Expected: each line reports the correct PNG dimensions. + +- [ ] **Step 3: Verify the PWA manifest reference resolves** + +Open `http://localhost:3000/port-nimara/scan/manifest.webmanifest` (the existing scanner manifest endpoint), confirm it references the icon paths. Open `http://localhost:3000/icon-192.png` in a tab — should show the navy square. + +- [ ] **Step 4: Commit** + +```bash +git add public/icon-192.png public/icon-512.png public/icon-512-maskable.png public/apple-touch-icon.png +git commit -m "chore(pwa): add placeholder icons (icon-192/512/512-maskable, apple-touch-icon)" +``` + +--- + +## Task 10: Create `` (context for topbar slots) + +**Files:** + +- Create: `src/components/layout/mobile/mobile-layout-provider.tsx` + +- [ ] **Step 1: Create the provider + hook** + +```tsx +// src/components/layout/mobile/mobile-layout-provider.tsx +'use client'; + +import { createContext, useContext, useMemo, useState, type ReactNode } from 'react'; + +type MobileChromeState = { + title: string | null; + primaryAction: ReactNode | null; + showBackButton: boolean; +}; + +type MobileChromeApi = MobileChromeState & { + setChrome: (next: Partial) => void; +}; + +const MobileChromeContext = createContext(null); + +export function MobileLayoutProvider({ children }: { children: ReactNode }) { + const [state, setState] = useState({ + title: null, + primaryAction: null, + showBackButton: false, + }); + + const value = useMemo( + () => ({ + ...state, + setChrome: (next) => setState((prev) => ({ ...prev, ...next })), + }), + [state], + ); + + return {children}; +} + +/** + * Page-level hook to push a title / back-button / primary action into the + * mobile topbar. The provider is only mounted by ``, so + * desktop-shell renders never call into this context. + */ +export function useMobileChrome() { + const ctx = useContext(MobileChromeContext); + if (!ctx) { + throw new Error('useMobileChrome must be used inside '); + } + return ctx; +} +``` + +- [ ] **Step 2: Verify typecheck** + +Run: `pnpm exec tsc --noEmit` +Expected: no errors. + +- [ ] **Step 3: Commit** + +```bash +git add src/components/layout/mobile/mobile-layout-provider.tsx +git commit -m "feat(mobile): add MobileLayoutProvider context + useMobileChrome hook" +``` + +--- + +## Task 11: Create `` + +**Files:** + +- Create: `src/components/layout/mobile/mobile-topbar.tsx` + +- [ ] **Step 1: Create the topbar component** + +```tsx +// src/components/layout/mobile/mobile-topbar.tsx +'use client'; + +import { ChevronLeft } from 'lucide-react'; +import { useRouter, usePathname } from 'next/navigation'; + +import { cn } from '@/lib/utils'; +import { useMobileChrome } from './mobile-layout-provider'; + +/** + * Fixed compact topbar (52px + safe-area top inset). Renders the page title + * (auto-truncating), an optional back button, and an optional primary action + * — all driven by `useMobileChrome()` from the active page. + */ +export function MobileTopbar() { + const { title, primaryAction, showBackButton } = useMobileChrome(); + const router = useRouter(); + const pathname = usePathname(); + + // Fall back to the last path segment (Title Case) if no page-supplied title. + const fallbackTitle = + pathname + .split('/') + .filter(Boolean) + .pop() + ?.replace(/-/g, ' ') + .replace(/\b\w/g, (c) => c.toUpperCase()) ?? 'Port Nimara'; + + return ( +
+ {showBackButton ? ( + + ) : ( +
+ )} + +

+ {title ?? fallbackTitle} +

+ +
{primaryAction}
+
+ ); +} +``` + +- [ ] **Step 2: Verify typecheck** + +Run: `pnpm exec tsc --noEmit` +Expected: no errors. + +- [ ] **Step 3: Commit** + +```bash +git add src/components/layout/mobile/mobile-topbar.tsx +git commit -m "feat(mobile): add MobileTopbar with title, back-button, and primary-action slots" +``` + +--- + +## Task 12: Create `` + +**Files:** + +- Create: `src/components/layout/mobile/mobile-bottom-tabs.tsx` + +- [ ] **Step 1: Create the bottom tab bar** + +```tsx +// src/components/layout/mobile/mobile-bottom-tabs.tsx +'use client'; + +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; +import { LayoutDashboard, Users, Ship, Anchor, Menu } from 'lucide-react'; + +import { cn } from '@/lib/utils'; + +type TabSpec = { + label: string; + icon: typeof LayoutDashboard; + segment: string; // route segment after /[portSlug]/ +}; + +const TABS: TabSpec[] = [ + { label: 'Dashboard', icon: LayoutDashboard, segment: 'dashboard' }, + { label: 'Clients', icon: Users, segment: 'clients' }, + { label: 'Yachts', icon: Ship, segment: 'yachts' }, + { label: 'Berths', icon: Anchor, segment: 'berths' }, +]; + +export function MobileBottomTabs({ onMoreClick }: { onMoreClick: () => void }) { + const pathname = usePathname(); + + // Derive the active port slug from the URL so tab links always target the + // current port, even after a port-switch. The dashboard route shape is + // /[portSlug]/, so the slug is the first non-empty path segment. + const portSlug = pathname.split('/').filter(Boolean)[0] ?? 'port-nimara'; + + function isActive(segment: string): boolean { + return pathname.startsWith(`/${portSlug}/${segment}`); + } + + return ( + + ); +} +``` + +- [ ] **Step 2: Verify typecheck** + +Run: `pnpm exec tsc --noEmit` +Expected: no errors. + +- [ ] **Step 3: Commit** + +```bash +git add src/components/layout/mobile/mobile-bottom-tabs.tsx +git commit -m "feat(mobile): add MobileBottomTabs with 5 fixed tabs (Dashboard/Clients/Yachts/Berths/More)" +``` + +--- + +## Task 13: Create `` (vaul wrapper) + +**Files:** + +- Create: `src/components/shared/drawer.tsx` + +- [ ] **Step 1: Create the vaul wrapper** + +```tsx +// src/components/shared/drawer.tsx +'use client'; + +import * as React from 'react'; +import { Drawer as VaulDrawer } from 'vaul'; + +import { cn } from '@/lib/utils'; + +const Drawer = ({ + shouldScaleBackground = true, + ...props +}: React.ComponentProps) => ( + +); +Drawer.displayName = 'Drawer'; + +const DrawerTrigger = VaulDrawer.Trigger; +const DrawerPortal = VaulDrawer.Portal; +const DrawerClose = VaulDrawer.Close; + +const DrawerOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DrawerOverlay.displayName = 'DrawerOverlay'; + +const DrawerContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + +
+ {children} + + +)); +DrawerContent.displayName = 'DrawerContent'; + +const DrawerHeader = ({ className, ...props }: React.HTMLAttributes) => ( +
+); +DrawerHeader.displayName = 'DrawerHeader'; + +const DrawerTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DrawerTitle.displayName = 'DrawerTitle'; + +const DrawerDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DrawerDescription.displayName = 'DrawerDescription'; + +export { + Drawer, + DrawerPortal, + DrawerOverlay, + DrawerTrigger, + DrawerClose, + DrawerContent, + DrawerHeader, + DrawerTitle, + DrawerDescription, +}; +``` + +- [ ] **Step 2: Verify typecheck** + +Run: `pnpm exec tsc --noEmit` +Expected: no errors. + +- [ ] **Step 3: Commit** + +```bash +git add src/components/shared/drawer.tsx +git commit -m "feat(mobile): add Drawer (vaul wrapper) for native-feel bottom sheets" +``` + +--- + +## Task 14: Create `` + +**Files:** + +- Create: `src/components/layout/mobile/more-sheet.tsx` + +- [ ] **Step 1: Create the More bottom sheet** + +```tsx +// src/components/layout/mobile/more-sheet.tsx +'use client'; + +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; +import { + Building2, + Bookmark, + Receipt, + FileText, + FolderOpen, + Mail, + Bell, + ShieldAlert, + BarChart3, + Settings, + Shield, +} from 'lucide-react'; + +import { + Drawer, + DrawerContent, + DrawerHeader, + DrawerTitle, + DrawerClose, +} from '@/components/shared/drawer'; + +type MoreItem = { + label: string; + icon: typeof Building2; + segment: string; +}; + +const MORE_ITEMS: MoreItem[] = [ + { label: 'Companies', icon: Building2, segment: 'companies' }, + { label: 'Interests', icon: Bookmark, segment: 'interests' }, + { label: 'Invoices', icon: FileText, segment: 'invoices' }, + { label: 'Expenses', icon: Receipt, segment: 'expenses' }, + { label: 'Documents', icon: FolderOpen, segment: 'documents' }, + { label: 'Email', icon: Mail, segment: 'email' }, + { label: 'Alerts', icon: ShieldAlert, segment: 'alerts' }, + { label: 'Reports', icon: BarChart3, segment: 'reports' }, + { label: 'Reminders', icon: Bell, segment: 'reminders' }, + { label: 'Settings', icon: Settings, segment: 'settings' }, + { label: 'Admin', icon: Shield, segment: 'admin' }, +]; + +export function MoreSheet({ + open, + onOpenChange, +}: { + open: boolean; + onOpenChange: (next: boolean) => void; +}) { + const pathname = usePathname(); + const portSlug = pathname.split('/').filter(Boolean)[0] ?? 'port-nimara'; + return ( + + + + More + +
    + {MORE_ITEMS.map((item) => { + const Icon = item.icon; + return ( +
  • + + + + {item.label} + + +
  • + ); + })} +
+
+
+ ); +} +``` + +- [ ] **Step 2: Verify typecheck** + +Run: `pnpm exec tsc --noEmit` +Expected: no errors. + +- [ ] **Step 3: Commit** + +```bash +git add src/components/layout/mobile/more-sheet.tsx +git commit -m "feat(mobile): add MoreSheet (3-column grid of long-tail nav items in a bottom drawer)" +``` + +--- + +## Task 15: Create `` + +**Files:** + +- Create: `src/components/layout/mobile/mobile-layout.tsx` + +- [ ] **Step 1: Create the mobile layout shell** + +```tsx +// src/components/layout/mobile/mobile-layout.tsx +'use client'; + +import { useState, type ReactNode } from 'react'; + +import { MobileLayoutProvider } from './mobile-layout-provider'; +import { MobileTopbar } from './mobile-topbar'; +import { MobileBottomTabs } from './mobile-bottom-tabs'; +import { MoreSheet } from './more-sheet'; + +/** + * Mobile shell: fixed compact topbar + scrollable content + fixed bottom tab + * bar. Renders only when CSS reveals it (data-shell="mobile") — both shells + * are in the DOM, see src/app/globals.css. The bottom tabs and More sheet + * derive the active port slug from the URL themselves, so this layout takes + * no portSlug prop. + */ +export function MobileLayout({ children }: { children: ReactNode }) { + const [moreOpen, setMoreOpen] = useState(false); + + return ( +
+ + +
+ {children} +
+ setMoreOpen(true)} /> + +
+
+ ); +} +``` + +- [ ] **Step 2: Verify typecheck** + +Run: `pnpm exec tsc --noEmit` +Expected: no errors. + +- [ ] **Step 3: Commit** + +```bash +git add src/components/layout/mobile/mobile-layout.tsx +git commit -m "feat(mobile): add MobileLayout shell composing topbar + content + bottom tabs + more sheet" +``` + +--- + +## Task 16: Wire `` into the dashboard layout + +**Files:** + +- Modify: `src/app/(dashboard)/layout.tsx` + +- [ ] **Step 1: Wrap the existing shell in a `data-shell="desktop"` div, render `` alongside it** + +Change the return JSX: + +```tsx +import { MobileLayout } from '@/components/layout/mobile/mobile-layout'; + +// ... + +return ( + + + + + {/* Desktop shell — hidden by CSS on mobile */} +
+ +
+ +
{children}
+
+
+ + {/* Mobile shell — hidden by CSS on desktop */} + {children} +
+
+
+
+); +``` + +Note: `children` is rendered TWICE (once in each shell). React handles this fine because only one is visible. `` keeps both shells in sync via context. + +- [ ] **Step 2: Remove the legacy mobile-drawer hamburger from ``** + +The existing `` component renders both the desktop sidebar (`hidden md:flex`) and a mobile drawer with a hamburger button (`md:hidden fixed top-3 left-3`). With the new mobile shell, the mobile drawer is dead weight — there's no `md:hidden` zone visible anymore (we hide the entire desktop shell on mobile via `data-form-factor`). + +Open `src/components/layout/sidebar.tsx`. Find the `` block at the end of the component (the one with `` + `Menu` icon — currently lines ~384-407). Delete that entire block plus the surrounding `<>` fragment — leaving only the `