Compare commits

..

20 Commits

Author SHA1 Message Date
c702306ce5 fix: seed content images onto active filesystem disk
ContentSeeder always wrote to public; preview/prod use r2 so
URLs pointed at empty media host. Put fixtures on PublicImageUploadRules disk.
2026-08-06 10:15:52 -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
8a12b7d82c chore: add husky pre-commit and pre-push hooks (#12) 2026-08-05 23:02:46 -03:00
af8484c74e fix: keep motion entrance at full opacity for contrast
Axe sampled hero/page-open text mid opacity fade (~0.58), reporting
false WCAG failures. Animate transform/clip-path only.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 20:28:40 -03:00
35bc5a59d9 feat: Dossiê vivo motion for the public site (#10)
* feat: add Dossiê vivo motion to the public site

Introduce a focal Home opening, chapter index, and restrained continuity
reveals while keeping reduced-motion and visual baselines intact.

Co-authored-by: Cursor <cursoragent@cursor.com>

* test: refresh visual baselines from CI Ubuntu screenshots

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 20:16:51 -03:00
27711ad0c2 feat: add production testimonials seeder (#11)
Canonical upsert for five authorized couples so staging/prod
can load depoimentos without DatabaseSeeder/ContentSeeder.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 20:11:33 -03:00
af31738021 test: align Livewire temp-disk assertion with PHPUnit disk swap
FileUploadConfiguration: :disk() always returns tmp-for-tests under PHPUnit; assert config + isUsingS3() instead.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 19:53:19 -03:00
286522d8a2 fix: keep Livewire temp uploads local when using R2
Avoid browser PUT to R2 (CORS) by defaulting Filament/Livewire temp disk to local, and document Dokploy tinker + R2 CORS gotchas.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 00:11:32 -03:00
3e68192cf3 fix: join Dokploy network so Compose reaches managed Postgres (#9)
Migrate could not resolve the database internal hostname because the
Compose stack used an isolated project network. Attach services to the
external dokploy-network used by Dokploy Postgres.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 23:15:52 -03:00
1a31dd1cf6 fix: surface Dokploy API errors and normalize panel URL (#8)
curl -f hid 404 bodies; strip accidental /api on DOKPLOY_URL so compose.deploy diagnostics are actionable.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 23:14:12 -03:00
9060036024 test: refresh visual baselines from CI Ubuntu screenshots (#7)
Heritage Editorial redesign snaps were generated outside CI and fail
assertScreenshotMatches on FrankenPHP/Ubuntu Chromium. Promote the
browser-failure-artifacts PNGs from run 30729267028 into Pest snaps.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 22:23:58 -03:00
116 changed files with 3217 additions and 624 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

3
.husky/pre-commit Executable file
View File

@@ -0,0 +1,3 @@
#!/usr/bin/env sh
composer pint:check && composer phpstan

33
.husky/pre-push Executable file
View File

@@ -0,0 +1,33 @@
#!/usr/bin/env sh
# Abort push when the test database is unreachable, so broken code never
# reaches CI. Test DB settings come from phpunit.xml.
php -r '
$xml = @simplexml_load_file("phpunit.xml");
if ($xml === false) {
fwrite(STDERR, "phpunit.xml not found — aborting pre-push.\n");
exit(1);
}
$defaults = ["DB_HOST" => "127.0.0.1", "DB_PORT" => "5432", "DB_DATABASE" => "amare_test", "DB_USERNAME" => "amare", "DB_PASSWORD" => "secret"];
$env = [];
foreach ($xml->php->env as $node) {
$name = (string) $node["name"];
if (isset($defaults[$name])) {
$env[$name] = (string) $node["value"];
}
}
$config = array_merge($defaults, $env);
$dsn = sprintf("pgsql:host=%s;port=%s;dbname=%s", $config["DB_HOST"], $config["DB_PORT"], $config["DB_DATABASE"]);
try {
new PDO($dsn, $config["DB_USERNAME"], $config["DB_PASSWORD"], [PDO::ATTR_TIMEOUT => 3]);
} catch (PDOException $e) {
fwrite(STDERR, "\033[31mPostgreSQL is unreachable on {$config["DB_HOST"]}:{$config["DB_PORT"]} (db: {$config["DB_DATABASE"]}).\033[0m\n");
fwrite(STDERR, "Start it with: docker compose up -d\n");
fwrite(STDERR, "Then retry the push.\n");
exit(1);
}
' || exit 1
composer test:unit && composer test:feature

View File

@@ -12,5 +12,6 @@ related_targets: ["resources/views/pages/services/index.blade.php","resources/vi
- **Action, proof, and constraints:** ação principal é enviar briefing inicial. Prova disponível: cinco depoimentos reais de casamentos, com nomes e datas; publicação depende de autorização final. Não inventar cases corporativos, credenciais ou resultados. Fotografias permanecem ilustrativas e marcadas até chegada de acervo autorizado. - **Action, proof, and constraints:** ação principal é enviar briefing inicial. Prova disponível: cinco depoimentos reais de casamentos, com nomes e datas; publicação depende de autorização final. Não inventar cases corporativos, credenciais ou resultados. Fotografias permanecem ilustrativas e marcadas até chegada de acervo autorizado.
- **Chosen direction:** “Dossiê Editorial do Evento”. Home funciona como capa e índice; serviços viram capítulos, portfólio vira cadernos de caso, sobre vira perfil editorial e contato vira ficha de briefing. Sistema visual global segue Heritage Editorial em DESIGN.md. - **Chosen direction:** “Dossiê Editorial do Evento”. Home funciona como capa e índice; serviços viram capítulos, portfólio vira cadernos de caso, sobre vira perfil editorial e contato vira ficha de briefing. Sistema visual global segue Heritage Editorial em DESIGN.md.
- **Memorable moment:** coração facetado atua como selo editorial enquanto índice discreto acompanha capítulos e deixa serviço, método e próximo passo visíveis sem transformar página em dashboard. - **Memorable moment:** coração facetado atua como selo editorial enquanto índice discreto acompanha capítulos e deixa serviço, método e próximo passo visíveis sem transformar página em dashboard.
- **Motion thesis (Dossiê vivo):** Home recebe abertura autoral (selo → título → recorte de imagem → CTAs, ≤800ms). Índice lateral no desktop e linha de progresso no mobile acompanham capítulos. Serviços, portfólio e sobre usam abertura curta e revelações discretas; contato, privacidade e erros permanecem quase estáticos. Sem parallax, loops, bounce ou scroll-jacking. `prefers-reduced-motion` entrega estado final imediato.
- **Responsive and interaction:** spreads assimétricos no desktop; sequência linear no mobile. CTAs recorrentes, navegação clara, formulário com loading, erro e sucesso, foco visível e suporte a movimento reduzido. - **Responsive and interaction:** spreads assimétricos no desktop; sequência linear no mobile. CTAs recorrentes, navegação clara, formulário com loading, erro e sucesso, foco visível e suporte a movimento reduzido.
- **Unresolved:** logo transparente ou vetorial; fotografias autorizadas; WhatsApp, e-mail e Instagram oficiais; textos jurídicos; autorização dos depoimentos; provas reais de eventos corporativos. - **Unresolved:** logo transparente ou vetorial; fotografias autorizadas; WhatsApp, e-mail e Instagram oficiais; textos jurídicos; autorização dos depoimentos; provas reais de eventos corporativos.

View File

@@ -15,6 +15,17 @@ This is a Laravel 13 application for an event-planning consultancy. Application
Feature and browser tests require the `amare_test` PostgreSQL database configured in `phpunit.xml`. Feature and browser tests require the `amare_test` PostgreSQL database configured in `phpunit.xml`.
## Worktrees
Always work in a git worktree created from the `main` ref — never modify `main` directly and never commit from the primary working tree. Create a dedicated worktree per feature/branch with `git worktree add -b <branch> <path> main`. On finishing work, create a PR, watch CI until green, then merge it. Clean up the worktree with `git worktree remove` after merge.
## Git Hooks (husky)
Hooks live in `.husky/` and auto-install on any plain `npm install` via the `prepare` script. Note `composer setup` runs `npm install --ignore-scripts`, which skips hook installation — after setup, run `npm install` once (or `npx husky`) to activate hooks.
- `pre-commit`: runs `composer pint:check` and `composer phpstan`.
- `pre-push`: gates on the `amare_test` database (settings parsed from `phpunit.xml`), blocks the push with a `docker compose up -d` hint when Postgres is unreachable, then runs `composer test:unit` and `composer test:feature`. Browser tests are CI-only (FrankenPHP container).
## Coding Style & Naming Conventions ## Coding Style & Naming Conventions
Follow PSR-4 and Laravel conventions: PascalCase classes, camelCase methods, and snake_case database columns. Use four spaces (two in YAML, except four in Compose files), LF endings, and UTF-8 as defined by `.editorconfig`. Every project-owned PHP file must place `declare(strict_types=1);` immediately after `<?php`. Keep domain code independent of Filament and Livewire. Run `composer pint` to format and `composer phpstan` before review. Follow PSR-4 and Laravel conventions: PascalCase classes, camelCase methods, and snake_case database columns. Use four spaces (two in YAML, except four in Compose files), LF endings, and UTF-8 as defined by `.editorconfig`. Every project-owned PHP file must place `declare(strict_types=1);` immediately after `<?php`. Keep domain code independent of Filament and Livewire. Run `composer pint` to format and `composer phpstan` before review.

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

@@ -21,6 +21,8 @@ final readonly class PageMeta
public ?string $ogImageUrl = null, public ?string $ogImageUrl = null,
public ?string $ogImageAlt = null, public ?string $ogImageAlt = null,
public ?array $jsonLd = null, public ?array $jsonLd = null,
public string $siteName = '',
public string $robots = 'index, follow',
) {} ) {}
/** /**
@@ -44,6 +46,7 @@ final readonly class PageMeta
ogImageUrl: $ogImageUrl ?? self::defaultOgImageUrl($settings), ogImageUrl: $ogImageUrl ?? self::defaultOgImageUrl($settings),
ogImageAlt: $ogImageAlt ?? $settings->default_og_image_alt, ogImageAlt: $ogImageAlt ?? $settings->default_og_image_alt,
jsonLd: $jsonLd, jsonLd: $jsonLd,
siteName: (string) $settings->brand_name,
); );
} }
@@ -77,9 +80,53 @@ final readonly class PageMeta
ogImageUrl: $ogImageUrl, ogImageUrl: $ogImageUrl,
ogImageAlt: $ogImageAlt, ogImageAlt: $ogImageAlt,
jsonLd: $jsonLd, jsonLd: $jsonLd,
siteName: (string) $settings->brand_name,
); );
} }
/**
* Build the metadata for branded error pages.
*
* Error pages carry no canonical, are excluded from search indexes and use
* the page name suffixed with the brand name as their title.
*/
public static function forErrorPage(
SiteSetting $settings,
int $status = 404,
): self {
[$title, $description] = match ($status) {
500 => ['Algo deu errado', 'Não foi possível concluir o pedido. Tente novamente em instantes.'],
419 => ['Sessão expirada', 'Sua sessão expirou. Volte e tente enviar novamente.'],
429 => ['Muitas solicitações', 'Você enviou muitas solicitações em pouco tempo. Aguarde um instante e tente novamente.'],
503 => ['Em manutenção', 'Estamos realizando uma breve manutenção. Tente novamente em instantes.'],
default => ['Página não encontrada', 'A página que você procura não existe ou foi movida.'],
};
return new self(
title: trim($title).' - '.$settings->brand_name,
description: $description,
canonical: '',
robots: 'noindex, nofollow',
siteName: (string) $settings->brand_name,
);
}
/**
* Append the brand name to a page title when it is not already present.
*/
public static function withBrandSuffix(string $title, SiteSetting $settings): string
{
$brand = filled($settings->default_meta_title)
? (string) $settings->default_meta_title
: (string) $settings->brand_name;
if (str_contains($title, $brand)) {
return $title;
}
return trim($title).' - '.$brand;
}
private static function defaultTitle(SiteSetting $settings): string private static function defaultTitle(SiteSetting $settings): string
{ {
return filled($settings->default_meta_title) return filled($settings->default_meta_title)

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

@@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\PublicSite;
use App\Http\Controllers\Controller;
use App\Http\Requests\PublicSite\ContactBriefingRequest;
use App\Mail\ContactBriefing;
use App\Mail\ContactBriefingConfirmation;
use App\Models\SiteSetting;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Throwable;
final class ContactController extends Controller
{
private const string DUPLICATE_SESSION_KEY = 'contact_briefing_hash';
public function store(ContactBriefingRequest $request): RedirectResponse
{
if (filled($request->input('empresa'))) {
return $this->success();
}
$validated = $request->validated();
unset($validated['privacidade']);
if ($this->isDuplicate($validated)) {
return $this->success();
}
$this->rememberSubmission($validated);
$settings = SiteSetting::instance();
$fields = $this->buildFields($validated);
$this->dispatchEmails($settings, $validated['nome'], $validated['email'], $fields);
return $this->success();
}
/**
* @param array<string, mixed> $validated
*/
private function buildFields(array $validated): array
{
return [
'Nome' => (string) $validated['nome'],
'E-mail' => (string) $validated['email'],
'Telefone/WhatsApp' => (string) $validated['telefone'],
'Tipo de evento' => (string) $validated['tipo_evento'],
'Data ou período desejado' => isset($validated['data_periodo']) ? (string) $validated['data_periodo'] : null,
'Cidade' => (string) $validated['cidade'],
'Número estimado de convidados' => isset($validated['convidados']) ? (string) $validated['convidados'] : null,
'Serviço de interesse' => isset($validated['servico_interesse']) ? (string) $validated['servico_interesse'] : null,
'Mensagem' => (string) $validated['mensagem'],
];
}
/**
* @param array<string, mixed> $validated
*/
private function isDuplicate(array $validated): bool
{
$hash = $this->hash($validated);
return session()->get(self::DUPLICATE_SESSION_KEY) === $hash;
}
/**
* @param array<string, mixed> $validated
*/
private function rememberSubmission(array $validated): void
{
session()->put(self::DUPLICATE_SESSION_KEY, $this->hash($validated));
}
/**
* @param array<string, mixed> $validated
*/
private function hash(array $validated): string
{
return hash('sha256', serialize($validated));
}
/**
* @param array<string, mixed> $fields
*/
private function dispatchEmails(SiteSetting $settings, string $name, string $email, array $fields): void
{
$attempt = function () use ($settings, $name, $email, $fields): void {
if (filled($settings->email)) {
Mail::to($settings->email)->send(new ContactBriefing($fields));
}
if (filled(config('mail.default'))) {
Mail::to($email)->send(new ContactBriefingConfirmation($name, (string) $settings->brand_name));
}
};
try {
$attempt();
} catch (Throwable $exception) {
Log::error('Falha ao enviar briefing de contato', [
'exception' => $exception,
]);
}
}
private function success(): RedirectResponse
{
return redirect()->route('contact')->with('status', 'briefing-sent');
}
}

View File

@@ -20,7 +20,7 @@ final class PageController extends Controller
'pageMeta' => PageMeta::forPage( 'pageMeta' => PageMeta::forPage(
canonical: route('about'), canonical: route('about'),
settings: $settings, settings: $settings,
title: 'Sobre', title: PageMeta::withBrandSuffix('Sobre', $settings),
description: $settings->about_summary ?: ('Conheça a '.$settings->brand_name.'.'), description: $settings->about_summary ?: ('Conheça a '.$settings->brand_name.'.'),
), ),
]); ]);
@@ -35,7 +35,7 @@ final class PageController extends Controller
'pageMeta' => PageMeta::forPage( 'pageMeta' => PageMeta::forPage(
canonical: route('privacy'), canonical: route('privacy'),
settings: $settings, settings: $settings,
title: 'Política de privacidade', title: PageMeta::withBrandSuffix('Política de privacidade', $settings),
description: 'Política de privacidade da '.$settings->brand_name.'.', description: 'Política de privacidade da '.$settings->brand_name.'.',
), ),
]); ]);
@@ -50,7 +50,7 @@ final class PageController extends Controller
'pageMeta' => PageMeta::forPage( 'pageMeta' => PageMeta::forPage(
canonical: route('contact'), canonical: route('contact'),
settings: $settings, settings: $settings,
title: 'Contato', title: PageMeta::withBrandSuffix('Contato', $settings),
description: 'Fale com a '.$settings->brand_name.'.', description: 'Fale com a '.$settings->brand_name.'.',
), ),
]); ]);

