Compare commits

..

16 Commits

Author SHA1 Message Date
5949fad9b1 fix(deploy): array command form for dokploy + resilient seed
sh -c via folded scalar broke dokoy compose deploy; use array command.
Seed+media best-effort so web starts even if R2 seed errors.
2026-08-06 11:23:13 -03:00
db4453b165 fix(deploy): keep staging web up when content seed is best-effort
Seed+media step made non-fatal so a transient R2 write issue
doesn't block web/queue/scheduler from starting. Seed output
preserved in dokploy deployment logs for diagnosis.
2026-08-06 11:11:45 -03:00
10a1f0dc7c fix(deploy): run seed + media:generate-variants after migrate
staging R2 was empty — migrate ran but no seeder, so ContentSeeder
never placed fixtures on the configured disk (r2) and media:generate-variants
had nothing for image paths. Staging deploy now seeds content.
2026-08-06 10:55:58 -03:00
c04790ee1f Merge pull request #18 from manoel-freitas/fix/seed-to-active-disk
fix: seed content images onto active filesystem disk
2026-08-06 10:21:14 -03:00
edf1c48050 fix: seed content images onto active filesystem disk
Preview/prod use FILESYSTEM_DISK=r2; seeder wrote only to public,
so media.hellomanoel.com URLs 404/403. Put fixtures on active disk.
2026-08-06 10:16:23 -03:00
3c711a74f3 Merge pull request #17 from manoel-freitas/fix/seed-fixtures-in-image
fix: ship seed image fixtures inside Docker image
2026-08-06 10:05:22 -03:00
88d63f349d fix: ship seed image fixtures inside Docker image
Move demo JPEGs from tests/fixtures (dockerignored) to
database/fixtures so ContentSeeder works in the container.
2026-08-06 10:03:26 -03:00
e0390ff333 Merge pull request #16 from manoel-freitas/feat/visual-placeholders
feat: visual placeholders, type scale, CTAs and about image
2026-08-06 09:54:52 -03:00
321f0cee11 fix: restore lazy loading and CI visual baselines
Drop test-wide eager override that broke MediaImageComponentTest.
Replace local Chromium baselines with CI FrankenPHP host captures.
2026-08-06 09:50:48 -03:00
1d23e16625 merge: origin/main into feat/visual-placeholders
Resolve conflicts keeping DESIGN.md type scale, main blade fallbacks,
and StableScreenshot visual helper. Regenerate browser baselines.
2026-08-06 09:42:37 -03:00
33788af172 feat: visual placeholders, type scale, CTAs and about image
Replace tiny JPEG stubs with per-slot Unsplash editorial placeholders,
align public type scale to DESIGN.md display/headline tokens, add
final-cta on all subpages, about_image_path on site settings, and harden
visual regression with solid-color VisualContentSeeder fixtures.
2026-08-06 09:38:13 -03:00
119711d6f0 chore: mark browser CI gate tasks complete in tasks.md 2026-08-06 09:09:08 -03:00
f09ea83071 fix: stabilize and diagnose browser CI gate (#15)
* fix: stabilize and diagnose browser CI gate

- Replace networkidle screenshot wait (5s client timeout, flaky with
  long-lived connections) with readyState + fonts-loaded wait and fixed
  settle in a StableScreenshot helper.
- Export standalone diff/expected/actual PNGs on visual mismatch so CI
  artifacts are directly viewable (vendor only writes an HTML diff view).
- Create .env in CI test jobs; without it Laravel's env bootstrap emits a
  file_get_contents warning on every test.
- Bump checkout/cache actions to v5 (Node 20 deprecation).
- Gitignore tests/Browser/Screenshots.

* docs: track browser CI gate task status in tasks.md
2026-08-06 08:59:03 -03:00
2fe262ab59 Hardening public blades: overflow, fallbacks, branded errors, JS robustness (#14)
* fix: prevent long strings from overflowing public layout

* fix: fall back to default hero copy and tighten hero line-height

* fix: skip blank testimonials and guard empty portfolio metadata

* feat: add branded 419, 429 and 503 error pages

* fix: reset submit state on bfcache restore, swap label while sending, trap mobile menu focus

* chore: track hardening task status

* fix: restore contact submit state after bfcache

* fix: allow contact links to wrap long unbroken strings
2026-08-06 00:22:53 -03:00
018b43e24f docs: sync SPEC.md and openspec with ratified decisions 2026-08-05 23:42:37 -03:00
9fac784f6a feat: frontend audit — formulário de contato, a11y, SEO, performance e conteúdo (#13)
* chore: add husky pre-commit and pre-push hooks

* feat: frontend audit — formulário de contato, a11y, SEO, performance e conteúdo

* test: regenerate visual baselines from CI environment
2026-08-05 23:27:14 -03:00
73 changed files with 779 additions and 122 deletions

View File

@@ -31,7 +31,8 @@ jobs:
name: static name: static
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v5
- run: cp .env.example .env
- uses: shivammathur/setup-php@v2 - uses: shivammathur/setup-php@v2
with: with:
@@ -39,7 +40,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@v4 - uses: actions/cache@v5
with: with:
path: ~/.composer/cache/files path: ~/.composer/cache/files
key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }} key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }}
@@ -55,7 +56,8 @@ jobs:
name: unit name: unit
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v5
- run: cp .env.example .env
- uses: shivammathur/setup-php@v2 - uses: shivammathur/setup-php@v2
with: with:
@@ -63,7 +65,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@v4 - uses: actions/cache@v5
with: with:
path: ~/.composer/cache/files path: ~/.composer/cache/files
key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }} key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }}
@@ -92,7 +94,8 @@ jobs:
--health-timeout 5s --health-timeout 5s
--health-retries 10 --health-retries 10
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v5
- run: cp .env.example .env
- uses: shivammathur/setup-php@v2 - uses: shivammathur/setup-php@v2
with: with:
@@ -100,13 +103,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@v4 - uses: actions/cache@v5
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@v4 - uses: actions/cache@v5
with: with:
path: ~/.npm path: ~/.npm
key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }} key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
@@ -136,7 +139,8 @@ jobs:
--health-timeout 5s --health-timeout 5s
--health-retries 10 --health-retries 10
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v5
- run: cp .env.example .env
- uses: shivammathur/setup-php@v2 - uses: shivammathur/setup-php@v2
with: with:
@@ -144,13 +148,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@v4 - uses: actions/cache@v5
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@v4 - uses: actions/cache@v5
with: with:
path: ~/.npm path: ~/.npm
key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }} key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
@@ -224,7 +228,7 @@ jobs:
name: container name: container
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v5
- 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
View File

@@ -18,6 +18,7 @@
/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
View File

