Compare commits

..

3 Commits

Author SHA1 Message Date
2fe262ab59 Hardening public blades: overflow, fallbacks, branded errors, JS robustness (#14)
* fix: prevent long strings from overflowing public layout

* fix: fall back to default hero copy and tighten hero line-height

* fix: skip blank testimonials and guard empty portfolio metadata

* feat: add branded 419, 429 and 503 error pages

* fix: reset submit state on bfcache restore, swap label while sending, trap mobile menu focus

* chore: track hardening task status

* fix: restore contact submit state after bfcache

* fix: allow contact links to wrap long unbroken strings
2026-08-06 00:22:53 -03:00
018b43e24f docs: sync SPEC.md and openspec with ratified decisions 2026-08-05 23:42:37 -03:00
9fac784f6a feat: frontend audit — formulário de contato, a11y, SEO, performance e conteúdo (#13)
* chore: add husky pre-commit and pre-push hooks

* feat: frontend audit — formulário de contato, a11y, SEO, performance e conteúdo

* test: regenerate visual baselines from CI environment
2026-08-05 23:27:14 -03:00
25 changed files with 453 additions and 64 deletions

104
SPEC.md
View File

@@ -14,7 +14,8 @@
| Estágio | MVP | | Estágio | MVP |
| Status da especificação | Aprovada para implementação | | Status da especificação | Aprovada para implementação |
| Idioma da interface | Português do Brasil (`pt-BR`) | | Idioma da interface | Português do Brasil (`pt-BR`) |
| Timezone padrão | `America/Fortaleza` | | Timezone padrão | `America/Sao_Paulo` |
| Cidade de atuação | São Paulo (capital) |
| Moeda | BRL, sem conversão entre moedas | | Moeda | BRL, sem conversão entre moedas |
| Princípio principal | YAGNI — implementar somente o necessário para validar o produto | | Princípio principal | YAGNI — implementar somente o necessário para validar o produto |
| Arquitetura | Monólito modular Laravel | | Arquitetura | Monólito modular Laravel |
@@ -44,6 +45,8 @@ Em caso de conflito, seguir esta ordem:
O agente **NÃO DEVE** alterar silenciosamente uma decisão deste documento. Uma alteração de escopo ou arquitetura deve atualizar esta especificação ou criar uma ADR. O agente **NÃO DEVE** alterar silenciosamente uma decisão deste documento. Uma alteração de escopo ou arquitetura deve atualizar esta especificação ou criar uma ADR.
Mudanças incrementais são planejadas em `openspec/changes/` e, após arquivadas, este documento DEVE ser revalidado para incorporar decisões ratificadas (back-sync). Este arquivo permanece a fonte de verdade do produto.
--- ---
## 1. Contrato de operação para agentes ## 1. Contrato de operação para agentes
@@ -349,13 +352,14 @@ A home DEVE conter, nesta ordem aproximada:
1. header e navegação; 1. header e navegação;
2. hero com proposta de valor e CTA; 2. hero com proposta de valor e CTA;
3. prova visual por eventos em destaque; 3. manifesto da marca;
4. resumo dos serviços; 4. resumo dos serviços em destaque;
5. método de trabalho; 5. casos selecionados do portfólio;
6. casos selecionados; 6. método de trabalho (4 passos);
7. depoimentos; 7. depoimentos;
8. CTA final para briefing; 8. posicionamento/perfil;
9. footer com contato, redes e links legais. 9. CTA final para briefing;
10. footer com contato, redes e links legais.
A ordem pode variar apenas se a revisão de UX justificar a mudança. A ordem pode variar apenas se a revisão de UX justificar a mudança.
@@ -374,6 +378,15 @@ Centralizar tokens de:
Não espalhar valores visuais arbitrários por componentes. Não espalhar valores visuais arbitrários por componentes.
O MVP adota o sistema de design **Heritage Editorial** (ver DESIGN.md e ADR-013):
- tipografia serifada auto-hospedada (EB Garamond) com escala de display a label;
- paleta papel/oliva/sálvia/tinta com superfícies tonais; hierarquia sem sombras de card;
- raio de borda zero para superfícies interativas e de conteúdo;
- largura de container 1120px e ritmo de espaçamento de 8px;
- `prefers-reduced-motion` respeitado;
- contraste WCAG AA (tinta sobre papel e oliva sobre papel).
### 6.4 Requisitos de mídia ### 6.4 Requisitos de mídia
- Imagens públicas DEVERÃO possuir texto alternativo. - Imagens públicas DEVERÃO possuir texto alternativo.
@@ -383,6 +396,7 @@ Não espalhar valores visuais arbitrários por componentes.
- Dimensões DEVERÃO ser reservadas para evitar layout shift. - Dimensões DEVERÃO ser reservadas para evitar layout shift.
- Não armazenar blobs de imagem no PostgreSQL. - Não armazenar blobs de imagem no PostgreSQL.
- Não depender do disco efêmero do contêiner em produção. - Não depender do disco efêmero do contêiner em produção.
- O logotipo da marca DEVE possuir texto alternativo e variantes claro/escuro; `site_settings.logo_path` sobrescreve o asset padrão quando preenchido.
### 6.5 Acessibilidade ### 6.5 Acessibilidade
@@ -534,12 +548,10 @@ Campos públicos:
- validar no servidor; - validar no servidor;
- usar honeypot e rate limiting; - usar honeypot e rate limiting;
- impedir duplo envio acidental; - impedir duplo envio acidental;
- criar Lead com status `new`;
- registrar origem `website`;
- notificar administradores;
- exibir sucesso sem revelar dados internos;
- enviar e-mail de confirmação quando o serviço de e-mail estiver configurado; - enviar e-mail de confirmação quando o serviço de e-mail estiver configurado;
- falha no e-mail não pode apagar o lead já criado. - exibir sucesso sem revelar dados internos.
> **Estado atual (Fases 01):** o formulário usa Blade + Controller e envia apenas e-mails informativos (para a assessoria e confirmação ao visitante), sem criar Lead. A criação de Lead (status `new`, origem `website`), a notificação aos administradores e o registro de aceite de privacidade entram na **Fase 2**, quando o formulário passa a criar o Lead via `CaptureWebsiteLead`. A falha de e-mail não pode apagar dados já criados.
**Aceite:** **Aceite:**
@@ -1253,10 +1265,19 @@ Singleton:
- `id` bigint PK; - `id` bigint PK;
- `brand_name`; - `brand_name`;
- `logo_path` nullable;
- `logo_alt` nullable;
- `hero_eyebrow` nullable; - `hero_eyebrow` nullable;
- `hero_title`; - `hero_title`;
- `hero_subtitle`; - `hero_subtitle`;
- `hero_cta_label`; - `hero_cta_label`;
- `hero_cta_secondary_label` nullable;
- `hero_note` nullable;
- `manifesto_title`;
- `manifesto_lead`;
- `manifesto_body`;
- `method_steps` jsonb (4 passos tipados);
- `principles` jsonb (lista tipada);
- `about_summary` nullable; - `about_summary` nullable;
- `email`; - `email`;
- `phone`; - `phone`;
@@ -1265,7 +1286,8 @@ Singleton:
- `default_meta_title`; - `default_meta_title`;
- `default_meta_description`; - `default_meta_description`;
- `default_og_image_path` nullable; - `default_og_image_path` nullable;
- analytics fields nullable; - `default_og_image_alt` nullable;
- `analytics_enabled` boolean default false;
- timestamps. - timestamps.
#### `services` #### `services`
@@ -1536,7 +1558,7 @@ Constraints de banco devem proteger:
| Servidor | FrankenPHP + Caddy | | Servidor | FrankenPHP + Caddy |
| Assets | Vite | | Assets | Vite |
| Testes | Pest 4 + Pest Browser/Playwright | | Testes | Pest 4 + Pest Browser/Playwright |
| Arquivos | Laravel Filesystem + storage S3-compatible em produção | | Arquivos | Laravel Filesystem + Cloudflare R2 (S3-compatible) em produção |
| Fila | Database queue | | Fila | Database queue |
| Scheduler | Laravel Scheduler em processo separado | | Scheduler | Laravel Scheduler em processo separado |
@@ -1764,6 +1786,8 @@ Não transformar todas as seções estáticas em componentes Livewire. Usar Blad
### 11.2 Formulário de briefing ### 11.2 Formulário de briefing
> **Estado atual (Fases 01):** o formulário é implementado em Blade + Controller (`POST /contato`, `ContactBriefingRequest`), conforme WEB-05. Se a Fase 2 mantiver Blade + Controller, os requisitos abaixo valem para o formulário e seus testes independentemente da tecnologia; a criação de Lead segue para a Fase 2.
O componente deve: O componente deve:
- ter estado tipado ou Form Object quando útil; - ter estado tipado ou Form Object quando útil;
@@ -1970,7 +1994,7 @@ Determinismo obrigatório:
- Chromium e imagem Linux fixos; - Chromium e imagem Linux fixos;
- viewport fixo; - viewport fixo;
- timezone `America/Fortaleza`; - timezone `America/Sao_Paulo`;
- locale `pt-BR`; - locale `pt-BR`;
- fontes instaladas na imagem; - fontes instaladas na imagem;
- relógio congelado; - relógio congelado;
@@ -2052,7 +2076,7 @@ quality → Pint check + PHPStan/Larastan + audits + testes
| Job | Responsabilidade | Bloqueia merge | | Job | Responsabilidade | Bloqueia merge |
|---|---|---:| |---|---|---:|
| `static` | Pint, PHPStan/Larastan, Composer validate e audits | Sim | | `static` | Pint, PHPStan/Larastan, Composer validate, Composer e npm audit | Sim |
| `unit` | Unitários, arquitetura e cobertura | Sim | | `unit` | Unitários, arquitetura e cobertura | Sim |
| `feature` | PostgreSQL, migrations, Livewire, Filament e integração | Sim | | `feature` | PostgreSQL, migrations, Livewire, Filament e integração | Sim |
| `browser` | Vite, FrankenPHP, E2E, smoke, acessibilidade e visual | Sim | | `browser` | Vite, FrankenPHP, E2E, smoke, acessibilidade e visual | Sim |
@@ -2073,11 +2097,11 @@ quality → Pint check + PHPStan/Larastan + audits + testes
### 14.3 Branches e ambientes ### 14.3 Branches e ambientes
- PR: testes e preview opcional; - PR: testes e preview opcional;
- `main`: build imutável por SHA e deploy automático em staging; - `main`: build imutável por SHA publicado no GHCR e deploy automático em staging via Dokploy;
- staging: migration, cache warmup e smoke pós-deploy; - staging: Dokploy Compose executa migração, healthcheck `/up` e smoke pós-deploy (`/up`, `/`, `/admin/login`);
- produção: promoção da mesma imagem aprovada, sem rebuild; - produção: promoção da mesma imagem aprovada, sem rebuild (retag do digest em `:production`);
- produção requer aprovação humana explícita no MVP; - produção requer aprovação humana explícita no MVP (`workflow_dispatch` com confirmação);
- rollback usa imagem anterior; - rollback usa imagem anterior (SHA anterior, sem rebuild);
- migrations devem ser backward-compatible quando possível. - migrations devem ser backward-compatible quando possível.
### 14.4 Definition of Done ### 14.4 Definition of Done
@@ -2159,7 +2183,7 @@ APP_DEBUG=false
APP_URL APP_URL
APP_LOCALE=pt_BR APP_LOCALE=pt_BR
APP_FALLBACK_LOCALE=pt_BR APP_FALLBACK_LOCALE=pt_BR
APP_TIMEZONE=America/Fortaleza APP_TIMEZONE=America/Sao_Paulo
DB_CONNECTION=pgsql DB_CONNECTION=pgsql
DB_HOST DB_HOST
@@ -2172,19 +2196,16 @@ CACHE_STORE=database ou file conforme ambiente
QUEUE_CONNECTION=database QUEUE_CONNECTION=database
SESSION_DRIVER=database ou cookie conforme decisão SESSION_DRIVER=database ou cookie conforme decisão
FILESYSTEM_DISK=s3 em produção FILESYSTEM_DISK=r2 em produção
AWS_ACCESS_KEY_ID R2_ACCESS_KEY_ID
AWS_SECRET_ACCESS_KEY R2_SECRET_ACCESS_KEY
AWS_DEFAULT_REGION R2_BUCKET
AWS_BUCKET R2_ENDPOINT
AWS_ENDPOINT opcional R2_URL (domínio próprio opcional)
AWS_USE_PATH_STYLE_ENDPOINT opcional AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_DEFAULT_REGION, AWS_BUCKET, AWS_ENDPOINT, AWS_USE_PATH_STYLE_ENDPOINT opcionais, apenas se o disk s3 for usado
MAIL_MAILER MAIL_MAILER=resend em produção (mailer nativo Laravel)
MAIL_HOST RESEND_API_KEY
MAIL_PORT
MAIL_USERNAME
MAIL_PASSWORD
MAIL_FROM_ADDRESS MAIL_FROM_ADDRESS
MAIL_FROM_NAME MAIL_FROM_NAME
``` ```
@@ -2279,7 +2300,7 @@ O seed deve criar:
- configurações do site; - configurações do site;
- 3 serviços; - 3 serviços;
- 3 casos de portfólio; - 3 casos de portfólio;
- 3 depoimentos; - depoimentos reais: os 5 casais de `depoimentos.md` (Jeniffer e Maick, Quesia e Jhonata, Milena e Weslley, Raquel e Pedro, Victoria e Pedro), preservando texto e datas; autores fictícios de demonstração removidos; em produção permanecem não publicados até autorização explícita de publicação;
- leads em estados variados; - leads em estados variados;
- 2 eventos futuros e 1 concluído; - 2 eventos futuros e 1 concluído;
- tarefas vencidas e futuras; - tarefas vencidas e futuras;
@@ -2314,7 +2335,7 @@ O agente deve implementar na sequência, salvo instrução explícita.
- [x] Filament instalado e autenticado; - [x] Filament instalado e autenticado;
- [x] Livewire configurado; - [x] Livewire configurado;
- [x] Tailwind/Vite; - [x] Tailwind/Vite;
- [x] FrankenPHP e Docker Compose local; - [~] FrankenPHP e Docker Compose local (imagem pronta; serviço de aplicação local pendente);
- [x] papéis admin/assistant; - [x] papéis admin/assistant;
- [x] Pint; - [x] Pint;
- [x] PHPStan/Larastan; - [x] PHPStan/Larastan;
@@ -2325,6 +2346,13 @@ O agente deve implementar na sequência, salvo instrução explícita.
- [x] design tokens mínimos; - [x] design tokens mínimos;
- [x] healthcheck; - [x] healthcheck;
- [x] seed de admin local. - [x] seed de admin local.
- [ ] verificação de e-mail e reset seguro (MustVerifyEmail);
- [ ] npm audit no `composer quality` e no job `static`;
- [ ] gate de cobertura `Domain`/`Application` ≥ 80%;
- [ ] serviço de aplicação FrankenPHP no Compose local;
- [ ] hello-world implantado em staging (critério de saída).
> Os itens pendentes acima são tratados pela mudança OpenSpec `complete-foundation-parity`; o critério de saída da fase só é atingido com staging implantado.
**Critério de saída:** pipeline verde e hello-world implantado em staging. **Critério de saída:** pipeline verde e hello-world implantado em staging.
@@ -2481,6 +2509,10 @@ Toda operação financeira deve:
| ADR-008 | Database queue; Redis adiado | Aceita | | ADR-008 | Database queue; Redis adiado | Aceita |
| ADR-009 | Dinheiro em BRL armazenado como centavos inteiros | Aceita | | ADR-009 | Dinheiro em BRL armazenado como centavos inteiros | Aceita |
| ADR-010 | Home com estrutura fixa e CMS tipado, sem page builder | Aceita | | ADR-010 | Home com estrutura fixa e CMS tipado, sem page builder | Aceita |
| ADR-011 | Cloudflare R2 (S3-compatible) como storage de objetos em produção | Aceita |
| ADR-012 | E-mail transacional via Resend (mailer nativo Laravel) | Aceita |
| ADR-013 | Design system Heritage Editorial para o site público | Aceita |
| ADR-014 | Deploy via Dokploy Compose com imagem imutável por SHA no GHCR | Aceita |
--- ---

View File

@@ -85,7 +85,7 @@ final readonly class PageMeta
} }
/** /**
* Build the metadata for branded error pages (404/500). * Build the metadata for branded error pages.
* *
* Error pages carry no canonical, are excluded from search indexes and use * Error pages carry no canonical, are excluded from search indexes and use
* the page name suffixed with the brand name as their title. * the page name suffixed with the brand name as their title.
@@ -94,9 +94,13 @@ final readonly class PageMeta
SiteSetting $settings, SiteSetting $settings,
int $status = 404, int $status = 404,
): self { ): self {
[$title, $description] = $status === 500 [$title, $description] = match ($status) {
? ['Algo deu errado', 'Não foi possível concluir o pedido. Tente novamente em instantes.'] 500 => ['Algo deu errado', 'Não foi possível concluir o pedido. Tente novamente em instantes.'],
: ['Página não encontrada', 'A página que você procura não existe ou foi movida.']; 419 => ['Sessão expirada', 'Sua sessão expirou. Volte e tente enviar novamente.'],
429 => ['Muitas solicitações', 'Você enviou muitas solicitações em pouco tempo. Aguarde um instante e tente novamente.'],
503 => ['Em manutenção', 'Estamos realizando uma breve manutenção. Tente novamente em instantes.'],
default => ['Página não encontrada', 'A página que você procura não existe ou foi movida.'],
};
return new self( return new self(
title: trim($title).' - '.$settings->brand_name, title: trim($title).' - '.$settings->brand_name,

View File

@@ -2,7 +2,7 @@ schema: spec-driven
context: | context: |
Fonte de verdade: SPEC.md na raiz. Precedência: instrução do dono do produto > SPEC.md > ADRs > testes > convenções. Fonte de verdade: SPEC.md na raiz. Precedência: instrução do dono do produto > SPEC.md > ADRs > testes > convenções.
Produto: plataforma de assessoria de eventos, single-tenant, MVP. UI em pt-BR, timezone America/Fortaleza, BRL. Produto: plataforma de assessoria de eventos, single-tenant, MVP. UI em pt-BR, timezone America/Sao_Paulo, atuação em São Paulo (capital), BRL.
Stack: Laravel 13, Filament 5 (/admin), Livewire 4 + Blade + Alpine + Tailwind (site público), Stack: Laravel 13, Filament 5 (/admin), Livewire 4 + Blade + Alpine + Tailwind (site público),
PostgreSQL, FrankenPHP regular mode (sem worker mode), Vite, Pest 4 + Pest Browser, database queue. PostgreSQL, FrankenPHP regular mode (sem worker mode), Vite, Pest 4 + Pest Browser, database queue.
Arquitetura: monólito modular. Interface -> Application (Actions/Queries) -> Domain (Enums/VOs) -> Infrastructure. Arquitetura: monólito modular. Interface -> Application (Actions/Queries) -> Domain (Enums/VOs) -> Infrastructure.

View File

@@ -1,7 +1,7 @@
# container-runtime Specification # container-runtime Specification
## Purpose ## Purpose
TBD - created by archiving change setup-foundation. Update Purpose after archive. Define the production container image for the application: a reproducible multi-stage FrankenPHP build serving the public directory, run in regular mode as a non-root user, shared by web/queue/scheduler processes, with no secrets in layers and a healthcheck on `/up`.
## Requirements ## Requirements
### Requirement: Production image uses multi-stage FrankenPHP build ### Requirement: Production image uses multi-stage FrankenPHP build

View File

@@ -1,7 +1,7 @@
# design-tokens Specification # design-tokens Specification
## Purpose ## Purpose
TBD - created by archiving change setup-foundation. Update Purpose after archive. Centralize the public site's design tokens so the interface implements the Heritage Editorial system from DESIGN.md (EB Garamond, 8px rhythm, zero radius, 1120px container, paper/olive/sage/ink palette), honoring reduced motion and WCAG AA contrast without card-shadow hierarchy.
## Requirements ## Requirements
### Requirement: Design tokens are centralized for the public site ### Requirement: Design tokens are centralized for the public site

View File

@@ -1,7 +1,7 @@
# health-check Specification # health-check Specification
## Purpose ## Purpose
TBD - created by archiving change setup-foundation. Update Purpose after archive. Expose a public `GET /up` healthcheck that responds quickly and without authentication, exposes no secrets, and fails when the application cannot boot, so orchestrators and CI can verify availability.
## Requirements ## Requirements
### Requirement: Public health endpoint responds without authentication ### Requirement: Public health endpoint responds without authentication

View File

@@ -1,7 +1,7 @@
# internal-authentication Specification # internal-authentication Specification
## Purpose ## Purpose
TBD - created by archiving change setup-foundation. Update Purpose after archive. Provide session-based authentication for the internal Filament panel at `/admin`, limited to two roles (admin and assistant), with unique emails, password reset without enumeration, inactive users denied, and user management restricted to admins.
## Requirements ## Requirements
### Requirement: Internal users authenticate via Filament panel ### Requirement: Internal users authenticate via Filament panel

View File

@@ -59,6 +59,7 @@
background-color: var(--amare-color-bg); background-color: var(--amare-color-bg);
color: var(--amare-color-text); color: var(--amare-color-text);
font-family: var(--amare-font-serif); font-family: var(--amare-font-serif);
overflow-wrap: break-word;
} }
[id$='-heading'] { [id$='-heading'] {
@@ -168,6 +169,7 @@
text-transform: uppercase; text-transform: uppercase;
color: var(--amare-color-muted); color: var(--amare-color-muted);
text-decoration: none; text-decoration: none;
overflow-wrap: break-word;
transition: color var(--amare-duration-fast) var(--amare-ease-standard); transition: color var(--amare-duration-fast) var(--amare-ease-standard);
} }

View File

@@ -6,6 +6,7 @@ document.addEventListener('DOMContentLoaded', () => {
if (menuButton && navigation) { if (menuButton && navigation) {
const isDesktop = () => window.matchMedia('(min-width: 768px)').matches; const isDesktop = () => window.matchMedia('(min-width: 768px)').matches;
const isMenuOpen = () => navigation.classList.contains('is-open');
const setOpen = (isOpen, { returnFocus = false } = {}) => { const setOpen = (isOpen, { returnFocus = false } = {}) => {
navigation.classList.toggle('is-open', isOpen); navigation.classList.toggle('is-open', isOpen);
@@ -39,6 +40,27 @@ document.addEventListener('DOMContentLoaded', () => {
navigation.addEventListener('keydown', (event) => { navigation.addEventListener('keydown', (event) => {
if (event.key === 'Escape') { if (event.key === 'Escape') {
setOpen(false, { returnFocus: true }); setOpen(false, { returnFocus: true });
return;
}
if (event.key === 'Tab' && !isDesktop() && isMenuOpen()) {
const focusables = navigation.querySelectorAll('a[href], button:not([disabled])');
if (focusables.length === 0) {
return;
}
const first = focusables[0];
const last = focusables[focusables.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
} }
}); });
@@ -53,12 +75,33 @@ document.addEventListener('DOMContentLoaded', () => {
} }
document.querySelectorAll('form[data-contact-form]').forEach((form) => { document.querySelectorAll('form[data-contact-form]').forEach((form) => {
form.addEventListener('submit', () => { const submitButton = form.querySelector('[data-submit-button]');
const button = form.querySelector('[data-submit-button]');
if (button) { const resetSubmitState = () => {
button.disabled = true; form.removeAttribute('aria-busy');
button.setAttribute('aria-busy', 'true'); form.removeAttribute('aria-disabled');
if (submitButton) {
submitButton.disabled = false;
submitButton.textContent = submitButton.dataset.defaultLabel ?? 'Enviar briefing';
submitButton.removeAttribute('aria-busy');
}
};
form.addEventListener('submit', () => {
if (submitButton) {
submitButton.dataset.defaultLabel ??= submitButton.textContent.trim();
submitButton.textContent = 'Enviando…';
submitButton.disabled = true;
submitButton.setAttribute('aria-busy', 'true');
}
form.setAttribute('aria-busy', 'true');
});
window.addEventListener('pageshow', (event) => {
if (event.persisted) {
resetSubmitState();
} }
}); });
}); });

View File

@@ -14,7 +14,7 @@
href="{{ route('contact') }}" href="{{ route('contact') }}"
class="inline-flex min-h-11 items-center bg-amare-accent px-5 py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-accent-text transition-colors duration-(--amare-duration-normal) ease-(--amare-ease-standard) hover:bg-amare-accent-deep" class="inline-flex min-h-11 items-center bg-amare-accent px-5 py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-accent-text transition-colors duration-(--amare-duration-normal) ease-(--amare-ease-standard) hover:bg-amare-accent-deep"
> >
{{ $settings->hero_cta_label }} {{ $settings->hero_cta_label ?: 'Solicitar proposta' }}
</a> </a>
</div> </div>
</div> </div>

View File

@@ -17,8 +17,8 @@
@endif @endif
</div> </div>
<h1 id="hero-heading" data-motion-beat="title" class="max-w-3xl text-4xl font-medium leading-none text-amare-text md:text-5xl"> <h1 id="hero-heading" data-motion-beat="title" class="max-w-3xl text-4xl font-medium leading-tight text-amare-text md:text-5xl">
{{ $settings->hero_title }} {{ $settings->hero_title ?: 'Celebrações com propósito' }}
</h1> </h1>
@if (filled($settings->hero_subtitle)) @if (filled($settings->hero_subtitle))
@@ -31,7 +31,7 @@
data-testid="home-primary-cta" data-testid="home-primary-cta"
class="inline-flex min-h-11 items-center bg-amare-accent px-5 py-3 text-sm font-semibold text-amare-accent-text transition-colors duration-(--amare-duration-normal) ease-(--amare-ease-standard) hover:bg-amare-accent-hover" class="inline-flex min-h-11 items-center bg-amare-accent px-5 py-3 text-sm font-semibold text-amare-accent-text transition-colors duration-(--amare-duration-normal) ease-(--amare-ease-standard) hover:bg-amare-accent-hover"
> >
{{ $settings->hero_cta_label }} {{ $settings->hero_cta_label ?: 'Solicitar proposta' }}
</a> </a>
@if (filled($settings->hero_secondary_cta_label)) @if (filled($settings->hero_secondary_cta_label))

View File

@@ -12,6 +12,7 @@
<div class="grid gap-6 md:grid-cols-2"> <div class="grid gap-6 md:grid-cols-2">
@foreach ($testimonials as $testimonial) @foreach ($testimonials as $testimonial)
@continue(blank(trim((string) $testimonial->quote)))
@php @php
$paragraphs = preg_split('/\n\s*\n/', trim((string) $testimonial->quote)) ?: []; $paragraphs = preg_split('/\n\s*\n/', trim((string) $testimonial->quote)) ?: [];
$paragraphs = array_values(array_filter(array_map('trim', $paragraphs), fn (string $p): bool => $p !== '')); $paragraphs = array_values(array_filter(array_map('trim', $paragraphs), fn (string $p): bool => $p !== ''));

View File

@@ -0,0 +1,28 @@
@php
try {
$errorSettings = \App\Models\SiteSetting::instance();
$pageMeta = \App\Application\Data\PageMeta::forErrorPage($errorSettings, 419);
} catch (\Throwable $e) {
$pageMeta = new \App\Application\Data\PageMeta(
title: 'Sessão expirada - Amare Assessoria',
description: 'Sua sessão expirou. Volte e tente enviar novamente.',
canonical: '',
robots: 'noindex, nofollow',
);
}
@endphp
@extends('layouts.public')
@section('content')
<div class="container-amare max-w-2xl space-y-6 py-20 text-center">
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Erro 419</p>
<h1 class="text-4xl font-medium tracking-tight text-amare-text">Sessão expirada</h1>
<p class="text-amare-muted">Sua sessão expirou. Volte e tente enviar novamente.</p>
<p>
<a href="{{ route('home') }}" class="inline-flex min-h-11 items-center bg-amare-accent px-5 py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-accent-text transition-colors hover:bg-amare-accent-deep">
Voltar para a home
</a>
</p>
</div>
@endsection

View File

@@ -0,0 +1,28 @@
@php
try {
$errorSettings = \App\Models\SiteSetting::instance();
$pageMeta = \App\Application\Data\PageMeta::forErrorPage($errorSettings, 429);
} catch (\Throwable $e) {
$pageMeta = new \App\Application\Data\PageMeta(
title: 'Muitas solicitações - Amare Assessoria',
description: 'Você enviou muitas solicitações em pouco tempo. Aguarde um instante e tente novamente.',
canonical: '',
robots: 'noindex, nofollow',
);
}
@endphp
@extends('layouts.public')
@section('content')
<div class="container-amare max-w-2xl space-y-6 py-20 text-center">
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Erro 429</p>
<h1 class="text-4xl font-medium tracking-tight text-amare-text">Muitas solicitações</h1>
<p class="text-amare-muted">Você enviou muitas solicitações em pouco tempo. Aguarde um instante e tente novamente.</p>
<p>
<a href="{{ route('home') }}" class="inline-flex min-h-11 items-center bg-amare-accent px-5 py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-accent-text transition-colors hover:bg-amare-accent-deep">
Voltar para a home
</a>
</p>
</div>
@endsection

View File

@@ -0,0 +1,28 @@
@php
try {
$errorSettings = \App\Models\SiteSetting::instance();
$pageMeta = \App\Application\Data\PageMeta::forErrorPage($errorSettings, 503);
} catch (\Throwable $e) {
$pageMeta = new \App\Application\Data\PageMeta(
title: 'Em manutenção - Amare Assessoria',
description: 'Estamos realizando uma breve manutenção. Tente novamente em instantes.',
canonical: '',
robots: 'noindex, nofollow',
);
}
@endphp
@extends('layouts.public')
@section('content')
<div class="container-amare max-w-2xl space-y-6 py-20 text-center">
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Erro 503</p>
<h1 class="text-4xl font-medium tracking-tight text-amare-text">Em manutenção</h1>
<p class="text-amare-muted">Estamos realizando uma breve manutenção. Tente novamente em instantes.</p>
<p>
<a href="{{ route('home') }}" class="inline-flex min-h-11 items-center bg-amare-accent px-5 py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-accent-text transition-colors hover:bg-amare-accent-deep">
Voltar para a home
</a>
</p>
</div>
@endsection

View File

@@ -107,18 +107,18 @@
@endif @endif
@if ($siteSettings->email) @if ($siteSettings->email)
<p> <p>
<a href="mailto:{{ $siteSettings->email }}" class="inline-flex min-h-11 items-center text-amare-muted transition-colors hover:text-amare-accent">{{ $siteSettings->email }}</a> <a href="mailto:{{ $siteSettings->email }}" class="inline-flex max-w-full min-h-11 items-center text-amare-muted transition-colors hover:text-amare-accent"><span class="min-w-0 break-words">{{ $siteSettings->email }}</span></a>
</p> </p>
@endif @endif
@if ($siteSettings->phone) @if ($siteSettings->phone)
<p> <p>
<a href="tel:{{ preg_replace('/\D/', '', (string) $siteSettings->phone) }}" class="inline-flex min-h-11 items-center text-amare-muted transition-colors hover:text-amare-accent">{{ $siteSettings->phone }}</a> <a href="tel:{{ preg_replace('/\D/', '', (string) $siteSettings->phone) }}" class="inline-flex max-w-full min-h-11 items-center text-amare-muted transition-colors hover:text-amare-accent"><span class="min-w-0 break-words">{{ $siteSettings->phone }}</span></a>
</p> </p>
@endif @endif
@foreach ($siteSettings->social_links ?? [] as $network => $url) @foreach ($siteSettings->social_links ?? [] as $network => $url)
@if (filled($url)) @if (filled($url))
<p> <p>
<a href="{{ $url }}" class="inline-flex min-h-11 items-center text-amare-muted transition-colors hover:text-amare-accent" rel="noopener noreferrer" target="_blank">{{ ucfirst((string) $network) }}</a> <a href="{{ $url }}" class="inline-flex max-w-full min-h-11 items-center text-amare-muted transition-colors hover:text-amare-accent" rel="noopener noreferrer" target="_blank"><span class="min-w-0 break-words">{{ ucfirst((string) $network) }}</span></a>
</p> </p>
@endif @endif
@endforeach @endforeach

View File

@@ -15,18 +15,18 @@
<p>{{ $siteSettings->city ?: 'São Paulo - SP' }}</p> <p>{{ $siteSettings->city ?: 'São Paulo - SP' }}</p>
@if ($siteSettings->email) @if ($siteSettings->email)
<p> <p>
<a href="mailto:{{ $siteSettings->email }}" class="inline-flex min-h-11 items-center text-amare-accent transition-colors hover:text-amare-accent-deep">{{ $siteSettings->email }}</a> <a href="mailto:{{ $siteSettings->email }}" class="inline-flex max-w-full min-h-11 items-center text-amare-accent transition-colors hover:text-amare-accent-deep"><span class="min-w-0 break-words">{{ $siteSettings->email }}</span></a>
</p> </p>
@endif @endif
@if ($siteSettings->phone) @if ($siteSettings->phone)
<p> <p>
<a href="tel:{{ preg_replace('/\D/', '', (string) $siteSettings->phone) }}" class="inline-flex min-h-11 items-center text-amare-accent transition-colors hover:text-amare-accent-deep">{{ $siteSettings->phone }}</a> <a href="tel:{{ preg_replace('/\D/', '', (string) $siteSettings->phone) }}" class="inline-flex max-w-full min-h-11 items-center text-amare-accent transition-colors hover:text-amare-accent-deep"><span class="min-w-0 break-words">{{ $siteSettings->phone }}</span></a>
</p> </p>
@endif @endif
@foreach ($siteSettings->social_links ?? [] as $network => $url) @foreach ($siteSettings->social_links ?? [] as $network => $url)
@if (filled($url)) @if (filled($url))
<p> <p>
<a href="{{ $url }}" class="inline-flex min-h-11 items-center text-amare-accent transition-colors hover:text-amare-accent-deep" rel="noopener noreferrer" target="_blank">{{ ucfirst((string) $network) }}</a> <a href="{{ $url }}" class="inline-flex max-w-full min-h-11 items-center text-amare-accent transition-colors hover:text-amare-accent-deep" rel="noopener noreferrer" target="_blank"><span class="min-w-0 break-words">{{ ucfirst((string) $network) }}</span></a>
</p> </p>
@endif @endif
@endforeach @endforeach

View File

@@ -5,10 +5,12 @@
<div class="border-b border-amare-border bg-amare-bg"> <div class="border-b border-amare-border bg-amare-bg">
<div class="container-amare space-y-8 py-16 md:py-24"> <div class="container-amare space-y-8 py-16 md:py-24">
<header class="max-w-3xl space-y-4"> <header class="max-w-3xl space-y-4">
@if (filled($case->event_type))
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">{{ $case->event_type }}</p> <p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">{{ $case->event_type }}</p>
@endif
<h1 class="text-4xl font-medium tracking-tight text-amare-text md:text-5xl">{{ $case->title }}</h1> <h1 class="text-4xl font-medium tracking-tight text-amare-text md:text-5xl">{{ $case->title }}</h1>
<p class="text-lg text-amare-muted">{{ $case->summary }}</p> <p class="text-lg text-amare-muted">{{ $case->summary }}</p>
<p class="text-sm text-amare-muted"> <p class="text-sm break-words text-amare-muted">
@if ($case->city){{ $case->city }}@endif @if ($case->city){{ $case->city }}@endif
@if ($case->venue) · {{ $case->venue }}@endif @if ($case->venue) · {{ $case->venue }}@endif
@if ($case->event_date) · {{ $case->event_date->format('d/m/Y') }}@endif @if ($case->event_date) · {{ $case->event_date->format('d/m/Y') }}@endif

View File

@@ -45,3 +45,19 @@
- [x] E2E form: submit → sucesso role=status; 2 e-mails (novo briefing + confirmação) logados via MAIL_MAILER=log; fila drenada - [x] E2E form: submit → sucesso role=status; 2 e-mails (novo briefing + confirmação) logados via MAIL_MAILER=log; fila drenada
- [x] Mobile: sem overflow horizontal nas 7 rotas (390px); menu mobile abre/fecha + Escape + retorno de foco - [x] Mobile: sem overflow horizontal nas 7 rotas (390px); menu mobile abre/fecha + Escape + retorno de foco
- [x] Relatório de entrega - [x] Relatório de entrega
## T7 — Hardening Public Blades
- [x] Chunk 1: CSS overflow resilience (`overflow-wrap` body + `[data-chapter-index]` wrap safety, browser test)
- [x] Chunk 2: Hero + final-CTA fallbacks + `leading-tight` (TDD)
- [x] Chunk 3a: Skip blank-quote testimonials (TDD)
- [x] Chunk 3b: Guard empty `event_type` + `break-words` meta line (TDD)
- [x] Chunk 4: Extend `PageMeta::forErrorPage` + branded 419/429/503 error views (TDD)
- [x] Chunk 5: `app.js` defensive hardening — bfcache submit reset, "Enviando…" label swap, aria-busy, mobile-menu focus trap + Escape
- [x] Chunk 6: Quality gates — Pint passed, PHPStan clean, 104 feature tests passed (587 assertions), Vite build succeeded
### Deviations from plan
- `PageMeta::forErrorPage` landed on `main` during implementation; rebased and extended it for 419/429/503.
- Contact form landed on `main`; submit-state browser coverage was restored.
- `event_type: null` violates NOT NULL column — test uses `''` instead (guard covers both).

View File

@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
use App\Models\SiteSetting;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function (): void {
SiteSetting::instance();
});
it('does not overflow horizontally when contact info contains long unbroken strings', function (): void {
SiteSetting::instance()->update([
'email' => str_repeat('a', 50).'@email.'.str_repeat('b', 40),
'phone' => '(11) 99999-9999',
]);
$page = $this->visit('/contato');
$overflow = $page->script(
'() => document.documentElement.scrollWidth - document.documentElement.clientWidth',
);
expect((int) $overflow)->toBeLessThanOrEqual(0);
});
it('shows sending state on submit and resets on bfcache restore', function (): void {
$page = $this->visit('/contato');
$state = $page->script(<<<'JS'
() => {
const form = document.querySelector("form[data-contact-form]");
const button = document.querySelector("[data-submit-button]");
form?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
const afterSubmit = {
label: button?.textContent?.trim(),
disabled: button?.disabled ?? false,
formBusy: form?.getAttribute("aria-busy"),
};
window.dispatchEvent(new PageTransitionEvent("pageshow", { persisted: true }));
return {
afterSubmit,
afterRestore: {
label: button?.textContent?.trim(),
disabled: button?.disabled ?? false,
formBusy: form?.getAttribute("aria-busy"),
},
};
}
JS);
expect($state['afterSubmit']['label'])->toBe('Enviando…');
expect($state['afterSubmit']['disabled'])->toBeTrue();
expect($state['afterSubmit']['formBusy'])->toBe('true');
expect($state['afterRestore']['label'])->toBe('Enviar briefing');
expect($state['afterRestore']['disabled'])->toBeFalse();
expect($state['afterRestore']['formBusy'])->toBeNull();
});
it('traps focus within the open mobile menu', function (): void {
$page = $this->visit('/');
$page->resize(390, 844);
$page->click('[data-menu-button]');
for ($i = 0; $i < 20; $i++) {
$inside = $page->script(
'() => document.querySelector("[data-main-nav]")?.contains(document.activeElement) ?? false',
);
expect($inside)->toBeTrue();
$page->keys(':focus', 'Tab');
}
$page->script('() => document.querySelector("[data-main-nav] a")?.focus()');
$page->keys(':focus', 'Shift+Tab');
$wrappedToLast = $page->script('() => {
const nav = document.querySelector("[data-main-nav]");
const links = nav?.querySelectorAll("a[href], button:not([disabled])") ?? [];
return links.length > 0 && document.activeElement === links[links.length - 1];
}');
expect($wrappedToLast)->toBeTrue();
});

View File

@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\PublicSite;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Route;
use Tests\TestCase;
class ErrorPagesTest extends TestCase
{
use RefreshDatabase;
public function test_419_renders_branded_session_expired_page(): void
{
Route::get('/__test-419', fn (): never => abort(419));
$this->get('/__test-419')
->assertStatus(419)
->assertSee('Erro 419')
->assertSee('Sessão expirada')
->assertSee('Voltar para a home');
}
public function test_429_renders_branded_rate_limited_page(): void
{
Route::get('/__test-429', fn (): never => abort(429));
$this->get('/__test-429')
->assertStatus(429)
->assertSee('Erro 429')
->assertSee('Muitas solicitações')
->assertSee('Voltar para a home');
}
public function test_503_renders_branded_maintenance_page(): void
{
Route::get('/__test-503', fn (): never => abort(503));
$this->get('/__test-503')
->assertStatus(503)
->assertSee('Erro 503')
->assertSee('Em manutenção')
->assertSee('Voltar para a home');
}
public function test_error_pages_never_leak_debug_stack_traces(): void
{
config(['app.debug' => false]);
Route::get('/__test-503', fn (): never => abort(503));
$this->get('/__test-503')
->assertStatus(503)
->assertDontSee('segredo-interno-amare')
->assertDontSee('Exception');
}
}

View File

@@ -110,4 +110,42 @@ class HomePageContentTest extends TestCase
->assertSee('id="positioning-heading"', false) ->assertSee('id="positioning-heading"', false)
->assertSee('id="final-cta-heading"', false); ->assertSee('id="final-cta-heading"', false);
} }
public function test_hero_falls_back_to_default_copy_when_fields_are_empty(): void
{
SiteSetting::instance()->update([
'hero_title' => '',
'hero_cta_label' => '',
'hero_secondary_cta_label' => '',
'hero_subtitle' => '',
'hero_note' => '',
]);
$response = $this->get(route('home'));
$response
->assertOk()
->assertSeeInOrder(['id="hero-heading"', 'Celebrações com propósito'])
->assertSeeInOrder(['data-testid="home-primary-cta"', 'Solicitar proposta']);
}
public function test_blank_quote_testimonials_are_skipped(): void
{
Testimonial::factory()->published()->create([
'author_name' => 'Autor Mantido',
'quote' => 'Experiência impecável do início ao fim.',
]);
Testimonial::factory()->published()->create([
'author_name' => 'Autor Oculto',
'quote' => ' ',
]);
$response = $this->get(route('home'));
$response
->assertOk()
->assertSee('Autor Mantido')
->assertSee('Experiência impecável do início ao fim.')
->assertDontSee('Autor Oculto');
}
} }

View File

@@ -196,4 +196,26 @@ class PublicPagesTest extends TestCase
$this->assertLessThan(15, $queryCount); $this->assertLessThan(15, $queryCount);
} }
public function test_case_without_event_type_does_not_render_empty_eyebrow(): void
{
SiteSetting::instance();
$case = PortfolioCase::factory()->published()->create([
'slug' => 'caso-sem-tipo',
'title' => 'Caso Sem Tipo',
'event_type' => '',
]);
$response = $this->get(route('portfolio.show', $case->slug));
$response->assertOk()->assertSee('Caso Sem Tipo');
$emptyAccentParagraphs = preg_match_all(
'/class="[^"]*text-amare-accent[^"]*"><\/p>/',
(string) $response->getContent(),
);
expect($emptyAccentParagraphs)->toBe(0);
}
} }