View File

@@ -32,7 +32,7 @@ final class PortfolioController extends Controller
'pageMeta' => PageMeta::forPage( 'pageMeta' => PageMeta::forPage(
canonical: route('portfolio.index'), canonical: route('portfolio.index'),
settings: $settings, settings: $settings,
title: 'Portfólio', title: PageMeta::withBrandSuffix('Portfólio', $settings),
description: 'Casos reais de eventos conduzidos pela '.$settings->brand_name.'.', description: 'Casos reais de eventos conduzidos pela '.$settings->brand_name.'.',
), ),
]); ]);

View File

@@ -23,7 +23,7 @@ final class ServiceController extends Controller
'pageMeta' => PageMeta::forPage( 'pageMeta' => PageMeta::forPage(
canonical: route('services.index'), canonical: route('services.index'),
settings: $settings, settings: $settings,
title: 'Serviços', title: PageMeta::withBrandSuffix('Serviços', $settings),
description: 'Conheça os serviços de assessoria de eventos da '.$settings->brand_name.'.', description: 'Conheça os serviços de assessoria de eventos da '.$settings->brand_name.'.',
), ),
]); ]);

View File

@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\PublicSite;
use Illuminate\Foundation\Http\FormRequest;
final class ContactBriefingRequest extends FormRequest
{
/**
* @return array<string, array<int, string>>
*/
public function rules(): array
{
return [
'nome' => ['required', 'string', 'max:120'],
'email' => ['required', 'email', 'max:254'],
'telefone' => ['required', 'string', 'max:40'],
'tipo_evento' => ['required', 'string', 'max:80'],
'data_periodo' => ['nullable', 'string', 'max:80'],
'cidade' => ['required', 'string', 'max:80'],
'convidados' => ['nullable', 'integer', 'min:1', 'max:100000'],
'servico_interesse' => ['nullable', 'string', 'max:120'],
'mensagem' => ['required', 'string', 'max:3000'],
'privacidade' => ['accepted'],
];
}
/**
* @return array<string, string>
*/
public function messages(): array
{
return [
'nome.required' => 'Informe seu nome completo.',
'nome.max' => 'O nome deve ter no máximo :max caracteres.',
'email.required' => 'Informe seu e-mail.',
'email.email' => 'Informe um e-mail válido.',
'email.max' => 'O e-mail deve ter no máximo :max caracteres.',
'telefone.required' => 'Informe um telefone ou WhatsApp.',
'telefone.max' => 'O telefone deve ter no máximo :max caracteres.',
'tipo_evento.required' => 'Selecione o tipo de evento.',
'tipo_evento.max' => 'O tipo de evento deve ter no máximo :max caracteres.',
'data_periodo.max' => 'A data ou período deve ter no máximo :max caracteres.',
'cidade.required' => 'Informe a cidade do evento.',
'cidade.max' => 'A cidade deve ter no máximo :max caracteres.',
'convidados.integer' => 'Informe um número de convidados válido.',
'convidados.min' => 'O número de convidados deve ser maior que zero.',
'convidados.max' => 'O número de convidados informado é inválido.',
'servico_interesse.max' => 'O serviço deve ter no máximo :max caracteres.',
'mensagem.required' => 'Conte brevemente o que você precisa.',
'mensagem.max' => 'A mensagem deve ter no máximo :max caracteres.',
'privacidade.accepted' => 'Você precisa aceitar a política de privacidade.',
];
}
}

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
final class ContactBriefing extends Mailable implements ShouldQueue
{
use Queueable;
use SerializesModels;
/**
* @param array<string, mixed> $fields
*/
public function __construct(
public readonly array $fields,
) {}
public function envelope(): Envelope
{
return new Envelope(
subject: 'Novo briefing de contato — Amare Assessoria',
);
}
public function content(): Content
{
return new Content(
html: 'emails.contact-briefing',
text: 'emails.contact-briefing-text',
);
}
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
final class ContactBriefingConfirmation extends Mailable implements ShouldQueue
{
use Queueable;
use SerializesModels;
public function __construct(
public readonly string $name,
public readonly string $brandName,
) {}
public function envelope(): Envelope
{
return new Envelope(
subject: 'Recebemos sua mensagem — '.$this->brandName,
);
}
public function content(): Content
{
return new Content(
html: 'emails.contact-briefing-confirmation',
text: 'emails.contact-briefing-confirmation-text',
);
}
}

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',

View File

@@ -7,6 +7,9 @@ namespace App\Providers;
use App\Application\Data\PageMeta; use App\Application\Data\PageMeta;
use App\Models\SiteSetting; use App\Models\SiteSetting;
use Carbon\CarbonImmutable; use Carbon\CarbonImmutable;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\View; use Illuminate\Support\Facades\View;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Illuminate\View\View as ViewInstance; use Illuminate\View\View as ViewInstance;
@@ -26,7 +29,9 @@ class AppServiceProvider extends ServiceProvider
*/ */
public function boot(): void public function boot(): void
{ {
$this->configureLivewireTemporaryUploads();
$this->freezeClockWhenConfigured(); $this->freezeClockWhenConfigured();
$this->configureRateLimiters();
View::composer('layouts.public', function (ViewInstance $view): void { View::composer('layouts.public', function (ViewInstance $view): void {
$settings = $view->offsetExists('siteSettings') $settings = $view->offsetExists('siteSettings')
@@ -46,6 +51,29 @@ class AppServiceProvider extends ServiceProvider
}); });
} }
/**
* Keep Livewire/Filament temp uploads on the local disk.
*
* When FILESYSTEM_DISK=r2, Livewire would otherwise use the S3 driver and
* browser-PUT straight to R2 (CORS). Final media still uses the r2 disk via
* PublicImageUploadRules. Explicit LIVEWIRE_TEMPORARY_FILE_UPLOAD_DISK wins.
*/
private function configureLivewireTemporaryUploads(): void
{
if (filled(config('livewire.temporary_file_upload.disk'))) {
return;
}
config(['livewire.temporary_file_upload.disk' => 'local']);
}
private function configureRateLimiters(): void
{
RateLimiter::for('contact-briefing', function (Request $request): Limit {
return Limit::perMinute(5)->by($request->ip().'|contact-briefing');
});
}
private function freezeClockWhenConfigured(): void private function freezeClockWhenConfigured(): void
{ {
if ($this->app->environment('production')) { if ($this->app->environment('production')) {

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,7 +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\Models\Testimonial; 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;
@@ -23,7 +23,7 @@ class ContentSeeder extends Seeder
$this->seedSiteSettings(); $this->seedSiteSettings();
$this->seedServices(); $this->seedServices();
$this->seedPortfolioCases(); $this->seedPortfolioCases();
$this->seedTestimonials(); $this->call(TestimonialsSeeder::class);
} }
private function seedSiteSettings(): void private function seedSiteSettings(): void
@@ -37,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.',
@@ -92,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),
], ],
@@ -149,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),
@@ -161,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,
@@ -170,71 +172,12 @@ class ContentSeeder extends Seeder
} }
} }
private function seedTestimonials(): void
{
// Real couples from depoimentos.md. Production publication still requires
// explicit couple authorization before setting published_at outside local/demo seeds.
$testimonials = [
[
'quote' => "Mi, quero agradecer você e a sua equipe por todo empenho, atenção, vocês são abençoadas.\n\nEra nítida sua preocupação em garantir que todos os detalhes planejados desta comemoração, fossem atendidos.\n\nQue você possa transformar o grande dia das noivinhas sempre com essa sua leveza!!!\n\nMuito obrigada!",
'author_name' => 'Jeniffer e Maick',
'context' => 'Casamento · 06/12/2025',
'sort_order' => 1,
'is_featured' => true,
],
[
'quote' => "Mi, eu não tenho palavras pra agradecer você e tudo que você fez por mim e por nós na realização desse sonho. Eu tô ainda extasiada com tudo que aconteceu hoje; mas tenho certeza que sem a sua ajuda, muita coisa não aconteceria.\n\nObrigada por tudo !",
'author_name' => 'Quesia e Jhonata',
'context' => 'Casamento · 21/12/2025',
'sort_order' => 2,
'is_featured' => true,
],
[
'quote' => 'Que equipe!! Que equipe maravilhosa!! Obrigado pelo empenho de fazer tudo como eu queria!! Obrigado por se esforçar tanto e vir de tão longe pra realizar meu sonho!! Incríveis!!',
'author_name' => 'Milena e Weslley',
'context' => 'Casamento · 13/02/2026',
'sort_order' => 3,
'is_featured' => false,
],
[
'quote' => "Gostaríamos de agradecer por todo o acompanhamento e dedicação durante a realização do nosso casamento. Foi um dia muito especial e inesquecível para nós.\n\nDesde o início, conseguimos conduzir tudo aquilo que estávamos planejando, dentro dos horários que estipulamos, o que foi ótimo, e no grande dia sua equipe nos recebeu e tratou com muito carinho, atenção e cuidado, o que fez toda a diferença para vivermos esse momento com mais tranquilidade.\n\nTambém adoramos as sugestões e ideias para as fotos, que deixaram os registros ainda mais bonitos e espontâneos, porque não iríamos lembrar de quais poses fazer na hora.\n\nObrigada por fazer parte de um momento tão importante das nossas vidas. Desejamos muito sucesso e que muitos outros casais possam viver dias especiais através do trabalho da AMARE.",
'author_name' => 'Raquel e Pedro',
'context' => 'Casamento · 09/05/2026',
'sort_order' => 4,
'is_featured' => false,
],
[
'quote' => "Miiii, meu amor… você e sua equipe foram impecáveis.\n\nSuperou todas as nossas expectativas. Somos eternamente gratos por fazer nosso dia acontecer muito melhor do que imaginávamos.\n\nSempre muito atenciosa e paciente.\n\nAdoramos te conhecer e estamos muito felizes em termos escolhido você para assessorar nosso dia.",
'author_name' => 'Victoria e Pedro',
'context' => 'Casamento · 24/06/2026',
'sort_order' => 5,
'is_featured' => false,
],
];
$keepAuthors = array_column($testimonials, 'author_name');
Testimonial::query()
->whereNotIn('author_name', $keepAuthors)
->delete();
foreach ($testimonials as $testimonial) {
Testimonial::query()->updateOrCreate(
['author_name' => $testimonial['author_name']],
[
...$testimonial,
'photo_path' => null,
'photo_alt' => null,
'published_at' => Carbon::parse(self::SEED_TIMESTAMP),
],
);
}
}
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

@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace Database\Seeders;
use App\Models\Testimonial;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
class TestimonialsSeeder extends Seeder
{
private const PUBLISHED_AT = '2026-08-05 00:00:00';
public function run(): void
{
$testimonials = [
[
'quote' => "Mi, quero agradecer você e a sua equipe por todo empenho, atenção, vocês são abençoadas.\n\nEra nítida sua preocupação em garantir que todos os detalhes planejados desta comemoração, fossem atendidos.\n\nQue você possa transformar o grande dia das noivinhas sempre com essa sua leveza!!!\n\nMuito obrigada!",
'author_name' => 'Jeniffer e Maick',
'context' => 'Casamento · 06/12/2025',
'sort_order' => 1,
'is_featured' => true,
],
[
'quote' => "Mi, eu não tenho palavras pra agradecer você e tudo que você fez por mim e por nós na realização desse sonho. Eu tô ainda extasiada com tudo que aconteceu hoje; mas tenho certeza que sem a sua ajuda, muita coisa não aconteceria.\n\nObrigada por tudo !",
'author_name' => 'Quesia e Jhonata',
'context' => 'Casamento · 21/12/2025',
'sort_order' => 2,
'is_featured' => true,
],
[
'quote' => 'Que equipe!! Que equipe maravilhosa!! Obrigado pelo empenho de fazer tudo como eu queria!! Obrigado por se esforçar tanto e vir de tão longe pra realizar meu sonho!! Incríveis!!',
'author_name' => 'Milena e Weslley',
'context' => 'Casamento · 13/02/2026',
'sort_order' => 3,
'is_featured' => false,
],
[
'quote' => "Gostaríamos de agradecer por todo o acompanhamento e dedicação durante a realização do nosso casamento. Foi um dia muito especial e inesquecível para nós.\n\nDesde o início, conseguimos conduzir tudo aquilo que estávamos planejando, dentro dos horários que estipulamos, o que foi ótimo, e no grande dia sua equipe nos recebeu e tratou com muito carinho, atenção e cuidado, o que fez toda a diferença para vivermos esse momento com mais tranquilidade.\n\nTambém adoramos as sugestões e ideias para as fotos, que deixaram os registros ainda mais bonitos e espontâneos, porque não iríamos lembrar de quais poses fazer na hora.\n\nObrigada por fazer parte de um momento tão importante das nossas vidas. Desejamos muito sucesso e que muitos outros casais possam viver dias especiais através do trabalho da AMARE.",
'author_name' => 'Raquel e Pedro',
'context' => 'Casamento · 09/05/2026',
'sort_order' => 4,
'is_featured' => false,
],
[
'quote' => "Miiii, meu amor… você e sua equipe foram impecáveis.\n\nSuperou todas as nossas expectativas. Somos eternamente gratos por fazer nosso dia acontecer muito melhor do que imaginávamos.\n\nSempre muito atenciosa e paciente.\n\nAdoramos te conhecer e estamos muito felizes em termos escolhido você para assessorar nosso dia.",
'author_name' => 'Victoria e Pedro',
'context' => 'Casamento · 24/06/2026',
'sort_order' => 5,
'is_featured' => false,
],
];
DB::transaction(function () use ($testimonials): void {
foreach ($testimonials as $testimonial) {
Testimonial::query()->updateOrCreate(
['author_name' => $testimonial['author_name']],
[
'quote' => $testimonial['quote'],
'context' => $testimonial['context'],
'sort_order' => $testimonial['sort_order'],
'is_featured' => $testimonial['is_featured'],
'published_at' => self::PUBLISHED_AT,
],
);
}
});
}
}

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

