teqo-backup/src/components/CampaignNewsletterForm.tsx
Francisco Solla c723d6accf
merge(OPS75): convergência Forgejo→GitHub — GitHub como fonte única
* deploy: limpeza pós-deploy do build cache e tags locais antigas (INF3/F2)

O deploy compila no homeserver e acumulava build cache + tags locais
antigas no disco raiz (incidente 19/08: 45G de cache). Após o smoke:
docker builder prune -f + remoção das tags locais teqo-1313 exceto o
SHA em uso (best-effort, registry preserva o rollback).

* feat(S9): captura de novidades da campanha na home pública (form + CTA do hero)

Seção 'Receba as novidades da campanha' acima do rodapé: nome + WhatsApp
obrigatórios, e-mail/estado/cidade/comentário opcionais, toggle pré-selecionado
'Quero fazer parte do time' gravado como subscription.campaignLevel
(time|esporadico). CTA secundário do hero vira o atalho (#novidades, scroll
suave). Contact + Subscription em withPayloadTransaction com consentimento
novo fail-closed por chave estável 'campanha-novidades' — nada é gravado sem o
texto aprovado no admin. Migration 20260819_192605 (state de contact nullable
+ coluna campaign_level). Impl plan, changelog, int + e2e.

* plans(C141,C143,C142): permissões granulares por assessor + responsáveis de demanda + UI por perfil

* plans(C141,C143,C142): permissões granulares por assessor + responsáveis de demanda + UI por perfil

