← 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',
                          }}>
                            &ldquo;{sig.signer_comment}&rdquo;
                          </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>
  );
}