@@ -100,19 +100,41 @@ LOG_LEVEL=warning
Trusted proxies are configured in `bootstrap/app.php` so Traefik `X-Forwarded-*` headers work for HTTPS cookies and URLs. Trusted proxies are configured in `bootstrap/app.php` so Traefik `X-Forwarded-*` headers work for HTTPS cookies and URLs.
Livewire temporary uploads default to the **local** disk in `AppServiceProvider` (even when `FILESYSTEM_DISK=r2`), so Filament does not browser-PUT to R2. Final media still lands on R2 via `PublicImageUploadRules`. Optional override: `LIVEWIRE_TEMPORARY_FILE_UPLOAD_DISK` in Dokploy `.env` (`env_file` accepts any key — does not need a compose `environment:` entry).
### R2 CORS (public/media reads from JS)
Upload path does not need R2 CORS with the local temp-disk default. Still useful if the browser fetches R2 URLs cross-origin from JS. Origin must match `APP_URL` exactly; include `AllowedHeaders`:
```json
[
{
"AllowedOrigins": ["https://hellomanoel.com"],
"AllowedMethods": ["GET", "PUT", "POST", "HEAD"],
"AllowedHeaders": ["*"],
"ExposeHeaders": ["ETag", "Content-Type"],
"MaxAgeSeconds": 3600
}
]
```
Also enable public access / custom domain for `R2_URL` so `<img>` URLs work after save.
## GitHub Actions secrets ## GitHub Actions secrets
Repository secrets used by workflows: Repository secrets used by workflows:
| Secret | Purpose | | Secret | Purpose |
|---|---| |---|---|
| `DOKPLOY_URL` | Base URL of the Dokploy panel (no trailing slash) | | `DOKPLOY_URL` | Panel origin **without** `/api` (e.g. `https://panel.example.com`). Do not use the OpenAPI base URL that ends in `/api` — that yields `/api/api/...` and 404s. |
| `DOKPLOY_API_KEY` | API key from Dokploy profile → API/CLI | | `DOKPLOY_API_KEY` | API key from Dokploy profile → API/CLI |
| `DOKPLOY_STAGING_COMPOSE_ID` | Staging compose id | | `DOKPLOY_STAGING_COMPOSE_ID` | Staging **Compose** service id (not an Application id) |
| `DOKPLOY_PRODUCTION_COMPOSE_ID` | Production compose id | | `DOKPLOY_PRODUCTION_COMPOSE_ID` | Production **Compose** service id (not an Application id) |
| `STAGING_URL` | Public origin for staging smoke (e.g. `https://staging.example.com`) | | `STAGING_URL` | Public origin for staging smoke (e.g. `https://staging.example.com`) |
| `PRODUCTION_URL` | Public origin for production smoke | | `PRODUCTION_URL` | Public origin for production smoke |
HTTP 404 from `compose.deploy` usually means the compose id is wrong (Application id instead of Compose) or `DOKPLOY_URL` still includes `/api`.
`GITHUB_TOKEN` (automatic) publishes to GHCR with `packages:write`. No Laravel/`APP_KEY`/DB/R2/Resend secrets belong in GitHub for this pipeline. `GITHUB_TOKEN` (automatic) publishes to GHCR with `packages:write`. No Laravel/`APP_KEY`/DB/R2/Resend secrets belong in GitHub for this pipeline.
## Workflows ## Workflows
@@ -137,27 +159,67 @@ Repository secrets used by workflows:
Private repos on GitHub Free do not get Environment required reviewers; human approval is the explicit `workflow_dispatch` + confirmation string. GitHub Pro Environment reviewers are optional later. Private repos on GitHub Free do not get Environment required reviewers; human approval is the explicit `workflow_dispatch` + confirmation string. GitHub Pro Environment reviewers are optional later.
## First admin (no `db:seed` in production) ## First admin and authorized production seeding
Seed credentials are local-only. For staging/production: Seed credentials are local-only. For staging/production:
FrankenPHP sets `XDG_CONFIG_HOME=/config` (Caddy). PsySH/tinker then tries `/config/psysh`, which `appuser` cannot write — you get `Writing to directory /config/psysh is not allowed.` Override that env for the one-shot command:
```bash ```bash
# From Dokploy → staging/production → Open terminal on `web` (or one-off run) # From Dokploy → staging/production → Open terminal on `web` (or one-off run)
php artisan tinker --execute=" XDG_CONFIG_HOME=/tmp php artisan tinker --execute="
\\App\\Models\\User::query()->updateOrCreate( \$user = \\App\\Models\\User::query()->updateOrCreate(
['email' => 'admin@example.com'], ['email' => 'admin@example.com'],
[ [
'name' => 'Admin', 'name' => 'Admin',
'password' => bcrypt('use-a-strong-password'), 'password' => 'use-a-strong-password',
'role' => 'admin', 'role' => 'admin',
'is_active' => true, 'is_active' => true,
'email_verified_at' => now(),
] ]
); );
\$user->forceFill(['email_verified_at' => now()])->save();
echo \$user->email.PHP_EOL;
" "
``` ```
Adjust field names if the model evolves. Never reuse `admin@amare.local` / `password`. Plain password is enough: `User` casts `password` to `hashed` (and skips re-hash when value already hashed). `email_verified_at` is not mass-assignable — use `forceFill` as above.
Confirm:
```bash
XDG_CONFIG_HOME=/tmp php artisan tinker --execute="echo \\App\\Models\\User::query()->where('email', 'admin@example.com')->exists() ? 'ok' : 'missing';"
```
Never reuse `admin@amare.local` / `password`.
### Load authorized testimonials after migrations
After migrations, manually load the five authorized testimonials in **staging**, then repeat in **production**. From Dokploy, open a terminal on the environment's `web` service (or run an equivalent one-off process):
```bash
php artisan db:seed --class='Database\Seeders\TestimonialsSeeder' --force --no-interaction
```
This seeder is safe to rerun: it overwrites canonical source-owned fields, preserves curated photo fields, and leaves unrelated testimonials unchanged. The five records are published with the approved deterministic timestamp. Upsert keys on `author_name` (no unique DB constraint); keep one row per couple before/after running.
Optional verification:
```bash
XDG_CONFIG_HOME=/tmp php artisan tinker --execute="
\$expected = collect(['Jeniffer e Maick', 'Quesia e Jhonata', 'Milena e Weslley', 'Raquel e Pedro', 'Victoria e Pedro']);
\$rows = \\App\\Models\\Testimonial::query()->whereIn('author_name', \$expected)->get(['author_name', 'published_at'])->groupBy('author_name');
\$valid = \$expected->every(function (string \$author) use (\$rows): bool {
\$matches = \$rows->get(\$author, collect());
return \$matches->count() === 1
&& \$matches->first()->published_at?->format('Y-m-d H:i:s') === '2026-08-05 00:00:00';
});
echo (\$valid ? 'ok' : 'invalid').PHP_EOL;
"
```
Expected output: `ok`.
Do not run `DatabaseSeeder` or `ContentSeeder` in staging or production: they include local credentials and/or broad demo-content effects. Deployment workflows intentionally remain migrate-only; loading these testimonials is a deliberate manual operation in each environment.
## Backup and restore ## Backup and restore

262
frontend-audit.md Normal file
View File

@@ -0,0 +1,262 @@
Audite e melhore integralmente este projeto do site da **Amare**, incluindo todas as rotas públicas, componentes compartilhados, formulários, navegação, conteúdo, metadados e comportamento responsivo.
Você está autorizado a executar o projeto, inspecionar o repositório e modificar diretamente o código. Não entregue somente recomendações: implemente as correções necessárias e valide o resultado.
## Objetivo
Deixar o site tecnicamente sólido, visualmente refinado e pronto para apresentação à cliente e posterior lançamento, corrigindo problemas de:
* UI e UX;
* responsividade;
* posicionamento e conteúdo;
* acessibilidade;
* conversão;
* SEO;
* performance;
* robustez;
* qualidade e manutenção do código.
## Contexto do produto
A Amare é uma empresa de assessoria, produção e organização de eventos em São Paulo.
Ela atende:
* casamentos;
* eventos sociais e celebrações particulares;
* eventos corporativos.
O site não deve transmitir que a empresa trabalha exclusivamente com casamentos.
A experiência precisa equilibrar:
* emoção, proximidade, sensibilidade e sofisticação para eventos sociais;
* organização, segurança, método, clareza e credibilidade para eventos corporativos.
O resultado deve ser editorial, contemporâneo, humano, elegante e profissional, sem parecer excessivamente romântico nem corporativo demais.
Preview de referência:
`https://amare.preview.hellomanoel.com/`
Considere os documentos existentes no repositório, especialmente `AGENTS.md`, `README.md`, `SPEC.md`, `DESIGN.md` e equivalentes, como contexto autoritativo do projeto.
## Direção visual
Preserve a identidade visual existente quando ela funcionar corretamente:
* fundos off-white ou bege claro;
* verde oliva;
* composição editorial;
* fotografias em destaque;
* espaços em branco generosos;
* EB Garamond em títulos e destaques;
* elementos minimalistas;
* animações discretas;
* aparência refinada e atemporal.
Não preserve decisões que prejudiquem usabilidade, contraste, legibilidade, acessibilidade, responsividade ou performance.
Avalie especialmente:
* uso excessivo de Garamond em textos pequenos, menus, botões e formulários;
* verdes claros com contraste insuficiente;
* CTAs discretos demais;
* espaços vazios excessivos no celular;
* imagens que reforcem somente o posicionamento de casamento;
* falta de equilíbrio entre conteúdo social e corporativo;
* inconsistência tipográfica ou de espaçamento;
* aparência de template genérico de casamento.
## Escopo da auditoria
Analise todas as rotas encontradas no código e corrija problemas relacionados a:
### Produto e conteúdo
* clareza da proposta de valor;
* equilíbrio entre eventos sociais e corporativos;
* hierarquia das informações;
* conteúdo genérico, redundante ou sem função;
* coerência entre títulos, textos, imagens e CTAs;
* clareza dos serviços;
* confiança e credibilidade;
* caminho até contato ou solicitação de proposta.
Não invente história, números, clientes, prêmios, depoimentos, equipe, serviços, telefone, e-mail ou qualquer informação não confirmada.
### UI e experiência
* navegação;
* header e footer;
* menu mobile;
* hierarquia visual;
* tipografia;
* espaçamento;
* grids;
* CTAs;
* formulários;
* estados interativos;
* consistência entre páginas;
* experiência mobile-first;
* ausência de overflow, cortes, sobreposições ou distorções;
* adaptação real do layout ao celular, e não apenas redução da versão desktop.
### Acessibilidade
Use WCAG 2.2 AA como referência prática.
Corrija problemas de:
* HTML semântico;
* hierarquia de headings;
* navegação por teclado;
* foco visível;
* contraste;
* nomes acessíveis;
* labels;
* mensagens de erro;
* áreas de toque;
* textos alternativos;
* menu mobile;
* overlays;
* preferência por redução de movimento;
* uso correto de links e botões.
Prefira HTML semântico a ARIA desnecessária.
### Formulários e conversão
Garanta, quando aplicável:
* campos e obrigatoriedade claros;
* validação adequada;
* mensagens de erro específicas;
* estado de envio;
* prevenção de envio duplicado;
* estados de sucesso e falha;
* tratamento de erro de rede;
* preservação dos dados após erros recuperáveis;
* proteção antispam simples;
* privacidade e LGPD;
* links e mensagens de WhatsApp corretos;
* ausência de segredos expostos no cliente.
Quando algum dado real não estiver disponível, use configuração ou variável de ambiente e documente a pendência.
### SEO
Corrija, quando aplicável:
* títulos exclusivos por rota;
* meta descriptions;
* canonical;
* Open Graph;
* Twitter cards;
* sitemap;
* robots;
* favicon;
* idioma;
* headings;
* URLs;
* links internos;
* página 404;
* metadados sociais;
* indexação distinta entre preview e produção.
Não crie dados estruturados com informações não confirmadas.
### Performance
Corrija problemas relevantes relacionados a:
* imagens;
* tamanhos responsivos;
* formatos modernos;
* lazy loading;
* LCP;
* CLS;
* INP;
* fontes;
* scripts desnecessários;
* hidratação excessiva;
* JavaScript evitável;
* componentes client-side sem necessidade;
* animações custosas;
* dependências pesadas;
* carregamento de conteúdo abaixo da dobra.
Preserve a qualidade visual das fotografias.
### Qualidade técnica
Corrija:
* erros de TypeScript;
* erros de lint;
* erros de build;
* warnings de hidratação;
* erros de console;
* requisições quebradas;
* links inválidos;
* rotas órfãs;
* componentes duplicados quando a consolidação simplificar o projeto;
* tratamento de erros ausente;
* tipagem insegura;
* complexidade desnecessária diretamente relacionada ao escopo.
## Prioridades
Considere:
* **P0:** bloqueia funcionamento, segurança, uso ou lançamento;
* **P1:** prejudica significativamente experiência, conversão, acessibilidade, SEO, performance ou credibilidade;
* **P2:** refinamento não essencial para o lançamento.
Implemente todos os itens P0 e P1 que possam ser resolvidos com as informações existentes.
Itens dependentes de dados reais da cliente devem permanecer como pendências explícitas, sem conteúdo fictício.
## Restrições
* Preserve a stack e a arquitetura existentes quando forem adequadas.
* Prefira mudanças simples, localizadas e fáceis de manter.
* Não reescreva o projeto sem necessidade concreta.
* Não adicione bibliotecas quando a solução atual for suficiente.
* Não implemente pagamentos, CRM, área do cliente, chat, contratos avançados ou funcionalidades fora do MVP.
* Não altere arquivos não relacionados sem justificativa.
* Preserve alterações locais preexistentes.
* Não silencie problemas com `any`, `eslint-disable`, casts inseguros ou desativação de validações.
* Não reduza acessibilidade para preservar estética.
* Não deixe mocks ou soluções temporárias como implementação final.
## Critérios de conclusão
A tarefa estará concluída quando:
* todas as rotas públicas existentes tiverem sido auditadas;
* todos os problemas P0 e P1 solucionáveis tiverem sido corrigidos;
* o site funcionar corretamente em mobile e desktop;
* não houver overflow horizontal, sobreposição ou componentes quebrados;
* menu, navegação, links, CTAs e formulários funcionarem;
* o posicionamento social e corporativo estiver claro;
* a acessibilidade essencial estiver atendida;
* os metadados e fundamentos de SEO estiverem corretos;
* os principais problemas de performance tiverem sido tratados;
* erros causados ou revelados pelas alterações tiverem sido resolvidos;
* lint, TypeScript, testes e build disponíveis tiverem sido executados com sucesso.
Não declare uma validação como aprovada sem executá-la.
## Entrega final
Ao concluir, apresente:
1. rotas auditadas;
2. problemas principais encontrados;
3. alterações implementadas;
4. arquivos modificados;
5. comandos executados e seus resultados;
6. decisões e trade-offs relevantes;
7. informações que dependem da cliente;
8. itens P2 mantidos fora do escopo.

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

17
package-lock.json generated
View File