* feat(S8): seção 'Nossa história' na home — vídeo da trajetória embutido do YouTube (Closes #91)

* docs(S8): resolução do conflito com S9 (história → newsletter) + nota do guard e2e dev-only

* feat(S10): pixel do Meta na página pública de campanha (campo admin em SiteSettings + PageView na home + Lead no sucesso da captura)

Campo tracking.facebookPixelId no global SiteSettings (mesma validação/normalização do precedente Petition, regra centralizada em facebookPixel.ts); home (grupo (home)) injeta o base code + PageView quando o ID está configurado (fail-closed) e o CampaignNewsletterForm dispara exatamente um Lead no sucesso do S9. Migration 20260819_213947_add_site_settings_facebook_pixel_id. Closes #94

* refactor(S10): simplify pass — Lead wiring unit-tested e raças de estado compartilhado do e2e resolvidas

e2e: stub hermético de connect.facebook.net no guard compartilhado (specs paralelos renderizam a home com o pixel); advisory lock no Postgres serializando o login do usuário de teste (Payload perde sessão em login concorrente read-modify-write — classe documentada no webauthn); seedTestUser vira create-if-missing (sem janela de usuário ausente nem reset de senha); Lead do form protegido do catch de erro (tracking nunca falha uma captura commitada); descrição do campo compartilhada; unidade nova campaignNewsletterLead cobre exatamente um Lead no sucesso.

* feat(C141): perfis de permissão por assessor — Visão × Edição configuráveis, enforcement fail-closed (Closes #105)

* chore(C141): regenera changelog agregado após rebase com S10

* plans(C144): options write-scoped para formulários de escrita pós-C141

---------

Co-authored-by: Francisco Solla <admin@solla.dev>
2026-08-21 01:41:18 -03:00

198 lines
7.9 KiB
TypeScript

'use client'
import { submitCampaignNewsletter } from '@/app/(frontend)/actions/submitCampaignNewsletter'
import { CitySelect } from '@/components/CitySelect'
import { EmailInput } from '@/components/EmailInput'
import { NameInput } from '@/components/NameInput'
import { PhoneInput } from '@/components/PhoneInput'
import { StateSelect } from '@/components/StateSelect'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/Checkbox'
import { Field, FieldDescription, FieldError, FieldGroup } from '@/components/ui/field'
import { Textarea } from '@/components/ui/textarea'
import { trackMetaLead } from '@/lib/facebookPixel'
import { CampaignNewsletterInput, campaignNewsletterSchema } from '@/lib/schemas/campaignNewsletter'
import { zodResolver } from '@hookform/resolvers/zod'
import { CheckIcon } from 'lucide-react'
import { useState, useTransition } from 'react'
import { FormProvider, SubmitHandler, useForm, useWatch } from 'react-hook-form'
/** S10 — stable `content_name` for the `Lead` fired on a successful capture. */
const NEWSLETTER_LEAD_CONTENT_NAME = 'novidades-da-campanha'
interface CampaignNewsletterFormProps {
pixelId?: string
onSubmit: () => void
}
/**
* S9 — public capture of campaign "novidades" on the home: name + WhatsApp
* required, email/state/city/comment optional, engagement-level toggle
* pre-selected ("Quero fazer parte do time"). Success swaps the form for an
* in-place confirmation (the seam where S10 fires `Lead`).
*/
const CampaignNewsletterForm = ({ pixelId, onSubmit }: CampaignNewsletterFormProps) => {
const methods = useForm<CampaignNewsletterInput>({
resolver: zodResolver(campaignNewsletterSchema),
defaultValues: {
name: '',
phone: '',
email: '',
state: undefined,
city: '',
comment: '',
campaignLevel: 'time',
},
})
const [isSubmitting, startTransition] = useTransition()
const handleSubmit: SubmitHandler<CampaignNewsletterInput> = (input) => {
startTransition(async () => {
try {
await submitCampaignNewsletter(input)
// S10 — exactly one `Lead` per successful capture, never on page load
// (petition precedent). The inner guard keeps a tracking hiccup (e.g.
// `crypto.randomUUID` on a non-secure context) from turning an
// already-committed capture into the error UI; `trackMetaLead`
// no-ops safely without fbq.
if (pixelId) {
try {
trackMetaLead(pixelId, NEWSLETTER_LEAD_CONTENT_NAME, crypto.randomUUID())
} catch {
// tracking is invisible to the visitor
}
}
methods.reset()
onSubmit()
} catch (error) {
console.log(error)
methods.setError('root', {
message: 'Falha ao enviar. Tente novamente.',
})
}
})
}
const campaignLevel = useWatch({ control: methods.control, name: 'campaignLevel' })
return (
<FormProvider {...methods}>
<form
id="campanha-novidades"
onSubmit={methods.handleSubmit(handleSubmit)}
className="mx-auto max-w-3xl"
>
<FieldGroup>
<Field>
<NameInput placeholder="Nome completo*" />
<FieldError errors={[methods.formState.errors.name]} />
</Field>
<Field>
<PhoneInput placeholder="WhatsApp (com DDD)*" />
<FieldError errors={[methods.formState.errors.phone]} />
</Field>
<Field>
<EmailInput required={false} placeholder="E-mail (opcional)" />
</Field>
<div className="grid grid-cols-2 gap-3">
<Field>
<StateSelect required={false} placeholder="Estado (opcional)" />
</Field>
<Field>
<CitySelect required={false} placeholder="Cidade (opcional)" />
</Field>
</div>
<Field>
<Textarea
id="comment"
{...methods.register('comment')}
placeholder="Comentário (opcional)"
className="min-h-22"
/>
</Field>
<Field>
<div className="rounded-lg border border-(--campaign-line) bg-white p-4">
<div className="flex items-start gap-3">
<Checkbox
id="campaign-level"
checked={campaignLevel === 'time'}
onCheckedChange={(checked) =>
methods.setValue('campaignLevel', checked ? 'time' : 'esporadico')
}
aria-describedby="campaign-level-help"
className="mt-0.5 size-5 rounded border-(--pt-red) text-white data-[state=checked]:border-(--pt-red) data-[state=checked]:bg-(--pt-red)"
/>
<label
htmlFor="campaign-level"
className="cursor-pointer text-sm leading-snug text-(--campaign-ink)"
>
<strong>Quero fazer parte do time:</strong> receber novidades com frequência, ser
adicionado(a) aos grupos de WhatsApp da campanha e participar das ações.
</label>
</div>
<p
id="campaign-level-help"
className="m-0 mt-2 text-xs leading-snug text-(--campaign-muted)"
>
Sem marcar, você recebe apenas comunicações esporádicas da campanha — sem grupos e
sem envios frequentes.
</p>
</div>
</Field>
<Field>
{methods.formState.errors.root?.message ? (
<FieldDescription className="text-destructive">
{methods.formState.errors.root.message}
</FieldDescription>
) : null}
<Button
type="submit"
disabled={isSubmitting}
className="h-12 w-full rounded-lg bg-(--pt-red) text-sm font-extrabold text-white hover:brightness-95"
>
{isSubmitting ? 'ENVIANDO...' : 'QUERO RECEBER NOVIDADES'}
</Button>
</Field>
</FieldGroup>
</form>
<p className="mx-auto mt-4 max-w-xl text-center text-xs leading-relaxed text-(--campaign-muted)">
Ao enviar, você demonstra apoio à campanha e escolhe como quer acompanhar: comunicações
frequentes e grupos de WhatsApp (marcando a opção acima) ou comunicações esporádicas —
conforme a Política de Privacidade (LGPD).
</p>
</FormProvider>
)
}
/** S9 — in-place success shown after the capture is recorded. */
const CampaignNewsletterSuccess = () => (
<div className="mx-auto max-w-3xl rounded-xl border border-(--campaign-line) bg-white px-6 py-12 text-center">
<div className="mx-auto flex size-10 items-center justify-center rounded-full border-2 border-(--pt-red) text-(--pt-red)">
<CheckIcon className="size-5" />
</div>
<h3 className="mt-5 font-[family-name:var(--font-exo2)] text-2xl font-black tracking-[-0.02em] text-(--campaign-ink)">
Inscrição confirmada
</h3>
<p className="mx-auto mt-4 max-w-md text-sm leading-relaxed text-(--campaign-muted)">
Você vai receber as novidades da campanha. Enquanto isso, conheça as bandeiras da campanha.
</p>
<a
href="#bandeiras"
className="mt-6 inline-flex h-11 items-center justify-center rounded-lg border-2 border-(--campaign-line) bg-white px-6 text-sm font-extrabold text-(--pt-red) no-underline transition-colors hover:bg-(--campaign-band) focus-visible:ring-2 focus-visible:ring-(--pt-red) focus-visible:ring-offset-2 focus-visible:outline-none"
>
Ver bandeiras
</a>
</div>
)
/** S9 — wrapper owning the form ↔ success swap on the home section. */
export const CampaignNewsletterCapture = ({ pixelId }: { pixelId?: string }) => {
const [submitted, setSubmitted] = useState(false)
return submitted ? (
<CampaignNewsletterSuccess />
) : (
<CampaignNewsletterForm pixelId={pixelId} onSubmit={() => setSubmitted(true)} />
)
}