diff --git a/src/app/(frontend)/abaixo-assinado/[id]/page.tsx b/src/app/(frontend)/abaixo-assinado/[id]/page.tsx index 13210299..195f5788 100644 --- a/src/app/(frontend)/abaixo-assinado/[id]/page.tsx +++ b/src/app/(frontend)/abaixo-assinado/[id]/page.tsx @@ -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 (

{petition.subtitle}

+
Preencha o formulário para somar apoio público à proposta. Os dados serão usados apenas para registrar sua participação nesta campanha.

+
diff --git a/src/app/(frontend)/actions/getSignatureCount.ts b/src/app/(frontend)/actions/getSignatureCount.ts new file mode 100644 index 00000000..ce399183 --- /dev/null +++ b/src/app/(frontend)/actions/getSignatureCount.ts @@ -0,0 +1,15 @@ +'use server' + +import config from '@payload-config' +import { getPayload } from 'payload' + +export const getSignatureCount = async (petitionId: string): Promise => { + const payload = await getPayload({ config }) + + const { totalDocs } = await payload.count({ + collection: 'signature', + where: { petition: { equals: petitionId } }, + }) + + return totalDocs +} diff --git a/src/app/(frontend)/actions/submitPetitionSignature.ts b/src/app/(frontend)/actions/submitPetitionSignature.ts index 9d75c1d9..e00f7031 100644 --- a/src/app/(frontend)/actions/submitPetitionSignature.ts +++ b/src/app/(frontend)/actions/submitPetitionSignature.ts @@ -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 diff --git a/src/components/PetitionForm.tsx b/src/components/PetitionForm.tsx index ee97ccd8..a0dab10d 100644 --- a/src/components/PetitionForm.tsx +++ b/src/components/PetitionForm.tsx @@ -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(null) const onSubmit: SubmitHandler = (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} />
diff --git a/src/components/PetitionSuccessDialog.tsx b/src/components/PetitionSuccessDialog.tsx index 0bb625da..f3e53aa1 100644 --- a/src/components/PetitionSuccessDialog.tsx +++ b/src/components/PetitionSuccessDialog.tsx @@ -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 = ({ Obrigado por assinar! + {signatureNumber !== null ? ( +

+ Você é a assinatura nº{' '} + + {numberFormatter.format(signatureNumber)} + + . +

+ ) : null}
diff --git a/src/components/SignatureCounter.tsx b/src/components/SignatureCounter.tsx new file mode 100644 index 00000000..75109cf2 --- /dev/null +++ b/src/components/SignatureCounter.tsx @@ -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).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 ( +
+ + + + + {isHero ? ( + + {formatted} + pessoas já assinaram + + ) : ( + + {formatted} + + pessoas já assinaram este abaixo-assinado + + + )} +
+ ) +}