@@ -7,6 +7,7 @@
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.0.0", "@tailwindcss/vite": "^4.0.0",
"concurrently": "^9.0.1", "concurrently": "^9.0.1",
"husky": "^9.1.7",
"laravel-vite-plugin": "^3.1", "laravel-vite-plugin": "^3.1",
"playwright": "^1.62.0", "playwright": "^1.62.0",
"tailwindcss": "^4.0.0", "tailwindcss": "^4.0.0",
@@ -890,6 +891,22 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/husky": {
"version": "9.1.7",
"resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz",
"integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==",
"dev": true,
"license": "MIT",
"bin": {
"husky": "bin.js"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/typicode"
}
},
"node_modules/is-fullwidth-code-point": { "node_modules/is-fullwidth-code-point": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",

View File

@@ -4,11 +4,13 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"build": "vite build", "build": "vite build",
"dev": "vite" "dev": "vite",
"prepare": "husky"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.0.0", "@tailwindcss/vite": "^4.0.0",
"concurrently": "^9.0.1", "concurrently": "^9.0.1",
"husky": "^9.1.7",
"laravel-vite-plugin": "^3.1", "laravel-vite-plugin": "^3.1",
"playwright": "^1.62.0", "playwright": "^1.62.0",
"tailwindcss": "^4.0.0", "tailwindcss": "^4.0.0",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 0 B

After

Width:  |  Height:  |  Size: 4.2 KiB

4
public/favicon.svg Normal file
View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" fill="#556B2F"/>
<text x="32" y="45" font-family="Georgia, 'Times New Roman', serif" font-size="38" font-weight="600" fill="#FBF9F4" text-anchor="middle">A</text>
</svg>

After

Width:  |  Height:  |  Size: 264 B

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,11 @@
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'] {
scroll-margin-top: 5.5rem;
} }
a:focus-visible, a:focus-visible,
@@ -84,6 +96,14 @@
filter: saturate(0.88) contrast(0.96); filter: saturate(0.88) contrast(0.96);
} }
@utility honeypot {
position: absolute;
left: -9999px;
width: 1px;
height: 1px;
overflow: hidden;
}
@layer components { @layer components {
.main-nav.is-open { .main-nav.is-open {
display: flex; display: flex;
@@ -98,4 +118,141 @@
body.menu-open { body.menu-open {
overflow: hidden; overflow: hidden;
} }
/* Dossiê vivo — content visible by default; enhance only when opted in */
[data-chapter-index] {
display: none;
}
[data-chapter-progress] {
display: none;
pointer-events: none;
}
[data-chapter-index] a[aria-current="true"] {
color: var(--amare-color-accent-deep);
}
[data-chapter-index] a[aria-current="true"]::before {
content: '';
position: absolute;
left: 0;
top: 0.35em;
bottom: 0.35em;
width: 1px;
background: var(--amare-color-accent);
}
[data-chapter-progress] > span {
display: block;
height: 100%;
width: var(--chapter-progress, 0%);
background: var(--amare-color-accent);
transform-origin: left center;
}
@media (min-width: 1280px) {
[data-chapter-index] {
display: flex;
position: fixed;
top: 50%;
right: max(1rem, calc((100vw - var(--amare-container-max)) / 2 - 7.5rem));
z-index: 30;
max-width: 6.5rem;
translate: 0 -50%;
flex-direction: column;
gap: 0.75rem;
}
[data-chapter-index] a {
position: relative;
display: inline-flex;
align-items: center;
min-height: 2.75rem;
padding-left: 0.75rem;
font-size: var(--amare-text-xs);
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--amare-color-muted);
text-decoration: none;
overflow-wrap: break-word;
transition: color var(--amare-duration-fast) var(--amare-ease-standard);
}
[data-chapter-index] a:hover {
color: var(--amare-color-accent);
}
}
@media (max-width: 1279px) {
[data-chapter-progress] {
display: block;
position: fixed;
top: 0;
left: 0;
z-index: 45;
width: 100%;
height: 2px;
background: transparent;
}
}
@media (prefers-reduced-motion: no-preference) {
/* Transform/clip only — never fade text opacity (axe + WCAG mid-transition). */
html[data-motion="enhance"] [data-motion="dossie-hero"] [data-motion-beat="seal"],
html[data-motion="enhance"] [data-motion="dossie-hero"] [data-motion-beat="cta"] {
transform: translateY(0.5rem);
}
html[data-motion="enhance"] [data-motion="dossie-hero"] [data-motion-beat="title"] {
transform: translateY(0.4rem);
}
html[data-motion="enhance"] [data-motion="dossie-hero"] [data-motion-beat="media"] {
clip-path: inset(4% 0 0 0);
}
html[data-motion="enhance"] [data-motion="dossie-hero"].is-active [data-motion-beat] {
transform: none;
clip-path: inset(0 0 0 0);
transition:
transform var(--amare-duration-slow) var(--amare-ease-arrival),
clip-path var(--amare-duration-focal) var(--amare-ease-arrival);
}
html[data-motion="enhance"] [data-motion="dossie-hero"].is-active [data-motion-beat="seal"] {
transition-delay: 0ms;
}
html[data-motion="enhance"] [data-motion="dossie-hero"].is-active [data-motion-beat="title"] {
transition-delay: 80ms;
}
html[data-motion="enhance"] [data-motion="dossie-hero"].is-active [data-motion-beat="media"] {
transition-delay: 120ms;
}
html[data-motion="enhance"] [data-motion="dossie-hero"].is-active [data-motion-beat="cta"] {
transition-delay: 220ms;
}
html[data-motion="enhance"] [data-reveal]:not(.is-revealed) {
transform: translateY(0.5rem);
}
html[data-motion="enhance"] [data-reveal].is-revealed {
transform: none;
transition: transform var(--amare-duration-slow) var(--amare-ease-arrival);
}
html[data-motion="enhance"] [data-motion="page-open"]:not(.is-active) {
transform: translateY(0.4rem);
}
html[data-motion="enhance"] [data-motion="page-open"].is-active {
transform: none;
transition: transform var(--amare-duration-normal) var(--amare-ease-arrival);
}
}
} }

View File

@@ -56,7 +56,9 @@
--amare-duration-fast: 150ms; --amare-duration-fast: 150ms;
--amare-duration-normal: 250ms; --amare-duration-normal: 250ms;
--amare-duration-slow: 400ms; --amare-duration-slow: 400ms;
--amare-duration-focal: 720ms;
--amare-ease-standard: cubic-bezier(0.4, 0, 0.2, 1); --amare-ease-standard: cubic-bezier(0.4, 0, 0.2, 1);
--amare-ease-arrival: cubic-bezier(0.16, 1, 0.3, 1);
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
@@ -64,6 +66,7 @@
--amare-duration-fast: 0.01ms; --amare-duration-fast: 0.01ms;
--amare-duration-normal: 0.01ms; --amare-duration-normal: 0.01ms;
--amare-duration-slow: 0.01ms; --amare-duration-slow: 0.01ms;
--amare-duration-focal: 0.01ms;
} }
*, *,

View File

@@ -1,40 +1,108 @@
import './motion.js';
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
const menuButton = document.querySelector('[data-menu-button]'); const menuButton = document.querySelector('[data-menu-button]');
const navigation = document.querySelector('[data-main-nav]'); const navigation = document.querySelector('[data-main-nav]');
if (!menuButton || !navigation) { if (menuButton && navigation) {
return; const isDesktop = () => window.matchMedia('(min-width: 768px)').matches;
} const isMenuOpen = () => navigation.classList.contains('is-open');
const setOpen = (isOpen) => { const setOpen = (isOpen, { returnFocus = false } = {}) => {
navigation.classList.toggle('is-open', isOpen); navigation.classList.toggle('is-open', isOpen);
navigation.classList.toggle('hidden', !isOpen && !window.matchMedia('(min-width: 768px)').matches); navigation.classList.toggle('hidden', !isOpen && !isDesktop());
navigation.classList.toggle('flex', isOpen || window.matchMedia('(min-width: 768px)').matches); navigation.classList.toggle('flex', isOpen || isDesktop());
document.body.classList.toggle('menu-open', isOpen); document.body.classList.toggle('menu-open', isOpen);
menuButton.setAttribute('aria-expanded', String(isOpen)); menuButton.setAttribute('aria-expanded', String(isOpen));
menuButton.setAttribute('aria-label', isOpen ? 'Fechar menu' : 'Abrir menu'); menuButton.setAttribute('aria-label', isOpen ? 'Fechar menu' : 'Abrir menu');
if (!isOpen && returnFocus) {
menuButton.focus();
}
}; };
menuButton.addEventListener('click', () => { menuButton.addEventListener('click', () => {
const isOpen = menuButton.getAttribute('aria-expanded') !== 'true'; const isOpen = menuButton.getAttribute('aria-expanded') !== 'true';
setOpen(isOpen); setOpen(isOpen);
if (isOpen) {
const firstLink = navigation.querySelector('a');
if (firstLink) {
firstLink.focus();
}
}
}); });
navigation.querySelectorAll('a').forEach((link) => { navigation.querySelectorAll('a').forEach((link) => {
link.addEventListener('click', () => setOpen(false)); link.addEventListener('click', () => setOpen(false));
}); });
window.matchMedia('(min-width: 768px)').addEventListener('change', (event) => { navigation.addEventListener('keydown', (event) => {
if (event.matches) { if (event.key === 'Escape') {
setOpen(false); setOpen(false, { returnFocus: true });
navigation.classList.remove('hidden');
navigation.classList.add('flex'); return;
} else { }
navigation.classList.remove('is-open', 'flex');
navigation.classList.add('hidden'); if (event.key === 'Tab' && !isDesktop() && isMenuOpen()) {
document.body.classList.remove('menu-open'); const focusables = navigation.querySelectorAll('a[href], button:not([disabled])');
menuButton.setAttribute('aria-expanded', 'false');
menuButton.setAttribute('aria-label', 'Abrir menu'); if (focusables.length === 0) {
return;
}
const first = focusables[0];
const last = focusables[focusables.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
} }
}); });
window.matchMedia('(min-width: 768px)').addEventListener('change', (event) => {
setOpen(false);
if (event.matches) {
navigation.classList.remove('hidden');
navigation.classList.add('flex');
}
});
}
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', () => {
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();
}
});
});
}); });

166
resources/js/motion.js Normal file
View File

@@ -0,0 +1,166 @@
const MOTION_QUERY = '(prefers-reduced-motion: reduce)';
function prefersReducedMotion() {
return window.matchMedia(MOTION_QUERY).matches;
}
function enableEnhancement() {
if (prefersReducedMotion()) {
document.documentElement.removeAttribute('data-motion');
return false;
}
document.documentElement.dataset.motion = 'enhance';
return true;
}
function activateHero(enhance) {
const hero = document.querySelector('[data-motion="dossie-hero"]');
if (!hero) {
return;
}
const activate = () => hero.classList.add('is-active');
if (!enhance) {
activate();
return;
}
requestAnimationFrame(() => {
requestAnimationFrame(activate);
});
}
function activatePageOpen(enhance) {
document.querySelectorAll('[data-motion="page-open"]').forEach((node) => {
if (!enhance) {
node.classList.add('is-active');
return;
}
requestAnimationFrame(() => {
requestAnimationFrame(() => node.classList.add('is-active'));
});
});
}
function observeReveals(enhance) {
const nodes = Array.from(document.querySelectorAll('[data-reveal]'));
if (nodes.length === 0) {
return;
}
if (!enhance || typeof IntersectionObserver !== 'function') {
nodes.forEach((node) => node.classList.add('is-revealed'));
return;
}
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) {
return;
}
entry.target.classList.add('is-revealed');
observer.unobserve(entry.target);
});
},
{
rootMargin: '0px 0px -12% 0px',
threshold: 0.2,
},
);
nodes.forEach((node) => observer.observe(node));
}
function setupChapterIndex() {
const index = document.querySelector('[data-chapter-index]');
const progress = document.querySelector('[data-chapter-progress] span');
const chapters = Array.from(document.querySelectorAll('[data-chapter]'));
if (!index || chapters.length === 0) {
return;
}
const links = Array.from(index.querySelectorAll('a[href^="#"]'));
const setActive = (id) => {
links.forEach((link) => {
const isCurrent = link.getAttribute('href') === `#${id}`;
if (isCurrent) {
link.setAttribute('aria-current', 'true');
} else {
link.removeAttribute('aria-current');
}
});
};
const updateProgress = (ratio) => {
if (!progress) {
return;
}
const clamped = Math.min(1, Math.max(0, ratio));
progress.style.setProperty('--chapter-progress', `${(clamped * 100).toFixed(2)}%`);
};
const sync = () => {
const marker = window.scrollY + Math.min(window.innerHeight * 0.35, 280);
let current = chapters[0];
chapters.forEach((chapter) => {
if (chapter.offsetTop <= marker) {
current = chapter;
}
});
const heading = current.querySelector('[id$="-heading"]') || document.getElementById(`${current.dataset.chapter}-heading`);
const headingId = heading?.id
|| current.getAttribute('aria-labelledby')
|| `${current.dataset.chapter}-heading`;
setActive(headingId);
const doc = document.documentElement;
const max = Math.max(1, doc.scrollHeight - window.innerHeight);
updateProgress(window.scrollY / max);
};
setActive(chapters[0].getAttribute('aria-labelledby') || 'hero-heading');
sync();
window.addEventListener('scroll', sync, { passive: true });
window.addEventListener('resize', sync);
}
function bootMotion() {
const enhance = enableEnhancement();
activateHero(enhance);
activatePageOpen(enhance);
observeReveals(enhance);
setupChapterIndex();
window.matchMedia(MOTION_QUERY).addEventListener('change', (event) => {
if (event.matches) {
document.documentElement.removeAttribute('data-motion');
document.querySelectorAll('[data-motion="dossie-hero"], [data-motion="page-open"]').forEach((node) => {
node.classList.add('is-active');
});
document.querySelectorAll('[data-reveal]').forEach((node) => {
node.classList.add('is-revealed');
});
return;
}
document.documentElement.dataset.motion = 'enhance';
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', bootMotion, { once: true });
} else {
bootMotion();
}

View File

@@ -19,7 +19,6 @@
$variant = $variant === 'on-dark' ? 'on-dark' : 'on-light'; $variant = $variant === 'on-dark' ? 'on-dark' : 'on-light';
$kind = $mark ? 'mark' : 'lockup'; $kind = $mark ? 'mark' : 'lockup';
$staticSrc = asset("brand/{$kind}-{$variant}.webp"); $staticSrc = asset("brand/{$kind}-{$variant}.webp");
$staticFallback = asset("brand/{$kind}-{$variant}.png");
$src = filled($uploadedPath) $src = filled($uploadedPath)
? \Illuminate\Support\Facades\Storage::disk('public')->url($uploadedPath) ? \Illuminate\Support\Facades\Storage::disk('public')->url($uploadedPath)
@@ -34,8 +33,4 @@
'decoding' => 'async', 'decoding' => 'async',
'loading' => 'eager', 'loading' => 'eager',
]) }} ]) }}
@if (! filled($uploadedPath))
data-brand-fallback="{{ $staticFallback }}"
@endif
data-brand-variant="{{ $variant }}"
/> />

View File

@@ -6,7 +6,11 @@
$manifestFile = is_file($manifestPath) ? $manifestPath : $hotManifestPath; $manifestFile = is_file($manifestPath) ? $manifestPath : $hotManifestPath;
$manifest = is_string($manifestFile) && is_file($manifestFile) $manifest = is_string($manifestFile) && is_file($manifestFile)
? json_decode((string) file_get_contents($manifestFile), true) ? \Illuminate\Support\Facades\Cache::remember(
'fonts-manifest:'.md5($manifestFile.':'.(string) filemtime($manifestFile)),
3600,
static fn () => json_decode((string) file_get_contents($manifestFile), true) ?: null,
)
: null; : null;
$cssFile = is_array($manifest) ? ($manifest['style']['file'] ?? null) : null; $cssFile = is_array($manifest) ? ($manifest['style']['file'] ?? null) : null;

View File

@@ -1,37 +0,0 @@
@props([
'cases',
])
@if ($cases->isNotEmpty())
<section aria-labelledby="cases-heading" class="border-b border-amare-border py-16">
<div class="container-amare space-y-8">
<div class="max-w-2xl space-y-3">
<h2 id="cases-heading" class="text-3xl font-semibold text-amare-text">Casos selecionados</h2>
<p class="text-amare-text-muted">Histórias recentes de celebrações conduzidas pela Amare.</p>
</div>
<div class="grid gap-8">
@foreach ($cases as $case)
<article class="grid gap-4 border-t border-amare-border pt-6 md:grid-cols-[200px_minmax(0,1fr)]">
@if (filled($case->cover_image_path))
<x-media.image
:path="$case->cover_image_path"
:alt="$case->cover_image_alt ?: $case->title"
sizes="200px"
class="aspect-square w-full object-cover"
/>
@endif
<div class="space-y-2">
<h3 class="text-2xl font-semibold text-amare-text">{{ $case->title }}</h3>
<p class="text-sm text-amare-text-muted">{{ $case->event_type }}@if($case->city) · {{ $case->city }}@endif</p>
<p class="text-amare-text-muted">{{ $case->summary }}</p>
<a href="{{ url('/portfolio/'.$case->slug) }}" class="inline-flex text-sm font-semibold text-amare-accent hover:text-amare-accent-hover">
Ver caso
</a>
</div>
</article>
@endforeach
</div>
</div>
</section>
@endif

View File

