From 72622671a27598b812b3ff501e34abcf8a95149a Mon Sep 17 00:00:00 2001 From: bunker-admin Date: Fri, 3 Apr 2026 08:49:49 -0600 Subject: [PATCH] Add petition/action pages with signature collection, CRM integration, and campaign linking New influence submodule for public petitions with configurable sign forms, email verification, GeoIP tracking, dedup, CSV export, admin moderation, and post-sign CTA linking to advocacy campaigns. Includes competitive analysis document covering 30+ campaign tech platforms. Bunker Admin --- .../influence/PetitionModerationPage.tsx | 243 ++++++ .../influence/PetitionSignaturesPage.tsx | 277 +++++++ admin/src/pages/influence/PetitionsPage.tsx | 390 +++++++++ admin/src/pages/public/PetitionPage.tsx | 295 +++++++ admin/src/pages/public/PetitionsListPage.tsx | 89 ++ .../migration.sql | 131 +++ .../petitions/petitions-public.routes.ts | 100 +++ .../influence/petitions/petitions.routes.ts | 203 +++++ .../influence/petitions/petitions.schemas.ts | 103 +++ .../influence/petitions/petitions.service.ts | 777 ++++++++++++++++++ docs/COMPETITIVE_ANALYSIS.md | 343 ++++++++ 11 files changed, 2951 insertions(+) create mode 100644 admin/src/pages/influence/PetitionModerationPage.tsx create mode 100644 admin/src/pages/influence/PetitionSignaturesPage.tsx create mode 100644 admin/src/pages/influence/PetitionsPage.tsx create mode 100644 admin/src/pages/public/PetitionPage.tsx create mode 100644 admin/src/pages/public/PetitionsListPage.tsx create mode 100644 api/prisma/migrations/20260402200000_add_petitions/migration.sql create mode 100644 api/src/modules/influence/petitions/petitions-public.routes.ts create mode 100644 api/src/modules/influence/petitions/petitions.routes.ts create mode 100644 api/src/modules/influence/petitions/petitions.schemas.ts create mode 100644 api/src/modules/influence/petitions/petitions.service.ts create mode 100644 docs/COMPETITIVE_ANALYSIS.md diff --git a/admin/src/pages/influence/PetitionModerationPage.tsx b/admin/src/pages/influence/PetitionModerationPage.tsx new file mode 100644 index 00000000..7e4a709e --- /dev/null +++ b/admin/src/pages/influence/PetitionModerationPage.tsx @@ -0,0 +1,243 @@ +import { useState, useEffect, useCallback } from 'react'; +import { + Card, Table, Button, Space, Tag, Input, Select, Drawer, Typography, message, + Statistic, Row, Col, Modal, Grid, +} from 'antd'; +import { + CheckCircleOutlined, CloseCircleOutlined, ExclamationCircleOutlined, + SearchOutlined, ReloadOutlined, +} from '@ant-design/icons'; +import type { ColumnsType } from 'antd/es/table'; +import dayjs from 'dayjs'; +import { api } from '@/lib/api'; +import type { Petition, PetitionsListResponse, CampaignModerationStatus } from '@/types/api'; + +const { Text, Paragraph } = Typography; +const { TextArea } = Input; + +const MODERATION_STATUS_COLORS: Record = { + PENDING_REVIEW: 'orange', + APPROVED: 'green', + REJECTED: 'red', + CHANGES_REQUESTED: 'gold', +}; + +export default function PetitionModerationPage() { + const screens = Grid.useBreakpoint(); + const isMobile = !screens.md; + const [petitions, setPetitions] = useState([]); + const [loading, setLoading] = useState(false); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [pageSize] = useState(20); + const [search, setSearch] = useState(''); + const [statusFilter, setStatusFilter] = useState(undefined); + const [stats, setStats] = useState<{ pending: number; approved: number; rejected: number; changesRequested: number } | null>(null); + const [selectedPetition, setSelectedPetition] = useState(null); + const [drawerOpen, setDrawerOpen] = useState(false); + const [actionModalOpen, setActionModalOpen] = useState(false); + const [actionType, setActionType] = useState<'reject' | 'request_changes' | null>(null); + const [actionReason, setActionReason] = useState(''); + const [actionLoading, setActionLoading] = useState(false); + + const fetchQueue = useCallback(async () => { + setLoading(true); + try { + const params: Record = { page, limit: pageSize }; + if (search) params.search = search; + if (statusFilter) params.moderationStatus = statusFilter; + const { data } = await api.get('/petitions/moderation/queue', { params }); + setPetitions(data.petitions); + setTotal(data.pagination.total); + } catch { + message.error('Failed to load moderation queue'); + } finally { + setLoading(false); + } + }, [page, pageSize, search, statusFilter]); + + const fetchStats = useCallback(async () => { + try { + const { data } = await api.get('/petitions/moderation/stats'); + setStats(data); + } catch { /* non-critical */ } + }, []); + + useEffect(() => { fetchQueue(); }, [fetchQueue]); + useEffect(() => { fetchStats(); }, [fetchStats]); + + const handleApprove = async (petition: Petition) => { + try { + await api.patch(`/petitions/moderation/${petition.id}`, { action: 'approve' }); + message.success('Petition approved'); + fetchQueue(); + fetchStats(); + setDrawerOpen(false); + } catch { + message.error('Failed to approve petition'); + } + }; + + const handleActionSubmit = async () => { + if (!selectedPetition || !actionType) return; + setActionLoading(true); + try { + await api.patch(`/petitions/moderation/${selectedPetition.id}`, { + action: actionType, + reason: actionReason, + }); + message.success(actionType === 'reject' ? 'Petition rejected' : 'Changes requested'); + setActionModalOpen(false); + setActionReason(''); + setActionType(null); + setDrawerOpen(false); + fetchQueue(); + fetchStats(); + } catch { + message.error('Failed to update petition'); + } finally { + setActionLoading(false); + } + }; + + const columns: ColumnsType = [ + { + title: 'Title', + dataIndex: 'title', + key: 'title', + ellipsis: true, + render: (title: string, record: Petition) => ( + + ), + }, + { + title: 'Status', + dataIndex: 'moderationStatus', + key: 'status', + width: 130, + render: (status: CampaignModerationStatus | null) => + status ? {status.replace(/_/g, ' ')} : '-', + }, + { + title: 'Submitted By', + dataIndex: 'createdByUserName', + key: 'submitter', + width: 150, + ellipsis: true, + }, + ...(isMobile ? [] : [{ + title: 'Date', + dataIndex: 'createdAt', + key: 'date', + width: 110, + render: (d: string) => dayjs(d).format('MMM D, YYYY'), + } as any]), + { + title: 'Actions', + key: 'actions', + width: 200, + render: (_: unknown, record: Petition) => ( + + + + + ), + }, + ]; + + return ( +
+ {stats && ( + + + + + + + )} + + + } allowClear value={search} onChange={e => { setSearch(e.target.value); setPage(1); }} style={{ width: isMobile ? 140 : 200 }} /> +