forked from wishonia/wishonia
-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add petition management and analytics routes
Implemented new routes for managing and analyzing petitions. Added interfaces for daily and weekly petition summary notifications. Included detailed petition visualization features and admin controls. Took 33 minutes
- Loading branch information
Showing
34 changed files
with
2,801 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,22 @@ | ||
import { NextResponse } from 'next/server' | ||
import { sendDailySummaries, sendWeeklySummaries } from '@/lib/notifications/petition-summary' | ||
|
||
export const runtime = 'edge' | ||
|
||
export async function GET(request: Request) { | ||
try { | ||
const { searchParams } = new URL(request.url) | ||
const type = searchParams.get('type') | ||
|
||
if (type === 'daily') { | ||
await sendDailySummaries() | ||
} else if (type === 'weekly') { | ||
await sendWeeklySummaries() | ||
} | ||
|
||
return NextResponse.json({ success: true }) | ||
} catch (error) { | ||
console.error('Cron job failed:', error) | ||
return NextResponse.json({ error: 'Failed to process summaries' }, { status: 500 }) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,33 @@ | ||
import { getServerSession } from "next-auth/next" | ||
import { prisma } from "@/lib/prisma" | ||
import { redirect } from "next/navigation" | ||
import { PetitionAdminControls } from "../../components/PetitionAdminControls" | ||
|
||
export default async function PetitionAdminPage({ params }: { params: { id: string } }) { | ||
const session = await getServerSession() | ||
if (!session?.user) { | ||
redirect('/api/auth/signin') | ||
} | ||
|
||
const petition = await prisma.petition.findUnique({ | ||
where: { id: params.id }, | ||
include: { | ||
_count: { select: { signatures: true } }, | ||
milestones: true, | ||
statusUpdates: { | ||
orderBy: { createdAt: 'desc' } | ||
} | ||
} | ||
}) | ||
|
||
if (!petition || petition.creatorId !== session.user.id) { | ||
redirect('/petitions') | ||
} | ||
|
||
return ( | ||
<div className="container mx-auto px-4 py-8"> | ||
<h1 className="text-3xl font-bold mb-8">Manage Petition</h1> | ||
<PetitionAdminControls petition={petition} /> | ||
</div> | ||
) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,156 @@ | ||
'use client' | ||
|
||
import { getServerSession } from "next-auth/next" | ||
import { prisma } from "@/lib/prisma" | ||
import { redirect } from "next/navigation" | ||
import { Card } from "@/components/ui/card" | ||
import { ErrorBoundary } from "react-error-boundary" | ||
import { | ||
LineChart, | ||
Line, | ||
XAxis, | ||
YAxis, | ||
CartesianGrid, | ||
Tooltip, | ||
ResponsiveContainer, | ||
PieChart, | ||
Pie, | ||
Cell | ||
} from 'recharts' | ||
|
||
function ErrorFallback({ error, resetErrorBoundary }: { | ||
error: Error | ||
resetErrorBoundary: () => void | ||
}) { | ||
return ( | ||
<div className="p-4 bg-red-50 border border-red-200 rounded-lg"> | ||
<h2 className="text-red-800 font-medium">Something went wrong:</h2> | ||
<pre className="text-sm text-red-600 mt-2">{error.message}</pre> | ||
<button | ||
onClick={resetErrorBoundary} | ||
className="mt-4 px-4 py-2 bg-red-100 text-red-800 rounded hover:bg-red-200" | ||
> | ||
Try again | ||
</button> | ||
</div> | ||
) | ||
} | ||
|
||
function Chart({ children }: { children: React.ReactNode }) { | ||
return ( | ||
<ErrorBoundary | ||
FallbackComponent={ErrorFallback} | ||
onReset={() => { | ||
// Reset any state that might have caused the error | ||
}} | ||
> | ||
<div className="h-[400px] relative"> | ||
{children} | ||
</div> | ||
</ErrorBoundary> | ||
) | ||
} | ||
|
||
export default async function PetitionAnalyticsPage({ params }: { params: { id: string } }) { | ||
const session = await getServerSession() | ||
if (!session?.user) { | ||
redirect('/api/auth/signin') | ||
} | ||
|
||
const petition = await prisma.petition.findUnique({ | ||
where: { id: params.id }, | ||
include: { | ||
signatures: { | ||
orderBy: { signedAt: 'asc' }, | ||
select: { | ||
signedAt: true, | ||
referralSource: true, | ||
referrer: { | ||
select: { name: true } | ||
} | ||
} | ||
}, | ||
_count: { | ||
select: { signatures: true } | ||
} | ||
} | ||
}) | ||
|
||
if (!petition || petition.creatorId !== session.user.id) { | ||
redirect('/petitions') | ||
} | ||
|
||
// Calculate signatures by day | ||
const signaturesByDay = petition.signatures.reduce((acc: Record<string, number>, sig) => { | ||
const day = sig.signedAt.toISOString().split('T')[0] | ||
acc[day] = (acc[day] || 0) + 1 | ||
return acc | ||
}, {}) | ||
|
||
const dailyData = Object.entries(signaturesByDay).map(([date, count]) => ({ | ||
date, | ||
signatures: count | ||
})) | ||
|
||
// Calculate referral sources | ||
const referralSources = petition.signatures.reduce((acc: Record<string, number>, sig) => { | ||
const source = sig.referralSource?.toLowerCase() || 'direct' | ||
acc[source] = (acc[source] || 0) + 1 | ||
return acc | ||
}, {}) | ||
|
||
const referralData = Object.entries(referralSources).map(([source, count]) => ({ | ||
name: source, | ||
value: count | ||
})) | ||
|
||
const COLORS = ['#0088FE', '#00C49F', '#FFBB28', '#FF8042'] | ||
|
||
return ( | ||
<div className="container mx-auto px-4 py-8"> | ||
<h1 className="text-3xl font-bold mb-8">Petition Analytics</h1> | ||
|
||
<div className="grid gap-8"> | ||
<Card className="p-6"> | ||
<h2 className="text-xl font-semibold mb-4">Signatures Over Time</h2> | ||
<Chart> | ||
<ResponsiveContainer width="100%" height="100%"> | ||
<LineChart data={dailyData}> | ||
<CartesianGrid strokeDasharray="3 3" /> | ||
<XAxis dataKey="date" /> | ||
<YAxis /> | ||
<Tooltip /> | ||
<Line type="monotone" dataKey="signatures" stroke="#8884d8" /> | ||
</LineChart> | ||
</ResponsiveContainer> | ||
</Chart> | ||
</Card> | ||
|
||
<Card className="p-6"> | ||
<h2 className="text-xl font-semibold mb-4">Referral Sources</h2> | ||
<Chart> | ||
<ResponsiveContainer width="100%" height="100%"> | ||
<PieChart> | ||
<Pie | ||
data={referralData} | ||
cx="50%" | ||
cy="50%" | ||
labelLine={false} | ||
label={({ name, percent }) => `${name} (${(percent * 100).toFixed(0)}%)`} | ||
outerRadius={150} | ||
fill="#8884d8" | ||
dataKey="value" | ||
> | ||
{referralData.map((entry, index) => ( | ||
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} /> | ||
))} | ||
</Pie> | ||
<Tooltip /> | ||
</PieChart> | ||
</ResponsiveContainer> | ||
</Chart> | ||
</Card> | ||
</div> | ||
</div> | ||
) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,153 @@ | ||
import { getServerSession } from "next-auth/next" | ||
import { authOptions } from "@/lib/auth" | ||
import { prisma } from "@/lib/prisma" | ||
import { SignPetitionButton } from "../components/SignPetitionButton" | ||
import { ShareButtons } from "../components/ShareButtons" | ||
import { Comments } from "../components/Comments" | ||
import { FollowButton } from "../components/FollowButton" | ||
import { notFound } from "next/navigation" | ||
import { RepresentativeMessaging } from "../components/RepresentativeMessaging" | ||
import { Suspense } from 'react' | ||
import { LoadingSpinner } from '@/components/ui/loading-spinner' | ||
import { Metadata } from 'next' | ||
import { MDXRemote } from 'next-mdx-remote/rsc' | ||
|
||
const defaultMessageTemplate = `Dear [REP_NAME], | ||
As your constituent, I am writing to bring your attention to [PETITION_TITLE]. This issue is important to me and many other constituents in your district. | ||
I urge you to take action on this matter. | ||
Sincerely, | ||
[NAME]` | ||
|
||
const defaultCallScript = `Hello, my name is [NAME] and I'm a constituent calling about [PETITION_TITLE]. | ||
I'm calling to urge [REP_NAME] to take action on this important issue. | ||
This matters to me because it affects our community directly. | ||
Thank you for your time and for passing along my message.` | ||
|
||
export default async function PetitionPage({ params }: { params: { id: string } }) { | ||
const session = await getServerSession(authOptions) | ||
const petition = await prisma.petition.findUnique({ | ||
where: { id: params.id }, | ||
include: { | ||
_count: { select: { signatures: true } }, | ||
creator: { select: { name: true } }, | ||
comments: { | ||
orderBy: { createdAt: 'desc' }, | ||
include: { | ||
user: { | ||
select: { name: true, image: true } | ||
} | ||
} | ||
} | ||
} | ||
}) | ||
|
||
if (!petition) { | ||
notFound() | ||
} | ||
|
||
const [hasUserSigned, isFollowing] = await Promise.all([ | ||
session?.user?.id ? prisma.petitionSignature.findUnique({ | ||
where: { | ||
petitionId_userId: { | ||
petitionId: petition.id, | ||
userId: session.user.id, | ||
} | ||
} | ||
}) : null, | ||
session?.user?.id ? prisma.petitionFollow.findUnique({ | ||
where: { | ||
petitionId_userId: { | ||
petitionId: petition.id, | ||
userId: session.user.id, | ||
} | ||
} | ||
}) : null | ||
]) | ||
|
||
return ( | ||
<div className="container mx-auto px-4 py-8"> | ||
<div className="max-w-3xl mx-auto"> | ||
{petition.imageUrl && ( | ||
<img | ||
src={petition.imageUrl} | ||
alt={petition.title} | ||
className="w-full h-64 object-cover rounded-lg mb-6" | ||
/> | ||
)} | ||
|
||
<h1 className="text-4xl font-bold mb-4">{petition.title}</h1> | ||
<div className="flex items-center gap-4 text-gray-600 mb-8"> | ||
<span>Created by {petition.creator.name}</span> | ||
<span>{petition._count.signatures} signatures</span> | ||
</div> | ||
|
||
<div className="prose max-w-none dark:prose-invert mb-8"> | ||
<MDXRemote source={petition.content} /> | ||
</div> | ||
|
||
<div className="flex items-center gap-4 mb-8"> | ||
<SignPetitionButton | ||
petitionId={petition.id} | ||
hasSigned={!!hasUserSigned} | ||
/> | ||
<FollowButton | ||
petitionId={petition.id} | ||
initialFollowing={!!isFollowing} | ||
/> | ||
</div> | ||
|
||
{hasUserSigned && ( | ||
<> | ||
<ShareButtons | ||
petitionId={petition.id} | ||
userId={session!.user.id} | ||
/> | ||
|
||
<div className="mt-8"> | ||
<RepresentativeMessaging | ||
petitionTitle={petition.title} | ||
defaultMessageTemplate={petition.messageTemplate || defaultMessageTemplate} | ||
defaultCallScript={petition.callScript || defaultCallScript} | ||
/> | ||
</div> | ||
</> | ||
)} | ||
|
||
<Suspense fallback={<LoadingSpinner />}> | ||
<Comments | ||
petitionId={petition.id} | ||
initialComments={petition.comments} | ||
/> | ||
</Suspense> | ||
</div> | ||
</div> | ||
) | ||
} | ||
|
||
export async function generateMetadata({ params }: { params: { id: string } }): Promise<Metadata> { | ||
const petition = await prisma.petition.findUnique({ | ||
where: { id: params.id }, | ||
select: { | ||
title: true, | ||
summary: true | ||
} | ||
}) | ||
|
||
if (!petition) return {} | ||
|
||
return { | ||
title: petition.title, | ||
description: petition.summary, | ||
openGraph: { | ||
title: petition.title, | ||
description: petition.summary, | ||
type: 'website' | ||
} | ||
} | ||
} |
Oops, something went wrong.