@@ -0,0 +1,15 @@
@props([
'chapters' => [],
])
@if (count($chapters) > 0)
<nav data-chapter-index aria-label="Índice de capítulos">
@foreach ($chapters as $chapter)
<a href="#{{ $chapter['id'] }}">{{ $chapter['label'] }}</a>
@endforeach
</nav>
<div data-chapter-progress aria-hidden="true">
<span></span>
</div>
@endif

View File

@@ -2,19 +2,19 @@
'settings', 'settings',
]) ])
<section aria-labelledby="final-cta-heading" class="border-t border-amare-border bg-amare-bg-deep py-20"> <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"> <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">Todo grande encontro começa com uma boa conversa.</h2> <h2 id="final-cta-heading" class="text-headline font-medium text-amare-text">Do casamento ao evento corporativo, tudo começa com uma boa conversa.</h2>
<p class="mx-auto max-w-2xl text-amare-muted"> <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>
<div> <div>
<a <a
href="{{ route('contact') }}" href="{{ route('contact') }}"
class="inline-flex items-center bg-amare-accent px-5 py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-accent-text transition-colors duration-(--amare-duration-normal) ease-(--amare-ease-standard) hover:bg-amare-accent-deep" class="inline-flex min-h-11 items-center bg-amare-accent px-5 py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-accent-text transition-colors duration-(--amare-duration-normal) ease-(--amare-ease-standard) hover:bg-amare-accent-deep"
> >
{{ $settings->hero_cta_label }} {{ $settings->hero_cta_label ?: 'Solicitar proposta' }}
</a> </a>
</div> </div>
</div> </div>

View File

@@ -2,36 +2,44 @@
'settings', 'settings',
]) ])
<section aria-labelledby="hero-heading" class="border-b border-amare-border bg-amare-bg"> <section
aria-labelledby="hero-heading"
class="border-b border-amare-border bg-amare-bg"
data-chapter="hero"
data-motion="dossie-hero"
>
<div class="container-amare grid gap-12 py-20 md:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)] md:items-center md:py-28"> <div class="container-amare grid gap-12 py-20 md:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)] md:items-center md:py-28">
<div class="space-y-8"> <div class="space-y-8">
<div data-motion-beat="seal" class="flex items-center gap-4">
<x-brand.logo mark variant="on-light" class="h-8 w-auto" alt="" />
@if (filled($settings->hero_eyebrow)) @if (filled($settings->hero_eyebrow))
<p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent">{{ $settings->hero_eyebrow }}</p> <p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent">{{ $settings->hero_eyebrow }}</p>
@endif @endif
</div>
<h1 id="hero-heading" 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))
<p class="max-w-2xl text-lg text-amare-text-muted">{{ $settings->hero_subtitle }}</p> <p class="max-w-2xl text-lg text-amare-text-muted">{{ $settings->hero_subtitle }}</p>
@endif @endif
<div class="flex flex-wrap items-center gap-4"> <div data-motion-beat="cta" class="flex flex-wrap items-center gap-4">
<a <a
href="{{ route('contact') }}" href="{{ route('contact') }}"
data-testid="home-primary-cta" data-testid="home-primary-cta"
class="inline-flex items-center bg-amare-accent px-5 py-3 text-sm font-semibold text-amare-accent-text transition-colors duration-(--amare-duration-normal) ease-(--amare-ease-standard) hover:bg-amare-accent-hover" class="inline-flex min-h-11 items-center bg-amare-accent px-5 py-3 text-sm font-semibold text-amare-accent-text transition-colors duration-(--amare-duration-normal) ease-(--amare-ease-standard) hover:bg-amare-accent-hover"
> >
{{ $settings->hero_cta_label }} {{ $settings->hero_cta_label ?: 'Solicitar proposta' }}
</a> </a>
@if (filled($settings->hero_secondary_cta_label)) @if (filled($settings->hero_secondary_cta_label))
<a <a
href="{{ route('portfolio.index') }}" href="{{ route('portfolio.index') }}"
class="inline-flex items-center border-b border-amare-accent pb-1 text-sm font-semibold text-amare-accent transition-colors hover:text-amare-accent-deep" class="inline-flex min-h-11 items-center text-sm font-semibold text-amare-accent transition-colors hover:text-amare-accent-deep"
> >
{{ $settings->hero_secondary_cta_label }} <span class="border-b border-amare-accent pb-1">{{ $settings->hero_secondary_cta_label }}</span>
</a> </a>
@endif @endif
</div> </div>
@@ -42,11 +50,12 @@
</div> </div>
@if (filled($settings->default_og_image_path)) @if (filled($settings->default_og_image_path))
<div class="min-h-72 bg-amare-bg-deep"> <div data-motion-beat="media" class="min-h-72 overflow-hidden bg-amare-bg-deep">
<x-media.image <x-media.image
:path="$settings->default_og_image_path" :path="$settings->default_og_image_path"
:alt="$settings->default_og_image_alt ?: $settings->brand_name" :alt="$settings->default_og_image_alt ?: $settings->brand_name"
loading="eager" loading="eager"
fetchpriority="high"
sizes="(max-width: 768px) 100vw, 40vw" sizes="(max-width: 768px) 100vw, 40vw"
class="img-editorial h-full w-full object-cover" class="img-editorial h-full w-full object-cover"
/> />

View File

@@ -8,11 +8,11 @@
$body = $settings->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.'; $body = $settings->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.';
@endphp @endphp
<section aria-labelledby="manifesto-heading" class="border-b border-amare-border bg-amare-bg-deep py-20"> <section aria-labelledby="manifesto-heading" class="border-b border-amare-border bg-amare-bg-deep py-20" data-chapter="manifesto">
<div class="container-amare grid gap-8 md:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]"> <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

@@ -7,17 +7,17 @@
$intro = $settings->method_intro ?: 'Clareza em cada etapa. Tranquilidade durante todo o processo.'; $intro = $settings->method_intro ?: 'Clareza em cada etapa. Tranquilidade durante todo o processo.';
@endphp @endphp
<section aria-labelledby="method-heading" class="border-b border-amare-border bg-amare-bg-archive py-20"> <section aria-labelledby="method-heading" class="border-b border-amare-border bg-amare-bg-archive py-20" data-chapter="method">
<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"> <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>
<ol class="grid gap-5 border-t border-amare-border"> <ol class="grid gap-5 border-t border-amare-border">
@foreach ($steps as $index => $step) @foreach ($steps as $index => $step)
<li class="grid gap-2 border-b border-amare-border py-5 md:grid-cols-[4rem_minmax(0,1fr)]"> <li class="grid gap-2 border-b border-amare-border py-5 md:grid-cols-[4rem_minmax(0,1fr)]" data-reveal>
<span class="text-sm text-amare-accent">{{ str_pad((string) ($index + 1), 2, '0', STR_PAD_LEFT) }}</span> <span class="text-sm text-amare-accent">{{ str_pad((string) ($index + 1), 2, '0', STR_PAD_LEFT) }}</span>
<div class="space-y-2"> <div class="space-y-2">
<h3 class="text-2xl font-medium text-amare-text">{{ $step['title'] ?? '' }}</h3> <h3 class="text-2xl font-medium text-amare-text">{{ $step['title'] ?? '' }}</h3>

View File

@@ -3,19 +3,19 @@
]) ])
@if ($cases->isNotEmpty()) @if ($cases->isNotEmpty())
<section aria-labelledby="portfolio-heading" class="border-b border-amare-accent-deep bg-amare-accent-deep py-20 text-amare-accent-text"> <section aria-labelledby="portfolio-heading" class="border-b border-amare-accent-deep bg-amare-accent-deep py-20 text-amare-accent-text" data-chapter="portfolio">
<div class="container-amare space-y-10"> <div class="container-amare space-y-10">
<div class="grid gap-4 md:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]"> <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>
<div class="grid gap-10 md:grid-cols-2"> <div class="grid gap-10 md:grid-cols-2">
@foreach ($cases as $case) @foreach ($cases as $case)
<article class="space-y-4 border-t border-amare-accent-text/30 pt-4"> <article class="space-y-4 border-t border-amare-accent-text/30 pt-4" data-reveal>
@if (filled($case->cover_image_path)) @if (filled($case->cover_image_path))
<x-media.image <x-media.image
:path="$case->cover_image_path" :path="$case->cover_image_path"
@@ -27,8 +27,8 @@
<div class="space-y-2"> <div class="space-y-2">
<h3 class="text-2xl font-medium">{{ $case->title }}</h3> <h3 class="text-2xl font-medium">{{ $case->title }}</h3>
<p class="text-amare-accent-text/80">{{ $case->summary }}</p> <p class="text-amare-accent-text/80">{{ $case->summary }}</p>
<a href="{{ route('portfolio.show', $case->slug) }}" class="inline-flex border-b border-amare-accent-text pb-1 text-sm font-semibold transition-colors hover:text-amare-accent-text"> <a href="{{ route('portfolio.show', $case->slug) }}" class="inline-flex min-h-11 items-center text-sm font-semibold transition-colors hover:text-amare-accent-text">
Ver caso <span class="border-b border-amare-accent-text pb-1">Ver caso</span>
</a> </a>
</div> </div>
</article> </article>
@@ -39,8 +39,8 @@
<p class="max-w-2xl text-sm text-amare-accent-text/75"> <p class="max-w-2xl text-sm text-amare-accent-text/75">
Imagens demonstrativas enquanto o acervo autorizado da Amare está em organização. Imagens demonstrativas enquanto o acervo autorizado da Amare está em organização.
</p> </p>
<a href="{{ route('portfolio.index') }}" class="border-b border-amare-accent-text pb-1 text-sm font-semibold transition-colors hover:text-amare-accent-text"> <a href="{{ route('portfolio.index') }}" class="inline-flex min-h-11 items-center text-sm font-semibold transition-colors hover:text-amare-accent-text">
Conhecer o portfólio <span class="border-b border-amare-accent-text pb-1">Conhecer o portfólio</span>
</a> </a>
</div> </div>
</div> </div>

View File

@@ -7,13 +7,13 @@
$principles = filled($settings->principles) ? $settings->principles : \App\Models\SiteSetting::defaultPrinciples(); $principles = filled($settings->principles) ? $settings->principles : \App\Models\SiteSetting::defaultPrinciples();
@endphp @endphp
<section aria-labelledby="positioning-heading" class="border-b border-amare-border py-20"> <section aria-labelledby="positioning-heading" class="border-b border-amare-border py-20" data-chapter="positioning">
<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"> <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"> <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>
<ul class="grid gap-3 border-t border-amare-border"> <ul class="grid gap-3 border-t border-amare-border">
@foreach ($principles as $principle) @foreach ($principles as $principle)
@@ -21,8 +21,8 @@
@endforeach @endforeach
</ul> </ul>
<p> <p>
<a href="{{ route('about') }}" class="border-b border-amare-accent pb-1 text-sm font-semibold text-amare-accent transition-colors hover:text-amare-accent-deep"> <a href="{{ route('about') }}" class="inline-flex min-h-11 items-center text-sm font-semibold text-amare-accent transition-colors hover:text-amare-accent-deep">
Conhecer a Amare <span class="border-b border-amare-accent pb-1">Conhecer a Amare</span>
</a> </a>
</p> </p>
</div> </div>

View File

@@ -1,31 +0,0 @@
@props([
'cases',
])
@if ($cases->isNotEmpty())
<section aria-labelledby="proof-heading" class="border-b border-amare-border bg-amare-bg-muted py-16">
<div class="container-amare space-y-8">
<div class="max-w-2xl space-y-3">
<h2 id="proof-heading" class="text-3xl font-semibold text-amare-text">Prova visual</h2>
<p class="text-amare-text-muted">Eventos em destaque que mostram o cuidado com cada celebração.</p>
</div>
<div class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
@foreach ($cases as $case)
<article class="space-y-3">
@if (filled($case->cover_image_path))
<x-media.image
:path="$case->cover_image_path"
:alt="$case->cover_image_alt ?: $case->title"
sizes="(max-width: 768px) 100vw, 33vw"
class="aspect-[4/3] w-full object-cover"
/>
@endif
<h3 class="text-xl font-semibold text-amare-text">{{ $case->title }}</h3>
<p class="text-sm text-amare-text-muted">{{ $case->summary }}</p>
</article>
@endforeach
</div>
</div>
</section>
@endif

View File

@@ -3,9 +3,9 @@
]) ])
@if ($services->isNotEmpty()) @if ($services->isNotEmpty())
<section aria-labelledby="services-heading" class="border-b border-amare-border py-20"> <section aria-labelledby="services-heading" class="border-b border-amare-border py-20" data-chapter="services">
<div class="container-amare space-y-10"> <div class="container-amare space-y-10">
<div class="max-w-2xl space-y-3"> <div class="max-w-2xl space-y-3" data-reveal>
<p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent">Atuação</p> <p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent">Atuação</p>
<h2 id="services-heading" class="text-3xl font-medium text-amare-text">Serviços</h2> <h2 id="services-heading" class="text-3xl font-medium text-amare-text">Serviços</h2>
<p class="text-amare-text-muted">Assessoria sob medida para decisões importantes e celebrações bem conduzidas.</p> <p class="text-amare-text-muted">Assessoria sob medida para decisões importantes e celebrações bem conduzidas.</p>
@@ -13,7 +13,7 @@
<ol class="border-t border-amare-border"> <ol class="border-t border-amare-border">
@foreach ($services as $index => $service) @foreach ($services as $index => $service)
<li class="grid gap-3 border-b border-amare-border py-5 md:grid-cols-[4rem_minmax(0,0.8fr)_minmax(0,1.2fr)] md:gap-6"> <li class="grid gap-3 border-b border-amare-border py-5 md:grid-cols-[4rem_minmax(0,0.8fr)_minmax(0,1.2fr)] md:gap-6" data-reveal>
<span class="text-sm text-amare-accent">{{ str_pad((string) ($index + 1), 2, '0', STR_PAD_LEFT) }}</span> <span class="text-sm text-amare-accent">{{ str_pad((string) ($index + 1), 2, '0', STR_PAD_LEFT) }}</span>
<h3 class="text-2xl font-medium text-amare-text">{{ $service->title }}</h3> <h3 class="text-2xl font-medium text-amare-text">{{ $service->title }}</h3>
<p class="text-amare-text-muted">{{ $service->summary }}</p> <p class="text-amare-text-muted">{{ $service->summary }}</p>
@@ -22,8 +22,8 @@
</ol> </ol>
<p> <p>
<a href="{{ route('services.index') }}" class="border-b border-amare-accent pb-1 text-sm font-semibold text-amare-accent transition-colors hover:text-amare-accent-deep"> <a href="{{ route('services.index') }}" class="inline-flex min-h-11 items-center text-sm font-semibold text-amare-accent transition-colors hover:text-amare-accent-deep">
Ver todos os serviços <span class="border-b border-amare-accent pb-1">Ver todos os serviços</span>
</a> </a>
</p> </p>
</div> </div>

View File

@@ -3,20 +3,21 @@
]) ])
@if ($testimonials->isNotEmpty()) @if ($testimonials->isNotEmpty())
<section aria-labelledby="testimonials-heading" class="border-b border-amare-border bg-amare-bg-muted py-16"> <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"> <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 !== ''));
@endphp @endphp
<blockquote class="space-y-4 border-t border-amare-border pt-4"> <blockquote class="space-y-4 border-t border-amare-border pt-4" data-reveal>
<div class="space-y-3 text-lg text-amare-text"> <div class="space-y-3 text-lg text-amare-text">
@foreach ($paragraphs as $index => $paragraph) @foreach ($paragraphs as $index => $paragraph)
<p>@if ($index === 0)@endif{{ $paragraph }}@if ($index === count($paragraphs) - 1)@endif</p> <p>@if ($index === 0)@endif{{ $paragraph }}@if ($index === count($paragraphs) - 1)@endif</p>

View File

