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
+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>
</>
);
}