Compare commits
4 Commits
fix/seed-r
...
feat/front
| Author | SHA1 | Date | |
|---|---|---|---|
| 2b4e6a7d1c | |||
| f5830930bc | |||
| 8343867072 | |||
| 246410803d |
26
.github/workflows/ci.yml
vendored
@@ -31,8 +31,7 @@ jobs:
|
|||||||
name: static
|
name: static
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v4
|
||||||
- run: cp .env.example .env
|
|
||||||
|
|
||||||
- uses: shivammathur/setup-php@v2
|
- uses: shivammathur/setup-php@v2
|
||||||
with:
|
with:
|
||||||
@@ -40,7 +39,7 @@ jobs:
|
|||||||
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd
|
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd
|
||||||
coverage: none
|
coverage: none
|
||||||
|
|
||||||
- uses: actions/cache@v5
|
- uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: ~/.composer/cache/files
|
path: ~/.composer/cache/files
|
||||||
key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }}
|
key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }}
|
||||||
@@ -56,8 +55,7 @@ jobs:
|
|||||||
name: unit
|
name: unit
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v4
|
||||||
- run: cp .env.example .env
|
|
||||||
|
|
||||||
- uses: shivammathur/setup-php@v2
|
- uses: shivammathur/setup-php@v2
|
||||||
with:
|
with:
|
||||||
@@ -65,7 +63,7 @@ jobs:
|
|||||||
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd
|
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd
|
||||||
coverage: none
|
coverage: none
|
||||||
|
|
||||||
- uses: actions/cache@v5
|
- uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: ~/.composer/cache/files
|
path: ~/.composer/cache/files
|
||||||
key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }}
|
key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }}
|
||||||
@@ -94,8 +92,7 @@ jobs:
|
|||||||
--health-timeout 5s
|
--health-timeout 5s
|
||||||
--health-retries 10
|
--health-retries 10
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v4
|
||||||
- run: cp .env.example .env
|
|
||||||
|
|
||||||
- uses: shivammathur/setup-php@v2
|
- uses: shivammathur/setup-php@v2
|
||||||
with:
|
with:
|
||||||
@@ -103,13 +100,13 @@ jobs:
|
|||||||
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd
|
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd
|
||||||
coverage: none
|
coverage: none
|
||||||
|
|
||||||
- uses: actions/cache@v5
|
- uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: ~/.composer/cache/files
|
path: ~/.composer/cache/files
|
||||||
key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }}
|
key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }}
|
||||||
restore-keys: composer-${{ runner.os }}-
|
restore-keys: composer-${{ runner.os }}-
|
||||||
|
|
||||||
- uses: actions/cache@v5
|
- uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: ~/.npm
|
path: ~/.npm
|
||||||
key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
|
key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
|
||||||
@@ -139,8 +136,7 @@ jobs:
|
|||||||
--health-timeout 5s
|
--health-timeout 5s
|
||||||
--health-retries 10
|
--health-retries 10
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v4
|
||||||
- run: cp .env.example .env
|
|
||||||
|
|
||||||
- uses: shivammathur/setup-php@v2
|
- uses: shivammathur/setup-php@v2
|
||||||
with:
|
with:
|
||||||
@@ -148,13 +144,13 @@ jobs:
|
|||||||
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd
|
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd
|
||||||
coverage: none
|
coverage: none
|
||||||
|
|
||||||
- uses: actions/cache@v5
|
- uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: ~/.composer/cache/files
|
path: ~/.composer/cache/files
|
||||||
key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }}
|
key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }}
|
||||||
restore-keys: composer-${{ runner.os }}-
|
restore-keys: composer-${{ runner.os }}-
|
||||||
|
|
||||||
- uses: actions/cache@v5
|
- uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: ~/.npm
|
path: ~/.npm
|
||||||
key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
|
key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
|
||||||
@@ -228,7 +224,7 @@ jobs:
|
|||||||
name: container
|
name: container
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Build production image
|
- name: Build production image
|
||||||
run: docker build -t amare-app:ci .
|
run: docker build -t amare-app:ci .
|
||||||
|
|||||||
1
.gitignore
vendored
@@ -18,7 +18,6 @@
|
|||||||
/public/fonts-manifest.dev.json
|
/public/fonts-manifest.dev.json
|
||||||
/public/hot
|
/public/hot
|
||||||
/public/storage
|
/public/storage
|
||||||
/tests/Browser/Screenshots
|
|
||||||
/storage/*.key
|
/storage/*.key
|
||||||
/storage/pail
|
/storage/pail
|
||||||
/vendor
|
/vendor
|
||||||
|
|||||||
104
SPEC.md
@@ -14,8 +14,7 @@
|
|||||||
| Estágio | MVP |
|
| Estágio | MVP |
|
||||||
| Status da especificação | Aprovada para implementação |
|
| Status da especificação | Aprovada para implementação |
|
||||||
| Idioma da interface | Português do Brasil (`pt-BR`) |
|
| Idioma da interface | Português do Brasil (`pt-BR`) |
|
||||||
| Timezone padrão | `America/Sao_Paulo` |
|
| Timezone padrão | `America/Fortaleza` |
|
||||||
| Cidade de atuação | São Paulo (capital) |
|
|
||||||
| Moeda | BRL, sem conversão entre moedas |
|
| Moeda | BRL, sem conversão entre moedas |
|
||||||
| Princípio principal | YAGNI — implementar somente o necessário para validar o produto |
|
| Princípio principal | YAGNI — implementar somente o necessário para validar o produto |
|
||||||
| Arquitetura | Monólito modular Laravel |
|
| Arquitetura | Monólito modular Laravel |
|
||||||
@@ -45,8 +44,6 @@ Em caso de conflito, seguir esta ordem:
|
|||||||
|
|
||||||
O agente **NÃO DEVE** alterar silenciosamente uma decisão deste documento. Uma alteração de escopo ou arquitetura deve atualizar esta especificação ou criar uma ADR.
|
O agente **NÃO DEVE** alterar silenciosamente uma decisão deste documento. Uma alteração de escopo ou arquitetura deve atualizar esta especificação ou criar uma ADR.
|
||||||
|
|
||||||
Mudanças incrementais são planejadas em `openspec/changes/` e, após arquivadas, este documento DEVE ser revalidado para incorporar decisões ratificadas (back-sync). Este arquivo permanece a fonte de verdade do produto.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. Contrato de operação para agentes
|
## 1. Contrato de operação para agentes
|
||||||
@@ -352,14 +349,13 @@ A home DEVE conter, nesta ordem aproximada:
|
|||||||
|
|
||||||
1. header e navegação;
|
1. header e navegação;
|
||||||
2. hero com proposta de valor e CTA;
|
2. hero com proposta de valor e CTA;
|
||||||
3. manifesto da marca;
|
3. prova visual por eventos em destaque;
|
||||||
4. resumo dos serviços em destaque;
|
4. resumo dos serviços;
|
||||||
5. casos selecionados do portfólio;
|
5. método de trabalho;
|
||||||
6. método de trabalho (4 passos);
|
6. casos selecionados;
|
||||||
7. depoimentos;
|
7. depoimentos;
|
||||||
8. posicionamento/perfil;
|
8. CTA final para briefing;
|
||||||
9. CTA final para briefing;
|
9. footer com contato, redes e links legais.
|
||||||
10. footer com contato, redes e links legais.
|
|
||||||
|
|
||||||
A ordem pode variar apenas se a revisão de UX justificar a mudança.
|
A ordem pode variar apenas se a revisão de UX justificar a mudança.
|
||||||
|
|
||||||
@@ -378,15 +374,6 @@ Centralizar tokens de:
|
|||||||
|
|
||||||
Não espalhar valores visuais arbitrários por componentes.
|
Não espalhar valores visuais arbitrários por componentes.
|
||||||
|
|
||||||
O MVP adota o sistema de design **Heritage Editorial** (ver DESIGN.md e ADR-013):
|
|
||||||
|
|
||||||
- tipografia serifada auto-hospedada (EB Garamond) com escala de display a label;
|
|
||||||
- paleta papel/oliva/sálvia/tinta com superfícies tonais; hierarquia sem sombras de card;
|
|
||||||
- raio de borda zero para superfícies interativas e de conteúdo;
|
|
||||||
- largura de container 1120px e ritmo de espaçamento de 8px;
|
|
||||||
- `prefers-reduced-motion` respeitado;
|
|
||||||
- contraste WCAG AA (tinta sobre papel e oliva sobre papel).
|
|
||||||
|
|
||||||
### 6.4 Requisitos de mídia
|
### 6.4 Requisitos de mídia
|
||||||
|
|
||||||
- Imagens públicas DEVERÃO possuir texto alternativo.
|
- Imagens públicas DEVERÃO possuir texto alternativo.
|
||||||
@@ -396,7 +383,6 @@ O MVP adota o sistema de design **Heritage Editorial** (ver DESIGN.md e ADR-013)
|
|||||||
- Dimensões DEVERÃO ser reservadas para evitar layout shift.
|
- Dimensões DEVERÃO ser reservadas para evitar layout shift.
|
||||||
- Não armazenar blobs de imagem no PostgreSQL.
|
- Não armazenar blobs de imagem no PostgreSQL.
|
||||||
- Não depender do disco efêmero do contêiner em produção.
|
- Não depender do disco efêmero do contêiner em produção.
|
||||||
- O logotipo da marca DEVE possuir texto alternativo e variantes claro/escuro; `site_settings.logo_path` sobrescreve o asset padrão quando preenchido.
|
|
||||||
|
|
||||||
### 6.5 Acessibilidade
|
### 6.5 Acessibilidade
|
||||||
|
|
||||||
@@ -548,10 +534,12 @@ Campos públicos:
|
|||||||
- validar no servidor;
|
- validar no servidor;
|
||||||
- usar honeypot e rate limiting;
|
- usar honeypot e rate limiting;
|
||||||
- impedir duplo envio acidental;
|
- impedir duplo envio acidental;
|
||||||
|
- criar Lead com status `new`;
|
||||||
|
- registrar origem `website`;
|
||||||
|
- notificar administradores;
|
||||||
|
- exibir sucesso sem revelar dados internos;
|
||||||
- enviar e-mail de confirmação quando o serviço de e-mail estiver configurado;
|
- enviar e-mail de confirmação quando o serviço de e-mail estiver configurado;
|
||||||
- exibir sucesso sem revelar dados internos.
|
- falha no e-mail não pode apagar o lead já criado.
|
||||||
|
|
||||||
> **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:**
|
**Aceite:**
|
||||||
|
|
||||||
@@ -1265,19 +1253,10 @@ Singleton:
|
|||||||
|
|
||||||
- `id` bigint PK;
|
- `id` bigint PK;
|
||||||
- `brand_name`;
|
- `brand_name`;
|
||||||
- `logo_path` nullable;
|
|
||||||
- `logo_alt` nullable;
|
|
||||||
- `hero_eyebrow` nullable;
|
- `hero_eyebrow` nullable;
|
||||||
- `hero_title`;
|
- `hero_title`;
|
||||||
- `hero_subtitle`;
|
- `hero_subtitle`;
|
||||||
- `hero_cta_label`;
|
- `hero_cta_label`;
|
||||||
- `hero_cta_secondary_label` nullable;
|
|
||||||
- `hero_note` nullable;
|
|
||||||
- `manifesto_title`;
|
|
||||||
- `manifesto_lead`;
|
|
||||||
- `manifesto_body`;
|
|
||||||
- `method_steps` jsonb (4 passos tipados);
|
|
||||||
- `principles` jsonb (lista tipada);
|
|
||||||
- `about_summary` nullable;
|
- `about_summary` nullable;
|
||||||
- `email`;
|
- `email`;
|
||||||
- `phone`;
|
- `phone`;
|
||||||
@@ -1286,8 +1265,7 @@ Singleton:
|
|||||||
- `default_meta_title`;
|
- `default_meta_title`;
|
||||||
- `default_meta_description`;
|
- `default_meta_description`;
|
||||||
- `default_og_image_path` nullable;
|
- `default_og_image_path` nullable;
|
||||||
- `default_og_image_alt` nullable;
|
- analytics fields nullable;
|
||||||
- `analytics_enabled` boolean default false;
|
|
||||||
- timestamps.
|
- timestamps.
|
||||||
|
|
||||||
#### `services`
|
#### `services`
|
||||||
@@ -1558,7 +1536,7 @@ Constraints de banco devem proteger:
|
|||||||
| Servidor | FrankenPHP + Caddy |
|
| Servidor | FrankenPHP + Caddy |
|
||||||
| Assets | Vite |
|
| Assets | Vite |
|
||||||
| Testes | Pest 4 + Pest Browser/Playwright |
|
| Testes | Pest 4 + Pest Browser/Playwright |
|
||||||
| Arquivos | Laravel Filesystem + Cloudflare R2 (S3-compatible) em produção |
|
| Arquivos | Laravel Filesystem + storage S3-compatible em produção |
|
||||||
| Fila | Database queue |
|
| Fila | Database queue |
|
||||||
| Scheduler | Laravel Scheduler em processo separado |
|
| Scheduler | Laravel Scheduler em processo separado |
|
||||||
|
|
||||||
@@ -1786,8 +1764,6 @@ Não transformar todas as seções estáticas em componentes Livewire. Usar Blad
|
|||||||
|
|
||||||
### 11.2 Formulário de briefing
|
### 11.2 Formulário de briefing
|
||||||
|
|
||||||
> **Estado atual (Fases 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:
|
O componente deve:
|
||||||
|
|
||||||
- ter estado tipado ou Form Object quando útil;
|
- ter estado tipado ou Form Object quando útil;
|
||||||
@@ -1994,7 +1970,7 @@ Determinismo obrigatório:
|
|||||||
|
|
||||||
- Chromium e imagem Linux fixos;
|
- Chromium e imagem Linux fixos;
|
||||||
- viewport fixo;
|
- viewport fixo;
|
||||||
- timezone `America/Sao_Paulo`;
|
- timezone `America/Fortaleza`;
|
||||||
- locale `pt-BR`;
|
- locale `pt-BR`;
|
||||||
- fontes instaladas na imagem;
|
- fontes instaladas na imagem;
|
||||||
- relógio congelado;
|
- relógio congelado;
|
||||||
@@ -2076,7 +2052,7 @@ quality → Pint check + PHPStan/Larastan + audits + testes
|
|||||||
|
|
||||||
| Job | Responsabilidade | Bloqueia merge |
|
| Job | Responsabilidade | Bloqueia merge |
|
||||||
|---|---|---:|
|
|---|---|---:|
|
||||||
| `static` | Pint, PHPStan/Larastan, Composer validate, Composer e npm audit | Sim |
|
| `static` | Pint, PHPStan/Larastan, Composer validate e audits | Sim |
|
||||||
| `unit` | Unitários, arquitetura e cobertura | Sim |
|
| `unit` | Unitários, arquitetura e cobertura | Sim |
|
||||||
| `feature` | PostgreSQL, migrations, Livewire, Filament e integração | Sim |
|
| `feature` | PostgreSQL, migrations, Livewire, Filament e integração | Sim |
|
||||||
| `browser` | Vite, FrankenPHP, E2E, smoke, acessibilidade e visual | Sim |
|
| `browser` | Vite, FrankenPHP, E2E, smoke, acessibilidade e visual | Sim |
|
||||||
@@ -2097,11 +2073,11 @@ quality → Pint check + PHPStan/Larastan + audits + testes
|
|||||||
### 14.3 Branches e ambientes
|
### 14.3 Branches e ambientes
|
||||||
|
|
||||||
- PR: testes e preview opcional;
|
- PR: testes e preview opcional;
|
||||||
- `main`: build imutável por SHA publicado no GHCR e deploy automático em staging via Dokploy;
|
- `main`: build imutável por SHA e deploy automático em staging;
|
||||||
- staging: Dokploy Compose executa migração, healthcheck `/up` e smoke pós-deploy (`/up`, `/`, `/admin/login`);
|
- staging: migration, cache warmup e smoke pós-deploy;
|
||||||
- produção: promoção da mesma imagem aprovada, sem rebuild (retag do digest em `:production`);
|
- produção: promoção da mesma imagem aprovada, sem rebuild;
|
||||||
- produção requer aprovação humana explícita no MVP (`workflow_dispatch` com confirmação);
|
- produção requer aprovação humana explícita no MVP;
|
||||||
- rollback usa imagem anterior (SHA anterior, sem rebuild);
|
- rollback usa imagem anterior;
|
||||||
- migrations devem ser backward-compatible quando possível.
|
- migrations devem ser backward-compatible quando possível.
|
||||||
|
|
||||||
### 14.4 Definition of Done
|
### 14.4 Definition of Done
|
||||||
@@ -2183,7 +2159,7 @@ APP_DEBUG=false
|
|||||||
APP_URL
|
APP_URL
|
||||||
APP_LOCALE=pt_BR
|
APP_LOCALE=pt_BR
|
||||||
APP_FALLBACK_LOCALE=pt_BR
|
APP_FALLBACK_LOCALE=pt_BR
|
||||||
APP_TIMEZONE=America/Sao_Paulo
|
APP_TIMEZONE=America/Fortaleza
|
||||||
|
|
||||||
DB_CONNECTION=pgsql
|
DB_CONNECTION=pgsql
|
||||||
DB_HOST
|
DB_HOST
|
||||||
@@ -2196,16 +2172,19 @@ CACHE_STORE=database ou file conforme ambiente
|
|||||||
QUEUE_CONNECTION=database
|
QUEUE_CONNECTION=database
|
||||||
SESSION_DRIVER=database ou cookie conforme decisão
|
SESSION_DRIVER=database ou cookie conforme decisão
|
||||||
|
|
||||||
FILESYSTEM_DISK=r2 em produção
|
FILESYSTEM_DISK=s3 em produção
|
||||||
R2_ACCESS_KEY_ID
|
AWS_ACCESS_KEY_ID
|
||||||
R2_SECRET_ACCESS_KEY
|
AWS_SECRET_ACCESS_KEY
|
||||||
R2_BUCKET
|
AWS_DEFAULT_REGION
|
||||||
R2_ENDPOINT
|
AWS_BUCKET
|
||||||
R2_URL (domínio próprio opcional)
|
AWS_ENDPOINT opcional
|
||||||
AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_DEFAULT_REGION, AWS_BUCKET, AWS_ENDPOINT, AWS_USE_PATH_STYLE_ENDPOINT — opcionais, apenas se o disk s3 for usado
|
AWS_USE_PATH_STYLE_ENDPOINT opcional
|
||||||
|
|
||||||
MAIL_MAILER=resend em produção (mailer nativo Laravel)
|
MAIL_MAILER
|
||||||
RESEND_API_KEY
|
MAIL_HOST
|
||||||
|
MAIL_PORT
|
||||||
|
MAIL_USERNAME
|
||||||
|
MAIL_PASSWORD
|
||||||
MAIL_FROM_ADDRESS
|
MAIL_FROM_ADDRESS
|
||||||
MAIL_FROM_NAME
|
MAIL_FROM_NAME
|
||||||
```
|
```
|
||||||
@@ -2300,7 +2279,7 @@ O seed deve criar:
|
|||||||
- configurações do site;
|
- configurações do site;
|
||||||
- 3 serviços;
|
- 3 serviços;
|
||||||
- 3 casos de portfólio;
|
- 3 casos de portfólio;
|
||||||
- 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;
|
- 3 depoimentos;
|
||||||
- leads em estados variados;
|
- leads em estados variados;
|
||||||
- 2 eventos futuros e 1 concluído;
|
- 2 eventos futuros e 1 concluído;
|
||||||
- tarefas vencidas e futuras;
|
- tarefas vencidas e futuras;
|
||||||
@@ -2335,7 +2314,7 @@ O agente deve implementar na sequência, salvo instrução explícita.
|
|||||||
- [x] Filament instalado e autenticado;
|
- [x] Filament instalado e autenticado;
|
||||||
- [x] Livewire configurado;
|
- [x] Livewire configurado;
|
||||||
- [x] Tailwind/Vite;
|
- [x] Tailwind/Vite;
|
||||||
- [~] FrankenPHP e Docker Compose local (imagem pronta; serviço de aplicação local pendente);
|
- [x] FrankenPHP e Docker Compose local;
|
||||||
- [x] papéis admin/assistant;
|
- [x] papéis admin/assistant;
|
||||||
- [x] Pint;
|
- [x] Pint;
|
||||||
- [x] PHPStan/Larastan;
|
- [x] PHPStan/Larastan;
|
||||||
@@ -2346,13 +2325,6 @@ O agente deve implementar na sequência, salvo instrução explícita.
|
|||||||
- [x] design tokens mínimos;
|
- [x] design tokens mínimos;
|
||||||
- [x] healthcheck;
|
- [x] healthcheck;
|
||||||
- [x] seed de admin local.
|
- [x] seed de admin local.
|
||||||
- [ ] verificação de e-mail e reset seguro (MustVerifyEmail);
|
|
||||||
- [ ] npm audit no `composer quality` e no job `static`;
|
|
||||||
- [ ] gate de cobertura `Domain`/`Application` ≥ 80%;
|
|
||||||
- [ ] serviço de aplicação FrankenPHP no Compose local;
|
|
||||||
- [ ] hello-world implantado em staging (critério de saída).
|
|
||||||
|
|
||||||
> Os itens pendentes acima são tratados pela mudança OpenSpec `complete-foundation-parity`; o critério de saída da fase só é atingido com staging implantado.
|
|
||||||
|
|
||||||
**Critério de saída:** pipeline verde e hello-world implantado em staging.
|
**Critério de saída:** pipeline verde e hello-world implantado em staging.
|
||||||
|
|
||||||
@@ -2509,10 +2481,6 @@ Toda operação financeira deve:
|
|||||||
| ADR-008 | Database queue; Redis adiado | Aceita |
|
| ADR-008 | Database queue; Redis adiado | Aceita |
|
||||||
| ADR-009 | Dinheiro em BRL armazenado como centavos inteiros | Aceita |
|
| ADR-009 | Dinheiro em BRL armazenado como centavos inteiros | Aceita |
|
||||||
| ADR-010 | Home com estrutura fixa e CMS tipado, sem page builder | Aceita |
|
| ADR-010 | Home com estrutura fixa e CMS tipado, sem page builder | Aceita |
|
||||||
| ADR-011 | Cloudflare R2 (S3-compatible) como storage de objetos em produção | Aceita |
|
|
||||||
| ADR-012 | E-mail transacional via Resend (mailer nativo Laravel) | Aceita |
|
|
||||||
| ADR-013 | Design system Heritage Editorial para o site público | Aceita |
|
|
||||||
| ADR-014 | Deploy via Dokploy Compose com imagem imutável por SHA no GHCR | Aceita |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ final readonly class PageMeta
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the metadata for branded error pages.
|
* Build the metadata for branded error pages (404/500).
|
||||||
*
|
*
|
||||||
* Error pages carry no canonical, are excluded from search indexes and use
|
* Error pages carry no canonical, are excluded from search indexes and use
|
||||||
* the page name suffixed with the brand name as their title.
|
* the page name suffixed with the brand name as their title.
|
||||||
@@ -94,13 +94,9 @@ final readonly class PageMeta
|
|||||||
SiteSetting $settings,
|
SiteSetting $settings,
|
||||||
int $status = 404,
|
int $status = 404,
|
||||||
): self {
|
): self {
|
||||||
[$title, $description] = match ($status) {
|
[$title, $description] = $status === 500
|
||||||
500 => ['Algo deu errado', 'Não foi possível concluir o pedido. Tente novamente em instantes.'],
|
? ['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.'],
|
: ['Página não encontrada', 'A página que você procura não existe ou foi movida.'];
|
||||||
429 => ['Muitas solicitações', 'Você enviou muitas solicitações em pouco tempo. Aguarde um instante e tente novamente.'],
|
|
||||||
503 => ['Em manutenção', 'Estamos realizando uma breve manutenção. Tente novamente em instantes.'],
|
|
||||||
default => ['Página não encontrada', 'A página que você procura não existe ou foi movida.'],
|
|
||||||
};
|
|
||||||
|
|
||||||
return new self(
|
return new self(
|
||||||
title: trim($title).' - '.$settings->brand_name,
|
title: trim($title).' - '.$settings->brand_name,
|
||||||
|
|||||||
@@ -56,9 +56,6 @@ final class MediaGenerateVariantsCommand extends Command
|
|||||||
if ($settings && filled($settings->default_og_image_path)) {
|
if ($settings && filled($settings->default_og_image_path)) {
|
||||||
$paths[] = (string) $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) {
|
foreach (Service::query()->whereNotNull('cover_image_path')->pluck('cover_image_path') as $path) {
|
||||||
$paths[] = (string) $path;
|
$paths[] = (string) $path;
|
||||||
|
|||||||
@@ -228,12 +228,6 @@ class ManageSiteSettings extends Page
|
|||||||
->addActionLabel('Adicionar rede'),
|
->addActionLabel('Adicionar rede'),
|
||||||
])
|
])
|
||||||
->columns(2),
|
->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')
|
Section::make('SEO padrão')
|
||||||
->schema([
|
->schema([
|
||||||
TextInput::make('default_meta_title')
|
TextInput::make('default_meta_title')
|
||||||
|
|||||||
@@ -16,8 +16,6 @@ use Illuminate\Database\Eloquent\Model;
|
|||||||
* @property bool $analytics_enabled
|
* @property bool $analytics_enabled
|
||||||
* @property string|null $default_og_image_path
|
* @property string|null $default_og_image_path
|
||||||
* @property string|null $default_og_image_alt
|
* @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_path
|
||||||
* @property string|null $logo_alt
|
* @property string|null $logo_alt
|
||||||
*/
|
*/
|
||||||
@@ -32,8 +30,6 @@ use Illuminate\Database\Eloquent\Model;
|
|||||||
'hero_secondary_cta_label',
|
'hero_secondary_cta_label',
|
||||||
'hero_note',
|
'hero_note',
|
||||||
'about_summary',
|
'about_summary',
|
||||||
'about_image_path',
|
|
||||||
'about_image_alt',
|
|
||||||
'manifesto_title',
|
'manifesto_title',
|
||||||
'manifesto_lead',
|
'manifesto_lead',
|
||||||
'manifesto_body',
|
'manifesto_body',
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 151 KiB |
|
Before Width: | Height: | Size: 436 KiB |
|
Before Width: | Height: | Size: 254 KiB |
|
Before Width: | Height: | Size: 258 KiB |
|
Before Width: | Height: | Size: 622 KiB |
|
Before Width: | Height: | Size: 499 KiB |
|
Before Width: | Height: | Size: 244 KiB |
|
Before Width: | Height: | Size: 235 KiB |
|
Before Width: | Height: | Size: 239 KiB |
|
Before Width: | Height: | Size: 236 KiB |
|
Before Width: | Height: | Size: 360 KiB |
|
Before Width: | Height: | Size: 330 KiB |
|
Before Width: | Height: | Size: 302 KiB |
|
Before Width: | Height: | Size: 337 KiB |
@@ -1,25 +0,0 @@
|
|||||||
<?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,7 +8,6 @@ use App\Models\PortfolioCase;
|
|||||||
use App\Models\PortfolioImage;
|
use App\Models\PortfolioImage;
|
||||||
use App\Models\Service;
|
use App\Models\Service;
|
||||||
use App\Models\SiteSetting;
|
use App\Models\SiteSetting;
|
||||||
use App\Support\PublicImageUploadRules;
|
|
||||||
use Illuminate\Database\Seeder;
|
use Illuminate\Database\Seeder;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Facades\File;
|
use Illuminate\Support\Facades\File;
|
||||||
@@ -37,8 +36,6 @@ class ContentSeeder extends Seeder
|
|||||||
'hero_secondary_cta_label' => 'Conheça nosso olhar',
|
'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.',
|
'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_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_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_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.',
|
'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.',
|
||||||
@@ -94,7 +91,7 @@ class ContentSeeder extends Seeder
|
|||||||
['slug' => $service['slug']],
|
['slug' => $service['slug']],
|
||||||
[
|
[
|
||||||
...$service,
|
...$service,
|
||||||
'cover_image_path' => $this->copyFixture('service-'.$service['slug'].'.jpg', 'content/services/'.$service['slug'].'.jpg'),
|
'cover_image_path' => $this->copyFixture('service-cover.jpg', 'content/services/'.$service['slug'].'.jpg'),
|
||||||
'cover_image_alt' => 'Capa do serviço '.$service['title'],
|
'cover_image_alt' => 'Capa do serviço '.$service['title'],
|
||||||
'published_at' => Carbon::parse(self::SEED_TIMESTAMP),
|
'published_at' => Carbon::parse(self::SEED_TIMESTAMP),
|
||||||
],
|
],
|
||||||
@@ -151,7 +148,7 @@ class ContentSeeder extends Seeder
|
|||||||
['slug' => $caseData['slug']],
|
['slug' => $caseData['slug']],
|
||||||
[
|
[
|
||||||
...$caseData,
|
...$caseData,
|
||||||
'cover_image_path' => $this->copyFixture('case-'.$caseData['slug'].'.jpg', 'content/portfolio/'.$caseData['slug'].'-cover.jpg'),
|
'cover_image_path' => $this->copyFixture('portfolio-cover.jpg', 'content/portfolio/'.$caseData['slug'].'-cover.jpg'),
|
||||||
'cover_image_alt' => 'Capa do caso '.$caseData['title'],
|
'cover_image_alt' => 'Capa do caso '.$caseData['title'],
|
||||||
'is_featured' => true,
|
'is_featured' => true,
|
||||||
'published_at' => Carbon::parse(self::SEED_TIMESTAMP),
|
'published_at' => Carbon::parse(self::SEED_TIMESTAMP),
|
||||||
@@ -163,7 +160,7 @@ class ContentSeeder extends Seeder
|
|||||||
foreach ([1, 2] as $index) {
|
foreach ([1, 2] as $index) {
|
||||||
PortfolioImage::query()->create([
|
PortfolioImage::query()->create([
|
||||||
'portfolio_case_id' => $case->id,
|
'portfolio_case_id' => $case->id,
|
||||||
'path' => $this->copyFixture('gallery-'.$caseData['slug'].'-'.$index.'.jpg', 'content/portfolio/'.$caseData['slug'].'-gallery-'.$index.'.jpg'),
|
'path' => $this->copyFixture('gallery.jpg', 'content/portfolio/'.$caseData['slug'].'-gallery-'.$index.'.jpg'),
|
||||||
'alt_text' => 'Galeria '.$caseData['title'].' '.$index,
|
'alt_text' => 'Galeria '.$caseData['title'].' '.$index,
|
||||||
'caption' => $index === 1 ? 'Detalhe da decoração' : null,
|
'caption' => $index === 1 ? 'Detalhe da decoração' : null,
|
||||||
'sort_order' => $index,
|
'sort_order' => $index,
|
||||||
@@ -174,10 +171,8 @@ class ContentSeeder extends Seeder
|
|||||||
|
|
||||||
private function copyFixture(string $fixtureName, string $destination): string
|
private function copyFixture(string $fixtureName, string $destination): string
|
||||||
{
|
{
|
||||||
$source = base_path('database/fixtures/images/'.$fixtureName);
|
$source = base_path('tests/fixtures/images/'.$fixtureName);
|
||||||
$disk = PublicImageUploadRules::disk();
|
Storage::disk('public')->put($destination, File::get($source));
|
||||||
|
|
||||||
Storage::disk($disk)->put($destination, File::get($source), 'public');
|
|
||||||
|
|
||||||
return $destination;
|
return $destination;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ use App\Models\SiteSetting;
|
|||||||
use App\Models\Testimonial;
|
use App\Models\Testimonial;
|
||||||
use Illuminate\Database\Seeder;
|
use Illuminate\Database\Seeder;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Facades\File;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -43,8 +44,6 @@ class VisualContentSeeder extends Seeder
|
|||||||
'hero_secondary_cta_label' => 'Conheça nosso olhar',
|
'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.',
|
'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_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_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_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.',
|
'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.',
|
||||||
@@ -59,7 +58,7 @@ class VisualContentSeeder extends Seeder
|
|||||||
],
|
],
|
||||||
'default_meta_title' => 'Amare Assessoria de Eventos',
|
'default_meta_title' => 'Amare Assessoria de Eventos',
|
||||||
'default_meta_description' => 'Assessoria premium para casamentos e eventos corporativos em São Paulo.',
|
'default_meta_description' => 'Assessoria premium para casamentos e eventos corporativos em São Paulo.',
|
||||||
'default_og_image_path' => $this->writeSolidJpeg('visual/og/og-default.jpg', 1600, 1067, [85, 107, 47]),
|
'default_og_image_path' => $this->copyFixture('og-default.jpg', 'visual/og/og-default.jpg'),
|
||||||
'default_og_image_alt' => 'Identidade visual da Amare Assessoria de Eventos',
|
'default_og_image_alt' => 'Identidade visual da Amare Assessoria de Eventos',
|
||||||
'analytics_enabled' => false,
|
'analytics_enabled' => false,
|
||||||
'analytics_script' => null,
|
'analytics_script' => null,
|
||||||
@@ -90,7 +89,7 @@ class VisualContentSeeder extends Seeder
|
|||||||
['slug' => $service['slug']],
|
['slug' => $service['slug']],
|
||||||
[
|
[
|
||||||
...$service,
|
...$service,
|
||||||
'cover_image_path' => $this->writeSolidJpeg('visual/services/'.$service['slug'].'.jpg', 1600, 1000, [196, 200, 184]),
|
'cover_image_path' => $this->copyFixture('service-cover.jpg', 'visual/services/'.$service['slug'].'.jpg'),
|
||||||
'cover_image_alt' => 'Capa do serviço '.$service['title'],
|
'cover_image_alt' => 'Capa do serviço '.$service['title'],
|
||||||
'published_at' => Carbon::parse(self::SEED_TIMESTAMP),
|
'published_at' => Carbon::parse(self::SEED_TIMESTAMP),
|
||||||
],
|
],
|
||||||
@@ -134,7 +133,7 @@ class VisualContentSeeder extends Seeder
|
|||||||
['slug' => $caseData['slug']],
|
['slug' => $caseData['slug']],
|
||||||
[
|
[
|
||||||
...$caseData,
|
...$caseData,
|
||||||
'cover_image_path' => $this->writeSolidJpeg('visual/portfolio/'.$caseData['slug'].'-cover.jpg', 1600, 1200, [240, 238, 233]),
|
'cover_image_path' => $this->copyFixture('portfolio-cover.jpg', 'visual/portfolio/'.$caseData['slug'].'-cover.jpg'),
|
||||||
'cover_image_alt' => 'Capa do caso '.$caseData['title'],
|
'cover_image_alt' => 'Capa do caso '.$caseData['title'],
|
||||||
'is_featured' => true,
|
'is_featured' => true,
|
||||||
'published_at' => Carbon::parse(self::SEED_TIMESTAMP),
|
'published_at' => Carbon::parse(self::SEED_TIMESTAMP),
|
||||||
@@ -145,7 +144,7 @@ class VisualContentSeeder extends Seeder
|
|||||||
|
|
||||||
PortfolioImage::query()->create([
|
PortfolioImage::query()->create([
|
||||||
'portfolio_case_id' => $case->id,
|
'portfolio_case_id' => $case->id,
|
||||||
'path' => $this->writeSolidJpeg('visual/portfolio/'.$caseData['slug'].'-gallery-1.jpg', 1600, 1200, [228, 226, 221]),
|
'path' => $this->copyFixture('gallery.jpg', 'visual/portfolio/'.$caseData['slug'].'-gallery-1.jpg'),
|
||||||
'alt_text' => 'Galeria '.$caseData['title'].' 1',
|
'alt_text' => 'Galeria '.$caseData['title'].' 1',
|
||||||
'caption' => 'Detalhe da decoração',
|
'caption' => 'Detalhe da decoração',
|
||||||
'sort_order' => 1,
|
'sort_order' => 1,
|
||||||
@@ -187,21 +186,10 @@ 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
|
|
||||||
{
|
{
|
||||||
$image = imagecreatetruecolor($width, $height);
|
$source = base_path('tests/fixtures/images/'.$fixtureName);
|
||||||
$color = imagecolorallocate($image, $rgb[0], $rgb[1], $rgb[2]);
|
Storage::disk('public')->put($destination, File::get($source));
|
||||||
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;
|
return $destination;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ services:
|
|||||||
restart: "no"
|
restart: "no"
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
command: ["sh", "-c", "php artisan migrate --force --no-interaction && php artisan db:seed --class=ContentSeeder --force; php artisan media:generate-variants --force; true"]
|
command: ["php", "artisan", "migrate", "--force", "--no-interaction"]
|
||||||
networks:
|
networks:
|
||||||
- dokploy-network
|
- dokploy-network
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ schema: spec-driven
|
|||||||
|
|
||||||
context: |
|
context: |
|
||||||
Fonte de verdade: SPEC.md na raiz. Precedência: instrução do dono do produto > SPEC.md > ADRs > testes > convenções.
|
Fonte de verdade: SPEC.md na raiz. Precedência: instrução do dono do produto > SPEC.md > ADRs > testes > convenções.
|
||||||
Produto: plataforma de assessoria de eventos, single-tenant, MVP. UI em pt-BR, timezone America/Sao_Paulo, atuação em São Paulo (capital), BRL.
|
Produto: plataforma de assessoria de eventos, single-tenant, MVP. UI em pt-BR, timezone America/Fortaleza, BRL.
|
||||||
Stack: Laravel 13, Filament 5 (/admin), Livewire 4 + Blade + Alpine + Tailwind (site público),
|
Stack: Laravel 13, Filament 5 (/admin), Livewire 4 + Blade + Alpine + Tailwind (site público),
|
||||||
PostgreSQL, FrankenPHP regular mode (sem worker mode), Vite, Pest 4 + Pest Browser, database queue.
|
PostgreSQL, FrankenPHP regular mode (sem worker mode), Vite, Pest 4 + Pest Browser, database queue.
|
||||||
Arquitetura: monólito modular. Interface -> Application (Actions/Queries) -> Domain (Enums/VOs) -> Infrastructure.
|
Arquitetura: monólito modular. Interface -> Application (Actions/Queries) -> Domain (Enums/VOs) -> Infrastructure.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# container-runtime Specification
|
# container-runtime Specification
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
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`.
|
TBD - created by archiving change setup-foundation. Update Purpose after archive.
|
||||||
## Requirements
|
## Requirements
|
||||||
### Requirement: Production image uses multi-stage FrankenPHP build
|
### Requirement: Production image uses multi-stage FrankenPHP build
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# design-tokens Specification
|
# design-tokens Specification
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
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.
|
TBD - created by archiving change setup-foundation. Update Purpose after archive.
|
||||||
## Requirements
|
## Requirements
|
||||||
### Requirement: Design tokens are centralized for the public site
|
### Requirement: Design tokens are centralized for the public site
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# health-check Specification
|
# health-check Specification
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
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.
|
TBD - created by archiving change setup-foundation. Update Purpose after archive.
|
||||||
## Requirements
|
## Requirements
|
||||||
### Requirement: Public health endpoint responds without authentication
|
### Requirement: Public health endpoint responds without authentication
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# internal-authentication Specification
|
# internal-authentication Specification
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
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.
|
TBD - created by archiving change setup-foundation. Update Purpose after archive.
|
||||||
## Requirements
|
## Requirements
|
||||||
### Requirement: Internal users authenticate via Filament panel
|
### Requirement: Internal users authenticate via Filament panel
|
||||||
|
|
||||||
|
|||||||
@@ -18,13 +18,6 @@
|
|||||||
--text-3xl: var(--amare-text-3xl);
|
--text-3xl: var(--amare-text-3xl);
|
||||||
--text-4xl: var(--amare-text-4xl);
|
--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-1: var(--amare-space-1);
|
||||||
--spacing-2: var(--amare-space-2);
|
--spacing-2: var(--amare-space-2);
|
||||||
--spacing-3: var(--amare-space-3);
|
--spacing-3: var(--amare-space-3);
|
||||||
@@ -66,7 +59,6 @@
|
|||||||
background-color: var(--amare-color-bg);
|
background-color: var(--amare-color-bg);
|
||||||
color: var(--amare-color-text);
|
color: var(--amare-color-text);
|
||||||
font-family: var(--amare-font-serif);
|
font-family: var(--amare-font-serif);
|
||||||
overflow-wrap: break-word;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[id$='-heading'] {
|
[id$='-heading'] {
|
||||||
@@ -176,7 +168,6 @@
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--amare-color-muted);
|
color: var(--amare-color-muted);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
overflow-wrap: break-word;
|
|
||||||
transition: color var(--amare-duration-fast) var(--amare-ease-standard);
|
transition: color var(--amare-duration-fast) var(--amare-ease-standard);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
|
|
||||||
if (menuButton && navigation) {
|
if (menuButton && navigation) {
|
||||||
const isDesktop = () => window.matchMedia('(min-width: 768px)').matches;
|
const isDesktop = () => window.matchMedia('(min-width: 768px)').matches;
|
||||||
const isMenuOpen = () => navigation.classList.contains('is-open');
|
|
||||||
|
|
||||||
const setOpen = (isOpen, { returnFocus = false } = {}) => {
|
const setOpen = (isOpen, { returnFocus = false } = {}) => {
|
||||||
navigation.classList.toggle('is-open', isOpen);
|
navigation.classList.toggle('is-open', isOpen);
|
||||||
@@ -40,27 +39,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
navigation.addEventListener('keydown', (event) => {
|
navigation.addEventListener('keydown', (event) => {
|
||||||
if (event.key === 'Escape') {
|
if (event.key === 'Escape') {
|
||||||
setOpen(false, { returnFocus: true });
|
setOpen(false, { returnFocus: true });
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.key === 'Tab' && !isDesktop() && isMenuOpen()) {
|
|
||||||
const focusables = navigation.querySelectorAll('a[href], button:not([disabled])');
|
|
||||||
|
|
||||||
if (focusables.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const first = focusables[0];
|
|
||||||
const last = focusables[focusables.length - 1];
|
|
||||||
|
|
||||||
if (event.shiftKey && document.activeElement === first) {
|
|
||||||
event.preventDefault();
|
|
||||||
last.focus();
|
|
||||||
} else if (!event.shiftKey && document.activeElement === last) {
|
|
||||||
event.preventDefault();
|
|
||||||
first.focus();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -75,33 +53,12 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
document.querySelectorAll('form[data-contact-form]').forEach((form) => {
|
document.querySelectorAll('form[data-contact-form]').forEach((form) => {
|
||||||
const submitButton = form.querySelector('[data-submit-button]');
|
|
||||||
|
|
||||||
const resetSubmitState = () => {
|
|
||||||
form.removeAttribute('aria-busy');
|
|
||||||
form.removeAttribute('aria-disabled');
|
|
||||||
|
|
||||||
if (submitButton) {
|
|
||||||
submitButton.disabled = false;
|
|
||||||
submitButton.textContent = submitButton.dataset.defaultLabel ?? 'Enviar briefing';
|
|
||||||
submitButton.removeAttribute('aria-busy');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
form.addEventListener('submit', () => {
|
form.addEventListener('submit', () => {
|
||||||
if (submitButton) {
|
const button = form.querySelector('[data-submit-button]');
|
||||||
submitButton.dataset.defaultLabel ??= submitButton.textContent.trim();
|
|
||||||
submitButton.textContent = 'Enviando…';
|
|
||||||
submitButton.disabled = true;
|
|
||||||
submitButton.setAttribute('aria-busy', 'true');
|
|
||||||
}
|
|
||||||
|
|
||||||
form.setAttribute('aria-busy', 'true');
|
if (button) {
|
||||||
});
|
button.disabled = true;
|
||||||
|
button.setAttribute('aria-busy', 'true');
|
||||||
window.addEventListener('pageshow', (event) => {
|
|
||||||
if (event.persisted) {
|
|
||||||
resetSubmitState();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<section aria-labelledby="final-cta-heading" class="border-t border-amare-border bg-amare-bg-deep py-20" data-chapter="final-cta">
|
<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>
|
<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>
|
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Próximo passo</p>
|
||||||
<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>
|
<h2 id="final-cta-heading" class="text-3xl font-medium text-amare-text md:text-4xl">Do casamento ao evento corporativo, tudo começa com uma boa conversa.</h2>
|
||||||
<p class="mx-auto max-w-2xl text-amare-muted">
|
<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.
|
Compartilhe as primeiras informações do seu evento. A Amare retorna para entender o contexto e orientar os próximos passos.
|
||||||
</p>
|
</p>
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
href="{{ route('contact') }}"
|
href="{{ route('contact') }}"
|
||||||
class="inline-flex min-h-11 items-center bg-amare-accent px-5 py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-accent-text transition-colors duration-(--amare-duration-normal) ease-(--amare-ease-standard) hover:bg-amare-accent-deep"
|
class="inline-flex min-h-11 items-center bg-amare-accent px-5 py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-accent-text transition-colors duration-(--amare-duration-normal) ease-(--amare-ease-standard) hover:bg-amare-accent-deep"
|
||||||
>
|
>
|
||||||
{{ $settings->hero_cta_label ?: 'Solicitar proposta' }}
|
{{ $settings->hero_cta_label }}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -17,8 +17,8 @@
|
|||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 id="hero-heading" data-motion-beat="title" class="max-w-3xl text-display font-medium text-amare-text">
|
<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 ?: 'Celebrações com propósito' }}
|
{{ $settings->hero_title }}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
@if (filled($settings->hero_subtitle))
|
@if (filled($settings->hero_subtitle))
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
data-testid="home-primary-cta"
|
data-testid="home-primary-cta"
|
||||||
class="inline-flex min-h-11 items-center bg-amare-accent px-5 py-3 text-sm font-semibold text-amare-accent-text transition-colors duration-(--amare-duration-normal) ease-(--amare-ease-standard) hover:bg-amare-accent-hover"
|
class="inline-flex min-h-11 items-center bg-amare-accent px-5 py-3 text-sm font-semibold text-amare-accent-text transition-colors duration-(--amare-duration-normal) ease-(--amare-ease-standard) hover:bg-amare-accent-hover"
|
||||||
>
|
>
|
||||||
{{ $settings->hero_cta_label ?: 'Solicitar proposta' }}
|
{{ $settings->hero_cta_label }}
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
@if (filled($settings->hero_secondary_cta_label))
|
@if (filled($settings->hero_secondary_cta_label))
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
<div class="container-amare grid gap-8 md:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]" data-reveal>
|
<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>
|
<p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent">Manifesto</p>
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<h2 id="manifesto-heading" class="max-w-3xl text-headline font-medium text-amare-text">{{ $title }}</h2>
|
<h2 id="manifesto-heading" class="max-w-3xl text-3xl font-medium leading-tight text-amare-text md:text-4xl">{{ $title }}</h2>
|
||||||
<p class="max-w-2xl text-xl leading-relaxed text-amare-text">{{ $lead }}</p>
|
<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>
|
<p class="max-w-2xl text-amare-text-muted">{{ $body }}</p>
|
||||||
</div>
|
</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="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>
|
<div class="space-y-3" data-reveal>
|
||||||
<p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent">Método</p>
|
<p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent">Método</p>
|
||||||
<h2 id="method-heading" class="text-headline font-medium text-amare-text">Cuidado orientado por processo.</h2>
|
<h2 id="method-heading" class="text-3xl font-medium text-amare-text">Cuidado orientado por processo.</h2>
|
||||||
<p class="text-amare-text-muted">{{ $intro }}</p>
|
<p class="text-amare-text-muted">{{ $intro }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
<div class="grid gap-4 md:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]" data-reveal>
|
<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>
|
<p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent-text/80">Portfólio</p>
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<h2 id="portfolio-heading" class="text-headline font-medium">Celebrações que ganham forma com intenção.</h2>
|
<h2 id="portfolio-heading" class="text-3xl font-medium md:text-4xl">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>
|
<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>
|
||||||
</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="container-amare grid gap-10 md:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]">
|
||||||
<div class="space-y-3" data-reveal>
|
<div class="space-y-3" data-reveal>
|
||||||
<p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent">A Amare</p>
|
<p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent">A Amare</p>
|
||||||
<h2 id="positioning-heading" class="text-headline font-medium text-amare-text">Presença que organiza o essencial.</h2>
|
<h2 id="positioning-heading" class="text-3xl font-medium text-amare-text">Presença que organiza o essencial.</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-8" data-reveal>
|
<div class="space-y-8" data-reveal>
|
||||||
<p class="max-w-2xl text-xl leading-relaxed text-amare-text">{{ $summary }}</p>
|
<p class="max-w-2xl text-xl leading-relaxed text-amare-text">{{ $summary }}</p>
|
||||||
|
|||||||
@@ -3,16 +3,15 @@
|
|||||||
])
|
])
|
||||||
|
|
||||||
@if ($testimonials->isNotEmpty())
|
@if ($testimonials->isNotEmpty())
|
||||||
<section aria-labelledby="testimonials-heading" class="border-b border-amare-border bg-amare-bg py-16" data-chapter="testimonials">
|
<section aria-labelledby="testimonials-heading" class="border-b border-amare-border bg-amare-bg-muted py-16" data-chapter="testimonials">
|
||||||
<div class="container-amare space-y-8">
|
<div class="container-amare space-y-8">
|
||||||
<div class="max-w-2xl space-y-3" data-reveal>
|
<div class="max-w-2xl space-y-3" data-reveal>
|
||||||
<h2 id="testimonials-heading" class="text-3xl font-medium text-amare-text">Depoimentos</h2>
|
<h2 id="testimonials-heading" class="text-3xl font-semibold text-amare-text">Depoimentos</h2>
|
||||||
<p class="text-amare-text-muted">Quem celebrou com a Amare conta como foi a experiência.</p>
|
<p class="text-amare-text-muted">Quem celebrou com a Amare conta como foi a experiência.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid gap-6 md:grid-cols-2">
|
<div class="grid gap-6 md:grid-cols-2">
|
||||||
@foreach ($testimonials as $testimonial)
|
@foreach ($testimonials as $testimonial)
|
||||||
@continue(blank(trim((string) $testimonial->quote)))
|
|
||||||
@php
|
@php
|
||||||
$paragraphs = preg_split('/\n\s*\n/', trim((string) $testimonial->quote)) ?: [];
|
$paragraphs = preg_split('/\n\s*\n/', trim((string) $testimonial->quote)) ?: [];
|
||||||
$paragraphs = array_values(array_filter(array_map('trim', $paragraphs), fn (string $p): bool => $p !== ''));
|
$paragraphs = array_values(array_filter(array_map('trim', $paragraphs), fn (string $p): bool => $p !== ''));
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
|
|
||||||
$resolvedWidth = $width ?? $dimensions['width'] ?? null;
|
$resolvedWidth = $width ?? $dimensions['width'] ?? null;
|
||||||
$resolvedHeight = $height ?? $dimensions['height'] ?? null;
|
$resolvedHeight = $height ?? $dimensions['height'] ?? null;
|
||||||
$loadingValue = $loading;
|
$loadingValue = $loading === 'eager' ? 'eager' : 'lazy';
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<img
|
<img
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
@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
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
@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,28 +0,0 @@
|
|||||||
@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
|
|
||||||
@@ -95,6 +95,7 @@
|
|||||||
<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('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('about') }}" class="inline-flex min-h-11 items-center text-amare-muted transition-colors hover:text-amare-accent">A Amare</a></li>
|
||||||
<li><a href="{{ route('contact') }}" class="inline-flex min-h-11 items-center text-amare-muted transition-colors hover:text-amare-accent">Contato</a></li>
|
<li><a href="{{ route('contact') }}" class="inline-flex min-h-11 items-center text-amare-muted transition-colors hover:text-amare-accent">Contato</a></li>
|
||||||
|
<li><a href="{{ route('privacy') }}" class="inline-flex min-h-11 items-center text-amare-muted transition-colors hover:text-amare-accent">Política de privacidade</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -106,18 +107,18 @@
|
|||||||
@endif
|
@endif
|
||||||
@if ($siteSettings->email)
|
@if ($siteSettings->email)
|
||||||
<p>
|
<p>
|
||||||
<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>
|
<a href="mailto:{{ $siteSettings->email }}" class="inline-flex min-h-11 items-center text-amare-muted transition-colors hover:text-amare-accent">{{ $siteSettings->email }}</a>
|
||||||
</p>
|
</p>
|
||||||
@endif
|
@endif
|
||||||
@if ($siteSettings->phone)
|
@if ($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-muted transition-colors hover:text-amare-accent"><span class="min-w-0 break-words">{{ $siteSettings->phone }}</span></a>
|
<a href="tel:{{ preg_replace('/\D/', '', (string) $siteSettings->phone) }}" class="inline-flex min-h-11 items-center text-amare-muted transition-colors hover:text-amare-accent">{{ $siteSettings->phone }}</a>
|
||||||
</p>
|
</p>
|
||||||
@endif
|
@endif
|
||||||
@foreach ($siteSettings->social_links ?? [] as $network => $url)
|
@foreach ($siteSettings->social_links ?? [] as $network => $url)
|
||||||
@if (filled($url))
|
@if (filled($url))
|
||||||
<p>
|
<p>
|
||||||
<a href="{{ $url }}" class="inline-flex 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>
|
<a href="{{ $url }}" class="inline-flex min-h-11 items-center text-amare-muted transition-colors hover:text-amare-accent" rel="noopener noreferrer" target="_blank">{{ ucfirst((string) $network) }}</a>
|
||||||
</p>
|
</p>
|
||||||
@endif
|
@endif
|
||||||
@endforeach
|
@endforeach
|
||||||
|
|||||||
@@ -12,24 +12,12 @@
|
|||||||
<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="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">
|
<div class="space-y-6">
|
||||||
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">A Amare</p>
|
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">A Amare</p>
|
||||||
<h1 class="text-headline font-medium tracking-tight text-amare-text">Humana no cuidado. Precisa na entrega.</h1>
|
<h1 class="text-4xl font-medium tracking-tight text-amare-text md:text-5xl">Humana no cuidado. Precisa na entrega.</h1>
|
||||||
<p class="text-lg text-amare-muted">{{ $siteSettings->about_summary }}</p>
|
<p class="text-lg text-amare-muted">{{ $siteSettings->about_summary }}</p>
|
||||||
<p class="text-amare-muted">
|
<p class="text-amare-muted">
|
||||||
A {{ $siteSettings->brand_name }} atua em {{ $city }} com foco em planejamento completo,
|
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.
|
presença no dia do evento e uma condução serena do início ao fim.
|
||||||
</p>
|
</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>
|
</div>
|
||||||
|
|
||||||
<ul class="space-y-4 border-t border-amare-border pt-6" aria-label="Princípios da Amare">
|
<ul class="space-y-4 border-t border-amare-border pt-6" aria-label="Princípios da Amare">
|
||||||
@@ -42,6 +30,4 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<x-home.final-cta :settings="$siteSettings" />
|
|
||||||
@endsection
|
@endsection
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<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="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">
|
<div class="space-y-6">
|
||||||
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Vamos conversar</p>
|
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Vamos conversar</p>
|
||||||
<h1 class="text-headline font-medium tracking-tight text-amare-text">Todo grande encontro começa com uma boa conversa.</h1>
|
<h1 class="text-4xl font-medium tracking-tight text-amare-text md:text-5xl">Todo grande encontro começa com uma boa conversa.</h1>
|
||||||
<p class="max-w-2xl text-lg text-amare-muted">
|
<p class="max-w-2xl text-lg text-amare-muted">
|
||||||
Conte sobre o seu evento no briefing abaixo. Retornaremos com uma proposta sob medida e sem compromisso.
|
Conte sobre o seu evento no briefing abaixo. Retornaremos com uma proposta sob medida e sem compromisso.
|
||||||
</p>
|
</p>
|
||||||
@@ -15,18 +15,18 @@
|
|||||||
<p>{{ $siteSettings->city ?: 'São Paulo - SP' }}</p>
|
<p>{{ $siteSettings->city ?: 'São Paulo - SP' }}</p>
|
||||||
@if ($siteSettings->email)
|
@if ($siteSettings->email)
|
||||||
<p>
|
<p>
|
||||||
<a href="mailto:{{ $siteSettings->email }}" class="inline-flex 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>
|
<a href="mailto:{{ $siteSettings->email }}" class="inline-flex min-h-11 items-center text-amare-accent transition-colors hover:text-amare-accent-deep">{{ $siteSettings->email }}</a>
|
||||||
</p>
|
</p>
|
||||||
@endif
|
@endif
|
||||||
@if ($siteSettings->phone)
|
@if ($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>
|
<a href="tel:{{ preg_replace('/\D/', '', (string) $siteSettings->phone) }}" class="inline-flex min-h-11 items-center text-amare-accent transition-colors hover:text-amare-accent-deep">{{ $siteSettings->phone }}</a>
|
||||||
</p>
|
</p>
|
||||||
@endif
|
@endif
|
||||||
@foreach ($siteSettings->social_links ?? [] as $network => $url)
|
@foreach ($siteSettings->social_links ?? [] as $network => $url)
|
||||||
@if (filled($url))
|
@if (filled($url))
|
||||||
<p>
|
<p>
|
||||||
<a href="{{ $url }}" class="inline-flex 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>
|
<a href="{{ $url }}" class="inline-flex min-h-11 items-center text-amare-accent transition-colors hover:text-amare-accent-deep" rel="noopener noreferrer" target="_blank">{{ ucfirst((string) $network) }}</a>
|
||||||
</p>
|
</p>
|
||||||
@endif
|
@endif
|
||||||
@endforeach
|
@endforeach
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<div class="container-amare space-y-10 py-16 md:py-24">
|
<div class="container-amare space-y-10 py-16 md:py-24">
|
||||||
<div class="max-w-2xl space-y-4">
|
<div class="max-w-2xl space-y-4">
|
||||||
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Portfólio</p>
|
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Portfólio</p>
|
||||||
<h1 class="text-headline font-medium tracking-tight text-amare-text">Atmosferas que contam histórias.</h1>
|
<h1 class="text-4xl font-medium tracking-tight text-amare-text md:text-5xl">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>
|
<p class="text-lg text-amare-muted">Casos conduzidos com atenção a ritmo, composição e cada detalhe da experiência.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -43,6 +43,4 @@
|
|||||||
<p class="text-sm text-amare-accent">Imagens demonstrativas enquanto o acervo autorizado da Amare está em organização.</p>
|
<p class="text-sm text-amare-accent">Imagens demonstrativas enquanto o acervo autorizado da Amare está em organização.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<x-home.final-cta :settings="$siteSettings" />
|
|
||||||
@endsection
|
@endsection
|
||||||
|
|||||||
@@ -5,12 +5,10 @@
|
|||||||
<div class="border-b border-amare-border bg-amare-bg">
|
<div class="border-b border-amare-border bg-amare-bg">
|
||||||
<div class="container-amare space-y-8 py-16 md:py-24">
|
<div class="container-amare space-y-8 py-16 md:py-24">
|
||||||
<header class="max-w-3xl space-y-4">
|
<header class="max-w-3xl space-y-4">
|
||||||
@if (filled($case->event_type))
|
|
||||||
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">{{ $case->event_type }}</p>
|
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">{{ $case->event_type }}</p>
|
||||||
@endif
|
<h1 class="text-4xl font-medium tracking-tight text-amare-text md:text-5xl">{{ $case->title }}</h1>
|
||||||
<h1 class="text-headline font-medium tracking-tight text-amare-text">{{ $case->title }}</h1>
|
|
||||||
<p class="text-lg text-amare-muted">{{ $case->summary }}</p>
|
<p class="text-lg text-amare-muted">{{ $case->summary }}</p>
|
||||||
<p class="text-sm break-words text-amare-muted">
|
<p class="text-sm text-amare-muted">
|
||||||
@if ($case->city){{ $case->city }}@endif
|
@if ($case->city){{ $case->city }}@endif
|
||||||
@if ($case->venue) · {{ $case->venue }}@endif
|
@if ($case->venue) · {{ $case->venue }}@endif
|
||||||
@if ($case->event_date) · {{ $case->event_date->format('d/m/Y') }}@endif
|
@if ($case->event_date) · {{ $case->event_date->format('d/m/Y') }}@endif
|
||||||
@@ -71,6 +69,4 @@
|
|||||||
</section>
|
</section>
|
||||||
@endif
|
@endif
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<x-home.final-cta :settings="$siteSettings" />
|
|
||||||
@endsection
|
@endsection
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<div class="container-amare space-y-10 py-16 md:py-24">
|
<div class="container-amare space-y-10 py-16 md:py-24">
|
||||||
<div class="max-w-2xl space-y-4">
|
<div class="max-w-2xl space-y-4">
|
||||||
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Serviços</p>
|
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Serviços</p>
|
||||||
<h1 class="text-headline font-medium tracking-tight text-amare-text">Uma mesma excelência, diferentes ocasiões.</h1>
|
<h1 class="text-4xl font-medium tracking-tight text-amare-text md:text-5xl">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>
|
<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>
|
</div>
|
||||||
|
|
||||||
@@ -41,6 +41,4 @@
|
|||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<x-home.final-cta :settings="$siteSettings" />
|
|
||||||
@endsection
|
@endsection
|
||||||
|
|||||||
32
tasks.md
@@ -45,35 +45,3 @@
|
|||||||
- [x] E2E form: submit → sucesso role=status; 2 e-mails (novo briefing + confirmação) logados via MAIL_MAILER=log; fila drenada
|
- [x] E2E form: submit → sucesso role=status; 2 e-mails (novo briefing + confirmação) logados via MAIL_MAILER=log; fila drenada
|
||||||
- [x] Mobile: sem overflow horizontal nas 7 rotas (390px); menu mobile abre/fecha + Escape + retorno de foco
|
- [x] Mobile: sem overflow horizontal nas 7 rotas (390px); menu mobile abre/fecha + Escape + retorno de foco
|
||||||
- [x] Relatório de entrega
|
- [x] Relatório de entrega
|
||||||
|
|
||||||
## T7 — Hardening Public Blades
|
|
||||||
|
|
||||||
- [x] Chunk 1: CSS overflow resilience (`overflow-wrap` body + `[data-chapter-index]` wrap safety, browser test)
|
|
||||||
- [x] Chunk 2: Hero + final-CTA fallbacks + `leading-tight` (TDD)
|
|
||||||
- [x] Chunk 3a: Skip blank-quote testimonials (TDD)
|
|
||||||
- [x] Chunk 3b: Guard empty `event_type` + `break-words` meta line (TDD)
|
|
||||||
- [x] Chunk 4: Extend `PageMeta::forErrorPage` + branded 419/429/503 error views (TDD)
|
|
||||||
- [x] Chunk 5: `app.js` defensive hardening — bfcache submit reset, "Enviando…" label swap, aria-busy, mobile-menu focus trap + Escape
|
|
||||||
- [x] Chunk 6: Quality gates — Pint passed, PHPStan clean, 104 feature tests passed (587 assertions), Vite build succeeded
|
|
||||||
|
|
||||||
### Deviations from plan
|
|
||||||
|
|
||||||
- `PageMeta::forErrorPage` landed on `main` during implementation; rebased and extended it for 419/429/503.
|
|
||||||
- Contact form landed on `main`; submit-state browser coverage was restored.
|
|
||||||
- `event_type: null` violates NOT NULL column — test uses `''` instead (guard covers both).
|
|
||||||
|
|
||||||
# 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)
|
|
||||||
|
|||||||
@@ -1,86 +0,0 @@
|
|||||||
<?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,14 +41,13 @@ $viewports = [
|
|||||||
foreach ($screens as $screen => $path) {
|
foreach ($screens as $screen => $path) {
|
||||||
foreach ($viewports as $viewport => [$width, $height]) {
|
foreach ($viewports as $viewport => [$width, $height]) {
|
||||||
it("matches {$screen} {$viewport} visual baseline", function () use ($path, $width, $height): void {
|
it("matches {$screen} {$viewport} visual baseline", function () use ($path, $width, $height): void {
|
||||||
$this->assertStableScreenshotMatches(
|
|
||||||
$this->visit($path, [
|
$this->visit($path, [
|
||||||
'reducedMotion' => 'reduce',
|
'reducedMotion' => 'reduce',
|
||||||
])
|
])
|
||||||
->withLocale('pt-BR')
|
->withLocale('pt-BR')
|
||||||
->withTimezone('America/Fortaleza')
|
->withTimezone('America/Fortaleza')
|
||||||
->resize($width, $height)
|
->resize($width, $height)
|
||||||
);
|
->assertScreenshotMatches();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,59 +0,0 @@
|
|||||||
<?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,42 +110,4 @@ class HomePageContentTest extends TestCase
|
|||||||
->assertSee('id="positioning-heading"', false)
|
->assertSee('id="positioning-heading"', false)
|
||||||
->assertSee('id="final-cta-heading"', false);
|
->assertSee('id="final-cta-heading"', false);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_hero_falls_back_to_default_copy_when_fields_are_empty(): void
|
|
||||||
{
|
|
||||||
SiteSetting::instance()->update([
|
|
||||||
'hero_title' => '',
|
|
||||||
'hero_cta_label' => '',
|
|
||||||
'hero_secondary_cta_label' => '',
|
|
||||||
'hero_subtitle' => '',
|
|
||||||
'hero_note' => '',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response = $this->get(route('home'));
|
|
||||||
|
|
||||||
$response
|
|
||||||
->assertOk()
|
|
||||||
->assertSeeInOrder(['id="hero-heading"', 'Celebrações com propósito'])
|
|
||||||
->assertSeeInOrder(['data-testid="home-primary-cta"', 'Solicitar proposta']);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_blank_quote_testimonials_are_skipped(): void
|
|
||||||
{
|
|
||||||
Testimonial::factory()->published()->create([
|
|
||||||
'author_name' => 'Autor Mantido',
|
|
||||||
'quote' => 'Experiência impecável do início ao fim.',
|
|
||||||
]);
|
|
||||||
Testimonial::factory()->published()->create([
|
|
||||||
'author_name' => 'Autor Oculto',
|
|
||||||
'quote' => ' ',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response = $this->get(route('home'));
|
|
||||||
|
|
||||||
$response
|
|
||||||
->assertOk()
|
|
||||||
->assertSee('Autor Mantido')
|
|
||||||
->assertSee('Experiência impecável do início ao fim.')
|
|
||||||
->assertDontSee('Autor Oculto');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -196,26 +196,4 @@ class PublicPagesTest extends TestCase
|
|||||||
|
|
||||||
$this->assertLessThan(15, $queryCount);
|
$this->assertLessThan(15, $queryCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_case_without_event_type_does_not_render_empty_eyebrow(): void
|
|
||||||
{
|
|
||||||
SiteSetting::instance();
|
|
||||||
|
|
||||||
$case = PortfolioCase::factory()->published()->create([
|
|
||||||
'slug' => 'caso-sem-tipo',
|
|
||||||
'title' => 'Caso Sem Tipo',
|
|
||||||
'event_type' => '',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response = $this->get(route('portfolio.show', $case->slug));
|
|
||||||
|
|
||||||
$response->assertOk()->assertSee('Caso Sem Tipo');
|
|
||||||
|
|
||||||
$emptyAccentParagraphs = preg_match_all(
|
|
||||||
'/class="[^"]*text-amare-accent[^"]*"><\/p>/',
|
|
||||||
(string) $response->getContent(),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect($emptyAccentParagraphs)->toBe(0);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
<?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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
<?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,9 +3,8 @@
|
|||||||
namespace Tests;
|
namespace Tests;
|
||||||
|
|
||||||
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
|
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
|
||||||
use Tests\Support\StableScreenshot;
|
|
||||||
|
|
||||||
abstract class TestCase extends BaseTestCase
|
abstract class TestCase extends BaseTestCase
|
||||||
{
|
{
|
||||||
use StableScreenshot;
|
//
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
<?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,23 +5,14 @@ declare(strict_types=1);
|
|||||||
it('ships visual content fixtures as valid jpeg images', function (): void {
|
it('ships visual content fixtures as valid jpeg images', function (): void {
|
||||||
$fixtures = [
|
$fixtures = [
|
||||||
'og-default.jpg',
|
'og-default.jpg',
|
||||||
'service-casamentos.jpg',
|
'portfolio-cover.jpg',
|
||||||
'service-eventos-corporativos.jpg',
|
'service-cover.jpg',
|
||||||
'service-celebracoes-intimistas.jpg',
|
'gallery.jpg',
|
||||||
'case-casamento-ana-lucas.jpg',
|
'testimonial.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) {
|
foreach ($fixtures as $fixture) {
|
||||||
$path = dirname(__DIR__, 2).'/database/fixtures/images/'.$fixture;
|
$path = dirname(__DIR__).'/fixtures/images/'.$fixture;
|
||||||
|
|
||||||
expect(is_file($path))->toBeTrue("Missing fixture: {$fixture}");
|
expect(is_file($path))->toBeTrue("Missing fixture: {$fixture}");
|
||||||
|
|
||||||
|
|||||||
BIN
tests/fixtures/images/gallery.jpg
vendored
Normal file
|
After Width: | Height: | Size: 310 B |
BIN
tests/fixtures/images/og-default.jpg
vendored
Normal file
|
After Width: | Height: | Size: 310 B |
BIN
tests/fixtures/images/portfolio-cover.jpg
vendored
Normal file
|
After Width: | Height: | Size: 310 B |
BIN
tests/fixtures/images/service-cover.jpg
vendored
Normal file
|
After Width: | Height: | Size: 311 B |
BIN
tests/fixtures/images/testimonial.jpg
vendored
Normal file
|
After Width: | Height: | Size: 310 B |