@@ -3,6 +3,7 @@
'alt', 'alt',
'sizes' => '(max-width: 768px) 100vw, 960px', 'sizes' => '(max-width: 768px) 100vw, 960px',
'loading' => 'lazy', 'loading' => 'lazy',
'fetchpriority' => null,
'width' => null, 'width' => null,
'height' => null, 'height' => null,
'disk' => null, 'disk' => null,
@@ -32,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
@@ -42,6 +43,7 @@
@if ($resolvedWidth) width="{{ $resolvedWidth }}" @endif @if ($resolvedWidth) width="{{ $resolvedWidth }}" @endif
@if ($resolvedHeight) height="{{ $resolvedHeight }}" @endif @if ($resolvedHeight) height="{{ $resolvedHeight }}" @endif
loading="{{ $loadingValue }}" loading="{{ $loadingValue }}"
@if ($fetchpriority) fetchpriority="{{ $fetchpriority }}" @endif
@if ($class) class="{{ $class }}" @endif @if ($class) class="{{ $class }}" @endif
{{ $attributes->except(['path', 'alt', 'sizes', 'loading', 'width', 'height', 'disk', 'class']) }} {{ $attributes->except(['path', 'alt', 'sizes', 'loading', 'width', 'height', 'disk', 'class']) }}
> >

View File

@@ -3,12 +3,18 @@
]) ])
<meta name="description" content="{{ $pageMeta->description }}"> <meta name="description" content="{{ $pageMeta->description }}">
<link rel="canonical" href="{{ $pageMeta->canonical }}"> @if (filled($pageMeta->canonical))
<link rel="canonical" href="{{ $pageMeta->canonical }}">
@endif
<meta property="og:locale" content="pt_BR">
<meta property="og:site_name" content="{{ $pageMeta->siteName }}">
<meta property="og:title" content="{{ $pageMeta->title }}"> <meta property="og:title" content="{{ $pageMeta->title }}">
<meta property="og:description" content="{{ $pageMeta->description }}"> <meta property="og:description" content="{{ $pageMeta->description }}">
<meta property="og:type" content="{{ $pageMeta->ogType }}"> <meta property="og:type" content="{{ $pageMeta->ogType }}">
<meta property="og:url" content="{{ $pageMeta->canonical }}"> @if (filled($pageMeta->canonical))
<meta property="og:url" content="{{ $pageMeta->canonical }}">
@endif
@if ($pageMeta->ogImageUrl) @if ($pageMeta->ogImageUrl)
<meta property="og:image" content="{{ $pageMeta->ogImageUrl }}"> <meta property="og:image" content="{{ $pageMeta->ogImageUrl }}">
@if ($pageMeta->ogImageAlt) @if ($pageMeta->ogImageAlt)
@@ -16,6 +22,19 @@
@endif @endif
@endif @endif
@if ($pageMeta->jsonLd) <meta name="twitter:card" content="summary_large_image">
<script type="application/ld+json">{!! json_encode($pageMeta->jsonLd, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) !!}</script> <meta name="twitter:title" content="{{ $pageMeta->title }}">
<meta name="twitter:description" content="{{ $pageMeta->description }}">
@if ($pageMeta->ogImageUrl)
<meta name="twitter:image" content="{{ $pageMeta->ogImageUrl }}">
@endif
@if (app()->environment('production'))
<meta name="robots" content="{{ $pageMeta->robots }}">
@else
<meta name="robots" content="noindex, nofollow">
@endif
@if ($pageMeta->jsonLd)
<script type="application/ld+json">{!! json_encode($pageMeta->jsonLd, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) !!}</script>
@endif @endif

View File

@@ -0,0 +1,5 @@
Olá, {{ $name }}.
Recebemos sua mensagem e retornaremos em breve pelo canal informado.
{{ $brandName }}

View File