@@ -14,7 +14,8 @@
| 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/Fortaleza` | | Timezone padrão | `America/Sao_Paulo` |
| 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 |
@@ -44,6 +45,8 @@ Em caso de conflito, seguir esta ordem:
O agente **NÃO DEVE** alterar silenciosamente uma decisão deste documento. Uma alteração de escopo ou arquitetura deve atualizar esta especificação ou criar uma ADR. 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
@@ -349,13 +352,14 @@ 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. prova visual por eventos em destaque; 3. manifesto da marca;
4. resumo dos serviços; 4. resumo dos serviços em destaque;
5. método de trabalho; 5. casos selecionados do portfólio;
6. casos selecionados; 6. método de trabalho (4 passos);
7. depoimentos; 7. depoimentos;
8. CTA final para briefing; 8. posicionamento/perfil;
9. footer com contato, redes e links legais. 9. CTA final para briefing;
10. footer com contato, redes e links legais.
A ordem pode variar apenas se a revisão de UX justificar a mudança. A ordem pode variar apenas se a revisão de UX justificar a mudança.
@@ -374,6 +378,15 @@ Centralizar tokens de:
Não espalhar valores visuais arbitrários por componentes. 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.
@@ -383,6 +396,7 @@ Não espalhar valores visuais arbitrários por componentes.
- 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
@@ -534,12 +548,10 @@ 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;
- falha no e-mail não pode apagar o lead já criado. - exibir sucesso sem revelar dados internos.
> **Estado atual (Fases 01):** 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:**
@@ -1253,10 +1265,19 @@ 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`;
@@ -1265,7 +1286,8 @@ Singleton:
- `default_meta_title`; - `default_meta_title`;
- `default_meta_description`; - `default_meta_description`;
- `default_og_image_path` nullable; - `default_og_image_path` nullable;
- analytics fields nullable; - `default_og_image_alt` nullable;
- `analytics_enabled` boolean default false;
- timestamps. - timestamps.
#### `services` #### `services`
@@ -1536,7 +1558,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 + storage S3-compatible em produção | | Arquivos | Laravel Filesystem + Cloudflare R2 (S3-compatible) em produção |
| Fila | Database queue | | Fila | Database queue |
| Scheduler | Laravel Scheduler em processo separado | | Scheduler | Laravel Scheduler em processo separado |
@@ -1764,6 +1786,8 @@ Não transformar todas as seções estáticas em componentes Livewire. Usar Blad
### 11.2 Formulário de briefing ### 11.2 Formulário de briefing
> **Estado atual (Fases 01):** 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;
@@ -1970,7 +1994,7 @@ Determinismo obrigatório:
- Chromium e imagem Linux fixos; - Chromium e imagem Linux fixos;
- viewport fixo; - viewport fixo;
- timezone `America/Fortaleza`; - timezone `America/Sao_Paulo`;
- locale `pt-BR`; - locale `pt-BR`;
- fontes instaladas na imagem; - fontes instaladas na imagem;
- relógio congelado; - relógio congelado;
@@ -2052,7 +2076,7 @@ quality → Pint check + PHPStan/Larastan + audits + testes
| Job | Responsabilidade | Bloqueia merge | | Job | Responsabilidade | Bloqueia merge |
|---|---|---:| |---|---|---:|
| `static` | Pint, PHPStan/Larastan, Composer validate e audits | Sim | | `static` | Pint, PHPStan/Larastan, Composer validate, Composer e npm audit | Sim |
| `unit` | Unitários, arquitetura e cobertura | Sim | | `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 |
@@ -2073,11 +2097,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 e deploy automático em staging; - `main`: build imutável por SHA publicado no GHCR e deploy automático em staging via Dokploy;
- staging: migration, cache warmup e smoke pós-deploy; - staging: Dokploy Compose executa migração, healthcheck `/up` e smoke pós-deploy (`/up`, `/`, `/admin/login`);
- produção: promoção da mesma imagem aprovada, sem rebuild; - produção: promoção da mesma imagem aprovada, sem rebuild (retag do digest em `:production`);
- produção requer aprovação humana explícita no MVP; - produção requer aprovação humana explícita no MVP (`workflow_dispatch` com confirmação);
- rollback usa imagem anterior; - rollback usa imagem anterior (SHA anterior, sem rebuild);
- 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
@@ -2159,7 +2183,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/Fortaleza APP_TIMEZONE=America/Sao_Paulo
DB_CONNECTION=pgsql DB_CONNECTION=pgsql
DB_HOST DB_HOST
@@ -2172,19 +2196,16 @@ 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=s3 em produção FILESYSTEM_DISK=r2 em produção
AWS_ACCESS_KEY_ID R2_ACCESS_KEY_ID
AWS_SECRET_ACCESS_KEY R2_SECRET_ACCESS_KEY
AWS_DEFAULT_REGION R2_BUCKET
AWS_BUCKET R2_ENDPOINT
AWS_ENDPOINT opcional R2_URL (domínio próprio opcional)
AWS_USE_PATH_STYLE_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
MAIL_MAILER MAIL_MAILER=resend em produção (mailer nativo Laravel)
MAIL_HOST RESEND_API_KEY
MAIL_PORT
MAIL_USERNAME
MAIL_PASSWORD
MAIL_FROM_ADDRESS MAIL_FROM_ADDRESS
MAIL_FROM_NAME MAIL_FROM_NAME
``` ```
@@ -2279,7 +2300,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;
- 3 depoimentos; - depoimentos reais: os 5 casais de `depoimentos.md` (Jeniffer e Maick, Quesia e Jhonata, Milena e Weslley, Raquel e Pedro, Victoria e Pedro), preservando texto e datas; autores fictícios de demonstração removidos; em produção permanecem não publicados até autorização explícita de publicação;
- leads em estados variados; - 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;
@@ -2314,7 +2335,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;
- [x] FrankenPHP e Docker Compose local; - [~] FrankenPHP e Docker Compose local (imagem pronta; serviço de aplicação local pendente);
- [x] papéis admin/assistant; - [x] papéis admin/assistant;
- [x] Pint; - [x] Pint;
- [x] PHPStan/Larastan; - [x] PHPStan/Larastan;
@@ -2325,6 +2346,13 @@ O agente deve implementar na sequência, salvo instrução explícita.
- [x] design tokens mínimos; - [x] 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.
@@ -2481,6 +2509,10 @@ 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 |
--- ---

View File

@@ -85,7 +85,7 @@ final readonly class PageMeta
} }
/** /**
* Build the metadata for branded error pages (404/500). * Build the metadata for branded error pages.
* *
* Error pages carry no canonical, are excluded from search indexes and use * Error pages carry no canonical, are excluded from search indexes and use
* the page name suffixed with the brand name as their title. * the page name suffixed with the brand name as their title.
@@ -94,9 +94,13 @@ final readonly class PageMeta
SiteSetting $settings, SiteSetting $settings,
int $status = 404, int $status = 404,
): self { ): self {
[$title, $description] = $status === 500 [$title, $description] = match ($status) {
? ['Algo deu errado', 'Não foi possível concluir o pedido. Tente novamente em instantes.'] 500 => ['Algo deu errado', 'Não foi possível concluir o pedido. Tente novamente em instantes.'],
: ['Página não encontrada', 'A página que você procura não existe ou foi movida.']; 419 => ['Sessão expirada', 'Sua sessão expirou. Volte e tente enviar novamente.'],
429 => ['Muitas solicitações', 'Você enviou muitas solicitações em pouco tempo. Aguarde um instante e tente novamente.'],
503 => ['Em manutenção', 'Estamos realizando uma breve manutenção. Tente novamente em instantes.'],
default => ['Página não encontrada', 'A página que você procura não existe ou foi movida.'],
};
return new self( return new self(
title: trim($title).' - '.$settings->brand_name, title: trim($title).' - '.$settings->brand_name,

View File

@@ -56,6 +56,9 @@ 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;

View File

@@ -228,6 +228,12 @@ 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')

View File

@@ -16,6 +16,8 @@ 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
*/ */
@@ -30,6 +32,8 @@ 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',

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 436 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 622 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 499 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 235 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 360 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 330 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 302 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 337 KiB

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('site_settings', function (Blueprint $table): void {
$table->string('about_image_path')->nullable()->after('about_summary');
$table->string('about_image_alt')->nullable()->after('about_image_path');
});
}
public function down(): void
{
Schema::table('site_settings', function (Blueprint $table): void {
$table->dropColumn(['about_image_path', 'about_image_alt']);
});
}
};

View File

@@ -8,6 +8,7 @@ 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;
@@ -36,6 +37,8 @@ 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.',
@@ -91,7 +94,7 @@ class ContentSeeder extends Seeder
['slug' => $service['slug']], ['slug' => $service['slug']],
[ [
...$service, ...$service,
'cover_image_path' => $this->copyFixture('service-cover.jpg', 'content/services/'.$service['slug'].'.jpg'), 'cover_image_path' => $this->copyFixture('service-'.$service['slug'].'.jpg', 'content/services/'.$service['slug'].'.jpg'),
'cover_image_alt' => 'Capa do serviço '.$service['title'], 'cover_image_alt' => 'Capa do serviço '.$service['title'],
'published_at' => Carbon::parse(self::SEED_TIMESTAMP), 'published_at' => Carbon::parse(self::SEED_TIMESTAMP),
], ],
@@ -148,7 +151,7 @@ class ContentSeeder extends Seeder
['slug' => $caseData['slug']], ['slug' => $caseData['slug']],
[ [
...$caseData, ...$caseData,
'cover_image_path' => $this->copyFixture('portfolio-cover.jpg', 'content/portfolio/'.$caseData['slug'].'-cover.jpg'), 'cover_image_path' => $this->copyFixture('case-'.$caseData['slug'].'.jpg', 'content/portfolio/'.$caseData['slug'].'-cover.jpg'),
'cover_image_alt' => 'Capa do caso '.$caseData['title'], '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),
@@ -160,7 +163,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.jpg', 'content/portfolio/'.$caseData['slug'].'-gallery-'.$index.'.jpg'), 'path' => $this->copyFixture('gallery-'.$caseData['slug'].'-'.$index.'.jpg', 'content/portfolio/'.$caseData['slug'].'-gallery-'.$index.'.jpg'),
'alt_text' => 'Galeria '.$caseData['title'].' '.$index, '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,
@@ -171,8 +174,10 @@ class ContentSeeder extends Seeder
private function copyFixture(string $fixtureName, string $destination): string private function copyFixture(string $fixtureName, string $destination): string
{ {
$source = base_path('tests/fixtures/images/'.$fixtureName); $source = base_path('database/fixtures/images/'.$fixtureName);
Storage::disk('public')->put($destination, File::get($source)); $disk = PublicImageUploadRules::disk();
Storage::disk($disk)->put($destination, File::get($source), 'public');
return $destination; return $destination;
} }

View File

@@ -11,7 +11,6 @@ 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;
/** /**
@@ -44,6 +43,8 @@ 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.',
@@ -58,7 +59,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->copyFixture('og-default.jpg', 'visual/og/og-default.jpg'), 'default_og_image_path' => $this->writeSolidJpeg('visual/og/og-default.jpg', 1600, 1067, [85, 107, 47]),
'default_og_image_alt' => 'Identidade visual da Amare Assessoria de Eventos', 'default_og_image_alt' => 'Identidade visual da Amare Assessoria de Eventos',
'analytics_enabled' => false, 'analytics_enabled' => false,
'analytics_script' => null, 'analytics_script' => null,
@@ -89,7 +90,7 @@ class VisualContentSeeder extends Seeder
['slug' => $service['slug']], ['slug' => $service['slug']],
[ [
...$service, ...$service,
'cover_image_path' => $this->copyFixture('service-cover.jpg', 'visual/services/'.$service['slug'].'.jpg'), 'cover_image_path' => $this->writeSolidJpeg('visual/services/'.$service['slug'].'.jpg', 1600, 1000, [196, 200, 184]),
'cover_image_alt' => 'Capa do serviço '.$service['title'], 'cover_image_alt' => 'Capa do serviço '.$service['title'],
'published_at' => Carbon::parse(self::SEED_TIMESTAMP), 'published_at' => Carbon::parse(self::SEED_TIMESTAMP),
], ],
@@ -133,7 +134,7 @@ class VisualContentSeeder extends Seeder
['slug' => $caseData['slug']], ['slug' => $caseData['slug']],
[ [
...$caseData, ...$caseData,
'cover_image_path' => $this->copyFixture('portfolio-cover.jpg', 'visual/portfolio/'.$caseData['slug'].'-cover.jpg'), 'cover_image_path' => $this->writeSolidJpeg('visual/portfolio/'.$caseData['slug'].'-cover.jpg', 1600, 1200, [240, 238, 233]),
'cover_image_alt' => 'Capa do caso '.$caseData['title'], '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),
@@ -144,7 +145,7 @@ class VisualContentSeeder extends Seeder
PortfolioImage::query()->create([ PortfolioImage::query()->create([
'portfolio_case_id' => $case->id, 'portfolio_case_id' => $case->id,
'path' => $this->copyFixture('gallery.jpg', 'visual/portfolio/'.$caseData['slug'].'-gallery-1.jpg'), 'path' => $this->writeSolidJpeg('visual/portfolio/'.$caseData['slug'].'-gallery-1.jpg', 1600, 1200, [228, 226, 221]),
'alt_text' => 'Galeria '.$caseData['title'].' 1', 'alt_text' => 'Galeria '.$caseData['title'].' 1',
'caption' => 'Detalhe da decoração', 'caption' => 'Detalhe da decoração',
'sort_order' => 1, 'sort_order' => 1,
@@ -186,10 +187,21 @@ class VisualContentSeeder extends Seeder
} }
} }
private function copyFixture(string $fixtureName, string $destination): string /**
* @param array{0: int, 1: int, 2: int} $rgb
*/
private function writeSolidJpeg(string $destination, int $width, int $height, array $rgb): string
{ {
$source = base_path('tests/fixtures/images/'.$fixtureName); $image = imagecreatetruecolor($width, $height);
Storage::disk('public')->put($destination, File::get($source)); $color = imagecolorallocate($image, $rgb[0], $rgb[1], $rgb[2]);
imagefilledrectangle($image, 0, 0, $width, $height, $color);
ob_start();
imagejpeg($image, null, 90);
$binary = (string) ob_get_clean();
imagedestroy($image);
Storage::disk('public')->put($destination, $binary);
return $destination; return $destination;
} }

View File

@@ -14,7 +14,7 @@ services:
restart: "no" restart: "no"
env_file: env_file:
- .env - .env
command: ["php", "artisan", "migrate", "--force", "--no-interaction"] command: ["sh", "-c", "php artisan migrate --force --no-interaction && php artisan db:seed --class=ContentSeeder --force; php artisan media:generate-variants --force; true"]
networks: networks:
- dokploy-network - dokploy-network

View File

@@ -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/Fortaleza, BRL. Produto: plataforma de assessoria de eventos, single-tenant, MVP. UI em pt-BR, timezone America/Sao_Paulo, atuação em São Paulo (capital), BRL.
Stack: Laravel 13, Filament 5 (/admin), Livewire 4 + Blade + Alpine + Tailwind (site público), 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.

View File

@@ -1,7 +1,7 @@
# container-runtime Specification # container-runtime Specification
## Purpose ## Purpose
TBD - created by archiving change setup-foundation. Update Purpose after archive. Define the production container image for the application: a reproducible multi-stage FrankenPHP build serving the public directory, run in regular mode as a non-root user, shared by web/queue/scheduler processes, with no secrets in layers and a healthcheck on `/up`.
## Requirements ## Requirements
### Requirement: Production image uses multi-stage FrankenPHP build ### Requirement: Production image uses multi-stage FrankenPHP build

View File

@@ -1,7 +1,7 @@
# design-tokens Specification # design-tokens Specification
## Purpose ## Purpose
TBD - created by archiving change setup-foundation. Update Purpose after archive. Centralize the public site's design tokens so the interface implements the Heritage Editorial system from DESIGN.md (EB Garamond, 8px rhythm, zero radius, 1120px container, paper/olive/sage/ink palette), honoring reduced motion and WCAG AA contrast without card-shadow hierarchy.
## Requirements ## Requirements
### Requirement: Design tokens are centralized for the public site ### Requirement: Design tokens are centralized for the public site

View File

@@ -1,7 +1,7 @@
# health-check Specification # health-check Specification
## Purpose ## Purpose
TBD - created by archiving change setup-foundation. Update Purpose after archive. Expose a public `GET /up` healthcheck that responds quickly and without authentication, exposes no secrets, and fails when the application cannot boot, so orchestrators and CI can verify availability.
## Requirements ## Requirements
### Requirement: Public health endpoint responds without authentication ### Requirement: Public health endpoint responds without authentication

View File

@@ -1,7 +1,7 @@
# internal-authentication Specification # internal-authentication Specification
## Purpose ## Purpose
TBD - created by archiving change setup-foundation. Update Purpose after archive. Provide session-based authentication for the internal Filament panel at `/admin`, limited to two roles (admin and assistant), with unique emails, password reset without enumeration, inactive users denied, and user management restricted to admins.
## Requirements ## Requirements
### Requirement: Internal users authenticate via Filament panel ### Requirement: Internal users authenticate via Filament panel

View File

@@ -18,6 +18,13 @@
--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);
@@ -59,6 +66,7 @@
background-color: var(--amare-color-bg); background-color: var(--amare-color-bg);
color: var(--amare-color-text); color: var(--amare-color-text);
font-family: var(--amare-font-serif); font-family: var(--amare-font-serif);
overflow-wrap: break-word;
} }
[id$='-heading'] { [id$='-heading'] {
@@ -168,6 +176,7 @@
text-transform: uppercase; text-transform: uppercase;
color: var(--amare-color-muted); color: var(--amare-color-muted);
text-decoration: none; text-decoration: none;
overflow-wrap: break-word;
transition: color var(--amare-duration-fast) var(--amare-ease-standard); transition: color var(--amare-duration-fast) var(--amare-ease-standard);
} }

View File

@@ -6,6 +6,7 @@ document.addEventListener('DOMContentLoaded', () => {
if (menuButton && navigation) { if (menuButton && navigation) {
const isDesktop = () => window.matchMedia('(min-width: 768px)').matches; const isDesktop = () => window.matchMedia('(min-width: 768px)').matches;
const isMenuOpen = () => navigation.classList.contains('is-open');
const setOpen = (isOpen, { returnFocus = false } = {}) => { const setOpen = (isOpen, { returnFocus = false } = {}) => {
navigation.classList.toggle('is-open', isOpen); navigation.classList.toggle('is-open', isOpen);
@@ -39,6 +40,27 @@ document.addEventListener('DOMContentLoaded', () => {
navigation.addEventListener('keydown', (event) => { navigation.addEventListener('keydown', (event) => {
if (event.key === 'Escape') { if (event.key === 'Escape') {
setOpen(false, { returnFocus: true }); setOpen(false, { returnFocus: true });
return;
}
if (event.key === 'Tab' && !isDesktop() && isMenuOpen()) {
const focusables = navigation.querySelectorAll('a[href], button:not([disabled])');
if (focusables.length === 0) {
return;
}
const first = focusables[0];
const last = focusables[focusables.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
} }
}); });
@@ -53,12 +75,33 @@ document.addEventListener('DOMContentLoaded', () => {
} }
document.querySelectorAll('form[data-contact-form]').forEach((form) => { document.querySelectorAll('form[data-contact-form]').forEach((form) => {
form.addEventListener('submit', () => { const submitButton = form.querySelector('[data-submit-button]');
const button = form.querySelector('[data-submit-button]');
if (button) { const resetSubmitState = () => {
button.disabled = true; form.removeAttribute('aria-busy');
button.setAttribute('aria-busy', 'true'); form.removeAttribute('aria-disabled');
if (submitButton) {
submitButton.disabled = false;
submitButton.textContent = submitButton.dataset.defaultLabel ?? 'Enviar briefing';
submitButton.removeAttribute('aria-busy');
}
};
form.addEventListener('submit', () => {
if (submitButton) {
submitButton.dataset.defaultLabel ??= submitButton.textContent.trim();
submitButton.textContent = 'Enviando…';
submitButton.disabled = true;
submitButton.setAttribute('aria-busy', 'true');
}
form.setAttribute('aria-busy', 'true');
});
window.addEventListener('pageshow', (event) => {
if (event.persisted) {
resetSubmitState();
} }
}); });
}); });

View File

@@ -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-3xl font-medium text-amare-text md:text-4xl">Do casamento ao evento corporativo, tudo começa com uma boa conversa.</h2> <h2 id="final-cta-heading" class="text-headline font-medium text-amare-text">Do casamento ao evento corporativo, tudo começa com uma boa conversa.</h2>
<p class="mx-auto max-w-2xl text-amare-muted"> <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 }} {{ $settings->hero_cta_label ?: 'Solicitar proposta' }}
</a> </a>
</div> </div>
</div> </div>

View File

@@ -17,8 +17,8 @@
@endif @endif
</div> </div>
<h1 id="hero-heading" data-motion-beat="title" class="max-w-3xl text-4xl font-medium leading-none text-amare-text md:text-5xl"> <h1 id="hero-heading" data-motion-beat="title" class="max-w-3xl text-display font-medium text-amare-text">
{{ $settings->hero_title }} {{ $settings->hero_title ?: 'Celebrações com propósito' }}
</h1> </h1>
@if (filled($settings->hero_subtitle)) @if (filled($settings->hero_subtitle))
@@ -31,7 +31,7 @@
data-testid="home-primary-cta" data-testid="home-primary-cta"
class="inline-flex min-h-11 items-center bg-amare-accent px-5 py-3 text-sm font-semibold text-amare-accent-text transition-colors duration-(--amare-duration-normal) ease-(--amare-ease-standard) hover:bg-amare-accent-hover" class="inline-flex min-h-11 items-center bg-amare-accent px-5 py-3 text-sm font-semibold text-amare-accent-text transition-colors duration-(--amare-duration-normal) ease-(--amare-ease-standard) hover:bg-amare-accent-hover"
> >
{{ $settings->hero_cta_label }} {{ $settings->hero_cta_label ?: 'Solicitar proposta' }}
</a> </a>
@if (filled($settings->hero_secondary_cta_label)) @if (filled($settings->hero_secondary_cta_label))

View File

@@ -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-3xl font-medium leading-tight text-amare-text md:text-4xl">{{ $title }}</h2> <h2 id="manifesto-heading" class="max-w-3xl text-headline font-medium text-amare-text">{{ $title }}</h2>
<p class="max-w-2xl text-xl leading-relaxed text-amare-text">{{ $lead }}</p> <p class="max-w-2xl text-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>

View File

@@ -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-3xl font-medium text-amare-text">Cuidado orientado por processo.</h2> <h2 id="method-heading" class="text-headline font-medium text-amare-text">Cuidado orientado por processo.</h2>
<p class="text-amare-text-muted">{{ $intro }}</p> <p class="text-amare-text-muted">{{ $intro }}</p>
</div> </div>

View File

@@ -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-3xl font-medium md:text-4xl">Celebrações que ganham forma com intenção.</h2> <h2 id="portfolio-heading" class="text-headline font-medium">Celebrações que ganham forma com intenção.</h2>
<p class="max-w-2xl text-amare-accent-text/80">Recortes de eventos conduzidos com escuta, direção e presença em cada etapa.</p> <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>

View File

@@ -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-3xl font-medium text-amare-text">Presença que organiza o essencial.</h2> <h2 id="positioning-heading" class="text-headline font-medium text-amare-text">Presença que organiza o essencial.</h2>
</div> </div>
<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>

View File

@@ -3,15 +3,16 @@
]) ])
@if ($testimonials->isNotEmpty()) @if ($testimonials->isNotEmpty())
<section aria-labelledby="testimonials-heading" class="border-b border-amare-border bg-amare-bg-muted py-16" data-chapter="testimonials"> <section aria-labelledby="testimonials-heading" class="border-b border-amare-border bg-amare-bg py-16" data-chapter="testimonials">
<div class="container-amare space-y-8"> <div class="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-semibold text-amare-text">Depoimentos</h2> <h2 id="testimonials-heading" class="text-3xl font-medium text-amare-text">Depoimentos</h2>
<p class="text-amare-text-muted">Quem celebrou com a Amare conta como foi a experiência.</p> <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 !== ''));

View File

@@ -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 === 'eager' ? 'eager' : 'lazy'; $loadingValue = $loading;
@endphp @endphp
<img <img

View File

@@ -0,0 +1,28 @@
@php
try {
$errorSettings = \App\Models\SiteSetting::instance();
$pageMeta = \App\Application\Data\PageMeta::forErrorPage($errorSettings, 419);
} catch (\Throwable $e) {
$pageMeta = new \App\Application\Data\PageMeta(
title: 'Sessão expirada - Amare Assessoria',
description: 'Sua sessão expirou. Volte e tente enviar novamente.',
canonical: '',
robots: 'noindex, nofollow',
);
}
@endphp
@extends('layouts.public')
@section('content')
<div class="container-amare max-w-2xl space-y-6 py-20 text-center">
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Erro 419</p>
<h1 class="text-4xl font-medium tracking-tight text-amare-text">Sessão expirada</h1>
<p class="text-amare-muted">Sua sessão expirou. Volte e tente enviar novamente.</p>
<p>
<a href="{{ route('home') }}" class="inline-flex min-h-11 items-center bg-amare-accent px-5 py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-accent-text transition-colors hover:bg-amare-accent-deep">
Voltar para a home
</a>
</p>
</div>
@endsection

View File

@@ -0,0 +1,28 @@
@php
try {
$errorSettings = \App\Models\SiteSetting::instance();
$pageMeta = \App\Application\Data\PageMeta::forErrorPage($errorSettings, 429);
} catch (\Throwable $e) {
$pageMeta = new \App\Application\Data\PageMeta(
title: 'Muitas solicitações - Amare Assessoria',
description: 'Você enviou muitas solicitações em pouco tempo. Aguarde um instante e tente novamente.',
canonical: '',
robots: 'noindex, nofollow',
);
}
@endphp
@extends('layouts.public')
@section('content')
<div class="container-amare max-w-2xl space-y-6 py-20 text-center">
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Erro 429</p>
<h1 class="text-4xl font-medium tracking-tight text-amare-text">Muitas solicitações</h1>
<p class="text-amare-muted">Você enviou muitas solicitações em pouco tempo. Aguarde um instante e tente novamente.</p>
<p>
<a href="{{ route('home') }}" class="inline-flex min-h-11 items-center bg-amare-accent px-5 py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-accent-text transition-colors hover:bg-amare-accent-deep">
Voltar para a home
</a>
</p>
</div>
@endsection

View File

@@ -0,0 +1,28 @@
@php
try {
$errorSettings = \App\Models\SiteSetting::instance();
$pageMeta = \App\Application\Data\PageMeta::forErrorPage($errorSettings, 503);
} catch (\Throwable $e) {
$pageMeta = new \App\Application\Data\PageMeta(
title: 'Em manutenção - Amare Assessoria',
description: 'Estamos realizando uma breve manutenção. Tente novamente em instantes.',
canonical: '',
robots: 'noindex, nofollow',
);
}
@endphp
@extends('layouts.public')
@section('content')
<div class="container-amare max-w-2xl space-y-6 py-20 text-center">
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Erro 503</p>
<h1 class="text-4xl font-medium tracking-tight text-amare-text">Em manutenção</h1>
<p class="text-amare-muted">Estamos realizando uma breve manutenção. Tente novamente em instantes.</p>
<p>
<a href="{{ route('home') }}" class="inline-flex min-h-11 items-center bg-amare-accent px-5 py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-accent-text transition-colors hover:bg-amare-accent-deep">
Voltar para a home
</a>
</p>
</div>
@endsection

View File

@@ -95,7 +95,6 @@
<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>
@@ -107,18 +106,18 @@
@endif @endif
@if ($siteSettings->email) @if ($siteSettings->email)
<p> <p>
<a href="mailto:{{ $siteSettings->email }}" class="inline-flex min-h-11 items-center text-amare-muted transition-colors hover:text-amare-accent">{{ $siteSettings->email }}</a> <a href="mailto:{{ $siteSettings->email }}" class="inline-flex max-w-full min-h-11 items-center text-amare-muted transition-colors hover:text-amare-accent"><span class="min-w-0 break-words">{{ $siteSettings->email }}</span></a>
</p> </p>
@endif @endif
@if ($siteSettings->phone) @if ($siteSettings->phone)
<p> <p>
<a href="tel:{{ preg_replace('/\D/', '', (string) $siteSettings->phone) }}" class="inline-flex min-h-11 items-center text-amare-muted transition-colors hover:text-amare-accent">{{ $siteSettings->phone }}</a> <a href="tel:{{ preg_replace('/\D/', '', (string) $siteSettings->phone) }}" class="inline-flex max-w-full min-h-11 items-center text-amare-muted transition-colors hover:text-amare-accent"><span class="min-w-0 break-words">{{ $siteSettings->phone }}</span></a>
</p> </p>
@endif @endif
@foreach ($siteSettings->social_links ?? [] as $network => $url) @foreach ($siteSettings->social_links ?? [] as $network => $url)
@if (filled($url)) @if (filled($url))
<p> <p>
<a href="{{ $url }}" class="inline-flex min-h-11 items-center text-amare-muted transition-colors hover:text-amare-accent" rel="noopener noreferrer" target="_blank">{{ ucfirst((string) $network) }}</a> <a href="{{ $url }}" class="inline-flex max-w-full min-h-11 items-center text-amare-muted transition-colors hover:text-amare-accent" rel="noopener noreferrer" target="_blank"><span class="min-w-0 break-words">{{ ucfirst((string) $network) }}</span></a>
</p> </p>
@endif @endif
@endforeach @endforeach

View File

@@ -12,12 +12,24 @@
<div class="container-amare grid flex-1 content-start gap-12 py-16 md:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)] md:py-24"> <div class="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-4xl font-medium tracking-tight text-amare-text md:text-5xl">Humana no cuidado. Precisa na entrega.</h1> <h1 class="text-headline font-medium tracking-tight text-amare-text">Humana no cuidado. Precisa na entrega.</h1>
<p class="text-lg text-amare-muted">{{ $siteSettings->about_summary }}</p> <p class="text-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">
@@ -30,4 +42,6 @@
</ul> </ul>
</div> </div>
</div> </div>
<x-home.final-cta :settings="$siteSettings" />
@endsection @endsection

View File

@@ -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-4xl font-medium tracking-tight text-amare-text md:text-5xl">Todo grande encontro começa com uma boa conversa.</h1> <h1 class="text-headline font-medium tracking-tight text-amare-text">Todo grande encontro começa com uma boa conversa.</h1>
<p class="max-w-2xl text-lg text-amare-muted"> <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 min-h-11 items-center text-amare-accent transition-colors hover:text-amare-accent-deep">{{ $siteSettings->email }}</a> <a href="mailto:{{ $siteSettings->email }}" class="inline-flex max-w-full min-h-11 items-center text-amare-accent transition-colors hover:text-amare-accent-deep"><span class="min-w-0 break-words">{{ $siteSettings->email }}</span></a>
</p> </p>
@endif @endif
@if ($siteSettings->phone) @if ($siteSettings->phone)
<p> <p>
<a href="tel:{{ preg_replace('/\D/', '', (string) $siteSettings->phone) }}" class="inline-flex min-h-11 items-center text-amare-accent transition-colors hover:text-amare-accent-deep">{{ $siteSettings->phone }}</a> <a href="tel:{{ preg_replace('/\D/', '', (string) $siteSettings->phone) }}" class="inline-flex max-w-full min-h-11 items-center text-amare-accent transition-colors hover:text-amare-accent-deep"><span class="min-w-0 break-words">{{ $siteSettings->phone }}</span></a>
</p> </p>
@endif @endif
@foreach ($siteSettings->social_links ?? [] as $network => $url) @foreach ($siteSettings->social_links ?? [] as $network => $url)
@if (filled($url)) @if (filled($url))
<p> <p>
<a href="{{ $url }}" class="inline-flex min-h-11 items-center text-amare-accent transition-colors hover:text-amare-accent-deep" rel="noopener noreferrer" target="_blank">{{ ucfirst((string) $network) }}</a> <a href="{{ $url }}" class="inline-flex max-w-full min-h-11 items-center text-amare-accent transition-colors hover:text-amare-accent-deep" rel="noopener noreferrer" target="_blank"><span class="min-w-0 break-words">{{ ucfirst((string) $network) }}</span></a>
</p> </p>
@endif @endif
@endforeach @endforeach

View File

@@ -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-4xl font-medium tracking-tight text-amare-text md:text-5xl">Atmosferas que contam histórias.</h1> <h1 class="text-headline font-medium tracking-tight text-amare-text">Atmosferas que contam histórias.</h1>
<p class="text-lg text-amare-muted">Casos conduzidos com atenção a ritmo, composição e cada detalhe da experiência.</p> <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,4 +43,6 @@
<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

View File

@@ -5,10 +5,12 @@
<div class="border-b border-amare-border bg-amare-bg"> <div class="border-b border-amare-border bg-amare-bg">
<div class="container-amare space-y-8 py-16 md:py-24"> <div class="container-amare space-y-8 py-16 md:py-24">
<header class="max-w-3xl space-y-4"> <header class="max-w-3xl space-y-4">
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">{{ $case->event_type }}</p> @if (filled($case->event_type))
<h1 class="text-4xl font-medium tracking-tight text-amare-text md:text-5xl">{{ $case->title }}</h1> <p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">{{ $case->event_type }}</p>
@endif
<h1 class="text-headline font-medium tracking-tight text-amare-text">{{ $case->title }}</h1>
<p class="text-lg text-amare-muted">{{ $case->summary }}</p> <p class="text-lg text-amare-muted">{{ $case->summary }}</p>
<p class="text-sm text-amare-muted"> <p class="text-sm break-words text-amare-muted">
@if ($case->city){{ $case->city }}@endif @if ($case->city){{ $case->city }}@endif
@if ($case->venue) · {{ $case->venue }}@endif @if ($case->venue) · {{ $case->venue }}@endif
@if ($case->event_date) · {{ $case->event_date->format('d/m/Y') }}@endif @if ($case->event_date) · {{ $case->event_date->format('d/m/Y') }}@endif
@@ -69,4 +71,6 @@
</section> </section>
@endif @endif
</article> </article>
<x-home.final-cta :settings="$siteSettings" />
@endsection @endsection

View File

@@ -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-4xl font-medium tracking-tight text-amare-text md:text-5xl">Uma mesma excelência, diferentes ocasiões.</h1> <h1 class="text-headline font-medium tracking-tight text-amare-text">Uma mesma excelência, diferentes ocasiões.</h1>
<p class="text-lg text-amare-muted">O escopo é construído de acordo com o momento do projeto, o nível de apoio necessário e a complexidade de cada evento.</p> <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,4 +41,6 @@
@endif @endif
</div> </div>
</div> </div>
<x-home.final-cta :settings="$siteSettings" />
@endsection @endsection

View File

@@ -45,3 +45,35 @@
- [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)

View File

@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
use App\Models\SiteSetting;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function (): void {
SiteSetting::instance();
});
it('does not overflow horizontally when contact info contains long unbroken strings', function (): void {
SiteSetting::instance()->update([
'email' => str_repeat('a', 50).'@email.'.str_repeat('b', 40),
'phone' => '(11) 99999-9999',
]);
$page = $this->visit('/contato');
$overflow = $page->script(
'() => document.documentElement.scrollWidth - document.documentElement.clientWidth',
);
expect((int) $overflow)->toBeLessThanOrEqual(0);
});
it('shows sending state on submit and resets on bfcache restore', function (): void {
$page = $this->visit('/contato');
$state = $page->script(<<<'JS'
() => {
const form = document.querySelector("form[data-contact-form]");
const button = document.querySelector("[data-submit-button]");
form?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
const afterSubmit = {
label: button?.textContent?.trim(),
disabled: button?.disabled ?? false,
formBusy: form?.getAttribute("aria-busy"),
};
window.dispatchEvent(new PageTransitionEvent("pageshow", { persisted: true }));
return {
afterSubmit,
afterRestore: {
label: button?.textContent?.trim(),
disabled: button?.disabled ?? false,
formBusy: form?.getAttribute("aria-busy"),
},
};
}
JS);
expect($state['afterSubmit']['label'])->toBe('Enviando…');
expect($state['afterSubmit']['disabled'])->toBeTrue();
expect($state['afterSubmit']['formBusy'])->toBe('true');
expect($state['afterRestore']['label'])->toBe('Enviar briefing');
expect($state['afterRestore']['disabled'])->toBeFalse();
expect($state['afterRestore']['formBusy'])->toBeNull();
});
it('traps focus within the open mobile menu', function (): void {
$page = $this->visit('/');
$page->resize(390, 844);
$page->click('[data-menu-button]');
for ($i = 0; $i < 20; $i++) {
$inside = $page->script(
'() => document.querySelector("[data-main-nav]")?.contains(document.activeElement) ?? false',
);
expect($inside)->toBeTrue();
$page->keys(':focus', 'Tab');
}
$page->script('() => document.querySelector("[data-main-nav] a")?.focus()');
$page->keys(':focus', 'Shift+Tab');
$wrappedToLast = $page->script('() => {
const nav = document.querySelector("[data-main-nav]");
const links = nav?.querySelectorAll("a[href], button:not([disabled])") ?? [];
return links.length > 0 && document.activeElement === links[links.length - 1];
}');
expect($wrappedToLast)->toBeTrue();
});

View File

@@ -41,13 +41,14 @@ $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->visit($path, [ $this->assertStableScreenshotMatches(
'reducedMotion' => 'reduce', $this->visit($path, [
]) 'reducedMotion' => 'reduce',
->withLocale('pt-BR') ])
->withTimezone('America/Fortaleza') ->withLocale('pt-BR')
->resize($width, $height) ->withTimezone('America/Fortaleza')
->assertScreenshotMatches(); ->resize($width, $height)
);
}); });
} }
} }

View File

@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\PublicSite;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Route;
use Tests\TestCase;
class ErrorPagesTest extends TestCase
{
use RefreshDatabase;
public function test_419_renders_branded_session_expired_page(): void
{
Route::get('/__test-419', fn (): never => abort(419));
$this->get('/__test-419')
->assertStatus(419)
->assertSee('Erro 419')
->assertSee('Sessão expirada')
->assertSee('Voltar para a home');
}
public function test_429_renders_branded_rate_limited_page(): void
{
Route::get('/__test-429', fn (): never => abort(429));
$this->get('/__test-429')
->assertStatus(429)
->assertSee('Erro 429')
->assertSee('Muitas solicitações')
->assertSee('Voltar para a home');
}
public function test_503_renders_branded_maintenance_page(): void
{
Route::get('/__test-503', fn (): never => abort(503));
$this->get('/__test-503')
->assertStatus(503)
->assertSee('Erro 503')
->assertSee('Em manutenção')
->assertSee('Voltar para a home');
}
public function test_error_pages_never_leak_debug_stack_traces(): void
{
config(['app.debug' => false]);
Route::get('/__test-503', fn (): never => abort(503));
$this->get('/__test-503')
->assertStatus(503)
->assertDontSee('segredo-interno-amare')
->assertDontSee('Exception');
}
}

View File

@@ -110,4 +110,42 @@ class HomePageContentTest extends TestCase
->assertSee('id="positioning-heading"', false) ->assertSee('id="positioning-heading"', false)
->assertSee('id="final-cta-heading"', false); ->assertSee('id="final-cta-heading"', false);
} }
public function test_hero_falls_back_to_default_copy_when_fields_are_empty(): void
{
SiteSetting::instance()->update([
'hero_title' => '',
'hero_cta_label' => '',
'hero_secondary_cta_label' => '',
'hero_subtitle' => '',
'hero_note' => '',
]);
$response = $this->get(route('home'));
$response
->assertOk()
->assertSeeInOrder(['id="hero-heading"', 'Celebrações com propósito'])
->assertSeeInOrder(['data-testid="home-primary-cta"', 'Solicitar proposta']);
}
public function test_blank_quote_testimonials_are_skipped(): void
{
Testimonial::factory()->published()->create([
'author_name' => 'Autor Mantido',
'quote' => 'Experiência impecável do início ao fim.',
]);
Testimonial::factory()->published()->create([
'author_name' => 'Autor Oculto',
'quote' => ' ',
]);
$response = $this->get(route('home'));
$response
->assertOk()
->assertSee('Autor Mantido')
->assertSee('Experiência impecável do início ao fim.')
->assertDontSee('Autor Oculto');
}
} }

View File

@@ -196,4 +196,26 @@ class PublicPagesTest extends TestCase
$this->assertLessThan(15, $queryCount); $this->assertLessThan(15, $queryCount);
} }
public function test_case_without_event_type_does_not_render_empty_eyebrow(): void
{
SiteSetting::instance();
$case = PortfolioCase::factory()->published()->create([
'slug' => 'caso-sem-tipo',
'title' => 'Caso Sem Tipo',
'event_type' => '',
]);
$response = $this->get(route('portfolio.show', $case->slug));
$response->assertOk()->assertSee('Caso Sem Tipo');
$emptyAccentParagraphs = preg_match_all(
'/class="[^"]*text-amare-accent[^"]*"><\/p>/',
(string) $response->getContent(),
);
expect($emptyAccentParagraphs)->toBe(0);
}
} }

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace Tests\Support;
final class MismatchScreenshotExporter
{
/**
* Extracts the diff, expected, and actual images embedded in an ImageDiffView
* HTML file and writes them as standalone PNG files so they can be inspected
* directly in CI artifacts.
*
* @return list<string> paths of the written PNG files
*/
public static function export(string $htmlPath, string $targetDir): array
{
$html = (string) file_get_contents($htmlPath);
if (preg_match_all('/src="data:image\/png;base64,([^"]+)"/', $html, $matches) === false || ! isset($matches[1][2])) {
return [];
}
$base = pathinfo($htmlPath, PATHINFO_FILENAME);
$labels = ['diff', 'expected', 'actual'];
$written = [];
foreach (array_slice($matches[1], 0, 3) as $index => $base64) {
$path = $targetDir.'/'.$base.'-'.($labels[$index] ?? 'image-'.$index).'.png';
if (file_put_contents($path, (string) base64_decode($base64, true)) === false) {
continue;
}
$written[] = $path;
}
return $written;
}
}

View File

@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace Tests\Support;
use Pest\Browser\Api\AwaitableWebpage;
use Pest\Browser\Exceptions\BrowserExpectationFailedException;
use Pest\Browser\Support\Screenshot;
use Pest\TestSuite;
use PHPUnit\Framework\ExpectationFailedException;
trait StableScreenshot
{
/**
* Asserts the page matches its visual baseline without relying on the
* networkidle load state, which is inherently flaky (long-lived connections
* like fonts, polling, or keep-alive can keep it from ever firing and cause
* spurious timeouts at the 5s client default).
*
* On mismatch the vendor only writes an HTML diff view with base64-embedded
* images, so this helper additionally exports standalone diff/expected/actual
* PNG files that are directly viewable in CI artifacts.
*/
public function assertStableScreenshotMatches(AwaitableWebpage $awaitable): void
{
$page = $awaitable->page();
$page->addStyleTag('* {
transition: none !important;
animation: none !important;
font-family: Arial, sans-serif !important;
body {
-webkit-font-smoothing: antialiased !important;
-moz-osx-font-smoothing: grayscale !important;
}
}');
$page->waitForFunction(
'document.readyState === "complete"'
.' && document.fonts.status === "loaded"'
);
usleep(300_000);
try {
$page->expectScreenshot(true, false);
} catch (ExpectationFailedException $exception) {
$this->exportMismatchScreenshots();
throw BrowserExpectationFailedException::from($page, $exception);
}
}
private function exportMismatchScreenshots(): void
{
[$snapshotName] = TestSuite::getInstance()->snapshots->get();
$base = pathinfo($snapshotName, PATHINFO_FILENAME);
$htmlPath = Screenshot::dir().'/ImageDiffView/'.$base.'.html';
if (! is_file($htmlPath)) {
return;
}
MismatchScreenshotExporter::export($htmlPath, Screenshot::dir());
}
}

View File

@@ -3,8 +3,9 @@
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;
} }

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
use Tests\Support\MismatchScreenshotExporter;
it('exports diff, expected, and actual PNGs from an ImageDiffView html', function (): void {
$targetDir = sys_get_temp_dir().'/mismatch-exporter-'.uniqid();
mkdir($targetDir, 0755, true);
$expected = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgAAACAAFWoS02AAAAAElFTkSuQmCC';
$actual = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgAAACAAFWoS02AAAAAElFTkSuQmCC';
$diff = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgAAACAAFWoS02AAAAAElFTkSuQmCC';
$html = <<<HTML
<!DOCTYPE html>
<html><body>
<img alt="Diff" src="data:image/png;base64,{$diff}"/>
<img slot="image-1" alt="Expected" src="data:image/png;base64,{$expected}"/>
<img slot="image-2" alt="Actual" src="data:image/png;base64,{$actual}"/>
</body></html>
HTML;
$htmlPath = $targetDir.'/it_matches_home_desktop.html';
file_put_contents($htmlPath, $html);
$written = MismatchScreenshotExporter::export($htmlPath, $targetDir);
expect($written)->toHaveCount(3);
expect($written)->toContain($targetDir.'/it_matches_home_desktop-diff.png');
expect($written)->toContain($targetDir.'/it_matches_home_desktop-expected.png');
expect($written)->toContain($targetDir.'/it_matches_home_desktop-actual.png');
expect(base64_decode($diff, true))->toBe((string) file_get_contents($targetDir.'/it_matches_home_desktop-diff.png'));
expect(base64_decode($expected, true))->toBe((string) file_get_contents($targetDir.'/it_matches_home_desktop-expected.png'));
expect(base64_decode($actual, true))->toBe((string) file_get_contents($targetDir.'/it_matches_home_desktop-actual.png'));
});
it('returns an empty list when the html has no embedded images', function (): void {
$targetDir = sys_get_temp_dir().'/mismatch-exporter-'.uniqid();
mkdir($targetDir, 0755, true);
$htmlPath = $targetDir.'/plain.html';
file_put_contents($htmlPath, '<html><body>no images</body></html>');
expect(MismatchScreenshotExporter::export($htmlPath, $targetDir))->toBe([]);
});

View File

@@ -5,14 +5,23 @@ 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',
'portfolio-cover.jpg', 'service-casamentos.jpg',
'service-cover.jpg', 'service-eventos-corporativos.jpg',
'gallery.jpg', 'service-celebracoes-intimistas.jpg',
'testimonial.jpg', 'case-casamento-ana-lucas.jpg',
'gallery-casamento-ana-lucas-1.jpg',
'gallery-casamento-ana-lucas-2.jpg',
'case-lancamento-verano.jpg',
'gallery-lancamento-verano-1.jpg',
'gallery-lancamento-verano-2.jpg',
'case-mini-wedding-marina.jpg',
'gallery-mini-wedding-marina-1.jpg',
'gallery-mini-wedding-marina-2.jpg',
'about-image.jpg',
]; ];
foreach ($fixtures as $fixture) { foreach ($fixtures as $fixture) {
$path = dirname(__DIR__).'/fixtures/images/'.$fixture; $path = dirname(__DIR__, 2).'/database/fixtures/images/'.$fixture;
expect(is_file($path))->toBeTrue("Missing fixture: {$fixture}"); expect(is_file($path))->toBeTrue("Missing fixture: {$fixture}");

Binary file not shown.

Before

Width:  |  Height:  |  Size: 310 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 310 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 310 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 311 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 310 B