Compare commits
6 Commits
246410803d
...
fix/browse
| Author | SHA1 | Date | |
|---|---|---|---|
| a2a6dc86fb | |||
| 693e09a6cf | |||
| 2fe262ab59 | |||
| 018b43e24f | |||
| 9fac784f6a | |||
| 8a12b7d82c |
26
.github/workflows/ci.yml
vendored
26
.github/workflows/ci.yml
vendored
@@ -31,7 +31,8 @@ jobs:
|
||||
name: static
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- run: cp .env.example .env
|
||||
|
||||
- uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
@@ -39,7 +40,7 @@ jobs:
|
||||
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd
|
||||
coverage: none
|
||||
|
||||
- uses: actions/cache@v4
|
||||
- uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.composer/cache/files
|
||||
key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }}
|
||||
@@ -55,7 +56,8 @@ jobs:
|
||||
name: unit
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- run: cp .env.example .env
|
||||
|
||||
- uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
@@ -63,7 +65,7 @@ jobs:
|
||||
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd
|
||||
coverage: none
|
||||
|
||||
- uses: actions/cache@v4
|
||||
- uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.composer/cache/files
|
||||
key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }}
|
||||
@@ -92,7 +94,8 @@ jobs:
|
||||
--health-timeout 5s
|
||||
--health-retries 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- run: cp .env.example .env
|
||||
|
||||
- uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
@@ -100,13 +103,13 @@ jobs:
|
||||
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd
|
||||
coverage: none
|
||||
|
||||
- uses: actions/cache@v4
|
||||
- uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.composer/cache/files
|
||||
key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }}
|
||||
restore-keys: composer-${{ runner.os }}-
|
||||
|
||||
- uses: actions/cache@v4
|
||||
- uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
|
||||
@@ -136,7 +139,8 @@ jobs:
|
||||
--health-timeout 5s
|
||||
--health-retries 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- run: cp .env.example .env
|
||||
|
||||
- uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
@@ -144,13 +148,13 @@ jobs:
|
||||
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd
|
||||
coverage: none
|
||||
|
||||
- uses: actions/cache@v4
|
||||
- uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.composer/cache/files
|
||||
key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }}
|
||||
restore-keys: composer-${{ runner.os }}-
|
||||
|
||||
- uses: actions/cache@v4
|
||||
- uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
|
||||
@@ -224,7 +228,7 @@ jobs:
|
||||
name: container
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Build production image
|
||||
run: docker build -t amare-app:ci .
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -18,6 +18,7 @@
|
||||
/public/fonts-manifest.dev.json
|
||||
/public/hot
|
||||
/public/storage
|
||||
/tests/Browser/Screenshots
|
||||
/storage/*.key
|
||||
/storage/pail
|
||||
/vendor
|
||||
|
||||
@@ -15,6 +15,10 @@ This is a Laravel 13 application for an event-planning consultancy. Application
|
||||
|
||||
Feature and browser tests require the `amare_test` PostgreSQL database configured in `phpunit.xml`.
|
||||
|
||||
## Worktrees
|
||||
|
||||
Always work in a git worktree created from the `main` ref — never modify `main` directly and never commit from the primary working tree. Create a dedicated worktree per feature/branch with `git worktree add -b <branch> <path> main`. On finishing work, create a PR, watch CI until green, then merge it. Clean up the worktree with `git worktree remove` after merge.
|
||||
|
||||
## Git Hooks (husky)
|
||||
|
||||
Hooks live in `.husky/` and auto-install on any plain `npm install` via the `prepare` script. Note `composer setup` runs `npm install --ignore-scripts`, which skips hook installation — after setup, run `npm install` once (or `npx husky`) to activate hooks.
|
||||
|
||||
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 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -21,6 +21,8 @@ final readonly class PageMeta
|
||||
public ?string $ogImageUrl = null,
|
||||
public ?string $ogImageAlt = null,
|
||||
public ?array $jsonLd = null,
|
||||
public string $siteName = '',
|
||||
public string $robots = 'index, follow',
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -44,6 +46,7 @@ final readonly class PageMeta
|
||||
ogImageUrl: $ogImageUrl ?? self::defaultOgImageUrl($settings),
|
||||
ogImageAlt: $ogImageAlt ?? $settings->default_og_image_alt,
|
||||
jsonLd: $jsonLd,
|
||||
siteName: (string) $settings->brand_name,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -77,9 +80,53 @@ final readonly class PageMeta
|
||||
ogImageUrl: $ogImageUrl,
|
||||
ogImageAlt: $ogImageAlt,
|
||||
jsonLd: $jsonLd,
|
||||
siteName: (string) $settings->brand_name,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
public static function forErrorPage(
|
||||
SiteSetting $settings,
|
||||
int $status = 404,
|
||||
): self {
|
||||
[$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,
|
||||
description: $description,
|
||||
canonical: '',
|
||||
robots: 'noindex, nofollow',
|
||||
siteName: (string) $settings->brand_name,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Append the brand name to a page title when it is not already present.
|
||||
*/
|
||||
public static function withBrandSuffix(string $title, SiteSetting $settings): string
|
||||
{
|
||||
$brand = filled($settings->default_meta_title)
|
||||
? (string) $settings->default_meta_title
|
||||
: (string) $settings->brand_name;
|
||||
|
||||
if (str_contains($title, $brand)) {
|
||||
return $title;
|
||||
}
|
||||
|
||||
return trim($title).' - '.$brand;
|
||||
}
|
||||
|
||||
private static function defaultTitle(SiteSetting $settings): string
|
||||
{
|
||||
return filled($settings->default_meta_title)
|
||||
|
||||
116
app/Http/Controllers/PublicSite/ContactController.php
Normal file
116
app/Http/Controllers/PublicSite/ContactController.php
Normal file
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\PublicSite;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\PublicSite\ContactBriefingRequest;
|
||||
use App\Mail\ContactBriefing;
|
||||
use App\Mail\ContactBriefingConfirmation;
|
||||
use App\Models\SiteSetting;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Throwable;
|
||||
|
||||
final class ContactController extends Controller
|
||||
{
|
||||
private const string DUPLICATE_SESSION_KEY = 'contact_briefing_hash';
|
||||
|
||||
public function store(ContactBriefingRequest $request): RedirectResponse
|
||||
{
|
||||
if (filled($request->input('empresa'))) {
|
||||
return $this->success();
|
||||
}
|
||||
|
||||
$validated = $request->validated();
|
||||
unset($validated['privacidade']);
|
||||
|
||||
if ($this->isDuplicate($validated)) {
|
||||
return $this->success();
|
||||
}
|
||||
|
||||
$this->rememberSubmission($validated);
|
||||
|
||||
$settings = SiteSetting::instance();
|
||||
$fields = $this->buildFields($validated);
|
||||
|
||||
$this->dispatchEmails($settings, $validated['nome'], $validated['email'], $fields);
|
||||
|
||||
return $this->success();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $validated
|
||||
*/
|
||||
private function buildFields(array $validated): array
|
||||
{
|
||||
return [
|
||||
'Nome' => (string) $validated['nome'],
|
||||
'E-mail' => (string) $validated['email'],
|
||||
'Telefone/WhatsApp' => (string) $validated['telefone'],
|
||||
'Tipo de evento' => (string) $validated['tipo_evento'],
|
||||
'Data ou período desejado' => isset($validated['data_periodo']) ? (string) $validated['data_periodo'] : null,
|
||||
'Cidade' => (string) $validated['cidade'],
|
||||
'Número estimado de convidados' => isset($validated['convidados']) ? (string) $validated['convidados'] : null,
|
||||
'Serviço de interesse' => isset($validated['servico_interesse']) ? (string) $validated['servico_interesse'] : null,
|
||||
'Mensagem' => (string) $validated['mensagem'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $validated
|
||||
*/
|
||||
private function isDuplicate(array $validated): bool
|
||||
{
|
||||
$hash = $this->hash($validated);
|
||||
|
||||
return session()->get(self::DUPLICATE_SESSION_KEY) === $hash;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $validated
|
||||
*/
|
||||
private function rememberSubmission(array $validated): void
|
||||
{
|
||||
session()->put(self::DUPLICATE_SESSION_KEY, $this->hash($validated));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $validated
|
||||
*/
|
||||
private function hash(array $validated): string
|
||||
{
|
||||
return hash('sha256', serialize($validated));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $fields
|
||||
*/
|
||||
private function dispatchEmails(SiteSetting $settings, string $name, string $email, array $fields): void
|
||||
{
|
||||
$attempt = function () use ($settings, $name, $email, $fields): void {
|
||||
if (filled($settings->email)) {
|
||||
Mail::to($settings->email)->send(new ContactBriefing($fields));
|
||||
}
|
||||
|
||||
if (filled(config('mail.default'))) {
|
||||
Mail::to($email)->send(new ContactBriefingConfirmation($name, (string) $settings->brand_name));
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
$attempt();
|
||||
} catch (Throwable $exception) {
|
||||
Log::error('Falha ao enviar briefing de contato', [
|
||||
'exception' => $exception,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function success(): RedirectResponse
|
||||
{
|
||||
return redirect()->route('contact')->with('status', 'briefing-sent');
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ final class PageController extends Controller
|
||||
'pageMeta' => PageMeta::forPage(
|
||||
canonical: route('about'),
|
||||
settings: $settings,
|
||||
title: 'Sobre',
|
||||
title: PageMeta::withBrandSuffix('Sobre', $settings),
|
||||
description: $settings->about_summary ?: ('Conheça a '.$settings->brand_name.'.'),
|
||||
),
|
||||
]);
|
||||
@@ -35,7 +35,7 @@ final class PageController extends Controller
|
||||
'pageMeta' => PageMeta::forPage(
|
||||
canonical: route('privacy'),
|
||||
settings: $settings,
|
||||
title: 'Política de privacidade',
|
||||
title: PageMeta::withBrandSuffix('Política de privacidade', $settings),
|
||||
description: 'Política de privacidade da '.$settings->brand_name.'.',
|
||||
),
|
||||
]);
|
||||
@@ -50,7 +50,7 @@ final class PageController extends Controller
|
||||
'pageMeta' => PageMeta::forPage(
|
||||
canonical: route('contact'),
|
||||
settings: $settings,
|
||||
title: 'Contato',
|
||||
title: PageMeta::withBrandSuffix('Contato', $settings),
|
||||
description: 'Fale com a '.$settings->brand_name.'.',
|
||||
),
|
||||
]);
|
||||
|
||||
@@ -32,7 +32,7 @@ final class PortfolioController extends Controller
|
||||
'pageMeta' => PageMeta::forPage(
|
||||
canonical: route('portfolio.index'),
|
||||
settings: $settings,
|
||||
title: 'Portfólio',
|
||||
title: PageMeta::withBrandSuffix('Portfólio', $settings),
|
||||
description: 'Casos reais de eventos conduzidos pela '.$settings->brand_name.'.',
|
||||
),
|
||||
]);
|
||||
|
||||
@@ -23,7 +23,7 @@ final class ServiceController extends Controller
|
||||
'pageMeta' => PageMeta::forPage(
|
||||
canonical: route('services.index'),
|
||||
settings: $settings,
|
||||
title: 'Serviços',
|
||||
title: PageMeta::withBrandSuffix('Serviços', $settings),
|
||||
description: 'Conheça os serviços de assessoria de eventos da '.$settings->brand_name.'.',
|
||||
),
|
||||
]);
|
||||
|
||||
57
app/Http/Requests/PublicSite/ContactBriefingRequest.php
Normal file
57
app/Http/Requests/PublicSite/ContactBriefingRequest.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\PublicSite;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
final class ContactBriefingRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* @return array<string, array<int, string>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'nome' => ['required', 'string', 'max:120'],
|
||||
'email' => ['required', 'email', 'max:254'],
|
||||
'telefone' => ['required', 'string', 'max:40'],
|
||||
'tipo_evento' => ['required', 'string', 'max:80'],
|
||||
'data_periodo' => ['nullable', 'string', 'max:80'],
|
||||
'cidade' => ['required', 'string', 'max:80'],
|
||||
'convidados' => ['nullable', 'integer', 'min:1', 'max:100000'],
|
||||
'servico_interesse' => ['nullable', 'string', 'max:120'],
|
||||
'mensagem' => ['required', 'string', 'max:3000'],
|
||||
'privacidade' => ['accepted'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'nome.required' => 'Informe seu nome completo.',
|
||||
'nome.max' => 'O nome deve ter no máximo :max caracteres.',
|
||||
'email.required' => 'Informe seu e-mail.',
|
||||
'email.email' => 'Informe um e-mail válido.',
|
||||
'email.max' => 'O e-mail deve ter no máximo :max caracteres.',
|
||||
'telefone.required' => 'Informe um telefone ou WhatsApp.',
|
||||
'telefone.max' => 'O telefone deve ter no máximo :max caracteres.',
|
||||
'tipo_evento.required' => 'Selecione o tipo de evento.',
|
||||
'tipo_evento.max' => 'O tipo de evento deve ter no máximo :max caracteres.',
|
||||
'data_periodo.max' => 'A data ou período deve ter no máximo :max caracteres.',
|
||||
'cidade.required' => 'Informe a cidade do evento.',
|
||||
'cidade.max' => 'A cidade deve ter no máximo :max caracteres.',
|
||||
'convidados.integer' => 'Informe um número de convidados válido.',
|
||||
'convidados.min' => 'O número de convidados deve ser maior que zero.',
|
||||
'convidados.max' => 'O número de convidados informado é inválido.',
|
||||
'servico_interesse.max' => 'O serviço deve ter no máximo :max caracteres.',
|
||||
'mensagem.required' => 'Conte brevemente o que você precisa.',
|
||||
'mensagem.max' => 'A mensagem deve ter no máximo :max caracteres.',
|
||||
'privacidade.accepted' => 'Você precisa aceitar a política de privacidade.',
|
||||
];
|
||||
}
|
||||
}
|
||||
40
app/Mail/ContactBriefing.php
Normal file
40
app/Mail/ContactBriefing.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
final class ContactBriefing extends Mailable implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $fields
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly array $fields,
|
||||
) {}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(
|
||||
subject: 'Novo briefing de contato — Amare Assessoria',
|
||||
);
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(
|
||||
html: 'emails.contact-briefing',
|
||||
text: 'emails.contact-briefing-text',
|
||||
);
|
||||
}
|
||||
}
|
||||
38
app/Mail/ContactBriefingConfirmation.php
Normal file
38
app/Mail/ContactBriefingConfirmation.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
final class ContactBriefingConfirmation extends Mailable implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public readonly string $name,
|
||||
public readonly string $brandName,
|
||||
) {}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(
|
||||
subject: 'Recebemos sua mensagem — '.$this->brandName,
|
||||
);
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(
|
||||
html: 'emails.contact-briefing-confirmation',
|
||||
text: 'emails.contact-briefing-confirmation-text',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,9 @@ namespace App\Providers;
|
||||
use App\Application\Data\PageMeta;
|
||||
use App\Models\SiteSetting;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Cache\RateLimiting\Limit;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\Facades\View;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Illuminate\View\View as ViewInstance;
|
||||
@@ -28,6 +31,7 @@ class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
$this->configureLivewireTemporaryUploads();
|
||||
$this->freezeClockWhenConfigured();
|
||||
$this->configureRateLimiters();
|
||||
|
||||
View::composer('layouts.public', function (ViewInstance $view): void {
|
||||
$settings = $view->offsetExists('siteSettings')
|
||||
@@ -63,6 +67,13 @@ class AppServiceProvider extends ServiceProvider
|
||||
config(['livewire.temporary_file_upload.disk' => 'local']);
|
||||
}
|
||||
|
||||
private function configureRateLimiters(): void
|
||||
{
|
||||
RateLimiter::for('contact-briefing', function (Request $request): Limit {
|
||||
return Limit::perMinute(5)->by($request->ip().'|contact-briefing');
|
||||
});
|
||||
}
|
||||
|
||||
private function freezeClockWhenConfigured(): void
|
||||
{
|
||||
if ($this->app->environment('production')) {
|
||||
|
||||
262
frontend-audit.md
Normal file
262
frontend-audit.md
Normal file
@@ -0,0 +1,262 @@
|
||||
Audite e melhore integralmente este projeto do site da **Amare**, incluindo todas as rotas públicas, componentes compartilhados, formulários, navegação, conteúdo, metadados e comportamento responsivo.
|
||||
|
||||
Você está autorizado a executar o projeto, inspecionar o repositório e modificar diretamente o código. Não entregue somente recomendações: implemente as correções necessárias e valide o resultado.
|
||||
|
||||
## Objetivo
|
||||
|
||||
Deixar o site tecnicamente sólido, visualmente refinado e pronto para apresentação à cliente e posterior lançamento, corrigindo problemas de:
|
||||
|
||||
* UI e UX;
|
||||
* responsividade;
|
||||
* posicionamento e conteúdo;
|
||||
* acessibilidade;
|
||||
* conversão;
|
||||
* SEO;
|
||||
* performance;
|
||||
* robustez;
|
||||
* qualidade e manutenção do código.
|
||||
|
||||
## Contexto do produto
|
||||
|
||||
A Amare é uma empresa de assessoria, produção e organização de eventos em São Paulo.
|
||||
|
||||
Ela atende:
|
||||
|
||||
* casamentos;
|
||||
* eventos sociais e celebrações particulares;
|
||||
* eventos corporativos.
|
||||
|
||||
O site não deve transmitir que a empresa trabalha exclusivamente com casamentos.
|
||||
|
||||
A experiência precisa equilibrar:
|
||||
|
||||
* emoção, proximidade, sensibilidade e sofisticação para eventos sociais;
|
||||
* organização, segurança, método, clareza e credibilidade para eventos corporativos.
|
||||
|
||||
O resultado deve ser editorial, contemporâneo, humano, elegante e profissional, sem parecer excessivamente romântico nem corporativo demais.
|
||||
|
||||
Preview de referência:
|
||||
|
||||
`https://amare.preview.hellomanoel.com/`
|
||||
|
||||
Considere os documentos existentes no repositório, especialmente `AGENTS.md`, `README.md`, `SPEC.md`, `DESIGN.md` e equivalentes, como contexto autoritativo do projeto.
|
||||
|
||||
## Direção visual
|
||||
|
||||
Preserve a identidade visual existente quando ela funcionar corretamente:
|
||||
|
||||
* fundos off-white ou bege claro;
|
||||
* verde oliva;
|
||||
* composição editorial;
|
||||
* fotografias em destaque;
|
||||
* espaços em branco generosos;
|
||||
* EB Garamond em títulos e destaques;
|
||||
* elementos minimalistas;
|
||||
* animações discretas;
|
||||
* aparência refinada e atemporal.
|
||||
|
||||
Não preserve decisões que prejudiquem usabilidade, contraste, legibilidade, acessibilidade, responsividade ou performance.
|
||||
|
||||
Avalie especialmente:
|
||||
|
||||
* uso excessivo de Garamond em textos pequenos, menus, botões e formulários;
|
||||
* verdes claros com contraste insuficiente;
|
||||
* CTAs discretos demais;
|
||||
* espaços vazios excessivos no celular;
|
||||
* imagens que reforcem somente o posicionamento de casamento;
|
||||
* falta de equilíbrio entre conteúdo social e corporativo;
|
||||
* inconsistência tipográfica ou de espaçamento;
|
||||
* aparência de template genérico de casamento.
|
||||
|
||||
## Escopo da auditoria
|
||||
|
||||
Analise todas as rotas encontradas no código e corrija problemas relacionados a:
|
||||
|
||||
### Produto e conteúdo
|
||||
|
||||
* clareza da proposta de valor;
|
||||
* equilíbrio entre eventos sociais e corporativos;
|
||||
* hierarquia das informações;
|
||||
* conteúdo genérico, redundante ou sem função;
|
||||
* coerência entre títulos, textos, imagens e CTAs;
|
||||
* clareza dos serviços;
|
||||
* confiança e credibilidade;
|
||||
* caminho até contato ou solicitação de proposta.
|
||||
|
||||
Não invente história, números, clientes, prêmios, depoimentos, equipe, serviços, telefone, e-mail ou qualquer informação não confirmada.
|
||||
|
||||
### UI e experiência
|
||||
|
||||
* navegação;
|
||||
* header e footer;
|
||||
* menu mobile;
|
||||
* hierarquia visual;
|
||||
* tipografia;
|
||||
* espaçamento;
|
||||
* grids;
|
||||
* CTAs;
|
||||
* formulários;
|
||||
* estados interativos;
|
||||
* consistência entre páginas;
|
||||
* experiência mobile-first;
|
||||
* ausência de overflow, cortes, sobreposições ou distorções;
|
||||
* adaptação real do layout ao celular, e não apenas redução da versão desktop.
|
||||
|
||||
### Acessibilidade
|
||||
|
||||
Use WCAG 2.2 AA como referência prática.
|
||||
|
||||
Corrija problemas de:
|
||||
|
||||
* HTML semântico;
|
||||
* hierarquia de headings;
|
||||
* navegação por teclado;
|
||||
* foco visível;
|
||||
* contraste;
|
||||
* nomes acessíveis;
|
||||
* labels;
|
||||
* mensagens de erro;
|
||||
* áreas de toque;
|
||||
* textos alternativos;
|
||||
* menu mobile;
|
||||
* overlays;
|
||||
* preferência por redução de movimento;
|
||||
* uso correto de links e botões.
|
||||
|
||||
Prefira HTML semântico a ARIA desnecessária.
|
||||
|
||||
### Formulários e conversão
|
||||
|
||||
Garanta, quando aplicável:
|
||||
|
||||
* campos e obrigatoriedade claros;
|
||||
* validação adequada;
|
||||
* mensagens de erro específicas;
|
||||
* estado de envio;
|
||||
* prevenção de envio duplicado;
|
||||
* estados de sucesso e falha;
|
||||
* tratamento de erro de rede;
|
||||
* preservação dos dados após erros recuperáveis;
|
||||
* proteção antispam simples;
|
||||
* privacidade e LGPD;
|
||||
* links e mensagens de WhatsApp corretos;
|
||||
* ausência de segredos expostos no cliente.
|
||||
|
||||
Quando algum dado real não estiver disponível, use configuração ou variável de ambiente e documente a pendência.
|
||||
|
||||
### SEO
|
||||
|
||||
Corrija, quando aplicável:
|
||||
|
||||
* títulos exclusivos por rota;
|
||||
* meta descriptions;
|
||||
* canonical;
|
||||
* Open Graph;
|
||||
* Twitter cards;
|
||||
* sitemap;
|
||||
* robots;
|
||||
* favicon;
|
||||
* idioma;
|
||||
* headings;
|
||||
* URLs;
|
||||
* links internos;
|
||||
* página 404;
|
||||
* metadados sociais;
|
||||
* indexação distinta entre preview e produção.
|
||||
|
||||
Não crie dados estruturados com informações não confirmadas.
|
||||
|
||||
### Performance
|
||||
|
||||
Corrija problemas relevantes relacionados a:
|
||||
|
||||
* imagens;
|
||||
* tamanhos responsivos;
|
||||
* formatos modernos;
|
||||
* lazy loading;
|
||||
* LCP;
|
||||
* CLS;
|
||||
* INP;
|
||||
* fontes;
|
||||
* scripts desnecessários;
|
||||
* hidratação excessiva;
|
||||
* JavaScript evitável;
|
||||
* componentes client-side sem necessidade;
|
||||
* animações custosas;
|
||||
* dependências pesadas;
|
||||
* carregamento de conteúdo abaixo da dobra.
|
||||
|
||||
Preserve a qualidade visual das fotografias.
|
||||
|
||||
### Qualidade técnica
|
||||
|
||||
Corrija:
|
||||
|
||||
* erros de TypeScript;
|
||||
* erros de lint;
|
||||
* erros de build;
|
||||
* warnings de hidratação;
|
||||
* erros de console;
|
||||
* requisições quebradas;
|
||||
* links inválidos;
|
||||
* rotas órfãs;
|
||||
* componentes duplicados quando a consolidação simplificar o projeto;
|
||||
* tratamento de erros ausente;
|
||||
* tipagem insegura;
|
||||
* complexidade desnecessária diretamente relacionada ao escopo.
|
||||
|
||||
## Prioridades
|
||||
|
||||
Considere:
|
||||
|
||||
* **P0:** bloqueia funcionamento, segurança, uso ou lançamento;
|
||||
* **P1:** prejudica significativamente experiência, conversão, acessibilidade, SEO, performance ou credibilidade;
|
||||
* **P2:** refinamento não essencial para o lançamento.
|
||||
|
||||
Implemente todos os itens P0 e P1 que possam ser resolvidos com as informações existentes.
|
||||
|
||||
Itens dependentes de dados reais da cliente devem permanecer como pendências explícitas, sem conteúdo fictício.
|
||||
|
||||
## Restrições
|
||||
|
||||
* Preserve a stack e a arquitetura existentes quando forem adequadas.
|
||||
* Prefira mudanças simples, localizadas e fáceis de manter.
|
||||
* Não reescreva o projeto sem necessidade concreta.
|
||||
* Não adicione bibliotecas quando a solução atual for suficiente.
|
||||
* Não implemente pagamentos, CRM, área do cliente, chat, contratos avançados ou funcionalidades fora do MVP.
|
||||
* Não altere arquivos não relacionados sem justificativa.
|
||||
* Preserve alterações locais preexistentes.
|
||||
* Não silencie problemas com `any`, `eslint-disable`, casts inseguros ou desativação de validações.
|
||||
* Não reduza acessibilidade para preservar estética.
|
||||
* Não deixe mocks ou soluções temporárias como implementação final.
|
||||
|
||||
## Critérios de conclusão
|
||||
|
||||
A tarefa estará concluída quando:
|
||||
|
||||
* todas as rotas públicas existentes tiverem sido auditadas;
|
||||
* todos os problemas P0 e P1 solucionáveis tiverem sido corrigidos;
|
||||
* o site funcionar corretamente em mobile e desktop;
|
||||
* não houver overflow horizontal, sobreposição ou componentes quebrados;
|
||||
* menu, navegação, links, CTAs e formulários funcionarem;
|
||||
* o posicionamento social e corporativo estiver claro;
|
||||
* a acessibilidade essencial estiver atendida;
|
||||
* os metadados e fundamentos de SEO estiverem corretos;
|
||||
* os principais problemas de performance tiverem sido tratados;
|
||||
* erros causados ou revelados pelas alterações tiverem sido resolvidos;
|
||||
* lint, TypeScript, testes e build disponíveis tiverem sido executados com sucesso.
|
||||
|
||||
Não declare uma validação como aprovada sem executá-la.
|
||||
|
||||
## Entrega final
|
||||
|
||||
Ao concluir, apresente:
|
||||
|
||||
1. rotas auditadas;
|
||||
2. problemas principais encontrados;
|
||||
3. alterações implementadas;
|
||||
4. arquivos modificados;
|
||||
5. comandos executados e seus resultados;
|
||||
6. decisões e trade-offs relevantes;
|
||||
7. informações que dependem da cliente;
|
||||
8. itens P2 mantidos fora do escopo.
|
||||
@@ -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
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 0 B After Width: | Height: | Size: 4.2 KiB |
4
public/favicon.svg
Normal file
4
public/favicon.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<rect width="64" height="64" fill="#556B2F"/>
|
||||
<text x="32" y="45" font-family="Georgia, 'Times New Roman', serif" font-size="38" font-weight="600" fill="#FBF9F4" text-anchor="middle">A</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 264 B |
@@ -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'] {
|
||||
@@ -88,6 +89,14 @@
|
||||
filter: saturate(0.88) contrast(0.96);
|
||||
}
|
||||
|
||||
@utility honeypot {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.main-nav.is-open {
|
||||
display: flex;
|
||||
@@ -150,6 +159,9 @@
|
||||
|
||||
[data-chapter-index] a {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 2.75rem;
|
||||
padding-left: 0.75rem;
|
||||
font-size: var(--amare-text-xs);
|
||||
font-weight: 600;
|
||||
@@ -157,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);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,39 +4,105 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const menuButton = document.querySelector('[data-menu-button]');
|
||||
const navigation = document.querySelector('[data-main-nav]');
|
||||
|
||||
if (!menuButton || !navigation) {
|
||||
return;
|
||||
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);
|
||||
navigation.classList.toggle('hidden', !isOpen && !isDesktop());
|
||||
navigation.classList.toggle('flex', isOpen || isDesktop());
|
||||
document.body.classList.toggle('menu-open', isOpen);
|
||||
menuButton.setAttribute('aria-expanded', String(isOpen));
|
||||
menuButton.setAttribute('aria-label', isOpen ? 'Fechar menu' : 'Abrir menu');
|
||||
|
||||
if (!isOpen && returnFocus) {
|
||||
menuButton.focus();
|
||||
}
|
||||
};
|
||||
|
||||
menuButton.addEventListener('click', () => {
|
||||
const isOpen = menuButton.getAttribute('aria-expanded') !== 'true';
|
||||
setOpen(isOpen);
|
||||
|
||||
if (isOpen) {
|
||||
const firstLink = navigation.querySelector('a');
|
||||
if (firstLink) {
|
||||
firstLink.focus();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
navigation.querySelectorAll('a').forEach((link) => {
|
||||
link.addEventListener('click', () => setOpen(false));
|
||||
});
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
window.matchMedia('(min-width: 768px)').addEventListener('change', (event) => {
|
||||
setOpen(false);
|
||||
|
||||
if (event.matches) {
|
||||
navigation.classList.remove('hidden');
|
||||
navigation.classList.add('flex');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const setOpen = (isOpen) => {
|
||||
navigation.classList.toggle('is-open', isOpen);
|
||||
navigation.classList.toggle('hidden', !isOpen && !window.matchMedia('(min-width: 768px)').matches);
|
||||
navigation.classList.toggle('flex', isOpen || window.matchMedia('(min-width: 768px)').matches);
|
||||
document.body.classList.toggle('menu-open', isOpen);
|
||||
menuButton.setAttribute('aria-expanded', String(isOpen));
|
||||
menuButton.setAttribute('aria-label', isOpen ? 'Fechar menu' : 'Abrir menu');
|
||||
};
|
||||
document.querySelectorAll('form[data-contact-form]').forEach((form) => {
|
||||
const submitButton = form.querySelector('[data-submit-button]');
|
||||
|
||||
menuButton.addEventListener('click', () => {
|
||||
const isOpen = menuButton.getAttribute('aria-expanded') !== 'true';
|
||||
setOpen(isOpen);
|
||||
});
|
||||
const resetSubmitState = () => {
|
||||
form.removeAttribute('aria-busy');
|
||||
form.removeAttribute('aria-disabled');
|
||||
|
||||
navigation.querySelectorAll('a').forEach((link) => {
|
||||
link.addEventListener('click', () => setOpen(false));
|
||||
});
|
||||
if (submitButton) {
|
||||
submitButton.disabled = false;
|
||||
submitButton.textContent = submitButton.dataset.defaultLabel ?? 'Enviar briefing';
|
||||
submitButton.removeAttribute('aria-busy');
|
||||
}
|
||||
};
|
||||
|
||||
window.matchMedia('(min-width: 768px)').addEventListener('change', (event) => {
|
||||
if (event.matches) {
|
||||
setOpen(false);
|
||||
navigation.classList.remove('hidden');
|
||||
navigation.classList.add('flex');
|
||||
} else {
|
||||
navigation.classList.remove('is-open', 'flex');
|
||||
navigation.classList.add('hidden');
|
||||
document.body.classList.remove('menu-open');
|
||||
menuButton.setAttribute('aria-expanded', 'false');
|
||||
menuButton.setAttribute('aria-label', 'Abrir menu');
|
||||
}
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,7 +19,6 @@
|
||||
$variant = $variant === 'on-dark' ? 'on-dark' : 'on-light';
|
||||
$kind = $mark ? 'mark' : 'lockup';
|
||||
$staticSrc = asset("brand/{$kind}-{$variant}.webp");
|
||||
$staticFallback = asset("brand/{$kind}-{$variant}.png");
|
||||
|
||||
$src = filled($uploadedPath)
|
||||
? \Illuminate\Support\Facades\Storage::disk('public')->url($uploadedPath)
|
||||
@@ -34,8 +33,4 @@
|
||||
'decoding' => 'async',
|
||||
'loading' => 'eager',
|
||||
]) }}
|
||||
@if (! filled($uploadedPath))
|
||||
data-brand-fallback="{{ $staticFallback }}"
|
||||
@endif
|
||||
data-brand-variant="{{ $variant }}"
|
||||
/>
|
||||
|
||||
@@ -6,7 +6,11 @@
|
||||
|
||||
$manifestFile = is_file($manifestPath) ? $manifestPath : $hotManifestPath;
|
||||
$manifest = is_string($manifestFile) && is_file($manifestFile)
|
||||
? json_decode((string) file_get_contents($manifestFile), true)
|
||||
? \Illuminate\Support\Facades\Cache::remember(
|
||||
'fonts-manifest:'.md5($manifestFile.':'.(string) filemtime($manifestFile)),
|
||||
3600,
|
||||
static fn () => json_decode((string) file_get_contents($manifestFile), true) ?: null,
|
||||
)
|
||||
: null;
|
||||
|
||||
$cssFile = is_array($manifest) ? ($manifest['style']['file'] ?? null) : null;
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
@props([
|
||||
'cases',
|
||||
])
|
||||
|
||||
@if ($cases->isNotEmpty())
|
||||
<section aria-labelledby="cases-heading" class="border-b border-amare-border py-16">
|
||||
<div class="container-amare space-y-8">
|
||||
<div class="max-w-2xl space-y-3">
|
||||
<h2 id="cases-heading" class="text-3xl font-semibold text-amare-text">Casos selecionados</h2>
|
||||
<p class="text-amare-text-muted">Histórias recentes de celebrações conduzidas pela Amare.</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-8">
|
||||
@foreach ($cases as $case)
|
||||
<article class="grid gap-4 border-t border-amare-border pt-6 md:grid-cols-[200px_minmax(0,1fr)]">
|
||||
@if (filled($case->cover_image_path))
|
||||
<x-media.image
|
||||
:path="$case->cover_image_path"
|
||||
:alt="$case->cover_image_alt ?: $case->title"
|
||||
sizes="200px"
|
||||
class="aspect-square w-full object-cover"
|
||||
/>
|
||||
@endif
|
||||
<div class="space-y-2">
|
||||
<h3 class="text-2xl font-semibold text-amare-text">{{ $case->title }}</h3>
|
||||
<p class="text-sm text-amare-text-muted">{{ $case->event_type }}@if($case->city) · {{ $case->city }}@endif</p>
|
||||
<p class="text-amare-text-muted">{{ $case->summary }}</p>
|
||||
<a href="{{ url('/portfolio/'.$case->slug) }}" class="inline-flex text-sm font-semibold text-amare-accent hover:text-amare-accent-hover">
|
||||
Ver caso
|
||||
</a>
|
||||
</div>
|
||||
</article>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@endif
|
||||
@@ -5,16 +5,16 @@
|
||||
<section aria-labelledby="final-cta-heading" class="border-t border-amare-border bg-amare-bg-deep py-20" data-chapter="final-cta">
|
||||
<div class="container-amare space-y-6 text-center" data-reveal>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Próximo passo</p>
|
||||
<h2 id="final-cta-heading" class="text-3xl font-medium text-amare-text md:text-4xl">Todo grande encontro começa com uma boa conversa.</h2>
|
||||
<h2 id="final-cta-heading" class="text-3xl font-medium text-amare-text md:text-4xl">Do casamento ao evento corporativo, tudo começa com uma boa conversa.</h2>
|
||||
<p class="mx-auto max-w-2xl text-amare-muted">
|
||||
Compartilhe as primeiras informações do seu evento. A Amare retorna para entender o contexto e orientar os próximos passos.
|
||||
</p>
|
||||
<div>
|
||||
<a
|
||||
href="{{ route('contact') }}"
|
||||
class="inline-flex 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>
|
||||
</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))
|
||||
@@ -29,17 +29,17 @@
|
||||
<a
|
||||
href="{{ route('contact') }}"
|
||||
data-testid="home-primary-cta"
|
||||
class="inline-flex 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>
|
||||
|
||||
@if (filled($settings->hero_secondary_cta_label))
|
||||
<a
|
||||
href="{{ route('portfolio.index') }}"
|
||||
class="inline-flex items-center border-b border-amare-accent pb-1 text-sm font-semibold text-amare-accent transition-colors hover:text-amare-accent-deep"
|
||||
class="inline-flex min-h-11 items-center text-sm font-semibold text-amare-accent transition-colors hover:text-amare-accent-deep"
|
||||
>
|
||||
{{ $settings->hero_secondary_cta_label }}
|
||||
<span class="border-b border-amare-accent pb-1">{{ $settings->hero_secondary_cta_label }}</span>
|
||||
</a>
|
||||
@endif
|
||||
</div>
|
||||
@@ -55,6 +55,7 @@
|
||||
:path="$settings->default_og_image_path"
|
||||
:alt="$settings->default_og_image_alt ?: $settings->brand_name"
|
||||
loading="eager"
|
||||
fetchpriority="high"
|
||||
sizes="(max-width: 768px) 100vw, 40vw"
|
||||
class="img-editorial h-full w-full object-cover"
|
||||
/>
|
||||
|
||||
@@ -27,8 +27,8 @@
|
||||
<div class="space-y-2">
|
||||
<h3 class="text-2xl font-medium">{{ $case->title }}</h3>
|
||||
<p class="text-amare-accent-text/80">{{ $case->summary }}</p>
|
||||
<a href="{{ route('portfolio.show', $case->slug) }}" class="inline-flex border-b border-amare-accent-text pb-1 text-sm font-semibold transition-colors hover:text-amare-accent-text">
|
||||
Ver caso
|
||||
<a href="{{ route('portfolio.show', $case->slug) }}" class="inline-flex min-h-11 items-center text-sm font-semibold transition-colors hover:text-amare-accent-text">
|
||||
<span class="border-b border-amare-accent-text pb-1">Ver caso</span>
|
||||
</a>
|
||||
</div>
|
||||
</article>
|
||||
@@ -39,8 +39,8 @@
|
||||
<p class="max-w-2xl text-sm text-amare-accent-text/75">
|
||||
Imagens demonstrativas enquanto o acervo autorizado da Amare está em organização.
|
||||
</p>
|
||||
<a href="{{ route('portfolio.index') }}" class="border-b border-amare-accent-text pb-1 text-sm font-semibold transition-colors hover:text-amare-accent-text">
|
||||
Conhecer o portfólio
|
||||
<a href="{{ route('portfolio.index') }}" class="inline-flex min-h-11 items-center text-sm font-semibold transition-colors hover:text-amare-accent-text">
|
||||
<span class="border-b border-amare-accent-text pb-1">Conhecer o portfólio</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -21,8 +21,8 @@
|
||||
@endforeach
|
||||
</ul>
|
||||
<p>
|
||||
<a href="{{ route('about') }}" class="border-b border-amare-accent pb-1 text-sm font-semibold text-amare-accent transition-colors hover:text-amare-accent-deep">
|
||||
Conhecer a Amare
|
||||
<a href="{{ route('about') }}" class="inline-flex min-h-11 items-center text-sm font-semibold text-amare-accent transition-colors hover:text-amare-accent-deep">
|
||||
<span class="border-b border-amare-accent pb-1">Conhecer a Amare</span>
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
@props([
|
||||
'cases',
|
||||
])
|
||||
|
||||
@if ($cases->isNotEmpty())
|
||||
<section aria-labelledby="proof-heading" class="border-b border-amare-border bg-amare-bg-muted py-16">
|
||||
<div class="container-amare space-y-8">
|
||||
<div class="max-w-2xl space-y-3">
|
||||
<h2 id="proof-heading" class="text-3xl font-semibold text-amare-text">Prova visual</h2>
|
||||
<p class="text-amare-text-muted">Eventos em destaque que mostram o cuidado com cada celebração.</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
@foreach ($cases as $case)
|
||||
<article class="space-y-3">
|
||||
@if (filled($case->cover_image_path))
|
||||
<x-media.image
|
||||
:path="$case->cover_image_path"
|
||||
:alt="$case->cover_image_alt ?: $case->title"
|
||||
sizes="(max-width: 768px) 100vw, 33vw"
|
||||
class="aspect-[4/3] w-full object-cover"
|
||||
/>
|
||||
@endif
|
||||
<h3 class="text-xl font-semibold text-amare-text">{{ $case->title }}</h3>
|
||||
<p class="text-sm text-amare-text-muted">{{ $case->summary }}</p>
|
||||
</article>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@endif
|
||||
@@ -22,8 +22,8 @@
|
||||
</ol>
|
||||
|
||||
<p>
|
||||
<a href="{{ route('services.index') }}" class="border-b border-amare-accent pb-1 text-sm font-semibold text-amare-accent transition-colors hover:text-amare-accent-deep">
|
||||
Ver todos os serviços
|
||||
<a href="{{ route('services.index') }}" class="inline-flex min-h-11 items-center text-sm font-semibold text-amare-accent transition-colors hover:text-amare-accent-deep">
|
||||
<span class="border-b border-amare-accent pb-1">Ver todos os serviços</span>
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -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 !== ''));
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
'alt',
|
||||
'sizes' => '(max-width: 768px) 100vw, 960px',
|
||||
'loading' => 'lazy',
|
||||
'fetchpriority' => null,
|
||||
'width' => null,
|
||||
'height' => null,
|
||||
'disk' => null,
|
||||
@@ -42,6 +43,7 @@
|
||||
@if ($resolvedWidth) width="{{ $resolvedWidth }}" @endif
|
||||
@if ($resolvedHeight) height="{{ $resolvedHeight }}" @endif
|
||||
loading="{{ $loadingValue }}"
|
||||
@if ($fetchpriority) fetchpriority="{{ $fetchpriority }}" @endif
|
||||
@if ($class) class="{{ $class }}" @endif
|
||||
{{ $attributes->except(['path', 'alt', 'sizes', 'loading', 'width', 'height', 'disk', 'class']) }}
|
||||
>
|
||||
|
||||
@@ -3,12 +3,18 @@
|
||||
])
|
||||
|
||||
<meta name="description" content="{{ $pageMeta->description }}">
|
||||
<link rel="canonical" href="{{ $pageMeta->canonical }}">
|
||||
@if (filled($pageMeta->canonical))
|
||||
<link rel="canonical" href="{{ $pageMeta->canonical }}">
|
||||
@endif
|
||||
|
||||
<meta property="og:locale" content="pt_BR">
|
||||
<meta property="og:site_name" content="{{ $pageMeta->siteName }}">
|
||||
<meta property="og:title" content="{{ $pageMeta->title }}">
|
||||
<meta property="og:description" content="{{ $pageMeta->description }}">
|
||||
<meta property="og:type" content="{{ $pageMeta->ogType }}">
|
||||
<meta property="og:url" content="{{ $pageMeta->canonical }}">
|
||||
@if (filled($pageMeta->canonical))
|
||||
<meta property="og:url" content="{{ $pageMeta->canonical }}">
|
||||
@endif
|
||||
@if ($pageMeta->ogImageUrl)
|
||||
<meta property="og:image" content="{{ $pageMeta->ogImageUrl }}">
|
||||
@if ($pageMeta->ogImageAlt)
|
||||
@@ -16,6 +22,19 @@
|
||||
@endif
|
||||
@endif
|
||||
|
||||
@if ($pageMeta->jsonLd)
|
||||
<script type="application/ld+json">{!! json_encode($pageMeta->jsonLd, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) !!}</script>
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="{{ $pageMeta->title }}">
|
||||
<meta name="twitter:description" content="{{ $pageMeta->description }}">
|
||||
@if ($pageMeta->ogImageUrl)
|
||||
<meta name="twitter:image" content="{{ $pageMeta->ogImageUrl }}">
|
||||
@endif
|
||||
|
||||
@if (app()->environment('production'))
|
||||
<meta name="robots" content="{{ $pageMeta->robots }}">
|
||||
@else
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
@endif
|
||||
|
||||
@if ($pageMeta->jsonLd)
|
||||
<script type="application/ld+json">{!! json_encode($pageMeta->jsonLd, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) !!}</script>
|
||||
@endif
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
Olá, {{ $name }}.
|
||||
|
||||
Recebemos sua mensagem e retornaremos em breve pelo canal informado.
|
||||
|
||||
{{ $brandName }}
|
||||
@@ -0,0 +1,25 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Recebemos sua mensagem</title>
|
||||
<style>
|
||||
body { margin: 0; padding: 24px; background: #FBF9F4; font-family: Georgia, 'Times New Roman', serif; color: #1B1C19; }
|
||||
.wrap { max-width: 600px; margin: 0 auto; }
|
||||
p { font-size: 15px; line-height: 1.6; }
|
||||
.signature { margin-top: 24px; color: #5D6155; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<p>Olá, {{ $name }}.</p>
|
||||
<p>
|
||||
Recebemos sua mensagem e retornaremos em breve pelo canal informado.
|
||||
</p>
|
||||
<p class="signature">
|
||||
{{ $brandName }}
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
7
resources/views/emails/contact-briefing-text.blade.php
Normal file
7
resources/views/emails/contact-briefing-text.blade.php
Normal file
@@ -0,0 +1,7 @@
|
||||
Novo briefing de contato
|
||||
|
||||
@foreach ($fields as $label => $value)
|
||||
{{ $label }}: {{ $value ?: '—' }}
|
||||
@endforeach
|
||||
|
||||
Enviado pelo site {{ config('app.name') }}.
|
||||
33
resources/views/emails/contact-briefing.blade.php
Normal file
33
resources/views/emails/contact-briefing.blade.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Novo briefing de contato</title>
|
||||
<style>
|
||||
body { margin: 0; padding: 24px; background: #FBF9F4; font-family: Georgia, 'Times New Roman', serif; color: #1B1C19; }
|
||||
.wrap { max-width: 600px; margin: 0 auto; }
|
||||
h1 { font-size: 20px; font-weight: 600; margin: 0 0 16px; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th, td { text-align: left; vertical-align: top; padding: 10px 12px; border-bottom: 1px solid #C5C8B8; font-size: 14px; }
|
||||
th { width: 40%; font-weight: 600; color: #556B2F; }
|
||||
.footer { margin-top: 16px; font-size: 12px; color: #5D6155; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<h1>Novo briefing de contato</h1>
|
||||
<table>
|
||||
@foreach ($fields as $label => $value)
|
||||
<tr>
|
||||
<th scope="row">{{ $label }}</th>
|
||||
<td>{{ $value ?: '—' }}</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</table>
|
||||
<p class="footer">
|
||||
Enviado pelo site {{ config('app.name') }}.
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,3 +1,17 @@
|
||||
@php
|
||||
try {
|
||||
$errorSettings = \App\Models\SiteSetting::instance();
|
||||
$pageMeta = \App\Application\Data\PageMeta::forErrorPage($errorSettings, 404);
|
||||
} catch (\Throwable $e) {
|
||||
$pageMeta = new \App\Application\Data\PageMeta(
|
||||
title: 'Página não encontrada - Amare Assessoria',
|
||||
description: 'A página que você procura não existe ou foi movida.',
|
||||
canonical: '',
|
||||
robots: 'noindex, nofollow',
|
||||
);
|
||||
}
|
||||
@endphp
|
||||
|
||||
@extends('layouts.public')
|
||||
|
||||
@section('content')
|
||||
@@ -6,7 +20,7 @@
|
||||
<h1 class="text-4xl font-medium tracking-tight text-amare-text">Página não encontrada</h1>
|
||||
<p class="text-amare-muted">O endereço que você tentou abrir não existe ou foi movido.</p>
|
||||
<p>
|
||||
<a href="{{ route('home') }}" class="inline-flex 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">
|
||||
<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>
|
||||
|
||||
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
|
||||
@@ -1,3 +1,17 @@
|
||||
@php
|
||||
try {
|
||||
$errorSettings = \App\Models\SiteSetting::instance();
|
||||
$pageMeta = \App\Application\Data\PageMeta::forErrorPage($errorSettings, 500);
|
||||
} catch (\Throwable $e) {
|
||||
$pageMeta = new \App\Application\Data\PageMeta(
|
||||
title: 'Algo deu errado - Amare Assessoria',
|
||||
description: 'Não foi possível concluir o pedido. Tente novamente em instantes.',
|
||||
canonical: '',
|
||||
robots: 'noindex, nofollow',
|
||||
);
|
||||
}
|
||||
@endphp
|
||||
|
||||
@extends('layouts.public')
|
||||
|
||||
@section('content')
|
||||
@@ -6,7 +20,7 @@
|
||||
<h1 class="text-4xl font-medium tracking-tight text-amare-text">Algo deu errado</h1>
|
||||
<p class="text-amare-muted">Não foi possível concluir o pedido agora. Tente novamente em instantes.</p>
|
||||
<p>
|
||||
<a href="{{ route('home') }}" class="inline-flex 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">
|
||||
<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>
|
||||
|
||||
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
|
||||
@@ -4,12 +4,33 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="light">
|
||||
<meta name="theme-color" content="#FBF9F4">
|
||||
<link rel="icon" href="{{ asset('favicon.svg') }}" type="image/svg+xml">
|
||||
|
||||
<title>{{ $pageMeta->title }}</title>
|
||||
<x-seo.meta :page-meta="$pageMeta" />
|
||||
|
||||
<x-fonts />
|
||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||
|
||||
<noscript>
|
||||
<style>
|
||||
.main-nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.main-nav {
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
|
||||
.menu-button {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
</noscript>
|
||||
</head>
|
||||
<body class="min-h-screen bg-amare-bg font-serif text-amare-text antialiased">
|
||||
<a href="#conteudo" class="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:z-50 focus:bg-amare-accent focus:px-4 focus:py-2 focus:text-amare-accent-text">
|
||||
@@ -18,12 +39,12 @@
|
||||
|
||||
<header class="site-header sticky top-0 z-40 border-b border-amare-border/80 bg-amare-bg/90 backdrop-blur-sm">
|
||||
<div class="container-amare grid grid-cols-[auto_1fr_auto] items-center gap-4 py-4 md:grid-cols-[1fr_auto_1fr]">
|
||||
<nav id="main-nav" class="main-nav order-3 col-span-3 hidden flex-col gap-4 border-t border-amare-border pt-4 md:order-1 md:col-span-1 md:flex md:flex-row md:items-center md:border-0 md:pt-0" aria-label="Principal" data-main-nav>
|
||||
<a href="{{ route('home') }}" class="text-sm font-semibold uppercase tracking-[0.12em] text-amare-muted transition-colors hover:text-amare-accent">Início</a>
|
||||
<a href="{{ route('services.index') }}" class="text-sm font-semibold uppercase tracking-[0.12em] text-amare-muted transition-colors hover:text-amare-accent">Serviços</a>
|
||||
<a href="{{ route('portfolio.index') }}" class="text-sm font-semibold uppercase tracking-[0.12em] text-amare-muted transition-colors hover:text-amare-accent">Portfólio</a>
|
||||
<a href="{{ route('about') }}" class="text-sm font-semibold uppercase tracking-[0.12em] text-amare-muted transition-colors hover:text-amare-accent">Amare</a>
|
||||
<a href="{{ route('contact') }}" class="text-sm font-semibold uppercase tracking-[0.12em] text-amare-muted transition-colors hover:text-amare-accent md:hidden">Contato</a>
|
||||
<nav id="main-nav" class="main-nav order-3 col-span-3 hidden flex-col gap-4 border-t border-amare-border pt-4 md:order-1 md:col-span-1 md:flex md:flex-row md:items-center md:gap-1 md:border-0 md:pt-0" aria-label="Principal" data-main-nav>
|
||||
<a href="{{ route('home') }}" class="inline-flex items-center py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-muted transition-colors hover:text-amare-accent md:px-2">Início</a>
|
||||
<a href="{{ route('services.index') }}" class="inline-flex items-center py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-muted transition-colors hover:text-amare-accent md:px-2">Serviços</a>
|
||||
<a href="{{ route('portfolio.index') }}" class="inline-flex items-center py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-muted transition-colors hover:text-amare-accent md:px-2">Portfólio</a>
|
||||
<a href="{{ route('about') }}" class="inline-flex items-center py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-muted transition-colors hover:text-amare-accent md:px-2">Amare</a>
|
||||
<a href="{{ route('contact') }}" class="inline-flex items-center py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-muted transition-colors hover:text-amare-accent md:hidden">Solicitar proposta</a>
|
||||
</nav>
|
||||
|
||||
<a href="{{ route('home') }}" class="order-1 justify-self-start md:order-2 md:justify-self-center" aria-label="{{ $siteSettings->brand_name }} — página inicial">
|
||||
@@ -31,13 +52,13 @@
|
||||
</a>
|
||||
|
||||
<div class="order-2 flex items-center justify-end gap-3 md:order-3">
|
||||
<a href="{{ route('contact') }}" class="hidden text-sm font-semibold uppercase tracking-[0.12em] text-amare-accent transition-colors hover:text-amare-accent-deep md:inline-flex">
|
||||
<a href="{{ route('contact') }}" class="hidden items-center py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-accent transition-colors hover:text-amare-accent-deep md:inline-flex">
|
||||
Solicitar proposta
|
||||
</a>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="menu-button inline-flex h-10 w-10 items-center justify-center border border-amare-border text-amare-text md:hidden"
|
||||
class="menu-button inline-flex h-11 w-11 items-center justify-center border border-amare-border text-amare-text md:hidden"
|
||||
aria-label="Abrir menu"
|
||||
aria-controls="main-nav"
|
||||
aria-expanded="false"
|
||||
@@ -63,39 +84,45 @@
|
||||
<div class="space-y-4">
|
||||
<x-brand.logo variant="on-light" class="h-12 w-auto" />
|
||||
<p class="max-w-md text-amare-muted">
|
||||
{{ $siteSettings->about_summary ?: 'Assessoria, produção e organização de eventos sociais e corporativos em São Paulo - SP.' }}
|
||||
{{ $siteSettings->about_summary ?: 'Assessoria boutique em São Paulo - SP.' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3 text-sm">
|
||||
<div class="text-sm">
|
||||
<h2 class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-text">Navegação</h2>
|
||||
<p><a href="{{ route('services.index') }}" class="text-amare-muted transition-colors hover:text-amare-accent">Serviços</a></p>
|
||||
<p><a href="{{ route('portfolio.index') }}" class="text-amare-muted transition-colors hover:text-amare-accent">Portfólio</a></p>
|
||||
<p><a href="{{ route('about') }}" class="text-amare-muted transition-colors hover:text-amare-accent">A Amare</a></p>
|
||||
<p><a href="{{ route('contact') }}" class="text-amare-muted transition-colors hover:text-amare-accent">Contato</a></p>
|
||||
<p><a href="{{ route('privacy') }}" class="text-amare-muted transition-colors hover:text-amare-accent">Política de privacidade</a></p>
|
||||
<ul class="mt-3 space-y-3">
|
||||
<li><a href="{{ route('services.index') }}" class="inline-flex min-h-11 items-center text-amare-muted transition-colors hover:text-amare-accent">Serviços</a></li>
|
||||
<li><a href="{{ route('portfolio.index') }}" class="inline-flex min-h-11 items-center text-amare-muted transition-colors hover:text-amare-accent">Portfólio</a></li>
|
||||
<li><a href="{{ route('about') }}" class="inline-flex min-h-11 items-center text-amare-muted transition-colors hover:text-amare-accent">A Amare</a></li>
|
||||
<li><a href="{{ route('contact') }}" class="inline-flex min-h-11 items-center text-amare-muted transition-colors hover:text-amare-accent">Contato</a></li>
|
||||
<li><a href="{{ route('privacy') }}" class="inline-flex min-h-11 items-center text-amare-muted transition-colors hover:text-amare-accent">Política de privacidade</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3 text-sm">
|
||||
<div class="text-sm">
|
||||
<h2 class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-text">Contato</h2>
|
||||
@if ($siteSettings->city)
|
||||
<p class="text-amare-muted">{{ $siteSettings->city }}</p>
|
||||
@endif
|
||||
@if ($siteSettings->email)
|
||||
<p>
|
||||
<a href="mailto:{{ $siteSettings->email }}" class="text-amare-muted transition-colors hover:text-amare-accent">{{ $siteSettings->email }}</a>
|
||||
</p>
|
||||
@endif
|
||||
@if ($siteSettings->phone)
|
||||
<p class="text-amare-muted">{{ $siteSettings->phone }}</p>
|
||||
@endif
|
||||
@foreach ($siteSettings->social_links ?? [] as $network => $url)
|
||||
@if (filled($url))
|
||||
<address class="mt-3 space-y-3 not-italic">
|
||||
@if ($siteSettings->city)
|
||||
<p class="text-amare-muted">{{ $siteSettings->city }}</p>
|
||||
@endif
|
||||
@if ($siteSettings->email)
|
||||
<p>
|
||||
<a href="{{ $url }}" class="text-amare-muted transition-colors hover:text-amare-accent" rel="noopener noreferrer" target="_blank">{{ ucfirst((string) $network) }}</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
|
||||
@endforeach
|
||||
@if ($siteSettings->phone)
|
||||
<p>
|
||||
<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 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
|
||||
</address>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -103,7 +130,7 @@
|
||||
<div class="container-amare flex flex-col gap-2 py-6 text-sm text-amare-muted md:flex-row md:items-center md:justify-between">
|
||||
<p>© {{ now()->year }} {{ $siteSettings->brand_name }}. Todos os direitos reservados.</p>
|
||||
<p>
|
||||
<a href="{{ route('privacy') }}" class="transition-colors hover:text-amare-accent">Política de privacidade</a>
|
||||
<a href="{{ route('privacy') }}" class="inline-flex min-h-11 items-center transition-colors hover:text-amare-accent">Política de privacidade</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
@extends('layouts.public')
|
||||
|
||||
@section('content')
|
||||
<div class="flex min-h-[calc(100dvh-14rem)] flex-col border-b border-amare-border bg-amare-bg">
|
||||
<section class="flex min-h-[calc(100dvh-14rem)] flex-col border-b border-amare-border bg-amare-bg">
|
||||
<div class="container-amare grid flex-1 content-start gap-12 py-16 md:grid-cols-[minmax(0,1.2fr)_minmax(0,0.8fr)] md:py-24">
|
||||
<div class="space-y-6">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Vamos conversar</p>
|
||||
<h1 class="text-4xl font-medium tracking-tight text-amare-text md:text-5xl">Todo grande encontro começa com uma boa conversa.</h1>
|
||||
<p class="max-w-2xl text-lg text-amare-muted">
|
||||
Em breve você poderá enviar um briefing por aqui. Enquanto isso, fale conosco pelos canais abaixo.
|
||||
Conte sobre o seu evento no briefing abaixo. Retornaremos com uma proposta sob medida e sem compromisso.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -15,20 +15,263 @@
|
||||
<p>{{ $siteSettings->city ?: 'São Paulo - SP' }}</p>
|
||||
@if ($siteSettings->email)
|
||||
<p>
|
||||
<a href="mailto:{{ $siteSettings->email }}" class="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>{{ $siteSettings->phone }}</p>
|
||||
<p>
|
||||
<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="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
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="border-b border-amare-border bg-amare-bg" aria-labelledby="briefing-heading">
|
||||
<div class="container-amare py-16 md:py-24">
|
||||
<h2 id="briefing-heading" class="text-2xl font-medium tracking-tight text-amare-text md:text-3xl">Briefing de contato</h2>
|
||||
<p class="mt-2 max-w-2xl text-amare-muted">
|
||||
Preencha os campos abaixo. As informações obrigatórias estão marcadas com asterisco (*).
|
||||
</p>
|
||||
|
||||
@if (session('status') === 'briefing-sent')
|
||||
<div role="status" class="mt-8 border border-amare-border bg-amare-bg-deep px-6 py-5">
|
||||
<p class="font-semibold text-amare-text">Mensagem enviada.</p>
|
||||
<p class="mt-1 text-amare-muted">Recebemos seu briefing e retornaremos em breve pelo canal informado.</p>
|
||||
</div>
|
||||
@else
|
||||
<form
|
||||
method="POST"
|
||||
action="{{ route('contact.store') }}"
|
||||
class="mt-10 max-w-3xl space-y-10"
|
||||
data-contact-form
|
||||
>
|
||||
@csrf
|
||||
|
||||
<div class="honeypot" aria-hidden="true">
|
||||
<label for="empresa">Não preencha este campo</label>
|
||||
<input type="text" id="empresa" name="empresa" tabindex="-1" autocomplete="off">
|
||||
</div>
|
||||
|
||||
@if ($errors->any())
|
||||
<div role="alert" class="border border-amare-error/40 bg-amare-error/5 px-6 py-5">
|
||||
<p class="font-semibold text-amare-error">Não foi possível enviar.</p>
|
||||
<p class="mt-1 text-sm text-amare-muted">Revise os campos destacados abaixo e tente novamente.</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="grid gap-8 sm:grid-cols-2">
|
||||
<div>
|
||||
<label for="nome" class="mb-2 block text-xs font-semibold uppercase tracking-[0.14em] text-amare-text">Nome completo *</label>
|
||||
<input
|
||||
type="text"
|
||||
id="nome"
|
||||
name="nome"
|
||||
value="{{ old('nome') }}"
|
||||
required
|
||||
autocomplete="name"
|
||||
maxlength="120"
|
||||
placeholder="Seu nome"
|
||||
class="w-full border-0 border-b border-amare-border bg-transparent py-3 text-amare-text transition-colors placeholder:text-amare-muted/60 focus:border-amare-accent focus:outline-none @error('nome') border-amare-error @enderror"
|
||||
@error('nome') aria-invalid="true" aria-describedby="nome-error" @enderror
|
||||
>
|
||||
@error('nome')
|
||||
<p id="nome-error" class="mt-2 text-sm text-amare-error">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="email" class="mb-2 block text-xs font-semibold uppercase tracking-[0.14em] text-amare-text">E-mail *</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
value="{{ old('email') }}"
|
||||
required
|
||||
autocomplete="email"
|
||||
maxlength="254"
|
||||
placeholder="voce@email.com"
|
||||
class="w-full border-0 border-b border-amare-border bg-transparent py-3 text-amare-text transition-colors placeholder:text-amare-muted/60 focus:border-amare-accent focus:outline-none @error('email') border-amare-error @enderror"
|
||||
@error('email') aria-invalid="true" aria-describedby="email-error" @enderror
|
||||
>
|
||||
@error('email')
|
||||
<p id="email-error" class="mt-2 text-sm text-amare-error">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="telefone" class="mb-2 block text-xs font-semibold uppercase tracking-[0.14em] text-amare-text">Telefone/WhatsApp *</label>
|
||||
<input
|
||||
type="tel"
|
||||
id="telefone"
|
||||
name="telefone"
|
||||
value="{{ old('telefone') }}"
|
||||
required
|
||||
autocomplete="tel"
|
||||
maxlength="40"
|
||||
placeholder="(11) 90000-0000"
|
||||
class="w-full border-0 border-b border-amare-border bg-transparent py-3 text-amare-text transition-colors placeholder:text-amare-muted/60 focus:border-amare-accent focus:outline-none @error('telefone') border-amare-error @enderror"
|
||||
@error('telefone') aria-invalid="true" aria-describedby="telefone-error" @enderror
|
||||
>
|
||||
@error('telefone')
|
||||
<p id="telefone-error" class="mt-2 text-sm text-amare-error">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="tipo_evento" class="mb-2 block text-xs font-semibold uppercase tracking-[0.14em] text-amare-text">Tipo de evento *</label>
|
||||
<select
|
||||
id="tipo_evento"
|
||||
name="tipo_evento"
|
||||
required
|
||||
class="w-full border-0 border-b border-amare-border bg-transparent py-3 text-amare-text transition-colors focus:border-amare-accent focus:outline-none @error('tipo_evento') border-amare-error @enderror"
|
||||
@error('tipo_evento') aria-invalid="true" aria-describedby="tipo_evento-error" @enderror
|
||||
>
|
||||
<option value="" selected disabled>Selecione...</option>
|
||||
<option value="Casamento" @selected(old('tipo_evento') === 'Casamento')>Casamento</option>
|
||||
<option value="Evento corporativo" @selected(old('tipo_evento') === 'Evento corporativo')>Evento corporativo</option>
|
||||
<option value="Celebração intimista" @selected(old('tipo_evento') === 'Celebração intimista')>Celebração intimista</option>
|
||||
<option value="Outro" @selected(old('tipo_evento') === 'Outro')>Outro tipo de evento</option>
|
||||
</select>
|
||||
@error('tipo_evento')
|
||||
<p id="tipo_evento-error" class="mt-2 text-sm text-amare-error">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="data_periodo" class="mb-2 block text-xs font-semibold uppercase tracking-[0.14em] text-amare-text">Data ou período desejado</label>
|
||||
<input
|
||||
type="text"
|
||||
id="data_periodo"
|
||||
name="data_periodo"
|
||||
value="{{ old('data_periodo') }}"
|
||||
maxlength="80"
|
||||
placeholder="Ex.: novembro de 2027"
|
||||
class="w-full border-0 border-b border-amare-border bg-transparent py-3 text-amare-text transition-colors placeholder:text-amare-muted/60 focus:border-amare-accent focus:outline-none @error('data_periodo') border-amare-error @enderror"
|
||||
@error('data_periodo') aria-invalid="true" aria-describedby="data_periodo-error" @enderror
|
||||
>
|
||||
@error('data_periodo')
|
||||
<p id="data_periodo-error" class="mt-2 text-sm text-amare-error">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="cidade" class="mb-2 block text-xs font-semibold uppercase tracking-[0.14em] text-amare-text">Cidade do evento *</label>
|
||||
<input
|
||||
type="text"
|
||||
id="cidade"
|
||||
name="cidade"
|
||||
value="{{ old('cidade') }}"
|
||||
required
|
||||
maxlength="80"
|
||||
placeholder="Ex.: São Paulo"
|
||||
class="w-full border-0 border-b border-amare-border bg-transparent py-3 text-amare-text transition-colors placeholder:text-amare-muted/60 focus:border-amare-accent focus:outline-none @error('cidade') border-amare-error @enderror"
|
||||
@error('cidade') aria-invalid="true" aria-describedby="cidade-error" @enderror
|
||||
>
|
||||
@error('cidade')
|
||||
<p id="cidade-error" class="mt-2 text-sm text-amare-error">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="convidados" class="mb-2 block text-xs font-semibold uppercase tracking-[0.14em] text-amare-text">Número estimado de convidados</label>
|
||||
<input
|
||||
type="number"
|
||||
id="convidados"
|
||||
name="convidados"
|
||||
value="{{ old('convidados') }}"
|
||||
min="1"
|
||||
max="100000"
|
||||
inputmode="numeric"
|
||||
autocomplete="off"
|
||||
placeholder="Ex.: 120"
|
||||
class="w-full border-0 border-b border-amare-border bg-transparent py-3 text-amare-text transition-colors placeholder:text-amare-muted/60 focus:border-amare-accent focus:outline-none @error('convidados') border-amare-error @enderror"
|
||||
@error('convidados') aria-invalid="true" aria-describedby="convidados-error" @enderror
|
||||
>
|
||||
@error('convidados')
|
||||
<p id="convidados-error" class="mt-2 text-sm text-amare-error">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="servico_interesse" class="mb-2 block text-xs font-semibold uppercase tracking-[0.14em] text-amare-text">Serviço de interesse</label>
|
||||
<input
|
||||
type="text"
|
||||
id="servico_interesse"
|
||||
name="servico_interesse"
|
||||
value="{{ old('servico_interesse') }}"
|
||||
maxlength="120"
|
||||
placeholder="Ex.: planejamento completo"
|
||||
class="w-full border-0 border-b border-amare-border bg-transparent py-3 text-amare-text transition-colors placeholder:text-amare-muted/60 focus:border-amare-accent focus:outline-none @error('servico_interesse') border-amare-error @enderror"
|
||||
@error('servico_interesse') aria-invalid="true" aria-describedby="servico_interesse-error" @enderror
|
||||
>
|
||||
@error('servico_interesse')
|
||||
<p id="servico_interesse-error" class="mt-2 text-sm text-amare-error">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="mensagem" class="mb-2 block text-xs font-semibold uppercase tracking-[0.14em] text-amare-text">Mensagem / principal preocupação *</label>
|
||||
<textarea
|
||||
id="mensagem"
|
||||
name="mensagem"
|
||||
required
|
||||
rows="6"
|
||||
maxlength="3000"
|
||||
placeholder="Conte sobre o seu evento, expectativas e principais preocupações."
|
||||
class="w-full border-0 border-b border-amare-border bg-transparent py-3 text-amare-text transition-colors placeholder:text-amare-muted/60 focus:border-amare-accent focus:outline-none @error('mensagem') border-amare-error @enderror"
|
||||
@error('mensagem') aria-invalid="true" aria-describedby="mensagem-error" @enderror
|
||||
>{{ old('mensagem') }}</textarea>
|
||||
@error('mensagem')
|
||||
<p id="mensagem-error" class="mt-2 text-sm text-amare-error">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="privacidade" class="flex items-start gap-3 py-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="privacidade"
|
||||
name="privacidade"
|
||||
value="1"
|
||||
required
|
||||
@checked(old('privacidade'))
|
||||
class="mt-1 h-5 w-5 shrink-0 accent-amare-accent"
|
||||
@error('privacidade') aria-invalid="true" aria-describedby="privacidade-error" @enderror
|
||||
>
|
||||
<span class="text-sm text-amare-muted">
|
||||
Li e aceito a
|
||||
<a href="{{ route('privacy') }}" class="text-amare-accent underline underline-offset-2 transition-colors hover:text-amare-accent-deep">política de privacidade</a>
|
||||
e autorizo o tratamento dos meus dados para fins de atendimento.*
|
||||
</span>
|
||||
</label>
|
||||
@error('privacidade')
|
||||
<p id="privacidade-error" class="mt-2 text-sm text-amare-error">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-start gap-4">
|
||||
<button
|
||||
type="submit"
|
||||
data-submit-button
|
||||
class="inline-flex min-h-[44px] items-center justify-center bg-amare-accent px-8 py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-accent-text transition-colors hover:bg-amare-accent-deep"
|
||||
>
|
||||
Enviar briefing
|
||||
</button>
|
||||
<p class="text-sm text-amare-muted">
|
||||
* Campos obrigatórios. Seus dados são usados apenas para responder à sua solicitação.
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
@endif
|
||||
</div>
|
||||
</section>
|
||||
@endsection
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
</div>
|
||||
|
||||
@if ($cases->isEmpty())
|
||||
<p class="text-amare-muted">Em breve publicaremos novos casos.</p>
|
||||
<p class="text-amare-muted">Novos casos serão publicados assim que o acervo estiver organizado. Enquanto isso, fale conosco para conhecer o nosso trabalho.</p>
|
||||
@else
|
||||
<div class="grid gap-10 md:grid-cols-2">
|
||||
@foreach ($cases as $case)
|
||||
@@ -40,7 +40,7 @@
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<p class="text-sm text-amare-accent">Imagens demonstrativas até existir acervo autorizado da Amare.</p>
|
||||
<p class="text-sm text-amare-accent">Imagens demonstrativas enquanto o acervo autorizado da Amare está em organização.</p>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@@ -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">
|
||||
<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>
|
||||
<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
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
</div>
|
||||
|
||||
@if ($services->isEmpty())
|
||||
<p class="text-amare-muted">Em breve publicaremos o catálogo de serviços.</p>
|
||||
<p class="text-amare-muted">O catálogo de serviços está em organização. Enquanto isso, fale conosco para uma primeira conversa.</p>
|
||||
@else
|
||||
<div class="divide-y divide-amare-border border-y border-amare-border">
|
||||
@foreach ($services as $index => $service)
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -2,6 +2,7 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Http\Controllers\PublicSite\ContactController;
|
||||
use App\Http\Controllers\PublicSite\HomeController;
|
||||
use App\Http\Controllers\PublicSite\PageController;
|
||||
use App\Http\Controllers\PublicSite\PortfolioController;
|
||||
@@ -18,5 +19,8 @@ Route::get('/portfolio/{slug}', [PortfolioController::class, 'show'])->name('por
|
||||
Route::get('/sobre', [PageController::class, 'about'])->name('about');
|
||||
Route::get('/privacidade', [PageController::class, 'privacy'])->name('privacy');
|
||||
Route::get('/contato', [PageController::class, 'contact'])->name('contact');
|
||||
Route::post('/contato', [ContactController::class, 'store'])
|
||||
->middleware('throttle:contact-briefing')
|
||||
->name('contact.store');
|
||||
Route::get('/sitemap.xml', SitemapController::class)->name('sitemap');
|
||||
Route::get('/robots.txt', RobotsController::class)->name('robots');
|
||||
|
||||
85
tasks.md
85
tasks.md
@@ -1,8 +1,79 @@
|
||||
# Tasks: Git hooks (pre-commit + pre-push)
|
||||
# Frontend Audit — Amare site
|
||||
|
||||
- [x] Install husky + add prepare script
|
||||
- [x] Create .husky/pre-commit (pint + phpstan)
|
||||
- [x] Create .husky/pre-push (DB gate + tests)
|
||||
- [x] Install hooks into repo, chmod +x
|
||||
- [x] Verify hooks (DB up/down, pre-commit)
|
||||
- [x] Document hooks in AGENTS.md
|
||||
## T0 — Baseline
|
||||
- [x] Rodar `composer quality` p/ registrar estado verde atual (110 passed após composer install)
|
||||
- [x] Registrado estado baseline; Lighthouse final consolidado no T6
|
||||
- [x] Registrar achados baseline (audit completo em frontend-audit.md + relatório subagent)
|
||||
|
||||
## T1 — Formulário de contato (e-mail, sem CRM)
|
||||
- [x] Form em /contato (campos, LGPD obrigatório)
|
||||
- [x] Rota POST /contato + validação + honeypot + throttle
|
||||
- [x] Anti duplo-envio, estados sucesso/erro/rede, preservação de dados
|
||||
- [x] E-mail via MAIL_MAILER (Resend prod / log local)
|
||||
- [x] A11y do form (labels, erros ligados)
|
||||
- [x] Testes feature + atualizar PublicPagesTest — Feature suite: 97 passed
|
||||
|
||||
## T2 — A11y/navegação
|
||||
- [x] Menu mobile: Escape, retorno de foco, fallback sem-JS (app.js + noscript)
|
||||
- [x] Touch targets ≥44px (header, footer, CTAs, capítulos)
|
||||
- [x] Footer: ul/li + address
|
||||
|
||||
## T3 — SEO/metadados
|
||||
- [x] Títulos "Página · Amare" (PageMeta::withBrandSuffix)
|
||||
- [x] og:locale, og:site_name, Twitter cards
|
||||
- [x] Favicon real + link, theme-color
|
||||
- [x] Metadados corretos em 404/500 (PageMeta::forErrorPage)
|
||||
- [x] noindex fora de produção (robots meta condicional)
|
||||
|
||||
## T4 — Conteúdo/posicionamento
|
||||
- [x] Remover welcome.blade.php, home/cases, home/proof
|
||||
- [x] Dedup heading final-CTA vs h1 contato; dedup disclaimers
|
||||
- [x] CTA consistente mobile/desktop
|
||||
- [x] Corrigir copy "Em breve..." nas listas
|
||||
- [x] Rebalancear copy corporativo/social
|
||||
|
||||
## T5 — Performance
|
||||
- [x] fetchpriority + dimensões hero (LCP)
|
||||
- [x] Guarda CLS no media/image
|
||||
- [x] Cache manifest de fontes
|
||||
- [x] Escape JSON-LD
|
||||
|
||||
## T6 — Validação final
|
||||
- [x] pint, phpstan, testes unit/feature/architecture — `composer quality` verde (135 testes)
|
||||
- [x] Browser tests + visual regression (18 passaram; 8 baselines `.snap` atualizadas com `--update-snapshots`)
|
||||
- [x] Lighthouse final (home mobile: A11y 100, Best Practices 100, SEO 69 só por noindex intencional fora de prod; contato desktop: A11y 100, BP 100)
|
||||
- [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).
|
||||
|
||||
# Browser CI Gate — estabilidade e diagnosticabilidade
|
||||
|
||||
## BG — Causa raiz
|
||||
- [x] Diagnosticar 3 runs: mismatch visual (8 testes), acessibilidade (1), timeout networkidle 5s + overflow real
|
||||
- [x] Reproduzir local: `waitForLoadState('networkidle')` fragil (conexões longas), default 5s
|
||||
- [x] Reproduzir warning `file_get_contents(.env)` em TODO teste (CI sem .env)
|
||||
|
||||
## BG — Fixes
|
||||
- [x] Helper `StableScreenshot` (tests/Support/): drop networkidle → readyState + fonts + settle
|
||||
- [x] Exportar PNGs standalone (diff/expected/actual) no mismatch — visíveis nos artifacts
|
||||
- [x] CI: `cp .env.example .env` nos jobs de teste; checkout/cache v5
|
||||
- [x] Gitignore tests/Browser/Screenshots
|
||||
- [x] Unit test MismatchScreenshotExporter; pint/phpstan/unit/feature verdes
|
||||
- [ ] Push + PR + CI browser verde
|
||||
- [ ] Refresh baselines de CI se necessário (timing mudou ligeiramente)
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
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();
|
||||
});
|
||||
@@ -41,13 +41,14 @@ $viewports = [
|
||||
foreach ($screens as $screen => $path) {
|
||||
foreach ($viewports as $viewport => [$width, $height]) {
|
||||
it("matches {$screen} {$viewport} visual baseline", function () use ($path, $width, $height): void {
|
||||
$this->visit($path, [
|
||||
'reducedMotion' => 'reduce',
|
||||
])
|
||||
->withLocale('pt-BR')
|
||||
->withTimezone('America/Fortaleza')
|
||||
->resize($width, $height)
|
||||
->assertScreenshotMatches();
|
||||
$this->assertStableScreenshotMatches(
|
||||
$this->visit($path, [
|
||||
'reducedMotion' => 'reduce',
|
||||
])
|
||||
->withLocale('pt-BR')
|
||||
->withTimezone('America/Fortaleza')
|
||||
->resize($width, $height)
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
164
tests/Feature/PublicSite/ContactBriefingTest.php
Normal file
164
tests/Feature/PublicSite/ContactBriefingTest.php
Normal file
@@ -0,0 +1,164 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\PublicSite;
|
||||
|
||||
use App\Mail\ContactBriefing;
|
||||
use App\Mail\ContactBriefingConfirmation;
|
||||
use App\Models\SiteSetting;
|
||||
use Illuminate\Foundation\Http\Middleware\PreventRequestForgery;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Routing\Middleware\ThrottleRequests;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ContactBriefingTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->withoutMiddleware([ThrottleRequests::class, PreventRequestForgery::class]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $overrides
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function validPayload(array $overrides = []): array
|
||||
{
|
||||
return array_merge([
|
||||
'nome' => 'Maria Silva',
|
||||
'email' => 'maria@example.com',
|
||||
'telefone' => '(11) 98888-7777',
|
||||
'tipo_evento' => 'Casamento',
|
||||
'data_periodo' => 'novembro de 2027',
|
||||
'cidade' => 'São Paulo',
|
||||
'convidados' => '120',
|
||||
'servico_interesse' => 'Planejamento completo',
|
||||
'mensagem' => 'Queremos um casamento ao ar livre para 120 convidados.',
|
||||
'privacidade' => '1',
|
||||
'empresa' => '',
|
||||
], $overrides);
|
||||
}
|
||||
|
||||
public function test_contact_page_renders_briefing_form_with_all_fields(): void
|
||||
{
|
||||
SiteSetting::instance();
|
||||
|
||||
$response = $this->get(route('contact'));
|
||||
|
||||
$response
|
||||
->assertOk()
|
||||
->assertSee('<form', false)
|
||||
->assertSee('action="'.route('contact.store').'"', false)
|
||||
->assertSee('name="nome"', false)
|
||||
->assertSee('name="email"', false)
|
||||
->assertSee('name="telefone"', false)
|
||||
->assertSee('name="tipo_evento"', false)
|
||||
->assertSee('name="data_periodo"', false)
|
||||
->assertSee('name="cidade"', false)
|
||||
->assertSee('name="convidados"', false)
|
||||
->assertSee('name="servico_interesse"', false)
|
||||
->assertSee('name="mensagem"', false)
|
||||
->assertSee('name="privacidade"', false)
|
||||
->assertSee('name="empresa"', false)
|
||||
->assertSee(route('privacy'), false);
|
||||
}
|
||||
|
||||
public function test_valid_submission_sends_briefing_and_confirmation_emails(): void
|
||||
{
|
||||
$settings = SiteSetting::instance();
|
||||
Mail::fake();
|
||||
|
||||
$response = $this->post(route('contact.store'), $this->validPayload());
|
||||
|
||||
$response
|
||||
->assertRedirect(route('contact'))
|
||||
->assertSessionHas('status', 'briefing-sent');
|
||||
|
||||
Mail::assertQueued(ContactBriefing::class, function (ContactBriefing $mail) use ($settings): bool {
|
||||
return $mail->hasTo($settings->email);
|
||||
});
|
||||
|
||||
Mail::assertQueued(ContactBriefingConfirmation::class, function (ContactBriefingConfirmation $mail): bool {
|
||||
return $mail->hasTo('maria@example.com');
|
||||
});
|
||||
}
|
||||
|
||||
public function test_honeypot_submission_is_dropped_without_sending_emails(): void
|
||||
{
|
||||
SiteSetting::instance();
|
||||
Mail::fake();
|
||||
|
||||
$response = $this->post(route('contact.store'), $this->validPayload([
|
||||
'empresa' => 'http://spam.example',
|
||||
]));
|
||||
|
||||
$response
|
||||
->assertRedirect(route('contact'))
|
||||
->assertSessionHas('status', 'briefing-sent');
|
||||
|
||||
Mail::assertNothingSent();
|
||||
}
|
||||
|
||||
public function test_submission_requires_privacy_acceptance(): void
|
||||
{
|
||||
SiteSetting::instance();
|
||||
Mail::fake();
|
||||
|
||||
$response = $this->from(route('contact'))->post(route('contact.store'), $this->validPayload([
|
||||
'privacidade' => '',
|
||||
]));
|
||||
|
||||
$response->assertRedirect(route('contact'));
|
||||
$response->assertSessionHasErrors('privacidade');
|
||||
|
||||
Mail::assertNothingSent();
|
||||
}
|
||||
|
||||
public function test_invalid_submission_returns_validation_errors(): void
|
||||
{
|
||||
SiteSetting::instance();
|
||||
Mail::fake();
|
||||
|
||||
$response = $this->from(route('contact'))->post(route('contact.store'), []);
|
||||
|
||||
$response->assertRedirect(route('contact'));
|
||||
|
||||
foreach (['nome', 'email', 'telefone', 'tipo_evento', 'cidade', 'mensagem', 'privacidade'] as $field) {
|
||||
$response->assertSessionHasErrors($field);
|
||||
}
|
||||
|
||||
Mail::assertNothingSent();
|
||||
}
|
||||
|
||||
public function test_duplicate_submission_is_not_sent_twice(): void
|
||||
{
|
||||
SiteSetting::instance();
|
||||
Mail::fake();
|
||||
|
||||
$payload = $this->validPayload();
|
||||
|
||||
$this->post(route('contact.store'), $payload)->assertRedirect(route('contact'));
|
||||
$this->post(route('contact.store'), $payload)->assertRedirect(route('contact'));
|
||||
|
||||
Mail::assertQueued(ContactBriefing::class, 1);
|
||||
Mail::assertQueued(ContactBriefingConfirmation::class, 1);
|
||||
}
|
||||
|
||||
public function test_email_failure_does_not_block_submission(): void
|
||||
{
|
||||
SiteSetting::instance();
|
||||
config(['mail.default' => 'mailer-inexistente']);
|
||||
|
||||
$response = $this->post(route('contact.store'), $this->validPayload());
|
||||
|
||||
$response
|
||||
->assertRedirect(route('contact'))
|
||||
->assertSessionHas('status', 'briefing-sent');
|
||||
}
|
||||
}
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,8 +134,10 @@ class PublicPagesTest extends TestCase
|
||||
->assertDontSee('Fortaleza')
|
||||
->assertSee('https://instagram.com/amare', false)
|
||||
->assertSee('min-h-[calc(100dvh-14rem)]', false)
|
||||
->assertDontSee('<form', false)
|
||||
->assertDontSee('</form>', false);
|
||||
->assertSee('<form', false)
|
||||
->assertSee('name="nome"', false)
|
||||
->assertSee('name="privacidade"', false)
|
||||
->assertSee(route('privacy'), false);
|
||||
}
|
||||
|
||||
public function test_site_setting_defaults_use_sao_paulo_and_official_email(): void
|
||||
@@ -194,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);
|
||||
}
|
||||
}
|
||||
|
||||
40
tests/Support/MismatchScreenshotExporter.php
Normal file
40
tests/Support/MismatchScreenshotExporter.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Support;
|
||||
|
||||
final class MismatchScreenshotExporter
|
||||
{
|
||||
/**
|
||||
* Extracts the diff, expected, and actual images embedded in an ImageDiffView
|
||||
* HTML file and writes them as standalone PNG files so they can be inspected
|
||||
* directly in CI artifacts.
|
||||
*
|
||||
* @return list<string> paths of the written PNG files
|
||||
*/
|
||||
public static function export(string $htmlPath, string $targetDir): array
|
||||
{
|
||||
$html = (string) file_get_contents($htmlPath);
|
||||
|
||||
if (preg_match_all('/src="data:image\/png;base64,([^"]+)"/', $html, $matches) === false || ! isset($matches[1][2])) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$base = pathinfo($htmlPath, PATHINFO_FILENAME);
|
||||
$labels = ['diff', 'expected', 'actual'];
|
||||
$written = [];
|
||||
|
||||
foreach (array_slice($matches[1], 0, 3) as $index => $base64) {
|
||||
$path = $targetDir.'/'.$base.'-'.($labels[$index] ?? 'image-'.$index).'.png';
|
||||
|
||||
if (file_put_contents($path, (string) base64_decode($base64, true)) === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$written[] = $path;
|
||||
}
|
||||
|
||||
return $written;
|
||||
}
|
||||
}
|
||||
68
tests/Support/StableScreenshot.php
Normal file
68
tests/Support/StableScreenshot.php
Normal file
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Support;
|
||||
|
||||
use Pest\Browser\Api\AwaitableWebpage;
|
||||
use Pest\Browser\Exceptions\BrowserExpectationFailedException;
|
||||
use Pest\Browser\Support\Screenshot;
|
||||
use Pest\TestSuite;
|
||||
use PHPUnit\Framework\ExpectationFailedException;
|
||||
|
||||
trait StableScreenshot
|
||||
{
|
||||
/**
|
||||
* Asserts the page matches its visual baseline without relying on the
|
||||
* networkidle load state, which is inherently flaky (long-lived connections
|
||||
* like fonts, polling, or keep-alive can keep it from ever firing and cause
|
||||
* spurious timeouts at the 5s client default).
|
||||
*
|
||||
* On mismatch the vendor only writes an HTML diff view with base64-embedded
|
||||
* images, so this helper additionally exports standalone diff/expected/actual
|
||||
* PNG files that are directly viewable in CI artifacts.
|
||||
*/
|
||||
public function assertStableScreenshotMatches(AwaitableWebpage $awaitable): void
|
||||
{
|
||||
$page = $awaitable->page();
|
||||
|
||||
$page->addStyleTag('* {
|
||||
transition: none !important;
|
||||
animation: none !important;
|
||||
font-family: Arial, sans-serif !important;
|
||||
body {
|
||||
-webkit-font-smoothing: antialiased !important;
|
||||
-moz-osx-font-smoothing: grayscale !important;
|
||||
}
|
||||
}');
|
||||
|
||||
$page->waitForFunction(
|
||||
'document.readyState === "complete"'
|
||||
.' && document.fonts.status === "loaded"'
|
||||
);
|
||||
|
||||
usleep(300_000);
|
||||
|
||||
try {
|
||||
$page->expectScreenshot(true, false);
|
||||
} catch (ExpectationFailedException $exception) {
|
||||
$this->exportMismatchScreenshots();
|
||||
|
||||
throw BrowserExpectationFailedException::from($page, $exception);
|
||||
}
|
||||
}
|
||||
|
||||
private function exportMismatchScreenshots(): void
|
||||
{
|
||||
[$snapshotName] = TestSuite::getInstance()->snapshots->get();
|
||||
|
||||
$base = pathinfo($snapshotName, PATHINFO_FILENAME);
|
||||
$htmlPath = Screenshot::dir().'/ImageDiffView/'.$base.'.html';
|
||||
|
||||
if (! is_file($htmlPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
MismatchScreenshotExporter::export($htmlPath, Screenshot::dir());
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,9 @@
|
||||
namespace Tests;
|
||||
|
||||
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
|
||||
use Tests\Support\StableScreenshot;
|
||||
|
||||
abstract class TestCase extends BaseTestCase
|
||||
{
|
||||
//
|
||||
use StableScreenshot;
|
||||
}
|
||||
|
||||
47
tests/Unit/Support/MismatchScreenshotExporterTest.php
Normal file
47
tests/Unit/Support/MismatchScreenshotExporterTest.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Tests\Support\MismatchScreenshotExporter;
|
||||
|
||||
it('exports diff, expected, and actual PNGs from an ImageDiffView html', function (): void {
|
||||
$targetDir = sys_get_temp_dir().'/mismatch-exporter-'.uniqid();
|
||||
mkdir($targetDir, 0755, true);
|
||||
|
||||
$expected = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgAAACAAFWoS02AAAAAElFTkSuQmCC';
|
||||
$actual = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgAAACAAFWoS02AAAAAElFTkSuQmCC';
|
||||
$diff = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgAAACAAFWoS02AAAAAElFTkSuQmCC';
|
||||
|
||||
$html = <<<HTML
|
||||
<!DOCTYPE html>
|
||||
<html><body>
|
||||
<img alt="Diff" src="data:image/png;base64,{$diff}"/>
|
||||
<img slot="image-1" alt="Expected" src="data:image/png;base64,{$expected}"/>
|
||||
<img slot="image-2" alt="Actual" src="data:image/png;base64,{$actual}"/>
|
||||
</body></html>
|
||||
HTML;
|
||||
|
||||
$htmlPath = $targetDir.'/it_matches_home_desktop.html';
|
||||
file_put_contents($htmlPath, $html);
|
||||
|
||||
$written = MismatchScreenshotExporter::export($htmlPath, $targetDir);
|
||||
|
||||
expect($written)->toHaveCount(3);
|
||||
expect($written)->toContain($targetDir.'/it_matches_home_desktop-diff.png');
|
||||
expect($written)->toContain($targetDir.'/it_matches_home_desktop-expected.png');
|
||||
expect($written)->toContain($targetDir.'/it_matches_home_desktop-actual.png');
|
||||
|
||||
expect(base64_decode($diff, true))->toBe((string) file_get_contents($targetDir.'/it_matches_home_desktop-diff.png'));
|
||||
expect(base64_decode($expected, true))->toBe((string) file_get_contents($targetDir.'/it_matches_home_desktop-expected.png'));
|
||||
expect(base64_decode($actual, true))->toBe((string) file_get_contents($targetDir.'/it_matches_home_desktop-actual.png'));
|
||||
});
|
||||
|
||||
it('returns an empty list when the html has no embedded images', function (): void {
|
||||
$targetDir = sys_get_temp_dir().'/mismatch-exporter-'.uniqid();
|
||||
mkdir($targetDir, 0755, true);
|
||||
|
||||
$htmlPath = $targetDir.'/plain.html';
|
||||
file_put_contents($htmlPath, '<html><body>no images</body></html>');
|
||||
|
||||
expect(MismatchScreenshotExporter::export($htmlPath, $targetDir))->toBe([]);
|
||||
});
|
||||
Reference in New Issue
Block a user