← back to Grant
initial scaffold (2026-05-06 overnight session)
9561414c0651f754442fed7e3918af29874e9172 · 2026-05-06 10:22:13 -0700 · Steve Abrams
Files touched
A .gitignoreA CLAUDE.mdA README.mdA REVIEW-2026-05-04.mdA app/api/auth/login/route.tsA app/api/auth/logout/route.tsA app/api/auth/session/route.tsA app/api/collaborations/discover/route.tsA app/api/collaborations/route.tsA app/api/dashboard/route.tsA app/api/grants/[id]/proposals/route.tsA app/api/grants/discover-real/route.tsA app/api/grants/discover/route.tsA app/api/grants/route.tsA app/api/news/discover/route.tsA app/api/news/route.tsA app/api/outreach/generate/route.tsA app/api/outreach/route.tsA app/favicon.icoA app/globals.cssA app/icon.svgA app/layout.tsxA app/login/page.tsxA app/page.tsxA app/river/RiverCanvas.tsxA app/river/RiverTable.tsxA app/river/page.tsxA components/AppShell.tsxA components/AuthProvider.tsxA components/ErrorBoundary.tsxA components/Sidebar.tsxA components/Skeleton.tsxA components/ToastProvider.tsxA components/collaborations/CollaborationsTab.tsxA components/dashboard/DashboardTab.tsxA components/grants/AddGrantWizard.tsxA components/grants/GrantsTab.tsxA components/grants/ProposalModal.tsxA components/news/NewsTab.tsxA components/outreach/OutreachTab.tsxA components/settings/SettingsTab.tsxA components/shared/SortDropdown.tsxA db/schema.sqlA ecosystem.config.jsA hooks/useClientSort.tsA hooks/useDebounce.tsA lib/audit.tsA lib/auth.tsA lib/db.tsA lib/gemini.tsA lib/sanitize.tsA lib/sister-auth.tsA middleware.tsA next.config.tsA package-lock.jsonA package.jsonA postcss.config.mjsA public/favicon.svgA public/file.svgA public/globe.svgA public/next.svgA public/vercel.svgA public/window.svgA scripts/rotate-pg-grant.shA tsconfig.json
Diff
commit 9561414c0651f754442fed7e3918af29874e9172
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed May 6 10:22:13 2026 -0700
initial scaffold (2026-05-06 overnight session)
---
.gitignore | 50 +
CLAUDE.md | 35 +
README.md | 36 +
REVIEW-2026-05-04.md | 159 ++
app/api/auth/login/route.ts | 78 +
app/api/auth/logout/route.ts | 8 +
app/api/auth/session/route.ts | 12 +
app/api/collaborations/discover/route.ts | 120 ++
app/api/collaborations/route.ts | 163 ++
app/api/dashboard/route.ts | 117 ++
app/api/grants/[id]/proposals/route.ts | 193 +++
app/api/grants/discover-real/route.ts | 146 ++
app/api/grants/discover/route.ts | 124 ++
app/api/grants/route.ts | 192 +++
app/api/news/discover/route.ts | 129 ++
app/api/news/route.ts | 77 +
app/api/outreach/generate/route.ts | 121 ++
app/api/outreach/route.ts | 75 +
app/favicon.ico | Bin 0 -> 25931 bytes
app/globals.css | 343 ++++
app/icon.svg | 4 +
app/layout.tsx | 21 +
app/login/page.tsx | 161 ++
app/page.tsx | 5 +
app/river/RiverCanvas.tsx | 261 +++
app/river/RiverTable.tsx | 141 ++
app/river/page.tsx | 109 ++
components/AppShell.tsx | 223 +++
components/AuthProvider.tsx | 132 ++
components/ErrorBoundary.tsx | 126 ++
components/Sidebar.tsx | 322 ++++
components/Skeleton.tsx | 49 +
components/ToastProvider.tsx | 94 ++
components/collaborations/CollaborationsTab.tsx | 720 ++++++++
components/dashboard/DashboardTab.tsx | 447 +++++
components/grants/AddGrantWizard.tsx | 550 +++++++
components/grants/GrantsTab.tsx | 658 ++++++++
components/grants/ProposalModal.tsx | 621 +++++++
components/news/NewsTab.tsx | 368 +++++
components/outreach/OutreachTab.tsx | 625 +++++++
components/settings/SettingsTab.tsx | 203 +++
components/shared/SortDropdown.tsx | 42 +
db/schema.sql | 195 +++
ecosystem.config.js | 15 +
hooks/useClientSort.ts | 34 +
hooks/useDebounce.ts | 10 +
lib/audit.ts | 35 +
lib/auth.ts | 31 +
lib/db.ts | 45 +
lib/gemini.ts | 111 ++
lib/sanitize.ts | 44 +
lib/sister-auth.ts | 49 +
middleware.ts | 90 +
next.config.ts | 7 +
package-lock.json | 2013 +++++++++++++++++++++++
package.json | 31 +
postcss.config.mjs | 7 +
public/favicon.svg | 4 +
public/file.svg | 1 +
public/globe.svg | 1 +
public/next.svg | 1 +
public/vercel.svg | 1 +
public/window.svg | 1 +
scripts/rotate-pg-grant.sh | 153 ++
tsconfig.json | 34 +
65 files changed, 10973 insertions(+)
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..f522159
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,50 @@
+# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
+
+# dependencies
+/node_modules
+/.pnp
+.pnp.*
+.yarn/*
+!.yarn/patches
+!.yarn/plugins
+!.yarn/releases
+!.yarn/versions
+
+# testing
+/coverage
+
+# next.js
+/.next/
+/out/
+
+# production
+/build
+
+# misc
+.DS_Store
+*.pem
+
+# debug
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+.pnpm-debug.log*
+
+# env files (can opt-in for committing if needed)
+.env*
+
+# vercel
+.vercel
+
+# typescript
+*.tsbuildinfo
+next-env.d.ts
+node_modules/
+.env.local
+.env.*.local
+.env.*
+tmp/
+*.log
+dist/
+build/
+*.bak
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..a62763a
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,35 @@
+# Grant - Non-Profit Fundraiser
+
+## Project Info
+- **Directory**: /root/Projects/Grant/
+- **Port**: 7450 | **PM2**: grant-app
+- **Database**: connection string lives in `.env.local` (DATABASE_URL); use `dw_admin@127.0.0.1:5432/grant_app`. NEVER commit credentials to docs.
+- **Auth**: admin / (AUTH_PASSWORD env — see .env.local; no longer hardcoded) + Google Sign-In (user login, future)
+- **Stack**: Next.js 16 (App Router) + Tailwind 4 + PostgreSQL
+
+## Purpose
+AI-powered fundraising platform for non-profit organizations.
+Grant discovery, proposal generation, news monitoring, collaboration tracking, and outreach tools.
+
+## Key Files
+- lib/db.ts -- Postgres pool
+- lib/auth.ts -- Cookie auth (admin + future Google OAuth)
+- lib/audit.ts -- Audit logger with org_id scoping
+- app/globals.css -- Emerald/green dark theme design tokens
+- components/AppShell.tsx -- Main layout with sidebar
+- components/AuthProvider.tsx -- Auth context provider
+
+## Database Tables
+- users -- User accounts (Google OAuth + admin)
+- organizations -- Non-profit org profiles with onboarding
+- grants -- Grant opportunities with AI fit scoring
+- grant_proposals -- Proposal drafts (LOI, full, one-pager)
+- news_items -- Monitored news for the org
+- collaborations -- Partners (nonprofits, politicians, corporations, municipalities)
+- outreach_templates -- AI-generated email templates
+- audit_events -- Full audit trail
+
+## Design Theme
+- Primary: #059669 (emerald-600)
+- Accent: #10b981 (emerald-500)
+- Dark background with emerald accents
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..e215bc4
--- /dev/null
+++ b/README.md
@@ -0,0 +1,36 @@
+This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
+
+## Getting Started
+
+First, run the development server:
+
+```bash
+npm run dev
+# or
+yarn dev
+# or
+pnpm dev
+# or
+bun dev
+```
+
+Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
+
+You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
+
+This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
+
+## Learn More
+
+To learn more about Next.js, take a look at the following resources:
+
+- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
+- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
+
+You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
+
+## Deploy on Vercel
+
+The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
+
+Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
diff --git a/REVIEW-2026-05-04.md b/REVIEW-2026-05-04.md
new file mode 100644
index 0000000..7230b69
--- /dev/null
+++ b/REVIEW-2026-05-04.md
@@ -0,0 +1,159 @@
+# Grant — Tick 14 Review (extension to overnight rotation)
+
+**Date:** 2026-05-04
+**Reviewers:** code-reviewer (completed) + architect-reviewer (rate-limited by Anthropic mid-flight)
+**Constraints:** local-only safe patches; NO pm2 restart, NO deploy, NO new deps, NO schema changes.
+
+## Headline
+
+Grant is the **fundraiser-side companion to Freddy** — both Next.js 16 + TS + PG, both wave-3 `@dw/nextjs-admin-login` consumers. Code-reviewer surfaced 4 P0s (timing-attack login, hardcoded creds in CLAUDE.md, two cross-tenant IDORs on PATCH/proposals), 5 P1s (rate-limit absent on AI routes, prompt-injection surface, stored-XSS surface, Gemini fetch timeouts, Secure cookie flag), and 5 P2s. **9 patches landed local; 4 P1/P2 deferred (need new deps or schema migration).**
+
+## Findings + dispositions
+
+| # | Severity | Finding | Status |
+|---|---|---|---|
+| P0-1 | Critical | `app/api/auth/login/route.ts:23` uses `!==` for both username and password — timing-attack enumerable | **Patched** — `timingSafeEqual` with equal-length-pad fallback |
+| P0-2 | Critical | `CLAUDE.md:6` ships hardcoded `postgresql://dw_admin:DW2024SecurePass@...` cleartext | **Patched** — replaced with `.env.local` placeholder. **Steve must rotate `DW2024SecurePass` if this repo was ever pushed.** |
+| P0-3 | Critical | PATCH `/api/grants` and PATCH `/api/collaborations` UPDATE without `AND org_id = $N` — cross-tenant write IDOR | **Patched** — both UPDATEs scoped via `LIMIT 1` org lookup + scoped WHERE |
+| P0-4 | Critical | GET `/api/grants/[id]/proposals` reads any org's proposals — cross-tenant read IDOR (subject, body, recipient_email) | **Patched** — JOIN through grants on `g.org_id = $2` |
+| P1-1 | High | No rate-limit on 5 AI routes; single session can burn unlimited Gemini credits | Deferred — needs design decision (in-memory Map vs PG `ai_call_log` table) |
+| P1-2 | High | `context`, `target_name`, `target_org` interpolated into prompts unbounded — prompt-injection + multi-MB DoS | **Patched** — capped at 2000 chars (context) / 500 chars (names) in proposals + outreach |
+| P1-3 | High | `body_html` accepted from client and stored verbatim — stored-XSS time-bomb | **Partial** — 200KB size cap + type guard. Sanitizer (sanitize-html) deferred (new dep). |
+| P1-4 | High | Five Gemini `fetch()` calls with no timeout — hung upstream exhausts pg pool max=10 | **Patched (5/5)** — `AbortSignal.timeout(25_000)` on all five, return 504 on abort |
+| P1-5 | High | `Secure` cookie flag only when NODE_ENV=production — Kamatera dev-mode leaks cookie over plaintext | Deferred — fix lives in `@dw/nextjs-admin-login` shim, not in Grant |
+| P2-1 | Med | `SELECT id FROM organizations LIMIT 1` is a multi-tenancy time-bomb (~10 sites) | Out of scope — single-org for now; flagged in summary |
+| P2-2 | Med | News-discover prompt fabricates "plausible URL at that outlet" — credibility/liability risk | Deferred — needs schema column + UI label |
+| P2-3 | Med | AI-call routes (4) not audited; no IP/correlation trail if Gemini key abused | Deferred — touches `lib/audit.ts` + every AI route |
+| P2-4 | Med | `grants POST` accepts undefined `title` → opaque 500 | **Patched** — explicit type + trim guard, returns 400 |
+| P2-5 | Low | Logout cookie missing `Secure` flag | Deferred — same `@dw/nextjs-admin-login` shim location as P1-5 |
+| Refactor | — | 5× duplication of `callGemini` pattern (key + URL + fetch + parse + strip-fence) — extract to `lib/gemini.ts` | Deferred — natural home for P1-1 rate-limit + P1-4 timeout consolidation |
+
+## Patches landed (9 edits across 6 files)
+
+1. `app/api/auth/login/route.ts` — `timingSafeEqual`-based `safeCompare()` + use for both username and password (P0-1)
+2. `app/api/grants/route.ts` POST — explicit `title` guard returns 400 (P2-4)
+3. `app/api/grants/route.ts` PATCH — scoped UPDATE with `AND org_id = $N` (P0-3)
+4. `app/api/collaborations/route.ts` PATCH — scoped UPDATE with `AND org_id = $N` (P0-3)
+5. `app/api/grants/[id]/proposals/route.ts` GET — JOIN through grants for tenant isolation (P0-4)
+6. `app/api/grants/[id]/proposals/route.ts` POST — context length cap (P1-2), body_html size cap + type guard (P1-3), Gemini AbortSignal 25s (P1-4)
+7. `app/api/grants/discover/route.ts` POST — Gemini AbortSignal 25s (P1-4)
+8. `app/api/collaborations/discover/route.ts` POST — Gemini AbortSignal 25s (P1-4)
+9. `app/api/news/discover/route.ts` POST — Gemini AbortSignal 25s (P1-4)
+10. `app/api/outreach/generate/route.ts` POST — input field caps (P1-2), Gemini AbortSignal 25s (P1-4)
+11. `CLAUDE.md` — DB cleartext credential stripped (P0-2)
+
+`./node_modules/.bin/tsc --noEmit` clean across all 6 patched .ts files.
+
+## Production-touching items pending Steve
+
+- **Rotate `DW2024SecurePass`** — was committed in `CLAUDE.md`. If this repo ever lived on a remote (GitHub/GitLab/Kamatera bare), the password is compromised. Mint a new value via `/secrets`, route to Grant + every other service that uses `dw_admin@127.0.0.1`.
+- **Deploy patches** — 9 patches sit local; no Mac2 pm2 process is running grant-app right now (`pm2 jlist | grep grant` returned nothing), so this is "next time you start grant-app, the patched code goes live."
+- **Address P1-1** — pick rate-limit approach (in-memory Map for v0, PG `ai_call_log` table for production).
+- **Add `sanitize-html` dep** — close P1-3 properly, not just the size cap.
+- **Patch the shim** — P1-5 + P2-5 fix lives in `@dw/nextjs-admin-login` source, not in Grant. The shim is loaded from a tarball at `/tmp/dw-nextjs-admin-login-0.1.0.tgz`; its actual source is at `~/Projects/dw-nextjs-admin-login/` (or wherever Steve maintains it).
+
+## Architect-reviewer note
+
+The architect agent hit Anthropic's "Server is temporarily limiting requests (not your usage limit)" rate-limit and bailed at 672ms with no findings. The launchd watcher (`~/Library/LaunchAgents/com.steve.yolo-rate-limit-watcher.plist`) caught it. Architect re-run can be queued in a follow-up tick if Steve wants the place-graph-style cross-project verdict against Freddy/Patty/PoppyPetitions.
+
+## Tick summary
+
+Tick 14 (extension to the FINAL 13-tick rotation). Code-reviewer landed clean, architect-reviewer rate-limited mid-flight. Grant — Steve's AI-powered fundraising platform for non-profits, sibling to Freddy — got 9 local-only safe patches across 6 files closing 4 P0 (timing-attack login, cleartext PG password in docs, two cross-tenant IDORs) + 4 P1 (input length caps, body_html size guard, 5 Gemini upstream timeouts) + 1 P2 (title null guard). All TypeScript clean. Patches NOT deployed; no pm2 process running grant-app locally. **The big DW2024SecurePass rotation is the most urgent Steve-side item** — that password was visible cleartext in `CLAUDE.md` until this tick.
+
+---
+
+# Tick 15 — architect retry + sibling P0 leak + lib/gemini.ts extraction
+
+**Reviewers:** architect-reviewer (retry succeeded after Anthropic rate-limit)
+**Constraints:** local-only safe patches; NO pm2 restart, NO deploy.
+
+## Architect verdict (one-liner)
+
+Architecture is sound (clean App Router + thin lib/) but ships with **5x duplication of the Gemini call pattern** and a **placeholder org-resolution baked into every handler**. Biggest issue: the `SELECT id FROM organizations LIMIT 1` placeholder leaks into every route. Biggest opportunity: extract `lib/gemini.ts` (PoppyPetitions already has a working reference impl).
+
+## Cross-project P0 (NEW, found by architect)
+
+**P0-CROSS: Hardcoded Gemini API key in `~/Projects/PoppyPetitions/lib/gemini.ts:1`** — cleartext value `AIzaSy...` (47-char Google API key prefix) committed in plaintext.
+- **Patched** — replaced with `process.env.GEMINI_API_KEY`, fail-fast module-load check, AbortSignal 25s timeout added to match Grant pattern.
+- **Steve-side rotation REQUIRED** — the literal value is compromised; do not assume git history is clean. Mint new key at https://aistudio.google.com/apikey, route via `/secrets` to all consumers (PoppyPetitions, Grant, future siblings).
+
+## Tick-15 patches landed (3 edits)
+
+1. `~/Projects/PoppyPetitions/lib/gemini.ts` — scrubbed hardcoded key, added env-var fail-fast + AbortSignal 25s
+2. `~/Projects/Grant/lib/gemini.ts` — NEW. `callGemini<T>()` helper per architect spec: Result-shaped (`{ok, data, raw, inputTokens, outputTokens}` | `{ok:false, reason, status, detail}`). Centralizes key/URL/timeout/fence-strip/parse. Reasons: `no_key`/`timeout`/`upstream`/`parse`. No Zod (architect: "schema validation belongs at the call site").
+3. `~/Projects/Grant/app/api/news/discover/route.ts` — migrated to `callGemini<NewsItem[]>({prompt, maxTokens: 8192})`. Removed ~30 LOC (boilerplate fetch + try/catch + parse + fence-strip). 1/5 callsites done.
+
+`tsc --noEmit` clean across all 3.
+
+## Pending Gemini-callsite migrations (4/5 remaining)
+
+Recipe — migrate each in turn:
+```ts
+// BEFORE (per-route boilerplate, ~30 LOC):
+// const GEMINI_KEY = process.env.GEMINI_API_KEY; ...
+// try { fetch(GEMINI_URL, { ...signal: AbortSignal.timeout(25_000) }) } catch { 504 }
+// if (!geminiRes.ok) { 502 }
+// text = data.candidates?.[0]... ; text.replace(/```json\s*/, '')...
+// try { JSON.parse } catch { 500 }
+
+// AFTER (~10 LOC):
+import { callGemini } from '@/lib/gemini';
+const result = await callGemini<ParsedShape>({ prompt, maxTokens: 8192 });
+if (!result.ok) {
+ return NextResponse.json(
+ { error: result.reason === 'no_key' ? 'AI service not configured' : 'AI service error' },
+ { status: result.status },
+ );
+}
+const items = result.data;
+```
+
+Files to migrate:
+- `app/api/grants/discover/route.ts` (maxTokens=8192)
+- `app/api/collaborations/discover/route.ts` (maxTokens=8192)
+- `app/api/outreach/generate/route.ts` (maxTokens=4096; parsed shape: `{subject, body_html, body_text}`)
+- `app/api/grants/[id]/proposals/route.ts` (maxTokens=4096; parsed shape: `{subject, recipient_name, body_html, body_text}`)
+
+## Architect's other actionable findings (deferred — Steve-level)
+
+1. **Multi-tenancy**: extend `@dw/nextjs-admin-login` cookie payload with `org_id`, change `verifyAuth` return shape from `User | null` to `{user, orgId} | null`. Then a single search-and-replace eliminates the 7+ `LIMIT 1` placeholder lookups. Patty already has `lib/sister-auth.ts` as precedent for shim extension.
+2. **Middleware.ts**: Grant has none. Recommended scope (priority order): auth gate for `/api/**` except `/api/auth/*` (collapses 14 copies of `verifyAuth(request); if (!user) return 401`), `x-request-id` injection, sliding-window rate-limit on the 5 Gemini routes.
+3. **Don't share `lib/db.ts`** (project-config, not framework code). **Don't share `lib/audit.ts`** (35 lines, no shared migration story yet — Grant's evolved version adds `org_id`+`user_id` columns vs Freddy's 5-col schema). **Do share `lib/gemini.ts`** as `@dw/gemini-flash` once it's been battle-tested in Grant.
+
+## Tick-15 cumulative (ticks 14+15 against Grant + 1 sibling P0)
+
+- 12 patches across 7 files (Grant: 6 files, 11 patches; PoppyPetitions: 1 file, 1 patch)
+- New file: `Grant/lib/gemini.ts` (~85 LOC) — single source of truth for Gemini calls
+- Cross-project P0 closed: PoppyPetitions hardcoded API key scrubbed
+- All TypeScript clean
+- **Two Steve-side rotations now required**: `DW2024SecurePass` (PG, leaked in CLAUDE.md), `AIzaSyB...` Gemini key (leaked in PoppyPetitions/lib/gemini.ts)
+- 4 of 5 Gemini callsites still on the inline pattern; migration recipe documented above
+
+**Tick 15 summary paragraph:** Architect-reviewer's retry landed cleanly and surfaced a real P0 across a sibling project (PoppyPetitions had a hardcoded Google API key). Scrubbed the key, made env-var-only, added the 25s timeout pattern. Then built the architect-spec'd `lib/gemini.ts` wrapper for Grant — Result-shaped, generic-typed, no Zod. Migrated one callsite (news/discover) end-to-end as proof; 4 remain on the inline pattern with a documented mechanical recipe. **Two Steve rotations still required**: PG password + leaked Gemini key. Both are pre-existing leaks discovered tonight, not regressions introduced by the loop.
+
+---
+
+# DECISION TO FINALIZE — what's the right next move on Grant in a single ~30-60 min focused session?
+
+**Steve's framing:** "use dream team to finalize" — debate the four options below, rank, pick one, defend it.
+
+## Options on the table
+
+**A) Mechanical sweep** — write `middleware.ts` (auth gate for `/api/**` except `/api/auth/*`, `x-request-id` injection, sliding-window rate-limit on the 5 Gemini routes) + migrate the 4 remaining Gemini callsites to `callGemini<T>()`. Low-risk, ~5 files, completes the architect's recommendation. Cost: doesn't unlock new behavior, just closes hygiene debt and exercises the new wrapper.
+
+**B) Multi-tenancy fix** — extend `@dw/nextjs-admin-login` cookie payload with `org_id`, change `verifyAuth` return shape from `User|null` to `{user, orgId}|null`. Then a single search-and-replace eliminates the 7+ `SELECT id FROM organizations LIMIT 1` placeholders. Patty already has `lib/sister-auth.ts` as precedent for shim extension. Cross-project (touches Grant + Freddy + Patty + PoppyPetitions). Higher leverage, higher blast radius.
+
+**C) Real product feature** — replace the AI-fabricated `*/discover` routes with actual integrations. Grants.gov public API (free, no auth) + Federal Register API + real news via NewsAPI. Current routes ask Gemini to invent "plausible URL at that outlet" which ships fake data to users (P2-2 from code review). Visible product progress, but biggest scope.
+
+**D) Something else** — consolidate the 5 `lib/audit.ts` variants across the 4 wave-3 projects; or build the `ai_call_log` rate-limit table now since P1-1 is the highest-cost open issue; or address the two Steve-side key rotations as actual code changes (move key validation into `lib/secrets.ts` that fails fast on missing env, etc.).
+
+## Constraints
+
+- Single-tenant for now (one org row in PG); Steve hasn't said multi-tenant is imminent.
+- pm2 grant-app is NOT running locally → patches can land any time without restart drama.
+- No Kamatera deploys, no DNS, no Shopify writes, no domain registration.
+- Wave-3 shim source is at `~/Projects/dw-nextjs-admin-login/`; tarball at `/tmp/dw-nextjs-admin-login-0.1.0.tgz` is what gets installed into projects.
+
+## Question for the dream team
+
+Pick ONE option. Defend it. Steve wants finalization, not "well, it depends." The reviewer should also flag any option I haven't listed.
diff --git a/app/api/auth/login/route.ts b/app/api/auth/login/route.ts
new file mode 100644
index 0000000..cc56cda
--- /dev/null
+++ b/app/api/auth/login/route.ts
@@ -0,0 +1,78 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { timingSafeEqual } from 'crypto';
+import { createSessionWithOrg, buildAuthCookie } from '@/lib/auth';
+import { query } from '@/lib/db';
+
+// 2026-05-04 (code-reviewer P0-1): constant-time credential comparison.
+// Plain `!==` on user-controlled strings leaks the username/password length
+// AND the prefix-match length via timing — repeated probes can enumerate
+// valid credentials. timingSafeEqual requires equal-length buffers, so we
+// pad to a fixed length first and treat any short-circuit as a mismatch.
+function safeCompare(a: string, b: string): boolean {
+ const aBuf = Buffer.from(a, 'utf8');
+ const bBuf = Buffer.from(b, 'utf8');
+ if (aBuf.length !== bBuf.length) {
+ // Still call timingSafeEqual against equal-length zero buffer to keep
+ // the wall-clock cost identical to the success path.
+ timingSafeEqual(aBuf, Buffer.alloc(aBuf.length));
+ return false;
+ }
+ return timingSafeEqual(aBuf, bBuf);
+}
+
+export async function POST(request: NextRequest) {
+ try {
+ const body = await request.json();
+ const { username, password } = body as { username?: string; password?: string };
+
+ if (!username || !password) {
+ return NextResponse.json(
+ { error: 'Username and password are required' },
+ { status: 400 },
+ );
+ }
+
+ const expectedUsername = process.env.AUTH_USERNAME ?? 'admin';
+ const expectedPassword = process.env.AUTH_PASSWORD;
+ if (!expectedPassword) {
+ console.error('[auth/login] AUTH_PASSWORD env unset — refusing all logins (fail-closed)');
+ return NextResponse.json({ error: 'Server misconfigured' }, { status: 500 });
+ }
+
+ // Both checks via constant-time compare; AND-ing the two booleans does
+ // not introduce a short-circuit because both calls always run.
+ const userOk = safeCompare(username, expectedUsername);
+ const passOk = safeCompare(password, expectedPassword);
+ if (!(userOk && passOk)) {
+ return NextResponse.json(
+ { error: 'Invalid credentials' },
+ { status: 401 },
+ );
+ }
+
+ // 2026-05-05 (tick 19): embed org_id in session token so /api/* routes
+ // can read it without a `SELECT id FROM organizations LIMIT 1` placeholder.
+ // If no org row exists yet, mint a v1 (3-part) token — verifyAuthWithOrg
+ // will return orgId:null and routes can fall back to the placeholder
+ // until onboarding creates the first org row.
+ let orgId: string | null = null;
+ try {
+ const orgRes = await query('SELECT id FROM organizations LIMIT 1');
+ if (orgRes.rows.length > 0) orgId = orgRes.rows[0].id;
+ } catch (e) {
+ console.error('[auth/login] org lookup failed (proceeding with null orgId):', (e as Error).message);
+ }
+ const token = createSessionWithOrg(username, orgId);
+ const cookieValue = buildAuthCookie(token);
+
+ const response = NextResponse.json({ success: true });
+ response.headers.set('Set-Cookie', cookieValue);
+ return response;
+ } catch (err) {
+ console.error('[auth/login] Error:', (err as Error).message);
+ return NextResponse.json(
+ { error: 'Internal server error' },
+ { status: 500 },
+ );
+ }
+}
diff --git a/app/api/auth/logout/route.ts b/app/api/auth/logout/route.ts
new file mode 100644
index 0000000..f304238
--- /dev/null
+++ b/app/api/auth/logout/route.ts
@@ -0,0 +1,8 @@
+import { NextResponse } from 'next/server';
+import { buildLogoutCookie } from '@/lib/auth';
+
+export async function POST() {
+ const response = NextResponse.json({ success: true });
+ response.headers.set('Set-Cookie', buildLogoutCookie());
+ return response;
+}
diff --git a/app/api/auth/session/route.ts b/app/api/auth/session/route.ts
new file mode 100644
index 0000000..298d70c
--- /dev/null
+++ b/app/api/auth/session/route.ts
@@ -0,0 +1,12 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { verifyAuth } from '@/lib/auth';
+
+export async function GET(request: NextRequest) {
+ const username = verifyAuth(request);
+
+ if (username) {
+ return NextResponse.json({ authenticated: true, user: username });
+ }
+
+ return NextResponse.json({ authenticated: false }, { status: 401 });
+}
diff --git a/app/api/collaborations/discover/route.ts b/app/api/collaborations/discover/route.ts
new file mode 100644
index 0000000..cac798c
--- /dev/null
+++ b/app/api/collaborations/discover/route.ts
@@ -0,0 +1,120 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { query } from '@/lib/db';
+import { verifyAuthWithOrg, resolveOrgId } from '@/lib/auth';
+import { auditLog } from '@/lib/audit';
+import { callGemini } from '@/lib/gemini';
+
+export async function POST(request: NextRequest) {
+ const session = verifyAuthWithOrg(request);
+ if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ const user = session.username;
+
+ try {
+ const orgId = await resolveOrgId(session);
+ if (!orgId) return NextResponse.json({ error: 'No organization found' }, { status: 400 });
+ const orgRes = await query(
+ 'SELECT id, name, ai_mission, ai_focus_areas, ai_keywords, ai_org_summary, city, state FROM organizations WHERE id = $1',
+ [orgId],
+ );
+ if (orgRes.rows.length === 0) {
+ return NextResponse.json({ error: 'No organization found' }, { status: 400 });
+ }
+ const org = orgRes.rows[0];
+
+ // Get existing collaborators to avoid duplicates
+ const existingRes = await query(
+ 'SELECT name, organization FROM collaborations WHERE org_id = $1',
+ [org.id],
+ );
+ const existingNames = existingRes.rows.map(
+ (r) => `${r.name} (${r.organization || 'N/A'})`,
+ );
+
+ const prompt = `You are a nonprofit partnerships and advocacy strategy consultant. Suggest 8-10 potential collaborators for this organization:
+
+Organization: ${org.name}
+Location: ${org.city}, ${org.state}
+Mission: ${org.ai_mission}
+Focus Areas: ${(org.ai_focus_areas || []).join(', ')}
+Keywords: ${(org.ai_keywords || []).join(', ')}
+Summary: ${org.ai_org_summary}
+
+${existingNames.length > 0 ? `Already connected (DO NOT repeat): ${existingNames.join('; ')}` : ''}
+
+Suggest a mix of:
+- 2-3 Nonprofits (real organizations working on student debt, education access, consumer protection)
+- 2-3 Politicians (real US senators, representatives, or state officials who champion education/debt issues)
+- 2 Corporations (companies with relevant CSR programs or education benefits)
+- 1-2 Municipalities (city or county governments with student debt programs)
+
+Return ONLY a JSON array (no markdown, no code fences) of objects with:
+- collab_type: string (one of: "nonprofit", "politician", "corporation", "municipality")
+- name: string (real person or organization contact name)
+- title: string (their title/role)
+- organization: string (their organization name)
+- website_url: string (real website URL)
+- email: string (plausible contact email, use info@ or press@ formats)
+- district: string (for politicians, their district; for others, null)
+- state: string (two-letter state code)
+- ai_reason: string (2-3 sentences explaining why this is a valuable collaboration)
+- ai_relevance: number (0-1 relevance score)
+- ai_talking_points: string[] (3-4 specific talking points for initial outreach)`;
+
+ // 2026-05-05 (tick 16): migrated to lib/gemini.ts wrapper.
+ const result = await callGemini<Record<string, unknown>[]>({ prompt, maxTokens: 8192 });
+ if (!result.ok) {
+ console.error('[collabs/discover] gemini failed:', result.reason, result.detail);
+ return NextResponse.json(
+ { error: result.reason === 'no_key' ? 'AI service not configured' : 'AI service error' },
+ { status: result.status },
+ );
+ }
+ const collabs = result.data;
+ if (!Array.isArray(collabs)) {
+ return NextResponse.json({ error: 'AI returned invalid format' }, { status: 500 });
+ }
+
+ const inserted = [];
+ for (const c of collabs) {
+ try {
+ const result = await query(
+ `INSERT INTO collaborations (org_id, collab_type, name, title, organization, website_url, email, district, state, ai_reason, ai_relevance, ai_talking_points, status)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, 'suggested')
+ RETURNING id, org_id, collab_type, name, title, organization, website_url, email, phone, district, state, ai_reason, ai_relevance, ai_talking_points, status, notes, last_contacted, created_at, updated_at`,
+ [
+ org.id,
+ c.collab_type || 'nonprofit',
+ c.name || 'Unknown',
+ c.title || null,
+ c.organization || null,
+ c.website_url || null,
+ c.email || null,
+ c.district || null,
+ c.state || null,
+ c.ai_reason || null,
+ c.ai_relevance != null ? c.ai_relevance : 0.5,
+ c.ai_talking_points || [],
+ ],
+ );
+ inserted.push(result.rows[0]);
+ } catch (insertErr) {
+ console.error('[collabs/discover] Insert error:', c.name, insertErr);
+ }
+ }
+
+ // 2026-05-05 (architect P2-3): audit AI call.
+ auditLog('collaboration.ai_discovered', 'collaboration', null, org.id, user, {
+ discovered_count: inserted.length,
+ input_tokens: result.inputTokens,
+ output_tokens: result.outputTokens,
+ }, request.headers.get('x-forwarded-for') || undefined);
+
+ return NextResponse.json({
+ discovered: inserted.length,
+ collaborations: inserted,
+ });
+ } catch (err) {
+ console.error('[collabs/discover]', err);
+ return NextResponse.json({ error: 'Collaboration discovery failed' }, { status: 500 });
+ }
+}
diff --git a/app/api/collaborations/route.ts b/app/api/collaborations/route.ts
new file mode 100644
index 0000000..7365461
--- /dev/null
+++ b/app/api/collaborations/route.ts
@@ -0,0 +1,163 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { query } from '@/lib/db';
+import { verifyAuthWithOrg, resolveOrgId } from '@/lib/auth';
+
+/* ─── GET /api/collaborations ─────────────────────────────────────────────── */
+export async function GET(request: NextRequest) {
+ const session = verifyAuthWithOrg(request);
+ if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+ try {
+ const url = new URL(request.url);
+ const collabType = url.searchParams.get('collab_type');
+ const status = url.searchParams.get('status');
+ const search = url.searchParams.get('search');
+
+ const orgId = await resolveOrgId(session);
+ if (!orgId) return NextResponse.json({ rows: [], typeCounts: {} });
+
+ let sql = 'SELECT id, org_id, collab_type, name, title, organization, website_url, email, phone, district, state, ai_reason, ai_relevance, ai_talking_points, status, notes, last_contacted, created_at, updated_at FROM collaborations WHERE org_id = $1';
+ const params: unknown[] = [orgId];
+ let paramIdx = 2;
+
+ if (collabType && collabType !== 'all') {
+ sql += ` AND collab_type = $${paramIdx}`;
+ params.push(collabType);
+ paramIdx++;
+ }
+
+ if (status && status !== 'all') {
+ sql += ` AND status = $${paramIdx}`;
+ params.push(status);
+ paramIdx++;
+ }
+
+ if (search) {
+ sql += ` AND (name ILIKE $${paramIdx} OR organization ILIKE $${paramIdx} OR ai_reason ILIKE $${paramIdx})`;
+ params.push(`%${search}%`);
+ paramIdx++;
+ }
+
+ sql += ' ORDER BY ai_relevance DESC NULLS LAST, created_at DESC';
+
+ const result = await query(sql, params);
+
+ // Type counts (always unfiltered)
+ const countsRes = await query(
+ `SELECT collab_type, COUNT(*)::int as count FROM collaborations WHERE org_id = $1 GROUP BY collab_type`,
+ [orgId],
+ );
+ const typeCounts: Record<string, number> = {};
+ let total = 0;
+ for (const row of countsRes.rows) {
+ typeCounts[row.collab_type] = row.count;
+ total += row.count;
+ }
+ typeCounts['all'] = total;
+
+ return NextResponse.json({ rows: result.rows, typeCounts });
+ } catch (err) {
+ console.error('[collaborations GET]', err);
+ return NextResponse.json({ error: 'Failed to fetch collaborations' }, { status: 500 });
+ }
+}
+
+/* ─── POST /api/collaborations ────────────────────────────────────────────── */
+export async function POST(request: NextRequest) {
+ const session = verifyAuthWithOrg(request);
+ if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+ try {
+ const body = await request.json();
+ const orgId = await resolveOrgId(session);
+ if (!orgId) return NextResponse.json({ error: 'No organization found' }, { status: 400 });
+
+ const result = await query(
+ `INSERT INTO collaborations (org_id, collab_type, name, title, organization, website_url, email, phone, district, state, ai_reason, ai_relevance, ai_talking_points, status, notes)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
+ RETURNING id, org_id, collab_type, name, title, organization, website_url, email, phone, district, state, ai_reason, ai_relevance, ai_talking_points, status, notes, last_contacted, created_at, updated_at`,
+ [
+ orgId,
+ body.collab_type || 'nonprofit',
+ body.name,
+ body.title || null,
+ body.organization || null,
+ body.website_url || null,
+ body.email || null,
+ body.phone || null,
+ body.district || null,
+ body.state || null,
+ body.ai_reason || null,
+ body.ai_relevance || null,
+ body.ai_talking_points || [],
+ body.status || 'suggested',
+ body.notes || null,
+ ],
+ );
+
+ return NextResponse.json(result.rows[0], { status: 201 });
+ } catch (err) {
+ console.error('[collaborations POST]', err);
+ return NextResponse.json({ error: 'Failed to create collaboration' }, { status: 500 });
+ }
+}
+
+/* ─── PATCH /api/collaborations ───────────────────────────────────────────── */
+export async function PATCH(request: NextRequest) {
+ const session = verifyAuthWithOrg(request);
+ if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+ try {
+ const body = await request.json();
+ const { id, ...fields } = body;
+
+ if (!id) {
+ return NextResponse.json({ error: 'id is required' }, { status: 400 });
+ }
+
+ const allowedFields = [
+ 'status', 'notes', 'last_contacted', 'email', 'phone',
+ 'website_url', 'title', 'organization', 'district', 'state',
+ ];
+
+ const setClauses: string[] = [];
+ const params: unknown[] = [];
+ let paramIdx = 1;
+
+ for (const key of Object.keys(fields)) {
+ if (allowedFields.includes(key)) {
+ setClauses.push(`"${key}" = $${paramIdx}`);
+ params.push(fields[key]);
+ paramIdx++;
+ }
+ }
+
+ if (setClauses.length === 0) {
+ return NextResponse.json({ error: 'No valid fields to update' }, { status: 400 });
+ }
+
+ // 2026-05-04 (code-reviewer P0-3): tenant isolation on PATCH.
+ // 2026-05-05 (tick 19+20): cookie-orgId fast path with LIMIT 1 fallback.
+ const patchOrgId = await resolveOrgId(session);
+ if (!patchOrgId) {
+ return NextResponse.json({ error: 'No organization found' }, { status: 400 });
+ }
+
+ params.push(id);
+ const idIdx = paramIdx;
+ paramIdx++;
+ params.push(patchOrgId);
+ const orgIdx = paramIdx;
+ const sql = `UPDATE collaborations SET ${setClauses.join(', ')} WHERE id = $${idIdx} AND org_id = $${orgIdx} RETURNING id, org_id, collab_type, name, title, organization, website_url, email, phone, district, state, ai_reason, ai_relevance, ai_talking_points, status, notes, last_contacted, created_at, updated_at`;
+ const result = await query(sql, params);
+
+ if (result.rows.length === 0) {
+ return NextResponse.json({ error: 'Collaboration not found' }, { status: 404 });
+ }
+
+ return NextResponse.json(result.rows[0]);
+ } catch (err) {
+ console.error('[collaborations PATCH]', err);
+ return NextResponse.json({ error: 'Failed to update collaboration' }, { status: 500 });
+ }
+}
diff --git a/app/api/dashboard/route.ts b/app/api/dashboard/route.ts
new file mode 100644
index 0000000..dd3f4c2
--- /dev/null
+++ b/app/api/dashboard/route.ts
@@ -0,0 +1,117 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { query } from '@/lib/db';
+import { verifyAuthWithOrg, resolveOrgId } from '@/lib/auth';
+
+export async function GET(request: NextRequest) {
+ const session = verifyAuthWithOrg(request);
+ if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+ try {
+ const orgId = await resolveOrgId(session);
+ if (!orgId) {
+ return NextResponse.json({
+ org_name: 'No Organization',
+ grant_count: 0,
+ total_funding: 0,
+ news_count: 0,
+ collab_count: 0,
+ proposal_count: 0,
+ upcoming_deadlines: [],
+ recent_activity: [],
+ });
+ }
+ const nameRes = await query('SELECT name FROM organizations WHERE id = $1', [orgId]);
+ const orgName = nameRes.rows[0]?.name ?? 'Organization';
+
+ // Run all queries in parallel
+ const [
+ grantCountRes,
+ fundingRes,
+ newsCountRes,
+ collabCountRes,
+ proposalCountRes,
+ deadlinesRes,
+ recentGrantsRes,
+ recentCollabsRes,
+ recentProposalsRes,
+ ] = await Promise.all([
+ query('SELECT COUNT(*)::int as count FROM grants WHERE org_id = $1', [orgId]),
+ query('SELECT COALESCE(SUM(amount_awarded), 0)::numeric as total FROM grants WHERE org_id = $1 AND amount_awarded > 0', [orgId]),
+ query('SELECT COUNT(*)::int as count FROM news_items WHERE org_id = $1', [orgId]),
+ query('SELECT COUNT(*)::int as count FROM collaborations WHERE org_id = $1', [orgId]),
+ query('SELECT COUNT(*)::int as count FROM grant_proposals WHERE org_id = $1', [orgId]),
+ query(
+ `SELECT id, title, funder, deadline, status, priority, ai_fit_score
+ FROM grants
+ WHERE org_id = $1 AND deadline > NOW()
+ ORDER BY deadline ASC
+ LIMIT 5`,
+ [orgId],
+ ),
+ query(
+ `SELECT id, title, funder, status, created_at, 'grant' as entity_type
+ FROM grants WHERE org_id = $1
+ ORDER BY created_at DESC LIMIT 5`,
+ [orgId],
+ ),
+ query(
+ `SELECT id, name, organization, collab_type, status, created_at, 'collaboration' as entity_type
+ FROM collaborations WHERE org_id = $1
+ ORDER BY created_at DESC LIMIT 5`,
+ [orgId],
+ ),
+ query(
+ `SELECT gp.id, gp.subject, gp.proposal_type, gp.status, gp.created_at, 'proposal' as entity_type, g.title as grant_title
+ FROM grant_proposals gp
+ JOIN grants g ON g.id = gp.grant_id
+ WHERE gp.org_id = $1
+ ORDER BY gp.created_at DESC LIMIT 5`,
+ [orgId],
+ ),
+ ]);
+
+ // Merge and sort recent activity
+ const recentActivity = [
+ ...recentGrantsRes.rows.map((r) => ({
+ id: r.id,
+ type: 'grant' as const,
+ label: `Grant discovered: ${r.title}`,
+ detail: r.funder,
+ status: r.status,
+ created_at: r.created_at,
+ })),
+ ...recentCollabsRes.rows.map((r) => ({
+ id: r.id,
+ type: 'collaboration' as const,
+ label: `Collaborator: ${r.name}`,
+ detail: r.organization || r.collab_type,
+ status: r.status,
+ created_at: r.created_at,
+ })),
+ ...recentProposalsRes.rows.map((r) => ({
+ id: r.id,
+ type: 'proposal' as const,
+ label: `Proposal: ${r.subject}`,
+ detail: r.grant_title,
+ status: r.status,
+ created_at: r.created_at,
+ })),
+ ]
+ .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
+ .slice(0, 8);
+
+ return NextResponse.json({
+ org_name: orgName,
+ grant_count: grantCountRes.rows[0].count,
+ total_funding: Number(fundingRes.rows[0].total),
+ news_count: newsCountRes.rows[0].count,
+ collab_count: collabCountRes.rows[0].count,
+ proposal_count: proposalCountRes.rows[0].count,
+ upcoming_deadlines: deadlinesRes.rows,
+ recent_activity: recentActivity,
+ });
+ } catch (err) {
+ console.error('[dashboard GET]', err);
+ return NextResponse.json({ error: 'Failed to fetch dashboard' }, { status: 500 });
+ }
+}
diff --git a/app/api/grants/[id]/proposals/route.ts b/app/api/grants/[id]/proposals/route.ts
new file mode 100644
index 0000000..0ebc294
--- /dev/null
+++ b/app/api/grants/[id]/proposals/route.ts
@@ -0,0 +1,193 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { query } from '@/lib/db';
+import { verifyAuthWithOrg, resolveOrgId } from '@/lib/auth';
+import { auditLog } from '@/lib/audit';
+import { callGemini } from '@/lib/gemini';
+import { sanitizeProposalBody, bodyHtmlTooLarge } from '@/lib/sanitize';
+
+type ProposalShape = {
+ subject?: string;
+ recipient_name?: string;
+ body_html?: string;
+ body_text?: string;
+};
+
+// 2026-05-06 (tick 48): SANITIZE_OPTS extracted to lib/sanitize.ts as
+// the canonical allowlist. Future routes that store user-supplied HTML
+// should import sanitizeProposalBody from there.
+
+/* ─── GET /api/grants/[id]/proposals ──────────────────────────────────────── */
+export async function GET(
+ request: NextRequest,
+ { params }: { params: Promise<{ id: string }> },
+) {
+ const session = verifyAuthWithOrg(request);
+ if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+ try {
+ const { id } = await params;
+ // 2026-05-04 (code-reviewer P0-4): tenant isolation via JOIN through grants.
+ const orgId = await resolveOrgId(session);
+ if (!orgId) return NextResponse.json({ proposals: [] });
+ const result = await query(
+ `SELECT gp.id, gp.grant_id, gp.org_id, gp.subject, gp.recipient_email, gp.recipient_name, gp.body_html, gp.body_text, gp.proposal_type, gp.status, gp.version, gp.sent_at, gp.created_at, gp.updated_at
+ FROM grant_proposals gp
+ JOIN grants g ON g.id = gp.grant_id
+ WHERE gp.grant_id = $1 AND g.org_id = $2
+ ORDER BY gp.version DESC, gp.created_at DESC`,
+ [id, orgId],
+ );
+ return NextResponse.json({ proposals: result.rows });
+ } catch (err) {
+ console.error('[proposals GET]', err);
+ return NextResponse.json({ error: 'Failed to fetch proposals' }, { status: 500 });
+ }
+}
+
+/* ─── POST /api/grants/[id]/proposals ─────────────────────────────────────── */
+export async function POST(
+ request: NextRequest,
+ { params }: { params: Promise<{ id: string }> },
+) {
+ const session = verifyAuthWithOrg(request);
+ if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ const user = session.username;
+
+ try {
+ const { id: grantId } = await params;
+ const body = await request.json();
+ const proposalType = body.proposal_type || 'letter_of_inquiry';
+ // 2026-05-04 (code-reviewer P1-2): cap user-controlled context interpolated
+ // into the Gemini prompt. Unbounded input is a prompt-injection / cost-
+ // amplification surface (multi-MB string → multi-MB prompt).
+ const MAX_CONTEXT_CHARS = 2000;
+ const context = String(body.context || '').slice(0, MAX_CONTEXT_CHARS);
+
+ // Get grant details
+ const grantRes = await query('SELECT id, org_id, title, funder, amount_min, amount_max, description, eligibility, deadline, ai_suggestion FROM grants WHERE id = $1', [grantId]);
+ if (grantRes.rows.length === 0) {
+ return NextResponse.json({ error: 'Grant not found' }, { status: 404 });
+ }
+ const grant = grantRes.rows[0];
+
+ // Get org details
+ const orgRes = await query(
+ 'SELECT id, name, ai_mission, ai_focus_areas, ai_org_summary, city, state, nonprofit_status, staff_names FROM organizations WHERE id = $1',
+ [grant.org_id],
+ );
+ const org = orgRes.rows[0];
+
+ // If body_html is provided, just save it
+ if (body.body_html && body.subject) {
+ // 2026-05-05 (P1-3 final): size cap + sanitize-html allowlist.
+ // The 200KB cap stops cost amplification; sanitize-html strips XSS
+ // vectors so a future component using dangerouslySetInnerHTML can't
+ // render malicious payloads. Allowlist matches the formatting tags
+ // the AI prompt asks for.
+ if (bodyHtmlTooLarge(body.body_html)) {
+ return NextResponse.json({ error: 'body_html too large or invalid' }, { status: 400 });
+ }
+ const cleanHtml = sanitizeProposalBody(body.body_html);
+ const result = await query(
+ `INSERT INTO grant_proposals (grant_id, org_id, subject, recipient_email, recipient_name, body_html, body_text, proposal_type, status)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'draft')
+ RETURNING id, grant_id, org_id, subject, recipient_email, recipient_name, body_html, body_text, proposal_type, status, version, sent_at, created_at, updated_at`,
+ [
+ grantId,
+ org.id,
+ body.subject,
+ body.recipient_email || null,
+ body.recipient_name || null,
+ cleanHtml,
+ body.body_text || cleanHtml.replace(/<[^>]+>/g, ''),
+ proposalType,
+ ],
+ );
+ return NextResponse.json(result.rows[0], { status: 201 });
+ }
+
+ // Generate with AI
+ const typeLabels: Record<string, string> = {
+ letter_of_inquiry: 'Letter of Inquiry (LOI)',
+ full_proposal: 'Full Grant Proposal',
+ one_pager: 'One-Page Summary',
+ meeting_request: 'Meeting Request Email',
+ };
+
+ const prompt = `You are a professional nonprofit fundraising writer. Generate a ${typeLabels[proposalType] || 'Letter of Inquiry'} for this grant application.
+
+GRANT DETAILS:
+- Grant: ${grant.title}
+- Funder: ${grant.funder}
+- Amount Range: $${grant.amount_min?.toLocaleString() || 'N/A'} - $${grant.amount_max?.toLocaleString() || 'N/A'}
+- Description: ${grant.description || 'N/A'}
+- Eligibility: ${grant.eligibility || 'N/A'}
+- Deadline: ${grant.deadline || 'Rolling'}
+- AI Suggestion: ${grant.ai_suggestion || 'N/A'}
+
+ORGANIZATION:
+- Name: ${org.name}
+- Location: ${org.city}, ${org.state}
+- Status: ${org.nonprofit_status}
+- Mission: ${org.ai_mission}
+- Focus Areas: ${(org.ai_focus_areas || []).join(', ')}
+- Summary: ${org.ai_org_summary}
+- Key Staff: ${(org.staff_names || []).join(', ')}
+
+${context ? `ADDITIONAL CONTEXT: ${context}` : ''}
+
+Return ONLY a JSON object (no markdown, no code fences) with:
+- subject: string (email subject line)
+- recipient_name: string (appropriate contact title, e.g., "Grant Review Committee" or "Program Officer")
+- body_html: string (the full ${typeLabels[proposalType] || 'letter'} formatted in HTML with proper paragraphs, using <p>, <strong>, <ul>, <li> tags. Professional nonprofit fundraising tone. Include specific details about how the organization's work aligns with the funder's priorities.)
+- body_text: string (plain text version)`;
+
+ // 2026-05-05 (tick 16): migrated to lib/gemini.ts wrapper.
+ const geminiResult = await callGemini<ProposalShape>({ prompt, maxTokens: 4096 });
+ if (!geminiResult.ok) {
+ console.error('[proposals/generate] gemini failed:', geminiResult.reason, geminiResult.detail);
+ return NextResponse.json(
+ { error: geminiResult.reason === 'no_key' ? 'AI service not configured' : 'AI service error' },
+ { status: geminiResult.status },
+ );
+ }
+ const proposal = geminiResult.data;
+
+ // Get current max version for this grant
+ const versionRes = await query(
+ 'SELECT COALESCE(MAX(version), 0) as max_version FROM grant_proposals WHERE grant_id = $1',
+ [grantId],
+ );
+ const nextVersion = (versionRes.rows[0].max_version || 0) + 1;
+
+ const result = await query(
+ `INSERT INTO grant_proposals (grant_id, org_id, subject, recipient_name, body_html, body_text, proposal_type, status, version)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, 'draft', $8)
+ RETURNING id, grant_id, org_id, subject, recipient_email, recipient_name, body_html, body_text, proposal_type, status, version, sent_at, created_at, updated_at`,
+ [
+ grantId,
+ org.id,
+ proposal.subject || `${typeLabels[proposalType]} - ${org.name}`,
+ proposal.recipient_name || 'Grant Review Committee',
+ proposal.body_html || '<p>Failed to generate proposal content.</p>',
+ proposal.body_text || 'Failed to generate proposal content.',
+ proposalType,
+ nextVersion,
+ ],
+ );
+
+ // 2026-05-05 (architect P2-3): audit AI calls — gives a trail if the
+ // Gemini key is ever abused.
+ auditLog('proposal.ai_generated', 'grant_proposal', result.rows[0].id, org.id, user, {
+ grant_id: grantId,
+ proposal_type: proposalType,
+ input_tokens: geminiResult.inputTokens,
+ output_tokens: geminiResult.outputTokens,
+ }, request.headers.get('x-forwarded-for') || undefined);
+
+ return NextResponse.json(result.rows[0], { status: 201 });
+ } catch (err) {
+ console.error('[proposals POST]', err);
+ return NextResponse.json({ error: 'Failed to create proposal' }, { status: 500 });
+ }
+}
diff --git a/app/api/grants/discover-real/route.ts b/app/api/grants/discover-real/route.ts
new file mode 100644
index 0000000..63057ce
--- /dev/null
+++ b/app/api/grants/discover-real/route.ts
@@ -0,0 +1,146 @@
+/**
+ * /api/grants/discover-real — REAL grants from Grants.gov public API.
+ *
+ * 2026-05-05 (tick 18): replaces the AI-fabricated /api/grants/discover
+ * pattern (which Gemini just makes up "plausible URLs at that outlet"
+ * and then ships the fakes to users — credibility/liability risk per
+ * P2-2). This route hits api.grants.gov/v1/api/search2 — free, no auth,
+ * real federal grant opportunities.
+ *
+ * The original /api/grants/discover route stays in place for now (until
+ * a UI cutover decides). Both write to the same `grants` table; this
+ * route tags rows with source_api='grants_gov'.
+ */
+
+import { NextRequest, NextResponse } from 'next/server';
+import { query } from '@/lib/db';
+import { verifyAuthWithOrg, resolveOrgId } from '@/lib/auth';
+import { auditLog } from '@/lib/audit';
+
+const SEARCH_URL = 'https://api.grants.gov/v1/api/search2';
+
+type GrantsGovHit = {
+ id: string;
+ number?: string;
+ title?: string;
+ agencyCode?: string;
+ agency?: string;
+ openDate?: string; // MM/DD/YYYY
+ closeDate?: string; // MM/DD/YYYY
+ oppStatus?: string;
+ docType?: string;
+ cfdaList?: string[];
+};
+
+type GrantsGovResponse = {
+ errorcode: number;
+ msg: string;
+ data?: {
+ hitCount?: number;
+ oppHits?: GrantsGovHit[];
+ };
+};
+
+// MM/DD/YYYY → YYYY-MM-DD (PG date)
+function normalizeDate(s?: string): string | null {
+ if (!s) return null;
+ const m = s.match(/^(\d{2})\/(\d{2})\/(\d{4})$/);
+ if (!m) return null;
+ return `${m[3]}-${m[1]}-${m[2]}`;
+}
+
+export async function POST(request: NextRequest) {
+ const session = verifyAuthWithOrg(request);
+ if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ const user = session.username;
+
+ try {
+ const body = await request.json().catch(() => ({}));
+ const keyword = String(body.keyword || 'education').slice(0, 200);
+ const rows = Math.min(Math.max(parseInt(body.rows ?? '25', 10) || 25, 1), 100);
+
+ const orgId = await resolveOrgId(session);
+ if (!orgId) return NextResponse.json({ error: 'No organization found' }, { status: 400 });
+
+ // 25s timeout matches the Gemini wrapper's pattern for consistency.
+ let res: Response;
+ try {
+ res = await fetch(SEARCH_URL, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ keyword,
+ oppStatuses: 'forecasted|posted',
+ rows,
+ }),
+ signal: AbortSignal.timeout(25_000),
+ });
+ } catch (e) {
+ console.error('[grants/discover-real] grants.gov fetch aborted:', (e as Error).message);
+ return NextResponse.json({ error: 'Grants.gov timeout' }, { status: 504 });
+ }
+
+ if (!res.ok) {
+ console.error('[grants/discover-real] grants.gov error:', res.status);
+ return NextResponse.json({ error: 'Grants.gov upstream error' }, { status: 502 });
+ }
+
+ const json = (await res.json()) as GrantsGovResponse;
+ if (json.errorcode !== 0) {
+ console.error('[grants/discover-real] grants.gov errorcode:', json.errorcode, json.msg);
+ return NextResponse.json({ error: 'Grants.gov returned error' }, { status: 502 });
+ }
+
+ const hits = json.data?.oppHits ?? [];
+ const inserted: unknown[] = [];
+ for (const h of hits) {
+ // Skip if we already imported this opportunity number.
+ if (h.number) {
+ const dup = await query(
+ 'SELECT id FROM grants WHERE org_id = $1 AND grants_gov_id = $2 LIMIT 1',
+ [orgId, h.id],
+ );
+ if (dup.rows.length > 0) continue;
+ }
+
+ try {
+ const result = await query(
+ `INSERT INTO grants (org_id, title, funder, funder_url, deadline, status, priority, source_api, grants_gov_id, ai_suggestion, application_url)
+ VALUES ($1, $2, $3, $4, $5, 'discovered', 'medium', 'grants_gov', $6, $7, $8)
+ RETURNING id, org_id, title, funder, funder_url, amount_min, amount_max, description, eligibility, focus_areas, application_url, deadline, cycle, ai_fit_score, ai_suggestion, status, priority, notes, applied_at, amount_requested, amount_awarded, tags, is_bookmarked, grants_gov_id, federal_register_id, source_api, created_at, updated_at`,
+ [
+ orgId,
+ h.title || `Untitled (${h.number || h.id})`,
+ h.agency || h.agencyCode || 'Federal',
+ null,
+ normalizeDate(h.closeDate),
+ h.id,
+ h.cfdaList && h.cfdaList.length ? `CFDA ${h.cfdaList.join(', ')}` : null,
+ `https://www.grants.gov/search-results-detail/${h.id}`,
+ ],
+ );
+ inserted.push(result.rows[0]);
+ } catch (insertErr) {
+ console.error('[grants/discover-real] insert error:', h.title, (insertErr as Error).message);
+ }
+ }
+
+ auditLog('grant.real_discovered', 'grant', null, orgId, user, {
+ keyword,
+ hit_count: json.data?.hitCount ?? 0,
+ inserted_count: inserted.length,
+ source: 'grants.gov',
+ }, request.headers.get('x-forwarded-for') || undefined);
+
+ return NextResponse.json({
+ keyword,
+ total_hits: json.data?.hitCount ?? 0,
+ discovered: inserted.length,
+ grants: inserted,
+ source: 'grants.gov · public API · no AI fabrication',
+ });
+ } catch (err) {
+ console.error('[grants/discover-real]', err);
+ return NextResponse.json({ error: 'Real discovery failed' }, { status: 500 });
+ }
+}
diff --git a/app/api/grants/discover/route.ts b/app/api/grants/discover/route.ts
new file mode 100644
index 0000000..e3dc50b
--- /dev/null
+++ b/app/api/grants/discover/route.ts
@@ -0,0 +1,124 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { query } from '@/lib/db';
+import { verifyAuthWithOrg, resolveOrgId } from '@/lib/auth';
+import { auditLog } from '@/lib/audit';
+import { callGemini } from '@/lib/gemini';
+
+export async function POST(request: NextRequest) {
+ const session = verifyAuthWithOrg(request);
+ if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ const user = session.username;
+
+ try {
+ const orgId = await resolveOrgId(session);
+ if (!orgId) return NextResponse.json({ error: 'No organization found' }, { status: 400 });
+ const orgRes = await query(
+ 'SELECT id, name, ai_mission, ai_focus_areas, ai_keywords, ai_org_summary FROM organizations WHERE id = $1',
+ [orgId],
+ );
+ if (orgRes.rows.length === 0) {
+ return NextResponse.json({ error: 'No organization found' }, { status: 400 });
+ }
+ const org = orgRes.rows[0];
+
+ // Get existing grant titles to avoid duplicates
+ const existingRes = await query(
+ 'SELECT title, funder FROM grants WHERE org_id = $1',
+ [org.id],
+ );
+ const existingTitles = existingRes.rows.map(
+ (r) => `${r.title} - ${r.funder}`,
+ );
+
+ const prompt = `You are a nonprofit grant research assistant. Find 8-10 REAL grant opportunities for this organization:
+
+Organization: ${org.name}
+Mission: ${org.ai_mission}
+Focus Areas: ${(org.ai_focus_areas || []).join(', ')}
+Keywords: ${(org.ai_keywords || []).join(', ')}
+Summary: ${org.ai_org_summary}
+
+${existingTitles.length > 0 ? `Already discovered (DO NOT repeat): ${existingTitles.join('; ')}` : ''}
+
+Return ONLY a JSON array (no markdown, no code fences) of grant objects with these exact fields:
+- title: string (the actual grant program name)
+- funder: string (the actual foundation, government agency, or corporation)
+- funder_url: string (the actual website URL for the funder)
+- amount_min: number (minimum grant amount in dollars)
+- amount_max: number (maximum grant amount in dollars)
+- description: string (2-3 sentences about what the grant funds)
+- eligibility: string (who can apply)
+- focus_areas: string[] (array of 2-4 focus area tags)
+- application_url: string (URL to apply or learn more)
+- deadline: string (ISO date string, estimate if exact date unknown, use dates in 2026)
+- cycle: string (one of: "annual", "biannual", "rolling", "one-time")
+- ai_fit_score: number (0-1 score of how well this matches the org, be realistic)
+- ai_suggestion: string (1-2 sentences on why this is a good fit and how to approach)
+- priority: string (one of: "high", "medium", "low")
+- tags: string[] (array of relevant tags)
+
+Use real foundations like Ford Foundation, Gates Foundation, Lumina Foundation, Kresge Foundation, Arnold Ventures, Robin Hood Foundation, Tides Foundation, etc. Also include federal grants from Education Dept, CFPB, etc. Make amounts realistic for education/advocacy nonprofits.`;
+
+ // 2026-05-05 (tick 16): migrated to lib/gemini.ts wrapper.
+ const result = await callGemini<Record<string, unknown>[]>({ prompt, maxTokens: 8192 });
+ if (!result.ok) {
+ console.error('[grants/discover] gemini failed:', result.reason, result.detail);
+ return NextResponse.json(
+ { error: result.reason === 'no_key' ? 'AI service not configured' : 'AI service error' },
+ { status: result.status },
+ );
+ }
+ const grants = result.data;
+ if (!Array.isArray(grants)) {
+ return NextResponse.json({ error: 'AI returned invalid format' }, { status: 500 });
+ }
+
+ // Insert each grant
+ const inserted = [];
+ for (const g of grants) {
+ try {
+ const result = await query(
+ `INSERT INTO grants (org_id, title, funder, funder_url, amount_min, amount_max, description, eligibility, focus_areas, application_url, deadline, cycle, ai_fit_score, ai_suggestion, priority, tags, status, source_api)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, 'discovered', 'ai_discovery')
+ RETURNING id, org_id, title, funder, funder_url, amount_min, amount_max, description, eligibility, focus_areas, application_url, deadline, cycle, ai_fit_score, ai_suggestion, status, priority, notes, applied_at, amount_requested, amount_awarded, tags, is_bookmarked, source_api, created_at, updated_at`,
+ [
+ org.id,
+ g.title || 'Untitled Grant',
+ g.funder || 'Unknown',
+ g.funder_url || null,
+ g.amount_min || null,
+ g.amount_max || null,
+ g.description || null,
+ g.eligibility || null,
+ g.focus_areas || [],
+ g.application_url || null,
+ g.deadline || null,
+ g.cycle || 'annual',
+ g.ai_fit_score != null ? g.ai_fit_score : 0.5,
+ g.ai_suggestion || null,
+ g.priority || 'medium',
+ g.tags || [],
+ ],
+ );
+ inserted.push(result.rows[0]);
+ } catch (insertErr) {
+ console.error('[grants/discover] Insert error for:', g.title, insertErr);
+ }
+ }
+
+ // 2026-05-05 (architect P2-3): audit AI call.
+ auditLog('grant.ai_discovered', 'grant', null, org.id, user, {
+ discovered_count: inserted.length,
+ input_tokens: result.inputTokens,
+ output_tokens: result.outputTokens,
+ }, request.headers.get('x-forwarded-for') || undefined);
+
+ return NextResponse.json({
+ discovered: inserted.length,
+ grants: inserted,
+ });
+ } catch (err) {
+ console.error('[grants/discover]', err);
+ return NextResponse.json({ error: 'Discovery failed' }, { status: 500 });
+ }
+}
diff --git a/app/api/grants/route.ts b/app/api/grants/route.ts
new file mode 100644
index 0000000..ed7f2f0
--- /dev/null
+++ b/app/api/grants/route.ts
@@ -0,0 +1,192 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { query } from '@/lib/db';
+import { verifyAuthWithOrg, resolveOrgId } from '@/lib/auth';
+import { auditLog } from '@/lib/audit';
+
+/* ─── GET /api/grants ─────────────────────────────────────────────────────── */
+export async function GET(request: NextRequest) {
+ const session = verifyAuthWithOrg(request);
+ if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+ try {
+ const url = new URL(request.url);
+ const status = url.searchParams.get('status');
+ const search = url.searchParams.get('search');
+
+ const orgId = await resolveOrgId(session);
+ if (!orgId) {
+ return NextResponse.json({ rows: [], statusCounts: {} });
+ }
+
+ let sql = `SELECT id, org_id, title, funder, funder_url, amount_min, amount_max, description, eligibility, focus_areas, application_url, deadline, cycle, ai_fit_score, ai_suggestion, status, priority, notes, applied_at, amount_requested, amount_awarded, tags, is_bookmarked, grants_gov_id, federal_register_id, source_api, created_at, updated_at FROM grants WHERE org_id = $1`;
+ const params: unknown[] = [orgId];
+ let paramIdx = 2;
+
+ if (status && status !== 'all') {
+ sql += ` AND status = $${paramIdx}`;
+ params.push(status);
+ paramIdx++;
+ }
+
+ if (search) {
+ sql += ` AND (title ILIKE $${paramIdx} OR funder ILIKE $${paramIdx})`;
+ params.push(`%${search}%`);
+ paramIdx++;
+ }
+
+ sql += ` ORDER BY CASE WHEN deadline IS NOT NULL AND deadline > NOW() THEN 0 ELSE 1 END, deadline ASC NULLS LAST, created_at DESC`;
+
+ const result = await query(sql, params);
+
+ // Status counts (always unfiltered)
+ const countsRes = await query(
+ `SELECT status, COUNT(*)::int as count FROM grants WHERE org_id = $1 GROUP BY status`,
+ [orgId],
+ );
+ const statusCounts: Record<string, number> = {};
+ let total = 0;
+ for (const row of countsRes.rows) {
+ statusCounts[row.status] = row.count;
+ total += row.count;
+ }
+ statusCounts['all'] = total;
+
+ return NextResponse.json({ rows: result.rows, statusCounts });
+ } catch (err) {
+ console.error('[grants GET]', err);
+ return NextResponse.json({ error: 'Failed to fetch grants' }, { status: 500 });
+ }
+}
+
+/* ─── POST /api/grants ────────────────────────────────────────────────────── */
+export async function POST(request: NextRequest) {
+ const session = verifyAuthWithOrg(request);
+ if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+ try {
+ const body = await request.json();
+ if (!body.title || typeof body.title !== 'string' || !body.title.trim()) {
+ return NextResponse.json({ error: 'title is required' }, { status: 400 });
+ }
+ const orgId = await resolveOrgId(session);
+ if (!orgId) {
+ return NextResponse.json({ error: 'No organization found' }, { status: 400 });
+ }
+
+ const result = await query(
+ `INSERT INTO grants (org_id, title, funder, funder_url, amount_min, amount_max, description, eligibility, deadline, focus_areas, priority, application_url, cycle, notes, tags, status)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
+ RETURNING id, org_id, title, funder, funder_url, amount_min, amount_max, description, eligibility, focus_areas, application_url, deadline, cycle, ai_fit_score, ai_suggestion, status, priority, notes, applied_at, amount_requested, amount_awarded, tags, is_bookmarked, grants_gov_id, federal_register_id, source_api, created_at, updated_at`,
+ [
+ orgId,
+ body.title,
+ body.funder || 'Unknown Funder',
+ body.funder_url || null,
+ body.amount_min || null,
+ body.amount_max || null,
+ body.description || null,
+ body.eligibility || null,
+ body.deadline || null,
+ body.focus_areas || [],
+ body.priority || 'medium',
+ body.application_url || null,
+ body.cycle || null,
+ body.notes || null,
+ body.tags || [],
+ body.status || 'discovered',
+ ],
+ );
+
+ return NextResponse.json(result.rows[0], { status: 201 });
+ } catch (err) {
+ console.error('[grants POST]', err);
+ return NextResponse.json({ error: 'Failed to create grant' }, { status: 500 });
+ }
+}
+
+/* ─── PATCH /api/grants ───────────────────────────────────────────────────── */
+export async function PATCH(request: NextRequest) {
+ const session = verifyAuthWithOrg(request);
+ if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+ try {
+ const body = await request.json();
+ const { id, ...fields } = body;
+
+ if (!id) {
+ return NextResponse.json({ error: 'id is required' }, { status: 400 });
+ }
+
+ const allowedFields = [
+ 'title', 'funder', 'funder_url', 'amount_min', 'amount_max',
+ 'description', 'eligibility', 'deadline', 'focus_areas', 'priority',
+ 'application_url', 'cycle', 'notes', 'tags', 'status',
+ 'is_bookmarked', 'applied_at', 'amount_requested', 'amount_awarded',
+ 'ai_fit_score', 'ai_suggestion',
+ ];
+
+ const setClauses: string[] = [];
+ const params: unknown[] = [];
+ let paramIdx = 1;
+
+ for (const key of Object.keys(fields)) {
+ if (allowedFields.includes(key)) {
+ setClauses.push(`"${key}" = $${paramIdx}`);
+ params.push(fields[key]);
+ paramIdx++;
+ }
+ }
+
+ if (setClauses.length === 0) {
+ return NextResponse.json({ error: 'No valid fields to update' }, { status: 400 });
+ }
+
+ // 2026-05-04 (code-reviewer P0-3): tenant isolation on PATCH. Resolves
+ // org from the cookie payload (v0.2.0) or LIMIT 1 fallback (legacy).
+ const patchOrgId = await resolveOrgId(session);
+ if (!patchOrgId) {
+ return NextResponse.json({ error: 'No organization found' }, { status: 400 });
+ }
+
+ // If changing status to submitted, fetch previous status for audit
+ let previousStatus: string | null = null;
+ if (fields.status === 'submitted') {
+ const prev = await query('SELECT status, org_id FROM grants WHERE id = $1 AND org_id = $2', [id, patchOrgId]);
+ if (prev.rows.length > 0) {
+ previousStatus = prev.rows[0].status;
+ }
+ }
+
+ params.push(id);
+ const idIdx = paramIdx;
+ paramIdx++;
+ params.push(patchOrgId);
+ const orgIdx = paramIdx;
+ const sql = `UPDATE grants SET ${setClauses.join(', ')} WHERE id = $${idIdx} AND org_id = $${orgIdx} RETURNING id, org_id, title, funder, funder_url, amount_min, amount_max, description, eligibility, focus_areas, application_url, deadline, cycle, ai_fit_score, ai_suggestion, status, priority, notes, applied_at, amount_requested, amount_awarded, tags, is_bookmarked, grants_gov_id, federal_register_id, source_api, created_at, updated_at`;
+ const result = await query(sql, params);
+
+ if (result.rows.length === 0) {
+ // Same response for "not in your org" and "doesn't exist" — don't leak existence.
+ return NextResponse.json({ error: 'Grant not found' }, { status: 404 });
+ }
+
+ // Audit log for status changes to 'submitted'
+ if (fields.status === 'submitted') {
+ const updatedGrant = result.rows[0];
+ await auditLog(
+ 'grant.submitted',
+ 'grant',
+ id,
+ updatedGrant.org_id || null,
+ 'admin',
+ { previous_status: previousStatus, title: updatedGrant.title },
+ request.headers.get('x-forwarded-for') || undefined,
+ );
+ }
+
+ return NextResponse.json(result.rows[0]);
+ } catch (err) {
+ console.error('[grants PATCH]', err);
+ return NextResponse.json({ error: 'Failed to update grant' }, { status: 500 });
+ }
+}
diff --git a/app/api/news/discover/route.ts b/app/api/news/discover/route.ts
new file mode 100644
index 0000000..e51004c
--- /dev/null
+++ b/app/api/news/discover/route.ts
@@ -0,0 +1,129 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { query } from '@/lib/db';
+import { verifyAuthWithOrg, resolveOrgId } from '@/lib/auth';
+import { auditLog } from '@/lib/audit';
+import { callGemini } from '@/lib/gemini';
+
+type NewsItem = {
+ headline?: string;
+ outlet?: string;
+ url?: string;
+ published_at?: string;
+ summary?: string;
+ tags?: string[];
+ relevance_score?: number;
+ author_name?: string;
+ source_type?: string;
+};
+
+export async function POST(request: NextRequest) {
+ const session = verifyAuthWithOrg(request);
+ if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ const user = session.username;
+
+ try {
+ const orgId = await resolveOrgId(session);
+ if (!orgId) return NextResponse.json({ error: 'No organization found' }, { status: 400 });
+ const orgRes = await query(
+ 'SELECT id, name, ai_mission, ai_focus_areas, ai_keywords, ai_org_summary FROM organizations WHERE id = $1',
+ [orgId],
+ );
+ if (orgRes.rows.length === 0) {
+ return NextResponse.json({ error: 'No organization found' }, { status: 400 });
+ }
+ const org = orgRes.rows[0];
+
+ // Get existing headlines to avoid duplicates
+ const existingRes = await query(
+ 'SELECT headline FROM news_items WHERE org_id = $1',
+ [org.id],
+ );
+ const existingHeadlines = existingRes.rows.map((r) => r.headline);
+
+ const prompt = `You are a news research assistant for a nonprofit organization. Find 8-12 REAL and RECENT news articles relevant to this organization's mission.
+
+Organization: ${org.name}
+Mission: ${org.ai_mission}
+Focus Areas: ${(org.ai_focus_areas || []).join(', ')}
+Keywords: ${(org.ai_keywords || []).join(', ')}
+
+${existingHeadlines.length > 0 ? `Already tracked (DO NOT repeat): ${existingHeadlines.slice(0, 20).join('; ')}` : ''}
+
+Focus on news from late 2025 and early 2026 about:
+- Student debt policy changes, forgiveness programs, PSLF updates
+- Higher education funding and reform
+- FAFSA changes, Pell Grant updates
+- For-profit college accountability
+- Consumer financial protection related to student loans
+- Congressional actions on education funding
+- State-level student debt initiatives
+
+Return ONLY a JSON array (no markdown, no code fences) of news objects with:
+- headline: string (realistic news headline)
+- outlet: string (real news outlet name like NPR, The Washington Post, Inside Higher Ed, The Hill, Reuters, CNBC, Politico, etc.)
+- url: string (plausible URL at that outlet)
+- published_at: string (ISO date, recent dates in Jan-Feb 2026)
+- summary: string (2-3 sentence summary of the article)
+- tags: string[] (3-5 relevant tags)
+- relevance_score: number (0-1 score of relevance to the org)
+- author_name: string (plausible journalist name)
+- source_type: string (one of: "google_news", "press_release", "policy_brief", "academic", "industry")`;
+
+ // 2026-05-04 (architect-reviewer): migrated to lib/gemini.ts wrapper.
+ // The wrapper centralizes timeout / fence-strip / parse / 503-503-502-504
+ // status mapping; this handler now only owns prompt + persistence.
+ const result = await callGemini<NewsItem[]>({ prompt, maxTokens: 8192 });
+ if (!result.ok) {
+ console.error('[news/discover] gemini failed:', result.reason, result.detail);
+ return NextResponse.json(
+ { error: result.reason === 'no_key' ? 'AI service not configured' : 'AI service error' },
+ { status: result.status },
+ );
+ }
+ const newsItems = result.data;
+ if (!Array.isArray(newsItems)) {
+ return NextResponse.json({ error: 'AI returned invalid format' }, { status: 500 });
+ }
+
+ const inserted = [];
+ for (const n of newsItems) {
+ try {
+ const result = await query(
+ `INSERT INTO news_items (org_id, headline, outlet, url, published_at, summary, tags, relevance_score, author_name, source_type)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
+ RETURNING id, org_id, headline, outlet, url, published_at, summary, tags, relevance_score, author_name, source_type, created_at, updated_at`,
+ [
+ org.id,
+ n.headline || 'Untitled',
+ n.outlet || 'Unknown',
+ n.url || null,
+ n.published_at || null,
+ n.summary || null,
+ n.tags || [],
+ n.relevance_score != null ? n.relevance_score : 0.5,
+ n.author_name || null,
+ n.source_type || 'google_news',
+ ],
+ );
+ inserted.push(result.rows[0]);
+ } catch (insertErr) {
+ console.error('[news/discover] Insert error:', n.headline, insertErr);
+ }
+ }
+
+ // 2026-05-05 (architect P2-3): audit AI call.
+ auditLog('news.ai_discovered', 'news_item', null, org.id, user, {
+ discovered_count: inserted.length,
+ input_tokens: result.inputTokens,
+ output_tokens: result.outputTokens,
+ }, request.headers.get('x-forwarded-for') || undefined);
+
+ return NextResponse.json({
+ discovered: inserted.length,
+ news: inserted,
+ });
+ } catch (err) {
+ console.error('[news/discover]', err);
+ return NextResponse.json({ error: 'News discovery failed' }, { status: 500 });
+ }
+}
diff --git a/app/api/news/route.ts b/app/api/news/route.ts
new file mode 100644
index 0000000..ef75a20
--- /dev/null
+++ b/app/api/news/route.ts
@@ -0,0 +1,77 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { query } from '@/lib/db';
+import { verifyAuthWithOrg, resolveOrgId } from '@/lib/auth';
+
+/* ─── GET /api/news ───────────────────────────────────────────────────────── */
+export async function GET(request: NextRequest) {
+ const session = verifyAuthWithOrg(request);
+ if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+ try {
+ const url = new URL(request.url);
+ const search = url.searchParams.get('search');
+ const sourceType = url.searchParams.get('source_type');
+
+ const orgId = await resolveOrgId(session);
+ if (!orgId) return NextResponse.json({ rows: [] });
+
+ let sql = 'SELECT id, org_id, headline, outlet, url, published_at, summary, tags, relevance_score, author_name, source_type, created_at, updated_at FROM news_items WHERE org_id = $1';
+ const params: unknown[] = [orgId];
+ let paramIdx = 2;
+
+ if (search) {
+ sql += ` AND (headline ILIKE $${paramIdx} OR summary ILIKE $${paramIdx} OR outlet ILIKE $${paramIdx})`;
+ params.push(`%${search}%`);
+ paramIdx++;
+ }
+
+ if (sourceType && sourceType !== 'all') {
+ sql += ` AND source_type = $${paramIdx}`;
+ params.push(sourceType);
+ paramIdx++;
+ }
+
+ sql += ' ORDER BY published_at DESC NULLS LAST, created_at DESC';
+
+ const result = await query(sql, params);
+ return NextResponse.json({ rows: result.rows });
+ } catch (err) {
+ console.error('[news GET]', err);
+ return NextResponse.json({ error: 'Failed to fetch news' }, { status: 500 });
+ }
+}
+
+/* ─── POST /api/news ──────────────────────────────────────────────────────── */
+export async function POST(request: NextRequest) {
+ const session = verifyAuthWithOrg(request);
+ if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+ try {
+ const body = await request.json();
+ const orgId = await resolveOrgId(session);
+ if (!orgId) return NextResponse.json({ error: 'No organization found' }, { status: 400 });
+
+ const result = await query(
+ `INSERT INTO news_items (org_id, headline, outlet, url, published_at, summary, tags, relevance_score, author_name, source_type)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
+ RETURNING id, org_id, headline, outlet, url, published_at, summary, tags, relevance_score, author_name, source_type, created_at, updated_at`,
+ [
+ orgId,
+ body.headline,
+ body.outlet || null,
+ body.url || null,
+ body.published_at || null,
+ body.summary || null,
+ body.tags || [],
+ body.relevance_score || null,
+ body.author_name || null,
+ body.source_type || 'manual',
+ ],
+ );
+
+ return NextResponse.json(result.rows[0], { status: 201 });
+ } catch (err) {
+ console.error('[news POST]', err);
+ return NextResponse.json({ error: 'Failed to create news item' }, { status: 500 });
+ }
+}
diff --git a/app/api/outreach/generate/route.ts b/app/api/outreach/generate/route.ts
new file mode 100644
index 0000000..97722d1
--- /dev/null
+++ b/app/api/outreach/generate/route.ts
@@ -0,0 +1,121 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { query } from '@/lib/db';
+import { verifyAuthWithOrg, resolveOrgId } from '@/lib/auth';
+import { auditLog } from '@/lib/audit';
+import { callGemini } from '@/lib/gemini';
+
+type OutreachShape = { subject?: string; body_html?: string; body_text?: string };
+
+export async function POST(request: NextRequest) {
+ const session = verifyAuthWithOrg(request);
+ if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ const user = session.username;
+
+ try {
+ const body = await request.json();
+ // 2026-05-04 (code-reviewer P1-2): cap user-controlled fields interpolated
+ // into the Gemini prompt — prompt-injection + cost-amplification surface.
+ const MAX_FIELD = 500;
+ const MAX_CONTEXT = 2000;
+ const target_name = String(body.target_name || '').slice(0, MAX_FIELD);
+ const target_org = String(body.target_org || '').slice(0, MAX_FIELD);
+ const target_type = String(body.target_type || '').slice(0, 50);
+ const template_type = String(body.template_type || '').slice(0, 50);
+ const context = String(body.context || '').slice(0, MAX_CONTEXT);
+
+ const orgId = await resolveOrgId(session);
+ if (!orgId) return NextResponse.json({ error: 'No organization found' }, { status: 400 });
+ const orgRes = await query(
+ 'SELECT id, name, ai_mission, ai_focus_areas, ai_org_summary, city, state, staff_names FROM organizations WHERE id = $1',
+ [orgId],
+ );
+ if (orgRes.rows.length === 0) {
+ return NextResponse.json({ error: 'No organization found' }, { status: 400 });
+ }
+ const org = orgRes.rows[0];
+
+ const typeLabels: Record<string, string> = {
+ meeting_request: 'Meeting Request',
+ one_pager: 'One-Page Organization Summary',
+ thank_you: 'Thank You Letter',
+ follow_up: 'Follow-Up Email',
+ introduction: 'Introduction Email',
+ donation_ask: 'Donation Request',
+ };
+
+ const targetLabels: Record<string, string> = {
+ official: 'Government Official / Elected Representative',
+ corporation: 'Corporate CSR / Partnerships Contact',
+ nonprofit: 'Nonprofit Partner',
+ donor: 'Individual Donor',
+ general: 'General Contact',
+ };
+
+ const prompt = `You are a professional nonprofit communications writer. Generate a ${typeLabels[template_type] || 'Introduction Email'} for outreach to a ${targetLabels[target_type] || 'general contact'}.
+
+SENDER ORGANIZATION:
+- Name: ${org.name}
+- Location: ${org.city}, ${org.state}
+- Mission: ${org.ai_mission}
+- Focus Areas: ${(org.ai_focus_areas || []).join(', ')}
+- Summary: ${org.ai_org_summary}
+- Key Staff: ${(org.staff_names || []).join(', ')}
+
+RECIPIENT:
+- Name: ${target_name || 'N/A'}
+- Organization: ${target_org || 'N/A'}
+- Type: ${targetLabels[target_type] || 'General'}
+
+${context ? `ADDITIONAL CONTEXT: ${context}` : ''}
+
+Return ONLY a JSON object (no markdown, no code fences) with:
+- subject: string (compelling email subject line)
+- body_html: string (professional HTML-formatted email with <p>, <strong>, <ul>, <li> tags. Appropriate tone for the recipient type. Include specific talking points about student debt advocacy and how collaboration benefits both parties.)
+- body_text: string (plain text version)`;
+
+ // 2026-05-05 (tick 16): migrated to lib/gemini.ts wrapper.
+ const result = await callGemini<OutreachShape>({ prompt, maxTokens: 4096 });
+ if (!result.ok) {
+ console.error('[outreach/generate] gemini failed:', result.reason, result.detail);
+ return NextResponse.json(
+ { error: result.reason === 'no_key' ? 'AI service not configured' : 'AI service error' },
+ { status: result.status },
+ );
+ }
+ const generated = result.data;
+
+ // Save as a template
+ const title = `${typeLabels[template_type] || 'Email'} - ${target_name || target_org || 'General'}`;
+ const saveRes = await query(
+ `INSERT INTO outreach_templates (org_id, template_type, target_type, title, subject, body_html, body_text, is_ai_generated)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, true)
+ RETURNING id, org_id, template_type, target_type, title, subject, body_html, body_text, is_ai_generated, version, created_at, updated_at`,
+ [
+ org.id,
+ template_type || 'introduction',
+ target_type || 'general',
+ title,
+ generated.subject || 'Outreach from ' + org.name,
+ generated.body_html || '',
+ generated.body_text || '',
+ ],
+ );
+
+ // 2026-05-05 (architect P2-3): audit AI call.
+ auditLog('outreach.ai_generated', 'outreach_template', saveRes.rows[0].id, org.id, user, {
+ template_type, target_type,
+ input_tokens: result.inputTokens,
+ output_tokens: result.outputTokens,
+ }, request.headers.get('x-forwarded-for') || undefined);
+
+ return NextResponse.json({
+ subject: generated.subject,
+ body_html: generated.body_html,
+ body_text: generated.body_text,
+ template: saveRes.rows[0],
+ });
+ } catch (err) {
+ console.error('[outreach/generate]', err);
+ return NextResponse.json({ error: 'Generation failed' }, { status: 500 });
+ }
+}
diff --git a/app/api/outreach/route.ts b/app/api/outreach/route.ts
new file mode 100644
index 0000000..ce04262
--- /dev/null
+++ b/app/api/outreach/route.ts
@@ -0,0 +1,75 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { query } from '@/lib/db';
+import { verifyAuthWithOrg, resolveOrgId } from '@/lib/auth';
+
+/* ─── GET /api/outreach ───────────────────────────────────────────────────── */
+export async function GET(request: NextRequest) {
+ const session = verifyAuthWithOrg(request);
+ if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+ try {
+ const url = new URL(request.url);
+ const templateType = url.searchParams.get('template_type');
+ const targetType = url.searchParams.get('target_type');
+
+ const orgId = await resolveOrgId(session);
+ if (!orgId) return NextResponse.json({ rows: [] });
+
+ let sql = 'SELECT id, org_id, template_type, target_type, title, subject, body_html, body_text, is_ai_generated, version, created_at, updated_at FROM outreach_templates WHERE org_id = $1';
+ const params: unknown[] = [orgId];
+ let paramIdx = 2;
+
+ if (templateType && templateType !== 'all') {
+ sql += ` AND template_type = $${paramIdx}`;
+ params.push(templateType);
+ paramIdx++;
+ }
+
+ if (targetType && targetType !== 'all') {
+ sql += ` AND target_type = $${paramIdx}`;
+ params.push(targetType);
+ paramIdx++;
+ }
+
+ sql += ' ORDER BY created_at DESC';
+
+ const result = await query(sql, params);
+ return NextResponse.json({ rows: result.rows });
+ } catch (err) {
+ console.error('[outreach GET]', err);
+ return NextResponse.json({ error: 'Failed to fetch templates' }, { status: 500 });
+ }
+}
+
+/* ─── POST /api/outreach ──────────────────────────────────────────────────── */
+export async function POST(request: NextRequest) {
+ const session = verifyAuthWithOrg(request);
+ if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+ try {
+ const body = await request.json();
+ const orgId = await resolveOrgId(session);
+ if (!orgId) return NextResponse.json({ error: 'No organization found' }, { status: 400 });
+
+ const result = await query(
+ `INSERT INTO outreach_templates (org_id, template_type, target_type, title, subject, body_html, body_text, is_ai_generated)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
+ RETURNING id, org_id, template_type, target_type, title, subject, body_html, body_text, is_ai_generated, version, created_at, updated_at`,
+ [
+ orgId,
+ body.template_type || 'introduction',
+ body.target_type || 'general',
+ body.title || 'Untitled Template',
+ body.subject || null,
+ body.body_html || '',
+ body.body_text || '',
+ body.is_ai_generated !== undefined ? body.is_ai_generated : false,
+ ],
+ );
+
+ return NextResponse.json(result.rows[0], { status: 201 });
+ } catch (err) {
+ console.error('[outreach POST]', err);
+ return NextResponse.json({ error: 'Failed to create template' }, { status: 500 });
+ }
+}
diff --git a/app/favicon.ico b/app/favicon.ico
new file mode 100644
index 0000000..718d6fe
Binary files /dev/null and b/app/favicon.ico differ
diff --git a/app/globals.css b/app/globals.css
new file mode 100644
index 0000000..ccab04c
--- /dev/null
+++ b/app/globals.css
@@ -0,0 +1,343 @@
+@import "tailwindcss";
+
+/* ─── Grant Design Tokens ─────────────────────────────────────────────────── */
+:root {
+ /* Backgrounds */
+ --color-bg: #0a0f0d;
+ --color-surface: #0f1712;
+ --color-surface-el: #152019;
+
+ /* Brand */
+ --color-primary: #059669;
+ --color-primary-hover:#047857;
+ --color-secondary: #f59e0b;
+
+ /* Accent shades */
+ --color-accent-light: #34d399;
+ --color-accent-muted: #065f46;
+
+ /* Semantic */
+ --color-success: #22c55e;
+ --color-warning: #f59e0b;
+ --color-error: #ef4444;
+ --color-info: #3b82f6;
+
+ /* Text */
+ --color-text: #f0fdf4;
+ --color-text-secondary: #a7b8b0;
+ --color-text-muted: #6b7f75;
+
+ /* Structure */
+ --color-border: #1e3329;
+
+ /* Radius */
+ --radius-sm: 4px;
+ --radius-md: 8px;
+ --radius-lg: 12px;
+
+ /* Transition */
+ --transition: 150ms ease;
+}
+
+/* ─── Base ───────────────────────────────────────────────────────────────── */
+html {
+ scroll-behavior: smooth;
+ color-scheme: dark;
+}
+
+*,
+*::before,
+*::after {
+ box-sizing: border-box;
+}
+
+body {
+ background-color: var(--color-bg);
+ color: var(--color-text);
+ font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
+ "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ min-height: 100vh;
+}
+
+/* ─── Scrollbar ──────────────────────────────────────────────────────────── */
+::-webkit-scrollbar {
+ width: 6px;
+ height: 6px;
+}
+
+::-webkit-scrollbar-track {
+ background: var(--color-bg);
+}
+
+::-webkit-scrollbar-thumb {
+ background: var(--color-border);
+ border-radius: 3px;
+}
+
+::-webkit-scrollbar-thumb:hover {
+ background: var(--color-text-muted);
+}
+
+/* ─── Cards ──────────────────────────────────────────────────────────────── */
+.card {
+ background-color: var(--color-surface);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-lg);
+ padding: 1rem;
+ transition: border-color var(--transition);
+}
+
+.card:hover {
+ border-color: #2a4a3a;
+}
+
+.card-elevated {
+ background-color: var(--color-surface-el);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-lg);
+ padding: 1rem;
+}
+
+/* ─── Buttons ────────────────────────────────────────────────────────────── */
+.btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.375rem;
+ padding: 0.5rem 1rem;
+ border-radius: var(--radius-md);
+ font-size: 0.875rem;
+ font-weight: 500;
+ line-height: 1.25rem;
+ cursor: pointer;
+ border: 1px solid transparent;
+ transition: background-color var(--transition), color var(--transition),
+ border-color var(--transition), opacity var(--transition);
+ white-space: nowrap;
+ user-select: none;
+}
+
+.btn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.btn:focus-visible {
+ outline: 2px solid var(--color-primary);
+ outline-offset: 2px;
+}
+
+.btn-primary {
+ background-color: var(--color-primary);
+ color: #fff;
+ border-color: var(--color-primary);
+}
+
+.btn-primary:hover:not(:disabled) {
+ background-color: var(--color-primary-hover);
+ border-color: var(--color-primary-hover);
+}
+
+.btn-secondary {
+ background-color: var(--color-surface-el);
+ color: var(--color-text);
+ border-color: var(--color-border);
+}
+
+.btn-secondary:hover:not(:disabled) {
+ background-color: #1a2d22;
+ border-color: #2a4a3a;
+}
+
+.btn-danger {
+ background-color: transparent;
+ color: var(--color-error);
+ border-color: var(--color-error);
+}
+
+.btn-danger:hover:not(:disabled) {
+ background-color: rgba(239, 68, 68, 0.12);
+}
+
+.btn-ghost {
+ background-color: transparent;
+ color: var(--color-text-secondary);
+ border-color: transparent;
+}
+
+.btn-ghost:hover:not(:disabled) {
+ background-color: var(--color-surface-el);
+ color: var(--color-text);
+}
+
+.btn-sm {
+ padding: 0.25rem 0.625rem;
+ font-size: 0.8125rem;
+ border-radius: var(--radius-sm);
+}
+
+.btn-lg {
+ padding: 0.75rem 1.5rem;
+ font-size: 1rem;
+}
+
+/* ─── Badges ─────────────────────────────────────────────────────────────── */
+.badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.25rem;
+ padding: 0.125rem 0.5rem;
+ border-radius: 9999px;
+ font-size: 0.75rem;
+ font-weight: 600;
+ line-height: 1rem;
+ letter-spacing: 0.02em;
+ text-transform: uppercase;
+}
+
+.badge-success {
+ background-color: rgba(34, 197, 94, 0.15);
+ color: var(--color-success);
+ border: 1px solid rgba(34, 197, 94, 0.3);
+}
+
+.badge-warning {
+ background-color: rgba(245, 158, 11, 0.15);
+ color: var(--color-warning);
+ border: 1px solid rgba(245, 158, 11, 0.3);
+}
+
+.badge-error {
+ background-color: rgba(239, 68, 68, 0.15);
+ color: var(--color-error);
+ border: 1px solid rgba(239, 68, 68, 0.3);
+}
+
+.badge-info {
+ background-color: rgba(59, 130, 246, 0.15);
+ color: var(--color-info);
+ border: 1px solid rgba(59, 130, 246, 0.3);
+}
+
+.badge-primary {
+ background-color: rgba(5, 150, 105, 0.15);
+ color: var(--color-accent-light);
+ border: 1px solid rgba(5, 150, 105, 0.3);
+}
+
+/* ─── Form Inputs ────────────────────────────────────────────────────────── */
+.input {
+ width: 100%;
+ background-color: var(--color-surface-el);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-md);
+ padding: 0.5rem 0.75rem;
+ font-size: 0.875rem;
+ color: var(--color-text);
+ outline: none;
+ transition: border-color var(--transition), box-shadow var(--transition);
+}
+
+.input::placeholder {
+ color: var(--color-text-muted);
+}
+
+.input:focus {
+ border-color: var(--color-primary);
+ box-shadow: 0 0 0 3px rgba(5, 150, 105, 0.2);
+}
+
+/* ─── Utility Helpers ────────────────────────────────────────────────────── */
+.divider {
+ border: none;
+ border-top: 1px solid var(--color-border);
+ margin: 0;
+}
+
+.text-primary { color: var(--color-text); }
+.text-secondary { color: var(--color-text-secondary); }
+.text-muted { color: var(--color-text-muted); }
+.text-brand { color: var(--color-primary); }
+.text-gold { color: var(--color-secondary); }
+
+.surface { background-color: var(--color-surface); }
+.surface-el { background-color: var(--color-surface-el); }
+
+/* Toast animation */
+@keyframes toast-in {
+ from { opacity: 0; transform: translateY(12px) scale(0.95); }
+ to { opacity: 1; transform: translateY(0) scale(1); }
+}
+
+/* Deadline pulse */
+@keyframes pulse {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.6; }
+}
+
+/* Loading spinner */
+@keyframes spin {
+ to { transform: rotate(360deg); }
+}
+
+.spinner {
+ display: inline-block;
+ width: 1.25rem;
+ height: 1.25rem;
+ border: 2px solid var(--color-border);
+ border-top-color: var(--color-primary);
+ border-radius: 50%;
+ animation: spin 0.7s linear infinite;
+}
+
+/* ─── Skeleton Loaders ───────────────────────────────────────────────────── */
+.skeleton {
+ background: linear-gradient(90deg, var(--color-surface-el) 25%, var(--color-border) 50%, var(--color-surface-el) 75%);
+ background-size: 200% 100%;
+ animation: skeleton-shimmer 1.5s ease-in-out infinite;
+}
+
+@keyframes skeleton-shimmer {
+ 0% { background-position: 200% 0; }
+ 100% { background-position: -200% 0; }
+}
+
+/* ─── Mobile Responsive Sidebar ──────────────────────────────────────────── */
+.sidebar-backdrop {
+ display: none;
+}
+
+@media (max-width: 768px) {
+ .sidebar-root {
+ position: fixed !important;
+ z-index: 40;
+ height: 100vh;
+ transform: translateX(-100%);
+ transition: transform 0.2s ease, width 0.2s cubic-bezier(0.4, 0, 0.2, 1), min-width 0.2s cubic-bezier(0.4, 0, 0.2, 1);
+ }
+ .sidebar-root.sidebar-open {
+ transform: translateX(0);
+ }
+ #mobile-menu-btn {
+ display: flex !important;
+ }
+ .sidebar-backdrop {
+ display: block;
+ }
+}
+
+/* ─── Stat Cards ─────────────────────────────────────────────────────────── */
+.stat-card {
+ background-color: var(--color-surface);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-lg);
+ padding: 1.25rem;
+ transition: border-color var(--transition), transform var(--transition);
+}
+
+.stat-card:hover {
+ border-color: #2a4a3a;
+ transform: translateY(-1px);
+}
diff --git a/app/icon.svg b/app/icon.svg
new file mode 100644
index 0000000..c9c3429
--- /dev/null
+++ b/app/icon.svg
@@ -0,0 +1,4 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
+<rect width="32" height="32" rx="6" fill="#10b981"/>
+<text x="50%" y="55%" text-anchor="middle" dominant-baseline="middle" font-size="20" font-family="Apple Color Emoji, Segoe UI Emoji, sans-serif" fill="white">$</text>
+</svg>
\ No newline at end of file
diff --git a/app/layout.tsx b/app/layout.tsx
new file mode 100644
index 0000000..aae19cb
--- /dev/null
+++ b/app/layout.tsx
@@ -0,0 +1,21 @@
+import type { Metadata } from 'next';
+import './globals.css';
+
+export const metadata: Metadata = {
+ title: 'Grant - Non-Profit Fundraiser',
+ description: 'AI-powered fundraising platform for non-profit organizations',
+};
+
+export default function RootLayout({
+ children,
+}: Readonly<{
+ children: React.ReactNode;
+}>) {
+ return (
+ <html lang="en" className="dark">
+ <body>
+ <div id="app-root">{children}</div>
+ </body>
+ </html>
+ );
+}
diff --git a/app/login/page.tsx b/app/login/page.tsx
new file mode 100644
index 0000000..95cde53
--- /dev/null
+++ b/app/login/page.tsx
@@ -0,0 +1,161 @@
+'use client';
+
+import { useState, FormEvent } from 'react';
+import { useRouter } from 'next/navigation';
+import { Loader2 } from 'lucide-react';
+
+export default function LoginPage() {
+ const router = useRouter();
+ const [username, setUsername] = useState('');
+ const [password, setPassword] = useState('');
+ const [error, setError] = useState('');
+ const [loading, setLoading] = useState(false);
+
+ async function handleSubmit(e: FormEvent<HTMLFormElement>) {
+ e.preventDefault();
+ setError('');
+ setLoading(true);
+
+ try {
+ const res = await fetch('/api/auth/login', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ username, password }),
+ });
+
+ const data = await res.json();
+
+ if (res.ok && data.success) {
+ router.push('/');
+ router.refresh();
+ } else {
+ setError(data.error ?? 'Login failed. Please try again.');
+ }
+ } catch {
+ setError('Network error. Please try again.');
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ return (
+ <div
+ className="min-h-screen flex items-center justify-center px-4"
+ style={{ backgroundColor: 'var(--color-bg)' }}
+ >
+ <div className="w-full max-w-sm">
+ {/* Logo / Brand mark */}
+ <div className="text-center mb-8">
+ <div
+ className="inline-flex items-center justify-center w-14 h-14 rounded-2xl mb-4"
+ style={{
+ background: 'linear-gradient(135deg, #059669, #047857)',
+ boxShadow: '0 4px 16px rgba(5, 150, 105, 0.3)',
+ }}
+ >
+ <span className="text-2xl font-black text-white">
+ G
+ </span>
+ </div>
+ <h1 className="text-2xl font-bold" style={{ color: 'var(--color-text)' }}>
+ Grant
+ </h1>
+ <p className="mt-1 text-sm" style={{ color: 'var(--color-text-secondary)' }}>
+ Non-Profit Fundraiser -- Sign in to your account
+ </p>
+ </div>
+
+ {/* Card */}
+ <div
+ className="rounded-xl p-6"
+ style={{
+ backgroundColor: 'var(--color-surface)',
+ border: '1px solid var(--color-border)',
+ }}
+ >
+ <form onSubmit={handleSubmit} noValidate className="space-y-4">
+ {/* Error message */}
+ {error && (
+ <div
+ className="px-3 py-2.5 rounded-lg text-sm"
+ style={{
+ backgroundColor: 'rgba(239,68,68,0.1)',
+ border: '1px solid rgba(239,68,68,0.3)',
+ color: 'var(--color-error)',
+ }}
+ role="alert"
+ >
+ {error}
+ </div>
+ )}
+
+ {/* Username */}
+ <div>
+ <label
+ htmlFor="username"
+ className="block text-sm font-medium mb-1.5"
+ style={{ color: 'var(--color-text-secondary)' }}
+ >
+ Username
+ </label>
+ <input
+ id="username"
+ type="text"
+ autoComplete="username"
+ autoFocus
+ required
+ className="input"
+ placeholder="admin"
+ value={username}
+ onChange={(e) => setUsername(e.target.value)}
+ disabled={loading}
+ />
+ </div>
+
+ {/* Password */}
+ <div>
+ <label
+ htmlFor="password"
+ className="block text-sm font-medium mb-1.5"
+ style={{ color: 'var(--color-text-secondary)' }}
+ >
+ Password
+ </label>
+ <input
+ id="password"
+ type="password"
+ autoComplete="current-password"
+ required
+ className="input"
+ placeholder="Enter password"
+ value={password}
+ onChange={(e) => setPassword(e.target.value)}
+ disabled={loading}
+ />
+ </div>
+
+ {/* Submit */}
+ <button
+ type="submit"
+ disabled={loading || !username || !password}
+ className="btn btn-primary btn-lg w-full mt-2"
+ >
+ {loading ? (
+ <>
+ <Loader2 size={16} className="animate-spin" />
+ Signing in...
+ </>
+ ) : (
+ 'Sign In'
+ )}
+ </button>
+ </form>
+ </div>
+
+ <p className="text-center text-xs mt-6" style={{ color: 'var(--color-text-muted)' }}>
+ Grant -- AI-Powered Non-Profit Fundraising Platform
+ </p>
+ </div>
+ </div>
+ );
+}
diff --git a/app/page.tsx b/app/page.tsx
new file mode 100644
index 0000000..c6bdf56
--- /dev/null
+++ b/app/page.tsx
@@ -0,0 +1,5 @@
+import AppShell from '@/components/AppShell';
+
+export default function Home() {
+ return <AppShell />;
+}
diff --git a/app/river/RiverCanvas.tsx b/app/river/RiverCanvas.tsx
new file mode 100644
index 0000000..1384c71
--- /dev/null
+++ b/app/river/RiverCanvas.tsx
@@ -0,0 +1,261 @@
+'use client';
+
+/**
+ * RiverCanvas — SVG visualization of grants as fish swimming toward
+ * close-date. Pure SVG (no canvas) so we get free hover + accessibility.
+ *
+ * 2026-05-05 (tick 37 build).
+ */
+
+import { useState, useMemo, useEffect } from 'react';
+
+type Grant = {
+ id: string;
+ number?: string;
+ title?: string;
+ agencyCode?: string;
+ agency?: string;
+ openDate?: string;
+ closeDate?: string;
+ oppStatus?: string;
+ cfdaList?: string[];
+};
+
+// ---------------------------------------------------------------------------
+// Color by CFDA prefix (47=NSF, 84=Education, 93=HHS, 10=USDA, etc.)
+// ---------------------------------------------------------------------------
+function cfdaColor(cfdaList: string[] | undefined): string {
+ const first = cfdaList?.[0]?.split('.')?.[0] ?? '';
+ const map: Record<string, string> = {
+ '47': '#3b82f6', // NSF — blue
+ '84': '#10b981', // Education — emerald
+ '93': '#ef4444', // HHS — red
+ '10': '#f59e0b', // USDA — amber
+ '15': '#8b5cf6', // Interior — violet
+ '21': '#ec4899', // Treasury — pink
+ '12': '#06b6d4', // Defense — cyan
+ '14': '#fb923c', // HUD — orange
+ '11': '#84cc16', // Commerce — lime
+ '20': '#a855f7', // Transportation — purple
+ };
+ return map[first] ?? '#94a3b8';
+}
+
+// ---------------------------------------------------------------------------
+// Fish silhouette path — agency determines body shape variant
+// ---------------------------------------------------------------------------
+function fishPath(agency: string | undefined, scale: number): string {
+ const s = scale;
+ // Default oval body + tail triangle. Variants tweak proportions.
+ const a = (agency || '').toLowerCase();
+ if (a.includes('nsf') || a.includes('science')) {
+ // sleek trout
+ return `M0,0 Q${10*s},-${4*s} ${20*s},0 Q${10*s},${4*s} 0,0 M${20*s},0 L${28*s},-${5*s} L${28*s},${5*s} Z`;
+ }
+ if (a.includes('hhs') || a.includes('health')) {
+ // round salmon
+ return `M0,0 Q${12*s},-${5*s} ${22*s},0 Q${12*s},${5*s} 0,0 M${22*s},0 L${30*s},-${6*s} L${30*s},${6*s} Z`;
+ }
+ if (a.includes('defense') || a.includes('army') || a.includes('navy')) {
+ // marlin
+ return `M-${4*s},0 L0,-${2*s} Q${14*s},-${4*s} ${24*s},0 Q${14*s},${4*s} 0,${2*s} Z M${24*s},0 L${32*s},-${6*s} L${32*s},${6*s} Z`;
+ }
+ // default catfish
+ return `M0,0 Q${11*s},-${4*s} ${20*s},0 Q${11*s},${4*s} 0,0 M${20*s},0 L${27*s},-${4*s} L${27*s},${4*s} Z`;
+}
+
+// ---------------------------------------------------------------------------
+// Date math
+// ---------------------------------------------------------------------------
+function parseDate(s: string | undefined): Date | null {
+ if (!s) return null;
+ const m = s.match(/^(\d{2})\/(\d{2})\/(\d{4})$/);
+ if (!m) return null;
+ return new Date(`${m[3]}-${m[1]}-${m[2]}T00:00:00Z`);
+}
+
+function daysUntil(d: Date | null): number {
+ if (!d) return 365;
+ return Math.round((d.getTime() - Date.now()) / 86_400_000);
+}
+
+// ---------------------------------------------------------------------------
+// Component
+// ---------------------------------------------------------------------------
+const W = 1400;
+const H = 600;
+const MARGIN = { top: 40, right: 60, bottom: 40, left: 80 };
+const HORIZON_DAYS = 540; // 18 months
+
+export default function RiverCanvas({ grants }: { grants: Grant[] }) {
+ const [hover, setHover] = useState<Grant | null>(null);
+ const [tick, setTick] = useState(0);
+
+ // Slow tick drives the swim animation
+ useEffect(() => {
+ const id = setInterval(() => setTick((t) => t + 1), 80);
+ return () => clearInterval(id);
+ }, []);
+
+ // Layout grants
+ const fishes = useMemo(() => {
+ return grants.map((g) => {
+ const close = parseDate(g.closeDate);
+ const days = daysUntil(close);
+ // X: today=margin.left, +18mo = W - margin.right. Past-deadline = beyond right.
+ const xFrac = Math.min(1, Math.max(-0.05, days / HORIZON_DAYS));
+ const x = MARGIN.left + xFrac * (W - MARGIN.left - MARGIN.right);
+ // Y: hash by id for spread (we have no $ in search2 response, use deterministic distribution)
+ const hash = [...g.id].reduce((a, c) => a + c.charCodeAt(0), 0);
+ const yFrac = (hash % 100) / 100;
+ const y = MARGIN.top + yFrac * (H - MARGIN.top - MARGIN.bottom);
+ // Urgency: closer to deadline = brighter glow + faster wiggle
+ const urgency = Math.max(0, Math.min(1, 1 - days / 90));
+ return { g, x, y, urgency, days };
+ });
+ }, [grants]);
+
+ return (
+ <div style={{ position: 'relative', width: '100%', overflow: 'hidden' }}>
+ <svg viewBox={`0 0 ${W} ${H}`} width="100%" style={{ display: 'block' }}>
+ {/* water gradient */}
+ <defs>
+ <linearGradient id="water" x1="0" y1="0" x2="0" y2="1">
+ <stop offset="0" stopColor="#0a1f1c" />
+ <stop offset="1" stopColor="#020807" />
+ </linearGradient>
+ <linearGradient id="surface" x1="0" y1="0" x2="1" y2="0">
+ <stop offset="0" stopColor="#1f2925" stopOpacity="0" />
+ <stop offset="0.5" stopColor="#1f2925" />
+ <stop offset="1" stopColor="#1f2925" stopOpacity="0" />
+ </linearGradient>
+ <radialGradient id="urgent">
+ <stop offset="0" stopColor="#10b981" stopOpacity="0.6" />
+ <stop offset="1" stopColor="#10b981" stopOpacity="0" />
+ </radialGradient>
+ </defs>
+ <rect x="0" y="0" width={W} height={H} fill="url(#water)" />
+
+ {/* time axis */}
+ <line
+ x1={MARGIN.left} y1={H - MARGIN.bottom + 10}
+ x2={W - MARGIN.right} y2={H - MARGIN.bottom + 10}
+ stroke="url(#surface)" strokeWidth="1"
+ />
+ {[0, 90, 180, 270, 360, 450, 540].map((d) => {
+ const x = MARGIN.left + (d / HORIZON_DAYS) * (W - MARGIN.left - MARGIN.right);
+ return (
+ <g key={d}>
+ <line x1={x} y1={MARGIN.top} x2={x} y2={H - MARGIN.bottom + 8}
+ stroke="#1f2925" strokeDasharray="2,4" strokeWidth="0.5" />
+ <text x={x} y={H - MARGIN.bottom + 24} textAnchor="middle"
+ fill="#6c7d75" fontSize="10" fontFamily="ui-monospace, monospace">
+ {d === 0 ? 'today' : `+${d}d`}
+ </text>
+ </g>
+ );
+ })}
+ {/* danger zone (next 14 days) */}
+ <rect
+ x={MARGIN.left} y={MARGIN.top}
+ width={(14 / HORIZON_DAYS) * (W - MARGIN.left - MARGIN.right)}
+ height={H - MARGIN.top - MARGIN.bottom}
+ fill="#ef4444" opacity="0.04"
+ />
+
+ {/* fish */}
+ {fishes.map(({ g, x, y, urgency, days }) => {
+ // Wiggle: faster + bigger as urgency rises
+ const wiggleAmp = 1 + urgency * 4;
+ const wigglePeriod = 20 - urgency * 12;
+ const wiggle = Math.sin((tick + g.id.length * 7) / wigglePeriod) * wiggleAmp;
+ const fx = x + Math.sin(tick / 30 + g.id.length) * 1.2;
+ const fy = y + wiggle;
+ const color = cfdaColor(g.cfdaList);
+ const scale = 1 + urgency * 0.35;
+ const isPast = days < 0;
+ const opacity = isPast ? 0.18 : 1;
+ const isHover = hover?.id === g.id;
+ return (
+ <g
+ key={g.id}
+ transform={`translate(${fx} ${fy})`}
+ onMouseEnter={() => setHover(g)}
+ onMouseLeave={() => setHover(null)}
+ onClick={() => window.open(`https://www.grants.gov/search-results-detail/${g.id}`, '_blank')}
+ style={{ cursor: 'pointer' }}
+ opacity={opacity}
+ >
+ {urgency > 0.6 && (
+ <circle cx={10} cy={0} r={28 * scale} fill="url(#urgent)" />
+ )}
+ {isHover && (
+ <circle cx={10} cy={0} r={22 * scale} fill="none" stroke={color}
+ strokeWidth="1.5" strokeOpacity="0.8" />
+ )}
+ <path
+ d={fishPath(g.agency, scale)}
+ fill={color}
+ opacity={0.85}
+ />
+ <circle cx={4 * scale} cy={-1 * scale} r={1.2 * scale} fill="#0a1f1c" />
+ </g>
+ );
+ })}
+ </svg>
+
+ {/* hover detail */}
+ {hover && (
+ <div style={{
+ position: 'absolute', top: 16, left: 16, maxWidth: 420,
+ background: '#0f1311', border: `1px solid ${cfdaColor(hover.cfdaList)}`,
+ borderRadius: 8, padding: '14px 16px', pointerEvents: 'none',
+ boxShadow: '0 4px 24px rgba(0,0,0,0.4)',
+ }}>
+ <div style={{ fontSize: 13, fontWeight: 600, color: '#d8e8df', marginBottom: 4 }}>
+ {hover.title || 'Untitled'}
+ </div>
+ <div style={{ fontSize: 11, color: '#6c7d75', display: 'flex', gap: 12 }}>
+ <span>{hover.agency || hover.agencyCode || 'Federal'}</span>
+ <span>· #{hover.number || '—'}</span>
+ <span>· CFDA {hover.cfdaList?.join(', ') || '—'}</span>
+ </div>
+ <div style={{ fontSize: 11, color: '#6c7d75', marginTop: 6, display: 'flex', gap: 12 }}>
+ <span>opens: {hover.openDate || '—'}</span>
+ <span>closes: <strong style={{ color: '#d8e8df' }}>{hover.closeDate || '—'}</strong></span>
+ <span>· {hover.oppStatus}</span>
+ </div>
+ <div style={{ fontSize: 10, color: cfdaColor(hover.cfdaList), marginTop: 8 }}>
+ click fish to open grants.gov
+ </div>
+ </div>
+ )}
+
+ {/* 2026-05-05 (tick 42): legend in bottom-right */}
+ {!hover && (
+ <div style={{
+ position: 'absolute', bottom: 12, right: 12, padding: '12px 14px',
+ background: 'rgba(15,19,17,0.92)', border: '1px solid #1f2925',
+ borderRadius: 6, fontSize: 10, color: '#6c7d75', maxWidth: 220,
+ lineHeight: 1.5,
+ }}>
+ <div style={{ color: '#d8e8df', marginBottom: 6, fontWeight: 600, letterSpacing: '0.05em', textTransform: 'uppercase', fontSize: 9 }}>
+ Legend
+ </div>
+ <div style={{ display: 'grid', gridTemplateColumns: '14px 1fr', gap: '4px 8px', alignItems: 'center' }}>
+ <span style={{ width: 12, height: 5, background: '#3b82f6', borderRadius: 3 }} /> NSF (47.x)
+ <span style={{ width: 12, height: 5, background: '#10b981', borderRadius: 3 }} /> Education (84.x)
+ <span style={{ width: 12, height: 5, background: '#ef4444', borderRadius: 3 }} /> HHS (93.x)
+ <span style={{ width: 12, height: 5, background: '#f59e0b', borderRadius: 3 }} /> USDA (10.x)
+ <span style={{ width: 12, height: 5, background: '#06b6d4', borderRadius: 3 }} /> Defense (12.x)
+ <span style={{ width: 12, height: 5, background: '#94a3b8', borderRadius: 3 }} /> other
+ </div>
+ <div style={{ marginTop: 8, paddingTop: 8, borderTop: '1px solid #1f2925' }}>
+ silhouette → agency · glow → urgency<br />
+ past-deadline fish drift right (faded)
+ </div>
+ </div>
+ )}
+ </div>
+ );
+}
diff --git a/app/river/RiverTable.tsx b/app/river/RiverTable.tsx
new file mode 100644
index 0000000..3beaa59
--- /dev/null
+++ b/app/river/RiverTable.tsx
@@ -0,0 +1,141 @@
+'use client';
+
+/**
+ * RiverTable — sortable list view of the same grants the river renders.
+ *
+ * 2026-05-06 (tick 52): client component with click-to-sort column headers.
+ * No external dep, just useState for sort state.
+ */
+
+import { useState, useMemo } from 'react';
+
+type Grant = {
+ id: string;
+ number?: string;
+ title?: string;
+ agencyCode?: string;
+ agency?: string;
+ openDate?: string;
+ closeDate?: string;
+ oppStatus?: string;
+ cfdaList?: string[];
+};
+
+type SortKey = 'title' | 'agency' | 'openDate' | 'closeDate' | 'oppStatus' | 'cfda';
+type SortDir = 'asc' | 'desc';
+
+function parseDate(s: string | undefined): number {
+ if (!s) return Number.MAX_SAFE_INTEGER;
+ const m = s.match(/^(\d{2})\/(\d{2})\/(\d{4})$/);
+ if (!m) return Number.MAX_SAFE_INTEGER;
+ return new Date(`${m[3]}-${m[1]}-${m[2]}T00:00:00Z`).getTime();
+}
+
+function compare(a: Grant, b: Grant, key: SortKey, dir: SortDir): number {
+ let av: string | number = '';
+ let bv: string | number = '';
+ switch (key) {
+ case 'title': av = (a.title || '').toLowerCase(); bv = (b.title || '').toLowerCase(); break;
+ case 'agency': av = (a.agency || a.agencyCode || '').toLowerCase(); bv = (b.agency || b.agencyCode || '').toLowerCase(); break;
+ case 'openDate': av = parseDate(a.openDate); bv = parseDate(b.openDate); break;
+ case 'closeDate': av = parseDate(a.closeDate); bv = parseDate(b.closeDate); break;
+ case 'oppStatus': av = a.oppStatus || ''; bv = b.oppStatus || ''; break;
+ case 'cfda': av = (a.cfdaList?.[0] || ''); bv = (b.cfdaList?.[0] || ''); break;
+ }
+ if (av < bv) return dir === 'asc' ? -1 : 1;
+ if (av > bv) return dir === 'asc' ? 1 : -1;
+ return 0;
+}
+
+export default function RiverTable({ grants }: { grants: Grant[] }) {
+ const [sortKey, setSortKey] = useState<SortKey>('closeDate');
+ const [sortDir, setSortDir] = useState<SortDir>('asc');
+
+ const sorted = useMemo(() => {
+ return [...grants].sort((a, b) => compare(a, b, sortKey, sortDir));
+ }, [grants, sortKey, sortDir]);
+
+ function clickSort(k: SortKey) {
+ if (k === sortKey) {
+ setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
+ } else {
+ setSortKey(k);
+ setSortDir('asc');
+ }
+ }
+
+ function arrow(k: SortKey) {
+ if (k !== sortKey) return <span style={{ opacity: 0.25 }}> ↕</span>;
+ return <span style={{ color: '#10b981' }}> {sortDir === 'asc' ? '↑' : '↓'}</span>;
+ }
+
+ const TH: React.CSSProperties = {
+ padding: '10px 14px', textAlign: 'left', fontSize: 11,
+ fontWeight: 600, letterSpacing: '0.05em', textTransform: 'uppercase',
+ color: '#6c7d75', background: '#0a0f0d', borderBottom: '1px solid #1f2925',
+ cursor: 'pointer', userSelect: 'none' as const, whiteSpace: 'nowrap' as const,
+ };
+ const TD: React.CSSProperties = {
+ padding: '10px 14px', borderBottom: '1px solid #1f2925',
+ fontSize: 13, color: '#d8e8df', verticalAlign: 'top' as const,
+ };
+
+ return (
+ <div style={{ padding: '20px 28px 40px', background: '#07090a' }}>
+ <div style={{ marginBottom: 12, fontSize: 11, color: '#6c7d75',
+ textTransform: 'uppercase', letterSpacing: '0.06em', fontWeight: 600 }}>
+ Grid view · {sorted.length} opportunities · click any column to sort
+ </div>
+ <div style={{ overflowX: 'auto', border: '1px solid #1f2925', borderRadius: 8 }}>
+ <table style={{ width: '100%', borderCollapse: 'collapse', background: '#0f1311' }}>
+ <thead>
+ <tr>
+ <th style={TH} onClick={() => clickSort('title')}>opportunity{arrow('title')}</th>
+ <th style={TH} onClick={() => clickSort('agency')}>agency{arrow('agency')}</th>
+ <th style={TH} onClick={() => clickSort('cfda')}>CFDA{arrow('cfda')}</th>
+ <th style={TH} onClick={() => clickSort('openDate')}>opens{arrow('openDate')}</th>
+ <th style={TH} onClick={() => clickSort('closeDate')}>closes{arrow('closeDate')}</th>
+ <th style={TH} onClick={() => clickSort('oppStatus')}>status{arrow('oppStatus')}</th>
+ </tr>
+ </thead>
+ <tbody>
+ {sorted.map((g) => (
+ <tr key={g.id} style={{ transition: 'background 0.1s' }}>
+ <td style={TD}>
+ <a
+ href={`https://www.grants.gov/search-results-detail/${g.id}`}
+ target="_blank" rel="noopener"
+ style={{ color: '#d8e8df', textDecoration: 'none', fontWeight: 500 }}
+ >
+ {g.title || 'Untitled'}
+ </a>
+ <div style={{ color: '#6c7d75', fontSize: 11, marginTop: 2 }}>
+ #{g.number || '—'}
+ </div>
+ </td>
+ <td style={TD}>{g.agency || g.agencyCode || '—'}</td>
+ <td style={{ ...TD, fontFamily: 'ui-monospace, monospace', fontSize: 12 }}>
+ {g.cfdaList?.join(', ') || '—'}
+ </td>
+ <td style={{ ...TD, fontFamily: 'ui-monospace, monospace', fontSize: 12, color: '#6c7d75' }}>
+ {g.openDate || '—'}
+ </td>
+ <td style={{ ...TD, fontFamily: 'ui-monospace, monospace', fontSize: 12 }}>
+ {g.closeDate || '—'}
+ </td>
+ <td style={TD}>
+ <span style={{
+ fontSize: 10, padding: '2px 8px', borderRadius: 99,
+ fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.04em',
+ background: g.oppStatus === 'posted' ? '#064e3b' : '#1e293b',
+ color: g.oppStatus === 'posted' ? '#10b981' : '#93c5fd',
+ }}>{g.oppStatus || '—'}</span>
+ </td>
+ </tr>
+ ))}
+ </tbody>
+ </table>
+ </div>
+ </div>
+ );
+}
diff --git a/app/river/page.tsx b/app/river/page.tsx
new file mode 100644
index 0000000..fa8bff4
--- /dev/null
+++ b/app/river/page.tsx
@@ -0,0 +1,109 @@
+/**
+ * /river — "Deadline River" public visualization of federal grant
+ * opportunities as fish swimming toward their close-date.
+ *
+ * 2026-05-05 (tick 37): server component fetches Grants.gov public API
+ * directly (no auth), passes the rows to the client canvas. No new API
+ * surface added — the existing /api/grants/discover-real is auth-gated by
+ * middleware, this route bypasses by going straight to the upstream.
+ */
+
+import RiverCanvas from './RiverCanvas';
+import RiverTable from './RiverTable';
+
+export const dynamic = 'force-dynamic';
+export const revalidate = 600; // 10 min cache
+
+type GrantsGovHit = {
+ id: string;
+ number?: string;
+ title?: string;
+ agencyCode?: string;
+ agency?: string;
+ openDate?: string;
+ closeDate?: string;
+ oppStatus?: string;
+ cfdaList?: string[];
+};
+
+async function fetchGrants(keyword = 'education'): Promise<GrantsGovHit[]> {
+ try {
+ const res = await fetch('https://api.grants.gov/v1/api/search2', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ keyword, oppStatuses: 'forecasted|posted', rows: 80 }),
+ signal: AbortSignal.timeout(15_000),
+ next: { revalidate: 600 },
+ });
+ if (!res.ok) return [];
+ const json = await res.json();
+ if (json.errorcode !== 0) return [];
+ return json.data?.oppHits ?? [];
+ } catch {
+ return [];
+ }
+}
+
+export default async function RiverPage({
+ searchParams,
+}: {
+ searchParams: Promise<{ q?: string }>;
+}) {
+ const { q } = await searchParams;
+ const keyword = (q || 'education').slice(0, 80);
+ const grants = await fetchGrants(keyword);
+
+ return (
+ <main style={{
+ background: '#07090a',
+ color: '#d8e8df',
+ fontFamily: '-apple-system, BlinkMacSystemFont, system-ui, sans-serif',
+ minHeight: '100vh',
+ padding: 0, margin: 0,
+ }}>
+ <header style={{
+ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between',
+ padding: '20px 28px', borderBottom: '1px solid #1f2925',
+ }}>
+ <div>
+ <h1 style={{ margin: 0, fontSize: 22, fontWeight: 600, letterSpacing: '-0.01em' }}>
+ <span style={{ color: '#10b981' }}>~</span>~ Deadline River
+ <span style={{ color: '#6c7d75', fontWeight: 400, fontSize: 14, marginLeft: 12 }}>
+ {grants.length} federal grant opportunities · keyword <code style={{
+ background: '#111', padding: '1px 6px', borderRadius: 3, color: '#d8e8df',
+ }}>{keyword}</code>
+ </span>
+ </h1>
+ </div>
+ <form method="get" style={{ display: 'flex', gap: 8 }}>
+ <input
+ name="q"
+ defaultValue={keyword}
+ placeholder="keyword…"
+ style={{
+ background: '#0f1311', color: '#d8e8df', border: '1px solid #1f2925',
+ borderRadius: 6, padding: '6px 12px', fontSize: 13, width: 200,
+ fontFamily: 'inherit',
+ }}
+ />
+ <button type="submit" style={{
+ background: '#064e3b', color: '#10b981', border: '1px solid #10b981',
+ borderRadius: 6, padding: '6px 16px', fontSize: 13, cursor: 'pointer',
+ fontWeight: 500,
+ }}>search</button>
+ </form>
+ </header>
+
+ <RiverCanvas grants={grants} />
+
+ <RiverTable grants={grants} />
+
+ <footer style={{
+ padding: '16px 28px', borderTop: '1px solid #1f2925', color: '#6c7d75',
+ fontSize: 11, lineHeight: 1.7,
+ }}>
+ Each fish = a real federal grant opportunity. <strong style={{ color: '#d8e8df' }}>X</strong> = time-to-deadline (today→18mo). <strong style={{ color: '#d8e8df' }}>Y</strong> = funding amount (log scale). <strong style={{ color: '#d8e8df' }}>color</strong> = CFDA category prefix. <strong style={{ color: '#d8e8df' }}>silhouette</strong> = agency. Glow + speed scale with deadline urgency. Hover for detail · click to open grants.gov.
+ </footer>
+ </main>
+ );
+}
diff --git a/components/AppShell.tsx b/components/AppShell.tsx
new file mode 100644
index 0000000..66ca0f3
--- /dev/null
+++ b/components/AppShell.tsx
@@ -0,0 +1,223 @@
+'use client';
+
+import { useState } from 'react';
+import { LogOut, Menu, LayoutGrid } from 'lucide-react';
+import { AuthProvider, useAuth } from './AuthProvider';
+import { ToastProvider } from './ToastProvider';
+import ErrorBoundary from './ErrorBoundary';
+import Sidebar, { type TabId } from './Sidebar';
+import DashboardTab from './dashboard/DashboardTab';
+import GrantsTab from './grants/GrantsTab';
+import NewsTab from './news/NewsTab';
+import CollaborationsTab from './collaborations/CollaborationsTab';
+import OutreachTab from './outreach/OutreachTab';
+import SettingsTab from './settings/SettingsTab';
+
+const APP_LINKS = [
+ { name: 'Hub', port: 7480, color: '#0891b2', icon: 'H' },
+ { name: 'SDCC', port: 7400, color: '#3b82f6', icon: 'S' },
+ { name: 'Grant', port: 7450, color: '#059669', icon: 'G' },
+ { name: 'Patty', port: 7460, color: '#7c3aed', icon: 'P' },
+ { name: 'Freddy', port: 7470, color: '#d97706', icon: 'F' },
+];
+const CURRENT_PORT = 7450;
+
+/* ─── Inner shell (needs auth context) ──────────────────────────────────── */
+function Shell() {
+ const { user, logout } = useAuth();
+ const [activeTab, setActiveTab] = useState<TabId>('dashboard');
+ const [sidebarOpen, setSidebarOpen] = useState(false);
+ const [showAppSwitcher, setShowAppSwitcher] = useState(false);
+
+ function handleTabChange(tab: TabId) {
+ setActiveTab(tab);
+ setSidebarOpen(false);
+ }
+
+ function renderPanel() {
+ switch (activeTab) {
+ case 'dashboard': return <DashboardTab onNavigate={(tab) => handleTabChange(tab as TabId)} />;
+ case 'grants': return <GrantsTab />;
+ case 'news': return <NewsTab />;
+ case 'collaborations': return <CollaborationsTab />;
+ case 'outreach': return <OutreachTab />;
+ case 'settings': return <SettingsTab />;
+ }
+ }
+
+ /* Tab label for the top bar */
+ const TAB_LABELS: Record<TabId, string> = {
+ dashboard: 'Dashboard',
+ grants: 'Grant Discovery & Tracking',
+ news: 'News Monitoring',
+ collaborations: 'Collaborations',
+ outreach: 'Outreach Tools',
+ settings: 'Settings',
+ };
+
+ return (
+ <div
+ className="flex"
+ style={{ backgroundColor: 'var(--color-bg)', minHeight: '100vh' }}
+ >
+ {/* ── Left Sidebar ─────────────────────────────────────────────────── */}
+ <Sidebar activeTab={activeTab} onTabChange={handleTabChange} isOpen={sidebarOpen} onToggle={() => setSidebarOpen(p => !p)} />
+
+ {/* ── Right: Header + Content ──────────────────────────────────────── */}
+ <div className="flex flex-col flex-1 min-w-0">
+ {/* ── Top Header Bar ─────────────────────────────────────────────── */}
+ <header
+ className="flex items-center justify-between px-5 h-14 shrink-0"
+ style={{
+ backgroundColor: 'var(--color-surface)',
+ borderBottom: '1px solid var(--color-border)',
+ }}
+ >
+ {/* Left: hamburger + section name */}
+ <div className="flex items-center gap-2">
+ <button
+ className="btn btn-ghost btn-sm"
+ onClick={() => setSidebarOpen(p => !p)}
+ style={{ display: 'none' }}
+ aria-label="Toggle menu"
+ id="mobile-menu-btn"
+ >
+ <Menu size={18} />
+ </button>
+ <h1
+ className="text-sm font-semibold"
+ style={{ color: 'var(--color-text)' }}
+ >
+ {TAB_LABELS[activeTab]}
+ </h1>
+ </div>
+
+ {/* Right: app switcher + user + logout */}
+ <div className="flex items-center gap-3">
+ {/* App Switcher */}
+ <div style={{ position: 'relative' }}>
+ <button
+ className="btn btn-ghost btn-sm"
+ onClick={() => setShowAppSwitcher(p => !p)}
+ title="Switch app"
+ aria-label="Switch app"
+ >
+ <LayoutGrid size={16} />
+ </button>
+ {showAppSwitcher && (
+ <>
+ <div
+ style={{ position: 'fixed', inset: 0, zIndex: 49 }}
+ onClick={() => setShowAppSwitcher(false)}
+ />
+ <div
+ style={{
+ position: 'absolute',
+ top: '100%',
+ right: 0,
+ marginTop: 8,
+ padding: 8,
+ borderRadius: 12,
+ backgroundColor: 'var(--color-surface)',
+ border: '1px solid var(--color-border)',
+ boxShadow: '0 8px 24px rgba(0,0,0,0.4)',
+ zIndex: 50,
+ minWidth: 200,
+ }}
+ >
+ <div style={{ padding: '4px 8px 8px', fontSize: 10, fontWeight: 600, color: 'var(--color-text-muted)', letterSpacing: '0.06em', textTransform: 'uppercase' }}>
+ SDCC Ecosystem
+ </div>
+ {APP_LINKS.map((app) => (
+ <a
+ key={app.name}
+ href={`http://45.61.58.125:${app.port}`}
+ target="_blank"
+ rel="noopener noreferrer"
+ style={{
+ display: 'flex',
+ alignItems: 'center',
+ gap: 10,
+ padding: '8px 10px',
+ borderRadius: 8,
+ textDecoration: 'none',
+ color: 'var(--color-text)',
+ fontSize: '0.8125rem',
+ fontWeight: 500,
+ transition: 'background-color 0.15s',
+ borderLeft: app.port === CURRENT_PORT ? `3px solid ${app.color}` : '3px solid transparent',
+ }}
+ onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = 'var(--color-surface-el)'; }}
+ onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = 'transparent'; }}
+ onClick={() => setShowAppSwitcher(false)}
+ >
+ <div
+ style={{
+ width: 28,
+ height: 28,
+ borderRadius: 8,
+ background: `linear-gradient(135deg, ${app.color}, ${app.color}dd)`,
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ color: '#fff',
+ fontSize: 12,
+ fontWeight: 800,
+ flexShrink: 0,
+ }}
+ >
+ {app.icon}
+ </div>
+ <div>
+ <div style={{ lineHeight: 1.2 }}>{app.name}</div>
+ <div style={{ fontSize: 10, color: 'var(--color-text-muted)', lineHeight: 1.2 }}>
+ Port {app.port}
+ </div>
+ </div>
+ </a>
+ ))}
+ </div>
+ </>
+ )}
+ </div>
+ {user && (
+ <span
+ className="text-xs hidden sm:inline"
+ style={{ color: 'var(--color-text-muted)' }}
+ >
+ {user}
+ </span>
+ )}
+ <button
+ onClick={logout}
+ className="btn btn-ghost btn-sm"
+ aria-label="Sign out"
+ title="Sign out"
+ >
+ <LogOut size={15} aria-hidden="true" />
+ <span className="hidden sm:inline">Sign out</span>
+ </button>
+ </div>
+ </header>
+
+ {/* ── Main Content ───────────────────────────────────────────────── */}
+ <main className="flex-1 overflow-auto">
+ <ErrorBoundary>
+ {renderPanel()}
+ </ErrorBoundary>
+ </main>
+ </div>
+ </div>
+ );
+}
+
+/* ─── Public export -- wraps Shell in AuthProvider ────────────────────────── */
+export default function AppShell() {
+ return (
+ <AuthProvider>
+ <ToastProvider>
+ <Shell />
+ </ToastProvider>
+ </AuthProvider>
+ );
+}
diff --git a/components/AuthProvider.tsx b/components/AuthProvider.tsx
new file mode 100644
index 0000000..a003eb7
--- /dev/null
+++ b/components/AuthProvider.tsx
@@ -0,0 +1,132 @@
+'use client';
+
+import {
+ createContext,
+ useContext,
+ useEffect,
+ useState,
+ useCallback,
+ ReactNode,
+} from 'react';
+import { useRouter, usePathname } from 'next/navigation';
+import { Loader2 } from 'lucide-react';
+
+/* ─── Types ──────────────────────────────────────────────────────────────── */
+interface AuthContextValue {
+ user: string | null;
+ loading: boolean;
+ login: (username: string, password: string) => Promise<{ error?: string }>;
+ logout: () => Promise<void>;
+}
+
+/* ─── Context ────────────────────────────────────────────────────────────── */
+const AuthContext = createContext<AuthContextValue | null>(null);
+
+/* ─── Provider ───────────────────────────────────────────────────────────── */
+export function AuthProvider({ children }: { children: ReactNode }) {
+ const router = useRouter();
+ const pathname = usePathname();
+
+ const [user, setUser] = useState<string | null>(null);
+ const [loading, setLoading] = useState(true);
+
+ /* Check session on mount */
+ useEffect(() => {
+ let cancelled = false;
+
+ async function checkSession() {
+ try {
+ const res = await fetch('/api/auth/session', { credentials: 'include' });
+ if (!cancelled) {
+ if (res.ok) {
+ const data = await res.json();
+ setUser(data.authenticated ? data.user : null);
+ } else {
+ setUser(null);
+ }
+ }
+ } catch {
+ if (!cancelled) setUser(null);
+ } finally {
+ if (!cancelled) setLoading(false);
+ }
+ }
+
+ checkSession();
+ return () => { cancelled = true; };
+ }, []);
+
+ /* Redirect unauthenticated users away from protected routes */
+ useEffect(() => {
+ if (loading) return;
+ if (!user && pathname !== '/login') {
+ router.replace('/login');
+ }
+ }, [user, loading, pathname, router]);
+
+ /* Login */
+ const login = useCallback(async (username: string, password: string) => {
+ const res = await fetch('/api/auth/login', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ body: JSON.stringify({ username, password }),
+ });
+
+ const data = await res.json();
+
+ if (res.ok && data.success) {
+ setUser(username);
+ router.push('/');
+ router.refresh();
+ return {};
+ }
+
+ return { error: data.error ?? 'Login failed' };
+ }, [router]);
+
+ /* Logout */
+ const logout = useCallback(async () => {
+ await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' });
+ setUser(null);
+ router.replace('/login');
+ }, [router]);
+
+ /* Loading screen -- shown on every route while session is being verified */
+ if (loading) {
+ return (
+ <div
+ className="min-h-screen flex flex-col items-center justify-center gap-3"
+ style={{ backgroundColor: 'var(--color-bg)' }}
+ >
+ <Loader2
+ size={28}
+ className="animate-spin"
+ style={{ color: 'var(--color-primary)' }}
+ aria-hidden="true"
+ />
+ <span
+ className="text-sm"
+ style={{ color: 'var(--color-text-secondary)' }}
+ >
+ Loading...
+ </span>
+ </div>
+ );
+ }
+
+ return (
+ <AuthContext.Provider value={{ user, loading, login, logout }}>
+ {children}
+ </AuthContext.Provider>
+ );
+}
+
+/* ─── Hook ───────────────────────────────────────────────────────────────── */
+export function useAuth(): AuthContextValue {
+ const ctx = useContext(AuthContext);
+ if (!ctx) {
+ throw new Error('useAuth must be used inside <AuthProvider>');
+ }
+ return ctx;
+}
diff --git a/components/ErrorBoundary.tsx b/components/ErrorBoundary.tsx
new file mode 100644
index 0000000..74a06bd
--- /dev/null
+++ b/components/ErrorBoundary.tsx
@@ -0,0 +1,126 @@
+'use client';
+
+import { Component, ErrorInfo, ReactNode } from 'react';
+import { AlertTriangle, RefreshCw } from 'lucide-react';
+
+interface ErrorBoundaryProps {
+ children: ReactNode;
+}
+
+interface ErrorBoundaryState {
+ hasError: boolean;
+ error: Error | null;
+}
+
+export default class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
+ constructor(props: ErrorBoundaryProps) {
+ super(props);
+ this.state = { hasError: false, error: null };
+ }
+
+ static getDerivedStateFromError(error: Error): ErrorBoundaryState {
+ return { hasError: true, error };
+ }
+
+ componentDidCatch(error: Error, errorInfo: ErrorInfo) {
+ // Log error for debugging -- could be sent to an error reporting service
+ if (typeof window !== 'undefined') {
+ console.error('ErrorBoundary caught:', error, errorInfo);
+ }
+ }
+
+ handleReload = () => {
+ window.location.reload();
+ };
+
+ handleReset = () => {
+ this.setState({ hasError: false, error: null });
+ };
+
+ render() {
+ if (this.state.hasError) {
+ return (
+ <div
+ className="flex flex-col items-center justify-center p-8 text-center"
+ style={{
+ minHeight: '60vh',
+ backgroundColor: 'var(--color-bg)',
+ }}
+ >
+ <div
+ className="w-16 h-16 rounded-2xl flex items-center justify-center mb-6"
+ style={{
+ backgroundColor: 'rgba(239, 68, 68, 0.12)',
+ border: '1px solid rgba(239, 68, 68, 0.25)',
+ }}
+ >
+ <AlertTriangle size={28} style={{ color: '#f87171' }} />
+ </div>
+
+ <h2
+ className="text-lg font-bold mb-2"
+ style={{ color: 'var(--color-text)' }}
+ >
+ Something went wrong
+ </h2>
+ <p
+ className="text-sm max-w-md mb-6 leading-relaxed"
+ style={{ color: 'var(--color-text-muted)' }}
+ >
+ An unexpected error occurred while rendering this section.
+ You can try reloading the page or resetting the view.
+ </p>
+
+ {/* Error details (collapsed by default for dev) */}
+ {this.state.error && (
+ <details
+ className="mb-6 w-full max-w-lg text-left"
+ style={{
+ backgroundColor: 'var(--color-surface)',
+ border: '1px solid var(--color-border)',
+ borderRadius: 10,
+ padding: '12px 16px',
+ }}
+ >
+ <summary
+ className="text-xs font-medium cursor-pointer"
+ style={{ color: 'var(--color-text-secondary)' }}
+ >
+ Error details
+ </summary>
+ <pre
+ className="mt-2 text-xs overflow-auto"
+ style={{
+ color: '#f87171',
+ whiteSpace: 'pre-wrap',
+ wordBreak: 'break-word',
+ maxHeight: 160,
+ }}
+ >
+ {this.state.error.message}
+ </pre>
+ </details>
+ )}
+
+ <div className="flex gap-3">
+ <button
+ className="btn btn-secondary"
+ onClick={this.handleReset}
+ >
+ Try Again
+ </button>
+ <button
+ className="btn btn-primary"
+ onClick={this.handleReload}
+ >
+ <RefreshCw size={15} />
+ Reload Page
+ </button>
+ </div>
+ </div>
+ );
+ }
+
+ return this.props.children;
+ }
+}
diff --git a/components/Sidebar.tsx b/components/Sidebar.tsx
new file mode 100644
index 0000000..9cf5f3c
--- /dev/null
+++ b/components/Sidebar.tsx
@@ -0,0 +1,322 @@
+'use client';
+
+import { useState } from 'react';
+import {
+ LayoutDashboard,
+ Award,
+ Newspaper,
+ Users,
+ Megaphone,
+ Settings,
+ ChevronLeft,
+ ChevronRight,
+} from 'lucide-react';
+
+/* ─── Types ──────────────────────────────────────────────────────────────── */
+export type TabId =
+ | 'dashboard'
+ | 'grants'
+ | 'news'
+ | 'collaborations'
+ | 'outreach'
+ | 'settings';
+
+interface NavItem {
+ id: TabId;
+ label: string;
+ Icon: React.ElementType;
+ section: 'main' | 'tools';
+}
+
+interface SidebarProps {
+ activeTab: TabId;
+ onTabChange: (tab: TabId) => void;
+ isOpen?: boolean;
+ onToggle?: () => void;
+}
+
+/* ─── Navigation config ──────────────────────────────────────────────────── */
+const NAV_ITEMS: NavItem[] = [
+ { id: 'dashboard', label: 'Dashboard', Icon: LayoutDashboard, section: 'main' },
+ { id: 'grants', label: 'Grants', Icon: Award, section: 'main' },
+ { id: 'news', label: 'News', Icon: Newspaper, section: 'main' },
+ { id: 'collaborations', label: 'Collaborations', Icon: Users, section: 'main' },
+ { id: 'outreach', label: 'Outreach', Icon: Megaphone, section: 'tools' },
+ { id: 'settings', label: 'Settings', Icon: Settings, section: 'tools' },
+];
+
+/* ─── Sidebar ────────────────────────────────────────────────────────────── */
+export default function Sidebar({ activeTab, onTabChange, isOpen, onToggle }: SidebarProps) {
+ const [collapsed, setCollapsed] = useState(false);
+
+ const mainItems = NAV_ITEMS.filter((i) => i.section === 'main');
+ const toolItems = NAV_ITEMS.filter((i) => i.section === 'tools');
+
+ return (
+ <>
+ {isOpen && onToggle && (
+ <div
+ onClick={onToggle}
+ className="sidebar-backdrop"
+ style={{
+ position: 'fixed',
+ inset: 0,
+ backgroundColor: 'rgba(0,0,0,0.5)',
+ zIndex: 39,
+ }}
+ />
+ )}
+ <aside
+ className={`sidebar-root${isOpen ? ' sidebar-open' : ''}`}
+ style={{
+ width: collapsed ? 64 : 220,
+ minWidth: collapsed ? 64 : 220,
+ transition: 'width 0.2s cubic-bezier(0.4, 0, 0.2, 1), min-width 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
+ height: '100vh',
+ display: 'flex',
+ flexDirection: 'column',
+ backgroundColor: 'var(--color-surface)',
+ borderRight: '1px solid var(--color-border)',
+ position: 'relative',
+ overflow: 'hidden',
+ flexShrink: 0,
+ }}
+ aria-label="Main navigation"
+ >
+ {/* ── Brand ──────────────────────────────────────────────────────────── */}
+ <div
+ style={{
+ padding: collapsed ? '16px 0' : '16px',
+ display: 'flex',
+ alignItems: 'center',
+ gap: 10,
+ justifyContent: collapsed ? 'center' : 'flex-start',
+ borderBottom: '1px solid var(--color-border)',
+ height: 56,
+ flexShrink: 0,
+ }}
+ >
+ <div
+ style={{
+ width: 32,
+ height: 32,
+ borderRadius: 10,
+ background: 'linear-gradient(135deg, #059669, #047857)',
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ flexShrink: 0,
+ boxShadow: '0 2px 8px rgba(5, 150, 105, 0.3)',
+ }}
+ >
+ <span style={{ color: '#fff', fontSize: 14, fontWeight: 900, lineHeight: 1 }}>G</span>
+ </div>
+ {!collapsed && (
+ <div style={{ overflow: 'hidden', whiteSpace: 'nowrap' }}>
+ <div style={{ fontSize: 14, fontWeight: 700, color: 'var(--color-text)', lineHeight: 1.2 }}>
+ Grant
+ </div>
+ <div style={{ fontSize: 10, color: 'var(--color-text-muted)', lineHeight: 1.2, letterSpacing: '0.02em' }}>
+ Non-Profit Fundraiser
+ </div>
+ </div>
+ )}
+ </div>
+
+ {/* ── Main Nav ───────────────────────────────────────────────────────── */}
+ <nav style={{ flex: 1, overflowY: 'auto', overflowX: 'hidden', padding: '8px 8px 0' }}>
+ {!collapsed && (
+ <div style={{ padding: '4px 8px 6px', fontSize: 10, fontWeight: 600, color: 'var(--color-text-muted)', letterSpacing: '0.06em', textTransform: 'uppercase' }}>
+ Workspace
+ </div>
+ )}
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
+ {mainItems.map(({ id, label, Icon }) => (
+ <SidebarButton
+ key={id}
+ id={id}
+ label={label}
+ Icon={Icon}
+ isActive={activeTab === id}
+ collapsed={collapsed}
+ onClick={() => onTabChange(id)}
+ />
+ ))}
+ </div>
+
+ {/* Divider */}
+ <div
+ style={{
+ height: 1,
+ backgroundColor: 'var(--color-border)',
+ margin: collapsed ? '12px 8px' : '12px 4px',
+ opacity: 0.6,
+ }}
+ />
+
+ {!collapsed && (
+ <div style={{ padding: '4px 8px 6px', fontSize: 10, fontWeight: 600, color: 'var(--color-text-muted)', letterSpacing: '0.06em', textTransform: 'uppercase' }}>
+ Tools
+ </div>
+ )}
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
+ {toolItems.map(({ id, label, Icon }) => (
+ <SidebarButton
+ key={id}
+ id={id}
+ label={label}
+ Icon={Icon}
+ isActive={activeTab === id}
+ collapsed={collapsed}
+ onClick={() => onTabChange(id)}
+ />
+ ))}
+ </div>
+
+ {/* 2026-05-05 (tick 38): /river demo link — public visualization of */}
+ {/* federal grant data as fish swimming toward close-date. */}
+ {!collapsed && (
+ <a
+ href="/river?q=education"
+ target="_blank"
+ rel="noopener"
+ style={{
+ display: 'flex', alignItems: 'center', gap: 10,
+ padding: '8px 12px', marginTop: 8,
+ border: '1px dashed var(--color-border)', borderRadius: 6,
+ color: 'var(--color-accent, #10b981)', textDecoration: 'none',
+ fontSize: 12, fontWeight: 500,
+ }}
+ title="Deadline River — federal grants visualized"
+ >
+ <span style={{ fontSize: 14 }}>~~</span> Deadline River <span style={{ marginLeft: 'auto', fontSize: 9, opacity: 0.6 }}>↗</span>
+ </a>
+ )}
+ </nav>
+
+ {/* ── Collapse toggle ────────────────────────────────────────────────── */}
+ <div
+ style={{
+ padding: '8px',
+ borderTop: '1px solid var(--color-border)',
+ flexShrink: 0,
+ }}
+ >
+ <button
+ onClick={() => setCollapsed((c) => !c)}
+ title={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
+ aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
+ style={{
+ width: '100%',
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: collapsed ? 'center' : 'flex-start',
+ gap: 10,
+ padding: collapsed ? '8px 0' : '8px 12px',
+ borderRadius: 8,
+ border: 'none',
+ backgroundColor: 'transparent',
+ color: 'var(--color-text-muted)',
+ cursor: 'pointer',
+ fontSize: 12,
+ transition: 'background-color 0.15s, color 0.15s',
+ }}
+ onMouseEnter={(e) => {
+ e.currentTarget.style.backgroundColor = 'var(--color-surface-el)';
+ e.currentTarget.style.color = 'var(--color-text-secondary)';
+ }}
+ onMouseLeave={(e) => {
+ e.currentTarget.style.backgroundColor = 'transparent';
+ e.currentTarget.style.color = 'var(--color-text-muted)';
+ }}
+ >
+ {collapsed ? <ChevronRight size={16} /> : <ChevronLeft size={16} />}
+ {!collapsed && <span>Collapse</span>}
+ </button>
+ </div>
+ </aside>
+ </>
+ );
+}
+
+/* ─── SidebarButton ──────────────────────────────────────────────────────── */
+function SidebarButton({
+ id,
+ label,
+ Icon,
+ isActive,
+ collapsed,
+ onClick,
+}: {
+ id: string;
+ label: string;
+ Icon: React.ElementType;
+ isActive: boolean;
+ collapsed: boolean;
+ onClick: () => void;
+}) {
+ return (
+ <button
+ onClick={onClick}
+ aria-current={isActive ? 'page' : undefined}
+ title={collapsed ? label : undefined}
+ style={{
+ display: 'flex',
+ alignItems: 'center',
+ gap: 10,
+ padding: collapsed ? '10px 0' : '8px 12px',
+ justifyContent: collapsed ? 'center' : 'flex-start',
+ borderRadius: 8,
+ border: 'none',
+ cursor: 'pointer',
+ position: 'relative',
+ backgroundColor: isActive ? 'rgba(5, 150, 105, 0.12)' : 'transparent',
+ color: isActive ? 'var(--color-text)' : 'var(--color-text-secondary)',
+ fontSize: 13,
+ fontWeight: isActive ? 600 : 500,
+ transition: 'background-color 0.15s, color 0.15s',
+ width: '100%',
+ overflow: 'hidden',
+ whiteSpace: 'nowrap',
+ }}
+ onMouseEnter={(e) => {
+ if (!isActive) {
+ e.currentTarget.style.backgroundColor = 'var(--color-surface-el)';
+ e.currentTarget.style.color = 'var(--color-text)';
+ }
+ }}
+ onMouseLeave={(e) => {
+ if (!isActive) {
+ e.currentTarget.style.backgroundColor = 'transparent';
+ e.currentTarget.style.color = 'var(--color-text-secondary)';
+ }
+ }}
+ >
+ {/* Active indicator bar */}
+ {isActive && (
+ <div
+ style={{
+ position: 'absolute',
+ left: 0,
+ top: '20%',
+ bottom: '20%',
+ width: 3,
+ borderRadius: '0 3px 3px 0',
+ background: 'linear-gradient(180deg, #34d399, #059669)',
+ }}
+ />
+ )}
+ <Icon
+ size={18}
+ aria-hidden="true"
+ style={{
+ color: isActive ? '#34d399' : 'currentColor',
+ flexShrink: 0,
+ transition: 'color 0.15s',
+ }}
+ />
+ {!collapsed && <span>{label}</span>}
+ {collapsed && <span className="sr-only">{label}</span>}
+ </button>
+ );
+}
diff --git a/components/Skeleton.tsx b/components/Skeleton.tsx
new file mode 100644
index 0000000..9b26db6
--- /dev/null
+++ b/components/Skeleton.tsx
@@ -0,0 +1,49 @@
+'use client';
+
+export function SkeletonCard() {
+ return (
+ <div className="card" style={{ padding: '1rem' }}>
+ <div style={{ display: 'flex', alignItems: 'start', gap: 12 }}>
+ <div className="skeleton" style={{ width: 40, height: 40, borderRadius: 10, flexShrink: 0 }} />
+ <div style={{ flex: 1 }}>
+ <div className="skeleton" style={{ width: '60%', height: 14, borderRadius: 4, marginBottom: 8 }} />
+ <div className="skeleton" style={{ width: '40%', height: 10, borderRadius: 4, marginBottom: 12 }} />
+ <div className="skeleton" style={{ width: '90%', height: 10, borderRadius: 4, marginBottom: 6 }} />
+ <div className="skeleton" style={{ width: '75%', height: 10, borderRadius: 4 }} />
+ </div>
+ </div>
+ </div>
+ );
+}
+
+export function SkeletonStatCard() {
+ return (
+ <div className="stat-card" style={{ padding: '1.25rem' }}>
+ <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
+ <div className="skeleton" style={{ width: 36, height: 36, borderRadius: 8 }} />
+ </div>
+ <div className="skeleton" style={{ width: '50%', height: 24, borderRadius: 4, marginBottom: 6 }} />
+ <div className="skeleton" style={{ width: '70%', height: 10, borderRadius: 4 }} />
+ </div>
+ );
+}
+
+export function SkeletonList({ count = 4 }: { count?: number }) {
+ return (
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
+ {Array.from({ length: count }).map((_, i) => (
+ <SkeletonCard key={i} />
+ ))}
+ </div>
+ );
+}
+
+export function SkeletonStats({ count = 4 }: { count?: number }) {
+ return (
+ <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))', gap: 16 }}>
+ {Array.from({ length: count }).map((_, i) => (
+ <SkeletonStatCard key={i} />
+ ))}
+ </div>
+ );
+}
diff --git a/components/ToastProvider.tsx b/components/ToastProvider.tsx
new file mode 100644
index 0000000..28a987e
--- /dev/null
+++ b/components/ToastProvider.tsx
@@ -0,0 +1,94 @@
+'use client';
+import { createContext, useContext, useState, useCallback, useRef, type ReactNode } from 'react';
+
+type ToastType = 'success' | 'error' | 'info' | 'warning';
+
+interface Toast {
+ id: number;
+ message: string;
+ type: ToastType;
+}
+
+interface ToastContextType {
+ addToast: (message: string, type?: ToastType) => void;
+}
+
+const ToastContext = createContext<ToastContextType>({ addToast: () => {} });
+
+export function useToast() {
+ return useContext(ToastContext);
+}
+
+const TYPE_STYLES: Record<ToastType, { bg: string; border: string; text: string }> = {
+ success: { bg: 'rgba(34,197,94,0.15)', border: 'rgba(34,197,94,0.4)', text: '#4ade80' },
+ error: { bg: 'rgba(239,68,68,0.15)', border: 'rgba(239,68,68,0.4)', text: '#f87171' },
+ info: { bg: 'rgba(59,130,246,0.15)', border: 'rgba(59,130,246,0.4)', text: '#60a5fa' },
+ warning: { bg: 'rgba(245,158,11,0.15)', border: 'rgba(245,158,11,0.4)', text: '#fbbf24' },
+};
+
+const ICONS: Record<ToastType, string> = {
+ success: '\u2713',
+ error: '\u2715',
+ warning: '\u26A0',
+ info: '\u2139',
+};
+
+export function ToastProvider({ children }: { children: ReactNode }) {
+ const [toasts, setToasts] = useState<Toast[]>([]);
+ const nextId = useRef(0);
+
+ const addToast = useCallback((message: string, type: ToastType = 'success') => {
+ const id = ++nextId.current;
+ setToasts((prev) => [...prev, { id, message, type }]);
+ setTimeout(() => {
+ setToasts((prev) => prev.filter((t) => t.id !== id));
+ }, 3500);
+ }, []);
+
+ return (
+ <ToastContext.Provider value={{ addToast }}>
+ {children}
+ <div role="status" aria-live="polite" style={{
+ position: 'fixed',
+ bottom: 16,
+ right: 16,
+ zIndex: 9999,
+ display: 'flex',
+ flexDirection: 'column',
+ gap: 8,
+ pointerEvents: 'none',
+ maxWidth: 380,
+ }}>
+ {toasts.map((toast) => {
+ const style = TYPE_STYLES[toast.type];
+ return (
+ <div
+ key={toast.id}
+ style={{
+ padding: '10px 16px',
+ borderRadius: 10,
+ backgroundColor: style.bg,
+ border: `1px solid ${style.border}`,
+ color: style.text,
+ fontSize: '0.8125rem',
+ fontWeight: 500,
+ backdropFilter: 'blur(12px)',
+ boxShadow: '0 4px 12px rgba(0,0,0,0.3)',
+ animation: 'toast-in 0.25s ease-out',
+ pointerEvents: 'auto',
+ display: 'flex',
+ alignItems: 'center',
+ gap: 8,
+ }}
+ >
+ <span style={{ fontSize: '1rem', lineHeight: 1 }}>
+ {ICONS[toast.type]}
+ </span>
+ {toast.message}
+ </div>
+ );
+ })}
+ </div>
+ </ToastContext.Provider>
+ );
+}
diff --git a/components/collaborations/CollaborationsTab.tsx b/components/collaborations/CollaborationsTab.tsx
new file mode 100644
index 0000000..48b8c20
--- /dev/null
+++ b/components/collaborations/CollaborationsTab.tsx
@@ -0,0 +1,720 @@
+'use client';
+
+import { useState, useEffect, useCallback, useRef } from 'react';
+import { SkeletonList } from '../Skeleton';
+import {
+ Users,
+ Building2,
+ Landmark,
+ MapPin,
+ Search,
+ Sparkles,
+ Loader2,
+ ChevronDown,
+ ChevronUp,
+ ExternalLink,
+ Mail,
+ Phone,
+ Globe,
+ MessageSquare,
+ X,
+} from 'lucide-react';
+import { useToast } from '../ToastProvider';
+import { useDebounce } from '@/hooks/useDebounce';
+import SortDropdown from '../shared/SortDropdown';
+import { useClientSort, SortConfig } from '@/hooks/useClientSort';
+
+const COLLAB_SORT_OPTIONS = [
+ { value: 'name_asc', label: 'Name A-Z' },
+ { value: 'type', label: 'Type A-Z' },
+ { value: 'status', label: 'Status A-Z' },
+ { value: 'date_desc', label: 'Newest First' },
+];
+
+const COLLAB_SORT_CONFIGS: Record<string, SortConfig> = {
+ name_asc: { key: 'name', direction: 'asc', type: 'string' },
+ type: { key: 'collab_type', direction: 'asc', type: 'string' },
+ status: { key: 'status', direction: 'asc', type: 'string' },
+ date_desc: { key: 'created_at', direction: 'desc', type: 'date' },
+};
+
+/* ─── Types ──────────────────────────────────────────────────────────────── */
+interface Collaboration {
+ id: string;
+ collab_type: string;
+ name: string;
+ title: string | null;
+ organization: string | null;
+ website_url: string | null;
+ email: string | null;
+ phone: string | null;
+ district: string | null;
+ state: string | null;
+ ai_reason: string | null;
+ ai_relevance: number | null;
+ ai_talking_points: string[];
+ status: string;
+ notes: string | null;
+ last_contacted: string | null;
+ created_at: string;
+}
+
+type TypeFilter = 'all' | 'nonprofit' | 'politician' | 'corporation' | 'municipality';
+type StatusFilter = 'all' | 'suggested' | 'contacted' | 'in_discussion' | 'partnered';
+
+const TYPE_OPTIONS: { id: TypeFilter; label: string; Icon: React.ElementType }[] = [
+ { id: 'all', label: 'All', Icon: Users },
+ { id: 'nonprofit', label: 'Nonprofits', Icon: Users },
+ { id: 'politician', label: 'Politicians', Icon: Landmark },
+ { id: 'corporation', label: 'Corporations', Icon: Building2 },
+ { id: 'municipality', label: 'Municipalities', Icon: MapPin },
+];
+
+const STATUS_OPTIONS: { id: StatusFilter; label: string }[] = [
+ { id: 'all', label: 'All Status' },
+ { id: 'suggested', label: 'Suggested' },
+ { id: 'contacted', label: 'Contacted' },
+ { id: 'in_discussion', label: 'In Discussion' },
+ { id: 'partnered', label: 'Partnered' },
+];
+
+const TYPE_COLORS: Record<string, { bg: string; text: string; border: string }> = {
+ nonprofit: { bg: 'rgba(5,150,105,0.15)', text: '#34d399', border: 'rgba(5,150,105,0.3)' },
+ politician: { bg: 'rgba(59,130,246,0.15)', text: '#60a5fa', border: 'rgba(59,130,246,0.3)' },
+ corporation: { bg: 'rgba(245,158,11,0.15)', text: '#fbbf24', border: 'rgba(245,158,11,0.3)' },
+ municipality: { bg: 'rgba(168,85,247,0.15)', text: '#c084fc', border: 'rgba(168,85,247,0.3)' },
+};
+
+const STATUS_COLORS: Record<string, { bg: string; text: string; border: string }> = {
+ suggested: { bg: 'rgba(59,130,246,0.15)', text: '#60a5fa', border: 'rgba(59,130,246,0.3)' },
+ contacted: { bg: 'rgba(245,158,11,0.15)', text: '#fbbf24', border: 'rgba(245,158,11,0.3)' },
+ in_discussion: { bg: 'rgba(168,85,247,0.15)', text: '#c084fc', border: 'rgba(168,85,247,0.3)' },
+ partnered: { bg: 'rgba(34,197,94,0.15)', text: '#22c55e', border: 'rgba(34,197,94,0.3)' },
+ declined: { bg: 'rgba(239,68,68,0.15)', text: '#ef4444', border: 'rgba(239,68,68,0.3)' },
+ archived: { bg: 'rgba(107,127,117,0.15)', text: '#6b7f75', border: 'rgba(107,127,117,0.3)' },
+};
+
+/* ─── Main Component ─────────────────────────────────────────────────────── */
+export default function CollaborationsTab() {
+ const { addToast } = useToast();
+ const [collabs, setCollabs] = useState<Collaboration[]>([]);
+ const [typeCounts, setTypeCounts] = useState<Record<string, number>>({});
+ const [loading, setLoading] = useState(true);
+ const [discovering, setDiscovering] = useState(false);
+ const [typeFilter, setTypeFilter] = useState<TypeFilter>('all');
+ const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
+ const [search, setSearch] = useState('');
+ const debouncedSearch = useDebounce(search, 300);
+ const [expandedId, setExpandedId] = useState<string | null>(null);
+ const [outreachModal, setOutreachModal] = useState<{ name: string; org: string; type: string } | null>(null);
+ const [sortBy, setSortBy] = useState('name_asc');
+ const sortedCollabs = useClientSort(collabs, sortBy, COLLAB_SORT_CONFIGS);
+
+ const fetchCollabs = useCallback(async () => {
+ try {
+ const params = new URLSearchParams();
+ if (typeFilter !== 'all') params.set('collab_type', typeFilter);
+ if (statusFilter !== 'all') params.set('status', statusFilter);
+ if (debouncedSearch) params.set('search', debouncedSearch);
+ const res = await fetch(`/api/collaborations?${params}`, { credentials: 'include' });
+ if (res.ok) {
+ const data = await res.json();
+ setCollabs(data.rows || []);
+ setTypeCounts(data.typeCounts || {});
+ }
+ } catch {
+ // fetch error handled by loading state
+ } finally {
+ setLoading(false);
+ }
+ }, [typeFilter, statusFilter, debouncedSearch]);
+
+ useEffect(() => {
+ fetchCollabs();
+ }, [fetchCollabs]);
+
+ const handleDiscover = async () => {
+ setDiscovering(true);
+ try {
+ const res = await fetch('/api/collaborations/discover', {
+ method: 'POST',
+ credentials: 'include',
+ });
+ if (res.ok) {
+ await fetchCollabs();
+ addToast('Collaborators discovered', 'success');
+ } else {
+ addToast('Discovery failed', 'error');
+ }
+ } catch {
+ addToast('Discovery failed', 'error');
+ } finally {
+ setDiscovering(false);
+ }
+ };
+
+ const handleStatusChange = async (id: string, newStatus: string) => {
+ try {
+ await fetch('/api/collaborations', {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ body: JSON.stringify({ id, status: newStatus }),
+ });
+ await fetchCollabs();
+ addToast(`Status updated to ${newStatus.replace(/_/g, ' ')}`, 'success');
+ } catch {
+ addToast('Status update failed', 'error');
+ }
+ };
+
+ return (
+ <div className="p-6 space-y-6">
+ {/* Header */}
+ <div className="flex items-center justify-between flex-wrap gap-3">
+ <div>
+ <h2 className="text-lg font-bold" style={{ color: 'var(--color-text)' }}>
+ Collaborations
+ </h2>
+ <p className="text-sm mt-1" style={{ color: 'var(--color-text-muted)' }}>
+ Build partnerships across sectors to amplify your impact.
+ </p>
+ </div>
+ <button
+ className="btn btn-secondary"
+ onClick={handleDiscover}
+ disabled={discovering}
+ >
+ {discovering ? (
+ <Loader2 size={15} className="animate-spin" />
+ ) : (
+ <Sparkles size={15} />
+ )}
+ {discovering ? 'Discovering...' : 'AI Discover Collaborators'}
+ </button>
+ </div>
+
+ {/* Type Filter Pills */}
+ <div className="flex gap-2 flex-wrap">
+ {TYPE_OPTIONS.map(({ id, label, Icon }) => {
+ const count = typeCounts[id] ?? 0;
+ const isActive = typeFilter === id;
+ return (
+ <button
+ key={id}
+ className="btn btn-sm"
+ onClick={() => setTypeFilter(id)}
+ style={{
+ backgroundColor: isActive ? 'rgba(5,150,105,0.12)' : 'var(--color-surface-el)',
+ color: isActive ? '#34d399' : 'var(--color-text-secondary)',
+ border: isActive
+ ? '1px solid rgba(5,150,105,0.3)'
+ : '1px solid var(--color-border)',
+ }}
+ >
+ <Icon size={13} />
+ {label}
+ {count > 0 && <span className="ml-1 text-xs" style={{ opacity: 0.7 }}>({count})</span>}
+ </button>
+ );
+ })}
+ </div>
+
+ {/* Sort + Status Filter + Search */}
+ <div className="flex items-center gap-3 flex-wrap">
+ <SortDropdown options={COLLAB_SORT_OPTIONS} value={sortBy} onChange={setSortBy} />
+ <div className="relative flex-1 min-w-[200px]">
+ <Search
+ size={16}
+ className="absolute left-3 top-1/2 -translate-y-1/2"
+ style={{ color: 'var(--color-text-muted)' }}
+ />
+ <input
+ type="text"
+ className="input"
+ placeholder="Search collaborators..."
+ aria-label="Search"
+ style={{ paddingLeft: '2.25rem' }}
+ value={search}
+ onChange={(e) => setSearch(e.target.value)}
+ />
+ </div>
+ <select
+ className="input"
+ style={{ width: 'auto' }}
+ value={statusFilter}
+ onChange={(e) => setStatusFilter(e.target.value as StatusFilter)}
+ aria-label="Filter by status"
+ >
+ {STATUS_OPTIONS.map(({ id, label }) => (
+ <option key={id} value={id}>{label}</option>
+ ))}
+ </select>
+ </div>
+
+ {/* Cards */}
+ {loading ? (
+ <SkeletonList count={4} />
+ ) : sortedCollabs.length === 0 ? (
+ <div className="card">
+ <div className="flex flex-col items-center justify-center py-16 text-center">
+ <div
+ className="w-14 h-14 rounded-xl flex items-center justify-center mb-4"
+ style={{
+ backgroundColor: 'var(--color-surface-el)',
+ border: '1px solid var(--color-border)',
+ }}
+ >
+ <Users size={24} style={{ color: 'var(--color-text-muted)' }} />
+ </div>
+ <h3 className="text-base font-semibold mb-2" style={{ color: 'var(--color-text)' }}>
+ No collaborators found
+ </h3>
+ <p className="text-sm max-w-md" style={{ color: 'var(--color-text-muted)' }}>
+ Click "AI Discover Collaborators" to find potential partners for your mission.
+ </p>
+ </div>
+ </div>
+ ) : (
+ <div className="space-y-3">
+ {sortedCollabs.map((c) => {
+ const isExpanded = expandedId === c.id;
+ const typeStyle = TYPE_COLORS[c.collab_type] || TYPE_COLORS.nonprofit;
+ const statusStyle = STATUS_COLORS[c.status] || STATUS_COLORS.suggested;
+ const relevancePct = c.ai_relevance != null ? Math.round(c.ai_relevance * 100) : null;
+
+ return (
+ <div key={c.id} className="card" style={{ padding: 0 }}>
+ <div className="p-4">
+ <div className="flex items-start justify-between gap-3">
+ <div className="flex-1 min-w-0">
+ <div className="flex items-center gap-2 flex-wrap mb-1">
+ <h3 className="text-sm font-semibold" style={{ color: 'var(--color-text)' }}>
+ {c.name}
+ </h3>
+ <span
+ className="badge"
+ style={{
+ backgroundColor: typeStyle.bg,
+ color: typeStyle.text,
+ border: `1px solid ${typeStyle.border}`,
+ }}
+ >
+ {c.collab_type}
+ </span>
+ <span
+ className="badge"
+ style={{
+ backgroundColor: statusStyle.bg,
+ color: statusStyle.text,
+ border: `1px solid ${statusStyle.border}`,
+ }}
+ >
+ {c.status.replace(/_/g, ' ')}
+ </span>
+ </div>
+
+ <div className="flex items-center gap-3 text-xs flex-wrap" style={{ color: 'var(--color-text-muted)' }}>
+ {c.title && <span>{c.title}</span>}
+ {c.organization && (
+ <span className="font-medium" style={{ color: 'var(--color-text-secondary)' }}>
+ {c.organization}
+ </span>
+ )}
+ {c.district && <span>{c.district}</span>}
+ {c.state && <span>{c.state}</span>}
+ </div>
+
+ {c.ai_reason && (
+ <p className="text-xs mt-2 leading-relaxed" style={{ color: 'var(--color-text-secondary)' }}>
+ {c.ai_reason}
+ </p>
+ )}
+ </div>
+
+ <div className="flex items-center gap-2 shrink-0">
+ {/* Relevance score */}
+ {relevancePct != null && (
+ <div className="text-center">
+ <div
+ className="w-10 h-10 rounded-lg flex items-center justify-center text-xs font-bold"
+ style={{
+ backgroundColor: relevancePct >= 70 ? 'rgba(34,197,94,0.15)' : 'rgba(245,158,11,0.15)',
+ color: relevancePct >= 70 ? '#22c55e' : '#f59e0b',
+ border: `1px solid ${relevancePct >= 70 ? 'rgba(34,197,94,0.3)' : 'rgba(245,158,11,0.3)'}`,
+ }}
+ >
+ {relevancePct}%
+ </div>
+ </div>
+ )}
+ <button
+ className="btn btn-ghost btn-sm"
+ onClick={() => setExpandedId(isExpanded ? null : c.id)}
+ aria-label={isExpanded ? `Collapse ${c.name}` : `Expand ${c.name}`}
+ aria-expanded={isExpanded}
+ >
+ {isExpanded ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
+ </button>
+ </div>
+ </div>
+ </div>
+
+ {/* Expanded */}
+ {isExpanded && (
+ <div
+ className="px-4 pb-4 space-y-3"
+ style={{ borderTop: '1px solid var(--color-border)' }}
+ >
+ {/* Contact info */}
+ <div className="flex gap-3 flex-wrap pt-3 text-xs" style={{ color: 'var(--color-text-muted)' }}>
+ {c.email && (
+ <a href={`mailto:${c.email}`} className="flex items-center gap-1 hover:underline" style={{ color: '#60a5fa' }}>
+ <Mail size={12} />
+ {c.email}
+ </a>
+ )}
+ {c.phone && (
+ <span className="flex items-center gap-1">
+ <Phone size={12} />
+ {c.phone}
+ </span>
+ )}
+ {c.website_url && (
+ <a href={c.website_url} target="_blank" rel="noopener noreferrer" className="flex items-center gap-1 hover:underline" style={{ color: '#60a5fa' }}>
+ <Globe size={12} />
+ Website
+ </a>
+ )}
+ </div>
+
+ {/* Talking Points */}
+ {c.ai_talking_points && c.ai_talking_points.length > 0 && (
+ <div>
+ <h4 className="text-xs font-semibold mb-1 flex items-center gap-1" style={{ color: '#34d399' }}>
+ <Sparkles size={12} />
+ Talking Points
+ </h4>
+ <ul className="space-y-1">
+ {c.ai_talking_points.map((point, i) => (
+ <li
+ key={i}
+ className="text-xs leading-relaxed pl-3 relative"
+ style={{ color: 'var(--color-text-secondary)' }}
+ >
+ <span
+ className="absolute left-0 top-1.5 w-1.5 h-1.5 rounded-full"
+ style={{ backgroundColor: '#34d399' }}
+ />
+ {point}
+ </li>
+ ))}
+ </ul>
+ </div>
+ )}
+
+ {/* Relevance bar */}
+ {relevancePct != null && (
+ <div>
+ <div className="flex items-center justify-between mb-1">
+ <span className="text-xs font-medium" style={{ color: 'var(--color-text-secondary)' }}>
+ AI Relevance
+ </span>
+ <span className="text-xs font-bold" style={{ color: relevancePct >= 70 ? '#34d399' : '#fbbf24' }}>
+ {relevancePct}%
+ </span>
+ </div>
+ <div className="h-1.5 rounded-full overflow-hidden" style={{ backgroundColor: 'var(--color-surface-el)' }}>
+ <div
+ className="h-full rounded-full"
+ style={{
+ width: `${relevancePct}%`,
+ backgroundColor: relevancePct >= 70 ? '#34d399' : '#fbbf24',
+ }}
+ />
+ </div>
+ </div>
+ )}
+
+ {/* Actions */}
+ <div className="flex gap-2 flex-wrap pt-1">
+ <button
+ className="btn btn-primary btn-sm"
+ onClick={() =>
+ setOutreachModal({
+ name: c.name,
+ org: c.organization || '',
+ type: c.collab_type,
+ })
+ }
+ >
+ <MessageSquare size={14} />
+ Draft Outreach
+ </button>
+ <select
+ className="input"
+ style={{ width: 'auto', padding: '0.25rem 0.5rem', fontSize: '0.8125rem' }}
+ value={c.status}
+ onChange={(e) => handleStatusChange(c.id, e.target.value)}
+ aria-label={`Status for ${c.name}`}
+ >
+ <option value="suggested">Suggested</option>
+ <option value="contacted">Contacted</option>
+ <option value="in_discussion">In Discussion</option>
+ <option value="partnered">Partnered</option>
+ <option value="declined">Declined</option>
+ <option value="archived">Archived</option>
+ </select>
+ </div>
+ </div>
+ )}
+ </div>
+ );
+ })}
+ </div>
+ )}
+
+ {/* Outreach Modal */}
+ {outreachModal && (
+ <OutreachGenerateModal
+ targetName={outreachModal.name}
+ targetOrg={outreachModal.org}
+ targetType={outreachModal.type === 'politician' ? 'official' : outreachModal.type}
+ onClose={() => setOutreachModal(null)}
+ />
+ )}
+ </div>
+ );
+}
+
+/* ─── Outreach Generate Modal ────────────────────────────────────────────── */
+function OutreachGenerateModal({
+ targetName,
+ targetOrg,
+ targetType,
+ onClose,
+}: {
+ targetName: string;
+ targetOrg: string;
+ targetType: string;
+ onClose: () => void;
+}) {
+ const { addToast } = useToast();
+ const modalRef = useRef<HTMLDivElement>(null);
+ const previousFocusRef = useRef<HTMLElement | null>(null);
+
+ // Store previously focused element and focus the modal on mount
+ useEffect(() => {
+ previousFocusRef.current = document.activeElement as HTMLElement;
+ modalRef.current?.focus();
+ return () => {
+ previousFocusRef.current?.focus();
+ };
+ }, []);
+
+ // Escape to close + focus trapping
+ const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
+ if (e.key === 'Escape') {
+ onClose();
+ return;
+ }
+ if (e.key === 'Tab' && modalRef.current) {
+ const focusable = modalRef.current.querySelectorAll<HTMLElement>(
+ 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
+ );
+ if (focusable.length === 0) return;
+ const first = focusable[0];
+ const last = focusable[focusable.length - 1];
+ if (e.shiftKey) {
+ if (document.activeElement === first) {
+ e.preventDefault();
+ last.focus();
+ }
+ } else {
+ if (document.activeElement === last) {
+ e.preventDefault();
+ first.focus();
+ }
+ }
+ }
+ }, [onClose]);
+
+ const [templateType, setTemplateType] = useState('introduction');
+ const [context, setContext] = useState('');
+ const [generating, setGenerating] = useState(false);
+ const [result, setResult] = useState<{ subject: string; body_html: string; body_text: string } | null>(null);
+ const [copied, setCopied] = useState(false);
+
+ const handleGenerate = async () => {
+ setGenerating(true);
+ try {
+ const res = await fetch('/api/outreach/generate', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ body: JSON.stringify({
+ target_name: targetName,
+ target_org: targetOrg,
+ target_type: targetType,
+ template_type: templateType,
+ context,
+ }),
+ });
+ if (res.ok) {
+ const data = await res.json();
+ setResult(data);
+ addToast('Outreach email generated', 'success');
+ } else {
+ addToast('Generation failed', 'error');
+ }
+ } catch {
+ addToast('Generation failed', 'error');
+ } finally {
+ setGenerating(false);
+ }
+ };
+
+ const handleCopy = async () => {
+ if (!result) return;
+ try {
+ await navigator.clipboard.writeText(result.body_text);
+ } catch {
+ const ta = document.createElement('textarea');
+ ta.value = result.body_text;
+ document.body.appendChild(ta);
+ ta.select();
+ document.execCommand('copy');
+ document.body.removeChild(ta);
+ }
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ addToast('Copied to clipboard', 'success');
+ };
+
+ return (
+ <div
+ className="fixed inset-0 z-50 flex items-center justify-center p-4"
+ style={{ backgroundColor: 'rgba(0,0,0,0.6)' }}
+ onClick={onClose}
+ role="dialog"
+ aria-modal="true"
+ aria-labelledby="outreach-modal-title"
+ onKeyDown={handleKeyDown}
+ >
+ <div
+ ref={modalRef}
+ className="card w-full max-w-2xl max-h-[90vh] overflow-auto"
+ style={{ backgroundColor: 'var(--color-surface)' }}
+ onClick={(e) => e.stopPropagation()}
+ tabIndex={-1}
+ >
+ <div className="flex items-center justify-between mb-4">
+ <div>
+ <h3 id="outreach-modal-title" className="text-base font-semibold" style={{ color: 'var(--color-text)' }}>
+ Draft Outreach
+ </h3>
+ <p className="text-xs mt-0.5" style={{ color: 'var(--color-text-muted)' }}>
+ To: {targetName} {targetOrg ? `(${targetOrg})` : ''}
+ </p>
+ </div>
+ <button className="btn btn-ghost btn-sm" onClick={onClose} aria-label="Close dialog">
+ <X size={16} />
+ </button>
+ </div>
+
+ {!result ? (
+ <div className="space-y-3">
+ <div>
+ <label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>
+ Email Type
+ </label>
+ <select
+ className="input"
+ value={templateType}
+ onChange={(e) => setTemplateType(e.target.value)}
+ >
+ <option value="introduction">Introduction</option>
+ <option value="meeting_request">Meeting Request</option>
+ <option value="follow_up">Follow Up</option>
+ <option value="thank_you">Thank You</option>
+ <option value="donation_ask">Donation Ask</option>
+ </select>
+ </div>
+ <div>
+ <label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>
+ Additional Context (optional)
+ </label>
+ <textarea
+ className="input"
+ rows={3}
+ placeholder="Any specific talking points or context..."
+ value={context}
+ onChange={(e) => setContext(e.target.value)}
+ style={{ resize: 'vertical' }}
+ />
+ </div>
+ <button
+ className="btn btn-primary w-full"
+ onClick={handleGenerate}
+ disabled={generating}
+ >
+ {generating ? <Loader2 size={15} className="animate-spin" /> : <Sparkles size={15} />}
+ {generating ? 'Generating...' : 'Generate Email'}
+ </button>
+ </div>
+ ) : (
+ <div className="space-y-3">
+ <div
+ className="p-3 rounded-lg"
+ style={{ backgroundColor: 'var(--color-surface-el)', border: '1px solid var(--color-border)' }}
+ >
+ <h4 className="text-xs font-semibold mb-1" style={{ color: 'var(--color-text-secondary)' }}>
+ Subject
+ </h4>
+ <p className="text-sm font-medium" style={{ color: 'var(--color-text)' }}>
+ {result.subject}
+ </p>
+ </div>
+ <div
+ className="p-4 rounded-lg"
+ style={{
+ backgroundColor: 'var(--color-surface-el)',
+ border: '1px solid var(--color-border)',
+ color: 'var(--color-text-secondary)',
+ fontSize: '0.8125rem',
+ lineHeight: '1.6',
+ }}
+ >
+ {result.body_text.split('\n').map((line, i) => (
+ <p key={i} style={{ marginBottom: line.trim() ? '0.5rem' : '0.25rem' }}>
+ {line || '\u00A0'}
+ </p>
+ ))}
+ </div>
+ <div className="flex gap-2">
+ <button className="btn btn-secondary flex-1" onClick={handleCopy}>
+ <Mail size={14} />
+ {copied ? 'Copied!' : 'Copy to Clipboard'}
+ </button>
+ <button
+ className="btn btn-primary flex-1"
+ onClick={() => {
+ window.open(
+ `mailto:?subject=${encodeURIComponent(result.subject)}&body=${encodeURIComponent(result.body_text)}`,
+ '_blank',
+ );
+ }}
+ >
+ <ExternalLink size={14} />
+ Open in Email
+ </button>
+ </div>
+ <button className="btn btn-ghost btn-sm w-full" onClick={() => setResult(null)}>
+ Generate Another
+ </button>
+ </div>
+ )}
+ </div>
+ </div>
+ );
+}
diff --git a/components/dashboard/DashboardTab.tsx b/components/dashboard/DashboardTab.tsx
new file mode 100644
index 0000000..d5a47c0
--- /dev/null
+++ b/components/dashboard/DashboardTab.tsx
@@ -0,0 +1,447 @@
+'use client';
+
+import { useState, useEffect } from 'react';
+import { SkeletonStats, SkeletonList } from '../Skeleton';
+import {
+ Award,
+ Newspaper,
+ Users,
+ Megaphone,
+ TrendingUp,
+ DollarSign,
+ Calendar,
+ Target,
+ FileText,
+ Clock,
+} from 'lucide-react';
+
+/* ─── Types ──────────────────────────────────────────────────────────────── */
+interface DashboardData {
+ org_name: string;
+ grant_count: number;
+ total_funding: number;
+ news_count: number;
+ collab_count: number;
+ proposal_count: number;
+ upcoming_deadlines: {
+ id: string;
+ title: string;
+ funder: string;
+ deadline: string;
+ status: string;
+ priority: string;
+ ai_fit_score: number | null;
+ }[];
+ recent_activity: {
+ id: string;
+ type: 'grant' | 'collaboration' | 'proposal';
+ label: string;
+ detail: string;
+ status: string;
+ created_at: string;
+ }[];
+}
+
+function formatCurrency(n: number): string {
+ if (n === 0) return '$0';
+ return '$' + n.toLocaleString('en-US', { maximumFractionDigits: 0 });
+}
+
+function formatDate(d: string): string {
+ return new Date(d).toLocaleDateString('en-US', {
+ month: 'short',
+ day: 'numeric',
+ year: 'numeric',
+ });
+}
+
+function timeAgo(d: string): string {
+ const diff = Date.now() - new Date(d).getTime();
+ const mins = Math.floor(diff / 60000);
+ if (mins < 1) return 'just now';
+ if (mins < 60) return `${mins}m ago`;
+ const hours = Math.floor(mins / 60);
+ if (hours < 24) return `${hours}h ago`;
+ const days = Math.floor(hours / 24);
+ return `${days}d ago`;
+}
+
+function daysUntil(d: string): number {
+ return Math.ceil((new Date(d).getTime() - Date.now()) / (1000 * 60 * 60 * 24));
+}
+
+const ACTIVITY_ICONS: Record<string, React.ElementType> = {
+ grant: Award,
+ collaboration: Users,
+ proposal: FileText,
+};
+
+const ACTIVITY_COLORS: Record<string, string> = {
+ grant: '#059669',
+ collaboration: '#8b5cf6',
+ proposal: '#3b82f6',
+};
+
+/* ─── Stat Card ──────────────────────────────────────────────────────────── */
+function StatCard({
+ label,
+ value,
+ Icon,
+ accent,
+}: {
+ label: string;
+ value: string;
+ Icon: React.ElementType;
+ accent: string;
+}) {
+ return (
+ <div className="stat-card">
+ <div className="flex items-center justify-between mb-3">
+ <div
+ className="w-9 h-9 rounded-lg flex items-center justify-center"
+ style={{
+ backgroundColor: `${accent}18`,
+ border: `1px solid ${accent}30`,
+ }}
+ >
+ <Icon size={18} style={{ color: accent }} />
+ </div>
+ </div>
+ <div className="text-2xl font-bold mb-1" style={{ color: 'var(--color-text)' }}>
+ {value}
+ </div>
+ <div className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
+ {label}
+ </div>
+ </div>
+ );
+}
+
+/* ─── Dashboard Tab ──────────────────────────────────────────────────────── */
+interface DashboardTabProps {
+ onNavigate?: (tab: string) => void;
+}
+
+export default function DashboardTab({ onNavigate }: DashboardTabProps) {
+ const [data, setData] = useState<DashboardData | null>(null);
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ (async () => {
+ try {
+ const res = await fetch('/api/dashboard', { credentials: 'include' });
+ if (res.ok) {
+ setData(await res.json());
+ }
+ } catch {
+ // fetch error handled by loading state
+ } finally {
+ setLoading(false);
+ }
+ })();
+ }, []);
+
+ if (loading) {
+ return (
+ <div className="p-6" style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
+ <SkeletonStats count={4} />
+ <SkeletonList count={3} />
+ </div>
+ );
+ }
+
+ if (!data) {
+ return (
+ <div className="p-6">
+ <div className="card text-center py-16">
+ <p style={{ color: 'var(--color-text-muted)' }}>Failed to load dashboard data.</p>
+ </div>
+ </div>
+ );
+ }
+
+ return (
+ <div className="p-6 space-y-6">
+ {/* Welcome Banner */}
+ <div
+ className="rounded-xl p-6"
+ style={{
+ background: 'linear-gradient(135deg, rgba(5,150,105,0.15), rgba(4,120,87,0.08))',
+ border: '1px solid rgba(5,150,105,0.2)',
+ }}
+ >
+ <h2 className="text-xl font-bold mb-2" style={{ color: 'var(--color-text)' }}>
+ {data.org_name}
+ </h2>
+ <p
+ className="text-sm leading-relaxed max-w-2xl"
+ style={{ color: 'var(--color-text-secondary)' }}
+ >
+ Your AI-powered fundraising command center. Discover grants, monitor relevant news,
+ build collaborations, and manage outreach -- all in one place.
+ </p>
+ </div>
+
+ {/* Stats Grid */}
+ <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
+ <StatCard
+ label="Active Grants"
+ value={String(data.grant_count)}
+ Icon={Award}
+ accent="#059669"
+ />
+ <StatCard
+ label="Total Funding"
+ value={formatCurrency(data.total_funding)}
+ Icon={DollarSign}
+ accent="#f59e0b"
+ />
+ <StatCard
+ label="News Items"
+ value={String(data.news_count)}
+ Icon={Newspaper}
+ accent="#3b82f6"
+ />
+ <StatCard
+ label="Collaborations"
+ value={String(data.collab_count)}
+ Icon={Users}
+ accent="#8b5cf6"
+ />
+ </div>
+
+ {/* Two-Column Layout */}
+ <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
+ {/* Upcoming Deadlines */}
+ <div className="card">
+ <div className="flex items-center gap-2 mb-4">
+ <Calendar size={16} style={{ color: 'var(--color-primary)' }} />
+ <h3 className="text-sm font-semibold" style={{ color: 'var(--color-text)' }}>
+ Upcoming Deadlines
+ </h3>
+ </div>
+
+ {data.upcoming_deadlines.length === 0 ? (
+ <div className="flex flex-col items-center justify-center py-8 text-center">
+ <div
+ className="w-10 h-10 rounded-lg flex items-center justify-center mb-3"
+ style={{
+ backgroundColor: 'var(--color-surface-el)',
+ border: '1px solid var(--color-border)',
+ }}
+ >
+ <Calendar size={18} style={{ color: 'var(--color-text-muted)' }} />
+ </div>
+ <p className="text-sm" style={{ color: 'var(--color-text-muted)' }}>
+ No upcoming deadlines
+ </p>
+ <p className="text-xs mt-1" style={{ color: 'var(--color-text-muted)' }}>
+ Discovered grants with deadlines will appear here
+ </p>
+ </div>
+ ) : (
+ <div className="space-y-2">
+ {data.upcoming_deadlines.map((g) => {
+ const days = daysUntil(g.deadline);
+ const isUrgent = days <= 14;
+ return (
+ <div
+ key={g.id}
+ className="flex items-center justify-between p-3 rounded-lg"
+ style={{
+ backgroundColor: 'var(--color-surface-el)',
+ border: `1px solid ${isUrgent ? 'rgba(239,68,68,0.3)' : 'var(--color-border)'}`,
+ }}
+ >
+ <div className="min-w-0 flex-1">
+ <p className="text-sm font-medium truncate" style={{ color: 'var(--color-text)' }}>
+ {g.title}
+ </p>
+ <p className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
+ {g.funder}
+ </p>
+ </div>
+ <div className="flex items-center gap-2 shrink-0 ml-3">
+ <div className="text-right">
+ <p
+ className="text-xs font-semibold"
+ style={{ color: isUrgent ? '#ef4444' : 'var(--color-text-secondary)' }}
+ >
+ {formatDate(g.deadline)}
+ </p>
+ <p
+ className="text-xs"
+ style={{ color: isUrgent ? '#ef4444' : 'var(--color-text-muted)' }}
+ >
+ {days}d left
+ </p>
+ </div>
+ {isUrgent && (
+ <Clock size={14} style={{ color: '#ef4444' }} />
+ )}
+ </div>
+ </div>
+ );
+ })}
+ </div>
+ )}
+ </div>
+
+ {/* Recent Activity */}
+ <div className="card">
+ <div className="flex items-center gap-2 mb-4">
+ <TrendingUp size={16} style={{ color: 'var(--color-primary)' }} />
+ <h3 className="text-sm font-semibold" style={{ color: 'var(--color-text)' }}>
+ Recent Activity
+ </h3>
+ </div>
+
+ {data.recent_activity.length === 0 ? (
+ <div className="flex flex-col items-center justify-center py-8 text-center">
+ <div
+ className="w-10 h-10 rounded-lg flex items-center justify-center mb-3"
+ style={{
+ backgroundColor: 'var(--color-surface-el)',
+ border: '1px solid var(--color-border)',
+ }}
+ >
+ <TrendingUp size={18} style={{ color: 'var(--color-text-muted)' }} />
+ </div>
+ <p className="text-sm" style={{ color: 'var(--color-text-muted)' }}>
+ No recent activity
+ </p>
+ <p className="text-xs mt-1" style={{ color: 'var(--color-text-muted)' }}>
+ Your actions and AI-generated insights will appear here
+ </p>
+ </div>
+ ) : (
+ <div className="space-y-2">
+ {data.recent_activity.map((a) => {
+ const ActivityIcon = ACTIVITY_ICONS[a.type] || Award;
+ const activityColor = ACTIVITY_COLORS[a.type] || '#059669';
+ return (
+ <div
+ key={a.id}
+ className="flex items-start gap-3 p-3 rounded-lg"
+ style={{
+ backgroundColor: 'var(--color-surface-el)',
+ border: '1px solid var(--color-border)',
+ }}
+ >
+ <div
+ className="w-7 h-7 rounded-md flex items-center justify-center shrink-0 mt-0.5"
+ style={{
+ backgroundColor: `${activityColor}18`,
+ border: `1px solid ${activityColor}30`,
+ }}
+ >
+ <ActivityIcon size={13} style={{ color: activityColor }} />
+ </div>
+ <div className="flex-1 min-w-0">
+ <p className="text-xs font-medium truncate" style={{ color: 'var(--color-text)' }}>
+ {a.label}
+ </p>
+ <p className="text-xs truncate" style={{ color: 'var(--color-text-muted)' }}>
+ {a.detail}
+ </p>
+ </div>
+ <span className="text-xs shrink-0" style={{ color: 'var(--color-text-muted)' }}>
+ {timeAgo(a.created_at)}
+ </span>
+ </div>
+ );
+ })}
+ </div>
+ )}
+ </div>
+ </div>
+
+ {/* Quick Actions */}
+ <div className="card">
+ <div className="flex items-center gap-2 mb-4">
+ <Target size={16} style={{ color: 'var(--color-primary)' }} />
+ <h3 className="text-sm font-semibold" style={{ color: 'var(--color-text)' }}>
+ Quick Actions
+ </h3>
+ </div>
+ <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
+ <QuickAction
+ Icon={Award}
+ label="Discover Grants"
+ description="Find new funding opportunities"
+ onClick={() => onNavigate?.('grants')}
+ />
+ <QuickAction
+ Icon={Newspaper}
+ label="Scan News"
+ description="Monitor relevant headlines"
+ onClick={() => onNavigate?.('news')}
+ />
+ <QuickAction
+ Icon={Users}
+ label="Find Partners"
+ description="AI-suggested collaborations"
+ onClick={() => onNavigate?.('collaborations')}
+ />
+ <QuickAction
+ Icon={Megaphone}
+ label="Draft Outreach"
+ description="Generate outreach emails"
+ onClick={() => onNavigate?.('outreach')}
+ />
+ </div>
+ </div>
+ </div>
+ );
+}
+
+function QuickAction({
+ Icon,
+ label,
+ description,
+ onClick,
+}: {
+ Icon: React.ElementType;
+ label: string;
+ description: string;
+ onClick?: () => void;
+}) {
+ return (
+ <button
+ className="flex items-start gap-3 p-3 rounded-lg text-left w-full"
+ onClick={onClick}
+ style={{
+ backgroundColor: 'var(--color-surface-el)',
+ border: '1px solid var(--color-border)',
+ transition: 'border-color 0.15s, background-color 0.15s',
+ cursor: 'pointer',
+ }}
+ onMouseEnter={(e) => {
+ e.currentTarget.style.borderColor = 'rgba(5,150,105,0.4)';
+ e.currentTarget.style.backgroundColor = 'rgba(5,150,105,0.06)';
+ }}
+ onMouseLeave={(e) => {
+ e.currentTarget.style.borderColor = 'var(--color-border)';
+ e.currentTarget.style.backgroundColor = 'var(--color-surface-el)';
+ }}
+ >
+ <div
+ className="w-8 h-8 rounded-md flex items-center justify-center shrink-0 mt-0.5"
+ style={{
+ backgroundColor: 'rgba(5,150,105,0.12)',
+ border: '1px solid rgba(5,150,105,0.2)',
+ }}
+ >
+ <Icon size={15} style={{ color: '#34d399' }} />
+ </div>
+ <div>
+ <div className="text-sm font-medium" style={{ color: 'var(--color-text)' }}>
+ {label}
+ </div>
+ <div className="text-xs mt-0.5" style={{ color: 'var(--color-text-muted)' }}>
+ {description}
+ </div>
+ </div>
+ </button>
+ );
+}
diff --git a/components/grants/AddGrantWizard.tsx b/components/grants/AddGrantWizard.tsx
new file mode 100644
index 0000000..efae2d6
--- /dev/null
+++ b/components/grants/AddGrantWizard.tsx
@@ -0,0 +1,550 @@
+'use client';
+
+import { useState, useEffect, useCallback, useRef } from 'react';
+import { X, Loader2, Plus, Check, ChevronRight, ChevronLeft } from 'lucide-react';
+import { useToast } from '../ToastProvider';
+
+interface AddGrantWizardProps {
+ onClose: () => void;
+ onCreated: () => void;
+}
+
+const STEPS = [
+ { label: 'Basic Info', number: 1 },
+ { label: 'Details', number: 2 },
+ { label: 'Priority & Metadata', number: 3 },
+];
+
+export default function AddGrantWizard({ onClose, onCreated }: AddGrantWizardProps) {
+ const { addToast } = useToast();
+ const modalRef = useRef<HTMLDivElement>(null);
+ const previousFocusRef = useRef<HTMLElement | null>(null);
+ const [step, setStep] = useState(1);
+ const [saving, setSaving] = useState(false);
+ const [titleError, setTitleError] = useState(false);
+
+ // Store previously focused element and focus the modal on mount
+ useEffect(() => {
+ previousFocusRef.current = document.activeElement as HTMLElement;
+ modalRef.current?.focus();
+ return () => {
+ previousFocusRef.current?.focus();
+ };
+ }, []);
+
+ // Step 1 fields
+ const [title, setTitle] = useState('');
+ const [funder, setFunder] = useState('');
+ const [amountMin, setAmountMin] = useState('');
+ const [amountMax, setAmountMax] = useState('');
+ const [deadline, setDeadline] = useState('');
+
+ // Step 2 fields
+ const [description, setDescription] = useState('');
+ const [eligibility, setEligibility] = useState('');
+ const [focusAreas, setFocusAreas] = useState<string[]>([]);
+ const [focusInput, setFocusInput] = useState('');
+ const [applicationUrl, setApplicationUrl] = useState('');
+
+ // Step 3 fields
+ const [priority, setPriority] = useState('medium');
+ const [cycle, setCycle] = useState('');
+ const [tags, setTags] = useState('');
+ const [notes, setNotes] = useState('');
+
+ // Escape to close + focus trapping
+ const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
+ if (e.key === 'Escape') {
+ onClose();
+ return;
+ }
+ if (e.key === 'Tab' && modalRef.current) {
+ const focusable = modalRef.current.querySelectorAll<HTMLElement>(
+ 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
+ );
+ if (focusable.length === 0) return;
+ const first = focusable[0];
+ const last = focusable[focusable.length - 1];
+ if (e.shiftKey) {
+ if (document.activeElement === first) {
+ e.preventDefault();
+ last.focus();
+ }
+ } else {
+ if (document.activeElement === last) {
+ e.preventDefault();
+ first.focus();
+ }
+ }
+ }
+ }, [onClose]);
+
+ const handleNext = useCallback(() => {
+ if (step === 1) {
+ if (!title.trim()) {
+ setTitleError(true);
+ return;
+ }
+ setTitleError(false);
+ }
+ setStep((s) => Math.min(s + 1, 3));
+ }, [step, title]);
+
+ const handleBack = useCallback(() => {
+ setStep((s) => Math.max(s - 1, 1));
+ }, []);
+
+ const handleFocusKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ const val = focusInput.trim();
+ if (val && !focusAreas.includes(val)) {
+ setFocusAreas((prev) => [...prev, val]);
+ }
+ setFocusInput('');
+ }
+ };
+
+ const removeFocusArea = (area: string) => {
+ setFocusAreas((prev) => prev.filter((a) => a !== area));
+ };
+
+ const handleSubmit = async () => {
+ setSaving(true);
+ try {
+ const res = await fetch('/api/grants', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ body: JSON.stringify({
+ title: title.trim(),
+ funder: funder.trim() || undefined,
+ amount_min: amountMin ? Number(amountMin) : null,
+ amount_max: amountMax ? Number(amountMax) : null,
+ deadline: deadline || null,
+ description: description.trim() || null,
+ eligibility: eligibility.trim() || null,
+ focus_areas: focusAreas,
+ application_url: applicationUrl.trim() || null,
+ priority,
+ cycle: cycle || null,
+ tags: tags ? tags.split(',').map((t) => t.trim()).filter(Boolean) : [],
+ notes: notes.trim() || null,
+ }),
+ });
+ if (res.ok) {
+ onCreated();
+ onClose();
+ addToast('Grant added successfully', 'success');
+ } else {
+ addToast('Failed to add grant', 'error');
+ }
+ } catch {
+ addToast('Failed to add grant', 'error');
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ return (
+ <div
+ className="fixed inset-0 z-50 flex items-center justify-center p-4"
+ style={{ backgroundColor: 'rgba(0,0,0,0.6)' }}
+ onClick={onClose}
+ role="dialog"
+ aria-modal="true"
+ aria-labelledby="add-grant-modal-title"
+ onKeyDown={handleKeyDown}
+ >
+ <div
+ ref={modalRef}
+ className="card w-full max-w-lg max-h-[85vh] overflow-auto"
+ style={{ backgroundColor: 'var(--color-surface)' }}
+ onClick={(e) => e.stopPropagation()}
+ tabIndex={-1}
+ >
+ {/* Header */}
+ <div className="flex items-center justify-between mb-5">
+ <h3 id="add-grant-modal-title" className="text-base font-semibold" style={{ color: 'var(--color-text)' }}>
+ Add Grant
+ </h3>
+ <button className="btn btn-ghost btn-sm" onClick={onClose} aria-label="Close dialog">
+ <X size={16} />
+ </button>
+ </div>
+
+ {/* Step Indicator */}
+ <div className="flex items-center justify-center mb-6">
+ {STEPS.map((s, i) => (
+ <div key={s.number} className="flex items-center">
+ {/* Circle */}
+ <div className="flex flex-col items-center">
+ <div
+ style={{
+ width: 32,
+ height: 32,
+ borderRadius: '50%',
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ fontSize: '0.75rem',
+ fontWeight: 700,
+ transition: 'all 150ms ease',
+ backgroundColor:
+ step > s.number
+ ? '#059669'
+ : step === s.number
+ ? '#059669'
+ : 'var(--color-surface-el)',
+ color:
+ step >= s.number ? '#fff' : 'var(--color-text-muted)',
+ border:
+ step >= s.number
+ ? '2px solid #059669'
+ : '2px solid var(--color-border)',
+ }}
+ >
+ {step > s.number ? <Check size={14} /> : s.number}
+ </div>
+ <span
+ className="text-xs mt-1"
+ style={{
+ color:
+ step === s.number
+ ? 'var(--color-text)'
+ : 'var(--color-text-muted)',
+ fontWeight: step === s.number ? 600 : 400,
+ }}
+ >
+ {s.label}
+ </span>
+ </div>
+ {/* Connector line */}
+ {i < STEPS.length - 1 && (
+ <div
+ style={{
+ width: 48,
+ height: 2,
+ backgroundColor:
+ step > s.number ? '#059669' : 'var(--color-border)',
+ marginLeft: 8,
+ marginRight: 8,
+ marginBottom: 18,
+ transition: 'background-color 150ms ease',
+ }}
+ />
+ )}
+ </div>
+ ))}
+ </div>
+
+ {/* Step 1 — Basic Info */}
+ {step === 1 && (
+ <div className="space-y-3">
+ <div>
+ <label
+ className="block text-xs font-medium mb-1"
+ style={{ color: 'var(--color-text-secondary)' }}
+ >
+ Title *
+ </label>
+ <input
+ className="input"
+ placeholder="Grant program name"
+ value={title}
+ onChange={(e) => {
+ setTitle(e.target.value);
+ if (e.target.value.trim()) setTitleError(false);
+ }}
+ style={
+ titleError
+ ? { borderColor: '#ef4444', boxShadow: '0 0 0 3px rgba(239,68,68,0.2)' }
+ : undefined
+ }
+ />
+ {titleError && (
+ <p className="text-xs mt-1" style={{ color: '#ef4444' }}>
+ Title is required
+ </p>
+ )}
+ </div>
+ <div>
+ <label
+ className="block text-xs font-medium mb-1"
+ style={{ color: 'var(--color-text-secondary)' }}
+ >
+ Funder
+ </label>
+ <input
+ className="input"
+ placeholder="Foundation or agency name"
+ value={funder}
+ onChange={(e) => setFunder(e.target.value)}
+ />
+ </div>
+ <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
+ <div>
+ <label
+ className="block text-xs font-medium mb-1"
+ style={{ color: 'var(--color-text-secondary)' }}
+ >
+ Min Amount ($)
+ </label>
+ <input
+ className="input"
+ type="number"
+ placeholder="10000"
+ value={amountMin}
+ onChange={(e) => setAmountMin(e.target.value)}
+ />
+ </div>
+ <div>
+ <label
+ className="block text-xs font-medium mb-1"
+ style={{ color: 'var(--color-text-secondary)' }}
+ >
+ Max Amount ($)
+ </label>
+ <input
+ className="input"
+ type="number"
+ placeholder="50000"
+ value={amountMax}
+ onChange={(e) => setAmountMax(e.target.value)}
+ />
+ </div>
+ </div>
+ <div>
+ <label
+ className="block text-xs font-medium mb-1"
+ style={{ color: 'var(--color-text-secondary)' }}
+ >
+ Deadline
+ </label>
+ <input
+ className="input"
+ type="date"
+ value={deadline}
+ onChange={(e) => setDeadline(e.target.value)}
+ />
+ </div>
+ </div>
+ )}
+
+ {/* Step 2 — Details */}
+ {step === 2 && (
+ <div className="space-y-3">
+ <div>
+ <label
+ className="block text-xs font-medium mb-1"
+ style={{ color: 'var(--color-text-secondary)' }}
+ >
+ Description
+ </label>
+ <textarea
+ className="input"
+ rows={4}
+ placeholder="What does this grant fund?"
+ value={description}
+ onChange={(e) => setDescription(e.target.value)}
+ style={{ resize: 'vertical' }}
+ />
+ </div>
+ <div>
+ <label
+ className="block text-xs font-medium mb-1"
+ style={{ color: 'var(--color-text-secondary)' }}
+ >
+ Eligibility
+ </label>
+ <textarea
+ className="input"
+ rows={3}
+ placeholder="Who is eligible to apply?"
+ value={eligibility}
+ onChange={(e) => setEligibility(e.target.value)}
+ style={{ resize: 'vertical' }}
+ />
+ </div>
+ <div>
+ <label
+ className="block text-xs font-medium mb-1"
+ style={{ color: 'var(--color-text-secondary)' }}
+ >
+ Focus Areas
+ </label>
+ <input
+ className="input"
+ placeholder="Type a focus area and press Enter"
+ value={focusInput}
+ onChange={(e) => setFocusInput(e.target.value)}
+ onKeyDown={handleFocusKeyDown}
+ />
+ {focusAreas.length > 0 && (
+ <div className="flex flex-wrap gap-1.5 mt-2">
+ {focusAreas.map((area) => (
+ <span
+ key={area}
+ className="inline-flex items-center gap-1 text-xs px-2 py-1 rounded-full"
+ style={{
+ backgroundColor: 'rgba(5,150,105,0.12)',
+ color: '#34d399',
+ border: '1px solid rgba(5,150,105,0.25)',
+ }}
+ >
+ {area}
+ <button
+ type="button"
+ onClick={() => removeFocusArea(area)}
+ aria-label={`Remove ${area}`}
+ style={{
+ background: 'none',
+ border: 'none',
+ color: '#34d399',
+ cursor: 'pointer',
+ padding: 0,
+ lineHeight: 1,
+ fontSize: '0.875rem',
+ }}
+ >
+ <X size={12} />
+ </button>
+ </span>
+ ))}
+ </div>
+ )}
+ </div>
+ <div>
+ <label
+ className="block text-xs font-medium mb-1"
+ style={{ color: 'var(--color-text-secondary)' }}
+ >
+ Application URL
+ </label>
+ <input
+ className="input"
+ type="url"
+ placeholder="https://..."
+ value={applicationUrl}
+ onChange={(e) => setApplicationUrl(e.target.value)}
+ />
+ </div>
+ </div>
+ )}
+
+ {/* Step 3 — Priority & Metadata */}
+ {step === 3 && (
+ <div className="space-y-3">
+ <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
+ <div>
+ <label
+ className="block text-xs font-medium mb-1"
+ style={{ color: 'var(--color-text-secondary)' }}
+ >
+ Priority
+ </label>
+ <select
+ className="input"
+ value={priority}
+ onChange={(e) => setPriority(e.target.value)}
+ >
+ <option value="high">High</option>
+ <option value="medium">Medium</option>
+ <option value="low">Low</option>
+ </select>
+ </div>
+ <div>
+ <label
+ className="block text-xs font-medium mb-1"
+ style={{ color: 'var(--color-text-secondary)' }}
+ >
+ Cycle
+ </label>
+ <select
+ className="input"
+ value={cycle}
+ onChange={(e) => setCycle(e.target.value)}
+ >
+ <option value="">Select cycle...</option>
+ <option value="Annual">Annual</option>
+ <option value="Biannual">Biannual</option>
+ <option value="Rolling">Rolling</option>
+ <option value="One-time">One-time</option>
+ </select>
+ </div>
+ </div>
+ <div>
+ <label
+ className="block text-xs font-medium mb-1"
+ style={{ color: 'var(--color-text-secondary)' }}
+ >
+ Tags (comma-separated)
+ </label>
+ <input
+ className="input"
+ placeholder="education, advocacy, youth"
+ value={tags}
+ onChange={(e) => setTags(e.target.value)}
+ />
+ </div>
+ <div>
+ <label
+ className="block text-xs font-medium mb-1"
+ style={{ color: 'var(--color-text-secondary)' }}
+ >
+ Notes
+ </label>
+ <textarea
+ className="input"
+ rows={3}
+ placeholder="Any internal notes about this grant..."
+ value={notes}
+ onChange={(e) => setNotes(e.target.value)}
+ style={{ resize: 'vertical' }}
+ />
+ </div>
+ </div>
+ )}
+
+ {/* Navigation buttons */}
+ <div className="flex gap-2 pt-5 mt-2" style={{ borderTop: '1px solid var(--color-border)' }}>
+ {step > 1 && (
+ <button
+ type="button"
+ className="btn btn-secondary"
+ onClick={handleBack}
+ >
+ <ChevronLeft size={15} />
+ Back
+ </button>
+ )}
+ <div className="flex-1" />
+ {step < 3 ? (
+ <button
+ type="button"
+ className="btn btn-primary"
+ onClick={handleNext}
+ >
+ Next
+ <ChevronRight size={15} />
+ </button>
+ ) : (
+ <button
+ type="button"
+ className="btn btn-primary"
+ onClick={handleSubmit}
+ disabled={saving}
+ >
+ {saving ? (
+ <Loader2 size={15} className="animate-spin" />
+ ) : (
+ <Plus size={15} />
+ )}
+ {saving ? 'Creating...' : 'Create Grant'}
+ </button>
+ )}
+ </div>
+ </div>
+ </div>
+ );
+}
diff --git a/components/grants/GrantsTab.tsx b/components/grants/GrantsTab.tsx
new file mode 100644
index 0000000..037de99
--- /dev/null
+++ b/components/grants/GrantsTab.tsx
@@ -0,0 +1,658 @@
+'use client';
+
+import { useState, useEffect, useCallback } from 'react';
+import { SkeletonList } from '../Skeleton';
+import {
+ Award,
+ Search,
+ Plus,
+ Sparkles,
+ ChevronDown,
+ ChevronUp,
+ Bookmark,
+ BookmarkCheck,
+ Calendar,
+ DollarSign,
+ FileText,
+ Loader2,
+ ExternalLink,
+ Clock,
+ Send,
+} from 'lucide-react';
+import { useToast } from '../ToastProvider';
+import { useDebounce } from '@/hooks/useDebounce';
+import AddGrantWizard from './AddGrantWizard';
+import ProposalModal from './ProposalModal';
+import SortDropdown from '../shared/SortDropdown';
+import { useClientSort, SortConfig } from '@/hooks/useClientSort';
+
+const GRANT_SORT_OPTIONS = [
+ { value: 'deadline_asc', label: 'Deadline Soonest' },
+ { value: 'fit_desc', label: 'Best Fit' },
+ { value: 'amount_desc', label: 'Highest Amount' },
+ { value: 'status', label: 'Status A-Z' },
+ { value: 'date_desc', label: 'Newest First' },
+];
+
+const GRANT_SORT_CONFIGS: Record<string, SortConfig> = {
+ deadline_asc: { key: 'deadline', direction: 'asc', type: 'date' },
+ fit_desc: { key: 'ai_fit_score', direction: 'desc', type: 'number' },
+ amount_desc: { key: 'amount_max', direction: 'desc', type: 'number' },
+ status: { key: 'status', direction: 'asc', type: 'string' },
+ date_desc: { key: 'created_at', direction: 'desc', type: 'date' },
+};
+
+/* ─── Types ──────────────────────────────────────────────────────────────── */
+interface Grant {
+ id: string;
+ title: string;
+ funder: string;
+ funder_url: string | null;
+ amount_min: number | null;
+ amount_max: number | null;
+ description: string | null;
+ eligibility: string | null;
+ focus_areas: string[];
+ application_url: string | null;
+ deadline: string | null;
+ cycle: string | null;
+ ai_fit_score: number | null;
+ ai_suggestion: string | null;
+ status: string;
+ priority: string;
+ notes: string | null;
+ tags: string[];
+ is_bookmarked: boolean;
+ created_at: string;
+ [key: string]: unknown;
+}
+
+type StatusFilter = 'all' | 'discovered' | 'researching' | 'preparing' | 'submitted' | 'awarded';
+
+const STATUS_OPTIONS: { id: StatusFilter; label: string }[] = [
+ { id: 'all', label: 'All' },
+ { id: 'discovered', label: 'Discovered' },
+ { id: 'researching', label: 'Researching' },
+ { id: 'preparing', label: 'Preparing' },
+ { id: 'submitted', label: 'Submitted' },
+ { id: 'awarded', label: 'Awarded' },
+];
+
+const PRIORITY_COLORS: Record<string, string> = {
+ high: '#ef4444',
+ medium: '#f59e0b',
+ low: '#6b7280',
+};
+
+const STATUS_COLORS: Record<string, { bg: string; text: string; border: string }> = {
+ discovered: { bg: 'rgba(59,130,246,0.15)', text: '#60a5fa', border: 'rgba(59,130,246,0.3)' },
+ researching: { bg: 'rgba(168,85,247,0.15)', text: '#c084fc', border: 'rgba(168,85,247,0.3)' },
+ preparing: { bg: 'rgba(245,158,11,0.15)', text: '#fbbf24', border: 'rgba(245,158,11,0.3)' },
+ submitted: { bg: 'rgba(5,150,105,0.15)', text: '#34d399', border: 'rgba(5,150,105,0.3)' },
+ awarded: { bg: 'rgba(34,197,94,0.15)', text: '#22c55e', border: 'rgba(34,197,94,0.3)' },
+ declined: { bg: 'rgba(239,68,68,0.15)', text: '#ef4444', border: 'rgba(239,68,68,0.3)' },
+ expired: { bg: 'rgba(107,127,117,0.15)', text: '#6b7f75', border: 'rgba(107,127,117,0.3)' },
+};
+
+const QUICK_APPLY_STATUSES = ['discovered', 'researching', 'preparing'];
+
+/* ─── Helpers ────────────────────────────────────────────────────────────── */
+function formatCurrency(n: number | null): string {
+ if (n == null) return 'N/A';
+ return '$' + n.toLocaleString('en-US', { maximumFractionDigits: 0 });
+}
+
+function formatDate(d: string | null): string {
+ if (!d) return 'No deadline';
+ return new Date(d).toLocaleDateString('en-US', {
+ month: 'short',
+ day: 'numeric',
+ year: 'numeric',
+ });
+}
+
+function daysUntil(d: string | null): number | null {
+ if (!d) return null;
+ const diff = new Date(d).getTime() - Date.now();
+ return Math.ceil(diff / (1000 * 60 * 60 * 24));
+}
+
+/* ─── Deadline Badge ─────────────────────────────────────────────────────── */
+function DeadlineBadge({ deadline }: { deadline: string | null }) {
+ if (!deadline) return null;
+ const days = Math.ceil((new Date(deadline).getTime() - Date.now()) / 86400000);
+
+ if (days < 0)
+ return (
+ <span
+ style={{
+ fontSize: '0.6875rem',
+ fontWeight: 600,
+ padding: '2px 8px',
+ borderRadius: 6,
+ backgroundColor: 'rgba(239,68,68,0.15)',
+ color: '#f87171',
+ border: '1px solid rgba(239,68,68,0.3)',
+ }}
+ >
+ Overdue
+ </span>
+ );
+ if (days <= 7)
+ return (
+ <span
+ style={{
+ fontSize: '0.6875rem',
+ fontWeight: 600,
+ padding: '2px 8px',
+ borderRadius: 6,
+ backgroundColor: 'rgba(239,68,68,0.15)',
+ color: '#f87171',
+ border: '1px solid rgba(239,68,68,0.3)',
+ animation: 'pulse 2s infinite',
+ }}
+ >
+ {days}d left
+ </span>
+ );
+ if (days <= 30)
+ return (
+ <span
+ style={{
+ fontSize: '0.6875rem',
+ fontWeight: 600,
+ padding: '2px 8px',
+ borderRadius: 6,
+ backgroundColor: 'rgba(245,158,11,0.15)',
+ color: '#fbbf24',
+ border: '1px solid rgba(245,158,11,0.3)',
+ }}
+ >
+ {days}d left
+ </span>
+ );
+ return (
+ <span
+ style={{
+ fontSize: '0.6875rem',
+ fontWeight: 600,
+ padding: '2px 8px',
+ borderRadius: 6,
+ backgroundColor: 'rgba(16,185,129,0.15)',
+ color: '#34d399',
+ border: '1px solid rgba(16,185,129,0.3)',
+ }}
+ >
+ {days}d left
+ </span>
+ );
+}
+
+/* ─── Main Component ─────────────────────────────────────────────────────── */
+export default function GrantsTab() {
+ const { addToast } = useToast();
+ const [grants, setGrants] = useState<Grant[]>([]);
+ const [statusCounts, setStatusCounts] = useState<Record<string, number>>({});
+ const [loading, setLoading] = useState(true);
+ const [discovering, setDiscovering] = useState(false);
+ const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
+ const [search, setSearch] = useState('');
+ const debouncedSearch = useDebounce(search, 300);
+ const [expandedId, setExpandedId] = useState<string | null>(null);
+ const [showAddModal, setShowAddModal] = useState(false);
+ const [proposalGrant, setProposalGrant] = useState<Grant | null>(null);
+ const [sortBy, setSortBy] = useState('deadline_asc');
+ const sortedGrants = useClientSort(grants, sortBy, GRANT_SORT_CONFIGS);
+
+ const fetchGrants = useCallback(async () => {
+ try {
+ const params = new URLSearchParams();
+ if (statusFilter !== 'all') params.set('status', statusFilter);
+ if (debouncedSearch) params.set('search', debouncedSearch);
+ const res = await fetch(`/api/grants?${params}`, { credentials: 'include' });
+ if (res.ok) {
+ const data = await res.json();
+ setGrants(data.rows || []);
+ setStatusCounts(data.statusCounts || {});
+ }
+ } catch {
+ // fetch error handled by loading state
+ } finally {
+ setLoading(false);
+ }
+ }, [statusFilter, debouncedSearch]);
+
+ useEffect(() => {
+ fetchGrants();
+ }, [fetchGrants]);
+
+ const handleDiscover = async () => {
+ setDiscovering(true);
+ try {
+ const res = await fetch('/api/grants/discover', {
+ method: 'POST',
+ credentials: 'include',
+ });
+ if (res.ok) {
+ await fetchGrants();
+ addToast('New grants discovered', 'success');
+ } else {
+ addToast('Discovery failed', 'error');
+ }
+ } catch {
+ addToast('Discovery failed', 'error');
+ } finally {
+ setDiscovering(false);
+ }
+ };
+
+ const handleBookmark = async (id: string, current: boolean) => {
+ try {
+ await fetch('/api/grants', {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ body: JSON.stringify({ id, is_bookmarked: !current }),
+ });
+ setGrants((prev) =>
+ prev.map((g) => (g.id === id ? { ...g, is_bookmarked: !current } : g)),
+ );
+ addToast(current ? 'Bookmark removed' : 'Bookmarked', 'success');
+ } catch {
+ addToast('Bookmark failed', 'error');
+ }
+ };
+
+ const handleStatusChange = async (id: string, newStatus: string) => {
+ try {
+ await fetch('/api/grants', {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ body: JSON.stringify({ id, status: newStatus }),
+ });
+ // Update local state immediately
+ setGrants((prev) =>
+ prev.map((g) => (g.id === id ? { ...g, status: newStatus } : g)),
+ );
+ addToast(`Status updated to ${newStatus}`, 'success');
+ } catch {
+ addToast('Status update failed', 'error');
+ }
+ };
+
+ const handleProposalStatusChange = (id: string, newStatus: string) => {
+ setGrants((prev) =>
+ prev.map((g) => (g.id === id ? { ...g, status: newStatus } : g)),
+ );
+ };
+
+ return (
+ <div className="p-6 space-y-6">
+ {/* Header */}
+ <div className="flex items-center justify-between flex-wrap gap-3">
+ <div>
+ <h2 className="text-lg font-bold" style={{ color: 'var(--color-text)' }}>
+ Grant Discovery & Tracking
+ </h2>
+ <p className="text-sm mt-1" style={{ color: 'var(--color-text-muted)' }}>
+ Find, track, and apply for grant opportunities matched to your organization.
+ </p>
+ </div>
+ <div className="flex gap-2">
+ <button
+ className="btn btn-secondary"
+ onClick={handleDiscover}
+ disabled={discovering}
+ >
+ {discovering ? (
+ <Loader2 size={15} className="animate-spin" />
+ ) : (
+ <Sparkles size={15} />
+ )}
+ {discovering ? 'Discovering...' : 'AI Discover Grants'}
+ </button>
+ <button className="btn btn-primary" onClick={() => setShowAddModal(true)}>
+ <Plus size={15} />
+ Add Grant
+ </button>
+ </div>
+ </div>
+
+ {/* Search */}
+ <div className="relative">
+ <Search
+ size={16}
+ className="absolute left-3 top-1/2 -translate-y-1/2"
+ style={{ color: 'var(--color-text-muted)' }}
+ />
+ <input
+ type="text"
+ className="input"
+ placeholder="Search grants by name, funder, or keywords..."
+ aria-label="Search"
+ style={{ paddingLeft: '2.25rem' }}
+ value={search}
+ onChange={(e) => setSearch(e.target.value)}
+ />
+ </div>
+
+ {/* Sort + Status Filter Pills */}
+ <div className="flex gap-2 flex-wrap items-center">
+ <SortDropdown options={GRANT_SORT_OPTIONS} value={sortBy} onChange={setSortBy} />
+ {STATUS_OPTIONS.map(({ id, label }) => {
+ const count = statusCounts[id] ?? 0;
+ const isActive = statusFilter === id;
+ return (
+ <button
+ key={id}
+ className="btn btn-sm"
+ onClick={() => setStatusFilter(id)}
+ style={{
+ backgroundColor: isActive ? 'rgba(5,150,105,0.12)' : 'var(--color-surface-el)',
+ color: isActive ? '#34d399' : 'var(--color-text-secondary)',
+ border: isActive
+ ? '1px solid rgba(5,150,105,0.3)'
+ : '1px solid var(--color-border)',
+ }}
+ >
+ {label}
+ {count > 0 && (
+ <span
+ className="ml-1 text-xs"
+ style={{ opacity: 0.7 }}
+ >
+ ({count})
+ </span>
+ )}
+ </button>
+ );
+ })}
+ </div>
+
+ {/* Grant Cards */}
+ {loading ? (
+ <SkeletonList count={4} />
+ ) : sortedGrants.length === 0 ? (
+ <div className="card">
+ <div className="flex flex-col items-center justify-center py-16 text-center">
+ <div
+ className="w-14 h-14 rounded-xl flex items-center justify-center mb-4"
+ style={{
+ backgroundColor: 'var(--color-surface-el)',
+ border: '1px solid var(--color-border)',
+ }}
+ >
+ <Award size={24} style={{ color: 'var(--color-text-muted)' }} />
+ </div>
+ <h3 className="text-base font-semibold mb-2" style={{ color: 'var(--color-text)' }}>
+ No grants found
+ </h3>
+ <p className="text-sm max-w-md" style={{ color: 'var(--color-text-muted)' }}>
+ Click "AI Discover Grants" to find funding opportunities matched to your organization.
+ </p>
+ </div>
+ </div>
+ ) : (
+ <div className="space-y-3">
+ {sortedGrants.map((grant) => {
+ const isExpanded = expandedId === grant.id;
+ const statusStyle = STATUS_COLORS[grant.status] || STATUS_COLORS.discovered;
+ const fitPct = grant.ai_fit_score != null ? Math.round(grant.ai_fit_score * 100) : null;
+
+ return (
+ <div key={grant.id} className="card" style={{ padding: 0 }}>
+ {/* Main row */}
+ <div className="p-4">
+ <div className="flex items-start justify-between gap-3">
+ <div className="flex-1 min-w-0">
+ <div className="flex items-center gap-2 flex-wrap mb-1">
+ <h3
+ className="text-sm font-semibold truncate"
+ style={{ color: 'var(--color-text)' }}
+ >
+ {grant.title}
+ </h3>
+ <span
+ className="badge"
+ style={{
+ backgroundColor: statusStyle.bg,
+ color: statusStyle.text,
+ border: `1px solid ${statusStyle.border}`,
+ }}
+ >
+ {grant.status}
+ </span>
+ <span
+ className="badge"
+ style={{
+ backgroundColor: `${PRIORITY_COLORS[grant.priority]}18`,
+ color: PRIORITY_COLORS[grant.priority],
+ border: `1px solid ${PRIORITY_COLORS[grant.priority]}40`,
+ }}
+ >
+ {grant.priority}
+ </span>
+ </div>
+
+ <p className="text-xs mb-2" style={{ color: 'var(--color-text-secondary)' }}>
+ {grant.funder}
+ </p>
+
+ <div className="flex items-center gap-4 flex-wrap text-xs" style={{ color: 'var(--color-text-muted)' }}>
+ <span className="flex items-center gap-1">
+ <DollarSign size={12} />
+ {grant.amount_min || grant.amount_max
+ ? `${formatCurrency(grant.amount_min)} - ${formatCurrency(grant.amount_max)}`
+ : 'Amount TBD'}
+ </span>
+ <span className="flex items-center gap-1">
+ <Calendar size={12} />
+ {formatDate(grant.deadline)}
+ <DeadlineBadge deadline={grant.deadline} />
+ </span>
+ {fitPct != null && (
+ <span className="flex items-center gap-1">
+ <Sparkles size={12} style={{ color: '#34d399' }} />
+ <span style={{ color: fitPct >= 70 ? '#34d399' : fitPct >= 40 ? '#fbbf24' : '#6b7f75' }}>
+ {fitPct}% fit
+ </span>
+ </span>
+ )}
+ {grant.cycle && (
+ <span className="flex items-center gap-1">
+ <Clock size={12} />
+ {grant.cycle}
+ </span>
+ )}
+ </div>
+ </div>
+
+ <div className="flex items-center gap-1 shrink-0">
+ {/* Quick Apply */}
+ {QUICK_APPLY_STATUSES.includes(grant.status) && (
+ <button
+ className="btn btn-ghost btn-sm"
+ onClick={() => setProposalGrant(grant)}
+ title="Quick Apply"
+ aria-label={`Quick apply for ${grant.title}`}
+ >
+ <Send size={14} style={{ color: '#059669' }} />
+ </button>
+ )}
+ <button
+ className="btn btn-ghost btn-sm"
+ onClick={() => handleBookmark(grant.id, grant.is_bookmarked)}
+ title={grant.is_bookmarked ? 'Remove bookmark' : 'Bookmark'}
+ aria-label={grant.is_bookmarked ? `Remove bookmark from ${grant.title}` : `Bookmark ${grant.title}`}
+ >
+ {grant.is_bookmarked ? (
+ <BookmarkCheck size={16} style={{ color: '#f59e0b' }} />
+ ) : (
+ <Bookmark size={16} />
+ )}
+ </button>
+ <button
+ className="btn btn-ghost btn-sm"
+ onClick={() => setExpandedId(isExpanded ? null : grant.id)}
+ aria-label={isExpanded ? `Collapse ${grant.title}` : `Expand ${grant.title}`}
+ aria-expanded={isExpanded}
+ >
+ {isExpanded ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
+ </button>
+ </div>
+ </div>
+
+ {/* Tags */}
+ {grant.tags && grant.tags.length > 0 && (
+ <div className="flex gap-1 mt-2 flex-wrap">
+ {grant.tags.map((tag) => (
+ <span
+ key={tag}
+ className="text-xs px-2 py-0.5 rounded-full"
+ style={{
+ backgroundColor: 'rgba(5,150,105,0.1)',
+ color: '#34d399',
+ border: '1px solid rgba(5,150,105,0.2)',
+ }}
+ >
+ {tag}
+ </span>
+ ))}
+ </div>
+ )}
+ </div>
+
+ {/* Expanded content */}
+ {isExpanded && (
+ <div
+ className="px-4 pb-4 space-y-3"
+ style={{ borderTop: '1px solid var(--color-border)' }}
+ >
+ <div className="pt-3 grid grid-cols-1 md:grid-cols-2 gap-4">
+ {grant.description && (
+ <div>
+ <h4 className="text-xs font-semibold mb-1" style={{ color: 'var(--color-text-secondary)' }}>
+ Description
+ </h4>
+ <p className="text-xs leading-relaxed" style={{ color: 'var(--color-text-muted)' }}>
+ {grant.description}
+ </p>
+ </div>
+ )}
+ {grant.eligibility && (
+ <div>
+ <h4 className="text-xs font-semibold mb-1" style={{ color: 'var(--color-text-secondary)' }}>
+ Eligibility
+ </h4>
+ <p className="text-xs leading-relaxed" style={{ color: 'var(--color-text-muted)' }}>
+ {grant.eligibility}
+ </p>
+ </div>
+ )}
+ </div>
+
+ {grant.ai_suggestion && (
+ <div
+ className="p-3 rounded-lg"
+ style={{
+ backgroundColor: 'rgba(5,150,105,0.06)',
+ border: '1px solid rgba(5,150,105,0.15)',
+ }}
+ >
+ <h4 className="text-xs font-semibold mb-1 flex items-center gap-1" style={{ color: '#34d399' }}>
+ <Sparkles size={12} />
+ AI Suggestion
+ </h4>
+ <p className="text-xs leading-relaxed" style={{ color: 'var(--color-text-secondary)' }}>
+ {grant.ai_suggestion}
+ </p>
+ </div>
+ )}
+
+ {/* AI Fit Score Bar */}
+ {fitPct != null && (
+ <div>
+ <div className="flex items-center justify-between mb-1">
+ <span className="text-xs font-medium" style={{ color: 'var(--color-text-secondary)' }}>
+ AI Fit Score
+ </span>
+ <span className="text-xs font-bold" style={{ color: fitPct >= 70 ? '#34d399' : fitPct >= 40 ? '#fbbf24' : '#6b7f75' }}>
+ {fitPct}%
+ </span>
+ </div>
+ <div
+ className="h-1.5 rounded-full overflow-hidden"
+ style={{ backgroundColor: 'var(--color-surface-el)' }}
+ >
+ <div
+ className="h-full rounded-full transition-all"
+ style={{
+ width: `${fitPct}%`,
+ backgroundColor: fitPct >= 70 ? '#34d399' : fitPct >= 40 ? '#fbbf24' : '#6b7f75',
+ }}
+ />
+ </div>
+ </div>
+ )}
+
+ {/* Actions */}
+ <div className="flex gap-2 flex-wrap pt-1">
+ <button
+ className="btn btn-primary btn-sm"
+ onClick={() => setProposalGrant(grant)}
+ >
+ <FileText size={14} />
+ Write Proposal
+ </button>
+ {grant.application_url && (
+ <a
+ href={grant.application_url}
+ target="_blank"
+ rel="noopener noreferrer"
+ className="btn btn-secondary btn-sm"
+ >
+ <ExternalLink size={14} />
+ Apply
+ </a>
+ )}
+ <select
+ className="input"
+ style={{ width: 'auto', padding: '0.25rem 0.5rem', fontSize: '0.8125rem' }}
+ value={grant.status}
+ onChange={(e) => handleStatusChange(grant.id, e.target.value)}
+ aria-label={`Status for ${grant.title}`}
+ >
+ <option value="discovered">Discovered</option>
+ <option value="researching">Researching</option>
+ <option value="preparing">Preparing</option>
+ <option value="submitted">Submitted</option>
+ <option value="awarded">Awarded</option>
+ <option value="declined">Declined</option>
+ <option value="expired">Expired</option>
+ </select>
+ </div>
+ </div>
+ )}
+ </div>
+ );
+ })}
+ </div>
+ )}
+
+ {/* Add Grant Wizard */}
+ {showAddModal && (
+ <AddGrantWizard onClose={() => setShowAddModal(false)} onCreated={fetchGrants} />
+ )}
+
+ {/* Proposal Modal */}
+ {proposalGrant && (
+ <ProposalModal
+ grant={proposalGrant}
+ onClose={() => setProposalGrant(null)}
+ onStatusChange={handleProposalStatusChange}
+ />
+ )}
+ </div>
+ );
+}
diff --git a/components/grants/ProposalModal.tsx b/components/grants/ProposalModal.tsx
new file mode 100644
index 0000000..0e47702
--- /dev/null
+++ b/components/grants/ProposalModal.tsx
@@ -0,0 +1,621 @@
+'use client';
+
+import { useState, useEffect, useRef, useCallback } from 'react';
+import {
+ X,
+ Loader2,
+ Copy,
+ ExternalLink,
+ Sparkles,
+ FileText,
+ Pencil,
+ Check,
+ Send,
+} from 'lucide-react';
+import { useToast } from '../ToastProvider';
+
+interface Grant {
+ id: string;
+ title: string;
+ funder: string;
+ status: string;
+ [key: string]: unknown;
+}
+
+interface Proposal {
+ id: string;
+ subject: string;
+ body_html: string;
+ body_text: string;
+ proposal_type: string;
+ status: string;
+ version: number;
+ created_at: string;
+}
+
+const TYPE_LABELS: Record<string, string> = {
+ letter_of_inquiry: 'Letter of Inquiry',
+ full_proposal: 'Full Proposal',
+ one_pager: 'One-Pager',
+ meeting_request: 'Meeting Request',
+};
+
+function formatDate(d: string | null): string {
+ if (!d) return '';
+ return new Date(d).toLocaleDateString('en-US', {
+ month: 'short',
+ day: 'numeric',
+ year: 'numeric',
+ });
+}
+
+function stripHtml(html: string): string {
+ return html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
+}
+
+interface ProposalModalProps {
+ grant: Grant;
+ onClose: () => void;
+ onStatusChange: (id: string, status: string) => void;
+}
+
+export default function ProposalModal({
+ grant,
+ onClose,
+ onStatusChange,
+}: ProposalModalProps) {
+ const { addToast } = useToast();
+ const modalRef = useRef<HTMLDivElement>(null);
+ const previousFocusRef = useRef<HTMLElement | null>(null);
+
+ // Store previously focused element and focus the modal on mount
+ useEffect(() => {
+ previousFocusRef.current = document.activeElement as HTMLElement;
+ // Focus the modal container on mount
+ modalRef.current?.focus();
+ return () => {
+ // Restore focus on unmount
+ previousFocusRef.current?.focus();
+ };
+ }, []);
+
+ // Escape to close + focus trapping
+ const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
+ if (e.key === 'Escape') {
+ onClose();
+ return;
+ }
+ if (e.key === 'Tab' && modalRef.current) {
+ const focusable = modalRef.current.querySelectorAll<HTMLElement>(
+ 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
+ );
+ if (focusable.length === 0) return;
+ const first = focusable[0];
+ const last = focusable[focusable.length - 1];
+ if (e.shiftKey) {
+ if (document.activeElement === first) {
+ e.preventDefault();
+ last.focus();
+ }
+ } else {
+ if (document.activeElement === last) {
+ e.preventDefault();
+ first.focus();
+ }
+ }
+ }
+ }, [onClose]);
+
+ const [proposalType, setProposalType] = useState('letter_of_inquiry');
+ const [context, setContext] = useState('');
+ const [generating, setGenerating] = useState(false);
+ const [proposal, setProposal] = useState<Proposal | null>(null);
+ const [copied, setCopied] = useState(false);
+ const [existingProposals, setExistingProposals] = useState<Proposal[]>([]);
+ const [loadingExisting, setLoadingExisting] = useState(true);
+
+ // Editing state
+ const [isEditing, setIsEditing] = useState(false);
+ const [editBody, setEditBody] = useState('');
+
+ // Send & Submit confirmation flow
+ const [showSendConfirm, setShowSendConfirm] = useState(false);
+
+ // Submitting state
+ const [submitting, setSubmitting] = useState(false);
+
+ // Load existing proposals
+ useEffect(() => {
+ (async () => {
+ try {
+ const res = await fetch(`/api/grants/${grant.id}/proposals`, {
+ credentials: 'include',
+ });
+ if (res.ok) {
+ const data = await res.json();
+ setExistingProposals(data.proposals || []);
+ }
+ } catch {
+ // fetch error silently handled
+ } finally {
+ setLoadingExisting(false);
+ }
+ })();
+ }, [grant.id]);
+
+ const handleGenerate = async () => {
+ setGenerating(true);
+ try {
+ const res = await fetch(`/api/grants/${grant.id}/proposals`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ body: JSON.stringify({ proposal_type: proposalType, context }),
+ });
+ if (res.ok) {
+ const data = await res.json();
+ setProposal(data);
+ setExistingProposals((prev) => [data, ...prev]);
+ addToast('Proposal generated', 'success');
+ } else {
+ addToast('Proposal generation failed', 'error');
+ }
+ } catch {
+ addToast('Proposal generation failed', 'error');
+ } finally {
+ setGenerating(false);
+ }
+ };
+
+ const handleCopy = async (text: string) => {
+ try {
+ await navigator.clipboard.writeText(text);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ addToast('Copied to clipboard', 'success');
+ } catch {
+ const ta = document.createElement('textarea');
+ ta.value = text;
+ document.body.appendChild(ta);
+ ta.select();
+ document.execCommand('copy');
+ document.body.removeChild(ta);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ addToast('Copied to clipboard', 'success');
+ }
+ };
+
+ const handleOpenEmail = (subject: string, bodyText: string) => {
+ const mailtoUrl = `mailto:?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(bodyText)}`;
+ window.open(mailtoUrl, '_blank');
+ };
+
+ const handleStartEdit = () => {
+ if (!proposal) return;
+ setEditBody(proposal.body_text || stripHtml(proposal.body_html));
+ setIsEditing(true);
+ };
+
+ const handleSaveEdit = () => {
+ if (!proposal) return;
+ // Update local state (PATCH route may not exist yet)
+ setProposal({ ...proposal, body_text: editBody });
+ // Also update in existing proposals list
+ setExistingProposals((prev) =>
+ prev.map((p) =>
+ p.id === proposal.id ? { ...p, body_text: editBody } : p,
+ ),
+ );
+ setIsEditing(false);
+ addToast('Proposal updated', 'success');
+ };
+
+ const handleCancelEdit = () => {
+ setIsEditing(false);
+ setEditBody('');
+ };
+
+ const handleMarkSubmitted = async () => {
+ setSubmitting(true);
+ try {
+ const res = await fetch('/api/grants', {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ body: JSON.stringify({
+ id: grant.id,
+ status: 'submitted',
+ applied_at: new Date().toISOString(),
+ }),
+ });
+ if (res.ok) {
+ addToast('Grant marked as submitted', 'success');
+ onStatusChange(grant.id, 'submitted');
+ onClose();
+ } else {
+ addToast('Failed to update status', 'error');
+ }
+ } catch {
+ addToast('Failed to update status', 'error');
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ const handleSendAndSubmit = (subject: string, bodyText: string) => {
+ handleOpenEmail(subject, bodyText);
+ setShowSendConfirm(true);
+ };
+
+ const canSubmit =
+ grant.status !== 'submitted' && grant.status !== 'awarded';
+
+ const bodyText = proposal
+ ? proposal.body_text || stripHtml(proposal.body_html)
+ : '';
+
+ return (
+ <div
+ className="fixed inset-0 z-50 flex items-center justify-center p-4"
+ style={{ backgroundColor: 'rgba(0,0,0,0.6)' }}
+ onClick={onClose}
+ role="dialog"
+ aria-modal="true"
+ aria-labelledby="proposal-modal-title"
+ onKeyDown={handleKeyDown}
+ >
+ <div
+ ref={modalRef}
+ className="card w-full max-w-2xl max-h-[90vh] overflow-auto"
+ style={{ backgroundColor: 'var(--color-surface)' }}
+ onClick={(e) => e.stopPropagation()}
+ tabIndex={-1}
+ >
+ {/* Header */}
+ <div className="flex items-center justify-between mb-4">
+ <div>
+ <h3
+ id="proposal-modal-title"
+ className="text-base font-semibold"
+ style={{ color: 'var(--color-text)' }}
+ >
+ Write Proposal
+ </h3>
+ <p
+ className="text-xs mt-0.5"
+ style={{ color: 'var(--color-text-muted)' }}
+ >
+ {grant.title}
+ </p>
+ </div>
+ <button className="btn btn-ghost btn-sm" onClick={onClose} aria-label="Close dialog">
+ <X size={16} />
+ </button>
+ </div>
+
+ {/* Generate Form */}
+ {!proposal && (
+ <div className="space-y-3">
+ <div>
+ <label
+ className="block text-xs font-medium mb-1"
+ style={{ color: 'var(--color-text-secondary)' }}
+ >
+ Proposal Type
+ </label>
+ <select
+ className="input"
+ value={proposalType}
+ onChange={(e) => setProposalType(e.target.value)}
+ >
+ {Object.entries(TYPE_LABELS).map(([val, label]) => (
+ <option key={val} value={val}>
+ {label}
+ </option>
+ ))}
+ </select>
+ </div>
+ <div>
+ <label
+ className="block text-xs font-medium mb-1"
+ style={{ color: 'var(--color-text-secondary)' }}
+ >
+ Additional Context (optional)
+ </label>
+ <textarea
+ className="input"
+ rows={3}
+ placeholder="Any specific points to include, recent accomplishments, specific programs to highlight..."
+ value={context}
+ onChange={(e) => setContext(e.target.value)}
+ style={{ resize: 'vertical' }}
+ />
+ </div>
+ <button
+ className="btn btn-primary w-full"
+ onClick={handleGenerate}
+ disabled={generating}
+ >
+ {generating ? (
+ <Loader2 size={15} className="animate-spin" />
+ ) : (
+ <Sparkles size={15} />
+ )}
+ {generating ? 'Generating with AI...' : 'Generate Proposal'}
+ </button>
+ </div>
+ )}
+
+ {/* Generated Proposal */}
+ {proposal && (
+ <div className="space-y-3">
+ {/* Subject */}
+ <div
+ className="p-3 rounded-lg"
+ style={{
+ backgroundColor: 'var(--color-surface-el)',
+ border: '1px solid var(--color-border)',
+ }}
+ >
+ <div className="flex items-center justify-between mb-2">
+ <h4
+ className="text-xs font-semibold"
+ style={{ color: 'var(--color-text-secondary)' }}
+ >
+ Subject
+ </h4>
+ <span className="badge badge-primary">
+ {TYPE_LABELS[proposal.proposal_type] ||
+ proposal.proposal_type}
+ </span>
+ </div>
+ <p
+ className="text-sm font-medium"
+ style={{ color: 'var(--color-text)' }}
+ >
+ {proposal.subject}
+ </p>
+ </div>
+
+ {/* Body — read-only or editable */}
+ <div
+ className="p-4 rounded-lg"
+ style={{
+ backgroundColor: 'var(--color-surface-el)',
+ border: '1px solid var(--color-border)',
+ }}
+ >
+ <div className="flex items-center justify-between mb-2">
+ <h4
+ className="text-xs font-semibold"
+ style={{ color: 'var(--color-text-secondary)' }}
+ >
+ Body
+ </h4>
+ {!isEditing && (
+ <button
+ className="btn btn-ghost btn-sm"
+ onClick={handleStartEdit}
+ title="Edit proposal body"
+ >
+ <Pencil size={13} />
+ <span className="text-xs">Edit</span>
+ </button>
+ )}
+ </div>
+
+ {isEditing ? (
+ <div>
+ <textarea
+ className="input"
+ rows={12}
+ value={editBody}
+ onChange={(e) => setEditBody(e.target.value)}
+ aria-label="Edit proposal body"
+ style={{
+ resize: 'vertical',
+ fontSize: '0.8125rem',
+ lineHeight: '1.6',
+ }}
+ />
+ <div className="flex gap-2 mt-2">
+ <button
+ className="btn btn-primary btn-sm"
+ onClick={handleSaveEdit}
+ >
+ <Check size={13} />
+ Save
+ </button>
+ <button
+ className="btn btn-secondary btn-sm"
+ onClick={handleCancelEdit}
+ >
+ Cancel
+ </button>
+ </div>
+ </div>
+ ) : (
+ <div
+ style={{
+ color: 'var(--color-text-secondary)',
+ fontSize: '0.8125rem',
+ lineHeight: '1.6',
+ }}
+ >
+ {bodyText
+ .split('\n')
+ .map((line: string, i: number) => (
+ <p
+ key={i}
+ style={{
+ marginBottom: line.trim() ? '0.5rem' : '0.25rem',
+ }}
+ >
+ {line || '\u00A0'}
+ </p>
+ ))}
+ </div>
+ )}
+ </div>
+
+ {/* Action buttons */}
+ <div className="flex gap-2">
+ <button
+ className="btn btn-secondary flex-1"
+ onClick={() => handleCopy(bodyText)}
+ >
+ <Copy size={14} />
+ {copied ? 'Copied!' : 'Copy to Clipboard'}
+ </button>
+ <button
+ className="btn btn-primary flex-1"
+ onClick={() =>
+ handleSendAndSubmit(proposal.subject, bodyText)
+ }
+ >
+ <Send size={14} />
+ Send & Submit
+ </button>
+ </div>
+
+ {/* Send confirmation bar */}
+ {showSendConfirm && (
+ <div
+ className="p-3 rounded-lg flex items-center justify-between"
+ style={{
+ backgroundColor: 'rgba(5,150,105,0.08)',
+ border: '1px solid rgba(5,150,105,0.2)',
+ }}
+ >
+ <span
+ className="text-xs font-medium"
+ style={{ color: 'var(--color-text-secondary)' }}
+ >
+ Did you send the email?
+ </span>
+ <div className="flex gap-2">
+ <button
+ className="btn btn-sm"
+ style={{
+ backgroundColor: '#059669',
+ color: '#fff',
+ border: '1px solid #059669',
+ }}
+ onClick={handleMarkSubmitted}
+ disabled={submitting}
+ >
+ {submitting ? (
+ <Loader2 size={12} className="animate-spin" />
+ ) : (
+ <Check size={12} />
+ )}
+ Yes, Mark Submitted
+ </button>
+ <button
+ className="btn btn-ghost btn-sm"
+ onClick={() => setShowSendConfirm(false)}
+ >
+ Not Yet
+ </button>
+ </div>
+ </div>
+ )}
+
+ {/* Mark as Submitted standalone */}
+ {canSubmit && !showSendConfirm && (
+ <button
+ className="btn w-full"
+ style={{
+ backgroundColor: '#059669',
+ color: '#fff',
+ border: '1px solid #059669',
+ }}
+ onClick={handleMarkSubmitted}
+ disabled={submitting}
+ >
+ {submitting ? (
+ <Loader2 size={15} className="animate-spin" />
+ ) : (
+ <FileText size={15} />
+ )}
+ {submitting ? 'Updating...' : 'Mark as Submitted'}
+ </button>
+ )}
+
+ <button
+ className="btn btn-ghost btn-sm w-full"
+ onClick={() => {
+ setProposal(null);
+ setIsEditing(false);
+ setShowSendConfirm(false);
+ }}
+ >
+ Generate Another
+ </button>
+ </div>
+ )}
+
+ {/* Existing Proposals */}
+ {existingProposals.length > 0 && !proposal && (
+ <div
+ className="mt-4 pt-4"
+ style={{ borderTop: '1px solid var(--color-border)' }}
+ >
+ <h4
+ className="text-xs font-semibold mb-2"
+ style={{ color: 'var(--color-text-secondary)' }}
+ >
+ Previous Proposals ({existingProposals.length})
+ </h4>
+ <div className="space-y-2">
+ {existingProposals.map((p) => (
+ <button
+ key={p.id}
+ className="w-full text-left p-2 rounded-lg"
+ style={{
+ backgroundColor: 'var(--color-surface-el)',
+ border: '1px solid var(--color-border)',
+ }}
+ onClick={() => setProposal(p)}
+ >
+ <div className="flex items-center justify-between">
+ <span
+ className="text-xs font-medium truncate"
+ style={{ color: 'var(--color-text)' }}
+ >
+ {p.subject}
+ </span>
+ <span
+ className="badge badge-info"
+ style={{ fontSize: '0.625rem' }}
+ >
+ v{p.version}
+ </span>
+ </div>
+ <span
+ className="text-xs"
+ style={{ color: 'var(--color-text-muted)' }}
+ >
+ {TYPE_LABELS[p.proposal_type] || p.proposal_type} -{' '}
+ {formatDate(p.created_at)}
+ </span>
+ </button>
+ ))}
+ </div>
+ </div>
+ )}
+
+ {loadingExisting && !proposal && (
+ <div className="flex justify-center py-4">
+ <Loader2
+ size={16}
+ className="animate-spin"
+ style={{ color: 'var(--color-text-muted)' }}
+ />
+ </div>
+ )}
+ </div>
+ </div>
+ );
+}
diff --git a/components/news/NewsTab.tsx b/components/news/NewsTab.tsx
new file mode 100644
index 0000000..9ad63c1
--- /dev/null
+++ b/components/news/NewsTab.tsx
@@ -0,0 +1,368 @@
+'use client';
+
+import { useState, useEffect, useCallback } from 'react';
+import { SkeletonList } from '../Skeleton';
+import {
+ Newspaper,
+ Search,
+ Sparkles,
+ ExternalLink,
+ Loader2,
+ Calendar,
+ Tag,
+ User,
+} from 'lucide-react';
+import { useToast } from '../ToastProvider';
+import { useDebounce } from '@/hooks/useDebounce';
+import SortDropdown from '../shared/SortDropdown';
+import { useClientSort, SortConfig } from '@/hooks/useClientSort';
+
+const NEWS_SORT_OPTIONS = [
+ { value: 'date_desc', label: 'Newest First' },
+ { value: 'date_asc', label: 'Oldest First' },
+ { value: 'relevance_desc', label: 'Most Relevant' },
+ { value: 'outlet', label: 'Outlet A-Z' },
+];
+
+const NEWS_SORT_CONFIGS: Record<string, SortConfig> = {
+ date_desc: { key: 'published_at', direction: 'desc', type: 'date' },
+ date_asc: { key: 'published_at', direction: 'asc', type: 'date' },
+ relevance_desc: { key: 'relevance_score', direction: 'desc', type: 'number' },
+ outlet: { key: 'outlet', direction: 'asc', type: 'string' },
+};
+
+/* ─── Types ──────────────────────────────────────────────────────────────── */
+interface NewsItem {
+ id: string;
+ headline: string;
+ outlet: string | null;
+ url: string | null;
+ published_at: string | null;
+ summary: string | null;
+ tags: string[];
+ relevance_score: number | null;
+ author_name: string | null;
+ source_type: string | null;
+ created_at: string;
+}
+
+const SOURCE_TYPES = [
+ { id: 'all', label: 'All Sources' },
+ { id: 'google_news', label: 'News' },
+ { id: 'press_release', label: 'Press Release' },
+ { id: 'policy_brief', label: 'Policy Brief' },
+ { id: 'academic', label: 'Academic' },
+ { id: 'industry', label: 'Industry' },
+];
+
+const OUTLET_COLORS: Record<string, string> = {
+ 'NPR': '#e01e5a',
+ 'The Washington Post': '#2b2b2b',
+ 'Inside Higher Ed': '#006699',
+ 'The Hill': '#2e68ab',
+ 'Reuters': '#ff8000',
+ 'CNBC': '#005689',
+ 'Politico': '#e81c23',
+ 'The New York Times': '#1a1a1a',
+ 'Forbes': '#b5121b',
+ 'AP News': '#ed1c24',
+};
+
+function formatDate(d: string | null): string {
+ if (!d) return 'Unknown date';
+ return new Date(d).toLocaleDateString('en-US', {
+ month: 'short',
+ day: 'numeric',
+ year: 'numeric',
+ });
+}
+
+/* ─── Main Component ─────────────────────────────────────────────────────── */
+export default function NewsTab() {
+ const { addToast } = useToast();
+ const [news, setNews] = useState<NewsItem[]>([]);
+ const [loading, setLoading] = useState(true);
+ const [discovering, setDiscovering] = useState(false);
+ const [search, setSearch] = useState('');
+ const debouncedSearch = useDebounce(search, 300);
+ const [sourceFilter, setSourceFilter] = useState('all');
+ const [sortBy, setSortBy] = useState('date_desc');
+ const sortedNews = useClientSort(news, sortBy, NEWS_SORT_CONFIGS);
+
+ const fetchNews = useCallback(async () => {
+ try {
+ const params = new URLSearchParams();
+ if (debouncedSearch) params.set('search', debouncedSearch);
+ if (sourceFilter !== 'all') params.set('source_type', sourceFilter);
+ const res = await fetch(`/api/news?${params}`, { credentials: 'include' });
+ if (res.ok) {
+ const data = await res.json();
+ setNews(data.rows || []);
+ }
+ } catch {
+ // fetch error handled by loading state
+ } finally {
+ setLoading(false);
+ }
+ }, [debouncedSearch, sourceFilter]);
+
+ useEffect(() => {
+ fetchNews();
+ }, [fetchNews]);
+
+ const handleDiscover = async () => {
+ setDiscovering(true);
+ try {
+ const res = await fetch('/api/news/discover', {
+ method: 'POST',
+ credentials: 'include',
+ });
+ if (res.ok) {
+ await fetchNews();
+ addToast('News articles discovered', 'success');
+ } else {
+ addToast('News discovery failed', 'error');
+ }
+ } catch {
+ addToast('News discovery failed', 'error');
+ } finally {
+ setDiscovering(false);
+ }
+ };
+
+ return (
+ <div className="p-6 space-y-6">
+ {/* Header */}
+ <div className="flex items-center justify-between flex-wrap gap-3">
+ <div>
+ <h2 className="text-lg font-bold" style={{ color: 'var(--color-text)' }}>
+ News Monitoring
+ </h2>
+ <p className="text-sm mt-1" style={{ color: 'var(--color-text-muted)' }}>
+ AI-curated news relevant to your mission, funders, and sector.
+ </p>
+ </div>
+ <button
+ className="btn btn-secondary"
+ onClick={handleDiscover}
+ disabled={discovering}
+ >
+ {discovering ? (
+ <Loader2 size={15} className="animate-spin" />
+ ) : (
+ <Sparkles size={15} />
+ )}
+ {discovering ? 'Discovering...' : 'AI Discover News'}
+ </button>
+ </div>
+
+ {/* Search */}
+ <div className="relative">
+ <Search
+ size={16}
+ className="absolute left-3 top-1/2 -translate-y-1/2"
+ style={{ color: 'var(--color-text-muted)' }}
+ />
+ <input
+ type="text"
+ className="input"
+ placeholder="Search news articles..."
+ aria-label="Search"
+ style={{ paddingLeft: '2.25rem' }}
+ value={search}
+ onChange={(e) => setSearch(e.target.value)}
+ />
+ </div>
+
+ {/* Sort + Source Type Filter */}
+ <div className="flex gap-2 flex-wrap items-center">
+ <SortDropdown options={NEWS_SORT_OPTIONS} value={sortBy} onChange={setSortBy} />
+ {SOURCE_TYPES.map(({ id, label }) => {
+ const isActive = sourceFilter === id;
+ return (
+ <button
+ key={id}
+ className="btn btn-sm"
+ onClick={() => setSourceFilter(id)}
+ style={{
+ backgroundColor: isActive ? 'rgba(5,150,105,0.12)' : 'var(--color-surface-el)',
+ color: isActive ? '#34d399' : 'var(--color-text-secondary)',
+ border: isActive
+ ? '1px solid rgba(5,150,105,0.3)'
+ : '1px solid var(--color-border)',
+ }}
+ >
+ {label}
+ </button>
+ );
+ })}
+ </div>
+
+ {/* News Cards */}
+ {loading ? (
+ <SkeletonList count={4} />
+ ) : sortedNews.length === 0 ? (
+ <div className="card">
+ <div className="flex flex-col items-center justify-center py-16 text-center">
+ <div
+ className="w-14 h-14 rounded-xl flex items-center justify-center mb-4"
+ style={{
+ backgroundColor: 'var(--color-surface-el)',
+ border: '1px solid var(--color-border)',
+ }}
+ >
+ <Newspaper size={24} style={{ color: 'var(--color-text-muted)' }} />
+ </div>
+ <h3 className="text-base font-semibold mb-2" style={{ color: 'var(--color-text)' }}>
+ No news articles yet
+ </h3>
+ <p className="text-sm max-w-md" style={{ color: 'var(--color-text-muted)' }}>
+ Click "AI Discover News" to find relevant articles about student debt, education policy, and nonprofit funding.
+ </p>
+ </div>
+ </div>
+ ) : (
+ <div className="space-y-3">
+ {sortedNews.map((item) => {
+ const relevancePct = item.relevance_score != null
+ ? Math.round(item.relevance_score * 100)
+ : null;
+ const outletColor = item.outlet
+ ? OUTLET_COLORS[item.outlet] || '#059669'
+ : '#6b7f75';
+
+ return (
+ <div key={item.id} className="card" style={{ padding: '1rem' }}>
+ <div className="flex items-start justify-between gap-3">
+ <div className="flex-1 min-w-0">
+ {/* Headline */}
+ <div className="flex items-center gap-2 flex-wrap mb-1">
+ {item.url ? (
+ <a
+ href={item.url}
+ target="_blank"
+ rel="noopener noreferrer"
+ className="text-sm font-semibold hover:underline"
+ style={{ color: 'var(--color-text)' }}
+ >
+ {item.headline}
+ </a>
+ ) : (
+ <h3 className="text-sm font-semibold" style={{ color: 'var(--color-text)' }}>
+ {item.headline}
+ </h3>
+ )}
+ {item.url && (
+ <a
+ href={item.url}
+ target="_blank"
+ rel="noopener noreferrer"
+ className="shrink-0"
+ >
+ <ExternalLink size={12} style={{ color: 'var(--color-text-muted)' }} />
+ </a>
+ )}
+ </div>
+
+ {/* Meta row */}
+ <div className="flex items-center gap-3 flex-wrap text-xs mb-2" style={{ color: 'var(--color-text-muted)' }}>
+ {item.outlet && (
+ <span
+ className="px-2 py-0.5 rounded-full font-semibold text-xs"
+ style={{
+ backgroundColor: `${outletColor}20`,
+ color: outletColor,
+ border: `1px solid ${outletColor}40`,
+ }}
+ >
+ {item.outlet}
+ </span>
+ )}
+ <span className="flex items-center gap-1">
+ <Calendar size={11} />
+ {formatDate(item.published_at)}
+ </span>
+ {item.author_name && (
+ <span className="flex items-center gap-1">
+ <User size={11} />
+ {item.author_name}
+ </span>
+ )}
+ {item.source_type && (
+ <span className="flex items-center gap-1">
+ <Tag size={11} />
+ {item.source_type.replace(/_/g, ' ')}
+ </span>
+ )}
+ </div>
+
+ {/* Summary */}
+ {item.summary && (
+ <p className="text-xs leading-relaxed mb-2" style={{ color: 'var(--color-text-secondary)' }}>
+ {item.summary}
+ </p>
+ )}
+
+ {/* Tags */}
+ {item.tags && item.tags.length > 0 && (
+ <div className="flex gap-1 flex-wrap">
+ {item.tags.map((tag) => (
+ <span
+ key={tag}
+ className="text-xs px-2 py-0.5 rounded-full"
+ style={{
+ backgroundColor: 'rgba(59,130,246,0.1)',
+ color: '#60a5fa',
+ border: '1px solid rgba(59,130,246,0.2)',
+ }}
+ >
+ {tag}
+ </span>
+ ))}
+ </div>
+ )}
+ </div>
+
+ {/* Relevance Score */}
+ {relevancePct != null && (
+ <div className="shrink-0 text-center">
+ <div
+ className="w-12 h-12 rounded-lg flex items-center justify-center text-xs font-bold"
+ style={{
+ backgroundColor:
+ relevancePct >= 80
+ ? 'rgba(34,197,94,0.15)'
+ : relevancePct >= 50
+ ? 'rgba(245,158,11,0.15)'
+ : 'rgba(107,127,117,0.15)',
+ color:
+ relevancePct >= 80
+ ? '#22c55e'
+ : relevancePct >= 50
+ ? '#f59e0b'
+ : '#6b7f75',
+ border: `1px solid ${
+ relevancePct >= 80
+ ? 'rgba(34,197,94,0.3)'
+ : relevancePct >= 50
+ ? 'rgba(245,158,11,0.3)'
+ : 'rgba(107,127,117,0.3)'
+ }`,
+ }}
+ >
+ {relevancePct}%
+ </div>
+ <span className="text-xs mt-0.5 block" style={{ color: 'var(--color-text-muted)', fontSize: '0.625rem' }}>
+ relevance
+ </span>
+ </div>
+ )}
+ </div>
+ </div>
+ );
+ })}
+ </div>
+ )}
+ </div>
+ );
+}
diff --git a/components/outreach/OutreachTab.tsx b/components/outreach/OutreachTab.tsx
new file mode 100644
index 0000000..96b17fe
--- /dev/null
+++ b/components/outreach/OutreachTab.tsx
@@ -0,0 +1,625 @@
+'use client';
+
+import { useState, useEffect, useCallback, useRef } from 'react';
+import { SkeletonList } from '../Skeleton';
+import {
+ Megaphone,
+ Mail,
+ FileText,
+ Heart,
+ ArrowRight,
+ Send,
+ Plus,
+ Sparkles,
+ Loader2,
+ X,
+ Copy,
+ ExternalLink,
+ ChevronDown,
+ ChevronUp,
+} from 'lucide-react';
+import { useToast } from '../ToastProvider';
+import SortDropdown from '../shared/SortDropdown';
+import { useClientSort, SortConfig } from '@/hooks/useClientSort';
+
+const OUTREACH_SORT_OPTIONS = [
+ { value: 'date_desc', label: 'Newest First' },
+ { value: 'date_asc', label: 'Oldest First' },
+ { value: 'type', label: 'Type A-Z' },
+];
+
+const OUTREACH_SORT_CONFIGS: Record<string, SortConfig> = {
+ date_desc: { key: 'created_at', direction: 'desc', type: 'date' },
+ date_asc: { key: 'created_at', direction: 'asc', type: 'date' },
+ type: { key: 'template_type', direction: 'asc', type: 'string' },
+};
+
+/* ─── Types ──────────────────────────────────────────────────────────────── */
+interface OutreachTemplate {
+ id: string;
+ template_type: string;
+ target_type: string | null;
+ title: string;
+ subject: string | null;
+ body_html: string;
+ body_text: string | null;
+ is_ai_generated: boolean;
+ version: number;
+ created_at: string;
+}
+
+const TEMPLATE_TYPES: { Icon: React.ElementType; label: string; desc: string; type: string }[] = [
+ { Icon: Mail, label: 'Meeting Request', desc: 'Request a meeting with a funder or partner', type: 'meeting_request' },
+ { Icon: FileText, label: 'One-Pager', desc: 'Generate a concise organization summary', type: 'one_pager' },
+ { Icon: Heart, label: 'Thank You', desc: 'Send gratitude to donors and supporters', type: 'thank_you' },
+ { Icon: ArrowRight, label: 'Follow Up', desc: 'Follow up on prior conversations', type: 'follow_up' },
+ { Icon: Send, label: 'Introduction', desc: 'Introduce your organization to a new contact', type: 'introduction' },
+ { Icon: Megaphone, label: 'Donation Ask', desc: 'Request financial support from donors', type: 'donation_ask' },
+];
+
+const TYPE_LABELS: Record<string, string> = {
+ meeting_request: 'Meeting Request',
+ one_pager: 'One-Pager',
+ thank_you: 'Thank You',
+ follow_up: 'Follow Up',
+ introduction: 'Introduction',
+ donation_ask: 'Donation Ask',
+};
+
+const TARGET_LABELS: Record<string, string> = {
+ official: 'Official',
+ corporation: 'Corporation',
+ nonprofit: 'Nonprofit',
+ donor: 'Donor',
+ general: 'General',
+};
+
+const TYPE_BADGE_COLORS: Record<string, string> = {
+ meeting_request: '#3b82f6',
+ one_pager: '#8b5cf6',
+ thank_you: '#ec4899',
+ follow_up: '#f59e0b',
+ introduction: '#059669',
+ donation_ask: '#ef4444',
+};
+
+function formatDate(d: string): string {
+ return new Date(d).toLocaleDateString('en-US', {
+ month: 'short',
+ day: 'numeric',
+ year: 'numeric',
+ });
+}
+
+function stripHtml(html: string): string {
+ return html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
+}
+
+/* ─── Main Component ─────────────────────────────────────────────────────── */
+export default function OutreachTab() {
+ const [templates, setTemplates] = useState<OutreachTemplate[]>([]);
+ const [loading, setLoading] = useState(true);
+ const [showGenerateModal, setShowGenerateModal] = useState<string | null>(null);
+ const [expandedId, setExpandedId] = useState<string | null>(null);
+ const [sortBy, setSortBy] = useState('date_desc');
+ const sortedTemplates = useClientSort(templates, sortBy, OUTREACH_SORT_CONFIGS);
+
+ const fetchTemplates = useCallback(async () => {
+ try {
+ const res = await fetch('/api/outreach', { credentials: 'include' });
+ if (res.ok) {
+ const data = await res.json();
+ setTemplates(data.rows || []);
+ }
+ } catch {
+ // fetch error handled by loading state
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ fetchTemplates();
+ }, [fetchTemplates]);
+
+ return (
+ <div className="p-6 space-y-6">
+ {/* Header */}
+ <div className="flex items-center justify-between flex-wrap gap-3">
+ <div>
+ <h2 className="text-lg font-bold" style={{ color: 'var(--color-text)' }}>
+ Outreach Tools
+ </h2>
+ <p className="text-sm mt-1" style={{ color: 'var(--color-text-muted)' }}>
+ AI-generated templates and tools for effective donor and partner outreach.
+ </p>
+ </div>
+ <div className="flex items-center gap-2">
+ <SortDropdown options={OUTREACH_SORT_OPTIONS} value={sortBy} onChange={setSortBy} />
+ <button className="btn btn-primary" onClick={() => setShowGenerateModal('introduction')}>
+ <Plus size={15} />
+ New Template
+ </button>
+ </div>
+ </div>
+
+ {/* Template Type Grid */}
+ <div>
+ <h3 className="text-sm font-semibold mb-3" style={{ color: 'var(--color-text)' }}>
+ Generate from Template
+ </h3>
+ <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
+ {TEMPLATE_TYPES.map(({ Icon, label, desc, type }) => (
+ <button
+ key={type}
+ className="flex items-start gap-3 p-4 rounded-lg text-left w-full"
+ style={{
+ backgroundColor: 'var(--color-surface)',
+ border: '1px solid var(--color-border)',
+ transition: 'border-color 0.15s, background-color 0.15s',
+ cursor: 'pointer',
+ }}
+ onClick={() => setShowGenerateModal(type)}
+ onMouseEnter={(e) => {
+ e.currentTarget.style.borderColor = 'rgba(5,150,105,0.4)';
+ }}
+ onMouseLeave={(e) => {
+ e.currentTarget.style.borderColor = 'var(--color-border)';
+ }}
+ >
+ <div
+ className="w-9 h-9 rounded-lg flex items-center justify-center shrink-0"
+ style={{
+ backgroundColor: 'rgba(5,150,105,0.12)',
+ border: '1px solid rgba(5,150,105,0.2)',
+ }}
+ >
+ <Icon size={16} style={{ color: '#34d399' }} />
+ </div>
+ <div>
+ <div className="text-sm font-medium" style={{ color: 'var(--color-text)' }}>
+ {label}
+ </div>
+ <div className="text-xs mt-0.5" style={{ color: 'var(--color-text-muted)' }}>
+ {desc}
+ </div>
+ </div>
+ </button>
+ ))}
+ </div>
+ </div>
+
+ {/* Saved Templates */}
+ <div>
+ <div className="flex items-center gap-2 mb-3">
+ <FileText size={16} style={{ color: 'var(--color-primary)' }} />
+ <h3 className="text-sm font-semibold" style={{ color: 'var(--color-text)' }}>
+ Saved Templates ({templates.length})
+ </h3>
+ </div>
+
+ {loading ? (
+ <SkeletonList count={3} />
+ ) : sortedTemplates.length === 0 ? (
+ <div className="card">
+ <div className="flex flex-col items-center justify-center py-12 text-center">
+ <div
+ className="w-12 h-12 rounded-xl flex items-center justify-center mb-3"
+ style={{
+ backgroundColor: 'var(--color-surface-el)',
+ border: '1px solid var(--color-border)',
+ }}
+ >
+ <Megaphone size={20} style={{ color: 'var(--color-text-muted)' }} />
+ </div>
+ <p className="text-sm" style={{ color: 'var(--color-text-muted)' }}>
+ No saved templates yet
+ </p>
+ <p className="text-xs mt-1" style={{ color: 'var(--color-text-muted)' }}>
+ Generated outreach templates will be saved here for reuse
+ </p>
+ </div>
+ </div>
+ ) : (
+ <div className="space-y-3">
+ {sortedTemplates.map((t) => {
+ const isExpanded = expandedId === t.id;
+ const badgeColor = TYPE_BADGE_COLORS[t.template_type] || '#059669';
+ const plainText = t.body_text || stripHtml(t.body_html);
+
+ return (
+ <div key={t.id} className="card" style={{ padding: 0 }}>
+ <div className="p-4">
+ <div className="flex items-start justify-between gap-3">
+ <div className="flex-1 min-w-0">
+ <div className="flex items-center gap-2 flex-wrap mb-1">
+ <h4 className="text-sm font-semibold truncate" style={{ color: 'var(--color-text)' }}>
+ {t.title}
+ </h4>
+ <span
+ className="badge"
+ style={{
+ backgroundColor: `${badgeColor}20`,
+ color: badgeColor,
+ border: `1px solid ${badgeColor}40`,
+ }}
+ >
+ {TYPE_LABELS[t.template_type] || t.template_type}
+ </span>
+ {t.target_type && (
+ <span className="badge badge-info">
+ {TARGET_LABELS[t.target_type] || t.target_type}
+ </span>
+ )}
+ {t.is_ai_generated && (
+ <Sparkles size={12} style={{ color: '#34d399' }} />
+ )}
+ </div>
+
+ {t.subject && (
+ <p className="text-xs mb-1" style={{ color: 'var(--color-text-secondary)' }}>
+ Subject: {t.subject}
+ </p>
+ )}
+
+ {!isExpanded && (
+ <p className="text-xs truncate" style={{ color: 'var(--color-text-muted)' }}>
+ {plainText.substring(0, 150)}...
+ </p>
+ )}
+
+ <span className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
+ {formatDate(t.created_at)}
+ </span>
+ </div>
+ <button
+ className="btn btn-ghost btn-sm shrink-0"
+ onClick={() => setExpandedId(isExpanded ? null : t.id)}
+ aria-label={isExpanded ? `Collapse ${t.title}` : `Expand ${t.title}`}
+ aria-expanded={isExpanded}
+ >
+ {isExpanded ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
+ </button>
+ </div>
+ </div>
+
+ {isExpanded && (
+ <div
+ className="px-4 pb-4 space-y-3"
+ style={{ borderTop: '1px solid var(--color-border)' }}
+ >
+ <div
+ className="p-4 rounded-lg mt-3"
+ style={{
+ backgroundColor: 'var(--color-surface-el)',
+ border: '1px solid var(--color-border)',
+ color: 'var(--color-text-secondary)',
+ fontSize: '0.8125rem',
+ lineHeight: '1.6',
+ }}
+ >
+ {plainText.split('\n').map((line, i) => (
+ <p key={i} style={{ marginBottom: line.trim() ? '0.5rem' : '0.25rem' }}>
+ {line || '\u00A0'}
+ </p>
+ ))}
+ </div>
+ <div className="flex gap-2">
+ <button
+ className="btn btn-secondary btn-sm flex-1"
+ onClick={async () => {
+ try {
+ await navigator.clipboard.writeText(plainText);
+ } catch {
+ const ta = document.createElement('textarea');
+ ta.value = plainText;
+ document.body.appendChild(ta);
+ ta.select();
+ document.execCommand('copy');
+ document.body.removeChild(ta);
+ }
+ }}
+ >
+ <Copy size={14} />
+ Copy
+ </button>
+ <button
+ className="btn btn-primary btn-sm flex-1"
+ onClick={() => {
+ window.open(
+ `mailto:?subject=${encodeURIComponent(t.subject || '')}&body=${encodeURIComponent(plainText)}`,
+ '_blank',
+ );
+ }}
+ >
+ <ExternalLink size={14} />
+ Open in Email
+ </button>
+ </div>
+ </div>
+ )}
+ </div>
+ );
+ })}
+ </div>
+ )}
+ </div>
+
+ {/* Generate Modal */}
+ {showGenerateModal && (
+ <GenerateTemplateModal
+ defaultType={showGenerateModal}
+ onClose={() => setShowGenerateModal(null)}
+ onCreated={fetchTemplates}
+ />
+ )}
+ </div>
+ );
+}
+
+/* ─── Generate Template Modal ────────────────────────────────────────────── */
+function GenerateTemplateModal({
+ defaultType,
+ onClose,
+ onCreated,
+}: {
+ defaultType: string;
+ onClose: () => void;
+ onCreated: () => void;
+}) {
+ const { addToast } = useToast();
+ const modalRef = useRef<HTMLDivElement>(null);
+ const previousFocusRef = useRef<HTMLElement | null>(null);
+
+ // Store previously focused element and focus the modal on mount
+ useEffect(() => {
+ previousFocusRef.current = document.activeElement as HTMLElement;
+ modalRef.current?.focus();
+ return () => {
+ previousFocusRef.current?.focus();
+ };
+ }, []);
+
+ // Escape to close + focus trapping
+ const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
+ if (e.key === 'Escape') {
+ onClose();
+ return;
+ }
+ if (e.key === 'Tab' && modalRef.current) {
+ const focusable = modalRef.current.querySelectorAll<HTMLElement>(
+ 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
+ );
+ if (focusable.length === 0) return;
+ const first = focusable[0];
+ const last = focusable[focusable.length - 1];
+ if (e.shiftKey) {
+ if (document.activeElement === first) {
+ e.preventDefault();
+ last.focus();
+ }
+ } else {
+ if (document.activeElement === last) {
+ e.preventDefault();
+ first.focus();
+ }
+ }
+ }
+ }, [onClose]);
+
+ const [templateType, setTemplateType] = useState(defaultType);
+ const [targetType, setTargetType] = useState('general');
+ const [targetName, setTargetName] = useState('');
+ const [targetOrg, setTargetOrg] = useState('');
+ const [context, setContext] = useState('');
+ const [generating, setGenerating] = useState(false);
+ const [result, setResult] = useState<{ subject: string; body_text: string } | null>(null);
+ const [copied, setCopied] = useState(false);
+
+ const handleGenerate = async () => {
+ setGenerating(true);
+ try {
+ const res = await fetch('/api/outreach/generate', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ body: JSON.stringify({
+ target_name: targetName || undefined,
+ target_org: targetOrg || undefined,
+ target_type: targetType,
+ template_type: templateType,
+ context: context || undefined,
+ }),
+ });
+ if (res.ok) {
+ const data = await res.json();
+ setResult({ subject: data.subject, body_text: data.body_text });
+ onCreated();
+ addToast('Template generated', 'success');
+ } else {
+ addToast('Generation failed', 'error');
+ }
+ } catch {
+ addToast('Generation failed', 'error');
+ } finally {
+ setGenerating(false);
+ }
+ };
+
+ const handleCopy = async () => {
+ if (!result) return;
+ try {
+ await navigator.clipboard.writeText(result.body_text);
+ } catch {
+ const ta = document.createElement('textarea');
+ ta.value = result.body_text;
+ document.body.appendChild(ta);
+ ta.select();
+ document.execCommand('copy');
+ document.body.removeChild(ta);
+ }
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ addToast('Copied to clipboard', 'success');
+ };
+
+ return (
+ <div
+ className="fixed inset-0 z-50 flex items-center justify-center p-4"
+ style={{ backgroundColor: 'rgba(0,0,0,0.6)' }}
+ onClick={onClose}
+ role="dialog"
+ aria-modal="true"
+ aria-labelledby="generate-template-modal-title"
+ onKeyDown={handleKeyDown}
+ >
+ <div
+ ref={modalRef}
+ className="card w-full max-w-2xl max-h-[90vh] overflow-auto"
+ style={{ backgroundColor: 'var(--color-surface)' }}
+ onClick={(e) => e.stopPropagation()}
+ tabIndex={-1}
+ >
+ <div className="flex items-center justify-between mb-4">
+ <h3 id="generate-template-modal-title" className="text-base font-semibold" style={{ color: 'var(--color-text)' }}>
+ Generate Outreach Template
+ </h3>
+ <button className="btn btn-ghost btn-sm" onClick={onClose} aria-label="Close dialog">
+ <X size={16} />
+ </button>
+ </div>
+
+ {!result ? (
+ <div className="space-y-3">
+ <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
+ <div>
+ <label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>
+ Template Type
+ </label>
+ <select
+ className="input"
+ value={templateType}
+ onChange={(e) => setTemplateType(e.target.value)}
+ >
+ {Object.entries(TYPE_LABELS).map(([val, label]) => (
+ <option key={val} value={val}>{label}</option>
+ ))}
+ </select>
+ </div>
+ <div>
+ <label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>
+ Target Type
+ </label>
+ <select
+ className="input"
+ value={targetType}
+ onChange={(e) => setTargetType(e.target.value)}
+ >
+ {Object.entries(TARGET_LABELS).map(([val, label]) => (
+ <option key={val} value={val}>{label}</option>
+ ))}
+ </select>
+ </div>
+ </div>
+ <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
+ <div>
+ <label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>
+ Contact Name
+ </label>
+ <input
+ className="input"
+ placeholder="John Smith"
+ value={targetName}
+ onChange={(e) => setTargetName(e.target.value)}
+ />
+ </div>
+ <div>
+ <label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>
+ Organization
+ </label>
+ <input
+ className="input"
+ placeholder="Ford Foundation"
+ value={targetOrg}
+ onChange={(e) => setTargetOrg(e.target.value)}
+ />
+ </div>
+ </div>
+ <div>
+ <label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>
+ Context (optional)
+ </label>
+ <textarea
+ className="input"
+ rows={3}
+ placeholder="Any context about the relationship, recent events, specific ask..."
+ value={context}
+ onChange={(e) => setContext(e.target.value)}
+ style={{ resize: 'vertical' }}
+ />
+ </div>
+ <button
+ className="btn btn-primary w-full"
+ onClick={handleGenerate}
+ disabled={generating}
+ >
+ {generating ? <Loader2 size={15} className="animate-spin" /> : <Sparkles size={15} />}
+ {generating ? 'Generating...' : 'Generate Template'}
+ </button>
+ </div>
+ ) : (
+ <div className="space-y-3">
+ <div
+ className="p-3 rounded-lg"
+ style={{ backgroundColor: 'var(--color-surface-el)', border: '1px solid var(--color-border)' }}
+ >
+ <h4 className="text-xs font-semibold mb-1" style={{ color: 'var(--color-text-secondary)' }}>
+ Subject
+ </h4>
+ <p className="text-sm font-medium" style={{ color: 'var(--color-text)' }}>
+ {result.subject}
+ </p>
+ </div>
+ <div
+ className="p-4 rounded-lg"
+ style={{
+ backgroundColor: 'var(--color-surface-el)',
+ border: '1px solid var(--color-border)',
+ color: 'var(--color-text-secondary)',
+ fontSize: '0.8125rem',
+ lineHeight: '1.6',
+ }}
+ >
+ {result.body_text.split('\n').map((line, i) => (
+ <p key={i} style={{ marginBottom: line.trim() ? '0.5rem' : '0.25rem' }}>
+ {line || '\u00A0'}
+ </p>
+ ))}
+ </div>
+ <div className="flex gap-2">
+ <button className="btn btn-secondary flex-1" onClick={handleCopy}>
+ <Copy size={14} />
+ {copied ? 'Copied!' : 'Copy to Clipboard'}
+ </button>
+ <button
+ className="btn btn-primary flex-1"
+ onClick={() => {
+ window.open(
+ `mailto:?subject=${encodeURIComponent(result.subject)}&body=${encodeURIComponent(result.body_text)}`,
+ '_blank',
+ );
+ }}
+ >
+ <ExternalLink size={14} />
+ Open in Email
+ </button>
+ </div>
+ <button className="btn btn-ghost btn-sm w-full" onClick={() => setResult(null)}>
+ Generate Another
+ </button>
+ </div>
+ )}
+ </div>
+ </div>
+ );
+}
diff --git a/components/settings/SettingsTab.tsx b/components/settings/SettingsTab.tsx
new file mode 100644
index 0000000..db65696
--- /dev/null
+++ b/components/settings/SettingsTab.tsx
@@ -0,0 +1,203 @@
+'use client';
+
+import { Settings, Building2, User, Bell, Database } from 'lucide-react';
+
+export default function SettingsTab() {
+ return (
+ <div className="p-6 space-y-6">
+ {/* Header */}
+ <div>
+ <h2
+ className="text-lg font-bold"
+ style={{ color: 'var(--color-text)' }}
+ >
+ Settings
+ </h2>
+ <p className="text-sm mt-1" style={{ color: 'var(--color-text-muted)' }}>
+ Manage your organization profile, account settings, and integrations.
+ </p>
+ </div>
+
+ {/* Settings Sections */}
+ <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
+ {/* Organization Profile */}
+ <div className="card">
+ <div className="flex items-center gap-2 mb-4">
+ <Building2 size={16} style={{ color: 'var(--color-primary)' }} aria-hidden="true" />
+ <h3
+ className="text-sm font-semibold"
+ style={{ color: 'var(--color-text)' }}
+ >
+ Organization Profile
+ </h3>
+ </div>
+ <div className="space-y-3">
+ <div>
+ <label
+ className="block text-xs font-medium mb-1"
+ style={{ color: 'var(--color-text-secondary)' }}
+ >
+ Organization Name
+ </label>
+ <input
+ type="text"
+ className="input"
+ placeholder="Your non-profit name"
+ disabled
+ />
+ </div>
+ <div>
+ <label
+ className="block text-xs font-medium mb-1"
+ style={{ color: 'var(--color-text-secondary)' }}
+ >
+ Website
+ </label>
+ <input
+ type="url"
+ className="input"
+ placeholder="https://yourorg.org"
+ disabled
+ />
+ </div>
+ <div>
+ <label
+ className="block text-xs font-medium mb-1"
+ style={{ color: 'var(--color-text-secondary)' }}
+ >
+ Non-Profit Status
+ </label>
+ <select className="input" disabled>
+ <option>Planning</option>
+ <option>501(c)(3)</option>
+ <option>501(c)(4)</option>
+ <option>Other</option>
+ </select>
+ </div>
+ <p className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
+ Complete the onboarding wizard to set up your organization profile.
+ </p>
+ </div>
+ </div>
+
+ {/* Account */}
+ <div className="card">
+ <div className="flex items-center gap-2 mb-4">
+ <User size={16} style={{ color: 'var(--color-primary)' }} aria-hidden="true" />
+ <h3
+ className="text-sm font-semibold"
+ style={{ color: 'var(--color-text)' }}
+ >
+ Account
+ </h3>
+ </div>
+ <div className="space-y-3">
+ <div
+ className="flex items-center justify-between p-3 rounded-lg"
+ style={{
+ backgroundColor: 'var(--color-surface-el)',
+ border: '1px solid var(--color-border)',
+ }}
+ >
+ <div>
+ <div className="text-sm font-medium" style={{ color: 'var(--color-text)' }}>
+ Admin Account
+ </div>
+ <div className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
+ admin
+ </div>
+ </div>
+ <span className="badge badge-success">Active</span>
+ </div>
+ <div
+ className="flex items-center justify-between p-3 rounded-lg"
+ style={{
+ backgroundColor: 'var(--color-surface-el)',
+ border: '1px solid var(--color-border)',
+ }}
+ >
+ <div>
+ <div className="text-sm font-medium" style={{ color: 'var(--color-text)' }}>
+ Google Sign-In
+ </div>
+ <div className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
+ Connect your Google account for Gmail integration
+ </div>
+ </div>
+ <span className="badge badge-warning">Coming Soon</span>
+ </div>
+ </div>
+ </div>
+
+ {/* Notifications */}
+ <div className="card">
+ <div className="flex items-center gap-2 mb-4">
+ <Bell size={16} style={{ color: 'var(--color-primary)' }} aria-hidden="true" />
+ <h3
+ className="text-sm font-semibold"
+ style={{ color: 'var(--color-text)' }}
+ >
+ Notifications
+ </h3>
+ </div>
+ <div className="flex flex-col items-center justify-center py-8 text-center">
+ <p className="text-sm" style={{ color: 'var(--color-text-muted)' }}>
+ Notification preferences coming soon
+ </p>
+ </div>
+ </div>
+
+ {/* Data & Integrations */}
+ <div className="card">
+ <div className="flex items-center gap-2 mb-4">
+ <Database size={16} style={{ color: 'var(--color-primary)' }} aria-hidden="true" />
+ <h3
+ className="text-sm font-semibold"
+ style={{ color: 'var(--color-text)' }}
+ >
+ Data & Integrations
+ </h3>
+ </div>
+ <div className="space-y-2">
+ <div
+ className="flex items-center justify-between p-3 rounded-lg"
+ style={{
+ backgroundColor: 'var(--color-surface-el)',
+ border: '1px solid var(--color-border)',
+ }}
+ >
+ <div className="text-sm" style={{ color: 'var(--color-text)' }}>
+ Grants.gov API
+ </div>
+ <span className="badge badge-warning">Planned</span>
+ </div>
+ <div
+ className="flex items-center justify-between p-3 rounded-lg"
+ style={{
+ backgroundColor: 'var(--color-surface-el)',
+ border: '1px solid var(--color-border)',
+ }}
+ >
+ <div className="text-sm" style={{ color: 'var(--color-text)' }}>
+ Google Drive
+ </div>
+ <span className="badge badge-warning">Planned</span>
+ </div>
+ <div
+ className="flex items-center justify-between p-3 rounded-lg"
+ style={{
+ backgroundColor: 'var(--color-surface-el)',
+ border: '1px solid var(--color-border)',
+ }}
+ >
+ <div className="text-sm" style={{ color: 'var(--color-text)' }}>
+ Gmail
+ </div>
+ <span className="badge badge-warning">Planned</span>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+}
diff --git a/components/shared/SortDropdown.tsx b/components/shared/SortDropdown.tsx
new file mode 100644
index 0000000..6f9349b
--- /dev/null
+++ b/components/shared/SortDropdown.tsx
@@ -0,0 +1,42 @@
+'use client';
+
+import { ArrowUpDown } from 'lucide-react';
+
+interface SortOption {
+ value: string;
+ label: string;
+}
+
+interface SortDropdownProps {
+ options: SortOption[];
+ value: string;
+ onChange: (value: string) => void;
+}
+
+export default function SortDropdown({ options, value, onChange }: SortDropdownProps) {
+ return (
+ <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
+ <ArrowUpDown size={14} style={{ color: 'var(--color-text-muted)', flexShrink: 0 }} />
+ <select
+ value={value}
+ onChange={(e) => onChange(e.target.value)}
+ aria-label="Sort order"
+ style={{
+ backgroundColor: 'var(--color-surface-el)',
+ color: 'var(--color-text)',
+ border: '1px solid var(--color-border)',
+ borderRadius: 8,
+ padding: '6px 10px',
+ fontSize: '0.75rem',
+ fontWeight: 500,
+ cursor: 'pointer',
+ outline: 'none',
+ }}
+ >
+ {options.map((opt) => (
+ <option key={opt.value} value={opt.value}>{opt.label}</option>
+ ))}
+ </select>
+ </div>
+ );
+}
diff --git a/db/schema.sql b/db/schema.sql
new file mode 100644
index 0000000..cbb1351
--- /dev/null
+++ b/db/schema.sql
@@ -0,0 +1,195 @@
+-- Grant App Database Schema
+-- Database: grant_app
+-- Connection: postgresql://dw_admin@127.0.0.1:5432/ # password in .env.local (DATABASE_URL)
+-- grant_app
+
+-- Requires: CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
+
+CREATE TABLE users (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ google_id TEXT UNIQUE,
+ email TEXT NOT NULL UNIQUE,
+ display_name TEXT,
+ avatar_url TEXT,
+ phone TEXT,
+ role TEXT NOT NULL DEFAULT 'member' CHECK (role IN ('owner', 'admin', 'member')),
+ org_id UUID,
+ gmail_tokens JSONB,
+ drive_tokens JSONB,
+ last_login_at TIMESTAMPTZ,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE TABLE organizations (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ name TEXT NOT NULL,
+ address TEXT,
+ city TEXT,
+ state TEXT,
+ zip TEXT,
+ phone TEXT,
+ website_url TEXT,
+ nonprofit_status TEXT NOT NULL DEFAULT 'planning' CHECK (nonprofit_status IN ('have_501c3', 'have_501c4', 'planning', 'other')),
+ ein TEXT,
+ onboarding_step INTEGER NOT NULL DEFAULT 0,
+ onboarding_complete BOOLEAN NOT NULL DEFAULT false,
+ funding_losing_money INTEGER DEFAULT 0,
+ funding_free_overhead INTEGER DEFAULT 0,
+ funding_working_alone INTEGER DEFAULT 0,
+ funding_donations INTEGER DEFAULT 0,
+ funding_grants INTEGER DEFAULT 0,
+ funding_intern_hours INTEGER DEFAULT 0,
+ staff_count INTEGER DEFAULT 1,
+ staff_names TEXT[],
+ ai_mission TEXT,
+ ai_focus_areas TEXT[],
+ ai_keywords TEXT[],
+ ai_org_summary TEXT,
+ ai_profile_json JSONB,
+ site_scraped_at TIMESTAMPTZ,
+ sample_donation_email TEXT,
+ sample_petition_email TEXT,
+ sample_general_email TEXT,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+ALTER TABLE users ADD CONSTRAINT fk_users_org FOREIGN KEY (org_id) REFERENCES organizations(id);
+CREATE INDEX idx_users_org ON users(org_id);
+
+CREATE TABLE grants (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
+ title TEXT NOT NULL,
+ funder TEXT NOT NULL,
+ funder_url TEXT,
+ amount_min NUMERIC,
+ amount_max NUMERIC,
+ description TEXT,
+ eligibility TEXT,
+ focus_areas TEXT[],
+ application_url TEXT,
+ deadline TIMESTAMPTZ,
+ cycle TEXT CHECK (cycle IN ('annual', 'biannual', 'rolling', 'one-time')),
+ ai_fit_score REAL,
+ ai_suggestion TEXT,
+ status TEXT NOT NULL DEFAULT 'discovered' CHECK (status IN ('discovered', 'researching', 'preparing', 'submitted', 'awarded', 'declined', 'expired')),
+ priority TEXT NOT NULL DEFAULT 'medium' CHECK (priority IN ('high', 'medium', 'low')),
+ notes TEXT,
+ applied_at TIMESTAMPTZ,
+ amount_requested NUMERIC,
+ amount_awarded NUMERIC,
+ tags TEXT[],
+ is_bookmarked BOOLEAN DEFAULT false,
+ grants_gov_id TEXT,
+ federal_register_id TEXT,
+ source_api TEXT,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX idx_grants_org ON grants(org_id);
+CREATE INDEX idx_grants_deadline ON grants(deadline);
+
+CREATE TABLE grant_proposals (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ grant_id UUID NOT NULL REFERENCES grants(id) ON DELETE CASCADE,
+ org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
+ subject TEXT NOT NULL,
+ recipient_email TEXT,
+ recipient_name TEXT,
+ body_html TEXT NOT NULL,
+ body_text TEXT,
+ proposal_type TEXT DEFAULT 'letter_of_inquiry' CHECK (proposal_type IN ('letter_of_inquiry', 'full_proposal', 'one_pager', 'meeting_request')),
+ status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'review', 'sent', 'archived')),
+ version INTEGER NOT NULL DEFAULT 1,
+ sent_at TIMESTAMPTZ,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE TABLE news_items (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
+ headline TEXT NOT NULL,
+ outlet TEXT,
+ url TEXT,
+ published_at TIMESTAMPTZ,
+ summary TEXT,
+ tags TEXT[],
+ relevance_score REAL,
+ author_name TEXT,
+ author_linkedin TEXT,
+ is_used BOOLEAN DEFAULT false,
+ source_type TEXT DEFAULT 'google_news',
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX idx_news_org ON news_items(org_id);
+
+CREATE TABLE collaborations (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
+ collab_type TEXT NOT NULL CHECK (collab_type IN ('nonprofit', 'politician', 'corporation', 'municipality')),
+ name TEXT NOT NULL,
+ title TEXT,
+ organization TEXT,
+ website_url TEXT,
+ email TEXT,
+ phone TEXT,
+ district TEXT,
+ state TEXT,
+ ai_reason TEXT,
+ ai_relevance REAL,
+ ai_talking_points TEXT[],
+ status TEXT NOT NULL DEFAULT 'suggested' CHECK (status IN ('suggested', 'contacted', 'in_discussion', 'partnered', 'declined', 'archived')),
+ notes TEXT,
+ last_contacted TIMESTAMPTZ,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX idx_collabs_org ON collaborations(org_id);
+CREATE INDEX idx_collabs_type ON collaborations(collab_type);
+
+CREATE TABLE outreach_templates (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
+ template_type TEXT NOT NULL CHECK (template_type IN ('meeting_request', 'one_pager', 'thank_you', 'follow_up', 'introduction', 'donation_ask')),
+ target_type TEXT CHECK (target_type IN ('official', 'corporation', 'nonprofit', 'donor', 'general')),
+ title TEXT NOT NULL,
+ subject TEXT,
+ body_html TEXT NOT NULL,
+ body_text TEXT,
+ is_ai_generated BOOLEAN DEFAULT true,
+ version INTEGER DEFAULT 1,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE TABLE audit_events (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ org_id UUID,
+ user_id UUID,
+ event_type TEXT NOT NULL,
+ entity_type TEXT NOT NULL,
+ entity_id UUID,
+ metadata JSONB,
+ ip_address TEXT,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX idx_audit_org ON audit_events(org_id);
+
+-- Updated_at triggers
+CREATE OR REPLACE FUNCTION update_updated_at_column() RETURNS TRIGGER AS $$ BEGIN NEW.updated_at = NOW(); RETURN NEW; END; $$ LANGUAGE plpgsql;
+
+CREATE TRIGGER tr_users_updated BEFORE UPDATE ON users FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
+CREATE TRIGGER tr_orgs_updated BEFORE UPDATE ON organizations FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
+CREATE TRIGGER tr_grants_updated BEFORE UPDATE ON grants FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
+CREATE TRIGGER tr_proposals_updated BEFORE UPDATE ON grant_proposals FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
+CREATE TRIGGER tr_news_updated BEFORE UPDATE ON news_items FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
+CREATE TRIGGER tr_collabs_updated BEFORE UPDATE ON collaborations FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
+CREATE TRIGGER tr_templates_updated BEFORE UPDATE ON outreach_templates FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
diff --git a/ecosystem.config.js b/ecosystem.config.js
new file mode 100644
index 0000000..92c8489
--- /dev/null
+++ b/ecosystem.config.js
@@ -0,0 +1,15 @@
+module.exports = {
+ apps: [{
+ name: 'grant-app',
+ script: 'node_modules/.bin/next',
+ args: 'start -p 7450',
+ cwd: '/root/Projects/Grant',
+ env: {
+ NODE_ENV: 'production',
+ PORT: '7450',
+ },
+ max_memory_restart: '512M',
+ instances: 1,
+ autorestart: true,
+ }],
+};
diff --git a/hooks/useClientSort.ts b/hooks/useClientSort.ts
new file mode 100644
index 0000000..8cd5401
--- /dev/null
+++ b/hooks/useClientSort.ts
@@ -0,0 +1,34 @@
+import { useMemo } from 'react';
+
+export interface SortConfig {
+ key: string;
+ direction: 'asc' | 'desc';
+ type: 'string' | 'number' | 'date';
+}
+
+export function useClientSort<T>(
+ items: T[],
+ sortBy: string,
+ configs: Record<string, SortConfig>
+): T[] {
+ return useMemo(() => {
+ const config = configs[sortBy];
+ if (!config || !items.length) return items;
+ return [...items].sort((a, b) => {
+ const aVal = (a as Record<string, unknown>)[config.key];
+ const bVal = (b as Record<string, unknown>)[config.key];
+ if (aVal == null && bVal == null) return 0;
+ if (aVal == null) return 1;
+ if (bVal == null) return -1;
+ let cmp = 0;
+ if (config.type === 'date') {
+ cmp = new Date(aVal as string).getTime() - new Date(bVal as string).getTime();
+ } else if (config.type === 'number') {
+ cmp = (Number(aVal) || 0) - (Number(bVal) || 0);
+ } else {
+ cmp = String(aVal).localeCompare(String(bVal));
+ }
+ return config.direction === 'desc' ? -cmp : cmp;
+ });
+ }, [items, sortBy, configs]);
+}
diff --git a/hooks/useDebounce.ts b/hooks/useDebounce.ts
new file mode 100644
index 0000000..5f07bef
--- /dev/null
+++ b/hooks/useDebounce.ts
@@ -0,0 +1,10 @@
+import { useState, useEffect } from 'react';
+
+export function useDebounce<T>(value: T, delay: number = 300): T {
+ const [debounced, setDebounced] = useState(value);
+ useEffect(() => {
+ const timer = setTimeout(() => setDebounced(value), delay);
+ return () => clearTimeout(timer);
+ }, [value, delay]);
+ return debounced;
+}
diff --git a/lib/audit.ts b/lib/audit.ts
new file mode 100644
index 0000000..abe6cba
--- /dev/null
+++ b/lib/audit.ts
@@ -0,0 +1,35 @@
+import { query } from './db';
+
+/**
+ * Log an audit event to the audit_events table.
+ *
+ * @param eventType - e.g. 'grant.created', 'login', 'org.updated'
+ * @param entityType - e.g. 'grant', 'user', 'organization'
+ * @param entityId - primary key of the affected entity (nullable)
+ * @param orgId - organization ID for multi-tenant scoping (nullable)
+ * @param userId - user who performed the action (nullable)
+ * @param metadata - arbitrary JSON payload with extra context
+ * @param ipAddress - client IP if available
+ */
+export async function auditLog(
+ eventType: string,
+ entityType: string,
+ entityId: string | null,
+ orgId?: string | null,
+ userId?: string | null,
+ metadata?: Record<string, unknown>,
+ ipAddress?: string,
+): Promise<void> {
+ const metaJson = metadata ? JSON.stringify(metadata) : null;
+
+ try {
+ await query(
+ `INSERT INTO audit_events (event_type, entity_type, entity_id, org_id, user_id, metadata, ip_address)
+ VALUES ($1, $2, $3, $4, $5, $6, $7)`,
+ [eventType, entityType, entityId, orgId ?? null, userId ?? null, metaJson, ipAddress ?? null],
+ );
+ } catch (err) {
+ // Audit failures should never crash the calling operation
+ console.error('[audit] Failed to write audit event:', (err as Error).message);
+ }
+}
diff --git a/lib/auth.ts b/lib/auth.ts
new file mode 100644
index 0000000..2c06b3e
--- /dev/null
+++ b/lib/auth.ts
@@ -0,0 +1,31 @@
+import { createAuth, type AuthSession } from '@dw/nextjs-admin-login';
+import { query } from './db';
+
+export const auth = createAuth({ cookieName: 'grant-auth' });
+export const {
+ COOKIE_NAME: AUTH_COOKIE_NAME,
+ createSession,
+ verifyAuth,
+ buildAuthCookie,
+ buildLogoutCookie,
+ hashPassword,
+ // 2026-05-05 (tick 19): v0.2.0 — org-scoped session helpers.
+ // verifyAuthWithOrg accepts BOTH legacy 3-part tokens (returns orgId:null)
+ // and new 4-part tokens (returns orgId:string), so existing logged-in
+ // browsers don't get bumped.
+ createSessionWithOrg,
+ verifyAuthWithOrg,
+} = auth;
+export type { AuthSession };
+
+/**
+ * Resolve org_id for the current request. Prefers the session cookie's
+ * embedded orgId (v0.2.0 fast path); falls back to `SELECT id FROM
+ * organizations LIMIT 1` for legacy v1 sessions or pre-onboarding state.
+ * Returns null if neither path produces an org row.
+ */
+export async function resolveOrgId(session: AuthSession): Promise<string | null> {
+ if (session.orgId) return session.orgId;
+ const orgRes = await query('SELECT id FROM organizations LIMIT 1');
+ return orgRes.rows.length > 0 ? orgRes.rows[0].id : null;
+}
diff --git a/lib/db.ts b/lib/db.ts
new file mode 100644
index 0000000..443c7b5
--- /dev/null
+++ b/lib/db.ts
@@ -0,0 +1,45 @@
+import { Pool, PoolClient, QueryResult, QueryResultRow } from 'pg';
+
+const pool = new Pool({
+ connectionString: process.env.DATABASE_URL,
+ max: 10,
+ idleTimeoutMillis: 30000,
+ connectionTimeoutMillis: 5000,
+});
+
+// Log pool errors so they don't crash the process
+pool.on('error', (err: Error) => {
+ console.error('[db] Unexpected pool error:', err.message);
+});
+
+/**
+ * Execute a parameterized query against the pool.
+ * Returns the full QueryResult so callers can read .rows, .rowCount, etc.
+ */
+export async function query<T extends QueryResultRow = QueryResultRow>(
+ text: string,
+ params?: unknown[],
+): Promise<QueryResult<T>> {
+ const start = Date.now();
+ try {
+ const result = await pool.query<T>(text, params);
+ const duration = Date.now() - start;
+ if (duration > 2000) {
+ console.warn(`[db] Slow query (${duration}ms):`, text.slice(0, 120));
+ }
+ return result;
+ } catch (err) {
+ console.error('[db] Query error:', (err as Error).message, '\n SQL:', text.slice(0, 200));
+ throw err;
+ }
+}
+
+/**
+ * Acquire a dedicated client from the pool -- use for transactions.
+ * IMPORTANT: Always call client.release() in a finally block.
+ */
+export async function getClient(): Promise<PoolClient> {
+ return pool.connect();
+}
+
+export default pool;
diff --git a/lib/gemini.ts b/lib/gemini.ts
new file mode 100644
index 0000000..7f34b21
--- /dev/null
+++ b/lib/gemini.ts
@@ -0,0 +1,111 @@
+/**
+ * Gemini 2.0 Flash wrapper — single source of truth for the 5 AI call sites
+ * across Grant (grants/discover, collaborations/discover, news/discover,
+ * outreach/generate, grants/[id]/proposals).
+ *
+ * 2026-05-04: extracted by architect-reviewer's recommendation. The previous
+ * 5x duplication (key + URL + AbortSignal + fetch + parse + fence-strip) made
+ * any cross-cutting change (Gemini 2.5 bump, token metering, rate-limit) a
+ * 5-file change. This file is the future home for those concerns.
+ *
+ * Returns a Result-shaped object instead of throwing — callers map status
+ * codes (504 abort / 502 upstream / 500 parse). Generic <T> is the typed
+ * shape for parsed JSON when parseJson=true; raw string is also returned.
+ *
+ * Reference impl: ~/Projects/PoppyPetitions/lib/gemini.ts (post-scrub).
+ */
+
+const GEMINI_KEY = process.env.GEMINI_API_KEY;
+const MODEL = 'gemini-2.0-flash';
+const GEMINI_URL = `https://generativelanguage.googleapis.com/v1beta/models/${MODEL}:generateContent`;
+
+export type GeminiOk<T> = {
+ ok: true;
+ data: T;
+ raw: string;
+ inputTokens: number;
+ outputTokens: number;
+};
+
+export type GeminiErr = {
+ ok: false;
+ reason: 'no_key' | 'timeout' | 'upstream' | 'parse';
+ status: number;
+ detail?: string;
+};
+
+export type GeminiResult<T> = GeminiOk<T> | GeminiErr;
+
+export async function callGemini<T = unknown>(opts: {
+ prompt: string;
+ maxTokens?: number;
+ temperature?: number;
+ timeoutMs?: number;
+ parseJson?: boolean;
+}): Promise<GeminiResult<T>> {
+ if (!GEMINI_KEY) {
+ return { ok: false, reason: 'no_key', status: 503, detail: 'GEMINI_API_KEY env unset' };
+ }
+
+ const {
+ prompt,
+ maxTokens = 4096,
+ temperature = 0.7,
+ timeoutMs = 25_000,
+ parseJson = true,
+ } = opts;
+
+ let res: Response;
+ try {
+ res = await fetch(`${GEMINI_URL}?key=${GEMINI_KEY}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ contents: [{ parts: [{ text: prompt }] }],
+ generationConfig: { temperature, maxOutputTokens: maxTokens },
+ }),
+ signal: AbortSignal.timeout(timeoutMs),
+ });
+ } catch (e) {
+ return { ok: false, reason: 'timeout', status: 504, detail: (e as Error).message };
+ }
+
+ if (!res.ok) {
+ const detail = await res.text().catch(() => '');
+ console.error('[lib/gemini] upstream error:', res.status, detail.slice(0, 500));
+ return { ok: false, reason: 'upstream', status: 502, detail: `gemini ${res.status}` };
+ }
+
+ const data = await res.json();
+ const raw: string = data.candidates?.[0]?.content?.parts?.[0]?.text ?? '';
+ const usage = data.usageMetadata ?? {};
+ const inputTokens: number = usage.promptTokenCount ?? 0;
+ const outputTokens: number = usage.candidatesTokenCount ?? 0;
+
+ if (!parseJson) {
+ return {
+ ok: true,
+ data: raw as unknown as T,
+ raw,
+ inputTokens,
+ outputTokens,
+ };
+ }
+
+ // Strip ```json fences then JSON.parse. Caller is responsible for
+ // structural validation of the parsed payload.
+ const cleaned = raw.replace(/```json\s*/gi, '').replace(/```\s*/g, '').trim();
+ try {
+ const parsed = JSON.parse(cleaned) as T;
+ return {
+ ok: true,
+ data: parsed,
+ raw,
+ inputTokens,
+ outputTokens,
+ };
+ } catch (e) {
+ console.error('[lib/gemini] parse error:', cleaned.slice(0, 500));
+ return { ok: false, reason: 'parse', status: 500, detail: (e as Error).message };
+ }
+}
diff --git a/lib/sanitize.ts b/lib/sanitize.ts
new file mode 100644
index 0000000..3f09287
--- /dev/null
+++ b/lib/sanitize.ts
@@ -0,0 +1,44 @@
+/**
+ * lib/sanitize.ts — proposal body_html allowlist sanitizer for Grant.
+ *
+ * 2026-05-06 (tick 48): extracted from app/api/grants/[id]/proposals/route.ts
+ * to mirror Patty's lib/sanitize.ts pattern. Single source of truth for the
+ * allowlist; future routes that store user-supplied HTML can import this.
+ *
+ * Sanitization happens at the storage boundary (POST/PATCH /api/grants/
+ * [id]/proposals) so the database itself is the trust line. Render-side
+ * does not currently dangerouslySetInnerHTML body_html anywhere — but if
+ * that changes, the same `sanitizeProposalBody()` should be applied as
+ * defense-in-depth (cheap, idempotent).
+ */
+
+import sanitizeHtmlLib, { type IOptions } from 'sanitize-html';
+
+export const PROPOSAL_HTML_OPTS: IOptions = {
+ // Tags suitable for grant-proposal body content (LOI / full / one-pager).
+ // No <img>, no <iframe>, no <script>, no <style>.
+ allowedTags: [
+ 'p', 'strong', 'em', 'b', 'i', 'u',
+ 'ul', 'ol', 'li', 'br',
+ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
+ 'blockquote', 'a',
+ ],
+ allowedAttributes: { a: ['href', 'title', 'target', 'rel'] },
+ allowedSchemes: ['http', 'https', 'mailto'],
+ disallowedTagsMode: 'discard',
+};
+
+export function sanitizeProposalBody(html: string): string {
+ if (typeof html !== 'string') return '';
+ return sanitizeHtmlLib(html, PROPOSAL_HTML_OPTS);
+}
+
+/**
+ * Hard size cap for body_html — 200 KB. See Patty's lib/sanitize.ts for
+ * the same rationale (cost amplification + DoS guard).
+ */
+export const MAX_BODY_HTML_BYTES = 200_000;
+
+export function bodyHtmlTooLarge(html: unknown): boolean {
+ return typeof html !== 'string' || html.length > MAX_BODY_HTML_BYTES;
+}
diff --git a/lib/sister-auth.ts b/lib/sister-auth.ts
new file mode 100644
index 0000000..9596962
--- /dev/null
+++ b/lib/sister-auth.ts
@@ -0,0 +1,49 @@
+import { NextResponse } from 'next/server';
+
+/**
+ * lib/sister-auth.ts — outbound credentials for cross-service calls into
+ * sibling DW services (Patty, PoppyPetitions, Freddy, etc.).
+ *
+ * 2026-05-05 (tick 30 + architect-reviewer reverse port): copied from
+ * Patty's lib/sister-auth.ts. Grant doesn't currently make sister-service
+ * calls, but the moment it does (e.g. → Patty for "find petitions matching
+ * this grant's cause", → Freddy for "lookup foundations in the marketplace
+ * catalog"), this is the canonical helper to use.
+ *
+ * Falls back to AUTH_PASSWORD which Grant's own login already requires.
+ * If neither password env is set, SISTER_AUTH is '' and SISTER_OK is false;
+ * route handlers MUST gate on SISTER_OK and return sisterUnconfigured()
+ * instead of fetching with empty creds (which would silently 401 and look
+ * like a bug).
+ *
+ * NOTE on `?? 'admin'`: the username default is intentional and SAFE —
+ * 'admin' is not a secret, just the conventional username across all DW
+ * Express services. The anti-pattern rule (no literal fallbacks) targets
+ * PASSWORDS and SECRETS specifically. Using `??` (not `||`) so an explicit
+ * empty-string SISTER_AUTH_USERNAME is preserved (treated as "unset" only
+ * when actually undefined/null).
+ */
+
+const SISTER_PASS = process.env.SISTER_AUTH_PASSWORD ?? process.env.AUTH_PASSWORD ?? '';
+const SISTER_USER = process.env.SISTER_AUTH_USERNAME ?? 'admin';
+
+export const SISTER_AUTH: string = SISTER_PASS
+ ? 'Basic ' + Buffer.from(`${SISTER_USER}:${SISTER_PASS}`).toString('base64')
+ : '';
+
+export const SISTER_OK: boolean = SISTER_AUTH !== '';
+
+export function sisterUnconfigured(): NextResponse {
+ return NextResponse.json(
+ { error: 'sister auth not configured (SISTER_AUTH_PASSWORD or AUTH_PASSWORD env required)' },
+ { status: 503 },
+ );
+}
+
+/**
+ * Raw credentials for sister services that use form/JSON login (e.g. cookie
+ * auth via /api/auth/login) rather than a Basic Authorization header.
+ */
+export function sisterCredentials(): { username: string; password: string } {
+ return { username: SISTER_USER, password: SISTER_PASS };
+}
diff --git a/middleware.ts b/middleware.ts
new file mode 100644
index 0000000..c03a0e1
--- /dev/null
+++ b/middleware.ts
@@ -0,0 +1,90 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { verifyAuthWithOrg } from '@/lib/auth';
+
+/**
+ * Grant middleware — auth gate, request-id injection, rate-limit on AI routes.
+ *
+ * 2026-05-05 (architect-reviewer + tick 16): collapses 14 copies of
+ * `verifyAuth(request); if (!user) return 401;` into one place. Adds
+ * `x-request-id` for log correlation. Adds in-memory sliding-window
+ * rate-limit on the 5 Gemini routes (P1-1 from code-reviewer).
+ *
+ * Runtime: Node (verifyAuth uses node:crypto). Next.js 16 supports
+ * Node runtime middleware natively.
+ */
+
+export const config = {
+ runtime: 'nodejs',
+ matcher: ['/api/:path*'],
+};
+
+// 2026-05-05 (P1-1): in-memory sliding-window rate-limit. Per-user-per-route
+// counters live in a Map; entries expire after WINDOW_MS. This is the v1
+// implementation — survives a single-process pm2 restart but not horizontal
+// scale. Promote to PG `ai_call_log` if Grant ever moves multi-instance.
+type Bucket = { count: number; resetAt: number };
+const buckets: Map<string, Bucket> = new Map();
+const WINDOW_MS = 60_000;
+const AI_LIMIT_PER_MINUTE = 10;
+
+const AI_ROUTES = new Set([
+ '/api/grants/discover',
+ '/api/collaborations/discover',
+ '/api/news/discover',
+ '/api/outreach/generate',
+]);
+function isAiRoute(pathname: string): boolean {
+ if (AI_ROUTES.has(pathname)) return true;
+ // /api/grants/[id]/proposals is the proposals AI route
+ return /^\/api\/grants\/[^/]+\/proposals$/.test(pathname);
+}
+
+const PUBLIC_API = new Set([
+ '/api/auth/login',
+ '/api/auth/logout',
+ '/api/auth/session',
+]);
+
+export function middleware(request: NextRequest) {
+ const { pathname } = request.nextUrl;
+
+ // Public auth endpoints bypass the gate.
+ if (PUBLIC_API.has(pathname)) {
+ return injectRequestId(NextResponse.next(), request);
+ }
+
+ // Auth gate for everything else under /api/**.
+ const session = verifyAuthWithOrg(request);
+ if (!session) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ // Rate-limit the Gemini-paid AI routes. Key by orgId+route so two users
+ // in the same org share a quota; falls back to username for legacy v1
+ // sessions where orgId is null.
+ if (isAiRoute(pathname)) {
+ const tenant = session.orgId ?? `u:${session.username}`;
+ const key = `${tenant}:${pathname}`;
+ const now = Date.now();
+ const b = buckets.get(key);
+ if (!b || b.resetAt < now) {
+ buckets.set(key, { count: 1, resetAt: now + WINDOW_MS });
+ } else if (b.count >= AI_LIMIT_PER_MINUTE) {
+ return NextResponse.json(
+ { error: 'rate_limited', retry_after_ms: b.resetAt - now },
+ { status: 429, headers: { 'retry-after': Math.ceil((b.resetAt - now) / 1000).toString() } },
+ );
+ } else {
+ b.count++;
+ }
+ }
+
+ return injectRequestId(NextResponse.next(), request);
+}
+
+function injectRequestId(res: NextResponse, request: NextRequest): NextResponse {
+ const incoming = request.headers.get('x-request-id');
+ const reqId = incoming || crypto.randomUUID();
+ res.headers.set('x-request-id', reqId);
+ return res;
+}
diff --git a/next.config.ts b/next.config.ts
new file mode 100644
index 0000000..e9ffa30
--- /dev/null
+++ b/next.config.ts
@@ -0,0 +1,7 @@
+import type { NextConfig } from "next";
+
+const nextConfig: NextConfig = {
+ /* config options here */
+};
+
+export default nextConfig;
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..85eca9c
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,2013 @@
+{
+ "name": "grant-app",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "grant-app",
+ "version": "0.1.0",
+ "dependencies": {
+ "@dw/nextjs-admin-login": "file:/tmp/dw-nextjs-admin-login-0.2.0.tgz",
+ "@next/swc-darwin-arm64": "^16.2.4",
+ "@types/pg": "^8.16.0",
+ "@types/sanitize-html": "^2.16.1",
+ "lucide-react": "^0.575.0",
+ "next": "16.1.6",
+ "pg": "^8.19.0",
+ "react": "19.2.3",
+ "react-dom": "19.2.3",
+ "sanitize-html": "^2.17.3"
+ },
+ "devDependencies": {
+ "@tailwindcss/postcss": "^4",
+ "@types/node": "^20",
+ "@types/react": "^19",
+ "@types/react-dom": "^19",
+ "tailwindcss": "^4",
+ "typescript": "^5"
+ }
+ },
+ "node_modules/@alloc/quick-lru": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
+ "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@dw/nextjs-admin-login": {
+ "version": "0.2.0",
+ "resolved": "file:../../../../tmp/dw-nextjs-admin-login-0.2.0.tgz",
+ "peerDependencies": {
+ "next": "^15.0.0 || ^16.0.0"
+ }
+ },
+ "node_modules/@emnapi/runtime": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz",
+ "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@img/colour": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz",
+ "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@img/sharp-darwin-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
+ "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-arm64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-darwin-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
+ "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-x64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-arm64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
+ "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-x64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
+ "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
+ "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
+ "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-ppc64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
+ "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-riscv64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
+ "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-s390x": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
+ "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-x64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
+ "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-arm64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
+ "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-x64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
+ "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
+ "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
+ "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-ppc64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
+ "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-ppc64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-riscv64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
+ "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-riscv64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-s390x": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
+ "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
+ "cpu": [
+ "s390x"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-s390x": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
+ "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-x64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
+ "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
+ "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-x64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-wasm32": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
+ "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
+ "cpu": [
+ "wasm32"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/runtime": "^1.7.0"
+ },
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
+ "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-ia32": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
+ "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
+ "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@next/env": {
+ "version": "16.1.6",
+ "resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.6.tgz",
+ "integrity": "sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ==",
+ "license": "MIT"
+ },
+ "node_modules/@next/swc-darwin-arm64": {
+ "version": "16.2.4",
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.4.tgz",
+ "integrity": "sha512-OXTFFox5EKN1Ym08vfrz+OXxmCcEjT4SFMbNRsWZE99dMqt2Kcusl5MqPXcW232RYkMLQTy0hqgAMEsfEd/l2A==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-darwin-x64": {
+ "version": "16.1.6",
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.6.tgz",
+ "integrity": "sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-arm64-gnu": {
+ "version": "16.1.6",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.6.tgz",
+ "integrity": "sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-arm64-musl": {
+ "version": "16.1.6",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.6.tgz",
+ "integrity": "sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-x64-gnu": {
+ "version": "16.1.6",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.6.tgz",
+ "integrity": "sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-x64-musl": {
+ "version": "16.1.6",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.6.tgz",
+ "integrity": "sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-win32-arm64-msvc": {
+ "version": "16.1.6",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.6.tgz",
+ "integrity": "sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-win32-x64-msvc": {
+ "version": "16.1.6",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.6.tgz",
+ "integrity": "sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@swc/helpers": {
+ "version": "0.5.15",
+ "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
+ "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.8.0"
+ }
+ },
+ "node_modules/@tailwindcss/node": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.1.tgz",
+ "integrity": "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/remapping": "^2.3.5",
+ "enhanced-resolve": "^5.19.0",
+ "jiti": "^2.6.1",
+ "lightningcss": "1.31.1",
+ "magic-string": "^0.30.21",
+ "source-map-js": "^1.2.1",
+ "tailwindcss": "4.2.1"
+ }
+ },
+ "node_modules/@tailwindcss/oxide": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.1.tgz",
+ "integrity": "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 20"
+ },
+ "optionalDependencies": {
+ "@tailwindcss/oxide-android-arm64": "4.2.1",
+ "@tailwindcss/oxide-darwin-arm64": "4.2.1",
+ "@tailwindcss/oxide-darwin-x64": "4.2.1",
+ "@tailwindcss/oxide-freebsd-x64": "4.2.1",
+ "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1",
+ "@tailwindcss/oxide-linux-arm64-gnu": "4.2.1",
+ "@tailwindcss/oxide-linux-arm64-musl": "4.2.1",
+ "@tailwindcss/oxide-linux-x64-gnu": "4.2.1",
+ "@tailwindcss/oxide-linux-x64-musl": "4.2.1",
+ "@tailwindcss/oxide-wasm32-wasi": "4.2.1",
+ "@tailwindcss/oxide-win32-arm64-msvc": "4.2.1",
+ "@tailwindcss/oxide-win32-x64-msvc": "4.2.1"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-android-arm64": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.1.tgz",
+ "integrity": "sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-darwin-arm64": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.1.tgz",
+ "integrity": "sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-darwin-x64": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.1.tgz",
+ "integrity": "sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-freebsd-x64": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.1.tgz",
+ "integrity": "sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.1.tgz",
+ "integrity": "sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.1.tgz",
+ "integrity": "sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm64-musl": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.1.tgz",
+ "integrity": "sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-x64-gnu": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.1.tgz",
+ "integrity": "sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-x64-musl": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.1.tgz",
+ "integrity": "sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.1.tgz",
+ "integrity": "sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==",
+ "bundleDependencies": [
+ "@napi-rs/wasm-runtime",
+ "@emnapi/core",
+ "@emnapi/runtime",
+ "@tybys/wasm-util",
+ "@emnapi/wasi-threads",
+ "tslib"
+ ],
+ "cpu": [
+ "wasm32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/core": "^1.8.1",
+ "@emnapi/runtime": "^1.8.1",
+ "@emnapi/wasi-threads": "^1.1.0",
+ "@napi-rs/wasm-runtime": "^1.1.1",
+ "@tybys/wasm-util": "^0.10.1",
+ "tslib": "^2.8.1"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.1.tgz",
+ "integrity": "sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-win32-x64-msvc": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.1.tgz",
+ "integrity": "sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/postcss": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.2.1.tgz",
+ "integrity": "sha512-OEwGIBnXnj7zJeonOh6ZG9woofIjGrd2BORfvE5p9USYKDCZoQmfqLcfNiRWoJlRWLdNPn2IgVZuWAOM4iTYMw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@alloc/quick-lru": "^5.2.0",
+ "@tailwindcss/node": "4.2.1",
+ "@tailwindcss/oxide": "4.2.1",
+ "postcss": "^8.5.6",
+ "tailwindcss": "4.2.1"
+ }
+ },
+ "node_modules/@types/node": {
+ "version": "20.19.35",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.35.tgz",
+ "integrity": "sha512-Uarfe6J91b9HAUXxjvSOdiO2UPOKLm07Q1oh0JHxoZ1y8HoqxDAu3gVrsrOHeiio0kSsoVBt4wFrKOm0dKxVPQ==",
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~6.21.0"
+ }
+ },
+ "node_modules/@types/pg": {
+ "version": "8.16.0",
+ "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.16.0.tgz",
+ "integrity": "sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*",
+ "pg-protocol": "*",
+ "pg-types": "^2.2.0"
+ }
+ },
+ "node_modules/@types/react": {
+ "version": "19.2.14",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
+ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "csstype": "^3.2.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "19.2.3",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
+ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "^19.2.0"
+ }
+ },
+ "node_modules/@types/sanitize-html": {
+ "version": "2.16.1",
+ "resolved": "https://registry.npmjs.org/@types/sanitize-html/-/sanitize-html-2.16.1.tgz",
+ "integrity": "sha512-n9wjs8bCOTyN/ynwD8s/nTcTreIHB1vf31vhLMGqUPNHaweKC4/fAl4Dj+hUlCTKYgm4P3k83fmiFfzkZ6sgMA==",
+ "license": "MIT",
+ "dependencies": {
+ "htmlparser2": "^10.1"
+ }
+ },
+ "node_modules/baseline-browser-mapping": {
+ "version": "2.10.0",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz",
+ "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==",
+ "license": "Apache-2.0",
+ "bin": {
+ "baseline-browser-mapping": "dist/cli.cjs"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/caniuse-lite": {
+ "version": "1.0.30001774",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001774.tgz",
+ "integrity": "sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "CC-BY-4.0"
+ },
+ "node_modules/client-only": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
+ "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
+ "license": "MIT"
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/deepmerge": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
+ "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "devOptional": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/dom-serializer": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
+ "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
+ "license": "MIT",
+ "dependencies": {
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.2",
+ "entities": "^4.2.0"
+ },
+ "funding": {
+ "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
+ }
+ },
+ "node_modules/dom-serializer/node_modules/entities": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
+ "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/domelementtype": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
+ "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ ],
+ "license": "BSD-2-Clause"
+ },
+ "node_modules/domhandler": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
+ "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "domelementtype": "^2.3.0"
+ },
+ "engines": {
+ "node": ">= 4"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/domhandler?sponsor=1"
+ }
+ },
+ "node_modules/domutils": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
+ "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "dom-serializer": "^2.0.0",
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.3"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/domutils?sponsor=1"
+ }
+ },
+ "node_modules/enhanced-resolve": {
+ "version": "5.19.0",
+ "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz",
+ "integrity": "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "graceful-fs": "^4.2.4",
+ "tapable": "^2.3.0"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/entities": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
+ "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/escape-string-regexp": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/graceful-fs": {
+ "version": "4.2.11",
+ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
+ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/htmlparser2": {
+ "version": "10.1.0",
+ "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
+ "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",
+ "funding": [
+ "https://github.com/fb55/htmlparser2?sponsor=1",
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.3",
+ "domutils": "^3.2.2",
+ "entities": "^7.0.1"
+ }
+ },
+ "node_modules/is-plain-object": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz",
+ "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/jiti": {
+ "version": "2.6.1",
+ "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
+ "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jiti": "lib/jiti-cli.mjs"
+ }
+ },
+ "node_modules/lightningcss": {
+ "version": "1.31.1",
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz",
+ "integrity": "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==",
+ "dev": true,
+ "license": "MPL-2.0",
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "lightningcss-android-arm64": "1.31.1",
+ "lightningcss-darwin-arm64": "1.31.1",
+ "lightningcss-darwin-x64": "1.31.1",
+ "lightningcss-freebsd-x64": "1.31.1",
+ "lightningcss-linux-arm-gnueabihf": "1.31.1",
+ "lightningcss-linux-arm64-gnu": "1.31.1",
+ "lightningcss-linux-arm64-musl": "1.31.1",
+ "lightningcss-linux-x64-gnu": "1.31.1",
+ "lightningcss-linux-x64-musl": "1.31.1",
+ "lightningcss-win32-arm64-msvc": "1.31.1",
+ "lightningcss-win32-x64-msvc": "1.31.1"
+ }
+ },
+ "node_modules/lightningcss-android-arm64": {
+ "version": "1.31.1",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.31.1.tgz",
+ "integrity": "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-arm64": {
+ "version": "1.31.1",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.31.1.tgz",
+ "integrity": "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-x64": {
+ "version": "1.31.1",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.31.1.tgz",
+ "integrity": "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-freebsd-x64": {
+ "version": "1.31.1",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.31.1.tgz",
+ "integrity": "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.31.1",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.31.1.tgz",
+ "integrity": "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.31.1",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.31.1.tgz",
+ "integrity": "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-musl": {
+ "version": "1.31.1",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.31.1.tgz",
+ "integrity": "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-gnu": {
+ "version": "1.31.1",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.31.1.tgz",
+ "integrity": "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.31.1",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.31.1.tgz",
+ "integrity": "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-arm64-msvc": {
+ "version": "1.31.1",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.31.1.tgz",
+ "integrity": "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.31.1",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.31.1.tgz",
+ "integrity": "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lucide-react": {
+ "version": "0.575.0",
+ "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.575.0.tgz",
+ "integrity": "sha512-VuXgKZrk0uiDlWjGGXmKV6MSk9Yy4l10qgVvzGn2AWBx1Ylt0iBexKOAoA6I7JO3m+M9oeovJd3yYENfkUbOeg==",
+ "license": "ISC",
+ "peerDependencies": {
+ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.11",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
+ "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/next": {
+ "version": "16.1.6",
+ "resolved": "https://registry.npmjs.org/next/-/next-16.1.6.tgz",
+ "integrity": "sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==",
+ "license": "MIT",
+ "dependencies": {
+ "@next/env": "16.1.6",
+ "@swc/helpers": "0.5.15",
+ "baseline-browser-mapping": "^2.8.3",
+ "caniuse-lite": "^1.0.30001579",
+ "postcss": "8.4.31",
+ "styled-jsx": "5.1.6"
+ },
+ "bin": {
+ "next": "dist/bin/next"
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "optionalDependencies": {
+ "@next/swc-darwin-arm64": "16.1.6",
+ "@next/swc-darwin-x64": "16.1.6",
+ "@next/swc-linux-arm64-gnu": "16.1.6",
+ "@next/swc-linux-arm64-musl": "16.1.6",
+ "@next/swc-linux-x64-gnu": "16.1.6",
+ "@next/swc-linux-x64-musl": "16.1.6",
+ "@next/swc-win32-arm64-msvc": "16.1.6",
+ "@next/swc-win32-x64-msvc": "16.1.6",
+ "sharp": "^0.34.4"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": "^1.1.0",
+ "@playwright/test": "^1.51.1",
+ "babel-plugin-react-compiler": "*",
+ "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
+ "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
+ "sass": "^1.3.0"
+ },
+ "peerDependenciesMeta": {
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "@playwright/test": {
+ "optional": true
+ },
+ "babel-plugin-react-compiler": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/next/node_modules/@next/swc-darwin-arm64": {
+ "version": "16.1.6",
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.6.tgz",
+ "integrity": "sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/next/node_modules/postcss": {
+ "version": "8.4.31",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
+ "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.6",
+ "picocolors": "^1.0.0",
+ "source-map-js": "^1.0.2"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/parse-srcset": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz",
+ "integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==",
+ "license": "MIT"
+ },
+ "node_modules/pg": {
+ "version": "8.19.0",
+ "resolved": "https://registry.npmjs.org/pg/-/pg-8.19.0.tgz",
+ "integrity": "sha512-QIcLGi508BAHkQ3pJNptsFz5WQMlpGbuBGBaIaXsWK8mel2kQ/rThYI+DbgjUvZrIr7MiuEuc9LcChJoEZK1xQ==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-connection-string": "^2.11.0",
+ "pg-pool": "^3.12.0",
+ "pg-protocol": "^1.12.0",
+ "pg-types": "2.2.0",
+ "pgpass": "1.0.5"
+ },
+ "engines": {
+ "node": ">= 16.0.0"
+ },
+ "optionalDependencies": {
+ "pg-cloudflare": "^1.3.0"
+ },
+ "peerDependencies": {
+ "pg-native": ">=3.0.1"
+ },
+ "peerDependenciesMeta": {
+ "pg-native": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/pg-cloudflare": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz",
+ "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/pg-connection-string": {
+ "version": "2.11.0",
+ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.11.0.tgz",
+ "integrity": "sha512-kecgoJwhOpxYU21rZjULrmrBJ698U2RxXofKVzOn5UDj61BPj/qMb7diYUR1nLScCDbrztQFl1TaQZT0t1EtzQ==",
+ "license": "MIT"
+ },
+ "node_modules/pg-int8": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
+ "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/pg-pool": {
+ "version": "3.12.0",
+ "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.12.0.tgz",
+ "integrity": "sha512-eIJ0DES8BLaziFHW7VgJEBPi5hg3Nyng5iKpYtj3wbcAUV9A1wLgWiY7ajf/f/oO1wfxt83phXPY8Emztg7ITg==",
+ "license": "MIT",
+ "peerDependencies": {
+ "pg": ">=8.0"
+ }
+ },
+ "node_modules/pg-protocol": {
+ "version": "1.12.0",
+ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.12.0.tgz",
+ "integrity": "sha512-uOANXNRACNdElMXJ0tPz6RBM0XQ61nONGAwlt8da5zs/iUOOCLBQOHSXnrC6fMsvtjxbOJrZZl5IScGv+7mpbg==",
+ "license": "MIT"
+ },
+ "node_modules/pg-types": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
+ "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-int8": "1.0.1",
+ "postgres-array": "~2.0.0",
+ "postgres-bytea": "~1.0.0",
+ "postgres-date": "~1.0.4",
+ "postgres-interval": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/pgpass": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
+ "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
+ "license": "MIT",
+ "dependencies": {
+ "split2": "^4.1.0"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "license": "ISC"
+ },
+ "node_modules/postcss": {
+ "version": "8.5.6",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
+ "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.11",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/postgres-array": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
+ "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/postgres-bytea": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
+ "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-date": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
+ "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-interval": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
+ "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "xtend": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react": {
+ "version": "19.2.3",
+ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
+ "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "19.2.3",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
+ "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
+ "license": "MIT",
+ "dependencies": {
+ "scheduler": "^0.27.0"
+ },
+ "peerDependencies": {
+ "react": "^19.2.3"
+ }
+ },
+ "node_modules/sanitize-html": {
+ "version": "2.17.3",
+ "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.3.tgz",
+ "integrity": "sha512-Kn4srCAo2+wZyvCNKCSyB2g8RQ8IkX/gQs2uqoSRNu5t9I2qvUyAVvRDiFUVAiX3N3PNuwStY0eNr+ooBHVWEg==",
+ "license": "MIT",
+ "dependencies": {
+ "deepmerge": "^4.2.2",
+ "escape-string-regexp": "^4.0.0",
+ "htmlparser2": "^10.1.0",
+ "is-plain-object": "^5.0.0",
+ "parse-srcset": "^1.0.2",
+ "postcss": "^8.3.11"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.27.0",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
+ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
+ "license": "MIT"
+ },
+ "node_modules/semver": {
+ "version": "7.7.4",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
+ "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
+ "license": "ISC",
+ "optional": true,
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/sharp": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
+ "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "dependencies": {
+ "@img/colour": "^1.0.0",
+ "detect-libc": "^2.1.2",
+ "semver": "^7.7.3"
+ },
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-darwin-arm64": "0.34.5",
+ "@img/sharp-darwin-x64": "0.34.5",
+ "@img/sharp-libvips-darwin-arm64": "1.2.4",
+ "@img/sharp-libvips-darwin-x64": "1.2.4",
+ "@img/sharp-libvips-linux-arm": "1.2.4",
+ "@img/sharp-libvips-linux-arm64": "1.2.4",
+ "@img/sharp-libvips-linux-ppc64": "1.2.4",
+ "@img/sharp-libvips-linux-riscv64": "1.2.4",
+ "@img/sharp-libvips-linux-s390x": "1.2.4",
+ "@img/sharp-libvips-linux-x64": "1.2.4",
+ "@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
+ "@img/sharp-libvips-linuxmusl-x64": "1.2.4",
+ "@img/sharp-linux-arm": "0.34.5",
+ "@img/sharp-linux-arm64": "0.34.5",
+ "@img/sharp-linux-ppc64": "0.34.5",
+ "@img/sharp-linux-riscv64": "0.34.5",
+ "@img/sharp-linux-s390x": "0.34.5",
+ "@img/sharp-linux-x64": "0.34.5",
+ "@img/sharp-linuxmusl-arm64": "0.34.5",
+ "@img/sharp-linuxmusl-x64": "0.34.5",
+ "@img/sharp-wasm32": "0.34.5",
+ "@img/sharp-win32-arm64": "0.34.5",
+ "@img/sharp-win32-ia32": "0.34.5",
+ "@img/sharp-win32-x64": "0.34.5"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/split2": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
+ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">= 10.x"
+ }
+ },
+ "node_modules/styled-jsx": {
+ "version": "5.1.6",
+ "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
+ "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==",
+ "license": "MIT",
+ "dependencies": {
+ "client-only": "0.0.1"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "peerDependencies": {
+ "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0"
+ },
+ "peerDependenciesMeta": {
+ "@babel/core": {
+ "optional": true
+ },
+ "babel-plugin-macros": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/tailwindcss": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz",
+ "integrity": "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tapable": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz",
+ "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ }
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "license": "0BSD"
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "6.21.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+ "license": "MIT"
+ },
+ "node_modules/xtend": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+ "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..2c96d22
--- /dev/null
+++ b/package.json
@@ -0,0 +1,31 @@
+{
+ "name": "grant-app",
+ "version": "0.1.0",
+ "private": true,
+ "scripts": {
+ "dev": "next dev -p 7450",
+ "build": "next build",
+ "start": "next start -p 7450",
+ "lint": "next lint"
+ },
+ "dependencies": {
+ "@dw/nextjs-admin-login": "file:/tmp/dw-nextjs-admin-login-0.2.0.tgz",
+ "@next/swc-darwin-arm64": "^16.2.4",
+ "@types/pg": "^8.16.0",
+ "@types/sanitize-html": "^2.16.1",
+ "lucide-react": "^0.575.0",
+ "next": "16.1.6",
+ "pg": "^8.19.0",
+ "react": "19.2.3",
+ "react-dom": "19.2.3",
+ "sanitize-html": "^2.17.3"
+ },
+ "devDependencies": {
+ "@tailwindcss/postcss": "^4",
+ "@types/node": "^20",
+ "@types/react": "^19",
+ "@types/react-dom": "^19",
+ "tailwindcss": "^4",
+ "typescript": "^5"
+ }
+}
diff --git a/postcss.config.mjs b/postcss.config.mjs
new file mode 100644
index 0000000..61e3684
--- /dev/null
+++ b/postcss.config.mjs
@@ -0,0 +1,7 @@
+const config = {
+ plugins: {
+ "@tailwindcss/postcss": {},
+ },
+};
+
+export default config;
diff --git a/public/favicon.svg b/public/favicon.svg
new file mode 100644
index 0000000..c9c3429
--- /dev/null
+++ b/public/favicon.svg
@@ -0,0 +1,4 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
+<rect width="32" height="32" rx="6" fill="#10b981"/>
+<text x="50%" y="55%" text-anchor="middle" dominant-baseline="middle" font-size="20" font-family="Apple Color Emoji, Segoe UI Emoji, sans-serif" fill="white">$</text>
+</svg>
\ No newline at end of file
diff --git a/public/file.svg b/public/file.svg
new file mode 100644
index 0000000..004145c
--- /dev/null
+++ b/public/file.svg
@@ -0,0 +1 @@
+<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
\ No newline at end of file
diff --git a/public/globe.svg b/public/globe.svg
new file mode 100644
index 0000000..567f17b
--- /dev/null
+++ b/public/globe.svg
@@ -0,0 +1 @@
+<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
\ No newline at end of file
diff --git a/public/next.svg b/public/next.svg
new file mode 100644
index 0000000..5174b28
--- /dev/null
+++ b/public/next.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
\ No newline at end of file
diff --git a/public/vercel.svg b/public/vercel.svg
new file mode 100644
index 0000000..7705396
--- /dev/null
+++ b/public/vercel.svg
@@ -0,0 +1 @@
+<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
\ No newline at end of file
diff --git a/public/window.svg b/public/window.svg
new file mode 100644
index 0000000..b2b2a44
--- /dev/null
+++ b/public/window.svg
@@ -0,0 +1 @@
+<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
\ No newline at end of file
diff --git a/scripts/rotate-pg-grant.sh b/scripts/rotate-pg-grant.sh
new file mode 100755
index 0000000..065d262
--- /dev/null
+++ b/scripts/rotate-pg-grant.sh
@@ -0,0 +1,153 @@
+#!/usr/bin/env bash
+# rotate-pg-grant.sh — Lane B PG rotation for Grant only.
+#
+# Creates (or updates) a scoped PG user `grant_app_user` with permissions only
+# on the `grant_app` database, swaps Grant's .env.local DATABASE_URL to use it,
+# and registers the password in ~/Projects/secrets-manager/.env via the
+# canonical secrets-manager CLI. Leaves shared dw_admin untouched.
+#
+# Usage:
+# ./rotate-pg-grant.sh
+# (the script will prompt for the new password via `read -s`, no echo;
+# nothing lands in shell history or argv)
+#
+# Per Steve's standing rules:
+# - Never $-interpolate secrets into bash command lines (we use stdin pipes
+# into psql + python heredocs)
+# - Use /secrets skill for routing (we call cli.js add at the end)
+# - Local-only — Kamatera mirror not touched
+
+set -euo pipefail
+
+GRANT_DIR="${GRANT_DIR:-$HOME/Projects/Grant}"
+SECRETS_CLI="$HOME/Projects/secrets-manager/cli.js"
+
+if [[ ! -f "$GRANT_DIR/.env.local" ]]; then
+ echo "ERROR: $GRANT_DIR/.env.local does not exist — aborting." >&2
+ exit 1
+fi
+
+echo
+echo "Lane B: scoped Grant PG user rotation"
+echo " - creates/updates grant_app_user (login, scoped to grant_app DB)"
+echo " - updates $GRANT_DIR/.env.local DATABASE_URL"
+echo " - registers in secrets-manager"
+echo " - leaves shared dw_admin untouched"
+echo
+
+# Read password silently — no echo to terminal, no entry in shell history.
+# Suggested: 32+ chars, alphanumeric + symbols, generate with `openssl rand -base64 32`
+read -s -p "New password for grant_app_user (input hidden): " NEW_PG
+echo
+read -s -p "Confirm: " CONFIRM
+echo
+if [[ "$NEW_PG" != "$CONFIRM" ]]; then
+ echo "Passwords do not match — aborting." >&2
+ unset NEW_PG CONFIRM
+ exit 1
+fi
+unset CONFIRM
+
+if [[ ${#NEW_PG} -lt 16 ]]; then
+ echo "Password must be at least 16 chars — aborting." >&2
+ unset NEW_PG
+ exit 1
+fi
+
+# Step 1 — apply role change via psql, password sent on stdin (never in argv).
+# psql reads SQL from stdin via `-f -`; the SQL itself contains the password
+# but the SQL never lands in any shell argv.
+echo "Applying PG role change..."
+NEW_PG="$NEW_PG" python3 - <<'PYEOF'
+import os, subprocess, sys
+pw = os.environ['NEW_PG']
+sql = f"""
+DO $$
+BEGIN
+ IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'grant_app_user') THEN
+ CREATE ROLE grant_app_user WITH LOGIN PASSWORD '{pw}';
+ ELSE
+ ALTER ROLE grant_app_user WITH LOGIN PASSWORD '{pw}';
+ END IF;
+END
+$$;
+GRANT CONNECT ON DATABASE grant_app TO grant_app_user;
+\\c grant_app
+GRANT USAGE ON SCHEMA public TO grant_app_user;
+GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO grant_app_user;
+GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO grant_app_user;
+ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO grant_app_user;
+ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT USAGE, SELECT ON SEQUENCES TO grant_app_user;
+"""
+r = subprocess.run(
+ ['psql', '-d', 'postgres', '-v', 'ON_ERROR_STOP=1', '-X'],
+ input=sql, text=True, capture_output=True,
+)
+if r.returncode != 0:
+ print('psql failed:', r.stderr, file=sys.stderr)
+ sys.exit(1)
+print(r.stdout.strip() or 'role + grants OK')
+PYEOF
+
+# Step 2 — verify the new user can actually connect
+echo "Verifying connection..."
+NEW_PG="$NEW_PG" python3 - <<'PYEOF'
+import os, subprocess, sys
+pw = os.environ['NEW_PG']
+url = f"postgresql://grant_app_user:{pw}@127.0.0.1:5432/grant_app"
+r = subprocess.run(
+ ['psql', url, '-c', 'SELECT current_user, current_database()'],
+ capture_output=True, text=True,
+)
+if r.returncode != 0:
+ print('verify failed:', r.stderr, file=sys.stderr)
+ sys.exit(1)
+print(r.stdout.strip())
+PYEOF
+
+# Step 3 — update Grant's .env.local DATABASE_URL
+echo "Updating $GRANT_DIR/.env.local..."
+NEW_PG="$NEW_PG" GRANT_DIR="$GRANT_DIR" python3 - <<'PYEOF'
+import os
+pw = os.environ['NEW_PG']
+env = os.path.join(os.environ['GRANT_DIR'], '.env.local')
+new_url = f"postgresql://grant_app_user:{pw}@127.0.0.1:5432/grant_app"
+
+with open(env) as f:
+ lines = f.readlines()
+
+found = False
+for i, line in enumerate(lines):
+ if line.startswith('DATABASE_URL='):
+ lines[i] = f'DATABASE_URL={new_url}\n'
+ found = True
+ break
+if not found:
+ lines.append(f'\nDATABASE_URL={new_url}\n')
+
+# atomic write
+tmp = env + '.tmp'
+with open(tmp, 'w') as f:
+ f.writelines(lines)
+os.chmod(tmp, 0o600)
+os.replace(tmp, env)
+print(f'wrote DATABASE_URL → {env}')
+PYEOF
+
+# Step 4 — register in secrets-manager (canonical routing destination)
+if [[ -f "$SECRETS_CLI" ]]; then
+ echo "Registering in secrets-manager..."
+ printf '%s' "GRANT_APP_DB_PASSWORD=$NEW_PG" | node "$SECRETS_CLI" import-paste 2>&1 | tail -5 || \
+ echo " (secrets-manager add failed — manual entry in master .env may be needed)"
+else
+ echo " (secrets-manager CLI not found at $SECRETS_CLI — skipping registry)"
+fi
+
+# Final cleanup
+unset NEW_PG
+
+echo
+echo "✅ Done. Grant now connects as grant_app_user (scoped to grant_app DB)."
+echo " Shared dw_admin password is unchanged."
+echo " Old DW2024SecurePass is still on disk in Time Machine snapshots —"
+echo " schedule a global dw_admin rotation in a maintenance window when ready."
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..3a13f90
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,34 @@
+{
+ "compilerOptions": {
+ "target": "ES2017",
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "allowJs": true,
+ "skipLibCheck": true,
+ "strict": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "react-jsx",
+ "incremental": true,
+ "plugins": [
+ {
+ "name": "next"
+ }
+ ],
+ "paths": {
+ "@/*": ["./*"]
+ }
+ },
+ "include": [
+ "next-env.d.ts",
+ "**/*.ts",
+ "**/*.tsx",
+ ".next/types/**/*.ts",
+ ".next/dev/types/**/*.ts",
+ "**/*.mts"
+ ],
+ "exclude": ["node_modules"]
+}
(oldest)
·
back to Grant
·
ecosystem: bind Next to 127.0.0.1:7450 (tailscaled holds :74 c95598d →