← back to Patty
app/petitions/[slug]/page.tsx
511 lines
import { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { query } from '@/lib/db';
import { sanitizePetitionBody } from '@/lib/sanitize';
import SignatureForm from './SignatureForm';
import ShareButtons from './ShareButtons';
import PostToPlatformsPanel from './PostToPlatformsPanel';
interface PetitionRow {
id: string;
title: string;
slug: string;
summary: string | null;
body_html: string;
body_text: string | null;
target: string | null;
category: string | null;
tags: string[] | null;
status: string;
signature_count: number;
signature_goal: number;
share_count: number;
image_url: string | null;
created_at: string;
updated_at: string;
}
interface PageProps {
params: Promise<{ slug: string }>;
}
async function getPetition(slug: string): Promise<PetitionRow | null> {
const result = await query<PetitionRow>(
`SELECT id, title, slug, summary, body_html, body_text, target, category, tags,
status, signature_count, signature_goal, share_count, image_url,
created_at, updated_at
FROM petitions WHERE slug = $1 LIMIT 1`,
[slug]
);
return result.rows[0] || null;
}
async function getRecentSignatures(petitionId: string) {
const result = await query(
`SELECT signer_name, signer_comment, created_at
FROM signatures
WHERE petition_id = $1 AND is_public = true
ORDER BY created_at DESC
LIMIT 10`,
[petitionId]
);
return result.rows;
}
/**
* Sanitize admin-authored HTML to strip script tags and event handlers.
* body_html is authored by authenticated admins only (via PATCH/POST endpoints),
* but we apply basic sanitization as defense-in-depth.
*/
// 2026-05-05 (P1 architect-review + tick 27): the handwritten regex-only
// sanitizer was bypassable (SVG, data: URIs, mathml, malformed nesting).
// Replaced with the shared allowlist from lib/sanitize.ts, which is the
// SAME allowlist applied at POST/PATCH boundary in api/petitions/route.ts.
// Drift between the two layers is exactly the gap stored XSS slips through.
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { slug } = await params;
const petition = await getPetition(slug);
if (!petition) {
return { title: 'Petition Not Found' };
}
const publicUrl = `http://45.61.58.125:7460/petitions/${petition.slug}`;
return {
title: `${petition.title} | Patty Petitions`,
description: petition.summary || petition.body_text?.slice(0, 160) || petition.title,
openGraph: {
title: petition.title,
description: petition.summary || petition.body_text?.slice(0, 160) || petition.title,
url: publicUrl,
type: 'website',
siteName: 'Patty - The Petition Specialist',
...(petition.image_url ? { images: [{ url: petition.image_url }] } : {}),
},
twitter: {
card: 'summary_large_image',
title: petition.title,
description: petition.summary || petition.body_text?.slice(0, 160) || petition.title,
},
};
}
export default async function PetitionPage({ params }: PageProps) {
const { slug } = await params;
const petition = await getPetition(slug);
if (!petition) {
notFound();
}
const isActive = petition.status === 'active';
const signatures = await getRecentSignatures(petition.id);
const publicUrl = `http://45.61.58.125:7460/petitions/${petition.slug}`;
const progressPercent = petition.signature_goal > 0
? Math.min(100, Math.round((petition.signature_count / petition.signature_goal) * 100))
: 0;
const formattedDate = new Date(petition.created_at).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
const sanitizedBody = sanitizePetitionBody(petition.body_html);
return (
<div style={{
minHeight: '100vh',
backgroundColor: 'var(--color-bg)',
color: 'var(--color-text)',
}}>
{/* Header */}
<header style={{
borderBottom: '1px solid var(--color-border)',
backgroundColor: 'var(--color-surface)',
}}>
<div style={{
maxWidth: '1100px',
margin: '0 auto',
padding: '1rem 1.5rem',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}>
<a href="/" style={{
color: 'var(--color-primary)',
textDecoration: 'none',
fontWeight: 700,
fontSize: '1.125rem',
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
}}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m3 11 18-5v12L3 13z"/>
<path d="M11.6 16.8a3 3 0 1 1-5.8-1.6"/>
</svg>
Patty
</a>
<span style={{ color: 'var(--color-text-muted)', fontSize: '0.8125rem' }}>
Petition Platform
</span>
</div>
</header>
{/* Main content */}
<main style={{
maxWidth: '1100px',
margin: '0 auto',
padding: '2rem 1.5rem 4rem',
}}>
{/* Petition status banner */}
{!isActive && (
<div style={{
padding: '0.75rem 1rem',
borderRadius: 'var(--radius-md)',
marginBottom: '1.5rem',
backgroundColor: petition.status === 'closed' || petition.status === 'delivered'
? 'rgba(239, 68, 68, 0.1)'
: 'rgba(245, 158, 11, 0.1)',
border: `1px solid ${
petition.status === 'closed' || petition.status === 'delivered'
? 'rgba(239, 68, 68, 0.3)'
: 'rgba(245, 158, 11, 0.3)'
}`,
color: petition.status === 'closed' || petition.status === 'delivered'
? 'var(--color-error)'
: 'var(--color-warning)',
fontSize: '0.875rem',
fontWeight: 500,
textAlign: 'center',
}}>
{petition.status === 'delivered'
? 'This petition has been delivered to its target.'
: petition.status === 'closed'
? 'This petition has been closed.'
: petition.status === 'paused'
? 'This petition is currently paused.'
: 'This petition is not yet active.'}
</div>
)}
<div style={{
display: 'grid',
gridTemplateColumns: '1fr',
gap: '2rem',
}}>
{/* Desktop: two-column layout */}
<div className="petition-layout" style={{ display: 'flex', gap: '2rem', flexWrap: 'wrap' }}>
{/* Left column - petition content */}
<div style={{ flex: '1 1 600px', minWidth: 0 }}>
{/* Category + tags */}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem', marginBottom: '1rem' }}>
{petition.category && (
<span className="badge badge-active" style={{ textTransform: 'capitalize' }}>
{petition.category}
</span>
)}
{petition.tags?.map((rawTag) => {
const tag = rawTag.replace(/^["'{]+|["'}]+$/g, '').trim();
if (!tag) return null;
return (
<span key={tag} style={{
display: 'inline-block',
padding: '0.125rem 0.5rem',
borderRadius: '9999px',
fontSize: '0.75rem',
fontWeight: 500,
backgroundColor: 'rgba(124, 58, 237, 0.12)',
color: 'var(--color-secondary)',
border: '1px solid rgba(124, 58, 237, 0.25)',
}}>
{tag}
</span>
);
})}
</div>
{/* Title */}
<h1 style={{
fontSize: 'clamp(1.5rem, 4vw, 2.25rem)',
fontWeight: 800,
lineHeight: 1.2,
color: 'var(--color-text)',
marginBottom: '0.75rem',
}}>
{petition.title}
</h1>
{/* Meta */}
<div style={{
display: 'flex',
flexWrap: 'wrap',
gap: '1rem',
alignItems: 'center',
marginBottom: '1.5rem',
fontSize: '0.875rem',
color: 'var(--color-text-muted)',
}}>
<span>Started {formattedDate}</span>
{petition.target && (
<>
<span style={{ color: 'var(--color-border)' }}>|</span>
<span>
Directed to: <strong style={{ color: 'var(--color-text-secondary)' }}>{petition.target}</strong>
</span>
</>
)}
</div>
{/* Summary */}
{petition.summary && (
<p style={{
fontSize: '1.0625rem',
lineHeight: 1.7,
color: 'var(--color-text-secondary)',
marginBottom: '1.5rem',
padding: '1rem 1.25rem',
backgroundColor: 'var(--color-surface)',
borderLeft: '3px solid var(--color-primary)',
borderRadius: '0 var(--radius-md) var(--radius-md) 0',
}}>
{petition.summary}
</p>
)}
{/* Body HTML — admin-authored content, sanitized as defense-in-depth */}
<div
dangerouslySetInnerHTML={{ __html: sanitizedBody }}
style={{
lineHeight: 1.8,
fontSize: '1rem',
color: 'var(--color-text-secondary)',
}}
className="petition-body"
/>
{/* Recent signatures */}
{signatures.length > 0 && (
<div style={{ marginTop: '2.5rem' }}>
<h3 style={{
color: 'var(--color-text)',
fontSize: '1.125rem',
fontWeight: 700,
marginBottom: '1rem',
}}>
Recent Supporters
</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
{signatures.map((sig, i) => (
<div key={i} style={{
padding: '0.75rem 1rem',
backgroundColor: 'var(--color-surface)',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-md)',
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontWeight: 600, color: 'var(--color-text)', fontSize: '0.875rem' }}>
{sig.signer_name || 'Anonymous'}
</span>
<span style={{ color: 'var(--color-text-muted)', fontSize: '0.75rem' }}>
{new Date(sig.created_at).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
})}
</span>
</div>
{sig.signer_comment && (
<p style={{
color: 'var(--color-text-secondary)',
fontSize: '0.8125rem',
marginTop: '0.375rem',
fontStyle: 'italic',
}}>
“{sig.signer_comment}”
</p>
)}
</div>
))}
</div>
</div>
)}
</div>
{/* Right column - signature form + progress + share */}
<div className="petition-sidebar" style={{
flex: '0 0 auto',
width: '360px',
maxWidth: '100%',
display: 'flex',
flexDirection: 'column',
gap: '1.5rem',
alignSelf: 'flex-start',
position: 'sticky',
top: '1.5rem',
}}>
{/* Progress card */}
<div style={{
backgroundColor: 'var(--color-surface)',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-lg)',
padding: '1.5rem',
}}>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'baseline',
marginBottom: '0.75rem',
}}>
<span style={{
fontSize: '2rem',
fontWeight: 800,
color: 'var(--color-primary)',
}}>
{petition.signature_count.toLocaleString()}
</span>
<span style={{
color: 'var(--color-text-muted)',
fontSize: '0.875rem',
}}>
of {petition.signature_goal.toLocaleString()} goal
</span>
</div>
{/* Progress bar */}
<div style={{
width: '100%',
height: '8px',
backgroundColor: 'var(--color-surface-el)',
borderRadius: '4px',
overflow: 'hidden',
marginBottom: '0.5rem',
}}>
<div style={{
width: `${progressPercent}%`,
height: '100%',
backgroundColor: 'var(--color-primary)',
borderRadius: '4px',
transition: 'width 0.5s ease',
minWidth: progressPercent > 0 ? '4px' : '0',
}} />
</div>
<p style={{
color: 'var(--color-text-muted)',
fontSize: '0.8125rem',
textAlign: 'right',
}}>
{progressPercent}% complete
</p>
</div>
{/* Signature form */}
<SignatureForm
petitionId={petition.id}
petitionTitle={petition.title}
isActive={isActive}
/>
{/* Share buttons */}
<ShareButtons
url={publicUrl}
title={petition.title}
summary={petition.summary || ''}
/>
{/* Post to Platforms (admin review panel) */}
<PostToPlatformsPanel
petitionSlug={petition.slug}
petitionTitle={petition.title}
petitionId={petition.id}
/>
</div>
</div>
</div>
</main>
{/* Footer */}
<footer style={{
borderTop: '1px solid var(--color-border)',
backgroundColor: 'var(--color-surface)',
padding: '1.5rem',
textAlign: 'center',
}}>
<p style={{ color: 'var(--color-text-muted)', fontSize: '0.8125rem' }}>
Powered by <strong style={{ color: 'var(--color-primary)' }}>Patty</strong> - The Petition Specialist
</p>
</footer>
{/* Petition body styles */}
<style>{`
.petition-body h1,
.petition-body h2,
.petition-body h3,
.petition-body h4 {
color: var(--color-text);
margin-top: 1.5em;
margin-bottom: 0.75em;
font-weight: 700;
line-height: 1.3;
}
.petition-body h1 { font-size: 1.75rem; }
.petition-body h2 { font-size: 1.5rem; }
.petition-body h3 { font-size: 1.25rem; }
.petition-body h4 { font-size: 1.125rem; }
.petition-body p {
margin-bottom: 1em;
}
.petition-body ul,
.petition-body ol {
margin-bottom: 1em;
padding-left: 1.5em;
}
.petition-body li {
margin-bottom: 0.375em;
}
.petition-body a {
color: var(--color-secondary);
text-decoration: underline;
text-underline-offset: 2px;
}
.petition-body a:hover {
color: var(--color-primary);
}
.petition-body blockquote {
border-left: 3px solid var(--color-primary);
padding: 0.75rem 1rem;
margin: 1em 0;
background-color: var(--color-surface);
border-radius: 0 var(--radius-md) var(--radius-md) 0;
font-style: italic;
color: var(--color-text-secondary);
}
.petition-body strong,
.petition-body b {
color: var(--color-text);
font-weight: 700;
}
.petition-body img {
max-width: 100%;
border-radius: var(--radius-md);
margin: 1em 0;
}
.petition-body hr {
border: none;
border-top: 1px solid var(--color-border);
margin: 2em 0;
}
/* Responsive: stack columns on mobile */
@media (max-width: 768px) {
.petition-body {
font-size: 0.9375rem !important;
}
}
`}</style>
</div>
);
}