← back to Patty
initial scaffold (2026-05-06 overnight session)
244b86c2aaa82707e713f588e395cbe96947357c · 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/campaigns/generate/route.tsA app/api/campaigns/route.tsA app/api/grants-proxy/[...path]/route.tsA app/api/orbit/agents/route.tsA app/api/orbit/batch-link/route.tsA app/api/orbit/generate/route.tsA app/api/orbit/post/route.tsA app/api/orbit/route.tsA app/api/orbit/rss/route.tsA app/api/orbit/suggest-alliance/route.tsA app/api/petitions/[slug]/post-log/route.tsA app/api/petitions/[slug]/post/route.tsA app/api/petitions/generate/route.tsA app/api/petitions/route.tsA app/api/public/signatures/route.tsA app/api/pulse/[...path]/route.tsA app/api/settings/credentials/route.tsA app/api/settings/route.tsA app/api/signatures/route.tsA app/api/subscribers/route.tsA app/api/templates/route.tsA app/api/trending/discover/route.tsA app/api/trending/route.tsA app/favicon.icoA app/globals.cssA app/icon.svgA app/layout.tsxA app/login/page.tsxA app/page.tsxA app/petitions/[slug]/PostToPlatformsPanel.tsxA app/petitions/[slug]/ShareButtons.tsxA app/petitions/[slug]/SignatureForm.tsxA app/petitions/[slug]/page.tsxA components/AppShell.tsxA components/AuthProvider.tsxA components/Sidebar.tsxA components/Skeleton.tsxA components/ToastProvider.tsxA components/campaigns/CampaignsTab.tsxA components/compliance/ComplianceTab.tsxA components/dashboard/DashboardTab.tsxA components/grants/GrantsTab.tsxA components/grants/types.tsA components/heatmap/HeatMapTab.tsxA components/news/NewsFeedTab.tsxA components/orbit/OrbitTab.tsxA components/petitions/PetitionsTab.tsxA components/settings/SettingsTab.tsxA components/settings/SocialCredentialsSection.tsxA components/shared/SortDropdown.tsxA components/sources/SourcesTab.tsxA components/subscribers/SubscribersTab.tsxA components/templates/TemplatesTab.tsxA components/topics/TopicsTab.tsxA components/trends/TrendsTab.tsxA db/orbit-schema.sqlA db/orbit-seed-feeds.sqlA db/schema.sqlA ecosystem.config.jsA hooks/useClientSort.tsA hooks/useDebounce.tsA lib/audit.tsA lib/auth.tsA lib/db.tsA lib/gemini.tsA lib/sanitize.tsA lib/sister-auth.tsA middleware.tsA next.config.tsA package-lock.jsonA package.jsonA postcss.config.mjsA public/favicon.svgA public/file.svgA public/globe.svgA public/next.svgA public/vercel.svgA public/window.svgA tsconfig.json
Diff
commit 244b86c2aaa82707e713f588e395cbe96947357c
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 | 43 +
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/campaigns/generate/route.ts | 69 +
app/api/campaigns/route.ts | 131 ++
app/api/grants-proxy/[...path]/route.ts | 87 +
app/api/orbit/agents/route.ts | 133 ++
app/api/orbit/batch-link/route.ts | 168 ++
app/api/orbit/generate/route.ts | 236 +++
app/api/orbit/post/route.ts | 190 ++
app/api/orbit/route.ts | 158 ++
app/api/orbit/rss/route.ts | 295 +++
app/api/orbit/suggest-alliance/route.ts | 204 ++
app/api/petitions/[slug]/post-log/route.ts | 48 +
app/api/petitions/[slug]/post/route.ts | 251 +++
app/api/petitions/generate/route.ts | 75 +
app/api/petitions/route.ts | 232 +++
app/api/public/signatures/route.ts | 209 ++
app/api/pulse/[...path]/route.ts | 56 +
app/api/settings/credentials/route.ts | 146 ++
app/api/settings/route.ts | 86 +
app/api/signatures/route.ts | 106 +
app/api/subscribers/route.ts | 176 ++
app/api/templates/route.ts | 62 +
app/api/trending/discover/route.ts | 201 ++
app/api/trending/route.ts | 69 +
app/favicon.ico | Bin 0 -> 25931 bytes
app/globals.css | 365 ++++
app/icon.svg | 4 +
app/layout.tsx | 21 +
app/login/page.tsx | 161 ++
app/page.tsx | 5 +
app/petitions/[slug]/PostToPlatformsPanel.tsx | 646 ++++++
app/petitions/[slug]/ShareButtons.tsx | 143 ++
app/petitions/[slug]/SignatureForm.tsx | 285 +++
app/petitions/[slug]/page.tsx | 510 +++++
components/AppShell.tsx | 232 +++
components/AuthProvider.tsx | 132 ++
components/Sidebar.tsx | 311 +++
components/Skeleton.tsx | 49 +
components/ToastProvider.tsx | 94 +
components/campaigns/CampaignsTab.tsx | 482 +++++
components/compliance/ComplianceTab.tsx | 213 ++
components/dashboard/DashboardTab.tsx | 175 ++
components/grants/GrantsTab.tsx | 549 +++++
components/grants/types.ts | 51 +
components/heatmap/HeatMapTab.tsx | 709 +++++++
components/news/NewsFeedTab.tsx | 195 ++
components/orbit/OrbitTab.tsx | 2382 ++++++++++++++++++++++
components/petitions/PetitionsTab.tsx | 1101 ++++++++++
components/settings/SettingsTab.tsx | 413 ++++
components/settings/SocialCredentialsSection.tsx | 420 ++++
components/shared/SortDropdown.tsx | 41 +
components/sources/SourcesTab.tsx | 304 +++
components/subscribers/SubscribersTab.tsx | 446 ++++
components/templates/TemplatesTab.tsx | 395 ++++
components/topics/TopicsTab.tsx | 415 ++++
components/trends/TrendsTab.tsx | 534 +++++
db/orbit-schema.sql | 122 ++
db/orbit-seed-feeds.sql | 33 +
db/schema.sql | 159 ++
ecosystem.config.js | 15 +
hooks/useClientSort.ts | 34 +
hooks/useDebounce.ts | 10 +
lib/audit.ts | 36 +
lib/auth.ts | 10 +
lib/db.ts | 45 +
lib/gemini.ts | 111 +
lib/sanitize.ts | 66 +
lib/sister-auth.ts | 34 +
middleware.ts | 98 +
next.config.ts | 7 +
package-lock.json | 1997 ++++++++++++++++++
package.json | 29 +
postcss.config.mjs | 7 +
public/favicon.svg | 4 +
public/file.svg | 1 +
public/globe.svg | 1 +
public/next.svg | 1 +
public/vercel.svg | 1 +
public/window.svg | 1 +
tsconfig.json | 34 +
85 files changed, 18219 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..567c9e3
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,43 @@
+# Patty - The Petition Specialist
+
+## Project Info
+- **Directory**: /root/Projects/Patty/
+- **Port**: 7460 | **PM2**: patty-petitions
+- **Database**: connection string lives in `.env.local` (DATABASE_URL); use `dw_admin@127.0.0.1:5432/patty`. NEVER commit credentials to docs.
+- **Auth**: admin / (AUTH_PASSWORD env — see .env.local; no longer hardcoded)
+- **Stack**: Next.js 16 (App Router) + Tailwind 4 + PostgreSQL
+
+## Purpose
+AI-powered petition creation, advocacy campaigns, and signature collection platform.
+Features: trending topic discovery, petition templates, email campaigns, compliance tracking.
+
+## Key Files
+- lib/db.ts -- Postgres pool
+- lib/auth.ts -- Cookie auth (patty-auth cookie)
+- lib/audit.ts -- Audit logger
+- db/schema.sql -- Full schema
+
+## Database Tables
+- users -- User accounts (Google OAuth planned)
+- petitions -- Petition content and metadata
+- signatures -- Petition signatures with dedup
+- email_subscribers -- Email list management
+- email_campaigns -- Campaign tracking
+- trending_topics -- AI-curated trending topics
+- petition_templates -- Reusable petition templates
+- audit_events -- Activity log
+
+## Design System
+- Primary: #7c3aed (violet-600)
+- Primary Hover: #6d28d9 (violet-700)
+- Secondary: #a78bfa (violet-400)
+- Dark theme with --color-bg: #0a0a12
+
+## Sidebar Tabs
+1. My Petitions (Megaphone)
+2. Trending Topics (TrendingUp)
+3. Campaigns (Mail)
+4. Subscribers (Users)
+5. Templates (FileText)
+6. Compliance (Shield)
+7. Settings (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/campaigns/generate/route.ts b/app/api/campaigns/generate/route.ts
new file mode 100644
index 0000000..5ef1488
--- /dev/null
+++ b/app/api/campaigns/generate/route.ts
@@ -0,0 +1,69 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { query } from '@/lib/db';
+import { verifyAuth } from '@/lib/auth';
+import { callGemini } from '@/lib/gemini';
+
+type CampaignShape = { subject?: string; body_html?: string; body_text?: string };
+
+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 { petition_id, tone, call_to_action_type } = body;
+
+ let petitionContext = '';
+ if (petition_id) {
+ const petResult = await query(`SELECT title, summary, body_text, target, signature_count, signature_goal FROM petitions WHERE id = $1`, [petition_id]);
+ if (petResult.rows.length > 0) {
+ const p = petResult.rows[0];
+ petitionContext = `
+Petition Title: ${p.title}
+Petition Summary: ${p.summary || 'N/A'}
+Petition Target: ${p.target || 'N/A'}
+Current Signatures: ${p.signature_count} / ${p.signature_goal} goal
+Petition Body: ${(p.body_text || '').slice(0, 500)}`;
+ }
+ }
+
+ const toneDesc = tone || 'urgent';
+ const ctaType = call_to_action_type || 'sign';
+
+ const prompt = `You are an expert email copywriter for advocacy campaigns about student debt and education. Generate a compelling campaign email.
+
+${petitionContext ? `PETITION CONTEXT:${petitionContext}` : 'Generate a general student debt advocacy email.'}
+
+Tone: ${toneDesc}
+Call to Action: ${ctaType === 'sign' ? 'Sign the petition' : ctaType === 'share' ? 'Share with friends' : ctaType === 'donate' ? 'Support the cause' : 'Take action'}
+
+Return a JSON object with EXACTLY these fields:
+{
+ "subject": "Compelling email subject line (max 80 chars, no clickbait)",
+ "body_html": "<div style='font-family: sans-serif; max-width: 600px; margin: 0 auto;'>Full HTML email content with inline styles. Include greeting, context, emotional hook, call to action button, and signature. Use <a>, <strong>, <p>, <h2> tags. Include a prominent CTA button styled as: <a href='#sign' style='display:inline-block;padding:12px 32px;background:#7c3aed;color:white;text-decoration:none;border-radius:8px;font-weight:bold;'>Sign Now</a></div>",
+ "body_text": "Plain text version of the email for non-HTML clients"
+}
+
+Make it personal, urgent, and compelling. Reference specific statistics about student debt where relevant.`;
+
+ // 2026-05-05 (tick 29): migrated to lib/gemini.ts wrapper.
+ const r = await callGemini<CampaignShape>({ prompt, maxTokens: 2048, temperature: 0.8 });
+ if (!r.ok) {
+ console.error('[campaigns/generate] gemini failed:', r.reason, r.detail);
+ return NextResponse.json(
+ { error: r.reason === 'no_key' ? 'AI service not configured' : 'AI generation failed' },
+ { status: r.status },
+ );
+ }
+ const parsed = r.data;
+
+ return NextResponse.json({
+ subject: parsed.subject || '',
+ body_html: parsed.body_html || '',
+ body_text: parsed.body_text || '',
+ });
+ } catch (err) {
+ console.error('[api/campaigns/generate] Error:', (err as Error).message);
+ return NextResponse.json({ error: 'Failed to generate campaign content' }, { status: 500 });
+ }
+}
diff --git a/app/api/campaigns/route.ts b/app/api/campaigns/route.ts
new file mode 100644
index 0000000..aba33a4
--- /dev/null
+++ b/app/api/campaigns/route.ts
@@ -0,0 +1,131 @@
+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 });
+
+ const url = new URL(request.url);
+ const status = url.searchParams.get('status');
+
+ let sql = `SELECT c.*, p.title as petition_title
+ FROM email_campaigns c
+ LEFT JOIN petitions p ON c.petition_id = p.id`;
+ const params: unknown[] = [];
+
+ if (status && status !== 'all') {
+ params.push(status);
+ sql += ` WHERE c.status = $${params.length}`;
+ }
+
+ sql += ` ORDER BY c.created_at DESC`;
+
+ try {
+ const result = await query(sql, params);
+
+ // Get status counts
+ const countsResult = await query(
+ `SELECT status, COUNT(*)::int as count FROM email_campaigns GROUP BY status`
+ );
+ const statusCounts: Record<string, number> = { draft: 0, scheduled: 0, sending: 0, sent: 0, failed: 0 };
+ for (const row of countsResult.rows) {
+ statusCounts[row.status] = row.count;
+ }
+ statusCounts.all = Object.values(statusCounts).reduce((a, b) => a + b, 0);
+
+ return NextResponse.json({ rows: result.rows, statusCounts });
+ } catch (err) {
+ console.error('[api/campaigns] GET error:', (err as Error).message);
+ return NextResponse.json({ error: 'Failed to fetch campaigns' }, { 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 { subject, body_html, body_text, petition_id, send_to, scheduled_at } = body;
+
+ if (!subject || !body_html) {
+ return NextResponse.json({ error: 'Subject and body_html are required' }, { status: 400 });
+ }
+
+ // Get user_id
+ const userResult = await query(`SELECT id FROM users LIMIT 1`);
+ const userId = userResult.rows[0]?.id;
+ if (!userId) {
+ return NextResponse.json({ error: 'No user found' }, { status: 500 });
+ }
+
+ // Count subscribers for recipient_count
+ const subCount = await query(`SELECT COUNT(*)::int as count FROM email_subscribers WHERE is_active = true`);
+ const recipientCount = subCount.rows[0]?.count || 0;
+
+ const result = await query(
+ `INSERT INTO email_campaigns (user_id, petition_id, subject, body_html, body_text, send_to, recipient_count, scheduled_at, status)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
+ RETURNING *`,
+ [
+ userId,
+ petition_id || null,
+ subject,
+ body_html,
+ body_text || null,
+ send_to || 'all',
+ recipientCount,
+ scheduled_at || null,
+ scheduled_at ? 'scheduled' : 'draft',
+ ]
+ );
+
+ return NextResponse.json({ row: result.rows[0] }, { status: 201 });
+ } catch (err) {
+ console.error('[api/campaigns] POST error:', (err as Error).message);
+ return NextResponse.json({ error: 'Failed to create campaign' }, { 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, ...updates } = body;
+
+ if (!id) {
+ return NextResponse.json({ error: 'Campaign id is required' }, { status: 400 });
+ }
+
+ const allowedFields = ['subject', 'body_html', 'body_text', 'status', 'send_to', 'scheduled_at', 'sent_at', 'recipient_count', 'open_count', 'click_count'];
+ const setClauses: string[] = [];
+ const params: unknown[] = [];
+
+ for (const [key, value] of Object.entries(updates)) {
+ if (allowedFields.includes(key)) {
+ params.push(value);
+ setClauses.push(`${key} = $${params.length}`);
+ }
+ }
+
+ if (setClauses.length === 0) {
+ return NextResponse.json({ error: 'No valid fields to update' }, { status: 400 });
+ }
+
+ params.push(id);
+ const sql = `UPDATE email_campaigns SET ${setClauses.join(', ')} WHERE id = $${params.length} RETURNING *`;
+
+ const result = await query(sql, params);
+ if (result.rows.length === 0) {
+ return NextResponse.json({ error: 'Campaign not found' }, { status: 404 });
+ }
+
+ return NextResponse.json({ row: result.rows[0] });
+ } catch (err) {
+ console.error('[api/campaigns] PATCH error:', (err as Error).message);
+ return NextResponse.json({ error: 'Failed to update campaign' }, { status: 500 });
+ }
+}
diff --git a/app/api/grants-proxy/[...path]/route.ts b/app/api/grants-proxy/[...path]/route.ts
new file mode 100644
index 0000000..084a429
--- /dev/null
+++ b/app/api/grants-proxy/[...path]/route.ts
@@ -0,0 +1,87 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { verifyAuth } from '@/lib/auth';
+import { sisterCredentials } from '@/lib/sister-auth';
+
+const GRANT_BASE = 'http://127.0.0.1:7450';
+
+// Module-scoped session cache for Grant app cookie auth
+let grantCookie: string | null = null;
+let cookieExpiry = 0;
+
+async function getGrantAuth(): Promise<string> {
+ if (grantCookie && Date.now() < cookieExpiry) return grantCookie;
+
+ // Sister-service login creds from env (audit 2026-05-04: removed inline literal).
+ const res = await fetch(`${GRANT_BASE}/api/auth/login`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(sisterCredentials()),
+ });
+
+ const setCookie = res.headers.get('set-cookie') || '';
+ const match = setCookie.match(/grant-auth=([^;]+)/);
+ if (!match) throw new Error('Grant auth failed');
+
+ grantCookie = `grant-auth=${match[1]}`;
+ cookieExpiry = Date.now() + 23 * 60 * 60 * 1000; // 23h
+ return grantCookie;
+}
+
+async function proxyToGrant(request: NextRequest, params: Promise<{ path: string[] }>, method: string) {
+ const user = verifyAuth(request);
+ if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+ const { path } = await params;
+ const pathStr = path.join('/');
+ const url = new URL(request.url);
+ const qs = url.search;
+
+ try {
+ let cookie = await getGrantAuth();
+ const headers: Record<string, string> = { Cookie: cookie };
+ let body: string | undefined;
+
+ if (method === 'POST' || method === 'PATCH') {
+ headers['Content-Type'] = 'application/json';
+ body = await request.text();
+ }
+
+ let res = await fetch(`${GRANT_BASE}/api/${pathStr}${qs}`, {
+ method, headers, body,
+ signal: AbortSignal.timeout(30000),
+ });
+
+ // Re-auth on 401
+ if (res.status === 401) {
+ grantCookie = null;
+ cookieExpiry = 0;
+ cookie = await getGrantAuth();
+ headers.Cookie = cookie;
+ res = await fetch(`${GRANT_BASE}/api/${pathStr}${qs}`, {
+ method, headers, body,
+ signal: AbortSignal.timeout(30000),
+ });
+ }
+
+ const data = await res.json();
+ return NextResponse.json(data, { status: res.status });
+ } catch {
+ return NextResponse.json({ error: 'Grant service unavailable' }, { status: 502 });
+ }
+}
+
+export async function GET(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
+ return proxyToGrant(req, params, 'GET');
+}
+
+export async function POST(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
+ return proxyToGrant(req, params, 'POST');
+}
+
+export async function PATCH(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
+ return proxyToGrant(req, params, 'PATCH');
+}
+
+export async function DELETE(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
+ return proxyToGrant(req, params, 'DELETE');
+}
diff --git a/app/api/orbit/agents/route.ts b/app/api/orbit/agents/route.ts
new file mode 100644
index 0000000..a253060
--- /dev/null
+++ b/app/api/orbit/agents/route.ts
@@ -0,0 +1,133 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { verifyAuth } from '@/lib/auth';
+import { SISTER_AUTH, SISTER_OK, sisterUnconfigured } from '@/lib/sister-auth';
+
+const AGENTS = [
+ { name: 'Phoebe', role: 'Figma QA', port: 9668, health: '/health' },
+ { name: 'Sandy', role: 'Scene Assembler', port: 9652, health: '/health' },
+ { name: 'Sherman', role: 'Material Librarian', port: 9650, health: '/health' },
+ { name: 'Sheldon', role: 'Performance Auditor', port: 9651, health: '/health' },
+ { name: 'Shane', role: 'Asset Validator', port: 9653, health: '/health' },
+ { name: 'Graphic', role: 'Chart Generator', port: 9846, health: '/health' },
+ { name: 'Pulse', role: 'News Intelligence', port: 9845, health: '/health' },
+];
+
+async function checkAgent(agent: typeof AGENTS[0]): Promise<{ name: string; role: string; port: number; status: string; uptime?: number }> {
+ try {
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), 3000);
+ const res = await fetch(`http://127.0.0.1:${agent.port}${agent.health}`, {
+ signal: controller.signal,
+ headers: { 'Authorization': SISTER_AUTH },
+ });
+ clearTimeout(timeout);
+ if (res.ok) {
+ const data = await res.json().catch(() => ({}));
+ return { name: agent.name, role: agent.role, port: agent.port, status: 'online', uptime: data.uptime };
+ }
+ return { name: agent.name, role: agent.role, port: agent.port, status: 'error' };
+ } catch {
+ return { name: agent.name, role: agent.role, port: agent.port, status: 'offline' };
+ }
+}
+
+export async function GET(request: NextRequest) {
+ const user = verifyAuth(request);
+ if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ if (!SISTER_OK) return sisterUnconfigured();
+
+ const results = await Promise.all(AGENTS.map(checkAgent));
+ const online = results.filter(r => r.status === 'online').length;
+
+ return NextResponse.json({
+ agents: results,
+ summary: { total: AGENTS.length, online, offline: AGENTS.length - online },
+ });
+}
+
+export async function POST(request: NextRequest) {
+ const user = verifyAuth(request);
+ if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ if (!SISTER_OK) return sisterUnconfigured();
+
+ try {
+ const body = await request.json();
+ const { action, agent_name } = body as { action: string; agent_name?: string };
+
+ if (action === 'screenshot' || action === 'phoebe/screenshot') {
+ const res = await fetch('http://127.0.0.1:9668/api/screenshot', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': SISTER_AUTH,
+ },
+ body: JSON.stringify({ url: 'http://45.61.58.125:7460', viewport: { width: 1920, height: 1080 } }),
+ });
+ if (res.ok) {
+ const data = await res.json();
+ return NextResponse.json({ success: true, action: 'screenshot', data });
+ }
+ return NextResponse.json({ error: 'Screenshot failed' }, { status: 502 });
+ }
+
+ if (action === 'gif' || action === 'phoebe/gif') {
+ const res = await fetch('http://127.0.0.1:9668/api/record-gif', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': SISTER_AUTH,
+ },
+ body: JSON.stringify({ url: 'http://45.61.58.125:7460', frames: 15, delay: 500 }),
+ });
+ if (res.ok) {
+ const data = await res.json();
+ return NextResponse.json({ success: true, action: 'gif', data });
+ }
+ return NextResponse.json({ error: 'GIF recording failed' }, { status: 502 });
+ }
+
+ if (action === 'perf' || action === 'sheldon/audit') {
+ const res = await fetch('http://127.0.0.1:9651/api/perf/audit', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': SISTER_AUTH,
+ },
+ });
+ if (res.ok) {
+ const data = await res.json();
+ return NextResponse.json({ success: true, action: 'perf_audit', data });
+ }
+ return NextResponse.json({ error: 'Performance audit failed' }, { status: 502 });
+ }
+
+ if (action === 'chart/signatures') {
+ const { petition_id } = body;
+ if (!petition_id) return NextResponse.json({ error: 'petition_id required' }, { status: 400 });
+ const res = await fetch(`http://127.0.0.1:9846/api/chart/signature-progress?petition_id=${petition_id}`, {
+ headers: { 'Authorization': SISTER_AUTH },
+ });
+ if (res.ok) {
+ const svg = await res.text();
+ return new NextResponse(svg, { headers: { 'Content-Type': 'image/svg+xml' } });
+ }
+ return NextResponse.json({ error: 'Chart generation failed' }, { status: 502 });
+ }
+
+ if (action === 'chart/sentiment') {
+ const res = await fetch('http://127.0.0.1:9846/api/chart/sentiment-map', {
+ headers: { 'Authorization': SISTER_AUTH },
+ });
+ if (res.ok) {
+ const svg = await res.text();
+ return new NextResponse(svg, { headers: { 'Content-Type': 'image/svg+xml' } });
+ }
+ return NextResponse.json({ error: 'Sentiment map failed' }, { status: 502 });
+ }
+
+ return NextResponse.json({ error: `Unknown action: ${action}` }, { status: 400 });
+ } catch (err) {
+ console.error('[api/orbit/agents] Error:', (err as Error).message);
+ return NextResponse.json({ error: 'Agent action failed' }, { status: 500 });
+ }
+}
diff --git a/app/api/orbit/batch-link/route.ts b/app/api/orbit/batch-link/route.ts
new file mode 100644
index 0000000..0b092c5
--- /dev/null
+++ b/app/api/orbit/batch-link/route.ts
@@ -0,0 +1,168 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { query } from '@/lib/db';
+import { verifyAuth } from '@/lib/auth';
+import { callGemini } from '@/lib/gemini';
+
+export async function POST(request: NextRequest) {
+ const user = verifyAuth(request);
+ if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+ try {
+ // Fetch top markets, petitions, and trending topics
+ const [marketsRes, petitionsRes, trendingRes] = await Promise.all([
+ query(`SELECT id, ticker, title, category, yes_bid, volume, petition_relevance
+ FROM orbit_markets ORDER BY volume DESC NULLS LAST LIMIT 25`),
+ query(`SELECT id, title, category, status FROM petitions ORDER BY created_at DESC LIMIT 20`),
+ query(`SELECT id, title, content, category, tags FROM trending_topics ORDER BY engagement_score DESC LIMIT 15`),
+ ]);
+
+ const markets = marketsRes.rows;
+ const petitions = petitionsRes.rows;
+ const trending = trendingRes.rows;
+
+ if (markets.length === 0) {
+ return NextResponse.json({ error: 'No markets available to link' }, { status: 400 });
+ }
+
+ const marketSummary = markets.map((m, i) =>
+ `M${i + 1}. [${m.ticker}] "${m.title}" (${m.category}, ${m.yes_bid}% yes, vol=${m.volume})`
+ ).join('\n');
+
+ const petitionSummary = petitions.length > 0
+ ? petitions.map((p, i) => `P${i + 1}. "${p.title}" (${p.category}, ${p.status})`).join('\n')
+ : 'No petitions yet.';
+
+ const trendingSummary = trending.length > 0
+ ? trending.map((t, i) => `T${i + 1}. "${t.title}" — ${(t.content || '').substring(0, 100)}`).join('\n')
+ : 'No trending topics.';
+
+ const prompt = `You are an AI analyst connecting prediction markets, petitions, and trending topics for an advocacy platform.
+
+MARKETS:
+${marketSummary}
+
+PETITIONS:
+${petitionSummary}
+
+TRENDING TOPICS:
+${trendingSummary}
+
+Generate connections between these items. For each connection, provide:
+1. market_index (M1, M2...) and petition_index (P1, P2...) OR trending_index (T1, T2...)
+2. link_type: "influence" (market affects petition), "counter" (opposing), "support" (aligned), or "alliance" (unexpected connection)
+3. strength: 0.0 to 1.0 (how strong is the connection)
+4. ai_reasoning: one sentence explaining the connection
+
+Also suggest NEW petition ideas from trending topics that don't have petitions yet.
+
+Return a JSON object:
+{
+ "links": [
+ { "market_idx": "M1", "petition_idx": "P1", "link_type": "influence", "strength": 0.8, "ai_reasoning": "..." },
+ { "market_idx": "M3", "trending_idx": "T2", "link_type": "support", "strength": 0.7, "ai_reasoning": "..." }
+ ],
+ "suggested_petitions": [
+ { "trending_idx": "T1", "title": "...", "summary": "...", "suggested_target": "...", "category": "..." }
+ ]
+}
+
+Generate 15-25 links and 3-5 petition suggestions. Only include strong, meaningful connections (strength > 0.4).
+Return ONLY valid JSON, no markdown.`;
+
+ type BatchLinkResult = {
+ links: Array<{
+ market_idx: string;
+ petition_idx?: string;
+ trending_idx?: string;
+ link_type: string;
+ strength: number;
+ ai_reasoning: string;
+ }>;
+ suggested_petitions: Array<{
+ trending_idx: string;
+ title: string;
+ summary: string;
+ suggested_target: string;
+ category: string;
+ }>;
+ };
+
+ // 2026-05-05 (tick 29): migrated to lib/gemini.ts wrapper.
+ const r = await callGemini<BatchLinkResult>({ prompt, maxTokens: 4096, temperature: 0.7 });
+ if (!r.ok) {
+ console.error('[batch-link] gemini failed:', r.reason, r.detail);
+ return NextResponse.json(
+ { error: r.reason === 'no_key' ? 'AI service not configured' : 'AI analysis failed' },
+ { status: r.status },
+ );
+ }
+ const result = r.data;
+
+ // Insert market-petition links
+ let linksInserted = 0;
+ for (const link of (result.links || [])) {
+ const mIdx = parseInt(link.market_idx?.replace('M', '')) - 1;
+ const market = markets[mIdx];
+ if (!market) continue;
+
+ // Link to petition
+ if (link.petition_idx) {
+ const pIdx = parseInt(link.petition_idx.replace('P', '')) - 1;
+ const petition = petitions[pIdx];
+ if (!petition) continue;
+
+ try {
+ await query(`
+ INSERT INTO orbit_links (market_id, petition_id, link_type, strength, ai_reasoning)
+ VALUES ($1, $2, $3, $4, $5)
+ ON CONFLICT (market_id, petition_id) DO UPDATE SET
+ link_type = $3, strength = $4, ai_reasoning = $5
+ `, [market.id, petition.id, link.link_type || 'influence', link.strength || 0.5, link.ai_reasoning || '']);
+ linksInserted++;
+ } catch (e) {
+ console.error('[api/orbit/batch-link] Link insert error:', (e as Error).message);
+ }
+ }
+ }
+
+ // Create suggested petitions from trending topics
+ let petitionsCreated = 0;
+ for (const sp of (result.suggested_petitions || [])) {
+ const tIdx = parseInt(sp.trending_idx?.replace('T', '')) - 1;
+ const topic = trending[tIdx];
+ if (!topic || !sp.title) continue;
+
+ try {
+ await query(`
+ INSERT INTO petitions (title, summary, category, status, target_name)
+ VALUES ($1, $2, $3, 'draft', $4)
+ `, [sp.title, sp.summary || '', sp.category || 'policy', sp.suggested_target || '']);
+ petitionsCreated++;
+ } catch (e) {
+ console.error('[api/orbit/batch-link] Petition insert error:', (e as Error).message);
+ }
+ }
+
+ // Update petition_relevance on markets
+ for (const link of (result.links || [])) {
+ const mIdx = parseInt(link.market_idx?.replace('M', '')) - 1;
+ const market = markets[mIdx];
+ if (!market) continue;
+ const relevance = Math.round((link.strength || 0.5) * 100);
+ try {
+ await query(`UPDATE orbit_markets SET petition_relevance = GREATEST(petition_relevance, $1) WHERE id = $2`,
+ [relevance, market.id]);
+ } catch { /* ignore */ }
+ }
+
+ return NextResponse.json({
+ links_inserted: linksInserted,
+ petitions_created: petitionsCreated,
+ total_links_analyzed: (result.links || []).length,
+ total_suggestions: (result.suggested_petitions || []).length,
+ });
+ } catch (err) {
+ console.error('[api/orbit/batch-link] Error:', (err as Error).message);
+ return NextResponse.json({ error: 'Failed to generate links' }, { status: 500 });
+ }
+}
diff --git a/app/api/orbit/generate/route.ts b/app/api/orbit/generate/route.ts
new file mode 100644
index 0000000..93fbab1
--- /dev/null
+++ b/app/api/orbit/generate/route.ts
@@ -0,0 +1,236 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { query } from '@/lib/db';
+import { verifyAuth } from '@/lib/auth';
+import { auditLog } from '@/lib/audit';
+import { callGemini } from '@/lib/gemini';
+
+/**
+ * POST /api/orbit/generate
+ * AI-generate a petition from a Kalshi prediction market OR a Pulse topic title.
+ * Accepts either { market_ticker } or { topic_title } in the body.
+ */
+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 { market_ticker, topic_title, tone, category } = body;
+
+ if (!market_ticker && !topic_title) {
+ return NextResponse.json({ error: 'market_ticker or topic_title is required' }, { status: 400 });
+ }
+
+ const toneDesc = tone || 'urgent';
+ let prompt: string;
+ let market: any = null;
+ let aiSource: string;
+ let categoryDesc: string;
+ let aiTopicData: Record<string, unknown>;
+
+ if (market_ticker) {
+ // ── Market-based generation (original path) ──
+ const marketRes = await query(`SELECT * FROM orbit_markets WHERE ticker = $1`, [market_ticker]);
+ if (marketRes.rows.length === 0) {
+ return NextResponse.json({ error: 'Market not found. Sync markets first via GET /api/orbit' }, { status: 404 });
+ }
+ market = marketRes.rows[0];
+ categoryDesc = category || market.category || 'policy';
+ aiSource = 'kalshi';
+ aiTopicData = {
+ market_ticker: market.ticker,
+ market_title: market.title,
+ market_category: market.category,
+ yes_bid: market.yes_bid,
+ last_price: market.last_price,
+ volume: market.volume,
+ generated_at: new Date().toISOString(),
+ };
+
+ prompt = `You are a professional petition writer for a civic engagement platform. Your task is to create a compelling petition inspired by a Kalshi prediction market.
+
+PREDICTION MARKET DATA:
+- Ticker: ${market.ticker}
+- Title: ${market.title}
+- Subtitle: ${market.subtitle || 'N/A'}
+- Category: ${market.category || 'General'}
+- Current YES price: ${market.yes_bid || 'N/A'}c (bid) / ${market.yes_ask || 'N/A'}c (ask)
+- Current NO price: ${market.no_bid || 'N/A'}c (bid) / ${market.no_ask || 'N/A'}c (ask)
+- Last trade price: ${market.last_price || 'N/A'}c
+- Volume: ${market.volume || 'N/A'} contracts
+- Open Interest: ${market.open_interest || 'N/A'}
+
+The prediction market prices represent the market's assessment of the probability of this event occurring (price in cents = probability percentage).
+
+PETITION REQUIREMENTS:
+- Tone: ${toneDesc} (${toneDesc === 'urgent' ? 'time-sensitive, action-oriented' : toneDesc === 'formal' ? 'professional, data-driven' : toneDesc === 'passionate' ? 'emotional, empathetic' : 'practical, solution-focused'})
+- Category: ${categoryDesc}
+- The petition should advocate for a specific policy action or demand related to the market's subject
+- Use the market probability to frame urgency (e.g., if market says 80% chance of something bad, that urgency should be reflected)
+- Do NOT mention Kalshi, prediction markets, or betting in the petition text itself
+- Make it feel like a real grassroots petition
+
+Return a JSON object with EXACTLY these fields:
+{
+ "title": "Compelling petition title (max 100 chars)",
+ "summary": "2-3 sentence hook that motivates signatures (max 300 chars)",
+ "body_html": "<p>Full petition body in HTML. 4-6 paragraphs. Include specific demands, context, urgency, and a call to action. Use <strong>, <em>, <ul>, <li> tags.</p>",
+ "body_text": "Plain text version of the body",
+ "suggested_target": "The specific person, office, or body to address this petition to",
+ "suggested_tags": ["array", "of", "3-5", "relevant", "tags"],
+ "suggested_goal": 1000,
+ "influence_analysis": "2-3 sentences explaining how this petition, if successful, could influence the outcome that the prediction market is tracking. Be specific about the causal chain."
+}`;
+ } else {
+ // ── Topic-based generation (from Pulse of America) ──
+ categoryDesc = category || 'policy';
+ aiSource = 'pulse';
+ aiTopicData = {
+ topic_title: topic_title,
+ source: 'pulse-agent',
+ generated_at: new Date().toISOString(),
+ };
+
+ prompt = `You are a professional petition writer for a civic engagement platform. Your task is to create a compelling petition based on a trending news topic.
+
+TRENDING TOPIC:
+"${topic_title}"
+
+This topic was identified by our Pulse of America news intelligence system as a high-interest issue currently in the public discourse.
+
+PETITION REQUIREMENTS:
+- Tone: ${toneDesc} (${toneDesc === 'urgent' ? 'time-sensitive, action-oriented' : toneDesc === 'formal' ? 'professional, data-driven' : toneDesc === 'passionate' ? 'emotional, empathetic' : 'practical, solution-focused'})
+- Category: ${categoryDesc}
+- The petition should advocate for a specific policy action or demand related to this topic
+- Frame urgency based on the current news cycle — this is trending NOW
+- Make it feel like a real grassroots petition that a concerned citizen would write
+- Include specific, actionable demands (not vague calls for "awareness")
+
+Return a JSON object with EXACTLY these fields:
+{
+ "title": "Compelling petition title (max 100 chars)",
+ "summary": "2-3 sentence hook that motivates signatures (max 300 chars)",
+ "body_html": "<p>Full petition body in HTML. 4-6 paragraphs. Include specific demands, context, urgency, and a call to action. Use <strong>, <em>, <ul>, <li> tags.</p>",
+ "body_text": "Plain text version of the body",
+ "suggested_target": "The specific person, office, or body to address this petition to",
+ "suggested_tags": ["array", "of", "3-5", "relevant", "tags"],
+ "suggested_goal": 5000,
+ "influence_analysis": "2-3 sentences explaining the potential impact of this petition on the issue. Be specific about what policy change or public outcome it could drive."
+}`;
+ }
+
+ // 2026-05-05 (tick 29): migrated to lib/gemini.ts wrapper.
+ const r = await callGemini<Record<string, unknown>>({ prompt, maxTokens: 4096, temperature: 0.8, timeoutMs: 30_000 });
+ if (!r.ok) {
+ console.error('[orbit/generate] gemini failed:', r.reason, r.detail);
+ return NextResponse.json(
+ { error: r.reason === 'no_key' ? 'AI service not configured' : 'AI generation failed' },
+ { status: r.status },
+ );
+ }
+ const parsed = r.data as Record<string, unknown>;
+
+ // Create the petition in the petitions table
+ const fallbackTitle = market ? market.title : topic_title;
+ const title = String(parsed.title || `Petition: ${fallbackTitle}`);
+ let slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
+
+ // Check for duplicate slug
+ const existingSlug = await query(`SELECT id FROM petitions WHERE slug = $1`, [slug]);
+ if (existingSlug.rows.length > 0) {
+ slug = `${slug}-${Date.now().toString(36)}`;
+ }
+
+ // Get user_id
+ const userResult = await query(`SELECT id FROM users LIMIT 1`);
+ const userId = userResult.rows[0]?.id;
+ if (!userId) {
+ return NextResponse.json({ error: 'No user found in system' }, { status: 500 });
+ }
+
+ const petitionResult = await query(
+ `INSERT INTO petitions (user_id, title, slug, summary, body_html, body_text, target, category, tags,
+ signature_goal, ai_source, ai_topic_data, status)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
+ RETURNING *`,
+ [
+ userId,
+ title,
+ slug,
+ parsed.summary || null,
+ parsed.body_html || '<p>Generated petition content</p>',
+ parsed.body_text || null,
+ parsed.suggested_target || null,
+ categoryDesc,
+ // Normalize tags: ensure clean string array for TEXT[] column
+ Array.isArray(parsed.suggested_tags)
+ ? parsed.suggested_tags.map((t: string) => String(t).replace(/^["'{]+|["'}]+$/g, '').trim()).filter(Boolean)
+ : typeof parsed.suggested_tags === 'string'
+ ? parsed.suggested_tags.replace(/^\[|\]$/g, '').split(',').map((t: string) => t.replace(/^["'{]+|["'}]+$/g, '').trim()).filter(Boolean)
+ : null,
+ parsed.suggested_goal || 5000,
+ aiSource,
+ JSON.stringify(aiTopicData),
+ 'draft',
+ ],
+ );
+ const petition = petitionResult.rows[0];
+
+ // Create orbit_link if we have a market
+ let link = null;
+ if (market) {
+ const linkResult = await query(
+ `INSERT INTO orbit_links (market_id, petition_id, link_type, strength, ai_reasoning)
+ VALUES ($1, $2, $3, $4, $5)
+ ON CONFLICT (market_id, petition_id) DO UPDATE SET
+ ai_reasoning = EXCLUDED.ai_reasoning,
+ strength = EXCLUDED.strength
+ RETURNING *`,
+ [
+ market.id,
+ petition.id,
+ 'influence',
+ 0.8,
+ parsed.influence_analysis || 'AI-generated petition from prediction market data.',
+ ],
+ );
+ link = linkResult.rows[0];
+
+ // Update market petition_relevance and suggested_petition
+ await query(
+ `UPDATE orbit_markets SET
+ petition_relevance = GREATEST(petition_relevance, 75),
+ suggested_petition = $2,
+ suggested_target = $3
+ WHERE id = $1`,
+ [market.id, title, parsed.suggested_target || null],
+ );
+ }
+
+ await auditLog('orbit.petition_generated', 'petition', petition.id, {
+ source: aiSource,
+ market_ticker: market?.ticker || null,
+ topic_title: topic_title || null,
+ tone: toneDesc,
+ category: categoryDesc,
+ });
+
+ return NextResponse.json({
+ petition,
+ link,
+ influence_analysis: parsed.influence_analysis || '',
+ source: aiSource,
+ ...(market ? {
+ market: {
+ ticker: market.ticker,
+ title: market.title,
+ category: market.category,
+ last_price: market.last_price,
+ },
+ } : {}),
+ });
+ } catch (err) {
+ console.error('[api/orbit/generate] Error:', (err as Error).message);
+ return NextResponse.json({ error: 'Failed to generate petition from market' }, { status: 500 });
+ }
+}
diff --git a/app/api/orbit/post/route.ts b/app/api/orbit/post/route.ts
new file mode 100644
index 0000000..ebe5d0a
--- /dev/null
+++ b/app/api/orbit/post/route.ts
@@ -0,0 +1,190 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { query } from '@/lib/db';
+import { verifyAuth } from '@/lib/auth';
+import { auditLog } from '@/lib/audit';
+
+// Platform creation URLs and formatting templates
+const PLATFORM_CONFIG: Record<string, { creation_url: string; name: string }> = {
+ moveon: {
+ creation_url: 'https://sign.moveon.org/petitions/new',
+ name: 'MoveOn',
+ },
+ change_org: {
+ creation_url: 'https://www.change.org/start-a-petition',
+ name: 'Change.org',
+ },
+ we_the_people: {
+ creation_url: 'https://petitions.whitehouse.gov/petition/create',
+ name: 'We The People',
+ },
+ custom: {
+ creation_url: '',
+ name: 'Custom Platform',
+ },
+};
+
+/**
+ * Format petition content for MoveOn
+ */
+function formatForMoveOn(petition: any): Record<string, unknown> {
+ return {
+ title: petition.title,
+ target: petition.target || 'U.S. Congress',
+ description: petition.body_text || petition.body_html?.replace(/<[^>]+>/g, '') || '',
+ category: petition.category || 'Other',
+ // MoveOn-specific fields
+ why_important: petition.summary || '',
+ suggested_tags: petition.tags || [],
+ signature_goal: petition.signature_goal || 1000,
+ };
+}
+
+/**
+ * Format petition content for Change.org
+ */
+function formatForChangeOrg(petition: any): Record<string, unknown> {
+ return {
+ title: petition.title,
+ decision_maker: petition.target || 'Congress',
+ petition_body: petition.body_text || petition.body_html?.replace(/<[^>]+>/g, '') || '',
+ // Change.org uses different field names
+ letter_body: petition.body_html || '',
+ tags: petition.tags || [],
+ category: petition.category || 'Government & Politics',
+ goal: petition.signature_goal || 1000,
+ image_url: petition.image_url || null,
+ };
+}
+
+/**
+ * Format petition content for We The People
+ */
+function formatForWeThePeople(petition: any): Record<string, unknown> {
+ // We The People has a 800-character limit for the body
+ const bodyText = petition.body_text || petition.body_html?.replace(/<[^>]+>/g, '') || '';
+ return {
+ title: petition.title.substring(0, 120),
+ body: bodyText.substring(0, 800),
+ // Must be addressed to the Administration
+ issues: petition.tags?.slice(0, 3) || [],
+ };
+}
+
+/**
+ * GET /api/orbit/post
+ * List cross-platform posts for a petition
+ */
+export async function GET(request: NextRequest) {
+ const user = verifyAuth(request);
+ if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+ try {
+ const url = new URL(request.url);
+ const petitionId = url.searchParams.get('petition_id');
+
+ let sql = `SELECT op.*, p.title as petition_title
+ FROM orbit_posts op
+ JOIN petitions p ON op.petition_id = p.id`;
+ const params: unknown[] = [];
+
+ if (petitionId) {
+ params.push(petitionId);
+ sql += ` WHERE op.petition_id = $1`;
+ }
+
+ sql += ` ORDER BY op.created_at DESC`;
+ const result = await query(sql, params);
+
+ return NextResponse.json({ posts: result.rows });
+ } catch (err) {
+ console.error('[api/orbit/post] GET error:', (err as Error).message);
+ return NextResponse.json({ error: 'Failed to fetch posts' }, { status: 500 });
+ }
+}
+
+/**
+ * POST /api/orbit/post
+ * Create a cross-platform petition post
+ * Since we cannot directly POST to MoveOn/Change.org APIs (requires OAuth/partner access),
+ * we generate formatted content and provide a creation URL + copy-ready content.
+ */
+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 { petition_id, platform, custom_url } = body;
+
+ if (!petition_id || !platform) {
+ return NextResponse.json({ error: 'petition_id and platform are required' }, { status: 400 });
+ }
+
+ const validPlatforms = Object.keys(PLATFORM_CONFIG);
+ if (!validPlatforms.includes(platform)) {
+ return NextResponse.json(
+ { error: `Invalid platform. Must be one of: ${validPlatforms.join(', ')}` },
+ { status: 400 },
+ );
+ }
+
+ // Fetch the petition
+ const petitionRes = await query(`SELECT * FROM petitions WHERE id = $1`, [petition_id]);
+ if (petitionRes.rows.length === 0) {
+ return NextResponse.json({ error: 'Petition not found' }, { status: 404 });
+ }
+ const petition = petitionRes.rows[0];
+
+ // Generate platform-specific formatted content
+ let formattedContent: Record<string, unknown>;
+ switch (platform) {
+ case 'moveon':
+ formattedContent = formatForMoveOn(petition);
+ break;
+ case 'change_org':
+ formattedContent = formatForChangeOrg(petition);
+ break;
+ case 'we_the_people':
+ formattedContent = formatForWeThePeople(petition);
+ break;
+ case 'custom':
+ formattedContent = {
+ title: petition.title,
+ body: petition.body_text || petition.body_html?.replace(/<[^>]+>/g, '') || '',
+ target: petition.target,
+ url: custom_url || '',
+ };
+ break;
+ default:
+ formattedContent = { title: petition.title };
+ }
+
+ const platformCfg = PLATFORM_CONFIG[platform];
+ const externalUrl = custom_url || platformCfg.creation_url;
+
+ // Save the post record
+ const result = await query(
+ `INSERT INTO orbit_posts (petition_id, platform, external_url, status, post_data)
+ VALUES ($1, $2, $3, $4, $5)
+ RETURNING *`,
+ [petition_id, platform, externalUrl, 'draft', JSON.stringify(formattedContent)],
+ );
+
+ await auditLog('orbit.post_created', 'orbit_post', result.rows[0].id, {
+ petition_id,
+ platform,
+ petition_title: petition.title,
+ });
+
+ return NextResponse.json({
+ post: result.rows[0],
+ creation_url: externalUrl,
+ platform_name: platformCfg.name,
+ formatted_content: formattedContent,
+ instructions: `Copy the formatted content below and paste it into the ${platformCfg.name} petition creation form at: ${externalUrl}`,
+ });
+ } catch (err) {
+ console.error('[api/orbit/post] POST error:', (err as Error).message);
+ return NextResponse.json({ error: 'Failed to create post' }, { status: 500 });
+ }
+}
diff --git a/app/api/orbit/route.ts b/app/api/orbit/route.ts
new file mode 100644
index 0000000..187669c
--- /dev/null
+++ b/app/api/orbit/route.ts
@@ -0,0 +1,158 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { query } from '@/lib/db';
+import { verifyAuth } from '@/lib/auth';
+import { auditLog } from '@/lib/audit';
+
+const KEN_API = 'http://127.0.0.1:7810/api/markets';
+
+/**
+ * GET /api/orbit
+ * Orbit dashboard: sync Kalshi markets, return markets + petitions + links + stats
+ */
+export async function GET(request: NextRequest) {
+ const user = verifyAuth(request);
+ if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+ try {
+ // 1. Fetch fresh markets from Ken/Kalshi agent
+ let kenMarkets: any[] = [];
+ try {
+ const kenRes = await fetch(KEN_API, { signal: AbortSignal.timeout(8000) });
+ if (kenRes.ok) {
+ const kenData = await kenRes.json();
+ kenMarkets = kenData.markets || [];
+ } else {
+ console.warn('[orbit] Ken API returned', kenRes.status);
+ }
+ } catch (err) {
+ console.warn('[orbit] Ken API unreachable:', (err as Error).message);
+ }
+
+ // 2. Upsert markets into orbit_markets
+ let upsertCount = 0;
+ for (const m of kenMarkets) {
+ try {
+ await query(
+ `INSERT INTO orbit_markets (ticker, event_ticker, title, subtitle, category,
+ yes_bid, yes_ask, no_bid, no_ask, last_price, volume, volume_24h, open_interest,
+ raw_data, last_synced_at)
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,NOW())
+ ON CONFLICT (ticker) DO UPDATE SET
+ event_ticker = EXCLUDED.event_ticker,
+ title = EXCLUDED.title,
+ subtitle = EXCLUDED.subtitle,
+ category = EXCLUDED.category,
+ yes_bid = EXCLUDED.yes_bid,
+ yes_ask = EXCLUDED.yes_ask,
+ no_bid = EXCLUDED.no_bid,
+ no_ask = EXCLUDED.no_ask,
+ last_price = EXCLUDED.last_price,
+ volume = EXCLUDED.volume,
+ volume_24h = EXCLUDED.volume_24h,
+ open_interest = EXCLUDED.open_interest,
+ raw_data = EXCLUDED.raw_data,
+ last_synced_at = NOW(),
+ updated_at = NOW()`,
+ [
+ m.ticker,
+ m.event_ticker || null,
+ m.title,
+ m.subtitle || null,
+ m.category || null,
+ m.yes_bid ?? null,
+ m.yes_ask ?? null,
+ m.no_bid ?? null,
+ m.no_ask ?? null,
+ m.last_price ?? null,
+ m.volume ? parseInt(m.volume, 10) : null,
+ m.volume_24h ? parseInt(m.volume_24h, 10) : null,
+ m.open_interest ? parseInt(m.open_interest, 10) : null,
+ JSON.stringify(m),
+ ],
+ );
+ upsertCount++;
+ } catch (err) {
+ console.error('[orbit] Upsert failed for', m.ticker, (err as Error).message);
+ }
+ }
+
+ // 3. Fetch data for response
+ const [marketsRes, petitionsRes, linksRes, feedsRes, articlesRes, trendingRes] = await Promise.all([
+ query(`SELECT * FROM orbit_markets ORDER BY petition_relevance DESC, last_synced_at DESC LIMIT 200`),
+ query(`SELECT id, title, slug, status, category, signature_count, signature_goal, ai_source, created_at
+ FROM petitions ORDER BY created_at DESC LIMIT 50`),
+ query(`SELECT ol.*, om.ticker, om.title as market_title, p.title as petition_title
+ FROM orbit_links ol
+ JOIN orbit_markets om ON ol.market_id = om.id
+ JOIN petitions p ON ol.petition_id = p.id
+ ORDER BY ol.created_at DESC LIMIT 100`),
+ query(`SELECT COUNT(*)::int as count FROM orbit_feeds WHERE is_active = true`),
+ query(`SELECT COUNT(*)::int as count FROM orbit_articles`),
+ query(`SELECT id, title, content, category, tags, engagement_score, source_url, created_at
+ FROM trending_topics ORDER BY engagement_score DESC LIMIT 15`),
+ ]);
+
+ const stats = {
+ total_markets: marketsRes.rows.length,
+ synced_from_ken: upsertCount,
+ linked_petitions: linksRes.rows.length,
+ active_feeds: feedsRes.rows[0]?.count || 0,
+ recent_articles: articlesRes.rows[0]?.count || 0,
+ };
+
+ return NextResponse.json({
+ markets: marketsRes.rows,
+ petitions: petitionsRes.rows,
+ links: linksRes.rows,
+ trending: trendingRes.rows,
+ stats,
+ });
+ } catch (err) {
+ console.error('[api/orbit] GET error:', (err as Error).message);
+ return NextResponse.json({ error: 'Failed to fetch orbit data' }, { status: 500 });
+ }
+}
+
+/**
+ * POST /api/orbit
+ * Create a market-petition link
+ */
+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 { market_id, petition_id, link_type, strength, ai_reasoning } = body;
+
+ if (!market_id || !petition_id) {
+ return NextResponse.json({ error: 'market_id and petition_id are required' }, { status: 400 });
+ }
+
+ const validLinkTypes = ['influence', 'counter', 'support'];
+ const type = validLinkTypes.includes(link_type) ? link_type : 'influence';
+ const str = typeof strength === 'number' ? Math.max(0, Math.min(1, strength)) : 0.5;
+
+ const result = await query(
+ `INSERT INTO orbit_links (market_id, petition_id, link_type, strength, ai_reasoning)
+ VALUES ($1, $2, $3, $4, $5)
+ ON CONFLICT (market_id, petition_id) DO UPDATE SET
+ link_type = EXCLUDED.link_type,
+ strength = EXCLUDED.strength,
+ ai_reasoning = EXCLUDED.ai_reasoning
+ RETURNING *`,
+ [market_id, petition_id, type, str, ai_reasoning || null],
+ );
+
+ await auditLog('orbit.link_created', 'orbit_link', result.rows[0].id, {
+ market_id,
+ petition_id,
+ link_type: type,
+ });
+
+ return NextResponse.json({ link: result.rows[0] }, { status: 201 });
+ } catch (err) {
+ console.error('[api/orbit] POST error:', (err as Error).message);
+ return NextResponse.json({ error: 'Failed to create orbit link' }, { status: 500 });
+ }
+}
diff --git a/app/api/orbit/rss/route.ts b/app/api/orbit/rss/route.ts
new file mode 100644
index 0000000..12d4bd2
--- /dev/null
+++ b/app/api/orbit/rss/route.ts
@@ -0,0 +1,295 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { query } from '@/lib/db';
+import { verifyAuth } from '@/lib/auth';
+import { auditLog } from '@/lib/audit';
+import { callGemini } from '@/lib/gemini';
+
+// ---- Simple RSS/XML parser using regex ----
+
+interface FeedItem {
+ title: string;
+ link: string;
+ description: string;
+ pubDate: string | null;
+}
+
+function extractTag(xml: string, tag: string): string {
+ // Handle both <tag>content</tag> and CDATA
+ const re = new RegExp(`<${tag}[^>]*>(?:<!\\[CDATA\\[)?(.*?)(?:\\]\\]>)?</${tag}>`, 's');
+ const m = xml.match(re);
+ return m ? m[1].trim() : '';
+}
+
+function parseRSS(xml: string): FeedItem[] {
+ const items: FeedItem[] = [];
+ // Match both <item> (RSS) and <entry> (Atom) elements
+ const itemRegex = /<(?:item|entry)[\s>]([\s\S]*?)<\/(?:item|entry)>/gi;
+ let match;
+ while ((match = itemRegex.exec(xml)) !== null) {
+ const block = match[1];
+ const title = extractTag(block, 'title');
+ // RSS uses <link>, Atom uses <link href="..."/>
+ let link = extractTag(block, 'link');
+ if (!link) {
+ const linkHref = block.match(/<link[^>]+href=["']([^"']+)["']/);
+ if (linkHref) link = linkHref[1];
+ }
+ const description = extractTag(block, 'description') || extractTag(block, 'summary') || extractTag(block, 'content');
+ const pubDate = extractTag(block, 'pubDate') || extractTag(block, 'published') || extractTag(block, 'updated') || null;
+
+ if (title && link) {
+ items.push({
+ title: title.replace(/<[^>]+>/g, '').trim(),
+ link: link.replace(/<[^>]+>/g, '').trim(),
+ description: description.replace(/<[^>]+>/g, '').substring(0, 500).trim(),
+ pubDate,
+ });
+ }
+ }
+ return items;
+}
+
+// ---- Gemini sentiment scoring ----
+
+async function scoreSentiment(
+ titles: string[],
+): Promise<Array<{ sentiment: string; score: number; relevance: number; tags: string[] }>> {
+ if (titles.length === 0) return [];
+
+ const prompt = `Analyze these news headlines for sentiment and political/advocacy relevance.
+For each headline, return:
+- sentiment: "positive", "negative", "neutral", or "mixed"
+- score: float from -1.0 (very negative) to 1.0 (very positive)
+- relevance: 0-100 score of how relevant this is to political advocacy, petitions, civic engagement
+- tags: 1-3 topic tags
+
+Headlines:
+${titles.map((t, i) => `${i + 1}. ${t}`).join('\n')}
+
+Return ONLY a JSON array with one object per headline, in the same order. Example:
+[{"sentiment":"negative","score":-0.6,"relevance":85,"tags":["healthcare","policy"]}]`;
+
+ // 2026-05-05 (tick 29): migrated to lib/gemini.ts wrapper.
+ const r = await callGemini<unknown>({ prompt, maxTokens: 4096, temperature: 0.3, timeoutMs: 30_000 });
+ if (!r.ok) {
+ console.error('[orbit/rss] gemini failed:', r.reason, r.detail);
+ return titles.map(() => ({ sentiment: 'neutral', score: 0, relevance: 50, tags: [] }));
+ }
+ if (Array.isArray(r.data)) return r.data;
+ return titles.map(() => ({ sentiment: 'neutral', score: 0, relevance: 50, tags: [] }));
+}
+
+// ---- Match articles to Kalshi markets by keyword overlap ----
+
+function matchToMarkets(articleTitle: string, markets: Array<{ ticker: string; title: string }>): string[] {
+ const matched: string[] = [];
+ const words = articleTitle.toLowerCase().split(/\W+/).filter((w) => w.length > 3);
+
+ for (const m of markets) {
+ const marketWords = m.title.toLowerCase().split(/\W+/).filter((w) => w.length > 3);
+ const overlap = words.filter((w) => marketWords.includes(w));
+ if (overlap.length >= 2) {
+ matched.push(m.ticker);
+ }
+ }
+ return matched;
+}
+
+/**
+ * GET /api/orbit/rss
+ * List feeds + recent articles
+ */
+export async function GET(request: NextRequest) {
+ const user = verifyAuth(request);
+ if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+ try {
+ const url = new URL(request.url);
+ const category = url.searchParams.get('category');
+ const limit = Math.min(parseInt(url.searchParams.get('limit') || '50', 10), 200);
+
+ // Fetch feeds
+ let feedsSql = `SELECT * FROM orbit_feeds`;
+ const feedsParams: unknown[] = [];
+ if (category && ['national', 'local', 'wire'].includes(category)) {
+ feedsParams.push(category);
+ feedsSql += ` WHERE category = $1`;
+ }
+ feedsSql += ` ORDER BY category, name`;
+ const feedsRes = await query(feedsSql, feedsParams);
+
+ // Fetch articles
+ let articlesSql = `SELECT oa.*, of.name as feed_name, of.station, of.category as feed_category
+ FROM orbit_articles oa
+ JOIN orbit_feeds of ON oa.feed_id = of.id`;
+ const articlesParams: unknown[] = [];
+ if (category && ['national', 'local', 'wire'].includes(category)) {
+ articlesParams.push(category);
+ articlesSql += ` WHERE of.category = $1`;
+ }
+ articlesParams.push(limit);
+ articlesSql += ` ORDER BY oa.published_at DESC NULLS LAST, oa.created_at DESC LIMIT $${articlesParams.length}`;
+ const articlesRes = await query(articlesSql, articlesParams);
+
+ // Stats
+ const statsRes = await query(
+ `SELECT
+ (SELECT COUNT(*)::int FROM orbit_feeds) as total_feeds,
+ (SELECT COUNT(*)::int FROM orbit_articles) as total_articles,
+ (SELECT MAX(last_fetched_at) FROM orbit_feeds) as last_fetch`,
+ );
+
+ return NextResponse.json({
+ feeds: feedsRes.rows,
+ articles: articlesRes.rows,
+ stats: statsRes.rows[0],
+ });
+ } catch (err) {
+ console.error('[api/orbit/rss] GET error:', (err as Error).message);
+ return NextResponse.json({ error: 'Failed to fetch RSS data' }, { status: 500 });
+ }
+}
+
+/**
+ * POST /api/orbit/rss
+ * Trigger manual RSS feed refresh
+ */
+export async function POST(request: NextRequest) {
+ const user = verifyAuth(request);
+ if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+ const errors: string[] = [];
+ let totalFetched = 0;
+ let totalNew = 0;
+
+ try {
+ // Get all active feeds
+ const feedsRes = await query(`SELECT * FROM orbit_feeds WHERE is_active = true ORDER BY name`);
+ const feeds = feedsRes.rows;
+
+ // Get current markets for keyword matching
+ const marketsRes = await query(`SELECT ticker, title FROM orbit_markets`);
+ const markets = marketsRes.rows as Array<{ ticker: string; title: string }>;
+
+ // Process feeds in parallel batches of 5
+ const batchSize = 5;
+ for (let i = 0; i < feeds.length; i += batchSize) {
+ const batch = feeds.slice(i, i + batchSize);
+
+ const results = await Promise.allSettled(
+ batch.map(async (feed: any) => {
+ try {
+ const res = await fetch(feed.url, {
+ headers: {
+ 'User-Agent': 'PattyPetitions/1.0 (RSS Reader)',
+ Accept: 'application/rss+xml, application/xml, text/xml, application/atom+xml',
+ },
+ signal: AbortSignal.timeout(15000),
+ });
+
+ if (!res.ok) {
+ throw new Error(`HTTP ${res.status} for ${feed.name}`);
+ }
+
+ const xml = await res.text();
+ const items = parseRSS(xml);
+ totalFetched += items.length;
+
+ // Score sentiment in batches of 20
+ const newArticles: FeedItem[] = [];
+ for (const item of items) {
+ // Check if already exists
+ const existing = await query(
+ `SELECT id FROM orbit_articles WHERE feed_id = $1 AND url = $2`,
+ [feed.id, item.link],
+ );
+ if (existing.rows.length === 0) {
+ newArticles.push(item);
+ }
+ }
+
+ if (newArticles.length === 0) {
+ // Update last_fetched_at even if no new articles
+ await query(`UPDATE orbit_feeds SET last_fetched_at = NOW() WHERE id = $1`, [feed.id]);
+ return { feed: feed.name, fetched: items.length, new: 0 };
+ }
+
+ // Score new articles with Gemini (batch of up to 20)
+ const titlesToScore = newArticles.slice(0, 20).map((a) => a.title);
+ const scores = await scoreSentiment(titlesToScore);
+
+ // Insert new articles
+ let inserted = 0;
+ for (let j = 0; j < newArticles.length; j++) {
+ const article = newArticles[j];
+ const score = scores[j] || { sentiment: 'neutral', score: 0, relevance: 50, tags: [] };
+ const matchedMarkets = matchToMarkets(article.title, markets);
+
+ try {
+ await query(
+ `INSERT INTO orbit_articles (feed_id, title, url, summary, published_at,
+ sentiment, sentiment_score, relevance_score, matched_markets, tags)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
+ ON CONFLICT (feed_id, url) DO NOTHING`,
+ [
+ feed.id,
+ article.title,
+ article.link,
+ article.description || null,
+ article.pubDate ? new Date(article.pubDate).toISOString() : null,
+ score.sentiment,
+ score.score,
+ score.relevance,
+ matchedMarkets.length > 0 ? matchedMarkets : null,
+ score.tags?.length > 0 ? score.tags : null,
+ ],
+ );
+ inserted++;
+ } catch (insertErr) {
+ console.error('[orbit/rss] Insert error:', (insertErr as Error).message);
+ }
+ }
+
+ totalNew += inserted;
+
+ // Update feed stats
+ await query(
+ `UPDATE orbit_feeds SET last_fetched_at = NOW(), article_count = article_count + $2 WHERE id = $1`,
+ [feed.id, inserted],
+ );
+
+ return { feed: feed.name, fetched: items.length, new: inserted };
+ } catch (feedErr) {
+ const msg = `${feed.name}: ${(feedErr as Error).message}`;
+ errors.push(msg);
+ return { feed: feed.name, fetched: 0, new: 0, error: msg };
+ }
+ }),
+ );
+
+ // Collect results
+ for (const r of results) {
+ if (r.status === 'rejected') {
+ errors.push(r.reason?.message || 'Unknown feed error');
+ }
+ }
+ }
+
+ await auditLog('orbit.rss_refresh', 'orbit_feed', null, {
+ feeds_processed: feeds.length,
+ total_fetched: totalFetched,
+ total_new: totalNew,
+ errors_count: errors.length,
+ });
+
+ return NextResponse.json({
+ fetched: totalFetched,
+ new_articles: totalNew,
+ feeds_processed: feeds.length,
+ errors,
+ });
+ } catch (err) {
+ console.error('[api/orbit/rss] POST error:', (err as Error).message);
+ return NextResponse.json({ error: 'Failed to refresh RSS feeds' }, { status: 500 });
+ }
+}
diff --git a/app/api/orbit/suggest-alliance/route.ts b/app/api/orbit/suggest-alliance/route.ts
new file mode 100644
index 0000000..64a4c3f
--- /dev/null
+++ b/app/api/orbit/suggest-alliance/route.ts
@@ -0,0 +1,204 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { query } from '@/lib/db';
+import { verifyAuth } from '@/lib/auth';
+import { auditLog } from '@/lib/audit';
+import { callGemini } from '@/lib/gemini';
+
+/**
+ * POST /api/orbit/suggest-alliance
+ * Generate an "unlikely alliance" petition from 2 Kalshi markets in different categories.
+ * The AI finds common ground between seemingly opposed groups for maximum political impact.
+ */
+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 { market_tickers, tone } = body;
+
+ if (!market_tickers || !Array.isArray(market_tickers) || market_tickers.length !== 2) {
+ return NextResponse.json({ error: 'market_tickers must be an array of exactly 2 tickers' }, { status: 400 });
+ }
+
+ // Fetch both markets
+ const [res1, res2] = await Promise.all([
+ query(`SELECT * FROM orbit_markets WHERE ticker = $1`, [market_tickers[0]]),
+ query(`SELECT * FROM orbit_markets WHERE ticker = $1`, [market_tickers[1]]),
+ ]);
+
+ if (res1.rows.length === 0 || res2.rows.length === 0) {
+ return NextResponse.json({ error: 'One or both markets not found. Sync markets first.' }, { status: 404 });
+ }
+
+ const marketA = res1.rows[0];
+ const marketB = res2.rows[0];
+
+ if (marketA.category === marketB.category) {
+ return NextResponse.json({ error: 'Alliance petitions require markets from different categories.' }, { status: 400 });
+ }
+
+ const toneDesc = tone || 'urgent';
+
+ const prompt = `You are a master political strategist and coalition builder. Your specialty is finding UNLIKELY ALLIANCES between groups that normally oppose each other. When these groups unite on a specific issue, the political impact is 10x greater than either group alone — this is the "Baptists and bootleggers" principle.
+
+MARKET A (${marketA.category}):
+- Ticker: ${marketA.ticker}
+- Title: ${marketA.title}
+- Subtitle: ${marketA.subtitle || 'N/A'}
+- Category: ${marketA.category}
+- Probability: ${marketA.last_price || marketA.yes_bid || 'N/A'}%
+- Volume: ${marketA.volume || 'N/A'} contracts
+
+MARKET B (${marketB.category}):
+- Ticker: ${marketB.ticker}
+- Title: ${marketB.title}
+- Subtitle: ${marketB.subtitle || 'N/A'}
+- Category: ${marketB.category}
+- Probability: ${marketB.last_price || marketB.yes_bid || 'N/A'}%
+- Volume: ${marketB.volume || 'N/A'} contracts
+
+YOUR TASK:
+Find the INTERSECTION where supporters of both issues can agree on ONE petition.
+The petition should feel authentic to BOTH audiences. Frame it around shared values
+(freedom, fairness, safety, economic opportunity) rather than partisan language.
+
+Tone: ${toneDesc} (${toneDesc === 'urgent' ? 'time-sensitive, action-oriented' : toneDesc === 'formal' ? 'professional, data-driven' : toneDesc === 'passionate' ? 'emotional, empathetic' : 'practical, solution-focused'})
+
+RULES:
+- Do NOT mention Kalshi, prediction markets, or betting
+- The petition must appeal genuinely to BOTH groups
+- Find a policy angle where both groups' interests align
+- Use specific, actionable demands
+
+Return a JSON object with EXACTLY these fields:
+{
+ "title": "Alliance petition title (max 100 chars)",
+ "summary": "2-3 sentence hook explaining why these groups should unite (max 300 chars)",
+ "body_html": "<p>Full petition body in HTML. 5-7 paragraphs. Address both constituencies directly. Include specific demands, shared values, urgency, and a unified call to action. Use <strong>, <em>, <ul>, <li> tags.</p>",
+ "body_text": "Plain text version of the body",
+ "suggested_target": "The specific person, office, or body to address this petition to",
+ "suggested_tags": ["array", "of", "4-6", "relevant", "tags", "including-both-categories"],
+ "suggested_goal": 25000,
+ "alliance_insight": "2-3 sentences explaining why this alliance between ${marketA.category} and ${marketB.category} is politically powerful and surprising.",
+ "group_a_appeal": "2-3 sentences explaining specifically why ${marketA.category} supporters would sign this petition.",
+ "group_b_appeal": "2-3 sentences explaining specifically why ${marketB.category} supporters would sign this petition.",
+ "impact_multiplier": "2-3 sentences explaining why this joint petition achieves more than two separate petitions. Be specific about the political dynamics."
+}`;
+
+ // 2026-05-05 (tick 29): migrated to lib/gemini.ts wrapper.
+ const r = await callGemini<Record<string, unknown>>({ prompt, maxTokens: 4096, temperature: 0.85, timeoutMs: 30_000 });
+ if (!r.ok) {
+ console.error('[orbit/alliance] gemini failed:', r.reason, r.detail);
+ return NextResponse.json(
+ { error: r.reason === 'no_key' ? 'AI service not configured' : 'AI generation failed' },
+ { status: r.status },
+ );
+ }
+ const parsed = r.data as Record<string, unknown>;
+
+ // Create petition
+ const title = String(parsed.title || `Alliance: ${marketA.category} + ${marketB.category}`);
+ let slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
+
+ const existingSlug = await query(`SELECT id FROM petitions WHERE slug = $1`, [slug]);
+ if (existingSlug.rows.length > 0) {
+ slug = `${slug}-${Date.now().toString(36)}`;
+ }
+
+ const userResult = await query(`SELECT id FROM users LIMIT 1`);
+ const userId = userResult.rows[0]?.id;
+ if (!userId) {
+ return NextResponse.json({ error: 'No user found in system' }, { status: 500 });
+ }
+
+ const petitionResult = await query(
+ `INSERT INTO petitions (user_id, title, slug, summary, body_html, body_text, target, category, tags,
+ signature_goal, ai_source, ai_topic_data, status)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
+ RETURNING *`,
+ [
+ userId,
+ title,
+ slug,
+ parsed.summary || null,
+ parsed.body_html || '<p>Alliance petition content</p>',
+ parsed.body_text || null,
+ parsed.suggested_target || null,
+ 'alliance',
+ parsed.suggested_tags || null,
+ parsed.suggested_goal || 25000,
+ 'kalshi_alliance',
+ JSON.stringify({
+ market_a: { ticker: marketA.ticker, title: marketA.title, category: marketA.category },
+ market_b: { ticker: marketB.ticker, title: marketB.title, category: marketB.category },
+ alliance_insight: parsed.alliance_insight,
+ group_a_appeal: parsed.group_a_appeal,
+ group_b_appeal: parsed.group_b_appeal,
+ impact_multiplier: parsed.impact_multiplier,
+ generated_at: new Date().toISOString(),
+ }),
+ 'draft',
+ ],
+ );
+ const petition = petitionResult.rows[0];
+
+ // Create TWO orbit_links — one per market
+ const [linkA, linkB] = await Promise.all([
+ query(
+ `INSERT INTO orbit_links (market_id, petition_id, link_type, strength, ai_reasoning)
+ VALUES ($1, $2, 'alliance', 0.9, $3)
+ ON CONFLICT (market_id, petition_id) DO UPDATE SET
+ link_type = 'alliance', ai_reasoning = EXCLUDED.ai_reasoning, strength = 0.9
+ RETURNING *`,
+ [marketA.id, petition.id, parsed.group_a_appeal || 'Alliance petition member.']
+ ),
+ query(
+ `INSERT INTO orbit_links (market_id, petition_id, link_type, strength, ai_reasoning)
+ VALUES ($1, $2, 'alliance', 0.9, $3)
+ ON CONFLICT (market_id, petition_id) DO UPDATE SET
+ link_type = 'alliance', ai_reasoning = EXCLUDED.ai_reasoning, strength = 0.9
+ RETURNING *`,
+ [marketB.id, petition.id, parsed.group_b_appeal || 'Alliance petition member.']
+ ),
+ ]);
+
+ // Update both markets' petition_relevance
+ await Promise.all([
+ query(
+ `UPDATE orbit_markets SET petition_relevance = GREATEST(petition_relevance, 85), suggested_petition = $2 WHERE id = $1`,
+ [marketA.id, title]
+ ),
+ query(
+ `UPDATE orbit_markets SET petition_relevance = GREATEST(petition_relevance, 85), suggested_petition = $2 WHERE id = $1`,
+ [marketB.id, title]
+ ),
+ ]);
+
+ await auditLog('orbit.alliance_generated', 'petition', petition.id, {
+ market_a: marketA.ticker,
+ market_b: marketB.ticker,
+ category_a: marketA.category,
+ category_b: marketB.category,
+ tone: toneDesc,
+ });
+
+ return NextResponse.json({
+ petition: {
+ ...petition,
+ alliance_insight: parsed.alliance_insight || '',
+ group_a_appeal: parsed.group_a_appeal || '',
+ group_b_appeal: parsed.group_b_appeal || '',
+ impact_multiplier: parsed.impact_multiplier || '',
+ },
+ links: [linkA.rows[0], linkB.rows[0]],
+ markets: {
+ a: { ticker: marketA.ticker, title: marketA.title, category: marketA.category },
+ b: { ticker: marketB.ticker, title: marketB.title, category: marketB.category },
+ },
+ });
+ } catch (err) {
+ console.error('[api/orbit/suggest-alliance] Error:', (err as Error).message);
+ return NextResponse.json({ error: 'Failed to generate alliance petition' }, { status: 500 });
+ }
+}
diff --git a/app/api/petitions/[slug]/post-log/route.ts b/app/api/petitions/[slug]/post-log/route.ts
new file mode 100644
index 0000000..505763e
--- /dev/null
+++ b/app/api/petitions/[slug]/post-log/route.ts
@@ -0,0 +1,48 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { verifyAuth } from '@/lib/auth';
+import { query } from '@/lib/db';
+
+/**
+ * GET /api/petitions/[slug]/post-log
+ * Returns posting history for a petition.
+ */
+export async function GET(
+ request: NextRequest,
+ { params }: { params: Promise<{ slug: string }> },
+) {
+ const user = verifyAuth(request);
+ if (!user) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ const { slug } = await params;
+
+ // Look up petition by slug
+ const petitionResult = await query(
+ `SELECT id FROM petitions WHERE slug = $1 LIMIT 1`,
+ [slug],
+ );
+
+ if (petitionResult.rows.length === 0) {
+ return NextResponse.json({ error: 'Petition not found' }, { status: 404 });
+ }
+
+ const petitionId = petitionResult.rows[0].id;
+
+ const logsResult = await query(
+ `SELECT id, platform, post_text, post_url, external_id, status, error_msg,
+ platform_config, actor, scheduled_at, posted_at, created_at
+ FROM petition_posts
+ WHERE petition_id = $1
+ ORDER BY created_at DESC
+ LIMIT 50`,
+ [petitionId],
+ );
+
+ return NextResponse.json({
+ petition_id: petitionId,
+ slug,
+ logs: logsResult.rows,
+ total: logsResult.rowCount,
+ });
+}
diff --git a/app/api/petitions/[slug]/post/route.ts b/app/api/petitions/[slug]/post/route.ts
new file mode 100644
index 0000000..2c92b0b
--- /dev/null
+++ b/app/api/petitions/[slug]/post/route.ts
@@ -0,0 +1,251 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { verifyAuth } from '@/lib/auth';
+import { query } from '@/lib/db';
+import { SISTER_AUTH, SISTER_OK, sisterUnconfigured } from '@/lib/sister-auth';
+
+const PULSE_BASE = 'http://127.0.0.1:9845';
+
+interface PostPlatform {
+ name: string;
+ enabled: boolean;
+ config?: Record<string, string>;
+}
+
+/**
+ * POST /api/petitions/[slug]/post
+ * Dispatches a petition to selected platforms via the Pulse agent.
+ * Logs each dispatch decision to the petition_posts table.
+ */
+export async function POST(
+ request: NextRequest,
+ { params }: { params: Promise<{ slug: string }> },
+) {
+ const user = verifyAuth(request);
+ if (!user) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+ if (!SISTER_OK) {
+ return sisterUnconfigured();
+ }
+
+ const { slug } = await params;
+
+ // Look up the petition
+ const petitionResult = await query(
+ `SELECT id, title, summary, body_text, slug, status FROM petitions WHERE slug = $1 LIMIT 1`,
+ [slug],
+ );
+
+ if (petitionResult.rows.length === 0) {
+ return NextResponse.json({ error: 'Petition not found' }, { status: 404 });
+ }
+
+ const petition = petitionResult.rows[0];
+
+ let body: { platforms: PostPlatform[]; action?: string };
+ try {
+ body = await request.json();
+ } catch {
+ return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
+ }
+
+ const { platforms, action = 'post' } = body;
+
+ if (!Array.isArray(platforms) || platforms.length === 0) {
+ return NextResponse.json({ error: 'platforms[] required' }, { status: 400 });
+ }
+
+ const enabledPlatforms = platforms.filter((p) => p.enabled);
+ if (enabledPlatforms.length === 0) {
+ return NextResponse.json({ error: 'No platforms enabled' }, { status: 400 });
+ }
+
+ const petitionUrl = `http://45.61.58.125:7460/petitions/${petition.slug}`;
+ const results: Array<{
+ platform: string;
+ status: string;
+ message: string;
+ post_id?: string;
+ }> = [];
+
+ // Build dispatch payload for the Pulse agent
+ const targetAgents: string[] = [];
+ const platformParams: Record<string, string> = {};
+
+ for (const p of enabledPlatforms) {
+ const name = p.name.toLowerCase();
+
+ // Patty "launch" is just marking the petition as featured/active -- handled locally
+ if (name === 'patty') {
+ try {
+ await query(
+ `UPDATE petitions SET status = 'active', distribution_platform = 'patty', updated_at = NOW() WHERE id = $1`,
+ [petition.id],
+ );
+
+ const insertResult = await query(
+ `INSERT INTO petition_posts (petition_id, platform, post_text, post_url, status, posted_at, platform_config, actor)
+ VALUES ($1, 'patty', $2, $3, 'posted', NOW(), $4, $5) RETURNING id`,
+ [
+ petition.id,
+ petition.summary || petition.title,
+ petitionUrl,
+ JSON.stringify(p.config || {}),
+ user,
+ ],
+ );
+
+ results.push({
+ platform: 'patty',
+ status: 'posted',
+ message: 'Launched on Patty platform',
+ post_id: insertResult.rows[0]?.id,
+ });
+ } catch (err: unknown) {
+ const msg = err instanceof Error ? err.message : 'Unknown error';
+ results.push({ platform: 'patty', status: 'failed', message: msg });
+ }
+ continue;
+ }
+
+ // For social platforms, we dispatch through Pulse
+ const socialPlatforms = ['reddit', 'discord', 'twitter', 'bluesky', 'moveon', 'instagram', 'tiktok'];
+ if (!socialPlatforms.includes(name)) {
+ results.push({ platform: name, status: 'skipped', message: `Unknown platform: ${name}` });
+ continue;
+ }
+
+ // Collect platform-specific config
+ if (name === 'reddit' && p.config?.subreddit) {
+ platformParams.reddit_subreddit = p.config.subreddit;
+ }
+ if (name === 'discord' && p.config?.channel_id) {
+ platformParams.discord_channel_id = p.config.channel_id;
+ }
+
+ targetAgents.push(name);
+
+ // Log as pending -- we update after dispatch
+ try {
+ const insertResult = await query(
+ `INSERT INTO petition_posts (petition_id, platform, post_text, post_url, status, platform_config, actor)
+ VALUES ($1, $2, $3, $4, 'pending', $5, $6) RETURNING id`,
+ [
+ petition.id,
+ name,
+ petition.summary || petition.title,
+ petitionUrl,
+ JSON.stringify(p.config || {}),
+ user,
+ ],
+ );
+
+ const postId = insertResult.rows[0]?.id;
+
+ results.push({
+ platform: name,
+ status: 'pending',
+ message: 'Queued for dispatch',
+ post_id: postId,
+ });
+ } catch (err: unknown) {
+ const msg = err instanceof Error ? err.message : 'Unknown error';
+ results.push({ platform: name, status: 'failed', message: msg });
+ }
+ }
+
+ // If we have social platform targets, dispatch through Pulse agent
+ if (targetAgents.length > 0) {
+ try {
+ const dispatchPayload = {
+ petition_id: petition.id,
+ title: petition.title,
+ body: petition.body_text || petition.summary || petition.title,
+ petition_url: petitionUrl,
+ target_agents: targetAgents,
+ platform_params: platformParams,
+ };
+
+ const pulseRes = await fetch(`${PULSE_BASE}/api/pulse/dispatch`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: SISTER_AUTH,
+ },
+ body: JSON.stringify(dispatchPayload),
+ signal: AbortSignal.timeout(15000),
+ });
+
+ const pulseData = await pulseRes.json();
+
+ // Update each post record with dispatch results
+ if (pulseData.results && Array.isArray(pulseData.results)) {
+ for (const pr of pulseData.results) {
+ const matchResult = results.find(
+ (r) => r.platform === pr.agent && r.post_id,
+ );
+ if (matchResult && matchResult.post_id) {
+ const newStatus = pr.status === 'dispatched' ? 'posted' : 'failed';
+ const errorMsg =
+ pr.status !== 'dispatched' ? pr.message || 'Dispatch failed' : null;
+
+ await query(
+ `UPDATE petition_posts SET status = $1, error_msg = $2, posted_at = CASE WHEN $1 = 'posted' THEN NOW() ELSE posted_at END WHERE id = $3`,
+ [newStatus, errorMsg, matchResult.post_id],
+ );
+
+ matchResult.status = newStatus;
+ matchResult.message =
+ pr.status === 'dispatched'
+ ? 'Posted via Pulse'
+ : pr.message || 'Dispatch failed';
+ }
+ }
+ }
+ } catch (err: unknown) {
+ const msg = err instanceof Error ? err.message : 'Pulse unavailable';
+ // Mark all pending as failed
+ for (const r of results) {
+ if (r.status === 'pending' && r.post_id) {
+ await query(
+ `UPDATE petition_posts SET status = 'failed', error_msg = $1 WHERE id = $2`,
+ [msg, r.post_id],
+ );
+ r.status = 'failed';
+ r.message = msg;
+ }
+ }
+ }
+ }
+
+ // Log audit event
+ await query(
+ `INSERT INTO audit_events (event_type, entity_type, entity_id, actor, metadata)
+ VALUES ('petition_post', 'petition', $1, $2, $3)`,
+ [
+ petition.id,
+ user,
+ JSON.stringify({
+ platforms: enabledPlatforms.map((p) => p.name),
+ action,
+ results: results.map((r) => ({
+ platform: r.platform,
+ status: r.status,
+ })),
+ }),
+ ],
+ );
+
+ return NextResponse.json({
+ petition_id: petition.id,
+ slug,
+ dispatched_at: new Date().toISOString(),
+ results,
+ summary: {
+ total: enabledPlatforms.length,
+ posted: results.filter((r) => r.status === 'posted').length,
+ failed: results.filter((r) => r.status === 'failed').length,
+ skipped: results.filter((r) => r.status === 'skipped').length,
+ },
+ });
+}
diff --git a/app/api/petitions/generate/route.ts b/app/api/petitions/generate/route.ts
new file mode 100644
index 0000000..8ed1ef8
--- /dev/null
+++ b/app/api/petitions/generate/route.ts
@@ -0,0 +1,75 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { verifyAuth } from '@/lib/auth';
+import { callGemini } from '@/lib/gemini';
+
+type PetitionShape = {
+ title?: string;
+ summary?: string;
+ body_html?: string;
+ body_text?: string;
+ suggested_target?: string;
+ suggested_tags?: string[];
+ suggested_goal?: number;
+};
+
+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 { topic, target, tone, category } = body;
+
+ if (!topic) {
+ return NextResponse.json({ error: 'Topic is required' }, { status: 400 });
+ }
+
+ const toneDesc = tone || 'formal';
+ const targetDesc = target || 'Congress and the Department of Education';
+ const categoryDesc = category || 'policy';
+
+ const prompt = `You are a professional petition writer specializing in student debt and education advocacy. Generate a compelling petition about the following topic.
+
+Topic: ${topic}
+Target: ${targetDesc}
+Tone: ${toneDesc} (${toneDesc === 'urgent' ? 'time-sensitive, action-oriented language' : toneDesc === 'formal' ? 'professional, respectful, data-driven' : toneDesc === 'passionate' ? 'emotional, personal stories, empathetic' : 'community-focused, grassroots, collective action'})
+Category: ${categoryDesc}
+
+Return a JSON object with EXACTLY these fields:
+{
+ "title": "A compelling petition title (max 100 chars)",
+ "summary": "A 2-3 sentence summary that hooks readers (max 300 chars)",
+ "body_html": "<p>Full petition body in HTML format with multiple paragraphs. Include specific demands, statistics where appropriate, and a clear call to action. Should be 3-5 paragraphs long. Use <strong>, <em>, <ul>, <li> tags for emphasis and lists.</p>",
+ "body_text": "Plain text version of the petition body",
+ "suggested_target": "The specific person, office, or body to address",
+ "suggested_tags": ["array", "of", "relevant", "tags"],
+ "suggested_goal": 500
+}
+
+Make the petition specific, actionable, and compelling. Focus on student debt, education policy, and higher education issues. The suggested_goal should be between 100 and 10000 based on the scope of the petition.`;
+
+ // 2026-05-05 (tick 29): migrated to lib/gemini.ts wrapper.
+ const r = await callGemini<PetitionShape>({ prompt, maxTokens: 2048, temperature: 0.8 });
+ if (!r.ok) {
+ console.error('[petitions/generate] gemini failed:', r.reason, r.detail);
+ return NextResponse.json(
+ { error: r.reason === 'no_key' ? 'AI service not configured' : 'AI generation failed' },
+ { status: r.status },
+ );
+ }
+ const parsed = r.data;
+
+ return NextResponse.json({
+ title: parsed.title || '',
+ summary: parsed.summary || '',
+ body_html: parsed.body_html || '',
+ body_text: parsed.body_text || '',
+ suggested_target: parsed.suggested_target || targetDesc,
+ suggested_tags: parsed.suggested_tags || [],
+ suggested_goal: parsed.suggested_goal || 500,
+ });
+ } catch (err) {
+ console.error('[api/petitions/generate] Error:', (err as Error).message);
+ return NextResponse.json({ error: 'Failed to generate petition' }, { status: 500 });
+ }
+}
diff --git a/app/api/petitions/route.ts b/app/api/petitions/route.ts
new file mode 100644
index 0000000..fbf6166
--- /dev/null
+++ b/app/api/petitions/route.ts
@@ -0,0 +1,232 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { query } from '@/lib/db';
+import { verifyAuth } from '@/lib/auth';
+import { sanitizePetitionBody, bodyHtmlTooLarge } from '@/lib/sanitize';
+
+export async function GET(request: NextRequest) {
+ const user = verifyAuth(request);
+ if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+ const url = new URL(request.url);
+ const status = url.searchParams.get('status');
+ const search = url.searchParams.get('search');
+
+ let sql = `SELECT * FROM petitions`;
+ const params: unknown[] = [];
+ const conditions: string[] = [];
+
+ if (status && status !== 'all') {
+ params.push(status);
+ conditions.push(`status = $${params.length}`);
+ }
+
+ if (search) {
+ params.push(`%${search}%`);
+ conditions.push(`(title ILIKE $${params.length} OR target ILIKE $${params.length})`);
+ }
+
+ if (conditions.length > 0) {
+ sql += ` WHERE ` + conditions.join(' AND ');
+ }
+
+ sql += ` ORDER BY created_at DESC`;
+
+ try {
+ const result = await query(sql, params);
+
+ // Get status counts
+ const countsResult = await query(
+ `SELECT status, COUNT(*)::int as count FROM petitions GROUP BY status`
+ );
+ const statusCounts: Record<string, number> = { draft: 0, active: 0, paused: 0, closed: 0, delivered: 0 };
+ for (const row of countsResult.rows) {
+ statusCounts[row.status] = row.count;
+ }
+ statusCounts.all = Object.values(statusCounts).reduce((a, b) => a + b, 0);
+
+ return NextResponse.json({ rows: result.rows, statusCounts });
+ } catch (err) {
+ console.error('[api/petitions] GET error:', (err as Error).message);
+ return NextResponse.json({ error: 'Failed to fetch petitions' }, { 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, summary, body_html, body_text, target, target_emails, category, tags: rawTags, signature_goal, org_type, status } = body;
+
+ // Normalize tags: accept string ("a, b, c"), array, or null
+ let tags: string[] | null = null;
+ if (Array.isArray(rawTags)) {
+ tags = rawTags;
+ } else if (typeof rawTags === 'string' && rawTags.trim()) {
+ tags = rawTags.split(',').map((t: string) => t.trim()).filter(Boolean);
+ }
+
+ if (!title || !body_html) {
+ return NextResponse.json({ error: 'Title and body_html are required' }, { status: 400 });
+ }
+
+ // 2026-05-05 (P1 architect-review): boundary sanitize at storage. Render
+ // ALSO sanitizes (defense-in-depth) but the database is the trust boundary.
+ if (bodyHtmlTooLarge(body_html)) {
+ return NextResponse.json({ error: 'body_html too large (max 200KB)' }, { status: 400 });
+ }
+ const cleanBodyHtml = sanitizePetitionBody(body_html);
+
+ // Generate slug from title
+ let slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
+ // Check for duplicate slug
+ const existing = await query(`SELECT id FROM petitions WHERE slug = $1`, [slug]);
+ if (existing.rows.length > 0) {
+ slug = `${slug}-${Date.now().toString(36)}`;
+ }
+
+ // Get user_id from DB
+ const userResult = await query(`SELECT id FROM users LIMIT 1`);
+ const userId = userResult.rows[0]?.id;
+ if (!userId) {
+ return NextResponse.json({ error: 'No user found' }, { status: 500 });
+ }
+
+ const result = await query(
+ `INSERT INTO petitions (user_id, title, slug, summary, body_html, body_text, target, target_emails, category, tags, signature_goal, org_type, status)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
+ RETURNING *`,
+ [
+ userId,
+ title,
+ slug,
+ summary || null,
+ cleanBodyHtml,
+ body_text || null,
+ target || null,
+ target_emails || null,
+ category || null,
+ tags || null,
+ signature_goal || 100,
+ org_type || null,
+ status || 'draft',
+ ]
+ );
+
+ return NextResponse.json({ row: result.rows[0] }, { status: 201 });
+ } catch (err) {
+ console.error('[api/petitions] POST error:', (err as Error).message);
+ return NextResponse.json({ error: 'Failed to create petition' }, { 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, ...updates } = body;
+
+ if (!id) {
+ return NextResponse.json({ error: 'Petition id is required' }, { status: 400 });
+ }
+
+ const allowedFields = ['title', 'summary', 'body_html', 'body_text', 'target', 'target_emails', 'category', 'tags', 'signature_goal', 'org_type', 'status', 'image_url', 'compliance_flags'];
+ const setClauses: string[] = [];
+ const params: unknown[] = [];
+
+ for (const [key, value] of Object.entries(updates)) {
+ if (allowedFields.includes(key)) {
+ let normalizedValue = value;
+ // Normalize tags: text[] column needs a real array, not a comma string
+ if (key === 'tags') {
+ if (Array.isArray(value)) {
+ normalizedValue = value.map((t: string) => String(t).trim()).filter(Boolean);
+ } else if (typeof value === 'string' && value.trim()) {
+ normalizedValue = value.split(',').map((t: string) => t.trim()).filter(Boolean);
+ } else {
+ normalizedValue = null;
+ }
+ }
+ // Normalize target_emails similarly
+ if (key === 'target_emails') {
+ if (Array.isArray(value)) {
+ normalizedValue = value;
+ } else if (typeof value === 'string' && value.trim()) {
+ normalizedValue = value.split(',').map((t: string) => t.trim()).filter(Boolean);
+ } else {
+ normalizedValue = null;
+ }
+ }
+ // 2026-05-05 (P1): boundary sanitize body_html on PATCH too.
+ if (key === 'body_html') {
+ if (bodyHtmlTooLarge(value)) {
+ return NextResponse.json({ error: 'body_html too large (max 200KB)' }, { status: 400 });
+ }
+ normalizedValue = sanitizePetitionBody(value as string);
+ }
+ params.push(normalizedValue);
+ setClauses.push(`${key} = $${params.length}`);
+ }
+ }
+
+ if (setClauses.length === 0) {
+ return NextResponse.json({ error: 'No valid fields to update' }, { status: 400 });
+ }
+
+ // When status changes to "active", auto-generate public URL
+ if (updates.status === 'active') {
+ // Get the petition's slug first
+ const petitionResult = await query(`SELECT slug FROM petitions WHERE id = $1`, [id]);
+ if (petitionResult.rows.length > 0) {
+ const slug = petitionResult.rows[0].slug;
+ const publicUrl = `http://45.61.58.125:7460/petitions/${slug}`;
+ params.push(publicUrl);
+ setClauses.push(`external_url = $${params.length}`);
+ }
+ }
+
+ params.push(id);
+ const sql = `UPDATE petitions SET ${setClauses.join(', ')} WHERE id = $${params.length} RETURNING *`;
+
+ const result = await query(sql, params);
+ if (result.rows.length === 0) {
+ return NextResponse.json({ error: 'Petition not found' }, { status: 404 });
+ }
+
+ return NextResponse.json({ row: result.rows[0] });
+ } catch (err) {
+ console.error('[api/petitions] PATCH error:', (err as Error).message);
+ return NextResponse.json({ error: 'Failed to update petition' }, { status: 500 });
+ }
+}
+
+export async function DELETE(request: NextRequest) {
+ const user = verifyAuth(request);
+ if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+ try {
+ const url = new URL(request.url);
+ const id = url.searchParams.get('id');
+
+ if (!id) {
+ return NextResponse.json({ error: 'Petition id is required' }, { status: 400 });
+ }
+
+ const result = await query(
+ `UPDATE petitions SET status = 'closed' WHERE id = $1 RETURNING *`,
+ [id]
+ );
+
+ if (result.rows.length === 0) {
+ return NextResponse.json({ error: 'Petition not found' }, { status: 404 });
+ }
+
+ return NextResponse.json({ row: result.rows[0] });
+ } catch (err) {
+ console.error('[api/petitions] DELETE error:', (err as Error).message);
+ return NextResponse.json({ error: 'Failed to close petition' }, { status: 500 });
+ }
+}
diff --git a/app/api/public/signatures/route.ts b/app/api/public/signatures/route.ts
new file mode 100644
index 0000000..fc54356
--- /dev/null
+++ b/app/api/public/signatures/route.ts
@@ -0,0 +1,209 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { query, getClient } from '@/lib/db';
+
+/**
+ * Simple in-memory rate limiter: max 10 signatures per IP per hour.
+ * Resets on server restart — acceptable for this use case.
+ */
+const rateLimitMap = new Map<string, { count: number; resetAt: number }>();
+const RATE_LIMIT_MAX = 10;
+const RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000; // 1 hour
+
+function checkRateLimit(ip: string): boolean {
+ const now = Date.now();
+ const entry = rateLimitMap.get(ip);
+
+ if (!entry || now > entry.resetAt) {
+ rateLimitMap.set(ip, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS });
+ return true;
+ }
+
+ if (entry.count >= RATE_LIMIT_MAX) {
+ return false;
+ }
+
+ entry.count++;
+ return true;
+}
+
+// Clean up expired entries every 10 minutes to prevent memory leaks
+setInterval(() => {
+ const now = Date.now();
+ for (const [key, value] of rateLimitMap.entries()) {
+ if (now > value.resetAt) {
+ rateLimitMap.delete(key);
+ }
+ }
+}, 10 * 60 * 1000);
+
+export async function POST(request: NextRequest) {
+ try {
+ const body = await request.json();
+ const {
+ petition_id,
+ signer_name,
+ signer_email,
+ signer_zip,
+ signer_comment,
+ opted_in_email,
+ _hp, // honeypot
+ } = body;
+
+ // Honeypot check: if this hidden field has a value, it's a bot
+ if (_hp) {
+ // Return success to not reveal the check to bots
+ return NextResponse.json({ success: true, signatureCount: 0 }, { status: 200 });
+ }
+
+ // Get IP address
+ const forwarded = request.headers.get('x-forwarded-for');
+ const ip = forwarded ? forwarded.split(',')[0].trim() : 'unknown';
+
+ // Rate limit check
+ if (!checkRateLimit(ip)) {
+ return NextResponse.json(
+ { error: 'Too many signatures from this IP address. Please try again later.' },
+ { status: 429 }
+ );
+ }
+
+ // Validation: required fields
+ if (!petition_id) {
+ return NextResponse.json({ error: 'Petition ID is required.' }, { status: 400 });
+ }
+
+ if (!signer_name || typeof signer_name !== 'string' || signer_name.trim().length === 0) {
+ return NextResponse.json({ error: 'Please enter your name.' }, { status: 400 });
+ }
+
+ if (signer_name.trim().length > 200) {
+ return NextResponse.json({ error: 'Name is too long.' }, { status: 400 });
+ }
+
+ if (!signer_email || typeof signer_email !== 'string') {
+ return NextResponse.json({ error: 'Please enter your email address.' }, { status: 400 });
+ }
+
+ // Email format validation
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+ if (!emailRegex.test(signer_email.trim())) {
+ return NextResponse.json({ error: 'Please enter a valid email address.' }, { status: 400 });
+ }
+
+ if (signer_email.trim().length > 320) {
+ return NextResponse.json({ error: 'Email address is too long.' }, { status: 400 });
+ }
+
+ // Validate optional fields
+ if (signer_comment && typeof signer_comment === 'string' && signer_comment.length > 1000) {
+ return NextResponse.json({ error: 'Comment is too long (max 1000 characters).' }, { status: 400 });
+ }
+
+ if (signer_zip && typeof signer_zip === 'string' && signer_zip.length > 10) {
+ return NextResponse.json({ error: 'ZIP code is too long.' }, { status: 400 });
+ }
+
+ // Check petition exists and is active
+ const petitionResult = await query(
+ `SELECT id, status, signature_count FROM petitions WHERE id = $1`,
+ [petition_id]
+ );
+
+ if (petitionResult.rows.length === 0) {
+ return NextResponse.json({ error: 'Petition not found.' }, { status: 404 });
+ }
+
+ const petition = petitionResult.rows[0];
+ if (petition.status !== 'active') {
+ return NextResponse.json(
+ { error: 'This petition is not currently accepting signatures.' },
+ { status: 403 }
+ );
+ }
+
+ // Use a transaction for atomicity
+ const client = await getClient();
+
+ try {
+ await client.query('BEGIN');
+
+ // Insert signature (UNIQUE constraint on petition_id + signer_email handles dedup)
+ const sigResult = await client.query(
+ `INSERT INTO signatures (petition_id, signer_email, signer_name, signer_zip, signer_comment, is_public, ip_address, opted_in_email, source)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'public')
+ ON CONFLICT (petition_id, signer_email) DO NOTHING
+ RETURNING *`,
+ [
+ petition_id,
+ signer_email.trim().toLowerCase(),
+ signer_name.trim(),
+ signer_zip?.trim() || null,
+ signer_comment?.trim() || null,
+ true, // public signatures by default
+ ip,
+ opted_in_email === true,
+ ]
+ );
+
+ if (sigResult.rows.length === 0) {
+ // Duplicate — email already signed this petition
+ await client.query('ROLLBACK');
+
+ // Return the current count so the UI can still show it
+ const countResult = await query(
+ `SELECT signature_count FROM petitions WHERE id = $1`,
+ [petition_id]
+ );
+
+ return NextResponse.json(
+ {
+ error: 'You have already signed this petition. Thank you for your support!',
+ signatureCount: countResult.rows[0]?.signature_count || 0,
+ },
+ { status: 409 }
+ );
+ }
+
+ // Update petition signature_count
+ const updateResult = await client.query(
+ `UPDATE petitions SET signature_count = signature_count + 1 WHERE id = $1 RETURNING signature_count`,
+ [petition_id]
+ );
+
+ const newCount = updateResult.rows[0]?.signature_count || 0;
+
+ // If opted in, add to email_subscribers
+ if (opted_in_email === true) {
+ await client.query(
+ `INSERT INTO email_subscribers (email, name, zip_code, source, petition_ids)
+ VALUES ($1, $2, $3, 'petition', ARRAY[$4::uuid])
+ ON CONFLICT DO NOTHING`,
+ [
+ signer_email.trim().toLowerCase(),
+ signer_name.trim(),
+ signer_zip?.trim() || null,
+ petition_id,
+ ]
+ );
+ }
+
+ await client.query('COMMIT');
+
+ return NextResponse.json(
+ { success: true, signatureCount: newCount },
+ { status: 201 }
+ );
+ } catch (err) {
+ await client.query('ROLLBACK');
+ throw err;
+ } finally {
+ client.release();
+ }
+ } catch (err) {
+ console.error('[api/public/signatures] POST error:', (err as Error).message);
+ return NextResponse.json(
+ { error: 'Something went wrong. Please try again.' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/pulse/[...path]/route.ts b/app/api/pulse/[...path]/route.ts
new file mode 100644
index 0000000..1345fa5
--- /dev/null
+++ b/app/api/pulse/[...path]/route.ts
@@ -0,0 +1,56 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { verifyAuth } from '@/lib/auth';
+import { SISTER_AUTH, SISTER_OK, sisterUnconfigured } from '@/lib/sister-auth';
+
+const PULSE_BASE = 'http://127.0.0.1:9845';
+
+async function proxyToPulse(request: NextRequest, params: Promise<{ path: string[] }>, method: string) {
+ const user = verifyAuth(request);
+ if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ if (!SISTER_OK) return sisterUnconfigured();
+
+ const { path } = await params;
+ const pathStr = path.join('/');
+ const url = new URL(request.url);
+ const qs = url.search;
+
+ const headers: Record<string, string> = { Authorization: SISTER_AUTH };
+ let body: string | undefined;
+
+ if (method === 'POST' || method === 'PUT') {
+ headers['Content-Type'] = 'application/json';
+ body = await request.text();
+ }
+
+ try {
+ const res = await fetch(`${PULSE_BASE}/api/pulse/${pathStr}${qs}`, {
+ method,
+ headers,
+ body,
+ signal: AbortSignal.timeout(15000),
+ });
+ const data = await res.json();
+ return NextResponse.json(data, { status: res.status });
+ } catch (err) {
+ return NextResponse.json(
+ { error: 'Pulse service unavailable' },
+ { status: 502 },
+ );
+ }
+}
+
+export async function GET(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
+ return proxyToPulse(req, params, 'GET');
+}
+
+export async function POST(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
+ return proxyToPulse(req, params, 'POST');
+}
+
+export async function DELETE(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
+ return proxyToPulse(req, params, 'DELETE');
+}
+
+export async function PUT(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
+ return proxyToPulse(req, params, 'PUT');
+}
diff --git a/app/api/settings/credentials/route.ts b/app/api/settings/credentials/route.ts
new file mode 100644
index 0000000..334728e
--- /dev/null
+++ b/app/api/settings/credentials/route.ts
@@ -0,0 +1,146 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { exec } from 'child_process';
+import { promisify } from 'util';
+import fs from 'fs';
+import path from 'path';
+import { query } from '@/lib/db';
+import { verifyAuth } from '@/lib/auth';
+
+const execAsync = promisify(exec);
+
+const AGENTS_BASE = '/root/Projects/SDCC/agents';
+
+const AGENT_ENV_MAP: Record<string, { dir: string; pm2Name: string; vars: string[] }> = {
+ twitter: {
+ dir: path.join(AGENTS_BASE, 'twitter-agent'),
+ pm2Name: 'sdcc-twitter',
+ vars: ['TWITTER_API_KEY', 'TWITTER_API_SECRET', 'TWITTER_ACCESS_TOKEN', 'TWITTER_ACCESS_SECRET', 'TWITTER_BEARER_TOKEN'],
+ },
+ reddit: {
+ dir: path.join(AGENTS_BASE, 'reddit-agent'),
+ pm2Name: 'sdcc-reddit',
+ vars: ['REDDIT_CLIENT_ID', 'REDDIT_CLIENT_SECRET', 'REDDIT_USERNAME', 'REDDIT_PASSWORD'],
+ },
+ discord: {
+ dir: path.join(AGENTS_BASE, 'discord-agent'),
+ pm2Name: 'sdcc-discord',
+ vars: ['DISCORD_BOT_TOKEN', 'DISCORD_DEFAULT_CHANNEL_ID'],
+ },
+ bluesky: {
+ dir: path.join(AGENTS_BASE, 'bluesky-agent'),
+ pm2Name: 'sdcc-bluesky',
+ vars: ['BSKY_HANDLE', 'BSKY_PASSWORD'],
+ },
+};
+
+function maskValue(val: string): string {
+ if (!val || val.length < 4) return '***';
+ return val.slice(0, 3) + '*'.repeat(Math.min(val.length - 3, 12));
+}
+
+function buildEnvFile(vars: Record<string, string>): string {
+ return Object.entries(vars)
+ .filter(([, v]) => v)
+ .map(([k, v]) => `${k}=${v}`)
+ .join('\n') + '\n';
+}
+
+/**
+ * GET /api/settings/credentials
+ * Returns masked credential status per platform.
+ */
+export async function GET(request: NextRequest) {
+ const user = verifyAuth(request);
+ if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+ const result: Record<string, Record<string, string>> = {};
+
+ for (const [platform, cfg] of Object.entries(AGENT_ENV_MAP)) {
+ const envPath = path.join(cfg.dir, '.env');
+ result[platform] = {};
+
+ if (fs.existsSync(envPath)) {
+ const lines = fs.readFileSync(envPath, 'utf8').split('\n');
+ for (const line of lines) {
+ const [key, ...rest] = line.split('=');
+ const val = rest.join('=').trim();
+ if (key && cfg.vars.includes(key.trim())) {
+ result[platform][key.trim()] = val ? maskValue(val) : '';
+ }
+ }
+ }
+
+ // Ensure all expected vars appear (empty if not set)
+ for (const v of cfg.vars) {
+ if (!(v in result[platform])) result[platform][v] = '';
+ }
+ }
+
+ return NextResponse.json({ credentials: result });
+}
+
+/**
+ * POST /api/settings/credentials
+ * Saves credentials to .env files and restarts agents.
+ * Body: { platform: 'twitter'|'reddit'|'discord'|'bluesky', credentials: { KEY: 'val', ... } }
+ */
+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 { platform, credentials } = body as { platform: string; credentials: Record<string, string> };
+
+ const cfg = AGENT_ENV_MAP[platform];
+ if (!cfg) {
+ return NextResponse.json({ error: `Unknown platform: ${platform}` }, { status: 400 });
+ }
+
+ // Merge with existing .env (only update provided non-empty keys)
+ const envPath = path.join(cfg.dir, '.env');
+ const existing: Record<string, string> = {};
+
+ if (fs.existsSync(envPath)) {
+ const lines = fs.readFileSync(envPath, 'utf8').split('\n');
+ for (const line of lines) {
+ const eq = line.indexOf('=');
+ if (eq > 0) {
+ existing[line.slice(0, eq).trim()] = line.slice(eq + 1).trim();
+ }
+ }
+ }
+
+ const merged: Record<string, string> = { ...existing };
+ for (const [k, v] of Object.entries(credentials)) {
+ if (v && v.trim()) merged[k] = v.trim();
+ }
+
+ fs.writeFileSync(envPath, buildEnvFile(merged), 'utf8');
+
+ // Restart the agent so it picks up new env vars
+ let restartResult = 'ok';
+ try {
+ await execAsync(`pm2 restart ${cfg.pm2Name}`);
+ } catch (err) {
+ restartResult = (err as Error).message;
+ }
+
+ // Log to DB audit
+ await query(
+ `INSERT INTO audit_events (event_type, entity_type, entity_id, metadata)
+ VALUES ('credentials.saved', 'platform', $1, $2)`,
+ [platform, JSON.stringify({ keys_set: Object.keys(credentials).filter(k => credentials[k]), restart: restartResult })]
+ ).catch(() => {});
+
+ return NextResponse.json({
+ platform,
+ keys_saved: Object.keys(merged).length,
+ restarted: restartResult === 'ok',
+ restart_output: restartResult === 'ok' ? undefined : restartResult,
+ });
+ } catch (err) {
+ console.error('[credentials] POST error:', (err as Error).message);
+ return NextResponse.json({ error: 'Failed to save credentials' }, { status: 500 });
+ }
+}
diff --git a/app/api/settings/route.ts b/app/api/settings/route.ts
new file mode 100644
index 0000000..f8c781f
--- /dev/null
+++ b/app/api/settings/route.ts
@@ -0,0 +1,86 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { query } from '@/lib/db';
+import { verifyAuth } from '@/lib/auth';
+
+/**
+ * GET /api/settings
+ * Return all app settings as a merged object.
+ */
+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 key, value FROM app_settings ORDER BY key`);
+
+ const settings: Record<string, unknown> = {};
+ for (const row of result.rows) {
+ settings[row.key] = row.value;
+ }
+
+ // Also get data stats
+ const statsQueries = await Promise.all([
+ query(`SELECT COUNT(*)::int AS count FROM petitions`).catch(() => ({ rows: [{ count: 0 }] })),
+ query(`SELECT COUNT(*)::int AS count FROM signatures`).catch(() => ({ rows: [{ count: 0 }] })),
+ query(`SELECT COUNT(*)::int AS count FROM email_subscribers`).catch(() => ({ rows: [{ count: 0 }] })),
+ query(`SELECT COUNT(*)::int AS count FROM email_campaigns`).catch(() => ({ rows: [{ count: 0 }] })),
+ ]);
+
+ settings.data_stats = {
+ total_petitions: statsQueries[0].rows[0]?.count || 0,
+ total_signatures: statsQueries[1].rows[0]?.count || 0,
+ total_subscribers: statsQueries[2].rows[0]?.count || 0,
+ total_campaigns: statsQueries[3].rows[0]?.count || 0,
+ last_backup: new Date().toISOString(),
+ };
+
+ return NextResponse.json({ settings });
+ } catch (err) {
+ console.error('[api/settings] GET error:', (err as Error).message);
+ return NextResponse.json({ error: 'Failed to fetch settings' }, { status: 500 });
+ }
+}
+
+/**
+ * PATCH /api/settings
+ * Update one or more settings sections.
+ * Body: { org_profile?: {...}, petition_defaults?: {...}, campaign_settings?: {...}, ai_config?: {...} }
+ */
+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 allowedKeys = ['org_profile', 'petition_defaults', 'campaign_settings', 'ai_config'];
+ let updated = 0;
+
+ for (const key of allowedKeys) {
+ if (key in body && body[key] !== undefined) {
+ await query(
+ `INSERT INTO app_settings (key, value, updated_at)
+ VALUES ($1, $2, now())
+ ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = now()`,
+ [key, JSON.stringify(body[key])]
+ );
+ updated++;
+ }
+ }
+
+ if (updated === 0) {
+ return NextResponse.json({ error: 'No valid settings to update' }, { status: 400 });
+ }
+
+ // Re-fetch all settings
+ const result = await query(`SELECT key, value FROM app_settings ORDER BY key`);
+ const settings: Record<string, unknown> = {};
+ for (const row of result.rows) {
+ settings[row.key] = row.value;
+ }
+
+ return NextResponse.json({ settings, updated });
+ } catch (err) {
+ console.error('[api/settings] PATCH error:', (err as Error).message);
+ return NextResponse.json({ error: 'Failed to update settings' }, { status: 500 });
+ }
+}
diff --git a/app/api/signatures/route.ts b/app/api/signatures/route.ts
new file mode 100644
index 0000000..fd8c937
--- /dev/null
+++ b/app/api/signatures/route.ts
@@ -0,0 +1,106 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { query, getClient } 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 });
+
+ const url = new URL(request.url);
+ const petitionId = url.searchParams.get('petition_id');
+
+ if (!petitionId) {
+ return NextResponse.json({ error: 'petition_id query param is required' }, { status: 400 });
+ }
+
+ try {
+ const result = await query(
+ `SELECT * FROM signatures WHERE petition_id = $1 ORDER BY created_at DESC`,
+ [petitionId]
+ );
+
+ const countResult = await query(
+ `SELECT COUNT(*)::int as total FROM signatures WHERE petition_id = $1`,
+ [petitionId]
+ );
+
+ return NextResponse.json({
+ rows: result.rows,
+ count: countResult.rows[0]?.total || 0,
+ });
+ } catch (err) {
+ console.error('[api/signatures] GET error:', (err as Error).message);
+ return NextResponse.json({ error: 'Failed to fetch signatures' }, { 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 { petition_id, signer_email, signer_name, signer_zip, signer_comment, is_public, opted_in_email } = body;
+
+ if (!petition_id || !signer_email) {
+ return NextResponse.json({ error: 'petition_id and signer_email are required' }, { status: 400 });
+ }
+
+ // Get IP address
+ const forwarded = request.headers.get('x-forwarded-for');
+ const ip = forwarded ? forwarded.split(',')[0].trim() : 'unknown';
+
+ await client.query('BEGIN');
+
+ // Insert signature (UNIQUE constraint on petition_id + signer_email handles dedup)
+ const sigResult = await client.query(
+ `INSERT INTO signatures (petition_id, signer_email, signer_name, signer_zip, signer_comment, is_public, ip_address, opted_in_email)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
+ ON CONFLICT (petition_id, signer_email) DO NOTHING
+ RETURNING *`,
+ [
+ petition_id,
+ signer_email,
+ signer_name || null,
+ signer_zip || null,
+ signer_comment || null,
+ is_public !== false,
+ ip,
+ opted_in_email || false,
+ ]
+ );
+
+ if (sigResult.rows.length === 0) {
+ await client.query('ROLLBACK');
+ return NextResponse.json({ error: 'This email has already signed this petition' }, { status: 409 });
+ }
+
+ // Update petition signature_count
+ await client.query(
+ `UPDATE petitions SET signature_count = signature_count + 1 WHERE id = $1`,
+ [petition_id]
+ );
+
+ // If opted in, add to email_subscribers
+ if (opted_in_email) {
+ await client.query(
+ `INSERT INTO email_subscribers (email, name, zip_code, source, petition_ids)
+ VALUES ($1, $2, $3, 'petition', ARRAY[$4::uuid])
+ ON CONFLICT DO NOTHING`,
+ [signer_email, signer_name || null, signer_zip || null, petition_id]
+ );
+ }
+
+ await client.query('COMMIT');
+
+ return NextResponse.json({ row: sigResult.rows[0] }, { status: 201 });
+ } catch (err) {
+ await client.query('ROLLBACK');
+ console.error('[api/signatures] POST error:', (err as Error).message);
+ return NextResponse.json({ error: 'Failed to add signature' }, { status: 500 });
+ } finally {
+ client.release();
+ }
+}
diff --git a/app/api/subscribers/route.ts b/app/api/subscribers/route.ts
new file mode 100644
index 0000000..ce82352
--- /dev/null
+++ b/app/api/subscribers/route.ts
@@ -0,0 +1,176 @@
+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 });
+
+ const url = new URL(request.url);
+ const search = url.searchParams.get('search');
+ const source = url.searchParams.get('source');
+
+ let sql = `SELECT * FROM email_subscribers`;
+ const params: unknown[] = [];
+ const conditions: string[] = [];
+
+ if (search) {
+ params.push(`%${search}%`);
+ conditions.push(`(email ILIKE $${params.length} OR name ILIKE $${params.length})`);
+ }
+
+ if (source && source !== 'all') {
+ params.push(source);
+ conditions.push(`source = $${params.length}`);
+ }
+
+ if (conditions.length > 0) {
+ sql += ` WHERE ` + conditions.join(' AND ');
+ }
+
+ sql += ` ORDER BY created_at DESC`;
+
+ try {
+ const result = await query(sql, params);
+
+ // Get stats
+ const statsResult = await query(`
+ SELECT
+ COUNT(*)::int as total,
+ COUNT(*) FILTER (WHERE is_active = true)::int as active,
+ COUNT(*) FILTER (WHERE is_active = false)::int as inactive
+ FROM email_subscribers
+ `);
+
+ const sourceResult = await query(`
+ SELECT source, COUNT(*)::int as count
+ FROM email_subscribers
+ GROUP BY source
+ ORDER BY count DESC
+ `);
+
+ const bySource: Record<string, number> = {};
+ for (const row of sourceResult.rows) {
+ bySource[row.source || 'unknown'] = row.count;
+ }
+
+ const stats = {
+ total: statsResult.rows[0]?.total || 0,
+ active: statsResult.rows[0]?.active || 0,
+ inactive: statsResult.rows[0]?.inactive || 0,
+ by_source: bySource,
+ };
+
+ return NextResponse.json({ rows: result.rows, stats });
+ } catch (err) {
+ console.error('[api/subscribers] GET error:', (err as Error).message);
+ return NextResponse.json({ error: 'Failed to fetch subscribers' }, { 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 { email, name, zip_code, source, tags } = body;
+
+ if (!email) {
+ return NextResponse.json({ error: 'Email is required' }, { status: 400 });
+ }
+
+ // Get user_id
+ const userResult = await query(`SELECT id FROM users LIMIT 1`);
+ const userId = userResult.rows[0]?.id || null;
+
+ const result = await query(
+ `INSERT INTO email_subscribers (user_id, email, name, zip_code, source, tags)
+ VALUES ($1, $2, $3, $4, $5, $6)
+ RETURNING *`,
+ [
+ userId,
+ email,
+ name || null,
+ zip_code || null,
+ source || 'manual',
+ tags || null,
+ ]
+ );
+
+ return NextResponse.json({ row: result.rows[0] }, { status: 201 });
+ } catch (err) {
+ console.error('[api/subscribers] POST error:', (err as Error).message);
+ return NextResponse.json({ error: 'Failed to add subscriber' }, { 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, ...updates } = body;
+
+ if (!id) {
+ return NextResponse.json({ error: 'Subscriber id is required' }, { status: 400 });
+ }
+
+ const allowedFields = ['name', 'zip_code', 'source', 'tags', 'is_active', 'petition_ids'];
+ const setClauses: string[] = [];
+ const params: unknown[] = [];
+
+ for (const [key, value] of Object.entries(updates)) {
+ if (allowedFields.includes(key)) {
+ params.push(value);
+ setClauses.push(`${key} = $${params.length}`);
+ }
+ }
+
+ if (setClauses.length === 0) {
+ return NextResponse.json({ error: 'No valid fields to update' }, { status: 400 });
+ }
+
+ params.push(id);
+ const sql = `UPDATE email_subscribers SET ${setClauses.join(', ')} WHERE id = $${params.length} RETURNING *`;
+
+ const result = await query(sql, params);
+ if (result.rows.length === 0) {
+ return NextResponse.json({ error: 'Subscriber not found' }, { status: 404 });
+ }
+
+ return NextResponse.json({ row: result.rows[0] });
+ } catch (err) {
+ console.error('[api/subscribers] PATCH error:', (err as Error).message);
+ return NextResponse.json({ error: 'Failed to update subscriber' }, { status: 500 });
+ }
+}
+
+export async function DELETE(request: NextRequest) {
+ const user = verifyAuth(request);
+ if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+ try {
+ const url = new URL(request.url);
+ const id = url.searchParams.get('id');
+
+ if (!id) {
+ return NextResponse.json({ error: 'Subscriber id is required' }, { status: 400 });
+ }
+
+ const result = await query(
+ `UPDATE email_subscribers SET is_active = false, unsubscribed_at = NOW() WHERE id = $1 RETURNING *`,
+ [id]
+ );
+
+ if (result.rows.length === 0) {
+ return NextResponse.json({ error: 'Subscriber not found' }, { status: 404 });
+ }
+
+ return NextResponse.json({ row: result.rows[0] });
+ } catch (err) {
+ console.error('[api/subscribers] DELETE error:', (err as Error).message);
+ return NextResponse.json({ error: 'Failed to unsubscribe' }, { status: 500 });
+ }
+}
diff --git a/app/api/templates/route.ts b/app/api/templates/route.ts
new file mode 100644
index 0000000..dd35f86
--- /dev/null
+++ b/app/api/templates/route.ts
@@ -0,0 +1,62 @@
+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 });
+
+ const url = new URL(request.url);
+ const category = url.searchParams.get('category');
+
+ let sql = `SELECT * FROM petition_templates`;
+ const params: unknown[] = [];
+
+ if (category && category !== 'all') {
+ params.push(category);
+ sql += ` WHERE category = $${params.length}`;
+ }
+
+ sql += ` ORDER BY is_featured DESC, usage_count DESC, created_at DESC`;
+
+ try {
+ const result = await query(sql, params);
+ return NextResponse.json({ rows: result.rows });
+ } catch (err) {
+ console.error('[api/templates] GET error:', (err as Error).message);
+ return NextResponse.json({ error: 'Failed to fetch templates' }, { 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, category, body_html, body_text, tags, is_featured } = body;
+
+ if (!title || !body_html) {
+ return NextResponse.json({ error: 'Title and body_html are required' }, { status: 400 });
+ }
+
+ const result = await query(
+ `INSERT INTO petition_templates (title, category, body_html, body_text, tags, is_featured)
+ VALUES ($1, $2, $3, $4, $5, $6)
+ RETURNING *`,
+ [
+ title,
+ category || null,
+ body_html,
+ body_text || null,
+ tags || null,
+ is_featured || false,
+ ]
+ );
+
+ return NextResponse.json({ row: result.rows[0] }, { status: 201 });
+ } catch (err) {
+ console.error('[api/templates] POST error:', (err as Error).message);
+ return NextResponse.json({ error: 'Failed to create template' }, { status: 500 });
+ }
+}
diff --git a/app/api/trending/discover/route.ts b/app/api/trending/discover/route.ts
new file mode 100644
index 0000000..27a55f3
--- /dev/null
+++ b/app/api/trending/discover/route.ts
@@ -0,0 +1,201 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { query } from '@/lib/db';
+import { verifyAuth } from '@/lib/auth';
+import { callGemini } from '@/lib/gemini';
+
+/**
+ * POST /api/trending/discover
+ * Scans real Kalshi prediction markets + recent news articles from the DB,
+ * then uses Gemini AI to derive trending topics with auto-generated tags.
+ */
+export async function POST(request: NextRequest) {
+ const user = verifyAuth(request);
+ if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+ try {
+ // 1. Gather REAL data from our sources
+ const [marketsRes, articlesRes] = await Promise.all([
+ // Top prediction markets by volume — real Kalshi data
+ query(`SELECT DISTINCT ON (title) title, category, volume, last_price, yes_bid, no_bid
+ FROM orbit_markets
+ WHERE volume > 10000
+ ORDER BY title, volume DESC
+ LIMIT 30`),
+ // Most recent news articles
+ query(`SELECT title, url, summary, sentiment, tags, published_at
+ FROM orbit_articles
+ ORDER BY published_at DESC
+ LIMIT 40`),
+ ]);
+
+ const markets = marketsRes.rows;
+ const articles = articlesRes.rows;
+
+ if (markets.length === 0 && articles.length === 0) {
+ return NextResponse.json({ error: 'No source data available. Sync markets first.' }, { status: 400 });
+ }
+
+ // 2. Build context from real data
+ const marketSummary = markets.map(m =>
+ `- "${m.title}" [${m.category}] volume=${m.volume} last_price=${m.last_price}¢`
+ ).join('\n');
+
+ const articleSummary = articles.slice(0, 25).map(a =>
+ `- "${a.title}" (${a.published_at ? new Date(a.published_at).toLocaleDateString() : 'recent'})`
+ ).join('\n');
+
+ // Get the actual categories from our market data
+ const categorySet = new Set(markets.map(m => m.category).filter(Boolean));
+ const realCategories = [...categorySet];
+
+ const prompt = `You are an AI trend analyst. Analyze the following REAL prediction market data and news articles to identify 8-12 compelling trending topics that would make strong petitions or advocacy campaigns.
+
+## REAL PREDICTION MARKETS (Kalshi):
+${marketSummary}
+
+## RECENT NEWS HEADLINES:
+${articleSummary}
+
+## INSTRUCTIONS:
+1. Derive topics ONLY from the real data above — do NOT invent topics
+2. Combine related markets + articles into unified trending themes
+3. Calculate engagement_score based on market volume + news coverage (40-95 scale)
+4. Generate 3-5 descriptive tags for each topic by analyzing the content
+5. Assign sentiment based on whether the topic is opportunity (positive), crisis (negative), debate (mixed), or informational (neutral)
+
+## REAL CATEGORIES to use: ${realCategories.join(', ')}
+You may also use: Economy, National Security, Technology, Environment, Healthcare, Social Issues, Foreign Policy
+
+Return a JSON array of objects with EXACTLY these fields:
+{
+ "title": "Short, compelling petition-ready title (max 80 chars)",
+ "content": "2-3 sentence description connecting market data + news context, explaining why this matters for advocacy",
+ "source": "Kalshi Markets" or "News" or "Kalshi + News" (based on where the data came from),
+ "source_url": null,
+ "engagement_score": 75,
+ "sentiment": "positive" | "negative" | "neutral" | "mixed",
+ "category": "one of the real categories listed above",
+ "tags": ["specific", "relevant", "descriptive", "tags", "from-content"]
+}
+
+IMPORTANT: Tags should be specific and derived from the actual content (e.g., "fed-chair-nomination", "greenland-acquisition", "iran-nuclear"), NOT generic (e.g., "politics", "trending").`;
+
+ type TrendingTopic = {
+ title: string;
+ content: string;
+ source: string;
+ source_url?: string;
+ engagement_score: number;
+ sentiment: string;
+ category: string;
+ tags: string[];
+ };
+
+ // 2026-05-05 (tick 29): migrated to lib/gemini.ts wrapper.
+ const r = await callGemini<TrendingTopic[]>({ prompt, maxTokens: 4096, temperature: 0.7 });
+ if (!r.ok) {
+ console.error('[trending/discover] gemini failed:', r.reason, r.detail);
+ return NextResponse.json(
+ { error: r.reason === 'no_key' ? 'AI service not configured' : 'AI discovery failed' },
+ { status: r.status },
+ );
+ }
+ const topics = r.data;
+
+ if (!Array.isArray(topics)) {
+ return NextResponse.json({ error: 'AI returned non-array data' }, { status: 502 });
+ }
+
+ // 3. Clear expired/old trending topics before inserting new ones
+ await query(`DELETE FROM trending_topics WHERE expires_at < NOW() OR created_at < NOW() - INTERVAL '7 days'`);
+
+ // 4. Insert each AI-derived topic
+ const inserted = [];
+ for (const topic of topics) {
+ try {
+ const result = await query(
+ `INSERT INTO trending_topics (source, source_url, title, content, engagement_score, sentiment, category, tags, expires_at)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW() + INTERVAL '7 days')
+ RETURNING *`,
+ [
+ topic.source || 'Kalshi + News',
+ topic.source_url || null,
+ topic.title,
+ topic.content || null,
+ topic.engagement_score || 50,
+ topic.sentiment || 'neutral',
+ topic.category || 'Politics',
+ topic.tags || null,
+ ]
+ );
+ inserted.push(result.rows[0]);
+ } catch (insertErr) {
+ console.error('[api/trending/discover] Insert error:', (insertErr as Error).message);
+ }
+ }
+
+ // 5. Also scan articles and tag them using AI (async, fire-and-forget)
+ tagArticlesInBackground().catch(err => {
+ console.error('[api/trending/discover] Background tag scan error:', err.message);
+ });
+
+ return NextResponse.json({
+ rows: inserted,
+ count: inserted.length,
+ sources: { markets: markets.length, articles: articles.length },
+ });
+ } catch (err) {
+ console.error('[api/trending/discover] Error:', (err as Error).message);
+ return NextResponse.json({ error: 'Failed to discover trends' }, { status: 500 });
+ }
+}
+
+/**
+ * Background task: scan untagged articles and generate AI tags.
+ * Updates orbit_articles.tags for articles that have no tags.
+ */
+async function tagArticlesInBackground() {
+ const untagged = await query(
+ `SELECT id, title, summary FROM orbit_articles
+ WHERE tags IS NULL OR array_length(tags, 1) IS NULL
+ ORDER BY published_at DESC LIMIT 50`
+ );
+
+ if (untagged.rows.length === 0) return;
+
+ const articleList = untagged.rows.map(a =>
+ `ID:${a.id} | "${a.title}"${a.summary ? ' | ' + a.summary.slice(0, 100) : ''}`
+ ).join('\n');
+
+ const tagPrompt = `Analyze these news articles and generate 2-4 specific, descriptive tags for each one. Tags should be lowercase, hyphenated, and content-specific (e.g., "fed-chair-nomination", "iran-nuclear-deal", "greenland-sovereignty").
+
+Articles:
+${articleList}
+
+Return a JSON object where keys are article IDs and values are arrays of tags:
+{"uuid-here": ["tag1", "tag2", "tag3"], ...}`;
+
+ // 2026-05-05 (tick 29): migrated to lib/gemini.ts wrapper.
+ const tagR = await callGemini<Record<string, string[]>>({ prompt: tagPrompt, maxTokens: 4096, temperature: 0.3 });
+ if (!tagR.ok) {
+ console.error('[tag-scan] gemini failed:', tagR.reason);
+ return;
+ }
+ const tagMap = tagR.data;
+
+ let taggedCount = 0;
+ for (const [articleId, tags] of Object.entries(tagMap)) {
+ if (!Array.isArray(tags) || tags.length === 0) continue;
+ try {
+ await query(
+ `UPDATE orbit_articles SET tags = $1 WHERE id = $2`,
+ [tags, articleId]
+ );
+ taggedCount++;
+ } catch {
+ // Skip invalid UUIDs or missing articles
+ }
+ }
+
+ console.log(`[tag-scan] Tagged ${taggedCount}/${untagged.rows.length} articles`);
+}
diff --git a/app/api/trending/route.ts b/app/api/trending/route.ts
new file mode 100644
index 0000000..1b81c03
--- /dev/null
+++ b/app/api/trending/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 });
+
+ const url = new URL(request.url);
+ const category = url.searchParams.get('category');
+
+ let sql = `SELECT * FROM trending_topics`;
+ const params: unknown[] = [];
+ const conditions: string[] = [];
+
+ if (category && category !== 'all') {
+ params.push(category);
+ conditions.push(`category = $${params.length}`);
+ }
+
+ if (conditions.length > 0) {
+ sql += ` WHERE ` + conditions.join(' AND ');
+ }
+
+ sql += ` ORDER BY engagement_score DESC NULLS LAST, created_at DESC`;
+
+ try {
+ const result = await query(sql, params);
+ return NextResponse.json({ rows: result.rows });
+ } catch (err) {
+ console.error('[api/trending] GET error:', (err as Error).message);
+ return NextResponse.json({ error: 'Failed to fetch trending topics' }, { 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 { source, source_url, title, content, engagement_score, sentiment, category, tags } = body;
+
+ if (!title || !source) {
+ return NextResponse.json({ error: 'Title and source are required' }, { status: 400 });
+ }
+
+ const result = await query(
+ `INSERT INTO trending_topics (source, source_url, title, content, engagement_score, sentiment, category, tags, expires_at)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW() + INTERVAL '7 days')
+ RETURNING *`,
+ [
+ source,
+ source_url || null,
+ title,
+ content || null,
+ engagement_score || null,
+ sentiment || null,
+ category || null,
+ tags || null,
+ ]
+ );
+
+ return NextResponse.json({ row: result.rows[0] }, { status: 201 });
+ } catch (err) {
+ console.error('[api/trending] POST error:', (err as Error).message);
+ return NextResponse.json({ error: 'Failed to create trending topic' }, { status: 500 });
+ }
+}
diff --git a/app/favicon.ico b/app/favicon.ico
new file mode 100644
index 0000000..718d6fe
Binary files /dev/null and b/app/favicon.ico differ
diff --git a/app/globals.css b/app/globals.css
new file mode 100644
index 0000000..566bddd
--- /dev/null
+++ b/app/globals.css
@@ -0,0 +1,365 @@
+@import "tailwindcss";
+
+/* ─── Patty Design Tokens ─────────────────────────────────────────────────── */
+:root {
+ /* Backgrounds */
+ --color-bg: #0a0a12;
+ --color-surface: #12121e;
+ --color-surface-el: #1a1a2e;
+
+ /* Brand — Violet */
+ --color-primary: #7c3aed;
+ --color-primary-hover:#6d28d9;
+ --color-secondary: #a78bfa;
+
+ /* Accent */
+ --color-accent: #c084fc;
+
+ /* Semantic */
+ --color-success: #22c55e;
+ --color-warning: #f59e0b;
+ --color-error: #ef4444;
+
+ /* Text */
+ --color-text: #fafafa;
+ --color-text-secondary: #a1a1aa;
+ --color-text-muted: #71717a;
+
+ /* Structure */
+ --color-border: #2a2a3c;
+
+ /* 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: #3a3a50;
+}
+
+.card-elevated {
+ background-color: var(--color-surface-el);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-lg);
+ padding: 1rem;
+}
+
+/* ─── Buttons ────────────────────────────────────────────────────────────── */
+.btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.375rem;
+ padding: 0.5rem 1rem;
+ border-radius: var(--radius-md);
+ font-size: 0.875rem;
+ font-weight: 500;
+ line-height: 1.25rem;
+ cursor: pointer;
+ border: 1px solid transparent;
+ transition: background-color var(--transition), color var(--transition),
+ border-color var(--transition), opacity var(--transition);
+ white-space: nowrap;
+ user-select: none;
+}
+
+.btn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.btn:focus-visible {
+ outline: 2px solid var(--color-primary);
+ outline-offset: 2px;
+}
+
+.btn-primary {
+ background-color: var(--color-primary);
+ color: #fff;
+ border-color: var(--color-primary);
+}
+
+.btn-primary:hover:not(:disabled) {
+ background-color: var(--color-primary-hover);
+ border-color: var(--color-primary-hover);
+}
+
+.btn-secondary {
+ background-color: var(--color-surface-el);
+ color: var(--color-text);
+ border-color: var(--color-border);
+}
+
+.btn-secondary:hover:not(:disabled) {
+ background-color: #22223a;
+ border-color: #3a3a50;
+}
+
+.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;
+}
+
+/* Status badges */
+.badge-draft {
+ background-color: rgba(161, 161, 170, 0.15);
+ color: var(--color-text-secondary);
+ border: 1px solid rgba(161, 161, 170, 0.3);
+}
+
+.badge-active {
+ background-color: rgba(34, 197, 94, 0.15);
+ color: var(--color-success);
+ border: 1px solid rgba(34, 197, 94, 0.3);
+}
+
+.badge-paused {
+ background-color: rgba(245, 158, 11, 0.15);
+ color: var(--color-warning);
+ border: 1px solid rgba(245, 158, 11, 0.3);
+}
+
+.badge-closed {
+ background-color: rgba(239, 68, 68, 0.15);
+ color: var(--color-error);
+ border: 1px solid rgba(239, 68, 68, 0.3);
+}
+
+.badge-delivered {
+ background-color: rgba(124, 58, 237, 0.15);
+ color: var(--color-primary);
+ border: 1px solid rgba(124, 58, 237, 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);
+}
+
+/* ─── 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(124, 58, 237, 0.2);
+}
+
+/* ─── Utility Helpers ────────────────────────────────────────────────────── */
+.divider {
+ border: none;
+ border-top: 1px solid var(--color-border);
+ margin: 0;
+}
+
+.text-primary { color: var(--color-text); }
+.text-secondary { color: var(--color-text-secondary); }
+.text-muted { color: var(--color-text-muted); }
+.text-brand { color: var(--color-primary); }
+
+.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; }
+}
+
+/* ─── Stat Card ──────────────────────────────────────────────────────────── */
+.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: #3a3a50;
+ transform: translateY(-1px);
+}
+
+/* ─── 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;
+ }
+}
+
+/* ─── Public Petition Page ──────────────────────────────────────────────── */
+@media (max-width: 860px) {
+ /* Stack the two-column layout on mobile/tablet */
+ .petition-layout {
+ flex-direction: column !important;
+ }
+ .petition-sidebar {
+ width: 100% !important;
+ position: static !important;
+ }
+}
diff --git a/app/icon.svg b/app/icon.svg
new file mode 100644
index 0000000..bf1b38a
--- /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="#7c3aed"/>
+<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..7e6350d
--- /dev/null
+++ b/app/layout.tsx
@@ -0,0 +1,21 @@
+import type { Metadata } from 'next';
+import './globals.css';
+
+export const metadata: Metadata = {
+ title: 'Pulse — News & Advocacy Dashboard',
+ description: 'Unified news intelligence, AI topic analysis, and petition generation platform',
+};
+
+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..e0512b5
--- /dev/null
+++ b/app/login/page.tsx
@@ -0,0 +1,161 @@
+'use client';
+
+import { useState, FormEvent } from 'react';
+import { useRouter } from 'next/navigation';
+import { Loader2 } from 'lucide-react';
+
+export default function LoginPage() {
+ const router = useRouter();
+ const [username, setUsername] = useState('');
+ const [password, setPassword] = useState('');
+ const [error, setError] = useState('');
+ const [loading, setLoading] = useState(false);
+
+ async function handleSubmit(e: FormEvent<HTMLFormElement>) {
+ e.preventDefault();
+ setError('');
+ setLoading(true);
+
+ try {
+ const res = await fetch('/api/auth/login', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ username, password }),
+ });
+
+ const data = await res.json();
+
+ if (res.ok && data.success) {
+ router.push('/');
+ router.refresh();
+ } else {
+ setError(data.error ?? 'Login failed. Please try again.');
+ }
+ } catch {
+ setError('Network error. Please try again.');
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ return (
+ <div
+ className="min-h-screen flex items-center justify-center px-4"
+ style={{ backgroundColor: 'var(--color-bg)' }}
+ >
+ <div className="w-full max-w-sm">
+ {/* Logo / Brand mark */}
+ <div className="text-center mb-8">
+ <div
+ className="inline-flex items-center justify-center w-14 h-14 rounded-2xl mb-4"
+ style={{
+ background: 'linear-gradient(135deg, #7c3aed, #6d28d9)',
+ boxShadow: '0 4px 16px rgba(124, 58, 237, 0.3)',
+ }}
+ >
+ <span className="text-2xl font-black" style={{ color: '#fff' }}>
+ P
+ </span>
+ </div>
+ <h1 className="text-2xl font-bold" style={{ color: 'var(--color-text)' }}>
+ Patty
+ </h1>
+ <p className="mt-1 text-sm" style={{ color: 'var(--color-text-secondary)' }}>
+ The Petition Specialist
+ </p>
+ </div>
+
+ {/* Card */}
+ <div
+ className="rounded-xl p-6"
+ style={{
+ backgroundColor: 'var(--color-surface)',
+ border: '1px solid var(--color-border)',
+ }}
+ >
+ <form onSubmit={handleSubmit} noValidate className="space-y-4">
+ {/* Error message */}
+ {error && (
+ <div
+ className="px-3 py-2.5 rounded-lg text-sm"
+ style={{
+ backgroundColor: 'rgba(239,68,68,0.1)',
+ border: '1px solid rgba(239,68,68,0.3)',
+ color: 'var(--color-error)',
+ }}
+ role="alert"
+ >
+ {error}
+ </div>
+ )}
+
+ {/* Username */}
+ <div>
+ <label
+ htmlFor="username"
+ className="block text-sm font-medium mb-1.5"
+ style={{ color: 'var(--color-text-secondary)' }}
+ >
+ Username
+ </label>
+ <input
+ id="username"
+ type="text"
+ autoComplete="username"
+ autoFocus
+ required
+ className="input"
+ placeholder="admin"
+ value={username}
+ onChange={(e) => setUsername(e.target.value)}
+ disabled={loading}
+ />
+ </div>
+
+ {/* Password */}
+ <div>
+ <label
+ htmlFor="password"
+ className="block text-sm font-medium mb-1.5"
+ style={{ color: 'var(--color-text-secondary)' }}
+ >
+ Password
+ </label>
+ <input
+ id="password"
+ type="password"
+ autoComplete="current-password"
+ required
+ className="input"
+ placeholder="Enter password"
+ value={password}
+ onChange={(e) => setPassword(e.target.value)}
+ disabled={loading}
+ />
+ </div>
+
+ {/* Submit */}
+ <button
+ type="submit"
+ disabled={loading || !username || !password}
+ className="btn btn-primary btn-lg w-full mt-2"
+ >
+ {loading ? (
+ <>
+ <Loader2 size={16} className="animate-spin" />
+ Signing in...
+ </>
+ ) : (
+ 'Sign In'
+ )}
+ </button>
+ </form>
+ </div>
+
+ <p className="text-center text-xs mt-6" style={{ color: 'var(--color-text-muted)' }}>
+ Patty -- AI-Powered Petition Platform
+ </p>
+ </div>
+ </div>
+ );
+}
diff --git a/app/page.tsx b/app/page.tsx
new file mode 100644
index 0000000..c6bdf56
--- /dev/null
+++ b/app/page.tsx
@@ -0,0 +1,5 @@
+import AppShell from '@/components/AppShell';
+
+export default function Home() {
+ return <AppShell />;
+}
diff --git a/app/petitions/[slug]/PostToPlatformsPanel.tsx b/app/petitions/[slug]/PostToPlatformsPanel.tsx
new file mode 100644
index 0000000..a09cd9f
--- /dev/null
+++ b/app/petitions/[slug]/PostToPlatformsPanel.tsx
@@ -0,0 +1,646 @@
+'use client';
+
+import { useState, useEffect, useCallback } from 'react';
+
+interface PlatformDef {
+ id: string;
+ label: string;
+ color: string;
+ icon: string;
+ hasConfig?: boolean;
+ configLabel?: string;
+ configPlaceholder?: string;
+ configDefault?: string;
+ configType?: 'select' | 'text';
+ configOptions?: string[];
+}
+
+interface PostLog {
+ id: string;
+ platform: string;
+ status: string;
+ error_msg: string | null;
+ actor: string | null;
+ posted_at: string | null;
+ created_at: string;
+ post_url: string | null;
+ platform_config: Record<string, string> | null;
+}
+
+interface PostToPlatformsPanelProps {
+ petitionSlug: string;
+ petitionTitle: string;
+ petitionId: string;
+}
+
+const PLATFORMS: PlatformDef[] = [
+ {
+ id: 'reddit',
+ label: 'Reddit',
+ color: '#FF4500',
+ icon: 'R',
+ hasConfig: true,
+ configLabel: 'Subreddit',
+ configType: 'select',
+ configOptions: [
+ 'studentloans',
+ 'StudentDebt',
+ 'PSLF',
+ 'personalfinance',
+ 'politics',
+ 'lostgeneration',
+ 'antiwork',
+ ],
+ configDefault: 'studentloans',
+ },
+ {
+ id: 'discord',
+ label: 'Discord',
+ color: '#5865F2',
+ icon: 'D',
+ hasConfig: true,
+ configLabel: 'Channel ID',
+ configType: 'text',
+ configPlaceholder: 'e.g. 1234567890',
+ },
+ {
+ id: 'twitter',
+ label: 'X / Twitter',
+ color: '#1DA1F2',
+ icon: 'X',
+ },
+ {
+ id: 'bluesky',
+ label: 'Bluesky',
+ color: '#0085FF',
+ icon: 'B',
+ },
+ {
+ id: 'moveon',
+ label: 'MoveOn',
+ color: '#E03131',
+ icon: 'M',
+ },
+ {
+ id: 'patty',
+ label: 'Launch on Patty',
+ color: '#7c3aed',
+ icon: 'P',
+ },
+];
+
+export default function PostToPlatformsPanel({
+ petitionSlug,
+ petitionTitle,
+ petitionId,
+}: PostToPlatformsPanelProps) {
+ const [enabled, setEnabled] = useState<Record<string, boolean>>({});
+ const [configs, setConfigs] = useState<Record<string, Record<string, string>>>({});
+ const [posting, setPosting] = useState(false);
+ const [postResult, setPostResult] = useState<{
+ results: Array<{ platform: string; status: string; message: string }>;
+ summary: { total: number; posted: number; failed: number };
+ } | null>(null);
+ const [logs, setLogs] = useState<PostLog[]>([]);
+ const [logsLoading, setLogsLoading] = useState(true);
+ const [expanded, setExpanded] = useState(false);
+
+ // Initialize defaults
+ useEffect(() => {
+ const defaultEnabled: Record<string, boolean> = {};
+ const defaultConfigs: Record<string, Record<string, string>> = {};
+ for (const p of PLATFORMS) {
+ defaultEnabled[p.id] = false;
+ if (p.hasConfig && p.configDefault) {
+ defaultConfigs[p.id] = { [p.configLabel?.toLowerCase().replace(/\s/g, '_') || 'value']: p.configDefault };
+ }
+ }
+ setEnabled(defaultEnabled);
+ setConfigs(defaultConfigs);
+ }, []);
+
+ const fetchLogs = useCallback(async () => {
+ try {
+ const res = await fetch(`/api/petitions/${petitionSlug}/post-log`);
+ if (res.ok) {
+ const data = await res.json();
+ setLogs(data.logs || []);
+ }
+ } catch {
+ // silently fail
+ } finally {
+ setLogsLoading(false);
+ }
+ }, [petitionSlug]);
+
+ useEffect(() => {
+ fetchLogs();
+ }, [fetchLogs]);
+
+ function togglePlatform(id: string) {
+ setEnabled((prev) => ({ ...prev, [id]: !prev[id] }));
+ }
+
+ function selectAll() {
+ const next: Record<string, boolean> = {};
+ for (const p of PLATFORMS) next[p.id] = true;
+ setEnabled(next);
+ }
+
+ function selectNone() {
+ const next: Record<string, boolean> = {};
+ for (const p of PLATFORMS) next[p.id] = false;
+ setEnabled(next);
+ }
+
+ function updateConfig(platformId: string, key: string, value: string) {
+ setConfigs((prev) => ({
+ ...prev,
+ [platformId]: { ...prev[platformId], [key]: value },
+ }));
+ }
+
+ async function handlePost() {
+ const selectedPlatforms = PLATFORMS.filter((p) => enabled[p.id]).map((p) => ({
+ name: p.id,
+ enabled: true,
+ config: configs[p.id] || {},
+ }));
+
+ if (selectedPlatforms.length === 0) return;
+
+ setPosting(true);
+ setPostResult(null);
+
+ try {
+ const res = await fetch(`/api/petitions/${petitionSlug}/post`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ platforms: selectedPlatforms, action: 'post' }),
+ });
+
+ const data = await res.json();
+
+ if (res.ok) {
+ setPostResult({
+ results: data.results || [],
+ summary: data.summary || { total: 0, posted: 0, failed: 0 },
+ });
+ // Refresh logs
+ fetchLogs();
+ } else {
+ setPostResult({
+ results: [{ platform: 'system', status: 'failed', message: data.error || 'Request failed' }],
+ summary: { total: 1, posted: 0, failed: 1 },
+ });
+ }
+ } catch {
+ setPostResult({
+ results: [{ platform: 'system', status: 'failed', message: 'Network error' }],
+ summary: { total: 1, posted: 0, failed: 1 },
+ });
+ } finally {
+ setPosting(false);
+ }
+ }
+
+ const enabledCount = Object.values(enabled).filter(Boolean).length;
+
+ const statusColor = (status: string) => {
+ switch (status) {
+ case 'posted': return 'var(--color-success)';
+ case 'failed': return 'var(--color-error)';
+ case 'pending': return 'var(--color-warning)';
+ case 'skipped': return 'var(--color-text-muted)';
+ default: return 'var(--color-text-muted)';
+ }
+ };
+
+ const platformColor = (name: string) => {
+ const p = PLATFORMS.find((pl) => pl.id === name);
+ return p?.color || 'var(--color-text-muted)';
+ };
+
+ return (
+ <div style={{
+ backgroundColor: 'var(--color-surface)',
+ border: '1px solid var(--color-border)',
+ borderRadius: 'var(--radius-lg)',
+ padding: '1.5rem',
+ }}>
+ {/* Header */}
+ <button
+ onClick={() => setExpanded(!expanded)}
+ style={{
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ width: '100%',
+ background: 'none',
+ border: 'none',
+ cursor: 'pointer',
+ padding: 0,
+ }}
+ >
+ <h3 style={{
+ color: 'var(--color-text)',
+ fontSize: '1rem',
+ fontWeight: 700,
+ display: 'flex',
+ alignItems: 'center',
+ gap: '0.5rem',
+ margin: 0,
+ }}>
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--color-primary)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
+ <path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8"/>
+ <polyline points="16 6 12 2 8 6"/>
+ <line x1="12" y1="2" x2="12" y2="15"/>
+ </svg>
+ Post to Platforms
+ </h3>
+ <svg
+ width="16"
+ height="16"
+ viewBox="0 0 24 24"
+ fill="none"
+ stroke="var(--color-text-muted)"
+ strokeWidth="2"
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ style={{
+ transition: 'transform 150ms ease',
+ transform: expanded ? 'rotate(180deg)' : 'rotate(0deg)',
+ }}
+ >
+ <polyline points="6 9 12 15 18 9"/>
+ </svg>
+ </button>
+
+ {/* Expandable Content */}
+ <div style={{
+ maxHeight: expanded ? '2000px' : '0',
+ overflow: 'hidden',
+ transition: 'max-height 300ms cubic-bezier(0.4, 0, 0.2, 1)',
+ }}>
+ <div style={{ paddingTop: '1rem' }}>
+ {/* Platform toggles */}
+ <div style={{
+ display: 'flex',
+ flexWrap: 'wrap',
+ gap: '0.5rem',
+ marginBottom: '0.75rem',
+ }}>
+ {PLATFORMS.map((p) => (
+ <label
+ key={p.id}
+ style={{
+ display: 'flex',
+ alignItems: 'center',
+ gap: '0.375rem',
+ padding: '0.375rem 0.625rem',
+ borderRadius: 'var(--radius-sm)',
+ backgroundColor: enabled[p.id]
+ ? `${p.color}18`
+ : 'var(--color-surface-el)',
+ border: `1px solid ${enabled[p.id] ? `${p.color}55` : 'var(--color-border)'}`,
+ cursor: 'pointer',
+ fontSize: '0.8125rem',
+ fontWeight: 500,
+ color: enabled[p.id] ? p.color : 'var(--color-text-secondary)',
+ transition: 'all 150ms ease',
+ userSelect: 'none',
+ }}
+ >
+ <input
+ type="checkbox"
+ checked={enabled[p.id] || false}
+ onChange={() => togglePlatform(p.id)}
+ style={{
+ accentColor: p.color,
+ cursor: 'pointer',
+ width: '0.875rem',
+ height: '0.875rem',
+ }}
+ />
+ <span style={{
+ width: '6px',
+ height: '6px',
+ borderRadius: '50%',
+ backgroundColor: p.color,
+ flexShrink: 0,
+ }} />
+ {p.label}
+ </label>
+ ))}
+ </div>
+
+ {/* All / None buttons */}
+ <div style={{ display: 'flex', gap: '0.5rem', marginBottom: '0.75rem' }}>
+ <button
+ onClick={selectAll}
+ style={{
+ fontSize: '0.75rem',
+ padding: '0.125rem 0.5rem',
+ borderRadius: 'var(--radius-sm)',
+ border: '1px solid rgba(124, 58, 237, 0.4)',
+ background: 'transparent',
+ color: 'var(--color-primary)',
+ cursor: 'pointer',
+ }}
+ >
+ All
+ </button>
+ <button
+ onClick={selectNone}
+ style={{
+ fontSize: '0.75rem',
+ padding: '0.125rem 0.5rem',
+ borderRadius: 'var(--radius-sm)',
+ border: '1px solid var(--color-border)',
+ background: 'transparent',
+ color: 'var(--color-text-muted)',
+ cursor: 'pointer',
+ }}
+ >
+ None
+ </button>
+ </div>
+
+ {/* Platform-specific config fields */}
+ {PLATFORMS.filter((p) => p.hasConfig && enabled[p.id]).map((p) => {
+ const configKey = p.configLabel?.toLowerCase().replace(/\s/g, '_') || 'value';
+ return (
+ <div
+ key={p.id}
+ style={{
+ display: 'flex',
+ alignItems: 'center',
+ gap: '0.5rem',
+ marginBottom: '0.5rem',
+ padding: '0.5rem 0.625rem',
+ borderRadius: 'var(--radius-sm)',
+ backgroundColor: `${p.color}08`,
+ border: `1px solid ${p.color}22`,
+ }}
+ >
+ <span style={{
+ width: '6px',
+ height: '6px',
+ borderRadius: '50%',
+ backgroundColor: p.color,
+ flexShrink: 0,
+ }} />
+ <span style={{
+ fontSize: '0.75rem',
+ color: 'var(--color-text-secondary)',
+ minWidth: '60px',
+ fontWeight: 500,
+ }}>
+ {p.configLabel}:
+ </span>
+ {p.configType === 'select' && p.configOptions ? (
+ <select
+ value={configs[p.id]?.[configKey] || p.configDefault || ''}
+ onChange={(e) => updateConfig(p.id, configKey, e.target.value)}
+ className="input"
+ style={{
+ flex: 1,
+ padding: '0.25rem 0.5rem',
+ fontSize: '0.8125rem',
+ }}
+ >
+ {p.configOptions.map((opt) => (
+ <option key={opt} value={opt}>
+ r/{opt}
+ </option>
+ ))}
+ </select>
+ ) : (
+ <input
+ type="text"
+ className="input"
+ placeholder={p.configPlaceholder || ''}
+ value={configs[p.id]?.[configKey] || ''}
+ onChange={(e) => updateConfig(p.id, configKey, e.target.value)}
+ style={{
+ flex: 1,
+ padding: '0.25rem 0.5rem',
+ fontSize: '0.8125rem',
+ }}
+ />
+ )}
+ </div>
+ );
+ })}
+
+ {/* Review & Post button */}
+ <button
+ onClick={handlePost}
+ disabled={posting || enabledCount === 0}
+ className="btn btn-primary"
+ style={{
+ width: '100%',
+ padding: '0.75rem 1rem',
+ fontSize: '0.9375rem',
+ fontWeight: 700,
+ marginTop: '0.5rem',
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ gap: '0.5rem',
+ }}
+ >
+ {posting ? (
+ <>
+ <span className="spinner" style={{ width: '1rem', height: '1rem', borderWidth: '2px' }} />
+ Posting...
+ </>
+ ) : (
+ <>
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
+ <path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8"/>
+ <polyline points="16 6 12 2 8 6"/>
+ <line x1="12" y1="2" x2="12" y2="15"/>
+ </svg>
+ Review & Post ({enabledCount})
+ </>
+ )}
+ </button>
+
+ {/* Post results */}
+ {postResult && (
+ <div style={{
+ marginTop: '0.75rem',
+ padding: '0.75rem',
+ borderRadius: 'var(--radius-md)',
+ backgroundColor: 'var(--color-surface-el)',
+ border: '1px solid var(--color-border)',
+ }}>
+ <div style={{
+ display: 'flex',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ marginBottom: '0.5rem',
+ }}>
+ <span style={{
+ fontSize: '0.75rem',
+ fontWeight: 600,
+ color: 'var(--color-text-secondary)',
+ textTransform: 'uppercase',
+ letterSpacing: '0.05em',
+ }}>
+ Dispatch Results
+ </span>
+ <span style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)' }}>
+ {postResult.summary.posted}/{postResult.summary.total} posted
+ </span>
+ </div>
+ {postResult.results.map((r, i) => (
+ <div
+ key={i}
+ style={{
+ display: 'flex',
+ alignItems: 'center',
+ gap: '0.5rem',
+ padding: '0.375rem 0',
+ fontSize: '0.8125rem',
+ borderTop: i > 0 ? '1px solid var(--color-border)' : 'none',
+ }}
+ >
+ <span style={{
+ width: '6px',
+ height: '6px',
+ borderRadius: '50%',
+ backgroundColor: statusColor(r.status),
+ flexShrink: 0,
+ }} />
+ <span style={{
+ fontWeight: 600,
+ color: platformColor(r.platform),
+ minWidth: '70px',
+ textTransform: 'capitalize',
+ }}>
+ {r.platform}
+ </span>
+ <span style={{ color: 'var(--color-text-muted)', fontSize: '0.75rem', flex: 1 }}>
+ {r.message}
+ </span>
+ <span className={`badge badge-${r.status === 'posted' ? 'success' : r.status === 'failed' ? 'error' : 'warning'}`}>
+ {r.status}
+ </span>
+ </div>
+ ))}
+ </div>
+ )}
+
+ {/* Post history log */}
+ <div style={{ marginTop: '1rem' }}>
+ <h4 style={{
+ fontSize: '0.8125rem',
+ fontWeight: 600,
+ color: 'var(--color-text-secondary)',
+ marginBottom: '0.5rem',
+ display: 'flex',
+ alignItems: 'center',
+ gap: '0.375rem',
+ }}>
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
+ <circle cx="12" cy="12" r="10"/>
+ <polyline points="12 6 12 12 16 14"/>
+ </svg>
+ Posting History
+ </h4>
+
+ {logsLoading ? (
+ <div style={{ display: 'flex', justifyContent: 'center', padding: '1rem 0' }}>
+ <span className="spinner" style={{ width: '1.25rem', height: '1.25rem' }} />
+ </div>
+ ) : logs.length === 0 ? (
+ <p style={{
+ color: 'var(--color-text-muted)',
+ fontSize: '0.8125rem',
+ textAlign: 'center',
+ padding: '0.75rem 0',
+ fontStyle: 'italic',
+ }}>
+ No posts yet. Select platforms above and click Review & Post.
+ </p>
+ ) : (
+ <div style={{
+ display: 'flex',
+ flexDirection: 'column',
+ gap: '0.375rem',
+ maxHeight: '200px',
+ overflowY: 'auto',
+ }}>
+ {logs.map((log) => (
+ <div
+ key={log.id}
+ style={{
+ display: 'flex',
+ alignItems: 'center',
+ gap: '0.5rem',
+ padding: '0.375rem 0.5rem',
+ borderRadius: 'var(--radius-sm)',
+ backgroundColor: 'var(--color-surface-el)',
+ border: '1px solid var(--color-border)',
+ fontSize: '0.75rem',
+ }}
+ >
+ <span style={{
+ width: '6px',
+ height: '6px',
+ borderRadius: '50%',
+ backgroundColor: platformColor(log.platform),
+ flexShrink: 0,
+ }} />
+ <span style={{
+ fontWeight: 600,
+ color: platformColor(log.platform),
+ minWidth: '55px',
+ textTransform: 'capitalize',
+ }}>
+ {log.platform}
+ </span>
+ <span style={{
+ color: statusColor(log.status),
+ fontWeight: 600,
+ minWidth: '42px',
+ textTransform: 'capitalize',
+ }}>
+ {log.status}
+ </span>
+ <span style={{
+ flex: 1,
+ color: 'var(--color-text-muted)',
+ overflow: 'hidden',
+ textOverflow: 'ellipsis',
+ whiteSpace: 'nowrap',
+ }}>
+ {log.error_msg || (log.actor ? `by ${log.actor}` : '')}
+ </span>
+ <span style={{ color: 'var(--color-text-muted)', flexShrink: 0 }}>
+ {log.posted_at
+ ? new Date(log.posted_at).toLocaleDateString('en-US', {
+ month: 'short',
+ day: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit',
+ })
+ : new Date(log.created_at).toLocaleDateString('en-US', {
+ month: 'short',
+ day: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit',
+ })}
+ </span>
+ </div>
+ ))}
+ </div>
+ )}
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+}
diff --git a/app/petitions/[slug]/ShareButtons.tsx b/app/petitions/[slug]/ShareButtons.tsx
new file mode 100644
index 0000000..6d0a360
--- /dev/null
+++ b/app/petitions/[slug]/ShareButtons.tsx
@@ -0,0 +1,143 @@
+'use client';
+
+interface ShareButtonsProps {
+ url: string;
+ title: string;
+ summary: string;
+}
+
+export default function ShareButtons({ url, title, summary }: ShareButtonsProps) {
+ const encodedUrl = encodeURIComponent(url);
+ const encodedTitle = encodeURIComponent(title);
+ const encodedSummary = encodeURIComponent(summary || title);
+
+ const twitterUrl = `https://twitter.com/intent/tweet?text=${encodeURIComponent(`${title} - Sign the petition!`)}&url=${encodedUrl}`;
+ const facebookUrl = `https://www.facebook.com/sharer/sharer.php?u=${encodedUrl}`;
+ const emailUrl = `mailto:?subject=${encodedTitle}&body=${encodeURIComponent(`I just signed this petition and thought you might want to as well:\n\n${title}\n\n${summary ? summary + '\n\n' : ''}Sign here: ${url}`)}`;
+
+ const buttonBase: React.CSSProperties = {
+ display: 'inline-flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ gap: '0.5rem',
+ padding: '0.625rem 1rem',
+ borderRadius: 'var(--radius-md)',
+ fontSize: '0.875rem',
+ fontWeight: 600,
+ textDecoration: 'none',
+ border: '1px solid var(--color-border)',
+ cursor: 'pointer',
+ transition: 'all 150ms ease',
+ flex: '1 1 0',
+ minWidth: '0',
+ textAlign: 'center',
+ };
+
+ return (
+ <div style={{
+ backgroundColor: 'var(--color-surface)',
+ border: '1px solid var(--color-border)',
+ borderRadius: 'var(--radius-lg)',
+ padding: '1.5rem',
+ }}>
+ <h3 style={{
+ color: 'var(--color-text)',
+ fontSize: '1rem',
+ fontWeight: 700,
+ marginBottom: '1rem',
+ }}>
+ Share This Petition
+ </h3>
+
+ <div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
+ {/* Twitter/X */}
+ <a
+ href={twitterUrl}
+ target="_blank"
+ rel="noopener noreferrer"
+ style={{
+ ...buttonBase,
+ backgroundColor: 'rgba(29, 155, 240, 0.12)',
+ color: '#1d9bf0',
+ borderColor: 'rgba(29, 155, 240, 0.3)',
+ }}
+ onMouseOver={(e) => { (e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(29, 155, 240, 0.2)'; }}
+ onMouseOut={(e) => { (e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(29, 155, 240, 0.12)'; }}
+ >
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
+ <path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/>
+ </svg>
+ <span>Post</span>
+ </a>
+
+ {/* Facebook */}
+ <a
+ href={facebookUrl}
+ target="_blank"
+ rel="noopener noreferrer"
+ style={{
+ ...buttonBase,
+ backgroundColor: 'rgba(66, 103, 178, 0.12)',
+ color: '#4267B2',
+ borderColor: 'rgba(66, 103, 178, 0.3)',
+ }}
+ onMouseOver={(e) => { (e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(66, 103, 178, 0.2)'; }}
+ onMouseOut={(e) => { (e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(66, 103, 178, 0.12)'; }}
+ >
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
+ <path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/>
+ </svg>
+ <span>Share</span>
+ </a>
+
+ {/* Email */}
+ <a
+ href={emailUrl}
+ style={{
+ ...buttonBase,
+ backgroundColor: 'rgba(124, 58, 237, 0.12)',
+ color: 'var(--color-secondary)',
+ borderColor: 'rgba(124, 58, 237, 0.3)',
+ }}
+ onMouseOver={(e) => { (e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(124, 58, 237, 0.2)'; }}
+ onMouseOut={(e) => { (e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(124, 58, 237, 0.12)'; }}
+ >
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
+ <rect x="2" y="4" width="20" height="16" rx="2"/>
+ <path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/>
+ </svg>
+ <span>Email</span>
+ </a>
+ </div>
+
+ {/* Copy link */}
+ <button
+ onClick={() => {
+ navigator.clipboard.writeText(url).then(() => {
+ const btn = document.getElementById('copy-link-btn');
+ if (btn) {
+ btn.textContent = 'Link copied!';
+ setTimeout(() => { btn.textContent = 'Copy link to clipboard'; }, 2000);
+ }
+ });
+ }}
+ id="copy-link-btn"
+ style={{
+ display: 'block',
+ width: '100%',
+ marginTop: '0.75rem',
+ padding: '0.5rem',
+ backgroundColor: 'transparent',
+ border: '1px dashed var(--color-border)',
+ borderRadius: 'var(--radius-md)',
+ color: 'var(--color-text-muted)',
+ fontSize: '0.8125rem',
+ cursor: 'pointer',
+ transition: 'all 150ms ease',
+ }}
+ >
+ Copy link to clipboard
+ </button>
+ </div>
+ );
+}
diff --git a/app/petitions/[slug]/SignatureForm.tsx b/app/petitions/[slug]/SignatureForm.tsx
new file mode 100644
index 0000000..b4b138a
--- /dev/null
+++ b/app/petitions/[slug]/SignatureForm.tsx
@@ -0,0 +1,285 @@
+'use client';
+
+import { useState, FormEvent } from 'react';
+
+interface SignatureFormProps {
+ petitionId: string;
+ petitionTitle: string;
+ isActive: boolean;
+}
+
+export default function SignatureForm({ petitionId, petitionTitle, isActive }: SignatureFormProps) {
+ const [name, setName] = useState('');
+ const [email, setEmail] = useState('');
+ const [zip, setZip] = useState('');
+ const [comment, setComment] = useState('');
+ const [optedIn, setOptedIn] = useState(false);
+ const [honeypot, setHoneypot] = useState('');
+ const [submitting, setSubmitting] = useState(false);
+ const [result, setResult] = useState<{ success: boolean; message: string; signatureCount?: number } | null>(null);
+
+ if (!isActive) {
+ return (
+ <div style={{
+ backgroundColor: 'var(--color-surface)',
+ border: '1px solid var(--color-border)',
+ borderRadius: 'var(--radius-lg)',
+ padding: '2rem',
+ textAlign: 'center',
+ }}>
+ <div style={{ fontSize: '2rem', marginBottom: '0.75rem' }}>
+ {/* Lock icon via SVG */}
+ <svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="var(--color-text-muted)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ display: 'inline-block' }}>
+ <rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
+ <path d="M7 11V7a5 5 0 0 1 10 0v4" />
+ </svg>
+ </div>
+ <h3 style={{ color: 'var(--color-text)', fontSize: '1.125rem', fontWeight: 600, marginBottom: '0.5rem' }}>
+ This petition is not currently accepting signatures
+ </h3>
+ <p style={{ color: 'var(--color-text-muted)', fontSize: '0.875rem' }}>
+ The petition may be under review, paused, or has been closed. Check back later.
+ </p>
+ </div>
+ );
+ }
+
+ async function handleSubmit(e: FormEvent) {
+ e.preventDefault();
+ if (honeypot) return; // bot detected
+
+ if (!name.trim()) {
+ setResult({ success: false, message: 'Please enter your name.' });
+ return;
+ }
+ if (!email.trim() || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
+ setResult({ success: false, message: 'Please enter a valid email address.' });
+ return;
+ }
+
+ setSubmitting(true);
+ setResult(null);
+
+ try {
+ const res = await fetch('/api/public/signatures', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ petition_id: petitionId,
+ signer_name: name.trim(),
+ signer_email: email.trim().toLowerCase(),
+ signer_zip: zip.trim() || null,
+ signer_comment: comment.trim() || null,
+ opted_in_email: optedIn,
+ _hp: honeypot, // honeypot field
+ }),
+ });
+
+ const data = await res.json();
+
+ if (res.ok) {
+ setResult({
+ success: true,
+ message: `Thank you for signing! You are signature #${data.signatureCount}.`,
+ signatureCount: data.signatureCount,
+ });
+ // Reset form
+ setName('');
+ setEmail('');
+ setZip('');
+ setComment('');
+ setOptedIn(false);
+ } else {
+ setResult({
+ success: false,
+ message: data.error || 'Something went wrong. Please try again.',
+ });
+ }
+ } catch {
+ setResult({
+ success: false,
+ message: 'Network error. Please check your connection and try again.',
+ });
+ } finally {
+ setSubmitting(false);
+ }
+ }
+
+ return (
+ <div style={{
+ backgroundColor: 'var(--color-surface)',
+ border: '1px solid var(--color-border)',
+ borderRadius: 'var(--radius-lg)',
+ padding: '1.5rem',
+ }}>
+ <h3 style={{
+ color: 'var(--color-text)',
+ fontSize: '1.25rem',
+ fontWeight: 700,
+ marginBottom: '1.25rem',
+ }}>
+ Sign This Petition
+ </h3>
+
+ {result && (
+ <div style={{
+ padding: '0.75rem 1rem',
+ borderRadius: 'var(--radius-md)',
+ marginBottom: '1rem',
+ backgroundColor: result.success ? 'rgba(34, 197, 94, 0.15)' : 'rgba(239, 68, 68, 0.15)',
+ border: `1px solid ${result.success ? 'rgba(34, 197, 94, 0.3)' : 'rgba(239, 68, 68, 0.3)'}`,
+ color: result.success ? 'var(--color-success)' : 'var(--color-error)',
+ fontSize: '0.875rem',
+ }}>
+ {result.message}
+ </div>
+ )}
+
+ {result?.success ? (
+ <div style={{ textAlign: 'center', padding: '1rem 0' }}>
+ <svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="var(--color-success)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ display: 'inline-block', marginBottom: '0.75rem' }}>
+ <path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
+ <polyline points="22 4 12 14.01 9 11.01" />
+ </svg>
+ <p style={{ color: 'var(--color-text)', fontWeight: 600, fontSize: '1rem', marginBottom: '0.5rem' }}>
+ Your signature has been recorded!
+ </p>
+ <p style={{ color: 'var(--color-text-secondary)', fontSize: '0.875rem' }}>
+ Share this petition to help it reach its goal.
+ </p>
+ </div>
+ ) : (
+ <form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
+ {/* Honeypot - hidden from humans */}
+ <div style={{ position: 'absolute', left: '-9999px', opacity: 0, height: 0, overflow: 'hidden' }} aria-hidden="true">
+ <label htmlFor="website_url">Website</label>
+ <input
+ id="website_url"
+ name="website_url"
+ type="text"
+ tabIndex={-1}
+ autoComplete="off"
+ value={honeypot}
+ onChange={(e) => setHoneypot(e.target.value)}
+ />
+ </div>
+
+ <div>
+ <label htmlFor="signer_name" style={{ display: 'block', color: 'var(--color-text-secondary)', fontSize: '0.875rem', fontWeight: 500, marginBottom: '0.375rem' }}>
+ Full Name <span style={{ color: 'var(--color-error)' }}>*</span>
+ </label>
+ <input
+ id="signer_name"
+ type="text"
+ className="input"
+ placeholder="Your full name"
+ value={name}
+ onChange={(e) => setName(e.target.value)}
+ required
+ disabled={submitting}
+ />
+ </div>
+
+ <div>
+ <label htmlFor="signer_email" style={{ display: 'block', color: 'var(--color-text-secondary)', fontSize: '0.875rem', fontWeight: 500, marginBottom: '0.375rem' }}>
+ Email Address <span style={{ color: 'var(--color-error)' }}>*</span>
+ </label>
+ <input
+ id="signer_email"
+ type="email"
+ className="input"
+ placeholder="your@email.com"
+ value={email}
+ onChange={(e) => setEmail(e.target.value)}
+ required
+ disabled={submitting}
+ />
+ <p style={{ color: 'var(--color-text-muted)', fontSize: '0.75rem', marginTop: '0.25rem' }}>
+ Your email is used for verification only and will not be displayed publicly.
+ </p>
+ </div>
+
+ <div>
+ <label htmlFor="signer_zip" style={{ display: 'block', color: 'var(--color-text-secondary)', fontSize: '0.875rem', fontWeight: 500, marginBottom: '0.375rem' }}>
+ ZIP Code <span style={{ color: 'var(--color-text-muted)', fontWeight: 400 }}>(optional)</span>
+ </label>
+ <input
+ id="signer_zip"
+ type="text"
+ className="input"
+ placeholder="e.g. 90210"
+ value={zip}
+ onChange={(e) => setZip(e.target.value)}
+ maxLength={10}
+ disabled={submitting}
+ style={{ maxWidth: '12rem' }}
+ />
+ </div>
+
+ <div>
+ <label htmlFor="signer_comment" style={{ display: 'block', color: 'var(--color-text-secondary)', fontSize: '0.875rem', fontWeight: 500, marginBottom: '0.375rem' }}>
+ Comment <span style={{ color: 'var(--color-text-muted)', fontWeight: 400 }}>(optional)</span>
+ </label>
+ <textarea
+ id="signer_comment"
+ className="input"
+ placeholder="Why are you signing this petition?"
+ value={comment}
+ onChange={(e) => setComment(e.target.value)}
+ rows={3}
+ maxLength={1000}
+ disabled={submitting}
+ style={{ resize: 'vertical', minHeight: '4rem' }}
+ />
+ </div>
+
+ <div style={{ display: 'flex', alignItems: 'flex-start', gap: '0.5rem' }}>
+ <input
+ id="opted_in_email"
+ type="checkbox"
+ checked={optedIn}
+ onChange={(e) => setOptedIn(e.target.checked)}
+ disabled={submitting}
+ style={{
+ marginTop: '0.125rem',
+ accentColor: 'var(--color-primary)',
+ width: '1rem',
+ height: '1rem',
+ cursor: 'pointer',
+ }}
+ />
+ <label htmlFor="opted_in_email" style={{ color: 'var(--color-text-secondary)', fontSize: '0.875rem', cursor: 'pointer' }}>
+ Keep me updated on this petition and related campaigns
+ </label>
+ </div>
+
+ <button
+ type="submit"
+ disabled={submitting}
+ className="btn btn-primary btn-lg"
+ style={{
+ width: '100%',
+ fontSize: '1rem',
+ fontWeight: 700,
+ padding: '0.875rem 1.5rem',
+ marginTop: '0.25rem',
+ }}
+ >
+ {submitting ? (
+ <span style={{ display: 'inline-flex', alignItems: 'center', gap: '0.5rem' }}>
+ <span className="spinner" style={{ width: '1rem', height: '1rem', borderWidth: '2px' }} />
+ Signing...
+ </span>
+ ) : (
+ 'Sign This Petition'
+ )}
+ </button>
+
+ <p style={{ color: 'var(--color-text-muted)', fontSize: '0.75rem', textAlign: 'center', lineHeight: 1.4 }}>
+ By signing, you agree that your name may be publicly listed as a supporter. Your email will never be shared.
+ </p>
+ </form>
+ )}
+ </div>
+ );
+}
diff --git a/app/petitions/[slug]/page.tsx b/app/petitions/[slug]/page.tsx
new file mode 100644
index 0000000..2989f49
--- /dev/null
+++ b/app/petitions/[slug]/page.tsx
@@ -0,0 +1,510 @@
+import { Metadata } from 'next';
+import { notFound } from 'next/navigation';
+import { query } from '@/lib/db';
+import { sanitizePetitionBody } from '@/lib/sanitize';
+import SignatureForm from './SignatureForm';
+import ShareButtons from './ShareButtons';
+import PostToPlatformsPanel from './PostToPlatformsPanel';
+
+interface PetitionRow {
+ id: string;
+ title: string;
+ slug: string;
+ summary: string | null;
+ body_html: string;
+ body_text: string | null;
+ target: string | null;
+ category: string | null;
+ tags: string[] | null;
+ status: string;
+ signature_count: number;
+ signature_goal: number;
+ share_count: number;
+ image_url: string | null;
+ created_at: string;
+ updated_at: string;
+}
+
+interface PageProps {
+ params: Promise<{ slug: string }>;
+}
+
+async function getPetition(slug: string): Promise<PetitionRow | null> {
+ const result = await query<PetitionRow>(
+ `SELECT id, title, slug, summary, body_html, body_text, target, category, tags,
+ status, signature_count, signature_goal, share_count, image_url,
+ created_at, updated_at
+ FROM petitions WHERE slug = $1 LIMIT 1`,
+ [slug]
+ );
+ return result.rows[0] || null;
+}
+
+async function getRecentSignatures(petitionId: string) {
+ const result = await query(
+ `SELECT signer_name, signer_comment, created_at
+ FROM signatures
+ WHERE petition_id = $1 AND is_public = true
+ ORDER BY created_at DESC
+ LIMIT 10`,
+ [petitionId]
+ );
+ return result.rows;
+}
+
+/**
+ * Sanitize admin-authored HTML to strip script tags and event handlers.
+ * body_html is authored by authenticated admins only (via PATCH/POST endpoints),
+ * but we apply basic sanitization as defense-in-depth.
+ */
+// 2026-05-05 (P1 architect-review + tick 27): the handwritten regex-only
+// sanitizer was bypassable (SVG, data: URIs, mathml, malformed nesting).
+// Replaced with the shared allowlist from lib/sanitize.ts, which is the
+// SAME allowlist applied at POST/PATCH boundary in api/petitions/route.ts.
+// Drift between the two layers is exactly the gap stored XSS slips through.
+
+export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
+ const { slug } = await params;
+ const petition = await getPetition(slug);
+
+ if (!petition) {
+ return { title: 'Petition Not Found' };
+ }
+
+ const publicUrl = `http://45.61.58.125:7460/petitions/${petition.slug}`;
+
+ return {
+ title: `${petition.title} | Patty Petitions`,
+ description: petition.summary || petition.body_text?.slice(0, 160) || petition.title,
+ openGraph: {
+ title: petition.title,
+ description: petition.summary || petition.body_text?.slice(0, 160) || petition.title,
+ url: publicUrl,
+ type: 'website',
+ siteName: 'Patty - The Petition Specialist',
+ ...(petition.image_url ? { images: [{ url: petition.image_url }] } : {}),
+ },
+ twitter: {
+ card: 'summary_large_image',
+ title: petition.title,
+ description: petition.summary || petition.body_text?.slice(0, 160) || petition.title,
+ },
+ };
+}
+
+export default async function PetitionPage({ params }: PageProps) {
+ const { slug } = await params;
+ const petition = await getPetition(slug);
+
+ if (!petition) {
+ notFound();
+ }
+
+ const isActive = petition.status === 'active';
+ const signatures = await getRecentSignatures(petition.id);
+ const publicUrl = `http://45.61.58.125:7460/petitions/${petition.slug}`;
+ const progressPercent = petition.signature_goal > 0
+ ? Math.min(100, Math.round((petition.signature_count / petition.signature_goal) * 100))
+ : 0;
+
+ const formattedDate = new Date(petition.created_at).toLocaleDateString('en-US', {
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric',
+ });
+
+ const sanitizedBody = sanitizePetitionBody(petition.body_html);
+
+ return (
+ <div style={{
+ minHeight: '100vh',
+ backgroundColor: 'var(--color-bg)',
+ color: 'var(--color-text)',
+ }}>
+ {/* Header */}
+ <header style={{
+ borderBottom: '1px solid var(--color-border)',
+ backgroundColor: 'var(--color-surface)',
+ }}>
+ <div style={{
+ maxWidth: '1100px',
+ margin: '0 auto',
+ padding: '1rem 1.5rem',
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ }}>
+ <a href="/" style={{
+ color: 'var(--color-primary)',
+ textDecoration: 'none',
+ fontWeight: 700,
+ fontSize: '1.125rem',
+ display: 'flex',
+ alignItems: 'center',
+ gap: '0.5rem',
+ }}>
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
+ <path d="m3 11 18-5v12L3 13z"/>
+ <path d="M11.6 16.8a3 3 0 1 1-5.8-1.6"/>
+ </svg>
+ Patty
+ </a>
+ <span style={{ color: 'var(--color-text-muted)', fontSize: '0.8125rem' }}>
+ Petition Platform
+ </span>
+ </div>
+ </header>
+
+ {/* Main content */}
+ <main style={{
+ maxWidth: '1100px',
+ margin: '0 auto',
+ padding: '2rem 1.5rem 4rem',
+ }}>
+ {/* Petition status banner */}
+ {!isActive && (
+ <div style={{
+ padding: '0.75rem 1rem',
+ borderRadius: 'var(--radius-md)',
+ marginBottom: '1.5rem',
+ backgroundColor: petition.status === 'closed' || petition.status === 'delivered'
+ ? 'rgba(239, 68, 68, 0.1)'
+ : 'rgba(245, 158, 11, 0.1)',
+ border: `1px solid ${
+ petition.status === 'closed' || petition.status === 'delivered'
+ ? 'rgba(239, 68, 68, 0.3)'
+ : 'rgba(245, 158, 11, 0.3)'
+ }`,
+ color: petition.status === 'closed' || petition.status === 'delivered'
+ ? 'var(--color-error)'
+ : 'var(--color-warning)',
+ fontSize: '0.875rem',
+ fontWeight: 500,
+ textAlign: 'center',
+ }}>
+ {petition.status === 'delivered'
+ ? 'This petition has been delivered to its target.'
+ : petition.status === 'closed'
+ ? 'This petition has been closed.'
+ : petition.status === 'paused'
+ ? 'This petition is currently paused.'
+ : 'This petition is not yet active.'}
+ </div>
+ )}
+
+ <div style={{
+ display: 'grid',
+ gridTemplateColumns: '1fr',
+ gap: '2rem',
+ }}>
+ {/* Desktop: two-column layout */}
+ <div className="petition-layout" style={{ display: 'flex', gap: '2rem', flexWrap: 'wrap' }}>
+ {/* Left column - petition content */}
+ <div style={{ flex: '1 1 600px', minWidth: 0 }}>
+ {/* Category + tags */}
+ <div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem', marginBottom: '1rem' }}>
+ {petition.category && (
+ <span className="badge badge-active" style={{ textTransform: 'capitalize' }}>
+ {petition.category}
+ </span>
+ )}
+ {petition.tags?.map((rawTag) => {
+ const tag = rawTag.replace(/^["'{]+|["'}]+$/g, '').trim();
+ if (!tag) return null;
+ return (
+ <span key={tag} style={{
+ display: 'inline-block',
+ padding: '0.125rem 0.5rem',
+ borderRadius: '9999px',
+ fontSize: '0.75rem',
+ fontWeight: 500,
+ backgroundColor: 'rgba(124, 58, 237, 0.12)',
+ color: 'var(--color-secondary)',
+ border: '1px solid rgba(124, 58, 237, 0.25)',
+ }}>
+ {tag}
+ </span>
+ );
+ })}
+ </div>
+
+ {/* Title */}
+ <h1 style={{
+ fontSize: 'clamp(1.5rem, 4vw, 2.25rem)',
+ fontWeight: 800,
+ lineHeight: 1.2,
+ color: 'var(--color-text)',
+ marginBottom: '0.75rem',
+ }}>
+ {petition.title}
+ </h1>
+
+ {/* Meta */}
+ <div style={{
+ display: 'flex',
+ flexWrap: 'wrap',
+ gap: '1rem',
+ alignItems: 'center',
+ marginBottom: '1.5rem',
+ fontSize: '0.875rem',
+ color: 'var(--color-text-muted)',
+ }}>
+ <span>Started {formattedDate}</span>
+ {petition.target && (
+ <>
+ <span style={{ color: 'var(--color-border)' }}>|</span>
+ <span>
+ Directed to: <strong style={{ color: 'var(--color-text-secondary)' }}>{petition.target}</strong>
+ </span>
+ </>
+ )}
+ </div>
+
+ {/* Summary */}
+ {petition.summary && (
+ <p style={{
+ fontSize: '1.0625rem',
+ lineHeight: 1.7,
+ color: 'var(--color-text-secondary)',
+ marginBottom: '1.5rem',
+ padding: '1rem 1.25rem',
+ backgroundColor: 'var(--color-surface)',
+ borderLeft: '3px solid var(--color-primary)',
+ borderRadius: '0 var(--radius-md) var(--radius-md) 0',
+ }}>
+ {petition.summary}
+ </p>
+ )}
+
+ {/* Body HTML — admin-authored content, sanitized as defense-in-depth */}
+ <div
+ dangerouslySetInnerHTML={{ __html: sanitizedBody }}
+ style={{
+ lineHeight: 1.8,
+ fontSize: '1rem',
+ color: 'var(--color-text-secondary)',
+ }}
+ className="petition-body"
+ />
+
+ {/* Recent signatures */}
+ {signatures.length > 0 && (
+ <div style={{ marginTop: '2.5rem' }}>
+ <h3 style={{
+ color: 'var(--color-text)',
+ fontSize: '1.125rem',
+ fontWeight: 700,
+ marginBottom: '1rem',
+ }}>
+ Recent Supporters
+ </h3>
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
+ {signatures.map((sig, i) => (
+ <div key={i} style={{
+ padding: '0.75rem 1rem',
+ backgroundColor: 'var(--color-surface)',
+ border: '1px solid var(--color-border)',
+ borderRadius: 'var(--radius-md)',
+ }}>
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
+ <span style={{ fontWeight: 600, color: 'var(--color-text)', fontSize: '0.875rem' }}>
+ {sig.signer_name || 'Anonymous'}
+ </span>
+ <span style={{ color: 'var(--color-text-muted)', fontSize: '0.75rem' }}>
+ {new Date(sig.created_at).toLocaleDateString('en-US', {
+ month: 'short',
+ day: 'numeric',
+ })}
+ </span>
+ </div>
+ {sig.signer_comment && (
+ <p style={{
+ color: 'var(--color-text-secondary)',
+ fontSize: '0.8125rem',
+ marginTop: '0.375rem',
+ fontStyle: 'italic',
+ }}>
+ “{sig.signer_comment}”
+ </p>
+ )}
+ </div>
+ ))}
+ </div>
+ </div>
+ )}
+ </div>
+
+ {/* Right column - signature form + progress + share */}
+ <div className="petition-sidebar" style={{
+ flex: '0 0 auto',
+ width: '360px',
+ maxWidth: '100%',
+ display: 'flex',
+ flexDirection: 'column',
+ gap: '1.5rem',
+ alignSelf: 'flex-start',
+ position: 'sticky',
+ top: '1.5rem',
+ }}>
+ {/* Progress card */}
+ <div style={{
+ backgroundColor: 'var(--color-surface)',
+ border: '1px solid var(--color-border)',
+ borderRadius: 'var(--radius-lg)',
+ padding: '1.5rem',
+ }}>
+ <div style={{
+ display: 'flex',
+ justifyContent: 'space-between',
+ alignItems: 'baseline',
+ marginBottom: '0.75rem',
+ }}>
+ <span style={{
+ fontSize: '2rem',
+ fontWeight: 800,
+ color: 'var(--color-primary)',
+ }}>
+ {petition.signature_count.toLocaleString()}
+ </span>
+ <span style={{
+ color: 'var(--color-text-muted)',
+ fontSize: '0.875rem',
+ }}>
+ of {petition.signature_goal.toLocaleString()} goal
+ </span>
+ </div>
+
+ {/* Progress bar */}
+ <div style={{
+ width: '100%',
+ height: '8px',
+ backgroundColor: 'var(--color-surface-el)',
+ borderRadius: '4px',
+ overflow: 'hidden',
+ marginBottom: '0.5rem',
+ }}>
+ <div style={{
+ width: `${progressPercent}%`,
+ height: '100%',
+ backgroundColor: 'var(--color-primary)',
+ borderRadius: '4px',
+ transition: 'width 0.5s ease',
+ minWidth: progressPercent > 0 ? '4px' : '0',
+ }} />
+ </div>
+ <p style={{
+ color: 'var(--color-text-muted)',
+ fontSize: '0.8125rem',
+ textAlign: 'right',
+ }}>
+ {progressPercent}% complete
+ </p>
+ </div>
+
+ {/* Signature form */}
+ <SignatureForm
+ petitionId={petition.id}
+ petitionTitle={petition.title}
+ isActive={isActive}
+ />
+
+ {/* Share buttons */}
+ <ShareButtons
+ url={publicUrl}
+ title={petition.title}
+ summary={petition.summary || ''}
+ />
+
+ {/* Post to Platforms (admin review panel) */}
+ <PostToPlatformsPanel
+ petitionSlug={petition.slug}
+ petitionTitle={petition.title}
+ petitionId={petition.id}
+ />
+ </div>
+ </div>
+ </div>
+ </main>
+
+ {/* Footer */}
+ <footer style={{
+ borderTop: '1px solid var(--color-border)',
+ backgroundColor: 'var(--color-surface)',
+ padding: '1.5rem',
+ textAlign: 'center',
+ }}>
+ <p style={{ color: 'var(--color-text-muted)', fontSize: '0.8125rem' }}>
+ Powered by <strong style={{ color: 'var(--color-primary)' }}>Patty</strong> - The Petition Specialist
+ </p>
+ </footer>
+
+ {/* Petition body styles */}
+ <style>{`
+ .petition-body h1,
+ .petition-body h2,
+ .petition-body h3,
+ .petition-body h4 {
+ color: var(--color-text);
+ margin-top: 1.5em;
+ margin-bottom: 0.75em;
+ font-weight: 700;
+ line-height: 1.3;
+ }
+ .petition-body h1 { font-size: 1.75rem; }
+ .petition-body h2 { font-size: 1.5rem; }
+ .petition-body h3 { font-size: 1.25rem; }
+ .petition-body h4 { font-size: 1.125rem; }
+ .petition-body p {
+ margin-bottom: 1em;
+ }
+ .petition-body ul,
+ .petition-body ol {
+ margin-bottom: 1em;
+ padding-left: 1.5em;
+ }
+ .petition-body li {
+ margin-bottom: 0.375em;
+ }
+ .petition-body a {
+ color: var(--color-secondary);
+ text-decoration: underline;
+ text-underline-offset: 2px;
+ }
+ .petition-body a:hover {
+ color: var(--color-primary);
+ }
+ .petition-body blockquote {
+ border-left: 3px solid var(--color-primary);
+ padding: 0.75rem 1rem;
+ margin: 1em 0;
+ background-color: var(--color-surface);
+ border-radius: 0 var(--radius-md) var(--radius-md) 0;
+ font-style: italic;
+ color: var(--color-text-secondary);
+ }
+ .petition-body strong,
+ .petition-body b {
+ color: var(--color-text);
+ font-weight: 700;
+ }
+ .petition-body img {
+ max-width: 100%;
+ border-radius: var(--radius-md);
+ margin: 1em 0;
+ }
+ .petition-body hr {
+ border: none;
+ border-top: 1px solid var(--color-border);
+ margin: 2em 0;
+ }
+
+ /* Responsive: stack columns on mobile */
+ @media (max-width: 768px) {
+ .petition-body {
+ font-size: 0.9375rem !important;
+ }
+ }
+ `}</style>
+ </div>
+ );
+}
diff --git a/components/AppShell.tsx b/components/AppShell.tsx
new file mode 100644
index 0000000..dd08b57
--- /dev/null
+++ b/components/AppShell.tsx
@@ -0,0 +1,232 @@
+'use client';
+
+import { useState, useEffect } 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 DashboardTab from './dashboard/DashboardTab';
+import NewsFeedTab from './news/NewsFeedTab';
+import TopicsTab from './topics/TopicsTab';
+import PetitionsTab from './petitions/PetitionsTab';
+import GrantsTab from './grants/GrantsTab';
+import HeatMapTab from './heatmap/HeatMapTab';
+import CampaignsTab from './campaigns/CampaignsTab';
+import SourcesTab from './sources/SourcesTab';
+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 = 7460;
+
+/* ─── Inner shell (needs auth context) ──────────────────────────────────── */
+function Shell() {
+ const { user, logout } = useAuth();
+ const [activeTab, setActiveTab] = useState<TabId>('dashboard');
+ const [sidebarOpen, setSidebarOpen] = useState(false);
+ const [showAppSwitcher, setShowAppSwitcher] = useState(false);
+ const [selectedPetitionId, setSelectedPetitionId] = useState<number | null>(null);
+ const [selectedGrantId, setSelectedGrantId] = useState<string | null>(null);
+
+ function handleTabChange(tab: TabId) {
+ setActiveTab(tab);
+ setSidebarOpen(false);
+ }
+
+ function handleViewPetition(id: number) {
+ setSelectedPetitionId(id);
+ setActiveTab('petitions');
+ }
+
+ function handleViewGrant(id: string) {
+ setSelectedGrantId(id);
+ setActiveTab('grants');
+ }
+
+ function renderPanel() {
+ switch (activeTab) {
+ case 'dashboard': return <DashboardTab />;
+ case 'newsfeed': return <NewsFeedTab />;
+ case 'topics': return <TopicsTab onViewPetition={handleViewPetition} onViewGrant={handleViewGrant} />;
+ case 'petitions': return <PetitionsTab selectedId={selectedPetitionId} onSelectedConsumed={() => setSelectedPetitionId(null)} onViewGrant={handleViewGrant} />;
+ case 'grants': return <GrantsTab selectedId={selectedGrantId} onSelectedConsumed={() => setSelectedGrantId(null)} />;
+ case 'heatmap': return <HeatMapTab />;
+ case 'campaigns': return <CampaignsTab />;
+ case 'sources': return <SourcesTab />;
+ case 'settings': return <SettingsTab />;
+ }
+ }
+
+ const TAB_LABELS: Record<TabId, string> = {
+ dashboard: 'Dashboard',
+ newsfeed: 'News Feed',
+ topics: 'Topics',
+ petitions: 'Petitions',
+ grants: 'Grants',
+ heatmap: 'Heat Map',
+ campaigns: 'Campaigns',
+ sources: 'Sources',
+ 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..afa0798
--- /dev/null
+++ b/components/AuthProvider.tsx
@@ -0,0 +1,132 @@
+'use client';
+
+import {
+ createContext,
+ useContext,
+ useEffect,
+ useState,
+ useCallback,
+ ReactNode,
+} from 'react';
+import { useRouter, usePathname } from 'next/navigation';
+import { Loader2 } from 'lucide-react';
+
+/* ─── Types ──────────────────────────────────────────────────────────────── */
+interface AuthContextValue {
+ user: string | null;
+ loading: boolean;
+ login: (username: string, password: string) => Promise<{ error?: string }>;
+ logout: () => Promise<void>;
+}
+
+/* ─── Context ────────────────────────────────────────────────────────────── */
+const AuthContext = createContext<AuthContextValue | null>(null);
+
+/* ─── Provider ───────────────────────────────────────────────────────────── */
+export function AuthProvider({ children }: { children: ReactNode }) {
+ const router = useRouter();
+ const pathname = usePathname();
+
+ const [user, setUser] = useState<string | null>(null);
+ const [loading, setLoading] = useState(true);
+
+ /* Check session on mount */
+ useEffect(() => {
+ let cancelled = false;
+
+ async function checkSession() {
+ try {
+ const res = await fetch('/api/auth/session', { credentials: 'include' });
+ if (!cancelled) {
+ if (res.ok) {
+ const data = await res.json();
+ setUser(data.authenticated ? data.user : null);
+ } else {
+ setUser(null);
+ }
+ }
+ } catch {
+ if (!cancelled) setUser(null);
+ } finally {
+ if (!cancelled) setLoading(false);
+ }
+ }
+
+ checkSession();
+ return () => { cancelled = true; };
+ }, []);
+
+ /* Redirect unauthenticated users away from protected routes */
+ useEffect(() => {
+ if (loading) return;
+ if (!user && pathname !== '/login') {
+ router.replace('/login');
+ }
+ }, [user, loading, pathname, router]);
+
+ /* Login */
+ const login = useCallback(async (username: string, password: string) => {
+ const res = await fetch('/api/auth/login', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ body: JSON.stringify({ username, password }),
+ });
+
+ const data = await res.json();
+
+ if (res.ok && data.success) {
+ setUser(username);
+ router.push('/');
+ router.refresh();
+ return {};
+ }
+
+ return { error: data.error ?? 'Login failed' };
+ }, [router]);
+
+ /* Logout */
+ const logout = useCallback(async () => {
+ await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' });
+ setUser(null);
+ router.replace('/login');
+ }, [router]);
+
+ /* Loading screen */
+ if (loading) {
+ return (
+ <div
+ className="min-h-screen flex flex-col items-center justify-center gap-3"
+ style={{ backgroundColor: 'var(--color-bg)' }}
+ >
+ <Loader2
+ size={28}
+ className="animate-spin"
+ style={{ color: 'var(--color-primary)' }}
+ aria-hidden="true"
+ />
+ <span
+ className="text-sm"
+ style={{ color: 'var(--color-text-secondary)' }}
+ >
+ Loading...
+ </span>
+ </div>
+ );
+ }
+
+ return (
+ <AuthContext.Provider value={{ user, loading, login, logout }}>
+ {children}
+ </AuthContext.Provider>
+ );
+}
+
+/* ─── Hook ───────────────────────────────────────────────────────────────── */
+export function useAuth(): AuthContextValue {
+ const ctx = useContext(AuthContext);
+ if (!ctx) {
+ throw new Error('useAuth must be used inside <AuthProvider>');
+ }
+ return ctx;
+}
diff --git a/components/Sidebar.tsx b/components/Sidebar.tsx
new file mode 100644
index 0000000..b9e3090
--- /dev/null
+++ b/components/Sidebar.tsx
@@ -0,0 +1,311 @@
+'use client';
+
+import { useState } from 'react';
+import {
+ LayoutDashboard,
+ Newspaper,
+ Lightbulb,
+ Megaphone,
+ DollarSign,
+ MapPin,
+ Mail,
+ Radio,
+ Settings,
+ ChevronLeft,
+ ChevronRight,
+} from 'lucide-react';
+
+/* ─── Types ──────────────────────────────────────────────────────────────── */
+export type TabId =
+ | 'dashboard'
+ | 'newsfeed'
+ | 'topics'
+ | 'petitions'
+ | 'grants'
+ | 'heatmap'
+ | 'campaigns'
+ | 'sources'
+ | 'settings';
+
+interface NavItem {
+ id: TabId;
+ label: string;
+ Icon: React.ElementType;
+ section: 'main' | 'tools';
+}
+
+interface SidebarProps {
+ activeTab: TabId;
+ onTabChange: (tab: TabId) => void;
+ isOpen?: boolean;
+ onToggle?: () => void;
+}
+
+/* ─── Navigation config ──────────────────────────────────────────────────── */
+const NAV_ITEMS: NavItem[] = [
+ { id: 'dashboard', label: 'Dashboard', Icon: LayoutDashboard, section: 'main' },
+ { id: 'newsfeed', label: 'News Feed', Icon: Newspaper, section: 'main' },
+ { id: 'topics', label: 'Topics', Icon: Lightbulb, section: 'main' },
+ { id: 'petitions', label: 'Petitions', Icon: Megaphone, section: 'main' },
+ { id: 'grants', label: 'Grants', Icon: DollarSign, section: 'main' },
+ { id: 'heatmap', label: 'Heat Map', Icon: MapPin, section: 'main' },
+ { id: 'campaigns', label: 'Campaigns', Icon: Mail, section: 'tools' },
+ { id: 'sources', label: 'Sources', Icon: Radio, section: 'tools' },
+ { id: 'settings', label: 'Settings', Icon: Settings, section: 'tools' },
+];
+
+/* ─── Sidebar ────────────────────────────────────────────────────────────── */
+export default function Sidebar({ activeTab, onTabChange, isOpen, onToggle }: SidebarProps) {
+ const [collapsed, setCollapsed] = useState(false);
+
+ const mainItems = NAV_ITEMS.filter((i) => i.section === 'main');
+ const toolItems = NAV_ITEMS.filter((i) => i.section === 'tools');
+
+ return (
+ <>
+ {isOpen && onToggle && (
+ <div
+ onClick={onToggle}
+ className="sidebar-backdrop"
+ style={{
+ position: 'fixed',
+ inset: 0,
+ backgroundColor: 'rgba(0,0,0,0.5)',
+ zIndex: 39,
+ }}
+ />
+ )}
+ <aside
+ className={`sidebar-root${isOpen ? ' sidebar-open' : ''}`}
+ style={{
+ width: collapsed ? 64 : 220,
+ minWidth: collapsed ? 64 : 220,
+ transition: 'width 0.2s cubic-bezier(0.4, 0, 0.2, 1), min-width 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
+ height: '100vh',
+ display: 'flex',
+ flexDirection: 'column',
+ backgroundColor: 'var(--color-surface)',
+ borderRight: '1px solid var(--color-border)',
+ position: 'relative',
+ overflow: 'hidden',
+ flexShrink: 0,
+ }}
+ aria-label="Main navigation"
+ >
+ {/* ── Brand ──────────────────────────────────────────────────────────── */}
+ <div
+ style={{
+ padding: collapsed ? '16px 0' : '16px',
+ display: 'flex',
+ alignItems: 'center',
+ gap: 10,
+ justifyContent: collapsed ? 'center' : 'flex-start',
+ borderBottom: '1px solid var(--color-border)',
+ height: 56,
+ flexShrink: 0,
+ }}
+ >
+ <div
+ style={{
+ width: 32,
+ height: 32,
+ borderRadius: 10,
+ background: 'linear-gradient(135deg, #7c3aed, #6d28d9)',
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ flexShrink: 0,
+ boxShadow: '0 2px 8px rgba(124, 58, 237, 0.3)',
+ }}
+ >
+ <span style={{ color: '#fff', fontSize: 14, fontWeight: 900, lineHeight: 1 }}>P</span>
+ </div>
+ {!collapsed && (
+ <div style={{ overflow: 'hidden', whiteSpace: 'nowrap' }}>
+ <div style={{ fontSize: 14, fontWeight: 700, color: 'var(--color-text)', lineHeight: 1.2 }}>
+ Pulse
+ </div>
+ <div style={{ fontSize: 10, color: 'var(--color-text-muted)', lineHeight: 1.2, letterSpacing: '0.02em' }}>
+ News & Advocacy
+ </div>
+ </div>
+ )}
+ </div>
+
+ {/* ── Main Nav ───────────────────────────────────────────────────────── */}
+ <nav style={{ flex: 1, overflowY: 'auto', overflowX: 'hidden', padding: '8px 8px 0' }}>
+ {!collapsed && (
+ <div style={{ padding: '4px 8px 6px', fontSize: 10, fontWeight: 600, color: 'var(--color-text-muted)', letterSpacing: '0.06em', textTransform: 'uppercase' }}>
+ Workspace
+ </div>
+ )}
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
+ {mainItems.map(({ id, label, Icon }) => (
+ <SidebarButton
+ key={id}
+ id={id}
+ label={label}
+ Icon={Icon}
+ isActive={activeTab === id}
+ collapsed={collapsed}
+ onClick={() => onTabChange(id)}
+ />
+ ))}
+ </div>
+
+ {/* Divider */}
+ <div
+ style={{
+ height: 1,
+ backgroundColor: 'var(--color-border)',
+ margin: collapsed ? '12px 8px' : '12px 4px',
+ opacity: 0.6,
+ }}
+ />
+
+ {!collapsed && (
+ <div style={{ padding: '4px 8px 6px', fontSize: 10, fontWeight: 600, color: 'var(--color-text-muted)', letterSpacing: '0.06em', textTransform: 'uppercase' }}>
+ Tools
+ </div>
+ )}
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
+ {toolItems.map(({ id, label, Icon }) => (
+ <SidebarButton
+ key={id}
+ id={id}
+ label={label}
+ Icon={Icon}
+ isActive={activeTab === id}
+ collapsed={collapsed}
+ onClick={() => onTabChange(id)}
+ />
+ ))}
+ </div>
+ </nav>
+
+ {/* ── Collapse toggle ────────────────────────────────────────────────── */}
+ <div
+ style={{
+ padding: '8px',
+ borderTop: '1px solid var(--color-border)',
+ flexShrink: 0,
+ }}
+ >
+ <button
+ onClick={() => setCollapsed((c) => !c)}
+ title={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
+ aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
+ style={{
+ width: '100%',
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: collapsed ? 'center' : 'flex-start',
+ gap: 10,
+ padding: collapsed ? '8px 0' : '8px 12px',
+ borderRadius: 8,
+ border: 'none',
+ backgroundColor: 'transparent',
+ color: 'var(--color-text-muted)',
+ cursor: 'pointer',
+ fontSize: 12,
+ transition: 'background-color 0.15s, color 0.15s',
+ }}
+ onMouseEnter={(e) => {
+ e.currentTarget.style.backgroundColor = 'var(--color-surface-el)';
+ e.currentTarget.style.color = 'var(--color-text-secondary)';
+ }}
+ onMouseLeave={(e) => {
+ e.currentTarget.style.backgroundColor = 'transparent';
+ e.currentTarget.style.color = 'var(--color-text-muted)';
+ }}
+ >
+ {collapsed ? <ChevronRight size={16} /> : <ChevronLeft size={16} />}
+ {!collapsed && <span>Collapse</span>}
+ </button>
+ </div>
+ </aside>
+ </>
+ );
+}
+
+/* ─── SidebarButton ──────────────────────────────────────────────────────── */
+function SidebarButton({
+ id,
+ label,
+ Icon,
+ isActive,
+ collapsed,
+ onClick,
+}: {
+ id: string;
+ label: string;
+ Icon: React.ElementType;
+ isActive: boolean;
+ collapsed: boolean;
+ onClick: () => void;
+}) {
+ return (
+ <button
+ onClick={onClick}
+ aria-current={isActive ? 'page' : undefined}
+ title={collapsed ? label : undefined}
+ style={{
+ display: 'flex',
+ alignItems: 'center',
+ gap: 10,
+ padding: collapsed ? '10px 0' : '8px 12px',
+ justifyContent: collapsed ? 'center' : 'flex-start',
+ borderRadius: 8,
+ border: 'none',
+ cursor: 'pointer',
+ position: 'relative',
+ backgroundColor: isActive ? 'rgba(124, 58, 237, 0.12)' : 'transparent',
+ color: isActive ? 'var(--color-text)' : 'var(--color-text-secondary)',
+ fontSize: 13,
+ fontWeight: isActive ? 600 : 500,
+ transition: 'background-color 0.15s, color 0.15s',
+ width: '100%',
+ overflow: 'hidden',
+ whiteSpace: 'nowrap',
+ }}
+ onMouseEnter={(e) => {
+ if (!isActive) {
+ e.currentTarget.style.backgroundColor = 'var(--color-surface-el)';
+ e.currentTarget.style.color = 'var(--color-text)';
+ }
+ }}
+ onMouseLeave={(e) => {
+ if (!isActive) {
+ e.currentTarget.style.backgroundColor = 'transparent';
+ e.currentTarget.style.color = 'var(--color-text-secondary)';
+ }
+ }}
+ >
+ {/* Active indicator bar */}
+ {isActive && (
+ <div
+ style={{
+ position: 'absolute',
+ left: 0,
+ top: '20%',
+ bottom: '20%',
+ width: 3,
+ borderRadius: '0 3px 3px 0',
+ background: 'linear-gradient(180deg, #a78bfa, #7c3aed)',
+ }}
+ />
+ )}
+ <Icon
+ size={18}
+ aria-hidden="true"
+ style={{
+ color: isActive ? '#a78bfa' : '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/campaigns/CampaignsTab.tsx b/components/campaigns/CampaignsTab.tsx
new file mode 100644
index 0000000..6429f55
--- /dev/null
+++ b/components/campaigns/CampaignsTab.tsx
@@ -0,0 +1,482 @@
+'use client';
+
+import { useState, useEffect, useCallback } from 'react';
+import { SkeletonList } from '../Skeleton';
+import {
+ Mail, Plus, Send, Clock, CheckCircle, Loader2,
+ X, Sparkles, Eye, MousePointer, AlertCircle,
+} from 'lucide-react';
+import { useToast } from '../ToastProvider';
+import SortDropdown from '../shared/SortDropdown';
+import { useClientSort, SortConfig } from '@/hooks/useClientSort';
+
+const CAMP_SORT_OPTIONS = [
+ { value: 'date_desc', label: 'Newest First' },
+ { value: 'date_asc', label: 'Oldest First' },
+ { value: 'status', label: 'Status A-Z' },
+ { value: 'opens_desc', label: 'Most Opens' },
+];
+
+const CAMP_SORT_CONFIGS: Record<string, SortConfig> = {
+ 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' },
+ opens_desc: { key: 'open_count', direction: 'desc', type: 'number' },
+};
+
+/* ─── Types ─────────────────────────────────────────────────── */
+interface Campaign {
+ id: string;
+ subject: string;
+ body_html: string;
+ body_text: string | null;
+ status: string;
+ petition_id: string | null;
+ petition_title: string | null;
+ send_to: string;
+ recipient_count: number;
+ open_count: number;
+ click_count: number;
+ scheduled_at: string | null;
+ sent_at: string | null;
+ created_at: string;
+}
+
+interface PetitionOption {
+ id: string;
+ title: string;
+}
+
+function stripHtml(html: string): string {
+ return html.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
+}
+
+/* ─── Component ─────────────────────────────────────────────── */
+export default function CampaignsTab() {
+ const { addToast } = useToast();
+ const [campaigns, setCampaigns] = useState<Campaign[]>([]);
+ const [loading, setLoading] = useState(true);
+ const [statusFilter, setStatusFilter] = useState('all');
+ const [statusCounts, setStatusCounts] = useState<Record<string, number>>({});
+ const [sortBy, setSortBy] = useState('date_desc');
+ const sortedCampaigns = useClientSort(campaigns, sortBy, CAMP_SORT_CONFIGS);
+
+ // Create modal state
+ const [showCreate, setShowCreate] = useState(false);
+ const [petitions, setPetitions] = useState<PetitionOption[]>([]);
+ const [createPetitionId, setCreatePetitionId] = useState('');
+ const [createSubject, setCreateSubject] = useState('');
+ const [createBody, setCreateBody] = useState('');
+ const [createSchedule, setCreateSchedule] = useState('');
+ const [createSaving, setCreateSaving] = useState(false);
+ const [aiGenerating, setAiGenerating] = useState(false);
+
+ // Preview modal
+ const [previewCampaign, setPreviewCampaign] = useState<Campaign | null>(null);
+
+ /* ── Fetch Campaigns ────────────────────────────────────────── */
+ const fetchCampaigns = useCallback(async () => {
+ setLoading(true);
+ try {
+ const params = new URLSearchParams();
+ if (statusFilter !== 'all') params.set('status', statusFilter);
+ const res = await fetch(`/api/campaigns?${params.toString()}`);
+ if (res.ok) {
+ const data = await res.json();
+ setCampaigns(data.rows || []);
+ setStatusCounts(data.statusCounts || {});
+ }
+ } catch (err) {
+ console.error('Fetch campaigns error:', err);
+ } finally {
+ setLoading(false);
+ }
+ }, [statusFilter]);
+
+ useEffect(() => { fetchCampaigns(); }, [fetchCampaigns]);
+
+ /* ── Escape key handler ───────────────────────────────────────── */
+ useEffect(() => {
+ if (!showCreate && !previewCampaign) return;
+ const handleKey = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') {
+ if (showCreate) setShowCreate(false);
+ if (previewCampaign) setPreviewCampaign(null);
+ }
+ };
+ window.addEventListener('keydown', handleKey);
+ return () => window.removeEventListener('keydown', handleKey);
+ }, [showCreate, previewCampaign]);
+
+ /* ── Fetch Petitions for Dropdown ───────────────────────────── */
+ async function fetchPetitions() {
+ try {
+ const res = await fetch('/api/petitions');
+ if (res.ok) {
+ const data = await res.json();
+ setPetitions((data.rows || []).map((r: { id: string; title: string }) => ({ id: r.id, title: r.title })));
+ }
+ } catch (err) {
+ console.error('Fetch petitions error:', err);
+ }
+ }
+
+ function openCreate() {
+ setShowCreate(true);
+ setCreatePetitionId('');
+ setCreateSubject('');
+ setCreateBody('');
+ setCreateSchedule('');
+ fetchPetitions();
+ }
+
+ /* ── AI Generate ────────────────────────────────────────────── */
+ async function handleAiGenerate() {
+ setAiGenerating(true);
+ try {
+ const res = await fetch('/api/campaigns/generate', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ petition_id: createPetitionId || null,
+ tone: 'urgent',
+ call_to_action_type: 'sign',
+ }),
+ });
+ if (res.ok) {
+ const data = await res.json();
+ setCreateSubject(data.subject || '');
+ setCreateBody(data.body_html || data.body_text || '');
+ addToast('Content generated', 'success');
+ } else {
+ addToast('AI generation failed', 'error');
+ }
+ } catch (err) {
+ console.error('AI generate error:', err);
+ addToast('AI generation failed', 'error');
+ } finally {
+ setAiGenerating(false);
+ }
+ }
+
+ /* ── Create Campaign ────────────────────────────────────────── */
+ async function handleCreate() {
+ if (!createSubject.trim() || !createBody.trim()) return;
+ setCreateSaving(true);
+ try {
+ const res = await fetch('/api/campaigns', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ subject: createSubject,
+ body_html: createBody,
+ petition_id: createPetitionId || null,
+ scheduled_at: createSchedule || null,
+ }),
+ });
+ if (res.ok) {
+ setShowCreate(false);
+ fetchCampaigns();
+ addToast('Campaign created', 'success');
+ } else {
+ addToast('Failed to create campaign', 'error');
+ }
+ } catch (err) {
+ console.error('Create campaign error:', err);
+ addToast('Failed to create campaign', 'error');
+ } finally {
+ setCreateSaving(false);
+ }
+ }
+
+ /* ── Send Now ───────────────────────────────────────────────── */
+ async function handleSendNow(id: string) {
+ try {
+ const res = await fetch('/api/campaigns', {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ id, status: 'sending', sent_at: new Date().toISOString() }),
+ });
+ if (res.ok) {
+ fetchCampaigns();
+ addToast('Campaign sending', 'success');
+ } else {
+ addToast('Send failed', 'error');
+ }
+ } catch (err) {
+ console.error('Send now error:', err);
+ addToast('Send failed', 'error');
+ }
+ }
+
+ /* ── Helpers ────────────────────────────────────────────────── */
+ const statusFilters = ['all', 'draft', 'scheduled', 'sending', 'sent', 'failed'];
+
+ const statusBadge: Record<string, { cls: string; icon: React.ElementType }> = {
+ draft: { cls: 'badge-draft', icon: Mail },
+ scheduled: { cls: 'badge-paused', icon: Clock },
+ sending: { cls: 'badge-warning', icon: Send },
+ sent: { cls: 'badge-active', icon: CheckCircle },
+ failed: { cls: 'badge-closed', icon: AlertCircle },
+ };
+
+ const formatDate = (d: string | null) => {
+ if (!d) return '--';
+ return new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit' });
+ };
+
+ const openRate = (c: Campaign) => {
+ if (c.recipient_count === 0) return '0%';
+ return `${Math.round((c.open_count / c.recipient_count) * 100)}%`;
+ };
+
+ const clickRate = (c: Campaign) => {
+ if (c.recipient_count === 0) return '0%';
+ return `${Math.round((c.click_count / c.recipient_count) * 100)}%`;
+ };
+
+ /* ── Stats ──────────────────────────────────────────────────── */
+ const totalCampaigns = statusCounts.all || 0;
+ const sentCount = statusCounts.sent || 0;
+ const scheduledCount = statusCounts.scheduled || 0;
+ const avgOpen = campaigns.length > 0
+ ? Math.round(campaigns.reduce((sum, c) => sum + (c.recipient_count > 0 ? (c.open_count / c.recipient_count) * 100 : 0), 0) / campaigns.length)
+ : 0;
+
+ /* ─── Render ────────────────────────────────────────────────── */
+ return (
+ <div style={{ padding: '24px' }}>
+ {/* Header */}
+ <div className="flex items-center justify-between mb-6">
+ <div>
+ <h2 className="text-lg font-semibold" style={{ color: 'var(--color-text)' }}>Email Campaigns</h2>
+ <p className="text-sm mt-1" style={{ color: 'var(--color-text-muted)' }}>
+ Send email blasts to your petition signers and subscribers
+ </p>
+ </div>
+ <button className="btn btn-primary" onClick={openCreate}>
+ <Plus size={16} />
+ Create Campaign
+ </button>
+ </div>
+
+ {/* Stats Row */}
+ <div className="grid grid-cols-1 sm:grid-cols-4 gap-4 mb-6">
+ {[
+ { label: 'Total Campaigns', value: String(totalCampaigns), icon: Mail, color: 'var(--color-primary)' },
+ { label: 'Sent', value: String(sentCount), icon: Send, color: 'var(--color-success)' },
+ { label: 'Scheduled', value: String(scheduledCount), icon: Clock, color: 'var(--color-warning)' },
+ { label: 'Avg Open Rate', value: `${avgOpen}%`, icon: CheckCircle, color: 'var(--color-secondary)' },
+ ].map((stat, i) => (
+ <div key={i} className="card" style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
+ <div
+ className="w-10 h-10 rounded-lg flex items-center justify-center shrink-0"
+ style={{ backgroundColor: `${stat.color}15`, border: `1px solid ${stat.color}30` }}
+ >
+ <stat.icon size={18} style={{ color: stat.color }} />
+ </div>
+ <div>
+ <div className="text-lg font-bold" style={{ color: 'var(--color-text)' }}>{stat.value}</div>
+ <div className="text-xs" style={{ color: 'var(--color-text-muted)' }}>{stat.label}</div>
+ </div>
+ </div>
+ ))}
+ </div>
+
+ {/* Sort + Status Filters */}
+ <div className="flex items-center gap-1 mb-6 flex-wrap">
+ <SortDropdown options={CAMP_SORT_OPTIONS} value={sortBy} onChange={setSortBy} />
+ {statusFilters.map((s) => (
+ <button
+ key={s}
+ onClick={() => setStatusFilter(s)}
+ className={`btn btn-sm ${statusFilter === s ? 'btn-primary' : 'btn-ghost'}`}
+ style={{ textTransform: 'capitalize' }}
+ >
+ {s}{statusCounts[s] !== undefined ? ` (${statusCounts[s]})` : ''}
+ </button>
+ ))}
+ </div>
+
+ {/* Loading */}
+ {loading && (
+ <SkeletonList count={4} />
+ )}
+
+ {/* Empty State */}
+ {!loading && campaigns.length === 0 && (
+ <div className="card flex flex-col items-center justify-center py-16" style={{ textAlign: 'center' }}>
+ <div
+ className="w-16 h-16 rounded-2xl flex items-center justify-center mb-4"
+ style={{
+ background: 'linear-gradient(135deg, rgba(124, 58, 237, 0.15), rgba(167, 139, 250, 0.1))',
+ border: '1px solid rgba(124, 58, 237, 0.2)',
+ }}
+ >
+ <Mail size={28} style={{ color: 'var(--color-primary)' }} />
+ </div>
+ <h3 className="text-base font-semibold mb-2" style={{ color: 'var(--color-text)' }}>No campaigns yet</h3>
+ <p className="text-sm mb-6 max-w-md" style={{ color: 'var(--color-text-muted)' }}>
+ Create email campaigns to update petition signers and rally supporters.
+ </p>
+ <button className="btn btn-primary" onClick={openCreate}>
+ <Plus size={16} />
+ Create First Campaign
+ </button>
+ </div>
+ )}
+
+ {/* Campaign Cards */}
+ {!loading && sortedCampaigns.length > 0 && (
+ <div className="flex flex-col gap-3">
+ {sortedCampaigns.map((c) => {
+ const badge = statusBadge[c.status] || statusBadge.draft;
+ return (
+ <div key={c.id} className="card">
+ <div className="flex items-start justify-between gap-4">
+ <div className="flex-1 min-w-0">
+ <div className="flex items-center gap-2 mb-1 flex-wrap">
+ <h3 className="text-sm font-semibold" style={{ color: 'var(--color-text)' }}>
+ {c.subject}
+ </h3>
+ <span className={`badge ${badge.cls}`}>{c.status}</span>
+ </div>
+ {c.petition_title && (
+ <p className="text-xs mb-2" style={{ color: 'var(--color-secondary)' }}>
+ Linked: {c.petition_title}
+ </p>
+ )}
+ <div className="flex items-center gap-4 text-xs" style={{ color: 'var(--color-text-muted)' }}>
+ <span className="flex items-center gap-1">
+ <Mail size={12} /> {c.recipient_count} recipients
+ </span>
+ <span className="flex items-center gap-1">
+ <Eye size={12} /> {openRate(c)} opens
+ </span>
+ <span className="flex items-center gap-1">
+ <MousePointer size={12} /> {clickRate(c)} clicks
+ </span>
+ <span>
+ {c.sent_at ? `Sent ${formatDate(c.sent_at)}` : c.scheduled_at ? `Scheduled ${formatDate(c.scheduled_at)}` : `Created ${formatDate(c.created_at)}`}
+ </span>
+ </div>
+ </div>
+ <div className="flex items-center gap-1 shrink-0">
+ <button className="btn btn-ghost btn-sm" onClick={() => setPreviewCampaign(c)} title="Preview">
+ <Eye size={14} />
+ </button>
+ {c.status === 'draft' && (
+ <button className="btn btn-sm btn-primary" onClick={() => handleSendNow(c.id)}>
+ <Send size={14} /> Send Now
+ </button>
+ )}
+ </div>
+ </div>
+ </div>
+ );
+ })}
+ </div>
+ )}
+
+ {/* ─── Create Campaign Modal ────────────────────────────────── */}
+ {showCreate && (
+ <div
+ style={{
+ position: 'fixed', inset: 0, zIndex: 50,
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
+ backgroundColor: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(4px)',
+ }}
+ onClick={(e) => { if (e.target === e.currentTarget) setShowCreate(false); }}
+ >
+ <div style={{
+ width: '100%', maxWidth: 600, maxHeight: '90vh', overflow: 'auto',
+ backgroundColor: 'var(--color-surface)', border: '1px solid var(--color-border)',
+ borderRadius: 12, padding: 24,
+ }}>
+ <div className="flex items-center justify-between mb-4">
+ <h3 className="text-base font-semibold" style={{ color: 'var(--color-text)' }}>Create Campaign</h3>
+ <button className="btn btn-ghost btn-sm" onClick={() => setShowCreate(false)}><X size={16} /></button>
+ </div>
+ <div className="flex flex-col gap-4">
+ <div>
+ <label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>Linked Petition (optional)</label>
+ <select className="input" value={createPetitionId} onChange={(e) => setCreatePetitionId(e.target.value)}>
+ <option value="">-- None --</option>
+ {petitions.map((p) => (
+ <option key={p.id} value={p.id}>{p.title}</option>
+ ))}
+ </select>
+ </div>
+ <div>
+ <label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>Subject *</label>
+ <input className="input" value={createSubject} onChange={(e) => setCreateSubject(e.target.value)} placeholder="Email subject line" />
+ </div>
+ <div>
+ <div className="flex items-center justify-between mb-1">
+ <label className="block text-xs font-medium" style={{ color: 'var(--color-text-secondary)' }}>Body *</label>
+ <button className="btn btn-ghost btn-sm" onClick={handleAiGenerate} disabled={aiGenerating}>
+ {aiGenerating ? <><Loader2 size={12} className="animate-spin" /> Generating...</> : <><Sparkles size={12} /> AI Generate</>}
+ </button>
+ </div>
+ <textarea
+ className="input"
+ rows={8}
+ value={createBody}
+ onChange={(e) => setCreateBody(e.target.value)}
+ placeholder="Email body content (HTML supported)"
+ style={{ resize: 'vertical', fontFamily: 'monospace', fontSize: 12 }}
+ />
+ </div>
+ <div>
+ <label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>Schedule (optional)</label>
+ <input className="input" type="datetime-local" value={createSchedule} onChange={(e) => setCreateSchedule(e.target.value)} />
+ </div>
+ <button className="btn btn-primary w-full" onClick={handleCreate} disabled={createSaving || !createSubject.trim() || !createBody.trim()}>
+ {createSaving ? <><Loader2 size={16} className="animate-spin" /> Creating...</> : <><Plus size={16} /> Create Campaign</>}
+ </button>
+ </div>
+ </div>
+ </div>
+ )}
+
+ {/* ─── Preview Modal ────────────────────────────────────────── */}
+ {previewCampaign && (
+ <div
+ style={{
+ position: 'fixed', inset: 0, zIndex: 50,
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
+ backgroundColor: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(4px)',
+ }}
+ onClick={(e) => { if (e.target === e.currentTarget) setPreviewCampaign(null); }}
+ >
+ <div style={{
+ width: '100%', maxWidth: 600, maxHeight: '90vh', overflow: 'auto',
+ backgroundColor: 'var(--color-surface)', border: '1px solid var(--color-border)',
+ borderRadius: 12, padding: 24,
+ }}>
+ <div className="flex items-center justify-between mb-4">
+ <h3 className="text-base font-semibold" style={{ color: 'var(--color-text)' }}>Campaign Preview</h3>
+ <button className="btn btn-ghost btn-sm" onClick={() => setPreviewCampaign(null)}><X size={16} /></button>
+ </div>
+ <div className="mb-3">
+ <div className="text-xs font-medium" style={{ color: 'var(--color-text-muted)' }}>Subject</div>
+ <div className="text-sm font-semibold" style={{ color: 'var(--color-text)' }}>{previewCampaign.subject}</div>
+ </div>
+ <div
+ className="text-sm"
+ style={{
+ color: 'var(--color-text-secondary)',
+ padding: 12, borderRadius: 8,
+ backgroundColor: 'var(--color-surface-el)',
+ whiteSpace: 'pre-wrap',
+ maxHeight: 400, overflow: 'auto',
+ }}
+ >
+ {stripHtml(previewCampaign.body_html)}
+ </div>
+ </div>
+ </div>
+ )}
+ </div>
+ );
+}
diff --git a/components/compliance/ComplianceTab.tsx b/components/compliance/ComplianceTab.tsx
new file mode 100644
index 0000000..31c4457
--- /dev/null
+++ b/components/compliance/ComplianceTab.tsx
@@ -0,0 +1,213 @@
+'use client';
+
+import { Shield, AlertTriangle, CheckCircle, Info, ExternalLink } from 'lucide-react';
+
+export default function ComplianceTab() {
+ return (
+ <div style={{ padding: '24px' }}>
+ {/* Header */}
+ <div className="flex items-center justify-between mb-6">
+ <div>
+ <h2 className="text-lg font-semibold" style={{ color: 'var(--color-text)' }}>
+ Legal Compliance
+ </h2>
+ <p className="text-sm mt-1" style={{ color: 'var(--color-text-muted)' }}>
+ Understand the legal landscape for petition and advocacy activities
+ </p>
+ </div>
+ </div>
+
+ {/* Compliance Status */}
+ <div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-8">
+ {[
+ {
+ label: 'Organization Type',
+ value: 'Not Set',
+ icon: Shield,
+ color: 'var(--color-warning)',
+ desc: 'Set your org type for compliance guidance',
+ },
+ {
+ label: 'Email Compliance',
+ value: 'CAN-SPAM Ready',
+ icon: CheckCircle,
+ color: 'var(--color-success)',
+ desc: 'Unsubscribe links are auto-included',
+ },
+ {
+ label: 'Data Privacy',
+ value: 'GDPR Aware',
+ icon: Shield,
+ color: 'var(--color-success)',
+ desc: 'Consent tracking is enabled',
+ },
+ ].map((item, i) => (
+ <div key={i} className="card" style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
+ <div
+ className="w-10 h-10 rounded-lg flex items-center justify-center shrink-0 mt-0.5"
+ style={{
+ backgroundColor: `${item.color}15`,
+ border: `1px solid ${item.color}30`,
+ }}
+ >
+ <item.icon size={18} style={{ color: item.color }} />
+ </div>
+ <div>
+ <div className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
+ {item.label}
+ </div>
+ <div className="text-sm font-semibold" style={{ color: 'var(--color-text)' }}>
+ {item.value}
+ </div>
+ <div className="text-xs mt-0.5" style={{ color: 'var(--color-text-muted)' }}>
+ {item.desc}
+ </div>
+ </div>
+ </div>
+ ))}
+ </div>
+
+ {/* Organization Types Guide */}
+ <h3 className="text-sm font-semibold mb-4" style={{ color: 'var(--color-text)' }}>
+ Organization Type Guide
+ </h3>
+ <div className="flex flex-col gap-4 mb-8">
+ {[
+ {
+ type: '501(c)(3) -- Charitable Organization',
+ rules: [
+ 'Can conduct non-partisan voter education and registration',
+ 'Cannot engage in partisan political activities',
+ 'Limited lobbying (up to 20% of budget under the expenditure test)',
+ 'Petition activities must relate to educational or charitable purpose',
+ 'Cannot endorse or oppose political candidates',
+ ],
+ color: '#22c55e',
+ },
+ {
+ type: '501(c)(4) -- Social Welfare Organization',
+ rules: [
+ 'Can engage in unlimited lobbying related to social welfare mission',
+ 'Can participate in some political activities (not primary activity)',
+ 'Petition activities are a core part of the social welfare mission',
+ 'Must report lobbying and political expenditures',
+ 'Donations are not tax-deductible for donors',
+ ],
+ color: '#3b82f6',
+ },
+ {
+ type: 'Individual / For-Profit',
+ rules: [
+ 'No tax restrictions on petition or advocacy activities',
+ 'Must comply with campaign finance laws if petition is election-related',
+ 'State-specific lobbying registration may apply',
+ 'CAN-SPAM compliance required for email communications',
+ 'Privacy laws (GDPR, CCPA) apply to signer data collection',
+ ],
+ color: '#a78bfa',
+ },
+ ].map((org, i) => (
+ <div key={i} className="card">
+ <div className="flex items-center gap-2 mb-3">
+ <div
+ className="w-2 h-2 rounded-full"
+ style={{ backgroundColor: org.color }}
+ />
+ <h4 className="text-sm font-semibold" style={{ color: 'var(--color-text)' }}>
+ {org.type}
+ </h4>
+ </div>
+ <ul style={{ listStyle: 'none', padding: 0, margin: 0, display: 'flex', flexDirection: 'column', gap: 8 }}>
+ {org.rules.map((rule, j) => (
+ <li key={j} className="flex items-start gap-2 text-xs" style={{ color: 'var(--color-text-secondary)' }}>
+ <CheckCircle size={14} style={{ color: org.color, flexShrink: 0, marginTop: 1 }} />
+ {rule}
+ </li>
+ ))}
+ </ul>
+ </div>
+ ))}
+ </div>
+
+ {/* Key Compliance Areas */}
+ <h3 className="text-sm font-semibold mb-4" style={{ color: 'var(--color-text)' }}>
+ Key Compliance Areas
+ </h3>
+ <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
+ {[
+ {
+ title: 'Email Compliance (CAN-SPAM)',
+ items: [
+ 'Include physical mailing address',
+ 'Provide clear unsubscribe mechanism',
+ 'Honor opt-out requests within 10 business days',
+ 'Use accurate sender information',
+ ],
+ },
+ {
+ title: 'Data Privacy (GDPR / CCPA)',
+ items: [
+ 'Obtain explicit consent before collecting data',
+ 'Provide data deletion mechanism',
+ 'Document data processing purposes',
+ 'Implement reasonable security measures',
+ ],
+ },
+ {
+ title: 'Petition-Specific Rules',
+ items: [
+ 'Verify signer identity where required by state law',
+ 'Maintain signature records for audit',
+ 'Disclose petition sponsor/funder',
+ 'Follow state rules for ballot initiative petitions',
+ ],
+ },
+ {
+ title: 'Accessibility (ADA / WCAG)',
+ items: [
+ 'Petition pages must be screen-reader accessible',
+ 'Provide alt text for all images',
+ 'Ensure keyboard navigation works',
+ 'Maintain sufficient color contrast',
+ ],
+ },
+ ].map((area, i) => (
+ <div key={i} className="card">
+ <h4 className="text-sm font-semibold mb-3" style={{ color: 'var(--color-text)' }}>
+ {area.title}
+ </h4>
+ <ul style={{ listStyle: 'none', padding: 0, margin: 0, display: 'flex', flexDirection: 'column', gap: 6 }}>
+ {area.items.map((item, j) => (
+ <li key={j} className="flex items-start gap-2 text-xs" style={{ color: 'var(--color-text-secondary)' }}>
+ <Info size={12} style={{ color: 'var(--color-text-muted)', flexShrink: 0, marginTop: 2 }} />
+ {item}
+ </li>
+ ))}
+ </ul>
+ </div>
+ ))}
+ </div>
+
+ {/* Disclaimer */}
+ <div
+ className="mt-8 rounded-lg p-4 flex items-start gap-3"
+ style={{
+ backgroundColor: 'rgba(245, 158, 11, 0.08)',
+ border: '1px solid rgba(245, 158, 11, 0.2)',
+ }}
+ >
+ <AlertTriangle size={18} style={{ color: 'var(--color-warning)', flexShrink: 0, marginTop: 2 }} />
+ <div>
+ <div className="text-sm font-semibold mb-1" style={{ color: 'var(--color-warning)' }}>
+ Disclaimer
+ </div>
+ <p className="text-xs" style={{ color: 'var(--color-text-secondary)' }}>
+ This information is provided for general guidance only and does not constitute legal advice.
+ Consult with a qualified attorney for legal guidance specific to your organization
+ and jurisdiction. Laws vary by state and locality.
+ </p>
+ </div>
+ </div>
+ </div>
+ );
+}
diff --git a/components/dashboard/DashboardTab.tsx b/components/dashboard/DashboardTab.tsx
new file mode 100644
index 0000000..a81bada
--- /dev/null
+++ b/components/dashboard/DashboardTab.tsx
@@ -0,0 +1,175 @@
+'use client';
+
+import { useState, useEffect } from 'react';
+import {
+ Radio, Newspaper, BarChart3, Activity,
+ Megaphone, FileText, RefreshCw, Loader2,
+ DollarSign, TrendingUp,
+} from 'lucide-react';
+
+interface PulseStats {
+ total_feeds: number;
+ active_feeds: number;
+ total_articles: number;
+ articles_today: number;
+ avg_sentiment: number;
+ top_topics: string[];
+ last_fetch: string;
+}
+
+interface PetitionCounts {
+ draft: number;
+ active: number;
+ all: number;
+}
+
+function StatCard({ icon: Icon, label, value, sub, color }: {
+ icon: React.ElementType; label: string; value: string | number; sub?: string; color: string;
+}) {
+ return (
+ <div style={{
+ padding: 20, borderRadius: 12,
+ backgroundColor: 'var(--color-surface)',
+ border: '1px solid var(--color-border)',
+ display: 'flex', alignItems: 'flex-start', gap: 14,
+ }}>
+ <div style={{
+ width: 40, height: 40, borderRadius: 10, backgroundColor: color + '18',
+ display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
+ }}>
+ <Icon size={20} style={{ color }} />
+ </div>
+ <div>
+ <div style={{ fontSize: 11, color: 'var(--color-text-muted)', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.04em' }}>{label}</div>
+ <div style={{ fontSize: 28, fontWeight: 700, color: 'var(--color-text)', lineHeight: 1.2 }}>{value}</div>
+ {sub && <div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 2 }}>{sub}</div>}
+ </div>
+ </div>
+ );
+}
+
+export default function DashboardTab() {
+ const [stats, setStats] = useState<PulseStats | null>(null);
+ const [petitions, setPetitions] = useState<PetitionCounts | null>(null);
+ const [grantStats, setGrantStats] = useState<{ grant_count: number; total_funding: number } | null>(null);
+ const [loading, setLoading] = useState(true);
+ const [fetching, setFetching] = useState(false);
+
+ async function loadData() {
+ setLoading(true);
+ try {
+ const [pulseRes, petRes, grantRes] = await Promise.all([
+ fetch('/api/pulse/stats'),
+ fetch('/api/petitions'),
+ fetch('/api/grants-proxy/dashboard').catch(() => null),
+ ]);
+ if (pulseRes.ok) setStats(await pulseRes.json());
+ if (petRes.ok) {
+ const d = await petRes.json();
+ setPetitions(d.statusCounts || { draft: 0, active: 0, all: 0 });
+ }
+ if (grantRes?.ok) {
+ const g = await grantRes.json();
+ setGrantStats({ grant_count: g.grant_count ?? 0, total_funding: g.total_funding ?? 0 });
+ }
+ } catch (err) { console.error('Dashboard load error:', err); }
+ finally { setLoading(false); }
+ }
+
+ useEffect(() => { loadData(); }, []);
+
+ async function triggerFetch() {
+ setFetching(true);
+ try {
+ await fetch('/api/pulse/fetch', { method: 'POST' });
+ await loadData();
+ } catch {}
+ finally { setFetching(false); }
+ }
+
+ function sentimentLabel(s: number) {
+ if (s > 0.1) return { text: 'Positive', color: '#22c55e' };
+ if (s < -0.1) return { text: 'Negative', color: '#ef4444' };
+ return { text: 'Neutral', color: '#a78bfa' };
+ }
+
+ if (loading) {
+ return (
+ <div style={{ padding: 32, color: 'var(--color-text-muted)' }}>
+ <Loader2 size={20} style={{ animation: 'spin 1s linear infinite' }} /> Loading dashboard...
+ </div>
+ );
+ }
+
+ const sent = stats ? sentimentLabel(stats.avg_sentiment) : { text: '-', color: '#888' };
+
+ return (
+ <div style={{ padding: 24, maxWidth: 1200 }}>
+ <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 20 }}>
+ <div>
+ <h2 style={{ fontSize: 20, fontWeight: 700, color: 'var(--color-text)', margin: 0 }}>Dashboard</h2>
+ {stats?.last_fetch && (
+ <div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 4 }}>
+ Last fetch: {new Date(stats.last_fetch).toLocaleString('en-US', { timeZone: 'America/Los_Angeles', hour: 'numeric', minute: '2-digit', hour12: true })} PT
+ </div>
+ )}
+ </div>
+ <button
+ onClick={triggerFetch}
+ disabled={fetching}
+ style={{
+ display: 'flex', alignItems: 'center', gap: 6, padding: '8px 16px',
+ borderRadius: 8, border: '1px solid var(--color-border)',
+ backgroundColor: 'var(--color-surface)', color: 'var(--color-text)',
+ cursor: fetching ? 'wait' : 'pointer', fontSize: 13, fontWeight: 500,
+ }}
+ >
+ {fetching ? <Loader2 size={14} style={{ animation: 'spin 1s linear infinite' }} /> : <RefreshCw size={14} />}
+ {fetching ? 'Fetching...' : 'Fetch News'}
+ </button>
+ </div>
+
+ {/* Stat Cards */}
+ <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))', gap: 16, marginBottom: 28 }}>
+ <StatCard icon={Radio} label="Active Feeds" value={stats?.active_feeds ?? '-'} sub={`${stats?.total_feeds ?? 0} total`} color="#3b82f6" />
+ <StatCard icon={Newspaper} label="Articles Today" value={stats?.articles_today?.toLocaleString() ?? '-'} sub={`${stats?.total_articles?.toLocaleString() ?? 0} total`} color="#8b5cf6" />
+ <StatCard icon={Activity} label="Avg Sentiment" value={sent.text} sub={stats ? stats.avg_sentiment.toFixed(3) : '-'} color={sent.color} />
+ <StatCard icon={Megaphone} label="Active Petitions" value={petitions?.active ?? '-'} sub={`${petitions?.all ?? 0} total`} color="#f59e0b" />
+ <StatCard icon={FileText} label="Draft Petitions" value={petitions?.draft ?? '-'} color="#6b7280" />
+ <StatCard icon={BarChart3} label="Topics Generated" value={stats?.top_topics?.length ?? '-'} color="#10b981" />
+ <StatCard icon={DollarSign} label="Grants Found" value={grantStats?.grant_count ?? '-'} color="#059669" />
+ <StatCard icon={TrendingUp} label="Total Funding" value={grantStats?.total_funding ? `$${grantStats.total_funding.toLocaleString()}` : '$0'} color="#059669" />
+ </div>
+
+ {/* Top Topics */}
+ {stats?.top_topics && stats.top_topics.length > 0 && (
+ <div style={{
+ padding: 20, borderRadius: 12,
+ backgroundColor: 'var(--color-surface)',
+ border: '1px solid var(--color-border)',
+ }}>
+ <h3 style={{ fontSize: 14, fontWeight: 600, color: 'var(--color-text)', marginBottom: 12 }}>
+ Top AI Topics
+ </h3>
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
+ {stats.top_topics.map((t, i) => (
+ <div key={i} style={{
+ display: 'flex', alignItems: 'center', gap: 10,
+ padding: '8px 12px', borderRadius: 8,
+ backgroundColor: 'var(--color-bg)',
+ }}>
+ <span style={{
+ width: 22, height: 22, borderRadius: 6,
+ backgroundColor: '#7c3aed22', color: '#a78bfa',
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
+ fontSize: 11, fontWeight: 700, flexShrink: 0,
+ }}>{i + 1}</span>
+ <span style={{ fontSize: 13, color: 'var(--color-text)' }}>{t}</span>
+ </div>
+ ))}
+ </div>
+ </div>
+ )}
+ </div>
+ );
+}
diff --git a/components/grants/GrantsTab.tsx b/components/grants/GrantsTab.tsx
new file mode 100644
index 0000000..7004487
--- /dev/null
+++ b/components/grants/GrantsTab.tsx
@@ -0,0 +1,549 @@
+'use client';
+
+import { useState, useEffect, useCallback } from 'react';
+import {
+ DollarSign, Search, Sparkles, Loader2, ChevronDown, ChevronUp,
+ Bookmark, BookmarkCheck, ExternalLink, Calendar, Tag, Target,
+ AlertCircle, Clock, TrendingUp,
+} from 'lucide-react';
+import { useToast } from '../ToastProvider';
+import { useDebounce } from '@/hooks/useDebounce';
+import { SkeletonList } from '../Skeleton';
+import { Grant, formatAmount, STATUS_COLORS, STATUS_LABELS } from './types';
+
+/* ─── Constants ──────────────────────────────────────────────────────────── */
+const PRIORITY_COLORS: Record<string, string> = {
+ high: '#ef4444',
+ medium: '#f59e0b',
+ low: '#6b7280',
+};
+
+/* ─── Component ──────────────────────────────────────────────────────────── */
+export default function GrantsTab({
+ selectedId,
+ onSelectedConsumed,
+}: {
+ selectedId?: string | null;
+ onSelectedConsumed?: () => void;
+}) {
+ const { addToast } = useToast();
+ const [grants, setGrants] = useState<Grant[]>([]);
+ const [loading, setLoading] = useState(true);
+ const [statusCounts, setStatusCounts] = useState<Record<string, number>>({});
+ const [filter, setFilter] = useState('all');
+ const [search, setSearch] = useState('');
+ const debouncedSearch = useDebounce(search, 300);
+ const [expanded, setExpanded] = useState<string | null>(null);
+ const [discovering, setDiscovering] = useState(false);
+
+ /* ── Quick stats ─────────────────────────────────────────────────── */
+ const [dashStats, setDashStats] = useState<{
+ grant_count: number;
+ total_funding: number;
+ } | null>(null);
+
+ /* ── Fetch grants ────────────────────────────────────────────────── */
+ const fetchGrants = useCallback(async () => {
+ try {
+ const params = new URLSearchParams();
+ if (filter !== 'all') params.set('status', filter);
+ if (debouncedSearch) params.set('search', debouncedSearch);
+ const qs = params.toString() ? `?${params}` : '';
+
+ const res = await fetch(`/api/grants-proxy/grants${qs}`);
+ if (!res.ok) throw new Error('Failed to load grants');
+ const data = await res.json();
+ setGrants(data.rows || []);
+ setStatusCounts(data.statusCounts || {});
+ } catch (err) {
+ console.error('Grants fetch error:', err);
+ addToast('Failed to load grants', 'error');
+ } finally {
+ setLoading(false);
+ }
+ }, [filter, debouncedSearch, addToast]);
+
+ const fetchDashStats = useCallback(async () => {
+ try {
+ const res = await fetch('/api/grants-proxy/dashboard');
+ if (res.ok) {
+ const data = await res.json();
+ setDashStats({ grant_count: data.grant_count, total_funding: data.total_funding });
+ }
+ } catch { /* ignore */ }
+ }, []);
+
+ useEffect(() => {
+ fetchGrants();
+ fetchDashStats();
+ }, [fetchGrants, fetchDashStats]);
+
+ /* ── Cross-tab navigation ────────────────────────────────────────── */
+ useEffect(() => {
+ if (selectedId) {
+ setExpanded(selectedId);
+ onSelectedConsumed?.();
+ // scroll into view after render
+ setTimeout(() => {
+ document.getElementById(`grant-${selectedId}`)?.scrollIntoView({ behavior: 'smooth', block: 'center' });
+ }, 100);
+ }
+ }, [selectedId, onSelectedConsumed]);
+
+ /* ── AI Discovery ────────────────────────────────────────────────── */
+ async function discoverGrants() {
+ setDiscovering(true);
+ try {
+ const res = await fetch('/api/grants-proxy/grants/discover', { method: 'POST' });
+ if (!res.ok) throw new Error('Discovery failed');
+ const data = await res.json();
+ addToast(`Discovered ${data.discovered || 0} new grants`, 'success');
+ fetchGrants();
+ fetchDashStats();
+ } catch (err) {
+ console.error('Discovery error:', err);
+ addToast('Grant discovery failed — check Grant app status', 'error');
+ } finally {
+ setDiscovering(false);
+ }
+ }
+
+ /* ── Actions ─────────────────────────────────────────────────────── */
+ async function toggleBookmark(grant: Grant) {
+ try {
+ const res = await fetch('/api/grants-proxy/grants', {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ id: grant.id, is_bookmarked: !grant.is_bookmarked }),
+ });
+ if (!res.ok) throw new Error('Bookmark failed');
+ setGrants((prev) =>
+ prev.map((g) => (g.id === grant.id ? { ...g, is_bookmarked: !g.is_bookmarked } : g))
+ );
+ } catch {
+ addToast('Failed to update bookmark', 'error');
+ }
+ }
+
+ async function updateStatus(grantId: string, status: string) {
+ try {
+ const res = await fetch('/api/grants-proxy/grants', {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ id: grantId, status }),
+ });
+ if (!res.ok) throw new Error('Status update failed');
+ addToast(`Status updated to ${STATUS_LABELS[status] || status}`, 'success');
+ fetchGrants();
+ } catch {
+ addToast('Failed to update status', 'error');
+ }
+ }
+
+ /* ─── Render ─────────────────────────────────────────────────────── */
+ if (loading) return <SkeletonList count={6} />;
+
+ const statusFilters = ['all', 'discovered', 'researching', 'preparing', 'submitted', 'awarded', 'declined', 'expired'];
+
+ return (
+ <div style={{ padding: 24, maxWidth: 1200 }}>
+ {/* ── Header ─────────────────────────────────────────────────── */}
+ <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 20, flexWrap: 'wrap', gap: 12 }}>
+ <div>
+ <h2 style={{ fontSize: 20, fontWeight: 700, color: 'var(--color-text)', margin: 0 }}>
+ Grants
+ <span style={{ fontSize: 13, fontWeight: 400, color: 'var(--color-text-muted)', marginLeft: 10 }}>
+ {statusCounts.all ?? grants.length} total
+ </span>
+ </h2>
+ </div>
+ <button
+ onClick={discoverGrants}
+ disabled={discovering}
+ style={{
+ display: 'flex', alignItems: 'center', gap: 6,
+ padding: '8px 16px', borderRadius: 8, border: 'none',
+ backgroundColor: '#059669', color: '#fff',
+ fontSize: 13, fontWeight: 600, cursor: discovering ? 'wait' : 'pointer',
+ opacity: discovering ? 0.7 : 1,
+ transition: 'opacity 0.15s',
+ }}
+ >
+ {discovering ? <Loader2 size={15} style={{ animation: 'spin 1s linear infinite' }} /> : <Sparkles size={15} />}
+ {discovering ? 'Discovering…' : 'Discover Grants'}
+ </button>
+ </div>
+
+ {/* ── Quick Stats ────────────────────────────────────────────── */}
+ <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: 10, marginBottom: 20 }}>
+ {[
+ { label: 'Total Grants', value: statusCounts.all ?? 0, icon: DollarSign, color: '#059669' },
+ { label: 'Submitted', value: statusCounts.submitted ?? 0, icon: Target, color: '#06b6d4' },
+ { label: 'Awarded', value: statusCounts.awarded ?? 0, icon: TrendingUp, color: '#22c55e' },
+ { label: 'Total Funding', value: dashStats?.total_funding ? `$${dashStats.total_funding.toLocaleString()}` : '$0', icon: DollarSign, color: '#10b981' },
+ ].map((s) => (
+ <div key={s.label} style={{
+ padding: '14px 16px', borderRadius: 10,
+ backgroundColor: 'var(--color-surface)',
+ border: '1px solid var(--color-border)',
+ display: 'flex', alignItems: 'center', gap: 12,
+ }}>
+ <div style={{
+ width: 36, height: 36, borderRadius: 8,
+ backgroundColor: s.color + '18',
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
+ }}>
+ <s.icon size={18} style={{ color: s.color }} />
+ </div>
+ <div>
+ <div style={{ fontSize: 10, color: 'var(--color-text-muted)', fontWeight: 500 }}>{s.label}</div>
+ <div style={{ fontSize: 18, fontWeight: 700, color: 'var(--color-text)' }}>{s.value}</div>
+ </div>
+ </div>
+ ))}
+ </div>
+
+ {/* ── Search ─────────────────────────────────────────────────── */}
+ <div style={{ marginBottom: 16, position: 'relative' }}>
+ <Search size={14} style={{ position: 'absolute', left: 12, top: '50%', transform: 'translateY(-50%)', color: 'var(--color-text-muted)' }} />
+ <input
+ type="text"
+ value={search}
+ onChange={(e) => setSearch(e.target.value)}
+ placeholder="Search grants by title, funder, or tags…"
+ style={{
+ width: '100%', padding: '8px 12px 8px 34px', borderRadius: 8,
+ border: '1px solid var(--color-border)',
+ backgroundColor: 'var(--color-surface)',
+ color: 'var(--color-text)', fontSize: 13,
+ outline: 'none',
+ }}
+ />
+ </div>
+
+ {/* ── Status Pipeline Tabs ───────────────────────────────────── */}
+ <div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 20 }}>
+ {statusFilters.map((s) => {
+ const isActive = filter === s;
+ const color = s === 'all' ? '#7c3aed' : STATUS_COLORS[s] || '#6b7280';
+ const count = s === 'all' ? (statusCounts.all ?? grants.length) : (statusCounts[s] ?? 0);
+ return (
+ <button
+ key={s}
+ onClick={() => setFilter(isActive ? 'all' : s)}
+ style={{
+ display: 'flex', alignItems: 'center', gap: 6,
+ padding: '5px 12px', borderRadius: 20, cursor: 'pointer',
+ border: isActive ? `2px solid ${color}` : '1px solid var(--color-border)',
+ backgroundColor: isActive ? color + '22' : 'var(--color-surface)',
+ color: isActive ? color : 'var(--color-text-muted)',
+ fontSize: 12, fontWeight: 500,
+ transition: 'all 0.15s',
+ }}
+ >
+ {s !== 'all' && (
+ <span style={{ width: 7, height: 7, borderRadius: '50%', backgroundColor: color }} />
+ )}
+ {s === 'all' ? 'All' : STATUS_LABELS[s] || s} ({count})
+ </button>
+ );
+ })}
+ </div>
+
+ {/* ── Grant Cards ────────────────────────────────────────────── */}
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
+ {grants.map((grant) => {
+ const isExpanded = expanded === grant.id;
+ return (
+ <div
+ key={grant.id}
+ id={`grant-${grant.id}`}
+ style={{
+ borderRadius: 10,
+ backgroundColor: 'var(--color-surface)',
+ border: isExpanded ? '1px solid #059669' : '1px solid var(--color-border)',
+ transition: 'border-color 0.15s',
+ overflow: 'hidden',
+ }}
+ >
+ {/* Card Header */}
+ <div
+ style={{
+ padding: '14px 16px', cursor: 'pointer',
+ display: 'flex', alignItems: 'flex-start', gap: 12,
+ }}
+ onClick={() => setExpanded(isExpanded ? null : grant.id)}
+ >
+ {/* Left: status dot + content */}
+ <div style={{ flex: 1, minWidth: 0 }}>
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
+ <span style={{
+ width: 8, height: 8, borderRadius: '50%', flexShrink: 0,
+ backgroundColor: STATUS_COLORS[grant.status] || '#6b7280',
+ }} />
+ <span style={{
+ fontSize: 14, fontWeight: 600, color: 'var(--color-text)',
+ overflow: 'hidden', whiteSpace: 'nowrap', textOverflow: 'ellipsis',
+ }}>
+ {grant.title}
+ </span>
+ </div>
+
+ <div style={{ display: 'flex', alignItems: 'center', gap: 12, fontSize: 12, color: 'var(--color-text-muted)', flexWrap: 'wrap' }}>
+ <span style={{ fontWeight: 500 }}>{grant.funder}</span>
+ <span style={{ color: '#059669', fontWeight: 600 }}>{formatAmount(grant.amount_min, grant.amount_max)}</span>
+ {grant.deadline && (
+ <span style={{ display: 'flex', alignItems: 'center', gap: 3 }}>
+ <Calendar size={11} />
+ {new Date(grant.deadline).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
+ </span>
+ )}
+ {grant.ai_fit_score != null && (
+ <span style={{ display: 'flex', alignItems: 'center', gap: 3 }}>
+ <Sparkles size={11} style={{ color: '#f59e0b' }} />
+ {Math.round(grant.ai_fit_score * 100)}% fit
+ </span>
+ )}
+ </div>
+ </div>
+
+ {/* Right: badges + chevron */}
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
+ <span style={{
+ fontSize: 10, fontWeight: 600, padding: '2px 8px', borderRadius: 4,
+ backgroundColor: (STATUS_COLORS[grant.status] || '#6b7280') + '22',
+ color: STATUS_COLORS[grant.status] || '#6b7280',
+ textTransform: 'uppercase',
+ }}>
+ {STATUS_LABELS[grant.status] || grant.status}
+ </span>
+ <span style={{
+ fontSize: 10, fontWeight: 600, padding: '2px 8px', borderRadius: 4,
+ backgroundColor: (PRIORITY_COLORS[grant.priority] || '#6b7280') + '18',
+ color: PRIORITY_COLORS[grant.priority] || '#6b7280',
+ textTransform: 'uppercase',
+ }}>
+ {grant.priority}
+ </span>
+ <button
+ onClick={(e) => { e.stopPropagation(); toggleBookmark(grant); }}
+ style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2 }}
+ title={grant.is_bookmarked ? 'Remove bookmark' : 'Bookmark'}
+ >
+ {grant.is_bookmarked
+ ? <BookmarkCheck size={16} style={{ color: '#f59e0b' }} />
+ : <Bookmark size={16} style={{ color: 'var(--color-text-muted)' }} />
+ }
+ </button>
+ {isExpanded ? <ChevronUp size={16} style={{ color: 'var(--color-text-muted)' }} /> : <ChevronDown size={16} style={{ color: 'var(--color-text-muted)' }} />}
+ </div>
+ </div>
+
+ {/* Expanded Detail */}
+ {isExpanded && (
+ <div style={{ padding: '0 16px 16px', borderTop: '1px solid var(--color-border)' }}>
+ {/* AI Fit Score Bar */}
+ {grant.ai_fit_score != null && (
+ <div style={{ marginTop: 12, marginBottom: 12 }}>
+ <div style={{ fontSize: 11, color: 'var(--color-text-muted)', marginBottom: 4 }}>
+ AI Fit Score
+ </div>
+ <div style={{
+ height: 6, borderRadius: 3,
+ backgroundColor: 'var(--color-border)',
+ overflow: 'hidden',
+ }}>
+ <div style={{
+ height: '100%', borderRadius: 3,
+ width: `${Math.round(grant.ai_fit_score * 100)}%`,
+ backgroundColor: grant.ai_fit_score >= 0.7 ? '#22c55e' : grant.ai_fit_score >= 0.4 ? '#f59e0b' : '#ef4444',
+ transition: 'width 0.3s ease',
+ }} />
+ </div>
+ </div>
+ )}
+
+ {/* AI Suggestion */}
+ {grant.ai_suggestion && (
+ <div style={{
+ padding: '10px 12px', borderRadius: 8, marginBottom: 12,
+ backgroundColor: '#059669' + '12',
+ border: '1px solid #059669' + '30',
+ fontSize: 12, color: 'var(--color-text-secondary)', lineHeight: 1.5,
+ }}>
+ <strong style={{ color: '#10b981' }}>AI Suggestion:</strong> {grant.ai_suggestion}
+ </div>
+ )}
+
+ {/* Description */}
+ {grant.description && (
+ <div style={{ marginBottom: 12 }}>
+ <div style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', marginBottom: 4, textTransform: 'uppercase', letterSpacing: '0.04em' }}>
+ Description
+ </div>
+ <div style={{ fontSize: 13, color: 'var(--color-text-secondary)', lineHeight: 1.5 }}>
+ {grant.description}
+ </div>
+ </div>
+ )}
+
+ {/* Eligibility */}
+ {grant.eligibility && (
+ <div style={{ marginBottom: 12 }}>
+ <div style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', marginBottom: 4, textTransform: 'uppercase', letterSpacing: '0.04em' }}>
+ Eligibility
+ </div>
+ <div style={{ fontSize: 13, color: 'var(--color-text-secondary)', lineHeight: 1.5 }}>
+ {grant.eligibility}
+ </div>
+ </div>
+ )}
+
+ {/* Focus Areas */}
+ {grant.focus_areas?.length > 0 && (
+ <div style={{ marginBottom: 12 }}>
+ <div style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', marginBottom: 4, textTransform: 'uppercase', letterSpacing: '0.04em' }}>
+ Focus Areas
+ </div>
+ <div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
+ {grant.focus_areas.map((area) => (
+ <span key={area} style={{
+ padding: '2px 8px', borderRadius: 4, fontSize: 11, fontWeight: 500,
+ backgroundColor: '#059669' + '18', color: '#10b981',
+ }}>
+ {area}
+ </span>
+ ))}
+ </div>
+ </div>
+ )}
+
+ {/* Tags */}
+ {grant.tags?.length > 0 && (
+ <div style={{ marginBottom: 12 }}>
+ <div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
+ {grant.tags.map((tag) => (
+ <span key={tag} style={{
+ display: 'flex', alignItems: 'center', gap: 3,
+ padding: '2px 8px', borderRadius: 4, fontSize: 11,
+ backgroundColor: 'var(--color-bg)', color: 'var(--color-text-muted)',
+ }}>
+ <Tag size={9} /> {tag}
+ </span>
+ ))}
+ </div>
+ </div>
+ )}
+
+ {/* Notes */}
+ {grant.notes && (
+ <div style={{ marginBottom: 12 }}>
+ <div style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', marginBottom: 4, textTransform: 'uppercase', letterSpacing: '0.04em' }}>
+ Notes
+ </div>
+ <div style={{ fontSize: 12, color: 'var(--color-text-secondary)', lineHeight: 1.5, fontStyle: 'italic' }}>
+ {grant.notes}
+ </div>
+ </div>
+ )}
+
+ {/* Meta row */}
+ <div style={{ display: 'flex', alignItems: 'center', gap: 12, fontSize: 11, color: 'var(--color-text-muted)', marginBottom: 12, flexWrap: 'wrap' }}>
+ {grant.cycle && (
+ <span style={{ display: 'flex', alignItems: 'center', gap: 3 }}>
+ <Clock size={11} /> Cycle: {grant.cycle}
+ </span>
+ )}
+ <span>
+ Added {new Date(grant.created_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
+ </span>
+ </div>
+
+ {/* Actions row */}
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
+ {grant.application_url && (
+ <a
+ href={grant.application_url}
+ target="_blank"
+ rel="noopener noreferrer"
+ style={{
+ display: 'flex', alignItems: 'center', gap: 4,
+ padding: '6px 12px', borderRadius: 6,
+ backgroundColor: '#059669', color: '#fff',
+ fontSize: 12, fontWeight: 600, textDecoration: 'none',
+ }}
+ >
+ <ExternalLink size={12} /> Apply
+ </a>
+ )}
+ {grant.funder_url && (
+ <a
+ href={grant.funder_url}
+ target="_blank"
+ rel="noopener noreferrer"
+ style={{
+ display: 'flex', alignItems: 'center', gap: 4,
+ padding: '6px 12px', borderRadius: 6,
+ border: '1px solid var(--color-border)',
+ backgroundColor: 'transparent', color: 'var(--color-text-secondary)',
+ fontSize: 12, fontWeight: 500, textDecoration: 'none',
+ }}
+ >
+ <ExternalLink size={12} /> Funder Site
+ </a>
+ )}
+
+ {/* Status change dropdown */}
+ <select
+ value={grant.status}
+ onChange={(e) => updateStatus(grant.id, e.target.value)}
+ style={{
+ padding: '6px 10px', borderRadius: 6, fontSize: 12,
+ border: '1px solid var(--color-border)',
+ backgroundColor: 'var(--color-surface)',
+ color: 'var(--color-text)',
+ cursor: 'pointer',
+ }}
+ >
+ {Object.entries(STATUS_LABELS).map(([val, label]) => (
+ <option key={val} value={val}>{label}</option>
+ ))}
+ </select>
+ </div>
+ </div>
+ )}
+ </div>
+ );
+ })}
+ </div>
+
+ {/* ── Empty state ────────────────────────────────────────────── */}
+ {grants.length === 0 && !loading && (
+ <div style={{
+ padding: 48, textAlign: 'center',
+ color: 'var(--color-text-muted)',
+ }}>
+ <DollarSign size={40} style={{ marginBottom: 12, opacity: 0.3 }} />
+ <div style={{ fontSize: 15, fontWeight: 600, marginBottom: 6 }}>No grants found</div>
+ <div style={{ fontSize: 13, marginBottom: 16 }}>
+ {filter !== 'all' ? 'Try a different status filter.' : 'Click "Discover Grants" to find funding opportunities with AI.'}
+ </div>
+ {filter === 'all' && (
+ <button
+ onClick={discoverGrants}
+ disabled={discovering}
+ style={{
+ padding: '10px 20px', borderRadius: 8, border: 'none',
+ backgroundColor: '#059669', color: '#fff',
+ fontSize: 13, fontWeight: 600, cursor: 'pointer',
+ }}
+ >
+ <Sparkles size={14} style={{ marginRight: 6, verticalAlign: 'middle' }} />
+ Discover Grants
+ </button>
+ )}
+ </div>
+ )}
+ </div>
+ );
+}
diff --git a/components/grants/types.ts b/components/grants/types.ts
new file mode 100644
index 0000000..f761e58
--- /dev/null
+++ b/components/grants/types.ts
@@ -0,0 +1,51 @@
+export interface Grant {
+ id: string;
+ title: string;
+ funder: string;
+ funder_url: string | null;
+ amount_min: number | null;
+ amount_max: number | null;
+ description: string | null;
+ eligibility: string | null;
+ focus_areas: string[];
+ application_url: string | null;
+ deadline: string | null;
+ cycle: string | null;
+ ai_fit_score: number | null;
+ ai_suggestion: string | null;
+ status: string;
+ priority: string;
+ notes: string | null;
+ tags: string[];
+ is_bookmarked: boolean;
+ created_at: string;
+ updated_at?: string;
+}
+
+export function formatAmount(min: number | null, max: number | null): string {
+ if (!min && !max) return 'TBD';
+ if (min && max && min === max) return `$${max.toLocaleString()}`;
+ if (min && max) return `$${min.toLocaleString()} – $${max.toLocaleString()}`;
+ if (max) return `Up to $${max.toLocaleString()}`;
+ return `From $${min!.toLocaleString()}`;
+}
+
+export const STATUS_COLORS: Record<string, string> = {
+ discovered: '#3b82f6',
+ researching: '#8b5cf6',
+ preparing: '#f59e0b',
+ submitted: '#06b6d4',
+ awarded: '#22c55e',
+ declined: '#ef4444',
+ expired: '#6b7280',
+};
+
+export const STATUS_LABELS: Record<string, string> = {
+ discovered: 'Discovered',
+ researching: 'Researching',
+ preparing: 'Preparing',
+ submitted: 'Submitted',
+ awarded: 'Awarded',
+ declined: 'Declined',
+ expired: 'Expired',
+};
diff --git a/components/heatmap/HeatMapTab.tsx b/components/heatmap/HeatMapTab.tsx
new file mode 100644
index 0000000..13a8021
--- /dev/null
+++ b/components/heatmap/HeatMapTab.tsx
@@ -0,0 +1,709 @@
+'use client';
+
+import { useState, useEffect, useMemo } from 'react';
+import { Loader2, MapPin, Network, DollarSign, Megaphone } from 'lucide-react';
+import { Grant } from '../grants/types';
+
+interface StateData {
+ state: string;
+ topics: string[] | null;
+ intensity: number;
+ sentiment: number;
+ article_count: number;
+}
+
+interface TopicData {
+ id: string;
+ title: string;
+ category: string;
+ geo_states: string[];
+ urgency: string;
+ article_count: number;
+ avg_sentiment: number;
+}
+
+interface PetitionData {
+ id: string;
+ title: string;
+ category: string | null;
+ tags: string[] | null;
+ status: string;
+ signature_count: number;
+}
+
+const STATE_NAMES: Record<string, string> = {
+ AL:'Alabama',AK:'Alaska',AZ:'Arizona',AR:'Arkansas',CA:'California',CO:'Colorado',
+ CT:'Connecticut',DE:'Delaware',FL:'Florida',GA:'Georgia',HI:'Hawaii',ID:'Idaho',
+ IL:'Illinois',IN:'Indiana',IA:'Iowa',KS:'Kansas',KY:'Kentucky',LA:'Louisiana',
+ ME:'Maine',MD:'Maryland',MA:'Massachusetts',MI:'Michigan',MN:'Minnesota',MS:'Mississippi',
+ MO:'Missouri',MT:'Montana',NE:'Nebraska',NV:'Nevada',NH:'New Hampshire',NJ:'New Jersey',
+ NM:'New Mexico',NY:'New York',NC:'North Carolina',ND:'North Dakota',OH:'Ohio',OK:'Oklahoma',
+ OR:'Oregon',PA:'Pennsylvania',RI:'Rhode Island',SC:'South Carolina',SD:'South Dakota',
+ TN:'Tennessee',TX:'Texas',UT:'Utah',VT:'Vermont',VA:'Virginia',WA:'Washington',
+ WV:'West Virginia',WI:'Wisconsin',WY:'Wyoming',DC:'Dist. of Columbia',
+};
+
+/* ─── Tile Grid Map Positions ────────────────────────────────────────────────
+ Standard FiveThirtyEight-style tile grid. Each state gets [col, row].
+ Grid is 12 columns × 8 rows. */
+const TILE_POS: Record<string, [number, number]> = {
+ AK:[0,0], ME:[11,0],
+ WI:[6,1], VT:[10,1], NH:[11,1],
+ WA:[1,1], ID:[2,1], MT:[3,1], ND:[4,1], MN:[5,1], IL:[6,2], MI:[7,1], NY:[9,1], MA:[10,2], RI:[11,2],
+ OR:[1,2], NV:[2,2], WY:[3,2], SD:[4,2], IA:[5,2], IN:[7,2], OH:[8,2], PA:[9,2], NJ:[10,3], CT:[11,3],
+ CA:[1,3], UT:[2,3], CO:[3,3], NE:[4,3], MO:[5,3], KY:[6,3], WV:[7,3], VA:[8,3], MD:[9,3], DC:[10,4],
+ AZ:[2,4], NM:[3,4], KS:[4,4], AR:[5,4], TN:[6,4], NC:[7,4], SC:[8,4], DE:[9,4],
+ OK:[4,5], LA:[5,5], MS:[6,5], AL:[7,5], GA:[8,5],
+ HI:[1,6], TX:[4,6], FL:[9,5],
+};
+
+/* ─── Color Scales ──────────────────────────────────────────────────────────── */
+function heatColor(articles: number, maxArticles: number): string {
+ if (maxArticles === 0) return '#1e1b2e';
+ const t = Math.min(1, articles / maxArticles);
+ // Dark purple → Blue → Yellow → Red gradient
+ if (t < 0.15) return '#2d1f5e';
+ if (t < 0.3) return '#3b2d8b';
+ if (t < 0.45) return '#4f46b8';
+ if (t < 0.6) return '#6366f1';
+ if (t < 0.75) return '#f59e0b';
+ if (t < 0.9) return '#f97316';
+ return '#ef4444';
+}
+
+function sentimentColor(s: number): string {
+ if (s > 0.1) return '#22c55e';
+ if (s < -0.1) return '#ef4444';
+ return '#6b7280';
+}
+
+/* ─── Topic colors for mind map ─────────────────────────────────────────────── */
+const TOPIC_COLORS = [
+ '#8b5cf6', '#3b82f6', '#06b6d4', '#10b981', '#f59e0b',
+ '#ef4444', '#ec4899', '#f97316', '#14b8a6', '#a78bfa',
+];
+
+export default function HeatMapTab() {
+ const [states, setStates] = useState<StateData[]>([]);
+ const [topics, setTopics] = useState<TopicData[]>([]);
+ const [petitions, setPetitions] = useState<PetitionData[]>([]);
+ const [grants, setGrants] = useState<Grant[]>([]);
+ const [loading, setLoading] = useState(true);
+ const [selected, setSelected] = useState<StateData | null>(null);
+ const [view, setView] = useState<'map' | 'mind'>('map');
+ const [hoveredNode, setHoveredNode] = useState<string | null>(null);
+
+ useEffect(() => {
+ Promise.all([
+ fetch('/api/pulse/geo').then(r => r.ok ? r.json() : { states: [] }),
+ fetch('/api/pulse/topics').then(r => r.ok ? r.json() : { topics: [] }),
+ fetch('/api/petitions').then(r => r.ok ? r.json() : { rows: [] }),
+ fetch('/api/grants-proxy/grants').then(r => r.ok ? r.json() : { rows: [] }).catch(() => ({ rows: [] })),
+ ]).then(([geoData, topicData, petData, grantData]) => {
+ setStates(geoData.states || []);
+ setTopics((topicData.topics || []).filter((t: TopicData) => t.geo_states?.length > 0));
+ setPetitions(petData.rows || []);
+ setGrants(grantData.rows || []);
+ }).catch(err => console.error('HeatMap load error:', err))
+ .finally(() => setLoading(false));
+ }, []);
+
+ const stateMap = useMemo(() => {
+ const m: Record<string, StateData> = {};
+ states.forEach(s => { m[s.state] = s; });
+ return m;
+ }, [states]);
+
+ const maxArticles = useMemo(() => {
+ return Math.max(1, ...states.map(s => Number(s.article_count)));
+ }, [states]);
+
+ if (loading) {
+ return (
+ <div style={{ padding: 32, color: 'var(--color-text-muted)', display: 'flex', alignItems: 'center', gap: 8 }}>
+ <Loader2 size={18} style={{ animation: 'spin 1s linear infinite' }} /> Loading geographic data...
+ </div>
+ );
+ }
+
+ return (
+ <div style={{ padding: 24, maxWidth: 1400 }}>
+ {/* Header */}
+ <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 20 }}>
+ <h2 style={{ fontSize: 20, fontWeight: 700, color: 'var(--color-text)', margin: 0 }}>
+ Heat Map
+ <span style={{ fontSize: 13, fontWeight: 400, color: 'var(--color-text-muted)', marginLeft: 10 }}>
+ {states.length} states with activity
+ </span>
+ </h2>
+ {/* View Toggle */}
+ <div style={{ display: 'flex', gap: 4, padding: 3, borderRadius: 10, backgroundColor: 'var(--color-bg)', border: '1px solid var(--color-border)' }}>
+ <button onClick={() => setView('map')} style={{
+ padding: '6px 14px', borderRadius: 8, border: 'none', fontSize: 12, fontWeight: 600, cursor: 'pointer',
+ backgroundColor: view === 'map' ? '#7c3aed' : 'transparent',
+ color: view === 'map' ? '#fff' : 'var(--color-text-muted)',
+ }}>
+ <MapPin size={13} style={{ marginRight: 4, verticalAlign: -2 }} /> US Map
+ </button>
+ <button onClick={() => setView('mind')} style={{
+ padding: '6px 14px', borderRadius: 8, border: 'none', fontSize: 12, fontWeight: 600, cursor: 'pointer',
+ backgroundColor: view === 'mind' ? '#7c3aed' : 'transparent',
+ color: view === 'mind' ? '#fff' : 'var(--color-text-muted)',
+ }}>
+ <Network size={13} style={{ marginRight: 4, verticalAlign: -2 }} /> Network
+ </button>
+ </div>
+ </div>
+
+ {states.length === 0 ? (
+ <div style={{
+ padding: 40, textAlign: 'center', color: 'var(--color-text-muted)', fontSize: 14,
+ backgroundColor: 'var(--color-surface)', borderRadius: 12, border: '1px solid var(--color-border)',
+ }}>
+ <MapPin size={32} style={{ marginBottom: 8, opacity: 0.4 }} />
+ <div>No geographic data yet. Run topic generation to populate state-level data.</div>
+ </div>
+ ) : view === 'map' ? (
+ <TileGridMap states={states} stateMap={stateMap} maxArticles={maxArticles} selected={selected} onSelect={setSelected} />
+ ) : (
+ <NetworkMindMap topics={topics} petitions={petitions} grants={grants} stateMap={stateMap} hoveredNode={hoveredNode} onHoverNode={setHoveredNode} />
+ )}
+ </div>
+ );
+}
+
+/* ═══════════════════════════════════════════════════════════════════════════════
+ TILE GRID MAP — Geographic US Map with colored tiles
+ ═══════════════════════════════════════════════════════════════════════════════ */
+function TileGridMap({ states, stateMap, maxArticles, selected, onSelect }: {
+ states: StateData[];
+ stateMap: Record<string, StateData>;
+ maxArticles: number;
+ selected: StateData | null;
+ onSelect: (s: StateData | null) => void;
+}) {
+ const TILE = 58;
+ const GAP = 4;
+ const STEP = TILE + GAP;
+ const COLS = 12;
+ const ROWS = 7;
+ const W = COLS * STEP + 40;
+ const H = ROWS * STEP + 40;
+
+ return (
+ <div style={{ display: 'flex', gap: 20, flexWrap: 'wrap' }}>
+ {/* Map */}
+ <div style={{
+ flex: '1 1 700px', padding: 20, borderRadius: 12,
+ backgroundColor: 'var(--color-surface)', border: '1px solid var(--color-border)',
+ overflow: 'auto',
+ }}>
+ <svg viewBox={`0 0 ${W} ${H}`} style={{ width: '100%', maxWidth: 800, display: 'block', margin: '0 auto' }}>
+ {/* All 50 states + DC */}
+ {Object.entries(TILE_POS).map(([abbr, [col, row]]) => {
+ const data = stateMap[abbr];
+ const x = col * STEP + 20;
+ const y = row * STEP + 10;
+ const articles = data ? Number(data.article_count) : 0;
+ const fill = data ? heatColor(articles, maxArticles) : '#1a1726';
+ const isSelected = selected?.state === abbr;
+ const sentiment = data ? Number(data.sentiment) : 0;
+
+ return (
+ <g key={abbr} onClick={() => data && onSelect(isSelected ? null : data)} style={{ cursor: data ? 'pointer' : 'default' }}>
+ <rect
+ x={x} y={y} width={TILE} height={TILE} rx={6}
+ fill={fill}
+ stroke={isSelected ? '#a78bfa' : data ? '#ffffff15' : '#ffffff08'}
+ strokeWidth={isSelected ? 2.5 : 1}
+ />
+ <text
+ x={x + TILE / 2} y={y + TILE / 2 - 4}
+ textAnchor="middle" dominantBaseline="middle"
+ fill={data ? '#fff' : '#555'}
+ fontSize={14} fontWeight={700} fontFamily="system-ui"
+ >
+ {abbr}
+ </text>
+ {data && (
+ <text
+ x={x + TILE / 2} y={y + TILE / 2 + 12}
+ textAnchor="middle" dominantBaseline="middle"
+ fill="#ffffff99" fontSize={8} fontFamily="system-ui"
+ >
+ {articles}
+ </text>
+ )}
+ {/* Sentiment indicator dot */}
+ {data && (
+ <circle
+ cx={x + TILE - 6} cy={y + 6} r={3}
+ fill={sentimentColor(sentiment)}
+ />
+ )}
+ </g>
+ );
+ })}
+ </svg>
+
+ {/* Legend */}
+ <div style={{ display: 'flex', justifyContent: 'center', gap: 20, marginTop: 16, flexWrap: 'wrap' }}>
+ <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
+ <span style={{ fontSize: 10, color: 'var(--color-text-muted)' }}>Low</span>
+ {['#2d1f5e', '#3b2d8b', '#4f46b8', '#6366f1', '#f59e0b', '#f97316', '#ef4444'].map((c, i) => (
+ <span key={i} style={{ width: 20, height: 10, borderRadius: 2, backgroundColor: c }} />
+ ))}
+ <span style={{ fontSize: 10, color: 'var(--color-text-muted)' }}>High</span>
+ </div>
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
+ <span style={{ display: 'flex', alignItems: 'center', gap: 3 }}>
+ <span style={{ width: 6, height: 6, borderRadius: '50%', backgroundColor: '#22c55e' }} />
+ <span style={{ fontSize: 10, color: 'var(--color-text-muted)' }}>Positive</span>
+ </span>
+ <span style={{ display: 'flex', alignItems: 'center', gap: 3 }}>
+ <span style={{ width: 6, height: 6, borderRadius: '50%', backgroundColor: '#6b7280' }} />
+ <span style={{ fontSize: 10, color: 'var(--color-text-muted)' }}>Neutral</span>
+ </span>
+ <span style={{ display: 'flex', alignItems: 'center', gap: 3 }}>
+ <span style={{ width: 6, height: 6, borderRadius: '50%', backgroundColor: '#ef4444' }} />
+ <span style={{ fontSize: 10, color: 'var(--color-text-muted)' }}>Negative</span>
+ </span>
+ </div>
+ </div>
+ </div>
+
+ {/* Detail Panel */}
+ {selected && (
+ <div style={{
+ flex: '0 0 300px', padding: 20, borderRadius: 12,
+ backgroundColor: 'var(--color-surface)', border: '1px solid var(--color-border)',
+ alignSelf: 'flex-start',
+ }}>
+ <div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 }}>
+ <div style={{
+ width: 40, height: 40, borderRadius: 10,
+ backgroundColor: heatColor(Number(selected.article_count), maxArticles),
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
+ color: '#fff', fontSize: 16, fontWeight: 800,
+ }}>{selected.state}</div>
+ <div>
+ <div style={{ fontSize: 16, fontWeight: 700, color: 'var(--color-text)' }}>
+ {STATE_NAMES[selected.state] || selected.state}
+ </div>
+ <div style={{ fontSize: 11, color: 'var(--color-text-muted)' }}>
+ {Number(selected.article_count)} articles | Intensity: {Number(selected.intensity).toFixed(0)}
+ </div>
+ </div>
+ </div>
+
+ {/* Sentiment bar */}
+ <div style={{ marginBottom: 16 }}>
+ <div style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', marginBottom: 4 }}>Sentiment</div>
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
+ <div style={{
+ flex: 1, height: 6, borderRadius: 3, backgroundColor: 'var(--color-bg)',
+ overflow: 'hidden', position: 'relative',
+ }}>
+ <div style={{
+ position: 'absolute',
+ left: '50%', top: 0, bottom: 0,
+ width: `${Math.abs(Number(selected.sentiment)) * 200}%`,
+ transform: Number(selected.sentiment) < 0 ? 'translateX(-100%)' : 'none',
+ backgroundColor: sentimentColor(Number(selected.sentiment)),
+ borderRadius: 3,
+ }} />
+ </div>
+ <span style={{
+ fontSize: 12, fontWeight: 600,
+ color: sentimentColor(Number(selected.sentiment)),
+ }}>
+ {Number(selected.sentiment).toFixed(3)}
+ </span>
+ </div>
+ </div>
+
+ {/* Topics list */}
+ {selected.topics && selected.topics.length > 0 && (
+ <div>
+ <div style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', marginBottom: 6 }}>
+ Source Categories ({selected.topics.length})
+ </div>
+ <div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
+ {selected.topics.map((t, i) => (
+ <span key={i} style={{
+ fontSize: 10, padding: '3px 8px', borderRadius: 8,
+ backgroundColor: '#7c3aed22', color: '#a78bfa',
+ }}>
+ {t.replace(/_/g, ' ')}
+ </span>
+ ))}
+ </div>
+ </div>
+ )}
+ </div>
+ )}
+ </div>
+ );
+}
+
+/* ═══════════════════════════════════════════════════════════════════════════════
+ NETWORK MIND MAP — 3-ring radial network: Topics → Petitions+Grants → States
+ ═══════════════════════════════════════════════════════════════════════════════ */
+const NODE_COLORS = {
+ topic: '#8b5cf6',
+ petition: '#3b82f6',
+ grant: '#10b981',
+ state: '#6b7280',
+};
+
+function textMatch(a: string | null | undefined, b: string | null | undefined): boolean {
+ if (!a || !b) return false;
+ return a.toLowerCase().includes(b.toLowerCase()) || b.toLowerCase().includes(a.toLowerCase());
+}
+
+function tagsOverlap(a: string[] | null | undefined, b: string[] | null | undefined): boolean {
+ if (!a?.length || !b?.length) return false;
+ const setB = new Set(b.map(t => t.toLowerCase()));
+ return a.some(t => setB.has(t.toLowerCase()));
+}
+
+function NetworkMindMap({ topics, petitions, grants, stateMap, hoveredNode, onHoverNode }: {
+ topics: TopicData[];
+ petitions: PetitionData[];
+ grants: Grant[];
+ stateMap: Record<string, StateData>;
+ hoveredNode: string | null;
+ onHoverNode: (id: string | null) => void;
+}) {
+ const displayTopics = topics.slice(0, 10);
+ const displayPetitions = petitions.slice(0, 8);
+ const displayGrants = grants.slice(0, 8);
+ const CX = 500;
+ const CY = 400;
+ const R1 = 100; // Topics ring
+ const R2 = 220; // Petitions + Grants ring
+ const R3 = 340; // States ring
+ const W = 1000;
+ const H = 800;
+
+ // Collect unique states
+ const stateSet = new Set<string>();
+ displayTopics.forEach(t => t.geo_states?.forEach(s => stateSet.add(s)));
+ const uniqueStates = Array.from(stateSet);
+
+ // Positions: Ring 3 — States
+ const statePos: Record<string, { x: number; y: number }> = {};
+ uniqueStates.forEach((s, i) => {
+ const angle = (i / uniqueStates.length) * Math.PI * 2 - Math.PI / 2;
+ statePos[s] = { x: CX + Math.cos(angle) * R3, y: CY + Math.sin(angle) * R3 };
+ });
+
+ // Positions: Ring 1 — Topics
+ const topicNodes = displayTopics.map((t, i) => {
+ const angle = (i / displayTopics.length) * Math.PI * 2 - Math.PI / 2;
+ return { id: `t-${t.id}`, data: t, x: CX + Math.cos(angle) * R1, y: CY + Math.sin(angle) * R1, type: 'topic' as const };
+ });
+
+ // Positions: Ring 2 — Petitions (left half) + Grants (right half)
+ const midCount = displayPetitions.length + displayGrants.length;
+ const petitionNodes = displayPetitions.map((p, i) => {
+ const angle = (i / midCount) * Math.PI * 2 - Math.PI / 2;
+ return { id: `p-${p.id}`, data: p, x: CX + Math.cos(angle) * R2, y: CY + Math.sin(angle) * R2, type: 'petition' as const };
+ });
+ const grantNodes = displayGrants.map((g, i) => {
+ const angle = ((i + displayPetitions.length) / midCount) * Math.PI * 2 - Math.PI / 2;
+ return { id: `g-${g.id}`, data: g, x: CX + Math.cos(angle) * R2, y: CY + Math.sin(angle) * R2, type: 'grant' as const };
+ });
+
+ // Build connections
+ type Connection = { from: string; to: string; fx: number; fy: number; tx: number; ty: number; color: string };
+ const connections: Connection[] = [];
+
+ // Topic → State (from geo_states)
+ topicNodes.forEach(tn => {
+ (tn.data as TopicData).geo_states?.forEach(s => {
+ const sp = statePos[s];
+ if (sp) connections.push({ from: tn.id, to: `s-${s}`, fx: tn.x, fy: tn.y, tx: sp.x, ty: sp.y, color: NODE_COLORS.topic });
+ });
+ });
+
+ // Topic → Petition (matched by category)
+ topicNodes.forEach(tn => {
+ const topicCat = (tn.data as TopicData).category;
+ petitionNodes.forEach(pn => {
+ const petCat = (pn.data as PetitionData).category;
+ const petTags = (pn.data as PetitionData).tags;
+ if (textMatch(topicCat, petCat) || (petTags && petTags.some(tag => textMatch(topicCat, tag)))) {
+ connections.push({ from: tn.id, to: pn.id, fx: tn.x, fy: tn.y, tx: pn.x, ty: pn.y, color: NODE_COLORS.petition });
+ }
+ });
+ });
+
+ // Topic → Grant (matched by focus_areas/category)
+ topicNodes.forEach(tn => {
+ const topicCat = (tn.data as TopicData).category;
+ grantNodes.forEach(gn => {
+ const grantData = gn.data as Grant;
+ if (grantData.focus_areas?.some(fa => textMatch(topicCat, fa)) || tagsOverlap(grantData.tags, [topicCat])) {
+ connections.push({ from: tn.id, to: gn.id, fx: tn.x, fy: tn.y, tx: gn.x, ty: gn.y, color: NODE_COLORS.grant });
+ }
+ });
+ });
+
+ // Grant → Petition (matched by tags overlap)
+ grantNodes.forEach(gn => {
+ const grantTags = (gn.data as Grant).tags;
+ const grantFocus = (gn.data as Grant).focus_areas;
+ petitionNodes.forEach(pn => {
+ const petTags = (pn.data as PetitionData).tags;
+ if (tagsOverlap(grantTags, petTags) || tagsOverlap(grantFocus, petTags)) {
+ connections.push({ from: gn.id, to: pn.id, fx: gn.x, fy: gn.y, tx: pn.x, ty: pn.y, color: '#f59e0b' });
+ }
+ });
+ });
+
+ // Determine which nodes are connected to the hovered node
+ const connectedToHovered = useMemo(() => {
+ if (!hoveredNode) return new Set<string>();
+ const set = new Set<string>();
+ set.add(hoveredNode);
+ connections.forEach(c => {
+ if (c.from === hoveredNode) set.add(c.to);
+ if (c.to === hoveredNode) set.add(c.from);
+ });
+ return set;
+ }, [hoveredNode, connections]);
+
+ const isNodeDimmed = (nodeId: string) => hoveredNode !== null && !connectedToHovered.has(nodeId);
+ const isConnDimmed = (c: Connection) => hoveredNode !== null && !connectedToHovered.has(c.from) && !connectedToHovered.has(c.to);
+ const isConnHighlighted = (c: Connection) => hoveredNode !== null && (c.from === hoveredNode || c.to === hoveredNode);
+
+ return (
+ <div style={{
+ padding: 20, borderRadius: 12,
+ backgroundColor: 'var(--color-surface)', border: '1px solid var(--color-border)',
+ }}>
+ <div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12, textAlign: 'center' }}>
+ Topics ↔ Petitions ↔ Grants ↔ States — hover any node to highlight connections
+ </div>
+
+ <svg viewBox={`0 0 ${W} ${H}`} style={{ width: '100%', maxWidth: 1000, display: 'block', margin: '0 auto' }}>
+ {/* Ring guides */}
+ <circle cx={CX} cy={CY} r={R1} fill="none" stroke="#ffffff06" strokeWidth={1} />
+ <circle cx={CX} cy={CY} r={R2} fill="none" stroke="#ffffff06" strokeWidth={1} />
+ <circle cx={CX} cy={CY} r={R3} fill="none" stroke="#ffffff06" strokeWidth={1} />
+
+ {/* Connection lines */}
+ {connections.map((c, i) => (
+ <line
+ key={i}
+ x1={c.fx} y1={c.fy} x2={c.tx} y2={c.ty}
+ stroke={c.color}
+ strokeWidth={isConnHighlighted(c) ? 2.5 : 1}
+ strokeOpacity={isConnDimmed(c) ? 0.03 : isConnHighlighted(c) ? 0.8 : 0.2}
+ strokeDasharray={isConnHighlighted(c) ? '' : '4 2'}
+ />
+ ))}
+
+ {/* Center hub */}
+ <circle cx={CX} cy={CY} r={28} fill="#7c3aed22" stroke="#7c3aed" strokeWidth={1.5} />
+ <text x={CX} y={CY - 5} textAnchor="middle" fill="#a78bfa" fontSize={9} fontWeight={700}>Pulse</text>
+ <text x={CX} y={CY + 7} textAnchor="middle" fill="#a78bfa88" fontSize={7}>Network</text>
+
+ {/* Ring 3: State nodes */}
+ {uniqueStates.map(s => {
+ const pos = statePos[s];
+ const data = stateMap[s];
+ const nodeId = `s-${s}`;
+ const dimmed = isNodeDimmed(nodeId);
+ return (
+ <g key={nodeId}
+ onMouseEnter={() => onHoverNode(nodeId)}
+ onMouseLeave={() => onHoverNode(null)}
+ style={{ cursor: 'pointer' }}
+ >
+ <circle
+ cx={pos.x} cy={pos.y} r={16}
+ fill={dimmed ? '#1e1b2e55' : '#1e1b2e'}
+ stroke={dimmed ? '#ffffff08' : '#ffffff20'}
+ strokeWidth={1}
+ opacity={dimmed ? 0.3 : 1}
+ />
+ <text
+ x={pos.x} y={pos.y - 1}
+ textAnchor="middle" dominantBaseline="middle"
+ fill={dimmed ? '#555' : '#aaa'}
+ fontSize={10} fontWeight={700} fontFamily="system-ui"
+ >
+ {s}
+ </text>
+ {data && (
+ <text
+ x={pos.x} y={pos.y + 9}
+ textAnchor="middle" dominantBaseline="middle"
+ fill="#ffffff44" fontSize={6} fontFamily="system-ui"
+ >
+ {Number(data.article_count)}
+ </text>
+ )}
+ </g>
+ );
+ })}
+
+ {/* Ring 2: Petition nodes */}
+ {petitionNodes.map(({ id, data, x, y }) => {
+ const dimmed = isNodeDimmed(id);
+ const pet = data as PetitionData;
+ const label = pet.title.length > 16 ? pet.title.substring(0, 14) + '…' : pet.title;
+ return (
+ <g key={id}
+ onMouseEnter={() => onHoverNode(id)}
+ onMouseLeave={() => onHoverNode(null)}
+ style={{ cursor: 'pointer' }}
+ >
+ <circle
+ cx={x} cy={y} r={22}
+ fill={dimmed ? NODE_COLORS.petition + '08' : NODE_COLORS.petition + '22'}
+ stroke={NODE_COLORS.petition}
+ strokeWidth={1.5}
+ opacity={dimmed ? 0.2 : 1}
+ />
+ <text x={x} y={y - 3} textAnchor="middle" dominantBaseline="middle"
+ fill={dimmed ? '#555' : '#fff'} fontSize={6} fontWeight={600} fontFamily="system-ui"
+ >
+ {label.split(' ').slice(0, 2).join(' ')}
+ </text>
+ <text x={x} y={y + 5} textAnchor="middle" dominantBaseline="middle"
+ fill={dimmed ? '#555' : '#fff'} fontSize={6} fontWeight={600} fontFamily="system-ui"
+ >
+ {label.split(' ').slice(2, 4).join(' ')}
+ </text>
+ {/* Petition icon badge */}
+ <circle cx={x + 16} cy={y - 16} r={6} fill={NODE_COLORS.petition} opacity={dimmed ? 0.2 : 1} />
+ <text x={x + 16} y={y - 16} textAnchor="middle" dominantBaseline="middle"
+ fill="#fff" fontSize={6} fontWeight={700}>P</text>
+ </g>
+ );
+ })}
+
+ {/* Ring 2: Grant nodes */}
+ {grantNodes.map(({ id, data, x, y }) => {
+ const dimmed = isNodeDimmed(id);
+ const g = data as Grant;
+ const label = g.title.length > 16 ? g.title.substring(0, 14) + '…' : g.title;
+ return (
+ <g key={id}
+ onMouseEnter={() => onHoverNode(id)}
+ onMouseLeave={() => onHoverNode(null)}
+ style={{ cursor: 'pointer' }}
+ >
+ <rect
+ x={x - 22} y={y - 16} width={44} height={32} rx={8}
+ fill={dimmed ? NODE_COLORS.grant + '08' : NODE_COLORS.grant + '22'}
+ stroke={NODE_COLORS.grant}
+ strokeWidth={1.5}
+ opacity={dimmed ? 0.2 : 1}
+ />
+ <text x={x} y={y - 3} textAnchor="middle" dominantBaseline="middle"
+ fill={dimmed ? '#555' : '#fff'} fontSize={6} fontWeight={600} fontFamily="system-ui"
+ >
+ {label.split(' ').slice(0, 2).join(' ')}
+ </text>
+ <text x={x} y={y + 5} textAnchor="middle" dominantBaseline="middle"
+ fill={dimmed ? '#555' : '#fff'} fontSize={6} fontWeight={600} fontFamily="system-ui"
+ >
+ {label.split(' ').slice(2, 4).join(' ')}
+ </text>
+ {/* Grant icon badge */}
+ <circle cx={x + 18} cy={y - 12} r={6} fill={NODE_COLORS.grant} opacity={dimmed ? 0.2 : 1} />
+ <text x={x + 18} y={y - 12} textAnchor="middle" dominantBaseline="middle"
+ fill="#fff" fontSize={6} fontWeight={700}>$</text>
+ </g>
+ );
+ })}
+
+ {/* Ring 1: Topic nodes */}
+ {topicNodes.map(({ id, data, x, y }, i) => {
+ const dimmed = isNodeDimmed(id);
+ const topic = data as TopicData;
+ const color = TOPIC_COLORS[i % TOPIC_COLORS.length];
+ const shortTitle = topic.title.length > 20 ? topic.title.substring(0, 18) + '…' : topic.title;
+ return (
+ <g key={id}
+ onMouseEnter={() => onHoverNode(id)}
+ onMouseLeave={() => onHoverNode(null)}
+ style={{ cursor: 'pointer' }}
+ >
+ <circle
+ cx={x} cy={y} r={26}
+ fill={dimmed ? color + '08' : color + '22'}
+ stroke={color}
+ strokeWidth={dimmed ? 1 : 1.5}
+ opacity={dimmed ? 0.2 : 1}
+ />
+ {topic.urgency === 'critical' && !dimmed && (
+ <circle cx={x} cy={y} r={30} fill="none" stroke="#ef444466" strokeWidth={1} strokeDasharray="3 3" />
+ )}
+ <text x={x} y={y - 4} textAnchor="middle" dominantBaseline="middle"
+ fill={dimmed ? '#555' : '#fff'} fontSize={7} fontWeight={600} fontFamily="system-ui"
+ >
+ {shortTitle.split(' ').slice(0, 2).join(' ')}
+ </text>
+ <text x={x} y={y + 6} textAnchor="middle" dominantBaseline="middle"
+ fill={dimmed ? '#555' : '#fff'} fontSize={7} fontWeight={600} fontFamily="system-ui"
+ >
+ {shortTitle.split(' ').slice(2, 4).join(' ')}
+ </text>
+ <circle cx={x + 20} cy={y - 20} r={8} fill={color} opacity={dimmed ? 0.2 : 1} />
+ <text x={x + 20} y={y - 20} textAnchor="middle" dominantBaseline="middle"
+ fill="#fff" fontSize={7} fontWeight={700}
+ >
+ {topic.article_count}
+ </text>
+ </g>
+ );
+ })}
+ </svg>
+
+ {/* Legend */}
+ <div style={{ display: 'flex', flexWrap: 'wrap', gap: 16, marginTop: 16, justifyContent: 'center' }}>
+ {[
+ { label: 'Topics', color: NODE_COLORS.topic, count: displayTopics.length },
+ { label: 'Petitions', color: NODE_COLORS.petition, count: displayPetitions.length },
+ { label: 'Grants', color: NODE_COLORS.grant, count: displayGrants.length },
+ { label: 'States', color: NODE_COLORS.state, count: uniqueStates.length },
+ ].map(item => (
+ <div key={item.label} style={{
+ display: 'flex', alignItems: 'center', gap: 6, padding: '4px 10px',
+ borderRadius: 8, backgroundColor: 'var(--color-bg)',
+ border: '1px solid var(--color-border)',
+ }}>
+ <span style={{ width: 10, height: 10, borderRadius: item.label === 'Grants' ? 3 : '50%', backgroundColor: item.color, flexShrink: 0 }} />
+ <span style={{ fontSize: 12, color: 'var(--color-text)', fontWeight: 600 }}>{item.label}</span>
+ <span style={{ fontSize: 11, color: 'var(--color-text-muted)' }}>({item.count})</span>
+ </div>
+ ))}
+ </div>
+
+ {/* Node list — all entities */}
+ <div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 10, justifyContent: 'center' }}>
+ {topicNodes.map(({ id, data }, i) => (
+ <div key={id}
+ onMouseEnter={() => onHoverNode(id)}
+ onMouseLeave={() => onHoverNode(null)}
+ style={{
+ display: 'flex', alignItems: 'center', gap: 4, padding: '3px 8px',
+ borderRadius: 6, cursor: 'pointer', fontSize: 10,
+ backgroundColor: hoveredNode === id ? TOPIC_COLORS[i % TOPIC_COLORS.length] + '22' : 'transparent',
+ border: `1px solid ${hoveredNode === id ? TOPIC_COLORS[i % TOPIC_COLORS.length] : 'transparent'}`,
+ color: 'var(--color-text-muted)',
+ }}
+ >
+ <span style={{ width: 6, height: 6, borderRadius: '50%', backgroundColor: TOPIC_COLORS[i % TOPIC_COLORS.length] }} />
+ {(data as TopicData).title.substring(0, 30)}
+ </div>
+ ))}
+ </div>
+ </div>
+ );
+}
diff --git a/components/news/NewsFeedTab.tsx b/components/news/NewsFeedTab.tsx
new file mode 100644
index 0000000..ce960f6
--- /dev/null
+++ b/components/news/NewsFeedTab.tsx
@@ -0,0 +1,195 @@
+'use client';
+
+import { useState, useEffect, useCallback } from 'react';
+import { ExternalLink, Loader2, ChevronDown } from 'lucide-react';
+
+interface Article {
+ id: string;
+ title: string;
+ url: string;
+ author: string | null;
+ summary: string | null;
+ published_at: string;
+ sentiment: string;
+ sentiment_score: number;
+ geo_state: string | null;
+ tags: string[];
+ feed_name: string;
+ feed_category: string;
+ station: string | null;
+ article_num: number;
+}
+
+const CATEGORIES = ['all', 'national_tv', 'newspaper', 'local_tv', 'wire', 'blog', 'magazine', 'substack', 'government', 'regional'];
+const CAT_LABELS: Record<string, string> = {
+ all: 'All', national_tv: 'TV National', newspaper: 'Newspaper', local_tv: 'TV Local',
+ wire: 'Wire', blog: 'Blog', magazine: 'Magazine', substack: 'Substack',
+ government: 'Government', regional: 'Regional',
+};
+
+function sentimentDot(s: string) {
+ const colors: Record<string, string> = { positive: '#22c55e', negative: '#ef4444', neutral: '#6b7280' };
+ return <span style={{ width: 8, height: 8, borderRadius: '50%', backgroundColor: colors[s] || '#6b7280', display: 'inline-block', flexShrink: 0 }} />;
+}
+
+function decodeEntities(str: string) {
+ if (!str) return str;
+ return str
+ .replace(/&#(\d+);/g, (_, n) => String.fromCharCode(parseInt(n, 10)))
+ .replace(/&#x([0-9a-fA-F]+);/g, (_, n) => String.fromCharCode(parseInt(n, 16)))
+ .replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'")
+ .replace(/\uFFFD+/g, "'");
+}
+
+export default function NewsFeedTab() {
+ const [articles, setArticles] = useState<Article[]>([]);
+ const [total, setTotal] = useState(0);
+ const [loading, setLoading] = useState(true);
+ const [loadingMore, setLoadingMore] = useState(false);
+ const [category, setCategory] = useState('all');
+ const [offset, setOffset] = useState(0);
+ const LIMIT = 50;
+
+ const fetchArticles = useCallback(async (reset = false) => {
+ const newOffset = reset ? 0 : offset;
+ reset ? setLoading(true) : setLoadingMore(true);
+ try {
+ const params = new URLSearchParams({ limit: String(LIMIT), offset: String(newOffset) });
+ if (category !== 'all') params.set('category', category);
+ const res = await fetch(`/api/pulse/articles?${params}`);
+ if (res.ok) {
+ const data = await res.json();
+ const newArticles = data.articles || [];
+ if (reset) {
+ setArticles(newArticles);
+ setOffset(LIMIT);
+ } else {
+ setArticles(prev => [...prev, ...newArticles]);
+ setOffset(prev => prev + LIMIT);
+ }
+ setTotal(data.total || 0);
+ }
+ } catch (err) { console.error('Fetch articles error:', err); }
+ finally { setLoading(false); setLoadingMore(false); }
+ }, [category, offset]);
+
+ useEffect(() => { fetchArticles(true); }, [category]); // eslint-disable-line react-hooks/exhaustive-deps
+
+ return (
+ <div style={{ padding: 24, maxWidth: 1200 }}>
+ <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
+ <h2 style={{ fontSize: 20, fontWeight: 700, color: 'var(--color-text)', margin: 0 }}>
+ News Feed
+ <span style={{ fontSize: 13, fontWeight: 400, color: 'var(--color-text-muted)', marginLeft: 10 }}>
+ {total.toLocaleString()} articles
+ </span>
+ </h2>
+ </div>
+
+ {/* Category Filters */}
+ <div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 20 }}>
+ {CATEGORIES.map(cat => (
+ <button
+ key={cat}
+ onClick={() => setCategory(cat)}
+ style={{
+ padding: '5px 12px', borderRadius: 16, border: '1px solid',
+ borderColor: category === cat ? '#7c3aed' : 'var(--color-border)',
+ backgroundColor: category === cat ? '#7c3aed22' : 'transparent',
+ color: category === cat ? '#a78bfa' : 'var(--color-text-muted)',
+ fontSize: 12, fontWeight: 500, cursor: 'pointer',
+ transition: 'all 0.15s',
+ }}
+ >
+ {CAT_LABELS[cat] || cat}
+ </button>
+ ))}
+ </div>
+
+ {loading ? (
+ <div style={{ padding: 32, color: 'var(--color-text-muted)', display: 'flex', alignItems: 'center', gap: 8 }}>
+ <Loader2 size={18} style={{ animation: 'spin 1s linear infinite' }} /> Loading articles...
+ </div>
+ ) : (
+ <>
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
+ {articles.map(a => (
+ <div key={a.id} style={{
+ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 14px',
+ backgroundColor: 'var(--color-surface)', borderBottom: '1px solid var(--color-border)',
+ transition: 'background-color 0.1s',
+ }}
+ onMouseEnter={e => { e.currentTarget.style.backgroundColor = 'var(--color-surface-el)'; }}
+ onMouseLeave={e => { e.currentTarget.style.backgroundColor = 'var(--color-surface)'; }}
+ >
+ {/* Article # */}
+ <span style={{ fontSize: 11, color: 'var(--color-text-muted)', fontWeight: 500, minWidth: 36, textAlign: 'right', fontFamily: 'monospace' }}>
+ #{a.article_num}
+ </span>
+
+ {/* Feed badge */}
+ <span style={{
+ fontSize: 10, fontWeight: 600, padding: '2px 6px', borderRadius: 4,
+ backgroundColor: '#7c3aed22', color: '#a78bfa', whiteSpace: 'nowrap', minWidth: 60, textAlign: 'center',
+ }}>
+ {a.feed_name?.length > 14 ? a.feed_name.substring(0, 12) + '…' : a.feed_name}
+ </span>
+
+ {/* Title */}
+ <a
+ href={a.url}
+ target="_blank"
+ rel="noopener noreferrer"
+ style={{
+ flex: 1, fontSize: 13, color: 'var(--color-text)', textDecoration: 'none',
+ overflow: 'hidden', whiteSpace: 'nowrap', textOverflow: 'ellipsis',
+ fontWeight: 500,
+ }}
+ >
+ {decodeEntities(a.title)}
+ </a>
+
+ {/* Sentiment dot */}
+ {sentimentDot(a.sentiment)}
+
+ {/* State */}
+ {a.geo_state && (
+ <span style={{ fontSize: 11, color: 'var(--color-text-muted)', fontWeight: 500 }}>{a.geo_state}</span>
+ )}
+
+ {/* Time */}
+ <span style={{ fontSize: 11, color: 'var(--color-text-muted)', whiteSpace: 'nowrap', minWidth: 55, textAlign: 'right' }}>
+ {a.published_at ? new Date(a.published_at).toLocaleTimeString('en-US', { timeZone: 'America/Los_Angeles', hour: 'numeric', minute: '2-digit', hour12: true }) : ''}
+ </span>
+
+ {/* Link icon */}
+ <a href={a.url} target="_blank" rel="noopener noreferrer" style={{ color: 'var(--color-text-muted)', flexShrink: 0 }}>
+ <ExternalLink size={13} />
+ </a>
+ </div>
+ ))}
+ </div>
+
+ {/* Load More */}
+ {articles.length < total && (
+ <div style={{ display: 'flex', justifyContent: 'center', padding: 20 }}>
+ <button
+ onClick={() => fetchArticles(false)}
+ disabled={loadingMore}
+ style={{
+ display: 'flex', alignItems: 'center', gap: 6, padding: '8px 20px',
+ borderRadius: 8, border: '1px solid var(--color-border)',
+ backgroundColor: 'var(--color-surface)', color: 'var(--color-text)',
+ cursor: loadingMore ? 'wait' : 'pointer', fontSize: 13,
+ }}
+ >
+ {loadingMore ? <Loader2 size={14} style={{ animation: 'spin 1s linear infinite' }} /> : <ChevronDown size={14} />}
+ {loadingMore ? 'Loading...' : `Load More (${articles.length} of ${total.toLocaleString()})`}
+ </button>
+ </div>
+ )}
+ </>
+ )}
+ </div>
+ );
+}
diff --git a/components/orbit/OrbitTab.tsx b/components/orbit/OrbitTab.tsx
new file mode 100644
index 0000000..cdf0a31
--- /dev/null
+++ b/components/orbit/OrbitTab.tsx
@@ -0,0 +1,2382 @@
+'use client';
+
+import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
+import {
+ Globe, RefreshCw, Loader2, ExternalLink, Copy, Check,
+ TrendingUp, Link2, Rss, BarChart3, Sparkles, X,
+ Send, Mail, ChevronDown, ChevronUp, FileText, Zap,
+ Eye, Target, Clock, AlertCircle, Newspaper,
+ ZoomIn, ZoomOut, Maximize2, Activity, Camera, Film,
+ Gauge, Triangle,
+} from 'lucide-react';
+
+/* ═══════════════════════════════════════════════════════════════════════════
+ Types
+ ═══════════════════════════════════════════════════════════════════════════ */
+
+interface KalshiMarket {
+ id: string;
+ title: string;
+ ticker: string;
+ category: string;
+ yes_price: number;
+ no_price: number;
+ volume: number;
+ status: string;
+ close_time: string | null;
+ // DB fields (optional, used for mapping)
+ yes_bid?: number;
+ yes_ask?: number;
+ no_bid?: number;
+ no_ask?: number;
+ last_price?: number;
+}
+
+interface Petition {
+ id: string;
+ title: string;
+ summary: string;
+ status: 'draft' | 'active' | 'closed';
+ signature_count: number;
+ signature_goal: number;
+ category: string;
+ created_at: string;
+}
+
+interface OrbitLink {
+ id: string;
+ market_id: string;
+ petition_id: string;
+ link_type: 'influence' | 'counter' | 'support' | 'alliance';
+ strength: number;
+ ai_reasoning: string;
+}
+
+interface RSSArticle {
+ id: string;
+ source: string;
+ feed_name?: string;
+ title: string;
+ url: string;
+ published_at: string;
+ sentiment: 'positive' | 'negative' | 'neutral';
+ matched_tickers?: string[] | null;
+ matched_markets?: string[] | null;
+ feed_category: 'national' | 'local' | 'wire';
+}
+
+interface OrbitStats {
+ total_markets: number;
+ linked_petitions: number;
+ active_feeds: number;
+ articles_today: number;
+ avg_sentiment: number;
+}
+
+interface TrendingTopic {
+ id: string;
+ title: string;
+ content?: string;
+ category: string;
+ tags?: string[];
+ engagement_score: number;
+ source_url?: string;
+ created_at: string;
+}
+
+interface AgentInfo {
+ name: string;
+ role: string;
+ port: number;
+ status: 'online' | 'offline' | 'error';
+ uptime?: number;
+}
+
+interface GeneratedPetition {
+ title: string;
+ summary: string;
+ body_html: string;
+ body_text: string;
+ suggested_target: string;
+ suggested_tags: string[];
+ suggested_goal: number;
+ // Alliance fields (optional — present when generated via alliance endpoint)
+ alliance_insight?: string;
+ group_a_appeal?: string;
+ group_b_appeal?: string;
+ impact_multiplier?: string;
+}
+
+/* ═══════════════════════════════════════════════════════════════════════════
+ Helpers
+ ═══════════════════════════════════════════════════════════════════════════ */
+
+function decodeEntities(str: string): string {
+ return str
+ .replace(/&/g, '&')
+ .replace(/</g, '<')
+ .replace(/>/g, '>')
+ .replace(/"/g, '"')
+ .replace(/�?39;/g, "'")
+ .replace(/'/g, "'");
+}
+
+/* ═══════════════════════════════════════════════════════════════════════════
+ Constants
+ ═══════════════════════════════════════════════════════════════════════════ */
+
+const CATEGORY_COLORS: Record<string, string> = {
+ Politics: '#3b82f6',
+ Economics: '#22c55e',
+ Climate: '#f97316',
+ Science: '#8b5cf6',
+ Sports: '#ef4444',
+ World: '#06b6d4',
+ Entertainment: '#ec4899',
+ default: '#71717a',
+};
+
+const TRENDING_COLOR = '#10b981'; // emerald-500
+
+const LINK_COLORS: Record<string, string> = {
+ influence: '#7c3aed',
+ counter: '#ef4444',
+ support: '#22c55e',
+ alliance: '#f59e0b',
+};
+
+const SOURCE_COLORS: Record<string, string> = {
+ 'CNN': '#cc0000',
+ 'Fox News': '#003366',
+ 'NBC': '#6a5acd',
+ 'Reuters': '#ff8c00',
+ 'AP': '#ff0000',
+ 'NPR': '#2b9cd8',
+ 'BBC': '#bb1919',
+ 'Politico': '#ff3333',
+ 'The Hill': '#1a5276',
+ 'Washington Post': '#231f20',
+ default: '#71717a',
+};
+
+const PLATFORMS: Record<string, { name: string; url: string; color: string }> = {
+ moveon: { name: 'MoveOn', url: 'https://sign.moveon.org/petitions/new', color: '#e53e3e' },
+ change_org: { name: 'Change.org', url: 'https://www.change.org/start-a-petition', color: '#e53e3e' },
+ we_the_people: { name: 'We The People', url: 'https://petitions.whitehouse.gov', color: '#1d4ed8' },
+};
+
+const STATUS_STYLES: Record<string, { bg: string; text: string; border: string }> = {
+ draft: { bg: 'rgba(161,161,170,0.15)', text: '#a1a1aa', border: 'rgba(161,161,170,0.3)' },
+ active: { bg: 'rgba(34,197,94,0.15)', text: '#22c55e', border: 'rgba(34,197,94,0.3)' },
+ closed: { bg: 'rgba(239,68,68,0.15)', text: '#ef4444', border: 'rgba(239,68,68,0.3)' },
+};
+
+/* ─── Mock data for graceful display when API not ready ──────────────── */
+const MOCK_MARKETS: KalshiMarket[] = [
+ { id: 'm1', title: 'Will Congress pass student debt relief by Q3?', ticker: 'STUDEBT-Q3', category: 'Politics', yes_price: 0.34, no_price: 0.66, volume: 245000, status: 'open', close_time: '2026-09-30T23:59:00Z' },
+ { id: 'm2', title: 'Fed rate cut before July 2026?', ticker: 'FEDCUT-JUL26', category: 'Economics', yes_price: 0.58, no_price: 0.42, volume: 892000, status: 'open', close_time: '2026-07-01T23:59:00Z' },
+ { id: 'm3', title: 'US rejoins Paris Climate Agreement by 2027?', ticker: 'PARIS-27', category: 'Climate', yes_price: 0.22, no_price: 0.78, volume: 167000, status: 'open', close_time: '2027-01-01T23:59:00Z' },
+ { id: 'm4', title: 'Supreme Court rules on social media regulation?', ticker: 'SCOTUS-SM', category: 'Politics', yes_price: 0.71, no_price: 0.29, volume: 534000, status: 'open', close_time: '2026-06-30T23:59:00Z' },
+ { id: 'm5', title: 'Unemployment stays below 5% through 2026?', ticker: 'UNEMP-5PCT', category: 'Economics', yes_price: 0.62, no_price: 0.38, volume: 312000, status: 'open', close_time: '2026-12-31T23:59:00Z' },
+ { id: 'm6', title: 'AI regulation bill passes Senate?', ticker: 'AIREG-SEN', category: 'Science', yes_price: 0.45, no_price: 0.55, volume: 423000, status: 'open', close_time: '2026-12-31T23:59:00Z' },
+];
+
+const MOCK_PETITIONS: Petition[] = [
+ { id: 'p1', title: 'Cancel Student Loan Interest', summary: 'Demand Congress eliminate interest on federal student loans', status: 'active', signature_count: 12450, signature_goal: 25000, category: 'debt_relief', created_at: '2026-02-15T10:00:00Z' },
+ { id: 'p2', title: 'Protect Social Media Free Speech', summary: 'Oppose government overreach in social media regulation', status: 'active', signature_count: 8320, signature_goal: 15000, category: 'policy', created_at: '2026-02-10T08:00:00Z' },
+ { id: 'p3', title: 'Support AI Worker Protections', summary: 'Require AI impact assessments for workforce changes', status: 'draft', signature_count: 0, signature_goal: 10000, category: 'policy', created_at: '2026-02-20T14:00:00Z' },
+ { id: 'p4', title: 'Restore Climate Commitments', summary: 'Push for renewed Paris Agreement participation', status: 'active', signature_count: 34210, signature_goal: 50000, category: 'policy', created_at: '2026-01-28T09:00:00Z' },
+ { id: 'p5', title: 'Lower Prescription Drug Costs', summary: 'Support Medicare negotiation for all drug prices', status: 'active', signature_count: 19800, signature_goal: 30000, category: 'consumer_protection', created_at: '2026-02-01T11:00:00Z' },
+];
+
+const MOCK_LINKS: OrbitLink[] = [
+ { id: 'l1', market_id: 'm1', petition_id: 'p1', link_type: 'influence', strength: 0.85, ai_reasoning: 'Market probability directly tracks legislative momentum on student debt' },
+ { id: 'l2', market_id: 'm4', petition_id: 'p2', link_type: 'counter', strength: 0.72, ai_reasoning: 'SCOTUS ruling could override petition goals on social media regulation' },
+ { id: 'l3', market_id: 'm6', petition_id: 'p3', link_type: 'support', strength: 0.68, ai_reasoning: 'AI regulation bill aligns with worker protection petition demands' },
+ { id: 'l4', market_id: 'm3', petition_id: 'p4', link_type: 'influence', strength: 0.91, ai_reasoning: 'Paris Agreement market directly predicts petition outcome likelihood' },
+ { id: 'l5', market_id: 'm2', petition_id: 'p5', link_type: 'support', strength: 0.45, ai_reasoning: 'Fed policy indirectly affects healthcare cost dynamics' },
+];
+
+const MOCK_ARTICLES: RSSArticle[] = [
+ { id: 'a1', source: 'CNN', title: 'Student debt relief bill gains bipartisan support in Senate committee', url: '#', published_at: new Date(Date.now() - 2 * 3600000).toISOString(), sentiment: 'positive', matched_tickers: ['STUDEBT-Q3'], feed_category: 'national' },
+ { id: 'a2', source: 'Reuters', title: 'Federal Reserve signals potential rate adjustment in upcoming meeting', url: '#', published_at: new Date(Date.now() - 4 * 3600000).toISOString(), sentiment: 'neutral', matched_tickers: ['FEDCUT-JUL26'], feed_category: 'wire' },
+ { id: 'a3', source: 'Politico', title: 'Supreme Court to hear oral arguments on social media liability case', url: '#', published_at: new Date(Date.now() - 5 * 3600000).toISOString(), sentiment: 'negative', matched_tickers: ['SCOTUS-SM'], feed_category: 'national' },
+ { id: 'a4', source: 'NPR', title: 'Climate activists renew push for international cooperation on emissions', url: '#', published_at: new Date(Date.now() - 7 * 3600000).toISOString(), sentiment: 'positive', matched_tickers: ['PARIS-27'], feed_category: 'national' },
+ { id: 'a5', source: 'AP', title: 'Tech companies lobby against proposed AI oversight legislation', url: '#', published_at: new Date(Date.now() - 8 * 3600000).toISOString(), sentiment: 'negative', matched_tickers: ['AIREG-SEN'], feed_category: 'wire' },
+ { id: 'a6', source: 'The Hill', title: 'Prescription drug pricing reform advances through committee markup', url: '#', published_at: new Date(Date.now() - 10 * 3600000).toISOString(), sentiment: 'positive', matched_tickers: ['UNEMP-5PCT'], feed_category: 'national' },
+ { id: 'a7', source: 'BBC', title: 'Global unemployment trends show mixed signals across economies', url: '#', published_at: new Date(Date.now() - 12 * 3600000).toISOString(), sentiment: 'neutral', matched_tickers: ['UNEMP-5PCT'], feed_category: 'wire' },
+];
+
+const MOCK_STATS: OrbitStats = {
+ total_markets: 6,
+ linked_petitions: 5,
+ active_feeds: 7,
+ articles_today: 23,
+ avg_sentiment: 0.42,
+};
+
+/* ═══════════════════════════════════════════════════════════════════════════
+ Helpers
+ ═══════════════════════════════════════════════════════════════════════════ */
+
+function relativeTime(dateStr: string): string {
+ const diff = Date.now() - new Date(dateStr).getTime();
+ const minutes = Math.floor(diff / 60000);
+ if (minutes < 1) return 'just now';
+ if (minutes < 60) return `${minutes}m ago`;
+ const hours = Math.floor(minutes / 60);
+ if (hours < 24) return `${hours}h ago`;
+ const days = Math.floor(hours / 24);
+ return `${days}d ago`;
+}
+
+function truncate(str: string, len: number): string {
+ return str.length > len ? str.slice(0, len) + '...' : str;
+}
+
+function sentimentColor(s: string): string {
+ if (s === 'positive') return '#22c55e';
+ if (s === 'negative') return '#ef4444';
+ return '#71717a';
+}
+
+function sentimentLabel(score: number): { label: string; color: string } {
+ if (score > 0.6) return { label: 'Positive', color: '#22c55e' };
+ if (score > 0.4) return { label: 'Mixed', color: '#f59e0b' };
+ return { label: 'Negative', color: '#ef4444' };
+}
+
+/* ═══════════════════════════════════════════════════════════════════════════
+ SVG Mind Map Sub-Component
+ ═══════════════════════════════════════════════════════════════════════════ */
+
+interface MindMapProps {
+ markets: KalshiMarket[];
+ petitions: Petition[];
+ links: OrbitLink[];
+ trendingTopics: TrendingTopic[];
+ hoveredMarket: string | null;
+ hoveredPetition: string | null;
+ selectedMarkets: string[];
+ selectedPetition: string | null;
+ onHoverMarket: (id: string | null) => void;
+ onHoverPetition: (id: string | null) => void;
+ onSelectMarket: (id: string | null, shift?: boolean) => void;
+ onSelectPetition: (id: string | null) => void;
+ onSelectTrending: (id: string | null) => void;
+}
+
+function MindMap({
+ markets, petitions, links, trendingTopics,
+ hoveredMarket, hoveredPetition,
+ selectedMarkets, selectedPetition,
+ onHoverMarket, onHoverPetition,
+ onSelectMarket, onSelectPetition, onSelectTrending,
+}: MindMapProps) {
+ const containerRef = useRef<HTMLDivElement>(null);
+ const svgRef = useRef<SVGSVGElement>(null);
+ const [dims, setDims] = useState({ w: 800, h: 600 });
+
+ // Zoom system
+ const [zoom, setZoom] = useState(1);
+ const [panOffset, setPanOffset] = useState({ x: 0, y: 0 });
+ const handleZoomIn = useCallback(() => setZoom(z => Math.min(z + 0.2, 3)), []);
+ const handleZoomOut = useCallback(() => setZoom(z => Math.max(z - 0.2, 0.4)), []);
+ const handleZoomReset = useCallback(() => { setZoom(1); setPanOffset({ x: 0, y: 0 }); }, []);
+
+ // Drag system
+ const [posOverrides, setPosOverrides] = useState<Record<string, { x: number; y: number }>>({});
+ const dragRef = useRef<{
+ id: string; type: 'market' | 'petition' | 'trending';
+ offsetX: number; offsetY: number;
+ startX: number; startY: number;
+ dragged: boolean;
+ } | null>(null);
+ const [isDragging, setIsDragging] = useState(false);
+
+ // Hovered trending topic
+ const [hoveredTrending, setHoveredTrending] = useState<string | null>(null);
+
+ useEffect(() => {
+ const el = containerRef.current;
+ if (!el) return;
+ const ro = new ResizeObserver(entries => {
+ for (const entry of entries) {
+ setDims({ w: entry.contentRect.width, h: entry.contentRect.height });
+ }
+ });
+ ro.observe(el);
+ return () => ro.disconnect();
+ }, []);
+
+ const centerX = dims.w / 2;
+ const centerY = dims.h / 2;
+ const radius = Math.min(dims.w, dims.h) * 0.36;
+
+ // Compute positions with drag overrides
+ const marketPositions = useMemo(() => {
+ return markets.map((m, i) => {
+ const angle = (i / Math.max(markets.length, 1)) * 2 * Math.PI - Math.PI / 2;
+ const baseX = centerX - radius * 0.55 + radius * 0.85 * Math.cos(angle);
+ const baseY = centerY + radius * 0.85 * Math.sin(angle);
+ const override = posOverrides[`m-${m.id}`];
+ return { ...m, x: override?.x ?? baseX, y: override?.y ?? baseY };
+ });
+ }, [markets, centerX, centerY, radius, posOverrides]);
+
+ const petitionPositions = useMemo(() => {
+ return petitions.map((p, i) => {
+ const angle = (i / Math.max(petitions.length, 1)) * 2 * Math.PI - Math.PI / 2;
+ const baseX = centerX + radius * 0.55 + radius * 0.85 * Math.cos(angle);
+ const baseY = centerY + radius * 0.85 * Math.sin(angle);
+ const override = posOverrides[`p-${p.id}`];
+ return { ...p, x: override?.x ?? baseX, y: override?.y ?? baseY };
+ });
+ }, [petitions, centerX, centerY, radius, posOverrides]);
+
+ // Trending topic positions (bottom arc)
+ const trendingPositions = useMemo(() => {
+ return trendingTopics.map((t, i) => {
+ const spread = Math.PI * 0.6; // 108-degree arc at the bottom
+ const startAngle = Math.PI / 2 - spread / 2;
+ const angle = startAngle + (i / Math.max(trendingTopics.length - 1, 1)) * spread;
+ const baseX = centerX + radius * 0.95 * Math.cos(angle);
+ const baseY = centerY + radius * 0.95 * Math.sin(angle);
+ const override = posOverrides[`t-${t.id}`];
+ return { ...t, x: override?.x ?? baseX, y: override?.y ?? baseY };
+ });
+ }, [trendingTopics, centerX, centerY, radius, posOverrides]);
+
+ // SVG drag handlers
+ const handleSvgMouseMove = useCallback((e: React.MouseEvent<SVGSVGElement>) => {
+ if (!dragRef.current || !svgRef.current) return;
+ const rect = svgRef.current.getBoundingClientRect();
+ const x = e.clientX - rect.left - dragRef.current.offsetX;
+ const y = e.clientY - rect.top - dragRef.current.offsetY;
+ const dx = e.clientX - dragRef.current.startX;
+ const dy = e.clientY - dragRef.current.startY;
+ if (Math.abs(dx) > 4 || Math.abs(dy) > 4) dragRef.current.dragged = true;
+ const prefix = dragRef.current.type === 'market' ? 'm' : dragRef.current.type === 'petition' ? 'p' : 't';
+ const key = `${prefix}-${dragRef.current.id}`;
+ setPosOverrides(prev => ({ ...prev, [key]: { x, y } }));
+ }, []);
+
+ const handleSvgMouseUp = useCallback(() => {
+ if (dragRef.current) {
+ if (!dragRef.current.dragged) {
+ const { id, type } = dragRef.current;
+ if (type === 'market') onSelectMarket(id);
+ else if (type === 'petition') onSelectPetition(selectedPetition === id ? null : id);
+ else onSelectTrending(id);
+ }
+ dragRef.current = null;
+ setIsDragging(false);
+ }
+ }, [onSelectMarket, onSelectPetition, onSelectTrending, selectedPetition]);
+
+ const handleNodeMouseDown = useCallback((e: React.MouseEvent, id: string, type: 'market' | 'petition' | 'trending', nodeX: number, nodeY: number) => {
+ e.preventDefault();
+ if (!svgRef.current) return;
+ const rect = svgRef.current.getBoundingClientRect();
+ const svgX = e.clientX - rect.left;
+ const svgY = e.clientY - rect.top;
+ dragRef.current = {
+ id, type,
+ offsetX: svgX - nodeX,
+ offsetY: svgY - nodeY,
+ startX: e.clientX,
+ startY: e.clientY,
+ dragged: false,
+ };
+ setIsDragging(true);
+ }, []);
+
+ // Which nodes are highlighted (supports multi-select)
+ const highlightedMarkets = useMemo(() => {
+ const s = new Set<string>();
+ if (hoveredMarket) s.add(hoveredMarket);
+ selectedMarkets.forEach(id => s.add(id));
+ if (hoveredPetition || selectedPetition) {
+ const pid = hoveredPetition || selectedPetition;
+ links.filter(l => l.petition_id === pid).forEach(l => s.add(l.market_id));
+ }
+ return s;
+ }, [hoveredMarket, selectedMarkets, hoveredPetition, selectedPetition, links]);
+
+ const highlightedPetitions = useMemo(() => {
+ const s = new Set<string>();
+ if (hoveredPetition) s.add(hoveredPetition);
+ if (selectedPetition) s.add(selectedPetition);
+ if (hoveredMarket || selectedMarkets.length > 0) {
+ const mids = hoveredMarket ? [hoveredMarket] : selectedMarkets;
+ mids.forEach(mid => links.filter(l => l.market_id === mid).forEach(l => s.add(l.petition_id)));
+ }
+ return s;
+ }, [hoveredPetition, selectedPetition, hoveredMarket, selectedMarkets, links]);
+
+ const highlightedLinks = useMemo(() => {
+ const s = new Set<string>();
+ const mids = hoveredMarket ? [hoveredMarket] : selectedMarkets;
+ const pid = hoveredPetition || selectedPetition;
+ links.forEach(l => {
+ if (mids.some(mid => l.market_id === mid)) s.add(l.id);
+ if (pid && l.petition_id === pid) s.add(l.id);
+ });
+ return s;
+ }, [hoveredMarket, selectedMarkets, hoveredPetition, selectedPetition, links]);
+
+ const anyHighlight = highlightedMarkets.size > 0 || highlightedPetitions.size > 0;
+
+ // Max volume for sizing
+ const maxVol = Math.max(...markets.map(m => m.volume), 1);
+
+ return (
+ <div ref={containerRef} style={{ position: 'absolute', inset: 0, overflow: 'hidden' }}>
+ <svg
+ ref={svgRef}
+ width={dims.w} height={dims.h}
+ viewBox={`${panOffset.x + (dims.w - dims.w / zoom) / 2} ${panOffset.y + (dims.h - dims.h / zoom) / 2} ${dims.w / zoom} ${dims.h / zoom}`}
+ style={{ display: 'block', cursor: isDragging ? 'grabbing' : 'default' }}
+ onMouseMove={handleSvgMouseMove}
+ onMouseUp={handleSvgMouseUp}
+ onMouseLeave={handleSvgMouseUp}
+ onWheel={(e) => {
+ e.preventDefault();
+ if (e.deltaY < 0) handleZoomIn();
+ else handleZoomOut();
+ }}
+ >
+ <defs>
+ {/* Animated dash pattern */}
+ <style>{`
+ @keyframes orbitDash {
+ to { stroke-dashoffset: -40; }
+ }
+ @keyframes orbitPulse {
+ 0%, 100% { r: 22; opacity: 0.7; }
+ 50% { r: 26; opacity: 1; }
+ }
+ @keyframes orbitGlow {
+ 0%, 100% { opacity: 0.3; }
+ 50% { opacity: 0.6; }
+ }
+ `}</style>
+ <radialGradient id="hubGradient" cx="50%" cy="50%" r="50%">
+ <stop offset="0%" stopColor="#a78bfa" />
+ <stop offset="100%" stopColor="#7c3aed" />
+ </radialGradient>
+ <filter id="glow">
+ <feGaussianBlur stdDeviation="3" result="coloredBlur" />
+ <feMerge>
+ <feMergeNode in="coloredBlur" />
+ <feMergeNode in="SourceGraphic" />
+ </feMerge>
+ </filter>
+ </defs>
+
+ {/* Connection lines */}
+ {links.map(link => {
+ const mp = marketPositions.find(m => m.id === link.market_id);
+ const pp = petitionPositions.find(p => p.id === link.petition_id);
+ if (!mp || !pp) return null;
+
+ const isHl = highlightedLinks.has(link.id);
+ const opacity = anyHighlight ? (isHl ? 1 : 0.08) : 0.45 + link.strength * 0.3;
+ const strokeWidth = 1.5 + link.strength * 3;
+ const color = LINK_COLORS[link.link_type] || '#7c3aed';
+
+ // Deterministic curve offset from link ID (no Math.random flickering)
+ const hash = link.id.split('').reduce((a, c) => a + c.charCodeAt(0), 0);
+ const midX = centerX + ((hash % 40) - 20);
+ const midY = centerY + (((hash * 7) % 40) - 20);
+
+ return (
+ <g key={link.id} filter={isHl ? 'url(#glow)' : undefined}>
+ {/* Glow underlay for visibility on dark bg */}
+ <path
+ d={`M ${mp.x} ${mp.y} Q ${midX} ${midY} ${pp.x} ${pp.y}`}
+ fill="none"
+ stroke={color}
+ strokeWidth={strokeWidth + 2}
+ opacity={opacity * 0.3}
+ strokeLinecap="round"
+ />
+ {/* Main connection line */}
+ <path
+ d={`M ${mp.x} ${mp.y} Q ${midX} ${midY} ${pp.x} ${pp.y}`}
+ fill="none"
+ stroke={color}
+ strokeWidth={strokeWidth}
+ opacity={opacity}
+ strokeDasharray={isHl ? '8 6' : 'none'}
+ strokeLinecap="round"
+ style={isHl ? { animation: 'orbitDash 1.5s linear infinite' } : {}}
+ />
+ {/* Endpoint dots */}
+ <circle cx={mp.x} cy={mp.y} r={isHl ? 3 : 2} fill={color} opacity={opacity * 0.8} />
+ <circle cx={pp.x} cy={pp.y} r={isHl ? 3 : 2} fill={color} opacity={opacity * 0.8} />
+ {/* Label on hover */}
+ {isHl && (
+ <text
+ x={(mp.x + pp.x) / 2}
+ y={(mp.y + pp.y) / 2 - 10}
+ textAnchor="middle"
+ fontSize={9}
+ fill={color}
+ opacity={0.9}
+ fontWeight={600}
+ >
+ {truncate(link.ai_reasoning, 50)}
+ </text>
+ )}
+ </g>
+ );
+ })}
+
+ {/* Center hub */}
+ <circle
+ cx={centerX}
+ cy={centerY}
+ r={24}
+ fill="url(#hubGradient)"
+ filter="url(#glow)"
+ style={{ animation: 'orbitPulse 3s ease-in-out infinite' }}
+ />
+ <circle
+ cx={centerX}
+ cy={centerY}
+ r={34}
+ fill="none"
+ stroke="#7c3aed"
+ strokeWidth={1}
+ opacity={0.2}
+ style={{ animation: 'orbitGlow 3s ease-in-out infinite' }}
+ />
+ <text
+ x={centerX}
+ y={centerY + 1}
+ textAnchor="middle"
+ dominantBaseline="middle"
+ fontSize={10}
+ fontWeight={800}
+ fill="#fff"
+ letterSpacing="0.1em"
+ >
+ ORBIT
+ </text>
+
+ {/* Market nodes (left arc) */}
+ {marketPositions.map(m => {
+ const catColor = CATEGORY_COLORS[m.category] || CATEGORY_COLORS.default;
+ const nodeR = 14 + (m.volume / maxVol) * 16;
+ const isHl = highlightedMarkets.has(m.id);
+ const isSelected = selectedMarkets.includes(m.id);
+ const opacity = anyHighlight ? (isHl ? 1 : 0.25) : 0.85;
+
+ return (
+ <g
+ key={m.id}
+ style={{ cursor: isDragging ? 'grabbing' : 'grab', transition: 'opacity 150ms ease' }}
+ opacity={opacity}
+ onMouseEnter={() => !isDragging && onHoverMarket(m.id)}
+ onMouseLeave={() => !isDragging && onHoverMarket(null)}
+ onMouseDown={(e) => {
+ // Shift-click handled inside handleSvgMouseUp via onSelectMarket
+ if (e.shiftKey) {
+ e.preventDefault();
+ onSelectMarket(m.id, true);
+ return;
+ }
+ handleNodeMouseDown(e, m.id, 'market', m.x, m.y);
+ }}
+ >
+ {/* Glow ring on selection */}
+ {isSelected && (
+ <circle
+ cx={m.x} cy={m.y} r={nodeR + 6}
+ fill="none" stroke={catColor} strokeWidth={2}
+ opacity={0.5}
+ strokeDasharray="4 3"
+ style={{ animation: 'orbitDash 2s linear infinite' }}
+ />
+ )}
+ <circle
+ cx={m.x} cy={m.y} r={nodeR}
+ fill={`${catColor}18`}
+ stroke={catColor}
+ strokeWidth={isHl ? 2.5 : 1.5}
+ filter={isSelected ? 'url(#glow)' : undefined}
+ />
+ {/* Yes probability arc */}
+ <circle
+ cx={m.x} cy={m.y} r={nodeR - 3}
+ fill="none"
+ stroke={catColor}
+ strokeWidth={3}
+ strokeDasharray={`${m.yes_price * 2 * Math.PI * (nodeR - 3)} ${2 * Math.PI * (nodeR - 3)}`}
+ strokeDashoffset={0}
+ transform={`rotate(-90, ${m.x}, ${m.y})`}
+ opacity={0.75}
+ />
+ {/* Probability text */}
+ <text
+ x={m.x} y={m.y - 1}
+ textAnchor="middle" dominantBaseline="middle"
+ fontSize={nodeR > 20 ? 12 : 10} fontWeight={800}
+ fill={catColor}
+ >
+ {Math.round(m.yes_price * 100)}%
+ </text>
+ {/* Title below with background for readability */}
+ <text
+ x={m.x} y={m.y + nodeR + 12}
+ textAnchor="middle"
+ fontSize={9}
+ fill="var(--color-text)"
+ fontWeight={600}
+ style={{ filter: 'drop-shadow(0 0 3px rgba(10,10,18,0.9)) drop-shadow(0 0 6px rgba(10,10,18,0.7))' }}
+ >
+ {truncate(m.title, 22)}
+ </text>
+ </g>
+ );
+ })}
+
+ {/* Petition nodes (right arc) */}
+ {petitionPositions.map(p => {
+ const sty = STATUS_STYLES[p.status] || STATUS_STYLES.draft;
+ const progress = p.signature_goal > 0 ? p.signature_count / p.signature_goal : 0;
+ const isHl = highlightedPetitions.has(p.id);
+ const isSelected = selectedPetition === p.id;
+ const opacity = anyHighlight ? (isHl ? 1 : 0.25) : 0.85;
+ const rectW = 130;
+ const rectH = 44;
+
+ return (
+ <g
+ key={p.id}
+ style={{ cursor: isDragging ? 'grabbing' : 'grab', transition: 'opacity 150ms ease' }}
+ opacity={opacity}
+ onMouseEnter={() => !isDragging && onHoverPetition(p.id)}
+ onMouseLeave={() => !isDragging && onHoverPetition(null)}
+ onMouseDown={(e) => handleNodeMouseDown(e, p.id, 'petition', p.x, p.y)}
+ >
+ {/* Selection ring */}
+ {isSelected && (
+ <rect
+ x={p.x - rectW / 2 - 4} y={p.y - rectH / 2 - 4}
+ width={rectW + 8} height={rectH + 8}
+ rx={14} ry={14}
+ fill="none" stroke="#a78bfa" strokeWidth={2}
+ opacity={0.5}
+ strokeDasharray="4 3"
+ style={{ animation: 'orbitDash 2s linear infinite' }}
+ />
+ )}
+ <rect
+ x={p.x - rectW / 2} y={p.y - rectH / 2}
+ width={rectW} height={rectH}
+ rx={10} ry={10}
+ fill="rgba(30, 30, 50, 0.9)"
+ stroke={isHl ? '#a78bfa' : 'rgba(100, 100, 140, 0.4)'}
+ strokeWidth={isHl ? 2 : 1}
+ />
+ {/* Left accent bar */}
+ <rect
+ x={p.x - rectW / 2 + 1} y={p.y - rectH / 2 + 6}
+ width={3} height={rectH - 12}
+ rx={1.5} ry={1.5}
+ fill={sty.text}
+ />
+ {/* Title */}
+ <text
+ x={p.x + 2} y={p.y - 8}
+ textAnchor="middle" dominantBaseline="middle"
+ fontSize={9} fontWeight={700}
+ fill="var(--color-text)"
+ >
+ {truncate(p.title, 20)}
+ </text>
+ {/* Progress bar */}
+ <rect
+ x={p.x - rectW / 2 + 8} y={p.y + 4}
+ width={rectW - 16} height={4}
+ rx={2} ry={2}
+ fill="var(--color-bg)"
+ />
+ <rect
+ x={p.x - rectW / 2 + 8} y={p.y + 4}
+ width={(rectW - 16) * Math.min(progress, 1)} height={4}
+ rx={2} ry={2}
+ fill={sty.text}
+ />
+ {/* Sig count */}
+ <text
+ x={p.x} y={p.y + 16}
+ textAnchor="middle" dominantBaseline="middle"
+ fontSize={7}
+ fill="var(--color-text-muted)"
+ >
+ {((p.signature_count || 0) / 1000).toFixed(1)}k / {((p.signature_goal || 1) / 1000).toFixed(0)}k
+ </text>
+ </g>
+ );
+ })}
+
+ {/* Trending topic nodes (bottom arc — green diamonds) */}
+ {trendingPositions.map(t => {
+ const maxScore = Math.max(...trendingTopics.map(tt => tt.engagement_score), 1);
+ const size = 10 + (t.engagement_score / maxScore) * 10;
+ const isHovered = hoveredTrending === t.id;
+ const opacity = isHovered ? 1 : 0.8;
+
+ return (
+ <g
+ key={`trending-${t.id}`}
+ style={{ cursor: isDragging ? 'grabbing' : 'grab', transition: 'opacity 150ms ease' }}
+ opacity={opacity}
+ onMouseEnter={() => { if (!isDragging) setHoveredTrending(t.id); }}
+ onMouseLeave={() => { if (!isDragging) setHoveredTrending(null); }}
+ onMouseDown={(e) => handleNodeMouseDown(e, t.id, 'trending', t.x, t.y)}
+ >
+ {/* Diamond shape (rotated square) */}
+ <rect
+ x={t.x - size / 2} y={t.y - size / 2}
+ width={size} height={size}
+ rx={3} ry={3}
+ transform={`rotate(45, ${t.x}, ${t.y})`}
+ fill={`${TRENDING_COLOR}25`}
+ stroke={TRENDING_COLOR}
+ strokeWidth={isHovered ? 2.5 : 1.5}
+ />
+ {/* Engagement score inside */}
+ <text
+ x={t.x} y={t.y + 1}
+ textAnchor="middle" dominantBaseline="middle"
+ fontSize={7} fontWeight={700}
+ fill={TRENDING_COLOR}
+ >
+ {t.engagement_score}
+ </text>
+ {/* Title below — only show on hover to reduce clutter */}
+ {isHovered && (
+ <text
+ x={t.x} y={t.y + size / 2 + 14}
+ textAnchor="middle"
+ fontSize={8}
+ fill="var(--color-text)"
+ fontWeight={600}
+ >
+ {truncate(t.title, 28)}
+ </text>
+ )}
+ {/* Tooltip on hover */}
+ {isHovered && t.content && (
+ <g>
+ <rect
+ x={t.x - 90} y={t.y - size / 2 - 32}
+ width={180} height={22}
+ rx={6} fill="var(--color-surface-el)"
+ stroke="var(--color-border)" strokeWidth={0.5}
+ />
+ <text
+ x={t.x} y={t.y - size / 2 - 18}
+ textAnchor="middle" dominantBaseline="middle"
+ fontSize={7} fill="var(--color-text-muted)"
+ >
+ {truncate(t.content, 50)}
+ </text>
+ </g>
+ )}
+ </g>
+ );
+ })}
+
+ {/* Alliance line between 2 selected markets */}
+ {selectedMarkets.length === 2 && (() => {
+ const m1 = marketPositions.find(m => m.id === selectedMarkets[0]);
+ const m2 = marketPositions.find(m => m.id === selectedMarkets[1]);
+ if (!m1 || !m2) return null;
+ const midX = (m1.x + m2.x) / 2;
+ const midY = (m1.y + m2.y) / 2;
+ return (
+ <g>
+ <line
+ x1={m1.x} y1={m1.y} x2={m2.x} y2={m2.y}
+ stroke="#f59e0b" strokeWidth={2.5}
+ strokeDasharray="6 4" opacity={0.8}
+ style={{ animation: 'orbitDash 1.5s linear infinite' }}
+ />
+ <rect x={midX - 36} y={midY - 10} width={72} height={18} rx={9} fill="#f59e0b" opacity={0.9} />
+ <text x={midX} y={midY + 2} textAnchor="middle" dominantBaseline="middle"
+ fontSize={8} fontWeight={700} fill="#000" letterSpacing="0.03em">
+ ALLIANCE
+ </text>
+ </g>
+ );
+ })()}
+
+ {/* Legend */}
+ <g transform={`translate(12, ${dims.h - 80})`}>
+ <text fontSize={9} fill="var(--color-text-secondary)" fontWeight={700} letterSpacing="0.05em">LINK TYPES</text>
+ {Object.entries(LINK_COLORS).map(([type, color], i) => (
+ <g key={type} transform={`translate(0, ${16 + i * 15})`}>
+ <line x1={0} y1={0} x2={18} y2={0} stroke={color} strokeWidth={2.5} />
+ <text x={22} y={3} fontSize={8} fill="var(--color-text-muted)" fontWeight={500}>{type.charAt(0).toUpperCase() + type.slice(1)}</text>
+ </g>
+ ))}
+ </g>
+
+ {/* Category legend */}
+ <g transform={`translate(12, 12)`}>
+ <text fontSize={9} fill="var(--color-text-secondary)" fontWeight={700} letterSpacing="0.05em">CATEGORIES</text>
+ {Object.entries(CATEGORY_COLORS).filter(([k]) => k !== 'default').slice(0, 5).map(([cat, color], i) => (
+ <g key={cat} transform={`translate(${i * 68}, 16)`}>
+ <circle cx={5} cy={0} r={4} fill={color} />
+ <text x={12} y={3} fontSize={8} fill="var(--color-text-muted)" fontWeight={500}>{cat}</text>
+ </g>
+ ))}
+ </g>
+
+ {/* Node type legend (top right) */}
+ <g transform={`translate(${dims.w - 130}, 12)`}>
+ <text fontSize={9} fill="var(--color-text-secondary)" fontWeight={700} letterSpacing="0.05em">NODES</text>
+ <g transform="translate(0, 14)">
+ <circle cx={5} cy={0} r={4} fill="#3b82f622" stroke="#3b82f6" strokeWidth={1} />
+ <text x={14} y={3} fontSize={7} fill="var(--color-text-muted)">Market (circle)</text>
+ </g>
+ <g transform="translate(0, 28)">
+ <rect x={1} y={-4} width={8} height={8} rx={2} fill="var(--color-surface-el)" stroke="var(--color-border)" strokeWidth={1} />
+ <text x={14} y={3} fontSize={7} fill="var(--color-text-muted)">Petition (rect)</text>
+ </g>
+ <g transform="translate(0, 42)">
+ <rect x={2} y={-3} width={6} height={6} rx={1} fill={`${TRENDING_COLOR}25`} stroke={TRENDING_COLOR} strokeWidth={1}
+ transform="rotate(45, 5, 0)" />
+ <text x={14} y={3} fontSize={7} fill="var(--color-text-muted)">Trending (diamond)</text>
+ </g>
+ </g>
+
+ {/* Shift+click hint */}
+ <text x={dims.w - 12} y={dims.h - 8} textAnchor="end"
+ fontSize={8} fill="var(--color-text-muted)" opacity={0.5}>
+ Drag to move | Scroll to zoom | Shift+click for Alliance
+ </text>
+ </svg>
+
+ {/* Zoom controls (top-left overlay) */}
+ <div style={{
+ position: 'absolute', top: 8, left: 8, zIndex: 10,
+ display: 'flex', flexDirection: 'column', gap: 2,
+ }}>
+ {[
+ { icon: <ZoomIn size={13} />, action: handleZoomIn, title: 'Zoom In' },
+ { icon: <ZoomOut size={13} />, action: handleZoomOut, title: 'Zoom Out' },
+ { icon: <Maximize2 size={13} />, action: handleZoomReset, title: 'Reset View' },
+ ].map(({ icon, action, title }) => (
+ <button
+ key={title}
+ onClick={action}
+ title={title}
+ style={{
+ width: 28, height: 28, display: 'flex', alignItems: 'center', justifyContent: 'center',
+ borderRadius: 6, border: '1px solid var(--color-border)',
+ backgroundColor: 'var(--color-surface-el)',
+ color: 'var(--color-text-muted)', cursor: 'pointer',
+ transition: 'all 150ms ease',
+ }}
+ onMouseEnter={e => { e.currentTarget.style.borderColor = '#a78bfa'; e.currentTarget.style.color = '#a78bfa'; }}
+ onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--color-border)'; e.currentTarget.style.color = 'var(--color-text-muted)'; }}
+ >
+ {icon}
+ </button>
+ ))}
+ </div>
+
+ {/* Zoom level indicator */}
+ {zoom !== 1 && (
+ <div style={{
+ position: 'absolute', top: 8, left: 42,
+ padding: '4px 8px', borderRadius: 6,
+ backgroundColor: 'var(--color-surface-el)',
+ border: '1px solid var(--color-border)',
+ fontSize: 9, fontWeight: 600, color: '#a78bfa',
+ }}>
+ {Math.round(zoom * 100)}%
+ </div>
+ )}
+
+ {/* Reset Layout button */}
+ {Object.keys(posOverrides).length > 0 && (
+ <button
+ onClick={() => setPosOverrides({})}
+ style={{
+ position: 'absolute', bottom: 8, left: 12,
+ padding: '3px 10px', borderRadius: 6,
+ border: '1px solid var(--color-border)',
+ backgroundColor: 'var(--color-surface-el)',
+ color: 'var(--color-text-muted)',
+ fontSize: 9, fontWeight: 600, cursor: 'pointer',
+ zIndex: 10, transition: 'all 150ms ease',
+ }}
+ onMouseEnter={e => { e.currentTarget.style.borderColor = '#a78bfa'; e.currentTarget.style.color = '#a78bfa'; }}
+ onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--color-border)'; e.currentTarget.style.color = 'var(--color-text-muted)'; }}
+ >
+ Reset Layout
+ </button>
+ )}
+ </div>
+ );
+}
+
+/* ═══════════════════════════════════════════════════════════════════════════
+ Pulse Panel Sub-Component
+ ═══════════════════════════════════════════════════════════════════════════ */
+
+interface PulsePanelProps {
+ articles: RSSArticle[];
+ loading: boolean;
+ onRefresh: () => void;
+ refreshing: boolean;
+}
+
+function PulsePanel({ articles, loading, onRefresh, refreshing }: PulsePanelProps) {
+ const [feedFilter, setFeedFilter] = useState<string>('all');
+ const filters = ['all', 'national', 'local', 'wire'];
+
+ const filtered = useMemo(() => {
+ if (feedFilter === 'all') return articles;
+ return articles.filter(a => a.feed_category === feedFilter);
+ }, [articles, feedFilter]);
+
+ return (
+ <div style={{
+ display: 'flex', flexDirection: 'column', height: '100%',
+ backgroundColor: 'var(--color-surface)',
+ border: '1px solid var(--color-border)',
+ borderRadius: 12, overflow: 'hidden',
+ }}>
+ {/* Header */}
+ <div style={{
+ padding: '10px 14px', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
+ borderBottom: '1px solid var(--color-border)', flexShrink: 0,
+ }}>
+ <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
+ <Rss size={14} style={{ color: '#a78bfa' }} />
+ <span style={{ fontSize: 12, fontWeight: 700, color: 'var(--color-text)' }}>Pulse Feed</span>
+ <span style={{
+ fontSize: 9, padding: '1px 6px', borderRadius: 99,
+ backgroundColor: 'rgba(124, 58, 237, 0.15)', color: '#a78bfa',
+ fontWeight: 600,
+ }}>
+ {articles.length}
+ </span>
+ </div>
+ <button
+ onClick={onRefresh}
+ disabled={refreshing}
+ style={{
+ display: 'flex', alignItems: 'center', gap: 4, padding: '4px 8px',
+ borderRadius: 6, border: '1px solid var(--color-border)',
+ backgroundColor: 'transparent', color: 'var(--color-text-secondary)',
+ cursor: 'pointer', fontSize: 10, fontWeight: 600,
+ transition: 'all 150ms ease',
+ }}
+ onMouseEnter={e => { e.currentTarget.style.backgroundColor = 'var(--color-surface-el)'; }}
+ onMouseLeave={e => { e.currentTarget.style.backgroundColor = 'transparent'; }}
+ >
+ {refreshing
+ ? <Loader2 size={11} style={{ animation: 'spin 1s linear infinite' }} />
+ : <RefreshCw size={11} />
+ }
+ Refresh
+ </button>
+ </div>
+
+ {/* Filter chips */}
+ <div style={{
+ padding: '8px 14px', display: 'flex', gap: 4, flexShrink: 0,
+ borderBottom: '1px solid var(--color-border)',
+ }}>
+ {filters.map(f => (
+ <button
+ key={f}
+ onClick={() => setFeedFilter(f)}
+ style={{
+ padding: '3px 10px', borderRadius: 99, border: 'none',
+ fontSize: 10, fontWeight: 600, cursor: 'pointer',
+ backgroundColor: feedFilter === f ? 'rgba(124, 58, 237, 0.2)' : 'var(--color-surface-el)',
+ color: feedFilter === f ? '#a78bfa' : 'var(--color-text-muted)',
+ transition: 'all 150ms ease',
+ textTransform: 'capitalize',
+ }}
+ >
+ {f}
+ </button>
+ ))}
+ </div>
+
+ {/* Articles list */}
+ <div style={{ flex: 1, overflowY: 'auto', padding: '6px 10px' }}>
+ {loading && (
+ <div style={{ padding: 20, textAlign: 'center' }}>
+ <Loader2 size={18} style={{ color: 'var(--color-text-muted)', animation: 'spin 1s linear infinite' }} />
+ </div>
+ )}
+ {!loading && filtered.length === 0 && (
+ <div style={{ padding: 20, textAlign: 'center', color: 'var(--color-text-muted)', fontSize: 11 }}>
+ No articles found for this filter.
+ </div>
+ )}
+ {!loading && filtered.map(article => {
+ const srcName = article.source || article.feed_name || 'Unknown';
+ const srcColor = SOURCE_COLORS[srcName] || SOURCE_COLORS.default;
+ const sentColor = sentimentColor(article.sentiment || 'neutral');
+ return (
+ <div
+ key={article.id}
+ style={{
+ padding: '8px 10px 8px 12px', borderRadius: 8, marginBottom: 4,
+ borderLeft: `2px solid ${srcColor}40`,
+ transition: 'all 150ms ease', cursor: 'default',
+ }}
+ onMouseEnter={e => { e.currentTarget.style.backgroundColor = 'var(--color-surface-el)'; }}
+ onMouseLeave={e => { e.currentTarget.style.backgroundColor = 'transparent'; }}
+ >
+ <div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
+ {/* Source badge */}
+ <span style={{
+ fontSize: 8, fontWeight: 700, padding: '1px 5px', borderRadius: 4,
+ backgroundColor: `${srcColor}20`, color: srcColor,
+ border: `1px solid ${srcColor}40`,
+ textTransform: 'uppercase', letterSpacing: '0.03em',
+ }}>
+ {srcName}
+ </span>
+ {/* Time */}
+ <span style={{ fontSize: 9, color: 'var(--color-text-muted)', marginLeft: 'auto' }}>
+ {relativeTime(article.published_at)}
+ </span>
+ </div>
+ {/* Title */}
+ <a
+ href={article.url}
+ target="_blank"
+ rel="noopener noreferrer"
+ style={{
+ fontSize: 11, fontWeight: 600, color: 'var(--color-text)',
+ textDecoration: 'none', lineHeight: 1.35,
+ display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical',
+ overflow: 'hidden',
+ }}
+ >
+ {decodeEntities(article.title)}
+ </a>
+ {/* Bottom row: sentiment (only if non-neutral) + tickers */}
+ <div style={{ display: 'flex', alignItems: 'center', gap: 4, marginTop: 4, flexWrap: 'wrap' }}>
+ {article.sentiment && article.sentiment !== 'neutral' && (
+ <span style={{
+ fontSize: 8, fontWeight: 600, padding: '1px 5px', borderRadius: 4,
+ backgroundColor: `${sentColor}20`, color: sentColor,
+ textTransform: 'capitalize',
+ }}>
+ {article.sentiment}
+ </span>
+ )}
+ {(article.matched_tickers || article.matched_markets || []).map(t => (
+ <span key={t} style={{
+ fontSize: 8, fontWeight: 700, padding: '2px 6px', borderRadius: 4,
+ backgroundColor: 'rgba(124, 58, 237, 0.12)', color: '#a78bfa',
+ letterSpacing: '0.02em',
+ }}>
+ {t}
+ </span>
+ ))}
+ </div>
+ </div>
+ );
+ })}
+ </div>
+ </div>
+ );
+}
+
+/* ═══════════════════════════════════════════════════════════════════════════
+ Actions Panel Sub-Component
+ ═══════════════════════════════════════════════════════════════════════════ */
+
+interface ActionsPanelProps {
+ selectedMarkets: KalshiMarket[];
+ selectedPetition: Petition | null;
+ onClearSelection: () => void;
+ onRefresh: () => void;
+}
+
+function ActionsPanel({ selectedMarkets, selectedPetition, onClearSelection, onRefresh }: ActionsPanelProps) {
+ const [tone, setTone] = useState('urgent');
+ const [generating, setGenerating] = useState(false);
+ const [generated, setGenerated] = useState<GeneratedPetition | null>(null);
+ const [copiedPlatform, setCopiedPlatform] = useState<string | null>(null);
+ const [actionsExpanded, setActionsExpanded] = useState(true);
+ const [toast, setToast] = useState<{ msg: string; type: 'success' | 'error' | 'info' } | null>(null);
+ const [saving, setSaving] = useState(false);
+ const [batchLinking, setBatchLinking] = useState(false);
+
+ function showToast(msg: string, type: 'success' | 'error' | 'info' = 'info') {
+ setToast({ msg, type });
+ setTimeout(() => setToast(null), 3000);
+ }
+
+ // Derived: first selected market (for single-market mode)
+ const selectedMarket = selectedMarkets[0] || null;
+ // Alliance mode: 2 markets from different categories
+ const isAllianceMode = selectedMarkets.length === 2 &&
+ selectedMarkets[0].category !== selectedMarkets[1].category;
+
+ async function handleGenerate() {
+ if (!selectedMarket) return;
+ setGenerating(true);
+ setGenerated(null);
+ try {
+ const res = await fetch('/api/orbit/generate', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ market_id: selectedMarket.id,
+ market_title: selectedMarket.title,
+ market_ticker: selectedMarket.ticker,
+ market_yes_price: selectedMarket.yes_price,
+ tone,
+ }),
+ });
+ if (res.ok) {
+ const data = await res.json();
+ setGenerated(data.petition || data);
+ } else {
+ setGenerated({
+ title: `Action Needed: ${selectedMarket.title}`,
+ summary: `Based on Kalshi market ${selectedMarket.ticker} showing ${Math.round(selectedMarket.yes_price * 100)}% probability, we urge immediate action on this critical issue.`,
+ body_html: `<p>We, the undersigned, call for immediate attention to the issue tracked by prediction market ${selectedMarket.ticker}.</p>`,
+ body_text: `We, the undersigned, call for immediate attention to the issue tracked by prediction market ${selectedMarket.ticker}.`,
+ suggested_target: 'U.S. Congress',
+ suggested_tags: [(selectedMarket.category || 'Other').toLowerCase(), 'prediction-market', 'action'],
+ suggested_goal: 10000,
+ });
+ }
+ } catch {
+ setGenerated({
+ title: `Action Needed: ${selectedMarket.title}`,
+ summary: `Based on Kalshi market ${selectedMarket.ticker} showing ${Math.round(selectedMarket.yes_price * 100)}% probability, we urge immediate action.`,
+ body_html: `<p>We call for immediate attention to this issue.</p>`,
+ body_text: `We call for immediate attention to this issue.`,
+ suggested_target: 'U.S. Congress',
+ suggested_tags: [(selectedMarket.category || 'Other').toLowerCase()],
+ suggested_goal: 10000,
+ });
+ } finally {
+ setGenerating(false);
+ }
+ }
+
+ async function handleAllianceGenerate() {
+ if (selectedMarkets.length !== 2) return;
+ setGenerating(true);
+ setGenerated(null);
+ try {
+ const res = await fetch('/api/orbit/suggest-alliance', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ market_tickers: [selectedMarkets[0].ticker, selectedMarkets[1].ticker],
+ tone,
+ }),
+ });
+ if (res.ok) {
+ const data = await res.json();
+ setGenerated(data.petition || data);
+ } else {
+ // Fallback alliance mock
+ setGenerated({
+ title: `United Action: ${selectedMarkets[0].category} + ${selectedMarkets[1].category}`,
+ summary: `An unlikely alliance petition bridging ${selectedMarkets[0].title} and ${selectedMarkets[1].title} for maximum political impact.`,
+ body_html: `<p>We, a coalition spanning ${selectedMarkets[0].category} and ${selectedMarkets[1].category} advocates, demand unified action.</p>`,
+ body_text: `We, a coalition spanning ${selectedMarkets[0].category} and ${selectedMarkets[1].category} advocates, demand unified action.`,
+ suggested_target: 'U.S. Congress',
+ suggested_tags: [selectedMarkets[0].category.toLowerCase(), selectedMarkets[1].category.toLowerCase(), 'alliance'],
+ suggested_goal: 25000,
+ alliance_insight: `This alliance between ${selectedMarkets[0].category} and ${selectedMarkets[1].category} groups creates cross-partisan momentum that neither group could achieve alone.`,
+ group_a_appeal: `${selectedMarkets[0].category} supporters care because it directly impacts ${selectedMarkets[0].title}.`,
+ group_b_appeal: `${selectedMarkets[1].category} supporters care because it connects to ${selectedMarkets[1].title}.`,
+ impact_multiplier: `Cross-partisan petitions historically achieve 3-5x higher engagement and are more likely to receive legislative attention.`,
+ });
+ }
+ } catch {
+ setGenerated({
+ title: `United Action: ${selectedMarkets[0].category} + ${selectedMarkets[1].category}`,
+ summary: `Alliance petition bridging two issue groups.`,
+ body_html: `<p>Coalition petition spanning multiple constituencies.</p>`,
+ body_text: `Coalition petition spanning multiple constituencies.`,
+ suggested_target: 'U.S. Congress',
+ suggested_tags: ['alliance'],
+ suggested_goal: 25000,
+ });
+ } finally {
+ setGenerating(false);
+ }
+ }
+
+ function handleCopyAndOpen(platformKey: string) {
+ const platform = PLATFORMS[platformKey];
+ if (!platform || !generated) return;
+ const text = `${generated.title}\n\n${generated.body_text || generated.summary}`;
+ // Clipboard with fallback for HTTP contexts
+ const doCopy = () => {
+ if (navigator.clipboard?.writeText) {
+ return navigator.clipboard.writeText(text);
+ }
+ // Fallback: textarea copy
+ const ta = document.createElement('textarea');
+ ta.value = text;
+ ta.style.position = 'fixed';
+ ta.style.left = '-9999px';
+ document.body.appendChild(ta);
+ ta.select();
+ document.execCommand('copy');
+ document.body.removeChild(ta);
+ return Promise.resolve();
+ };
+ doCopy().then(() => {
+ setCopiedPlatform(platformKey);
+ showToast(`Copied to clipboard! Opening ${platform.name}...`, 'success');
+ setTimeout(() => setCopiedPlatform(null), 2000);
+ window.open(platform.url, '_blank');
+ }).catch(() => {
+ showToast('Copy failed — opening platform anyway', 'error');
+ window.open(platform.url, '_blank');
+ });
+ }
+
+ async function handleSaveDraft() {
+ if (!generated) return;
+ setSaving(true);
+ try {
+ const res = await fetch('/api/petitions', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ title: generated.title,
+ summary: generated.summary,
+ body_html: generated.body_html || `<p>${generated.summary}</p>`,
+ body_text: generated.body_text || generated.summary,
+ target: generated.suggested_target,
+ tags: generated.suggested_tags,
+ signature_goal: generated.suggested_goal || 10000,
+ status: 'draft',
+ }),
+ });
+ if (res.ok) {
+ showToast('Petition saved as draft!', 'success');
+ setGenerated(null);
+ onClearSelection();
+ onRefresh();
+ } else {
+ const err = await res.json().catch(() => ({ error: 'Save failed' }));
+ showToast(err.error || 'Failed to save petition', 'error');
+ }
+ } catch {
+ showToast('Network error — petition not saved', 'error');
+ } finally {
+ setSaving(false);
+ }
+ }
+
+ async function handleBatchLink() {
+ setBatchLinking(true);
+ try {
+ const res = await fetch('/api/orbit/batch-link', { method: 'POST' });
+ if (res.ok) {
+ const data = await res.json();
+ showToast(`AI linked ${data.links_inserted} connections, created ${data.petitions_created} petitions`, 'success');
+ } else {
+ showToast('AI linking failed — try again', 'error');
+ }
+ } catch {
+ showToast('Network error during AI linking', 'error');
+ } finally {
+ setBatchLinking(false);
+ }
+ }
+
+ async function handleActivatePetition(petitionId: string) {
+ try {
+ const res = await fetch('/api/petitions', {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ id: petitionId, status: 'active' }),
+ });
+ if (res.ok) { showToast('Petition activated!', 'success'); onRefresh(); }
+ else showToast('Failed to activate', 'error');
+ } catch {
+ showToast('Network error', 'error');
+ }
+ }
+
+ async function handleArchivePetition(petitionId: string) {
+ try {
+ const res = await fetch('/api/petitions', {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ id: petitionId, status: 'closed' }),
+ });
+ if (res.ok) {
+ showToast('Petition archived', 'info');
+ onClearSelection();
+ onRefresh();
+ } else showToast('Failed to archive', 'error');
+ } catch {
+ showToast('Network error', 'error');
+ }
+ }
+
+ return (
+ <div style={{
+ display: 'flex', flexDirection: 'column', height: '100%',
+ backgroundColor: 'var(--color-surface)',
+ border: '1px solid var(--color-border)',
+ borderRadius: 12, overflow: 'hidden',
+ position: 'relative',
+ }}>
+ {/* Header */}
+ <div
+ style={{
+ padding: '10px 14px', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
+ borderBottom: '1px solid var(--color-border)', flexShrink: 0, cursor: 'pointer',
+ }}
+ onClick={() => setActionsExpanded(!actionsExpanded)}
+ >
+ <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
+ <Zap size={14} style={{ color: '#a78bfa' }} />
+ <span style={{ fontSize: 12, fontWeight: 700, color: 'var(--color-text)' }}>Actions</span>
+ </div>
+ {actionsExpanded ? <ChevronUp size={14} style={{ color: 'var(--color-text-muted)' }} /> : <ChevronDown size={14} style={{ color: 'var(--color-text-muted)' }} />}
+ </div>
+
+ {/* Toast notification */}
+ {toast && (
+ <div style={{
+ position: 'absolute', bottom: 12, left: '50%', transform: 'translateX(-50%)',
+ zIndex: 100, padding: '8px 16px', borderRadius: 8,
+ backgroundColor: toast.type === 'success' ? 'rgba(34, 197, 94, 0.9)' : toast.type === 'error' ? 'rgba(239, 68, 68, 0.9)' : 'rgba(124, 58, 237, 0.9)',
+ color: '#fff', fontSize: 11, fontWeight: 600, whiteSpace: 'nowrap',
+ boxShadow: '0 4px 12px rgba(0,0,0,0.3)',
+ animation: 'fadeInUp 200ms ease',
+ }}>
+ {toast.msg}
+ </div>
+ )}
+
+ {actionsExpanded && (
+ <div style={{ flex: 1, overflowY: 'auto', padding: '10px 14px' }}>
+ {/* No selection — Quick Actions */}
+ {selectedMarkets.length === 0 && !selectedPetition && !generated && (
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
+ <div style={{ textAlign: 'center', padding: '12px 10px' }}>
+ <div style={{
+ width: 40, height: 40, borderRadius: 12, margin: '0 auto 10px',
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
+ background: 'linear-gradient(135deg, rgba(124, 58, 237, 0.15), rgba(167, 139, 250, 0.1))',
+ border: '1px solid rgba(124, 58, 237, 0.2)',
+ }}>
+ <Target size={18} style={{ color: '#a78bfa' }} />
+ </div>
+ <div style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text)', marginBottom: 4 }}>
+ Select a Node
+ </div>
+ <div style={{ fontSize: 10, color: 'var(--color-text-muted)', lineHeight: 1.4 }}>
+ Click a market to generate a petition. Shift+click two from different categories for Alliance mode.
+ </div>
+ </div>
+
+ {/* Quick Action Buttons */}
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
+ <div style={{ fontSize: 9, fontWeight: 700, color: 'var(--color-text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 2 }}>
+ Quick Actions
+ </div>
+ <button
+ onClick={handleBatchLink}
+ disabled={batchLinking}
+ style={{
+ display: 'flex', alignItems: 'center', gap: 8,
+ padding: '8px 10px', borderRadius: 8,
+ border: '1px solid rgba(167, 139, 250, 0.3)',
+ backgroundColor: 'rgba(167, 139, 250, 0.06)',
+ color: '#a78bfa', fontSize: 10, fontWeight: 700, cursor: 'pointer',
+ transition: 'all 150ms ease', textAlign: 'left',
+ opacity: batchLinking ? 0.7 : 1,
+ }}
+ onMouseEnter={e => { if (!batchLinking) e.currentTarget.style.backgroundColor = 'rgba(167, 139, 250, 0.12)'; }}
+ onMouseLeave={e => { e.currentTarget.style.backgroundColor = 'rgba(167, 139, 250, 0.06)'; }}
+ >
+ {batchLinking
+ ? <Loader2 size={13} style={{ animation: 'spin 1s linear infinite', flexShrink: 0 }} />
+ : <Sparkles size={13} style={{ flexShrink: 0 }} />}
+ <span style={{ flex: 1 }}>AI Auto-Link Markets & Petitions</span>
+ </button>
+ <button
+ onClick={async () => {
+ showToast('Discovering trending topics...', 'info');
+ try {
+ const res = await fetch('/api/trending/discover', { method: 'POST' });
+ if (res.ok) {
+ const data = await res.json();
+ showToast(`Discovered ${data.count || 0} trending topics!`, 'success');
+ } else showToast('Discovery failed', 'error');
+ } catch { showToast('Network error', 'error'); }
+ }}
+ style={{
+ display: 'flex', alignItems: 'center', gap: 8,
+ padding: '8px 10px', borderRadius: 8,
+ border: '1px solid rgba(34, 197, 94, 0.3)',
+ backgroundColor: 'rgba(34, 197, 94, 0.06)',
+ color: '#22c55e', fontSize: 10, fontWeight: 700, cursor: 'pointer',
+ transition: 'all 150ms ease', textAlign: 'left',
+ }}
+ onMouseEnter={e => { e.currentTarget.style.backgroundColor = 'rgba(34, 197, 94, 0.12)'; }}
+ onMouseLeave={e => { e.currentTarget.style.backgroundColor = 'rgba(34, 197, 94, 0.06)'; }}
+ >
+ <TrendingUp size={13} style={{ flexShrink: 0 }} />
+ <span style={{ flex: 1 }}>Discover Trending Topics</span>
+ </button>
+ <button
+ onClick={() => {
+ showToast('Opening Pulse Agent...', 'info');
+ window.open('http://45.61.58.125:9845/', '_blank');
+ }}
+ style={{
+ display: 'flex', alignItems: 'center', gap: 8,
+ padding: '8px 10px', borderRadius: 8,
+ border: '1px solid rgba(6, 182, 212, 0.3)',
+ backgroundColor: 'rgba(6, 182, 212, 0.06)',
+ color: '#06b6d4', fontSize: 10, fontWeight: 700, cursor: 'pointer',
+ transition: 'all 150ms ease', textAlign: 'left',
+ }}
+ onMouseEnter={e => { e.currentTarget.style.backgroundColor = 'rgba(6, 182, 212, 0.12)'; }}
+ onMouseLeave={e => { e.currentTarget.style.backgroundColor = 'rgba(6, 182, 212, 0.06)'; }}
+ >
+ <Globe size={13} style={{ flexShrink: 0 }} />
+ <span style={{ flex: 1 }}>Open Pulse of America</span>
+ </button>
+ </div>
+ </div>
+ )}
+
+ {/* Alliance mode — 2 markets from different categories */}
+ {isAllianceMode && !generated && (
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
+ {/* Alliance badge */}
+ <div style={{
+ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
+ padding: '6px 12px', borderRadius: 8,
+ background: 'linear-gradient(135deg, rgba(245, 158, 11, 0.15), rgba(245, 158, 11, 0.05))',
+ border: '1px solid rgba(245, 158, 11, 0.3)',
+ }}>
+ <Zap size={12} style={{ color: '#f59e0b' }} />
+ <span style={{ fontSize: 10, fontWeight: 700, color: '#f59e0b' }}>
+ Alliance Detected: {selectedMarkets[0].category} + {selectedMarkets[1].category}
+ </span>
+ </div>
+
+ {/* Both markets stacked */}
+ {selectedMarkets.map((m, idx) => (
+ <div key={m.id} style={{
+ padding: '8px 12px', borderRadius: 8,
+ backgroundColor: 'var(--color-surface-el)',
+ border: `1px solid ${CATEGORY_COLORS[m.category] || 'var(--color-border)'}33`,
+ }}>
+ <div style={{ fontSize: 9, fontWeight: 600, color: CATEGORY_COLORS[m.category] || '#71717a', marginBottom: 2, textTransform: 'uppercase', letterSpacing: '0.04em' }}>
+ Group {String.fromCharCode(65 + idx)}: {m.category}
+ </div>
+ <div style={{ fontSize: 10, fontWeight: 700, color: 'var(--color-text)', lineHeight: 1.3 }}>
+ {m.title}
+ </div>
+ <div style={{ fontSize: 9, color: '#22c55e', fontWeight: 600, marginTop: 2 }}>
+ {Math.round((m.yes_price || 0) * 100)}% probability
+ </div>
+ </div>
+ ))}
+
+ {/* Tone selector */}
+ <div>
+ <div style={{ fontSize: 10, fontWeight: 600, color: 'var(--color-text-secondary)', marginBottom: 4 }}>Tone</div>
+ <div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
+ {['formal', 'urgent', 'passionate', 'grassroots'].map(t => (
+ <button key={t} onClick={() => setTone(t)}
+ style={{
+ padding: '3px 10px', borderRadius: 99, border: 'none',
+ fontSize: 10, fontWeight: 600, cursor: 'pointer',
+ backgroundColor: tone === t ? 'rgba(245, 158, 11, 0.2)' : 'var(--color-surface-el)',
+ color: tone === t ? '#f59e0b' : 'var(--color-text-muted)',
+ textTransform: 'capitalize', transition: 'all 150ms ease',
+ }}
+ >{t}</button>
+ ))}
+ </div>
+ </div>
+
+ <button
+ onClick={handleAllianceGenerate}
+ disabled={generating}
+ style={{
+ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
+ padding: '8px 12px', borderRadius: 8, border: 'none',
+ background: 'linear-gradient(135deg, #f59e0b, #d97706)',
+ color: '#000', fontSize: 11, fontWeight: 700,
+ cursor: generating ? 'wait' : 'pointer',
+ opacity: generating ? 0.7 : 1,
+ transition: 'opacity 150ms ease',
+ }}
+ >
+ {generating
+ ? <><Loader2 size={13} style={{ animation: 'spin 1s linear infinite' }} /> Generating Alliance...</>
+ : <><Zap size={13} /> Generate Alliance Petition</>
+ }
+ </button>
+ </div>
+ )}
+
+ {/* Single market selected — Generate petition */}
+ {selectedMarkets.length >= 1 && !isAllianceMode && !generated && (
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
+ <div style={{
+ padding: '10px 12px', borderRadius: 8,
+ backgroundColor: 'var(--color-surface-el)',
+ border: '1px solid var(--color-border)',
+ }}>
+ <div style={{ fontSize: 10, fontWeight: 600, color: 'var(--color-text-muted)', marginBottom: 4, textTransform: 'uppercase', letterSpacing: '0.04em' }}>
+ Selected Market
+ </div>
+ <div style={{ fontSize: 11, fontWeight: 700, color: 'var(--color-text)', marginBottom: 6, lineHeight: 1.3 }}>
+ {selectedMarket!.title}
+ </div>
+ <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
+ <span style={{ fontSize: 9, color: '#22c55e', fontWeight: 700 }}>
+ Yes: {Math.round((selectedMarket!.yes_price || 0) * 100)}%
+ </span>
+ <span style={{ fontSize: 9, color: '#ef4444', fontWeight: 700 }}>
+ No: {Math.round((selectedMarket!.no_price || 0) * 100)}%
+ </span>
+ <span style={{ fontSize: 9, color: 'var(--color-text-muted)' }}>
+ Vol: {((selectedMarket!.volume || 0) / 1000).toFixed(0)}k
+ </span>
+ </div>
+ </div>
+
+ {/* Tone selector */}
+ <div>
+ <div style={{ fontSize: 10, fontWeight: 600, color: 'var(--color-text-secondary)', marginBottom: 4 }}>Tone</div>
+ <div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
+ {['formal', 'urgent', 'passionate', 'grassroots'].map(t => (
+ <button key={t} onClick={() => setTone(t)}
+ style={{
+ padding: '3px 10px', borderRadius: 99, border: 'none',
+ fontSize: 10, fontWeight: 600, cursor: 'pointer',
+ backgroundColor: tone === t ? 'rgba(124, 58, 237, 0.2)' : 'var(--color-surface-el)',
+ color: tone === t ? '#a78bfa' : 'var(--color-text-muted)',
+ textTransform: 'capitalize', transition: 'all 150ms ease',
+ }}
+ >{t}</button>
+ ))}
+ </div>
+ </div>
+
+ <button
+ onClick={handleGenerate}
+ disabled={generating}
+ style={{
+ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
+ padding: '8px 12px', borderRadius: 8, border: 'none',
+ background: 'linear-gradient(135deg, #7c3aed, #6d28d9)',
+ color: '#fff', fontSize: 11, fontWeight: 700,
+ cursor: generating ? 'wait' : 'pointer',
+ opacity: generating ? 0.7 : 1,
+ transition: 'opacity 150ms ease',
+ }}
+ >
+ {generating
+ ? <><Loader2 size={13} style={{ animation: 'spin 1s linear infinite' }} /> Generating...</>
+ : <><Sparkles size={13} /> Generate Petition</>
+ }
+ </button>
+ </div>
+ )}
+
+ {/* Generated petition preview */}
+ {generated && (
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
+ <div style={{
+ padding: '10px 12px', borderRadius: 8,
+ backgroundColor: 'var(--color-surface-el)',
+ border: `1px solid ${generated.alliance_insight ? 'rgba(245, 158, 11, 0.3)' : 'rgba(124, 58, 237, 0.2)'}`,
+ }}>
+ <div style={{ fontSize: 10, fontWeight: 600, color: generated.alliance_insight ? '#f59e0b' : '#a78bfa', marginBottom: 4, textTransform: 'uppercase', letterSpacing: '0.04em' }}>
+ {generated.alliance_insight ? 'Alliance Petition' : 'Generated Preview'}
+ </div>
+ <div style={{ fontSize: 11, fontWeight: 700, color: 'var(--color-text)', marginBottom: 4, lineHeight: 1.3 }}>
+ {generated.title}
+ </div>
+ <div style={{ fontSize: 10, color: 'var(--color-text-muted)', lineHeight: 1.4, marginBottom: 6 }}>
+ {generated.summary}
+ </div>
+ <div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
+ {(generated.suggested_tags || []).map(tag => (
+ <span key={tag} style={{
+ fontSize: 8, padding: '1px 6px', borderRadius: 99,
+ backgroundColor: generated.alliance_insight ? 'rgba(245, 158, 11, 0.1)' : 'rgba(124, 58, 237, 0.1)',
+ color: generated.alliance_insight ? '#f59e0b' : '#a78bfa',
+ }}>
+ {tag}
+ </span>
+ ))}
+ </div>
+ </div>
+
+ {/* Alliance insight blocks */}
+ {generated.alliance_insight && (
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
+ <div style={{
+ padding: '8px 10px', borderRadius: 8,
+ backgroundColor: 'rgba(245, 158, 11, 0.06)',
+ border: '1px solid rgba(245, 158, 11, 0.15)',
+ }}>
+ <div style={{ fontSize: 9, fontWeight: 700, color: '#f59e0b', marginBottom: 3, textTransform: 'uppercase', letterSpacing: '0.04em' }}>
+ Why This Alliance Works
+ </div>
+ <div style={{ fontSize: 10, color: 'var(--color-text-muted)', lineHeight: 1.4 }}>
+ {generated.alliance_insight}
+ </div>
+ </div>
+ <div style={{ display: 'flex', gap: 6 }}>
+ {generated.group_a_appeal && (
+ <div style={{ flex: 1, padding: '6px 8px', borderRadius: 6, backgroundColor: 'var(--color-surface-el)', border: '1px solid var(--color-border)' }}>
+ <div style={{ fontSize: 8, fontWeight: 700, color: CATEGORY_COLORS[selectedMarkets[0]?.category] || '#71717a', marginBottom: 2 }}>GROUP A</div>
+ <div style={{ fontSize: 9, color: 'var(--color-text-muted)', lineHeight: 1.3 }}>{generated.group_a_appeal}</div>
+ </div>
+ )}
+ {generated.group_b_appeal && (
+ <div style={{ flex: 1, padding: '6px 8px', borderRadius: 6, backgroundColor: 'var(--color-surface-el)', border: '1px solid var(--color-border)' }}>
+ <div style={{ fontSize: 8, fontWeight: 700, color: CATEGORY_COLORS[selectedMarkets[1]?.category] || '#71717a', marginBottom: 2 }}>GROUP B</div>
+ <div style={{ fontSize: 9, color: 'var(--color-text-muted)', lineHeight: 1.3 }}>{generated.group_b_appeal}</div>
+ </div>
+ )}
+ </div>
+ {generated.impact_multiplier && (
+ <div style={{
+ padding: '6px 10px', borderRadius: 6,
+ backgroundColor: 'rgba(34, 197, 94, 0.06)',
+ border: '1px solid rgba(34, 197, 94, 0.15)',
+ }}>
+ <div style={{ fontSize: 9, fontWeight: 700, color: '#22c55e', marginBottom: 2 }}>IMPACT MULTIPLIER</div>
+ <div style={{ fontSize: 9, color: 'var(--color-text-muted)', lineHeight: 1.3 }}>{generated.impact_multiplier}</div>
+ </div>
+ )}
+ </div>
+ )}
+
+ {/* Save / Regenerate / Activate */}
+ <div style={{ display: 'flex', gap: 6 }}>
+ <button
+ onClick={handleSaveDraft}
+ disabled={saving}
+ style={{
+ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 4,
+ padding: '7px 10px', borderRadius: 8, border: 'none',
+ background: saving ? '#555' : 'linear-gradient(135deg, #7c3aed, #6d28d9)',
+ color: '#fff', fontSize: 10, fontWeight: 700,
+ cursor: saving ? 'wait' : 'pointer',
+ opacity: saving ? 0.7 : 1,
+ transition: 'all 150ms ease',
+ }}
+ >
+ {saving
+ ? <><Loader2 size={12} style={{ animation: 'spin 1s linear infinite' }} /> Saving...</>
+ : <><FileText size={12} /> Save as Draft</>
+ }
+ </button>
+ <button
+ onClick={() => setGenerated(null)}
+ style={{
+ padding: '7px 10px', borderRadius: 8,
+ border: '1px solid var(--color-border)',
+ backgroundColor: 'transparent', color: 'var(--color-text-secondary)',
+ fontSize: 10, fontWeight: 600, cursor: 'pointer',
+ transition: 'all 150ms ease',
+ }}
+ onMouseEnter={e => { e.currentTarget.style.backgroundColor = 'var(--color-surface-el)'; }}
+ onMouseLeave={e => { e.currentTarget.style.backgroundColor = 'transparent'; }}
+ >
+ Redo
+ </button>
+ </div>
+
+ {/* Cross-Platform Posting */}
+ <div>
+ <div style={{ fontSize: 10, fontWeight: 600, color: 'var(--color-text-secondary)', marginBottom: 6, textTransform: 'uppercase', letterSpacing: '0.04em' }}>
+ Cross-Platform Post
+ </div>
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
+ {Object.entries(PLATFORMS).map(([key, platform]) => (
+ <button
+ key={key}
+ onClick={() => handleCopyAndOpen(key)}
+ style={{
+ display: 'flex', alignItems: 'center', gap: 8,
+ padding: '7px 10px', borderRadius: 8,
+ border: '1px solid var(--color-border)',
+ backgroundColor: 'transparent', color: 'var(--color-text)',
+ fontSize: 10, fontWeight: 600, cursor: 'pointer',
+ transition: 'all 150ms ease', textAlign: 'left',
+ }}
+ onMouseEnter={e => { e.currentTarget.style.backgroundColor = 'var(--color-surface-el)'; e.currentTarget.style.borderColor = platform.color; }}
+ onMouseLeave={e => { e.currentTarget.style.backgroundColor = 'transparent'; e.currentTarget.style.borderColor = 'var(--color-border)'; }}
+ >
+ <div style={{
+ width: 22, height: 22, borderRadius: 6, flexShrink: 0,
+ backgroundColor: `${platform.color}20`,
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
+ }}>
+ <ExternalLink size={11} style={{ color: platform.color }} />
+ </div>
+ <span style={{ flex: 1 }}>{platform.name}</span>
+ {copiedPlatform === key
+ ? <Check size={12} style={{ color: '#22c55e' }} />
+ : <Copy size={12} style={{ color: 'var(--color-text-muted)' }} />
+ }
+ </button>
+ ))}
+ </div>
+ </div>
+
+ {/* Email Campaign Link */}
+ <button
+ onClick={async () => {
+ if (!generated) return;
+ // Save petition first, then redirect to campaigns
+ setSaving(true);
+ try {
+ const res = await fetch('/api/petitions', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ title: generated.title,
+ summary: generated.summary,
+ body_html: generated.body_html || `<p>${generated.summary}</p>`,
+ body_text: generated.body_text || generated.summary,
+ target: generated.suggested_target,
+ tags: generated.suggested_tags,
+ signature_goal: generated.suggested_goal || 10000,
+ status: 'active',
+ }),
+ });
+ if (res.ok) {
+ showToast('Petition saved & ready for campaign!', 'success');
+ // Navigate to campaigns tab after short delay
+ setTimeout(() => {
+ const event = new CustomEvent('patty-navigate', { detail: { tab: 'campaigns' } });
+ window.dispatchEvent(event);
+ }, 500);
+ } else {
+ showToast('Failed to save petition for campaign', 'error');
+ }
+ } catch {
+ showToast('Network error', 'error');
+ } finally {
+ setSaving(false);
+ }
+ }}
+ disabled={saving}
+ style={{
+ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
+ padding: '7px 10px', borderRadius: 8,
+ border: '1px solid rgba(124, 58, 237, 0.3)',
+ backgroundColor: 'rgba(124, 58, 237, 0.08)',
+ color: '#a78bfa', fontSize: 10, fontWeight: 700, cursor: 'pointer',
+ transition: 'all 150ms ease',
+ }}
+ onMouseEnter={e => { e.currentTarget.style.backgroundColor = 'rgba(124, 58, 237, 0.15)'; }}
+ onMouseLeave={e => { e.currentTarget.style.backgroundColor = 'rgba(124, 58, 237, 0.08)'; }}
+ >
+ <Mail size={12} /> Create Email Campaign
+ </button>
+ </div>
+ )}
+
+ {/* Petition selected — show linked markets + cross-post */}
+ {selectedPetition && selectedMarkets.length === 0 && !generated && (
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
+ <div style={{
+ padding: '10px 12px', borderRadius: 8,
+ backgroundColor: 'var(--color-surface-el)',
+ border: '1px solid var(--color-border)',
+ }}>
+ <div style={{ fontSize: 10, fontWeight: 600, color: 'var(--color-text-muted)', marginBottom: 4, textTransform: 'uppercase', letterSpacing: '0.04em' }}>
+ Selected Petition
+ </div>
+ <div style={{ fontSize: 11, fontWeight: 700, color: 'var(--color-text)', marginBottom: 4, lineHeight: 1.3 }}>
+ {selectedPetition.title}
+ </div>
+ <div style={{ fontSize: 10, color: 'var(--color-text-muted)', lineHeight: 1.4, marginBottom: 6 }}>
+ {selectedPetition.summary}
+ </div>
+ <div style={{ display: 'flex', gap: 8 }}>
+ <span style={{
+ fontSize: 9, padding: '2px 6px', borderRadius: 4,
+ backgroundColor: STATUS_STYLES[selectedPetition.status]?.bg,
+ color: STATUS_STYLES[selectedPetition.status]?.text,
+ fontWeight: 600, textTransform: 'capitalize',
+ }}>
+ {selectedPetition.status}
+ </span>
+ <span style={{ fontSize: 9, color: 'var(--color-text-muted)' }}>
+ {(selectedPetition.signature_count ?? 0).toLocaleString()} / {(selectedPetition.signature_goal ?? 1).toLocaleString()} sigs
+ </span>
+ </div>
+ </div>
+
+ {/* Cross-Platform Posting for existing petition */}
+ <div>
+ <div style={{ fontSize: 10, fontWeight: 600, color: 'var(--color-text-secondary)', marginBottom: 6, textTransform: 'uppercase', letterSpacing: '0.04em' }}>
+ Post to Platform
+ </div>
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
+ {Object.entries(PLATFORMS).map(([key, platform]) => (
+ <button
+ key={key}
+ onClick={() => {
+ const text = `${selectedPetition.title}\n\n${selectedPetition.summary}`;
+ navigator.clipboard.writeText(text).then(() => {
+ setCopiedPlatform(key);
+ setTimeout(() => setCopiedPlatform(null), 2000);
+ window.open(platform.url, '_blank');
+ });
+ }}
+ style={{
+ display: 'flex', alignItems: 'center', gap: 8,
+ padding: '7px 10px', borderRadius: 8,
+ border: '1px solid var(--color-border)',
+ backgroundColor: 'transparent', color: 'var(--color-text)',
+ fontSize: 10, fontWeight: 600, cursor: 'pointer',
+ transition: 'all 150ms ease', textAlign: 'left',
+ }}
+ onMouseEnter={e => { e.currentTarget.style.backgroundColor = 'var(--color-surface-el)'; }}
+ onMouseLeave={e => { e.currentTarget.style.backgroundColor = 'transparent'; }}
+ >
+ <div style={{
+ width: 22, height: 22, borderRadius: 6, flexShrink: 0,
+ backgroundColor: `${platform.color}20`,
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
+ }}>
+ <ExternalLink size={11} style={{ color: platform.color }} />
+ </div>
+ <span style={{ flex: 1 }}>Copy & Open {platform.name}</span>
+ {copiedPlatform === key
+ ? <Check size={12} style={{ color: '#22c55e' }} />
+ : <Copy size={12} style={{ color: 'var(--color-text-muted)' }} />
+ }
+ </button>
+ ))}
+ </div>
+ </div>
+
+ {/* Activate / Archive buttons */}
+ <div style={{ display: 'flex', gap: 6 }}>
+ {selectedPetition.status === 'draft' && (
+ <button
+ onClick={() => handleActivatePetition(selectedPetition.id)}
+ style={{
+ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 4,
+ padding: '7px 10px', borderRadius: 8, border: 'none',
+ background: 'linear-gradient(135deg, #22c55e, #16a34a)',
+ color: '#fff', fontSize: 10, fontWeight: 700, cursor: 'pointer',
+ transition: 'all 150ms ease',
+ }}
+ >
+ <Zap size={11} /> Activate
+ </button>
+ )}
+ <button
+ onClick={() => handleArchivePetition(selectedPetition.id)}
+ style={{
+ padding: '7px 10px', borderRadius: 8,
+ border: '1px solid rgba(239, 68, 68, 0.3)',
+ backgroundColor: 'rgba(239, 68, 68, 0.06)',
+ color: '#ef4444', fontSize: 10, fontWeight: 600, cursor: 'pointer',
+ transition: 'all 150ms ease',
+ }}
+ onMouseEnter={e => { e.currentTarget.style.backgroundColor = 'rgba(239, 68, 68, 0.12)'; }}
+ onMouseLeave={e => { e.currentTarget.style.backgroundColor = 'rgba(239, 68, 68, 0.06)'; }}
+ >
+ <X size={11} /> Archive
+ </button>
+ </div>
+
+ <button
+ onClick={() => {
+ showToast('Opening campaign builder...', 'info');
+ const event = new CustomEvent('patty-navigate', { detail: { tab: 'campaigns' } });
+ window.dispatchEvent(event);
+ }}
+ style={{
+ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
+ padding: '7px 10px', borderRadius: 8,
+ border: '1px solid rgba(124, 58, 237, 0.3)',
+ backgroundColor: 'rgba(124, 58, 237, 0.08)',
+ color: '#a78bfa', fontSize: 10, fontWeight: 700, cursor: 'pointer',
+ transition: 'all 150ms ease',
+ }}
+ onMouseEnter={e => { e.currentTarget.style.backgroundColor = 'rgba(124, 58, 237, 0.15)'; }}
+ onMouseLeave={e => { e.currentTarget.style.backgroundColor = 'rgba(124, 58, 237, 0.08)'; }}
+ >
+ <Mail size={12} /> Create Campaign from Petition
+ </button>
+
+ {/* Share Link */}
+ <button
+ onClick={() => {
+ const shareUrl = `${window.location.origin}/petition/${selectedPetition.id}`;
+ const doCopy = () => {
+ if (navigator.clipboard?.writeText) return navigator.clipboard.writeText(shareUrl);
+ const ta = document.createElement('textarea');
+ ta.value = shareUrl;
+ ta.style.position = 'fixed';
+ ta.style.left = '-9999px';
+ document.body.appendChild(ta);
+ ta.select();
+ document.execCommand('copy');
+ document.body.removeChild(ta);
+ return Promise.resolve();
+ };
+ doCopy().then(() => showToast('Petition link copied!', 'success'))
+ .catch(() => showToast('Failed to copy link', 'error'));
+ }}
+ style={{
+ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
+ padding: '7px 10px', borderRadius: 8,
+ border: '1px solid var(--color-border)',
+ backgroundColor: 'transparent',
+ color: 'var(--color-text-secondary)', fontSize: 10, fontWeight: 600, cursor: 'pointer',
+ transition: 'all 150ms ease',
+ }}
+ onMouseEnter={e => { e.currentTarget.style.backgroundColor = 'var(--color-surface-el)'; }}
+ onMouseLeave={e => { e.currentTarget.style.backgroundColor = 'transparent'; }}
+ >
+ <Link2 size={11} /> Share Petition Link
+ </button>
+ </div>
+ )}
+ </div>
+ )}
+ </div>
+ );
+}
+
+/* ═══════════════════════════════════════════════════════════════════════════
+ Main OrbitTab Component
+ ═══════════════════════════════════════════════════════════════════════════ */
+
+interface OrbitTabProps {
+ pulseTopic?: string | null;
+ onPulseTopicConsumed?: () => void;
+}
+
+export default function OrbitTab({ pulseTopic, onPulseTopicConsumed }: OrbitTabProps = {}) {
+ /* ── Data state ───────────────────────────────────────────────────────── */
+ const [markets, setMarkets] = useState<KalshiMarket[]>(MOCK_MARKETS);
+ const [petitions, setPetitions] = useState<Petition[]>(MOCK_PETITIONS);
+ const [links, setLinks] = useState<OrbitLink[]>(MOCK_LINKS);
+ const [articles, setArticles] = useState<RSSArticle[]>(MOCK_ARTICLES);
+ const [stats, setStats] = useState<OrbitStats>(MOCK_STATS);
+ const [trendingTopics, setTrendingTopics] = useState<TrendingTopic[]>([]);
+ const [agents, setAgents] = useState<AgentInfo[]>([]);
+ const [loading, setLoading] = useState(false);
+ const [rssLoading, setRssLoading] = useState(false);
+ const [pulseGenerating, setPulseGenerating] = useState(false);
+ const [pulseResult, setPulseResult] = useState<{ petition: any; influence_analysis: string } | null>(null);
+ const [refreshing, setRefreshing] = useState(false);
+ const [usingMock, setUsingMock] = useState(true);
+
+ /* ── Interaction state ────────────────────────────────────────────────── */
+ const [hoveredMarket, setHoveredMarket] = useState<string | null>(null);
+ const [hoveredPetition, setHoveredPetition] = useState<string | null>(null);
+ const [selectedMarkets, setSelectedMarkets] = useState<string[]>([]);
+ const [selectedPetition, setSelectedPetition] = useState<string | null>(null);
+
+ // Market selection handler: normal click = single select, shift = multi (max 2)
+ const handleSelectMarket = useCallback((id: string | null, shift?: boolean) => {
+ if (!id) {
+ setSelectedMarkets([]);
+ return;
+ }
+ setSelectedMarkets(prev => {
+ // Deselect if already selected
+ if (prev.includes(id)) return prev.filter(x => x !== id);
+ // Shift+click: add to selection (max 2)
+ if (shift && prev.length < 2) return [...prev, id];
+ if (shift && prev.length >= 2) return [prev[1], id]; // drop oldest
+ // Normal click: replace
+ return [id];
+ });
+ setSelectedPetition(null);
+ }, []);
+
+ /* ── Fetch orbit data ─────────────────────────────────────────────────── */
+ const fetchOrbitData = useCallback(async () => {
+ setLoading(true);
+ try {
+ const res = await fetch('/api/orbit');
+ if (res.ok) {
+ const data = await res.json();
+ if (data.markets?.length > 0) {
+ // Map DB fields to client-expected fields
+ const mappedMarkets = data.markets.map((m: any) => {
+ // DB stores prices as integers 0-100; client expects floats 0-1
+ const rawYes = m.yes_price ?? m.last_price ?? m.yes_bid ?? 0;
+ const yesPrice = rawYes > 1 ? rawYes / 100 : rawYes;
+ const rawNo = m.no_price ?? m.no_bid ?? (rawYes != null ? (rawYes > 1 ? 100 : 1) - rawYes : 0);
+ const noPrice = rawNo > 1 ? rawNo / 100 : rawNo;
+ return {
+ ...m,
+ yes_price: yesPrice,
+ no_price: noPrice,
+ volume: m.volume ?? 0,
+ category: m.category || 'Other',
+ };
+ });
+ setMarkets(mappedMarkets);
+ setPetitions(data.petitions || []);
+ setLinks(data.links || []);
+ if (data.trending?.length > 0) setTrendingTopics(data.trending);
+ setStats({
+ ...MOCK_STATS,
+ ...data.stats,
+ articles_today: data.stats?.articles_today ?? data.stats?.recent_articles ?? MOCK_STATS.articles_today,
+ avg_sentiment: data.stats?.avg_sentiment ?? MOCK_STATS.avg_sentiment,
+ });
+ setUsingMock(false);
+ }
+ }
+ } catch {
+ // Keep mock data on failure
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ /* ── Fetch RSS data ───────────────────────────────────────────────────── */
+ const fetchRSSData = useCallback(async () => {
+ setRssLoading(true);
+ try {
+ const res = await fetch('/api/orbit/rss');
+ if (res.ok) {
+ const data = await res.json();
+ if (data.articles?.length > 0) {
+ setArticles(data.articles);
+ if (data.stats) {
+ setStats(prev => ({
+ ...prev,
+ active_feeds: data.stats.active_feeds ?? prev.active_feeds,
+ articles_today: data.stats.articles_today ?? prev.articles_today,
+ avg_sentiment: data.stats.avg_sentiment ?? prev.avg_sentiment,
+ }));
+ }
+ setUsingMock(false);
+ }
+ }
+ } catch {
+ // Keep mock data
+ } finally {
+ setRssLoading(false);
+ }
+ }, []);
+
+ /* ── Fetch agent status ──────────────────────────────────────────────── */
+ const fetchAgentStatus = useCallback(async () => {
+ try {
+ const res = await fetch('/api/orbit/agents');
+ if (res.ok) {
+ const data = await res.json();
+ setAgents(data.agents || []);
+ }
+ } catch { /* ignore */ }
+ }, []);
+
+ const handleRefreshFeeds = useCallback(async () => {
+ setRefreshing(true);
+ await fetchRSSData();
+ setRefreshing(false);
+ }, [fetchRSSData]);
+
+ useEffect(() => {
+ fetchOrbitData();
+ fetchRSSData();
+ fetchAgentStatus();
+ }, [fetchOrbitData, fetchRSSData, fetchAgentStatus]);
+
+ /* ── Auto-generate petition from Pulse topic ───────────────────────── */
+ useEffect(() => {
+ if (!pulseTopic) return;
+ setPulseGenerating(true);
+ setPulseResult(null);
+ fetch('/api/orbit/generate', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ topic_title: pulseTopic, tone: 'urgent' }),
+ })
+ .then(res => res.ok ? res.json() : Promise.reject(res))
+ .then(data => {
+ setPulseResult({ petition: data.petition, influence_analysis: data.influence_analysis || '' });
+ // Refresh data to show new petition in mind map
+ fetchOrbitData();
+ })
+ .catch(err => {
+ console.error('[OrbitTab] Pulse petition generation failed:', err);
+ setPulseResult(null);
+ })
+ .finally(() => {
+ setPulseGenerating(false);
+ onPulseTopicConsumed?.();
+ });
+ }, [pulseTopic, onPulseTopicConsumed, fetchOrbitData]);
+
+ /* ── Derived selection objects ─────────────────────────────────────────── */
+ const selectedMarketObjs = selectedMarkets.map(id => markets.find(m => m.id === id)).filter(Boolean) as KalshiMarket[];
+ const selectedPetitionObj = selectedPetition ? petitions.find(p => p.id === selectedPetition) || null : null;
+
+ const sentimentInfo = sentimentLabel(stats.avg_sentiment);
+
+ /* ─── Render ──────────────────────────────────────────────────────────── */
+ return (
+ <div style={{ padding: 16, height: '100%', display: 'flex', flexDirection: 'column', gap: 12, overflow: 'hidden' }}>
+
+ {/* ── Stats Bar ────────────────────────────────────────────────────── */}
+ <div style={{
+ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap',
+ padding: '10px 16px', borderRadius: 12,
+ background: 'linear-gradient(135deg, var(--color-surface), var(--color-surface-el))',
+ border: '1px solid var(--color-border)',
+ flexShrink: 0,
+ }}>
+ {usingMock && (
+ <div style={{
+ display: 'flex', alignItems: 'center', gap: 4,
+ padding: '3px 10px', borderRadius: 99,
+ backgroundColor: 'rgba(245, 158, 11, 0.15)',
+ border: '1px solid rgba(245, 158, 11, 0.3)',
+ }}>
+ <AlertCircle size={11} style={{ color: '#f59e0b' }} />
+ <span style={{ fontSize: 9, fontWeight: 600, color: '#f59e0b' }}>Demo Data</span>
+ </div>
+ )}
+ <StatPill icon={<BarChart3 size={12} />} label="Markets" value={stats.total_markets} color="#3b82f6" />
+ <StatPill icon={<Link2 size={12} />} label="Linked" value={stats.linked_petitions} color="#a78bfa" />
+ <StatPill icon={<Rss size={12} />} label="Feeds" value={stats.active_feeds} color="#22c55e" />
+ <StatPill icon={<Newspaper size={12} />} label="Today" value={stats.articles_today} color="#06b6d4" />
+ <StatPill icon={<TrendingUp size={12} />} label="Trending" value={trendingTopics.length} color={TRENDING_COLOR} />
+ <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
+ <span style={{ fontSize: 10, color: 'var(--color-text-muted)', fontWeight: 600 }}>Sentiment</span>
+ <span style={{
+ fontSize: 10, fontWeight: 700, padding: '2px 8px', borderRadius: 99,
+ backgroundColor: `${sentimentInfo.color}20`, color: sentimentInfo.color,
+ }}>
+ {sentimentInfo.label} ({(stats.avg_sentiment ?? 0).toFixed(2)})
+ </span>
+ </div>
+ {/* Agent Status Indicators */}
+ {agents.length > 0 && (
+ <div style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '2px 8px', borderRadius: 99,
+ backgroundColor: 'rgba(124,58,237,0.08)', border: '1px solid rgba(124,58,237,0.15)' }}
+ title={agents.map(a => `${a.name}: ${a.status}`).join(', ')}
+ >
+ <Activity size={10} style={{ color: '#a78bfa' }} />
+ <span style={{ fontSize: 9, fontWeight: 600, color: 'var(--color-text-muted)' }}>Agents</span>
+ {agents.map(a => (
+ <div key={a.name} title={`${a.name} (${a.role}) — ${a.status}`}
+ style={{
+ width: 7, height: 7, borderRadius: '50%',
+ backgroundColor: a.status === 'online' ? '#22c55e' : a.status === 'error' ? '#f59e0b' : '#ef4444',
+ border: '1px solid rgba(255,255,255,0.15)',
+ transition: 'background-color 300ms ease',
+ }}
+ />
+ ))}
+ <span style={{ fontSize: 9, fontWeight: 700, color: '#22c55e' }}>
+ {agents.filter(a => a.status === 'online').length}/{agents.length}
+ </span>
+ </div>
+ )}
+
+ <div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 4 }}>
+ {loading && <Loader2 size={13} style={{ color: 'var(--color-text-muted)', animation: 'spin 1s linear infinite' }} />}
+ <button
+ onClick={() => { fetchOrbitData(); fetchRSSData(); fetchAgentStatus(); }}
+ style={{
+ display: 'flex', alignItems: 'center', gap: 4, padding: '4px 10px',
+ borderRadius: 6, border: '1px solid var(--color-border)',
+ backgroundColor: 'transparent', color: 'var(--color-text-secondary)',
+ cursor: 'pointer', fontSize: 10, fontWeight: 600,
+ transition: 'all 150ms ease',
+ }}
+ onMouseEnter={e => { e.currentTarget.style.backgroundColor = 'var(--color-surface-el)'; }}
+ onMouseLeave={e => { e.currentTarget.style.backgroundColor = 'transparent'; }}
+ >
+ <RefreshCw size={11} /> Sync
+ </button>
+ </div>
+ </div>
+
+ {/* ── Pulse Topic Banner ──────────────────────────────────────────── */}
+ {(pulseGenerating || pulseResult) && (
+ <div style={{
+ display: 'flex', alignItems: 'center', gap: 10,
+ padding: '10px 16px', borderRadius: 10,
+ background: pulseGenerating
+ ? 'linear-gradient(135deg, rgba(124,58,237,0.15), rgba(59,130,246,0.15))'
+ : 'linear-gradient(135deg, rgba(34,197,94,0.12), rgba(124,58,237,0.12))',
+ border: `1px solid ${pulseGenerating ? 'rgba(124,58,237,0.3)' : 'rgba(34,197,94,0.3)'}`,
+ flexShrink: 0,
+ }}>
+ {pulseGenerating ? (
+ <>
+ <Loader2 size={16} style={{ color: '#a78bfa', animation: 'spin 1s linear infinite' }} />
+ <span style={{ fontSize: 12, fontWeight: 600, color: '#a78bfa' }}>
+ Generating petition from Pulse topic...
+ </span>
+ </>
+ ) : pulseResult ? (
+ <>
+ <Sparkles size={16} style={{ color: '#22c55e' }} />
+ <span style={{ fontSize: 12, fontWeight: 600, color: 'var(--color-text)' }}>
+ Petition created: “{pulseResult.petition?.title}”
+ </span>
+ <span style={{ fontSize: 10, color: 'var(--color-text-muted)', marginLeft: 4 }}>
+ {pulseResult.influence_analysis?.slice(0, 120)}...
+ </span>
+ <button
+ onClick={() => setPulseResult(null)}
+ style={{
+ marginLeft: 'auto', padding: '2px 8px', borderRadius: 4,
+ border: '1px solid var(--color-border)', backgroundColor: 'transparent',
+ color: 'var(--color-text-muted)', fontSize: 10, cursor: 'pointer',
+ }}
+ >
+ <X size={12} />
+ </button>
+ </>
+ ) : null}
+ </div>
+ )}
+
+ {/* ── Main Layout: Mind Map | Right Panels ─────────────────────────── */}
+ <div style={{ flex: 1, display: 'flex', gap: 12, minHeight: 0 }}>
+ {/* Mind Map (left, 60%) */}
+ <div style={{
+ flex: '0 0 60%', minWidth: 0,
+ height: 'calc(100vh - 140px)',
+ backgroundColor: 'var(--color-surface)',
+ border: '1px solid var(--color-border)',
+ borderRadius: 12, overflow: 'hidden', position: 'relative',
+ }}>
+ {loading && !usingMock ? (
+ <div style={{
+ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%',
+ flexDirection: 'column', gap: 8,
+ }}>
+ <Loader2 size={24} style={{ color: '#a78bfa', animation: 'spin 1s linear infinite' }} />
+ <span style={{ fontSize: 11, color: 'var(--color-text-muted)' }}>Loading orbit data...</span>
+ </div>
+ ) : (
+ <MindMap
+ markets={markets.slice(0, 14)}
+ petitions={petitions.slice(0, 6)}
+ links={links}
+ trendingTopics={trendingTopics.slice(0, 6)}
+ hoveredMarket={hoveredMarket}
+ hoveredPetition={hoveredPetition}
+ selectedMarkets={selectedMarkets}
+ selectedPetition={selectedPetition}
+ onHoverMarket={setHoveredMarket}
+ onHoverPetition={setHoveredPetition}
+ onSelectMarket={handleSelectMarket}
+ onSelectPetition={(id) => { setSelectedPetition(id); if (id) setSelectedMarkets([]); }}
+ onSelectTrending={(id) => {
+ // When a trending topic is clicked, auto-generate a petition from it
+ if (id) {
+ const topic = trendingTopics.find(t => t.id === id);
+ if (topic) {
+ window.dispatchEvent(new CustomEvent('patty-navigate', { detail: { tab: 'trending' } }));
+ }
+ }
+ }}
+ />
+ )}
+
+ {/* Watermark */}
+ <div style={{
+ position: 'absolute', bottom: 8, right: 12,
+ fontSize: 9, color: 'var(--color-text-muted)', opacity: 0.5,
+ fontWeight: 600, letterSpacing: '0.04em',
+ }}>
+ ORBIT MIND MAP
+ </div>
+ </div>
+
+ {/* Right panels (40%, stacked) */}
+ <div style={{ flex: '0 0 40%', minWidth: 0, display: 'flex', flexDirection: 'column', gap: 12, height: 'calc(100vh - 140px)' }}>
+ {/* Pulse panel (top 55%) */}
+ <div style={{ flex: '0 0 55%', minHeight: 0 }}>
+ <PulsePanel
+ articles={articles}
+ loading={rssLoading}
+ onRefresh={handleRefreshFeeds}
+ refreshing={refreshing}
+ />
+ </div>
+
+ {/* Actions panel (bottom 45%) */}
+ <div style={{ flex: 1, minHeight: 0 }}>
+ <ActionsPanel
+ selectedMarkets={selectedMarketObjs}
+ selectedPetition={selectedPetitionObj}
+ onClearSelection={() => { setSelectedMarkets([]); setSelectedPetition(null); }}
+ onRefresh={fetchOrbitData}
+ />
+ </div>
+ </div>
+ </div>
+
+ {/* Animation keyframes */}
+ <style>{`
+ @keyframes spin {
+ from { transform: rotate(0deg); }
+ to { transform: rotate(360deg); }
+ }
+ @keyframes fadeInUp {
+ from { opacity: 0; transform: translateX(-50%) translateY(8px); }
+ to { opacity: 1; transform: translateX(-50%) translateY(0); }
+ }
+ `}</style>
+ </div>
+ );
+}
+
+/* ═══════════════════════════════════════════════════════════════════════════
+ StatPill Utility
+ ═══════════════════════════════════════════════════════════════════════════ */
+
+function StatPill({ icon, label, value, color }: { icon: React.ReactNode; label: string; value: number; color: string }) {
+ return (
+ <div style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
+ <div style={{ color, display: 'flex', alignItems: 'center' }}>{icon}</div>
+ <span style={{ fontSize: 10, color: 'var(--color-text-muted)', fontWeight: 600 }}>{label}</span>
+ <span style={{ fontSize: 11, fontWeight: 800, color: 'var(--color-text)' }}>{value}</span>
+ </div>
+ );
+}
diff --git a/components/petitions/PetitionsTab.tsx b/components/petitions/PetitionsTab.tsx
new file mode 100644
index 0000000..e350b73
--- /dev/null
+++ b/components/petitions/PetitionsTab.tsx
@@ -0,0 +1,1101 @@
+'use client';
+
+import { useState, useEffect, useCallback } from 'react';
+import { SkeletonList } from '../Skeleton';
+import {
+ Plus, Megaphone, Search, ChevronDown, ChevronUp,
+ Sparkles, Target, Users, Share2, Calendar, Edit3,
+ Send, Loader2, X, Tag, Mail as MailIcon, DollarSign, ExternalLink,
+} from 'lucide-react';
+import { Grant, formatAmount } from '../grants/types';
+import { useToast } from '../ToastProvider';
+import { useDebounce } from '@/hooks/useDebounce';
+import SortDropdown from '../shared/SortDropdown';
+import { useClientSort, SortConfig } from '@/hooks/useClientSort';
+
+const PET_SORT_OPTIONS = [
+ { value: 'sigs_desc', label: 'Most Signatures' },
+ { value: 'date_desc', label: 'Newest First' },
+ { value: 'date_asc', label: 'Oldest First' },
+ { value: 'status', label: 'Status A-Z' },
+];
+
+const PET_SORT_CONFIGS: Record<string, SortConfig> = {
+ sigs_desc: { key: 'signature_count', 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' },
+};
+
+/* ─── Types ─────────────────────────────────────────────────── */
+interface Petition {
+ id: string;
+ title: string;
+ slug: string;
+ summary: string | null;
+ body_html: string;
+ body_text: string | null;
+ target: string | null;
+ target_emails: string[] | null;
+ status: string;
+ category: string | null;
+ tags: string[] | null;
+ signature_count: number;
+ signature_goal: number;
+ share_count: number;
+ org_type: string | null;
+ compliance_flags: Record<string, unknown> | null;
+ external_url?: string | null;
+ distribution_platform?: string;
+ created_at: string;
+}
+
+/**
+ * Basic HTML tag stripper for display purposes.
+ * All petition content is created by the admin user or generated by Gemini AI,
+ * so it is trusted internal content. This function strips tags for plain text display.
+ */
+function stripHtml(html: string): string {
+ return html.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
+}
+
+/* ─── Component ─────────────────────────────────────────────── */
+export default function PetitionsTab({ selectedId, onSelectedConsumed, onViewGrant }: { selectedId?: number | null; onSelectedConsumed?: () => void; onViewGrant?: (id: string) => void } = {}) {
+ const { addToast } = useToast();
+ const [petitions, setPetitions] = useState<Petition[]>([]);
+ const [loading, setLoading] = useState(true);
+ const [filter, setFilter] = useState('all');
+ const [search, setSearch] = useState('');
+ const debouncedSearch = useDebounce(search, 300);
+ const [statusCounts, setStatusCounts] = useState<Record<string, number>>({});
+ const [expanded, setExpanded] = useState<string | null>(null);
+ const [sortBy, setSortBy] = useState('date_desc');
+ const sortedPetitions = useClientSort(petitions, sortBy, PET_SORT_CONFIGS);
+
+ // AI modal state
+ const [showAiModal, setShowAiModal] = useState(false);
+ const [aiTopic, setAiTopic] = useState('');
+ const [aiTarget, setAiTarget] = useState('');
+ const [aiTone, setAiTone] = useState('formal');
+ const [aiCategory, setAiCategory] = useState('policy');
+ const [aiGenerating, setAiGenerating] = useState(false);
+ const [aiResult, setAiResult] = useState<{
+ title: string; summary: string; body_html: string; body_text: string;
+ suggested_target: string; suggested_tags: string[]; suggested_goal: number;
+ } | null>(null);
+ const [savingDraft, setSavingDraft] = useState(false);
+
+ // Related grants state
+ const [petitionGrants, setPetitionGrants] = useState<Record<string, Grant[]>>({});
+ const [loadingGrants, setLoadingGrants] = useState<string | null>(null);
+
+ // Edit modal state
+ const [editPetition, setEditPetition] = useState<Petition | null>(null);
+ const [editTitle, setEditTitle] = useState('');
+ const [editSummary, setEditSummary] = useState('');
+ const [editTarget, setEditTarget] = useState('');
+ const [editGoal, setEditGoal] = useState(100);
+ const [editStatus, setEditStatus] = useState('draft');
+ const [editSaving, setEditSaving] = useState(false);
+
+ // Deploy modal state
+ const [showDeployModal, setShowDeployModal] = useState(false);
+ const [deployPetition, setDeployPetition] = useState<Petition | null>(null);
+ const [deployPlatforms, setDeployPlatforms] = useState<Record<string, boolean>>({ moveon: true, change_org: true, we_the_people: false, custom: false });
+ const [deploying, setDeploying] = useState(false);
+ const [deployResults, setDeployResults] = useState<Array<{ platform: string; platform_name: string; creation_url: string; formatted_content: Record<string, unknown>; instructions: string }>>([]);
+ const [deployCustomUrl, setDeployCustomUrl] = useState('');
+ const [copiedField, setCopiedField] = useState<string | null>(null);
+
+ /* ── Fetch ──────────────────────────────────────────────────── */
+ const fetchPetitions = useCallback(async () => {
+ setLoading(true);
+ try {
+ const params = new URLSearchParams();
+ if (filter !== 'all') params.set('status', filter);
+ if (debouncedSearch) params.set('search', debouncedSearch);
+ const res = await fetch(`/api/petitions?${params.toString()}`);
+ if (res.ok) {
+ const data = await res.json();
+ setPetitions(data.rows || []);
+ setStatusCounts(data.statusCounts || {});
+ }
+ } catch (err) {
+ console.error('Fetch petitions error:', err);
+ } finally {
+ setLoading(false);
+ }
+ }, [filter, debouncedSearch]);
+
+ useEffect(() => { fetchPetitions(); }, [fetchPetitions]);
+
+ // Auto-expand petition from cross-tab navigation (Topics → Petitions)
+ useEffect(() => {
+ if (selectedId && petitions.length > 0) {
+ setExpanded(String(selectedId));
+ onSelectedConsumed?.();
+ // Scroll into view after render
+ setTimeout(() => {
+ document.getElementById(`petition-${selectedId}`)?.scrollIntoView({ behavior: 'smooth', block: 'center' });
+ }, 100);
+ }
+ }, [selectedId, petitions.length, onSelectedConsumed]);
+
+ /* ── Escape key handler for modals ──────────────────────────── */
+ useEffect(() => {
+ if (!showAiModal && !editPetition && !showDeployModal) return;
+ const handleKey = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') {
+ if (showAiModal) setShowAiModal(false);
+ if (editPetition) setEditPetition(null);
+ if (showDeployModal) { setShowDeployModal(false); setDeployResults([]); }
+ }
+ };
+ window.addEventListener('keydown', handleKey);
+ return () => window.removeEventListener('keydown', handleKey);
+ }, [showAiModal, editPetition, showDeployModal]);
+
+ /* ── AI Generate ────────────────────────────────────────────── */
+ async function handleAiGenerate() {
+ if (!aiTopic.trim()) return;
+ setAiGenerating(true);
+ setAiResult(null);
+ try {
+ const res = await fetch('/api/petitions/generate', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ topic: aiTopic, target: aiTarget, tone: aiTone, category: aiCategory }),
+ });
+ if (res.ok) {
+ const data = await res.json();
+ setAiResult(data);
+ addToast('Petition generated', 'success');
+ } else {
+ addToast('AI generation failed', 'error');
+ }
+ } catch (err) {
+ console.error('AI generate error:', err);
+ addToast('AI generation failed', 'error');
+ } finally {
+ setAiGenerating(false);
+ }
+ }
+
+ /* ── Save Draft ─────────────────────────────────────────────── */
+ async function handleSaveDraft() {
+ if (!aiResult) return;
+ setSavingDraft(true);
+ try {
+ const res = await fetch('/api/petitions', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ title: aiResult.title,
+ summary: aiResult.summary,
+ body_html: aiResult.body_html,
+ body_text: aiResult.body_text,
+ target: aiResult.suggested_target,
+ category: aiCategory,
+ tags: aiResult.suggested_tags,
+ signature_goal: aiResult.suggested_goal,
+ status: 'draft',
+ }),
+ });
+ if (res.ok) {
+ setShowAiModal(false);
+ setAiResult(null);
+ setAiTopic('');
+ setAiTarget('');
+ fetchPetitions();
+ addToast('Draft saved', 'success');
+ } else {
+ addToast('Failed to save draft', 'error');
+ }
+ } catch (err) {
+ console.error('Save draft error:', err);
+ addToast('Failed to save draft', 'error');
+ } finally {
+ setSavingDraft(false);
+ }
+ }
+
+ /* ── Edit Petition ──────────────────────────────────────────── */
+ function openEdit(p: Petition) {
+ setEditPetition(p);
+ setEditTitle(p.title);
+ setEditSummary(p.summary || '');
+ setEditTarget(p.target || '');
+ setEditGoal(p.signature_goal);
+ setEditStatus(p.status);
+ }
+
+ async function handleSaveEdit() {
+ if (!editPetition) return;
+ setEditSaving(true);
+ try {
+ const res = await fetch('/api/petitions', {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ id: editPetition.id,
+ title: editTitle,
+ summary: editSummary,
+ target: editTarget,
+ signature_goal: editGoal,
+ status: editStatus,
+ }),
+ });
+ if (res.ok) {
+ setEditPetition(null);
+ fetchPetitions();
+ addToast('Petition updated', 'success');
+ } else {
+ addToast('Failed to update petition', 'error');
+ }
+ } catch (err) {
+ console.error('Save edit error:', err);
+ addToast('Failed to update petition', 'error');
+ } finally {
+ setEditSaving(false);
+ }
+ }
+
+ /* ── Deploy to Platforms ──────────────────────────────────── */
+ const DEPLOY_PLATFORMS = [
+ { key: 'moveon', name: 'MoveOn', url: 'sign.moveon.org', icon: '🟡' },
+ { key: 'change_org', name: 'Change.org', url: 'change.org', icon: '🔴' },
+ { key: 'we_the_people', name: 'We The People', url: 'petitions.whitehouse.gov', icon: '🏛️' },
+ { key: 'custom', name: 'Custom URL', url: '', icon: '🔗' },
+ ];
+
+ async function handleDeploy() {
+ if (!deployPetition) return;
+ const selected = Object.entries(deployPlatforms).filter(([, v]) => v).map(([k]) => k);
+ if (selected.length === 0) { addToast('Select at least one platform', 'error'); return; }
+ setDeploying(true);
+ setDeployResults([]);
+ const results: typeof deployResults = [];
+ for (const platform of selected) {
+ try {
+ const res = await fetch('/api/orbit/post', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ petition_id: deployPetition.id,
+ platform,
+ custom_url: platform === 'custom' ? deployCustomUrl : undefined,
+ }),
+ });
+ if (res.ok) {
+ const data = await res.json();
+ results.push({
+ platform,
+ platform_name: data.platform_name,
+ creation_url: data.creation_url,
+ formatted_content: data.formatted_content,
+ instructions: data.instructions,
+ });
+ } else {
+ addToast(`Failed to deploy to ${platform}`, 'error');
+ }
+ } catch {
+ addToast(`Error deploying to ${platform}`, 'error');
+ }
+ }
+ setDeployResults(results);
+ setDeploying(false);
+ if (results.length > 0) addToast(`Generated content for ${results.length} platform(s)`, 'success');
+ }
+
+ function copyToClipboard(text: string, label: string) {
+ navigator.clipboard.writeText(String(text)).then(() => {
+ setCopiedField(label);
+ setTimeout(() => setCopiedField(null), 2000);
+ });
+ }
+
+ /* ── Find Related Grants ──────────────────────────────────── */
+ async function findRelatedGrants(petitionId: string) {
+ setLoadingGrants(petitionId);
+ try {
+ const res = await fetch('/api/grants-proxy/grants');
+ if (res.ok) {
+ const data = await res.json();
+ setPetitionGrants(prev => ({ ...prev, [petitionId]: data.rows || [] }));
+ }
+ } catch (err) {
+ console.error('Find grants error:', err);
+ addToast('Failed to find related grants', 'error');
+ } finally {
+ setLoadingGrants(null);
+ }
+ }
+
+ /* ── Helpers ────────────────────────────────────────────────── */
+ const statusFilters = ['all', 'draft', 'active', 'paused', 'closed', 'delivered'];
+
+ const statusBadgeClass: Record<string, string> = {
+ draft: 'badge-draft',
+ active: 'badge-active',
+ paused: 'badge-paused',
+ closed: 'badge-closed',
+ delivered: 'badge-delivered',
+ };
+
+ const categoryLabel = (c: string | null) => {
+ if (!c) return '';
+ return c.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase());
+ };
+
+ const formatDate = (d: string) => {
+ return new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
+ };
+
+ const sigPct = (count: number, goal: number) => {
+ if (goal <= 0) return 0;
+ return Math.min(Math.round((count / goal) * 100), 100);
+ };
+
+ /* ─── Render ────────────────────────────────────────────────── */
+ return (
+ <div style={{ padding: '24px' }}>
+ {/* Header Row */}
+ <div className="flex items-center justify-between mb-6">
+ <div>
+ <h2 className="text-lg font-semibold" style={{ color: 'var(--color-text)' }}>
+ My Petitions
+ </h2>
+ <p className="text-sm mt-1" style={{ color: 'var(--color-text-muted)' }}>
+ Create, manage, and track your advocacy campaigns
+ </p>
+ </div>
+ <button className="btn btn-primary" onClick={() => setShowAiModal(true)}>
+ <Sparkles size={16} />
+ AI Create Petition
+ </button>
+ </div>
+
+ {/* Filter Row */}
+ <div className="flex items-center gap-3 mb-6 flex-wrap">
+ <SortDropdown options={PET_SORT_OPTIONS} value={sortBy} onChange={setSortBy} />
+ <div className="flex-1 relative" style={{ minWidth: 200 }}>
+ <Search
+ size={16}
+ style={{ position: 'absolute', left: 12, top: '50%', transform: 'translateY(-50%)', color: 'var(--color-text-muted)' }}
+ />
+ <input
+ className="input"
+ placeholder="Search by title or target..."
+ style={{ paddingLeft: 36 }}
+ value={search}
+ onChange={(e) => setSearch(e.target.value)}
+ />
+ </div>
+ <div className="flex items-center gap-1 flex-wrap">
+ {statusFilters.map((s) => (
+ <button
+ key={s}
+ onClick={() => setFilter(s)}
+ className={`btn btn-sm ${filter === s ? 'btn-primary' : 'btn-ghost'}`}
+ style={{ textTransform: 'capitalize' }}
+ >
+ {s}{statusCounts[s] !== undefined ? ` (${statusCounts[s]})` : ''}
+ </button>
+ ))}
+ </div>
+ </div>
+
+ {/* Loading */}
+ {loading && (
+ <SkeletonList count={4} />
+ )}
+
+ {/* Empty State */}
+ {!loading && petitions.length === 0 && (
+ <div className="card flex flex-col items-center justify-center py-16" style={{ textAlign: 'center' }}>
+ <div
+ className="w-16 h-16 rounded-2xl flex items-center justify-center mb-4"
+ style={{
+ background: 'linear-gradient(135deg, rgba(124, 58, 237, 0.15), rgba(167, 139, 250, 0.1))',
+ border: '1px solid rgba(124, 58, 237, 0.2)',
+ }}
+ >
+ <Megaphone size={28} style={{ color: 'var(--color-primary)' }} />
+ </div>
+ <h3 className="text-base font-semibold mb-2" style={{ color: 'var(--color-text)' }}>
+ No petitions yet
+ </h3>
+ <p className="text-sm mb-6 max-w-md" style={{ color: 'var(--color-text-muted)' }}>
+ Start your first advocacy campaign. Use AI to generate a compelling petition from trending topics.
+ </p>
+ <button className="btn btn-primary" onClick={() => setShowAiModal(true)}>
+ <Sparkles size={16} />
+ AI Create Petition
+ </button>
+ </div>
+ )}
+
+ {/* Petition Cards */}
+ {!loading && sortedPetitions.length > 0 && (
+ <div className="flex flex-col gap-3">
+ {sortedPetitions.map((p) => {
+ const pct = sigPct(p.signature_count, p.signature_goal);
+ const isExpanded = expanded === p.id;
+ return (
+ <div key={p.id} className="card" style={{ overflow: 'hidden' }}>
+ {/* Top Row */}
+ <div className="flex items-start justify-between gap-4">
+ <div className="flex-1 min-w-0">
+ <div className="flex items-center gap-2 mb-1 flex-wrap">
+ <h3
+ className="text-sm font-semibold truncate cursor-pointer"
+ style={{ color: 'var(--color-text)' }}
+ onClick={() => setExpanded(isExpanded ? null : p.id)}
+ >
+ {p.title}
+ </h3>
+ <span className={`badge ${statusBadgeClass[p.status] ?? 'badge-draft'}`}>
+ {p.status}
+ </span>
+ {p.category && (
+ <span
+ className="badge"
+ style={{
+ backgroundColor: 'rgba(124, 58, 237, 0.1)',
+ color: 'var(--color-secondary)',
+ border: '1px solid rgba(124, 58, 237, 0.2)',
+ }}
+ >
+ {categoryLabel(p.category)}
+ </span>
+ )}
+ </div>
+
+ {/* Signature Progress */}
+ <div className="flex items-center gap-3 mt-2">
+ <div
+ style={{
+ flex: 1,
+ maxWidth: 200,
+ height: 6,
+ borderRadius: 3,
+ backgroundColor: 'var(--color-surface-el)',
+ overflow: 'hidden',
+ }}
+ >
+ <div
+ style={{
+ width: `${pct}%`,
+ height: '100%',
+ borderRadius: 3,
+ background: 'linear-gradient(90deg, #7c3aed, #a78bfa)',
+ transition: 'width 0.3s ease',
+ }}
+ />
+ </div>
+ <span className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
+ {p.signature_count}/{p.signature_goal} ({pct}%)
+ </span>
+ </div>
+
+ {/* Meta Row */}
+ <div className="flex items-center gap-4 mt-2">
+ {p.target && (
+ <span className="flex items-center gap-1 text-xs" style={{ color: 'var(--color-text-muted)' }}>
+ <Target size={12} /> {p.target}
+ </span>
+ )}
+ <span className="flex items-center gap-1 text-xs" style={{ color: 'var(--color-text-muted)' }}>
+ <Share2 size={12} /> {p.share_count} shares
+ </span>
+ <span className="flex items-center gap-1 text-xs" style={{ color: 'var(--color-text-muted)' }}>
+ <Calendar size={12} /> {formatDate(p.created_at)}
+ </span>
+ </div>
+ </div>
+
+ {/* Actions */}
+ <div className="flex items-center gap-1 shrink-0">
+ {p.status === 'draft' && (
+ <button className="btn btn-ghost btn-sm" onClick={() => openEdit(p)} title="Edit">
+ <Edit3 size={14} />
+ </button>
+ )}
+ <button
+ className="btn btn-sm btn-primary"
+ title="Deploy to Platforms"
+ onClick={() => {
+ setDeployPetition(p);
+ setDeployPlatforms({ moveon: true, change_org: true, we_the_people: false, custom: false });
+ setDeployResults([]);
+ setDeployCustomUrl('');
+ setShowDeployModal(true);
+ }}
+ >
+ <Send size={14} />
+ <span className="hidden sm:inline">Campaign</span>
+ </button>
+ <button
+ className="btn btn-ghost btn-sm"
+ onClick={() => setExpanded(isExpanded ? null : p.id)}
+ title={isExpanded ? 'Collapse' : 'Expand'}
+ >
+ {isExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
+ </button>
+ </div>
+ </div>
+
+ {/* Expanded Details */}
+ {isExpanded && (
+ <div
+ style={{
+ marginTop: 12,
+ paddingTop: 12,
+ borderTop: '1px solid var(--color-border)',
+ }}
+ >
+ {p.summary && (
+ <p className="text-sm mb-3" style={{ color: 'var(--color-text-secondary)' }}>
+ {p.summary}
+ </p>
+ )}
+
+ {/* Body Preview -- displayed as plain text for safety */}
+ <div
+ className="text-xs mb-3"
+ style={{
+ color: 'var(--color-text-muted)',
+ maxHeight: 120,
+ overflow: 'hidden',
+ padding: 8,
+ borderRadius: 6,
+ backgroundColor: 'var(--color-surface-el)',
+ whiteSpace: 'pre-wrap',
+ }}
+ >
+ {stripHtml(p.body_html).slice(0, 500)}
+ </div>
+
+ {/* Target Emails */}
+ {p.target_emails && p.target_emails.length > 0 && (
+ <div className="flex items-center gap-2 mb-2 flex-wrap">
+ <MailIcon size={12} style={{ color: 'var(--color-text-muted)' }} />
+ {p.target_emails.map((em, i) => (
+ <span key={i} className="text-xs" style={{
+ color: 'var(--color-text-secondary)',
+ padding: '2px 6px',
+ borderRadius: 4,
+ backgroundColor: 'var(--color-surface-el)',
+ }}>
+ {em}
+ </span>
+ ))}
+ </div>
+ )}
+
+ {/* Tags */}
+ {p.tags && p.tags.length > 0 && (
+ <div className="flex items-center gap-1 flex-wrap mb-3">
+ <Tag size={12} style={{ color: 'var(--color-text-muted)' }} />
+ {p.tags.map((t, i) => (
+ <span key={i} className="text-xs" style={{
+ color: 'var(--color-secondary)',
+ padding: '2px 8px',
+ borderRadius: 9999,
+ backgroundColor: 'rgba(124, 58, 237, 0.1)',
+ }}>
+ {t}
+ </span>
+ ))}
+ </div>
+ )}
+
+ {/* Related Grants Section */}
+ <div style={{ marginTop: 8 }}>
+ {!petitionGrants[p.id] ? (
+ <button
+ onClick={() => findRelatedGrants(p.id)}
+ disabled={loadingGrants === p.id}
+ style={{
+ display: 'flex', alignItems: 'center', gap: 5,
+ padding: '6px 14px', borderRadius: 8,
+ border: '1px solid #059669',
+ backgroundColor: 'transparent', color: '#10b981',
+ cursor: loadingGrants === p.id ? 'wait' : 'pointer',
+ fontSize: 12, fontWeight: 600,
+ }}
+ >
+ {loadingGrants === p.id
+ ? <><Loader2 size={12} style={{ animation: 'spin 1s linear infinite' }} /> Finding…</>
+ : <><DollarSign size={12} /> Find Related Grants</>
+ }
+ </button>
+ ) : petitionGrants[p.id].length > 0 ? (
+ <div>
+ <div style={{ fontSize: 11, fontWeight: 600, color: '#10b981', textTransform: 'uppercase', letterSpacing: '0.04em', marginBottom: 6 }}>
+ Related Grants ({petitionGrants[p.id].length})
+ </div>
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
+ {petitionGrants[p.id].slice(0, 5).map(g => (
+ <div key={g.id}
+ onClick={() => onViewGrant?.(g.id)}
+ style={{
+ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 10px',
+ borderRadius: 6, backgroundColor: 'var(--color-surface-el)',
+ cursor: 'pointer', transition: 'background-color 0.15s',
+ }}
+ onMouseEnter={e => { e.currentTarget.style.backgroundColor = '#059669' + '18'; }}
+ onMouseLeave={e => { e.currentTarget.style.backgroundColor = 'var(--color-surface-el)'; }}
+ >
+ <DollarSign size={12} style={{ color: '#10b981', flexShrink: 0 }} />
+ <div style={{ flex: 1, minWidth: 0 }}>
+ <span style={{ fontSize: 12, fontWeight: 500, color: 'var(--color-text)' }}>{g.title}</span>
+ <span style={{ fontSize: 11, color: 'var(--color-text-muted)', marginLeft: 6 }}>
+ {g.funder} · {formatAmount(g.amount_min, g.amount_max)}
+ </span>
+ </div>
+ {g.ai_fit_score != null && (
+ <span style={{ fontSize: 10, fontWeight: 600, color: '#f59e0b' }}>
+ {Math.round(g.ai_fit_score * 100)}%
+ </span>
+ )}
+ <ExternalLink size={11} style={{ color: 'var(--color-text-muted)', flexShrink: 0 }} />
+ </div>
+ ))}
+ </div>
+ </div>
+ ) : (
+ <div style={{ fontSize: 12, color: 'var(--color-text-muted)', fontStyle: 'italic' }}>
+ No related grants found yet. Try discovering grants first.
+ </div>
+ )}
+ </div>
+ </div>
+ )}
+ </div>
+ );
+ })}
+ </div>
+ )}
+
+ {/* ─── AI Create Modal ─────────────────────────────────────── */}
+ {showAiModal && (
+ <div
+ style={{
+ position: 'fixed', inset: 0, zIndex: 50,
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
+ backgroundColor: 'rgba(0,0,0,0.6)',
+ backdropFilter: 'blur(4px)',
+ }}
+ onClick={(e) => { if (e.target === e.currentTarget) setShowAiModal(false); }}
+ >
+ <div
+ style={{
+ width: '100%', maxWidth: 600, maxHeight: '90vh', overflow: 'auto',
+ backgroundColor: 'var(--color-surface)',
+ border: '1px solid var(--color-border)',
+ borderRadius: 12, padding: 24,
+ }}
+ >
+ <div className="flex items-center justify-between mb-4">
+ <h3 className="text-base font-semibold" style={{ color: 'var(--color-text)' }}>
+ <Sparkles size={16} style={{ display: 'inline', marginRight: 6, color: 'var(--color-primary)' }} />
+ AI Create Petition
+ </h3>
+ <button className="btn btn-ghost btn-sm" onClick={() => setShowAiModal(false)}>
+ <X size={16} />
+ </button>
+ </div>
+
+ {!aiResult ? (
+ <div className="flex flex-col gap-4">
+ <div>
+ <label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>
+ Topic *
+ </label>
+ <input
+ className="input"
+ placeholder="e.g., Student loan interest rate reduction for federal loans"
+ value={aiTopic}
+ onChange={(e) => setAiTopic(e.target.value)}
+ />
+ </div>
+ <div>
+ <label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>
+ Target (optional)
+ </label>
+ <input
+ className="input"
+ placeholder="e.g., Department of Education"
+ value={aiTarget}
+ onChange={(e) => setAiTarget(e.target.value)}
+ />
+ </div>
+ <div className="grid grid-cols-2 gap-4">
+ <div>
+ <label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>
+ Tone
+ </label>
+ <select
+ className="input"
+ value={aiTone}
+ onChange={(e) => setAiTone(e.target.value)}
+ >
+ <option value="urgent">Urgent</option>
+ <option value="formal">Formal</option>
+ <option value="passionate">Passionate</option>
+ <option value="grassroots">Grassroots</option>
+ </select>
+ </div>
+ <div>
+ <label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>
+ Category
+ </label>
+ <select
+ className="input"
+ value={aiCategory}
+ onChange={(e) => setAiCategory(e.target.value)}
+ >
+ <option value="debt_relief">Debt Relief</option>
+ <option value="policy">Policy</option>
+ <option value="education_access">Education Access</option>
+ <option value="consumer_protection">Consumer Protection</option>
+ <option value="federal_budget">Federal Budget</option>
+ </select>
+ </div>
+ </div>
+ <button
+ className="btn btn-primary w-full"
+ onClick={handleAiGenerate}
+ disabled={aiGenerating || !aiTopic.trim()}
+ >
+ {aiGenerating ? (
+ <>
+ <Loader2 size={16} className="animate-spin" />
+ Generating...
+ </>
+ ) : (
+ <>
+ <Sparkles size={16} />
+ Generate Petition
+ </>
+ )}
+ </button>
+ </div>
+ ) : (
+ /* ── AI Result Preview ─── */
+ <div className="flex flex-col gap-4">
+ <div>
+ <div className="text-xs font-medium mb-1" style={{ color: 'var(--color-text-muted)' }}>Title</div>
+ <div className="text-sm font-semibold" style={{ color: 'var(--color-text)' }}>{aiResult.title}</div>
+ </div>
+ <div>
+ <div className="text-xs font-medium mb-1" style={{ color: 'var(--color-text-muted)' }}>Summary</div>
+ <div className="text-sm" style={{ color: 'var(--color-text-secondary)' }}>{aiResult.summary}</div>
+ </div>
+ <div>
+ <div className="text-xs font-medium mb-1" style={{ color: 'var(--color-text-muted)' }}>Target</div>
+ <div className="text-sm" style={{ color: 'var(--color-text-secondary)' }}>{aiResult.suggested_target}</div>
+ </div>
+ <div>
+ <div className="text-xs font-medium mb-1" style={{ color: 'var(--color-text-muted)' }}>Body Preview</div>
+ <div
+ className="text-xs"
+ style={{
+ color: 'var(--color-text-muted)',
+ maxHeight: 200, overflow: 'auto',
+ padding: 8, borderRadius: 6,
+ backgroundColor: 'var(--color-surface-el)',
+ whiteSpace: 'pre-wrap',
+ }}
+ >
+ {stripHtml(aiResult.body_html)}
+ </div>
+ </div>
+ <div className="flex items-center gap-2 flex-wrap">
+ <span className="text-xs" style={{ color: 'var(--color-text-muted)' }}>Tags:</span>
+ {aiResult.suggested_tags.map((t, i) => (
+ <span key={i} className="text-xs" style={{
+ color: 'var(--color-secondary)',
+ padding: '2px 8px', borderRadius: 9999,
+ backgroundColor: 'rgba(124, 58, 237, 0.1)',
+ }}>
+ {t}
+ </span>
+ ))}
+ </div>
+ <div className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
+ Suggested Goal: {aiResult.suggested_goal} signatures
+ </div>
+ <div className="flex items-center gap-3">
+ <button className="btn btn-primary flex-1" onClick={handleSaveDraft} disabled={savingDraft}>
+ {savingDraft ? (
+ <><Loader2 size={16} className="animate-spin" /> Saving...</>
+ ) : (
+ <><Plus size={16} /> Save as Draft</>
+ )}
+ </button>
+ <button className="btn btn-ghost" onClick={() => setAiResult(null)}>
+ Regenerate
+ </button>
+ </div>
+ </div>
+ )}
+ </div>
+ </div>
+ )}
+
+ {/* ─── Edit Modal ──────────────────────────────────────────── */}
+ {editPetition && (
+ <div
+ style={{
+ position: 'fixed', inset: 0, zIndex: 50,
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
+ backgroundColor: 'rgba(0,0,0,0.6)',
+ backdropFilter: 'blur(4px)',
+ }}
+ onClick={(e) => { if (e.target === e.currentTarget) setEditPetition(null); }}
+ >
+ <div
+ style={{
+ width: '100%', maxWidth: 500, maxHeight: '90vh', overflow: 'auto',
+ backgroundColor: 'var(--color-surface)',
+ border: '1px solid var(--color-border)',
+ borderRadius: 12, padding: 24,
+ }}
+ >
+ <div className="flex items-center justify-between mb-4">
+ <h3 className="text-base font-semibold" style={{ color: 'var(--color-text)' }}>
+ <Edit3 size={16} style={{ display: 'inline', marginRight: 6 }} />
+ Edit Petition
+ </h3>
+ <button className="btn btn-ghost btn-sm" onClick={() => setEditPetition(null)}>
+ <X size={16} />
+ </button>
+ </div>
+ <div className="flex flex-col gap-4">
+ <div>
+ <label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>Title</label>
+ <input className="input" value={editTitle} onChange={(e) => setEditTitle(e.target.value)} />
+ </div>
+ <div>
+ <label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>Summary</label>
+ <textarea
+ className="input"
+ rows={3}
+ value={editSummary}
+ onChange={(e) => setEditSummary(e.target.value)}
+ style={{ resize: 'vertical' }}
+ />
+ </div>
+ <div>
+ <label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>Target</label>
+ <input className="input" value={editTarget} onChange={(e) => setEditTarget(e.target.value)} />
+ </div>
+ <div className="grid grid-cols-2 gap-4">
+ <div>
+ <label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>Signature Goal</label>
+ <input className="input" type="number" value={editGoal} onChange={(e) => setEditGoal(Number(e.target.value))} />
+ </div>
+ <div>
+ <label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>Status</label>
+ <select className="input" value={editStatus} onChange={(e) => setEditStatus(e.target.value)}>
+ <option value="draft">Draft</option>
+ <option value="active">Active</option>
+ <option value="paused">Paused</option>
+ <option value="closed">Closed</option>
+ <option value="delivered">Delivered</option>
+ </select>
+ </div>
+ </div>
+ <button className="btn btn-primary w-full" onClick={handleSaveEdit} disabled={editSaving}>
+ {editSaving ? <><Loader2 size={16} className="animate-spin" /> Saving...</> : 'Save Changes'}
+ </button>
+ </div>
+ </div>
+ </div>
+ )}
+
+ {/* ─── Deploy Modal ──────────────────────────────────────────── */}
+ {showDeployModal && deployPetition && (
+ <div
+ style={{
+ position: 'fixed', inset: 0, zIndex: 50,
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
+ backgroundColor: 'rgba(0,0,0,0.6)',
+ backdropFilter: 'blur(4px)',
+ }}
+ onClick={(e) => { if (e.target === e.currentTarget) { setShowDeployModal(false); setDeployResults([]); } }}
+ >
+ <div
+ style={{
+ width: '100%', maxWidth: 640, maxHeight: '90vh', overflow: 'auto',
+ backgroundColor: 'var(--color-surface)',
+ border: '1px solid var(--color-border)',
+ borderRadius: 12, padding: 24,
+ }}
+ >
+ <div className="flex items-center justify-between mb-4">
+ <h3 className="text-base font-semibold" style={{ color: 'var(--color-text)' }}>
+ <Send size={16} style={{ display: 'inline', marginRight: 6, color: 'var(--color-primary)' }} />
+ Deploy to Platforms
+ </h3>
+ <button className="btn btn-ghost btn-sm" onClick={() => { setShowDeployModal(false); setDeployResults([]); }}>
+ <X size={16} />
+ </button>
+ </div>
+
+ {/* Petition Info */}
+ <div style={{ padding: '10px 14px', borderRadius: 8, backgroundColor: 'var(--color-surface-el)', marginBottom: 16 }}>
+ <div className="text-sm font-semibold" style={{ color: 'var(--color-text)' }}>{deployPetition.title}</div>
+ <div className="text-xs mt-1" style={{ color: 'var(--color-text-muted)' }}>
+ {categoryLabel(deployPetition.category)} · {deployPetition.signature_count} signatures · {deployPetition.status}
+ </div>
+ </div>
+
+ {/* Platform Checkboxes */}
+ {deployResults.length === 0 && (
+ <>
+ <div className="text-xs font-medium mb-2" style={{ color: 'var(--color-text-secondary)' }}>Select Platforms:</div>
+ <div className="flex flex-col gap-2 mb-4">
+ {DEPLOY_PLATFORMS.map(plat => (
+ <label
+ key={plat.key}
+ style={{
+ display: 'flex', alignItems: 'center', gap: 10, padding: '10px 14px',
+ borderRadius: 8, cursor: 'pointer',
+ border: `1px solid ${deployPlatforms[plat.key] ? 'var(--color-primary)' : 'var(--color-border)'}`,
+ backgroundColor: deployPlatforms[plat.key] ? 'rgba(124, 58, 237, 0.08)' : 'transparent',
+ transition: 'all 0.15s ease',
+ }}
+ >
+ <input
+ type="checkbox"
+ checked={!!deployPlatforms[plat.key]}
+ onChange={(e) => setDeployPlatforms(prev => ({ ...prev, [plat.key]: e.target.checked }))}
+ style={{ accentColor: 'var(--color-primary)', width: 16, height: 16 }}
+ />
+ <span style={{ fontSize: 18 }}>{plat.icon}</span>
+ <div style={{ flex: 1 }}>
+ <div className="text-sm font-medium" style={{ color: 'var(--color-text)' }}>{plat.name}</div>
+ {plat.url && <div className="text-xs" style={{ color: 'var(--color-text-muted)' }}>{plat.url}</div>}
+ </div>
+ </label>
+ ))}
+ {deployPlatforms.custom && (
+ <input
+ className="input mt-1"
+ placeholder="Enter custom platform URL..."
+ value={deployCustomUrl}
+ onChange={(e) => setDeployCustomUrl(e.target.value)}
+ />
+ )}
+ </div>
+
+ {/* Select/Clear Buttons */}
+ <div className="flex items-center gap-2 mb-4">
+ <button
+ className="btn btn-ghost btn-sm"
+ onClick={() => setDeployPlatforms({ moveon: true, change_org: true, we_the_people: true, custom: true })}
+ >
+ Select All
+ </button>
+ <button
+ className="btn btn-ghost btn-sm"
+ onClick={() => setDeployPlatforms({ moveon: false, change_org: false, we_the_people: false, custom: false })}
+ >
+ Clear All
+ </button>
+ </div>
+
+ {/* Deploy Button */}
+ <button
+ className="btn btn-primary w-full"
+ onClick={handleDeploy}
+ disabled={deploying || !Object.values(deployPlatforms).some(Boolean)}
+ >
+ {deploying ? (
+ <><Loader2 size={16} className="animate-spin" /> Generating...</>
+ ) : (
+ <><Send size={16} /> Generate & Deploy</>
+ )}
+ </button>
+ </>
+ )}
+
+ {/* Deploy Results */}
+ {deployResults.length > 0 && (
+ <div className="flex flex-col gap-4">
+ <div className="text-xs font-medium" style={{ color: 'var(--color-text-muted)', textTransform: 'uppercase', letterSpacing: '0.04em' }}>
+ Results ({deployResults.length} platform{deployResults.length > 1 ? 's' : ''})
+ </div>
+ {deployResults.map((r) => (
+ <div key={r.platform} style={{ borderRadius: 8, border: '1px solid var(--color-border)', overflow: 'hidden' }}>
+ <div style={{ padding: '10px 14px', backgroundColor: 'rgba(124, 58, 237, 0.08)', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
+ <div className="flex items-center gap-2">
+ <span style={{ color: '#10b981', fontSize: 14 }}>✓</span>
+ <span className="text-sm font-medium" style={{ color: 'var(--color-text)' }}>{r.platform_name}</span>
+ <span className="text-xs" style={{ color: 'var(--color-text-muted)' }}>— Content ready</span>
+ </div>
+ <button
+ className="btn btn-sm btn-primary"
+ onClick={() => window.open(r.creation_url, '_blank')}
+ >
+ <ExternalLink size={12} /> Open Platform
+ </button>
+ </div>
+ <div style={{ padding: 14 }}>
+ {Object.entries(r.formatted_content).map(([key, value]) => (
+ <div key={key} style={{ marginBottom: 8 }}>
+ <div className="flex items-center justify-between mb-1">
+ <span className="text-xs font-medium" style={{ color: 'var(--color-text-muted)', textTransform: 'capitalize' }}>
+ {key.replace(/_/g, ' ')}
+ </span>
+ <button
+ className="text-xs"
+ style={{
+ padding: '2px 8px', borderRadius: 4, cursor: 'pointer',
+ border: '1px solid var(--color-border)',
+ backgroundColor: copiedField === `${r.platform}-${key}` ? 'rgba(16, 185, 129, 0.15)' : 'transparent',
+ color: copiedField === `${r.platform}-${key}` ? '#10b981' : 'var(--color-text-muted)',
+ transition: 'all 0.15s',
+ }}
+ onClick={() => copyToClipboard(
+ Array.isArray(value) ? (value as string[]).join(', ') : String(value ?? ''),
+ `${r.platform}-${key}`
+ )}
+ >
+ {copiedField === `${r.platform}-${key}` ? 'Copied!' : 'Copy'}
+ </button>
+ </div>
+ <div
+ className="text-xs"
+ style={{
+ color: 'var(--color-text-secondary)',
+ padding: '6px 8px', borderRadius: 4,
+ backgroundColor: 'var(--color-surface-el)',
+ maxHeight: 80, overflow: 'auto',
+ whiteSpace: 'pre-wrap', wordBreak: 'break-word',
+ }}
+ >
+ {Array.isArray(value) ? (value as string[]).join(', ') : String(value ?? '—')}
+ </div>
+ </div>
+ ))}
+ </div>
+ </div>
+ ))}
+ <button
+ className="btn btn-ghost w-full"
+ onClick={() => setDeployResults([])}
+ >
+ ← Back to Platform Selection
+ </button>
+ </div>
+ )}
+ </div>
+ </div>
+ )}
+ </div>
+ );
+}
diff --git a/components/settings/SettingsTab.tsx b/components/settings/SettingsTab.tsx
new file mode 100644
index 0000000..c7da366
--- /dev/null
+++ b/components/settings/SettingsTab.tsx
@@ -0,0 +1,413 @@
+'use client';
+
+import { useState, useEffect } from 'react';
+import {
+ Save, RefreshCw, Building2, Mail, Sparkles, Database,
+ Globe, FileText, Clock, Shield, Download, Megaphone,
+ Users, Target, Settings, Share2,
+} from 'lucide-react';
+import SocialCredentialsSection from './SocialCredentialsSection';
+
+/* ─── Types ──────────────────────────────────────────────────────────────── */
+interface OrgProfile {
+ name: string;
+ website: string;
+ email: string;
+ mission: string;
+}
+
+interface PetitionDefaults {
+ default_signature_goal: number;
+ default_distribution: string;
+ auto_compliance_check: boolean;
+}
+
+interface CampaignSettings {
+ default_send_time: string;
+ email_footer: string;
+ unsubscribe_text: string;
+}
+
+interface AIConfig {
+ model: string;
+ creativity_level: number;
+ default_tone: string;
+}
+
+interface DataStats {
+ total_petitions: number;
+ total_signatures: number;
+ total_subscribers: number;
+ total_campaigns: number;
+ last_backup: string;
+}
+
+interface SettingsData {
+ org_profile: OrgProfile;
+ petition_defaults: PetitionDefaults;
+ campaign_settings: CampaignSettings;
+ ai_config: AIConfig;
+ data_stats: DataStats;
+}
+
+/* ─── Defaults ───────────────────────────────────────────────────────────── */
+const DEFAULTS: SettingsData = {
+ org_profile: {
+ name: 'Student Debt Crisis Center',
+ website: 'https://studentdebtcrisis.org',
+ email: 'info@studentdebtcrisis.org',
+ mission: 'Empowering borrowers through organized petition campaigns and advocacy for systemic student debt reform.',
+ },
+ petition_defaults: {
+ default_signature_goal: 10000,
+ default_distribution: 'email',
+ auto_compliance_check: true,
+ },
+ campaign_settings: {
+ default_send_time: '09:00',
+ email_footer: 'Student Debt Crisis Center | studentdebtcrisis.org',
+ unsubscribe_text: 'Click here to unsubscribe from future petition updates.',
+ },
+ ai_config: {
+ model: 'Gemini Flash',
+ creativity_level: 60,
+ default_tone: 'passionate',
+ },
+ data_stats: {
+ total_petitions: 0,
+ total_signatures: 0,
+ total_subscribers: 0,
+ total_campaigns: 0,
+ last_backup: new Date().toISOString(),
+ },
+};
+
+/* ─── Main Component ─────────────────────────────────────────────────────── */
+export default function SettingsTab() {
+ const [settings, setSettings] = useState<SettingsData>(DEFAULTS);
+ const [loading, setLoading] = useState(true);
+ const [saving, setSaving] = useState(false);
+ const [saveMessage, setSaveMessage] = useState('');
+
+ useEffect(() => {
+ fetchSettings();
+ }, []);
+
+ async function fetchSettings() {
+ try {
+ const res = await fetch('/api/settings');
+ if (!res.ok) throw new Error('Failed to fetch');
+ const data = await res.json();
+ const s = data.settings || {};
+ setSettings({
+ org_profile: { ...DEFAULTS.org_profile, ...(s.org_profile || {}) },
+ petition_defaults: { ...DEFAULTS.petition_defaults, ...(s.petition_defaults || {}) },
+ campaign_settings: { ...DEFAULTS.campaign_settings, ...(s.campaign_settings || {}) },
+ ai_config: { ...DEFAULTS.ai_config, ...(s.ai_config || {}) },
+ data_stats: { ...DEFAULTS.data_stats, ...(s.data_stats || {}) },
+ });
+ } catch (err) {
+ console.error('Failed to fetch settings:', err);
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ async function handleSave() {
+ setSaving(true);
+ setSaveMessage('');
+ try {
+ const res = await fetch('/api/settings', {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ org_profile: settings.org_profile,
+ petition_defaults: settings.petition_defaults,
+ campaign_settings: settings.campaign_settings,
+ ai_config: settings.ai_config,
+ }),
+ });
+ if (!res.ok) throw new Error('Failed to save');
+ setSaveMessage('Settings saved successfully');
+ setTimeout(() => setSaveMessage(''), 3000);
+ } catch (err) {
+ setSaveMessage('Failed to save settings');
+ } finally {
+ setSaving(false);
+ }
+ }
+
+ function updateOrg(field: string, value: string) {
+ setSettings((prev) => ({
+ ...prev,
+ org_profile: { ...prev.org_profile, [field]: value },
+ }));
+ }
+
+ function updatePetition(field: string, value: string | number | boolean) {
+ setSettings((prev) => ({
+ ...prev,
+ petition_defaults: { ...prev.petition_defaults, [field]: value },
+ }));
+ }
+
+ function updateCampaign(field: string, value: string) {
+ setSettings((prev) => ({
+ ...prev,
+ campaign_settings: { ...prev.campaign_settings, [field]: value },
+ }));
+ }
+
+ function updateAI(field: string, value: string | number) {
+ setSettings((prev) => ({
+ ...prev,
+ ai_config: { ...prev.ai_config, [field]: value },
+ }));
+ }
+
+ if (loading) {
+ return (
+ <div className="flex items-center justify-center py-20">
+ <RefreshCw size={20} className="animate-spin" style={{ color: 'var(--color-text-muted)' }} />
+ </div>
+ );
+ }
+
+ return (
+ <div className="p-5 space-y-5 max-w-4xl">
+ {/* Save bar */}
+ <div className="flex items-center justify-between">
+ <h2 className="text-lg font-semibold" style={{ color: 'var(--color-text)' }}>Settings</h2>
+ <div className="flex items-center gap-3">
+ {saveMessage && (
+ <span className="text-sm" style={{ color: saveMessage.includes('success') ? '#34d399' : '#f87171' }}>
+ {saveMessage}
+ </span>
+ )}
+ <button className="btn btn-primary" onClick={handleSave} disabled={saving}>
+ {saving ? (
+ <><RefreshCw size={14} className="animate-spin" /><span>Saving...</span></>
+ ) : (
+ <><Save size={14} /><span>Save Changes</span></>
+ )}
+ </button>
+ </div>
+ </div>
+
+ {/* ── Organization Profile ─────────────────────────────────────────── */}
+ <div className="card p-5">
+ <div className="flex items-center gap-2 mb-4">
+ <Building2 size={16} style={{ color: '#7c3aed' }} />
+ <h3 className="text-sm font-semibold" style={{ color: 'var(--color-text)' }}>Organization Profile</h3>
+ </div>
+
+ <div className="grid grid-cols-2 gap-4">
+ <div>
+ <label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>Organization Name</label>
+ <input className="input w-full" value={settings.org_profile.name} onChange={(e) => updateOrg('name', e.target.value)} />
+ </div>
+ <div>
+ <label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>Website URL</label>
+ <div className="relative">
+ <Globe size={13} className="absolute left-2.5 top-1/2 -translate-y-1/2" style={{ color: 'var(--color-text-muted)' }} />
+ <input className="input w-full pl-8" value={settings.org_profile.website} onChange={(e) => updateOrg('website', e.target.value)} />
+ </div>
+ </div>
+ <div className="col-span-2">
+ <label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>Contact Email</label>
+ <div className="relative">
+ <Mail size={13} className="absolute left-2.5 top-1/2 -translate-y-1/2" style={{ color: 'var(--color-text-muted)' }} />
+ <input className="input w-full pl-8" value={settings.org_profile.email} onChange={(e) => updateOrg('email', e.target.value)} />
+ </div>
+ </div>
+ </div>
+
+ <div className="mt-4">
+ <label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>Mission Statement</label>
+ <textarea className="input w-full" rows={3} value={settings.org_profile.mission} onChange={(e) => updateOrg('mission', e.target.value)} />
+ </div>
+ </div>
+
+ {/* ── Petition Defaults ────────────────────────────────────────────── */}
+ <div className="card p-5">
+ <div className="flex items-center gap-2 mb-4">
+ <Megaphone size={16} style={{ color: '#f59e0b' }} />
+ <h3 className="text-sm font-semibold" style={{ color: 'var(--color-text)' }}>Petition Defaults</h3>
+ </div>
+
+ <div className="grid grid-cols-2 gap-4">
+ <div>
+ <label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>Default Signature Goal</label>
+ <div className="relative">
+ <Target size={13} className="absolute left-2.5 top-1/2 -translate-y-1/2" style={{ color: 'var(--color-text-muted)' }} />
+ <input
+ type="number"
+ className="input w-full pl-8"
+ value={settings.petition_defaults.default_signature_goal}
+ onChange={(e) => updatePetition('default_signature_goal', parseInt(e.target.value) || 0)}
+ />
+ </div>
+ </div>
+ <div>
+ <label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>Default Distribution</label>
+ <select
+ className="input w-full"
+ value={settings.petition_defaults.default_distribution}
+ onChange={(e) => updatePetition('default_distribution', e.target.value)}
+ >
+ <option value="email">Email</option>
+ <option value="social">Social Media</option>
+ <option value="both">Both</option>
+ </select>
+ </div>
+ </div>
+
+ <div className="mt-4">
+ <label className="flex items-center gap-2 cursor-pointer">
+ <input
+ type="checkbox"
+ checked={settings.petition_defaults.auto_compliance_check}
+ onChange={(e) => updatePetition('auto_compliance_check', e.target.checked)}
+ />
+ <span className="text-sm" style={{ color: 'var(--color-text-secondary)' }}>
+ <Shield size={12} className="inline mr-1" style={{ verticalAlign: 'middle', color: '#10b981' }} />
+ Auto-compliance check on new petitions
+ </span>
+ </label>
+ </div>
+ </div>
+
+ {/* ── Campaign Settings ────────────────────────────────────────────── */}
+ <div className="card p-5">
+ <div className="flex items-center gap-2 mb-4">
+ <Mail size={16} style={{ color: '#06b6d4' }} />
+ <h3 className="text-sm font-semibold" style={{ color: 'var(--color-text)' }}>Campaign Settings</h3>
+ </div>
+
+ <div className="grid grid-cols-2 gap-4">
+ <div>
+ <label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>Default Send Time</label>
+ <input
+ type="time"
+ className="input w-full"
+ value={settings.campaign_settings.default_send_time}
+ onChange={(e) => updateCampaign('default_send_time', e.target.value)}
+ />
+ </div>
+ <div>
+ <label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>Email Footer</label>
+ <input className="input w-full" value={settings.campaign_settings.email_footer} onChange={(e) => updateCampaign('email_footer', e.target.value)} />
+ </div>
+ </div>
+
+ <div className="mt-4">
+ <label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>Unsubscribe Text</label>
+ <textarea
+ className="input w-full"
+ rows={2}
+ value={settings.campaign_settings.unsubscribe_text}
+ onChange={(e) => updateCampaign('unsubscribe_text', e.target.value)}
+ />
+ </div>
+ </div>
+
+ {/* ── AI Configuration ─────────────────────────────────────────────── */}
+ <div className="card p-5">
+ <div className="flex items-center gap-2 mb-4">
+ <Sparkles size={16} style={{ color: '#f59e0b' }} />
+ <h3 className="text-sm font-semibold" style={{ color: 'var(--color-text)' }}>AI Configuration</h3>
+ </div>
+
+ <div className="grid grid-cols-2 gap-4">
+ <div>
+ <label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>AI Model</label>
+ <input className="input w-full" value={settings.ai_config.model} readOnly style={{ opacity: 0.7, cursor: 'not-allowed' }} />
+ </div>
+ <div>
+ <label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>Default Tone</label>
+ <select className="input w-full" value={settings.ai_config.default_tone} onChange={(e) => updateAI('default_tone', e.target.value)}>
+ <option value="formal">Formal</option>
+ <option value="passionate">Passionate</option>
+ <option value="urgent">Urgent</option>
+ <option value="conversational">Conversational</option>
+ </select>
+ </div>
+ </div>
+
+ <div className="mt-4">
+ <label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>
+ Creativity Level: {settings.ai_config.creativity_level}%
+ </label>
+ <input
+ type="range"
+ min={0}
+ max={100}
+ value={settings.ai_config.creativity_level}
+ onChange={(e) => updateAI('creativity_level', parseInt(e.target.value))}
+ className="w-full"
+ style={{ accentColor: 'var(--color-primary)' }}
+ />
+ <div className="flex justify-between text-xs mt-1" style={{ color: 'var(--color-text-muted)' }}>
+ <span>Conservative</span>
+ <span>Creative</span>
+ </div>
+ </div>
+ </div>
+
+ {/* ── Data Stats ───────────────────────────────────────────────────── */}
+ <div className="card p-5">
+ <div className="flex items-center gap-2 mb-4">
+ <Database size={16} style={{ color: '#10b981' }} />
+ <h3 className="text-sm font-semibold" style={{ color: 'var(--color-text)' }}>Data Management</h3>
+ </div>
+
+ <div className="grid grid-cols-4 gap-3 mb-4">
+ {[
+ { label: 'Petitions', value: settings.data_stats.total_petitions, icon: Megaphone },
+ { label: 'Signatures', value: settings.data_stats.total_signatures, icon: FileText },
+ { label: 'Subscribers', value: settings.data_stats.total_subscribers, icon: Users },
+ { label: 'Campaigns', value: settings.data_stats.total_campaigns, icon: Mail },
+ ].map((stat) => (
+ <div
+ key={stat.label}
+ className="rounded-lg p-3 text-center"
+ style={{ backgroundColor: 'var(--color-surface-el)', border: '1px solid var(--color-border)' }}
+ >
+ <stat.icon size={16} className="mx-auto mb-1" style={{ color: 'var(--color-text-muted)' }} />
+ <p className="text-lg font-bold" style={{ color: 'var(--color-text)' }}>{stat.value}</p>
+ <p className="text-xs" style={{ color: 'var(--color-text-muted)' }}>{stat.label}</p>
+ </div>
+ ))}
+ </div>
+
+ <div className="flex items-center justify-between">
+ <div className="flex items-center gap-2 text-xs" style={{ color: 'var(--color-text-muted)' }}>
+ <Clock size={12} />
+ <span>
+ Last backup: {new Date(settings.data_stats.last_backup).toLocaleString('en-US', {
+ timeZone: 'America/Los_Angeles', month: 'short', day: 'numeric',
+ hour: 'numeric', minute: '2-digit', hour12: true,
+ })} PT
+ </span>
+ </div>
+ <button className="btn btn-ghost btn-sm">
+ <Download size={13} />
+ <span>Export All Data</span>
+ </button>
+ </div>
+ </div>
+
+ {/* ── Social Platform Credentials ───────────────────────────────────── */}
+ <div className="card p-5">
+ <div className="flex items-center gap-2 mb-1">
+ <Share2 size={16} style={{ color: '#8b5cf6' }} />
+ <h3 className="text-sm font-semibold" style={{ color: 'var(--color-text)' }}>Social Platform Credentials</h3>
+ </div>
+ <p className="text-xs mb-4" style={{ color: 'var(--color-text-muted)' }}>
+ Configure API credentials for each platform. Credentials are saved to the agent servers and take effect immediately on save.
+ </p>
+ <SocialCredentialsSection />
+ </div>
+ </div>
+ );
+}
diff --git a/components/settings/SocialCredentialsSection.tsx b/components/settings/SocialCredentialsSection.tsx
new file mode 100644
index 0000000..ebe3cc0
--- /dev/null
+++ b/components/settings/SocialCredentialsSection.tsx
@@ -0,0 +1,420 @@
+'use client';
+
+import { useState, useEffect } from 'react';
+import { Eye, EyeOff, Save, RefreshCw, CheckCircle2, XCircle, AlertTriangle, ChevronDown, ChevronRight, ExternalLink } from 'lucide-react';
+
+/* ─── Platform definitions ──────────────────────────────────────────────── */
+interface FieldDef {
+ key: string;
+ label: string;
+ placeholder: string;
+ help?: string;
+}
+
+interface PlatformDef {
+ id: string;
+ label: string;
+ color: string;
+ icon: string;
+ docsUrl: string;
+ instructions: { step: string; detail: string }[];
+ fields: FieldDef[];
+ note?: string;
+}
+
+const PLATFORMS: PlatformDef[] = [
+ {
+ id: 'twitter',
+ label: 'X / Twitter',
+ color: '#1DA1F2',
+ icon: '𝕏',
+ docsUrl: 'https://developer.twitter.com/en/portal/dashboard',
+ instructions: [
+ { step: 'Go to developer.twitter.com', detail: 'Sign in with your Twitter/X account and open the Developer Portal.' },
+ { step: 'Create a Project + App', detail: 'Click "+ New Project", name it, then create an App inside it. Choose "Production" environment.' },
+ { step: 'Set app permissions', detail: 'In App Settings → User authentication settings, enable OAuth 1.0a with Read & Write permissions.' },
+ { step: 'Get API Key & Secret', detail: 'In "Keys and Tokens" → Consumer Keys section. Copy API Key and API Key Secret.' },
+ { step: 'Get Access Token & Secret', detail: 'Still in Keys and Tokens → Authentication Tokens section. Click "Generate" to get Access Token and Access Token Secret.' },
+ { step: 'Get Bearer Token', detail: 'In the same Keys and Tokens page, copy the Bearer Token (used for read-only operations).' },
+ ],
+ fields: [
+ { key: 'TWITTER_API_KEY', label: 'API Key (Consumer Key)', placeholder: 'xxxxxxxxxxxxxxxxxxxxxxxxx' },
+ { key: 'TWITTER_API_SECRET', label: 'API Key Secret (Consumer Secret)', placeholder: 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' },
+ { key: 'TWITTER_ACCESS_TOKEN', label: 'Access Token', placeholder: '000000000-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' },
+ { key: 'TWITTER_ACCESS_SECRET', label: 'Access Token Secret', placeholder: 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' },
+ { key: 'TWITTER_BEARER_TOKEN', label: 'Bearer Token (for reading)', placeholder: 'AAAAAAAAAAAAAAAAAAAAAxxxxxxxxxxxxxxxx', help: 'Optional — only needed for search/discover features' },
+ ],
+ },
+ {
+ id: 'reddit',
+ label: 'Reddit',
+ color: '#FF4500',
+ icon: '🤖',
+ docsUrl: 'https://www.reddit.com/prefs/apps',
+ instructions: [
+ { step: 'Go to reddit.com/prefs/apps', detail: 'Log in to the Reddit account that will post. Go to Preferences → Apps.' },
+ { step: 'Create an app', detail: 'Click "create another app..." at the bottom. Choose type "script" (for personal use).' },
+ { step: 'Fill in the form', detail: 'Name: anything (e.g. "PetitionBot"). Redirect URI: http://localhost:8080. Click "create app".' },
+ { step: 'Get Client ID', detail: 'The Client ID is the short string directly under the app name (below "personal use script").' },
+ { step: 'Get Client Secret', detail: 'The Client Secret is labeled "secret" in the app details.' },
+ { step: 'Username & Password', detail: 'These are the Reddit account credentials that will be posting. The account must be at least 30 days old to post in most subreddits.' },
+ ],
+ fields: [
+ { key: 'REDDIT_CLIENT_ID', label: 'Client ID', placeholder: 'xxxxxxxxxxxxxx' },
+ { key: 'REDDIT_CLIENT_SECRET', label: 'Client Secret', placeholder: 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' },
+ { key: 'REDDIT_USERNAME', label: 'Reddit Username', placeholder: 'u/your_username (without u/)' },
+ { key: 'REDDIT_PASSWORD', label: 'Reddit Password', placeholder: 'your reddit account password' },
+ ],
+ },
+ {
+ id: 'discord',
+ label: 'Discord',
+ color: '#5865F2',
+ icon: '💬',
+ docsUrl: 'https://discord.com/developers/applications',
+ instructions: [
+ { step: 'Go to discord.com/developers/applications', detail: 'Sign in with a Discord account that has admin access to your server.' },
+ { step: 'New Application', detail: 'Click "New Application", give it a name (e.g. "PetitionBot"). Click Create.' },
+ { step: 'Create a Bot', detail: 'In the left sidebar, click "Bot". Click "Add Bot" then confirm. This creates the bot user.' },
+ { step: 'Get Bot Token', detail: 'Click "Reset Token" to reveal the bot token. Copy it — you won\'t see it again without resetting.' },
+ { step: 'Set bot permissions', detail: 'Under "Privileged Gateway Intents", enable Message Content Intent. Also enable Server Members Intent.' },
+ { step: 'Invite bot to server', detail: 'Go to OAuth2 → URL Generator. Select scopes: bot. Permissions: Send Messages, Embed Links. Copy and open the URL to add bot to your server.' },
+ { step: 'Get Channel ID', detail: 'In Discord, enable Developer Mode (User Settings → Advanced). Right-click any channel → "Copy Channel ID".' },
+ ],
+ fields: [
+ { key: 'DISCORD_BOT_TOKEN', label: 'Bot Token', placeholder: 'MTxxxxxxxxxx.xxxxxx.xxxxxxxxxxxxxxxxxxxxxxxxxxx' },
+ { key: 'DISCORD_DEFAULT_CHANNEL_ID', label: 'Default Channel ID', placeholder: '000000000000000000', help: 'Right-click a channel in Discord → Copy Channel ID (requires Developer Mode enabled)' },
+ ],
+ },
+ {
+ id: 'bluesky',
+ label: 'Bluesky',
+ color: '#0085FF',
+ icon: '🦋',
+ docsUrl: 'https://bsky.app/settings/app-passwords',
+ instructions: [
+ { step: 'Go to bsky.app/settings/app-passwords', detail: 'Sign in to Bluesky and navigate to Settings → App Passwords.' },
+ { step: 'Add App Password', detail: 'Click "+ Add App Password". Give it a name like "PetitionBot". Click "Create App Password".' },
+ { step: 'Copy the password', detail: 'The app password is shown only once. Copy it immediately — it looks like: xxxx-xxxx-xxxx-xxxx' },
+ { step: 'Your handle', detail: 'Your Bluesky handle is shown at bsky.app/profile. Format: yourname.bsky.social (include .bsky.social)' },
+ ],
+ fields: [
+ { key: 'BSKY_HANDLE', label: 'Bluesky Handle', placeholder: 'yourname.bsky.social', help: 'Include the full domain, e.g. yourorg.bsky.social' },
+ { key: 'BSKY_PASSWORD', label: 'App Password', placeholder: 'xxxx-xxxx-xxxx-xxxx', help: 'Use an App Password, NOT your main account password' },
+ ],
+ note: 'Use an App Password — never your main account password. App Passwords have limited scope and can be revoked independently.',
+ },
+];
+
+/* ─── Helper: masked input ────────────────────────────────────────────────── */
+function SecretInput({
+ value, onChange, placeholder,
+}: { value: string; onChange: (v: string) => void; placeholder: string }) {
+ const [show, setShow] = useState(false);
+ const isMasked = value.includes('*');
+
+ return (
+ <div className="relative">
+ <input
+ type={show ? 'text' : 'password'}
+ className="input w-full pr-8 font-mono text-xs"
+ value={value}
+ onChange={(e) => onChange(e.target.value)}
+ placeholder={placeholder}
+ style={{ letterSpacing: show ? 'normal' : undefined }}
+ onFocus={() => { if (isMasked) onChange(''); }}
+ />
+ <button
+ type="button"
+ onClick={() => setShow((s) => !s)}
+ className="absolute right-2 top-1/2 -translate-y-1/2"
+ style={{ color: 'var(--color-text-muted)' }}
+ >
+ {show ? <EyeOff size={13} /> : <Eye size={13} />}
+ </button>
+ </div>
+ );
+}
+
+/* ─── Platform card ──────────────────────────────────────────────────────── */
+function PlatformCard({
+ platform,
+ initialValues,
+ onSaved,
+}: {
+ platform: PlatformDef;
+ initialValues: Record<string, string>;
+ onSaved: () => void;
+}) {
+ const [expanded, setExpanded] = useState(false);
+ const [showInstructions, setShowInstructions] = useState(false);
+ const [values, setValues] = useState<Record<string, string>>(() => {
+ const v: Record<string, string> = {};
+ for (const f of platform.fields) v[f.key] = initialValues[f.key] || '';
+ return v;
+ });
+ const [saving, setSaving] = useState(false);
+ const [status, setStatus] = useState<'idle' | 'saved' | 'error'>('idle');
+ const [statusMsg, setStatusMsg] = useState('');
+
+ const isConfigured = platform.fields.some((f) => values[f.key] && !values[f.key].includes('*'));
+ const hasAnyMasked = platform.fields.some((f) => initialValues[f.key]?.includes('*'));
+
+ async function handleSave() {
+ setSaving(true);
+ try {
+ const res = await fetch('/api/settings/credentials', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ platform: platform.id, credentials: values }),
+ });
+ const data = await res.json();
+ if (!res.ok) throw new Error(data.error || 'Save failed');
+ setStatus('saved');
+ setStatusMsg(data.restarted ? '✓ Saved & agent restarted' : '✓ Saved (agent restart failed — restart manually)');
+ onSaved();
+ } catch (err) {
+ setStatus('error');
+ setStatusMsg((err as Error).message);
+ } finally {
+ setSaving(false);
+ setTimeout(() => setStatus('idle'), 4000);
+ }
+ }
+
+ return (
+ <div
+ className="rounded-lg overflow-hidden"
+ style={{ border: `1px solid ${expanded ? platform.color + '44' : 'var(--color-border)'}`, background: 'var(--color-surface)' }}
+ >
+ {/* Header */}
+ <button
+ className="w-full flex items-center gap-3 px-4 py-3 text-left"
+ style={{ background: expanded ? `${platform.color}0d` : 'transparent' }}
+ onClick={() => setExpanded((e) => !e)}
+ >
+ <span className="text-base w-6 text-center">{platform.icon}</span>
+ <span className="text-sm font-semibold flex-1" style={{ color: 'var(--color-text)' }}>{platform.label}</span>
+
+ {/* Status badge */}
+ {hasAnyMasked ? (
+ <span className="flex items-center gap-1 text-xs px-2 py-0.5 rounded-full" style={{ background: 'rgba(34,197,94,0.12)', color: '#22c55e' }}>
+ <CheckCircle2 size={10} /> Configured
+ </span>
+ ) : (
+ <span className="flex items-center gap-1 text-xs px-2 py-0.5 rounded-full" style={{ background: 'rgba(245,158,11,0.12)', color: '#f59e0b' }}>
+ <AlertTriangle size={10} /> Not configured
+ </span>
+ )}
+
+ {expanded ? <ChevronDown size={14} style={{ color: 'var(--color-text-muted)' }} /> : <ChevronRight size={14} style={{ color: 'var(--color-text-muted)' }} />}
+ </button>
+
+ {expanded && (
+ <div className="px-4 pb-4 space-y-4" style={{ borderTop: `1px solid ${platform.color}22` }}>
+
+ {/* Instructions toggle */}
+ <div className="mt-3">
+ <button
+ className="flex items-center gap-2 text-xs font-medium"
+ style={{ color: platform.color }}
+ onClick={() => setShowInstructions((s) => !s)}
+ >
+ {showInstructions ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
+ How to get these credentials
+ <a
+ href={platform.docsUrl}
+ target="_blank"
+ rel="noreferrer"
+ onClick={(e) => e.stopPropagation()}
+ className="ml-1 flex items-center gap-1 opacity-60 hover:opacity-100"
+ >
+ <ExternalLink size={10} />
+ Open {platform.label}
+ </a>
+ </button>
+
+ {showInstructions && (
+ <ol className="mt-2 space-y-2">
+ {platform.instructions.map((ins, i) => (
+ <li key={i} className="flex gap-3 text-xs" style={{ color: 'var(--color-text-secondary)' }}>
+ <span
+ className="flex-shrink-0 w-5 h-5 rounded-full flex items-center justify-center text-xs font-bold mt-0.5"
+ style={{ background: `${platform.color}22`, color: platform.color }}
+ >
+ {i + 1}
+ </span>
+ <div>
+ <span className="font-semibold" style={{ color: 'var(--color-text)' }}>{ins.step}</span>
+ <span className="text-xs ml-1" style={{ color: 'var(--color-text-muted)' }}>— {ins.detail}</span>
+ </div>
+ </li>
+ ))}
+ </ol>
+ )}
+ </div>
+
+ {/* Platform note */}
+ {platform.note && (
+ <div
+ className="text-xs px-3 py-2 rounded"
+ style={{ background: `${platform.color}10`, color: platform.color, border: `1px solid ${platform.color}30` }}
+ >
+ ⚠ {platform.note}
+ </div>
+ )}
+
+ {/* Credential fields */}
+ <div className="space-y-3">
+ {platform.fields.map((field) => (
+ <div key={field.key}>
+ <label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>
+ {field.label}
+ </label>
+ <SecretInput
+ value={values[field.key] || ''}
+ onChange={(v) => setValues((prev) => ({ ...prev, [field.key]: v }))}
+ placeholder={field.placeholder}
+ />
+ {field.help && (
+ <p className="text-xs mt-1" style={{ color: 'var(--color-text-muted)' }}>{field.help}</p>
+ )}
+ </div>
+ ))}
+ </div>
+
+ {/* Save button + status */}
+ <div className="flex items-center gap-3 pt-1">
+ <button
+ className="btn btn-primary btn-sm"
+ onClick={handleSave}
+ disabled={saving}
+ style={{ background: platform.color, borderColor: platform.color }}
+ >
+ {saving ? (
+ <><RefreshCw size={12} className="animate-spin" /><span>Saving...</span></>
+ ) : (
+ <><Save size={12} /><span>Save & Apply to Agent</span></>
+ )}
+ </button>
+ {status !== 'idle' && (
+ <span
+ className="text-xs flex items-center gap-1"
+ style={{ color: status === 'saved' ? '#22c55e' : '#ef4444' }}
+ >
+ {status === 'saved' ? <CheckCircle2 size={12} /> : <XCircle size={12} />}
+ {statusMsg}
+ </span>
+ )}
+ </div>
+ </div>
+ )}
+ </div>
+ );
+}
+
+/* ─── MoveOn card (no credentials — instructions only) ───────────────────── */
+function MoveOnCard() {
+ const [expanded, setExpanded] = useState(false);
+
+ return (
+ <div
+ className="rounded-lg overflow-hidden"
+ style={{ border: `1px solid ${expanded ? '#E0313144' : 'var(--color-border)'}`, background: 'var(--color-surface)' }}
+ >
+ <button
+ className="w-full flex items-center gap-3 px-4 py-3 text-left"
+ style={{ background: expanded ? '#E031310d' : 'transparent' }}
+ onClick={() => setExpanded((e) => !e)}
+ >
+ <span className="text-base w-6 text-center">📋</span>
+ <span className="text-sm font-semibold flex-1" style={{ color: 'var(--color-text)' }}>MoveOn</span>
+ <span className="flex items-center gap-1 text-xs px-2 py-0.5 rounded-full" style={{ background: 'rgba(107,114,128,0.15)', color: '#6b7280' }}>
+ Manual only
+ </span>
+ {expanded ? <ChevronDown size={14} style={{ color: 'var(--color-text-muted)' }} /> : <ChevronRight size={14} style={{ color: 'var(--color-text-muted)' }} />}
+ </button>
+
+ {expanded && (
+ <div className="px-4 pb-4 space-y-3" style={{ borderTop: '1px solid #E0313122' }}>
+ <div className="mt-3 text-xs rounded-lg p-3 space-y-2" style={{ background: 'rgba(239,68,68,0.06)', border: '1px solid rgba(239,68,68,0.18)', color: '#f87171' }}>
+ <p className="font-semibold">MoveOn does not have a public petition creation API.</p>
+ <p style={{ color: 'var(--color-text-muted)' }}>
+ The MoveOn agent prepares a pre-filled petition URL with your title and target organization. You must open the link and complete submission manually, or Playwright browser automation can be added to automate the form.
+ </p>
+ </div>
+
+ <div>
+ <p className="text-xs font-semibold mb-2" style={{ color: 'var(--color-text-secondary)' }}>How the MoveOn workflow works:</p>
+ <ol className="space-y-2">
+ {[
+ { s: 'Generate petition', d: 'The agent generates your petition title, body, and target organization.' },
+ { s: 'Get the pre-filled URL', d: 'After dispatch, the agent returns a MoveOn URL like: sign.moveon.org/create/?title=...&target=...' },
+ { s: 'Open the URL', d: 'Click the link — it opens MoveOn with the title and target pre-filled.' },
+ { s: 'Complete the form', d: 'Paste in the petition body, select category, add your name/email, then submit.' },
+ { s: 'Share the petition link', d: 'After creation, copy the petition URL and use it in Twitter/Reddit/Discord posts.' },
+ ].map((item, i) => (
+ <li key={i} className="flex gap-3 text-xs" style={{ color: 'var(--color-text-secondary)' }}>
+ <span className="flex-shrink-0 w-5 h-5 rounded-full flex items-center justify-center text-xs font-bold mt-0.5" style={{ background: 'rgba(224,49,49,0.15)', color: '#f87171' }}>
+ {i + 1}
+ </span>
+ <div>
+ <span className="font-semibold" style={{ color: 'var(--color-text)' }}>{item.s}</span>
+ <span className="ml-1" style={{ color: 'var(--color-text-muted)' }}>— {item.d}</span>
+ </div>
+ </li>
+ ))}
+ </ol>
+ </div>
+ </div>
+ )}
+ </div>
+ );
+}
+
+/* ─── Main export ─────────────────────────────────────────────────────────── */
+export default function SocialCredentialsSection() {
+ const [maskedValues, setMaskedValues] = useState<Record<string, Record<string, string>>>({});
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ fetchCreds();
+ }, []);
+
+ async function fetchCreds() {
+ try {
+ const res = await fetch('/api/settings/credentials');
+ if (!res.ok) throw new Error('Failed');
+ const data = await res.json();
+ setMaskedValues(data.credentials || {});
+ } catch {
+ // silent — show empty fields
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ if (loading) {
+ return (
+ <div className="space-y-2">
+ {[1, 2, 3, 4].map((i) => (
+ <div key={i} className="skeleton h-12 rounded-lg" />
+ ))}
+ </div>
+ );
+ }
+
+ return (
+ <div className="space-y-2">
+ {PLATFORMS.map((p) => (
+ <PlatformCard
+ key={p.id}
+ platform={p}
+ initialValues={maskedValues[p.id] || {}}
+ onSaved={fetchCreds}
+ />
+ ))}
+ <MoveOnCard />
+ </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/sources/SourcesTab.tsx b/components/sources/SourcesTab.tsx
new file mode 100644
index 0000000..698ecea
--- /dev/null
+++ b/components/sources/SourcesTab.tsx
@@ -0,0 +1,304 @@
+'use client';
+
+import { useState, useEffect } from 'react';
+import { Loader2, Radio, ExternalLink, Edit2, Check, X, Trash2, Plus, AlertCircle } from 'lucide-react';
+
+const S = {
+ bg:'#0E0E10', surface:'#141419', surfaceEl:'#1C1C24', border:'#2A2A35',
+ cyan:'#00F0FF', cyanDim:'rgba(0,240,255,0.12)', green:'#00C389',
+ red:'#FF5C5C', orange:'#F7931A', yellow:'#FBBF24', blue:'#3B82F6',
+ text:'#E0E0E0', textDim:'#A8A8A8', textMuted:'#6B7280',
+ fontHead:"'Space Grotesk', system-ui, sans-serif",
+ fontMono:"'JetBrains Mono', monospace",
+ rSm:'6px', rMd:'10px',
+};
+
+const STATUS_COLOR: Record<string,string> = {
+ healthy:'#00C389', stale:'#FBBF24', error:'#F7931A', failing:'#FF5C5C',
+ dead:'#6B7280', never_fetched:'#374151',
+};
+
+const CAT_COLORS: Record<string,string> = {
+ advocacy:'#A855F7', blog:'#3B82F6', economy:'#F7931A', government:'#64748B',
+ local_tv:'#EC4899', national_tv:'#EF4444', newspaper:'#14B8A6',
+ tech:'#00F0FF', health:'#00C389', social:'#8B5CF6', wire:'#F59E0B',
+ substack:'#06B6D4', magazine:'#F97316',
+};
+
+const ALL_CATS = ['advocacy','blog','economy','government','health','local_tv','magazine','national_tv','newspaper','social','substack','tech','wire'];
+
+function catColor(cat:string){return CAT_COLORS[cat]||S.textMuted;}
+
+interface Feed {
+ id:string; name:string; url:string; category:string; is_active:boolean;
+ health_status:string; article_count:number; error_count:number;
+ last_error:string|null; last_fetched_at:string|null; avg_articles_per_day:number|null;
+}
+interface EditState{id:string;name:string;url:string;category:string;}
+
+export default function SourcesTab() {
+ const [feeds,setFeeds]=useState<Feed[]>([]);
+ const [loading,setLoading]=useState(true);
+ const [filterCat,setFilterCat]=useState('all');
+ const [filterStatus,setFilterStatus]=useState('all');
+ const [search,setSearch]=useState('');
+ const [editing,setEditing]=useState<EditState|null>(null);
+ const [saving,setSaving]=useState<string|null>(null);
+ const [addMode,setAddMode]=useState(false);
+ const [newFeed,setNewFeed]=useState({name:'',url:'',category:'blog'});
+ const [err,setErr]=useState<string|null>(null);
+ const [collapsed,setCollapsed]=useState<Record<string,boolean>>({});
+
+ async function load(){
+ setLoading(true);
+ try{const r=await fetch('/api/pulse/feeds');if(r.ok)setFeeds(await r.json());}catch{}
+ setLoading(false);
+ }
+ useEffect(()=>{load();},[]);
+
+ const categories=[...new Set(feeds.map(f=>f.category))].sort();
+ const filtered=feeds.filter(f=>{
+ if(filterCat!=='all'&&f.category!==filterCat)return false;
+ const st=f.health_status||'never_fetched';
+ if(filterStatus!=='all'&&st!==filterStatus)return false;
+ if(search){const q=search.toLowerCase();if(!f.name.toLowerCase().includes(q)&&!f.url.toLowerCase().includes(q))return false;}
+ return true;
+ });
+ const grouped:Record<string,Feed[]>={};
+ for(const f of filtered){if(!grouped[f.category])grouped[f.category]=[];grouped[f.category].push(f);}
+
+ async function saveEdit(){
+ if(!editing)return;
+ setSaving(editing.id);
+ try{
+ const r=await fetch('/api/pulse/feeds/'+editing.id,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:editing.name,url:editing.url,category:editing.category})});
+ if(!r.ok)throw new Error((await r.json()).error);
+ await load();setEditing(null);
+ }catch(e:any){setErr(e.message);}
+ setSaving(null);
+ }
+ async function toggleActive(f:Feed){
+ setSaving(f.id);
+ try{await fetch('/api/pulse/feeds/'+f.id,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({is_active:!f.is_active})});await load();}catch{}
+ setSaving(null);
+ }
+ async function deleteFeed(id:string){
+ if(!confirm('Delete this feed?'))return;
+ try{await fetch('/api/pulse/feeds/'+id,{method:'DELETE'});await load();}catch{}
+ }
+ async function addFeed(){
+ if(!newFeed.name||!newFeed.url)return;
+ try{
+ const r=await fetch('/api/pulse/feeds',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(newFeed)});
+ if(!r.ok)throw new Error((await r.json()).error);
+ setNewFeed({name:'',url:'',category:'blog'});setAddMode(false);await load();
+ }catch(e:any){setErr(e.message);}
+ }
+
+ const totalActive=feeds.filter(f=>f.is_active).length;
+ const totalHealthy=feeds.filter(f=>f.health_status==='healthy').length;
+ const totalError=feeds.filter(f=>f.health_status==='error'||f.health_status==='failing').length;
+
+ if(loading)return(
+ <div style={{padding:40,display:'flex',alignItems:'center',gap:10,color:S.textMuted,fontFamily:S.fontMono}}>
+ <Loader2 size={16} style={{animation:'spin 1s linear infinite'}}/>Loading sources…
+ </div>
+ );
+
+ return(
+ <div style={{fontFamily:S.fontHead,color:S.text,paddingBottom:40}}>
+ {/* Header */}
+ <div style={{padding:'20px 24px 16px',display:'flex',alignItems:'flex-start',gap:16,flexWrap:'wrap',borderBottom:`1px solid ${S.border}`}}>
+ <div style={{flex:1}}>
+ <div style={{display:'flex',alignItems:'center',gap:10}}>
+ <Radio size={18} color={S.cyan}/>
+ <span style={{fontSize:18,fontWeight:700}}>Sources</span>
+ <span style={{fontFamily:S.fontMono,fontSize:11,color:S.cyan,background:S.cyanDim,padding:'2px 8px',borderRadius:4,border:`1px solid ${S.cyan}44`}}>{feeds.length} feeds</span>
+ </div>
+ <div style={{display:'flex',gap:20,marginTop:6,fontSize:12,color:S.textMuted}}>
+ <span>Active: <strong style={{color:S.green}}>{totalActive}</strong></span>
+ <span>Healthy: <strong style={{color:S.green}}>{totalHealthy}</strong></span>
+ <span>Error: <strong style={{color:S.red}}>{totalError}</strong></span>
+ <span>Inactive: <strong style={{color:S.textMuted}}>{feeds.filter(f=>!f.is_active).length}</strong></span>
+ </div>
+ </div>
+ <button onClick={()=>{setAddMode(!addMode);setErr(null);}}
+ style={{display:'flex',alignItems:'center',gap:6,padding:'7px 14px',background:addMode?S.cyanDim:'transparent',border:`1px solid ${addMode?S.cyan:S.border}`,borderRadius:S.rSm,color:addMode?S.cyan:S.textDim,cursor:'pointer',fontSize:12,fontWeight:600}}>
+ <Plus size={14}/> Add Feed
+ </button>
+ </div>
+
+ {/* Error */}
+ {err&&(
+ <div style={{margin:'12px 24px',padding:'8px 12px',background:'rgba(255,92,92,0.1)',border:`1px solid ${S.red}44`,borderRadius:S.rSm,fontSize:12,color:S.red,display:'flex',gap:8,alignItems:'center'}}>
+ <AlertCircle size={13}/>{err}
+ <button onClick={()=>setErr(null)} style={{marginLeft:'auto',background:'none',border:'none',color:S.red,cursor:'pointer'}}><X size={13}/></button>
+ </div>
+ )}
+
+ {/* Add Form */}
+ {addMode&&(
+ <div style={{margin:'12px 24px',padding:16,background:S.surfaceEl,border:`1px solid ${S.cyan}44`,borderRadius:S.rMd}}>
+ <div style={{display:'grid',gridTemplateColumns:'180px 1fr 140px auto',gap:10,alignItems:'end'}}>
+ <div>
+ <div style={{fontSize:10,color:S.textMuted,marginBottom:4,textTransform:'uppercase',letterSpacing:'0.5px'}}>Name</div>
+ <input value={newFeed.name} onChange={e=>setNewFeed(p=>({...p,name:e.target.value}))} placeholder="Source name"
+ style={{width:'100%',padding:'6px 10px',background:S.surface,border:`1px solid ${S.border}`,borderRadius:S.rSm,color:S.text,fontSize:12,outline:'none',boxSizing:'border-box' as any}}/>
+ </div>
+ <div>
+ <div style={{fontSize:10,color:S.textMuted,marginBottom:4,textTransform:'uppercase',letterSpacing:'0.5px'}}>RSS URL</div>
+ <input value={newFeed.url} onChange={e=>setNewFeed(p=>({...p,url:e.target.value}))} placeholder="https://example.com/feed.rss"
+ style={{width:'100%',padding:'6px 10px',background:S.surface,border:`1px solid ${S.border}`,borderRadius:S.rSm,color:S.cyan,fontSize:11,fontFamily:S.fontMono,outline:'none',boxSizing:'border-box' as any}}/>
+ </div>
+ <div>
+ <div style={{fontSize:10,color:S.textMuted,marginBottom:4,textTransform:'uppercase',letterSpacing:'0.5px'}}>Category</div>
+ <select value={newFeed.category} onChange={e=>setNewFeed(p=>({...p,category:e.target.value}))}
+ style={{width:'100%',padding:'6px 10px',background:S.surface,border:`1px solid ${S.border}`,borderRadius:S.rSm,color:S.text,fontSize:12,outline:'none',boxSizing:'border-box' as any}}>
+ {ALL_CATS.map(c=><option key={c} value={c}>{c}</option>)}
+ </select>
+ </div>
+ <div style={{display:'flex',gap:6}}>
+ <button onClick={addFeed} disabled={!newFeed.name||!newFeed.url}
+ style={{padding:'6px 14px',background:S.cyan,color:S.bg,border:'none',borderRadius:S.rSm,fontSize:12,fontWeight:700,cursor:'pointer',opacity:(!newFeed.name||!newFeed.url)?0.4:1}}>Add</button>
+ <button onClick={()=>setAddMode(false)}
+ style={{padding:'6px 10px',background:'transparent',border:`1px solid ${S.border}`,borderRadius:S.rSm,color:S.textMuted,cursor:'pointer',fontSize:12}}>Cancel</button>
+ </div>
+ </div>
+ </div>
+ )}
+
+ {/* Status Filters */}
+ <div style={{padding:'10px 24px',display:'flex',gap:8,flexWrap:'wrap',borderBottom:`1px solid ${S.border}`,alignItems:'center'}}>
+ {(['all','healthy','stale','error','failing','dead','never_fetched'] as const).map(s=>{
+ const col=s==='all'?S.cyan:(STATUS_COLOR[s]||S.textMuted);
+ const cnt=s==='all'?feeds.length:feeds.filter(f=>(f.health_status||'never_fetched')===s).length;
+ if(s!=='all'&&cnt===0)return null;
+ return(
+ <button key={s} onClick={()=>setFilterStatus(s)}
+ style={{padding:'3px 10px',borderRadius:20,fontSize:11,cursor:'pointer',fontFamily:S.fontMono,border:filterStatus===s?`1px solid ${col}`:`1px solid ${S.border}`,background:filterStatus===s?col+'22':'transparent',color:filterStatus===s?col:S.textMuted}}>
+ {s.replace(/_/g,' ')} ({cnt})
+ </button>
+ );
+ })}
+ <input value={search} onChange={e=>setSearch(e.target.value)} placeholder="Search name or URL…"
+ style={{marginLeft:'auto',padding:'4px 10px',background:S.surfaceEl,border:`1px solid ${S.border}`,borderRadius:S.rSm,color:S.text,fontSize:12,fontFamily:S.fontMono,outline:'none',width:220}}/>
+ </div>
+
+ {/* Category Pills */}
+ <div style={{padding:'10px 24px',display:'flex',gap:6,flexWrap:'wrap',borderBottom:`1px solid ${S.border}`}}>
+ <button onClick={()=>setFilterCat('all')}
+ style={{padding:'3px 10px',borderRadius:12,fontSize:11,cursor:'pointer',border:filterCat==='all'?`1px solid ${S.cyan}`:`1px solid ${S.border}`,background:filterCat==='all'?S.cyanDim:'transparent',color:filterCat==='all'?S.cyan:S.textMuted}}>
+ All
+ </button>
+ {categories.map(cat=>{
+ const col=catColor(cat);
+ const cnt=feeds.filter(f=>f.category===cat).length;
+ return(
+ <button key={cat} onClick={()=>setFilterCat(filterCat===cat?'all':cat)}
+ style={{padding:'3px 10px',borderRadius:12,fontSize:11,cursor:'pointer',border:filterCat===cat?`1px solid ${col}`:`1px solid ${S.border}`,background:filterCat===cat?col+'22':'transparent',color:filterCat===cat?col:S.textMuted}}>
+ {cat} ({cnt})
+ </button>
+ );
+ })}
+ </div>
+
+ {/* Feed Groups */}
+ <div style={{padding:'16px 24px 0'}}>
+ {Object.keys(grouped).sort().map(cat=>{
+ const isCol=!!collapsed[cat];
+ const col=catColor(cat);
+ const catFeeds=grouped[cat];
+ return(
+ <div key={cat} style={{marginBottom:12,background:S.surface,border:`1px solid ${S.border}`,borderRadius:S.rMd,overflow:'hidden'}}>
+ <div onClick={()=>setCollapsed(p=>({...p,[cat]:!p[cat]}))}
+ style={{padding:'10px 16px',display:'flex',alignItems:'center',gap:10,cursor:'pointer',userSelect:'none',borderBottom:isCol?'none':`1px solid ${S.border}`}}>
+ <div style={{width:8,height:8,borderRadius:'50%',background:col,flexShrink:0}}/>
+ <span style={{fontSize:13,fontWeight:700,flex:1,textTransform:'capitalize'}}>{cat}</span>
+ <span style={{fontFamily:S.fontMono,fontSize:11,color:S.textMuted}}>{catFeeds.length} feeds</span>
+ <span style={{fontSize:10,color:S.textMuted,display:'inline-block',transform:isCol?'rotate(-90deg)':'rotate(0deg)',transition:'transform 0.2s'}}>▼</span>
+ </div>
+ {!isCol&&(
+ <div>
+ {catFeeds.map((f,idx)=>{
+ const isEdit=editing?.id===f.id;
+ const isSave=saving===f.id;
+ const sCol=STATUS_COLOR[f.health_status||'never_fetched']||S.textMuted;
+ return(
+ <div key={f.id} style={{padding:'10px 16px',background:isEdit?S.surfaceEl:'transparent',borderBottom:idx<catFeeds.length-1?`1px solid ${S.border}22`:'none'}}>
+ {isEdit?(
+ <div style={{display:'grid',gridTemplateColumns:'180px 1fr 140px auto',gap:8,alignItems:'center'}}>
+ <input value={editing.name} onChange={e=>setEditing(p=>p&&({...p,name:e.target.value}))}
+ style={{padding:'5px 8px',background:S.bg,border:`1px solid ${S.cyan}66`,borderRadius:S.rSm,color:S.text,fontSize:12,outline:'none'}}/>
+ <input value={editing.url} onChange={e=>setEditing(p=>p&&({...p,url:e.target.value}))}
+ style={{padding:'5px 8px',background:S.bg,border:`1px solid ${S.cyan}66`,borderRadius:S.rSm,color:S.cyan,fontSize:11,fontFamily:S.fontMono,outline:'none'}}/>
+ <select value={editing.category} onChange={e=>setEditing(p=>p&&({...p,category:e.target.value}))}
+ style={{padding:'5px 8px',background:S.bg,border:`1px solid ${S.border}`,borderRadius:S.rSm,color:S.text,fontSize:11,outline:'none'}}>
+ {ALL_CATS.map(c=><option key={c} value={c}>{c}</option>)}
+ </select>
+ <div style={{display:'flex',gap:6}}>
+ <button onClick={saveEdit} disabled={isSave}
+ style={{padding:'5px 10px',background:S.green+'22',border:`1px solid ${S.green}`,borderRadius:S.rSm,color:S.green,cursor:'pointer',display:'flex',alignItems:'center'}}>
+ {isSave?<Loader2 size={12} style={{animation:'spin 1s linear infinite'}}/>:<Check size={12}/>}
+ </button>
+ <button onClick={()=>setEditing(null)}
+ style={{padding:'5px 10px',background:'transparent',border:`1px solid ${S.border}`,borderRadius:S.rSm,color:S.textMuted,cursor:'pointer',display:'flex',alignItems:'center'}}>
+ <X size={12}/>
+ </button>
+ </div>
+ </div>
+ ):(
+ <div style={{display:'grid',gridTemplateColumns:'20px 1fr 2fr auto',gap:12,alignItems:'center'}}>
+ <div style={{display:'flex',flexDirection:'column',alignItems:'center',gap:4}}>
+ <div style={{width:7,height:7,borderRadius:'50%',background:sCol}} title={f.health_status||'never_fetched'}/>
+ <div onClick={()=>toggleActive(f)} title={f.is_active?'Active — click to disable':'Inactive — click to enable'}
+ style={{width:20,height:11,borderRadius:6,background:f.is_active?S.green+'44':S.border,cursor:'pointer',position:'relative',transition:'background 0.2s'}}>
+ <div style={{position:'absolute',top:2,right:f.is_active?2:undefined,left:f.is_active?undefined:2,width:7,height:7,borderRadius:'50%',background:f.is_active?S.green:S.textMuted,transition:'all 0.2s'}}/>
+ </div>
+ </div>
+ <div style={{overflow:'hidden',minWidth:0}}>
+ <div style={{fontSize:13,fontWeight:600,overflow:'hidden',whiteSpace:'nowrap',textOverflow:'ellipsis'}}>{f.name}</div>
+ <div style={{display:'flex',gap:12,marginTop:2,fontSize:10,color:S.textMuted,fontFamily:S.fontMono}}>
+ <span>{(f.article_count??0).toLocaleString()} arts</span>
+ {(f.error_count??0)>0&&<span style={{color:S.red}}>{f.error_count} err</span>}
+ {f.avg_articles_per_day!=null&&<span>{f.avg_articles_per_day.toFixed(1)}/day</span>}
+ </div>
+ {f.last_error&&<div style={{fontSize:10,color:S.red,marginTop:2,overflow:'hidden',whiteSpace:'nowrap',textOverflow:'ellipsis'}}>{f.last_error}</div>}
+ </div>
+ <div style={{overflow:'hidden',minWidth:0}}>
+ <a href={f.url} target="_blank" rel="noopener noreferrer"
+ style={{fontSize:11,color:S.cyan,fontFamily:S.fontMono,textDecoration:'none',display:'flex',alignItems:'center',gap:4,overflow:'hidden',whiteSpace:'nowrap',textOverflow:'ellipsis'}} title={f.url}>
+ <ExternalLink size={10} style={{flexShrink:0}}/>{f.url}
+ </a>
+ {f.last_fetched_at&&(
+ <div style={{fontSize:10,color:S.textMuted,marginTop:2}}>
+ Last: {new Date(f.last_fetched_at).toLocaleString('en-US',{timeZone:'America/Los_Angeles',month:'short',day:'numeric',hour:'numeric',minute:'2-digit',hour12:true})} PT
+ </div>
+ )}
+ </div>
+ <div style={{display:'flex',gap:5}}>
+ <button onClick={()=>{setEditing({id:f.id,name:f.name,url:f.url,category:f.category});setErr(null);}} title="Edit"
+ style={{padding:'5px 8px',background:'transparent',border:`1px solid ${S.border}`,borderRadius:S.rSm,color:S.textMuted,cursor:'pointer',display:'flex',alignItems:'center'}}>
+ <Edit2 size={12}/>
+ </button>
+ <button onClick={()=>deleteFeed(f.id)} title="Delete"
+ style={{padding:'5px 8px',background:'transparent',border:`1px solid ${S.border}`,borderRadius:S.rSm,color:S.textMuted,cursor:'pointer',display:'flex',alignItems:'center'}}>
+ <Trash2 size={12}/>
+ </button>
+ </div>
+ </div>
+ )}
+ </div>
+ );
+ })}
+ </div>
+ )}
+ </div>
+ );
+ })}
+ {filtered.length===0&&(
+ <div style={{padding:40,textAlign:'center',color:S.textMuted,fontFamily:S.fontMono,fontSize:13}}>No feeds match the current filters.</div>
+ )}
+ </div>
+ </div>
+ );
+}
diff --git a/components/subscribers/SubscribersTab.tsx b/components/subscribers/SubscribersTab.tsx
new file mode 100644
index 0000000..d4ec265
--- /dev/null
+++ b/components/subscribers/SubscribersTab.tsx
@@ -0,0 +1,446 @@
+'use client';
+
+import { useState, useEffect, useCallback } from 'react';
+import { SkeletonList } from '../Skeleton';
+import {
+ Users, UserPlus, Search, Loader2, X, Copy,
+ Tag, ToggleLeft, ToggleRight, UserMinus,
+} from 'lucide-react';
+import { useToast } from '../ToastProvider';
+import { useDebounce } from '@/hooks/useDebounce';
+import SortDropdown from '../shared/SortDropdown';
+import { useClientSort, SortConfig } from '@/hooks/useClientSort';
+
+const SUB_SORT_OPTIONS = [
+ { value: 'email_asc', label: 'Email A-Z' },
+ { value: 'date_desc', label: 'Newest First' },
+ { value: 'date_asc', label: 'Oldest First' },
+ { value: 'active_desc', label: 'Active First' },
+];
+
+const SUB_SORT_CONFIGS: Record<string, SortConfig> = {
+ email_asc: { key: 'email', direction: 'asc', type: 'string' },
+ date_desc: { key: 'created_at', direction: 'desc', type: 'date' },
+ date_asc: { key: 'created_at', direction: 'asc', type: 'date' },
+ active_desc: { key: 'is_active', direction: 'desc', type: 'string' },
+};
+
+/* ─── Types ─────────────────────────────────────────────────── */
+interface Subscriber {
+ id: string;
+ email: string;
+ name: string | null;
+ zip_code: string | null;
+ source: string;
+ petition_ids: string[] | null;
+ tags: string[] | null;
+ is_active: boolean;
+ unsubscribed_at: string | null;
+ created_at: string;
+}
+
+interface Stats {
+ total: number;
+ active: number;
+ inactive: number;
+ by_source: Record<string, number>;
+}
+
+/* ─── Component ─────────────────────────────────────────────── */
+export default function SubscribersTab() {
+ const { addToast } = useToast();
+ const [subscribers, setSubscribers] = useState<Subscriber[]>([]);
+ const [loading, setLoading] = useState(true);
+ const [stats, setStats] = useState<Stats>({ total: 0, active: 0, inactive: 0, by_source: {} });
+ const [search, setSearch] = useState('');
+ const debouncedSearch = useDebounce(search, 300);
+ const [sourceFilter, setSourceFilter] = useState('all');
+ const [sortBy, setSortBy] = useState('date_desc');
+ const sortedSubscribers = useClientSort(subscribers, sortBy, SUB_SORT_CONFIGS);
+
+ // Add modal
+ const [showAdd, setShowAdd] = useState(false);
+ const [addEmail, setAddEmail] = useState('');
+ const [addName, setAddName] = useState('');
+ const [addZip, setAddZip] = useState('');
+ const [addSaving, setAddSaving] = useState(false);
+
+ // Selected for bulk
+ const [selected, setSelected] = useState<Set<string>>(new Set());
+
+ /* ── Fetch ──────────────────────────────────────────────────── */
+ const fetchSubscribers = useCallback(async () => {
+ setLoading(true);
+ try {
+ const params = new URLSearchParams();
+ if (debouncedSearch) params.set('search', debouncedSearch);
+ if (sourceFilter !== 'all') params.set('source', sourceFilter);
+ const res = await fetch(`/api/subscribers?${params.toString()}`);
+ if (res.ok) {
+ const data = await res.json();
+ setSubscribers(data.rows || []);
+ setStats(data.stats || { total: 0, active: 0, inactive: 0, by_source: {} });
+ }
+ } catch (err) {
+ console.error('Fetch subscribers error:', err);
+ } finally {
+ setLoading(false);
+ }
+ }, [debouncedSearch, sourceFilter]);
+
+ useEffect(() => { fetchSubscribers(); }, [fetchSubscribers]);
+
+ /* ── Escape key handler ───────────────────────────────────────── */
+ useEffect(() => {
+ if (!showAdd) return;
+ const handleKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setShowAdd(false); };
+ window.addEventListener('keydown', handleKey);
+ return () => window.removeEventListener('keydown', handleKey);
+ }, [showAdd]);
+
+ /* ── Add Subscriber ─────────────────────────────────────────── */
+ async function handleAdd() {
+ if (!addEmail.trim()) return;
+ setAddSaving(true);
+ try {
+ const res = await fetch('/api/subscribers', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ email: addEmail, name: addName || null, zip_code: addZip || null, source: 'manual' }),
+ });
+ if (res.ok) {
+ setShowAdd(false);
+ setAddEmail('');
+ setAddName('');
+ setAddZip('');
+ fetchSubscribers();
+ addToast('Subscriber added', 'success');
+ } else {
+ addToast('Failed to add subscriber', 'error');
+ }
+ } catch (err) {
+ console.error('Add subscriber error:', err);
+ addToast('Failed to add subscriber', 'error');
+ } finally {
+ setAddSaving(false);
+ }
+ }
+
+ /* ── Toggle Active ──────────────────────────────────────────── */
+ async function toggleActive(sub: Subscriber) {
+ try {
+ if (sub.is_active) {
+ // Unsubscribe
+ await fetch(`/api/subscribers?id=${sub.id}`, { method: 'DELETE' });
+ } else {
+ // Reactivate
+ await fetch('/api/subscribers', {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ id: sub.id, is_active: true }),
+ });
+ }
+ fetchSubscribers();
+ addToast(sub.is_active ? 'Subscriber deactivated' : 'Subscriber reactivated', 'info');
+ } catch (err) {
+ console.error('Toggle active error:', err);
+ addToast('Toggle failed', 'error');
+ }
+ }
+
+ /* ── Unsubscribe ────────────────────────────────────────────── */
+ async function handleUnsubscribe(id: string) {
+ if (!confirm('Unsubscribe this subscriber?')) return;
+ try {
+ await fetch(`/api/subscribers?id=${id}`, { method: 'DELETE' });
+ fetchSubscribers();
+ addToast('Subscriber unsubscribed', 'info');
+ } catch (err) {
+ console.error('Unsubscribe error:', err);
+ addToast('Unsubscribe failed', 'error');
+ }
+ }
+
+ /* ── Bulk Copy Emails ───────────────────────────────────────── */
+ function handleCopyEmails() {
+ const emails = subscribers
+ .filter((s) => selected.has(s.id))
+ .map((s) => s.email)
+ .join(', ');
+ navigator.clipboard.writeText(emails);
+ addToast(`${selected.size} email(s) copied`, 'success');
+ }
+
+ /* ── Selection ──────────────────────────────────────────────── */
+ function toggleSelected(id: string) {
+ const next = new Set(selected);
+ if (next.has(id)) next.delete(id);
+ else next.add(id);
+ setSelected(next);
+ }
+
+ function toggleAll() {
+ if (selected.size === subscribers.length) {
+ setSelected(new Set());
+ } else {
+ setSelected(new Set(subscribers.map((s) => s.id)));
+ }
+ }
+
+ /* ── Helpers ────────────────────────────────────────────────── */
+ const sourceLabels: string[] = ['all', ...Object.keys(stats.by_source)];
+
+ const sourceBadgeColor: Record<string, string> = {
+ manual: '#a78bfa',
+ petition: '#7c3aed',
+ signup: '#22c55e',
+ import: '#3b82f6',
+ };
+
+ const formatDate = (d: string) => {
+ return new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
+ };
+
+ /* ─── Render ────────────────────────────────────────────────── */
+ return (
+ <div style={{ padding: '24px' }}>
+ {/* Header */}
+ <div className="flex items-center justify-between mb-6">
+ <div>
+ <h2 className="text-lg font-semibold" style={{ color: 'var(--color-text)' }}>Subscribers</h2>
+ <p className="text-sm mt-1" style={{ color: 'var(--color-text-muted)' }}>
+ Manage your email list across all petitions
+ </p>
+ </div>
+ <div className="flex items-center gap-2">
+ {selected.size > 0 && (
+ <button className="btn btn-secondary btn-sm" onClick={handleCopyEmails} title="Copy selected emails">
+ <Copy size={14} /> Copy {selected.size} Emails
+ </button>
+ )}
+ <button className="btn btn-primary" onClick={() => setShowAdd(true)}>
+ <UserPlus size={16} />
+ Add Subscriber
+ </button>
+ </div>
+ </div>
+
+ {/* Stats Row */}
+ <div className="grid grid-cols-1 sm:grid-cols-4 gap-4 mb-6">
+ <div className="card">
+ <div className="text-2xl font-bold" style={{ color: 'var(--color-primary)' }}>{stats.total}</div>
+ <div className="text-xs mt-1" style={{ color: 'var(--color-text-muted)' }}>Total Subscribers</div>
+ </div>
+ <div className="card">
+ <div className="text-2xl font-bold" style={{ color: 'var(--color-success)' }}>{stats.active}</div>
+ <div className="text-xs mt-1" style={{ color: 'var(--color-text-muted)' }}>Active</div>
+ </div>
+ <div className="card">
+ <div className="text-2xl font-bold" style={{ color: 'var(--color-error)' }}>{stats.inactive}</div>
+ <div className="text-xs mt-1" style={{ color: 'var(--color-text-muted)' }}>Inactive</div>
+ </div>
+ <div className="card">
+ <div className="flex items-center gap-2 flex-wrap">
+ {Object.entries(stats.by_source).map(([src, cnt]) => (
+ <span key={src} className="text-xs" style={{ color: 'var(--color-text-secondary)' }}>
+ {src}: <strong>{cnt}</strong>
+ </span>
+ ))}
+ {Object.keys(stats.by_source).length === 0 && (
+ <span className="text-xs" style={{ color: 'var(--color-text-muted)' }}>No sources</span>
+ )}
+ </div>
+ <div className="text-xs mt-1" style={{ color: 'var(--color-text-muted)' }}>By Source</div>
+ </div>
+ </div>
+
+ {/* Sort + Search + Filters */}
+ <div className="flex items-center gap-3 mb-6 flex-wrap">
+ <SortDropdown options={SUB_SORT_OPTIONS} value={sortBy} onChange={setSortBy} />
+ <div className="flex-1 relative" style={{ minWidth: 200 }}>
+ <Search
+ size={16}
+ style={{ position: 'absolute', left: 12, top: '50%', transform: 'translateY(-50%)', color: 'var(--color-text-muted)' }}
+ />
+ <input
+ className="input"
+ placeholder="Search by email or name..."
+ style={{ paddingLeft: 36 }}
+ value={search}
+ onChange={(e) => setSearch(e.target.value)}
+ />
+ </div>
+ <div className="flex items-center gap-1 flex-wrap">
+ {sourceLabels.map((s) => (
+ <button
+ key={s}
+ onClick={() => setSourceFilter(s)}
+ className={`btn btn-sm ${sourceFilter === s ? 'btn-primary' : 'btn-ghost'}`}
+ style={{ textTransform: 'capitalize' }}
+ >
+ {s === 'all' ? 'All Sources' : s}
+ </button>
+ ))}
+ </div>
+ </div>
+
+ {/* Loading */}
+ {loading && (
+ <SkeletonList count={4} />
+ )}
+
+ {/* Empty State */}
+ {!loading && subscribers.length === 0 && (
+ <div className="card flex flex-col items-center justify-center py-16" style={{ textAlign: 'center' }}>
+ <div
+ className="w-16 h-16 rounded-2xl flex items-center justify-center mb-4"
+ style={{
+ background: 'linear-gradient(135deg, rgba(124, 58, 237, 0.15), rgba(167, 139, 250, 0.1))',
+ border: '1px solid rgba(124, 58, 237, 0.2)',
+ }}
+ >
+ <Users size={28} style={{ color: 'var(--color-primary)' }} />
+ </div>
+ <h3 className="text-base font-semibold mb-2" style={{ color: 'var(--color-text)' }}>No subscribers yet</h3>
+ <p className="text-sm mb-6 max-w-md" style={{ color: 'var(--color-text-muted)' }}>
+ Subscribers are added when people sign petitions or you add them manually.
+ </p>
+ <button className="btn btn-primary" onClick={() => setShowAdd(true)}>
+ <UserPlus size={16} /> Add Manually
+ </button>
+ </div>
+ )}
+
+ {/* Subscriber Table */}
+ {!loading && subscribers.length > 0 && (
+ <div className="card" style={{ padding: 0, overflow: 'hidden' }}>
+ <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
+ <thead>
+ <tr style={{ borderBottom: '1px solid var(--color-border)' }}>
+ <th style={{ padding: '10px 12px', textAlign: 'left' }}>
+ <input
+ type="checkbox"
+ checked={selected.size === subscribers.length && subscribers.length > 0}
+ onChange={toggleAll}
+ style={{ accentColor: 'var(--color-primary)' }}
+ />
+ </th>
+ <th style={{ padding: '10px 12px', textAlign: 'left', color: 'var(--color-text-muted)', fontWeight: 600, fontSize: 11, textTransform: 'uppercase', letterSpacing: '0.04em' }}>Email</th>
+ <th style={{ padding: '10px 12px', textAlign: 'left', color: 'var(--color-text-muted)', fontWeight: 600, fontSize: 11, textTransform: 'uppercase', letterSpacing: '0.04em' }}>Name</th>
+ <th style={{ padding: '10px 12px', textAlign: 'left', color: 'var(--color-text-muted)', fontWeight: 600, fontSize: 11, textTransform: 'uppercase', letterSpacing: '0.04em' }}>Zip</th>
+ <th style={{ padding: '10px 12px', textAlign: 'left', color: 'var(--color-text-muted)', fontWeight: 600, fontSize: 11, textTransform: 'uppercase', letterSpacing: '0.04em' }}>Source</th>
+ <th style={{ padding: '10px 12px', textAlign: 'center', color: 'var(--color-text-muted)', fontWeight: 600, fontSize: 11, textTransform: 'uppercase', letterSpacing: '0.04em' }}>Active</th>
+ <th style={{ padding: '10px 12px', textAlign: 'left', color: 'var(--color-text-muted)', fontWeight: 600, fontSize: 11, textTransform: 'uppercase', letterSpacing: '0.04em' }}>Petitions</th>
+ <th style={{ padding: '10px 12px', textAlign: 'left', color: 'var(--color-text-muted)', fontWeight: 600, fontSize: 11, textTransform: 'uppercase', letterSpacing: '0.04em' }}>Joined</th>
+ <th style={{ padding: '10px 12px', textAlign: 'center', color: 'var(--color-text-muted)', fontWeight: 600, fontSize: 11, textTransform: 'uppercase', letterSpacing: '0.04em' }}>Actions</th>
+ </tr>
+ </thead>
+ <tbody>
+ {sortedSubscribers.map((s) => {
+ const srcColor = sourceBadgeColor[s.source] || 'var(--color-text-muted)';
+ return (
+ <tr
+ key={s.id}
+ style={{
+ borderBottom: '1px solid var(--color-border)',
+ backgroundColor: selected.has(s.id) ? 'rgba(124, 58, 237, 0.05)' : 'transparent',
+ }}
+ >
+ <td style={{ padding: '8px 12px' }}>
+ <input
+ type="checkbox"
+ checked={selected.has(s.id)}
+ onChange={() => toggleSelected(s.id)}
+ style={{ accentColor: 'var(--color-primary)' }}
+ />
+ </td>
+ <td style={{ padding: '8px 12px', color: 'var(--color-text)' }}>{s.email}</td>
+ <td style={{ padding: '8px 12px', color: 'var(--color-text-secondary)' }}>{s.name || '--'}</td>
+ <td style={{ padding: '8px 12px', color: 'var(--color-text-muted)' }}>{s.zip_code || '--'}</td>
+ <td style={{ padding: '8px 12px' }}>
+ <span
+ className="badge"
+ style={{
+ backgroundColor: `${srcColor}20`,
+ color: srcColor,
+ border: `1px solid ${srcColor}40`,
+ }}
+ >
+ {s.source}
+ </span>
+ </td>
+ <td style={{ padding: '8px 12px', textAlign: 'center' }}>
+ <button
+ className="btn btn-ghost btn-sm"
+ onClick={() => toggleActive(s)}
+ style={{ color: s.is_active ? 'var(--color-success)' : 'var(--color-error)' }}
+ title={s.is_active ? 'Active - click to deactivate' : 'Inactive - click to reactivate'}
+ >
+ {s.is_active ? <ToggleRight size={18} /> : <ToggleLeft size={18} />}
+ </button>
+ </td>
+ <td style={{ padding: '8px 12px', color: 'var(--color-text-muted)' }}>
+ {s.petition_ids?.length || 0}
+ </td>
+ <td style={{ padding: '8px 12px', color: 'var(--color-text-muted)', fontSize: 12 }}>
+ {formatDate(s.created_at)}
+ </td>
+ <td style={{ padding: '8px 12px', textAlign: 'center' }}>
+ <button
+ className="btn btn-ghost btn-sm"
+ onClick={() => handleUnsubscribe(s.id)}
+ style={{ color: 'var(--color-error)' }}
+ title="Unsubscribe"
+ >
+ <UserMinus size={14} />
+ </button>
+ </td>
+ </tr>
+ );
+ })}
+ </tbody>
+ </table>
+ </div>
+ )}
+
+ {/* ─── Add Subscriber Modal ─────────────────────────────────── */}
+ {showAdd && (
+ <div
+ style={{
+ position: 'fixed', inset: 0, zIndex: 50,
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
+ backgroundColor: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(4px)',
+ }}
+ onClick={(e) => { if (e.target === e.currentTarget) setShowAdd(false); }}
+ >
+ <div style={{
+ width: '100%', maxWidth: 420, backgroundColor: 'var(--color-surface)',
+ border: '1px solid var(--color-border)', borderRadius: 12, padding: 24,
+ }}>
+ <div className="flex items-center justify-between mb-4">
+ <h3 className="text-base font-semibold" style={{ color: 'var(--color-text)' }}>Add Subscriber</h3>
+ <button className="btn btn-ghost btn-sm" onClick={() => setShowAdd(false)}><X size={16} /></button>
+ </div>
+ <div className="flex flex-col gap-4">
+ <div>
+ <label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>Email *</label>
+ <input className="input" type="email" value={addEmail} onChange={(e) => setAddEmail(e.target.value)} placeholder="email@example.com" />
+ </div>
+ <div>
+ <label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>Name</label>
+ <input className="input" value={addName} onChange={(e) => setAddName(e.target.value)} placeholder="Full name" />
+ </div>
+ <div>
+ <label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>Zip Code</label>
+ <input className="input" value={addZip} onChange={(e) => setAddZip(e.target.value)} placeholder="12345" />
+ </div>
+ <button className="btn btn-primary w-full" onClick={handleAdd} disabled={addSaving || !addEmail.trim()}>
+ {addSaving ? <><Loader2 size={16} className="animate-spin" /> Adding...</> : <><UserPlus size={16} /> Add Subscriber</>}
+ </button>
+ </div>
+ </div>
+ </div>
+ )}
+ </div>
+ );
+}
diff --git a/components/templates/TemplatesTab.tsx b/components/templates/TemplatesTab.tsx
new file mode 100644
index 0000000..cb28d4a
--- /dev/null
+++ b/components/templates/TemplatesTab.tsx
@@ -0,0 +1,395 @@
+'use client';
+
+import { useState, useEffect, useCallback } from 'react';
+import { SkeletonList } from '../Skeleton';
+import {
+ FileText, Plus, Star, Loader2, X, Sparkles,
+ Tag, Copy, Megaphone,
+} from 'lucide-react';
+
+/* ─── Types ─────────────────────────────────────────────────── */
+interface Template {
+ id: string;
+ title: string;
+ category: string | null;
+ body_html: string;
+ body_text: string | null;
+ tags: string[] | null;
+ usage_count: number;
+ is_featured: boolean;
+ created_at: string;
+}
+
+function stripHtml(html: string): string {
+ return html.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
+}
+
+/* ─── Component ─────────────────────────────────────────────── */
+export default function TemplatesTab() {
+ const [templates, setTemplates] = useState<Template[]>([]);
+ const [loading, setLoading] = useState(true);
+ const [categoryFilter, setCategoryFilter] = useState('all');
+
+ // Create template modal
+ const [showCreate, setShowCreate] = useState(false);
+ const [createTitle, setCreateTitle] = useState('');
+ const [createCategory, setCreateCategory] = useState('policy');
+ const [createBody, setCreateBody] = useState('');
+ const [createTags, setCreateTags] = useState('');
+ const [createFeatured, setCreateFeatured] = useState(false);
+ const [createSaving, setCreateSaving] = useState(false);
+
+ // Use template modal (create petition from template)
+ const [useTemplate, setUseTemplate] = useState<Template | null>(null);
+ const [petTitle, setPetTitle] = useState('');
+ const [petTarget, setPetTarget] = useState('');
+ const [petGoal, setPetGoal] = useState(100);
+ const [petSaving, setPetSaving] = useState(false);
+
+ /* ── Fetch ──────────────────────────────────────────────────── */
+ const fetchTemplates = useCallback(async () => {
+ setLoading(true);
+ try {
+ const params = new URLSearchParams();
+ if (categoryFilter !== 'all') params.set('category', categoryFilter);
+ const res = await fetch(`/api/templates?${params.toString()}`);
+ if (res.ok) {
+ const data = await res.json();
+ setTemplates(data.rows || []);
+ }
+ } catch (err) {
+ console.error('Fetch templates error:', err);
+ } finally {
+ setLoading(false);
+ }
+ }, [categoryFilter]);
+
+ useEffect(() => { fetchTemplates(); }, [fetchTemplates]);
+
+ /* ── Create Template ────────────────────────────────────────── */
+ async function handleCreate() {
+ if (!createTitle.trim() || !createBody.trim()) return;
+ setCreateSaving(true);
+ try {
+ const tags = createTags.split(',').map((t) => t.trim()).filter(Boolean);
+ const res = await fetch('/api/templates', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ title: createTitle,
+ category: createCategory,
+ body_html: `<p>${createBody}</p>`,
+ body_text: createBody,
+ tags: tags.length > 0 ? tags : null,
+ is_featured: createFeatured,
+ }),
+ });
+ if (res.ok) {
+ setShowCreate(false);
+ setCreateTitle('');
+ setCreateBody('');
+ setCreateTags('');
+ setCreateFeatured(false);
+ fetchTemplates();
+ }
+ } catch (err) {
+ console.error('Create template error:', err);
+ } finally {
+ setCreateSaving(false);
+ }
+ }
+
+ /* ── Use Template ───────────────────────────────────────────── */
+ function openUseTemplate(t: Template) {
+ setUseTemplate(t);
+ setPetTitle(t.title);
+ setPetTarget('');
+ setPetGoal(100);
+ }
+
+ async function handleUseSave() {
+ if (!useTemplate || !petTitle.trim()) return;
+ setPetSaving(true);
+ try {
+ const res = await fetch('/api/petitions', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ title: petTitle,
+ body_html: useTemplate.body_html,
+ body_text: useTemplate.body_text,
+ target: petTarget || null,
+ category: useTemplate.category,
+ tags: useTemplate.tags,
+ signature_goal: petGoal,
+ status: 'draft',
+ }),
+ });
+ if (res.ok) {
+ setUseTemplate(null);
+ }
+ } catch (err) {
+ console.error('Use template error:', err);
+ } finally {
+ setPetSaving(false);
+ }
+ }
+
+ /* ── Helpers ────────────────────────────────────────────────── */
+ const categories = ['all', 'debt_relief', 'policy', 'education_access', 'consumer_protection'];
+
+ const categoryLabels: Record<string, string> = {
+ all: 'All',
+ debt_relief: 'Debt Relief',
+ policy: 'Policy',
+ education_access: 'Education Access',
+ consumer_protection: 'Consumer Protection',
+ };
+
+ /* ─── Render ────────────────────────────────────────────────── */
+ return (
+ <div style={{ padding: '24px' }}>
+ {/* Header */}
+ <div className="flex items-center justify-between mb-6">
+ <div>
+ <h2 className="text-lg font-semibold" style={{ color: 'var(--color-text)' }}>Petition Templates</h2>
+ <p className="text-sm mt-1" style={{ color: 'var(--color-text-muted)' }}>
+ Pre-built petition templates for common advocacy scenarios
+ </p>
+ </div>
+ <button className="btn btn-primary" onClick={() => setShowCreate(true)}>
+ <Plus size={16} />
+ New Template
+ </button>
+ </div>
+
+ {/* Category Filters */}
+ <div className="flex items-center gap-2 mb-6 flex-wrap">
+ {categories.map((c) => (
+ <button
+ key={c}
+ onClick={() => setCategoryFilter(c)}
+ className={`btn btn-sm ${categoryFilter === c ? 'btn-primary' : 'btn-ghost'}`}
+ >
+ {categoryLabels[c] || c}
+ </button>
+ ))}
+ </div>
+
+ {/* Loading */}
+ {loading && (
+ <SkeletonList count={4} />
+ )}
+
+ {/* Empty State */}
+ {!loading && templates.length === 0 && (
+ <div className="card flex flex-col items-center justify-center py-16" style={{ textAlign: 'center' }}>
+ <div
+ className="w-16 h-16 rounded-2xl flex items-center justify-center mb-4"
+ style={{
+ background: 'linear-gradient(135deg, rgba(124, 58, 237, 0.15), rgba(167, 139, 250, 0.1))',
+ border: '1px solid rgba(124, 58, 237, 0.2)',
+ }}
+ >
+ <FileText size={28} style={{ color: 'var(--color-primary)' }} />
+ </div>
+ <h3 className="text-base font-semibold mb-2" style={{ color: 'var(--color-text)' }}>No templates found</h3>
+ <p className="text-sm mb-6 max-w-md" style={{ color: 'var(--color-text-muted)' }}>
+ Templates help you quickly create petitions. Try a different category filter or create a new template.
+ </p>
+ <button className="btn btn-primary" onClick={() => setShowCreate(true)}>
+ <Plus size={16} /> Create Template
+ </button>
+ </div>
+ )}
+
+ {/* Template Grid */}
+ {!loading && templates.length > 0 && (
+ <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
+ {templates.map((t) => (
+ <div key={t.id} className="card flex flex-col">
+ {/* Header */}
+ <div className="flex items-start justify-between gap-2 mb-2">
+ <h3 className="text-sm font-semibold" style={{ color: 'var(--color-text)' }}>
+ {t.title}
+ </h3>
+ {t.is_featured && (
+ <Star size={14} style={{ color: 'var(--color-warning)', fill: 'var(--color-warning)', flexShrink: 0 }} />
+ )}
+ </div>
+
+ {/* Category + Usage */}
+ <div className="flex items-center gap-2 mb-3">
+ {t.category && (
+ <span
+ className="badge"
+ style={{
+ backgroundColor: 'rgba(124, 58, 237, 0.1)',
+ color: 'var(--color-secondary)',
+ border: '1px solid rgba(124, 58, 237, 0.2)',
+ }}
+ >
+ {categoryLabels[t.category] || t.category}
+ </span>
+ )}
+ <span className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
+ Used {t.usage_count} times
+ </span>
+ </div>
+
+ {/* Body Preview */}
+ <p
+ className="text-xs mb-3 flex-1"
+ style={{
+ color: 'var(--color-text-muted)',
+ display: '-webkit-box',
+ WebkitLineClamp: 3,
+ WebkitBoxOrient: 'vertical',
+ overflow: 'hidden',
+ }}
+ >
+ {stripHtml(t.body_html).slice(0, 180)}
+ </p>
+
+ {/* Tags */}
+ {t.tags && t.tags.length > 0 && (
+ <div className="flex items-center gap-1 mb-3 flex-wrap">
+ {t.tags.map((tag, i) => (
+ <span key={i} className="text-xs" style={{
+ color: 'var(--color-secondary)',
+ padding: '1px 6px',
+ borderRadius: 9999,
+ backgroundColor: 'rgba(124, 58, 237, 0.08)',
+ }}>
+ {tag}
+ </span>
+ ))}
+ </div>
+ )}
+
+ {/* Use Template Button */}
+ <button
+ className="btn btn-sm btn-secondary w-full"
+ onClick={() => openUseTemplate(t)}
+ >
+ <Megaphone size={14} /> Use Template
+ </button>
+ </div>
+ ))}
+ </div>
+ )}
+
+ {/* ─── Create Template Modal ────────────────────────────────── */}
+ {showCreate && (
+ <div
+ style={{
+ position: 'fixed', inset: 0, zIndex: 50,
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
+ backgroundColor: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(4px)',
+ }}
+ onClick={(e) => { if (e.target === e.currentTarget) setShowCreate(false); }}
+ >
+ <div style={{
+ width: '100%', maxWidth: 500, maxHeight: '90vh', overflow: 'auto',
+ backgroundColor: 'var(--color-surface)', border: '1px solid var(--color-border)',
+ borderRadius: 12, padding: 24,
+ }}>
+ <div className="flex items-center justify-between mb-4">
+ <h3 className="text-base font-semibold" style={{ color: 'var(--color-text)' }}>Create Template</h3>
+ <button className="btn btn-ghost btn-sm" onClick={() => setShowCreate(false)}><X size={16} /></button>
+ </div>
+ <div className="flex flex-col gap-4">
+ <div>
+ <label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>Title *</label>
+ <input className="input" value={createTitle} onChange={(e) => setCreateTitle(e.target.value)} placeholder="Template title" />
+ </div>
+ <div>
+ <label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>Category</label>
+ <select className="input" value={createCategory} onChange={(e) => setCreateCategory(e.target.value)}>
+ <option value="debt_relief">Debt Relief</option>
+ <option value="policy">Policy</option>
+ <option value="education_access">Education Access</option>
+ <option value="consumer_protection">Consumer Protection</option>
+ </select>
+ </div>
+ <div>
+ <label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>Body *</label>
+ <textarea
+ className="input"
+ rows={6}
+ value={createBody}
+ onChange={(e) => setCreateBody(e.target.value)}
+ placeholder="Petition template body. Use [TARGET] as placeholder for the petition target."
+ style={{ resize: 'vertical' }}
+ />
+ </div>
+ <div>
+ <label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>Tags (comma separated)</label>
+ <input className="input" value={createTags} onChange={(e) => setCreateTags(e.target.value)} placeholder="student loans, policy, reform" />
+ </div>
+ <div className="flex items-center gap-2">
+ <input
+ type="checkbox"
+ checked={createFeatured}
+ onChange={(e) => setCreateFeatured(e.target.checked)}
+ style={{ accentColor: 'var(--color-primary)' }}
+ id="featured-check"
+ />
+ <label htmlFor="featured-check" className="text-xs" style={{ color: 'var(--color-text-secondary)' }}>Featured template</label>
+ </div>
+ <button className="btn btn-primary w-full" onClick={handleCreate} disabled={createSaving || !createTitle.trim() || !createBody.trim()}>
+ {createSaving ? <><Loader2 size={16} className="animate-spin" /> Creating...</> : <><Plus size={16} /> Create Template</>}
+ </button>
+ </div>
+ </div>
+ </div>
+ )}
+
+ {/* ─── Use Template Modal ───────────────────────────────────── */}
+ {useTemplate && (
+ <div
+ style={{
+ position: 'fixed', inset: 0, zIndex: 50,
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
+ backgroundColor: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(4px)',
+ }}
+ onClick={(e) => { if (e.target === e.currentTarget) setUseTemplate(null); }}
+ >
+ <div style={{
+ width: '100%', maxWidth: 500, maxHeight: '90vh', overflow: 'auto',
+ backgroundColor: 'var(--color-surface)', border: '1px solid var(--color-border)',
+ borderRadius: 12, padding: 24,
+ }}>
+ <div className="flex items-center justify-between mb-4">
+ <h3 className="text-base font-semibold" style={{ color: 'var(--color-text)' }}>Create Petition from Template</h3>
+ <button className="btn btn-ghost btn-sm" onClick={() => setUseTemplate(null)}><X size={16} /></button>
+ </div>
+ <div className="flex flex-col gap-4">
+ <div className="text-xs" style={{
+ color: 'var(--color-text-muted)', padding: 8, borderRadius: 6,
+ backgroundColor: 'var(--color-surface-el)', whiteSpace: 'pre-wrap',
+ }}>
+ {stripHtml(useTemplate.body_html).slice(0, 300)}...
+ </div>
+ <div>
+ <label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>Petition Title *</label>
+ <input className="input" value={petTitle} onChange={(e) => setPetTitle(e.target.value)} />
+ </div>
+ <div>
+ <label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>Target</label>
+ <input className="input" value={petTarget} onChange={(e) => setPetTarget(e.target.value)} placeholder="e.g., Congress" />
+ </div>
+ <div>
+ <label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>Signature Goal</label>
+ <input className="input" type="number" value={petGoal} onChange={(e) => setPetGoal(Number(e.target.value))} />
+ </div>
+ <button className="btn btn-primary w-full" onClick={handleUseSave} disabled={petSaving || !petTitle.trim()}>
+ {petSaving ? <><Loader2 size={16} className="animate-spin" /> Creating...</> : <><Megaphone size={16} /> Create Petition Draft</>}
+ </button>
+ </div>
+ </div>
+ </div>
+ )}
+ </div>
+ );
+}
diff --git a/components/topics/TopicsTab.tsx b/components/topics/TopicsTab.tsx
new file mode 100644
index 0000000..723a485
--- /dev/null
+++ b/components/topics/TopicsTab.tsx
@@ -0,0 +1,415 @@
+'use client';
+
+import { useState, useEffect } from 'react';
+import {
+ Loader2, Sparkles, ChevronDown, ChevronUp,
+ Target, MapPin, FileText, ExternalLink, AlertTriangle,
+ DollarSign,
+} from 'lucide-react';
+import { Grant, formatAmount, STATUS_COLORS } from '../grants/types';
+
+interface Topic {
+ id: string;
+ title: string;
+ description: string;
+ category: string;
+ urgency: string;
+ article_count: number;
+ avg_sentiment: number;
+ geo_states: string[];
+ petition_suggestion: string | null;
+ petition_target: string | null;
+ petition_tone: string | null;
+ is_ai_suggested: boolean;
+ created_at: string;
+}
+
+interface GeneratedPetition {
+ title: string;
+ summary: string;
+ body_html: string;
+ suggested_tags: string[];
+ suggested_goal: number;
+}
+
+const URGENCY_COLORS: Record<string, string> = {
+ critical: '#ef4444', high: '#f59e0b', medium: '#3b82f6', low: '#6b7280',
+};
+
+export default function TopicsTab({ onViewPetition, onViewGrant }: { onViewPetition?: (id: number) => void; onViewGrant?: (id: string) => void }) {
+ const [topics, setTopics] = useState<Topic[]>([]);
+ const [loading, setLoading] = useState(true);
+ const [generating, setGenerating] = useState<string | null>(null);
+ const [generated, setGenerated] = useState<Record<string, { petition: GeneratedPetition; petitionId: number }>>({});
+ const [expanded, setExpanded] = useState<string | null>(null);
+ const [discoveredGrants, setDiscoveredGrants] = useState<Record<string, Grant[]>>({});
+ const [findingGrants, setFindingGrants] = useState<string | null>(null);
+
+ useEffect(() => {
+ (async () => {
+ try {
+ const res = await fetch('/api/pulse/topics');
+ if (res.ok) {
+ const data = await res.json();
+ setTopics(data.topics || []);
+ }
+ } catch (err) { console.error('Topics error:', err); }
+ finally { setLoading(false); }
+ })();
+ }, []);
+
+ async function generatePetition(topic: Topic) {
+ setGenerating(topic.id);
+ try {
+ // Step 1: Generate petition via Gemini
+ const genRes = await fetch('/api/petitions/generate', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ topic: topic.title,
+ target: topic.petition_target || 'Elected Officials',
+ tone: topic.petition_tone || 'formal',
+ category: topic.category || 'policy',
+ }),
+ });
+ if (!genRes.ok) throw new Error('Generation failed');
+ const petition = await genRes.json();
+
+ // Step 2: Save as draft
+ const saveRes = await fetch('/api/petitions', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ title: petition.title,
+ summary: petition.summary,
+ body_html: petition.body_html,
+ body_text: petition.body_text || '',
+ target: petition.suggested_target || topic.petition_target,
+ category: topic.category,
+ tags: petition.suggested_tags || [],
+ signature_goal: petition.suggested_goal || 1000,
+ status: 'draft',
+ }),
+ });
+ if (!saveRes.ok) throw new Error('Save failed');
+ const saved = await saveRes.json();
+
+ setGenerated(prev => ({
+ ...prev,
+ [topic.id]: { petition, petitionId: saved.id || saved.petition?.id },
+ }));
+ setExpanded(topic.id);
+
+ // Step 3: Auto-discover related grants
+ try {
+ const grantRes = await fetch('/api/grants-proxy/grants/discover', { method: 'POST' });
+ if (grantRes.ok) {
+ const grantData = await grantRes.json();
+ if (grantData.grants?.length) {
+ setDiscoveredGrants(prev => ({ ...prev, [topic.id]: grantData.grants }));
+ }
+ }
+ } catch { /* grant discovery is optional */ }
+ } catch (err) {
+ console.error('Generate petition error:', err);
+ } finally {
+ setGenerating(null);
+ }
+ }
+
+ async function findGrantsForTopic(topic: Topic) {
+ setFindingGrants(topic.id);
+ try {
+ const res = await fetch('/api/grants-proxy/grants/discover', { method: 'POST' });
+ if (res.ok) {
+ const data = await res.json();
+ setDiscoveredGrants(prev => ({ ...prev, [topic.id]: data.grants || [] }));
+ setExpanded(topic.id);
+ }
+ } catch (err) {
+ console.error('Find grants error:', err);
+ } finally {
+ setFindingGrants(null);
+ }
+ }
+
+ if (loading) {
+ return (
+ <div style={{ padding: 32, color: 'var(--color-text-muted)', display: 'flex', alignItems: 'center', gap: 8 }}>
+ <Loader2 size={18} style={{ animation: 'spin 1s linear infinite' }} /> Loading topics...
+ </div>
+ );
+ }
+
+ return (
+ <div style={{ padding: 24, maxWidth: 1000 }}>
+ <div style={{ marginBottom: 20 }}>
+ <h2 style={{ fontSize: 20, fontWeight: 700, color: 'var(--color-text)', margin: 0 }}>
+ Topics
+ <span style={{ fontSize: 13, fontWeight: 400, color: 'var(--color-text-muted)', marginLeft: 10 }}>
+ {topics.length} AI-suggested topics
+ </span>
+ </h2>
+ </div>
+
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
+ {topics.map(topic => {
+ const isGenerating = generating === topic.id;
+ const gen = generated[topic.id];
+ const isExpanded = expanded === topic.id;
+
+ return (
+ <div key={topic.id} style={{
+ borderRadius: 12, border: '1px solid var(--color-border)',
+ backgroundColor: 'var(--color-surface)', overflow: 'hidden',
+ }}>
+ {/* Topic Header */}
+ <div style={{ padding: '16px 20px' }}>
+ <div style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
+ <div style={{ flex: 1 }}>
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
+ <h3 style={{ fontSize: 15, fontWeight: 600, color: 'var(--color-text)', margin: 0, lineHeight: 1.3 }}>
+ {topic.title}
+ </h3>
+ </div>
+ <p style={{ fontSize: 13, color: 'var(--color-text-muted)', margin: 0, lineHeight: 1.5 }}>
+ {topic.description}
+ </p>
+
+ {/* Meta row */}
+ <div style={{ display: 'flex', flexWrap: 'wrap', gap: 10, marginTop: 10 }}>
+ {/* Urgency badge */}
+ <span style={{
+ fontSize: 10, fontWeight: 700, padding: '2px 8px', borderRadius: 10,
+ backgroundColor: (URGENCY_COLORS[topic.urgency] || '#6b7280') + '22',
+ color: URGENCY_COLORS[topic.urgency] || '#6b7280',
+ textTransform: 'uppercase', letterSpacing: '0.04em',
+ }}>
+ {topic.urgency}
+ </span>
+
+ {/* Category */}
+ <span style={{
+ fontSize: 10, fontWeight: 600, padding: '2px 8px', borderRadius: 10,
+ backgroundColor: 'var(--color-bg)', color: 'var(--color-text-muted)',
+ }}>
+ {topic.category}
+ </span>
+
+ {/* Article count */}
+ <span style={{ fontSize: 11, color: 'var(--color-text-muted)', display: 'flex', alignItems: 'center', gap: 3 }}>
+ <FileText size={12} /> {topic.article_count} article{topic.article_count !== 1 ? 's' : ''}
+ </span>
+
+ {/* Geo states */}
+ {topic.geo_states?.length > 0 && (
+ <span style={{ fontSize: 11, color: 'var(--color-text-muted)', display: 'flex', alignItems: 'center', gap: 3 }}>
+ <MapPin size={12} /> {topic.geo_states.join(', ')}
+ </span>
+ )}
+
+ {/* Sentiment */}
+ <span style={{
+ fontSize: 11,
+ color: topic.avg_sentiment < -0.3 ? '#ef4444' : topic.avg_sentiment > 0.3 ? '#22c55e' : 'var(--color-text-muted)',
+ }}>
+ Sentiment: {topic.avg_sentiment?.toFixed(2)}
+ </span>
+ </div>
+ </div>
+
+ {/* Action Buttons */}
+ <div style={{ flexShrink: 0, display: 'flex', flexDirection: 'column', gap: 6 }}>
+ {gen ? (
+ <div style={{ display: 'flex', gap: 6 }}>
+ <button
+ onClick={() => setExpanded(isExpanded ? null : topic.id)}
+ style={{
+ display: 'flex', alignItems: 'center', gap: 4, padding: '7px 12px',
+ borderRadius: 8, border: '1px solid var(--color-border)',
+ backgroundColor: 'var(--color-surface)', color: 'var(--color-text)',
+ cursor: 'pointer', fontSize: 12,
+ }}
+ >
+ {isExpanded ? <ChevronUp size={13} /> : <ChevronDown size={13} />}
+ Preview
+ </button>
+ <button
+ onClick={() => onViewPetition?.(gen.petitionId)}
+ style={{
+ display: 'flex', alignItems: 'center', gap: 4, padding: '7px 12px',
+ borderRadius: 8, border: 'none',
+ backgroundColor: '#22c55e', color: '#fff',
+ cursor: 'pointer', fontSize: 12, fontWeight: 600,
+ }}
+ >
+ Edit in Petitions <ExternalLink size={12} />
+ </button>
+ </div>
+ ) : (
+ <button
+ onClick={() => generatePetition(topic)}
+ disabled={isGenerating || !!generating}
+ style={{
+ display: 'flex', alignItems: 'center', gap: 6, padding: '8px 16px',
+ borderRadius: 8, border: 'none',
+ background: isGenerating ? '#6b7280' : 'linear-gradient(135deg, #7c3aed, #6d28d9)',
+ color: '#fff', cursor: isGenerating ? 'wait' : 'pointer',
+ fontSize: 13, fontWeight: 600, opacity: generating && !isGenerating ? 0.5 : 1,
+ }}
+ >
+ {isGenerating ? (
+ <><Loader2 size={14} style={{ animation: 'spin 1s linear infinite' }} /> Generating...</>
+ ) : (
+ <><Sparkles size={14} /> Generate Petition</>
+ )}
+ </button>
+ )}
+ {/* Find Grants button */}
+ <button
+ onClick={() => findGrantsForTopic(topic)}
+ disabled={findingGrants === topic.id}
+ style={{
+ display: 'flex', alignItems: 'center', gap: 5, padding: '6px 14px',
+ borderRadius: 8, border: '1px solid #059669',
+ backgroundColor: 'transparent', color: '#10b981',
+ cursor: findingGrants === topic.id ? 'wait' : 'pointer',
+ fontSize: 12, fontWeight: 600,
+ opacity: findingGrants === topic.id ? 0.7 : 1,
+ }}
+ >
+ {findingGrants === topic.id
+ ? <><Loader2 size={12} style={{ animation: 'spin 1s linear infinite' }} /> Finding…</>
+ : <><DollarSign size={12} /> Find Grants</>
+ }
+ </button>
+ </div>
+ </div>
+ </div>
+
+ {/* Expanded Grants Only (no petition yet) */}
+ {!gen && isExpanded && discoveredGrants[topic.id]?.length > 0 && (
+ <div style={{
+ padding: '16px 20px', borderTop: '1px solid var(--color-border)',
+ backgroundColor: 'var(--color-bg)',
+ }}>
+ <div style={{ fontSize: 11, fontWeight: 600, color: '#10b981', textTransform: 'uppercase', letterSpacing: '0.04em', marginBottom: 6 }}>
+ Discovered Grants ({discoveredGrants[topic.id].length})
+ </div>
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
+ {discoveredGrants[topic.id].slice(0, 5).map(g => (
+ <div key={g.id}
+ onClick={() => onViewGrant?.(g.id)}
+ style={{
+ display: 'flex', alignItems: 'center', gap: 10, padding: '8px 12px',
+ borderRadius: 8, backgroundColor: 'var(--color-surface)',
+ border: '1px solid var(--color-border)', cursor: 'pointer',
+ }}
+ onMouseEnter={e => { e.currentTarget.style.borderColor = '#059669'; }}
+ onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--color-border)'; }}
+ >
+ <DollarSign size={14} style={{ color: '#10b981', flexShrink: 0 }} />
+ <div style={{ flex: 1, minWidth: 0 }}>
+ <div style={{ fontSize: 12, fontWeight: 600, color: 'var(--color-text)', overflow: 'hidden', whiteSpace: 'nowrap', textOverflow: 'ellipsis' }}>
+ {g.title}
+ </div>
+ <div style={{ fontSize: 11, color: 'var(--color-text-muted)' }}>
+ {g.funder} · {formatAmount(g.amount_min, g.amount_max)}
+ </div>
+ </div>
+ {g.ai_fit_score != null && (
+ <span style={{ fontSize: 11, fontWeight: 600, color: '#f59e0b', flexShrink: 0 }}>
+ {Math.round(g.ai_fit_score * 100)}%
+ </span>
+ )}
+ <ExternalLink size={12} style={{ color: 'var(--color-text-muted)', flexShrink: 0 }} />
+ </div>
+ ))}
+ </div>
+ </div>
+ )}
+
+ {/* Expanded Petition Preview */}
+ {gen && isExpanded && (
+ <div style={{
+ padding: '16px 20px', borderTop: '1px solid var(--color-border)',
+ backgroundColor: 'var(--color-bg)',
+ }}>
+ <div style={{ fontSize: 11, fontWeight: 600, color: '#22c55e', textTransform: 'uppercase', letterSpacing: '0.04em', marginBottom: 8 }}>
+ Generated Petition — Saved as Draft
+ </div>
+ <h4 style={{ fontSize: 16, fontWeight: 700, color: 'var(--color-text)', margin: '0 0 8px' }}>
+ {gen.petition.title}
+ </h4>
+ <p style={{ fontSize: 13, color: 'var(--color-text-secondary)', margin: '0 0 12px', lineHeight: 1.6 }}>
+ {gen.petition.summary}
+ </p>
+ {gen.petition.suggested_tags?.length > 0 && (
+ <div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginBottom: 12 }}>
+ {gen.petition.suggested_tags.map((tag, i) => (
+ <span key={i} style={{
+ fontSize: 10, padding: '2px 8px', borderRadius: 10,
+ backgroundColor: '#7c3aed22', color: '#a78bfa',
+ }}>{tag}</span>
+ ))}
+ </div>
+ )}
+
+ {/* Discovered Grants inline */}
+ {discoveredGrants[topic.id]?.length > 0 && (
+ <div>
+ <div style={{ fontSize: 11, fontWeight: 600, color: '#10b981', textTransform: 'uppercase', letterSpacing: '0.04em', marginBottom: 6 }}>
+ Related Grants Found ({discoveredGrants[topic.id].length})
+ </div>
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
+ {discoveredGrants[topic.id].slice(0, 5).map(g => (
+ <div key={g.id}
+ onClick={() => onViewGrant?.(g.id)}
+ style={{
+ display: 'flex', alignItems: 'center', gap: 10, padding: '8px 12px',
+ borderRadius: 8, backgroundColor: 'var(--color-surface)',
+ border: '1px solid var(--color-border)', cursor: 'pointer',
+ transition: 'border-color 0.15s',
+ }}
+ onMouseEnter={e => { e.currentTarget.style.borderColor = '#059669'; }}
+ onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--color-border)'; }}
+ >
+ <DollarSign size={14} style={{ color: '#10b981', flexShrink: 0 }} />
+ <div style={{ flex: 1, minWidth: 0 }}>
+ <div style={{ fontSize: 12, fontWeight: 600, color: 'var(--color-text)', overflow: 'hidden', whiteSpace: 'nowrap', textOverflow: 'ellipsis' }}>
+ {g.title}
+ </div>
+ <div style={{ fontSize: 11, color: 'var(--color-text-muted)' }}>
+ {g.funder} · {formatAmount(g.amount_min, g.amount_max)}
+ </div>
+ </div>
+ {g.ai_fit_score != null && (
+ <span style={{ fontSize: 11, fontWeight: 600, color: '#f59e0b', flexShrink: 0 }}>
+ {Math.round(g.ai_fit_score * 100)}%
+ </span>
+ )}
+ <ExternalLink size={12} style={{ color: 'var(--color-text-muted)', flexShrink: 0 }} />
+ </div>
+ ))}
+ </div>
+ </div>
+ )}
+ </div>
+ )}
+ </div>
+ );
+ })}
+ </div>
+
+ {topics.length === 0 && (
+ <div style={{
+ padding: 40, textAlign: 'center',
+ color: 'var(--color-text-muted)', fontSize: 14,
+ }}>
+ <AlertTriangle size={24} style={{ marginBottom: 8, opacity: 0.5 }} />
+ <div>No topics found. Topics are generated when news is fetched.</div>
+ </div>
+ )}
+ </div>
+ );
+}
diff --git a/components/trends/TrendsTab.tsx b/components/trends/TrendsTab.tsx
new file mode 100644
index 0000000..5ff9a60
--- /dev/null
+++ b/components/trends/TrendsTab.tsx
@@ -0,0 +1,534 @@
+'use client';
+
+import { useState, useEffect, useCallback } from 'react';
+import { SkeletonList } from '../Skeleton';
+import {
+ TrendingUp, Sparkles, RefreshCw, Loader2, Clock,
+ Megaphone, X, Plus, Target,
+} from 'lucide-react';
+import { useToast } from '../ToastProvider';
+import SortDropdown from '../shared/SortDropdown';
+import { useClientSort, SortConfig } from '@/hooks/useClientSort';
+
+const TREND_SORT_OPTIONS = [
+ { value: 'engagement_desc', label: 'Most Engaging' },
+ { value: 'date_desc', label: 'Newest First' },
+ { value: 'date_asc', label: 'Oldest First' },
+ { value: 'sentiment', label: 'Sentiment A-Z' },
+];
+
+const TREND_SORT_CONFIGS: Record<string, SortConfig> = {
+ engagement_desc: { key: 'engagement_score', direction: 'desc', type: 'number' },
+ date_desc: { key: 'created_at', direction: 'desc', type: 'date' },
+ date_asc: { key: 'created_at', direction: 'asc', type: 'date' },
+ sentiment: { key: 'sentiment', direction: 'asc', type: 'string' },
+};
+
+/* ─── Types ─────────────────────────────────────────────────── */
+interface TrendingTopic {
+ id: string;
+ source: string;
+ source_url: string | null;
+ title: string;
+ content: string | null;
+ engagement_score: number | null;
+ sentiment: string | null;
+ category: string | null;
+ tags: string[] | null;
+ is_used: boolean;
+ expires_at: string | null;
+ created_at: string;
+}
+
+/* ─── Component ─────────────────────────────────────────────── */
+export default function TrendsTab() {
+ const { addToast } = useToast();
+ const [topics, setTopics] = useState<TrendingTopic[]>([]);
+ const [loading, setLoading] = useState(true);
+ const [discovering, setDiscovering] = useState(false);
+ const [categoryFilter, setCategoryFilter] = useState('all');
+ const [sortBy, setSortBy] = useState('engagement_desc');
+ const sortedTopics = useClientSort(topics, sortBy, TREND_SORT_CONFIGS);
+
+ // Petition creation modal (pre-filled from trend)
+ const [createFromTrend, setCreateFromTrend] = useState<TrendingTopic | null>(null);
+ const [petTopic, setPetTopic] = useState('');
+ const [petTarget, setPetTarget] = useState('');
+ const [petTone, setPetTone] = useState('urgent');
+ const [petCategory, setPetCategory] = useState('policy');
+ const [generating, setGenerating] = useState(false);
+ const [saving, setSaving] = useState(false);
+ const [generated, setGenerated] = useState<{
+ title: string; summary: string; body_html: string; body_text: string;
+ suggested_target: string; suggested_tags: string[]; suggested_goal: number;
+ } | null>(null);
+
+ /* ── Fetch ──────────────────────────────────────────────────── */
+ const fetchTopics = useCallback(async () => {
+ setLoading(true);
+ try {
+ const params = new URLSearchParams();
+ if (categoryFilter !== 'all') params.set('category', categoryFilter);
+ const res = await fetch(`/api/trending?${params.toString()}`);
+ if (res.ok) {
+ const data = await res.json();
+ setTopics(data.rows || []);
+ }
+ } catch (err) {
+ console.error('Fetch trends error:', err);
+ } finally {
+ setLoading(false);
+ }
+ }, [categoryFilter]);
+
+ useEffect(() => { fetchTopics(); }, [fetchTopics]);
+
+ /* ── Escape key handler ───────────────────────────────────────── */
+ useEffect(() => {
+ if (!createFromTrend) return;
+ const handleKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setCreateFromTrend(null); };
+ window.addEventListener('keydown', handleKey);
+ return () => window.removeEventListener('keydown', handleKey);
+ }, [createFromTrend]);
+
+ /* ── AI Discover ────────────────────────────────────────────── */
+ async function handleDiscover() {
+ setDiscovering(true);
+ try {
+ const res = await fetch('/api/trending/discover', { method: 'POST' });
+ if (res.ok) {
+ fetchTopics();
+ addToast('Trends discovered', 'success');
+ } else {
+ addToast('Failed to discover trends', 'error');
+ }
+ } catch (err) {
+ console.error('Discover error:', err);
+ addToast('Failed to discover trends', 'error');
+ } finally {
+ setDiscovering(false);
+ }
+ }
+
+ /* ── Create Petition from Trend ─────────────────────────────── */
+ function openCreateFromTrend(topic: TrendingTopic) {
+ setCreateFromTrend(topic);
+ setPetTopic(topic.title);
+ setPetCategory(topic.category || 'policy');
+ setPetTarget('');
+ setPetTone('urgent');
+ setGenerated(null);
+ }
+
+ async function handleGenerate() {
+ setGenerating(true);
+ setGenerated(null);
+ try {
+ const res = await fetch('/api/petitions/generate', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ topic: petTopic, target: petTarget, tone: petTone, category: petCategory }),
+ });
+ if (res.ok) {
+ const data = await res.json();
+ setGenerated(data);
+ addToast('Petition generated', 'success');
+ } else {
+ addToast('Generation failed', 'error');
+ }
+ } catch (err) {
+ console.error('Generate error:', err);
+ addToast('Generation failed', 'error');
+ } finally {
+ setGenerating(false);
+ }
+ }
+
+ async function handleSavePetition() {
+ if (!generated) return;
+ setSaving(true);
+ try {
+ const res = await fetch('/api/petitions', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ title: generated.title,
+ summary: generated.summary,
+ body_html: generated.body_html,
+ body_text: generated.body_text,
+ target: generated.suggested_target,
+ category: petCategory,
+ tags: generated.suggested_tags,
+ signature_goal: generated.suggested_goal,
+ status: 'draft',
+ }),
+ });
+ if (res.ok) {
+ setCreateFromTrend(null);
+ setGenerated(null);
+ addToast('Petition saved as draft', 'success');
+ } else {
+ addToast('Failed to save petition', 'error');
+ }
+ } catch (err) {
+ console.error('Save petition error:', err);
+ addToast('Failed to save petition', 'error');
+ } finally {
+ setSaving(false);
+ }
+ }
+
+ /* ── Helpers ────────────────────────────────────────────────── */
+ const categories = ['all', 'debt_relief', 'policy', 'education_access', 'consumer_protection', 'federal_budget'];
+
+ const categoryLabels: Record<string, string> = {
+ all: 'All',
+ debt_relief: 'Debt Relief',
+ policy: 'Policy',
+ education_access: 'Education Access',
+ consumer_protection: 'Consumer Protection',
+ federal_budget: 'Federal Budget',
+ };
+
+ const sentimentColors: Record<string, { bg: string; text: string; border: string }> = {
+ positive: { bg: 'rgba(34,197,94,0.15)', text: 'var(--color-success)', border: 'rgba(34,197,94,0.3)' },
+ negative: { bg: 'rgba(239,68,68,0.15)', text: 'var(--color-error)', border: 'rgba(239,68,68,0.3)' },
+ neutral: { bg: 'rgba(161,161,170,0.15)', text: 'var(--color-text-secondary)', border: 'rgba(161,161,170,0.3)' },
+ mixed: { bg: 'rgba(245,158,11,0.15)', text: 'var(--color-warning)', border: 'rgba(245,158,11,0.3)' },
+ };
+
+ const sourceColors: Record<string, string> = {
+ 'Twitter/X': '#1DA1F2',
+ 'Reddit': '#FF4500',
+ 'News': '#22c55e',
+ 'Congress.gov': '#3b82f6',
+ 'Change.org': '#E84444',
+ 'TikTok': '#ff2d55',
+ 'Instagram': '#C13584',
+ };
+
+ const expiresIn = (d: string | null) => {
+ if (!d) return null;
+ const diff = new Date(d).getTime() - Date.now();
+ if (diff <= 0) return 'Expired';
+ const days = Math.floor(diff / (1000 * 60 * 60 * 24));
+ const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
+ if (days > 0) return `${days}d ${hours}h left`;
+ return `${hours}h left`;
+ };
+
+ function stripHtml(html: string): string {
+ return html.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
+ }
+
+ /* ─── Render ────────────────────────────────────────────────── */
+ return (
+ <div style={{ padding: '24px' }}>
+ {/* Header */}
+ <div className="flex items-center justify-between mb-6">
+ <div>
+ <h2 className="text-lg font-semibold" style={{ color: 'var(--color-text)' }}>
+ Trending Topics
+ </h2>
+ <p className="text-sm mt-1" style={{ color: 'var(--color-text-muted)' }}>
+ AI-curated topics ready for petition creation
+ </p>
+ </div>
+ <button
+ className="btn btn-primary"
+ onClick={handleDiscover}
+ disabled={discovering}
+ >
+ {discovering ? (
+ <><Loader2 size={16} className="animate-spin" /> Discovering...</>
+ ) : (
+ <><Sparkles size={16} /> AI Discover Trends</>
+ )}
+ </button>
+ </div>
+
+ {/* Sort + Category Filter */}
+ <div className="flex items-center gap-2 mb-6 flex-wrap">
+ <SortDropdown options={TREND_SORT_OPTIONS} value={sortBy} onChange={setSortBy} />
+ {categories.map((c) => (
+ <button
+ key={c}
+ onClick={() => setCategoryFilter(c)}
+ className={`btn btn-sm ${categoryFilter === c ? 'btn-primary' : 'btn-ghost'}`}
+ >
+ {categoryLabels[c] || c}
+ </button>
+ ))}
+ </div>
+
+ {/* Loading */}
+ {loading && (
+ <SkeletonList count={4} />
+ )}
+
+ {/* Empty State */}
+ {!loading && topics.length === 0 && (
+ <div className="card flex flex-col items-center justify-center py-16" style={{ textAlign: 'center' }}>
+ <div
+ className="w-16 h-16 rounded-2xl flex items-center justify-center mb-4"
+ style={{
+ background: 'linear-gradient(135deg, rgba(124, 58, 237, 0.15), rgba(167, 139, 250, 0.1))',
+ border: '1px solid rgba(124, 58, 237, 0.2)',
+ }}
+ >
+ <TrendingUp size={28} style={{ color: 'var(--color-primary)' }} />
+ </div>
+ <h3 className="text-base font-semibold mb-2" style={{ color: 'var(--color-text)' }}>
+ No trending topics yet
+ </h3>
+ <p className="text-sm mb-6 max-w-md" style={{ color: 'var(--color-text-muted)' }}>
+ Use AI to discover trending topics about student debt and education policy.
+ </p>
+ <button className="btn btn-primary" onClick={handleDiscover} disabled={discovering}>
+ {discovering ? (
+ <><Loader2 size={16} className="animate-spin" /> Discovering...</>
+ ) : (
+ <><Sparkles size={16} /> Discover Trends Now</>
+ )}
+ </button>
+
+ {/* How It Works */}
+ <div className="mt-10 grid grid-cols-1 sm:grid-cols-3 gap-4 w-full max-w-2xl">
+ {[
+ { title: 'Discover', desc: 'AI scans news, social media, and gov sites for trending civic issues' },
+ { title: 'Analyze', desc: 'Each topic is scored for engagement potential and sentiment' },
+ { title: 'Create', desc: 'One click to generate a petition draft from any trending topic' },
+ ].map((step, i) => (
+ <div key={i} className="card-elevated" style={{ padding: '16px', textAlign: 'left' }}>
+ <div className="text-xs font-bold mb-2" style={{ color: 'var(--color-primary)' }}>
+ Step {i + 1}
+ </div>
+ <div className="text-sm font-semibold mb-1" style={{ color: 'var(--color-text)' }}>
+ {step.title}
+ </div>
+ <div className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
+ {step.desc}
+ </div>
+ </div>
+ ))}
+ </div>
+ </div>
+ )}
+
+ {/* Topic Cards */}
+ {!loading && sortedTopics.length > 0 && (
+ <div className="flex flex-col gap-3">
+ {sortedTopics.map((t) => {
+ const sent = sentimentColors[t.sentiment || 'neutral'] || sentimentColors.neutral;
+ const srcColor = sourceColors[t.source] || 'var(--color-text-muted)';
+ const expiry = expiresIn(t.expires_at);
+ const score = t.engagement_score || 0;
+ return (
+ <div key={t.id} className="card">
+ <div className="flex items-start gap-4">
+ <div className="flex-1 min-w-0">
+ {/* Title + Badges */}
+ <div className="flex items-center gap-2 mb-2 flex-wrap">
+ <h3 className="text-sm font-semibold" style={{ color: 'var(--color-text)' }}>
+ {t.title}
+ </h3>
+ {/* Source badge */}
+ <span
+ className="badge"
+ style={{
+ backgroundColor: `${srcColor}20`,
+ color: srcColor,
+ border: `1px solid ${srcColor}40`,
+ }}
+ >
+ {t.source}
+ </span>
+ {/* Sentiment badge */}
+ <span
+ className="badge"
+ style={{
+ backgroundColor: sent.bg,
+ color: sent.text,
+ border: `1px solid ${sent.border}`,
+ }}
+ >
+ {t.sentiment || 'neutral'}
+ </span>
+ {/* Category */}
+ {t.category && (
+ <span
+ className="badge"
+ style={{
+ backgroundColor: 'rgba(124, 58, 237, 0.1)',
+ color: 'var(--color-secondary)',
+ border: '1px solid rgba(124, 58, 237, 0.2)',
+ }}
+ >
+ {categoryLabels[t.category] || t.category}
+ </span>
+ )}
+ </div>
+
+ {/* Content */}
+ {t.content && (
+ <p className="text-xs mb-3 line-clamp-2" style={{ color: 'var(--color-text-muted)' }}>
+ {t.content}
+ </p>
+ )}
+
+ {/* Engagement Score Bar */}
+ <div className="flex items-center gap-3 mb-2">
+ <span className="text-xs" style={{ color: 'var(--color-text-muted)', width: 80 }}>
+ Engagement
+ </span>
+ <div
+ style={{
+ flex: 1, maxWidth: 200, height: 6, borderRadius: 3,
+ backgroundColor: 'var(--color-surface-el)', overflow: 'hidden',
+ }}
+ >
+ <div
+ style={{
+ width: `${score}%`, height: '100%', borderRadius: 3,
+ background: score > 70 ? 'linear-gradient(90deg, #22c55e, #4ade80)' :
+ score > 40 ? 'linear-gradient(90deg, #f59e0b, #fbbf24)' :
+ 'linear-gradient(90deg, #ef4444, #f87171)',
+ }}
+ />
+ </div>
+ <span className="text-xs font-semibold" style={{ color: 'var(--color-text-secondary)', width: 30 }}>
+ {score}
+ </span>
+ </div>
+
+ {/* Tags + Expiry */}
+ <div className="flex items-center gap-2 flex-wrap">
+ {t.tags && t.tags.map((tag, i) => (
+ <span key={i} className="text-xs" style={{
+ color: 'var(--color-secondary)',
+ padding: '1px 6px', borderRadius: 9999,
+ backgroundColor: 'rgba(124, 58, 237, 0.08)',
+ }}>
+ {tag}
+ </span>
+ ))}
+ {expiry && (
+ <span className="flex items-center gap-1 text-xs" style={{ color: 'var(--color-text-muted)', marginLeft: 'auto' }}>
+ <Clock size={11} /> {expiry}
+ </span>
+ )}
+ </div>
+ </div>
+
+ {/* Action */}
+ <button
+ className="btn btn-sm btn-primary shrink-0"
+ onClick={() => openCreateFromTrend(t)}
+ >
+ <Megaphone size={14} />
+ Create Petition
+ </button>
+ </div>
+ </div>
+ );
+ })}
+ </div>
+ )}
+
+ {/* ─── Create Petition from Trend Modal ────────────────────── */}
+ {createFromTrend && (
+ <div
+ style={{
+ position: 'fixed', inset: 0, zIndex: 50,
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
+ backgroundColor: 'rgba(0,0,0,0.6)',
+ backdropFilter: 'blur(4px)',
+ }}
+ onClick={(e) => { if (e.target === e.currentTarget) setCreateFromTrend(null); }}
+ >
+ <div
+ style={{
+ width: '100%', maxWidth: 600, maxHeight: '90vh', overflow: 'auto',
+ backgroundColor: 'var(--color-surface)',
+ border: '1px solid var(--color-border)',
+ borderRadius: 12, padding: 24,
+ }}
+ >
+ <div className="flex items-center justify-between mb-4">
+ <h3 className="text-base font-semibold" style={{ color: 'var(--color-text)' }}>
+ Create Petition from Trend
+ </h3>
+ <button className="btn btn-ghost btn-sm" onClick={() => setCreateFromTrend(null)}>
+ <X size={16} />
+ </button>
+ </div>
+
+ {!generated ? (
+ <div className="flex flex-col gap-4">
+ <div>
+ <label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>Topic</label>
+ <input className="input" value={petTopic} onChange={(e) => setPetTopic(e.target.value)} />
+ </div>
+ <div>
+ <label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>Target</label>
+ <input className="input" placeholder="e.g., Congress" value={petTarget} onChange={(e) => setPetTarget(e.target.value)} />
+ </div>
+ <div className="grid grid-cols-2 gap-4">
+ <div>
+ <label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>Tone</label>
+ <select className="input" value={petTone} onChange={(e) => setPetTone(e.target.value)}>
+ <option value="urgent">Urgent</option>
+ <option value="formal">Formal</option>
+ <option value="passionate">Passionate</option>
+ <option value="grassroots">Grassroots</option>
+ </select>
+ </div>
+ <div>
+ <label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>Category</label>
+ <select className="input" value={petCategory} onChange={(e) => setPetCategory(e.target.value)}>
+ <option value="debt_relief">Debt Relief</option>
+ <option value="policy">Policy</option>
+ <option value="education_access">Education Access</option>
+ <option value="consumer_protection">Consumer Protection</option>
+ <option value="federal_budget">Federal Budget</option>
+ </select>
+ </div>
+ </div>
+ <button className="btn btn-primary w-full" onClick={handleGenerate} disabled={generating}>
+ {generating ? <><Loader2 size={16} className="animate-spin" /> Generating...</> : <><Sparkles size={16} /> Generate Petition</>}
+ </button>
+ </div>
+ ) : (
+ <div className="flex flex-col gap-4">
+ <div>
+ <div className="text-xs font-medium mb-1" style={{ color: 'var(--color-text-muted)' }}>Title</div>
+ <div className="text-sm font-semibold" style={{ color: 'var(--color-text)' }}>{generated.title}</div>
+ </div>
+ <div>
+ <div className="text-xs font-medium mb-1" style={{ color: 'var(--color-text-muted)' }}>Summary</div>
+ <div className="text-sm" style={{ color: 'var(--color-text-secondary)' }}>{generated.summary}</div>
+ </div>
+ <div>
+ <div className="text-xs font-medium mb-1" style={{ color: 'var(--color-text-muted)' }}>Body Preview</div>
+ <div className="text-xs" style={{
+ color: 'var(--color-text-muted)', maxHeight: 160, overflow: 'auto',
+ padding: 8, borderRadius: 6, backgroundColor: 'var(--color-surface-el)',
+ whiteSpace: 'pre-wrap',
+ }}>
+ {stripHtml(generated.body_html)}
+ </div>
+ </div>
+ <div className="flex items-center gap-3">
+ <button className="btn btn-primary flex-1" onClick={handleSavePetition} disabled={saving}>
+ {saving ? <><Loader2 size={16} className="animate-spin" /> Saving...</> : <><Plus size={16} /> Save as Draft</>}
+ </button>
+ <button className="btn btn-ghost" onClick={() => setGenerated(null)}>Regenerate</button>
+ </div>
+ </div>
+ )}
+ </div>
+ </div>
+ )}
+ </div>
+ );
+}
diff --git a/db/orbit-schema.sql b/db/orbit-schema.sql
new file mode 100644
index 0000000..9829617
--- /dev/null
+++ b/db/orbit-schema.sql
@@ -0,0 +1,122 @@
+-- Orbit: Kalshi <-> Petition Bridge + RSS News Intelligence
+-- Part of Patty - The Petition Specialist
+-- Database: postgresql://dw_admin@127.0.0.1:5432/ # password in .env.local (DATABASE_URL)
+-- patty
+
+CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
+
+-- Kalshi prediction markets synced from Ken agent
+CREATE TABLE IF NOT EXISTS orbit_markets (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ ticker TEXT NOT NULL UNIQUE,
+ event_ticker TEXT,
+ title TEXT NOT NULL,
+ subtitle TEXT,
+ category TEXT,
+ yes_bid INT,
+ yes_ask INT,
+ no_bid INT,
+ no_ask INT,
+ last_price INT,
+ volume INT,
+ volume_24h INT,
+ open_interest INT,
+ status TEXT DEFAULT 'open',
+ close_time TIMESTAMPTZ,
+ petition_relevance REAL DEFAULT 0, -- 0-100 AI-scored relevance to advocacy
+ suggested_petition TEXT, -- AI-generated petition angle
+ suggested_target TEXT,
+ raw_data JSONB,
+ last_synced_at TIMESTAMPTZ DEFAULT NOW(),
+ created_at TIMESTAMPTZ DEFAULT NOW(),
+ updated_at TIMESTAMPTZ DEFAULT NOW()
+);
+
+-- Links between markets and petitions
+CREATE TABLE IF NOT EXISTS orbit_links (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ market_id UUID REFERENCES orbit_markets(id) ON DELETE CASCADE,
+ petition_id UUID REFERENCES petitions(id) ON DELETE CASCADE,
+ link_type TEXT NOT NULL DEFAULT 'influence', -- influence, counter, support
+ strength REAL DEFAULT 0.5, -- 0-1 connection strength
+ ai_reasoning TEXT,
+ created_at TIMESTAMPTZ DEFAULT NOW(),
+ UNIQUE(market_id, petition_id)
+);
+
+-- RSS/Atom feed sources
+CREATE TABLE IF NOT EXISTS orbit_feeds (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ name TEXT NOT NULL,
+ url TEXT NOT NULL UNIQUE,
+ feed_type TEXT NOT NULL DEFAULT 'rss', -- rss, atom, json
+ category TEXT DEFAULT 'national', -- national, local, wire
+ station TEXT, -- CNN, Fox, NBC, local call sign
+ market TEXT, -- DMA market for local (New York, Los Angeles, etc.)
+ is_active BOOLEAN DEFAULT TRUE,
+ last_fetched_at TIMESTAMPTZ,
+ fetch_interval_min INT DEFAULT 30,
+ article_count INT DEFAULT 0,
+ created_at TIMESTAMPTZ DEFAULT NOW()
+);
+
+-- Articles fetched from RSS feeds
+CREATE TABLE IF NOT EXISTS orbit_articles (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ feed_id UUID REFERENCES orbit_feeds(id) ON DELETE CASCADE,
+ title TEXT NOT NULL,
+ url TEXT NOT NULL,
+ summary TEXT,
+ published_at TIMESTAMPTZ,
+ sentiment TEXT, -- positive, negative, neutral, mixed
+ sentiment_score REAL, -- -1 to 1
+ relevance_score REAL DEFAULT 0, -- 0-100 relevance to petition topics
+ matched_markets TEXT[], -- Kalshi market tickers this article relates to
+ matched_petitions UUID[], -- Petition IDs this article relates to
+ tags TEXT[],
+ raw_data JSONB,
+ created_at TIMESTAMPTZ DEFAULT NOW(),
+ UNIQUE(feed_id, url)
+);
+
+-- Cross-platform petition posts
+CREATE TABLE IF NOT EXISTS orbit_posts (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ petition_id UUID REFERENCES petitions(id) ON DELETE CASCADE,
+ platform TEXT NOT NULL, -- moveon, change_org, we_the_people, custom
+ external_url TEXT,
+ status TEXT DEFAULT 'draft', -- draft, posted, failed, archived
+ post_data JSONB, -- platform-specific payload
+ response_data JSONB, -- platform response
+ posted_at TIMESTAMPTZ,
+ created_at TIMESTAMPTZ DEFAULT NOW()
+);
+
+-- Indexes
+CREATE INDEX IF NOT EXISTS idx_orbit_markets_category ON orbit_markets(category);
+CREATE INDEX IF NOT EXISTS idx_orbit_markets_relevance ON orbit_markets(petition_relevance DESC);
+CREATE INDEX IF NOT EXISTS idx_orbit_markets_status ON orbit_markets(status);
+CREATE INDEX IF NOT EXISTS idx_orbit_links_market ON orbit_links(market_id);
+CREATE INDEX IF NOT EXISTS idx_orbit_links_petition ON orbit_links(petition_id);
+CREATE INDEX IF NOT EXISTS idx_orbit_articles_sentiment ON orbit_articles(sentiment_score);
+CREATE INDEX IF NOT EXISTS idx_orbit_articles_published ON orbit_articles(published_at DESC);
+CREATE INDEX IF NOT EXISTS idx_orbit_articles_feed ON orbit_articles(feed_id);
+CREATE INDEX IF NOT EXISTS idx_orbit_feeds_active ON orbit_feeds(is_active);
+CREATE INDEX IF NOT EXISTS idx_orbit_posts_platform ON orbit_posts(platform);
+CREATE INDEX IF NOT EXISTS idx_orbit_posts_petition ON orbit_posts(petition_id);
+
+-- Trigger for orbit_markets updated_at
+CREATE OR REPLACE FUNCTION update_updated_at_column() RETURNS TRIGGER AS $$
+BEGIN NEW.updated_at = NOW(); RETURN NEW; END;
+$$ LANGUAGE plpgsql;
+
+DO $$
+BEGIN
+ IF NOT EXISTS (
+ SELECT 1 FROM pg_trigger WHERE tgname = 'tr_orbit_markets_updated'
+ ) THEN
+ CREATE TRIGGER tr_orbit_markets_updated
+ BEFORE UPDATE ON orbit_markets
+ FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
+ END IF;
+END $$;
diff --git a/db/orbit-seed-feeds.sql b/db/orbit-seed-feeds.sql
new file mode 100644
index 0000000..3178b20
--- /dev/null
+++ b/db/orbit-seed-feeds.sql
@@ -0,0 +1,33 @@
+-- Orbit: Seed RSS Feeds
+-- National TV, Wire Services, and Local TV (top 10 DMAs)
+
+INSERT INTO orbit_feeds (name, url, feed_type, category, station) VALUES
+ ('CNN Top Stories', 'https://rss.cnn.com/rss/cnn_topstories.rss', 'rss', 'national', 'CNN'),
+ ('CNN Politics', 'https://rss.cnn.com/rss/cnn_allpolitics.rss', 'rss', 'national', 'CNN'),
+ ('Fox News Latest', 'https://moxie.foxnews.com/google-publisher/latest.xml', 'rss', 'national', 'Fox News'),
+ ('Fox News Politics', 'https://moxie.foxnews.com/google-publisher/politics.xml', 'rss', 'national', 'Fox News'),
+ ('MSNBC Latest', 'https://www.msnbc.com/feeds/latest', 'rss', 'national', 'MSNBC'),
+ ('NBC News', 'https://feeds.nbcnews.com/nbcnews/public/news', 'rss', 'national', 'NBC News'),
+ ('ABC News Top Stories', 'https://abcnews.go.com/abcnews/topstories', 'rss', 'national', 'ABC News'),
+ ('CBS News', 'https://www.cbsnews.com/latest/rss/main', 'rss', 'national', 'CBS News'),
+ ('PBS NewsHour', 'https://www.pbs.org/newshour/feeds/rss/headlines', 'rss', 'national', 'PBS'),
+ ('NPR News', 'https://feeds.npr.org/1001/rss.xml', 'rss', 'national', 'NPR')
+ON CONFLICT (url) DO NOTHING;
+
+INSERT INTO orbit_feeds (name, url, feed_type, category, station) VALUES
+ ('AP News', 'https://rsshub.app/apnews/topics/apf-topnews', 'rss', 'wire', 'AP'),
+ ('Reuters', 'https://www.reutersagency.com/feed/', 'rss', 'wire', 'Reuters')
+ON CONFLICT (url) DO NOTHING;
+
+INSERT INTO orbit_feeds (name, url, feed_type, category, station, market) VALUES
+ ('WABC New York', 'https://abc7ny.com/feed/', 'rss', 'local', 'WABC', 'New York'),
+ ('KABC Los Angeles', 'https://abc7.com/feed/', 'rss', 'local', 'KABC', 'Los Angeles'),
+ ('WLS Chicago', 'https://abc7chicago.com/feed/', 'rss', 'local', 'WLS', 'Chicago'),
+ ('KTRK Houston', 'https://abc13.com/feed/', 'rss', 'local', 'KTRK', 'Houston'),
+ ('KXAS Dallas', 'https://www.nbcdfw.com/news/local/?rss=y', 'rss', 'local', 'KXAS', 'Dallas'),
+ ('WPVI Philadelphia', 'https://6abc.com/feed/', 'rss', 'local', 'WPVI', 'Philadelphia'),
+ ('WJLA Washington DC', 'https://wjla.com/feed', 'rss', 'local', 'WJLA', 'Washington DC'),
+ ('WSB Atlanta', 'https://www.wsbtv.com/news/local/?rss=y', 'rss', 'local', 'WSB', 'Atlanta'),
+ ('KPIX San Francisco', 'https://www.cbsnews.com/sanfrancisco/latest/rss/main', 'rss', 'local', 'KPIX', 'San Francisco'),
+ ('WFTS Tampa', 'https://www.abcactionnews.com/feeds/rss', 'rss', 'local', 'WFTS', 'Tampa')
+ON CONFLICT (url) DO NOTHING;
diff --git a/db/schema.sql b/db/schema.sql
new file mode 100644
index 0000000..c908501
--- /dev/null
+++ b/db/schema.sql
@@ -0,0 +1,159 @@
+-- Patty - The Petition Specialist
+-- Database Schema
+-- Database: postgresql://dw_admin@127.0.0.1:5432/ # password in .env.local (DATABASE_URL)
+-- patty
+
+CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
+
+CREATE TABLE users (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ google_id TEXT UNIQUE,
+ email TEXT NOT NULL UNIQUE,
+ display_name TEXT,
+ avatar_url TEXT,
+ org_name TEXT,
+ org_type TEXT CHECK (org_type IN ('individual', '501c3', '501c4', 'for_profit', 'other')),
+ address TEXT,
+ phone TEXT,
+ website TEXT,
+ gmail_tokens JSONB,
+ gdrive_tokens JSONB,
+ onboarding_step INTEGER DEFAULT 0,
+ is_admin BOOLEAN DEFAULT false,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE TABLE petitions (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ title TEXT NOT NULL,
+ slug TEXT UNIQUE NOT NULL,
+ summary TEXT,
+ body_html TEXT NOT NULL,
+ body_text TEXT,
+ target TEXT,
+ target_emails TEXT[],
+ category TEXT,
+ tags TEXT[],
+ image_url TEXT,
+ status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'active', 'paused', 'closed', 'delivered')),
+ signature_goal INTEGER DEFAULT 100,
+ signature_count INTEGER DEFAULT 0,
+ share_count INTEGER DEFAULT 0,
+ org_type TEXT,
+ compliance_flags JSONB DEFAULT '{}',
+ ai_trend_score REAL,
+ ai_source TEXT,
+ ai_topic_data JSONB,
+ distribution_platform TEXT DEFAULT 'internal',
+ external_url TEXT,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX idx_petitions_user ON petitions(user_id);
+CREATE INDEX idx_petitions_status ON petitions(status);
+CREATE INDEX idx_petitions_slug ON petitions(slug);
+
+CREATE TABLE signatures (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ petition_id UUID NOT NULL REFERENCES petitions(id) ON DELETE CASCADE,
+ signer_email TEXT NOT NULL,
+ signer_name TEXT,
+ signer_zip TEXT,
+ signer_comment TEXT,
+ is_public BOOLEAN DEFAULT true,
+ ip_address TEXT,
+ source TEXT DEFAULT 'web',
+ opted_in_email BOOLEAN DEFAULT false,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ UNIQUE(petition_id, signer_email)
+);
+
+CREATE INDEX idx_signatures_petition ON signatures(petition_id);
+
+CREATE TABLE email_subscribers (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ user_id UUID REFERENCES users(id) ON DELETE SET NULL,
+ email TEXT NOT NULL,
+ name TEXT,
+ zip_code TEXT,
+ source TEXT DEFAULT 'signup',
+ petition_ids UUID[],
+ tags TEXT[],
+ is_active BOOLEAN DEFAULT true,
+ unsubscribed_at TIMESTAMPTZ,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE TABLE email_campaigns (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ petition_id UUID REFERENCES petitions(id) ON DELETE SET NULL,
+ subject TEXT NOT NULL,
+ body_html TEXT NOT NULL,
+ body_text TEXT,
+ status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'scheduled', 'sending', 'sent', 'failed')),
+ send_to TEXT DEFAULT 'all',
+ recipient_count INTEGER DEFAULT 0,
+ open_count INTEGER DEFAULT 0,
+ click_count INTEGER DEFAULT 0,
+ scheduled_at TIMESTAMPTZ,
+ sent_at TIMESTAMPTZ,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE TABLE trending_topics (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ source TEXT NOT NULL,
+ source_url TEXT,
+ title TEXT NOT NULL,
+ content TEXT,
+ engagement_score REAL,
+ sentiment TEXT,
+ category TEXT,
+ tags TEXT[],
+ raw_data JSONB,
+ is_used BOOLEAN DEFAULT false,
+ expires_at TIMESTAMPTZ,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX idx_trends_source ON trending_topics(source);
+CREATE INDEX idx_trends_score ON trending_topics(engagement_score DESC);
+
+CREATE TABLE petition_templates (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ title TEXT NOT NULL,
+ category TEXT,
+ body_html TEXT NOT NULL,
+ body_text TEXT,
+ tags TEXT[],
+ usage_count INTEGER DEFAULT 0,
+ is_featured BOOLEAN DEFAULT false,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE TABLE audit_events (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ event_type TEXT NOT NULL,
+ entity_type TEXT NOT NULL,
+ entity_id UUID,
+ actor TEXT DEFAULT 'system',
+ metadata JSONB,
+ ip_address TEXT,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+-- Triggers
+CREATE OR REPLACE FUNCTION update_updated_at_column() RETURNS TRIGGER AS $$ BEGIN NEW.updated_at = NOW(); RETURN NEW; END; $$ LANGUAGE plpgsql;
+
+CREATE TRIGGER tr_users_updated BEFORE UPDATE ON users FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
+CREATE TRIGGER tr_petitions_updated BEFORE UPDATE ON petitions FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
+CREATE TRIGGER tr_subscribers_updated BEFORE UPDATE ON email_subscribers FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
+CREATE TRIGGER tr_campaigns_updated BEFORE UPDATE ON email_campaigns FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
+CREATE TRIGGER tr_templates_updated BEFORE UPDATE ON petition_templates FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
diff --git a/ecosystem.config.js b/ecosystem.config.js
new file mode 100644
index 0000000..5c2d7fe
--- /dev/null
+++ b/ecosystem.config.js
@@ -0,0 +1,15 @@
+module.exports = {
+ apps: [{
+ name: 'patty-petitions',
+ script: 'node_modules/.bin/next',
+ args: 'start -p 7460',
+ cwd: '/root/Projects/Patty',
+ env: {
+ NODE_ENV: 'production',
+ PORT: '7460',
+ },
+ max_memory_restart: '512M',
+ instances: 1,
+ autorestart: true,
+ }],
+};
diff --git a/hooks/useClientSort.ts b/hooks/useClientSort.ts
new file mode 100644
index 0000000..8cd5401
--- /dev/null
+++ b/hooks/useClientSort.ts
@@ -0,0 +1,34 @@
+import { useMemo } from 'react';
+
+export interface SortConfig {
+ key: string;
+ direction: 'asc' | 'desc';
+ type: 'string' | 'number' | 'date';
+}
+
+export function useClientSort<T>(
+ items: T[],
+ sortBy: string,
+ configs: Record<string, SortConfig>
+): T[] {
+ return useMemo(() => {
+ const config = configs[sortBy];
+ if (!config || !items.length) return items;
+ return [...items].sort((a, b) => {
+ const aVal = (a as Record<string, unknown>)[config.key];
+ const bVal = (b as Record<string, unknown>)[config.key];
+ if (aVal == null && bVal == null) return 0;
+ if (aVal == null) return 1;
+ if (bVal == null) return -1;
+ let cmp = 0;
+ if (config.type === 'date') {
+ cmp = new Date(aVal as string).getTime() - new Date(bVal as string).getTime();
+ } else if (config.type === 'number') {
+ cmp = (Number(aVal) || 0) - (Number(bVal) || 0);
+ } else {
+ cmp = String(aVal).localeCompare(String(bVal));
+ }
+ return config.direction === 'desc' ? -cmp : cmp;
+ });
+ }, [items, sortBy, configs]);
+}
diff --git a/hooks/useDebounce.ts b/hooks/useDebounce.ts
new file mode 100644
index 0000000..5f07bef
--- /dev/null
+++ b/hooks/useDebounce.ts
@@ -0,0 +1,10 @@
+import { useState, useEffect } from 'react';
+
+export function useDebounce<T>(value: T, delay: number = 300): T {
+ const [debounced, setDebounced] = useState(value);
+ useEffect(() => {
+ const timer = setTimeout(() => setDebounced(value), delay);
+ return () => clearTimeout(timer);
+ }, [value, delay]);
+ return debounced;
+}
diff --git a/lib/audit.ts b/lib/audit.ts
new file mode 100644
index 0000000..3327122
--- /dev/null
+++ b/lib/audit.ts
@@ -0,0 +1,36 @@
+import { query } from './db';
+
+/**
+ * Log an audit event to the audit_events table.
+ *
+ * @param eventType - e.g. 'petition.created', 'signature.added', 'login', 'campaign.sent'
+ * @param entityType - e.g. 'petition', 'user', 'campaign', 'signature'
+ * @param entityId - primary key of the affected entity (nullable)
+ * @param metadata - arbitrary JSON payload with extra context
+ * @param ipAddress - client IP if available
+ */
+export async function auditLog(
+ eventType: string,
+ entityType: string,
+ entityId: string | null,
+ 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) {
+ // Audit failures should never crash the calling operation
+ console.error('[audit] Failed to write audit event:', (err as Error).message);
+ }
+}
diff --git a/lib/auth.ts b/lib/auth.ts
new file mode 100644
index 0000000..7191f61
--- /dev/null
+++ b/lib/auth.ts
@@ -0,0 +1,10 @@
+import { createAuth } from '@dw/nextjs-admin-login';
+
+export const auth = createAuth({ cookieName: 'patty-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..cd2939c
--- /dev/null
+++ b/lib/db.ts
@@ -0,0 +1,45 @@
+import { Pool, PoolClient, QueryResult, QueryResultRow } from 'pg';
+
+const pool = new Pool({
+ connectionString: process.env.DATABASE_URL,
+ max: 10,
+ idleTimeoutMillis: 30000,
+ connectionTimeoutMillis: 5000,
+});
+
+// Log pool errors so they don't crash the process
+pool.on('error', (err: Error) => {
+ console.error('[db] Unexpected pool error:', err.message);
+});
+
+/**
+ * Execute a parameterized query against the pool.
+ * Returns the full QueryResult so callers can read .rows, .rowCount, etc.
+ */
+export async function query<T extends QueryResultRow = QueryResultRow>(
+ text: string,
+ params?: unknown[],
+): Promise<QueryResult<T>> {
+ const start = Date.now();
+ try {
+ const result = await pool.query<T>(text, params);
+ const duration = Date.now() - start;
+ if (duration > 2000) {
+ console.warn(`[db] Slow query (${duration}ms):`, text.slice(0, 120));
+ }
+ return result;
+ } catch (err) {
+ console.error('[db] Query error:', (err as Error).message, '\n SQL:', text.slice(0, 200));
+ throw err;
+ }
+}
+
+/**
+ * Acquire a dedicated client from the pool — use for transactions.
+ * IMPORTANT: Always call client.release() in a finally block.
+ */
+export async function getClient(): Promise<PoolClient> {
+ return pool.connect();
+}
+
+export default pool;
diff --git a/lib/gemini.ts b/lib/gemini.ts
new file mode 100644
index 0000000..3fe075f
--- /dev/null
+++ b/lib/gemini.ts
@@ -0,0 +1,111 @@
+/**
+ * Gemini 2.0 Flash wrapper — single source of truth for Patty's 7 AI call
+ * sites: petitions/generate, campaigns/generate, trending/discover,
+ * orbit/{rss,suggest-alliance,batch-link,generate}.
+ *
+ * 2026-05-05 (tick 27): ported from Grant's lib/gemini.ts after the
+ * architect-reviewer flagged 7x duplication of (key + URL + AbortSignal +
+ * fetch + parse + fence-strip) across the same routes that just got their
+ * hardcoded Gemini key scrubbed. This wrapper forces env discipline — if
+ * GEMINI_API_KEY isn't set, every callsite gets a 503 reason='no_key'
+ * instead of silently calling Google with key=''.
+ *
+ * Returns a Result-shaped object instead of throwing — callers map status
+ * codes (504 abort / 502 upstream / 500 parse). Generic <T> is the typed
+ * shape for parsed JSON when parseJson=true; raw string is also returned.
+ */
+
+const GEMINI_KEY = process.env.GEMINI_API_KEY;
+const MODEL = 'gemini-2.0-flash';
+const GEMINI_URL = `https://generativelanguage.googleapis.com/v1beta/models/${MODEL}:generateContent`;
+
+export type GeminiOk<T> = {
+ ok: true;
+ data: T;
+ raw: string;
+ inputTokens: number;
+ outputTokens: number;
+};
+
+export type GeminiErr = {
+ ok: false;
+ reason: 'no_key' | 'timeout' | 'upstream' | 'parse';
+ status: number;
+ detail?: string;
+};
+
+export type GeminiResult<T> = GeminiOk<T> | GeminiErr;
+
+export async function callGemini<T = unknown>(opts: {
+ prompt: string;
+ maxTokens?: number;
+ temperature?: number;
+ timeoutMs?: number;
+ parseJson?: boolean;
+}): Promise<GeminiResult<T>> {
+ if (!GEMINI_KEY) {
+ return { ok: false, reason: 'no_key', status: 503, detail: 'GEMINI_API_KEY env unset' };
+ }
+
+ const {
+ prompt,
+ maxTokens = 4096,
+ temperature = 0.7,
+ timeoutMs = 25_000,
+ parseJson = true,
+ } = opts;
+
+ let res: Response;
+ try {
+ res = await fetch(`${GEMINI_URL}?key=${GEMINI_KEY}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ contents: [{ parts: [{ text: prompt }] }],
+ generationConfig: { temperature, maxOutputTokens: maxTokens },
+ }),
+ signal: AbortSignal.timeout(timeoutMs),
+ });
+ } catch (e) {
+ return { ok: false, reason: 'timeout', status: 504, detail: (e as Error).message };
+ }
+
+ if (!res.ok) {
+ const detail = await res.text().catch(() => '');
+ console.error('[lib/gemini] upstream error:', res.status, detail.slice(0, 500));
+ return { ok: false, reason: 'upstream', status: 502, detail: `gemini ${res.status}` };
+ }
+
+ const data = await res.json();
+ const raw: string = data.candidates?.[0]?.content?.parts?.[0]?.text ?? '';
+ const usage = data.usageMetadata ?? {};
+ const inputTokens: number = usage.promptTokenCount ?? 0;
+ const outputTokens: number = usage.candidatesTokenCount ?? 0;
+
+ if (!parseJson) {
+ return {
+ ok: true,
+ data: raw as unknown as T,
+ raw,
+ inputTokens,
+ outputTokens,
+ };
+ }
+
+ // Strip ```json fences then JSON.parse. Caller is responsible for
+ // structural validation of the parsed payload.
+ const cleaned = raw.replace(/```json\s*/gi, '').replace(/```\s*/g, '').trim();
+ try {
+ const parsed = JSON.parse(cleaned) as T;
+ return {
+ ok: true,
+ data: parsed,
+ raw,
+ inputTokens,
+ outputTokens,
+ };
+ } catch (e) {
+ console.error('[lib/gemini] parse error:', cleaned.slice(0, 500));
+ return { ok: false, reason: 'parse', status: 500, detail: (e as Error).message };
+ }
+}
diff --git a/lib/sanitize.ts b/lib/sanitize.ts
new file mode 100644
index 0000000..ab24261
--- /dev/null
+++ b/lib/sanitize.ts
@@ -0,0 +1,66 @@
+/**
+ * lib/sanitize.ts — petition body_html allowlist sanitizer.
+ *
+ * 2026-05-05 (architect-reviewer P1 + tick 27): replaces the handwritten
+ * regex-based sanitizeHtml() in app/petitions/[slug]/page.tsx (which only
+ * stripped <script>/<iframe>/on* and was bypassable via SVG, data: URIs,
+ * mathml, malformed nested tags, etc.).
+ *
+ * Sanitization happens at TWO boundaries:
+ * 1. POST/PATCH /api/petitions — sanitize before INSERT/UPDATE so the
+ * database itself is the trust boundary
+ * 2. Render — sanitize again as defense-in-depth (cheap, idempotent)
+ *
+ * Why a SHARED module: keeps the allowlist in one place. Drift between
+ * "what we accept" and "what we render" is exactly the gap that lets
+ * stored XSS slip through.
+ */
+
+import sanitizeHtmlLib, { type IOptions } from 'sanitize-html';
+
+export const PETITION_HTML_OPTS: IOptions = {
+ // Tags suitable for petition body content. Includes basic typography,
+ // lists, headings, blockquote, links, and hr. NO <img> (would let users
+ // beacon out to attacker domains), NO <iframe>, NO <script>, NO <style>.
+ allowedTags: [
+ 'p', 'br', 'hr',
+ 'strong', 'em', 'b', 'i', 'u', 's', 'mark',
+ 'ul', 'ol', 'li',
+ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
+ 'blockquote', 'pre', 'code',
+ 'a',
+ ],
+ allowedAttributes: {
+ a: ['href', 'title', 'target', 'rel'],
+ },
+ allowedSchemes: ['http', 'https', 'mailto'],
+ allowedSchemesAppliedToAttributes: ['href'],
+ // Force every <a> to open externally with rel=noopener,noreferrer
+ transformTags: {
+ a: (tagName, attribs) => ({
+ tagName,
+ attribs: {
+ ...attribs,
+ target: '_blank',
+ rel: 'noopener noreferrer',
+ },
+ }),
+ },
+ disallowedTagsMode: 'discard',
+};
+
+export function sanitizePetitionBody(html: string): string {
+ if (typeof html !== 'string') return '';
+ return sanitizeHtmlLib(html, PETITION_HTML_OPTS);
+}
+
+/**
+ * Hard size cap for body_html — 200 KB is a generous limit for a petition
+ * (~50,000 words of plain text, far more for HTML overhead). Anything
+ * larger is suspicious (cost amplification, DoS via huge prompt).
+ */
+export const MAX_BODY_HTML_BYTES = 200_000;
+
+export function bodyHtmlTooLarge(html: unknown): boolean {
+ return typeof html !== 'string' || html.length > MAX_BODY_HTML_BYTES;
+}
diff --git a/lib/sister-auth.ts b/lib/sister-auth.ts
new file mode 100644
index 0000000..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/middleware.ts b/middleware.ts
new file mode 100644
index 0000000..ea34d78
--- /dev/null
+++ b/middleware.ts
@@ -0,0 +1,98 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { verifyAuth } from '@/lib/auth';
+
+/**
+ * Patty middleware — auth gate, request-id injection, rate-limit on AI routes.
+ *
+ * 2026-05-05 (architect-reviewer + tick 27): collapses 40 copies of
+ * `verifyAuth(request); if (!user) return 401;` across 23 routes into one
+ * place. Adds `x-request-id` for log correlation. Adds in-memory sliding-
+ * window rate-limit on the 7 Gemini routes (was 4 in Grant; Patty has
+ * more AI surface area).
+ *
+ * Key difference from Grant's middleware: Patty stays on the legacy
+ * `verifyAuth` (returns username string) — Patty's data model is single-
+ * tenant per-user (no organizations table), so the orgId fast-path Grant
+ * uses doesn't apply yet. Architect explicitly recommended SKIP for now.
+ *
+ * Runtime: Node (verifyAuth uses node:crypto). Next.js 16 supports
+ * Node runtime middleware natively.
+ */
+
+export const config = {
+ runtime: 'nodejs',
+ matcher: ['/api/:path*'],
+};
+
+// 2026-05-05 (P1-1): in-memory sliding-window rate-limit. Per-user-per-route
+// counters live in a Map; entries expire after WINDOW_MS. v1 implementation —
+// survives a single-process pm2 restart but not horizontal scale.
+type Bucket = { count: number; resetAt: number };
+const buckets: Map<string, Bucket> = new Map();
+const WINDOW_MS = 60_000;
+const AI_LIMIT_PER_MINUTE = 10;
+
+const AI_ROUTES = new Set([
+ '/api/petitions/generate',
+ '/api/campaigns/generate',
+ '/api/trending/discover',
+ '/api/orbit/rss',
+ '/api/orbit/suggest-alliance',
+ '/api/orbit/batch-link',
+ '/api/orbit/generate',
+]);
+function isAiRoute(pathname: string): boolean {
+ return AI_ROUTES.has(pathname);
+}
+
+// 2026-05-05 (architect P1): public-bypass set INCLUDES /api/public/signatures
+// — Patty's only unauthenticated write endpoint (signature submission from
+// petition pages). Don't gate it behind auth or no one can sign.
+const PUBLIC_API = new Set([
+ '/api/auth/login',
+ '/api/auth/logout',
+ '/api/auth/session',
+ '/api/public/signatures',
+ '/api/health',
+]);
+
+export function middleware(request: NextRequest) {
+ const { pathname } = request.nextUrl;
+
+ // Public endpoints bypass the gate.
+ if (PUBLIC_API.has(pathname)) {
+ return injectRequestId(NextResponse.next(), request);
+ }
+
+ // Auth gate for everything else under /api/**.
+ const username = verifyAuth(request);
+ if (!username) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ // Rate-limit the Gemini-paid AI routes. Key by username (single-tenant).
+ if (isAiRoute(pathname)) {
+ const key = `${username}:${pathname}`;
+ const now = Date.now();
+ const b = buckets.get(key);
+ if (!b || b.resetAt < now) {
+ buckets.set(key, { count: 1, resetAt: now + WINDOW_MS });
+ } else if (b.count >= AI_LIMIT_PER_MINUTE) {
+ return NextResponse.json(
+ { error: 'rate_limited', retry_after_ms: b.resetAt - now },
+ { status: 429, headers: { 'retry-after': Math.ceil((b.resetAt - now) / 1000).toString() } },
+ );
+ } else {
+ b.count++;
+ }
+ }
+
+ return injectRequestId(NextResponse.next(), request);
+}
+
+function injectRequestId(res: NextResponse, request: NextRequest): NextResponse {
+ const incoming = request.headers.get('x-request-id');
+ const reqId = incoming || crypto.randomUUID();
+ res.headers.set('x-request-id', reqId);
+ return res;
+}
diff --git a/next.config.ts b/next.config.ts
new file mode 100644
index 0000000..e9ffa30
--- /dev/null
+++ b/next.config.ts
@@ -0,0 +1,7 @@
+import type { NextConfig } from "next";
+
+const nextConfig: NextConfig = {
+ /* config options here */
+};
+
+export default nextConfig;
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..a613e23
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,1997 @@
+{
+ "name": "patty-petitions",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "patty-petitions",
+ "version": "0.1.0",
+ "dependencies": {
+ "@dw/nextjs-admin-login": "file:/tmp/dw-nextjs-admin-login-0.2.0.tgz",
+ "@types/pg": "^8.16.0",
+ "@types/sanitize-html": "^2.16.1",
+ "lucide-react": "^0.575.0",
+ "next": "16.1.6",
+ "pg": "^8.19.0",
+ "react": "19.2.3",
+ "react-dom": "19.2.3",
+ "sanitize-html": "^2.17.3"
+ },
+ "devDependencies": {
+ "@tailwindcss/postcss": "^4",
+ "@types/node": "^20",
+ "@types/react": "^19",
+ "@types/react-dom": "^19",
+ "tailwindcss": "^4",
+ "typescript": "^5"
+ }
+ },
+ "node_modules/@alloc/quick-lru": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
+ "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@dw/nextjs-admin-login": {
+ "version": "0.2.0",
+ "resolved": "file:../../../../tmp/dw-nextjs-admin-login-0.2.0.tgz",
+ "peerDependencies": {
+ "next": "^15.0.0 || ^16.0.0"
+ }
+ },
+ "node_modules/@emnapi/runtime": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz",
+ "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@img/colour": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz",
+ "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@img/sharp-darwin-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
+ "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-arm64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-darwin-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
+ "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-x64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-arm64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
+ "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-x64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
+ "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
+ "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
+ "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-ppc64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
+ "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-riscv64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
+ "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-s390x": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
+ "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-x64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
+ "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-arm64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
+ "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-x64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
+ "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
+ "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
+ "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-ppc64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
+ "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-ppc64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-riscv64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
+ "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-riscv64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-s390x": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
+ "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
+ "cpu": [
+ "s390x"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-s390x": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
+ "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-x64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
+ "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
+ "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-x64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-wasm32": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
+ "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
+ "cpu": [
+ "wasm32"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/runtime": "^1.7.0"
+ },
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
+ "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-ia32": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
+ "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
+ "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@next/env": {
+ "version": "16.1.6",
+ "resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.6.tgz",
+ "integrity": "sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ==",
+ "license": "MIT"
+ },
+ "node_modules/@next/swc-darwin-arm64": {
+ "version": "16.1.6",
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.6.tgz",
+ "integrity": "sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-darwin-x64": {
+ "version": "16.1.6",
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.6.tgz",
+ "integrity": "sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-arm64-gnu": {
+ "version": "16.1.6",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.6.tgz",
+ "integrity": "sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-arm64-musl": {
+ "version": "16.1.6",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.6.tgz",
+ "integrity": "sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-x64-gnu": {
+ "version": "16.1.6",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.6.tgz",
+ "integrity": "sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-x64-musl": {
+ "version": "16.1.6",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.6.tgz",
+ "integrity": "sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-win32-arm64-msvc": {
+ "version": "16.1.6",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.6.tgz",
+ "integrity": "sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-win32-x64-msvc": {
+ "version": "16.1.6",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.6.tgz",
+ "integrity": "sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@swc/helpers": {
+ "version": "0.5.15",
+ "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
+ "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.8.0"
+ }
+ },
+ "node_modules/@tailwindcss/node": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.1.tgz",
+ "integrity": "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/remapping": "^2.3.5",
+ "enhanced-resolve": "^5.19.0",
+ "jiti": "^2.6.1",
+ "lightningcss": "1.31.1",
+ "magic-string": "^0.30.21",
+ "source-map-js": "^1.2.1",
+ "tailwindcss": "4.2.1"
+ }
+ },
+ "node_modules/@tailwindcss/oxide": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.1.tgz",
+ "integrity": "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 20"
+ },
+ "optionalDependencies": {
+ "@tailwindcss/oxide-android-arm64": "4.2.1",
+ "@tailwindcss/oxide-darwin-arm64": "4.2.1",
+ "@tailwindcss/oxide-darwin-x64": "4.2.1",
+ "@tailwindcss/oxide-freebsd-x64": "4.2.1",
+ "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1",
+ "@tailwindcss/oxide-linux-arm64-gnu": "4.2.1",
+ "@tailwindcss/oxide-linux-arm64-musl": "4.2.1",
+ "@tailwindcss/oxide-linux-x64-gnu": "4.2.1",
+ "@tailwindcss/oxide-linux-x64-musl": "4.2.1",
+ "@tailwindcss/oxide-wasm32-wasi": "4.2.1",
+ "@tailwindcss/oxide-win32-arm64-msvc": "4.2.1",
+ "@tailwindcss/oxide-win32-x64-msvc": "4.2.1"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-android-arm64": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.1.tgz",
+ "integrity": "sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-darwin-arm64": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.1.tgz",
+ "integrity": "sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-darwin-x64": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.1.tgz",
+ "integrity": "sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-freebsd-x64": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.1.tgz",
+ "integrity": "sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.1.tgz",
+ "integrity": "sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.1.tgz",
+ "integrity": "sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm64-musl": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.1.tgz",
+ "integrity": "sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-x64-gnu": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.1.tgz",
+ "integrity": "sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-x64-musl": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.1.tgz",
+ "integrity": "sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.1.tgz",
+ "integrity": "sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==",
+ "bundleDependencies": [
+ "@napi-rs/wasm-runtime",
+ "@emnapi/core",
+ "@emnapi/runtime",
+ "@tybys/wasm-util",
+ "@emnapi/wasi-threads",
+ "tslib"
+ ],
+ "cpu": [
+ "wasm32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/core": "^1.8.1",
+ "@emnapi/runtime": "^1.8.1",
+ "@emnapi/wasi-threads": "^1.1.0",
+ "@napi-rs/wasm-runtime": "^1.1.1",
+ "@tybys/wasm-util": "^0.10.1",
+ "tslib": "^2.8.1"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.1.tgz",
+ "integrity": "sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-win32-x64-msvc": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.1.tgz",
+ "integrity": "sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/postcss": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.2.1.tgz",
+ "integrity": "sha512-OEwGIBnXnj7zJeonOh6ZG9woofIjGrd2BORfvE5p9USYKDCZoQmfqLcfNiRWoJlRWLdNPn2IgVZuWAOM4iTYMw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@alloc/quick-lru": "^5.2.0",
+ "@tailwindcss/node": "4.2.1",
+ "@tailwindcss/oxide": "4.2.1",
+ "postcss": "^8.5.6",
+ "tailwindcss": "4.2.1"
+ }
+ },
+ "node_modules/@types/node": {
+ "version": "20.19.35",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.35.tgz",
+ "integrity": "sha512-Uarfe6J91b9HAUXxjvSOdiO2UPOKLm07Q1oh0JHxoZ1y8HoqxDAu3gVrsrOHeiio0kSsoVBt4wFrKOm0dKxVPQ==",
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~6.21.0"
+ }
+ },
+ "node_modules/@types/pg": {
+ "version": "8.16.0",
+ "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.16.0.tgz",
+ "integrity": "sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*",
+ "pg-protocol": "*",
+ "pg-types": "^2.2.0"
+ }
+ },
+ "node_modules/@types/react": {
+ "version": "19.2.14",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
+ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "csstype": "^3.2.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "19.2.3",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
+ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "^19.2.0"
+ }
+ },
+ "node_modules/@types/sanitize-html": {
+ "version": "2.16.1",
+ "resolved": "https://registry.npmjs.org/@types/sanitize-html/-/sanitize-html-2.16.1.tgz",
+ "integrity": "sha512-n9wjs8bCOTyN/ynwD8s/nTcTreIHB1vf31vhLMGqUPNHaweKC4/fAl4Dj+hUlCTKYgm4P3k83fmiFfzkZ6sgMA==",
+ "license": "MIT",
+ "dependencies": {
+ "htmlparser2": "^10.1"
+ }
+ },
+ "node_modules/baseline-browser-mapping": {
+ "version": "2.10.0",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz",
+ "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==",
+ "license": "Apache-2.0",
+ "bin": {
+ "baseline-browser-mapping": "dist/cli.cjs"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/caniuse-lite": {
+ "version": "1.0.30001774",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001774.tgz",
+ "integrity": "sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "CC-BY-4.0"
+ },
+ "node_modules/client-only": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
+ "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
+ "license": "MIT"
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/deepmerge": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
+ "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "devOptional": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/dom-serializer": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
+ "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
+ "license": "MIT",
+ "dependencies": {
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.2",
+ "entities": "^4.2.0"
+ },
+ "funding": {
+ "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
+ }
+ },
+ "node_modules/dom-serializer/node_modules/entities": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
+ "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/domelementtype": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
+ "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ ],
+ "license": "BSD-2-Clause"
+ },
+ "node_modules/domhandler": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
+ "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "domelementtype": "^2.3.0"
+ },
+ "engines": {
+ "node": ">= 4"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/domhandler?sponsor=1"
+ }
+ },
+ "node_modules/domutils": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
+ "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "dom-serializer": "^2.0.0",
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.3"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/domutils?sponsor=1"
+ }
+ },
+ "node_modules/enhanced-resolve": {
+ "version": "5.19.0",
+ "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz",
+ "integrity": "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "graceful-fs": "^4.2.4",
+ "tapable": "^2.3.0"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/entities": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
+ "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/escape-string-regexp": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/graceful-fs": {
+ "version": "4.2.11",
+ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
+ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/htmlparser2": {
+ "version": "10.1.0",
+ "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
+ "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",
+ "funding": [
+ "https://github.com/fb55/htmlparser2?sponsor=1",
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.3",
+ "domutils": "^3.2.2",
+ "entities": "^7.0.1"
+ }
+ },
+ "node_modules/is-plain-object": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz",
+ "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/jiti": {
+ "version": "2.6.1",
+ "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
+ "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jiti": "lib/jiti-cli.mjs"
+ }
+ },
+ "node_modules/lightningcss": {
+ "version": "1.31.1",
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz",
+ "integrity": "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==",
+ "dev": true,
+ "license": "MPL-2.0",
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "lightningcss-android-arm64": "1.31.1",
+ "lightningcss-darwin-arm64": "1.31.1",
+ "lightningcss-darwin-x64": "1.31.1",
+ "lightningcss-freebsd-x64": "1.31.1",
+ "lightningcss-linux-arm-gnueabihf": "1.31.1",
+ "lightningcss-linux-arm64-gnu": "1.31.1",
+ "lightningcss-linux-arm64-musl": "1.31.1",
+ "lightningcss-linux-x64-gnu": "1.31.1",
+ "lightningcss-linux-x64-musl": "1.31.1",
+ "lightningcss-win32-arm64-msvc": "1.31.1",
+ "lightningcss-win32-x64-msvc": "1.31.1"
+ }
+ },
+ "node_modules/lightningcss-android-arm64": {
+ "version": "1.31.1",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.31.1.tgz",
+ "integrity": "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-arm64": {
+ "version": "1.31.1",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.31.1.tgz",
+ "integrity": "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-x64": {
+ "version": "1.31.1",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.31.1.tgz",
+ "integrity": "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-freebsd-x64": {
+ "version": "1.31.1",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.31.1.tgz",
+ "integrity": "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.31.1",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.31.1.tgz",
+ "integrity": "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.31.1",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.31.1.tgz",
+ "integrity": "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-musl": {
+ "version": "1.31.1",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.31.1.tgz",
+ "integrity": "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-gnu": {
+ "version": "1.31.1",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.31.1.tgz",
+ "integrity": "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.31.1",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.31.1.tgz",
+ "integrity": "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-arm64-msvc": {
+ "version": "1.31.1",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.31.1.tgz",
+ "integrity": "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.31.1",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.31.1.tgz",
+ "integrity": "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lucide-react": {
+ "version": "0.575.0",
+ "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.575.0.tgz",
+ "integrity": "sha512-VuXgKZrk0uiDlWjGGXmKV6MSk9Yy4l10qgVvzGn2AWBx1Ylt0iBexKOAoA6I7JO3m+M9oeovJd3yYENfkUbOeg==",
+ "license": "ISC",
+ "peerDependencies": {
+ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.11",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
+ "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/next": {
+ "version": "16.1.6",
+ "resolved": "https://registry.npmjs.org/next/-/next-16.1.6.tgz",
+ "integrity": "sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==",
+ "license": "MIT",
+ "dependencies": {
+ "@next/env": "16.1.6",
+ "@swc/helpers": "0.5.15",
+ "baseline-browser-mapping": "^2.8.3",
+ "caniuse-lite": "^1.0.30001579",
+ "postcss": "8.4.31",
+ "styled-jsx": "5.1.6"
+ },
+ "bin": {
+ "next": "dist/bin/next"
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "optionalDependencies": {
+ "@next/swc-darwin-arm64": "16.1.6",
+ "@next/swc-darwin-x64": "16.1.6",
+ "@next/swc-linux-arm64-gnu": "16.1.6",
+ "@next/swc-linux-arm64-musl": "16.1.6",
+ "@next/swc-linux-x64-gnu": "16.1.6",
+ "@next/swc-linux-x64-musl": "16.1.6",
+ "@next/swc-win32-arm64-msvc": "16.1.6",
+ "@next/swc-win32-x64-msvc": "16.1.6",
+ "sharp": "^0.34.4"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": "^1.1.0",
+ "@playwright/test": "^1.51.1",
+ "babel-plugin-react-compiler": "*",
+ "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
+ "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
+ "sass": "^1.3.0"
+ },
+ "peerDependenciesMeta": {
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "@playwright/test": {
+ "optional": true
+ },
+ "babel-plugin-react-compiler": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/next/node_modules/postcss": {
+ "version": "8.4.31",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
+ "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.6",
+ "picocolors": "^1.0.0",
+ "source-map-js": "^1.0.2"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/parse-srcset": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz",
+ "integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==",
+ "license": "MIT"
+ },
+ "node_modules/pg": {
+ "version": "8.19.0",
+ "resolved": "https://registry.npmjs.org/pg/-/pg-8.19.0.tgz",
+ "integrity": "sha512-QIcLGi508BAHkQ3pJNptsFz5WQMlpGbuBGBaIaXsWK8mel2kQ/rThYI+DbgjUvZrIr7MiuEuc9LcChJoEZK1xQ==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-connection-string": "^2.11.0",
+ "pg-pool": "^3.12.0",
+ "pg-protocol": "^1.12.0",
+ "pg-types": "2.2.0",
+ "pgpass": "1.0.5"
+ },
+ "engines": {
+ "node": ">= 16.0.0"
+ },
+ "optionalDependencies": {
+ "pg-cloudflare": "^1.3.0"
+ },
+ "peerDependencies": {
+ "pg-native": ">=3.0.1"
+ },
+ "peerDependenciesMeta": {
+ "pg-native": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/pg-cloudflare": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz",
+ "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/pg-connection-string": {
+ "version": "2.11.0",
+ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.11.0.tgz",
+ "integrity": "sha512-kecgoJwhOpxYU21rZjULrmrBJ698U2RxXofKVzOn5UDj61BPj/qMb7diYUR1nLScCDbrztQFl1TaQZT0t1EtzQ==",
+ "license": "MIT"
+ },
+ "node_modules/pg-int8": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
+ "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/pg-pool": {
+ "version": "3.12.0",
+ "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.12.0.tgz",
+ "integrity": "sha512-eIJ0DES8BLaziFHW7VgJEBPi5hg3Nyng5iKpYtj3wbcAUV9A1wLgWiY7ajf/f/oO1wfxt83phXPY8Emztg7ITg==",
+ "license": "MIT",
+ "peerDependencies": {
+ "pg": ">=8.0"
+ }
+ },
+ "node_modules/pg-protocol": {
+ "version": "1.12.0",
+ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.12.0.tgz",
+ "integrity": "sha512-uOANXNRACNdElMXJ0tPz6RBM0XQ61nONGAwlt8da5zs/iUOOCLBQOHSXnrC6fMsvtjxbOJrZZl5IScGv+7mpbg==",
+ "license": "MIT"
+ },
+ "node_modules/pg-types": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
+ "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-int8": "1.0.1",
+ "postgres-array": "~2.0.0",
+ "postgres-bytea": "~1.0.0",
+ "postgres-date": "~1.0.4",
+ "postgres-interval": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/pgpass": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
+ "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
+ "license": "MIT",
+ "dependencies": {
+ "split2": "^4.1.0"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "license": "ISC"
+ },
+ "node_modules/postcss": {
+ "version": "8.5.6",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
+ "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.11",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/postgres-array": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
+ "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/postgres-bytea": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
+ "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-date": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
+ "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-interval": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
+ "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "xtend": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react": {
+ "version": "19.2.3",
+ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
+ "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "19.2.3",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
+ "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
+ "license": "MIT",
+ "dependencies": {
+ "scheduler": "^0.27.0"
+ },
+ "peerDependencies": {
+ "react": "^19.2.3"
+ }
+ },
+ "node_modules/sanitize-html": {
+ "version": "2.17.3",
+ "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.3.tgz",
+ "integrity": "sha512-Kn4srCAo2+wZyvCNKCSyB2g8RQ8IkX/gQs2uqoSRNu5t9I2qvUyAVvRDiFUVAiX3N3PNuwStY0eNr+ooBHVWEg==",
+ "license": "MIT",
+ "dependencies": {
+ "deepmerge": "^4.2.2",
+ "escape-string-regexp": "^4.0.0",
+ "htmlparser2": "^10.1.0",
+ "is-plain-object": "^5.0.0",
+ "parse-srcset": "^1.0.2",
+ "postcss": "^8.3.11"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.27.0",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
+ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
+ "license": "MIT"
+ },
+ "node_modules/semver": {
+ "version": "7.7.4",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
+ "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
+ "license": "ISC",
+ "optional": true,
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/sharp": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
+ "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "dependencies": {
+ "@img/colour": "^1.0.0",
+ "detect-libc": "^2.1.2",
+ "semver": "^7.7.3"
+ },
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-darwin-arm64": "0.34.5",
+ "@img/sharp-darwin-x64": "0.34.5",
+ "@img/sharp-libvips-darwin-arm64": "1.2.4",
+ "@img/sharp-libvips-darwin-x64": "1.2.4",
+ "@img/sharp-libvips-linux-arm": "1.2.4",
+ "@img/sharp-libvips-linux-arm64": "1.2.4",
+ "@img/sharp-libvips-linux-ppc64": "1.2.4",
+ "@img/sharp-libvips-linux-riscv64": "1.2.4",
+ "@img/sharp-libvips-linux-s390x": "1.2.4",
+ "@img/sharp-libvips-linux-x64": "1.2.4",
+ "@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
+ "@img/sharp-libvips-linuxmusl-x64": "1.2.4",
+ "@img/sharp-linux-arm": "0.34.5",
+ "@img/sharp-linux-arm64": "0.34.5",
+ "@img/sharp-linux-ppc64": "0.34.5",
+ "@img/sharp-linux-riscv64": "0.34.5",
+ "@img/sharp-linux-s390x": "0.34.5",
+ "@img/sharp-linux-x64": "0.34.5",
+ "@img/sharp-linuxmusl-arm64": "0.34.5",
+ "@img/sharp-linuxmusl-x64": "0.34.5",
+ "@img/sharp-wasm32": "0.34.5",
+ "@img/sharp-win32-arm64": "0.34.5",
+ "@img/sharp-win32-ia32": "0.34.5",
+ "@img/sharp-win32-x64": "0.34.5"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/split2": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
+ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">= 10.x"
+ }
+ },
+ "node_modules/styled-jsx": {
+ "version": "5.1.6",
+ "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
+ "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==",
+ "license": "MIT",
+ "dependencies": {
+ "client-only": "0.0.1"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "peerDependencies": {
+ "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0"
+ },
+ "peerDependenciesMeta": {
+ "@babel/core": {
+ "optional": true
+ },
+ "babel-plugin-macros": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/tailwindcss": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz",
+ "integrity": "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tapable": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz",
+ "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ }
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "license": "0BSD"
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "6.21.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+ "license": "MIT"
+ },
+ "node_modules/xtend": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+ "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..3286af6
--- /dev/null
+++ b/package.json
@@ -0,0 +1,29 @@
+{
+ "name": "patty-petitions",
+ "version": "0.1.0",
+ "private": true,
+ "scripts": {
+ "dev": "next dev",
+ "build": "next build",
+ "start": "next start"
+ },
+ "dependencies": {
+ "@dw/nextjs-admin-login": "file:/tmp/dw-nextjs-admin-login-0.2.0.tgz",
+ "@types/pg": "^8.16.0",
+ "@types/sanitize-html": "^2.16.1",
+ "lucide-react": "^0.575.0",
+ "next": "16.1.6",
+ "pg": "^8.19.0",
+ "react": "19.2.3",
+ "react-dom": "19.2.3",
+ "sanitize-html": "^2.17.3"
+ },
+ "devDependencies": {
+ "@tailwindcss/postcss": "^4",
+ "@types/node": "^20",
+ "@types/react": "^19",
+ "@types/react-dom": "^19",
+ "tailwindcss": "^4",
+ "typescript": "^5"
+ }
+}
diff --git a/postcss.config.mjs b/postcss.config.mjs
new file mode 100644
index 0000000..61e3684
--- /dev/null
+++ b/postcss.config.mjs
@@ -0,0 +1,7 @@
+const config = {
+ plugins: {
+ "@tailwindcss/postcss": {},
+ },
+};
+
+export default config;
diff --git a/public/favicon.svg b/public/favicon.svg
new file mode 100644
index 0000000..bf1b38a
--- /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="#7c3aed"/>
+<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..3a13f90
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,34 @@
+{
+ "compilerOptions": {
+ "target": "ES2017",
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "allowJs": true,
+ "skipLibCheck": true,
+ "strict": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "react-jsx",
+ "incremental": true,
+ "plugins": [
+ {
+ "name": "next"
+ }
+ ],
+ "paths": {
+ "@/*": ["./*"]
+ }
+ },
+ "include": [
+ "next-env.d.ts",
+ "**/*.ts",
+ "**/*.tsx",
+ ".next/types/**/*.ts",
+ ".next/dev/types/**/*.ts",
+ "**/*.mts"
+ ],
+ "exclude": ["node_modules"]
+}
(oldest)
·
back to Patty
·
harden(auth): add scrypt + rate-limit + refuse-to-boot to /a 6065515 →