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
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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 !== ''));
|
||||||
|
|||||||
28
resources/views/errors/419.blade.php
Normal file
28
resources/views/errors/419.blade.php
Normal 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
|
||||||
28
resources/views/errors/429.blade.php
Normal file
28
resources/views/errors/429.blade.php
Normal 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
|
||||||
28
resources/views/errors/503.blade.php
Normal file
28
resources/views/errors/503.blade.php
Normal 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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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">
|
||||||
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">{{ $case->event_type }}</p>
|
@if (filled($case->event_type))
|
||||||
|
<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
|
||||||
|
|||||||
16
tasks.md
16
tasks.md
@@ -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).
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
86
tests/Browser/HardeningTest.php
Normal file
86
tests/Browser/HardeningTest.php
Normal 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();
|
||||||
|
});
|
||||||
59
tests/Feature/PublicSite/ErrorPagesTest.php
Normal file
59
tests/Feature/PublicSite/ErrorPagesTest.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user