Initial commit

This commit is contained in:
2026-03-18 18:26:24 +08:00
commit c784d07796
60 changed files with 3756 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
# Database
DATABASE_URL=postgresql://dokploy:PASSWORD@localhost:5432/ngotoolkit
# OpenAI
OPENAI_API_KEY=sk-...
OPENAI_MODEL=gpt-4o
# Auth0
AUTH0_SECRET=long-random-string
AUTH0_BASE_URL=http://localhost:3000
AUTH0_ISSUER_BASE_URL=https://YOUR_TENANT.auth0.com
AUTH0_CLIENT_ID=
AUTH0_CLIENT_SECRET=
# LemonSqueezy
LEMONSQUEEZY_API_KEY=
LEMONSQUEEZY_STORE_ID=
LEMONSQUEEZY_WEBHOOK_SECRET=
# App
NEXT_PUBLIC_APP_URL=http://localhost:3000
+4
View File
@@ -0,0 +1,4 @@
node_modules/
.next/
.env
*.tsbuildinfo
+19
View File
@@ -0,0 +1,19 @@
FROM oven/bun:1 AS deps
WORKDIR /app
COPY package.json bun.lock* ./
RUN bun install --frozen-lockfile
FROM oven/bun:1 AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN bun run build
FROM node:20-slim AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public* ./public/
EXPOSE 3000
CMD ["node", "server.js"]
+68
View File
@@ -0,0 +1,68 @@
# NGO Toolkit Lab
AI-powered grant writing tools for NGOs. 4 tools: Logframe Generator, Funder Rewriter, SMART Indicators, Theory of Change.
## Setup
```bash
# Install dependencies
bun install
# Copy env and fill in values
cp .env.example .env
# Run database migration
bun run db:migrate
# Start dev server
bun dev
```
## Environment Variables
See `.env.example` for all required variables:
- `DATABASE_URL` — PostgreSQL connection string
- `OPENAI_API_KEY` — OpenAI API key
- `AUTH0_*` — Auth0 configuration for admin routes
- `LEMONSQUEEZY_*` — Payment processing
## Database
Schema is in `lib/db/schema.sql`. Run manually or use the migration script:
```bash
psql $DATABASE_URL < lib/db/schema.sql
# or
bun run db:migrate
```
## Docker
```bash
docker compose up -d
```
## Routes
| Route | Description |
|-------|-------------|
| `/` | Hub — 4 tool cards |
| `/logframe` | Logframe Generator |
| `/funder-rewriter` | Funder Rewriter |
| `/smart-indicators` | SMART Indicators |
| `/theory-of-change` | Theory of Change |
| `/admin` | Dashboard (Auth0 protected) |
| `/admin/config` | Configuration editor |
| `/admin/leads` | Leads table |
## API Routes
- `POST /api/[tool]/generate-draft` — Generate initial draft
- `POST /api/[tool]/improve-section` — Improve a section (paid)
- `POST /api/[tool]/validate` — Quality assessment (paid)
- `POST /api/[tool]/finalize` — Produce final version (paid)
- `POST /api/payment/create-checkout` — Create LemonSqueezy checkout
- `POST /api/payment/webhook` — LemonSqueezy webhook handler
- `GET/POST /api/admin/config` — Admin config management
- `GET /api/admin/leads` — Paginated leads
- `GET /api/admin/metrics` — Dashboard metrics
- `POST /api/export/[format]` — Export (docx/excel/pdf/copy)
+149
View File
@@ -0,0 +1,149 @@
'use client';
import { useState } from 'react';
import { useParams } from 'next/navigation';
import { tools } from '@/lib/tools';
import ToolForm from '@/components/ToolForm';
import OutputPanel from '@/components/OutputPanel';
import { ToolName } from '@/lib/types';
export default function ToolPage() {
const params = useParams();
const slug = params.tool as string;
const tool = tools.find((t) => t.slug === slug);
const [sessionId, setSessionId] = useState<string | null>(null);
const [preview, setPreview] = useState<string | null>(null);
const [fullOutput, setFullOutput] = useState<string | null>(null);
const [isPaid, setIsPaid] = useState(false);
const [generating, setGenerating] = useState(false);
const [paymentLoading, setPaymentLoading] = useState(false);
const [email, setEmail] = useState('');
if (!tool) {
return (
<div className="flex items-center justify-center h-[60vh]">
<p className="text-gray-500">Tool not found</p>
</div>
);
}
const handleGenerate = async (input: Record<string, string>) => {
setGenerating(true);
setPreview(null);
setSessionId(null);
try {
const res = await fetch(`/api/${slug}/generate-draft`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ input, email: email || undefined }),
});
const data = await res.json();
setSessionId(data.session_id);
setPreview(data.preview);
setIsPaid(data.is_paid);
} catch (e) {
console.error('Generate failed:', e);
} finally {
setGenerating(false);
}
};
const handlePayment = async () => {
if (!sessionId) return;
setPaymentLoading(true);
try {
const res = await fetch('/api/payment/create-checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_id: sessionId, email }),
});
const data = await res.json();
if (data.checkout_url) {
window.location.href = data.checkout_url;
}
} catch (e) {
console.error('Payment failed:', e);
} finally {
setPaymentLoading(false);
}
};
// Check if returning from payment (poll session status)
const checkPaymentStatus = async () => {
if (!sessionId) return;
const res = await fetch(`/api/${slug}/validate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_id: sessionId }),
});
if (res.status !== 402) {
setIsPaid(true);
// Fetch full output
const exportRes = await fetch('/api/export/copy', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_id: sessionId }),
});
const data = await exportRes.json();
if (data.text) setFullOutput(data.text);
}
};
return (
<div className="max-w-7xl mx-auto px-4 py-8">
<div className="mb-6">
<h1 className="text-3xl font-bold text-dark">
{tool.icon} {tool.name}
</h1>
<p className="text-gray-600 mt-1">{tool.description}</p>
</div>
<div className="grid lg:grid-cols-2 gap-8">
{/* Left: Form */}
<div>
<div className="sticky top-20">
<div className="mb-4">
<label className="block text-sm font-medium text-dark mb-1">
Email (optional for receiving your output)
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="your@email.com"
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-accent/50 focus:border-accent"
/>
</div>
<ToolForm
fields={tool.fields}
onSubmit={handleGenerate}
loading={generating}
/>
</div>
</div>
{/* Right: Output */}
<div className="min-h-[400px]">
<OutputPanel
tool={slug}
sessionId={sessionId}
preview={preview}
fullOutput={fullOutput}
isPaid={isPaid}
onPaymentRequest={handlePayment}
paymentLoading={paymentLoading}
/>
{sessionId && !isPaid && (
<button
onClick={checkPaymentStatus}
className="mt-4 text-sm text-accent underline"
>
Already paid? Check status
</button>
)}
</div>
</div>
</div>
);
}
+92
View File
@@ -0,0 +1,92 @@
const pillars = [
{ title: 'Speed without shortcuts', desc: 'Professional-grade outputs in minutes. No templates, no copy-paste — real structured generation.' },
{ title: 'Built for practitioners', desc: 'Every feature exists because a grant writer asked for it. No feature exists because a PM thought it was clever.' },
{ title: 'Accessible by default', desc: 'Free tier forever. No signup wall. If you\'re writing grants for a small NGO, you shouldn\'t need a budget line to access tools.' },
];
const stats = [
{ value: '120+', label: 'Organisations served' },
{ value: '30', label: 'Countries' },
{ value: '4', label: 'Professional-grade tools' },
{ value: '<2 min', label: 'Average document time' },
];
export default function AboutPage() {
return (
<>
{/* Hero */}
<section className="bg-white">
<div className="max-w-6xl mx-auto px-6 py-24">
<p className="text-xs tracking-widest font-semibold text-accent uppercase mb-3">About</p>
<h1 className="text-4xl font-bold mb-4">We build tools for people who write grants.</h1>
<p className="text-muted max-w-lg">
NGO Toolkit Lab exists because grant writing is important work and the tooling around it has been stuck in 2005.
</p>
</div>
</section>
{/* Founder note */}
<section className="bg-warm">
<div className="max-w-6xl mx-auto px-6 py-24">
<div className="max-w-2xl">
<p className="text-xs tracking-widest font-semibold text-accent uppercase mb-6">From the founder</p>
<div className="border-l-4 border-accent pl-6">
<p className="italic text-muted leading-relaxed mb-4">
I spent years watching talented programme managers burn weekends on logframes and theories of change documents that should take hours, not days. The knowledge was there. The sector expertise was there. What was missing was tooling that respected their time.
</p>
<p className="italic text-muted leading-relaxed mb-4">
NGO Toolkit Lab started as a logframe generator I built for a friend submitting a DFID proposal. She finished in twenty minutes instead of three days. That felt worth pursuing.
</p>
<p className="italic text-muted leading-relaxed">
We're a small team. We ship tools that work. If something's broken or missing, tell us we fix things fast.
</p>
</div>
<p className="mt-6 text-sm font-semibold">Omair</p>
<p className="text-xs text-gray-500">Founder, NGO Toolkit Lab</p>
</div>
</div>
</section>
{/* Mission pillars */}
<section className="bg-white">
<div className="max-w-6xl mx-auto px-6 py-24">
<h2 className="text-3xl font-bold mb-12">What we believe</h2>
<div className="grid md:grid-cols-3 gap-8">
{pillars.map((p, i) => (
<div key={i}>
<h3 className="font-bold text-lg mb-2">{p.title}</h3>
<p className="text-muted text-sm">{p.desc}</p>
</div>
))}
</div>
</div>
</section>
{/* By the numbers */}
<section className="bg-sand">
<div className="max-w-6xl mx-auto px-6 py-24 text-center">
<h2 className="text-3xl font-bold mb-12">By the numbers</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-8">
{stats.map((s, i) => (
<div key={i}>
<p className="text-3xl font-bold text-accent">{s.value}</p>
<p className="text-muted text-sm mt-1">{s.label}</p>
</div>
))}
</div>
</div>
</section>
{/* CTA */}
<section className="bg-dark text-white">
<div className="max-w-6xl mx-auto px-6 py-24 text-center">
<h2 className="text-3xl font-bold mb-4">Try the tools. Judge the work.</h2>
<p className="text-gray-300 mb-8 max-w-md mx-auto">No pitch deck. No demo call. Just open a tool and see what it builds.</p>
<a href="/logframe" className="inline-block bg-accent text-white font-medium px-6 py-3 rounded-md hover:bg-accent-dark transition-colors">
Try free
</a>
</div>
</section>
</>
);
}
+77
View File
@@ -0,0 +1,77 @@
'use client';
import { useUser } from '@auth0/nextjs-auth0/client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
const CONFIG_LABELS: Record<string, string> = {
price_amount: 'Price (in pence/cents)',
price_currency: 'Currency',
free_tier_enabled: 'Free Tier Enabled',
max_improvements: 'Max Improvements per Session',
openai_model: 'OpenAI Model',
lemonsqueezy_variant_id: 'LemonSqueezy Variant ID',
};
export default function AdminConfig() {
const { user, isLoading } = useUser();
const [config, setConfig] = useState<Record<string, string>>({});
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
useEffect(() => {
if (user) {
fetch('/api/admin/config').then((r) => r.json()).then(setConfig);
}
}, [user]);
if (isLoading) return <div className="p-8 text-center">Loading...</div>;
if (!user) return <div className="p-8 text-center"><a href="/api/auth/login" className="bg-accent text-white px-6 py-2 rounded-lg">Sign In</a></div>;
const handleSave = async () => {
setSaving(true);
const res = await fetch('/api/admin/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config),
});
const updated = await res.json();
setConfig(updated);
setSaving(false);
setSaved(true);
setTimeout(() => setSaved(false), 2000);
};
return (
<div className="max-w-2xl mx-auto px-4 py-8">
<div className="flex justify-between items-center mb-8">
<h1 className="text-2xl font-bold">Configuration</h1>
<Link href="/admin" className="text-sm text-accent hover:underline"> Dashboard</Link>
</div>
<div className="space-y-4">
{Object.entries(config).map(([key, value]) => (
<div key={key}>
<label className="block text-sm font-medium text-dark mb-1">
{CONFIG_LABELS[key] || key}
</label>
<input
type="text"
value={value}
onChange={(e) => setConfig({ ...config, [key]: e.target.value })}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm"
/>
</div>
))}
</div>
<button
onClick={handleSave}
disabled={saving}
className="mt-6 bg-accent text-white px-6 py-2 rounded-lg hover:bg-accent/90 disabled:opacity-50"
>
{saving ? 'Saving...' : saved ? '✅ Saved!' : 'Save Changes'}
</button>
</div>
);
}
+78
View File
@@ -0,0 +1,78 @@
'use client';
import { useUser } from '@auth0/nextjs-auth0/client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { Lead } from '@/lib/types';
export default function AdminLeads() {
const { user, isLoading } = useUser();
const [leads, setLeads] = useState<Lead[]>([]);
const [page, setPage] = useState(1);
const [pages, setPages] = useState(1);
const [total, setTotal] = useState(0);
useEffect(() => {
if (user) {
fetch(`/api/admin/leads?page=${page}`).then((r) => r.json()).then((data) => {
setLeads(data.leads);
setPages(data.pages);
setTotal(data.total);
});
}
}, [user, page]);
if (isLoading) return <div className="p-8 text-center">Loading...</div>;
if (!user) return <div className="p-8 text-center"><a href="/api/auth/login" className="bg-accent text-white px-6 py-2 rounded-lg">Sign In</a></div>;
return (
<div className="max-w-6xl mx-auto px-4 py-8">
<div className="flex justify-between items-center mb-8">
<h1 className="text-2xl font-bold">Leads ({total})</h1>
<Link href="/admin" className="text-sm text-accent hover:underline"> Dashboard</Link>
</div>
<div className="border rounded-xl overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-50 border-b">
<tr>
<th className="text-left px-4 py-3 font-medium">Email</th>
<th className="text-left px-4 py-3 font-medium">Organisation</th>
<th className="text-left px-4 py-3 font-medium">Tool</th>
<th className="text-left px-4 py-3 font-medium">Date</th>
</tr>
</thead>
<tbody>
{leads.map((lead) => (
<tr key={lead.id} className="border-b last:border-0">
<td className="px-4 py-3">{lead.email}</td>
<td className="px-4 py-3">{lead.org_name || '—'}</td>
<td className="px-4 py-3 capitalize">{lead.tool.replace(/-/g, ' ')}</td>
<td className="px-4 py-3 text-gray-500">
{new Date(lead.created_at).toLocaleDateString()}
</td>
</tr>
))}
{leads.length === 0 && (
<tr><td colSpan={4} className="px-4 py-8 text-center text-gray-400">No leads yet</td></tr>
)}
</tbody>
</table>
</div>
{pages > 1 && (
<div className="flex justify-center gap-2 mt-4">
{Array.from({ length: pages }, (_, i) => (
<button
key={i}
onClick={() => setPage(i + 1)}
className={`px-3 py-1 rounded text-sm ${page === i + 1 ? 'bg-accent text-white' : 'bg-gray-100 hover:bg-gray-200'}`}
>
{i + 1}
</button>
))}
</div>
)}
</div>
);
}
+84
View File
@@ -0,0 +1,84 @@
'use client';
import { useUser } from '@auth0/nextjs-auth0/client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
interface Metrics {
total_sessions: number;
paid_sessions: number;
total_leads: number;
total_tokens: number;
by_tool: { tool: string; count: string }[];
}
export default function AdminDashboard() {
const { user, isLoading } = useUser();
const [metrics, setMetrics] = useState<Metrics | null>(null);
useEffect(() => {
if (user) {
fetch('/api/admin/metrics').then((r) => r.json()).then(setMetrics);
}
}, [user]);
if (isLoading) return <div className="p-8 text-center">Loading...</div>;
if (!user) {
return (
<div className="p-8 text-center">
<h1 className="text-2xl font-bold mb-4">Admin Access Required</h1>
<a href="/api/auth/login" className="bg-accent text-white px-6 py-2 rounded-lg">
Sign In
</a>
</div>
);
}
return (
<div className="max-w-6xl mx-auto px-4 py-8">
<div className="flex justify-between items-center mb-8">
<h1 className="text-2xl font-bold">Admin Dashboard</h1>
<div className="flex gap-4">
<Link href="/admin/config" className="text-sm text-accent hover:underline"> Config</Link>
<Link href="/admin/leads" className="text-sm text-accent hover:underline">👥 Leads</Link>
<a href="/api/auth/logout" className="text-sm text-gray-500 hover:underline">Sign Out</a>
</div>
</div>
{metrics && (
<div className="grid md:grid-cols-4 gap-4 mb-8">
<div className="bg-white border rounded-xl p-6">
<div className="text-3xl font-bold text-accent">{metrics.total_sessions}</div>
<div className="text-sm text-gray-500">Total Sessions</div>
</div>
<div className="bg-white border rounded-xl p-6">
<div className="text-3xl font-bold text-green-600">{metrics.paid_sessions}</div>
<div className="text-sm text-gray-500">Paid Sessions</div>
</div>
<div className="bg-white border rounded-xl p-6">
<div className="text-3xl font-bold text-blue-600">{metrics.total_leads}</div>
<div className="text-sm text-gray-500">Leads Captured</div>
</div>
<div className="bg-white border rounded-xl p-6">
<div className="text-3xl font-bold text-purple-600">{metrics.total_tokens.toLocaleString()}</div>
<div className="text-sm text-gray-500">Tokens Used</div>
</div>
</div>
)}
{metrics?.by_tool && (
<div className="bg-white border rounded-xl p-6">
<h2 className="font-semibold mb-4">Usage by Tool</h2>
<div className="space-y-2">
{metrics.by_tool.map((t) => (
<div key={t.tool} className="flex justify-between">
<span className="capitalize">{t.tool.replace(/-/g, ' ')}</span>
<span className="font-mono">{t.count}</span>
</div>
))}
</div>
</div>
)}
</div>
);
}
+50
View File
@@ -0,0 +1,50 @@
import { NextRequest, NextResponse } from 'next/server';
import { query } from '@/lib/db';
import { generate } from '@/lib/ai/client';
import { prompts } from '@/lib/ai/prompts';
import { ToolName, Session } from '@/lib/types';
export async function POST(
request: NextRequest,
{ params }: { params: { tool: string } }
) {
try {
const tool = params.tool as ToolName;
const { session_id, improvements } = await request.json();
const [session] = await query<Session>(
'SELECT * FROM sessions WHERE id = $1',
[session_id]
);
if (!session) {
return NextResponse.json({ error: 'Session not found' }, { status: 404 });
}
if (!session.is_paid) {
return NextResponse.json({ error: 'Payment required' }, { status: 402 });
}
if (!session.output) {
return NextResponse.json({ error: 'No output to finalize' }, { status: 400 });
}
const toolPrompts = prompts[tool];
const systemPrompt = 'You are a professional NGO grant writing assistant producing final documents.';
const userPrompt = toolPrompts.finalize(session.output, improvements || 'No additional improvements.');
const result = await generate(systemPrompt, userPrompt);
await query(
`UPDATE sessions SET output = $1, updated_at = NOW() WHERE id = $2`,
[result.text, session_id]
);
await query(
`INSERT INTO usage (session_id, tool, action, tokens_used) VALUES ($1, $2, $3, $4)`,
[session_id, tool, 'finalize', result.tokens]
);
return NextResponse.json({ finalized: result.text });
} catch (error) {
console.error('Finalize error:', error);
return NextResponse.json({ error: 'Failed to finalize' }, { status: 500 });
}
}
+107
View File
@@ -0,0 +1,107 @@
import { NextRequest, NextResponse } from 'next/server';
import { query, getConfig } from '@/lib/db';
import { generate } from '@/lib/ai/client';
import { prompts } from '@/lib/ai/prompts';
import { ToolName, Session } from '@/lib/types';
const validTools: ToolName[] = ['logframe', 'funder-rewriter', 'smart-indicators', 'theory-of-change'];
export async function POST(
request: NextRequest,
{ params }: { params: { tool: string } }
) {
try {
const tool = params.tool as ToolName;
if (!validTools.includes(tool)) {
return NextResponse.json({ error: 'Invalid tool' }, { status: 400 });
}
const body = await request.json();
const { input, email } = body as { input: Record<string, string>; email?: string };
if (!input) {
return NextResponse.json({ error: 'Input required' }, { status: 400 });
}
// --- Free tier check ---
// Email is required to use the free tier. If no email, require payment immediately.
let isFreeTier = false;
if (email) {
const freeTierEnabled = (await getConfig('free_tier_enabled')) !== 'false';
if (freeTierEnabled) {
// Check if this email has ever generated before (any tool)
const existing = await query<{ id: number }>(
'SELECT id FROM leads WHERE email = $1 LIMIT 1',
[email]
);
if (existing.length === 0) {
// First time — insert lead, grant free tier
await query(
`INSERT INTO leads (email, org_name, tool) VALUES ($1, $2, $3)
ON CONFLICT (email) DO NOTHING`,
[email, input.organisation || input.project_title || null, tool]
);
isFreeTier = true;
}
// If email already exists → not free, falls through to paid path below
}
}
// Create session — track whether it starts as paid (free tier unlock)
const sessions = await query<Session>(
`INSERT INTO sessions (tool, email, input, is_paid) VALUES ($1, $2, $3, $4) RETURNING *`,
[tool, email || null, JSON.stringify(input), isFreeTier]
);
const session = sessions[0];
// Generate the draft
const toolPrompts = prompts[tool];
const systemPrompt = 'You are a professional NGO grant writing assistant. Always respond with well-structured, professional content.';
const userPrompt = toolPrompts.master(input);
const result = await generate(systemPrompt, userPrompt);
// Store full output in session
await query(
`UPDATE sessions SET output = $1, updated_at = NOW() WHERE id = $2`,
[result.text, session.id]
);
// Log usage
await query(
`INSERT INTO usage (session_id, tool, action, tokens_used) VALUES ($1, $2, $3, $4)`,
[session.id, tool, 'generate', result.tokens]
);
// Preview = first ~40% of content (chars)
const previewEnd = Math.floor(result.text.length * 0.40);
const preview = result.text.substring(0, previewEnd);
// If free tier or already paid → return full output
if (isFreeTier) {
return NextResponse.json({
session_id: session.id,
preview,
full_output: result.text,
is_paid: true,
free_tier_used: true,
});
}
// Otherwise return preview only — requires payment to unlock
return NextResponse.json({
session_id: session.id,
preview,
full_length: result.text.length,
is_paid: false,
requires_email: !email, // hint to frontend to prompt for email
});
} catch (error) {
console.error('Generate draft error:', error);
return NextResponse.json(
{ error: 'Failed to generate draft' },
{ status: 500 }
);
}
}
+59
View File
@@ -0,0 +1,59 @@
import { NextRequest, NextResponse } from 'next/server';
import { query, getConfig } from '@/lib/db';
import { generate } from '@/lib/ai/client';
import { prompts } from '@/lib/ai/prompts';
import { ToolName, Session } from '@/lib/types';
export async function POST(
request: NextRequest,
{ params }: { params: { tool: string } }
) {
try {
const tool = params.tool as ToolName;
const { session_id, section, feedback } = await request.json();
if (!session_id || !section || !feedback) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
}
// Check session is paid
const [session] = await query<Session>(
'SELECT * FROM sessions WHERE id = $1',
[session_id]
);
if (!session) {
return NextResponse.json({ error: 'Session not found' }, { status: 404 });
}
if (!session.is_paid) {
return NextResponse.json({ error: 'Payment required' }, { status: 402 });
}
// Check improvement cap
const maxImprovements = parseInt((await getConfig('max_improvements')) || '5');
if (session.improvements_used >= maxImprovements) {
return NextResponse.json({ error: 'Improvement limit reached' }, { status: 429 });
}
const toolPrompts = prompts[tool];
const systemPrompt = 'You are a professional NGO grant writing assistant.';
const userPrompt = toolPrompts.improve(section, feedback);
const result = await generate(systemPrompt, userPrompt);
// Update session
await query(
`UPDATE sessions SET improvements_used = improvements_used + 1, updated_at = NOW() WHERE id = $1`,
[session_id]
);
await query(
`INSERT INTO usage (session_id, tool, action, tokens_used) VALUES ($1, $2, $3, $4)`,
[session_id, tool, 'improve', result.tokens]
);
return NextResponse.json({ improved: result.text });
} catch (error) {
console.error('Improve section error:', error);
return NextResponse.json({ error: 'Failed to improve section' }, { status: 500 });
}
}
+45
View File
@@ -0,0 +1,45 @@
import { NextRequest, NextResponse } from 'next/server';
import { query } from '@/lib/db';
import { generate } from '@/lib/ai/client';
import { prompts } from '@/lib/ai/prompts';
import { ToolName, Session } from '@/lib/types';
export async function POST(
request: NextRequest,
{ params }: { params: { tool: string } }
) {
try {
const tool = params.tool as ToolName;
const { session_id } = await request.json();
const [session] = await query<Session>(
'SELECT * FROM sessions WHERE id = $1',
[session_id]
);
if (!session) {
return NextResponse.json({ error: 'Session not found' }, { status: 404 });
}
if (!session.is_paid) {
return NextResponse.json({ error: 'Payment required' }, { status: 402 });
}
if (!session.output) {
return NextResponse.json({ error: 'No output to validate' }, { status: 400 });
}
const toolPrompts = prompts[tool];
const systemPrompt = 'You are a senior M&E and grant review specialist.';
const userPrompt = toolPrompts.validate(session.output);
const result = await generate(systemPrompt, userPrompt);
await query(
`INSERT INTO usage (session_id, tool, action, tokens_used) VALUES ($1, $2, $3, $4)`,
[session_id, tool, 'validate', result.tokens]
);
return NextResponse.json({ validation: result.text });
} catch (error) {
console.error('Validate error:', error);
return NextResponse.json({ error: 'Failed to validate' }, { status: 500 });
}
}
+30
View File
@@ -0,0 +1,30 @@
import { NextRequest, NextResponse } from 'next/server';
import { getAllConfig, setConfig } from '@/lib/db';
import { requireAdmin } from '@/lib/auth';
export async function GET() {
const unauthorized = await requireAdmin();
if (unauthorized) return unauthorized;
const config = await getAllConfig();
return NextResponse.json(config);
}
export async function POST(request: NextRequest) {
const unauthorized = await requireAdmin();
if (unauthorized) return unauthorized;
try {
const updates = await request.json() as Record<string, string>;
for (const [key, value] of Object.entries(updates)) {
await setConfig(key, value);
}
const config = await getAllConfig();
return NextResponse.json(config);
} catch (error) {
console.error('Config update error:', error);
return NextResponse.json({ error: 'Failed to update config' }, { status: 500 });
}
}
+30
View File
@@ -0,0 +1,30 @@
import { NextRequest, NextResponse } from 'next/server';
import { query } from '@/lib/db';
import { requireAdmin } from '@/lib/auth';
import { Lead } from '@/lib/types';
export async function GET(request: NextRequest) {
const unauthorized = await requireAdmin();
if (unauthorized) return unauthorized;
const { searchParams } = new URL(request.url);
const page = parseInt(searchParams.get('page') || '1');
const limit = parseInt(searchParams.get('limit') || '50');
const offset = (page - 1) * limit;
const leads = await query<Lead>(
'SELECT * FROM leads ORDER BY created_at DESC LIMIT $1 OFFSET $2',
[limit, offset]
);
const [{ count }] = await query<{ count: string }>(
'SELECT COUNT(*) as count FROM leads'
);
return NextResponse.json({
leads,
total: parseInt(count),
page,
pages: Math.ceil(parseInt(count) / limit),
});
}
+25
View File
@@ -0,0 +1,25 @@
import { NextResponse } from 'next/server';
import { query } from '@/lib/db';
import { requireAdmin } from '@/lib/auth';
export async function GET() {
const unauthorized = await requireAdmin();
if (unauthorized) return unauthorized;
const [sessions] = await query<{ count: string }>('SELECT COUNT(*) as count FROM sessions');
const [paid] = await query<{ count: string }>('SELECT COUNT(*) as count FROM sessions WHERE is_paid = TRUE');
const [leads] = await query<{ count: string }>('SELECT COUNT(*) as count FROM leads');
const [tokens] = await query<{ total: string }>('SELECT COALESCE(SUM(tokens_used), 0) as total FROM usage');
const toolBreakdown = await query<{ tool: string; count: string }>(
'SELECT tool, COUNT(*) as count FROM sessions GROUP BY tool ORDER BY count DESC'
);
return NextResponse.json({
total_sessions: parseInt(sessions.count),
paid_sessions: parseInt(paid.count),
total_leads: parseInt(leads.count),
total_tokens: parseInt(tokens.total),
by_tool: toolBreakdown,
});
}
+3
View File
@@ -0,0 +1,3 @@
import { handleAuth } from '@auth0/nextjs-auth0';
export const GET = handleAuth();
+68
View File
@@ -0,0 +1,68 @@
import { NextRequest, NextResponse } from 'next/server';
import { query } from '@/lib/db';
import { Session } from '@/lib/types';
import { generateDocx } from '@/lib/exports/docx';
import { generateExcel } from '@/lib/exports/excel';
import { generatePdf } from '@/lib/exports/pdf';
export async function POST(
request: NextRequest,
{ params }: { params: { format: string } }
) {
try {
const { session_id } = await request.json();
const format = params.format;
const [session] = await query<Session>(
'SELECT * FROM sessions WHERE id = $1',
[session_id]
);
if (!session) {
return NextResponse.json({ error: 'Session not found' }, { status: 404 });
}
if (!session.is_paid) {
return NextResponse.json({ error: 'Payment required' }, { status: 402 });
}
if (!session.output) {
return NextResponse.json({ error: 'No output to export' }, { status: 400 });
}
switch (format) {
case 'docx': {
const buffer = await generateDocx(session.output, session.tool);
return new NextResponse(new Uint8Array(buffer), {
headers: {
'Content-Type': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'Content-Disposition': `attachment; filename="${session.tool}-output.docx"`,
},
});
}
case 'excel': {
const buffer = await generateExcel(session.output, session.tool);
return new NextResponse(new Uint8Array(buffer), {
headers: {
'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'Content-Disposition': `attachment; filename="${session.tool}-output.xlsx"`,
},
});
}
case 'pdf': {
const buffer = await generatePdf(session.output, session.tool);
return new NextResponse(new Uint8Array(buffer), {
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename="${session.tool}-output.pdf"`,
},
});
}
case 'copy': {
return NextResponse.json({ text: session.output });
}
default:
return NextResponse.json({ error: 'Invalid format' }, { status: 400 });
}
} catch (error) {
console.error('Export error:', error);
return NextResponse.json({ error: 'Failed to export' }, { status: 500 });
}
}
+44
View File
@@ -0,0 +1,44 @@
import { NextRequest, NextResponse } from 'next/server';
import { query, getConfig } from '@/lib/db';
import { createCheckout } from '@/lib/lemonsqueezy';
import { Session } from '@/lib/types';
export async function POST(request: NextRequest) {
try {
const { session_id, email } = await request.json();
if (!session_id) {
return NextResponse.json({ error: 'Session ID required' }, { status: 400 });
}
// Look up session to get the tool
const sessions = await query<Session>(
'SELECT * FROM sessions WHERE id = $1 LIMIT 1',
[session_id]
);
const session = sessions[0];
if (!session) {
return NextResponse.json({ error: 'Session not found' }, { status: 404 });
}
// Get per-tool variant ID from admin config
const variantId = await getConfig(`variant_${session.tool}`);
if (!variantId) {
return NextResponse.json(
{ error: `Payment not configured for tool: ${session.tool}` },
{ status: 500 }
);
}
const checkoutUrl = await createCheckout(
variantId,
session_id,
email || session.email || undefined
);
return NextResponse.json({ checkout_url: checkoutUrl });
} catch (error) {
console.error('Create checkout error:', error);
return NextResponse.json({ error: 'Failed to create checkout' }, { status: 500 });
}
}
+34
View File
@@ -0,0 +1,34 @@
import { NextRequest, NextResponse } from 'next/server';
import { query } from '@/lib/db';
import { verifyWebhookSignature } from '@/lib/lemonsqueezy';
export async function POST(request: NextRequest) {
try {
const rawBody = await request.text();
const signature = request.headers.get('x-signature') || '';
if (!verifyWebhookSignature(rawBody, signature)) {
return NextResponse.json({ error: 'Invalid signature' }, { status: 401 });
}
const event = JSON.parse(rawBody);
const eventName = event.meta?.event_name;
if (eventName === 'order_created') {
const sessionId = event.meta?.custom_data?.session_id;
const orderId = event.data?.id;
if (sessionId) {
await query(
`UPDATE sessions SET is_paid = TRUE, payment_id = $1, updated_at = NOW() WHERE id = $2`,
[String(orderId), sessionId]
);
}
}
return NextResponse.json({ received: true });
} catch (error) {
console.error('Webhook error:', error);
return NextResponse.json({ error: 'Webhook processing failed' }, { status: 500 });
}
}
+23
View File
@@ -0,0 +1,23 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
html {
scroll-behavior: smooth;
}
body {
font-family: 'Inter', system-ui, sans-serif;
background-color: #fafaf8;
color: #111827;
}
::selection {
background-color: #e8f5ee;
color: #134d2b;
}
*:focus-visible {
outline: 2px solid #1a6b3c;
outline-offset: 2px;
}
+136
View File
@@ -0,0 +1,136 @@
const tools = [
{
title: 'Logframe Generator',
href: '/logframe',
desc: 'Structured goal → outcome → output → activity matrices aligned to donor requirements. Includes OVIs and means of verification.',
inputs: 'Project title, sector, geography, donor, objectives',
sample: 'A 4-level logframe with SMART indicators, assumptions column, and verification sources — ready for annexing.',
},
{
title: 'SMART Indicators',
href: '/smart-indicators',
desc: 'Evaluation-ready indicators that survive panel review. Auto-aligned to your logframe outputs.',
inputs: 'Logframe or project description, M&E framework preferences',
sample: '812 indicators per output with baselines, targets, data sources, and collection frequency.',
},
{
title: 'Theory of Change',
href: '/theory-of-change',
desc: 'Inputs → activities → outputs → outcomes → impact. Assumptions and evidence gaps surfaced clearly.',
inputs: 'Problem statement, target population, intervention design',
sample: 'A narrative ToC with causal pathways, assumption testing notes, and a visual summary.',
},
{
title: 'Funder Rewriter',
href: '/funder-rewriter',
desc: 'Reshape existing proposals to match a new funder\'s priorities, tone, and structure — without losing your voice.',
inputs: 'Existing proposal text, target funder name or guidelines',
sample: 'A rewritten proposal section with tracked changes summary and alignment notes.',
},
];
const comparison = [
{ task: 'Build a logframe from scratch', manual: '23 days', toolkit: '< 2 minutes' },
{ task: 'Write SMART indicators', manual: '46 hours', toolkit: '30 seconds' },
{ task: 'Draft a theory of change', manual: '12 days', toolkit: '< 2 minutes' },
{ task: 'Rewrite proposal for new funder', manual: '1 day', toolkit: '45 seconds' },
];
const quotes = [
{ text: 'I used to dread logframe season. Now I finish them over lunch.', author: 'Programme Manager, East Africa regional NGO' },
{ text: 'The SMART indicators it generates are better than what our M&E team was producing manually.', author: 'Grants Lead, UK-based INGO' },
{ text: 'We rewrote a USAID proposal for DFID in under a minute. It took three rounds of review last time.', author: 'Senior Grant Writer, health sector NGO' },
];
export default function GrantToolsPage() {
return (
<>
{/* Hero */}
<section className="bg-white">
<div className="max-w-6xl mx-auto px-6 py-24">
<p className="text-xs tracking-widest font-semibold text-accent uppercase mb-3">Grant tools</p>
<h1 className="text-4xl font-bold mb-4">Four tools. Zero fluff.</h1>
<p className="text-muted max-w-lg mb-16">
Each tool solves one specific grant-writing problem. Describe your project, get a professional draft, refine and export.
</p>
{/* Tool cards */}
<div className="grid md:grid-cols-2 gap-6">
{tools.map((t) => (
<div key={t.href} className="border border-gray-200 rounded-md p-8">
<h3 className="text-xl font-bold mb-3">{t.title}</h3>
<p className="text-muted text-sm mb-4">{t.desc}</p>
<div className="space-y-3 mb-6">
<div>
<span className="text-xs font-semibold uppercase text-accent tracking-wide">Inputs</span>
<p className="text-sm text-muted">{t.inputs}</p>
</div>
<div>
<span className="text-xs font-semibold uppercase text-accent tracking-wide">Sample output</span>
<p className="text-sm text-muted">{t.sample}</p>
</div>
</div>
<a href={t.href} className="text-accent font-medium text-sm hover:text-accent-dark transition-colors">
Try it free
</a>
</div>
))}
</div>
</div>
</section>
{/* Comparison */}
<section className="bg-warm">
<div className="max-w-6xl mx-auto px-6 py-24">
<h2 className="text-3xl font-bold mb-12 text-center">Manual vs. Toolkit</h2>
<div className="max-w-2xl mx-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-300">
<th className="text-left py-3 font-semibold">Task</th>
<th className="text-center py-3 font-semibold">Manual</th>
<th className="text-center py-3 font-semibold text-accent">Toolkit</th>
</tr>
</thead>
<tbody>
{comparison.map((row, i) => (
<tr key={i} className="border-b border-gray-200">
<td className="py-3 text-muted">{row.task}</td>
<td className="py-3 text-center text-muted">{row.manual}</td>
<td className="py-3 text-center font-semibold text-accent">{row.toolkit}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</section>
{/* Practitioner quotes */}
<section className="bg-white">
<div className="max-w-6xl mx-auto px-6 py-24">
<h2 className="text-3xl font-bold mb-12 text-center">From the field</h2>
<div className="grid md:grid-cols-3 gap-6">
{quotes.map((q, i) => (
<div key={i} className="border-l-2 border-accent pl-6">
<p className="italic text-muted mb-3">"{q.text}"</p>
<p className="text-xs text-gray-500">{q.author}</p>
</div>
))}
</div>
</div>
</section>
{/* CTA */}
<section className="bg-dark text-white">
<div className="max-w-6xl mx-auto px-6 py-24 text-center">
<h2 className="text-3xl font-bold mb-4">Pick a tool. Finish the document.</h2>
<p className="text-gray-300 mb-8">No signup required for your first document.</p>
<a href="/logframe" className="inline-block bg-accent text-white font-medium px-6 py-3 rounded-md hover:bg-accent-dark transition-colors">
Try free
</a>
</div>
</section>
</>
);
}
+44
View File
@@ -0,0 +1,44 @@
import type { Metadata } from 'next';
import { UserProvider } from '@auth0/nextjs-auth0/client';
import './globals.css';
export const metadata: Metadata = {
title: 'NGO Toolkit Lab — Grant Documents Done.',
description: 'Professional-grade tools for grant writers. Logframes, proposals, SMART indicators, and theories of change — finished before the kettle boils.',
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
</head>
<body className="bg-warm text-dark min-h-screen">
<UserProvider>
<nav className="border-t-2 border-accent-light bg-white sticky top-0 z-50">
<div className="max-w-6xl mx-auto px-6 h-14 flex items-center justify-between">
<a href="/" className="font-semibold text-lg text-accent">
NGO Toolkit Lab
</a>
<div className="flex items-center gap-6 text-sm">
<a href="/grant-tools" className="text-muted hover:text-accent transition-colors">Grant Tools</a>
<a href="/pricing" className="text-muted hover:text-accent transition-colors">Pricing</a>
<a href="/about" className="text-muted hover:text-accent transition-colors">About</a>
<a
href="/logframe"
className="bg-accent text-white text-sm font-medium px-4 py-2 rounded-md hover:bg-accent-dark transition-colors"
>
Try free
</a>
</div>
</div>
</nav>
<main>{children}</main>
</UserProvider>
</body>
</html>
);
}
+288
View File
@@ -0,0 +1,288 @@
import Image from 'next/image';
export default function Home() {
return (
<>
{/* ── 1. Hero ── */}
<section className="bg-white">
<div className="max-w-6xl mx-auto px-6 py-24 grid md:grid-cols-2 gap-16 items-center">
<div>
<p className="text-xs tracking-widest font-semibold text-accent uppercase mb-3">
Grant writing tools
</p>
<h1 className="text-4xl md:text-5xl font-bold leading-tight mb-6">
Finish the logframe.<br />
Get your evening back.
</h1>
<p className="text-lg text-muted mb-8 max-w-md">
Professional-grade grant documents logframes, theories of change, SMART indicators, funder rewrites finished in minutes, not days.
</p>
<div className="flex gap-4 mb-8">
<a
href="/logframe"
className="bg-accent text-white font-medium px-6 py-3 rounded-md hover:bg-accent-dark transition-colors"
>
Try the Logframe Generator
</a>
<a
href="#tools"
className="border border-accent text-accent font-medium px-6 py-3 rounded-md hover:bg-accent-light transition-colors"
>
See all tools
</a>
</div>
<p className="text-sm text-muted">
Trusted by 120+ organisations across 30 countries. No signup required for your first document.
</p>
</div>
<div>
<Image
src="/images/hero-desk.jpg"
alt="Laptop on a clean desk with warm lighting"
width={640}
height={480}
className="rounded-xl w-full h-auto"
priority
/>
</div>
</div>
</section>
{/* ── 2. Why we built this ── */}
<section className="bg-dark text-white">
<div className="max-w-6xl mx-auto px-6 py-24">
<p className="text-xs tracking-widest font-semibold text-accent-light uppercase mb-6">Why we built this</p>
{/* Founder note */}
<div className="border-l-4 border-accent pl-6 max-w-2xl mb-16">
<p className="italic text-gray-300 leading-relaxed">
I watched talented programme managers burn weekends on logframes documents that should take hours, not days.
The sector expertise was there. The tooling wasn't. So we built it.
</p>
<p className="mt-4 text-sm font-semibold text-white">Omair, Founder</p>
</div>
{/* Practitioner pain cards */}
<div className="grid md:grid-cols-3 gap-6">
{[
{ quote: 'I spent three days building a logframe from scratch for a £50k bid. We didn\'t even get shortlisted.', author: 'Programme Manager, East Africa' },
{ quote: 'Every funder wants a different format. I rewrite the same theory of change four times a quarter.', author: 'Grants Lead, UK-based INGO' },
{ quote: 'My indicators passed internal review but got torn apart by the evaluation panel.', author: 'M&E Officer, health sector NGO' },
].map((card, i) => (
<div key={i} className="border-l-2 border-accent pl-6">
<p className="italic text-gray-300 mb-3">"{card.quote}"</p>
<p className="text-xs text-gray-500">{card.author}</p>
</div>
))}
</div>
<p className="text-accent-light text-base mt-12">
You know the work. You know the sector. You just need tools that keep up with you.
</p>
</div>
</section>
{/* ── 3. Tools ── */}
<section id="tools" className="bg-warm">
<div className="max-w-6xl mx-auto px-6 py-24">
<p className="text-xs tracking-widest font-semibold text-accent uppercase mb-3">
What you can build
</p>
<h2 className="text-3xl font-bold mb-12">Four tools. Zero fluff.</h2>
<div className="grid md:grid-cols-2 gap-6 mb-16">
{[
{
title: 'Logframe Generator',
href: '/logframe',
points: [
'Goal, outcomes, outputs, activities — structured to donor spec',
'Objectively verifiable indicators and means of verification',
'Export to Word or Excel in one click',
],
},
{
title: 'SMART Indicators',
href: '/smart-indicators',
points: [
'Specific, measurable, achievable, relevant, time-bound',
'Aligned to your logframe outputs automatically',
'Evaluation-ready language that survives panel review',
],
},
{
title: 'Theory of Change',
href: '/theory-of-change',
points: [
'Inputs → activities → outputs → outcomes → impact',
'Assumptions and evidence gaps surfaced clearly',
'Visual and narrative formats for any funder',
],
},
{
title: 'Funder Rewriter',
href: '/funder-rewriter',
points: [
'Reshape existing proposals to match new funder priorities',
'Tone, structure, and emphasis adjusted per guidelines',
'Keep your voice — just sharpen the fit',
],
},
].map((tool) => (
<a
key={tool.href}
href={tool.href}
className="bg-white border border-gray-200 rounded-md p-8 hover:border-accent transition-colors"
>
<h3 className="text-xl font-bold mb-4">{tool.title}</h3>
<ul className="space-y-2 text-muted text-sm">
{tool.points.map((point, i) => (
<li key={i} className="flex gap-2">
<span className="text-accent mt-1"></span>
<span>{point}</span>
</li>
))}
</ul>
</a>
))}
</div>
<Image
src="/images/hands-typing.jpg"
alt="Hands typing on a keyboard"
width={1200}
height={500}
className="rounded-xl w-full h-64 object-cover"
/>
</div>
</section>
{/* ── 4. How It Works ── */}
<section className="bg-white">
<div className="max-w-6xl mx-auto px-6 py-24 grid md:grid-cols-2 gap-16 items-center">
<Image
src="/images/hands-coffee.jpg"
alt="Hands holding coffee beside a laptop"
width={640}
height={480}
className="rounded-xl w-full h-auto"
/>
<div>
<p className="text-xs tracking-widest font-semibold text-accent uppercase mb-3">
How it works
</p>
<h2 className="text-3xl font-bold mb-10">Three steps. One sitting.</h2>
<div className="space-y-8">
{[
{
num: '01',
title: 'Describe your project',
desc: 'Fill in the guided form — title, sector, geography, donor requirements. Takes two minutes.',
},
{
num: '02',
title: 'Get a professional draft',
desc: 'A complete, structured document lands in under thirty seconds. Review it line by line.',
},
{
num: '03',
title: 'Refine and export',
desc: 'Edit anything inline. When it reads right, export as Word, Excel, or PDF.',
},
].map((step) => (
<div key={step.num} className="flex gap-4">
<span className="text-accent font-bold text-lg">{step.num}</span>
<div>
<h3 className="font-semibold mb-1">{step.title}</h3>
<p className="text-muted text-sm">{step.desc}</p>
</div>
</div>
))}
</div>
<a href="/logframe" className="inline-block mt-8 text-accent font-medium text-sm hover:text-accent-dark transition-colors">
Try the Logframe Generator
</a>
</div>
</div>
</section>
{/* ── 5. Freemium ── */}
<section className="bg-sand">
<div className="max-w-6xl mx-auto px-6 py-24 text-center">
<p className="text-xs tracking-widest font-semibold text-accent uppercase mb-3">
Pricing
</p>
<h2 className="text-3xl font-bold mb-4">Start free. Upgrade when you need to.</h2>
<p className="text-muted mb-12 max-w-lg mx-auto">
Your first document is completely free no signup, no credit card. Pay only when you need volume.
</p>
<div className="grid md:grid-cols-2 gap-6 max-w-2xl mx-auto">
<div className="bg-white border border-gray-200 rounded-md p-8 text-left">
<h3 className="font-bold text-lg mb-4">Free</h3>
<ul className="space-y-2 text-sm text-muted">
<li className="flex gap-2"><span className="text-accent"></span>1 document per tool</li>
<li className="flex gap-2"><span className="text-accent"></span>Word and Excel export</li>
<li className="flex gap-2"><span className="text-accent"></span>No signup required</li>
</ul>
</div>
<div className="bg-white border border-accent rounded-md p-8 text-left">
<h3 className="font-bold text-lg mb-4">Pro <span className="text-accent text-sm font-normal"> coming soon</span></h3>
<ul className="space-y-2 text-sm text-muted">
<li className="flex gap-2"><span className="text-accent"></span>Unlimited documents</li>
<li className="flex gap-2"><span className="text-accent"></span>PDF export and templates</li>
<li className="flex gap-2"><span className="text-accent"></span>Save and revisit past work</li>
<li className="flex gap-2"><span className="text-accent"></span>Priority support</li>
</ul>
</div>
</div>
<a
href="/logframe"
className="inline-block mt-10 bg-accent text-white font-medium px-6 py-3 rounded-md hover:bg-accent-dark transition-colors"
>
Generate your first document free
</a>
</div>
</section>
{/* ── 6. Emotional Close ── */}
<section className="relative">
<Image
src="/images/window-dusk.jpg"
alt="Window at dusk with warm light"
fill
className="object-cover"
/>
<div className="absolute inset-0 bg-dark/70" />
<div className="relative max-w-6xl mx-auto px-6 py-24 text-center text-white">
<h2 className="text-3xl md:text-4xl font-bold mb-6 max-w-2xl mx-auto">
You got into this work to change things not to spend another weekend on a logframe.
</h2>
<p className="text-gray-300 mb-8 max-w-lg mx-auto">
Get the document done. Close the laptop. The evening is yours.
</p>
<a
href="/logframe"
className="inline-block border border-white text-white font-medium px-6 py-3 rounded-md hover:bg-white hover:text-dark transition-colors"
>
Start now it's free
</a>
</div>
</section>
{/* ── 7. Footer ── */}
<footer className="bg-dark text-gray-400">
<div className="max-w-6xl mx-auto px-6 py-12 flex flex-col md:flex-row items-center justify-between gap-6 text-sm">
<div>
<span className="text-white font-semibold">NGO Toolkit Lab</span>
<span className="ml-3 text-gray-500">Grant documents, done.</span>
</div>
<div className="flex gap-6">
<a href="/logframe" className="hover:text-white transition-colors">Logframe</a>
<a href="/smart-indicators" className="hover:text-white transition-colors">SMART Indicators</a>
<a href="/theory-of-change" className="hover:text-white transition-colors">Theory of Change</a>
<a href="/funder-rewriter" className="hover:text-white transition-colors">Funder Rewriter</a>
</div>
<p className="text-gray-500">© {new Date().getFullYear()} NGO Toolkit Lab</p>
</div>
</footer>
</>
);
}
+143
View File
@@ -0,0 +1,143 @@
'use client';
import { useState } from 'react';
const faqs = [
{ q: 'Can I use the free tier forever?', a: 'Yes. Your first document per tool is always free — no expiry, no signup.' },
{ q: 'What happens when I hit the free limit?', a: 'You\'ll see a prompt to upgrade. Your existing documents stay accessible.' },
{ q: 'Can I switch between monthly and annual?', a: 'Yes, at any time. Switching to annual applies a prorated credit.' },
{ q: 'Do you offer NGO discounts?', a: 'Pro is already priced for small NGOs. Team pricing includes volume discounts — contact us for 10+ seats.' },
{ q: 'What payment methods do you accept?', a: 'Visa, Mastercard, and bank transfer for annual Team plans.' },
];
const tiers = [
{
name: 'Free',
monthly: 0,
annual: 0,
desc: 'Try every tool — no signup required.',
features: ['1 document per tool', 'Word & Excel export', 'No account needed'],
cta: 'Start free',
href: '/logframe',
highlight: false,
},
{
name: 'Pro',
monthly: 29,
annual: 24,
desc: 'For grant writers who submit regularly.',
features: ['Unlimited documents', 'PDF export & templates', 'Save & revisit past work', 'Priority support', 'Funder-specific formatting'],
cta: 'Get Pro',
href: '/logframe',
highlight: true,
},
{
name: 'Team',
monthly: 79,
annual: 66,
desc: 'For organisations with multiple writers.',
features: ['Everything in Pro', 'Up to 10 seats', 'Shared document library', 'Brand & template presets', 'Dedicated onboarding'],
cta: 'Contact us',
href: 'mailto:hello@ngotoolkitlab.com',
highlight: false,
},
];
export default function PricingPage() {
const [annual, setAnnual] = useState(false);
return (
<>
<section className="bg-white">
<div className="max-w-6xl mx-auto px-6 py-24 text-center">
<p className="text-xs tracking-widest font-semibold text-accent uppercase mb-3">Pricing</p>
<h1 className="text-4xl font-bold mb-4">Simple pricing. No surprises.</h1>
<p className="text-muted max-w-lg mx-auto mb-10">
Start free, upgrade when you need volume. Cancel any time.
</p>
{/* Toggle */}
<div className="flex items-center justify-center gap-3 mb-16">
<span className={`text-sm ${!annual ? 'text-dark font-semibold' : 'text-muted'}`}>Monthly</span>
<button
onClick={() => setAnnual(!annual)}
className={`relative w-12 h-6 rounded-full transition-colors ${annual ? 'bg-accent' : 'bg-gray-300'}`}
aria-label="Toggle annual billing"
>
<span className={`absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform ${annual ? 'translate-x-6' : ''}`} />
</button>
<span className={`text-sm ${annual ? 'text-dark font-semibold' : 'text-muted'}`}>
Annual <span className="text-accent text-xs font-medium">save 17%</span>
</span>
</div>
{/* Tier cards */}
<div className="grid md:grid-cols-3 gap-6 max-w-4xl mx-auto text-left">
{tiers.map((t) => (
<div
key={t.name}
className={`rounded-md p-8 ${t.highlight ? 'bg-white border-2 border-accent shadow-sm' : 'bg-white border border-gray-200'}`}
>
<h3 className="font-bold text-lg mb-1">{t.name}</h3>
<p className="text-muted text-sm mb-4">{t.desc}</p>
<div className="mb-6">
<span className="text-3xl font-bold">
{t.monthly === 0 ? 'Free' : `$${annual ? t.annual : t.monthly}`}
</span>
{t.monthly > 0 && <span className="text-muted text-sm"> /month</span>}
</div>
<ul className="space-y-2 text-sm text-muted mb-8">
{t.features.map((f, i) => (
<li key={i} className="flex gap-2">
<span className="text-accent"></span>
<span>{f}</span>
</li>
))}
</ul>
<a
href={t.href}
className={`block text-center font-medium px-4 py-2.5 rounded-md transition-colors ${
t.highlight
? 'bg-accent text-white hover:bg-accent-dark'
: 'border border-accent text-accent hover:bg-accent-light'
}`}
>
{t.cta}
</a>
</div>
))}
</div>
</div>
</section>
{/* FAQ */}
<section className="bg-warm">
<div className="max-w-6xl mx-auto px-6 py-24">
<h2 className="text-3xl font-bold mb-12 text-center">Frequently asked questions</h2>
<div className="max-w-2xl mx-auto space-y-8">
{faqs.map((f, i) => (
<div key={i}>
<h3 className="font-semibold mb-1">{f.q}</h3>
<p className="text-muted text-sm">{f.a}</p>
</div>
))}
</div>
</div>
</section>
{/* CTA */}
<section className="bg-dark text-white">
<div className="max-w-6xl mx-auto px-6 py-24 text-center">
<h2 className="text-3xl font-bold mb-4">Ready to finish the logframe?</h2>
<p className="text-gray-300 mb-8 max-w-md mx-auto">Your first document is free. No signup needed.</p>
<a
href="/logframe"
className="inline-block bg-accent text-white font-medium px-6 py-3 rounded-md hover:bg-accent-dark transition-colors"
>
Try free
</a>
</div>
</section>
</>
);
}
+579
View File
@@ -0,0 +1,579 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "ngo-toolkit",
"dependencies": {
"@anthropic-ai/sdk": "^0.39.0",
"@auth0/nextjs-auth0": "^3.5.0",
"@react-pdf/renderer": "^4.1.5",
"crypto": "^1.0.1",
"docx": "^9.1.1",
"next": "14.2.21",
"pg": "^8.13.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"xlsx": "^0.18.5",
},
"devDependencies": {
"@types/crypto-js": "^4.2.2",
"@types/node": "^22.10.2",
"@types/pg": "^8.11.10",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.2",
},
},
},
"packages": {
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
"@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.39.0", "", { "dependencies": { "@types/node": "^18.11.18", "@types/node-fetch": "^2.6.4", "abort-controller": "^3.0.0", "agentkeepalive": "^4.2.1", "form-data-encoder": "1.7.2", "formdata-node": "^4.3.2", "node-fetch": "^2.6.7" } }, "sha512-eMyDIPRZbt1CCLErRCi3exlAvNkBtRe+kW5vvJyef93PmNr/clstYgHhtvmkxN82nlKgzyGPCyGxrm0JQ1ZIdg=="],
"@auth0/nextjs-auth0": ["@auth0/nextjs-auth0@3.8.0", "", { "dependencies": { "@panva/hkdf": "^1.0.2", "cookie": "^0.7.1", "debug": "^4.3.4", "joi": "^17.6.0", "jose": "^4.15.5", "oauth4webapi": "^2.17.0", "openid-client": "^5.7.1", "tslib": "^2.4.0", "url-join": "^4.0.1" }, "peerDependencies": { "next": "^10.0.0 || ^11.0.0 || ^12.3.5 || ^13.5.9 || ^14.2.25 || ^15.2.3" } }, "sha512-xMzpkCuJAZ7tquJOZr7W4Jm9155EDotAACtdd9R9t4ATZgNGUzDQfPMogYJ2O14WB86sZSrp3rpJo4cspQYcSA=="],
"@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="],
"@hapi/hoek": ["@hapi/hoek@9.3.0", "", {}, "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ=="],
"@hapi/topo": ["@hapi/topo@5.1.0", "", { "dependencies": { "@hapi/hoek": "^9.0.0" } }, "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@next/env": ["@next/env@14.2.21", "", {}, "sha512-lXcwcJd5oR01tggjWJ6SrNNYFGuOOMB9c251wUNkjCpkoXOPkDeF/15c3mnVlBqrW4JJXb2kVxDFhC4GduJt2A=="],
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@14.2.21", "", { "os": "darwin", "cpu": "arm64" }, "sha512-HwEjcKsXtvszXz5q5Z7wCtrHeTTDSTgAbocz45PHMUjU3fBYInfvhR+ZhavDRUYLonm53aHZbB09QtJVJj8T7g=="],
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@14.2.21", "", { "os": "darwin", "cpu": "x64" }, "sha512-TSAA2ROgNzm4FhKbTbyJOBrsREOMVdDIltZ6aZiKvCi/v0UwFmwigBGeqXDA97TFMpR3LNNpw52CbVelkoQBxA=="],
"@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@14.2.21", "", { "os": "linux", "cpu": "arm64" }, "sha512-0Dqjn0pEUz3JG+AImpnMMW/m8hRtl1GQCNbO66V1yp6RswSTiKmnHf3pTX6xMdJYSemf3O4Q9ykiL0jymu0TuA=="],
"@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@14.2.21", "", { "os": "linux", "cpu": "arm64" }, "sha512-Ggfw5qnMXldscVntwnjfaQs5GbBbjioV4B4loP+bjqNEb42fzZlAaK+ldL0jm2CTJga9LynBMhekNfV8W4+HBw=="],
"@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@14.2.21", "", { "os": "linux", "cpu": "x64" }, "sha512-uokj0lubN1WoSa5KKdThVPRffGyiWlm/vCc/cMkWOQHw69Qt0X1o3b2PyLLx8ANqlefILZh1EdfLRz9gVpG6tg=="],
"@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@14.2.21", "", { "os": "linux", "cpu": "x64" }, "sha512-iAEBPzWNbciah4+0yI4s7Pce6BIoxTQ0AGCkxn/UBuzJFkYyJt71MadYQkjPqCQCJAFQ26sYh7MOKdU+VQFgPg=="],
"@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@14.2.21", "", { "os": "win32", "cpu": "arm64" }, "sha512-plykgB3vL2hB4Z32W3ktsfqyuyGAPxqwiyrAi2Mr8LlEUhNn9VgkiAl5hODSBpzIfWweX3er1f5uNpGDygfQVQ=="],
"@next/swc-win32-ia32-msvc": ["@next/swc-win32-ia32-msvc@14.2.21", "", { "os": "win32", "cpu": "ia32" }, "sha512-w5bacz4Vxqrh06BjWgua3Yf7EMDb8iMcVhNrNx8KnJXt8t+Uu0Zg4JHLDL/T7DkTCEEfKXO/Er1fcfWxn2xfPA=="],
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@14.2.21", "", { "os": "win32", "cpu": "x64" }, "sha512-sT6+llIkzpsexGYZq8cjjthRyRGe5cJVhqh12FmlbxHqna6zsDDK8UNaV7g41T6atFHCJUPeLb3uyAwrBwy0NA=="],
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
"@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
"@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
"@panva/hkdf": ["@panva/hkdf@1.2.1", "", {}, "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw=="],
"@react-pdf/fns": ["@react-pdf/fns@3.1.2", "", {}, "sha512-qTKGUf0iAMGg2+OsUcp9ffKnKi41RukM/zYIWMDJ4hRVYSr89Q7e3wSDW/Koqx3ea3Uy/z3h2y3wPX6Bdfxk6g=="],
"@react-pdf/font": ["@react-pdf/font@4.0.4", "", { "dependencies": { "@react-pdf/pdfkit": "^4.1.0", "@react-pdf/types": "^2.9.2", "fontkit": "^2.0.2", "is-url": "^1.2.4" } }, "sha512-8YtgGtL511txIEc9AjiilpZ7yjid8uCd8OGUl6jaL3LIHnrToUupSN4IzsMQpVTCMYiDLFnDNQzpZsOYtRS/Pg=="],
"@react-pdf/image": ["@react-pdf/image@3.0.4", "", { "dependencies": { "@react-pdf/png-js": "^3.0.0", "jay-peg": "^1.1.1" } }, "sha512-z0ogVQE0bKqgXQ5smgzIU857rLV7bMgVdrYsu3UfXDDLSzI7QPvzf6MFTFllX6Dx2rcsF13E01dqKPtJEM799g=="],
"@react-pdf/layout": ["@react-pdf/layout@4.4.2", "", { "dependencies": { "@react-pdf/fns": "3.1.2", "@react-pdf/image": "^3.0.4", "@react-pdf/primitives": "^4.1.1", "@react-pdf/stylesheet": "^6.1.2", "@react-pdf/textkit": "^6.1.0", "@react-pdf/types": "^2.9.2", "emoji-regex-xs": "^1.0.0", "queue": "^6.0.1", "yoga-layout": "^3.2.1" } }, "sha512-gNu2oh8MiGR+NJZYTJ4c4q0nWCESBI6rKFiodVhE7OeVAjtzZzd6l65wsN7HXdWJqOZD3ttD97iE+tf5SOd/Yg=="],
"@react-pdf/pdfkit": ["@react-pdf/pdfkit@4.1.0", "", { "dependencies": { "@babel/runtime": "^7.20.13", "@react-pdf/png-js": "^3.0.0", "browserify-zlib": "^0.2.0", "crypto-js": "^4.2.0", "fontkit": "^2.0.2", "jay-peg": "^1.1.1", "linebreak": "^1.1.0", "vite-compatible-readable-stream": "^3.6.1" } }, "sha512-Wm/IOAv0h/U5Ra94c/PltFJGcpTUd/fwVMVeFD6X9tTTPCttIwg0teRG1Lqq617J8K4W7jpL/B0HTH0mjp3QpQ=="],
"@react-pdf/png-js": ["@react-pdf/png-js@3.0.0", "", { "dependencies": { "browserify-zlib": "^0.2.0" } }, "sha512-eSJnEItZ37WPt6Qv5pncQDxLJRK15eaRwPT+gZoujP548CodenOVp49GST8XJvKMFt9YqIBzGBV/j9AgrOQzVA=="],
"@react-pdf/primitives": ["@react-pdf/primitives@4.1.1", "", {}, "sha512-IuhxYls1luJb7NUWy6q5avb1XrNaVj9bTNI40U9qGRuS6n7Hje/8H8Qi99Z9UKFV74bBP3DOf3L1wV2qZVgVrQ=="],
"@react-pdf/reconciler": ["@react-pdf/reconciler@2.0.0", "", { "dependencies": { "object-assign": "^4.1.1", "scheduler": "0.25.0-rc-603e6108-20241029" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-7zaPRujpbHSmCpIrZ+b9HSTJHthcVZzX0Wx7RzvQGsGBUbHP4p6s5itXrAIOuQuPvDepoHGNOvf6xUuMVvdoyw=="],
"@react-pdf/render": ["@react-pdf/render@4.3.2", "", { "dependencies": { "@babel/runtime": "^7.20.13", "@react-pdf/fns": "3.1.2", "@react-pdf/primitives": "^4.1.1", "@react-pdf/textkit": "^6.1.0", "@react-pdf/types": "^2.9.2", "abs-svg-path": "^0.1.1", "color-string": "^1.9.1", "normalize-svg-path": "^1.1.0", "parse-svg-path": "^0.1.2", "svg-arc-to-cubic-bezier": "^3.2.0" } }, "sha512-el5KYM1sH/PKcO4tRCIm8/AIEmhtraaONbwCrBhFdehoGv6JtgnXiMxHGAvZbI5kEg051GbyP+XIU6f6YbOu6Q=="],
"@react-pdf/renderer": ["@react-pdf/renderer@4.3.2", "", { "dependencies": { "@babel/runtime": "^7.20.13", "@react-pdf/fns": "3.1.2", "@react-pdf/font": "^4.0.4", "@react-pdf/layout": "^4.4.2", "@react-pdf/pdfkit": "^4.1.0", "@react-pdf/primitives": "^4.1.1", "@react-pdf/reconciler": "^2.0.0", "@react-pdf/render": "^4.3.2", "@react-pdf/types": "^2.9.2", "events": "^3.3.0", "object-assign": "^4.1.1", "prop-types": "^15.6.2", "queue": "^6.0.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-EhPkj35gO9rXIyyx29W3j3axemvVY5RigMmlK4/6Ku0pXB8z9PEE/sz4ZBOShu2uot6V4xiCR3aG+t9IjJJlBQ=="],
"@react-pdf/stylesheet": ["@react-pdf/stylesheet@6.1.2", "", { "dependencies": { "@react-pdf/fns": "3.1.2", "@react-pdf/types": "^2.9.2", "color-string": "^1.9.1", "hsl-to-hex": "^1.0.0", "media-engine": "^1.0.3", "postcss-value-parser": "^4.1.0" } }, "sha512-E3ftGRYUQGKiN3JOgtGsLDo0hGekA6dmkmi/MYACytmPTKxQRBSO3126MebmCq+t1rgU9uRlREIEawJ+8nzSbw=="],
"@react-pdf/textkit": ["@react-pdf/textkit@6.1.0", "", { "dependencies": { "@react-pdf/fns": "3.1.2", "bidi-js": "^1.0.2", "hyphen": "^1.6.4", "unicode-properties": "^1.4.1" } }, "sha512-sFlzDC9CDFrJsnL3B/+NHrk9+Advqk7iJZIStiYQDdskbow8GF/AGYrpIk+vWSnh35YxaGbHkqXq53XOxnyrjQ=="],
"@react-pdf/types": ["@react-pdf/types@2.9.2", "", { "dependencies": { "@react-pdf/font": "^4.0.4", "@react-pdf/primitives": "^4.1.1", "@react-pdf/stylesheet": "^6.1.2" } }, "sha512-dufvpKId9OajLLbgn9q7VLUmyo1Jf+iyGk2ZHmCL8nIDtL8N1Ejh9TH7+pXXrR0tdie1nmnEb5Bz9U7g4hI4/g=="],
"@sideway/address": ["@sideway/address@4.1.5", "", { "dependencies": { "@hapi/hoek": "^9.0.0" } }, "sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q=="],
"@sideway/formula": ["@sideway/formula@3.0.1", "", {}, "sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg=="],
"@sideway/pinpoint": ["@sideway/pinpoint@2.0.0", "", {}, "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ=="],
"@swc/counter": ["@swc/counter@0.1.3", "", {}, "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ=="],
"@swc/helpers": ["@swc/helpers@0.5.5", "", { "dependencies": { "@swc/counter": "^0.1.3", "tslib": "^2.4.0" } }, "sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A=="],
"@types/crypto-js": ["@types/crypto-js@4.2.2", "", {}, "sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ=="],
"@types/node": ["@types/node@22.19.15", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg=="],
"@types/node-fetch": ["@types/node-fetch@2.6.13", "", { "dependencies": { "@types/node": "*", "form-data": "^4.0.4" } }, "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw=="],
"@types/pg": ["@types/pg@8.18.0", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-gT+oueVQkqnj6ajGJXblFR4iavIXWsGAFCk3dP4Kki5+a9R4NMt0JARdk6s8cUKcfUoqP5dAtDSLU8xYUTFV+Q=="],
"@types/prop-types": ["@types/prop-types@15.7.15", "", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="],
"@types/react": ["@types/react@18.3.28", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" } }, "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw=="],
"@types/react-dom": ["@types/react-dom@18.3.7", "", { "peerDependencies": { "@types/react": "^18.0.0" } }, "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ=="],
"abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
"abs-svg-path": ["abs-svg-path@0.1.1", "", {}, "sha512-d8XPSGjfyzlXC3Xx891DJRyZfqk5JU0BJrDQcsWomFIV1/BIzPW5HDH5iDdWpqWaav0YVIEzT1RHTwWr0FFshA=="],
"adler-32": ["adler-32@1.3.1", "", {}, "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A=="],
"agentkeepalive": ["agentkeepalive@4.6.0", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="],
"any-promise": ["any-promise@1.3.0", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="],
"anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="],
"arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="],
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
"autoprefixer": ["autoprefixer@10.4.27", "", { "dependencies": { "browserslist": "^4.28.1", "caniuse-lite": "^1.0.30001774", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA=="],
"base64-js": ["base64-js@0.0.8", "", {}, "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.8", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-PCLz/LXGBsNTErbtB6i5u4eLpHeMfi93aUv5duMmj6caNu6IphS4q6UevDnL36sZQv9lrP11dbPKGMaXPwMKfQ=="],
"bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="],
"binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
"brotli": ["brotli@1.3.3", "", { "dependencies": { "base64-js": "^1.1.2" } }, "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg=="],
"browserify-zlib": ["browserify-zlib@0.2.0", "", { "dependencies": { "pako": "~1.0.5" } }, "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA=="],
"browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
"busboy": ["busboy@1.6.0", "", { "dependencies": { "streamsearch": "^1.1.0" } }, "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA=="],
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
"camelcase-css": ["camelcase-css@2.0.1", "", {}, "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="],
"caniuse-lite": ["caniuse-lite@1.0.30001780", "", {}, "sha512-llngX0E7nQci5BPJDqoZSbuZ5Bcs9F5db7EtgfwBerX9XGtkkiO4NwfDDIRzHTTwcYC8vC7bmeUEPGrKlR/TkQ=="],
"cfb": ["cfb@1.2.2", "", { "dependencies": { "adler-32": "~1.3.0", "crc-32": "~1.2.0" } }, "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA=="],
"chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
"client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="],
"clone": ["clone@2.1.2", "", {}, "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w=="],
"codepage": ["codepage@1.15.0", "", {}, "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA=="],
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="],
"combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
"commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="],
"cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
"core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="],
"crc-32": ["crc-32@1.2.2", "", { "bin": { "crc32": "bin/crc32.njs" } }, "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ=="],
"crypto": ["crypto@1.0.1", "", {}, "sha512-VxBKmeNcqQdiUQUW2Tzq0t377b54N2bMtXO/qiLa+6eRRmmC4qT3D4OnTGoT/U6O9aklQ/jTwbOtRMTTY8G0Ig=="],
"crypto-js": ["crypto-js@4.2.0", "", {}, "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q=="],
"cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
"dfa": ["dfa@1.2.0", "", {}, "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q=="],
"didyoumean": ["didyoumean@1.2.2", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="],
"dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="],
"docx": ["docx@9.6.1", "", { "dependencies": { "@types/node": "^25.2.3", "hash.js": "^1.1.7", "jszip": "^3.10.1", "nanoid": "^5.1.3", "xml": "^1.0.1", "xml-js": "^1.6.8" } }, "sha512-ZJja9/KBUuFC109sCMzovoq2GR2wCG/AuxivjA+OHj/q0TEgJIm3S7yrlUxIy3B+bV8YDj/BiHfWyrRFmyWpDQ=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"electron-to-chromium": ["electron-to-chromium@1.5.313", "", {}, "sha512-QBMrTWEf00GXZmJyx2lbYD45jpI3TUFnNIzJ5BBc8piGUDwMPa1GV6HJWTZVvY/eiN3fSopl7NRbgGp9sZ9LTA=="],
"emoji-regex-xs": ["emoji-regex-xs@1.0.0", "", {}, "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg=="],
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="],
"events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
"fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
"fontkit": ["fontkit@2.0.4", "", { "dependencies": { "@swc/helpers": "^0.5.12", "brotli": "^1.3.2", "clone": "^2.1.2", "dfa": "^1.2.0", "fast-deep-equal": "^3.1.3", "restructure": "^3.0.0", "tiny-inflate": "^1.0.3", "unicode-properties": "^1.4.0", "unicode-trie": "^2.0.0" } }, "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g=="],
"form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="],
"form-data-encoder": ["form-data-encoder@1.7.2", "", {}, "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A=="],
"formdata-node": ["formdata-node@4.4.1", "", { "dependencies": { "node-domexception": "1.0.0", "web-streams-polyfill": "4.0.0-beta.3" } }, "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ=="],
"frac": ["frac@1.1.2", "", {}, "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA=="],
"fraction.js": ["fraction.js@5.3.4", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
"has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="],
"hash.js": ["hash.js@1.1.7", "", { "dependencies": { "inherits": "^2.0.3", "minimalistic-assert": "^1.0.1" } }, "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA=="],
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"hsl-to-hex": ["hsl-to-hex@1.0.0", "", { "dependencies": { "hsl-to-rgb-for-reals": "^1.1.0" } }, "sha512-K6GVpucS5wFf44X0h2bLVRDsycgJmf9FF2elg+CrqD8GcFU8c6vYhgXn8NjUkFCwj+xDFb70qgLbTUm6sxwPmA=="],
"hsl-to-rgb-for-reals": ["hsl-to-rgb-for-reals@1.1.1", "", {}, "sha512-LgOWAkrN0rFaQpfdWBQlv/VhkOxb5AsBjk6NQVx4yEzWS923T07X0M1Y0VNko2H52HeSpZrZNNMJ0aFqsdVzQg=="],
"humanize-ms": ["humanize-ms@1.2.1", "", { "dependencies": { "ms": "^2.0.0" } }, "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ=="],
"hyphen": ["hyphen@1.14.1", "", {}, "sha512-kvL8xYl5QMTh+LwohVN72ciOxC0OEV79IPdJSTwEXok9y9QHebXGdFgrED4sWfiax/ODx++CAMk3hMy4XPJPOw=="],
"immediate": ["immediate@3.0.6", "", {}, "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="],
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"is-arrayish": ["is-arrayish@0.3.4", "", {}, "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA=="],
"is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="],
"is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="],
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
"is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
"is-url": ["is-url@1.2.4", "", {}, "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww=="],
"isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="],
"jay-peg": ["jay-peg@1.1.1", "", { "dependencies": { "restructure": "^3.0.0" } }, "sha512-D62KEuBxz/ip2gQKOEhk/mx14o7eiFRaU+VNNSP4MOiIkwb/D6B3G1Mfas7C/Fit8EsSV2/IWjZElx/Gs6A4ww=="],
"jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="],
"joi": ["joi@17.13.3", "", { "dependencies": { "@hapi/hoek": "^9.3.0", "@hapi/topo": "^5.1.0", "@sideway/address": "^4.1.5", "@sideway/formula": "^3.0.1", "@sideway/pinpoint": "^2.0.0" } }, "sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA=="],
"jose": ["jose@4.15.9", "", {}, "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"jszip": ["jszip@3.10.1", "", { "dependencies": { "lie": "~3.3.0", "pako": "~1.0.2", "readable-stream": "~2.3.6", "setimmediate": "^1.0.5" } }, "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g=="],
"lie": ["lie@3.3.0", "", { "dependencies": { "immediate": "~3.0.5" } }, "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ=="],
"lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="],
"linebreak": ["linebreak@1.1.0", "", { "dependencies": { "base64-js": "0.0.8", "unicode-trie": "^2.0.0" } }, "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ=="],
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
"lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"media-engine": ["media-engine@1.0.3", "", {}, "sha512-aa5tG6sDoK+k70B9iEX1NeyfT8ObCKhNDs6lJVpwF6r8vhUfuKMslIcirq6HIUYuuUYLefcEQOn9bSBOvawtwg=="],
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
"mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"minimalistic-assert": ["minimalistic-assert@1.0.1", "", {}, "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"next": ["next@14.2.21", "", { "dependencies": { "@next/env": "14.2.21", "@swc/helpers": "0.5.5", "busboy": "1.6.0", "caniuse-lite": "^1.0.30001579", "graceful-fs": "^4.2.11", "postcss": "8.4.31", "styled-jsx": "5.1.1" }, "optionalDependencies": { "@next/swc-darwin-arm64": "14.2.21", "@next/swc-darwin-x64": "14.2.21", "@next/swc-linux-arm64-gnu": "14.2.21", "@next/swc-linux-arm64-musl": "14.2.21", "@next/swc-linux-x64-gnu": "14.2.21", "@next/swc-linux-x64-musl": "14.2.21", "@next/swc-win32-arm64-msvc": "14.2.21", "@next/swc-win32-ia32-msvc": "14.2.21", "@next/swc-win32-x64-msvc": "14.2.21" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.41.2", "react": "^18.2.0", "react-dom": "^18.2.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-rZmLwucLHr3/zfDMYbJXbw0ZeoBpirxkXuvsJbk7UPorvPYZhP7vq7aHbKnU7dQNCYIimRrbB2pp3xmf+wsYUg=="],
"node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="],
"node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="],
"node-releases": ["node-releases@2.0.36", "", {}, "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA=="],
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
"normalize-svg-path": ["normalize-svg-path@1.1.0", "", { "dependencies": { "svg-arc-to-cubic-bezier": "^3.0.0" } }, "sha512-r9KHKG2UUeB5LoTouwDzBy2VxXlHsiM6fyLQvnJa0S5hrhzqElH/CH7TUGhT1fVvIYBIKf3OpY4YJ4CK+iaqHg=="],
"oauth4webapi": ["oauth4webapi@2.17.0", "", {}, "sha512-lbC0Z7uzAFNFyzEYRIC+pkSVvDHJTbEW+dYlSBAlCYDe6RxUkJ26bClhk8ocBZip1wfI9uKTe0fm4Ib4RHn6uQ=="],
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
"object-hash": ["object-hash@3.0.0", "", {}, "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw=="],
"oidc-token-hash": ["oidc-token-hash@5.2.0", "", {}, "sha512-6gj2m8cJZ+iSW8bm0FXdGF0YhIQbKrfP4yWTNzxc31U6MOjfEmB1rHvlYvxI1B7t7BCi1F2vYTT6YhtQRG4hxw=="],
"openid-client": ["openid-client@5.7.1", "", { "dependencies": { "jose": "^4.15.9", "lru-cache": "^6.0.0", "object-hash": "^2.2.0", "oidc-token-hash": "^5.0.3" } }, "sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew=="],
"pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],
"parse-svg-path": ["parse-svg-path@0.1.2", "", {}, "sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ=="],
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
"pg": ["pg@8.20.0", "", { "dependencies": { "pg-connection-string": "^2.12.0", "pg-pool": "^3.13.0", "pg-protocol": "^1.13.0", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.3.0" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA=="],
"pg-cloudflare": ["pg-cloudflare@1.3.0", "", {}, "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ=="],
"pg-connection-string": ["pg-connection-string@2.12.0", "", {}, "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ=="],
"pg-int8": ["pg-int8@1.0.1", "", {}, "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="],
"pg-pool": ["pg-pool@3.13.0", "", { "peerDependencies": { "pg": ">=8.0" } }, "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA=="],
"pg-protocol": ["pg-protocol@1.13.0", "", {}, "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w=="],
"pg-types": ["pg-types@2.2.0", "", { "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" } }, "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA=="],
"pgpass": ["pgpass@1.0.5", "", { "dependencies": { "split2": "^4.1.0" } }, "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"pify": ["pify@2.3.0", "", {}, "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog=="],
"pirates": ["pirates@4.0.7", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="],
"postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="],
"postcss-import": ["postcss-import@15.1.0", "", { "dependencies": { "postcss-value-parser": "^4.0.0", "read-cache": "^1.0.0", "resolve": "^1.1.7" }, "peerDependencies": { "postcss": "^8.0.0" } }, "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew=="],
"postcss-js": ["postcss-js@4.1.0", "", { "dependencies": { "camelcase-css": "^2.0.1" }, "peerDependencies": { "postcss": "^8.4.21" } }, "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw=="],
"postcss-load-config": ["postcss-load-config@6.0.1", "", { "dependencies": { "lilconfig": "^3.1.1" }, "peerDependencies": { "jiti": ">=1.21.0", "postcss": ">=8.0.9", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["jiti", "postcss", "tsx", "yaml"] }, "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g=="],
"postcss-nested": ["postcss-nested@6.2.0", "", { "dependencies": { "postcss-selector-parser": "^6.1.1" }, "peerDependencies": { "postcss": "^8.2.14" } }, "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ=="],
"postcss-selector-parser": ["postcss-selector-parser@6.1.2", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg=="],
"postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],
"postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="],
"postgres-bytea": ["postgres-bytea@1.0.1", "", {}, "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ=="],
"postgres-date": ["postgres-date@1.0.7", "", {}, "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q=="],
"postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="],
"process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="],
"prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
"queue": ["queue@6.0.2", "", { "dependencies": { "inherits": "~2.0.3" } }, "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA=="],
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
"react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="],
"react-dom": ["react-dom@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" }, "peerDependencies": { "react": "^18.3.1" } }, "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw=="],
"react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
"read-cache": ["read-cache@1.0.0", "", { "dependencies": { "pify": "^2.3.0" } }, "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA=="],
"readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
"readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
"resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="],
"restructure": ["restructure@3.0.2", "", {}, "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw=="],
"reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
"run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
"safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
"sax": ["sax@1.6.0", "", {}, "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA=="],
"scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="],
"setimmediate": ["setimmediate@1.0.5", "", {}, "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="],
"simple-swizzle": ["simple-swizzle@0.2.4", "", { "dependencies": { "is-arrayish": "^0.3.1" } }, "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="],
"ssf": ["ssf@0.11.2", "", { "dependencies": { "frac": "~1.1.2" } }, "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g=="],
"streamsearch": ["streamsearch@1.1.0", "", {}, "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg=="],
"string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
"styled-jsx": ["styled-jsx@5.1.1", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0" } }, "sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw=="],
"sucrase": ["sucrase@3.35.1", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw=="],
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
"svg-arc-to-cubic-bezier": ["svg-arc-to-cubic-bezier@3.2.0", "", {}, "sha512-djbJ/vZKZO+gPoSDThGNpKDO+o+bAeA4XQKovvkNCqnIS2t+S4qnLAGQhyyrulhCFRl1WWzAp0wUDV8PpTVU3g=="],
"tailwindcss": ["tailwindcss@3.4.19", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.7", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ=="],
"thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="],
"thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="],
"tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="],
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
"tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
"ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"unicode-properties": ["unicode-properties@1.4.1", "", { "dependencies": { "base64-js": "^1.3.0", "unicode-trie": "^2.0.0" } }, "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg=="],
"unicode-trie": ["unicode-trie@2.0.0", "", { "dependencies": { "pako": "^0.2.5", "tiny-inflate": "^1.0.0" } }, "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ=="],
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
"url-join": ["url-join@4.0.1", "", {}, "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA=="],
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
"vite-compatible-readable-stream": ["vite-compatible-readable-stream@3.6.1", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-t20zYkrSf868+j/p31cRIGN28Phrjm3nRSLR2fyc2tiWi4cZGVdv68yNlwnIINTkMTmPoMiSlc0OadaO7DXZaQ=="],
"web-streams-polyfill": ["web-streams-polyfill@4.0.0-beta.3", "", {}, "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug=="],
"webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
"whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
"wmf": ["wmf@1.0.2", "", {}, "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw=="],
"word": ["word@0.3.0", "", {}, "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA=="],
"xlsx": ["xlsx@0.18.5", "", { "dependencies": { "adler-32": "~1.3.0", "cfb": "~1.2.1", "codepage": "~1.15.0", "crc-32": "~1.2.1", "ssf": "~0.11.2", "wmf": "~1.0.1", "word": "~0.3.0" }, "bin": { "xlsx": "bin/xlsx.njs" } }, "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ=="],
"xml": ["xml@1.0.1", "", {}, "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw=="],
"xml-js": ["xml-js@1.6.11", "", { "dependencies": { "sax": "^1.2.4" }, "bin": { "xml-js": "./bin/cli.js" } }, "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g=="],
"xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
"yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
"yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="],
"@anthropic-ai/sdk/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
"@react-pdf/reconciler/scheduler": ["scheduler@0.25.0-rc-603e6108-20241029", "", {}, "sha512-pFwF6H1XrSdYYNLfOcGlM28/j8CGLu8IvdrxqhjWULe2bPcKiKW4CV+OWqR/9fT52mywx65l7ysNkjLKBda7eA=="],
"@types/node-fetch/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
"brotli/base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
"chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"docx/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
"docx/nanoid": ["nanoid@5.1.7", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-ua3NDgISf6jdwezAheMOk4mbE1LXjm1DfMUDMuJf4AqxLFK3ccGpgWizwa5YV7Yz9EpXwEaWoRXSb/BnV0t5dQ=="],
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"fontkit/@swc/helpers": ["@swc/helpers@0.5.19", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-QamiFeIK3txNjgUTNppE6MiG3p7TdninpZu0E0PbqVh1a9FNLT2FRhisaa4NcaX52XVhA5l7Pk58Ft7Sqi/2sA=="],
"next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
"openid-client/object-hash": ["object-hash@2.2.0", "", {}, "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw=="],
"readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
"string_decoder/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
"tinyglobby/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
"unicode-properties/base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
"unicode-trie/pako": ["pako@0.2.9", "", {}, "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="],
"@anthropic-ai/sdk/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
"@types/node-fetch/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
"docx/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
}
}
+28
View File
@@ -0,0 +1,28 @@
'use client';
interface BlurOverlayProps {
onUnlock: () => void;
loading?: boolean;
}
export default function BlurOverlay({ onUnlock, loading }: BlurOverlayProps) {
return (
<div className="absolute inset-0 z-10 flex items-center justify-center backdrop-blur-md bg-white/60 rounded-lg">
<div className="text-center p-8 max-w-md">
<div className="text-4xl mb-4">🔒</div>
<h3 className="text-xl font-bold text-dark mb-2">Unlock Full Output</h3>
<p className="text-gray-600 mb-6">
Your complete, professional-quality output is ready. Unlock it to access the full document, improvements, validation, and exports.
</p>
<button
onClick={onUnlock}
disabled={loading}
className="bg-accent hover:bg-accent/90 text-white px-8 py-3 rounded-lg font-semibold transition-colors disabled:opacity-50"
>
{loading ? 'Processing...' : 'Unlock Now — Pay Once'}
</button>
<p className="text-xs text-gray-400 mt-3">Secure payment via LemonSqueezy</p>
</div>
</div>
);
}
+120
View File
@@ -0,0 +1,120 @@
'use client';
import { useState } from 'react';
interface EditableOutputProps {
content: string;
sessionId: string;
tool: string;
isPaid: boolean;
improvementsUsed: number;
maxImprovements: number;
onImproved: (newContent: string) => void;
}
export default function EditableOutput({
content,
sessionId,
tool,
isPaid,
improvementsUsed,
maxImprovements,
onImproved,
}: EditableOutputProps) {
const [feedback, setFeedback] = useState('');
const [loading, setLoading] = useState(false);
const [validating, setValidating] = useState(false);
const [validation, setValidation] = useState<string | null>(null);
const handleImprove = async () => {
if (!feedback.trim()) return;
setLoading(true);
try {
const res = await fetch(`/api/${tool}/improve-section`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_id: sessionId, section: content, feedback }),
});
const data = await res.json();
if (data.improved) {
onImproved(data.improved);
setFeedback('');
}
} catch (e) {
console.error('Improve failed:', e);
} finally {
setLoading(false);
}
};
const handleValidate = async () => {
setValidating(true);
try {
const res = await fetch(`/api/${tool}/validate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_id: sessionId }),
});
const data = await res.json();
if (data.validation) setValidation(data.validation);
} catch (e) {
console.error('Validate failed:', e);
} finally {
setValidating(false);
}
};
const remaining = maxImprovements - improvementsUsed;
return (
<div className="space-y-4">
<div className="prose prose-sm max-w-none bg-gray-50 p-6 rounded-lg border whitespace-pre-wrap font-mono text-sm">
{content}
</div>
{isPaid && (
<>
<div className="flex gap-2">
<button
onClick={handleValidate}
disabled={validating}
className="px-4 py-2 bg-blue-600 text-white rounded-lg text-sm hover:bg-blue-700 disabled:opacity-50"
>
{validating ? 'Analysing...' : '🔍 Analyse Quality'}
</button>
<span className="text-xs text-gray-400 self-center">
{remaining} improvements remaining
</span>
</div>
{validation && (
<div className="bg-blue-50 border border-blue-200 p-4 rounded-lg">
<h4 className="font-semibold text-blue-800 mb-2">Quality Assessment</h4>
<div className="text-sm whitespace-pre-wrap">{validation}</div>
</div>
)}
{remaining > 0 && (
<div className="flex gap-2">
<input
type="text"
value={feedback}
onChange={(e) => setFeedback(e.target.value)}
placeholder="Describe what to improve..."
className="flex-1 border rounded-lg px-3 py-2 text-sm"
onKeyDown={(e) => e.key === 'Enter' && handleImprove()}
/>
<button
onClick={handleImprove}
disabled={loading || !feedback.trim()}
className="px-4 py-2 bg-accent text-white rounded-lg text-sm hover:bg-accent/90 disabled:opacity-50"
>
{loading ? 'Improving...' : '✨ Improve'}
</button>
</div>
)}
</>
)}
</div>
);
}
+80
View File
@@ -0,0 +1,80 @@
'use client';
import { useState } from 'react';
interface ExportMenuProps {
sessionId: string;
disabled?: boolean;
}
export default function ExportMenu({ sessionId, disabled }: ExportMenuProps) {
const [open, setOpen] = useState(false);
const [exporting, setExporting] = useState<string | null>(null);
const handleExport = async (format: string) => {
setExporting(format);
try {
if (format === 'copy') {
const res = await fetch(`/api/export/copy`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_id: sessionId }),
});
const data = await res.json();
await navigator.clipboard.writeText(data.text);
alert('Copied to clipboard!');
} else {
const res = await fetch(`/api/export/${format}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_id: sessionId }),
});
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `output.${format === 'excel' ? 'xlsx' : format}`;
a.click();
URL.revokeObjectURL(url);
}
} catch (e) {
console.error('Export failed:', e);
} finally {
setExporting(null);
setOpen(false);
}
};
const formats = [
{ key: 'copy', label: '📋 Copy to Clipboard', ext: '' },
{ key: 'docx', label: '📄 Word Document', ext: '.docx' },
{ key: 'excel', label: '📊 Excel Spreadsheet', ext: '.xlsx' },
{ key: 'pdf', label: '📕 PDF Document', ext: '.pdf' },
];
return (
<div className="relative">
<button
onClick={() => setOpen(!open)}
disabled={disabled}
className="px-4 py-2 bg-dark text-white rounded-lg text-sm hover:bg-dark/90 disabled:opacity-50"
>
📥 Export
</button>
{open && (
<div className="absolute right-0 top-full mt-1 bg-white border rounded-lg shadow-lg py-1 w-56 z-20">
{formats.map((f) => (
<button
key={f.key}
onClick={() => handleExport(f.key)}
disabled={exporting !== null}
className="w-full text-left px-4 py-2 hover:bg-gray-50 text-sm disabled:opacity-50"
>
{exporting === f.key ? 'Exporting...' : f.label}
</button>
))}
</div>
)}
</div>
);
}
+124
View File
@@ -0,0 +1,124 @@
'use client';
import { useState } from 'react';
import BlurOverlay from './BlurOverlay';
import EditableOutput from './EditableOutput';
import ExportMenu from './ExportMenu';
type State = 'idle' | 'generating' | 'preview' | 'unlocked' | 'done';
interface OutputPanelProps {
tool: string;
sessionId: string | null;
preview: string | null;
fullOutput: string | null;
isPaid: boolean;
onPaymentRequest: () => void;
paymentLoading?: boolean;
}
export default function OutputPanel({
tool,
sessionId,
preview,
fullOutput,
isPaid,
onPaymentRequest,
paymentLoading,
}: OutputPanelProps) {
const [output, setOutput] = useState(fullOutput);
const [finalizing, setFinalizing] = useState(false);
const state: State = !sessionId
? 'idle'
: !preview
? 'generating'
: !isPaid
? 'preview'
: finalizing
? 'done'
: 'unlocked';
const displayOutput = output || fullOutput;
const handleFinalize = async () => {
setFinalizing(true);
try {
const res = await fetch(`/api/${tool}/finalize`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_id: sessionId }),
});
const data = await res.json();
if (data.finalized) setOutput(data.finalized);
} catch (e) {
console.error('Finalize failed:', e);
}
};
if (state === 'idle') {
return (
<div className="h-full flex items-center justify-center text-gray-400">
<div className="text-center">
<div className="text-5xl mb-4">📝</div>
<p>Fill in the form and click Generate to get started</p>
</div>
</div>
);
}
if (state === 'generating') {
return (
<div className="h-full flex items-center justify-center">
<div className="text-center">
<div className="animate-spin text-4xl mb-4"></div>
<p className="text-gray-600 font-medium">Generating your output...</p>
<p className="text-gray-400 text-sm mt-1">This may take 15-30 seconds</p>
</div>
</div>
);
}
return (
<div className="relative">
{state === 'preview' && (
<>
<div className="prose prose-sm max-w-none bg-gray-50 p-6 rounded-lg border whitespace-pre-wrap font-mono text-sm">
{preview}
<span className="text-gray-300">{'...'.repeat(20)}</span>
</div>
<BlurOverlay onUnlock={onPaymentRequest} loading={paymentLoading} />
</>
)}
{(state === 'unlocked' || state === 'done') && sessionId && displayOutput && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-dark">Your Output</h3>
<div className="flex gap-2">
{!finalizing && (
<button
onClick={handleFinalize}
className="px-4 py-2 bg-accent text-white rounded-lg text-sm hover:bg-accent/90"
>
🎯 Finalize
</button>
)}
<ExportMenu sessionId={sessionId} />
</div>
</div>
<EditableOutput
content={displayOutput}
sessionId={sessionId}
tool={tool}
isPaid={true}
improvementsUsed={0}
maxImprovements={5}
onImproved={setOutput}
/>
</div>
)}
</div>
);
}
+70
View File
@@ -0,0 +1,70 @@
'use client';
import { useState } from 'react';
import { FormField } from '@/lib/types';
interface ToolFormProps {
fields: FormField[];
onSubmit: (data: Record<string, string>) => void;
loading?: boolean;
}
export default function ToolForm({ fields, onSubmit, loading }: ToolFormProps) {
const [values, setValues] = useState<Record<string, string>>({});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSubmit(values);
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
{fields.map((field) => (
<div key={field.name}>
<label className="block text-sm font-medium text-dark mb-1">
{field.label}
{field.required && <span className="text-red-500 ml-1">*</span>}
</label>
{field.type === 'textarea' ? (
<textarea
value={values[field.name] || ''}
onChange={(e) => setValues({ ...values, [field.name]: e.target.value })}
placeholder={field.placeholder}
required={field.required}
rows={4}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-accent/50 focus:border-accent"
/>
) : field.type === 'select' ? (
<select
value={values[field.name] || ''}
onChange={(e) => setValues({ ...values, [field.name]: e.target.value })}
required={field.required}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-accent/50 focus:border-accent"
>
<option value="">Select...</option>
{field.options?.map((opt) => (
<option key={opt} value={opt}>{opt}</option>
))}
</select>
) : (
<input
type="text"
value={values[field.name] || ''}
onChange={(e) => setValues({ ...values, [field.name]: e.target.value })}
placeholder={field.placeholder}
required={field.required}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-accent/50 focus:border-accent"
/>
)}
</div>
))}
<button
type="submit"
disabled={loading}
className="w-full bg-accent hover:bg-accent/90 text-white py-3 rounded-lg font-semibold transition-colors disabled:opacity-50"
>
{loading ? 'Generating...' : '✨ Generate'}
</button>
</form>
);
}
+31
View File
@@ -0,0 +1,31 @@
version: '3.8'
services:
app:
build: .
restart: unless-stopped
environment:
- DATABASE_URL=${DATABASE_URL}
- OPENAI_API_KEY=${OPENAI_API_KEY}
- LEMONSQUEEZY_API_KEY=${LEMONSQUEEZY_API_KEY}
- LEMONSQUEEZY_WEBHOOK_SECRET=${LEMONSQUEEZY_WEBHOOK_SECRET}
- LEMONSQUEEZY_STORE_ID=${LEMONSQUEEZY_STORE_ID}
- AUTH0_SECRET=${AUTH0_SECRET}
- AUTH0_BASE_URL=${AUTH0_BASE_URL}
- AUTH0_ISSUER_BASE_URL=${AUTH0_ISSUER_BASE_URL}
- AUTH0_CLIENT_ID=${AUTH0_CLIENT_ID}
- AUTH0_CLIENT_SECRET=${AUTH0_CLIENT_SECRET}
- NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL}
- NODE_ENV=production
networks:
- dokploy-network
labels:
- "traefik.enable=true"
- "traefik.http.routers.ngotoolkit.rule=Host(`ngotoolkit.quikcue.com`)"
- "traefik.http.routers.ngotoolkit.entrypoints=websecure"
- "traefik.http.routers.ngotoolkit.tls.certresolver=letsencrypt"
- "traefik.http.services.ngotoolkit.loadbalancer.server.port=3000"
networks:
dokploy-network:
external: true
+33
View File
@@ -0,0 +1,33 @@
import Anthropic from '@anthropic-ai/sdk';
import { getConfig } from '@/lib/db';
const anthropic = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
export async function getModel(): Promise<string> {
return (await getConfig('openai_model')) || 'claude-3-5-sonnet-20241022';
}
export async function generate(
systemPrompt: string,
userPrompt: string
): Promise<{ text: string; tokens: number }> {
const model = await getModel();
const response = await anthropic.messages.create({
model,
max_tokens: 4096,
system: systemPrompt,
messages: [{ role: 'user', content: userPrompt }],
});
const text = response.content
.filter((b) => b.type === 'text')
.map((b) => (b as Anthropic.TextBlock).text)
.join('');
const tokens = (response.usage.input_tokens ?? 0) + (response.usage.output_tokens ?? 0);
return { text, tokens };
}
export default anthropic;
+64
View File
@@ -0,0 +1,64 @@
export const funderRewriterMaster = (input: Record<string, string>) => `
You are an expert grant writer who specialises in tailoring NGO proposals to specific funders' priorities and language.
Rewrite the following proposal text to align with the target funder's priorities:
Original Text:
${input.original_text}
Target Funder: ${input.funder_name}
Funder Type: ${input.funder_type}
Funder Priorities: ${input.funder_priorities}
Grant Size: ${input.grant_size}
Sector Focus: ${input.sector}
Rewrite the text to:
1. **Mirror the funder's language** — Use terminology and framing the funder prefers
2. **Highlight alignment** — Emphasise how the project meets their specific priorities
3. **Adjust tone** — Match the formality level (institutional vs. foundation vs. corporate)
4. **Strengthen impact claims** — Frame outcomes in terms the funder values
5. **Add strategic keywords** — Include terms from the funder's own strategy documents
Provide the rewritten text with annotations explaining key changes made and why.
`;
export const funderRewriterImprove = (section: string, feedback: string) => `
You are an expert grant writer. Improve the following rewritten section based on feedback.
Current Rewritten Section:
${section}
Feedback:
${feedback}
Refine the text to better address the feedback while maintaining alignment with the target funder's language and priorities.
`;
export const funderRewriterValidate = (rewrite: string) => `
You are a senior grants manager reviewing a funder-tailored proposal rewrite.
Review this rewritten proposal:
${rewrite}
Assess:
1. **Funder Alignment** — Does the language match the target funder's style?
2. **Strategic Fit** — Are the funder's priorities clearly addressed?
3. **Persuasiveness** — Is the case compelling and well-argued?
4. **Authenticity** — Does it feel genuine, not forced or generic?
5. **Clarity** — Is the writing clear, concise, and jargon-appropriate?
6. **Compliance** — Does it meet typical funder expectations for structure?
Score each area out of 10 and provide specific improvement suggestions.
`;
export const funderRewriterFinalize = (rewrite: string, improvements: string) => `
You are an expert grant writer producing the final funder-tailored version.
Rewritten proposal:
${rewrite}
Improvements incorporated:
${improvements}
Produce the final, polished version that is ready for submission. Ensure the funder alignment is seamless and the writing is compelling.
`;
+24
View File
@@ -0,0 +1,24 @@
import { ToolName } from '@/lib/types';
import { logframeMaster, logframeImprove, logframeValidate, logframeFinalize } from './logframe';
import { funderRewriterMaster, funderRewriterImprove, funderRewriterValidate, funderRewriterFinalize } from './funder-rewriter';
import { smartIndicatorsMaster, smartIndicatorsImprove, smartIndicatorsValidate, smartIndicatorsFinalize } from './smart-indicators';
import { theoryOfChangeMaster, theoryOfChangeImprove, theoryOfChangeValidate, theoryOfChangeFinalize } from './theory-of-change';
type PromptFn = (input: Record<string, string>) => string;
type SectionPromptFn = (section: string, feedback: string) => string;
type ValidateFn = (content: string) => string;
type FinalizeFn = (content: string, improvements: string) => string;
interface ToolPrompts {
master: PromptFn;
improve: SectionPromptFn;
validate: ValidateFn;
finalize: FinalizeFn;
}
export const prompts: Record<ToolName, ToolPrompts> = {
logframe: { master: logframeMaster, improve: logframeImprove, validate: logframeValidate, finalize: logframeFinalize },
'funder-rewriter': { master: funderRewriterMaster, improve: funderRewriterImprove, validate: funderRewriterValidate, finalize: funderRewriterFinalize },
'smart-indicators': { master: smartIndicatorsMaster, improve: smartIndicatorsImprove, validate: smartIndicatorsValidate, finalize: smartIndicatorsFinalize },
'theory-of-change': { master: theoryOfChangeMaster, improve: theoryOfChangeImprove, validate: theoryOfChangeValidate, finalize: theoryOfChangeFinalize },
};
+72
View File
@@ -0,0 +1,72 @@
export const logframeMaster = (input: Record<string, string>) => `
You are an expert international development consultant specialising in logical frameworks (logframes) for NGO grant applications.
Create a complete, professional logframe matrix for the following project:
Project Title: ${input.project_title}
Organisation: ${input.organisation}
Sector: ${input.sector}
Target Beneficiaries: ${input.beneficiaries}
Geographic Location: ${input.location}
Project Duration: ${input.duration}
Budget Range: ${input.budget}
Project Summary: ${input.summary}
Generate a complete logframe with:
1. **Goal (Impact)** — The high-level development objective this project contributes to
2. **Purpose (Outcome)** — The specific change the project will achieve
3. **Outputs** — 3-5 concrete deliverables/results
4. **Activities** — 2-3 key activities per output
5. **Indicators** — SMART indicators for each level (goal, purpose, outputs)
6. **Means of Verification** — How each indicator will be measured
7. **Assumptions** — Key assumptions at each level
Format as a structured logframe matrix using markdown tables. Be specific, measurable, and realistic.
`;
export const logframeImprove = (section: string, feedback: string) => `
You are an expert logframe consultant. Improve the following section based on the feedback provided.
Current Section:
${section}
Feedback:
${feedback}
Provide an improved version that addresses the feedback while maintaining professional quality and logical consistency with a standard logframe matrix.
`;
export const logframeValidate = (logframe: string) => `
You are a senior M&E (Monitoring & Evaluation) specialist reviewing a logframe for quality assurance.
Review this logframe:
${logframe}
Provide a detailed quality assessment covering:
1. **Logical Consistency** — Does the causal chain (activities → outputs → purpose → goal) hold?
2. **SMART Indicators** — Are indicators Specific, Measurable, Achievable, Relevant, Time-bound?
3. **Means of Verification** — Are MOVs realistic and cost-effective?
4. **Assumptions** — Are assumptions clearly stated and reasonable?
5. **Completeness** — Are there gaps or missing elements?
6. **Donor Readiness** — Would this pass scrutiny from major donors (DFID, USAID, EU)?
Score each area out of 10 and provide specific recommendations for improvement.
`;
export const logframeFinalize = (logframe: string, improvements: string) => `
You are an expert logframe consultant producing the final version of a logframe.
Original logframe:
${logframe}
Improvements and feedback incorporated:
${improvements}
Produce the final, polished logframe matrix. Ensure:
- All indicators are fully SMART
- Means of verification are practical and specific
- Assumptions are clearly articulated
- The logical chain is coherent and compelling
- Language is professional and donor-ready
- Format is clean markdown with proper table structure
`;
+73
View File
@@ -0,0 +1,73 @@
export const smartIndicatorsMaster = (input: Record<string, string>) => `
You are an M&E (Monitoring & Evaluation) specialist who designs SMART indicators for NGO programmes.
Design a comprehensive set of SMART indicators for:
Project/Programme: ${input.project_title}
Sector: ${input.sector}
Outcomes to Measure: ${input.outcomes}
Target Population: ${input.target_population}
Timeframe: ${input.timeframe}
Data Collection Capacity: ${input.data_capacity}
Generate:
1. **Impact Indicators** (2-3) — Long-term change indicators
2. **Outcome Indicators** (3-5) — Medium-term results indicators
3. **Output Indicators** (5-8) — Direct deliverable indicators
4. **Process Indicators** (3-4) — Implementation quality indicators
For each indicator provide:
- **Indicator statement** (precise, measurable wording)
- **Baseline** (suggested baseline approach)
- **Target** (realistic target with timeframe)
- **Data source** (where/how to collect)
- **Frequency** (how often to measure)
- **Disaggregation** (gender, age, location, etc.)
Format as a clear structured table.
`;
export const smartIndicatorsImprove = (section: string, feedback: string) => `
You are an M&E specialist. Improve these indicators based on feedback.
Current Indicators:
${section}
Feedback:
${feedback}
Refine the indicators to be more SMART (Specific, Measurable, Achievable, Relevant, Time-bound) based on the feedback.
`;
export const smartIndicatorsValidate = (indicators: string) => `
You are a senior M&E advisor reviewing a set of SMART indicators.
Review these indicators:
${indicators}
Assess each indicator against SMART criteria:
1. **Specific** — Is it clear what is being measured?
2. **Measurable** — Can it be quantified or objectively verified?
3. **Achievable** — Is the target realistic given context?
4. **Relevant** — Does it measure what matters for the programme?
5. **Time-bound** — Is there a clear timeframe?
Also assess:
6. **Data feasibility** — Can the data realistically be collected?
7. **Completeness** — Are all key results areas covered?
8. **Disaggregation** — Is equity adequately captured?
Score each indicator and provide specific improvement recommendations.
`;
export const smartIndicatorsFinalize = (indicators: string, improvements: string) => `
You are an M&E specialist producing the final indicator framework.
Draft indicators:
${indicators}
Improvements to incorporate:
${improvements}
Produce the final, polished indicator framework ready for donor submission. Ensure all indicators are fully SMART and data collection is feasible.
`;
+69
View File
@@ -0,0 +1,69 @@
export const theoryOfChangeMaster = (input: Record<string, string>) => `
You are a Theory of Change specialist who helps NGOs articulate their impact pathways.
Develop a comprehensive Theory of Change for:
Organisation/Project: ${input.project_title}
Mission: ${input.mission}
Problem Statement: ${input.problem}
Target Population: ${input.target_population}
Geographic Context: ${input.context}
Timeframe: ${input.timeframe}
Generate a complete Theory of Change including:
1. **Problem Analysis** — Root causes and contributing factors
2. **Long-term Change (Impact)** — The ultimate vision of change
3. **Medium-term Outcomes** (3-4) — Changes that lead to impact
4. **Short-term Outcomes** (4-6) — Early changes from activities
5. **Outputs** — What the project directly produces
6. **Activities/Interventions** — What the project does
7. **Inputs** — Resources required
8. **Assumptions** — What must hold true at each level
9. **Evidence Base** — What evidence supports each causal link
10. **Causal Pathways** — Clear narrative connecting each level
Format with clear headers and a narrative that explains the causal logic. Include a text-based pathway diagram.
`;
export const theoryOfChangeImprove = (section: string, feedback: string) => `
You are a Theory of Change specialist. Improve this section based on feedback.
Current Section:
${section}
Feedback:
${feedback}
Strengthen the causal logic, evidence base, and clarity based on the feedback provided.
`;
export const theoryOfChangeValidate = (toc: string) => `
You are a senior programme strategist reviewing a Theory of Change.
Review this Theory of Change:
${toc}
Assess:
1. **Causal Logic** — Are the pathways from activities to impact clear and plausible?
2. **Evidence Base** — Is there evidence supporting the causal assumptions?
3. **Assumptions** — Are assumptions explicit, testable, and reasonable?
4. **Completeness** — Are all levels fully articulated?
5. **Coherence** — Does everything fit together logically?
6. **Testability** — Could this ToC be tested/validated through M&E?
7. **Stakeholder Perspective** — Are affected communities reflected?
Score each area out of 10 and provide specific recommendations.
`;
export const theoryOfChangeFinalize = (toc: string, improvements: string) => `
You are a Theory of Change specialist producing the final version.
Draft Theory of Change:
${toc}
Improvements to incorporate:
${improvements}
Produce the final, polished Theory of Change ready for donor submission. Ensure causal pathways are clear, assumptions explicit, and the narrative compelling.
`;
+10
View File
@@ -0,0 +1,10 @@
import { getSession } from '@auth0/nextjs-auth0';
import { NextResponse } from 'next/server';
export async function requireAdmin(): Promise<NextResponse | null> {
const session = await getSession();
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
return null;
}
+40
View File
@@ -0,0 +1,40 @@
import { Pool } from 'pg';
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 10,
idleTimeoutMillis: 30000,
});
export async function query<T = Record<string, unknown>>(
text: string,
params?: unknown[]
): Promise<T[]> {
const result = await pool.query(text, params);
return result.rows as T[];
}
export async function getConfig(key: string): Promise<string | null> {
const rows = await query<{ value: string }>(
'SELECT value FROM admin_config WHERE key = $1',
[key]
);
return rows[0]?.value ?? null;
}
export async function getAllConfig(): Promise<Record<string, string>> {
const rows = await query<{ key: string; value: string }>(
'SELECT key, value FROM admin_config ORDER BY key'
);
return Object.fromEntries(rows.map((r) => [r.key, r.value]));
}
export async function setConfig(key: string, value: string): Promise<void> {
await query(
`INSERT INTO admin_config (key, value, updated_at) VALUES ($1, $2, NOW())
ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = NOW()`,
[key, value]
);
}
export default pool;
+16
View File
@@ -0,0 +1,16 @@
import { readFileSync } from 'fs';
import { Pool } from 'pg';
import { join } from 'path';
async function migrate() {
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const sql = readFileSync(join(__dirname, 'schema.sql'), 'utf-8');
await pool.query(sql);
console.log('✅ Migration complete');
await pool.end();
}
migrate().catch((e) => {
console.error('Migration failed:', e);
process.exit(1);
});
+64
View File
@@ -0,0 +1,64 @@
-- NGO Toolkit Lab — Database Schema
-- Run: psql $DATABASE_URL < lib/db/schema.sql
CREATE TABLE IF NOT EXISTS admin_config (
key VARCHAR(100) PRIMARY KEY,
value TEXT NOT NULL,
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS leads (
id SERIAL PRIMARY KEY,
email VARCHAR(255) NOT NULL,
org_name VARCHAR(255),
tool VARCHAR(50) NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
-- One free generation per email globally
CREATE UNIQUE INDEX IF NOT EXISTS leads_email_unique ON leads(email);
CREATE TABLE IF NOT EXISTS sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tool VARCHAR(50) NOT NULL,
email VARCHAR(255),
input JSONB NOT NULL DEFAULT '{}',
output TEXT,
is_paid BOOLEAN DEFAULT FALSE,
payment_id VARCHAR(255),
improvements_used INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS usage (
id SERIAL PRIMARY KEY,
session_id UUID REFERENCES sessions(id),
tool VARCHAR(50) NOT NULL,
action VARCHAR(50) NOT NULL,
tokens_used INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW()
);
-- Default config
INSERT INTO admin_config (key, value) VALUES
('price_currency', 'EUR'),
('free_tier_enabled', 'true'),
('cap_improve', '10'),
('cap_analyse', '3'),
('openai_model', 'claude-3-5-sonnet-20241022'), -- rename key kept for compat, value is Claude model
('lemonsqueezy_store_id', ''),
-- Per-tool prices (cents) and LemonSqueezy variant IDs
('price_logframe', '900'),
('variant_logframe', ''),
('price_funder-rewriter', '900'),
('variant_funder-rewriter', ''),
('price_smart-indicators', '900'),
('variant_smart-indicators', ''),
('price_theory-of-change', '900'),
('variant_theory-of-change', '')
ON CONFLICT (key) DO NOTHING;
CREATE INDEX IF NOT EXISTS idx_sessions_tool ON sessions(tool);
CREATE INDEX IF NOT EXISTS idx_sessions_email ON sessions(email);
CREATE INDEX IF NOT EXISTS idx_leads_email ON leads(email);
CREATE INDEX IF NOT EXISTS idx_usage_session ON usage(session_id);
+109
View File
@@ -0,0 +1,109 @@
import {
Document,
Paragraph,
TextRun,
HeadingLevel,
Packer,
AlignmentType,
} from 'docx';
export async function generateDocx(
content: string,
toolName: string
): Promise<Buffer> {
const lines = content.split('\n');
const children: Paragraph[] = [];
// Title
children.push(
new Paragraph({
children: [
new TextRun({
text: `NGO Toolkit Lab — ${toolName.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())}`,
bold: true,
size: 32,
color: '1a6b3c',
}),
],
heading: HeadingLevel.TITLE,
alignment: AlignmentType.CENTER,
spacing: { after: 400 },
})
);
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) {
children.push(new Paragraph({ spacing: { after: 100 } }));
continue;
}
if (trimmed.startsWith('### ')) {
children.push(
new Paragraph({
children: [new TextRun({ text: trimmed.slice(4), bold: true, size: 24 })],
heading: HeadingLevel.HEADING_3,
spacing: { before: 200, after: 100 },
})
);
} else if (trimmed.startsWith('## ')) {
children.push(
new Paragraph({
children: [new TextRun({ text: trimmed.slice(3), bold: true, size: 28, color: '1a6b3c' })],
heading: HeadingLevel.HEADING_2,
spacing: { before: 300, after: 150 },
})
);
} else if (trimmed.startsWith('# ')) {
children.push(
new Paragraph({
children: [new TextRun({ text: trimmed.slice(2), bold: true, size: 32, color: '1a6b3c' })],
heading: HeadingLevel.HEADING_1,
spacing: { before: 400, after: 200 },
})
);
} else if (trimmed.startsWith('- ') || trimmed.startsWith('* ')) {
children.push(
new Paragraph({
children: [new TextRun({ text: trimmed.slice(2), size: 22 })],
bullet: { level: 0 },
})
);
} else if (trimmed.startsWith('**') && trimmed.endsWith('**')) {
children.push(
new Paragraph({
children: [new TextRun({ text: trimmed.slice(2, -2), bold: true, size: 22 })],
})
);
} else {
children.push(
new Paragraph({
children: [new TextRun({ text: trimmed, size: 22 })],
spacing: { after: 100 },
})
);
}
}
// Footer
children.push(
new Paragraph({
children: [
new TextRun({
text: 'Generated by NGO Toolkit Lab — ngotoolkit.com',
italics: true,
size: 18,
color: '888888',
}),
],
alignment: AlignmentType.CENTER,
spacing: { before: 600 },
})
);
const doc = new Document({
sections: [{ children }],
});
return Buffer.from(await Packer.toBuffer(doc));
}
+54
View File
@@ -0,0 +1,54 @@
import * as XLSX from 'xlsx';
export async function generateExcel(
content: string,
toolName: string
): Promise<Buffer> {
const wb = XLSX.utils.book_new();
// Parse markdown tables if present
const lines = content.split('\n');
const tableRows: string[][] = [];
const textRows: string[][] = [];
let inTable = false;
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith('|') && trimmed.endsWith('|')) {
// Skip separator rows
if (trimmed.match(/^\|[\s-:|]+\|$/)) continue;
inTable = true;
const cells = trimmed
.split('|')
.slice(1, -1)
.map((c) => c.trim());
tableRows.push(cells);
} else {
if (inTable) inTable = false;
if (trimmed) {
textRows.push([trimmed]);
}
}
}
// Create table sheet if tables found
if (tableRows.length > 0) {
const ws = XLSX.utils.aoa_to_sheet(tableRows);
XLSX.utils.book_append_sheet(wb, ws, 'Framework');
}
// Create content sheet
const title = toolName.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
const contentRows = [
[`NGO Toolkit Lab — ${title}`],
[''],
...textRows,
[''],
['Generated by NGO Toolkit Lab'],
];
const contentWs = XLSX.utils.aoa_to_sheet(contentRows);
XLSX.utils.book_append_sheet(wb, contentWs, 'Full Content');
const buf = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' });
return Buffer.from(buf);
}
+103
View File
@@ -0,0 +1,103 @@
// Simple PDF generation using a text-to-PDF approach
// @react-pdf/renderer requires React component rendering which is complex in API routes
// Using a simpler approach with manual PDF construction
export async function generatePdf(
content: string,
toolName: string
): Promise<Buffer> {
// Simple PDF using minimal PDF spec
const title = toolName.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
const header = `NGO Toolkit Lab — ${title}`;
// Clean content for PDF
const cleanContent = content
.replace(/\*\*/g, '')
.replace(/#{1,3}\s/g, '')
.replace(/\|/g, ' | ');
const lines = cleanContent.split('\n').filter((l) => l.trim());
// Build minimal PDF
const objects: string[] = [];
let objectCount = 0;
const offsets: number[] = [];
const addObject = (content: string): number => {
objectCount++;
offsets.push(0); // placeholder
objects.push(content);
return objectCount;
};
// Catalog
addObject('1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj');
// Pages
addObject('2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj');
// Build page content
let textContent = `BT\n/F1 16 Tf\n50 800 Td\n(${escPdf(header)}) Tj\n`;
textContent += `/F1 10 Tf\n0 -30 Td\n`;
let y = 770;
for (const line of lines) {
if (y < 50) break; // Simple single-page for now
const safeLine = escPdf(line.substring(0, 100));
textContent += `0 -14 Td\n(${safeLine}) Tj\n`;
y -= 14;
}
// Footer
textContent += `0 -30 Td\n/F1 8 Tf\n(Generated by NGO Toolkit Lab) Tj\n`;
textContent += 'ET';
const streamBytes = Buffer.from(textContent, 'utf-8');
// Page content stream
addObject(
`4 0 obj\n<< /Length ${streamBytes.length} >>\nstream\n${textContent}\nendstream\nendobj`
);
// Font
addObject('5 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\nendobj');
// Page
// Replace object 3 — insert at position 2
objects.splice(
2,
0,
`3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>\nendobj`
);
offsets.splice(2, 0, 0);
objectCount = objects.length;
// Build PDF
let pdf = '%PDF-1.4\n';
for (let i = 0; i < objects.length; i++) {
offsets[i] = pdf.length;
pdf += objects[i] + '\n';
}
const xrefOffset = pdf.length;
pdf += 'xref\n';
pdf += `0 ${objectCount + 1}\n`;
pdf += '0000000000 65535 f \n';
for (let i = 0; i < objectCount; i++) {
pdf += `${String(offsets[i]).padStart(10, '0')} 00000 n \n`;
}
pdf += 'trailer\n';
pdf += `<< /Size ${objectCount + 1} /Root 1 0 R >>\n`;
pdf += 'startxref\n';
pdf += `${xrefOffset}\n`;
pdf += '%%EOF';
return Buffer.from(pdf, 'utf-8');
}
function escPdf(text: string): string {
return text
.replace(/\\/g, '\\\\')
.replace(/\(/g, '\\(')
.replace(/\)/g, '\\)');
}
+48
View File
@@ -0,0 +1,48 @@
import { createHmac } from 'crypto';
export function verifyWebhookSignature(
payload: string,
signature: string
): boolean {
const secret = process.env.LEMONSQUEEZY_WEBHOOK_SECRET || '';
const hmac = createHmac('sha256', secret);
const digest = hmac.update(payload).digest('hex');
return signature === digest;
}
export async function createCheckout(
variantId: string,
sessionId: string,
email?: string
): Promise<string> {
const response = await fetch('https://api.lemonsqueezy.com/v1/checkouts', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.LEMONSQUEEZY_API_KEY}`,
'Content-Type': 'application/vnd.api+json',
'Accept': 'application/vnd.api+json',
},
body: JSON.stringify({
data: {
type: 'checkouts',
attributes: {
checkout_data: {
email: email || undefined,
custom: { session_id: sessionId },
},
},
relationships: {
store: {
data: { type: 'stores', id: process.env.LEMONSQUEEZY_STORE_ID },
},
variant: {
data: { type: 'variants', id: variantId },
},
},
},
}),
});
const data = await response.json();
return data.data.attributes.url;
}
+66
View File
@@ -0,0 +1,66 @@
import { ToolConfig } from './types';
export const tools: ToolConfig[] = [
{
slug: 'logframe',
name: 'Logframe Generator',
description: 'Generate professional logical framework matrices for grant applications in minutes.',
icon: '📊',
fields: [
{ name: 'project_title', label: 'Project Title', type: 'text', required: true, placeholder: 'e.g. Clean Water Access Programme' },
{ name: 'organisation', label: 'Organisation', type: 'text', required: true, placeholder: 'e.g. WaterAid' },
{ name: 'sector', label: 'Sector', type: 'select', required: true, options: ['Health', 'Education', 'WASH', 'Livelihoods', 'Protection', 'Food Security', 'Climate', 'Governance', 'Other'] },
{ name: 'beneficiaries', label: 'Target Beneficiaries', type: 'textarea', required: true, placeholder: 'Describe who benefits and estimated numbers' },
{ name: 'location', label: 'Geographic Location', type: 'text', required: true, placeholder: 'e.g. Rural Kenya, Kakamega County' },
{ name: 'duration', label: 'Project Duration', type: 'text', required: true, placeholder: 'e.g. 24 months' },
{ name: 'budget', label: 'Budget Range', type: 'text', placeholder: 'e.g. £500,000 - £750,000' },
{ name: 'summary', label: 'Project Summary', type: 'textarea', required: true, placeholder: 'Describe the project in 2-3 paragraphs' },
],
},
{
slug: 'funder-rewriter',
name: 'Funder Rewriter',
description: 'Tailor your proposal language to match specific funders\' priorities and terminology.',
icon: '✍️',
fields: [
{ name: 'original_text', label: 'Original Proposal Text', type: 'textarea', required: true, placeholder: 'Paste your existing proposal text here' },
{ name: 'funder_name', label: 'Target Funder', type: 'text', required: true, placeholder: 'e.g. DFID, USAID, Gates Foundation' },
{ name: 'funder_type', label: 'Funder Type', type: 'select', required: true, options: ['Bilateral (Government)', 'Multilateral (UN/World Bank)', 'Foundation', 'Corporate', 'Trust', 'Other'] },
{ name: 'funder_priorities', label: 'Funder Priorities', type: 'textarea', required: true, placeholder: 'List the funder\'s key strategic priorities' },
{ name: 'grant_size', label: 'Grant Size', type: 'text', placeholder: 'e.g. £100,000 - £500,000' },
{ name: 'sector', label: 'Sector', type: 'text', required: true, placeholder: 'e.g. Health, Education' },
],
},
{
slug: 'smart-indicators',
name: 'SMART Indicators',
description: 'Design comprehensive SMART indicator frameworks for M&E systems.',
icon: '🎯',
fields: [
{ name: 'project_title', label: 'Project/Programme Name', type: 'text', required: true, placeholder: 'e.g. Maternal Health Improvement Programme' },
{ name: 'sector', label: 'Sector', type: 'select', required: true, options: ['Health', 'Education', 'WASH', 'Livelihoods', 'Protection', 'Food Security', 'Climate', 'Governance', 'Other'] },
{ name: 'outcomes', label: 'Outcomes to Measure', type: 'textarea', required: true, placeholder: 'List the key outcomes you need indicators for' },
{ name: 'target_population', label: 'Target Population', type: 'textarea', required: true, placeholder: 'Who are you measuring change for?' },
{ name: 'timeframe', label: 'Timeframe', type: 'text', required: true, placeholder: 'e.g. 3 years' },
{ name: 'data_capacity', label: 'Data Collection Capacity', type: 'select', required: true, options: ['Basic (paper-based)', 'Moderate (some digital)', 'Advanced (full digital M&E system)'] },
],
},
{
slug: 'theory-of-change',
name: 'Theory of Change',
description: 'Build compelling theories of change with clear causal pathways and evidence.',
icon: '🔄',
fields: [
{ name: 'project_title', label: 'Project/Organisation Name', type: 'text', required: true, placeholder: 'e.g. Youth Empowerment Initiative' },
{ name: 'mission', label: 'Mission Statement', type: 'textarea', required: true, placeholder: 'What is your organisation/project mission?' },
{ name: 'problem', label: 'Problem Statement', type: 'textarea', required: true, placeholder: 'What problem are you addressing? Include root causes.' },
{ name: 'target_population', label: 'Target Population', type: 'textarea', required: true, placeholder: 'Who are you trying to help?' },
{ name: 'context', label: 'Geographic/Social Context', type: 'textarea', required: true, placeholder: 'Describe the context in which you operate' },
{ name: 'timeframe', label: 'Timeframe', type: 'text', required: true, placeholder: 'e.g. 5 years' },
],
},
];
export function getToolBySlug(slug: string): ToolConfig | undefined {
return tools.find((t) => t.slug === slug);
}
+48
View File
@@ -0,0 +1,48 @@
export type ToolName = 'logframe' | 'funder-rewriter' | 'smart-indicators' | 'theory-of-change';
export interface Session {
id: string;
tool: ToolName;
email: string | null;
input: Record<string, string>;
output: string | null;
is_paid: boolean;
payment_id: string | null;
improvements_used: number;
created_at: string;
updated_at: string;
}
export interface Lead {
id: number;
email: string;
org_name: string | null;
tool: string;
created_at: string;
}
export interface AdminConfig {
price_amount: string;
price_currency: string;
free_tier_enabled: string;
max_improvements: string;
openai_model: string;
lemonsqueezy_variant_id: string;
}
export interface ToolConfig {
slug: ToolName;
name: string;
description: string;
icon: string;
fields: FormField[];
}
export interface FormField {
name: string;
label: string;
type: 'text' | 'textarea' | 'select';
placeholder?: string;
options?: string[];
required?: boolean;
}
+13
View File
@@ -0,0 +1,13 @@
import { withMiddlewareAuthRequired } from '@auth0/nextjs-auth0/edge';
import { NextRequest, NextResponse } from 'next/server';
export default function middleware(request: NextRequest) {
if (request.nextUrl.pathname.startsWith('/admin')) {
return withMiddlewareAuthRequired()(request, {} as never);
}
return NextResponse.next();
}
export const config = {
matcher: ['/admin/:path*'],
};
+5
View File
@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
+6
View File
@@ -0,0 +1,6 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
};
module.exports = nextConfig;
+35
View File
@@ -0,0 +1,35 @@
{
"name": "ngo-toolkit",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"db:migrate": "bun run lib/db/migrate.ts"
},
"dependencies": {
"next": "14.2.21",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"@auth0/nextjs-auth0": "^3.5.0",
"@anthropic-ai/sdk": "^0.39.0",
"pg": "^8.13.1",
"docx": "^9.1.1",
"xlsx": "^0.18.5",
"@react-pdf/renderer": "^4.1.5",
"crypto": "^1.0.1"
},
"devDependencies": {
"@types/node": "^22.10.2",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@types/pg": "^8.11.10",
"typescript": "^5.7.2",
"tailwindcss": "^3.4.17",
"postcss": "^8.4.49",
"autoprefixer": "^10.4.20",
"@types/crypto-js": "^4.2.2"
}
}
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 726 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 651 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 718 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 632 KiB

+24
View File
@@ -0,0 +1,24 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
'./app/**/*.{js,ts,jsx,tsx,mdx}',
'./components/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {
colors: {
accent: '#1a6b3c',
'accent-light': '#e8f5ee',
'accent-dark': '#134d2b',
dark: '#111827',
warm: '#fafaf8',
sand: '#f5f0e8',
muted: '#6b7280',
},
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
},
},
},
plugins: [],
};
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": { "@/*": ["./*"] }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}