feat: Add signature counter and display

Integrate a real-time signature counter component into petition pages.
This includes:

- A new `SignatureCounter` component that displays and animates the
  signature count.
- Server action `getSignatureCount` to fetch current signatures from
  Payload.
- Updates to `submitPetitionSignature` to return the signature number
  for immediate display.
- Event listeners for real-time updates when new signatures are
  submitted.
- Integration of the counter into both the hero section and card views
  of the petition page.
- Improved dialog for displaying signature success and number.
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-05-01 01:51:07 -03:00
parent 918d6f62a2
commit a862d0c582
6 changed files with 209 additions and 2 deletions

View file

@ -1,4 +1,6 @@
import { PetitionForm } from '@/components/PetitionForm'
import { SignatureCounter } from '@/components/SignatureCounter'
import { getSignatureCount } from '@/app/(frontend)/actions/getSignatureCount'
import { getCachedDocumentById, getDocuments } from '@/utilities/documents'
import { convertLexicalToHTML } from '@payloadcms/richtext-lexical/html'
import Link from 'next/link'
@ -24,6 +26,8 @@ export default async function Page({ params }: { params: Promise<{ id: string }>
? convertLexicalToHTML({ data: petition.form.consent.text })
: ''
const signatureCount = await getSignatureCount(petition.id)
return (
<main
data-theme="petition"
@ -42,6 +46,11 @@ export default async function Page({ params }: { params: Promise<{ id: string }>
<h2 className="max-w-2xl border-none text-lg leading-relaxed text-[var(--petition-hero-muted)] sm:text-xl">
{petition.subtitle}
</h2>
<SignatureCounter
petitionId={petition.id}
initialCount={signatureCount}
variant="hero"
/>
<div className="flex flex-wrap justify-center gap-3 border-t border-white/15 pt-6">
<Link
href="#formulario"
@ -83,6 +92,11 @@ export default async function Page({ params }: { params: Promise<{ id: string }>
Preencha o formulário para somar apoio público à proposta. Os dados serão usados
apenas para registrar sua participação nesta campanha.
</p>
<SignatureCounter
petitionId={petition.id}
initialCount={signatureCount}
variant="card"
/>
</div>
<div className="rounded-xl border border-border bg-card p-5 text-card-foreground shadow-[0_24px_60px_rgb(122_25_18/0.12)] sm:p-7">
<PetitionForm id="formulario" petition={petition} consentHTML={consentHTML} />

View file

@ -0,0 +1,15 @@
'use server'
import config from '@payload-config'
import { getPayload } from 'payload'
export const getSignatureCount = async (petitionId: string): Promise<number> => {
const payload = await getPayload({ config })
const { totalDocs } = await payload.count({
collection: 'signature',
where: { petition: { equals: petitionId } },
})
return totalDocs
}

View file

@ -52,8 +52,14 @@ export const submitPetitionSignature = async ({
}),
])
const { totalDocs: signatureNumber } = await payload.count({
collection: 'signature',
where: { petition: { equals: petitionId } },
req: { transactionID },
})
await payload.db.commitTransaction(transactionID)
return { ok: true }
return { ok: true, signatureNumber }
} catch (error) {
await payload.db.rollbackTransaction(transactionID)
throw error

View file

@ -23,6 +23,10 @@ import { StateSelect } from './StateSelect'
import { CitySelect } from './CitySelect'
import { PostalCodeInput } from './PostalCodeInput'
import { PetitionSuccessDialog } from './PetitionSuccessDialog'
import {
SIGNATURE_CREATED_EVENT,
type SignatureCreatedDetail,
} from './SignatureCounter'
interface PetitionFormProps {
id: string
@ -46,6 +50,7 @@ export const PetitionForm = ({ id, petition, consentHTML }: PetitionFormProps) =
const [isSubmitting, startTransition] = useTransition()
const [isSuccessOpen, setIsSuccessOpen] = useState(false)
const [signatureNumber, setSignatureNumber] = useState<number | null>(null)
const onSubmit: SubmitHandler<PetitionFormInput> = (input) => {
const consentId =
@ -53,12 +58,18 @@ export const PetitionForm = ({ id, petition, consentHTML }: PetitionFormProps) =
startTransition(async () => {
try {
await submitPetitionSignature({
const result = await submitPetitionSignature({
...input,
petitionId: petition.id,
consentId,
})
methods.reset()
setSignatureNumber(result.signatureNumber)
const detail: SignatureCreatedDetail = {
petitionId: petition.id,
count: result.signatureNumber,
}
window.dispatchEvent(new CustomEvent(SIGNATURE_CREATED_EVENT, { detail }))
setIsSuccessOpen(true)
} catch {
methods.setError('root', {
@ -74,6 +85,7 @@ export const PetitionForm = ({ id, petition, consentHTML }: PetitionFormProps) =
open={isSuccessOpen}
onOpenChange={setIsSuccessOpen}
petitionTitle={petition.title}
signatureNumber={signatureNumber}
/>
<form id={id} onSubmit={methods.handleSubmit(onSubmit)}>
<FieldSet>

View file

@ -16,12 +16,16 @@ interface PetitionSuccessDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
petitionTitle: string
signatureNumber: number | null
}
const numberFormatter = new Intl.NumberFormat('pt-BR')
export const PetitionSuccessDialog = ({
open,
onOpenChange,
petitionTitle,
signatureNumber,
}: PetitionSuccessDialogProps) => {
const [shareUrl, setShareUrl] = useState('')
@ -51,6 +55,15 @@ export const PetitionSuccessDialog = ({
<DialogTitle className="border-none pb-0 text-xl font-bold leading-tight sm:text-2xl">
Obrigado por assinar!
</DialogTitle>
{signatureNumber !== null ? (
<p className="mt-2 text-sm text-[var(--petition-hero-muted)] sm:text-base">
Você é a assinatura nº{' '}
<span className="font-bold tabular-nums text-[var(--petition-hero-foreground)]">
{numberFormatter.format(signatureNumber)}
</span>
.
</p>
) : null}
</div>
<div className="flex flex-col gap-4 bg-card px-6 py-6 text-card-foreground sm:px-8 sm:py-7">
<DialogDescription>

View file

@ -0,0 +1,147 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { cn } from '@/lib/utils'
import { getSignatureCount } from '@/app/(frontend)/actions/getSignatureCount'
const POLL_INTERVAL_MS = 10_000
const ANIMATION_DURATION_MS = 1200
export const SIGNATURE_CREATED_EVENT = 'signature:created'
export type SignatureCreatedDetail = { petitionId: string; count?: number }
const formatter = new Intl.NumberFormat('pt-BR')
const useAnimatedNumber = (target: number, durationMs = ANIMATION_DURATION_MS): number => {
const [displayed, setDisplayed] = useState(target)
const displayedRef = useRef(displayed)
displayedRef.current = displayed
useEffect(() => {
const from = displayedRef.current
if (from === target) return
const start = performance.now()
let raf = 0
const tick = (now: number) => {
const t = Math.min(1, (now - start) / durationMs)
const eased = 1 - Math.pow(1 - t, 3)
const next = Math.round(from + (target - from) * eased)
setDisplayed(next)
if (t < 1) raf = requestAnimationFrame(tick)
}
raf = requestAnimationFrame(tick)
return () => cancelAnimationFrame(raf)
}, [target, durationMs])
return displayed
}
interface SignatureCounterProps {
petitionId: string
initialCount: number
variant?: 'hero' | 'card'
className?: string
}
export const SignatureCounter = ({
petitionId,
initialCount,
variant = 'card',
className,
}: SignatureCounterProps) => {
const [count, setCount] = useState(initialCount)
const animated = useAnimatedNumber(count)
const formatted = formatter.format(animated)
const countRef = useRef(count)
countRef.current = count
useEffect(() => {
let cancelled = false
const refetch = async () => {
try {
const next = await getSignatureCount(petitionId)
if (cancelled) return
if (next > countRef.current) setCount(next)
} catch {
// transient failures are fine — next poll will retry
}
}
const interval = setInterval(() => {
if (document.visibilityState === 'visible') refetch()
}, POLL_INTERVAL_MS)
const handleVisibility = () => {
if (document.visibilityState === 'visible') refetch()
}
document.addEventListener('visibilitychange', handleVisibility)
const handleCreated = (event: Event) => {
const detail = (event as CustomEvent<SignatureCreatedDetail>).detail
if (detail?.petitionId !== petitionId) return
if (typeof detail.count === 'number' && detail.count > countRef.current) {
setCount(detail.count)
} else {
setCount((prev) => prev + 1)
}
}
window.addEventListener(SIGNATURE_CREATED_EVENT, handleCreated)
return () => {
cancelled = true
clearInterval(interval)
document.removeEventListener('visibilitychange', handleVisibility)
window.removeEventListener(SIGNATURE_CREATED_EVENT, handleCreated)
}
}, [petitionId])
const isHero = variant === 'hero'
return (
<div
aria-live="polite"
aria-atomic="true"
className={cn(
'inline-flex items-center gap-3',
isHero
? 'rounded-full border border-white/20 bg-white/10 px-4 py-2 text-sm text-[var(--petition-hero-foreground)] backdrop-blur-sm'
: 'w-full rounded-xl border border-border bg-secondary/40 px-5 py-4 text-secondary-foreground',
className,
)}
>
<span className="relative flex size-2.5 shrink-0 items-center justify-center">
<span
className={cn(
'absolute inline-flex size-full animate-ping rounded-full opacity-75',
isHero ? 'bg-[var(--petition-hero-cta)]' : 'bg-primary',
)}
/>
<span
className={cn(
'relative inline-flex size-2 rounded-full',
isHero ? 'bg-[var(--petition-hero-cta)]' : 'bg-primary',
)}
/>
</span>
{isHero ? (
<span className="leading-tight">
<span className="font-bold tabular-nums">{formatted}</span>
<span className="ml-1">pessoas já assinaram</span>
</span>
) : (
<span className="flex flex-col leading-tight">
<span className="text-2xl font-bold tabular-nums sm:text-3xl">{formatted}</span>
<span className="text-sm font-medium text-muted-foreground">
pessoas já assinaram este abaixo-assinado
</span>
</span>
)}
</div>
)
}