@@ -0,0 +1,25 @@
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Recebemos sua mensagem</title>
<style>
body { margin: 0; padding: 24px; background: #FBF9F4; font-family: Georgia, 'Times New Roman', serif; color: #1B1C19; }
.wrap { max-width: 600px; margin: 0 auto; }
p { font-size: 15px; line-height: 1.6; }
.signature { margin-top: 24px; color: #5D6155; }
</style>
</head>
<body>
<div class="wrap">
<p>Olá, {{ $name }}.</p>
<p>
Recebemos sua mensagem e retornaremos em breve pelo canal informado.
</p>
<p class="signature">
{{ $brandName }}
</p>
</div>
</body>
</html>

View File

@@ -0,0 +1,7 @@
Novo briefing de contato
@foreach ($fields as $label => $value)
{{ $label }}: {{ $value ?: '—' }}
@endforeach
Enviado pelo site {{ config('app.name') }}.

View File

@@ -0,0 +1,33 @@
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Novo briefing de contato</title>
<style>
body { margin: 0; padding: 24px; background: #FBF9F4; font-family: Georgia, 'Times New Roman', serif; color: #1B1C19; }
.wrap { max-width: 600px; margin: 0 auto; }
h1 { font-size: 20px; font-weight: 600; margin: 0 0 16px; }
table { width: 100%; border-collapse: collapse; }
th, td { text-align: left; vertical-align: top; padding: 10px 12px; border-bottom: 1px solid #C5C8B8; font-size: 14px; }
th { width: 40%; font-weight: 600; color: #556B2F; }
.footer { margin-top: 16px; font-size: 12px; color: #5D6155; }
</style>
</head>
<body>
<div class="wrap">
<h1>Novo briefing de contato</h1>
<table>
@foreach ($fields as $label => $value)
<tr>
<th scope="row">{{ $label }}</th>
<td>{{ $value ?: '—' }}</td>
</tr>
@endforeach
</table>
<p class="footer">
Enviado pelo site {{ config('app.name') }}.
</p>
</div>
</body>
</html>

View File

@@ -1,3 +1,17 @@
@php
try {
$errorSettings = \App\Models\SiteSetting::instance();
$pageMeta = \App\Application\Data\PageMeta::forErrorPage($errorSettings, 404);
} catch (\Throwable $e) {
$pageMeta = new \App\Application\Data\PageMeta(
title: 'Página não encontrada - Amare Assessoria',
description: 'A página que você procura não existe ou foi movida.',
canonical: '',
robots: 'noindex, nofollow',
);
}
@endphp
@extends('layouts.public') @extends('layouts.public')
@section('content') @section('content')
@@ -6,7 +20,7 @@
<h1 class="text-4xl font-medium tracking-tight text-amare-text">Página não encontrada</h1> <h1 class="text-4xl font-medium tracking-tight text-amare-text">Página não encontrada</h1>
<p class="text-amare-muted">O endereço que você tentou abrir não existe ou foi movido.</p> <p class="text-amare-muted">O endereço que você tentou abrir não existe ou foi movido.</p>
<p> <p>
<a href="{{ route('home') }}" class="inline-flex bg-amare-accent px-5 py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-accent-text transition-colors hover:bg-amare-accent-deep"> <a href="{{ route('home') }}" class="inline-flex min-h-11 items-center bg-amare-accent px-5 py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-accent-text transition-colors hover:bg-amare-accent-deep">
Voltar para a home Voltar para a home
</a> </a>
</p> </p>

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

@@ -1,3 +1,17 @@
@php
try {
$errorSettings = \App\Models\SiteSetting::instance();
$pageMeta = \App\Application\Data\PageMeta::forErrorPage($errorSettings, 500);
} catch (\Throwable $e) {
$pageMeta = new \App\Application\Data\PageMeta(
title: 'Algo deu errado - Amare Assessoria',
description: 'Não foi possível concluir o pedido. Tente novamente em instantes.',
canonical: '',
robots: 'noindex, nofollow',
);
}
@endphp
@extends('layouts.public') @extends('layouts.public')
@section('content') @section('content')
@@ -6,7 +20,7 @@
<h1 class="text-4xl font-medium tracking-tight text-amare-text">Algo deu errado</h1> <h1 class="text-4xl font-medium tracking-tight text-amare-text">Algo deu errado</h1>
<p class="text-amare-muted">Não foi possível concluir o pedido agora. Tente novamente em instantes.</p> <p class="text-amare-muted">Não foi possível concluir o pedido agora. Tente novamente em instantes.</p>
<p> <p>
<a href="{{ route('home') }}" class="inline-flex bg-amare-accent px-5 py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-accent-text transition-colors hover:bg-amare-accent-deep"> <a href="{{ route('home') }}" class="inline-flex min-h-11 items-center bg-amare-accent px-5 py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-accent-text transition-colors hover:bg-amare-accent-deep">
Voltar para a home Voltar para a home
</a> </a>
</p> </p>

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

@@ -4,12 +4,33 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light"> <meta name="color-scheme" content="light">
<meta name="theme-color" content="#FBF9F4">
<link rel="icon" href="{{ asset('favicon.svg') }}" type="image/svg+xml">
<title>{{ $pageMeta->title }}</title> <title>{{ $pageMeta->title }}</title>
<x-seo.meta :page-meta="$pageMeta" /> <x-seo.meta :page-meta="$pageMeta" />
<x-fonts /> <x-fonts />
@vite(['resources/css/app.css', 'resources/js/app.js']) @vite(['resources/css/app.css', 'resources/js/app.js'])
<noscript>
<style>
.main-nav {
display: flex;
flex-direction: column;
}
@media (min-width: 768px) {
.main-nav {
flex-direction: row;
}
}
.menu-button {
display: none;
}
</style>
</noscript>
</head> </head>
<body class="min-h-screen bg-amare-bg font-serif text-amare-text antialiased"> <body class="min-h-screen bg-amare-bg font-serif text-amare-text antialiased">
<a href="#conteudo" class="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:z-50 focus:bg-amare-accent focus:px-4 focus:py-2 focus:text-amare-accent-text"> <a href="#conteudo" class="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:z-50 focus:bg-amare-accent focus:px-4 focus:py-2 focus:text-amare-accent-text">
@@ -18,12 +39,12 @@
<header class="site-header sticky top-0 z-40 border-b border-amare-border/80 bg-amare-bg/90 backdrop-blur-sm"> <header class="site-header sticky top-0 z-40 border-b border-amare-border/80 bg-amare-bg/90 backdrop-blur-sm">
<div class="container-amare grid grid-cols-[auto_1fr_auto] items-center gap-4 py-4 md:grid-cols-[1fr_auto_1fr]"> <div class="container-amare grid grid-cols-[auto_1fr_auto] items-center gap-4 py-4 md:grid-cols-[1fr_auto_1fr]">
<nav id="main-nav" class="main-nav order-3 col-span-3 hidden flex-col gap-4 border-t border-amare-border pt-4 md:order-1 md:col-span-1 md:flex md:flex-row md:items-center md:border-0 md:pt-0" aria-label="Principal" data-main-nav> <nav id="main-nav" class="main-nav order-3 col-span-3 hidden flex-col gap-4 border-t border-amare-border pt-4 md:order-1 md:col-span-1 md:flex md:flex-row md:items-center md:gap-1 md:border-0 md:pt-0" aria-label="Principal" data-main-nav>
<a href="{{ route('home') }}" class="text-sm font-semibold uppercase tracking-[0.12em] text-amare-muted transition-colors hover:text-amare-accent">Início</a> <a href="{{ route('home') }}" class="inline-flex items-center py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-muted transition-colors hover:text-amare-accent md:px-2">Início</a>
<a href="{{ route('services.index') }}" class="text-sm font-semibold uppercase tracking-[0.12em] text-amare-muted transition-colors hover:text-amare-accent">Serviços</a> <a href="{{ route('services.index') }}" class="inline-flex items-center py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-muted transition-colors hover:text-amare-accent md:px-2">Serviços</a>
<a href="{{ route('portfolio.index') }}" class="text-sm font-semibold uppercase tracking-[0.12em] text-amare-muted transition-colors hover:text-amare-accent">Portfólio</a> <a href="{{ route('portfolio.index') }}" class="inline-flex items-center py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-muted transition-colors hover:text-amare-accent md:px-2">Portfólio</a>
<a href="{{ route('about') }}" class="text-sm font-semibold uppercase tracking-[0.12em] text-amare-muted transition-colors hover:text-amare-accent">Amare</a> <a href="{{ route('about') }}" class="inline-flex items-center py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-muted transition-colors hover:text-amare-accent md:px-2">Amare</a>
<a href="{{ route('contact') }}" class="text-sm font-semibold uppercase tracking-[0.12em] text-amare-muted transition-colors hover:text-amare-accent md:hidden">Contato</a> <a href="{{ route('contact') }}" class="inline-flex items-center py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-muted transition-colors hover:text-amare-accent md:hidden">Solicitar proposta</a>
</nav> </nav>
<a href="{{ route('home') }}" class="order-1 justify-self-start md:order-2 md:justify-self-center" aria-label="{{ $siteSettings->brand_name }} — página inicial"> <a href="{{ route('home') }}" class="order-1 justify-self-start md:order-2 md:justify-self-center" aria-label="{{ $siteSettings->brand_name }} — página inicial">
@@ -31,13 +52,13 @@
</a> </a>
<div class="order-2 flex items-center justify-end gap-3 md:order-3"> <div class="order-2 flex items-center justify-end gap-3 md:order-3">
<a href="{{ route('contact') }}" class="hidden text-sm font-semibold uppercase tracking-[0.12em] text-amare-accent transition-colors hover:text-amare-accent-deep md:inline-flex"> <a href="{{ route('contact') }}" class="hidden items-center py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-accent transition-colors hover:text-amare-accent-deep md:inline-flex">
Solicitar proposta Solicitar proposta
</a> </a>
<button <button
type="button" type="button"
class="menu-button inline-flex h-10 w-10 items-center justify-center border border-amare-border text-amare-text md:hidden" class="menu-button inline-flex h-11 w-11 items-center justify-center border border-amare-border text-amare-text md:hidden"
aria-label="Abrir menu" aria-label="Abrir menu"
aria-controls="main-nav" aria-controls="main-nav"
aria-expanded="false" aria-expanded="false"
@@ -63,39 +84,44 @@
<div class="space-y-4"> <div class="space-y-4">
<x-brand.logo variant="on-light" class="h-12 w-auto" /> <x-brand.logo variant="on-light" class="h-12 w-auto" />
<p class="max-w-md text-amare-muted"> <p class="max-w-md text-amare-muted">
{{ $siteSettings->about_summary ?: 'Assessoria, produção e organização de eventos sociais e corporativos em São Paulo - SP.' }} {{ $siteSettings->about_summary ?: 'Assessoria boutique em São Paulo - SP.' }}
</p> </p>
</div> </div>
<div class="space-y-3 text-sm"> <div class="text-sm">
<h2 class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-text">Navegação</h2> <h2 class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-text">Navegação</h2>
<p><a href="{{ route('services.index') }}" class="text-amare-muted transition-colors hover:text-amare-accent">Serviços</a></p> <ul class="mt-3 space-y-3">
<p><a href="{{ route('portfolio.index') }}" class="text-amare-muted transition-colors hover:text-amare-accent">Portfólio</a></p> <li><a href="{{ route('services.index') }}" class="inline-flex min-h-11 items-center text-amare-muted transition-colors hover:text-amare-accent">Serviços</a></li>
<p><a href="{{ route('about') }}" class="text-amare-muted transition-colors hover:text-amare-accent">A Amare</a></p> <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>
<p><a href="{{ route('contact') }}" class="text-amare-muted transition-colors hover:text-amare-accent">Contato</a></p> <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>
<p><a href="{{ route('privacy') }}" class="text-amare-muted transition-colors hover:text-amare-accent">Política de privacidade</a></p> <li><a href="{{ route('contact') }}" class="inline-flex min-h-11 items-center text-amare-muted transition-colors hover:text-amare-accent">Contato</a></li>
</ul>
</div> </div>
<div class="space-y-3 text-sm"> <div class="text-sm">
<h2 class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-text">Contato</h2> <h2 class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-text">Contato</h2>
<address class="mt-3 space-y-3 not-italic">
@if ($siteSettings->city) @if ($siteSettings->city)
<p class="text-amare-muted">{{ $siteSettings->city }}</p> <p class="text-amare-muted">{{ $siteSettings->city }}</p>
@endif @endif
@if ($siteSettings->email) @if ($siteSettings->email)
<p> <p>
<a href="mailto:{{ $siteSettings->email }}" class="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 class="text-amare-muted">{{ $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>
</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="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
</address>
</div> </div>
</div> </div>
@@ -103,7 +129,7 @@
<div class="container-amare flex flex-col gap-2 py-6 text-sm text-amare-muted md:flex-row md:items-center md:justify-between"> <div class="container-amare flex flex-col gap-2 py-6 text-sm text-amare-muted md:flex-row md:items-center md:justify-between">
<p>&copy; {{ now()->year }} {{ $siteSettings->brand_name }}. Todos os direitos reservados.</p> <p>&copy; {{ now()->year }} {{ $siteSettings->brand_name }}. Todos os direitos reservados.</p>
<p> <p>
<a href="{{ route('privacy') }}" class="transition-colors hover:text-amare-accent">Política de privacidade</a> <a href="{{ route('privacy') }}" class="inline-flex min-h-11 items-center transition-colors hover:text-amare-accent">Política de privacidade</a>
</p> </p>
</div> </div>
</div> </div>

View File

@@ -8,16 +8,28 @@
$city = $siteSettings->city ?: 'São Paulo - SP'; $city = $siteSettings->city ?: 'São Paulo - SP';
@endphp @endphp
<div class="flex min-h-[calc(100dvh-14rem)] flex-col border-b border-amare-border bg-amare-bg"> <div class="flex min-h-[calc(100dvh-14rem)] flex-col border-b border-amare-border bg-amare-bg" data-motion="page-open">
<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

@@ -1,13 +1,13 @@
@extends('layouts.public') @extends('layouts.public')
@section('content') @section('content')
<div class="flex min-h-[calc(100dvh-14rem)] flex-col border-b border-amare-border bg-amare-bg"> <section class="flex min-h-[calc(100dvh-14rem)] flex-col border-b border-amare-border bg-amare-bg">
<div class="container-amare grid flex-1 content-start gap-12 py-16 md:grid-cols-[minmax(0,1.2fr)_minmax(0,0.8fr)] md:py-24"> <div class="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">
Em breve você poderá enviar um briefing por aqui. Enquanto isso, fale conosco pelos canais abaixo. Conte sobre o seu evento no briefing abaixo. Retornaremos com uma proposta sob medida e sem compromisso.
</p> </p>
</div> </div>
@@ -15,20 +15,263 @@
<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="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>{{ $siteSettings->phone }}</p> <p>
<a href="tel:{{ preg_replace('/\D/', '', (string) $siteSettings->phone) }}" class="inline-flex max-w-full min-h-11 items-center text-amare-accent transition-colors hover:text-amare-accent-deep"><span class="min-w-0 break-words">{{ $siteSettings->phone }}</span></a>
</p>
@endif @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="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
</div> </div>
</div> </div>
</section>
<section class="border-b border-amare-border bg-amare-bg" aria-labelledby="briefing-heading">
<div class="container-amare py-16 md:py-24">
<h2 id="briefing-heading" class="text-2xl font-medium tracking-tight text-amare-text md:text-3xl">Briefing de contato</h2>
<p class="mt-2 max-w-2xl text-amare-muted">
Preencha os campos abaixo. As informações obrigatórias estão marcadas com asterisco (*).
</p>
@if (session('status') === 'briefing-sent')
<div role="status" class="mt-8 border border-amare-border bg-amare-bg-deep px-6 py-5">
<p class="font-semibold text-amare-text">Mensagem enviada.</p>
<p class="mt-1 text-amare-muted">Recebemos seu briefing e retornaremos em breve pelo canal informado.</p>
</div> </div>
@else
<form
method="POST"
action="{{ route('contact.store') }}"
class="mt-10 max-w-3xl space-y-10"
data-contact-form
>
@csrf
<div class="honeypot" aria-hidden="true">
<label for="empresa">Não preencha este campo</label>
<input type="text" id="empresa" name="empresa" tabindex="-1" autocomplete="off">
</div>
@if ($errors->any())
<div role="alert" class="border border-amare-error/40 bg-amare-error/5 px-6 py-5">
<p class="font-semibold text-amare-error">Não foi possível enviar.</p>
<p class="mt-1 text-sm text-amare-muted">Revise os campos destacados abaixo e tente novamente.</p>
</div>
@endif
<div class="grid gap-8 sm:grid-cols-2">
<div>
<label for="nome" class="mb-2 block text-xs font-semibold uppercase tracking-[0.14em] text-amare-text">Nome completo *</label>
<input
type="text"
id="nome"
name="nome"
value="{{ old('nome') }}"
required
autocomplete="name"
maxlength="120"
placeholder="Seu nome"
class="w-full border-0 border-b border-amare-border bg-transparent py-3 text-amare-text transition-colors placeholder:text-amare-muted/60 focus:border-amare-accent focus:outline-none @error('nome') border-amare-error @enderror"
@error('nome') aria-invalid="true" aria-describedby="nome-error" @enderror
>
@error('nome')
<p id="nome-error" class="mt-2 text-sm text-amare-error">{{ $message }}</p>
@enderror
</div>
<div>
<label for="email" class="mb-2 block text-xs font-semibold uppercase tracking-[0.14em] text-amare-text">E-mail *</label>
<input
type="email"
id="email"
name="email"
value="{{ old('email') }}"
required
autocomplete="email"
maxlength="254"
placeholder="voce@email.com"
class="w-full border-0 border-b border-amare-border bg-transparent py-3 text-amare-text transition-colors placeholder:text-amare-muted/60 focus:border-amare-accent focus:outline-none @error('email') border-amare-error @enderror"
@error('email') aria-invalid="true" aria-describedby="email-error" @enderror
>
@error('email')
<p id="email-error" class="mt-2 text-sm text-amare-error">{{ $message }}</p>
@enderror
</div>
<div>
<label for="telefone" class="mb-2 block text-xs font-semibold uppercase tracking-[0.14em] text-amare-text">Telefone/WhatsApp *</label>
<input
type="tel"
id="telefone"
name="telefone"
value="{{ old('telefone') }}"
required
autocomplete="tel"
maxlength="40"
placeholder="(11) 90000-0000"
class="w-full border-0 border-b border-amare-border bg-transparent py-3 text-amare-text transition-colors placeholder:text-amare-muted/60 focus:border-amare-accent focus:outline-none @error('telefone') border-amare-error @enderror"
@error('telefone') aria-invalid="true" aria-describedby="telefone-error" @enderror
>
@error('telefone')
<p id="telefone-error" class="mt-2 text-sm text-amare-error">{{ $message }}</p>
@enderror
</div>
<div>
<label for="tipo_evento" class="mb-2 block text-xs font-semibold uppercase tracking-[0.14em] text-amare-text">Tipo de evento *</label>
<select
id="tipo_evento"
name="tipo_evento"
required
class="w-full border-0 border-b border-amare-border bg-transparent py-3 text-amare-text transition-colors focus:border-amare-accent focus:outline-none @error('tipo_evento') border-amare-error @enderror"
@error('tipo_evento') aria-invalid="true" aria-describedby="tipo_evento-error" @enderror
>
<option value="" selected disabled>Selecione...</option>
<option value="Casamento" @selected(old('tipo_evento') === 'Casamento')>Casamento</option>
<option value="Evento corporativo" @selected(old('tipo_evento') === 'Evento corporativo')>Evento corporativo</option>
<option value="Celebração intimista" @selected(old('tipo_evento') === 'Celebração intimista')>Celebração intimista</option>
<option value="Outro" @selected(old('tipo_evento') === 'Outro')>Outro tipo de evento</option>
</select>
@error('tipo_evento')
<p id="tipo_evento-error" class="mt-2 text-sm text-amare-error">{{ $message }}</p>
@enderror
</div>
<div>
<label for="data_periodo" class="mb-2 block text-xs font-semibold uppercase tracking-[0.14em] text-amare-text">Data ou período desejado</label>
<input
type="text"
id="data_periodo"
name="data_periodo"
value="{{ old('data_periodo') }}"
maxlength="80"
placeholder="Ex.: novembro de 2027"
class="w-full border-0 border-b border-amare-border bg-transparent py-3 text-amare-text transition-colors placeholder:text-amare-muted/60 focus:border-amare-accent focus:outline-none @error('data_periodo') border-amare-error @enderror"
@error('data_periodo') aria-invalid="true" aria-describedby="data_periodo-error" @enderror
>
@error('data_periodo')
<p id="data_periodo-error" class="mt-2 text-sm text-amare-error">{{ $message }}</p>
@enderror
</div>
<div>
<label for="cidade" class="mb-2 block text-xs font-semibold uppercase tracking-[0.14em] text-amare-text">Cidade do evento *</label>
<input
type="text"
id="cidade"
name="cidade"
value="{{ old('cidade') }}"
required
maxlength="80"
placeholder="Ex.: São Paulo"
class="w-full border-0 border-b border-amare-border bg-transparent py-3 text-amare-text transition-colors placeholder:text-amare-muted/60 focus:border-amare-accent focus:outline-none @error('cidade') border-amare-error @enderror"
@error('cidade') aria-invalid="true" aria-describedby="cidade-error" @enderror
>
@error('cidade')
<p id="cidade-error" class="mt-2 text-sm text-amare-error">{{ $message }}</p>
@enderror
</div>
<div>
<label for="convidados" class="mb-2 block text-xs font-semibold uppercase tracking-[0.14em] text-amare-text">Número estimado de convidados</label>
<input
type="number"
id="convidados"
name="convidados"
value="{{ old('convidados') }}"
min="1"
max="100000"
inputmode="numeric"
autocomplete="off"
placeholder="Ex.: 120"
class="w-full border-0 border-b border-amare-border bg-transparent py-3 text-amare-text transition-colors placeholder:text-amare-muted/60 focus:border-amare-accent focus:outline-none @error('convidados') border-amare-error @enderror"
@error('convidados') aria-invalid="true" aria-describedby="convidados-error" @enderror
>
@error('convidados')
<p id="convidados-error" class="mt-2 text-sm text-amare-error">{{ $message }}</p>
@enderror
</div>
<div>
<label for="servico_interesse" class="mb-2 block text-xs font-semibold uppercase tracking-[0.14em] text-amare-text">Serviço de interesse</label>
<input
type="text"
id="servico_interesse"
name="servico_interesse"
value="{{ old('servico_interesse') }}"
maxlength="120"
placeholder="Ex.: planejamento completo"
class="w-full border-0 border-b border-amare-border bg-transparent py-3 text-amare-text transition-colors placeholder:text-amare-muted/60 focus:border-amare-accent focus:outline-none @error('servico_interesse') border-amare-error @enderror"
@error('servico_interesse') aria-invalid="true" aria-describedby="servico_interesse-error" @enderror
>
@error('servico_interesse')
<p id="servico_interesse-error" class="mt-2 text-sm text-amare-error">{{ $message }}</p>
@enderror
</div>
</div>
<div>
<label for="mensagem" class="mb-2 block text-xs font-semibold uppercase tracking-[0.14em] text-amare-text">Mensagem / principal preocupação *</label>
<textarea
id="mensagem"
name="mensagem"
required
rows="6"
maxlength="3000"
placeholder="Conte sobre o seu evento, expectativas e principais preocupações."
class="w-full border-0 border-b border-amare-border bg-transparent py-3 text-amare-text transition-colors placeholder:text-amare-muted/60 focus:border-amare-accent focus:outline-none @error('mensagem') border-amare-error @enderror"
@error('mensagem') aria-invalid="true" aria-describedby="mensagem-error" @enderror
>{{ old('mensagem') }}</textarea>
@error('mensagem')
<p id="mensagem-error" class="mt-2 text-sm text-amare-error">{{ $message }}</p>
@enderror
</div>
<div>
<label for="privacidade" class="flex items-start gap-3 py-2">
<input
type="checkbox"
id="privacidade"
name="privacidade"
value="1"
required
@checked(old('privacidade'))
class="mt-1 h-5 w-5 shrink-0 accent-amare-accent"
@error('privacidade') aria-invalid="true" aria-describedby="privacidade-error" @enderror
>
<span class="text-sm text-amare-muted">
Li e aceito a
<a href="{{ route('privacy') }}" class="text-amare-accent underline underline-offset-2 transition-colors hover:text-amare-accent-deep">política de privacidade</a>
e autorizo o tratamento dos meus dados para fins de atendimento.*
</span>
</label>
@error('privacidade')
<p id="privacidade-error" class="mt-2 text-sm text-amare-error">{{ $message }}</p>
@enderror
</div>
<div class="flex flex-col items-start gap-4">
<button
type="submit"
data-submit-button
class="inline-flex min-h-[44px] items-center justify-center bg-amare-accent px-8 py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-accent-text transition-colors hover:bg-amare-accent-deep"
>
Enviar briefing
</button>
<p class="text-sm text-amare-muted">
* Campos obrigatórios. Seus dados são usados apenas para responder à sua solicitação.
</p>
</div>
</form>
@endif
</div>
</section>
@endsection @endsection

View File

@@ -1,6 +1,32 @@
@extends('layouts.public') @extends('layouts.public')
@section('content') @section('content')
@php
$chapters = [
['id' => 'hero-heading', 'label' => 'Capa'],
['id' => 'manifesto-heading', 'label' => 'Manifesto'],
];
if ($content->featuredServices->isNotEmpty()) {
$chapters[] = ['id' => 'services-heading', 'label' => 'Serviços'];
}
if ($content->featuredCases->isNotEmpty()) {
$chapters[] = ['id' => 'portfolio-heading', 'label' => 'Portfólio'];
}
$chapters[] = ['id' => 'method-heading', 'label' => 'Método'];
if ($content->testimonials->isNotEmpty()) {
$chapters[] = ['id' => 'testimonials-heading', 'label' => 'Depoimentos'];
}
$chapters[] = ['id' => 'positioning-heading', 'label' => 'A Amare'];
$chapters[] = ['id' => 'final-cta-heading', 'label' => 'Próximo passo'];
@endphp
<x-home.chapter-index :chapters="$chapters" />
<x-home.hero :settings="$content->settings" /> <x-home.hero :settings="$content->settings" />
<x-home.manifesto :settings="$content->settings" /> <x-home.manifesto :settings="$content->settings" />
<x-home.services :services="$content->featuredServices" /> <x-home.services :services="$content->featuredServices" />

View File

@@ -1,16 +1,16 @@
@extends('layouts.public') @extends('layouts.public')
@section('content') @section('content')
<div class="border-b border-amare-border bg-amare-bg-deep"> <div class="border-b border-amare-border bg-amare-bg-deep" data-motion="page-open">
<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>
@if ($cases->isEmpty()) @if ($cases->isEmpty())
<p class="text-amare-muted">Em breve publicaremos novos casos.</p> <p class="text-amare-muted">Novos casos serão publicados assim que o acervo estiver organizado. Enquanto isso, fale conosco para conhecer o nosso trabalho.</p>
@else @else
<div class="grid gap-10 md:grid-cols-2"> <div class="grid gap-10 md:grid-cols-2">
@foreach ($cases as $case) @foreach ($cases as $case)
@@ -40,7 +40,9 @@
</div> </div>
@endif @endif
<p class="text-sm text-amare-accent">Imagens demonstrativas até existir acervo autorizado da Amare.</p> <p class="text-sm text-amare-accent">Imagens demonstrativas enquanto o acervo autorizado da Amare está em organização.</p>
</div> </div>
</div> </div>
<x-home.final-cta :settings="$siteSettings" />
@endsection @endsection

View File

@@ -1,14 +1,16 @@
@extends('layouts.public') @extends('layouts.public')
@section('content') @section('content')
<article> <article data-motion="page-open">
<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>
<h1 class="text-4xl font-medium tracking-tight text-amare-text md:text-5xl">{{ $case->title }}</h1> @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

@@ -1,16 +1,16 @@
@extends('layouts.public') @extends('layouts.public')
@section('content') @section('content')
<div class="border-b border-amare-border bg-amare-bg"> <div class="border-b border-amare-border bg-amare-bg" data-motion="page-open">
<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>
@if ($services->isEmpty()) @if ($services->isEmpty())
<p class="text-amare-muted">Em breve publicaremos o catálogo de serviços.</p> <p class="text-amare-muted">O catálogo de serviços está em organização. Enquanto isso, fale conosco para uma primeira conversa.</p>
@else @else
<div class="divide-y divide-amare-border border-y border-amare-border"> <div class="divide-y divide-amare-border border-y border-amare-border">
@foreach ($services as $index => $service) @foreach ($services as $index => $service)
@@ -41,4 +41,6 @@
@endif @endif
</div> </div>
</div> </div>
<x-home.final-cta :settings="$siteSettings" />
@endsection @endsection

File diff suppressed because one or more lines are too long

View File

@@ -2,6 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
use App\Http\Controllers\PublicSite\ContactController;
use App\Http\Controllers\PublicSite\HomeController; use App\Http\Controllers\PublicSite\HomeController;
use App\Http\Controllers\PublicSite\PageController; use App\Http\Controllers\PublicSite\PageController;
use App\Http\Controllers\PublicSite\PortfolioController; use App\Http\Controllers\PublicSite\PortfolioController;
@@ -18,5 +19,8 @@ Route::get('/portfolio/{slug}', [PortfolioController::class, 'show'])->name('por
Route::get('/sobre', [PageController::class, 'about'])->name('about'); Route::get('/sobre', [PageController::class, 'about'])->name('about');
Route::get('/privacidade', [PageController::class, 'privacy'])->name('privacy'); Route::get('/privacidade', [PageController::class, 'privacy'])->name('privacy');
Route::get('/contato', [PageController::class, 'contact'])->name('contact'); Route::get('/contato', [PageController::class, 'contact'])->name('contact');
Route::post('/contato', [ContactController::class, 'store'])
->middleware('throttle:contact-briefing')
->name('contact.store');
Route::get('/sitemap.xml', SitemapController::class)->name('sitemap'); Route::get('/sitemap.xml', SitemapController::class)->name('sitemap');
Route::get('/robots.txt', RobotsController::class)->name('robots'); Route::get('/robots.txt', RobotsController::class)->name('robots');

View File

@@ -1,7 +1,7 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Trigger a Dokploy Compose deploy and wait until it finishes. # Trigger a Dokploy Compose deploy and wait until it finishes.
# Required env: # Required env:
# DOKPLOY_URL e.g. https://panel.example.com # DOKPLOY_URL e.g. https://panel.example.com (panel origin, no /api)
# DOKPLOY_API_KEY x-api-key value # DOKPLOY_API_KEY x-api-key value
# DOKPLOY_COMPOSE_ID target compose id # DOKPLOY_COMPOSE_ID target compose id
# Optional env: # Optional env:
@@ -15,21 +15,53 @@ set -euo pipefail
: "${DOKPLOY_API_KEY:?DOKPLOY_API_KEY is required}" : "${DOKPLOY_API_KEY:?DOKPLOY_API_KEY is required}"
: "${DOKPLOY_COMPOSE_ID:?DOKPLOY_COMPOSE_ID is required}" : "${DOKPLOY_COMPOSE_ID:?DOKPLOY_COMPOSE_ID is required}"
# Panel origin only: strip trailing slash and accidental OpenAPI /api suffix.
DOKPLOY_URL="${DOKPLOY_URL%/}" DOKPLOY_URL="${DOKPLOY_URL%/}"
DOKPLOY_URL="${DOKPLOY_URL%/api}"
DOKPLOY_URL="${DOKPLOY_URL%/}"
DEPLOY_TITLE="${DEPLOY_TITLE:-GitHub deploy}" DEPLOY_TITLE="${DEPLOY_TITLE:-GitHub deploy}"
DEPLOY_TIMEOUT_SEC="${DEPLOY_TIMEOUT_SEC:-900}" DEPLOY_TIMEOUT_SEC="${DEPLOY_TIMEOUT_SEC:-900}"
DEPLOY_POLL_SEC="${DEPLOY_POLL_SEC:-10}" DEPLOY_POLL_SEC="${DEPLOY_POLL_SEC:-10}"
echo "Dokploy API base: ${DOKPLOY_URL}/api"
api() { api() {
local method="$1" local method="$1"
local path="$2" local path="$2"
shift 2 shift 2
curl -fsS -X "$method" \ local url="${DOKPLOY_URL}/api${path}"
local response http_code body
# Body then HTTP status on the last line (no -f so 4xx/5xx still return a body).
response="$(
curl -sS -w $'\n%{http_code}' -X "$method" \
-H "accept: application/json" \ -H "accept: application/json" \
-H "content-type: application/json" \ -H "content-type: application/json" \
-H "x-api-key: ${DOKPLOY_API_KEY}" \ -H "x-api-key: ${DOKPLOY_API_KEY}" \
"${DOKPLOY_URL}/api${path}" \ "$url" \
"$@" "$@"
)" || {
echo "Dokploy API request failed (curl transport error)." >&2
echo " method: ${method}" >&2
echo " url: ${url}" >&2
exit 1
}
http_code="$(printf '%s' "$response" | tail -n1)"
body="$(printf '%s' "$response" | sed '$d')"
if [[ ! "$http_code" =~ ^2[0-9][0-9]$ ]]; then
echo "Dokploy API error." >&2
echo " method: ${method}" >&2
echo " url: ${url}" >&2
echo " status: ${http_code}" >&2
echo " body:" >&2
printf '%s\n' "$body" >&2
exit 1
fi
printf '%s' "$body"
} }
echo "Triggering Dokploy compose.deploy for ${DOKPLOY_COMPOSE_ID}..." echo "Triggering Dokploy compose.deploy for ${DOKPLOY_COMPOSE_ID}..."

79
tasks.md Normal file
View File

@@ -0,0 +1,79 @@
# Frontend Audit — Amare site
## T0 — Baseline
- [x] Rodar `composer quality` p/ registrar estado verde atual (110 passed após composer install)
- [x] Registrado estado baseline; Lighthouse final consolidado no T6
- [x] Registrar achados baseline (audit completo em frontend-audit.md + relatório subagent)
## T1 — Formulário de contato (e-mail, sem CRM)
- [x] Form em /contato (campos, LGPD obrigatório)
- [x] Rota POST /contato + validação + honeypot + throttle
- [x] Anti duplo-envio, estados sucesso/erro/rede, preservação de dados
- [x] E-mail via MAIL_MAILER (Resend prod / log local)
- [x] A11y do form (labels, erros ligados)
- [x] Testes feature + atualizar PublicPagesTest — Feature suite: 97 passed
## T2 — A11y/navegação
- [x] Menu mobile: Escape, retorno de foco, fallback sem-JS (app.js + noscript)
- [x] Touch targets ≥44px (header, footer, CTAs, capítulos)
- [x] Footer: ul/li + address
## T3 — SEO/metadados
- [x] Títulos "Página · Amare" (PageMeta::withBrandSuffix)
- [x] og:locale, og:site_name, Twitter cards
- [x] Favicon real + link, theme-color
- [x] Metadados corretos em 404/500 (PageMeta::forErrorPage)
- [x] noindex fora de produção (robots meta condicional)
## T4 — Conteúdo/posicionamento
- [x] Remover welcome.blade.php, home/cases, home/proof
- [x] Dedup heading final-CTA vs h1 contato; dedup disclaimers
- [x] CTA consistente mobile/desktop
- [x] Corrigir copy "Em breve..." nas listas
- [x] Rebalancear copy corporativo/social
## T5 — Performance
- [x] fetchpriority + dimensões hero (LCP)
- [x] Guarda CLS no media/image
- [x] Cache manifest de fontes
- [x] Escape JSON-LD
## T6 — Validação final
- [x] pint, phpstan, testes unit/feature/architecture — `composer quality` verde (135 testes)
- [x] Browser tests + visual regression (18 passaram; 8 baselines `.snap` atualizadas com `--update-snapshots`)
- [x] Lighthouse final (home mobile: A11y 100, Best Practices 100, SEO 69 só por noindex intencional fora de prod; contato desktop: A11y 100, BP 100)
- [x] E2E form: submit → sucesso role=status; 2 e-mails (novo briefing + confirmação) logados via MAIL_MAILER=log; fila drenada
- [x] Mobile: sem overflow horizontal nas 7 rotas (390px); menu mobile abre/fecha + Escape + retorno de foco
- [x] Relatório de entrega
## T7 — Hardening Public Blades
- [x] Chunk 1: CSS overflow resilience (`overflow-wrap` body + `[data-chapter-index]` wrap safety, browser test)
- [x] Chunk 2: Hero + final-CTA fallbacks + `leading-tight` (TDD)
- [x] Chunk 3a: Skip blank-quote testimonials (TDD)
- [x] Chunk 3b: Guard empty `event_type` + `break-words` meta line (TDD)
- [x] Chunk 4: Extend `PageMeta::forErrorPage` + branded 419/429/503 error views (TDD)
- [x] Chunk 5: `app.js` defensive hardening — bfcache submit reset, "Enviando…" label swap, aria-busy, mobile-menu focus trap + Escape
- [x] Chunk 6: Quality gates — Pint passed, PHPStan clean, 104 feature tests passed (587 assertions), Vite build succeeded
### Deviations from plan
- `PageMeta::forErrorPage` landed on `main` during implementation; rebased and extended it for 419/429/503.
- Contact form landed on `main`; submit-state browser coverage was restored.
- `event_type: null` violates NOT NULL column — test uses `''` instead (guard covers both).
# Browser CI Gate — estabilidade e diagnosticabilidade
## BG — Causa raiz
- [x] Diagnosticar 3 runs: mismatch visual (8 testes), acessibilidade (1), timeout networkidle 5s + overflow real
- [x] Reproduzir local: `waitForLoadState('networkidle')` fragil (conexões longas), default 5s
- [x] Reproduzir warning `file_get_contents(.env)` em TODO teste (CI sem .env)
## BG — Fixes
- [x] Helper `StableScreenshot` (tests/Support/): drop networkidle → readyState + fonts + settle
- [x] Exportar PNGs standalone (diff/expected/actual) no mismatch — visíveis nos artifacts
- [x] CI: `cp .env.example .env` nos jobs de teste; checkout/cache v5
- [x] Gitignore tests/Browser/Screenshots
- [x] Unit test MismatchScreenshotExporter; pint/phpstan/unit/feature verdes
- [x] Push + PR + CI browser verde (PR #15 merge f09ea83, run 31098941038)
- [x] Refresh baselines de CI se necessário (não necessário — browser passou com baselines atuais)

View File

@@ -95,7 +95,22 @@ it('loads covered public routes without console errors', function (): void {
}); });
it('disables transitions when prefers-reduced-motion is reduce', function (): void { it('disables transitions when prefers-reduced-motion is reduce', function (): void {
$page = $this->visit('/', [ $case = PortfolioCase::factory()->published()->create([
'slug' => 'casamento-jardim',
]);
$routes = [
'/',
'/servicos',
'/portfolio',
'/portfolio/'.$case->slug,
'/sobre',
'/contato',
'/privacidade',
];
foreach ($routes as $route) {
$page = $this->visit($route, [
'reducedMotion' => 'reduce', 'reducedMotion' => 'reduce',
]); ]);
@@ -111,4 +126,5 @@ it('disables transitions when prefers-reduced-motion is reduce', function (): vo
JS); JS);
expect((float) $duration)->toBeLessThan(0.02); expect((float) $duration)->toBeLessThan(0.02);
}
}); });

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

@@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
use App\Models\Service;
use App\Models\SiteSetting;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function (): void {
SiteSetting::instance()->update([
'hero_title' => 'Celebrações com propósito',
'default_og_image_path' => 'media/hero.jpg',
'default_og_image_alt' => 'Capa editorial',
]);
Service::factory()->published()->featured()->create([
'title' => 'Casamentos',
'summary' => 'Assessoria completa',
]);
});
it('keeps home motion in final visible state when reduced motion is preferred', function (): void {
$page = $this->visit('/', [
'reducedMotion' => 'reduce',
]);
$state = $page->script(<<<'JS'
() => {
const hero = document.querySelector('[data-motion="dossie-hero"]');
const title = document.querySelector('#hero-heading');
const index = document.querySelector('[data-chapter-index]');
const active = document.querySelector('[data-chapter-index] [aria-current="true"]');
const titleStyle = title ? getComputedStyle(title) : null;
return {
heroActive: hero?.classList.contains('is-active') ?? false,
titleOpacity: titleStyle ? Number.parseFloat(titleStyle.opacity) : 0,
hasIndex: Boolean(index),
hasActiveChapter: Boolean(active),
};
}
JS);
expect($state['heroActive'])->toBeTrue();
expect($state['titleOpacity'])->toBeGreaterThan(0.9);
expect($state['hasIndex'])->toBeTrue();
expect($state['hasActiveChapter'])->toBeTrue();
});
it('activates the home focal opening once when motion is allowed', function (): void {
$page = $this->visit('/', [
'reducedMotion' => 'no-preference',
]);
$page->script(<<<'JS'
() => new Promise((resolve) => {
const wait = () => {
const hero = document.querySelector('[data-motion="dossie-hero"]');
if (hero?.classList.contains('is-active')) {
resolve(true);
return;
}
requestAnimationFrame(wait);
};
wait();
setTimeout(() => resolve(false), 2000);
})
JS);
$active = $page->script(<<<'JS'
() => document.querySelector('[data-motion="dossie-hero"]')?.classList.contains('is-active') ?? false
JS);
expect($active)->toBeTrue();
});
it('updates the active chapter while scrolling the home dossier', function (): void {
$page = $this->visit('/', [
'reducedMotion' => 'no-preference',
]);
$page->resize(1440, 1000);
$before = $page->script(<<<'JS'
() => document.querySelector('[data-chapter-index] [aria-current="true"]')?.getAttribute('href') ?? null
JS);
$page->script(<<<'JS'
() => {
const target = document.querySelector('#method-heading');
target?.scrollIntoView({ block: 'start' });
window.dispatchEvent(new Event('scroll'));
return Boolean(target);
}
JS);
$page->script(<<<'JS'
() => new Promise((resolve) => {
let frames = 0;
const check = () => {
const href = document.querySelector('[data-chapter-index] [aria-current="true"]')?.getAttribute('href');
if (href === '#method-heading' || frames > 60) {
resolve(href);
return;
}
frames += 1;
requestAnimationFrame(check);
};
check();
})
JS);
$after = $page->script(<<<'JS'
() => document.querySelector('[data-chapter-index] [aria-current="true"]')?.getAttribute('href') ?? null
JS);
expect($before)->not->toBeNull();
expect($after)->toBe('#method-heading');
});

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->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(); );
}); });
} }
} }

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
use Livewire\Features\SupportFileUploads\FileUploadConfiguration;
it('keeps livewire temporary uploads on the local disk when the default filesystem is r2', function (): void {
config(['filesystems.default' => 'r2']);
// Livewire swaps FileUploadConfiguration::disk() to tmp-for-tests under PHPUnit;
// isUsingS3() still reads the configured temp disk and drives signed R2 uploads.
expect(config('livewire.temporary_file_upload.disk'))->toBe('local')
->and(FileUploadConfiguration::isUsingS3())->toBeFalse();
});

Some files were not shown because too many files have changed in this diff Show More