Página Contato — Site Público Abracaf
Página pública da Abracaf em /abracaf/contato. Reúne os canais de contato, um formulário validado de envio de mensagem e uma sidebar com mapa da sede, horários de atendimento e FAQ.
Estrutura de arquivos
apps/web/app/[locale]/(extranet_public)/abracaf/contato/
├── page.tsx # RSC — hero + cards + form + sidebar
└── _components/
│ ├── abracaf-contact-form.tsx # 'use client' — formulário react-hook-form + Zod
│ ├── abracaf-map-wrapper.tsx # next/dynamic ssr:false do mapa da sede
│ └── abracaf-map.tsx # 'use client' — mapa Leaflet da sede
└── _lib/
├── contact-abracaf.schema.ts # Schema Zod + tipo ContactData
└── server/
└── contact-abracaf-action.ts # Server action de envio (next-safe-action)
Estrutura da página
O page.tsx é um Server Component que monta o hero e a seção de conteúdo:
| Bloco | Descrição |
|---|---|
| Hero | Fundo bg-abc-navy-900, eyebrow "Fale com a Abracaf", H1 e subtítulo. Inclui o ScrollDownButton. |
| Cards de canais | 3 cards (Associados, Imprensa, Geral) com e-mail dedicado |
| Formulário | Card com ContactForm |
| Sidebar | Card da sede (mapa + endereço + contatos), card de atendimento e card de FAQ |
A seção de conteúdo tem id="contato-conteudo" — destino do ScrollDownButton do hero.
Formulário de contato
ContactForm usa react-hook-form com Zod (zodResolver) e envia via next-safe-action (useAction + submitContact).
- Campos: nome, cargo, e-mail, telefone, concessionária, tipo de solicitação, mensagem e aceite de LGPD.
- Validação no modo
onBlur. - Telefone com máscara dinâmica (
maskPhone) — formata 10 ou 11 dígitos. - Estados de envio:
idle/success/error. Em sucesso, mostraAlertde confirmação e reseta o form após 5s. - Cores de status usam tokens da Abracaf:
abc-danger-*(erros) eabc-success-*(sucesso) — nuncared-*/green-*.
A opção de assunto e o FAQ referem-se ao portal interno como Intranet (não "extranet"). O valor interno da option (suporte_extranet) é mantido por compatibilidade com o backend, mas o rótulo visível é "Suporte Intranet".
Select de assunto
O campo "Tipo de solicitação" usa o componente @kit/ui/select (shadcn / Base UI) — o mesmo padrão dos filtros de concessionárias. Antes era um <select> HTML nativo; foi substituído para manter consistência visual com o resto do site.
<Select value={field.value ?? ''} onValueChange={(v) => field.onChange(v as ContactData['tipo'])}>
<FormControl>
<SelectTrigger className={cn(inputClass, 'w-full')}>
<SelectValue placeholder="Selecione o assunto" />
</SelectTrigger>
</FormControl>
<SelectContent
side="bottom"
sideOffset={4}
alignItemWithTrigger={false}
collisionAvoidance={{ side: 'none' }}
>
{TIPO_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
))}
</SelectContent>
</Select>
O SelectContent deve abrir sempre abaixo do input. Como o projeto usa Base UI (não Radix), a prop é collisionAvoidance={{ side: 'none' }} — side: 'none' desativa o flip de direção. A prop do Radix (avoidCollisions) não existe aqui e dispara erro de DOM. O SelectContent de @kit/ui/select repassa collisionAvoidance para o Positioner.
Textarea com auto-resize
O campo de mensagem cresce com o conteúdo (de 1 linha até no máximo 220px):
<Textarea
className={cn(inputClass, 'resize-none overflow-hidden py-2.5')}
rows={1}
{...field}
onInput={(e) => {
const el = e.currentTarget;
el.style.height = 'auto';
el.style.height = `${Math.min(el.scrollHeight, 220)}px`;
}}
/>
Botão de scroll do hero
O ScrollDownButton foi adicionado ao final do hero apontando para #contato-conteudo. Ver detalhes do componente em Design System → Botões de scroll.
import { ScrollDownButton } from '../_components/scroll-down-button';
// ...
<ScrollDownButton href="#contato-conteudo" />