Initial commit
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { handleAuth } from '@auth0/nextjs-auth0';
|
||||
|
||||
export const GET = handleAuth();
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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: '8–12 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: '2–3 days', toolkit: '< 2 minutes' },
|
||||
{ task: 'Write SMART indicators', manual: '4–6 hours', toolkit: '30 seconds' },
|
||||
{ task: 'Draft a theory of change', manual: '1–2 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user