← back to Patty
components/templates/TemplatesTab.tsx
396 lines
'use client';
import { useState, useEffect, useCallback } from 'react';
import { SkeletonList } from '../Skeleton';
import {
FileText, Plus, Star, Loader2, X, Sparkles,
Tag, Copy, Megaphone,
} from 'lucide-react';
/* ─── Types ─────────────────────────────────────────────────── */
interface Template {
id: string;
title: string;
category: string | null;
body_html: string;
body_text: string | null;
tags: string[] | null;
usage_count: number;
is_featured: boolean;
created_at: string;
}
function stripHtml(html: string): string {
return html.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
}
/* ─── Component ─────────────────────────────────────────────── */
export default function TemplatesTab() {
const [templates, setTemplates] = useState<Template[]>([]);
const [loading, setLoading] = useState(true);
const [categoryFilter, setCategoryFilter] = useState('all');
// Create template modal
const [showCreate, setShowCreate] = useState(false);
const [createTitle, setCreateTitle] = useState('');
const [createCategory, setCreateCategory] = useState('policy');
const [createBody, setCreateBody] = useState('');
const [createTags, setCreateTags] = useState('');
const [createFeatured, setCreateFeatured] = useState(false);
const [createSaving, setCreateSaving] = useState(false);
// Use template modal (create petition from template)
const [useTemplate, setUseTemplate] = useState<Template | null>(null);
const [petTitle, setPetTitle] = useState('');
const [petTarget, setPetTarget] = useState('');
const [petGoal, setPetGoal] = useState(100);
const [petSaving, setPetSaving] = useState(false);
/* ── Fetch ──────────────────────────────────────────────────── */
const fetchTemplates = useCallback(async () => {
setLoading(true);
try {
const params = new URLSearchParams();
if (categoryFilter !== 'all') params.set('category', categoryFilter);
const res = await fetch(`/api/templates?${params.toString()}`);
if (res.ok) {
const data = await res.json();
setTemplates(data.rows || []);
}
} catch (err) {
console.error('Fetch templates error:', err);
} finally {
setLoading(false);
}
}, [categoryFilter]);
useEffect(() => { fetchTemplates(); }, [fetchTemplates]);
/* ── Create Template ────────────────────────────────────────── */
async function handleCreate() {
if (!createTitle.trim() || !createBody.trim()) return;
setCreateSaving(true);
try {
const tags = createTags.split(',').map((t) => t.trim()).filter(Boolean);
const res = await fetch('/api/templates', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: createTitle,
category: createCategory,
body_html: `<p>${createBody}</p>`,
body_text: createBody,
tags: tags.length > 0 ? tags : null,
is_featured: createFeatured,
}),
});
if (res.ok) {
setShowCreate(false);
setCreateTitle('');
setCreateBody('');
setCreateTags('');
setCreateFeatured(false);
fetchTemplates();
}
} catch (err) {
console.error('Create template error:', err);
} finally {
setCreateSaving(false);
}
}
/* ── Use Template ───────────────────────────────────────────── */
function openUseTemplate(t: Template) {
setUseTemplate(t);
setPetTitle(t.title);
setPetTarget('');
setPetGoal(100);
}
async function handleUseSave() {
if (!useTemplate || !petTitle.trim()) return;
setPetSaving(true);
try {
const res = await fetch('/api/petitions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: petTitle,
body_html: useTemplate.body_html,
body_text: useTemplate.body_text,
target: petTarget || null,
category: useTemplate.category,
tags: useTemplate.tags,
signature_goal: petGoal,
status: 'draft',
}),
});
if (res.ok) {
setUseTemplate(null);
}
} catch (err) {
console.error('Use template error:', err);
} finally {
setPetSaving(false);
}
}
/* ── Helpers ────────────────────────────────────────────────── */
const categories = ['all', 'debt_relief', 'policy', 'education_access', 'consumer_protection'];
const categoryLabels: Record<string, string> = {
all: 'All',
debt_relief: 'Debt Relief',
policy: 'Policy',
education_access: 'Education Access',
consumer_protection: 'Consumer Protection',
};
/* ─── Render ────────────────────────────────────────────────── */
return (
<div style={{ padding: '24px' }}>
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-lg font-semibold" style={{ color: 'var(--color-text)' }}>Petition Templates</h2>
<p className="text-sm mt-1" style={{ color: 'var(--color-text-muted)' }}>
Pre-built petition templates for common advocacy scenarios
</p>
</div>
<button className="btn btn-primary" onClick={() => setShowCreate(true)}>
<Plus size={16} />
New Template
</button>
</div>
{/* Category Filters */}
<div className="flex items-center gap-2 mb-6 flex-wrap">
{categories.map((c) => (
<button
key={c}
onClick={() => setCategoryFilter(c)}
className={`btn btn-sm ${categoryFilter === c ? 'btn-primary' : 'btn-ghost'}`}
>
{categoryLabels[c] || c}
</button>
))}
</div>
{/* Loading */}
{loading && (
<SkeletonList count={4} />
)}
{/* Empty State */}
{!loading && templates.length === 0 && (
<div className="card flex flex-col items-center justify-center py-16" style={{ textAlign: 'center' }}>
<div
className="w-16 h-16 rounded-2xl flex items-center justify-center mb-4"
style={{
background: 'linear-gradient(135deg, rgba(124, 58, 237, 0.15), rgba(167, 139, 250, 0.1))',
border: '1px solid rgba(124, 58, 237, 0.2)',
}}
>
<FileText size={28} style={{ color: 'var(--color-primary)' }} />
</div>
<h3 className="text-base font-semibold mb-2" style={{ color: 'var(--color-text)' }}>No templates found</h3>
<p className="text-sm mb-6 max-w-md" style={{ color: 'var(--color-text-muted)' }}>
Templates help you quickly create petitions. Try a different category filter or create a new template.
</p>
<button className="btn btn-primary" onClick={() => setShowCreate(true)}>
<Plus size={16} /> Create Template
</button>
</div>
)}
{/* Template Grid */}
{!loading && templates.length > 0 && (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{templates.map((t) => (
<div key={t.id} className="card flex flex-col">
{/* Header */}
<div className="flex items-start justify-between gap-2 mb-2">
<h3 className="text-sm font-semibold" style={{ color: 'var(--color-text)' }}>
{t.title}
</h3>
{t.is_featured && (
<Star size={14} style={{ color: 'var(--color-warning)', fill: 'var(--color-warning)', flexShrink: 0 }} />
)}
</div>
{/* Category + Usage */}
<div className="flex items-center gap-2 mb-3">
{t.category && (
<span
className="badge"
style={{
backgroundColor: 'rgba(124, 58, 237, 0.1)',
color: 'var(--color-secondary)',
border: '1px solid rgba(124, 58, 237, 0.2)',
}}
>
{categoryLabels[t.category] || t.category}
</span>
)}
<span className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
Used {t.usage_count} times
</span>
</div>
{/* Body Preview */}
<p
className="text-xs mb-3 flex-1"
style={{
color: 'var(--color-text-muted)',
display: '-webkit-box',
WebkitLineClamp: 3,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
}}
>
{stripHtml(t.body_html).slice(0, 180)}
</p>
{/* Tags */}
{t.tags && t.tags.length > 0 && (
<div className="flex items-center gap-1 mb-3 flex-wrap">
{t.tags.map((tag, i) => (
<span key={i} className="text-xs" style={{
color: 'var(--color-secondary)',
padding: '1px 6px',
borderRadius: 9999,
backgroundColor: 'rgba(124, 58, 237, 0.08)',
}}>
{tag}
</span>
))}
</div>
)}
{/* Use Template Button */}
<button
className="btn btn-sm btn-secondary w-full"
onClick={() => openUseTemplate(t)}
>
<Megaphone size={14} /> Use Template
</button>
</div>
))}
</div>
)}
{/* ─── Create Template Modal ────────────────────────────────── */}
{showCreate && (
<div
style={{
position: 'fixed', inset: 0, zIndex: 50,
display: 'flex', alignItems: 'center', justifyContent: 'center',
backgroundColor: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(4px)',
}}
onClick={(e) => { if (e.target === e.currentTarget) setShowCreate(false); }}
>
<div style={{
width: '100%', maxWidth: 500, maxHeight: '90vh', overflow: 'auto',
backgroundColor: 'var(--color-surface)', border: '1px solid var(--color-border)',
borderRadius: 12, padding: 24,
}}>
<div className="flex items-center justify-between mb-4">
<h3 className="text-base font-semibold" style={{ color: 'var(--color-text)' }}>Create Template</h3>
<button className="btn btn-ghost btn-sm" onClick={() => setShowCreate(false)}><X size={16} /></button>
</div>
<div className="flex flex-col gap-4">
<div>
<label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>Title *</label>
<input className="input" value={createTitle} onChange={(e) => setCreateTitle(e.target.value)} placeholder="Template title" />
</div>
<div>
<label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>Category</label>
<select className="input" value={createCategory} onChange={(e) => setCreateCategory(e.target.value)}>
<option value="debt_relief">Debt Relief</option>
<option value="policy">Policy</option>
<option value="education_access">Education Access</option>
<option value="consumer_protection">Consumer Protection</option>
</select>
</div>
<div>
<label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>Body *</label>
<textarea
className="input"
rows={6}
value={createBody}
onChange={(e) => setCreateBody(e.target.value)}
placeholder="Petition template body. Use [TARGET] as placeholder for the petition target."
style={{ resize: 'vertical' }}
/>
</div>
<div>
<label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>Tags (comma separated)</label>
<input className="input" value={createTags} onChange={(e) => setCreateTags(e.target.value)} placeholder="student loans, policy, reform" />
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={createFeatured}
onChange={(e) => setCreateFeatured(e.target.checked)}
style={{ accentColor: 'var(--color-primary)' }}
id="featured-check"
/>
<label htmlFor="featured-check" className="text-xs" style={{ color: 'var(--color-text-secondary)' }}>Featured template</label>
</div>
<button className="btn btn-primary w-full" onClick={handleCreate} disabled={createSaving || !createTitle.trim() || !createBody.trim()}>
{createSaving ? <><Loader2 size={16} className="animate-spin" /> Creating...</> : <><Plus size={16} /> Create Template</>}
</button>
</div>
</div>
</div>
)}
{/* ─── Use Template Modal ───────────────────────────────────── */}
{useTemplate && (
<div
style={{
position: 'fixed', inset: 0, zIndex: 50,
display: 'flex', alignItems: 'center', justifyContent: 'center',
backgroundColor: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(4px)',
}}
onClick={(e) => { if (e.target === e.currentTarget) setUseTemplate(null); }}
>
<div style={{
width: '100%', maxWidth: 500, maxHeight: '90vh', overflow: 'auto',
backgroundColor: 'var(--color-surface)', border: '1px solid var(--color-border)',
borderRadius: 12, padding: 24,
}}>
<div className="flex items-center justify-between mb-4">
<h3 className="text-base font-semibold" style={{ color: 'var(--color-text)' }}>Create Petition from Template</h3>
<button className="btn btn-ghost btn-sm" onClick={() => setUseTemplate(null)}><X size={16} /></button>
</div>
<div className="flex flex-col gap-4">
<div className="text-xs" style={{
color: 'var(--color-text-muted)', padding: 8, borderRadius: 6,
backgroundColor: 'var(--color-surface-el)', whiteSpace: 'pre-wrap',
}}>
{stripHtml(useTemplate.body_html).slice(0, 300)}...
</div>
<div>
<label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>Petition Title *</label>
<input className="input" value={petTitle} onChange={(e) => setPetTitle(e.target.value)} />
</div>
<div>
<label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>Target</label>
<input className="input" value={petTarget} onChange={(e) => setPetTarget(e.target.value)} placeholder="e.g., Congress" />
</div>
<div>
<label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>Signature Goal</label>
<input className="input" type="number" value={petGoal} onChange={(e) => setPetGoal(Number(e.target.value))} />
</div>
<button className="btn btn-primary w-full" onClick={handleUseSave} disabled={petSaving || !petTitle.trim()}>
{petSaving ? <><Loader2 size={16} className="animate-spin" /> Creating...</> : <><Megaphone size={16} /> Create Petition Draft</>}
</button>
</div>
</div>
</div>
)}
</div>
);
}