Compare commits
3 Commits
feat/front
...
2fe262ab59
| Author | SHA1 | Date | |
|---|---|---|---|
| 2fe262ab59 | |||
| 018b43e24f | |||
| 9fac784f6a |
104
SPEC.md
104
SPEC.md
@@ -14,7 +14,8 @@
|
||||
| Estágio | MVP |
|
||||
| Status da especificação | Aprovada para implementação |
|
||||
| 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 |
|
||||
| Princípio principal | YAGNI — implementar somente o necessário para validar o produto |
|
||||
| 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.
|
||||
|
||||
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
|
||||
@@ -349,13 +352,14 @@ A home DEVE conter, nesta ordem aproximada:
|
||||
|
||||
1. header e navegação;
|
||||
2. hero com proposta de valor e CTA;
|
||||
3. prova visual por eventos em destaque;
|
||||
4. resumo dos serviços;
|
||||
5. método de trabalho;
|
||||
6. casos selecionados;
|
||||
3. manifesto da marca;
|
||||
4. resumo dos serviços em destaque;
|
||||
5. casos selecionados do portfólio;
|
||||
6. método de trabalho (4 passos);
|
||||
7. depoimentos;
|
||||
8. CTA final para briefing;
|
||||
9. footer com contato, redes e links legais.
|
||||
8. posicionamento/perfil;
|
||||
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.
|
||||
|
||||
@@ -374,6 +378,15 @@ Centralizar tokens de:
|
||||
|
||||
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
|
||||
|
||||
- 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.
|
||||
- Não armazenar blobs de imagem no PostgreSQL.
|
||||
- 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
|
||||
|
||||
@@ -534,12 +548,10 @@ Campos públicos:
|
||||
- validar no servidor;
|
||||
- usar honeypot e rate limiting;
|
||||
- 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;
|
||||
- falha no e-mail não pode apagar o lead já criado.
|
||||
- exibir sucesso sem revelar dados internos.
|
||||
|
||||
> **Estado atual (Fases 0–1):** 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:**
|
||||
|
||||
@@ -1253,10 +1265,19 @@ Singleton:
|
||||
|
||||
- `id` bigint PK;
|
||||
- `brand_name`;
|
||||
- `logo_path` nullable;
|
||||
- `logo_alt` nullable;
|
||||
- `hero_eyebrow` nullable;
|
||||
- `hero_title`;
|
||||
- `hero_subtitle`;
|
||||
- `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;
|
||||
- `email`;
|
||||
- `phone`;
|
||||
@@ -1265,7 +1286,8 @@ Singleton:
|
||||
- `default_meta_title`;
|
||||
- `default_meta_description`;
|
||||
- `default_og_image_path` nullable;
|
||||
- analytics fields nullable;
|
||||
- `default_og_image_alt` nullable;
|
||||
- `analytics_enabled` boolean default false;
|
||||
- timestamps.
|
||||
|
||||
#### `services`
|
||||
@@ -1536,7 +1558,7 @@ Constraints de banco devem proteger:
|
||||
| Servidor | FrankenPHP + Caddy |
|
||||
| Assets | Vite |
|
||||
| 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 |
|
||||
| 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
|
||||
|
||||
> **Estado atual (Fases 0–1):** 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:
|
||||
|
||||
- ter estado tipado ou Form Object quando útil;
|
||||
@@ -1970,7 +1994,7 @@ Determinismo obrigatório:
|
||||
|
||||
- Chromium e imagem Linux fixos;
|
||||
- viewport fixo;
|
||||
- timezone `America/Fortaleza`;
|
||||
- timezone `America/Sao_Paulo`;
|
||||
- locale `pt-BR`;
|
||||
- fontes instaladas na imagem;
|
||||
- relógio congelado;
|
||||
@@ -2052,7 +2076,7 @@ quality → Pint check + PHPStan/Larastan + audits + testes
|
||||
|
||||
| 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 |
|
||||
| `feature` | PostgreSQL, migrations, Livewire, Filament e integração | 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
|
||||
|
||||
- PR: testes e preview opcional;
|
||||
- `main`: build imutável por SHA e deploy automático em staging;
|
||||
- staging: migration, cache warmup e smoke pós-deploy;
|
||||
- produção: promoção da mesma imagem aprovada, sem rebuild;
|
||||
- produção requer aprovação humana explícita no MVP;
|
||||
- rollback usa imagem anterior;
|
||||
- `main`: build imutável por SHA publicado no GHCR e deploy automático em staging via Dokploy;
|
||||
- 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 (retag do digest em `:production`);
|
||||
- produção requer aprovação humana explícita no MVP (`workflow_dispatch` com confirmação);
|
||||
- rollback usa imagem anterior (SHA anterior, sem rebuild);
|
||||
- migrations devem ser backward-compatible quando possível.
|
||||
|
||||
### 14.4 Definition of Done
|
||||
@@ -2159,7 +2183,7 @@ APP_DEBUG=false
|
||||
APP_URL
|
||||
APP_LOCALE=pt_BR
|
||||
APP_FALLBACK_LOCALE=pt_BR
|
||||
APP_TIMEZONE=America/Fortaleza
|
||||
APP_TIMEZONE=America/Sao_Paulo
|
||||
|
||||
DB_CONNECTION=pgsql
|
||||
DB_HOST
|
||||
@@ -2172,19 +2196,16 @@ CACHE_STORE=database ou file conforme ambiente
|
||||
QUEUE_CONNECTION=database
|
||||
SESSION_DRIVER=database ou cookie conforme decisão
|
||||
|
||||
FILESYSTEM_DISK=s3 em produção
|
||||
AWS_ACCESS_KEY_ID
|
||||
AWS_SECRET_ACCESS_KEY
|
||||
AWS_DEFAULT_REGION
|
||||
AWS_BUCKET
|
||||
AWS_ENDPOINT opcional
|
||||
AWS_USE_PATH_STYLE_ENDPOINT opcional
|
||||
FILESYSTEM_DISK=r2 em produção
|
||||
R2_ACCESS_KEY_ID
|
||||
R2_SECRET_ACCESS_KEY
|
||||
R2_BUCKET
|
||||
R2_ENDPOINT
|
||||
R2_URL (domínio próprio 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_HOST
|
||||
MAIL_PORT
|
||||
MAIL_USERNAME
|
||||
MAIL_PASSWORD
|
||||
MAIL_MAILER=resend em produção (mailer nativo Laravel)
|
||||
RESEND_API_KEY
|
||||
MAIL_FROM_ADDRESS
|
||||
MAIL_FROM_NAME
|
||||
```
|
||||
@@ -2279,7 +2300,7 @@ O seed deve criar:
|
||||
- configurações do site;
|
||||
- 3 serviços;
|
||||
- 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;
|
||||
- 2 eventos futuros e 1 concluído;
|
||||
- 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] Livewire configurado;
|
||||
- [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] Pint;
|
||||
- [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] healthcheck;
|
||||
- [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.
|
||||
|
||||
@@ -2481,6 +2509,10 @@ Toda operação financeira deve:
|
||||
| ADR-008 | Database queue; Redis adiado | 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-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 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
* the page name suffixed with the brand name as their title.
|
||||
@@ -94,9 +94,13 @@ final readonly class PageMeta
|
||||
SiteSetting $settings,
|
||||
int $status = 404,
|
||||
): self {
|
||||
[$title, $description] = $status === 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.'];
|
||||
[$title, $description] = match ($status) {
|
||||
500 => ['Algo deu errado', 'Não foi possível concluir o pedido. Tente novamente em instantes.'],
|
||||
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(
|
||||
title: trim($title).' - '.$settings->brand_name,
|
||||
|
||||
@@ -2,7 +2,7 @@ schema: spec-driven
|
||||
|
||||
context: |
|
||||
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),
|
||||
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.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# container-runtime Specification
|
||||
|
||||
## 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
|
||||
### Requirement: Production image uses multi-stage FrankenPHP build
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# design-tokens Specification
|
||||
|
||||
## 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
|
||||
### Requirement: Design tokens are centralized for the public site
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# health-check Specification
|
||||
|
||||
## 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
|
||||
### Requirement: Public health endpoint responds without authentication
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# internal-authentication Specification
|
||||
|
||||
## 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
|
||||
### Requirement: Internal users authenticate via Filament panel
|
||||
|
||||
|
||||
@@ -59,6 +59,7 @@
|
||||
background-color: var(--amare-color-bg);
|
||||
color: var(--amare-color-text);
|
||||
font-family: var(--amare-font-serif);
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
[id$='-heading'] {
|
||||
@@ -168,6 +169,7 @@
|
||||
text-transform: uppercase;
|
||||
color: var(--amare-color-muted);
|
||||
text-decoration: none;
|
||||
overflow-wrap: break-word;
|
||||
transition: color var(--amare-duration-fast) var(--amare-ease-standard);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
if (menuButton && navigation) {
|
||||
const isDesktop = () => window.matchMedia('(min-width: 768px)').matches;
|
||||
const isMenuOpen = () => navigation.classList.contains('is-open');
|
||||
|
||||
const setOpen = (isOpen, { returnFocus = false } = {}) => {
|
||||
navigation.classList.toggle('is-open', isOpen);
|
||||
@@ -39,6 +40,27 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
navigation.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Escape') {
|
||||
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) => {
|
||||
form.addEventListener('submit', () => {
|
||||
const button = form.querySelector('[data-submit-button]');
|
||||
const submitButton = form.querySelector('[data-submit-button]');
|
||||
|
||||
if (button) {
|
||||
button.disabled = true;
|
||||
button.setAttribute('aria-busy', 'true');
|
||||
const resetSubmitState = () => {
|
||||
form.removeAttribute('aria-busy');
|
||||
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') }}"
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -17,8 +17,8 @@
|
||||
@endif
|
||||
</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">
|
||||
{{ $settings->hero_title }}
|
||||
<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 ?: 'Celebrações com propósito' }}
|
||||
</h1>
|
||||
|
||||
@if (filled($settings->hero_subtitle))
|
||||
@@ -31,7 +31,7 @@
|
||||
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"
|
||||
>
|
||||
{{ $settings->hero_cta_label }}
|
||||
{{ $settings->hero_cta_label ?: 'Solicitar proposta' }}
|
||||
</a>
|
||||
|
||||
@if (filled($settings->hero_secondary_cta_label))
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
|
||||
<div class="grid gap-6 md:grid-cols-2">
|
||||
@foreach ($testimonials as $testimonial)
|
||||
@continue(blank(trim((string) $testimonial->quote)))
|
||||
@php
|
||||
$paragraphs = preg_split('/\n\s*\n/', trim((string) $testimonial->quote)) ?: [];
|
||||
$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
|
||||
@if ($siteSettings->email)
|
||||
<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>
|
||||
@endif
|
||||
@if ($siteSettings->phone)
|
||||
<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>
|
||||
@endif
|
||||
@foreach ($siteSettings->social_links ?? [] as $network => $url)
|
||||
@if (filled($url))
|
||||
<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>
|
||||
@endif
|
||||
@endforeach
|
||||
|
||||
@@ -15,18 +15,18 @@
|
||||
<p>{{ $siteSettings->city ?: 'São Paulo - SP' }}</p>
|
||||
@if ($siteSettings->email)
|
||||
<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>
|
||||
@endif
|
||||
@if ($siteSettings->phone)
|
||||
<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>
|
||||
@endif
|
||||
@foreach ($siteSettings->social_links ?? [] as $network => $url)
|
||||
@if (filled($url))
|
||||
<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>
|
||||
@endif
|
||||
@endforeach
|
||||
|
||||
@@ -5,10 +5,12 @@
|
||||
<div class="border-b border-amare-border bg-amare-bg">
|
||||
<div class="container-amare space-y-8 py-16 md:py-24">
|
||||
<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>
|
||||
@endif
|
||||
<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-sm text-amare-muted">
|
||||
<p class="text-sm break-words text-amare-muted">
|
||||
@if ($case->city){{ $case->city }}@endif
|
||||
@if ($case->venue) · {{ $case->venue }}@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] Mobile: sem overflow horizontal nas 7 rotas (390px); menu mobile abre/fecha + Escape + retorno de foco
|
||||
- [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="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);
|
||||
}
|
||||
|
||||
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