mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-14 17:43:04 +00:00
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:
parent
918d6f62a2
commit
a862d0c582
6 changed files with 209 additions and 2 deletions
|
|
@ -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} />
|
||||
|
|
|
|||
15
src/app/(frontend)/actions/getSignatureCount.ts
Normal file
15
src/app/(frontend)/actions/getSignatureCount.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
147
src/components/SignatureCounter.tsx
Normal file
147
src/components/SignatureCounter.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in a new issue