Compare commits
16 Commits
246410803d
...
fix/seed-t
| Author | SHA1 | Date | |
|---|---|---|---|
| db4453b165 | |||
| 10a1f0dc7c | |||
| c04790ee1f | |||
| edf1c48050 | |||
| 3c711a74f3 | |||
| 88d63f349d | |||
| e0390ff333 | |||
| 321f0cee11 | |||
| 1d23e16625 | |||
| 33788af172 | |||
| 119711d6f0 | |||
| f09ea83071 | |||
| 2fe262ab59 | |||
| 018b43e24f | |||
| 9fac784f6a | |||
| 8a12b7d82c |
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
@@ -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
@@ -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)
|
||||
|
||||
@@ -56,6 +56,9 @@ final class MediaGenerateVariantsCommand extends Command
|
||||
if ($settings && filled($settings->default_og_image_path)) {
|
||||
$paths[] = (string) $settings->default_og_image_path;
|
||||
}
|
||||
if ($settings && filled($settings->about_image_path)) {
|
||||
$paths[] = (string) $settings->about_image_path;
|
||||
}
|
||||
|
||||
foreach (Service::query()->whereNotNull('cover_image_path')->pluck('cover_image_path') as $path) {
|
||||
$paths[] = (string) $path;
|
||||
|
||||
@@ -228,6 +228,12 @@ class ManageSiteSettings extends Page
|
||||
->addActionLabel('Adicionar rede'),
|
||||
])
|
||||
->columns(2),
|
||||
Section::make('Página Sobre')
|
||||
->schema([
|
||||
PublicImageUploadRules::fileUpload('about_image_path', 'Imagem da página Sobre', 'content/about'),
|
||||
PublicImageUploadRules::altTextField('about_image_alt', 'about_image_path'),
|
||||
])
|
||||
->columns(2),
|
||||
Section::make('SEO padrão')
|
||||
->schema([
|
||||
TextInput::make('default_meta_title')
|
||||
|
||||
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
@@ -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
@@ -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
@@ -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',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,8 @@ use Illuminate\Database\Eloquent\Model;
|
||||
* @property bool $analytics_enabled
|
||||
* @property string|null $default_og_image_path
|
||||
* @property string|null $default_og_image_alt
|
||||
* @property string|null $about_image_path
|
||||
* @property string|null $about_image_alt
|
||||
* @property string|null $logo_path
|
||||
* @property string|null $logo_alt
|
||||
*/
|
||||
@@ -30,6 +32,8 @@ use Illuminate\Database\Eloquent\Model;
|
||||
'hero_secondary_cta_label',
|
||||
'hero_note',
|
||||
'about_summary',
|
||||
'about_image_path',
|
||||
'about_image_alt',
|
||||
'manifesto_title',
|
||||
'manifesto_lead',
|
||||
'manifesto_body',
|
||||
|
||||
@@ -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')) {
|
||||
|
||||
BIN
database/fixtures/images/about-image.jpg
Normal file
|
After Width: | Height: | Size: 151 KiB |
BIN
database/fixtures/images/case-casamento-ana-lucas.jpg
Normal file
|
After Width: | Height: | Size: 436 KiB |
BIN
database/fixtures/images/case-lancamento-verano.jpg
Normal file
|
After Width: | Height: | Size: 254 KiB |
BIN
database/fixtures/images/case-mini-wedding-marina.jpg
Normal file
|
After Width: | Height: | Size: 258 KiB |
BIN
database/fixtures/images/gallery-casamento-ana-lucas-1.jpg
Normal file
|
After Width: | Height: | Size: 622 KiB |
BIN
database/fixtures/images/gallery-casamento-ana-lucas-2.jpg
Normal file
|
After Width: | Height: | Size: 499 KiB |
BIN
database/fixtures/images/gallery-lancamento-verano-1.jpg
Normal file
|
After Width: | Height: | Size: 244 KiB |
BIN
database/fixtures/images/gallery-lancamento-verano-2.jpg
Normal file
|
After Width: | Height: | Size: 235 KiB |
BIN
database/fixtures/images/gallery-mini-wedding-marina-1.jpg
Normal file
|
After Width: | Height: | Size: 239 KiB |
BIN
database/fixtures/images/gallery-mini-wedding-marina-2.jpg
Normal file
|
After Width: | Height: | Size: 236 KiB |
BIN
database/fixtures/images/og-default.jpg
Normal file
|
After Width: | Height: | Size: 360 KiB |
BIN
database/fixtures/images/service-casamentos.jpg
Normal file
|
After Width: | Height: | Size: 330 KiB |
BIN
database/fixtures/images/service-celebracoes-intimistas.jpg
Normal file
|
After Width: | Height: | Size: 302 KiB |
BIN
database/fixtures/images/service-eventos-corporativos.jpg
Normal file
|
After Width: | Height: | Size: 337 KiB |
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('site_settings', function (Blueprint $table): void {
|
||||
$table->string('about_image_path')->nullable()->after('about_summary');
|
||||
$table->string('about_image_alt')->nullable()->after('about_image_path');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('site_settings', function (Blueprint $table): void {
|
||||
$table->dropColumn(['about_image_path', 'about_image_alt']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -8,6 +8,7 @@ use App\Models\PortfolioCase;
|
||||
use App\Models\PortfolioImage;
|
||||
use App\Models\Service;
|
||||
use App\Models\SiteSetting;
|
||||
use App\Support\PublicImageUploadRules;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\File;
|
||||
@@ -36,6 +37,8 @@ class ContentSeeder extends Seeder
|
||||
'hero_secondary_cta_label' => 'Conheça nosso olhar',
|
||||
'hero_note' => 'Planejamento cuidadoso, comunicação clara e execução segura — do primeiro encontro ao último detalhe.',
|
||||
'about_summary' => 'Assessoria boutique especializada em experiências memoráveis em São Paulo.',
|
||||
'about_image_path' => $this->copyFixture('about-image.jpg', 'content/about/about-image.jpg'),
|
||||
'about_image_alt' => 'Mesa de planejamento com caderno, café e guardanapos de pano',
|
||||
'manifesto_title' => 'Sofisticação que também se traduz em organização.',
|
||||
'manifesto_lead' => 'Um evento memorável não nasce apenas de uma boa estética. Ele depende de decisões bem conduzidas, fornecedores alinhados e atenção constante ao que realmente importa.',
|
||||
'manifesto_body' => 'A Amare combina sensibilidade e precisão para criar encontros coerentes com cada cliente, marca e ocasião — sem fórmulas prontas, excessos ou ruído.',
|
||||
@@ -91,7 +94,7 @@ class ContentSeeder extends Seeder
|
||||
['slug' => $service['slug']],
|
||||
[
|
||||
...$service,
|
||||
'cover_image_path' => $this->copyFixture('service-cover.jpg', 'content/services/'.$service['slug'].'.jpg'),
|
||||
'cover_image_path' => $this->copyFixture('service-'.$service['slug'].'.jpg', 'content/services/'.$service['slug'].'.jpg'),
|
||||
'cover_image_alt' => 'Capa do serviço '.$service['title'],
|
||||
'published_at' => Carbon::parse(self::SEED_TIMESTAMP),
|
||||
],
|
||||
@@ -148,7 +151,7 @@ class ContentSeeder extends Seeder
|
||||
['slug' => $caseData['slug']],
|
||||
[
|
||||
...$caseData,
|
||||
'cover_image_path' => $this->copyFixture('portfolio-cover.jpg', 'content/portfolio/'.$caseData['slug'].'-cover.jpg'),
|
||||
'cover_image_path' => $this->copyFixture('case-'.$caseData['slug'].'.jpg', 'content/portfolio/'.$caseData['slug'].'-cover.jpg'),
|
||||
'cover_image_alt' => 'Capa do caso '.$caseData['title'],
|
||||
'is_featured' => true,
|
||||
'published_at' => Carbon::parse(self::SEED_TIMESTAMP),
|
||||
@@ -160,7 +163,7 @@ class ContentSeeder extends Seeder
|
||||
foreach ([1, 2] as $index) {
|
||||
PortfolioImage::query()->create([
|
||||
'portfolio_case_id' => $case->id,
|
||||
'path' => $this->copyFixture('gallery.jpg', 'content/portfolio/'.$caseData['slug'].'-gallery-'.$index.'.jpg'),
|
||||
'path' => $this->copyFixture('gallery-'.$caseData['slug'].'-'.$index.'.jpg', 'content/portfolio/'.$caseData['slug'].'-gallery-'.$index.'.jpg'),
|
||||
'alt_text' => 'Galeria '.$caseData['title'].' '.$index,
|
||||
'caption' => $index === 1 ? 'Detalhe da decoração' : null,
|
||||
'sort_order' => $index,
|
||||
@@ -171,8 +174,10 @@ class ContentSeeder extends Seeder
|
||||
|
||||
private function copyFixture(string $fixtureName, string $destination): string
|
||||
{
|
||||
$source = base_path('tests/fixtures/images/'.$fixtureName);
|
||||
Storage::disk('public')->put($destination, File::get($source));
|
||||
$source = base_path('database/fixtures/images/'.$fixtureName);
|
||||
$disk = PublicImageUploadRules::disk();
|
||||
|
||||
Storage::disk($disk)->put($destination, File::get($source), 'public');
|
||||
|
||||
return $destination;
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ use App\Models\SiteSetting;
|
||||
use App\Models\Testimonial;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
/**
|
||||
@@ -44,6 +43,8 @@ class VisualContentSeeder extends Seeder
|
||||
'hero_secondary_cta_label' => 'Conheça nosso olhar',
|
||||
'hero_note' => 'Planejamento cuidadoso, comunicação clara e execução segura — do primeiro encontro ao último detalhe.',
|
||||
'about_summary' => 'Assessoria boutique especializada em experiências memoráveis em São Paulo.',
|
||||
'about_image_path' => $this->writeSolidJpeg('visual/about/about-image.jpg', 1200, 900, [232, 228, 218]),
|
||||
'about_image_alt' => 'Imagem editorial da página Sobre',
|
||||
'manifesto_title' => 'Sofisticação que também se traduz em organização.',
|
||||
'manifesto_lead' => 'Um evento memorável não nasce apenas de uma boa estética. Ele depende de decisões bem conduzidas, fornecedores alinhados e atenção constante ao que realmente importa.',
|
||||
'manifesto_body' => 'A Amare combina sensibilidade e precisão para criar encontros coerentes com cada cliente, marca e ocasião — sem fórmulas prontas, excessos ou ruído.',
|
||||
@@ -58,7 +59,7 @@ class VisualContentSeeder extends Seeder
|
||||
],
|
||||
'default_meta_title' => 'Amare Assessoria de Eventos',
|
||||
'default_meta_description' => 'Assessoria premium para casamentos e eventos corporativos em São Paulo.',
|
||||
'default_og_image_path' => $this->copyFixture('og-default.jpg', 'visual/og/og-default.jpg'),
|
||||
'default_og_image_path' => $this->writeSolidJpeg('visual/og/og-default.jpg', 1600, 1067, [85, 107, 47]),
|
||||
'default_og_image_alt' => 'Identidade visual da Amare Assessoria de Eventos',
|
||||
'analytics_enabled' => false,
|
||||
'analytics_script' => null,
|
||||
@@ -89,7 +90,7 @@ class VisualContentSeeder extends Seeder
|
||||
['slug' => $service['slug']],
|
||||
[
|
||||
...$service,
|
||||
'cover_image_path' => $this->copyFixture('service-cover.jpg', 'visual/services/'.$service['slug'].'.jpg'),
|
||||
'cover_image_path' => $this->writeSolidJpeg('visual/services/'.$service['slug'].'.jpg', 1600, 1000, [196, 200, 184]),
|
||||
'cover_image_alt' => 'Capa do serviço '.$service['title'],
|
||||
'published_at' => Carbon::parse(self::SEED_TIMESTAMP),
|
||||
],
|
||||
@@ -133,7 +134,7 @@ class VisualContentSeeder extends Seeder
|
||||
['slug' => $caseData['slug']],
|
||||
[
|
||||
...$caseData,
|
||||
'cover_image_path' => $this->copyFixture('portfolio-cover.jpg', 'visual/portfolio/'.$caseData['slug'].'-cover.jpg'),
|
||||
'cover_image_path' => $this->writeSolidJpeg('visual/portfolio/'.$caseData['slug'].'-cover.jpg', 1600, 1200, [240, 238, 233]),
|
||||
'cover_image_alt' => 'Capa do caso '.$caseData['title'],
|
||||
'is_featured' => true,
|
||||
'published_at' => Carbon::parse(self::SEED_TIMESTAMP),
|
||||
@@ -144,7 +145,7 @@ class VisualContentSeeder extends Seeder
|
||||
|
||||
PortfolioImage::query()->create([
|
||||
'portfolio_case_id' => $case->id,
|
||||
'path' => $this->copyFixture('gallery.jpg', 'visual/portfolio/'.$caseData['slug'].'-gallery-1.jpg'),
|
||||
'path' => $this->writeSolidJpeg('visual/portfolio/'.$caseData['slug'].'-gallery-1.jpg', 1600, 1200, [228, 226, 221]),
|
||||
'alt_text' => 'Galeria '.$caseData['title'].' 1',
|
||||
'caption' => 'Detalhe da decoração',
|
||||
'sort_order' => 1,
|
||||
@@ -186,10 +187,21 @@ class VisualContentSeeder extends Seeder
|
||||
}
|
||||
}
|
||||
|
||||
private function copyFixture(string $fixtureName, string $destination): string
|
||||
/**
|
||||
* @param array{0: int, 1: int, 2: int} $rgb
|
||||
*/
|
||||
private function writeSolidJpeg(string $destination, int $width, int $height, array $rgb): string
|
||||
{
|
||||
$source = base_path('tests/fixtures/images/'.$fixtureName);
|
||||
Storage::disk('public')->put($destination, File::get($source));
|
||||
$image = imagecreatetruecolor($width, $height);
|
||||
$color = imagecolorallocate($image, $rgb[0], $rgb[1], $rgb[2]);
|
||||
imagefilledrectangle($image, 0, 0, $width, $height, $color);
|
||||
|
||||
ob_start();
|
||||
imagejpeg($image, null, 90);
|
||||
$binary = (string) ob_get_clean();
|
||||
imagedestroy($image);
|
||||
|
||||
Storage::disk('public')->put($destination, $binary);
|
||||
|
||||
return $destination;
|
||||
}
|
||||
|
||||
@@ -14,7 +14,12 @@ services:
|
||||
restart: "no"
|
||||
env_file:
|
||||
- .env
|
||||
command: ["php", "artisan", "migrate", "--force", "--no-interaction"]
|
||||
command: >
|
||||
sh -c
|
||||
"php artisan migrate --force --no-interaction
|
||||
&& (php artisan db:seed --class=ContentSeeder --force --no-interaction 2>&1
|
||||
&& php artisan media:generate-variants --force 2>&1) || true
|
||||
&& echo 'migrate+seed done'"
|
||||
networks:
|
||||
- dokploy-network
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 0 B After Width: | Height: | Size: 4.2 KiB |
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 |
@@ -18,6 +18,13 @@
|
||||
--text-3xl: var(--amare-text-3xl);
|
||||
--text-4xl: var(--amare-text-4xl);
|
||||
|
||||
--text-display: clamp(3rem, 7.7vw, 5.875rem);
|
||||
--text-display--line-height: 0.94;
|
||||
--text-display--letter-spacing: -0.01em;
|
||||
|
||||
--text-headline: clamp(2.375rem, 5.2vw, 4rem);
|
||||
--text-headline--line-height: 1.02;
|
||||
|
||||
--spacing-1: var(--amare-space-1);
|
||||
--spacing-2: var(--amare-space-2);
|
||||
--spacing-3: var(--amare-space-3);
|
||||
@@ -59,6 +66,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 +96,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 +166,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 +176,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-headline font-medium text-amare-text">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-display font-medium text-amare-text">
|
||||
{{ $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"
|
||||
/>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<div class="container-amare grid gap-8 md:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]" data-reveal>
|
||||
<p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent">Manifesto</p>
|
||||
<div class="space-y-6">
|
||||
<h2 id="manifesto-heading" class="max-w-3xl text-3xl font-medium leading-tight text-amare-text md:text-4xl">{{ $title }}</h2>
|
||||
<h2 id="manifesto-heading" class="max-w-3xl text-headline font-medium text-amare-text">{{ $title }}</h2>
|
||||
<p class="max-w-2xl text-xl leading-relaxed text-amare-text">{{ $lead }}</p>
|
||||
<p class="max-w-2xl text-amare-text-muted">{{ $body }}</p>
|
||||
</div>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<div class="container-amare grid gap-10 md:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)] md:items-start">
|
||||
<div class="space-y-3" data-reveal>
|
||||
<p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent">Método</p>
|
||||
<h2 id="method-heading" class="text-3xl font-medium text-amare-text">Cuidado orientado por processo.</h2>
|
||||
<h2 id="method-heading" class="text-headline font-medium text-amare-text">Cuidado orientado por processo.</h2>
|
||||
<p class="text-amare-text-muted">{{ $intro }}</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<div class="grid gap-4 md:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]" data-reveal>
|
||||
<p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent-text/80">Portfólio</p>
|
||||
<div class="space-y-3">
|
||||
<h2 id="portfolio-heading" class="text-3xl font-medium md:text-4xl">Celebrações que ganham forma com intenção.</h2>
|
||||
<h2 id="portfolio-heading" class="text-headline font-medium">Celebrações que ganham forma com intenção.</h2>
|
||||
<p class="max-w-2xl text-amare-accent-text/80">Recortes de eventos conduzidos com escuta, direção e presença em cada etapa.</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<div class="container-amare grid gap-10 md:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]">
|
||||
<div class="space-y-3" data-reveal>
|
||||
<p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent">A Amare</p>
|
||||
<h2 id="positioning-heading" class="text-3xl font-medium text-amare-text">Presença que organiza o essencial.</h2>
|
||||
<h2 id="positioning-heading" class="text-headline font-medium text-amare-text">Presença que organiza o essencial.</h2>
|
||||
</div>
|
||||
<div class="space-y-8" data-reveal>
|
||||
<p class="max-w-2xl text-xl leading-relaxed text-amare-text">{{ $summary }}</p>
|
||||
@@ -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>
|
||||
|
||||
@@ -3,15 +3,16 @@
|
||||
])
|
||||
|
||||
@if ($testimonials->isNotEmpty())
|
||||
<section aria-labelledby="testimonials-heading" class="border-b border-amare-border bg-amare-bg-muted py-16" data-chapter="testimonials">
|
||||
<section aria-labelledby="testimonials-heading" class="border-b border-amare-border bg-amare-bg py-16" data-chapter="testimonials">
|
||||
<div class="container-amare space-y-8">
|
||||
<div class="max-w-2xl space-y-3" data-reveal>
|
||||
<h2 id="testimonials-heading" class="text-3xl font-semibold text-amare-text">Depoimentos</h2>
|
||||
<h2 id="testimonials-heading" class="text-3xl font-medium text-amare-text">Depoimentos</h2>
|
||||
<p class="text-amare-text-muted">Quem celebrou com a Amare conta como foi a experiência.</p>
|
||||
</div>
|
||||
|
||||
<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,
|
||||
@@ -32,7 +33,7 @@
|
||||
|
||||
$resolvedWidth = $width ?? $dimensions['width'] ?? null;
|
||||
$resolvedHeight = $height ?? $dimensions['height'] ?? null;
|
||||
$loadingValue = $loading === 'eager' ? 'eager' : 'lazy';
|
||||
$loadingValue = $loading;
|
||||
@endphp
|
||||
|
||||
<img
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,44 @@
|
||||
<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>
|
||||
</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 +129,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>
|
||||
|
||||
@@ -12,12 +12,24 @@
|
||||
<div class="container-amare grid flex-1 content-start gap-12 py-16 md:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)] md:py-24">
|
||||
<div class="space-y-6">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">A Amare</p>
|
||||
<h1 class="text-4xl font-medium tracking-tight text-amare-text md:text-5xl">Humana no cuidado. Precisa na entrega.</h1>
|
||||
<h1 class="text-headline font-medium tracking-tight text-amare-text">Humana no cuidado. Precisa na entrega.</h1>
|
||||
<p class="text-lg text-amare-muted">{{ $siteSettings->about_summary }}</p>
|
||||
<p class="text-amare-muted">
|
||||
A {{ $siteSettings->brand_name }} atua em {{ $city }} com foco em planejamento completo,
|
||||
presença no dia do evento e uma condução serena do início ao fim.
|
||||
</p>
|
||||
|
||||
@if (filled($siteSettings->about_image_path))
|
||||
<div class="overflow-hidden bg-amare-bg-deep">
|
||||
<x-media.image
|
||||
:path="$siteSettings->about_image_path"
|
||||
:alt="$siteSettings->about_image_alt ?: $siteSettings->brand_name"
|
||||
loading="lazy"
|
||||
sizes="(max-width: 768px) 100vw, 50vw"
|
||||
class="img-editorial aspect-[4/3] w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<ul class="space-y-4 border-t border-amare-border pt-6" aria-label="Princípios da Amare">
|
||||
@@ -30,4 +42,6 @@
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<x-home.final-cta :settings="$siteSettings" />
|
||||
@endsection
|
||||
|
||||
@@ -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>
|
||||
<h1 class="text-headline font-medium tracking-tight text-amare-text">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
|
||||
|
||||
@@ -5,12 +5,12 @@
|
||||
<div class="container-amare space-y-10 py-16 md:py-24">
|
||||
<div class="max-w-2xl space-y-4">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Portfólio</p>
|
||||
<h1 class="text-4xl font-medium tracking-tight text-amare-text md:text-5xl">Atmosferas que contam histórias.</h1>
|
||||
<h1 class="text-headline font-medium tracking-tight text-amare-text">Atmosferas que contam histórias.</h1>
|
||||
<p class="text-lg text-amare-muted">Casos conduzidos com atenção a ritmo, composição e cada detalhe da experiência.</p>
|
||||
</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,9 @@
|
||||
</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>
|
||||
|
||||
<x-home.final-cta :settings="$siteSettings" />
|
||||
@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>
|
||||
<h1 class="text-4xl font-medium tracking-tight text-amare-text md:text-5xl">{{ $case->title }}</h1>
|
||||
@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-headline font-medium tracking-tight text-amare-text">{{ $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
|
||||
@@ -69,4 +71,6 @@
|
||||
</section>
|
||||
@endif
|
||||
</article>
|
||||
|
||||
<x-home.final-cta :settings="$siteSettings" />
|
||||
@endsection
|
||||
|
||||
@@ -5,12 +5,12 @@
|
||||
<div class="container-amare space-y-10 py-16 md:py-24">
|
||||
<div class="max-w-2xl space-y-4">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Serviços</p>
|
||||
<h1 class="text-4xl font-medium tracking-tight text-amare-text md:text-5xl">Uma mesma excelência, diferentes ocasiões.</h1>
|
||||
<h1 class="text-headline font-medium tracking-tight text-amare-text">Uma mesma excelência, diferentes ocasiões.</h1>
|
||||
<p class="text-lg text-amare-muted">O escopo é construído de acordo com o momento do projeto, o nível de apoio necessário e a complexidade de cada evento.</p>
|
||||
</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)
|
||||
@@ -41,4 +41,6 @@
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<x-home.final-cta :settings="$siteSettings" />
|
||||
@endsection
|
||||
|
||||
@@ -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
@@ -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
|
||||
- [x] Push + PR + CI browser verde (PR #15 merge f09ea83, run 31098941038)
|
||||
- [x] Refresh baselines de CI se necessário (não necessário — browser passou com baselines atuais)
|
||||
|
||||
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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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([]);
|
||||
});
|
||||
@@ -5,14 +5,23 @@ declare(strict_types=1);
|
||||
it('ships visual content fixtures as valid jpeg images', function (): void {
|
||||
$fixtures = [
|
||||
'og-default.jpg',
|
||||
'portfolio-cover.jpg',
|
||||
'service-cover.jpg',
|
||||
'gallery.jpg',
|
||||
'testimonial.jpg',
|
||||
'service-casamentos.jpg',
|
||||
'service-eventos-corporativos.jpg',
|
||||
'service-celebracoes-intimistas.jpg',
|
||||
'case-casamento-ana-lucas.jpg',
|
||||
'gallery-casamento-ana-lucas-1.jpg',
|
||||
'gallery-casamento-ana-lucas-2.jpg',
|
||||
'case-lancamento-verano.jpg',
|
||||
'gallery-lancamento-verano-1.jpg',
|
||||
'gallery-lancamento-verano-2.jpg',
|
||||
'case-mini-wedding-marina.jpg',
|
||||
'gallery-mini-wedding-marina-1.jpg',
|
||||
'gallery-mini-wedding-marina-2.jpg',
|
||||
'about-image.jpg',
|
||||
];
|
||||
|
||||
foreach ($fixtures as $fixture) {
|
||||
$path = dirname(__DIR__).'/fixtures/images/'.$fixture;
|
||||
$path = dirname(__DIR__, 2).'/database/fixtures/images/'.$fixture;
|
||||
|
||||
expect(is_file($path))->toBeTrue("Missing fixture: {$fixture}");
|
||||
|
||||
|
||||
BIN
tests/fixtures/images/gallery.jpg
vendored
|
Before Width: | Height: | Size: 310 B |
BIN
tests/fixtures/images/og-default.jpg
vendored
|
Before Width: | Height: | Size: 310 B |
BIN
tests/fixtures/images/portfolio-cover.jpg
vendored
|
Before Width: | Height: | Size: 310 B |
BIN
tests/fixtures/images/service-cover.jpg
vendored
|
Before Width: | Height: | Size: 311 B |
BIN
tests/fixtures/images/testimonial.jpg
vendored
|
Before Width: | Height: | Size: 310 B |