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