← back to Freddy
initial scaffold (2026-05-06 overnight session)
2401fcc334def73b55455cb8ac8c6bf190ee4201 · 2026-05-06 10:22:13 -0700 · Steve Abrams
Files touched
A .gitignoreA CLAUDE.mdA README.mdA app/api/auth/login/route.tsA app/api/auth/logout/route.tsA app/api/auth/session/route.tsA app/api/causes/discover/route.tsA app/api/causes/route.tsA app/api/contacts/discover/route.tsA app/api/contacts/generate-letter/route.tsA app/api/contacts/route.tsA app/api/funding/route.tsA app/api/granters/route.tsA app/api/impact/route.tsA app/api/matches/generate/route.tsA app/api/matches/route.tsA app/api/organizations/route.tsA app/api/pulse/route.tsA app/api/revenue/route.tsA app/api/votes/route.tsA app/favicon.icoA app/globals.cssA app/icon.svgA app/layout.tsxA app/login/page.tsxA app/page.tsxA components/AppShell.tsxA components/AuthProvider.tsxA components/Sidebar.tsxA components/Skeleton.tsxA components/ToastProvider.tsxA components/causes/CausesTab.tsxA components/funding/FundingTab.tsxA components/granters/GrantersTab.tsxA components/impact/ImpactTab.tsxA components/matches/MatchesTab.tsxA components/organizations/OrgsTab.tsxA components/pulse/PulseBoard.tsxA components/revenue/RevenueTab.tsxA components/settings/SettingsTab.tsxA components/shared/SortDropdown.tsxA components/solicitor/SolicitorTab.tsxA ecosystem.config.jsA hooks/useClientSort.tsA hooks/useDebounce.tsA lib/audit.tsA lib/auth.tsA lib/db.tsA lib/sister-auth.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 tsconfig.json
Diff
commit 2401fcc334def73b55455cb8ac8c6bf190ee4201
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 | 70 ++
README.md | 36 +
app/api/auth/login/route.ts | 43 +
app/api/auth/logout/route.ts | 8 +
app/api/auth/session/route.ts | 12 +
app/api/causes/discover/route.ts | 115 ++
app/api/causes/route.ts | 76 ++
app/api/contacts/discover/route.ts | 141 +++
app/api/contacts/generate-letter/route.ts | 123 ++
app/api/contacts/route.ts | 129 ++
app/api/funding/route.ts | 59 +
app/api/granters/route.ts | 58 +
app/api/impact/route.ts | 32 +
app/api/matches/generate/route.ts | 138 +++
app/api/matches/route.ts | 42 +
app/api/organizations/route.ts | 79 ++
app/api/pulse/route.ts | 69 ++
app/api/revenue/route.ts | 97 ++
app/api/votes/route.ts | 101 ++
app/favicon.ico | Bin 0 -> 25931 bytes
app/globals.css | 424 +++++++
app/icon.svg | 4 +
app/layout.tsx | 21 +
app/login/page.tsx | 158 +++
app/page.tsx | 5 +
components/AppShell.tsx | 220 ++++
components/AuthProvider.tsx | 123 ++
components/Sidebar.tsx | 303 +++++
components/Skeleton.tsx | 49 +
components/ToastProvider.tsx | 94 ++
components/causes/CausesTab.tsx | 387 ++++++
components/funding/FundingTab.tsx | 254 ++++
components/granters/GrantersTab.tsx | 308 +++++
components/impact/ImpactTab.tsx | 176 +++
components/matches/MatchesTab.tsx | 262 ++++
components/organizations/OrgsTab.tsx | 330 +++++
components/pulse/PulseBoard.tsx | 506 ++++++++
components/revenue/RevenueTab.tsx | 253 ++++
components/settings/SettingsTab.tsx | 128 ++
components/shared/SortDropdown.tsx | 41 +
components/solicitor/SolicitorTab.tsx | 425 +++++++
ecosystem.config.js | 17 +
hooks/useClientSort.ts | 34 +
hooks/useDebounce.ts | 10 +
lib/audit.ts | 29 +
lib/auth.ts | 10 +
lib/db.ts | 44 +
lib/sister-auth.ts | 34 +
next.config.ts | 7 +
package-lock.json | 1869 +++++++++++++++++++++++++++++
package.json | 28 +
postcss.config.mjs | 5 +
public/favicon.svg | 4 +
public/file.svg | 1 +
public/globe.svg | 1 +
public/next.svg | 1 +
public/vercel.svg | 1 +
public/window.svg | 1 +
tsconfig.json | 27 +
60 files changed, 8072 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..df40ba8
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,70 @@
+# Freddy - The Fundraising Coordinator
+
+## Project Info
+- **Directory**: /root/Projects/Freddy/
+- **Port**: 7470 | **PM2**: freddy-app
+- **Database**: connection string lives in `.env.local` (DATABASE_URL); use `dw_admin@127.0.0.1:5432/freddy`. NEVER commit credentials to docs.
+- **Auth**: admin / (AUTH_PASSWORD env — see .env.local; no longer hardcoded)
+- **Stack**: Next.js 15 (App Router) + Tailwind 4 + PostgreSQL + Gemini AI
+- **URL**: http://45.61.58.125:7470
+
+## Purpose
+Democratic funding marketplace that flips traditional grant funding from top-down to bottom-up.
+Citizens vote on causes, non-profits register capabilities, granters see ranked leaderboards,
+AI matches cause urgency + non-profit capability + granter preferences into a funding pipeline.
+
+## Revenue Model
+- **Introduction Fee**: $750 flat (paid by requesting party)
+- **Success Fee**: 5% of grant amount (paid by non-profit from grant funds, only if awarded)
+- **No upfront costs for granters**
+- **NOT A FIDUCIARY**: Freddy never touches grant money, introduction and facilitation only.
+
+## Key Concepts
+- **Causes**: Issues citizens care about, ranked by votes and urgency
+- **Organizations**: Non-profits addressing causes, scored by trust and impact
+- **Granters**: Funding organizations with budgets and focus areas
+- **Matches**: AI-generated cause-to-org-to-granter pipeline recommendations
+- **Solicitor**: Contact harvesting + AI-generated outreach letters for both parties
+- **Revenue**: Fee tracking for introduction fees and success percentages
+- **Impact Reports**: Outcomes tracking after funding is distributed
+
+## Database Tables
+users, organizations, granters, causes, votes, matches, funding_rounds, impact_reports, audit_events, revenue, contacts
+
+## Sidebar Tabs
+1. Pulse Board (BarChart3) - Main dashboard with stats, pipeline, trending causes, top orgs
+2. Causes (Flame) - Browse/vote/discover causes
+3. Organizations (Building2) - Non-profit registry
+4. Granters (Landmark) - Funding organizations
+5. Matches (Sparkles) - AI cause-to-org-to-granter matches
+6. Solicitor (Send) - Contact harvesting + outreach letter generation
+7. Revenue (DollarSign) - Fee tracking and commissions
+8. Impact (TrendingUp) - Outcome reports
+9. Settings (Settings) - Platform configuration
+
+## API Routes
+- Auth: /api/auth/login, /api/auth/logout, /api/auth/session
+- CRUD: /api/causes, /api/organizations, /api/granters, /api/matches, /api/votes, /api/funding, /api/impact, /api/contacts, /api/revenue
+- AI: /api/causes/discover, /api/matches/generate, /api/contacts/discover, /api/contacts/generate-letter
+- Dashboard: /api/pulse
+
+## Theme
+Amber/Gold dark theme: primary #d97706, accent #fbbf24
+Background: #0f0d0a, Surface: #171310, Surface-el: #1f1b16
+Border: #2d2820, Text: #f5f0e8, Muted: #8a8070
+
+## Key Files
+- lib/db.ts - Postgres pool
+- lib/auth.ts - Cookie auth (freddy-auth cookie, SHA-256 HMAC, 24h expiry)
+- lib/audit.ts - Audit logger
+- components/AppShell.tsx - Main app shell with sidebar routing
+- components/Sidebar.tsx - 9-tab sidebar with amber active indicator
+- components/pulse/PulseBoard.tsx - Hero dashboard
+- components/causes/CausesTab.tsx - Causes with AI discover
+- components/organizations/OrgsTab.tsx - Non-profit registry
+- components/granters/GrantersTab.tsx - Granter cards
+- components/matches/MatchesTab.tsx - AI match pipeline
+- components/solicitor/SolicitorTab.tsx - Contact harvesting and letter gen
+- components/revenue/RevenueTab.tsx - Revenue tracking
+- components/impact/ImpactTab.tsx - Impact reports
+- components/settings/SettingsTab.tsx - Platform settings
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/app/api/auth/login/route.ts b/app/api/auth/login/route.ts
new file mode 100644
index 0000000..f30e243
--- /dev/null
+++ b/app/api/auth/login/route.ts
@@ -0,0 +1,43 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { createSession, buildAuthCookie } from '@/lib/auth';
+
+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 });
+ }
+
+ if (username !== expectedUsername || password !== expectedPassword) {
+ return NextResponse.json(
+ { error: 'Invalid credentials' },
+ { status: 401 },
+ );
+ }
+
+ const token = createSession(username);
+ 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/causes/discover/route.ts b/app/api/causes/discover/route.ts
new file mode 100644
index 0000000..a559e53
--- /dev/null
+++ b/app/api/causes/discover/route.ts
@@ -0,0 +1,115 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { query } from '@/lib/db';
+import { verifyAuth } from '@/lib/auth';
+import { auditLog } from '@/lib/audit';
+
+const GEMINI_URL = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=' + (process.env.GEMINI_API_KEY || '');
+
+export async function POST(request: NextRequest) {
+ const user = verifyAuth(request);
+ if (!user) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ try {
+ const prompt = `You are a social impact analyst. Identify 8-10 trending social causes that need urgent attention right now.
+
+For each cause, provide:
+- title: A concise, compelling name (max 60 chars)
+- description: A 2-3 sentence explanation of why this cause matters now
+- category: One of: education, health, environment, poverty, housing, legal_aid, civil_rights, veterans, youth, elderly, disability, arts, other
+- urgency_score: A float between 0.0 and 1.0 (1.0 = most urgent)
+- tags: An array of 2-4 relevant tags (lowercase, no spaces)
+- petition_velocity: Estimated number of related petitions/actions per month (integer)
+- news_mentions: Estimated monthly news mentions (integer)
+
+Return ONLY valid JSON array, no markdown, no explanation. Example format:
+[{"title":"...", "description":"...", "category":"...", "urgency_score":0.85, "tags":["tag1","tag2"], "petition_velocity": 500, "news_mentions": 1200}]`;
+
+ const geminiRes = await fetch(GEMINI_URL, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ contents: [{ parts: [{ text: prompt }] }],
+ generationConfig: {
+ temperature: 0.7,
+ maxOutputTokens: 4096,
+ },
+ }),
+ });
+
+ if (!geminiRes.ok) {
+ const errText = await geminiRes.text();
+ console.error('[causes/discover] Gemini error:', errText);
+ return NextResponse.json({ error: 'AI service error' }, { status: 502 });
+ }
+
+ const geminiData = await geminiRes.json();
+ const rawText = geminiData?.candidates?.[0]?.content?.parts?.[0]?.text || '';
+
+ // Extract JSON from response (handle markdown code blocks)
+ let jsonStr = rawText.trim();
+ if (jsonStr.startsWith('```')) {
+ jsonStr = jsonStr.replace(/^```(?:json)?\n?/, '').replace(/\n?```$/, '');
+ }
+
+ let causes: Array<{
+ title: string;
+ description: string;
+ category: string;
+ urgency_score: number;
+ tags: string[];
+ petition_velocity: number;
+ news_mentions: number;
+ }>;
+
+ try {
+ causes = JSON.parse(jsonStr);
+ } catch {
+ console.error('[causes/discover] Failed to parse Gemini response:', jsonStr.slice(0, 300));
+ return NextResponse.json({ error: 'Failed to parse AI response' }, { status: 500 });
+ }
+
+ // Insert each discovered cause
+ const inserted = [];
+ for (const cause of causes) {
+ try {
+ const result = await query(
+ `INSERT INTO causes (title, description, category, urgency_score, tags, source, petition_velocity, news_mentions)
+ VALUES ($1, $2, $3, $4, $5, 'ai', $6, $7)
+ ON CONFLICT DO NOTHING
+ RETURNING *`,
+ [
+ cause.title,
+ cause.description,
+ cause.category || 'other',
+ Math.min(1, Math.max(0, cause.urgency_score || 0.5)),
+ cause.tags || [],
+ cause.petition_velocity || 0,
+ cause.news_mentions || 0,
+ ],
+ );
+ if (result.rows.length > 0) {
+ inserted.push(result.rows[0]);
+ }
+ } catch (insertErr) {
+ console.error('[causes/discover] Insert error for:', cause.title, (insertErr as Error).message);
+ }
+ }
+
+ await auditLog('causes.discovered', 'cause', null, {
+ user,
+ discovered: causes.length,
+ inserted: inserted.length,
+ });
+
+ return NextResponse.json({
+ discovered: causes.length,
+ inserted: inserted.length,
+ causes: inserted,
+ });
+ } catch (err) {
+ console.error('[causes/discover] Error:', (err as Error).message);
+ return NextResponse.json({ error: 'Failed to discover causes' }, { status: 500 });
+ }
+}
diff --git a/app/api/causes/route.ts b/app/api/causes/route.ts
new file mode 100644
index 0000000..adda367
--- /dev/null
+++ b/app/api/causes/route.ts
@@ -0,0 +1,76 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { query } from '@/lib/db';
+import { verifyAuth } from '@/lib/auth';
+import { auditLog } from '@/lib/audit';
+
+export async function GET(request: NextRequest) {
+ const user = verifyAuth(request);
+ if (!user) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ try {
+ const sort = request.nextUrl.searchParams.get('sort') || 'urgency';
+ const category = request.nextUrl.searchParams.get('category');
+
+ let sql = `
+ SELECT id, title, description, category, urgency_score, petition_velocity,
+ news_mentions, total_votes, total_supporters, trending_rank,
+ source, tags, featured, status, created_at, updated_at
+ FROM causes
+ WHERE status = 'active'
+ `;
+ const params: unknown[] = [];
+
+ if (category) {
+ params.push(category);
+ sql += ` AND category = $${params.length}`;
+ }
+
+ if (sort === 'votes') {
+ sql += ' ORDER BY total_votes DESC';
+ } else if (sort === 'trending') {
+ sql += ' ORDER BY trending_rank ASC NULLS LAST';
+ } else {
+ sql += ' ORDER BY urgency_score DESC';
+ }
+
+ sql += ' LIMIT 100';
+
+ const result = await query(sql, params);
+ return NextResponse.json({ causes: result.rows });
+ } catch (err) {
+ console.error('[causes] GET error:', (err as Error).message);
+ return NextResponse.json({ error: 'Failed to fetch causes' }, { status: 500 });
+ }
+}
+
+export async function POST(request: NextRequest) {
+ const user = verifyAuth(request);
+ if (!user) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ try {
+ const body = await request.json();
+ const { title, description, category, urgency_score, tags } = body;
+
+ if (!title) {
+ return NextResponse.json({ error: 'Title is required' }, { status: 400 });
+ }
+
+ const result = await query(
+ `INSERT INTO causes (title, description, category, urgency_score, tags, source)
+ VALUES ($1, $2, $3, $4, $5, 'user')
+ RETURNING *`,
+ [title, description || null, category || null, urgency_score || 0.5, tags || []],
+ );
+
+ await auditLog('cause.created', 'cause', result.rows[0].id, { title, user });
+
+ return NextResponse.json({ cause: result.rows[0] }, { status: 201 });
+ } catch (err) {
+ console.error('[causes] POST error:', (err as Error).message);
+ return NextResponse.json({ error: 'Failed to create cause' }, { status: 500 });
+ }
+}
diff --git a/app/api/contacts/discover/route.ts b/app/api/contacts/discover/route.ts
new file mode 100644
index 0000000..d8a11f0
--- /dev/null
+++ b/app/api/contacts/discover/route.ts
@@ -0,0 +1,141 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { query } from '@/lib/db';
+import { verifyAuth } from '@/lib/auth';
+import { auditLog } from '@/lib/audit';
+
+const GEMINI_URL = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=' + (process.env.GEMINI_API_KEY || '');
+
+export async function POST(request: NextRequest) {
+ const user = verifyAuth(request);
+ if (!user) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ try {
+ // Fetch context: top causes, orgs, and granters
+ const [causesRes, orgsRes, grantersRes] = await Promise.all([
+ query(`SELECT title, category, urgency_score FROM causes WHERE status = 'active' ORDER BY urgency_score DESC LIMIT 10`),
+ query(`SELECT name, category, mission FROM organizations WHERE status IN ('verified', 'featured') LIMIT 10`),
+ query(`SELECT name, funding_areas FROM granters WHERE status = 'active' LIMIT 10`),
+ ]);
+
+ const causesContext = causesRes.rows.map(c => c.title).join(', ');
+ const orgsContext = orgsRes.rows.map(o => o.name).join(', ');
+ const grantersContext = grantersRes.rows.map(g => g.name).join(', ');
+
+ const prompt = `You are a fundraising consultant specializing in connecting non-profits with grant funders. Generate 8-12 realistic fictional contacts for a fundraising coordination platform.
+
+CONTEXT:
+- Active causes: ${causesContext || 'education, health, poverty, environment'}
+- Registered orgs: ${orgsContext || 'various non-profits'}
+- Active granters: ${grantersContext || 'various foundations'}
+
+Generate contacts that include:
+- Non-profit executive directors and program directors
+- Foundation grant officers and program managers
+- Board members of funding organizations
+- Government grant coordinators
+
+For each contact, provide:
+- name: Full name (realistic)
+- title: Job title
+- organization: Their organization name
+- contact_type: One of: nonprofit_leader, grant_officer, board_member, program_director, government, media
+- email: Professional email address
+- phone: Phone number (xxx-xxx-xxxx format)
+- city: City
+- state: State abbreviation
+- relevance_score: 0.0-1.0 (how relevant to our causes)
+- ai_pitch: A 1-2 sentence suggestion for how to approach this person
+
+Return ONLY valid JSON array, no markdown:
+[{"name":"...", "title":"...", "organization":"...", "contact_type":"...", "email":"...", "phone":"...", "city":"...", "state":"...", "relevance_score":0.85, "ai_pitch":"..."}]`;
+
+ const geminiRes = await fetch(GEMINI_URL, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ contents: [{ parts: [{ text: prompt }] }],
+ generationConfig: { temperature: 0.8, maxOutputTokens: 4096 },
+ }),
+ });
+
+ if (!geminiRes.ok) {
+ const errText = await geminiRes.text();
+ console.error('[contacts/discover] Gemini error:', errText);
+ return NextResponse.json({ error: 'AI service error' }, { status: 502 });
+ }
+
+ const geminiData = await geminiRes.json();
+ const rawText = geminiData?.candidates?.[0]?.content?.parts?.[0]?.text || '';
+
+ let jsonStr = rawText.trim();
+ if (jsonStr.startsWith('```')) {
+ jsonStr = jsonStr.replace(/^```(?:json)?\n?/, '').replace(/\n?```$/, '');
+ }
+
+ let contacts: Array<{
+ name: string;
+ title: string;
+ organization: string;
+ contact_type: string;
+ email: string;
+ phone: string;
+ city: string;
+ state: string;
+ relevance_score: number;
+ ai_pitch: string;
+ }>;
+
+ try {
+ contacts = JSON.parse(jsonStr);
+ } catch {
+ console.error('[contacts/discover] Parse error:', jsonStr.slice(0, 300));
+ return NextResponse.json({ error: 'Failed to parse AI response' }, { status: 500 });
+ }
+
+ const validTypes = ['nonprofit_leader', 'grant_officer', 'board_member', 'program_director', 'government', 'media', 'other'];
+
+ const inserted = [];
+ for (const contact of contacts) {
+ const contactType = validTypes.includes(contact.contact_type) ? contact.contact_type : 'other';
+ try {
+ const result = await query(
+ `INSERT INTO contacts (contact_type, name, title, organization, email, phone, city, state, source, relevance_score, ai_pitch)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'ai_discovery', $9, $10)
+ RETURNING *`,
+ [
+ contactType,
+ contact.name,
+ contact.title || null,
+ contact.organization || null,
+ contact.email || null,
+ contact.phone || null,
+ contact.city || null,
+ contact.state || null,
+ Math.min(1, Math.max(0, contact.relevance_score || 0.5)),
+ contact.ai_pitch || null,
+ ],
+ );
+ inserted.push(result.rows[0]);
+ } catch (insertErr) {
+ console.error('[contacts/discover] Insert error for:', contact.name, (insertErr as Error).message);
+ }
+ }
+
+ await auditLog('contacts.discovered', 'contact', null, {
+ user,
+ discovered: contacts.length,
+ inserted: inserted.length,
+ });
+
+ return NextResponse.json({
+ discovered: contacts.length,
+ inserted: inserted.length,
+ contacts: inserted,
+ });
+ } catch (err) {
+ console.error('[contacts/discover] Error:', (err as Error).message);
+ return NextResponse.json({ error: 'Failed to discover contacts' }, { status: 500 });
+ }
+}
diff --git a/app/api/contacts/generate-letter/route.ts b/app/api/contacts/generate-letter/route.ts
new file mode 100644
index 0000000..3528be8
--- /dev/null
+++ b/app/api/contacts/generate-letter/route.ts
@@ -0,0 +1,123 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { query } from '@/lib/db';
+import { verifyAuth } from '@/lib/auth';
+import { auditLog } from '@/lib/audit';
+
+const GEMINI_URL = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=' + (process.env.GEMINI_API_KEY || '');
+
+const DISCLAIMER = `\n\n---\nFreddy provides introduction and facilitation services only. Freddy does not serve as fiscal agent, fiduciary, or guarantor of any grant, donation, or contractual agreement between parties.`;
+
+export async function POST(request: NextRequest) {
+ const user = verifyAuth(request);
+ if (!user) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ try {
+ const body = await request.json();
+ const { contact_id, letter_type } = body;
+
+ if (!contact_id || !letter_type) {
+ return NextResponse.json({ error: 'contact_id and letter_type are required' }, { status: 400 });
+ }
+
+ const validTypes = ['nonprofit_pitch', 'granter_pitch', 'introduction', 'follow_up'];
+ if (!validTypes.includes(letter_type)) {
+ return NextResponse.json({ error: 'Invalid letter_type' }, { status: 400 });
+ }
+
+ // Fetch contact
+ const contactRes = await query('SELECT * FROM contacts WHERE id = $1', [contact_id]);
+ if (contactRes.rows.length === 0) {
+ return NextResponse.json({ error: 'Contact not found' }, { status: 404 });
+ }
+ const contact = contactRes.rows[0];
+
+ // Fetch context
+ const [causesRes, orgsRes, grantersRes, matchesRes] = await Promise.all([
+ query(`SELECT title, urgency_score, total_votes, category FROM causes WHERE status = 'active' ORDER BY urgency_score DESC LIMIT 5`),
+ query(`SELECT name, mission, trust_score, total_votes, category FROM organizations WHERE status IN ('verified', 'featured') ORDER BY total_votes DESC LIMIT 5`),
+ query(`SELECT name, funding_areas, min_grant, max_grant FROM granters WHERE status = 'active' LIMIT 5`),
+ query(`SELECT m.match_score, c.title as cause_title, o.name as org_name, g.name as granter_name
+ FROM matches m
+ JOIN causes c ON c.id = m.cause_id
+ JOIN organizations o ON o.id = m.org_id
+ LEFT JOIN granters g ON g.id = m.granter_id
+ ORDER BY m.match_score DESC LIMIT 5`),
+ ]);
+
+ const typeDescriptions: Record<string, string> = {
+ nonprofit_pitch: `This is a pitch letter TO a non-profit organization leader. The pitch explains that Freddy has identified grant opportunities that match their mission. Emphasize the democratic validation (public votes), the specific grant matches we've found, and how our facilitation can help them secure funding.
+
+Fee structure to mention: Introduction Fee of $750 (one-time), and a 5% success fee only if the grant is awarded. No upfront costs to explore matches.`,
+
+ granter_pitch: `This is a pitch letter TO a granter/foundation. The pitch shows that Freddy has vetted, publicly-supported non-profits ready for funding. Emphasize the democratic validation (citizens voted on these causes), the trust scores and impact metrics of our registered organizations, and how our matching system ensures their funding reaches the right organizations.
+
+Fee structure to mention: No cost to the granter. Freddy's fees are covered by the non-profit side.`,
+
+ introduction: `This is a formal introduction letter connecting a granter with a non-profit (or vice versa). Professional, warm, and concise. Reference the specific match scores and why this introduction was generated.`,
+
+ follow_up: `This is a follow-up letter to a previous outreach. Reference the initial contact, ask about their interest, and provide any new relevant matches or updates.`,
+ };
+
+ const prompt = `You are writing a professional fundraising outreach letter on behalf of Freddy, The Fundraising Coordinator -- a democratic funding marketplace that connects citizens, non-profits, and grant-making organizations.
+
+LETTER TYPE: ${letter_type}
+${typeDescriptions[letter_type]}
+
+RECIPIENT:
+- Name: ${contact.name}
+- Title: ${contact.title || 'N/A'}
+- Organization: ${contact.organization || 'N/A'}
+- Type: ${contact.contact_type}
+${contact.ai_pitch ? `- AI-suggested approach: ${contact.ai_pitch}` : ''}
+
+PLATFORM CONTEXT:
+Top Causes (voted by citizens): ${JSON.stringify(causesRes.rows)}
+Top Organizations: ${JSON.stringify(orgsRes.rows)}
+Active Granters: ${JSON.stringify(grantersRes.rows)}
+Recent Matches: ${JSON.stringify(matchesRes.rows)}
+
+CRITICAL REQUIREMENTS:
+1. Professional but warm tone
+2. Reference specific data from our platform (votes, scores, matches)
+3. Be specific about why THIS person/org is a good fit
+4. End with a clear call-to-action (meeting, call, or email response)
+5. Keep to 250-400 words
+6. DO NOT include subject line or headers -- just the letter body
+7. Sign off as "The Freddy Team"
+
+Write the letter now:`;
+
+ const geminiRes = await fetch(GEMINI_URL, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ contents: [{ parts: [{ text: prompt }] }],
+ generationConfig: { temperature: 0.7, maxOutputTokens: 2048 },
+ }),
+ });
+
+ if (!geminiRes.ok) {
+ console.error('[contacts/generate-letter] Gemini error');
+ return NextResponse.json({ error: 'AI service error' }, { status: 502 });
+ }
+
+ const geminiData = await geminiRes.json();
+ let letterText = geminiData?.candidates?.[0]?.content?.parts?.[0]?.text || '';
+
+ // Append the legal disclaimer
+ letterText = letterText.trim() + DISCLAIMER;
+
+ await auditLog('letter.generated', 'contact', contact_id, {
+ user,
+ letter_type,
+ contact_name: contact.name,
+ });
+
+ return NextResponse.json({ letter: letterText, contact_id, letter_type });
+ } catch (err) {
+ console.error('[contacts/generate-letter] Error:', (err as Error).message);
+ return NextResponse.json({ error: 'Failed to generate letter' }, { status: 500 });
+ }
+}
diff --git a/app/api/contacts/route.ts b/app/api/contacts/route.ts
new file mode 100644
index 0000000..7c7a3d3
--- /dev/null
+++ b/app/api/contacts/route.ts
@@ -0,0 +1,129 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { query } from '@/lib/db';
+import { verifyAuth } from '@/lib/auth';
+import { auditLog } from '@/lib/audit';
+
+export async function GET(request: NextRequest) {
+ const user = verifyAuth(request);
+ if (!user) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ try {
+ const contactType = request.nextUrl.searchParams.get('type');
+
+ let sql = `
+ SELECT id, contact_type, name, title, organization, email, phone,
+ linkedin_url, city, state, source, relevance_score,
+ ai_pitch, solicited, solicited_at, response, notes,
+ created_at, updated_at
+ FROM contacts
+ `;
+ const params: unknown[] = [];
+
+ if (contactType) {
+ params.push(contactType);
+ sql += ` WHERE contact_type = $${params.length}`;
+ }
+
+ sql += ' ORDER BY relevance_score DESC, created_at DESC LIMIT 100';
+
+ const result = await query(sql, params);
+ return NextResponse.json({ contacts: result.rows });
+ } catch (err) {
+ console.error('[contacts] GET error:', (err as Error).message);
+ return NextResponse.json({ error: 'Failed to fetch contacts' }, { status: 500 });
+ }
+}
+
+export async function POST(request: NextRequest) {
+ const user = verifyAuth(request);
+ if (!user) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ try {
+ const body = await request.json();
+ const { contact_type, name, title, organization, email, phone, linkedin_url, city, state, relevance_score, ai_pitch } = body;
+
+ if (!name || !contact_type) {
+ return NextResponse.json({ error: 'name and contact_type are required' }, { status: 400 });
+ }
+
+ const result = await query(
+ `INSERT INTO contacts (contact_type, name, title, organization, email, phone, linkedin_url, city, state, relevance_score, ai_pitch)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
+ RETURNING *`,
+ [contact_type, name, title || null, organization || null, email || null, phone || null, linkedin_url || null, city || null, state || null, relevance_score || 0.5, ai_pitch || null],
+ );
+
+ await auditLog('contact.created', 'contact', result.rows[0].id, { name, contact_type, user });
+
+ return NextResponse.json({ contact: result.rows[0] }, { status: 201 });
+ } catch (err) {
+ console.error('[contacts] POST error:', (err as Error).message);
+ return NextResponse.json({ error: 'Failed to create contact' }, { status: 500 });
+ }
+}
+
+export async function PATCH(request: NextRequest) {
+ const user = verifyAuth(request);
+ if (!user) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ try {
+ const body = await request.json();
+ const { id, response, solicited, notes } = body;
+
+ if (!id) {
+ return NextResponse.json({ error: 'id is required' }, { status: 400 });
+ }
+
+ const updates: string[] = [];
+ const params: unknown[] = [];
+ let idx = 0;
+
+ if (response !== undefined) {
+ idx++;
+ updates.push(`response = $${idx}`);
+ params.push(response);
+ }
+ if (solicited !== undefined) {
+ idx++;
+ updates.push(`solicited = $${idx}`);
+ params.push(solicited);
+ if (solicited) {
+ updates.push(`solicited_at = NOW()`);
+ }
+ }
+ if (notes !== undefined) {
+ idx++;
+ updates.push(`notes = $${idx}`);
+ params.push(notes);
+ }
+
+ if (updates.length === 0) {
+ return NextResponse.json({ error: 'No updates provided' }, { status: 400 });
+ }
+
+ idx++;
+ params.push(id);
+
+ const result = await query(
+ `UPDATE contacts SET ${updates.join(', ')} WHERE id = $${idx} RETURNING *`,
+ params,
+ );
+
+ if (result.rows.length === 0) {
+ return NextResponse.json({ error: 'Contact not found' }, { status: 404 });
+ }
+
+ await auditLog('contact.updated', 'contact', id, { response, solicited, user });
+
+ return NextResponse.json({ contact: result.rows[0] });
+ } catch (err) {
+ console.error('[contacts] PATCH error:', (err as Error).message);
+ return NextResponse.json({ error: 'Failed to update contact' }, { status: 500 });
+ }
+}
diff --git a/app/api/funding/route.ts b/app/api/funding/route.ts
new file mode 100644
index 0000000..7dbbedf
--- /dev/null
+++ b/app/api/funding/route.ts
@@ -0,0 +1,59 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { query } from '@/lib/db';
+import { verifyAuth } from '@/lib/auth';
+import { auditLog } from '@/lib/audit';
+
+export async function GET(request: NextRequest) {
+ const user = verifyAuth(request);
+ if (!user) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ try {
+ const result = await query(`
+ SELECT fr.id, fr.title, fr.total_pool, fr.allocated, fr.remaining,
+ fr.status, fr.opens_at, fr.closes_at, fr.cause_filter,
+ fr.created_at, fr.updated_at,
+ g.name as granter_name, g.id as granter_id
+ FROM funding_rounds fr
+ JOIN granters g ON g.id = fr.granter_id
+ ORDER BY fr.created_at DESC
+ LIMIT 50
+ `);
+
+ return NextResponse.json({ funding_rounds: result.rows });
+ } catch (err) {
+ console.error('[funding] GET error:', (err as Error).message);
+ return NextResponse.json({ error: 'Failed to fetch funding rounds' }, { status: 500 });
+ }
+}
+
+export async function POST(request: NextRequest) {
+ const user = verifyAuth(request);
+ if (!user) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ try {
+ const body = await request.json();
+ const { title, granter_id, total_pool, opens_at, closes_at, cause_filter } = body;
+
+ if (!title || !granter_id || !total_pool) {
+ return NextResponse.json({ error: 'title, granter_id, and total_pool are required' }, { status: 400 });
+ }
+
+ const result = await query(
+ `INSERT INTO funding_rounds (title, granter_id, total_pool, remaining, opens_at, closes_at, cause_filter)
+ VALUES ($1, $2, $3, $3, $4, $5, $6)
+ RETURNING *`,
+ [title, granter_id, total_pool, opens_at || null, closes_at || null, cause_filter || []],
+ );
+
+ await auditLog('funding_round.created', 'funding_round', result.rows[0].id, { title, user });
+
+ return NextResponse.json({ funding_round: result.rows[0] }, { status: 201 });
+ } catch (err) {
+ console.error('[funding] POST error:', (err as Error).message);
+ return NextResponse.json({ error: 'Failed to create funding round' }, { status: 500 });
+ }
+}
diff --git a/app/api/granters/route.ts b/app/api/granters/route.ts
new file mode 100644
index 0000000..fc5f962
--- /dev/null
+++ b/app/api/granters/route.ts
@@ -0,0 +1,58 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { query } from '@/lib/db';
+import { verifyAuth } from '@/lib/auth';
+import { auditLog } from '@/lib/audit';
+
+export async function GET(request: NextRequest) {
+ const user = verifyAuth(request);
+ if (!user) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ try {
+ const result = await query(`
+ SELECT id, name, description, website_url, logo_url, city, state,
+ funding_areas, annual_budget, min_grant, max_grant, grants_per_year,
+ preference_tags, status, created_at, updated_at
+ FROM granters
+ WHERE status = 'active'
+ ORDER BY annual_budget DESC NULLS LAST
+ LIMIT 100
+ `);
+
+ return NextResponse.json({ granters: result.rows });
+ } catch (err) {
+ console.error('[granters] GET error:', (err as Error).message);
+ return NextResponse.json({ error: 'Failed to fetch granters' }, { status: 500 });
+ }
+}
+
+export async function POST(request: NextRequest) {
+ const user = verifyAuth(request);
+ if (!user) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ try {
+ const body = await request.json();
+ const { name, description, website_url, city, state, funding_areas, annual_budget, min_grant, max_grant, grants_per_year, preference_tags } = body;
+
+ if (!name) {
+ return NextResponse.json({ error: 'Granter name is required' }, { status: 400 });
+ }
+
+ const result = await query(
+ `INSERT INTO granters (name, description, website_url, city, state, funding_areas, annual_budget, min_grant, max_grant, grants_per_year, preference_tags)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
+ RETURNING *`,
+ [name, description || null, website_url || null, city || null, state || null, funding_areas || [], annual_budget || null, min_grant || null, max_grant || null, grants_per_year || null, preference_tags || []],
+ );
+
+ await auditLog('granter.created', 'granter', result.rows[0].id, { name, user });
+
+ return NextResponse.json({ granter: result.rows[0] }, { status: 201 });
+ } catch (err) {
+ console.error('[granters] POST error:', (err as Error).message);
+ return NextResponse.json({ error: 'Failed to create granter' }, { status: 500 });
+ }
+}
diff --git a/app/api/impact/route.ts b/app/api/impact/route.ts
new file mode 100644
index 0000000..35b77c2
--- /dev/null
+++ b/app/api/impact/route.ts
@@ -0,0 +1,32 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { query } from '@/lib/db';
+import { verifyAuth } from '@/lib/auth';
+
+export async function GET(request: NextRequest) {
+ const user = verifyAuth(request);
+ if (!user) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ try {
+ const result = await query(`
+ SELECT ir.id, ir.title, ir.summary as description, ir.people_helped, ir.funds_used,
+ ir.outcomes, ir.evidence_urls, ir.verified as status, ir.submitted_at as period_start,
+ ir.created_at,
+ o.name as org_name,
+ c.title as cause_title,
+ m.match_score
+ FROM impact_reports ir
+ JOIN organizations o ON o.id = ir.org_id
+ LEFT JOIN matches m ON m.id = ir.match_id
+ LEFT JOIN causes c ON c.id = m.cause_id
+ ORDER BY ir.created_at DESC
+ LIMIT 50
+ `);
+
+ return NextResponse.json({ impact_reports: result.rows });
+ } catch (err) {
+ console.error('[impact] GET error:', (err as Error).message);
+ return NextResponse.json({ error: 'Failed to fetch impact reports' }, { status: 500 });
+ }
+}
diff --git a/app/api/matches/generate/route.ts b/app/api/matches/generate/route.ts
new file mode 100644
index 0000000..68f27c7
--- /dev/null
+++ b/app/api/matches/generate/route.ts
@@ -0,0 +1,138 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { query } from '@/lib/db';
+import { verifyAuth } from '@/lib/auth';
+import { auditLog } from '@/lib/audit';
+
+const GEMINI_URL = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=' + (process.env.GEMINI_API_KEY || '');
+
+export async function POST(request: NextRequest) {
+ const user = verifyAuth(request);
+ if (!user) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ try {
+ // Fetch top causes, orgs, and granters
+ const [causesRes, orgsRes, grantersRes] = await Promise.all([
+ query(`SELECT id, title, description, category, urgency_score, total_votes, tags
+ FROM causes WHERE status = 'active' ORDER BY urgency_score DESC, total_votes DESC LIMIT 20`),
+ query(`SELECT id, name, mission, category, focus_areas, trust_score, impact_score, total_votes
+ FROM organizations WHERE status IN ('verified', 'featured') ORDER BY total_votes DESC LIMIT 20`),
+ query(`SELECT id, name, funding_areas, annual_budget, min_grant, max_grant, preference_tags
+ FROM granters WHERE status = 'active' LIMIT 20`),
+ ]);
+
+ if (causesRes.rows.length === 0 || orgsRes.rows.length === 0) {
+ return NextResponse.json({
+ error: 'Need at least one cause and one organization to generate matches',
+ }, { status: 400 });
+ }
+
+ const prompt = `You are a funding match analyst. Given these causes, organizations, and granters, generate optimal funding matches.
+
+CAUSES:
+${JSON.stringify(causesRes.rows, null, 2)}
+
+ORGANIZATIONS:
+${JSON.stringify(orgsRes.rows, null, 2)}
+
+GRANTERS:
+${JSON.stringify(grantersRes.rows, null, 2)}
+
+Generate up to 10 matches. Each match should pair a cause with the most suitable organization and optionally a granter. Score each match on:
+- cause_alignment (0-1): How well the org's focus matches the cause
+- public_support (0-1): Based on vote counts and urgency
+- granter_fit (0-1): How well granter preferences align (0 if no granter)
+- match_score (0-1): Overall weighted score
+
+Return ONLY valid JSON array:
+[{"cause_id":"uuid", "org_id":"uuid", "granter_id":"uuid or null", "match_score":0.85, "cause_alignment":0.9, "public_support":0.8, "granter_fit":0.85, "ai_reasoning":"2-3 sentence explanation"}]`;
+
+ const geminiRes = await fetch(GEMINI_URL, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ contents: [{ parts: [{ text: prompt }] }],
+ generationConfig: { temperature: 0.5, maxOutputTokens: 4096 },
+ }),
+ });
+
+ if (!geminiRes.ok) {
+ console.error('[matches/generate] Gemini error');
+ return NextResponse.json({ error: 'AI service error' }, { status: 502 });
+ }
+
+ const geminiData = await geminiRes.json();
+ const rawText = geminiData?.candidates?.[0]?.content?.parts?.[0]?.text || '';
+
+ let jsonStr = rawText.trim();
+ if (jsonStr.startsWith('```')) {
+ jsonStr = jsonStr.replace(/^```(?:json)?\n?/, '').replace(/\n?```$/, '');
+ }
+
+ let matches: Array<{
+ cause_id: string;
+ org_id: string;
+ granter_id: string | null;
+ match_score: number;
+ cause_alignment: number;
+ public_support: number;
+ granter_fit: number;
+ ai_reasoning: string;
+ }>;
+
+ try {
+ matches = JSON.parse(jsonStr);
+ } catch {
+ console.error('[matches/generate] Parse error:', jsonStr.slice(0, 300));
+ return NextResponse.json({ error: 'Failed to parse AI response' }, { status: 500 });
+ }
+
+ // Validate IDs exist
+ const validCauseIds = new Set(causesRes.rows.map(r => r.id));
+ const validOrgIds = new Set(orgsRes.rows.map(r => r.id));
+ const validGranterIds = new Set(grantersRes.rows.map(r => r.id));
+
+ const inserted = [];
+ for (const match of matches) {
+ if (!validCauseIds.has(match.cause_id) || !validOrgIds.has(match.org_id)) continue;
+ if (match.granter_id && !validGranterIds.has(match.granter_id)) {
+ match.granter_id = null;
+ }
+
+ try {
+ const result = await query(
+ `INSERT INTO matches (cause_id, org_id, granter_id, match_score, cause_alignment, public_support, granter_fit, ai_reasoning)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
+ RETURNING *`,
+ [
+ match.cause_id, match.org_id, match.granter_id,
+ Math.min(1, Math.max(0, match.match_score)),
+ Math.min(1, Math.max(0, match.cause_alignment)),
+ Math.min(1, Math.max(0, match.public_support)),
+ Math.min(1, Math.max(0, match.granter_fit)),
+ match.ai_reasoning,
+ ],
+ );
+ inserted.push(result.rows[0]);
+ } catch (insertErr) {
+ console.error('[matches/generate] Insert error:', (insertErr as Error).message);
+ }
+ }
+
+ await auditLog('matches.generated', 'match', null, {
+ user,
+ generated: matches.length,
+ inserted: inserted.length,
+ });
+
+ return NextResponse.json({
+ generated: matches.length,
+ inserted: inserted.length,
+ matches: inserted,
+ });
+ } catch (err) {
+ console.error('[matches/generate] Error:', (err as Error).message);
+ return NextResponse.json({ error: 'Failed to generate matches' }, { status: 500 });
+ }
+}
diff --git a/app/api/matches/route.ts b/app/api/matches/route.ts
new file mode 100644
index 0000000..324c1f8
--- /dev/null
+++ b/app/api/matches/route.ts
@@ -0,0 +1,42 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { query } from '@/lib/db';
+import { verifyAuth } from '@/lib/auth';
+
+export async function GET(request: NextRequest) {
+ const user = verifyAuth(request);
+ if (!user) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ try {
+ const status = request.nextUrl.searchParams.get('status');
+
+ let sql = `
+ SELECT m.id, m.cause_id, m.org_id, m.granter_id,
+ m.match_score, m.cause_alignment, m.public_support, m.granter_fit,
+ m.ai_reasoning, m.status, m.funding_amount, m.funded_at,
+ m.created_at, m.updated_at,
+ c.title as cause_title, c.urgency_score, c.category as cause_category,
+ o.name as org_name, o.trust_score, o.impact_score,
+ g.name as granter_name, g.annual_budget as granter_budget
+ FROM matches m
+ JOIN causes c ON c.id = m.cause_id
+ JOIN organizations o ON o.id = m.org_id
+ LEFT JOIN granters g ON g.id = m.granter_id
+ `;
+ const params: unknown[] = [];
+
+ if (status) {
+ params.push(status);
+ sql += ` WHERE m.status = $${params.length}`;
+ }
+
+ sql += ' ORDER BY m.match_score DESC LIMIT 100';
+
+ const result = await query(sql, params);
+ return NextResponse.json({ matches: result.rows });
+ } catch (err) {
+ console.error('[matches] GET error:', (err as Error).message);
+ return NextResponse.json({ error: 'Failed to fetch matches' }, { status: 500 });
+ }
+}
diff --git a/app/api/organizations/route.ts b/app/api/organizations/route.ts
new file mode 100644
index 0000000..5c1dffa
--- /dev/null
+++ b/app/api/organizations/route.ts
@@ -0,0 +1,79 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { query } from '@/lib/db';
+import { verifyAuth } from '@/lib/auth';
+import { auditLog } from '@/lib/audit';
+
+export async function GET(request: NextRequest) {
+ const user = verifyAuth(request);
+ if (!user) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ try {
+ const category = request.nextUrl.searchParams.get('category');
+ const sort = request.nextUrl.searchParams.get('sort') || 'votes';
+
+ let sql = `
+ SELECT id, name, ein, mission, description, website_url, logo_url,
+ city, state, category, focus_areas, year_founded, annual_budget,
+ staff_size, impact_score, trust_score, total_votes,
+ total_funding_received, status, verified_at, created_at, updated_at
+ FROM organizations
+ WHERE status IN ('verified', 'featured', 'pending')
+ `;
+ const params: unknown[] = [];
+
+ if (category) {
+ params.push(category);
+ sql += ` AND category = $${params.length}`;
+ }
+
+ if (sort === 'impact') {
+ sql += ' ORDER BY impact_score DESC';
+ } else if (sort === 'trust') {
+ sql += ' ORDER BY trust_score DESC';
+ } else if (sort === 'funding') {
+ sql += ' ORDER BY total_funding_received DESC';
+ } else {
+ sql += ' ORDER BY total_votes DESC';
+ }
+
+ sql += ' LIMIT 100';
+
+ const result = await query(sql, params);
+ return NextResponse.json({ organizations: result.rows });
+ } catch (err) {
+ console.error('[organizations] GET error:', (err as Error).message);
+ return NextResponse.json({ error: 'Failed to fetch organizations' }, { status: 500 });
+ }
+}
+
+export async function POST(request: NextRequest) {
+ const user = verifyAuth(request);
+ if (!user) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ try {
+ const body = await request.json();
+ const { name, ein, mission, description, website_url, city, state, category, focus_areas, year_founded, annual_budget, staff_size } = body;
+
+ if (!name) {
+ return NextResponse.json({ error: 'Organization name is required' }, { status: 400 });
+ }
+
+ const result = await query(
+ `INSERT INTO organizations (name, ein, mission, description, website_url, city, state, category, focus_areas, year_founded, annual_budget, staff_size)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
+ RETURNING *`,
+ [name, ein || null, mission || null, description || null, website_url || null, city || null, state || null, category || 'other', focus_areas || [], year_founded || null, annual_budget || null, staff_size || null],
+ );
+
+ await auditLog('org.created', 'organization', result.rows[0].id, { name, user });
+
+ return NextResponse.json({ organization: result.rows[0] }, { status: 201 });
+ } catch (err) {
+ console.error('[organizations] POST error:', (err as Error).message);
+ return NextResponse.json({ error: 'Failed to create organization' }, { status: 500 });
+ }
+}
diff --git a/app/api/pulse/route.ts b/app/api/pulse/route.ts
new file mode 100644
index 0000000..122c707
--- /dev/null
+++ b/app/api/pulse/route.ts
@@ -0,0 +1,69 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { query } from '@/lib/db';
+import { verifyAuth } from '@/lib/auth';
+
+export async function GET(request: NextRequest) {
+ const user = verifyAuth(request);
+ if (!user) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ try {
+ const [
+ causesCount,
+ totalVotes,
+ orgsCount,
+ grantersCount,
+ matchesCount,
+ fundedAmount,
+ revenueStats,
+ pendingFees,
+ topCauses,
+ topOrgs,
+ recentMatches,
+ fundingRounds,
+ ] = await Promise.all([
+ query(`SELECT COUNT(*)::int as count FROM causes WHERE status = 'active'`),
+ query(`SELECT COALESCE(SUM(total_votes), 0)::int as total FROM causes WHERE status = 'active'`),
+ query(`SELECT COUNT(*)::int as count FROM organizations WHERE status IN ('verified', 'featured', 'pending')`),
+ query(`SELECT COUNT(*)::int as count FROM granters WHERE status = 'active'`),
+ query(`SELECT COUNT(*)::int as count FROM matches`),
+ query(`SELECT COALESCE(SUM(funding_amount), 0)::numeric as total FROM matches WHERE status = 'funded'`),
+ query(`SELECT COALESCE(SUM(COALESCE(commission_amount, 0) + COALESCE(flat_fee, 0)), 0)::numeric as total FROM revenue WHERE status = 'paid'`),
+ query(`SELECT COALESCE(SUM(COALESCE(commission_amount, 0) + COALESCE(flat_fee, 0)), 0)::numeric as total FROM revenue WHERE status IN ('pending', 'invoiced')`),
+ query(`SELECT id, title, urgency_score, total_votes, total_supporters, category, tags, trending_rank
+ FROM causes WHERE status = 'active' ORDER BY urgency_score DESC, total_votes DESC LIMIT 10`),
+ query(`SELECT id, name, category, impact_score, trust_score, total_votes, total_funding_received, status
+ FROM organizations WHERE status IN ('verified', 'featured') ORDER BY total_votes DESC, impact_score DESC LIMIT 5`),
+ query(`SELECT m.id, m.match_score, m.status, m.created_at,
+ c.title as cause_title, o.name as org_name, g.name as granter_name
+ FROM matches m
+ JOIN causes c ON c.id = m.cause_id
+ JOIN organizations o ON o.id = m.org_id
+ LEFT JOIN granters g ON g.id = m.granter_id
+ ORDER BY m.created_at DESC LIMIT 5`),
+ query(`SELECT id, title, total_pool, allocated, remaining, status, opens_at, closes_at
+ FROM funding_rounds ORDER BY created_at DESC LIMIT 5`),
+ ]);
+
+ return NextResponse.json({
+ stats: {
+ total_causes: causesCount.rows[0].count,
+ total_votes: totalVotes.rows[0].total,
+ registered_orgs: orgsCount.rows[0].count,
+ active_granters: grantersCount.rows[0].count,
+ total_matches: matchesCount.rows[0].count,
+ funds_matched: parseFloat(fundedAmount.rows[0].total) || 0,
+ revenue_earned: parseFloat(revenueStats.rows[0].total) || 0,
+ pending_fees: parseFloat(pendingFees.rows[0].total) || 0,
+ },
+ top_causes: topCauses.rows,
+ top_orgs: topOrgs.rows,
+ recent_matches: recentMatches.rows,
+ funding_rounds: fundingRounds.rows,
+ });
+ } catch (err) {
+ console.error('[pulse] GET error:', (err as Error).message);
+ return NextResponse.json({ error: 'Failed to fetch pulse data' }, { status: 500 });
+ }
+}
diff --git a/app/api/revenue/route.ts b/app/api/revenue/route.ts
new file mode 100644
index 0000000..ab4958d
--- /dev/null
+++ b/app/api/revenue/route.ts
@@ -0,0 +1,97 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { query } from '@/lib/db';
+import { verifyAuth } from '@/lib/auth';
+
+export async function GET(request: NextRequest) {
+ const user = verifyAuth(request);
+ if (!user) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ try {
+ const statusFilter = request.nextUrl.searchParams.get('status');
+
+ let sql = `
+ SELECT r.id, r.match_id, r.org_id, r.granter_id,
+ r.fee_type, r.flat_fee, r.percentage_rate,
+ r.grant_amount, r.commission_amount,
+ r.status, r.invoice_date, r.paid_date, r.notes,
+ r.created_at, r.updated_at,
+ o.name as org_name,
+ g.name as granter_name,
+ CASE WHEN m.id IS NOT NULL THEN c.title ELSE NULL END as cause_title
+ FROM revenue r
+ LEFT JOIN organizations o ON o.id = r.org_id
+ LEFT JOIN granters g ON g.id = r.granter_id
+ LEFT JOIN matches m ON m.id = r.match_id
+ LEFT JOIN causes c ON m.id IS NOT NULL AND c.id = m.cause_id
+ `;
+ const params: unknown[] = [];
+
+ if (statusFilter) {
+ params.push(statusFilter);
+ sql += ` WHERE r.status = $${params.length}`;
+ }
+
+ sql += ' ORDER BY r.created_at DESC LIMIT 100';
+
+ const result = await query(sql, params);
+
+ // Calculate stats
+ const statsRes = await query(`
+ SELECT
+ COALESCE(SUM(COALESCE(commission_amount, 0) + COALESCE(flat_fee, 0)), 0)::numeric as total,
+ COALESCE(SUM(CASE WHEN status = 'pending' THEN COALESCE(commission_amount, 0) + COALESCE(flat_fee, 0) ELSE 0 END), 0)::numeric as pending,
+ COALESCE(SUM(CASE WHEN status = 'invoiced' THEN COALESCE(commission_amount, 0) + COALESCE(flat_fee, 0) ELSE 0 END), 0)::numeric as invoiced,
+ COALESCE(SUM(CASE WHEN status = 'paid' THEN COALESCE(commission_amount, 0) + COALESCE(flat_fee, 0) ELSE 0 END), 0)::numeric as paid
+ FROM revenue
+ `);
+
+ return NextResponse.json({
+ revenue: result.rows,
+ stats: {
+ total: parseFloat(statsRes.rows[0].total) || 0,
+ pending: parseFloat(statsRes.rows[0].pending) || 0,
+ invoiced: parseFloat(statsRes.rows[0].invoiced) || 0,
+ paid: parseFloat(statsRes.rows[0].paid) || 0,
+ },
+ });
+ } catch (err) {
+ console.error('[revenue] GET error:', (err as Error).message);
+ return NextResponse.json({ error: 'Failed to fetch revenue' }, { status: 500 });
+ }
+}
+
+export async function POST(request: NextRequest) {
+ const user = verifyAuth(request);
+ if (!user) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ try {
+ const body = await request.json();
+ const { match_id, org_id, granter_id, fee_type, flat_fee, percentage_rate, grant_amount, notes } = body;
+
+ if (!fee_type) {
+ return NextResponse.json({ error: 'fee_type is required' }, { status: 400 });
+ }
+
+ // Calculate commission
+ let commission = flat_fee || 0;
+ if (percentage_rate && grant_amount) {
+ commission += (percentage_rate / 100) * grant_amount;
+ }
+
+ const result = await query(
+ `INSERT INTO revenue (match_id, org_id, granter_id, fee_type, flat_fee, percentage_rate, grant_amount, commission_amount, notes)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
+ RETURNING *`,
+ [match_id || null, org_id || null, granter_id || null, fee_type, flat_fee || 0, percentage_rate || 0, grant_amount || null, commission, notes || null],
+ );
+
+ return NextResponse.json({ revenue: result.rows[0] }, { status: 201 });
+ } catch (err) {
+ console.error('[revenue] POST error:', (err as Error).message);
+ return NextResponse.json({ error: 'Failed to create revenue entry' }, { status: 500 });
+ }
+}
diff --git a/app/api/votes/route.ts b/app/api/votes/route.ts
new file mode 100644
index 0000000..bfa4f29
--- /dev/null
+++ b/app/api/votes/route.ts
@@ -0,0 +1,101 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { query, getClient } from '@/lib/db';
+import { verifyAuth } from '@/lib/auth';
+import { auditLog } from '@/lib/audit';
+
+export async function GET(request: NextRequest) {
+ const user = verifyAuth(request);
+ if (!user) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ try {
+ const cause_id = request.nextUrl.searchParams.get('cause_id');
+
+ let sql: string;
+ let params: unknown[];
+
+ if (cause_id) {
+ sql = `
+ SELECT v.id, v.cause_id, v.org_id, v.weight, v.reason, v.created_at,
+ o.name as org_name, c.title as cause_title
+ FROM votes v
+ JOIN organizations o ON o.id = v.org_id
+ JOIN causes c ON c.id = v.cause_id
+ WHERE v.cause_id = $1
+ ORDER BY v.created_at DESC
+ LIMIT 100
+ `;
+ params = [cause_id];
+ } else {
+ sql = `
+ SELECT v.id, v.cause_id, v.org_id, v.weight, v.reason, v.created_at,
+ o.name as org_name, c.title as cause_title
+ FROM votes v
+ JOIN organizations o ON o.id = v.org_id
+ JOIN causes c ON c.id = v.cause_id
+ ORDER BY v.created_at DESC
+ LIMIT 100
+ `;
+ params = [];
+ }
+
+ const result = await query(sql, params);
+ return NextResponse.json({ votes: result.rows });
+ } catch (err) {
+ console.error('[votes] GET error:', (err as Error).message);
+ return NextResponse.json({ error: 'Failed to fetch votes' }, { status: 500 });
+ }
+}
+
+export async function POST(request: NextRequest) {
+ const user = verifyAuth(request);
+ if (!user) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ const client = await getClient();
+
+ try {
+ const body = await request.json();
+ const { cause_id, org_id, weight, reason } = body;
+
+ if (!cause_id || !org_id) {
+ return NextResponse.json({ error: 'cause_id and org_id are required' }, { status: 400 });
+ }
+
+ await client.query('BEGIN');
+
+ // Insert vote
+ const result = await client.query(
+ `INSERT INTO votes (cause_id, org_id, weight, reason)
+ VALUES ($1, $2, $3, $4)
+ RETURNING *`,
+ [cause_id, org_id, weight || 1.0, reason || null],
+ );
+
+ // Update cause vote count
+ await client.query(
+ `UPDATE causes SET total_votes = total_votes + 1, total_supporters = total_supporters + 1 WHERE id = $1`,
+ [cause_id],
+ );
+
+ // Update org vote count
+ await client.query(
+ `UPDATE organizations SET total_votes = total_votes + 1 WHERE id = $1`,
+ [org_id],
+ );
+
+ await client.query('COMMIT');
+
+ await auditLog('vote.cast', 'vote', result.rows[0].id, { cause_id, org_id, user });
+
+ return NextResponse.json({ vote: result.rows[0] }, { status: 201 });
+ } catch (err) {
+ await client.query('ROLLBACK');
+ console.error('[votes] POST error:', (err as Error).message);
+ return NextResponse.json({ error: 'Failed to cast vote' }, { status: 500 });
+ } finally {
+ client.release();
+ }
+}
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..50368f5
--- /dev/null
+++ b/app/globals.css
@@ -0,0 +1,424 @@
+@import "tailwindcss";
+
+/* ---- Freddy Design Tokens (Amber/Gold Dark Theme) ---- */
+:root {
+ /* Backgrounds — warm dark */
+ --color-bg: #0f0d0a;
+ --color-surface: #171310;
+ --color-surface-el: #1f1b16;
+
+ /* Brand - Amber/Gold */
+ --color-primary: #d97706;
+ --color-primary-hover:#b45309;
+ --color-accent: #fbbf24;
+ --color-secondary: #f59e0b;
+
+ /* Semantic */
+ --color-success: #22c55e;
+ --color-warning: #f59e0b;
+ --color-error: #ef4444;
+
+ /* Text */
+ --color-text: #f5f0e8;
+ --color-text-secondary: #a89a88;
+ --color-text-muted: #8a8070;
+
+ /* Structure */
+ --color-border: #2d2820;
+
+ /* 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: #3d3428;
+}
+
+.card-elevated {
+ background-color: var(--color-surface-el);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-lg);
+ padding: 1rem;
+}
+
+/* ---- 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: rgba(217, 119, 6, 0.3);
+ transform: translateY(-1px);
+}
+
+/* ---- 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: #2a2520;
+ border-color: #3d3428;
+}
+
+.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-amber {
+ background-color: rgba(217, 119, 6, 0.15);
+ color: var(--color-accent);
+ border: 1px solid rgba(217, 119, 6, 0.3);
+}
+
+.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-blue {
+ background-color: rgba(59, 130, 246, 0.15);
+ color: #60a5fa;
+ border: 1px solid rgba(59, 130, 246, 0.3);
+}
+
+.badge-purple {
+ background-color: rgba(168, 85, 247, 0.15);
+ color: #c084fc;
+ border: 1px solid rgba(168, 85, 247, 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(217, 119, 6, 0.2);
+}
+
+textarea.input {
+ resize: vertical;
+ min-height: 60px;
+}
+
+select.input {
+ cursor: pointer;
+ appearance: auto;
+}
+
+/* ---- 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-accent); }
+
+.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); }
+}
+
+/* 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;
+ }
+}
+
+/* ---- Pulse animations ---- */
+@keyframes pulse-amber {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.5; }
+}
+
+.animate-pulse-amber {
+ animation: pulse-amber 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
+}
+
+/* ---- Urgency gauge ---- */
+.urgency-gauge {
+ height: 6px;
+ border-radius: 3px;
+ background: var(--color-surface-el);
+ overflow: hidden;
+}
+
+.urgency-gauge-fill {
+ height: 100%;
+ border-radius: 3px;
+ transition: width 0.5s ease;
+}
+
+/* ---- Score bar ---- */
+.score-bar {
+ height: 4px;
+ border-radius: 2px;
+ background: var(--color-surface-el);
+ overflow: hidden;
+}
+
+.score-bar-fill {
+ height: 100%;
+ border-radius: 2px;
+ transition: width 0.5s ease;
+ background: linear-gradient(90deg, var(--color-primary), var(--color-accent));
+}
+
+/* ---- Modal overlay ---- */
+.modal-overlay {
+ position: fixed;
+ inset: 0;
+ z-index: 50;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 1rem;
+ background-color: rgba(0, 0, 0, 0.65);
+ backdrop-filter: blur(4px);
+}
+
+.modal-content {
+ width: 100%;
+ max-width: 32rem;
+ max-height: 90vh;
+ overflow-y: auto;
+ border-radius: var(--radius-lg);
+ padding: 1.5rem;
+ background-color: var(--color-surface);
+ border: 1px solid var(--color-border);
+}
+
+/* ---- Letter preview ---- */
+.letter-preview {
+ background-color: var(--color-surface-el);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-md);
+ padding: 1.25rem;
+ font-size: 0.875rem;
+ line-height: 1.6;
+ color: var(--color-text);
+ white-space: pre-wrap;
+}
diff --git a/app/icon.svg b/app/icon.svg
new file mode 100644
index 0000000..66db7c6
--- /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="#d97706"/>
+<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..ac611b0
--- /dev/null
+++ b/app/layout.tsx
@@ -0,0 +1,21 @@
+import type { Metadata } from 'next';
+import './globals.css';
+
+export const metadata: Metadata = {
+ title: 'Freddy - The Fundraising Coordinator',
+ description: 'Democratic funding marketplace connecting citizens, non-profits, and granters',
+};
+
+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..30f6928
--- /dev/null
+++ b/app/login/page.tsx
@@ -0,0 +1,158 @@
+'use client';
+
+import { useState, FormEvent } from 'react';
+import { useRouter } from 'next/navigation';
+import { Loader2, Flame } 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, #d97706, #b45309)',
+ boxShadow: '0 4px 20px rgba(217, 119, 6, 0.3)',
+ }}
+ >
+ <Flame size={28} style={{ color: '#fff' }} />
+ </div>
+ <h1 className="text-2xl font-bold" style={{ color: 'var(--color-text)' }}>
+ Freddy
+ </h1>
+ <p className="mt-1 text-sm" style={{ color: 'var(--color-text-secondary)' }}>
+ The Fundraising Coordinator
+ </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"
+ 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)' }}>
+ Democratic Funding Marketplace
+ </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/components/AppShell.tsx b/components/AppShell.tsx
new file mode 100644
index 0000000..16dfd4c
--- /dev/null
+++ b/components/AppShell.tsx
@@ -0,0 +1,220 @@
+'use client';
+
+import { useState } from 'react';
+import { LogOut, Menu, LayoutGrid } from 'lucide-react';
+import { AuthProvider, useAuth } from './AuthProvider';
+import { ToastProvider } from './ToastProvider';
+import Sidebar, { type TabId } from './Sidebar';
+import PulseBoard from './pulse/PulseBoard';
+import CausesTab from './causes/CausesTab';
+import OrgsTab from './organizations/OrgsTab';
+import GrantersTab from './granters/GrantersTab';
+import MatchesTab from './matches/MatchesTab';
+import SolicitorTab from './solicitor/SolicitorTab';
+import RevenueTab from './revenue/RevenueTab';
+import ImpactTab from './impact/ImpactTab';
+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 = 7470;
+
+function Shell() {
+ const { user, logout } = useAuth();
+ const [activeTab, setActiveTab] = useState<TabId>('pulse');
+ const [sidebarOpen, setSidebarOpen] = useState(false);
+ const [showAppSwitcher, setShowAppSwitcher] = useState(false);
+
+ function handleTabChange(tab: TabId) {
+ setActiveTab(tab);
+ setSidebarOpen(false);
+ }
+
+ function renderPanel() {
+ switch (activeTab) {
+ case 'pulse': return <PulseBoard />;
+ case 'causes': return <CausesTab />;
+ case 'organizations': return <OrgsTab />;
+ case 'granters': return <GrantersTab />;
+ case 'matches': return <MatchesTab />;
+ case 'solicitor': return <SolicitorTab />;
+ case 'revenue': return <RevenueTab />;
+ case 'impact': return <ImpactTab />;
+ case 'settings': return <SettingsTab />;
+ default: return null;
+ }
+ }
+
+ const TAB_LABELS: Record<TabId, string> = {
+ pulse: 'Pulse Board',
+ causes: 'Causes',
+ organizations: 'Organizations',
+ granters: 'Granters',
+ matches: 'AI Matches',
+ solicitor: 'Solicitor',
+ revenue: 'Revenue',
+ impact: 'Impact Reports',
+ settings: 'Settings',
+ };
+
+ return (
+ <div
+ className="flex"
+ style={{ backgroundColor: 'var(--color-bg)', minHeight: '100vh' }}
+ >
+ <Sidebar activeTab={activeTab} onTabChange={handleTabChange} isOpen={sidebarOpen} onToggle={() => setSidebarOpen(p => !p)} />
+
+ <div className="flex flex-col flex-1 min-w-0">
+ <header
+ className="flex items-center justify-between px-5 h-14 shrink-0"
+ style={{
+ backgroundColor: 'var(--color-surface)',
+ borderBottom: '1px solid var(--color-border)',
+ }}
+ >
+ <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>
+
+ <div className="flex items-center gap-3">
+ <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 className="flex-1 overflow-auto">
+ {renderPanel()}
+ </main>
+ </div>
+ </div>
+ );
+}
+
+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..635f50f
--- /dev/null
+++ b/components/AuthProvider.tsx
@@ -0,0 +1,123 @@
+'use client';
+
+import {
+ createContext,
+ useContext,
+ useEffect,
+ useState,
+ useCallback,
+ ReactNode,
+} from 'react';
+import { useRouter, usePathname } from 'next/navigation';
+import { Loader2 } from 'lucide-react';
+
+interface AuthContextValue {
+ user: string | null;
+ loading: boolean;
+ login: (username: string, password: string) => Promise<{ error?: string }>;
+ logout: () => Promise<void>;
+}
+
+const AuthContext = createContext<AuthContextValue | null>(null);
+
+export function AuthProvider({ children }: { children: ReactNode }) {
+ const router = useRouter();
+ const pathname = usePathname();
+
+ const [user, setUser] = useState<string | null>(null);
+ const [loading, setLoading] = useState(true);
+
+ 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; };
+ }, []);
+
+ useEffect(() => {
+ if (loading) return;
+ if (!user && pathname !== '/login') {
+ router.replace('/login');
+ }
+ }, [user, loading, pathname, router]);
+
+ 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]);
+
+ const logout = useCallback(async () => {
+ await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' });
+ setUser(null);
+ router.replace('/login');
+ }, [router]);
+
+ 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>
+ );
+}
+
+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/Sidebar.tsx b/components/Sidebar.tsx
new file mode 100644
index 0000000..6f51166
--- /dev/null
+++ b/components/Sidebar.tsx
@@ -0,0 +1,303 @@
+'use client';
+
+import { useState } from 'react';
+import {
+ BarChart3,
+ Flame,
+ Building2,
+ Landmark,
+ Sparkles,
+ Send,
+ DollarSign,
+ TrendingUp,
+ Settings,
+ ChevronLeft,
+ ChevronRight,
+} from 'lucide-react';
+
+export type TabId =
+ | 'pulse'
+ | 'causes'
+ | 'organizations'
+ | 'granters'
+ | 'matches'
+ | 'solicitor'
+ | 'revenue'
+ | 'impact'
+ | '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;
+}
+
+const NAV_ITEMS: NavItem[] = [
+ { id: 'pulse', label: 'Pulse Board', Icon: BarChart3, section: 'main' },
+ { id: 'causes', label: 'Causes', Icon: Flame, section: 'main' },
+ { id: 'organizations', label: 'Organizations', Icon: Building2, section: 'main' },
+ { id: 'granters', label: 'Granters', Icon: Landmark, section: 'main' },
+ { id: 'matches', label: 'Matches', Icon: Sparkles, section: 'main' },
+ { id: 'solicitor', label: 'Solicitor', Icon: Send, section: 'main' },
+ { id: 'revenue', label: 'Revenue', Icon: DollarSign, section: 'main' },
+ { id: 'impact', label: 'Impact', Icon: TrendingUp, section: 'main' },
+ { id: 'settings', label: 'Settings', Icon: Settings, section: 'tools' },
+];
+
+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, #d97706, #b45309)',
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ flexShrink: 0,
+ boxShadow: '0 2px 8px rgba(217, 119, 6, 0.3)',
+ fontWeight: 800,
+ fontSize: 16,
+ color: '#fff',
+ fontFamily: 'Georgia, serif',
+ }}
+ >
+ F
+ </div>
+ {!collapsed && (
+ <div style={{ overflow: 'hidden', whiteSpace: 'nowrap' }}>
+ <div style={{ fontSize: 14, fontWeight: 700, color: 'var(--color-text)', lineHeight: 1.2 }}>
+ Freddy
+ </div>
+ <div style={{ fontSize: 10, color: 'var(--color-text-muted)', lineHeight: 1.2, letterSpacing: '0.02em' }}>
+ Fundraising Coordinator
+ </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' }}>
+ Platform
+ </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>
+
+ <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' }}>
+ System
+ </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>
+ </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>
+ </>
+ );
+}
+
+function SidebarButton({
+ 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(217, 119, 6, 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 -- amber */}
+ {isActive && (
+ <div
+ style={{
+ position: 'absolute',
+ left: 0,
+ top: '20%',
+ bottom: '20%',
+ width: 3,
+ borderRadius: '0 3px 3px 0',
+ background: 'linear-gradient(180deg, #fbbf24, #d97706)',
+ }}
+ />
+ )}
+ <Icon
+ size={18}
+ aria-hidden="true"
+ style={{
+ color: isActive ? '#fbbf24' : '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..612b1a2
--- /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 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/causes/CausesTab.tsx b/components/causes/CausesTab.tsx
new file mode 100644
index 0000000..8d14a6b
--- /dev/null
+++ b/components/causes/CausesTab.tsx
@@ -0,0 +1,387 @@
+'use client';
+
+import { useState, useEffect, useCallback } from 'react';
+import { motion, AnimatePresence } from 'framer-motion';
+import { SkeletonList } from '../Skeleton';
+import {
+ Flame, Search, Plus, Sparkles, Loader2, TrendingUp,
+ ArrowUpRight, Tag, Users, X,
+} from 'lucide-react';
+import { useToast } from '../ToastProvider';
+import { useDebounce } from '@/hooks/useDebounce';
+
+interface Cause {
+ id: string;
+ title: string;
+ description: string;
+ category: string;
+ urgency_score: number;
+ petition_velocity: number;
+ news_mentions: number;
+ total_votes: number;
+ total_supporters: number;
+ trending_rank: number | null;
+ source: string;
+ tags: string[];
+ featured: boolean;
+ status: string;
+ created_at: string;
+}
+
+const CATEGORIES = [
+ 'all', 'education', 'health', 'environment', 'poverty', 'housing',
+ 'legal_aid', 'civil_rights', 'veterans', 'youth', 'elderly',
+ 'disability', 'arts', 'other',
+];
+
+const fadeIn = {
+ hidden: { opacity: 0, y: 20 },
+ visible: (i: number) => ({
+ opacity: 1, y: 0,
+ transition: { delay: i * 0.04, duration: 0.35 },
+ }),
+};
+
+export default function CausesTab() {
+ const { addToast } = useToast();
+ const [causes, setCauses] = useState<Cause[]>([]);
+ const [loading, setLoading] = useState(true);
+ const [discovering, setDiscovering] = useState(false);
+ const [sortBy, setSortBy] = useState('urgency');
+ const [category, setCategory] = useState('all');
+ const [searchTerm, setSearchTerm] = useState('');
+ const debouncedSearch = useDebounce(searchTerm, 300);
+ const [showCreate, setShowCreate] = useState(false);
+
+ const fetchCauses = useCallback(async () => {
+ try {
+ const params = new URLSearchParams({ sort: sortBy });
+ if (category !== 'all') params.set('category', category);
+ const res = await fetch(`/api/causes?${params}`, { credentials: 'include' });
+ if (res.ok) {
+ const data = await res.json();
+ setCauses(data.causes || []);
+ }
+ } catch (err) {
+ console.error('Failed to fetch causes:', err);
+ } finally {
+ setLoading(false);
+ }
+ }, [sortBy, category]);
+
+ useEffect(() => { fetchCauses(); }, [fetchCauses]);
+
+ const handleDiscover = async () => {
+ setDiscovering(true);
+ try {
+ const res = await fetch('/api/causes/discover', {
+ method: 'POST',
+ credentials: 'include',
+ });
+ if (res.ok) {
+ await fetchCauses();
+ addToast('Causes discovered', 'success');
+ } else {
+ addToast('Discovery failed', 'error');
+ }
+ } catch (err) {
+ console.error('Discover error:', err);
+ addToast('Discovery failed', 'error');
+ } finally {
+ setDiscovering(false);
+ }
+ };
+
+ const handleCreateCause = async (formData: { title: string; description: string; category: string; tags: string }) => {
+ try {
+ const res = await fetch('/api/causes', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ body: JSON.stringify({
+ title: formData.title,
+ description: formData.description,
+ category: formData.category,
+ tags: formData.tags.split(',').map(t => t.trim()).filter(Boolean),
+ }),
+ });
+ if (res.ok) {
+ setShowCreate(false);
+ await fetchCauses();
+ addToast('Cause created', 'success');
+ } else {
+ addToast('Failed to create cause', 'error');
+ }
+ } catch (err) {
+ console.error('Create cause error:', err);
+ addToast('Failed to create cause', 'error');
+ }
+ };
+
+ const filtered = causes.filter(c =>
+ !debouncedSearch || c.title.toLowerCase().includes(debouncedSearch.toLowerCase()) ||
+ (c.description && c.description.toLowerCase().includes(debouncedSearch.toLowerCase()))
+ );
+
+ return (
+ <div className="p-6 space-y-6">
+ {/* Header */}
+ <div className="flex items-center justify-between flex-wrap gap-3">
+ <div>
+ <h2 className="text-xl font-bold flex items-center gap-2" style={{ color: 'var(--color-text)' }}>
+ <Flame size={20} style={{ color: '#ef4444' }} />
+ Causes
+ </h2>
+ <p className="text-sm mt-1" style={{ color: 'var(--color-text-muted)' }}>
+ Browse and vote on causes that matter most
+ </p>
+ </div>
+ <div className="flex items-center gap-2">
+ <button
+ onClick={handleDiscover}
+ disabled={discovering}
+ className="btn btn-primary"
+ >
+ {discovering ? <Loader2 size={14} className="animate-spin" /> : <Sparkles size={14} />}
+ {discovering ? 'Discovering...' : 'AI Discover Causes'}
+ </button>
+ <button onClick={() => setShowCreate(true)} className="btn btn-secondary">
+ <Plus size={14} />
+ Add Cause
+ </button>
+ </div>
+ </div>
+
+ {/* Filters */}
+ <div className="flex items-center gap-3 flex-wrap">
+ <div className="relative flex-1 max-w-xs">
+ <Search size={14} style={{ position: 'absolute', left: 10, top: '50%', transform: 'translateY(-50%)', color: 'var(--color-text-muted)' }} />
+ <input
+ className="input pl-8"
+ placeholder="Search causes..."
+ value={searchTerm}
+ onChange={(e) => setSearchTerm(e.target.value)}
+ />
+ </div>
+ <select
+ className="input"
+ style={{ width: 'auto' }}
+ value={sortBy}
+ onChange={(e) => setSortBy(e.target.value)}
+ >
+ <option value="urgency">Sort by Urgency</option>
+ <option value="votes">Sort by Votes</option>
+ <option value="trending">Sort by Trending</option>
+ </select>
+ </div>
+
+ {/* Category Pills */}
+ <div className="flex items-center gap-2 flex-wrap">
+ {CATEGORIES.map((cat) => (
+ <button
+ key={cat}
+ onClick={() => setCategory(cat)}
+ className="btn btn-sm"
+ style={{
+ backgroundColor: category === cat ? 'rgba(217, 119, 6, 0.2)' : 'var(--color-surface-el)',
+ color: category === cat ? '#fbbf24' : 'var(--color-text-secondary)',
+ borderColor: category === cat ? 'rgba(217, 119, 6, 0.3)' : 'var(--color-border)',
+ }}
+ >
+ {cat === 'all' ? 'All' : cat.replace('_', ' ')}
+ </button>
+ ))}
+ </div>
+
+ {/* Causes Grid */}
+ {loading ? (
+ <SkeletonList count={4} />
+ ) : (
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
+ <AnimatePresence>
+ {filtered.map((cause, i) => (
+ <motion.div
+ key={cause.id}
+ custom={i}
+ initial="hidden"
+ animate="visible"
+ variants={fadeIn}
+ className="card p-4 flex flex-col gap-3"
+ >
+ <div className="flex items-start justify-between">
+ <div className="flex-1 min-w-0">
+ <h3 className="text-sm font-semibold truncate" style={{ color: 'var(--color-text)' }}>
+ {cause.title}
+ </h3>
+ {cause.category && (
+ <span className="badge badge-amber text-xs mt-1">
+ {cause.category.replace('_', ' ')}
+ </span>
+ )}
+ </div>
+ <div className="flex items-center gap-1">
+ {cause.source === 'ai' && (
+ <span title="AI Discovered"><Sparkles size={12} style={{ color: '#c084fc' }} /></span>
+ )}
+ {cause.urgency_score > 0.7 && (
+ <ArrowUpRight size={14} style={{ color: '#ef4444' }} />
+ )}
+ </div>
+ </div>
+
+ {cause.description && (
+ <p className="text-xs line-clamp-3" style={{ color: 'var(--color-text-muted)' }}>
+ {cause.description}
+ </p>
+ )}
+
+ {/* Urgency Gauge */}
+ <div>
+ <div className="flex items-center justify-between text-xs mb-1">
+ <span style={{ color: 'var(--color-text-muted)' }}>Urgency</span>
+ <span style={{
+ color: cause.urgency_score > 0.7 ? '#ef4444' : cause.urgency_score > 0.4 ? '#d97706' : '#22c55e',
+ fontWeight: 600,
+ }}>
+ {(cause.urgency_score * 100).toFixed(0)}%
+ </span>
+ </div>
+ <div className="urgency-gauge">
+ <div
+ className="urgency-gauge-fill"
+ style={{
+ width: `${cause.urgency_score * 100}%`,
+ background: cause.urgency_score > 0.7
+ ? 'linear-gradient(90deg, #d97706, #ef4444)'
+ : cause.urgency_score > 0.4
+ ? 'linear-gradient(90deg, #22c55e, #d97706)'
+ : '#22c55e',
+ }}
+ />
+ </div>
+ </div>
+
+ {/* Stats */}
+ <div className="flex items-center gap-3 text-xs" style={{ color: 'var(--color-text-muted)' }}>
+ <span className="flex items-center gap-1">
+ <Users size={12} /> {cause.total_votes} votes
+ </span>
+ <span className="flex items-center gap-1">
+ <TrendingUp size={12} /> {cause.total_supporters} supporters
+ </span>
+ </div>
+
+ {/* Tags */}
+ {cause.tags && cause.tags.length > 0 && (
+ <div className="flex items-center gap-1 flex-wrap">
+ {cause.tags.slice(0, 4).map((tag: string) => (
+ <span key={tag} className="text-xs px-2 py-0.5 rounded-full"
+ style={{
+ backgroundColor: 'var(--color-surface-el)',
+ color: 'var(--color-text-muted)',
+ border: '1px solid var(--color-border)',
+ }}
+ >
+ <Tag size={10} style={{ display: 'inline', marginRight: 2 }} />
+ {tag}
+ </span>
+ ))}
+ </div>
+ )}
+ </motion.div>
+ ))}
+ </AnimatePresence>
+ </div>
+ )}
+
+ {!loading && filtered.length === 0 && (
+ <div className="text-center py-12">
+ <Flame size={32} style={{ color: 'var(--color-text-muted)', margin: '0 auto 12px' }} />
+ <p style={{ color: 'var(--color-text-muted)' }}>
+ No causes found. Click "AI Discover Causes" to find trending issues.
+ </p>
+ </div>
+ )}
+
+ {/* Create Cause Modal */}
+ {showCreate && (
+ <CreateCauseModal onClose={() => setShowCreate(false)} onSubmit={handleCreateCause} />
+ )}
+ </div>
+ );
+}
+
+function CreateCauseModal({
+ onClose,
+ onSubmit,
+}: {
+ onClose: () => void;
+ onSubmit: (data: { title: string; description: string; category: string; tags: string }) => void;
+}) {
+ const [title, setTitle] = useState('');
+ const [description, setDescription] = useState('');
+ const [cat, setCat] = useState('other');
+ const [tags, setTags] = useState('');
+
+ useEffect(() => {
+ const handleKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
+ window.addEventListener('keydown', handleKey);
+ return () => window.removeEventListener('keydown', handleKey);
+ }, [onClose]);
+
+ 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}
+ >
+ <motion.div
+ initial={{ opacity: 0, scale: 0.95 }}
+ animate={{ opacity: 1, scale: 1 }}
+ className="w-full max-w-md rounded-xl p-6"
+ style={{
+ backgroundColor: 'var(--color-surface)',
+ border: '1px solid var(--color-border)',
+ }}
+ onClick={(e) => e.stopPropagation()}
+ >
+ <div className="flex items-center justify-between mb-4">
+ <h3 className="text-lg font-semibold" style={{ color: 'var(--color-text)' }}>Add New Cause</h3>
+ <button onClick={onClose} className="btn btn-ghost btn-sm"><X size={16} /></button>
+ </div>
+ <div className="space-y-3">
+ <div>
+ <label className="block text-sm mb-1" style={{ color: 'var(--color-text-secondary)' }}>Title</label>
+ <input className="input" value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Clean Water Access" />
+ </div>
+ <div>
+ <label className="block text-sm mb-1" style={{ color: 'var(--color-text-secondary)' }}>Description</label>
+ <textarea className="input" rows={3} value={description} onChange={(e) => setDescription(e.target.value)} placeholder="Why this cause matters..." />
+ </div>
+ <div>
+ <label className="block text-sm mb-1" style={{ color: 'var(--color-text-secondary)' }}>Category</label>
+ <select className="input" value={cat} onChange={(e) => setCat(e.target.value)}>
+ {CATEGORIES.filter(c => c !== 'all').map(c => (
+ <option key={c} value={c}>{c.replace('_', ' ')}</option>
+ ))}
+ </select>
+ </div>
+ <div>
+ <label className="block text-sm mb-1" style={{ color: 'var(--color-text-secondary)' }}>Tags (comma-separated)</label>
+ <input className="input" value={tags} onChange={(e) => setTags(e.target.value)} placeholder="water, health, access" />
+ </div>
+ <div className="flex justify-end gap-2 pt-2">
+ <button onClick={onClose} className="btn btn-secondary">Cancel</button>
+ <button
+ onClick={() => onSubmit({ title, description, category: cat, tags })}
+ disabled={!title}
+ className="btn btn-primary"
+ >
+ Create Cause
+ </button>
+ </div>
+ </div>
+ </motion.div>
+ </div>
+ );
+}
diff --git a/components/funding/FundingTab.tsx b/components/funding/FundingTab.tsx
new file mode 100644
index 0000000..eb9d007
--- /dev/null
+++ b/components/funding/FundingTab.tsx
@@ -0,0 +1,254 @@
+'use client';
+
+import { useState, useEffect, useCallback } from 'react';
+import { motion, AnimatePresence } from 'framer-motion';
+import { SkeletonList } from '../Skeleton';
+import {
+ DollarSign, Plus, X, Loader2, RefreshCw,
+ Calendar, Landmark, Clock, CheckCircle, AlertCircle,
+} from 'lucide-react';
+
+interface FundingRound {
+ id: string;
+ title: string;
+ total_pool: string;
+ allocated: string;
+ remaining: string;
+ status: string;
+ opens_at: string | null;
+ closes_at: string | null;
+ cause_filter: string[];
+ granter_name: string;
+ granter_id: string;
+ created_at: string;
+}
+
+interface Granter {
+ id: string;
+ name: string;
+}
+
+const fadeIn = {
+ hidden: { opacity: 0, y: 20 },
+ visible: (i: number) => ({
+ opacity: 1, y: 0,
+ transition: { delay: i * 0.04, duration: 0.35 },
+ }),
+};
+
+export default function FundingTab() {
+ const [rounds, setRounds] = useState<FundingRound[]>([]);
+ const [granters, setGranters] = useState<Granter[]>([]);
+ const [loading, setLoading] = useState(true);
+ const [showCreate, setShowCreate] = useState(false);
+
+ const fetchData = useCallback(async () => {
+ try {
+ const [roundsRes, grantersRes] = await Promise.all([
+ fetch('/api/funding', { credentials: 'include' }),
+ fetch('/api/granters', { credentials: 'include' }),
+ ]);
+ if (roundsRes.ok) {
+ const data = await roundsRes.json();
+ setRounds(data.funding_rounds || []);
+ }
+ if (grantersRes.ok) {
+ const data = await grantersRes.json();
+ setGranters(data.granters || []);
+ }
+ } catch (err) {
+ console.error('Failed to fetch funding data:', err);
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => { fetchData(); }, [fetchData]);
+
+ const handleCreate = async (formData: Record<string, string>) => {
+ try {
+ const res = await fetch('/api/funding', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ body: JSON.stringify({
+ title: formData.title,
+ granter_id: formData.granter_id,
+ total_pool: parseFloat(formData.total_pool),
+ opens_at: formData.opens_at || null,
+ closes_at: formData.closes_at || null,
+ }),
+ });
+ if (res.ok) {
+ setShowCreate(false);
+ await fetchData();
+ }
+ } catch (err) {
+ console.error('Create funding round error:', err);
+ }
+ };
+
+ const fmtMoney = (val: string | null) => {
+ if (!val) return '$0';
+ const n = parseFloat(val);
+ return `$${n.toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 0 })}`;
+ };
+
+ const statusBadge = (s: string) => {
+ switch (s) {
+ case 'open': return 'badge-success';
+ case 'completed': return 'badge-blue';
+ case 'closed': return 'badge-error';
+ default: return 'badge-warning';
+ }
+ };
+
+ return (
+ <div className="p-6 space-y-6">
+ <div className="flex items-center justify-between flex-wrap gap-3">
+ <div>
+ <h2 className="text-xl font-bold flex items-center gap-2" style={{ color: 'var(--color-text)' }}>
+ <DollarSign size={20} style={{ color: '#22c55e' }} />
+ Funding Pipeline
+ </h2>
+ <p className="text-sm mt-1" style={{ color: 'var(--color-text-muted)' }}>
+ Granter-initiated funding rounds and allocation tracking
+ </p>
+ </div>
+ <div className="flex items-center gap-2">
+ <button onClick={() => setShowCreate(true)} className="btn btn-primary">
+ <Plus size={14} /> Create Funding Round
+ </button>
+ <button onClick={fetchData} className="btn btn-secondary btn-sm">
+ <RefreshCw size={14} />
+ </button>
+ </div>
+ </div>
+
+ {loading ? (
+ <SkeletonList count={4} />
+ ) : (
+ <div className="space-y-4">
+ <AnimatePresence>
+ {rounds.map((round, i) => {
+ const total = parseFloat(round.total_pool) || 1;
+ const allocated = parseFloat(round.allocated) || 0;
+ const remaining = parseFloat(round.remaining) || 0;
+ const pct = Math.min(100, (allocated / total) * 100);
+
+ return (
+ <motion.div key={round.id} custom={i} initial="hidden" animate="visible" variants={fadeIn} className="card p-5">
+ <div className="flex items-start justify-between mb-3">
+ <div>
+ <h3 className="text-base font-semibold" style={{ color: 'var(--color-text)' }}>{round.title}</h3>
+ <div className="flex items-center gap-2 mt-1 text-xs" style={{ color: 'var(--color-text-muted)' }}>
+ <Landmark size={12} /> {round.granter_name}
+ {round.opens_at && (
+ <span className="flex items-center gap-1"><Calendar size={10} /> Opens: {new Date(round.opens_at).toLocaleDateString()}</span>
+ )}
+ {round.closes_at && (
+ <span className="flex items-center gap-1"><Clock size={10} /> Closes: {new Date(round.closes_at).toLocaleDateString()}</span>
+ )}
+ </div>
+ </div>
+ <span className={`badge ${statusBadge(round.status)}`}>{round.status}</span>
+ </div>
+
+ <div className="grid grid-cols-3 gap-3 mb-3">
+ <div className="text-center p-2 rounded-lg" style={{ backgroundColor: 'var(--color-surface-el)' }}>
+ <div className="text-xs" style={{ color: 'var(--color-text-muted)' }}>Total Pool</div>
+ <div className="text-sm font-bold" style={{ color: 'var(--color-text)' }}>{fmtMoney(round.total_pool)}</div>
+ </div>
+ <div className="text-center p-2 rounded-lg" style={{ backgroundColor: 'var(--color-surface-el)' }}>
+ <div className="text-xs" style={{ color: 'var(--color-text-muted)' }}>Allocated</div>
+ <div className="text-sm font-bold" style={{ color: '#fbbf24' }}>{fmtMoney(round.allocated)}</div>
+ </div>
+ <div className="text-center p-2 rounded-lg" style={{ backgroundColor: 'var(--color-surface-el)' }}>
+ <div className="text-xs" style={{ color: 'var(--color-text-muted)' }}>Remaining</div>
+ <div className="text-sm font-bold" style={{ color: '#22c55e' }}>{fmtMoney(round.remaining)}</div>
+ </div>
+ </div>
+
+ <div className="flex items-center justify-between text-xs mb-1" style={{ color: 'var(--color-text-muted)' }}>
+ <span>Allocation Progress</span>
+ <span>{pct.toFixed(0)}%</span>
+ </div>
+ <div className="urgency-gauge">
+ <div className="urgency-gauge-fill" style={{ width: `${pct}%`, background: 'linear-gradient(90deg, #d97706, #fbbf24)' }} />
+ </div>
+ </motion.div>
+ );
+ })}
+ </AnimatePresence>
+ </div>
+ )}
+
+ {!loading && rounds.length === 0 && (
+ <div className="text-center py-12">
+ <DollarSign size={32} style={{ color: 'var(--color-text-muted)', margin: '0 auto 12px' }} />
+ <p style={{ color: 'var(--color-text-muted)' }}>No funding rounds yet. Create one to start allocating grants.</p>
+ </div>
+ )}
+
+ {showCreate && <CreateFundingModal granters={granters} onClose={() => setShowCreate(false)} onSubmit={handleCreate} />}
+ </div>
+ );
+}
+
+function CreateFundingModal({ granters, onClose, onSubmit }: { granters: Granter[]; onClose: () => void; onSubmit: (data: Record<string, string>) => void }) {
+ const [form, setForm] = useState({ title: '', granter_id: '', total_pool: '', opens_at: '', closes_at: '' });
+ const [submitting, setSubmitting] = useState(false);
+
+ const handleSubmit = async () => {
+ setSubmitting(true);
+ await onSubmit(form);
+ setSubmitting(false);
+ };
+
+ const set = (k: string) => (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => setForm(f => ({ ...f, [k]: e.target.value }));
+
+ return (
+ <div className="modal-overlay" onClick={onClose}>
+ <motion.div initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} className="modal-content" onClick={(e) => e.stopPropagation()}>
+ <div className="flex items-center justify-between mb-4">
+ <h3 className="text-lg font-semibold" style={{ color: 'var(--color-text)' }}>Create Funding Round</h3>
+ <button onClick={onClose} className="btn btn-ghost btn-sm"><X size={16} /></button>
+ </div>
+ <div className="space-y-3">
+ <div>
+ <label className="block text-sm mb-1" style={{ color: 'var(--color-text-secondary)' }}>Title *</label>
+ <input className="input" value={form.title} onChange={set('title')} placeholder="2024 Education Grant Round" />
+ </div>
+ <div>
+ <label className="block text-sm mb-1" style={{ color: 'var(--color-text-secondary)' }}>Granter *</label>
+ <select className="input" value={form.granter_id} onChange={set('granter_id')}>
+ <option value="">Select a granter...</option>
+ {granters.map(g => <option key={g.id} value={g.id}>{g.name}</option>)}
+ </select>
+ </div>
+ <div>
+ <label className="block text-sm mb-1" style={{ color: 'var(--color-text-secondary)' }}>Total Pool ($) *</label>
+ <input className="input" type="number" value={form.total_pool} onChange={set('total_pool')} placeholder="100000" />
+ </div>
+ <div className="grid grid-cols-2 gap-3">
+ <div>
+ <label className="block text-sm mb-1" style={{ color: 'var(--color-text-secondary)' }}>Opens</label>
+ <input className="input" type="date" value={form.opens_at} onChange={set('opens_at')} />
+ </div>
+ <div>
+ <label className="block text-sm mb-1" style={{ color: 'var(--color-text-secondary)' }}>Closes</label>
+ <input className="input" type="date" value={form.closes_at} onChange={set('closes_at')} />
+ </div>
+ </div>
+ <div className="flex justify-end gap-2 pt-2">
+ <button onClick={onClose} className="btn btn-secondary">Cancel</button>
+ <button onClick={handleSubmit} disabled={!form.title || !form.granter_id || !form.total_pool || submitting} className="btn btn-primary">
+ {submitting ? <Loader2 size={14} className="animate-spin" /> : null}
+ Create Round
+ </button>
+ </div>
+ </div>
+ </motion.div>
+ </div>
+ );
+}
diff --git a/components/granters/GrantersTab.tsx b/components/granters/GrantersTab.tsx
new file mode 100644
index 0000000..7cf7a90
--- /dev/null
+++ b/components/granters/GrantersTab.tsx
@@ -0,0 +1,308 @@
+'use client';
+
+import { useState, useEffect, useCallback } from 'react';
+import { motion, AnimatePresence } from 'framer-motion';
+import { SkeletonList } from '../Skeleton';
+import {
+ Landmark, Plus, X, Loader2, ExternalLink,
+ DollarSign, Tag, MapPin, Calendar,
+} from 'lucide-react';
+import { useToast } from '../ToastProvider';
+import SortDropdown from '../shared/SortDropdown';
+import { useClientSort, SortConfig } from '@/hooks/useClientSort';
+
+interface Granter {
+ id: string;
+ name: string;
+ description: string | null;
+ website_url: string | null;
+ city: string | null;
+ state: string | null;
+ funding_areas: string[];
+ annual_budget: string | null;
+ min_grant: string | null;
+ max_grant: string | null;
+ grants_per_year: number | null;
+ preference_tags: string[];
+ status: string;
+ created_at: string;
+}
+
+const SORT_OPTIONS = [
+ { value: 'name_asc', label: 'Name A-Z' },
+ { value: 'name_desc', label: 'Name Z-A' },
+ { value: 'budget_desc', label: 'Largest Budget' },
+ { value: 'date_desc', label: 'Newest First' },
+ { value: 'date_asc', label: 'Oldest First' },
+];
+
+const SORT_CONFIGS: Record<string, SortConfig> = {
+ name_asc: { key: 'name', direction: 'asc', type: 'string' },
+ name_desc: { key: 'name', direction: 'desc', type: 'string' },
+ budget_desc:{ key: 'annual_budget', direction: 'desc', type: 'number' },
+ date_desc: { key: 'created_at', direction: 'desc', type: 'date' },
+ date_asc: { key: 'created_at', direction: 'asc', type: 'date' },
+};
+
+const fadeIn = {
+ hidden: { opacity: 0, y: 20 },
+ visible: (i: number) => ({
+ opacity: 1, y: 0,
+ transition: { delay: i * 0.04, duration: 0.35 },
+ }),
+};
+
+export default function GrantersTab() {
+ const { addToast } = useToast();
+ const [granters, setGranters] = useState<Granter[]>([]);
+ const [loading, setLoading] = useState(true);
+ const [showCreate, setShowCreate] = useState(false);
+ const [sortBy, setSortBy] = useState('name_asc');
+ const sortedGranters = useClientSort(granters, sortBy, SORT_CONFIGS);
+
+ const fetchGranters = useCallback(async () => {
+ try {
+ const res = await fetch('/api/granters', { credentials: 'include' });
+ if (res.ok) {
+ const data = await res.json();
+ setGranters(data.granters || []);
+ }
+ } catch (err) {
+ console.error('Failed to fetch granters:', err);
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => { fetchGranters(); }, [fetchGranters]);
+
+ const handleCreate = async (formData: Record<string, string>) => {
+ try {
+ const res = await fetch('/api/granters', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ body: JSON.stringify({
+ ...formData,
+ funding_areas: formData.funding_areas ? formData.funding_areas.split(',').map(s => s.trim()).filter(Boolean) : [],
+ preference_tags: formData.preference_tags ? formData.preference_tags.split(',').map(s => s.trim()).filter(Boolean) : [],
+ annual_budget: formData.annual_budget ? parseFloat(formData.annual_budget) : null,
+ min_grant: formData.min_grant ? parseFloat(formData.min_grant) : null,
+ max_grant: formData.max_grant ? parseFloat(formData.max_grant) : null,
+ grants_per_year: formData.grants_per_year ? parseInt(formData.grants_per_year) : null,
+ }),
+ });
+ if (res.ok) {
+ setShowCreate(false);
+ await fetchGranters();
+ addToast('Granter added', 'success');
+ } else {
+ addToast('Failed to add granter', 'error');
+ }
+ } catch (err) {
+ console.error('Create granter error:', err);
+ addToast('Failed to add granter', 'error');
+ }
+ };
+
+ const fmtMoney = (val: string | null) => {
+ if (!val) return 'N/A';
+ const n = parseFloat(val);
+ if (n >= 1000000) return `$${(n / 1000000).toFixed(1)}M`;
+ if (n >= 1000) return `$${(n / 1000).toFixed(0)}K`;
+ return `$${n.toLocaleString()}`;
+ };
+
+ return (
+ <div className="p-6 space-y-6">
+ <div className="flex items-center justify-between flex-wrap gap-3">
+ <div>
+ <h2 className="text-xl font-bold flex items-center gap-2" style={{ color: 'var(--color-text)' }}>
+ <Landmark size={20} style={{ color: '#60a5fa' }} />
+ Granters
+ </h2>
+ <p className="text-sm mt-1" style={{ color: 'var(--color-text-muted)' }}>
+ Foundations, corporations, and government funders
+ </p>
+ </div>
+ <div className="flex items-center gap-2">
+ <SortDropdown options={SORT_OPTIONS} value={sortBy} onChange={setSortBy} />
+ <button onClick={() => setShowCreate(true)} className="btn btn-primary">
+ <Plus size={14} />
+ Add Granter
+ </button>
+ </div>
+ </div>
+
+ {loading ? (
+ <SkeletonList count={4} />
+ ) : (
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
+ <AnimatePresence>
+ {sortedGranters.map((g, i) => (
+ <motion.div key={g.id} custom={i} initial="hidden" animate="visible" variants={fadeIn} className="card p-4 flex flex-col gap-3">
+ <div className="flex items-start justify-between">
+ <div className="flex-1 min-w-0">
+ <h3 className="text-sm font-semibold truncate" style={{ color: 'var(--color-text)' }}>{g.name}</h3>
+ {(g.city || g.state) && (
+ <div className="flex items-center gap-1 text-xs mt-1" style={{ color: 'var(--color-text-muted)' }}>
+ <MapPin size={10} /> {[g.city, g.state].filter(Boolean).join(', ')}
+ </div>
+ )}
+ </div>
+ {g.website_url && (
+ <a href={g.website_url} target="_blank" rel="noreferrer" className="btn btn-ghost btn-sm">
+ <ExternalLink size={12} />
+ </a>
+ )}
+ </div>
+
+ {g.description && (
+ <p className="text-xs line-clamp-2" style={{ color: 'var(--color-text-muted)' }}>{g.description}</p>
+ )}
+
+ <div className="grid grid-cols-3 gap-2">
+ <div className="text-center p-2 rounded-lg" style={{ backgroundColor: 'var(--color-surface-el)' }}>
+ <DollarSign size={12} style={{ color: '#22c55e', margin: '0 auto 2px' }} />
+ <div className="text-xs font-bold" style={{ color: 'var(--color-text)' }}>{fmtMoney(g.annual_budget)}</div>
+ <div className="text-xs" style={{ color: 'var(--color-text-muted)' }}>Budget</div>
+ </div>
+ <div className="text-center p-2 rounded-lg" style={{ backgroundColor: 'var(--color-surface-el)' }}>
+ <DollarSign size={12} style={{ color: '#fbbf24', margin: '0 auto 2px' }} />
+ <div className="text-xs font-bold" style={{ color: 'var(--color-text)' }}>
+ {fmtMoney(g.min_grant)}-{fmtMoney(g.max_grant)}
+ </div>
+ <div className="text-xs" style={{ color: 'var(--color-text-muted)' }}>Range</div>
+ </div>
+ <div className="text-center p-2 rounded-lg" style={{ backgroundColor: 'var(--color-surface-el)' }}>
+ <Calendar size={12} style={{ color: '#c084fc', margin: '0 auto 2px' }} />
+ <div className="text-xs font-bold" style={{ color: 'var(--color-text)' }}>{g.grants_per_year ?? 'N/A'}</div>
+ <div className="text-xs" style={{ color: 'var(--color-text-muted)' }}>Per Year</div>
+ </div>
+ </div>
+
+ {g.funding_areas && g.funding_areas.length > 0 && (
+ <div className="flex items-center gap-1 flex-wrap">
+ {g.funding_areas.slice(0, 4).map((area: string) => (
+ <span key={area} 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)' }}>
+ {area}
+ </span>
+ ))}
+ </div>
+ )}
+
+ {g.preference_tags && g.preference_tags.length > 0 && (
+ <div className="flex items-center gap-1 flex-wrap">
+ {g.preference_tags.slice(0, 3).map((tag: string) => (
+ <span key={tag} className="text-xs px-2 py-0.5 rounded-full" style={{ backgroundColor: 'var(--color-surface-el)', color: 'var(--color-text-muted)', border: '1px solid var(--color-border)' }}>
+ <Tag size={10} style={{ display: 'inline', marginRight: 2 }} />{tag}
+ </span>
+ ))}
+ </div>
+ )}
+ </motion.div>
+ ))}
+ </AnimatePresence>
+ </div>
+ )}
+
+ {!loading && granters.length === 0 && (
+ <div className="text-center py-12">
+ <Landmark size={32} style={{ color: 'var(--color-text-muted)', margin: '0 auto 12px' }} />
+ <p style={{ color: 'var(--color-text-muted)' }}>No granters yet. Add funding organizations to start matching.</p>
+ </div>
+ )}
+
+ {showCreate && <CreateGranterModal onClose={() => setShowCreate(false)} onSubmit={handleCreate} />}
+ </div>
+ );
+}
+
+function CreateGranterModal({ onClose, onSubmit }: { onClose: () => void; onSubmit: (data: Record<string, string>) => void }) {
+ useEffect(() => {
+ const handleKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
+ window.addEventListener('keydown', handleKey);
+ return () => window.removeEventListener('keydown', handleKey);
+ }, [onClose]);
+
+ const [form, setForm] = useState({ name: '', description: '', website_url: '', city: '', state: '', funding_areas: '', annual_budget: '', min_grant: '', max_grant: '', grants_per_year: '', preference_tags: '' });
+ const [submitting, setSubmitting] = useState(false);
+
+ const handleSubmit = async () => {
+ setSubmitting(true);
+ await onSubmit(form);
+ setSubmitting(false);
+ };
+
+ const set = (k: string) => (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => setForm(f => ({ ...f, [k]: e.target.value }));
+
+ return (
+ <div className="modal-overlay" onClick={onClose}>
+ <motion.div initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} className="modal-content" onClick={(e) => e.stopPropagation()}>
+ <div className="flex items-center justify-between mb-4">
+ <h3 className="text-lg font-semibold" style={{ color: 'var(--color-text)' }}>Add Granter</h3>
+ <button onClick={onClose} className="btn btn-ghost btn-sm"><X size={16} /></button>
+ </div>
+ <div className="space-y-3">
+ <div>
+ <label className="block text-sm mb-1" style={{ color: 'var(--color-text-secondary)' }}>Name *</label>
+ <input className="input" value={form.name} onChange={set('name')} placeholder="Foundation name" />
+ </div>
+ <div>
+ <label className="block text-sm mb-1" style={{ color: 'var(--color-text-secondary)' }}>Description</label>
+ <textarea className="input" rows={2} value={form.description} onChange={set('description')} placeholder="About this funder..." />
+ </div>
+ <div>
+ <label className="block text-sm mb-1" style={{ color: 'var(--color-text-secondary)' }}>Website</label>
+ <input className="input" value={form.website_url} onChange={set('website_url')} placeholder="https://..." />
+ </div>
+ <div className="grid grid-cols-2 gap-3">
+ <div>
+ <label className="block text-sm mb-1" style={{ color: 'var(--color-text-secondary)' }}>City</label>
+ <input className="input" value={form.city} onChange={set('city')} />
+ </div>
+ <div>
+ <label className="block text-sm mb-1" style={{ color: 'var(--color-text-secondary)' }}>State</label>
+ <input className="input" value={form.state} onChange={set('state')} />
+ </div>
+ </div>
+ <div>
+ <label className="block text-sm mb-1" style={{ color: 'var(--color-text-secondary)' }}>Funding Areas (comma-separated)</label>
+ <input className="input" value={form.funding_areas} onChange={set('funding_areas')} placeholder="education, health, environment" />
+ </div>
+ <div className="grid grid-cols-3 gap-3">
+ <div>
+ <label className="block text-sm mb-1" style={{ color: 'var(--color-text-secondary)' }}>Annual Budget ($)</label>
+ <input className="input" type="number" value={form.annual_budget} onChange={set('annual_budget')} />
+ </div>
+ <div>
+ <label className="block text-sm mb-1" style={{ color: 'var(--color-text-secondary)' }}>Min Grant ($)</label>
+ <input className="input" type="number" value={form.min_grant} onChange={set('min_grant')} />
+ </div>
+ <div>
+ <label className="block text-sm mb-1" style={{ color: 'var(--color-text-secondary)' }}>Max Grant ($)</label>
+ <input className="input" type="number" value={form.max_grant} onChange={set('max_grant')} />
+ </div>
+ </div>
+ <div className="grid grid-cols-2 gap-3">
+ <div>
+ <label className="block text-sm mb-1" style={{ color: 'var(--color-text-secondary)' }}>Grants Per Year</label>
+ <input className="input" type="number" value={form.grants_per_year} onChange={set('grants_per_year')} />
+ </div>
+ <div>
+ <label className="block text-sm mb-1" style={{ color: 'var(--color-text-secondary)' }}>Preference Tags</label>
+ <input className="input" value={form.preference_tags} onChange={set('preference_tags')} placeholder="innovation, youth" />
+ </div>
+ </div>
+ <div className="flex justify-end gap-2 pt-2">
+ <button onClick={onClose} className="btn btn-secondary">Cancel</button>
+ <button onClick={handleSubmit} disabled={!form.name || submitting} className="btn btn-primary">
+ {submitting ? <Loader2 size={14} className="animate-spin" /> : null}
+ Add Granter
+ </button>
+ </div>
+ </div>
+ </motion.div>
+ </div>
+ );
+}
diff --git a/components/impact/ImpactTab.tsx b/components/impact/ImpactTab.tsx
new file mode 100644
index 0000000..6790923
--- /dev/null
+++ b/components/impact/ImpactTab.tsx
@@ -0,0 +1,176 @@
+'use client';
+
+import { useState, useEffect, useCallback } from 'react';
+import { motion, AnimatePresence } from 'framer-motion';
+import { SkeletonList } from '../Skeleton';
+import { useToast } from '../ToastProvider';
+import {
+ TrendingUp, Users, DollarSign, RefreshCw,
+ Building2, Flame, CheckCircle, FileText,
+} from 'lucide-react';
+import SortDropdown from '../shared/SortDropdown';
+import { useClientSort, SortConfig } from '@/hooks/useClientSort';
+
+interface ImpactReport {
+ id: string;
+ title: string;
+ description: string | null;
+ people_helped: number;
+ funds_used: string | null;
+ outcomes: string[] | null;
+ report_url: string | null;
+ status: string;
+ period_start: string | null;
+ period_end: string | null;
+ created_at: string;
+ org_name: string;
+ cause_title: string | null;
+ match_score: number | null;
+}
+
+const SORT_OPTIONS = [
+ { value: 'date_desc', label: 'Newest First' },
+ { value: 'date_asc', label: 'Oldest First' },
+ { value: 'people_desc', label: 'Most People Helped' },
+ { value: 'status', label: 'Status A-Z' },
+];
+
+const SORT_CONFIGS: Record<string, SortConfig> = {
+ date_desc: { key: 'created_at', direction: 'desc', type: 'date' },
+ date_asc: { key: 'created_at', direction: 'asc', type: 'date' },
+ people_desc: { key: 'people_helped', direction: 'desc', type: 'number' },
+ status: { key: 'status', direction: 'asc', type: 'string' },
+};
+
+const fadeIn = {
+ hidden: { opacity: 0, y: 20 },
+ visible: (i: number) => ({
+ opacity: 1, y: 0,
+ transition: { delay: i * 0.04, duration: 0.35 },
+ }),
+};
+
+export default function ImpactTab() {
+ const { addToast } = useToast();
+ const [reports, setReports] = useState<ImpactReport[]>([]);
+ const [loading, setLoading] = useState(true);
+ const [sortBy, setSortBy] = useState('date_desc');
+ const sortedReports = useClientSort(reports, sortBy, SORT_CONFIGS);
+
+ const fetchReports = useCallback(async () => {
+ try {
+ const res = await fetch('/api/impact', { credentials: 'include' });
+ if (res.ok) {
+ const data = await res.json();
+ setReports(data.impact_reports || []);
+ }
+ } catch (err) {
+ console.error('Failed to fetch impact reports:', err);
+ addToast('Failed to load impact reports', 'error');
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => { fetchReports(); }, [fetchReports]);
+
+ const fmtMoney = (val: string | null) => {
+ if (!val) return 'N/A';
+ const n = parseFloat(val);
+ return `$${n.toLocaleString()}`;
+ };
+
+ return (
+ <div className="p-6 space-y-6">
+ <div className="flex items-center justify-between flex-wrap gap-3">
+ <div>
+ <h2 className="text-xl font-bold flex items-center gap-2" style={{ color: 'var(--color-text)' }}>
+ <TrendingUp size={20} style={{ color: '#22c55e' }} />
+ Impact Reports
+ </h2>
+ <p className="text-sm mt-1" style={{ color: 'var(--color-text-muted)' }}>
+ Tracking real-world outcomes from funded matches
+ </p>
+ </div>
+ <div className="flex items-center gap-2">
+ <SortDropdown options={SORT_OPTIONS} value={sortBy} onChange={setSortBy} />
+ <button onClick={fetchReports} className="btn btn-secondary btn-sm">
+ <RefreshCw size={14} /> Refresh
+ </button>
+ </div>
+ </div>
+
+ {loading ? (
+ <SkeletonList count={4} />
+ ) : sortedReports.length > 0 ? (
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+ <AnimatePresence>
+ {sortedReports.map((report, i) => (
+ <motion.div key={report.id} custom={i} initial="hidden" animate="visible" variants={fadeIn} className="card p-5 flex flex-col gap-3">
+ <div className="flex items-start justify-between">
+ <div>
+ <h3 className="text-sm font-semibold" style={{ color: 'var(--color-text)' }}>{report.title}</h3>
+ <div className="flex items-center gap-2 mt-1 text-xs" style={{ color: 'var(--color-text-muted)' }}>
+ <Building2 size={10} /> {report.org_name}
+ {report.cause_title && <><Flame size={10} /> {report.cause_title}</>}
+ </div>
+ </div>
+ <span className={`badge ${report.status === 'published' ? 'badge-success' : 'badge-warning'}`}>
+ {report.status}
+ </span>
+ </div>
+
+ {report.description && (
+ <p className="text-xs" style={{ color: 'var(--color-text-secondary)' }}>{report.description}</p>
+ )}
+
+ <div className="grid grid-cols-2 gap-2">
+ <div className="text-center p-2 rounded-lg" style={{ backgroundColor: 'var(--color-surface-el)' }}>
+ <Users size={14} style={{ color: '#fbbf24', margin: '0 auto 2px' }} />
+ <div className="text-sm font-bold" style={{ color: 'var(--color-text)' }}>{(report.people_helped || 0).toLocaleString()}</div>
+ <div className="text-xs" style={{ color: 'var(--color-text-muted)' }}>People Helped</div>
+ </div>
+ <div className="text-center p-2 rounded-lg" style={{ backgroundColor: 'var(--color-surface-el)' }}>
+ <DollarSign size={14} style={{ color: '#22c55e', margin: '0 auto 2px' }} />
+ <div className="text-sm font-bold" style={{ color: 'var(--color-text)' }}>{fmtMoney(report.funds_used)}</div>
+ <div className="text-xs" style={{ color: 'var(--color-text-muted)' }}>Funds Used</div>
+ </div>
+ </div>
+
+ {report.outcomes && report.outcomes.length > 0 && (
+ <div>
+ <div className="text-xs font-semibold mb-1" style={{ color: 'var(--color-text-secondary)' }}>Outcomes</div>
+ <ul className="space-y-1">
+ {report.outcomes.map((outcome, j) => (
+ <li key={j} className="flex items-start gap-2 text-xs" style={{ color: 'var(--color-text-muted)' }}>
+ <CheckCircle size={10} style={{ color: '#22c55e', marginTop: 2, flexShrink: 0 }} />
+ {outcome}
+ </li>
+ ))}
+ </ul>
+ </div>
+ )}
+
+ {report.period_start && (
+ <div className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
+ Period: {new Date(report.period_start).toLocaleDateString()} - {report.period_end ? new Date(report.period_end).toLocaleDateString() : 'Ongoing'}
+ </div>
+ )}
+ </motion.div>
+ ))}
+ </AnimatePresence>
+ </div>
+ ) : (
+ <div className="text-center py-12">
+ <div className="w-16 h-16 rounded-2xl flex items-center justify-center mx-auto mb-4" style={{ backgroundColor: 'var(--color-surface-el)', border: '1px solid var(--color-border)' }}>
+ <TrendingUp size={28} style={{ color: 'var(--color-text-muted)' }} />
+ </div>
+ <h3 className="text-lg font-semibold mb-2" style={{ color: 'var(--color-text)' }}>No Impact Reports Yet</h3>
+ <p className="text-sm" style={{ color: 'var(--color-text-muted)' }}>
+ Impact reports will appear here as funded organizations report their outcomes.
+ </p>
+ </div>
+ )}
+ </div>
+ );
+}
diff --git a/components/matches/MatchesTab.tsx b/components/matches/MatchesTab.tsx
new file mode 100644
index 0000000..53f5cf4
--- /dev/null
+++ b/components/matches/MatchesTab.tsx
@@ -0,0 +1,262 @@
+'use client';
+
+import { useState, useEffect, useCallback } from 'react';
+import { motion, AnimatePresence } from 'framer-motion';
+import { SkeletonList } from '../Skeleton';
+import {
+ Sparkles, Loader2, RefreshCw, ArrowRight,
+ Flame, Building2, Landmark, Target,
+ CheckCircle, Clock, ThumbsUp, Ban,
+} from 'lucide-react';
+import { useToast } from '../ToastProvider';
+import SortDropdown from '../shared/SortDropdown';
+import { useClientSort, SortConfig } from '@/hooks/useClientSort';
+
+interface Match {
+ id: string;
+ cause_id: string;
+ org_id: string;
+ granter_id: string | null;
+ match_score: number;
+ cause_alignment: number;
+ public_support: number;
+ granter_fit: number;
+ ai_reasoning: string | null;
+ status: string;
+ funding_amount: string | null;
+ cause_title: string;
+ urgency_score: number;
+ cause_category: string;
+ org_name: string;
+ trust_score: number;
+ impact_score: number;
+ granter_name: string | null;
+ granter_budget: string | null;
+ created_at: string;
+}
+
+const SORT_OPTIONS = [
+ { value: 'score_desc', label: 'Best Match' },
+ { value: 'date_desc', label: 'Newest First' },
+ { value: 'date_asc', label: 'Oldest First' },
+ { value: 'status', label: 'Status A-Z' },
+];
+
+const SORT_CONFIGS: Record<string, SortConfig> = {
+ score_desc: { key: 'match_score', direction: 'desc', type: 'number' },
+ date_desc: { key: 'created_at', direction: 'desc', type: 'date' },
+ date_asc: { key: 'created_at', direction: 'asc', type: 'date' },
+ status: { key: 'status', direction: 'asc', type: 'string' },
+};
+
+const STATUS_FILTERS = ['all', 'suggested', 'presented', 'accepted', 'funded', 'declined'];
+
+const fadeIn = {
+ hidden: { opacity: 0, y: 20 },
+ visible: (i: number) => ({
+ opacity: 1, y: 0,
+ transition: { delay: i * 0.05, duration: 0.4 },
+ }),
+};
+
+export default function MatchesTab() {
+ const { addToast } = useToast();
+ const [matches, setMatches] = useState<Match[]>([]);
+ const [loading, setLoading] = useState(true);
+ const [generating, setGenerating] = useState(false);
+ const [statusFilter, setStatusFilter] = useState('all');
+ const [sortBy, setSortBy] = useState('score_desc');
+ const sortedMatches = useClientSort(matches, sortBy, SORT_CONFIGS);
+
+ const fetchMatches = useCallback(async () => {
+ try {
+ const params = new URLSearchParams();
+ if (statusFilter !== 'all') params.set('status', statusFilter);
+ const res = await fetch(`/api/matches?${params}`, { credentials: 'include' });
+ if (res.ok) {
+ const data = await res.json();
+ setMatches(data.matches || []);
+ }
+ } catch (err) {
+ console.error('Failed to fetch matches:', err);
+ } finally {
+ setLoading(false);
+ }
+ }, [statusFilter]);
+
+ useEffect(() => { fetchMatches(); }, [fetchMatches]);
+
+ const handleGenerate = async () => {
+ setGenerating(true);
+ try {
+ const res = await fetch('/api/matches/generate', {
+ method: 'POST',
+ credentials: 'include',
+ });
+ if (res.ok) {
+ await fetchMatches();
+ addToast('Matches generated', 'success');
+ } else {
+ addToast('Match generation failed', 'error');
+ }
+ } catch (err) {
+ console.error('Generate error:', err);
+ addToast('Match generation failed', 'error');
+ } finally {
+ setGenerating(false);
+ }
+ };
+
+ const statusIcon = (s: string) => {
+ switch (s) {
+ case 'funded': return <CheckCircle size={14} style={{ color: '#22c55e' }} />;
+ case 'accepted': return <ThumbsUp size={14} style={{ color: '#60a5fa' }} />;
+ case 'declined': return <Ban size={14} style={{ color: '#ef4444' }} />;
+ default: return <Clock size={14} style={{ color: '#fbbf24' }} />;
+ }
+ };
+
+ const statusBadge = (s: string) => {
+ switch (s) {
+ case 'funded': return 'badge-success';
+ case 'accepted': return 'badge-blue';
+ case 'declined': return 'badge-error';
+ case 'presented': return 'badge-warning';
+ default: return 'badge-amber';
+ }
+ };
+
+ return (
+ <div className="p-6 space-y-6">
+ <div className="flex items-center justify-between flex-wrap gap-3">
+ <div>
+ <h2 className="text-xl font-bold flex items-center gap-2" style={{ color: 'var(--color-text)' }}>
+ <Sparkles size={20} style={{ color: '#c084fc' }} />
+ AI Matches
+ </h2>
+ <p className="text-sm mt-1" style={{ color: 'var(--color-text-muted)' }}>
+ AI-generated cause-to-organization-to-granter match pipeline
+ </p>
+ </div>
+ <div className="flex items-center gap-2">
+ <button onClick={handleGenerate} disabled={generating} className="btn btn-primary">
+ {generating ? <Loader2 size={14} className="animate-spin" /> : <Sparkles size={14} />}
+ {generating ? 'Generating...' : 'Generate Matches'}
+ </button>
+ <button onClick={fetchMatches} className="btn btn-secondary btn-sm">
+ <RefreshCw size={14} />
+ </button>
+ </div>
+ </div>
+
+ {/* Status filter */}
+ <div className="flex items-center gap-2 flex-wrap">
+ <SortDropdown options={SORT_OPTIONS} value={sortBy} onChange={setSortBy} />
+ {STATUS_FILTERS.map((s) => (
+ <button
+ key={s}
+ onClick={() => setStatusFilter(s)}
+ className="btn btn-sm"
+ style={{
+ backgroundColor: statusFilter === s ? 'rgba(168, 85, 247, 0.2)' : 'var(--color-surface-el)',
+ color: statusFilter === s ? '#c084fc' : 'var(--color-text-secondary)',
+ borderColor: statusFilter === s ? 'rgba(168, 85, 247, 0.3)' : 'var(--color-border)',
+ }}
+ >
+ {s === 'all' ? 'All' : s}
+ </button>
+ ))}
+ </div>
+
+ {loading ? (
+ <SkeletonList count={4} />
+ ) : (
+ <div className="space-y-4">
+ <AnimatePresence>
+ {sortedMatches.map((m, i) => (
+ <motion.div key={m.id} custom={i} initial="hidden" animate="visible" variants={fadeIn} className="card p-5">
+ {/* Pipeline visual */}
+ <div className="flex items-center gap-3 flex-wrap mb-4">
+ <div className="flex items-center gap-2 px-3 py-2 rounded-lg" style={{ backgroundColor: 'var(--color-surface-el)' }}>
+ <Flame size={14} style={{ color: '#ef4444' }} />
+ <div>
+ <div className="text-xs font-semibold" style={{ color: 'var(--color-text)' }}>{m.cause_title}</div>
+ <div className="text-xs" style={{ color: 'var(--color-text-muted)' }}>{m.cause_category}</div>
+ </div>
+ </div>
+ <ArrowRight size={16} style={{ color: 'var(--color-text-muted)', flexShrink: 0 }} />
+ <div className="flex items-center gap-2 px-3 py-2 rounded-lg" style={{ backgroundColor: 'var(--color-surface-el)' }}>
+ <Building2 size={14} style={{ color: '#22c55e' }} />
+ <div>
+ <div className="text-xs font-semibold" style={{ color: 'var(--color-text)' }}>{m.org_name}</div>
+ <div className="text-xs" style={{ color: 'var(--color-text-muted)' }}>Trust: {((m.trust_score || 0) * 100).toFixed(0)}%</div>
+ </div>
+ </div>
+ {m.granter_name && (
+ <>
+ <ArrowRight size={16} style={{ color: 'var(--color-text-muted)', flexShrink: 0 }} />
+ <div className="flex items-center gap-2 px-3 py-2 rounded-lg" style={{ backgroundColor: 'var(--color-surface-el)' }}>
+ <Landmark size={14} style={{ color: '#60a5fa' }} />
+ <div>
+ <div className="text-xs font-semibold" style={{ color: 'var(--color-text)' }}>{m.granter_name}</div>
+ <div className="text-xs" style={{ color: 'var(--color-text-muted)' }}>Funder</div>
+ </div>
+ </div>
+ </>
+ )}
+ <div className="ml-auto flex items-center gap-2">
+ {statusIcon(m.status)}
+ <span className={`badge ${statusBadge(m.status)}`}>{m.status}</span>
+ </div>
+ </div>
+
+ {/* Score breakdown */}
+ <div className="grid grid-cols-4 gap-3 mb-3">
+ <ScoreBar label="Overall" value={m.match_score} color="#fbbf24" />
+ <ScoreBar label="Cause Alignment" value={m.cause_alignment} color="#ef4444" />
+ <ScoreBar label="Public Support" value={m.public_support} color="#22c55e" />
+ <ScoreBar label="Granter Fit" value={m.granter_fit} color="#60a5fa" />
+ </div>
+
+ {/* AI Reasoning */}
+ {m.ai_reasoning && (
+ <div className="p-3 rounded-lg text-xs" style={{ backgroundColor: 'var(--color-surface-el)', color: 'var(--color-text-secondary)', borderLeft: '3px solid #c084fc' }}>
+ <div className="flex items-center gap-1 mb-1">
+ <Sparkles size={10} style={{ color: '#c084fc' }} />
+ <span className="font-semibold" style={{ color: '#c084fc' }}>AI Reasoning</span>
+ </div>
+ {m.ai_reasoning}
+ </div>
+ )}
+ </motion.div>
+ ))}
+ </AnimatePresence>
+ </div>
+ )}
+
+ {!loading && matches.length === 0 && (
+ <div className="text-center py-12">
+ <Sparkles size={32} style={{ color: 'var(--color-text-muted)', margin: '0 auto 12px' }} />
+ <p style={{ color: 'var(--color-text-muted)' }}>
+ No matches yet. Add causes and organizations, then click "Generate Matches".
+ </p>
+ </div>
+ )}
+ </div>
+ );
+}
+
+function ScoreBar({ label, value, color }: { label: string; value: number; color: string }) {
+ const pct = Math.round((value || 0) * 100);
+ return (
+ <div>
+ <div className="flex items-center justify-between text-xs mb-1">
+ <span style={{ color: 'var(--color-text-muted)' }}>{label}</span>
+ <span style={{ color, fontWeight: 700 }}>{pct}%</span>
+ </div>
+ <div className="score-bar">
+ <div className="score-bar-fill" style={{ width: `${pct}%`, background: color }} />
+ </div>
+ </div>
+ );
+}
diff --git a/components/organizations/OrgsTab.tsx b/components/organizations/OrgsTab.tsx
new file mode 100644
index 0000000..a45f229
--- /dev/null
+++ b/components/organizations/OrgsTab.tsx
@@ -0,0 +1,330 @@
+'use client';
+
+import { useState, useEffect, useCallback } from 'react';
+import { motion, AnimatePresence } from 'framer-motion';
+import { SkeletonList } from '../Skeleton';
+import {
+ Building2, Search, Plus, Star, Shield, Users,
+ TrendingUp, ExternalLink, X, Loader2, MapPin,
+} from 'lucide-react';
+import { useToast } from '../ToastProvider';
+import { useDebounce } from '@/hooks/useDebounce';
+
+interface Org {
+ id: string;
+ name: string;
+ ein: string | null;
+ mission: string | null;
+ description: string | null;
+ website_url: string | null;
+ city: string | null;
+ state: string | null;
+ category: string;
+ focus_areas: string[];
+ year_founded: number | null;
+ annual_budget: string | null;
+ staff_size: number | null;
+ impact_score: number;
+ trust_score: number;
+ total_votes: number;
+ total_funding_received: string;
+ status: string;
+ created_at: string;
+}
+
+const CATEGORIES = [
+ 'all', 'education', 'health', 'environment', 'poverty', 'housing',
+ 'legal_aid', 'civil_rights', 'veterans', 'youth', 'elderly',
+ 'disability', 'arts', 'other',
+];
+
+const fadeIn = {
+ hidden: { opacity: 0, y: 20 },
+ visible: (i: number) => ({
+ opacity: 1, y: 0,
+ transition: { delay: i * 0.04, duration: 0.35 },
+ }),
+};
+
+export default function OrgsTab() {
+ const { addToast } = useToast();
+ const [orgs, setOrgs] = useState<Org[]>([]);
+ const [loading, setLoading] = useState(true);
+ const [sortBy, setSortBy] = useState('votes');
+ const [category, setCategory] = useState('all');
+ const [searchTerm, setSearchTerm] = useState('');
+ const debouncedSearch = useDebounce(searchTerm, 300);
+ const [showCreate, setShowCreate] = useState(false);
+
+ const fetchOrgs = useCallback(async () => {
+ try {
+ const params = new URLSearchParams({ sort: sortBy });
+ if (category !== 'all') params.set('category', category);
+ const res = await fetch(`/api/organizations?${params}`, { credentials: 'include' });
+ if (res.ok) {
+ const data = await res.json();
+ setOrgs(data.organizations || []);
+ }
+ } catch (err) {
+ console.error('Failed to fetch orgs:', err);
+ } finally {
+ setLoading(false);
+ }
+ }, [sortBy, category]);
+
+ useEffect(() => { fetchOrgs(); }, [fetchOrgs]);
+
+ const handleCreate = async (formData: Record<string, string>) => {
+ try {
+ const res = await fetch('/api/organizations', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ body: JSON.stringify({
+ ...formData,
+ focus_areas: formData.focus_areas ? formData.focus_areas.split(',').map(s => s.trim()).filter(Boolean) : [],
+ year_founded: formData.year_founded ? parseInt(formData.year_founded) : null,
+ staff_size: formData.staff_size ? parseInt(formData.staff_size) : null,
+ }),
+ });
+ if (res.ok) {
+ setShowCreate(false);
+ await fetchOrgs();
+ addToast('Organization registered', 'success');
+ } else {
+ addToast('Failed to register organization', 'error');
+ }
+ } catch (err) {
+ console.error('Create org error:', err);
+ addToast('Failed to register organization', 'error');
+ }
+ };
+
+ const filtered = orgs.filter(o =>
+ !debouncedSearch || o.name.toLowerCase().includes(debouncedSearch.toLowerCase()) ||
+ (o.mission && o.mission.toLowerCase().includes(debouncedSearch.toLowerCase()))
+ );
+
+ const statusColor = (s: string) => {
+ if (s === 'featured') return 'badge-amber';
+ if (s === 'verified') return 'badge-success';
+ return 'badge-warning';
+ };
+
+ return (
+ <div className="p-6 space-y-6">
+ <div className="flex items-center justify-between flex-wrap gap-3">
+ <div>
+ <h2 className="text-xl font-bold flex items-center gap-2" style={{ color: 'var(--color-text)' }}>
+ <Building2 size={20} style={{ color: '#22c55e' }} />
+ Organizations
+ </h2>
+ <p className="text-sm mt-1" style={{ color: 'var(--color-text-muted)' }}>
+ Non-profit registry — verified organizations addressing top causes
+ </p>
+ </div>
+ <button onClick={() => setShowCreate(true)} className="btn btn-primary">
+ <Plus size={14} />
+ Register Organization
+ </button>
+ </div>
+
+ <div className="flex items-center gap-3 flex-wrap">
+ <div className="relative flex-1 max-w-xs">
+ <Search size={14} style={{ position: 'absolute', left: 10, top: '50%', transform: 'translateY(-50%)', color: 'var(--color-text-muted)' }} />
+ <input className="input pl-8" placeholder="Search organizations..." value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} />
+ </div>
+ <select className="input" style={{ width: 'auto' }} value={sortBy} onChange={(e) => setSortBy(e.target.value)}>
+ <option value="votes">Sort by Votes</option>
+ <option value="impact">Sort by Impact</option>
+ <option value="trust">Sort by Trust</option>
+ <option value="funding">Sort by Funding</option>
+ </select>
+ </div>
+
+ <div className="flex items-center gap-2 flex-wrap">
+ {CATEGORIES.map((cat) => (
+ <button
+ key={cat}
+ onClick={() => setCategory(cat)}
+ className="btn btn-sm"
+ style={{
+ backgroundColor: category === cat ? 'rgba(217, 119, 6, 0.2)' : 'var(--color-surface-el)',
+ color: category === cat ? '#fbbf24' : 'var(--color-text-secondary)',
+ borderColor: category === cat ? 'rgba(217, 119, 6, 0.3)' : 'var(--color-border)',
+ }}
+ >
+ {cat === 'all' ? 'All' : cat.replace('_', ' ')}
+ </button>
+ ))}
+ </div>
+
+ {loading ? (
+ <SkeletonList count={4} />
+ ) : (
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
+ <AnimatePresence>
+ {filtered.map((org, i) => (
+ <motion.div key={org.id} custom={i} initial="hidden" animate="visible" variants={fadeIn} className="card p-4 flex flex-col gap-3">
+ <div className="flex items-start justify-between">
+ <div className="flex-1 min-w-0">
+ <h3 className="text-sm font-semibold truncate" style={{ color: 'var(--color-text)' }}>
+ {org.name}
+ </h3>
+ <div className="flex items-center gap-2 mt-1">
+ <span className={`badge ${statusColor(org.status)} text-xs`}>{org.status}</span>
+ {org.category && <span className="text-xs" style={{ color: 'var(--color-text-muted)' }}>{org.category.replace('_', ' ')}</span>}
+ </div>
+ </div>
+ {org.website_url && (
+ <a href={org.website_url} target="_blank" rel="noreferrer" className="btn btn-ghost btn-sm">
+ <ExternalLink size={12} />
+ </a>
+ )}
+ </div>
+
+ {org.mission && (
+ <p className="text-xs line-clamp-2" style={{ color: 'var(--color-text-muted)' }}>{org.mission}</p>
+ )}
+
+ {(org.city || org.state) && (
+ <div className="flex items-center gap-1 text-xs" style={{ color: 'var(--color-text-muted)' }}>
+ <MapPin size={10} /> {[org.city, org.state].filter(Boolean).join(', ')}
+ </div>
+ )}
+
+ <div className="grid grid-cols-3 gap-2">
+ <div className="text-center p-2 rounded-lg" style={{ backgroundColor: 'var(--color-surface-el)' }}>
+ <Shield size={12} style={{ color: '#60a5fa', margin: '0 auto 2px' }} />
+ <div className="text-xs font-bold" style={{ color: 'var(--color-text)' }}>{((org.trust_score || 0) * 100).toFixed(0)}%</div>
+ <div className="text-xs" style={{ color: 'var(--color-text-muted)' }}>Trust</div>
+ </div>
+ <div className="text-center p-2 rounded-lg" style={{ backgroundColor: 'var(--color-surface-el)' }}>
+ <TrendingUp size={12} style={{ color: '#22c55e', margin: '0 auto 2px' }} />
+ <div className="text-xs font-bold" style={{ color: 'var(--color-text)' }}>{((org.impact_score || 0) * 100).toFixed(0)}%</div>
+ <div className="text-xs" style={{ color: 'var(--color-text-muted)' }}>Impact</div>
+ </div>
+ <div className="text-center p-2 rounded-lg" style={{ backgroundColor: 'var(--color-surface-el)' }}>
+ <Users size={12} style={{ color: '#fbbf24', margin: '0 auto 2px' }} />
+ <div className="text-xs font-bold" style={{ color: 'var(--color-text)' }}>{org.total_votes}</div>
+ <div className="text-xs" style={{ color: 'var(--color-text-muted)' }}>Votes</div>
+ </div>
+ </div>
+
+ {org.focus_areas && org.focus_areas.length > 0 && (
+ <div className="flex items-center gap-1 flex-wrap">
+ {org.focus_areas.slice(0, 3).map((area: string) => (
+ <span key={area} className="text-xs px-2 py-0.5 rounded-full" style={{ backgroundColor: 'var(--color-surface-el)', color: 'var(--color-text-muted)', border: '1px solid var(--color-border)' }}>
+ {area}
+ </span>
+ ))}
+ </div>
+ )}
+ </motion.div>
+ ))}
+ </AnimatePresence>
+ </div>
+ )}
+
+ {!loading && filtered.length === 0 && (
+ <div className="text-center py-12">
+ <Building2 size={32} style={{ color: 'var(--color-text-muted)', margin: '0 auto 12px' }} />
+ <p style={{ color: 'var(--color-text-muted)' }}>No organizations found. Register one to get started.</p>
+ </div>
+ )}
+
+ {showCreate && <CreateOrgModal onClose={() => setShowCreate(false)} onSubmit={handleCreate} />}
+ </div>
+ );
+}
+
+function CreateOrgModal({ onClose, onSubmit }: { onClose: () => void; onSubmit: (data: Record<string, string>) => void }) {
+ useEffect(() => {
+ const handleKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
+ window.addEventListener('keydown', handleKey);
+ return () => window.removeEventListener('keydown', handleKey);
+ }, [onClose]);
+
+ const [form, setForm] = useState({ name: '', ein: '', mission: '', description: '', website_url: '', city: '', state: '', category: 'other', focus_areas: '', year_founded: '', annual_budget: '', staff_size: '' });
+ const [submitting, setSubmitting] = useState(false);
+
+ const handleSubmit = async () => {
+ setSubmitting(true);
+ await onSubmit(form);
+ setSubmitting(false);
+ };
+
+ const set = (k: string) => (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => setForm(f => ({ ...f, [k]: e.target.value }));
+
+ return (
+ <div className="modal-overlay" onClick={onClose}>
+ <motion.div initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} className="modal-content" onClick={(e) => e.stopPropagation()}>
+ <div className="flex items-center justify-between mb-4">
+ <h3 className="text-lg font-semibold" style={{ color: 'var(--color-text)' }}>Register Organization</h3>
+ <button onClick={onClose} className="btn btn-ghost btn-sm"><X size={16} /></button>
+ </div>
+ <div className="space-y-3">
+ <div>
+ <label className="block text-sm mb-1" style={{ color: 'var(--color-text-secondary)' }}>Name *</label>
+ <input className="input" value={form.name} onChange={set('name')} placeholder="Organization name" />
+ </div>
+ <div className="grid grid-cols-2 gap-3">
+ <div>
+ <label className="block text-sm mb-1" style={{ color: 'var(--color-text-secondary)' }}>EIN</label>
+ <input className="input" value={form.ein} onChange={set('ein')} placeholder="XX-XXXXXXX" />
+ </div>
+ <div>
+ <label className="block text-sm mb-1" style={{ color: 'var(--color-text-secondary)' }}>Category</label>
+ <select className="input" value={form.category} onChange={set('category')}>
+ {CATEGORIES.filter(c => c !== 'all').map(c => <option key={c} value={c}>{c.replace('_', ' ')}</option>)}
+ </select>
+ </div>
+ </div>
+ <div>
+ <label className="block text-sm mb-1" style={{ color: 'var(--color-text-secondary)' }}>Mission</label>
+ <textarea className="input" rows={2} value={form.mission} onChange={set('mission')} placeholder="Organization mission..." />
+ </div>
+ <div>
+ <label className="block text-sm mb-1" style={{ color: 'var(--color-text-secondary)' }}>Website</label>
+ <input className="input" value={form.website_url} onChange={set('website_url')} placeholder="https://..." />
+ </div>
+ <div className="grid grid-cols-2 gap-3">
+ <div>
+ <label className="block text-sm mb-1" style={{ color: 'var(--color-text-secondary)' }}>City</label>
+ <input className="input" value={form.city} onChange={set('city')} />
+ </div>
+ <div>
+ <label className="block text-sm mb-1" style={{ color: 'var(--color-text-secondary)' }}>State</label>
+ <input className="input" value={form.state} onChange={set('state')} />
+ </div>
+ </div>
+ <div>
+ <label className="block text-sm mb-1" style={{ color: 'var(--color-text-secondary)' }}>Focus Areas (comma-separated)</label>
+ <input className="input" value={form.focus_areas} onChange={set('focus_areas')} placeholder="education, youth, mentoring" />
+ </div>
+ <div className="grid grid-cols-3 gap-3">
+ <div>
+ <label className="block text-sm mb-1" style={{ color: 'var(--color-text-secondary)' }}>Year Founded</label>
+ <input className="input" type="number" value={form.year_founded} onChange={set('year_founded')} />
+ </div>
+ <div>
+ <label className="block text-sm mb-1" style={{ color: 'var(--color-text-secondary)' }}>Annual Budget</label>
+ <input className="input" value={form.annual_budget} onChange={set('annual_budget')} placeholder="$500K" />
+ </div>
+ <div>
+ <label className="block text-sm mb-1" style={{ color: 'var(--color-text-secondary)' }}>Staff Size</label>
+ <input className="input" type="number" value={form.staff_size} onChange={set('staff_size')} />
+ </div>
+ </div>
+ <div className="flex justify-end gap-2 pt-2">
+ <button onClick={onClose} className="btn btn-secondary">Cancel</button>
+ <button onClick={handleSubmit} disabled={!form.name || submitting} className="btn btn-primary">
+ {submitting ? <Loader2 size={14} className="animate-spin" /> : null}
+ Register
+ </button>
+ </div>
+ </div>
+ </motion.div>
+ </div>
+ );
+}
diff --git a/components/pulse/PulseBoard.tsx b/components/pulse/PulseBoard.tsx
new file mode 100644
index 0000000..ed6c460
--- /dev/null
+++ b/components/pulse/PulseBoard.tsx
@@ -0,0 +1,506 @@
+'use client';
+
+import { useState, useEffect, useCallback } from 'react';
+import { motion, AnimatePresence } from 'framer-motion';
+import { SkeletonStats, SkeletonList } from '../Skeleton';
+import {
+ BarChart3, Flame, Building2, Landmark, Sparkles, DollarSign,
+ TrendingUp, TrendingDown, ArrowRight, RefreshCw, Zap,
+ Users, Target, ArrowUpRight, ArrowDownRight, Minus,
+} from 'lucide-react';
+
+interface PulseData {
+ stats: {
+ total_causes: number;
+ total_votes: number;
+ registered_orgs: number;
+ active_granters: number;
+ total_matches: number;
+ funds_matched: number;
+ revenue_earned: number;
+ pending_fees: number;
+ };
+ top_causes: Array<{
+ id: string;
+ title: string;
+ urgency_score: number;
+ total_votes: number;
+ total_supporters: number;
+ category: string;
+ tags: string[];
+ trending_rank: number | null;
+ }>;
+ top_orgs: Array<{
+ id: string;
+ name: string;
+ category: string;
+ impact_score: number;
+ trust_score: number;
+ total_votes: number;
+ total_funding_received: string;
+ status: string;
+ }>;
+ recent_matches: Array<{
+ id: string;
+ match_score: number;
+ status: string;
+ created_at: string;
+ cause_title: string;
+ org_name: string;
+ granter_name: string | null;
+ }>;
+ funding_rounds: Array<{
+ id: string;
+ title: string;
+ total_pool: string;
+ allocated: string;
+ remaining: string;
+ status: string;
+ }>;
+}
+
+const fadeIn = {
+ hidden: { opacity: 0, y: 20 },
+ visible: (i: number) => ({
+ opacity: 1,
+ y: 0,
+ transition: { delay: i * 0.05, duration: 0.4, ease: 'easeOut' as const },
+ }),
+};
+
+export default function PulseBoard() {
+ const [data, setData] = useState<PulseData | null>(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState('');
+
+ const fetchData = useCallback(async () => {
+ try {
+ const res = await fetch('/api/pulse', { credentials: 'include' });
+ if (!res.ok) throw new Error('Failed to fetch');
+ const json = await res.json();
+ setData(json);
+ setError('');
+ } catch (err) {
+ setError((err as Error).message);
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ fetchData();
+ const interval = setInterval(fetchData, 30000);
+ return () => clearInterval(interval);
+ }, [fetchData]);
+
+ if (loading) {
+ return (
+ <div className="p-6" style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
+ <SkeletonStats count={4} />
+ <SkeletonList count={3} />
+ </div>
+ );
+ }
+
+ if (error) {
+ return (
+ <div className="p-6">
+ <div className="card p-4 text-center">
+ <p style={{ color: 'var(--color-error)' }}>Error: {error}</p>
+ <button className="btn btn-primary mt-3" onClick={fetchData}>Retry</button>
+ </div>
+ </div>
+ );
+ }
+
+ if (!data) return null;
+
+ const stats = [
+ { label: 'Active Causes', value: data.stats.total_causes, Icon: Flame, color: '#ef4444' },
+ { label: 'Total Votes', value: data.stats.total_votes.toLocaleString(), Icon: Users, color: '#fbbf24' },
+ { label: 'Registered Orgs', value: data.stats.registered_orgs, Icon: Building2, color: '#22c55e' },
+ { label: 'Active Granters', value: data.stats.active_granters, Icon: Landmark, color: '#60a5fa' },
+ { label: 'AI Matches', value: data.stats.total_matches, Icon: Sparkles, color: '#c084fc' },
+ { label: 'Funds Matched', value: `$${(data.stats.funds_matched || 0).toLocaleString()}`, Icon: DollarSign, color: '#d97706' },
+ { label: 'Revenue Earned', value: `$${(data.stats.revenue_earned || 0).toLocaleString()}`, Icon: TrendingUp, color: '#22c55e' },
+ { label: 'Pending Fees', value: `$${(data.stats.pending_fees || 0).toLocaleString()}`, Icon: Target, color: '#f59e0b' },
+ ];
+
+ return (
+ <div className="p-6 space-y-6">
+ {/* Hero Header */}
+ <motion.div
+ initial={{ opacity: 0, y: -10 }}
+ animate={{ opacity: 1, y: 0 }}
+ className="flex items-center justify-between"
+ >
+ <div>
+ <h2 className="text-xl font-bold flex items-center gap-2" style={{ color: 'var(--color-text)' }}>
+ <Zap size={20} style={{ color: '#fbbf24' }} />
+ Pulse Board
+ </h2>
+ <p className="text-sm mt-1" style={{ color: 'var(--color-text-muted)' }}>
+ Real-time democratic funding dashboard
+ </p>
+ </div>
+ <button onClick={fetchData} className="btn btn-secondary btn-sm">
+ <RefreshCw size={14} />
+ Refresh
+ </button>
+ </motion.div>
+
+ {/* Stats Grid */}
+ <div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-8 gap-3">
+ {stats.map((stat, i) => (
+ <motion.div
+ key={stat.label}
+ custom={i}
+ initial="hidden"
+ animate="visible"
+ variants={fadeIn}
+ className="card p-4"
+ >
+ <div className="flex items-center gap-2 mb-2">
+ <stat.Icon size={16} style={{ color: stat.color }} />
+ <span className="text-xs" style={{ color: 'var(--color-text-muted)' }}>{stat.label}</span>
+ </div>
+ <div className="text-xl font-bold" style={{ color: 'var(--color-text)' }}>
+ {stat.value}
+ </div>
+ </motion.div>
+ ))}
+ </div>
+
+ {/* Funding Pipeline Visual */}
+ <motion.div
+ initial={{ opacity: 0, y: 20 }}
+ animate={{ opacity: 1, y: 0 }}
+ transition={{ delay: 0.3 }}
+ className="card p-5"
+ >
+ <h3 className="text-sm font-semibold mb-4" style={{ color: 'var(--color-text-secondary)' }}>
+ FUNDING PIPELINE
+ </h3>
+ <div className="flex items-center justify-between gap-2 flex-wrap">
+ <PipelineStage
+ icon={<Flame size={20} />}
+ label="Causes"
+ count={data.stats.total_causes}
+ color="#ef4444"
+ subtitle="Active issues"
+ />
+ <ArrowRight size={20} style={{ color: 'var(--color-text-muted)', flexShrink: 0 }} />
+ <PipelineStage
+ icon={<Users size={20} />}
+ label="Votes"
+ count={data.stats.total_votes}
+ color="#fbbf24"
+ subtitle="Public support"
+ />
+ <ArrowRight size={20} style={{ color: 'var(--color-text-muted)', flexShrink: 0 }} />
+ <PipelineStage
+ icon={<Building2 size={20} />}
+ label="Organizations"
+ count={data.stats.registered_orgs}
+ color="#22c55e"
+ subtitle="Non-profits"
+ />
+ <ArrowRight size={20} style={{ color: 'var(--color-text-muted)', flexShrink: 0 }} />
+ <PipelineStage
+ icon={<Sparkles size={20} />}
+ label="Matches"
+ count={data.stats.total_matches}
+ color="#c084fc"
+ subtitle="AI-generated"
+ />
+ <ArrowRight size={20} style={{ color: 'var(--color-text-muted)', flexShrink: 0 }} />
+ <PipelineStage
+ icon={<DollarSign size={20} />}
+ label="Funded"
+ count={`$${(data.stats.funds_matched || 0).toLocaleString()}`}
+ color="#d97706"
+ subtitle="Matched funds"
+ />
+ </div>
+ </motion.div>
+
+ {/* Two Column: Cause Ticker + Top Orgs */}
+ <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
+ {/* Cause Ticker */}
+ <motion.div
+ initial={{ opacity: 0, x: -20 }}
+ animate={{ opacity: 1, x: 0 }}
+ transition={{ delay: 0.4 }}
+ className="card p-5"
+ >
+ <div className="flex items-center justify-between mb-4">
+ <h3 className="text-sm font-semibold flex items-center gap-2" style={{ color: 'var(--color-text-secondary)' }}>
+ <Flame size={14} style={{ color: '#ef4444' }} />
+ TRENDING CAUSES
+ </h3>
+ <span className="badge badge-amber">LIVE</span>
+ </div>
+ <div className="space-y-2">
+ <AnimatePresence>
+ {data.top_causes.map((cause, i) => (
+ <motion.div
+ key={cause.id}
+ initial={{ opacity: 0, x: -10 }}
+ animate={{ opacity: 1, x: 0 }}
+ transition={{ delay: 0.05 * i }}
+ className="flex items-center gap-3 p-3 rounded-lg"
+ style={{
+ backgroundColor: 'var(--color-surface-el)',
+ border: '1px solid var(--color-border)',
+ }}
+ >
+ <div className="flex items-center justify-center w-7 h-7 rounded-full text-xs font-bold"
+ style={{
+ backgroundColor: i < 3 ? 'rgba(217, 119, 6, 0.2)' : 'rgba(113, 113, 122, 0.2)',
+ color: i < 3 ? '#fbbf24' : 'var(--color-text-muted)',
+ }}
+ >
+ {i + 1}
+ </div>
+ <div className="flex-1 min-w-0">
+ <div className="text-sm font-medium truncate" style={{ color: 'var(--color-text)' }}>
+ {cause.title}
+ </div>
+ <div className="flex items-center gap-3 mt-1">
+ <span className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
+ {cause.total_votes} votes
+ </span>
+ <div className="urgency-gauge flex-1 max-w-[80px]">
+ <div
+ className="urgency-gauge-fill"
+ style={{
+ width: `${Math.round(cause.urgency_score * 100)}%`,
+ background: cause.urgency_score > 0.7
+ ? 'linear-gradient(90deg, #d97706, #ef4444)'
+ : cause.urgency_score > 0.4
+ ? 'linear-gradient(90deg, #22c55e, #d97706)'
+ : '#22c55e',
+ }}
+ />
+ </div>
+ </div>
+ </div>
+ <div className="text-right">
+ {cause.urgency_score > 0.7 ? (
+ <ArrowUpRight size={16} style={{ color: '#ef4444' }} />
+ ) : cause.urgency_score > 0.4 ? (
+ <Minus size={16} style={{ color: 'var(--color-text-muted)' }} />
+ ) : (
+ <ArrowDownRight size={16} style={{ color: '#22c55e' }} />
+ )}
+ </div>
+ </motion.div>
+ ))}
+ </AnimatePresence>
+ {data.top_causes.length === 0 && (
+ <p className="text-sm text-center py-4" style={{ color: 'var(--color-text-muted)' }}>
+ No causes yet. Use the Causes tab to discover or add causes.
+ </p>
+ )}
+ </div>
+ </motion.div>
+
+ {/* Top Organizations Leaderboard */}
+ <motion.div
+ initial={{ opacity: 0, x: 20 }}
+ animate={{ opacity: 1, x: 0 }}
+ transition={{ delay: 0.4 }}
+ className="card p-5"
+ >
+ <div className="flex items-center justify-between mb-4">
+ <h3 className="text-sm font-semibold flex items-center gap-2" style={{ color: 'var(--color-text-secondary)' }}>
+ <Target size={14} style={{ color: '#22c55e' }} />
+ TOP ORGANIZATIONS
+ </h3>
+ <span className="badge badge-success">Leaderboard</span>
+ </div>
+ <div className="space-y-2">
+ {data.top_orgs.map((org, i) => (
+ <motion.div
+ key={org.id}
+ initial={{ opacity: 0, x: 10 }}
+ animate={{ opacity: 1, x: 0 }}
+ transition={{ delay: 0.05 * i }}
+ className="flex items-center gap-3 p-3 rounded-lg"
+ style={{
+ backgroundColor: 'var(--color-surface-el)',
+ border: '1px solid var(--color-border)',
+ }}
+ >
+ <div className="flex items-center justify-center w-8 h-8 rounded-lg text-sm font-bold"
+ style={{
+ backgroundColor: i === 0 ? 'rgba(217, 119, 6, 0.2)' : i === 1 ? 'rgba(168, 162, 158, 0.2)' : i === 2 ? 'rgba(180, 83, 9, 0.2)' : 'rgba(113, 113, 122, 0.1)',
+ color: i === 0 ? '#fbbf24' : i === 1 ? '#a8a29e' : i === 2 ? '#d97706' : 'var(--color-text-muted)',
+ }}
+ >
+ #{i + 1}
+ </div>
+ <div className="flex-1 min-w-0">
+ <div className="text-sm font-medium truncate" style={{ color: 'var(--color-text)' }}>
+ {org.name}
+ </div>
+ <div className="flex items-center gap-3 mt-1">
+ <span className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
+ {org.total_votes} votes
+ </span>
+ <span className="text-xs" style={{ color: '#22c55e' }}>
+ Impact: {((org.impact_score || 0) * 100).toFixed(0)}%
+ </span>
+ <span className="text-xs" style={{ color: '#60a5fa' }}>
+ Trust: {((org.trust_score || 0) * 100).toFixed(0)}%
+ </span>
+ </div>
+ </div>
+ <div className="badge badge-amber text-xs">
+ {org.category || 'other'}
+ </div>
+ </motion.div>
+ ))}
+ {data.top_orgs.length === 0 && (
+ <p className="text-sm text-center py-4" style={{ color: 'var(--color-text-muted)' }}>
+ No organizations yet. Register non-profits in the Organizations tab.
+ </p>
+ )}
+ </div>
+ </motion.div>
+ </div>
+
+ {/* Recent Matches + Active Funding */}
+ <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
+ {/* Recent Matches */}
+ <motion.div
+ initial={{ opacity: 0, y: 20 }}
+ animate={{ opacity: 1, y: 0 }}
+ transition={{ delay: 0.5 }}
+ className="card p-5"
+ >
+ <h3 className="text-sm font-semibold flex items-center gap-2 mb-4" style={{ color: 'var(--color-text-secondary)' }}>
+ <Sparkles size={14} style={{ color: '#c084fc' }} />
+ RECENT AI MATCHES
+ </h3>
+ <div className="space-y-2">
+ {data.recent_matches.map((match) => (
+ <div
+ key={match.id}
+ className="flex items-center gap-3 p-3 rounded-lg"
+ style={{
+ backgroundColor: 'var(--color-surface-el)',
+ border: '1px solid var(--color-border)',
+ }}
+ >
+ <div className="flex-1 min-w-0">
+ <div className="text-sm truncate" style={{ color: 'var(--color-text)' }}>
+ {match.cause_title}
+ </div>
+ <div className="text-xs mt-1" style={{ color: 'var(--color-text-muted)' }}>
+ {match.org_name} {match.granter_name ? `via ${match.granter_name}` : ''}
+ </div>
+ </div>
+ <div className="text-right">
+ <div className="text-sm font-bold" style={{ color: '#fbbf24' }}>
+ {(match.match_score * 100).toFixed(0)}%
+ </div>
+ <span className={`badge ${match.status === 'funded' ? 'badge-success' : match.status === 'accepted' ? 'badge-blue' : 'badge-amber'} text-xs mt-1`}>
+ {match.status}
+ </span>
+ </div>
+ </div>
+ ))}
+ {data.recent_matches.length === 0 && (
+ <p className="text-sm text-center py-4" style={{ color: 'var(--color-text-muted)' }}>
+ No matches yet. Generate matches in the Matches tab.
+ </p>
+ )}
+ </div>
+ </motion.div>
+
+ {/* Active Funding Rounds */}
+ <motion.div
+ initial={{ opacity: 0, y: 20 }}
+ animate={{ opacity: 1, y: 0 }}
+ transition={{ delay: 0.5 }}
+ className="card p-5"
+ >
+ <h3 className="text-sm font-semibold flex items-center gap-2 mb-4" style={{ color: 'var(--color-text-secondary)' }}>
+ <DollarSign size={14} style={{ color: '#d97706' }} />
+ FUNDING ROUNDS
+ </h3>
+ <div className="space-y-2">
+ {data.funding_rounds.map((round) => {
+ const total = parseFloat(round.total_pool) || 1;
+ const allocated = parseFloat(round.allocated) || 0;
+ const pct = Math.min(100, (allocated / total) * 100);
+ return (
+ <div
+ key={round.id}
+ 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">
+ <span className="text-sm font-medium truncate" style={{ color: 'var(--color-text)' }}>
+ {round.title}
+ </span>
+ <span className={`badge ${round.status === 'open' ? 'badge-success' : round.status === 'completed' ? 'badge-blue' : 'badge-warning'} text-xs`}>
+ {round.status}
+ </span>
+ </div>
+ <div className="flex items-center justify-between text-xs mb-1" style={{ color: 'var(--color-text-muted)' }}>
+ <span>${allocated.toLocaleString()} / ${total.toLocaleString()}</span>
+ <span>{pct.toFixed(0)}%</span>
+ </div>
+ <div className="urgency-gauge">
+ <div
+ className="urgency-gauge-fill"
+ style={{
+ width: `${pct}%`,
+ background: 'linear-gradient(90deg, #d97706, #fbbf24)',
+ }}
+ />
+ </div>
+ </div>
+ );
+ })}
+ {data.funding_rounds.length === 0 && (
+ <p className="text-sm text-center py-4" style={{ color: 'var(--color-text-muted)' }}>
+ No funding rounds yet. Create one in the Funding tab.
+ </p>
+ )}
+ </div>
+ </motion.div>
+ </div>
+ </div>
+ );
+}
+
+function PipelineStage({
+ icon,
+ label,
+ count,
+ color,
+ subtitle,
+}: {
+ icon: React.ReactNode;
+ label: string;
+ count: number | string;
+ color: string;
+ subtitle: string;
+}) {
+ return (
+ <div className="flex flex-col items-center gap-1 px-3 py-2 rounded-lg"
+ style={{ backgroundColor: 'var(--color-surface-el)', minWidth: 100 }}
+ >
+ <div style={{ color }}>{icon}</div>
+ <div className="text-lg font-bold" style={{ color: 'var(--color-text)' }}>{count}</div>
+ <div className="text-xs font-semibold" style={{ color }}>{label}</div>
+ <div className="text-xs" style={{ color: 'var(--color-text-muted)' }}>{subtitle}</div>
+ </div>
+ );
+}
diff --git a/components/revenue/RevenueTab.tsx b/components/revenue/RevenueTab.tsx
new file mode 100644
index 0000000..6ee9582
--- /dev/null
+++ b/components/revenue/RevenueTab.tsx
@@ -0,0 +1,253 @@
+'use client';
+
+import { useState, useEffect, useCallback } from 'react';
+import { motion, AnimatePresence } from 'framer-motion';
+import { SkeletonList } from '../Skeleton';
+import { useToast } from '../ToastProvider';
+import {
+ DollarSign, RefreshCw, TrendingUp,
+ Clock, CheckCircle, FileText, AlertCircle,
+} from 'lucide-react';
+import SortDropdown from '../shared/SortDropdown';
+import { useClientSort, SortConfig } from '@/hooks/useClientSort';
+
+interface Revenue {
+ id: string;
+ match_id: string | null;
+ org_id: string | null;
+ granter_id: string | null;
+ fee_type: string;
+ flat_fee: string;
+ percentage_rate: number;
+ grant_amount: string | null;
+ commission_amount: string | null;
+ status: string;
+ invoice_date: string | null;
+ paid_date: string | null;
+ notes: string | null;
+ created_at: string;
+ cause_title?: string;
+ org_name?: string;
+ granter_name?: string;
+}
+
+const SORT_OPTIONS = [
+ { value: 'date_desc', label: 'Newest First' },
+ { value: 'date_asc', label: 'Oldest First' },
+ { value: 'commission_desc', label: 'Highest Commission' },
+ { value: 'fee_type', label: 'Fee Type A-Z' },
+];
+
+const SORT_CONFIGS: Record<string, SortConfig> = {
+ date_desc: { key: 'created_at', direction: 'desc', type: 'date' },
+ date_asc: { key: 'created_at', direction: 'asc', type: 'date' },
+ commission_desc: { key: 'commission_amount', direction: 'desc', type: 'number' },
+ fee_type: { key: 'fee_type', direction: 'asc', type: 'string' },
+};
+
+const STATUS_FILTERS = ['all', 'pending', 'invoiced', 'paid', 'waived', 'overdue'];
+
+const fadeIn = {
+ hidden: { opacity: 0, y: 20 },
+ visible: (i: number) => ({
+ opacity: 1, y: 0,
+ transition: { delay: i * 0.04, duration: 0.35 },
+ }),
+};
+
+export default function RevenueTab() {
+ const { addToast } = useToast();
+ const [revenue, setRevenue] = useState<Revenue[]>([]);
+ const [loading, setLoading] = useState(true);
+ const [statusFilter, setStatusFilter] = useState('all');
+ const [stats, setStats] = useState({ total: 0, pending: 0, invoiced: 0, paid: 0 });
+ const [sortBy, setSortBy] = useState('date_desc');
+ const sortedRevenue = useClientSort(revenue, sortBy, SORT_CONFIGS);
+
+ const fetchRevenue = useCallback(async () => {
+ try {
+ const params = new URLSearchParams();
+ if (statusFilter !== 'all') params.set('status', statusFilter);
+ const res = await fetch(`/api/revenue?${params}`, { credentials: 'include' });
+ if (res.ok) {
+ const data = await res.json();
+ setRevenue(data.revenue || []);
+ setStats(data.stats || { total: 0, pending: 0, invoiced: 0, paid: 0 });
+ }
+ } catch (err) {
+ console.error('Failed to fetch revenue:', err);
+ addToast('Failed to load revenue data', 'error');
+ } finally {
+ setLoading(false);
+ }
+ }, [statusFilter]);
+
+ useEffect(() => { fetchRevenue(); }, [fetchRevenue]);
+
+ const fmtMoney = (val: string | number | null) => {
+ if (!val) return '$0';
+ const n = typeof val === 'string' ? parseFloat(val) : val;
+ return `$${n.toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 0 })}`;
+ };
+
+ const statusIcon = (s: string) => {
+ switch (s) {
+ case 'paid': return <CheckCircle size={14} style={{ color: '#22c55e' }} />;
+ case 'invoiced': return <FileText size={14} style={{ color: '#60a5fa' }} />;
+ case 'overdue': return <AlertCircle size={14} style={{ color: '#ef4444' }} />;
+ default: return <Clock size={14} style={{ color: '#fbbf24' }} />;
+ }
+ };
+
+ const statusBadge = (s: string) => {
+ switch (s) {
+ case 'paid': return 'badge-success';
+ case 'invoiced': return 'badge-blue';
+ case 'overdue': return 'badge-error';
+ case 'waived': return 'badge-purple';
+ default: return 'badge-warning';
+ }
+ };
+
+ const feeLabel = (t: string) => {
+ switch (t) {
+ case 'introduction_fee': return 'Introduction Fee';
+ case 'success_fee': return 'Success Fee';
+ case 'subscription': return 'Subscription';
+ case 'consulting': return 'Consulting';
+ default: return t;
+ }
+ };
+
+ return (
+ <div className="p-6 space-y-6">
+ <div className="flex items-center justify-between flex-wrap gap-3">
+ <div>
+ <h2 className="text-xl font-bold flex items-center gap-2" style={{ color: 'var(--color-text)' }}>
+ <DollarSign size={20} style={{ color: '#d97706' }} />
+ Revenue
+ </h2>
+ <p className="text-sm mt-1" style={{ color: 'var(--color-text-muted)' }}>
+ Fee tracking, commissions, and payment status
+ </p>
+ </div>
+ <button onClick={fetchRevenue} className="btn btn-secondary btn-sm">
+ <RefreshCw size={14} /> Refresh
+ </button>
+ </div>
+
+ {/* Stats cards */}
+ <div className="grid grid-cols-2 md:grid-cols-4 gap-3">
+ <div className="stat-card">
+ <div className="flex items-center gap-2 mb-2">
+ <TrendingUp size={14} style={{ color: '#22c55e' }} />
+ <span className="text-xs" style={{ color: 'var(--color-text-muted)' }}>Total Earned</span>
+ </div>
+ <div className="text-xl font-bold" style={{ color: '#22c55e' }}>{fmtMoney(stats.total)}</div>
+ </div>
+ <div className="stat-card">
+ <div className="flex items-center gap-2 mb-2">
+ <Clock size={14} style={{ color: '#fbbf24' }} />
+ <span className="text-xs" style={{ color: 'var(--color-text-muted)' }}>Pending</span>
+ </div>
+ <div className="text-xl font-bold" style={{ color: '#fbbf24' }}>{fmtMoney(stats.pending)}</div>
+ </div>
+ <div className="stat-card">
+ <div className="flex items-center gap-2 mb-2">
+ <FileText size={14} style={{ color: '#60a5fa' }} />
+ <span className="text-xs" style={{ color: 'var(--color-text-muted)' }}>Invoiced</span>
+ </div>
+ <div className="text-xl font-bold" style={{ color: '#60a5fa' }}>{fmtMoney(stats.invoiced)}</div>
+ </div>
+ <div className="stat-card">
+ <div className="flex items-center gap-2 mb-2">
+ <CheckCircle size={14} style={{ color: '#22c55e' }} />
+ <span className="text-xs" style={{ color: 'var(--color-text-muted)' }}>Paid</span>
+ </div>
+ <div className="text-xl font-bold" style={{ color: '#22c55e' }}>{fmtMoney(stats.paid)}</div>
+ </div>
+ </div>
+
+ {/* Status filter */}
+ <div className="flex items-center gap-2 flex-wrap">
+ <SortDropdown options={SORT_OPTIONS} value={sortBy} onChange={setSortBy} />
+ {STATUS_FILTERS.map((s) => (
+ <button
+ key={s}
+ onClick={() => setStatusFilter(s)}
+ className="btn btn-sm"
+ style={{
+ backgroundColor: statusFilter === s ? 'rgba(217, 119, 6, 0.2)' : 'var(--color-surface-el)',
+ color: statusFilter === s ? '#fbbf24' : 'var(--color-text-secondary)',
+ borderColor: statusFilter === s ? 'rgba(217, 119, 6, 0.3)' : 'var(--color-border)',
+ }}
+ >
+ {s === 'all' ? 'All' : s}
+ </button>
+ ))}
+ </div>
+
+ {/* Revenue list */}
+ {loading ? (
+ <SkeletonList count={4} />
+ ) : (
+ <div className="space-y-3">
+ <AnimatePresence>
+ {sortedRevenue.map((r, i) => (
+ <motion.div key={r.id} custom={i} initial="hidden" animate="visible" variants={fadeIn} className="card p-4">
+ <div className="flex items-center justify-between flex-wrap gap-3">
+ <div className="flex items-center gap-3">
+ {statusIcon(r.status)}
+ <div>
+ <div className="text-sm font-semibold" style={{ color: 'var(--color-text)' }}>
+ {feeLabel(r.fee_type)}
+ </div>
+ <div className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
+ {r.org_name && `Org: ${r.org_name}`}
+ {r.granter_name && ` | Granter: ${r.granter_name}`}
+ {r.cause_title && ` | Cause: ${r.cause_title}`}
+ </div>
+ </div>
+ </div>
+
+ <div className="flex items-center gap-4">
+ <div className="text-right">
+ {parseFloat(r.flat_fee) > 0 && (
+ <div className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
+ Flat: {fmtMoney(r.flat_fee)}
+ </div>
+ )}
+ {r.percentage_rate > 0 && r.grant_amount && (
+ <div className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
+ {r.percentage_rate}% of {fmtMoney(r.grant_amount)}
+ </div>
+ )}
+ {r.commission_amount && (
+ <div className="text-sm font-bold" style={{ color: '#fbbf24' }}>
+ {fmtMoney(r.commission_amount)}
+ </div>
+ )}
+ </div>
+ <span className={`badge ${statusBadge(r.status)}`}>{r.status}</span>
+ </div>
+ </div>
+ {r.notes && (
+ <p className="text-xs mt-2" style={{ color: 'var(--color-text-muted)' }}>{r.notes}</p>
+ )}
+ </motion.div>
+ ))}
+ </AnimatePresence>
+ </div>
+ )}
+
+ {!loading && revenue.length === 0 && (
+ <div className="text-center py-12">
+ <DollarSign size={32} style={{ color: 'var(--color-text-muted)', margin: '0 auto 12px' }} />
+ <p style={{ color: 'var(--color-text-muted)' }}>
+ No revenue entries yet. Revenue is generated when matches lead to funded introductions.
+ </p>
+ </div>
+ )}
+ </div>
+ );
+}
diff --git a/components/settings/SettingsTab.tsx b/components/settings/SettingsTab.tsx
new file mode 100644
index 0000000..49633c4
--- /dev/null
+++ b/components/settings/SettingsTab.tsx
@@ -0,0 +1,128 @@
+'use client';
+
+import { useState } from 'react';
+import { Settings, Shield, Database, Zap, Globe, Bell, Check } from 'lucide-react';
+import { useToast } from '../ToastProvider';
+
+export default function SettingsTab() {
+ const { addToast } = useToast();
+
+ const handleSave = () => {
+ addToast('Settings saved', 'success');
+ };
+
+ return (
+ <div className="p-6 space-y-6 max-w-3xl">
+ <div>
+ <h2 className="text-xl font-bold flex items-center gap-2" style={{ color: 'var(--color-text)' }}>
+ <Settings size={20} style={{ color: 'var(--color-text-muted)' }} />
+ Settings
+ </h2>
+ <p className="text-sm mt-1" style={{ color: 'var(--color-text-muted)' }}>
+ Platform configuration and preferences
+ </p>
+ </div>
+
+ {/* Platform Info */}
+ <div className="card p-5 space-y-4">
+ <h3 className="text-sm font-semibold flex items-center gap-2" style={{ color: 'var(--color-text-secondary)' }}>
+ <Globe size={14} /> Platform Information
+ </h3>
+ <div className="grid grid-cols-2 gap-4 text-sm">
+ <div>
+ <span style={{ color: 'var(--color-text-muted)' }}>Application</span>
+ <p style={{ color: 'var(--color-text)' }}>Freddy - The Fundraising Coordinator</p>
+ </div>
+ <div>
+ <span style={{ color: 'var(--color-text-muted)' }}>Version</span>
+ <p style={{ color: 'var(--color-text)' }}>1.0.0</p>
+ </div>
+ <div>
+ <span style={{ color: 'var(--color-text-muted)' }}>Stack</span>
+ <p style={{ color: 'var(--color-text)' }}>Next.js 16 + PostgreSQL + Gemini AI</p>
+ </div>
+ <div>
+ <span style={{ color: 'var(--color-text-muted)' }}>Port</span>
+ <p style={{ color: 'var(--color-text)' }}>7470</p>
+ </div>
+ </div>
+ </div>
+
+ {/* Fee Configuration */}
+ <div className="card p-5 space-y-4">
+ <h3 className="text-sm font-semibold flex items-center gap-2" style={{ color: 'var(--color-text-secondary)' }}>
+ <Zap size={14} style={{ color: '#fbbf24' }} /> Fee Configuration
+ </h3>
+ <div className="space-y-3">
+ <div>
+ <label className="block text-sm mb-1" style={{ color: 'var(--color-text-secondary)' }}>Introduction Fee ($)</label>
+ <input className="input" type="number" defaultValue="750" style={{ maxWidth: 200 }} />
+ </div>
+ <div>
+ <label className="block text-sm mb-1" style={{ color: 'var(--color-text-secondary)' }}>Success Fee (%)</label>
+ <input className="input" type="number" defaultValue="5" step="0.5" style={{ maxWidth: 200 }} />
+ </div>
+ </div>
+ </div>
+
+ {/* AI Settings */}
+ <div className="card p-5 space-y-4">
+ <h3 className="text-sm font-semibold flex items-center gap-2" style={{ color: 'var(--color-text-secondary)' }}>
+ <Zap size={14} style={{ color: '#c084fc' }} /> AI Configuration
+ </h3>
+ <div className="space-y-3">
+ <div>
+ <label className="block text-sm mb-1" style={{ color: 'var(--color-text-secondary)' }}>AI Model</label>
+ <select className="input" defaultValue="gemini-2.0-flash" style={{ maxWidth: 300 }}>
+ <option value="gemini-2.0-flash">Gemini 2.0 Flash</option>
+ </select>
+ </div>
+ <div>
+ <label className="block text-sm mb-1" style={{ color: 'var(--color-text-secondary)' }}>Match Temperature</label>
+ <input className="input" type="number" defaultValue="0.5" step="0.1" min="0" max="1" style={{ maxWidth: 200 }} />
+ </div>
+ </div>
+ </div>
+
+ {/* Security */}
+ <div className="card p-5 space-y-4">
+ <h3 className="text-sm font-semibold flex items-center gap-2" style={{ color: 'var(--color-text-secondary)' }}>
+ <Shield size={14} style={{ color: '#22c55e' }} /> Security
+ </h3>
+ <div className="text-sm space-y-2" style={{ color: 'var(--color-text-muted)' }}>
+ <p>Authentication: Cookie-based session (SHA-256 HMAC)</p>
+ <p>Session Duration: 24 hours</p>
+ <p>Cookie Name: freddy-auth</p>
+ </div>
+ </div>
+
+ {/* Database */}
+ <div className="card p-5 space-y-4">
+ <h3 className="text-sm font-semibold flex items-center gap-2" style={{ color: 'var(--color-text-secondary)' }}>
+ <Database size={14} style={{ color: '#60a5fa' }} /> Database
+ </h3>
+ <div className="text-sm space-y-2" style={{ color: 'var(--color-text-muted)' }}>
+ <p>Engine: PostgreSQL 16</p>
+ <p>Database: freddy</p>
+ <p>Tables: users, organizations, granters, causes, votes, matches, funding_rounds, impact_reports, revenue, contacts, audit_events</p>
+ </div>
+ </div>
+
+ {/* Legal Disclaimer */}
+ <div className="card p-5 space-y-3" style={{ borderColor: 'rgba(217, 119, 6, 0.3)' }}>
+ <h3 className="text-sm font-semibold flex items-center gap-2" style={{ color: '#fbbf24' }}>
+ <Shield size={14} /> Legal Disclaimer
+ </h3>
+ <p className="text-xs" style={{ color: 'var(--color-text-secondary)', lineHeight: 1.6 }}>
+ Freddy provides introduction and facilitation services only. Freddy does not serve as fiscal agent, fiduciary, or guarantor of any grant, donation, or contractual agreement between parties. All financial transactions, grant agreements, and contractual obligations are solely between the granting organization and the non-profit recipient.
+ </p>
+ </div>
+
+ <div className="flex justify-end">
+ <button onClick={handleSave} className="btn btn-primary">
+ Save Settings
+ </button>
+ </div>
+ </div>
+ );
+}
diff --git a/components/shared/SortDropdown.tsx b/components/shared/SortDropdown.tsx
new file mode 100644
index 0000000..3d5cfe4
--- /dev/null
+++ b/components/shared/SortDropdown.tsx
@@ -0,0 +1,41 @@
+'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)}
+ 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/components/solicitor/SolicitorTab.tsx b/components/solicitor/SolicitorTab.tsx
new file mode 100644
index 0000000..55c6156
--- /dev/null
+++ b/components/solicitor/SolicitorTab.tsx
@@ -0,0 +1,425 @@
+'use client';
+
+import { useState, useEffect, useCallback } from 'react';
+import { motion, AnimatePresence } from 'framer-motion';
+import { SkeletonList } from '../Skeleton';
+import {
+ Send, Sparkles, Loader2, Search, Users, Mail,
+ FileText, User, Building2, Phone, Linkedin,
+ Star, MessageSquare, X, Copy, Check,
+ AlertTriangle, RefreshCw,
+} from 'lucide-react';
+import { useToast } from '../ToastProvider';
+import { useDebounce } from '@/hooks/useDebounce';
+import SortDropdown from '../shared/SortDropdown';
+import { useClientSort, SortConfig } from '@/hooks/useClientSort';
+
+interface Contact {
+ id: string;
+ contact_type: string;
+ name: string;
+ title: string | null;
+ organization: string | null;
+ email: string | null;
+ phone: string | null;
+ linkedin_url: string | null;
+ city: string | null;
+ state: string | null;
+ source: string;
+ relevance_score: number;
+ ai_pitch: string | null;
+ solicited: boolean;
+ solicited_at: string | null;
+ response: string | null;
+ notes: string | null;
+ created_at: string;
+}
+
+const SORT_OPTIONS = [
+ { value: 'date_desc', label: 'Newest First' },
+ { value: 'date_asc', label: 'Oldest First' },
+ { value: 'relevance_desc', label: 'Most Relevant' },
+ { value: 'name_asc', label: 'Name A-Z' },
+ { value: 'response', label: 'Response Status' },
+];
+
+const SORT_CONFIGS: Record<string, SortConfig> = {
+ date_desc: { key: 'created_at', direction: 'desc', type: 'date' },
+ date_asc: { key: 'created_at', direction: 'asc', type: 'date' },
+ relevance_desc: { key: 'relevance_score', direction: 'desc', type: 'number' },
+ name_asc: { key: 'name', direction: 'asc', type: 'string' },
+ response: { key: 'response', direction: 'asc', type: 'string' },
+};
+
+const CONTACT_TYPES = ['all', 'nonprofit_leader', 'grant_officer', 'board_member', 'program_director', 'government', 'media'];
+const RESPONSE_STATUSES = ['none', 'interested', 'meeting_set', 'signed', 'declined'];
+
+const fadeIn = {
+ hidden: { opacity: 0, y: 20 },
+ visible: (i: number) => ({
+ opacity: 1, y: 0,
+ transition: { delay: i * 0.04, duration: 0.35 },
+ }),
+};
+
+export default function SolicitorTab() {
+ const { addToast } = useToast();
+ const [contacts, setContacts] = useState<Contact[]>([]);
+ const [loading, setLoading] = useState(true);
+ const [discovering, setDiscovering] = useState(false);
+ const [typeFilter, setTypeFilter] = useState('all');
+ const [searchTerm, setSearchTerm] = useState('');
+ const debouncedSearch = useDebounce(searchTerm, 300);
+ const [letterContact, setLetterContact] = useState<Contact | null>(null);
+ const [generatedLetter, setGeneratedLetter] = useState('');
+ const [generatingLetter, setGeneratingLetter] = useState(false);
+ const [letterType, setLetterType] = useState('nonprofit_pitch');
+ const [copied, setCopied] = useState(false);
+ const [sortBy, setSortBy] = useState('relevance_desc');
+ const sortedContacts = useClientSort(contacts, sortBy, SORT_CONFIGS);
+
+ const fetchContacts = useCallback(async () => {
+ try {
+ const params = new URLSearchParams();
+ if (typeFilter !== 'all') params.set('type', typeFilter);
+ const res = await fetch(`/api/contacts?${params}`, { credentials: 'include' });
+ if (res.ok) {
+ const data = await res.json();
+ setContacts(data.contacts || []);
+ }
+ } catch (err) {
+ console.error('Failed to fetch contacts:', err);
+ } finally {
+ setLoading(false);
+ }
+ }, [typeFilter]);
+
+ useEffect(() => { fetchContacts(); }, [fetchContacts]);
+
+ /* ── Escape key handler ───────────────────────────────────────── */
+ useEffect(() => {
+ if (!letterContact) return;
+ const handleKey = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') { setLetterContact(null); setGeneratedLetter(''); }
+ };
+ window.addEventListener('keydown', handleKey);
+ return () => window.removeEventListener('keydown', handleKey);
+ }, [letterContact]);
+
+ const handleDiscover = async () => {
+ setDiscovering(true);
+ try {
+ const res = await fetch('/api/contacts/discover', {
+ method: 'POST',
+ credentials: 'include',
+ });
+ if (res.ok) {
+ await fetchContacts();
+ addToast('Contacts discovered', 'success');
+ } else {
+ addToast('Discovery failed', 'error');
+ }
+ } catch (err) {
+ console.error('Discover error:', err);
+ addToast('Discovery failed', 'error');
+ } finally {
+ setDiscovering(false);
+ }
+ };
+
+ const handleGenerateLetter = async (contact: Contact, type: string) => {
+ setLetterContact(contact);
+ setLetterType(type);
+ setGeneratingLetter(true);
+ setGeneratedLetter('');
+ try {
+ const res = await fetch('/api/contacts/generate-letter', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ body: JSON.stringify({ contact_id: contact.id, letter_type: type }),
+ });
+ if (res.ok) {
+ const data = await res.json();
+ setGeneratedLetter(data.letter || '');
+ addToast('Letter generated', 'success');
+ } else {
+ addToast('Letter generation failed', 'error');
+ }
+ } catch (err) {
+ console.error('Generate letter error:', err);
+ addToast('Letter generation failed', 'error');
+ } finally {
+ setGeneratingLetter(false);
+ }
+ };
+
+ const handleUpdateResponse = async (contactId: string, response: string) => {
+ try {
+ await fetch(`/api/contacts`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ body: JSON.stringify({ id: contactId, response, solicited: true }),
+ });
+ await fetchContacts();
+ addToast('Response updated', 'success');
+ } catch (err) {
+ console.error('Update error:', err);
+ addToast('Update failed', 'error');
+ }
+ };
+
+ const copyToClipboard = async () => {
+ await navigator.clipboard.writeText(generatedLetter);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ addToast('Letter copied to clipboard', 'success');
+ };
+
+ const filtered = sortedContacts.filter(c =>
+ !debouncedSearch || c.name.toLowerCase().includes(debouncedSearch.toLowerCase()) ||
+ (c.organization && c.organization.toLowerCase().includes(debouncedSearch.toLowerCase()))
+ );
+
+ const responseColor = (r: string | null) => {
+ switch (r) {
+ case 'interested': return 'badge-blue';
+ case 'meeting_set': return 'badge-warning';
+ case 'signed': return 'badge-success';
+ case 'declined': return 'badge-error';
+ default: return 'badge-amber';
+ }
+ };
+
+ const typeLabel = (t: string) => t.replace('_', ' ');
+
+ return (
+ <div className="p-6 space-y-6">
+ {/* Header */}
+ <div className="flex items-center justify-between flex-wrap gap-3">
+ <div>
+ <h2 className="text-xl font-bold flex items-center gap-2" style={{ color: 'var(--color-text)' }}>
+ <Send size={20} style={{ color: '#d97706' }} />
+ Solicitor
+ </h2>
+ <p className="text-sm mt-1" style={{ color: 'var(--color-text-muted)' }}>
+ Contact harvesting and outreach letter generation for both parties
+ </p>
+ </div>
+ <div className="flex items-center gap-2">
+ <button onClick={handleDiscover} disabled={discovering} className="btn btn-primary">
+ {discovering ? <Loader2 size={14} className="animate-spin" /> : <Sparkles size={14} />}
+ {discovering ? 'Discovering...' : 'AI Discover Contacts'}
+ </button>
+ <button onClick={fetchContacts} className="btn btn-secondary btn-sm">
+ <RefreshCw size={14} />
+ </button>
+ </div>
+ </div>
+
+ {/* Fee Structure Banner */}
+ <div className="card p-4" style={{ borderColor: 'rgba(217, 119, 6, 0.3)', background: 'linear-gradient(135deg, rgba(217, 119, 6, 0.05), rgba(251, 191, 36, 0.02))' }}>
+ <div className="flex items-start gap-3">
+ <AlertTriangle size={18} style={{ color: '#fbbf24', flexShrink: 0, marginTop: 2 }} />
+ <div>
+ <h4 className="text-sm font-semibold mb-2" style={{ color: '#fbbf24' }}>Fee Structure</h4>
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-3 text-xs" style={{ color: 'var(--color-text-secondary)' }}>
+ <div>
+ <span className="font-bold" style={{ color: 'var(--color-text)' }}>Introduction Fee: $750 flat</span>
+ <br />Paid by requesting party at time of introduction
+ </div>
+ <div>
+ <span className="font-bold" style={{ color: 'var(--color-text)' }}>Success Fee: 5% of grant amount</span>
+ <br />Paid by non-profit from grant funds, only if awarded
+ </div>
+ <div>
+ <span className="font-bold" style={{ color: 'var(--color-text)' }}>No upfront costs for granters</span>
+ <br />Granters explore the platform at no charge
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ {/* Filters */}
+ <div className="flex items-center gap-3 flex-wrap">
+ <div className="relative flex-1 max-w-xs">
+ <Search size={14} style={{ position: 'absolute', left: 10, top: '50%', transform: 'translateY(-50%)', color: 'var(--color-text-muted)' }} />
+ <input className="input pl-8" placeholder="Search contacts..." value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} />
+ </div>
+ <SortDropdown options={SORT_OPTIONS} value={sortBy} onChange={setSortBy} />
+ </div>
+
+ {/* Contact type pills */}
+ <div className="flex items-center gap-2 flex-wrap">
+ {CONTACT_TYPES.map((t) => (
+ <button
+ key={t}
+ onClick={() => setTypeFilter(t)}
+ className="btn btn-sm"
+ style={{
+ backgroundColor: typeFilter === t ? 'rgba(217, 119, 6, 0.2)' : 'var(--color-surface-el)',
+ color: typeFilter === t ? '#fbbf24' : 'var(--color-text-secondary)',
+ borderColor: typeFilter === t ? 'rgba(217, 119, 6, 0.3)' : 'var(--color-border)',
+ }}
+ >
+ {t === 'all' ? 'All' : typeLabel(t)}
+ </button>
+ ))}
+ </div>
+
+ {/* Contacts list */}
+ {loading ? (
+ <SkeletonList count={4} />
+ ) : (
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+ <AnimatePresence>
+ {filtered.map((contact, i) => (
+ <motion.div key={contact.id} custom={i} initial="hidden" animate="visible" variants={fadeIn} className="card p-4 flex flex-col gap-3">
+ <div className="flex items-start justify-between">
+ <div className="flex items-center gap-3">
+ <div className="w-10 h-10 rounded-full flex items-center justify-center" style={{ backgroundColor: 'var(--color-surface-el)', border: '1px solid var(--color-border)' }}>
+ <User size={18} style={{ color: 'var(--color-text-muted)' }} />
+ </div>
+ <div>
+ <h3 className="text-sm font-semibold" style={{ color: 'var(--color-text)' }}>{contact.name}</h3>
+ {contact.title && <p className="text-xs" style={{ color: 'var(--color-text-secondary)' }}>{contact.title}</p>}
+ {contact.organization && (
+ <p className="text-xs flex items-center gap-1" style={{ color: 'var(--color-text-muted)' }}>
+ <Building2 size={10} /> {contact.organization}
+ </p>
+ )}
+ </div>
+ </div>
+ <div className="flex flex-col items-end gap-1">
+ <span className={`badge ${responseColor(contact.response)} text-xs`}>
+ {contact.response || 'none'}
+ </span>
+ <span className="text-xs" style={{ color: 'var(--color-text-muted)' }}>{typeLabel(contact.contact_type)}</span>
+ </div>
+ </div>
+
+ {/* Contact info */}
+ <div className="flex items-center gap-3 flex-wrap text-xs" style={{ color: 'var(--color-text-muted)' }}>
+ {contact.email && (
+ <span className="flex items-center gap-1"><Mail size={10} /> {contact.email}</span>
+ )}
+ {contact.phone && (
+ <span className="flex items-center gap-1"><Phone size={10} /> {contact.phone}</span>
+ )}
+ {contact.linkedin_url && (
+ <a href={contact.linkedin_url} target="_blank" rel="noreferrer" className="flex items-center gap-1" style={{ color: '#60a5fa' }}>
+ <Linkedin size={10} /> LinkedIn
+ </a>
+ )}
+ </div>
+
+ {/* Relevance score */}
+ <div className="flex items-center gap-2">
+ <Star size={12} style={{ color: '#fbbf24' }} />
+ <div className="flex-1">
+ <div className="score-bar">
+ <div className="score-bar-fill" style={{ width: `${(contact.relevance_score || 0) * 100}%` }} />
+ </div>
+ </div>
+ <span className="text-xs font-bold" style={{ color: '#fbbf24' }}>{((contact.relevance_score || 0) * 100).toFixed(0)}%</span>
+ </div>
+
+ {/* AI pitch */}
+ {contact.ai_pitch && (
+ <p className="text-xs p-2 rounded" style={{ backgroundColor: 'var(--color-surface-el)', color: 'var(--color-text-secondary)', borderLeft: '2px solid #c084fc' }}>
+ {contact.ai_pitch}
+ </p>
+ )}
+
+ {/* Actions */}
+ <div className="flex items-center gap-2 flex-wrap pt-1">
+ <button onClick={() => handleGenerateLetter(contact, 'nonprofit_pitch')} className="btn btn-sm btn-secondary">
+ <FileText size={12} /> NP Pitch
+ </button>
+ <button onClick={() => handleGenerateLetter(contact, 'granter_pitch')} className="btn btn-sm btn-secondary">
+ <FileText size={12} /> Granter Pitch
+ </button>
+ <button onClick={() => handleGenerateLetter(contact, 'introduction')} className="btn btn-sm btn-secondary">
+ <MessageSquare size={12} /> Intro
+ </button>
+ <button onClick={() => handleGenerateLetter(contact, 'follow_up')} className="btn btn-sm btn-secondary">
+ <Send size={12} /> Follow-Up
+ </button>
+
+ {/* Response status dropdown */}
+ <select
+ className="input btn-sm"
+ style={{ width: 'auto', fontSize: '0.75rem', padding: '2px 6px' }}
+ value={contact.response || 'none'}
+ onChange={(e) => handleUpdateResponse(contact.id, e.target.value)}
+ >
+ {RESPONSE_STATUSES.map(s => (
+ <option key={s} value={s}>{s === 'none' ? 'No Response' : s.replace('_', ' ')}</option>
+ ))}
+ </select>
+ </div>
+ </motion.div>
+ ))}
+ </AnimatePresence>
+ </div>
+ )}
+
+ {!loading && filtered.length === 0 && (
+ <div className="text-center py-12">
+ <Users size={32} style={{ color: 'var(--color-text-muted)', margin: '0 auto 12px' }} />
+ <p style={{ color: 'var(--color-text-muted)' }}>
+ No contacts yet. Click "AI Discover Contacts" to find nonprofit leaders and grant officers.
+ </p>
+ </div>
+ )}
+
+ {/* Letter Generation Modal */}
+ {letterContact && (
+ <div className="modal-overlay" onClick={() => { setLetterContact(null); setGeneratedLetter(''); }}>
+ <motion.div
+ initial={{ opacity: 0, scale: 0.95 }}
+ animate={{ opacity: 1, scale: 1 }}
+ className="modal-content"
+ style={{ maxWidth: '40rem' }}
+ onClick={(e) => e.stopPropagation()}
+ >
+ <div className="flex items-center justify-between mb-4">
+ <div>
+ <h3 className="text-lg font-semibold" style={{ color: 'var(--color-text)' }}>
+ Generated Letter
+ </h3>
+ <p className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
+ To: {letterContact.name} — Type: {letterType.replace('_', ' ')}
+ </p>
+ </div>
+ <button onClick={() => { setLetterContact(null); setGeneratedLetter(''); }} className="btn btn-ghost btn-sm"><X size={16} /></button>
+ </div>
+
+ {generatingLetter ? (
+ <div className="flex items-center justify-center py-12 gap-2">
+ <Loader2 size={20} className="animate-spin" style={{ color: 'var(--color-primary)' }} />
+ <span className="text-sm" style={{ color: 'var(--color-text-secondary)' }}>Generating personalized letter...</span>
+ </div>
+ ) : generatedLetter ? (
+ <div className="space-y-3">
+ <div className="letter-preview">{generatedLetter}</div>
+ <div className="flex justify-end gap-2">
+ <button onClick={copyToClipboard} className="btn btn-secondary">
+ {copied ? <Check size={14} style={{ color: '#22c55e' }} /> : <Copy size={14} />}
+ {copied ? 'Copied!' : 'Copy'}
+ </button>
+ </div>
+ </div>
+ ) : (
+ <p className="text-sm text-center py-8" style={{ color: 'var(--color-text-muted)' }}>
+ No letter generated. Try again.
+ </p>
+ )}
+ </motion.div>
+ </div>
+ )}
+ </div>
+ );
+}
diff --git a/ecosystem.config.js b/ecosystem.config.js
new file mode 100644
index 0000000..27bd38b
--- /dev/null
+++ b/ecosystem.config.js
@@ -0,0 +1,17 @@
+module.exports = {
+ apps: [
+ {
+ name: 'freddy-app',
+ script: 'node_modules/.bin/next',
+ args: 'start -p 7470',
+ cwd: '/root/Projects/Freddy',
+ env: {
+ NODE_ENV: 'production',
+ PORT: '7470',
+ },
+ instances: 1,
+ autorestart: true,
+ max_memory_restart: '512M',
+ },
+ ],
+};
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..677b838
--- /dev/null
+++ b/lib/audit.ts
@@ -0,0 +1,29 @@
+import { query } from './db';
+
+/**
+ * Log an audit event to the audit_events table.
+ */
+export async function auditLog(
+ eventType: string,
+ entityType: string,
+ entityId: string | null,
+ metadata?: Record<string, unknown>,
+ ipAddress?: string,
+): Promise<void> {
+ const metaJson = metadata ? JSON.stringify(metadata) : null;
+
+ console.log(
+ `[audit] ${eventType} | ${entityType} | ${entityId ?? '(none)'} | ip=${ipAddress ?? 'unknown'}`,
+ metadata ? JSON.stringify(metadata) : '',
+ );
+
+ try {
+ await query(
+ `INSERT INTO audit_events (event_type, entity_type, entity_id, metadata, ip_address)
+ VALUES ($1, $2, $3, $4, $5)`,
+ [eventType, entityType, entityId, metaJson, ipAddress ?? null],
+ );
+ } catch (err) {
+ 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..1b3735c
--- /dev/null
+++ b/lib/auth.ts
@@ -0,0 +1,10 @@
+import { createAuth } from '@dw/nextjs-admin-login';
+
+export const auth = createAuth({ cookieName: 'freddy-auth' });
+export const { COOKIE_NAME: AUTH_COOKIE_NAME, createSession, verifyAuth,
+ buildAuthCookie, buildLogoutCookie, hashPassword,
+ // 2026-05-06 (tick 47): re-export v0.2.0 org-scoped helpers.
+ // Backward compatible — accepts both v1 (3-part) and v2
+ // (4-part) tokens. Adopt at consumer-route's discretion.
+ createSessionWithOrg, verifyAuthWithOrg } = auth;
+export type { AuthSession } from '@dw/nextjs-admin-login';
diff --git a/lib/db.ts b/lib/db.ts
new file mode 100644
index 0000000..b7dfde3
--- /dev/null
+++ b/lib/db.ts
@@ -0,0 +1,44 @@
+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.
+ */
+export async function getClient(): Promise<PoolClient> {
+ return pool.connect();
+}
+
+export default pool;
diff --git a/lib/sister-auth.ts b/lib/sister-auth.ts
new file mode 100644
index 0000000..5ce6fa7
--- /dev/null
+++ b/lib/sister-auth.ts
@@ -0,0 +1,34 @@
+import { NextResponse } from 'next/server';
+
+// Sister-service Basic auth — reads from env (audit 2026-05-04: removed inline literal).
+// Falls back to AUTH_PASSWORD which Patty'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.
+//
+// NOTE on `?? 'admin'` (audit P? 2026-05-04 lint sweep): the username default is
+// intentional and SAFE — 'admin' is not a secret, just the conventional username
+// across all DW Express services. The audit's 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)
+// rather than a Basic Authorization header.
+export function sisterCredentials(): { username: string; password: string } {
+ return { username: SISTER_USER, password: SISTER_PASS };
+}
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..10509af
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,1869 @@
+{
+ "name": "freddy-tmp",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "freddy-tmp",
+ "version": "0.1.0",
+ "dependencies": {
+ "@dw/nextjs-admin-login": "file:/tmp/dw-nextjs-admin-login-0.2.0.tgz",
+ "@types/pg": "^8.16.0",
+ "framer-motion": "^12.34.3",
+ "lucide-react": "^0.575.0",
+ "next": "15.5.12",
+ "pg": "^8.19.0",
+ "react": "19.1.0",
+ "react-dom": "19.1.0"
+ },
+ "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": "15.5.12",
+ "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.12.tgz",
+ "integrity": "sha512-pUvdJN1on574wQHjaBfNGDt9Mz5utDSZFsIIQkMzPgNS8ZvT4H2mwOrOIClwsQOb6EGx5M76/CZr6G8i6pSpLg==",
+ "license": "MIT"
+ },
+ "node_modules/@next/swc-darwin-arm64": {
+ "version": "15.5.12",
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.12.tgz",
+ "integrity": "sha512-RnRjBtH8S8eXCpUNkQ+543DUc7ys8y15VxmFU9HRqlo9BG3CcBUiwNtF8SNoi2xvGCVJq1vl2yYq+3oISBS0Zg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-darwin-x64": {
+ "version": "15.5.12",
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.12.tgz",
+ "integrity": "sha512-nqa9/7iQlboF1EFtNhWxQA0rQstmYRSBGxSM6g3GxvxHxcoeqVXfGNr9stJOme674m2V7r4E3+jEhhGvSQhJRA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-arm64-gnu": {
+ "version": "15.5.12",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.12.tgz",
+ "integrity": "sha512-dCzAjqhDHwmoB2M4eYfVKqXs99QdQxNQVpftvP1eGVppamXh/OkDAwV737Zr0KPXEqRUMN4uCjh6mjO+XtF3Mw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-arm64-musl": {
+ "version": "15.5.12",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.12.tgz",
+ "integrity": "sha512-+fpGWvQiITgf7PUtbWY1H7qUSnBZsPPLyyq03QuAKpVoTy/QUx1JptEDTQMVvQhvizCEuNLEeghrQUyXQOekuw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-x64-gnu": {
+ "version": "15.5.12",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.12.tgz",
+ "integrity": "sha512-jSLvgdRRL/hrFAPqEjJf1fFguC719kmcptjNVDJl26BnJIpjL3KH5h6mzR4mAweociLQaqvt4UyzfbFjgAdDcw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-x64-musl": {
+ "version": "15.5.12",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.12.tgz",
+ "integrity": "sha512-/uaF0WfmYqQgLfPmN6BvULwxY0dufI2mlN2JbOKqqceZh1G4hjREyi7pg03zjfyS6eqNemHAZPSoP84x17vo6w==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-win32-arm64-msvc": {
+ "version": "15.5.12",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.12.tgz",
+ "integrity": "sha512-xhsL1OvQSfGmlL5RbOmU+FV120urrgFpYLq+6U8C6KIym32gZT6XF/SDE92jKzzlPWskkbjOKCpqk5m4i8PEfg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-win32-x64-msvc": {
+ "version": "15.5.12",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.12.tgz",
+ "integrity": "sha512-Z1Dh6lhFkxvBDH1FoW6OU/L6prYwPSlwjLiZkExIAh8fbP6iI/M7iGTQAJPYJ9YFlWobCZ1PHbchFhFYb2ADkw==",
+ "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/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/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/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/framer-motion": {
+ "version": "12.34.3",
+ "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.34.3.tgz",
+ "integrity": "sha512-v81ecyZKYO/DfpTwHivqkxSUBzvceOpoI+wLfgCgoUIKxlFKEXdg0oR9imxwXumT4SFy8vRk9xzJ5l3/Du/55Q==",
+ "license": "MIT",
+ "dependencies": {
+ "motion-dom": "^12.34.3",
+ "motion-utils": "^12.29.2",
+ "tslib": "^2.4.0"
+ },
+ "peerDependencies": {
+ "@emotion/is-prop-valid": "*",
+ "react": "^18.0.0 || ^19.0.0",
+ "react-dom": "^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@emotion/is-prop-valid": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ },
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "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/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/motion-dom": {
+ "version": "12.34.3",
+ "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.34.3.tgz",
+ "integrity": "sha512-sYgFe+pR9aIM7o4fhs2aXtOI+oqlUd33N9Yoxcgo1Fv7M20sRkHtCmzE/VRNIcq7uNJ+qio+Xubt1FXH3pQ+eQ==",
+ "license": "MIT",
+ "dependencies": {
+ "motion-utils": "^12.29.2"
+ }
+ },
+ "node_modules/motion-utils": {
+ "version": "12.29.2",
+ "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.29.2.tgz",
+ "integrity": "sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A==",
+ "license": "MIT"
+ },
+ "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": "15.5.12",
+ "resolved": "https://registry.npmjs.org/next/-/next-15.5.12.tgz",
+ "integrity": "sha512-Fi/wQ4Etlrn60rz78bebG1i1SR20QxvV8tVp6iJspjLUSHcZoeUXCt+vmWoEcza85ElZzExK/jJ/F6SvtGktjA==",
+ "license": "MIT",
+ "dependencies": {
+ "@next/env": "15.5.12",
+ "@swc/helpers": "0.5.15",
+ "caniuse-lite": "^1.0.30001579",
+ "postcss": "8.4.31",
+ "styled-jsx": "5.1.6"
+ },
+ "bin": {
+ "next": "dist/bin/next"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^19.8.0 || >= 20.0.0"
+ },
+ "optionalDependencies": {
+ "@next/swc-darwin-arm64": "15.5.12",
+ "@next/swc-darwin-x64": "15.5.12",
+ "@next/swc-linux-arm64-gnu": "15.5.12",
+ "@next/swc-linux-arm64-musl": "15.5.12",
+ "@next/swc-linux-x64-gnu": "15.5.12",
+ "@next/swc-linux-x64-musl": "15.5.12",
+ "@next/swc-win32-arm64-msvc": "15.5.12",
+ "@next/swc-win32-x64-msvc": "15.5.12",
+ "sharp": "^0.34.3"
+ },
+ "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/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/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==",
+ "dev": true,
+ "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.1.0",
+ "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
+ "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "19.1.0",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
+ "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
+ "license": "MIT",
+ "dependencies": {
+ "scheduler": "^0.26.0"
+ },
+ "peerDependencies": {
+ "react": "^19.1.0"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.26.0",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz",
+ "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==",
+ "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..108c6eb
--- /dev/null
+++ b/package.json
@@ -0,0 +1,28 @@
+{
+ "name": "freddy-tmp",
+ "version": "0.1.0",
+ "private": true,
+ "scripts": {
+ "dev": "next dev --turbopack",
+ "build": "next build --turbopack",
+ "start": "next start"
+ },
+ "dependencies": {
+ "@dw/nextjs-admin-login": "file:/tmp/dw-nextjs-admin-login-0.2.0.tgz",
+ "@types/pg": "^8.16.0",
+ "framer-motion": "^12.34.3",
+ "lucide-react": "^0.575.0",
+ "next": "15.5.12",
+ "pg": "^8.19.0",
+ "react": "19.1.0",
+ "react-dom": "19.1.0"
+ },
+ "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..c7bcb4b
--- /dev/null
+++ b/postcss.config.mjs
@@ -0,0 +1,5 @@
+const config = {
+ plugins: ["@tailwindcss/postcss"],
+};
+
+export default config;
diff --git a/public/favicon.svg b/public/favicon.svg
new file mode 100644
index 0000000..66db7c6
--- /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="#d97706"/>
+<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/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..d8b9323
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,27 @@
+{
+ "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": "preserve",
+ "incremental": true,
+ "plugins": [
+ {
+ "name": "next"
+ }
+ ],
+ "paths": {
+ "@/*": ["./*"]
+ }
+ },
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
+ "exclude": ["node_modules"]
+}
(oldest)
·
back to Freddy
·
harden(auth): add scrypt + rate-limit + refuse-to-boot to /a 5430886 →