Compare commits

..

32 Commits

Author SHA1 Message Date
manoel.neto
343fd5e197 test: restaurar baselines visuais removidos por engano na reconciliação
O commit 58f24a6 ("chore: reconcile main deployment and agent docs")
apagou oito baselines de regressão visual — home, services, portfolio e
portfolio-detail, em desktop e mobile. O VisualRegressionTest continua
declarando os oito cenários, então as quatro telas mais importantes do
site ficaram sem proteção contra regressão visual.

Os arquivos voltam exatamente como estavam em 42b282c. Aquele commit e o
atual HEAD não têm nenhuma diferença que afete renderização: 58f24a6
tocou apenas AGENTS.md, docker-compose.deploy.yml, tasks.md e a própria
remoção dos snapshots. Nenhum Blade, CSS, token ou asset mudou, então os
baselines restaurados descrevem a renderização atual.

O job browser do CI, que roda em paridade com o ambiente de geração,
é a verificação desta mudança.

Co-Authored-By: Claude noreply@anthropic.com
AI-Assisted: yes
AI-Tool: claude-code
2026-08-10 02:18:57 -03:00
58f24a6d5a chore: reconcile main deployment and agent docs
Squash merge of the validated main reconciliation changes.
2026-08-07 22:22:24 -03:00
4061662c4b chore: setup agent skills config (#25) 2026-08-07 21:48:48 -03:00
42b282c1f2 feat: adicionar cadência editorial à home (#23)
* feat: adicionar cadência editorial à home

* fix: reconcile pr23 ci with current main

* test: atualizar baselines visuais da home para cadência editorial
2026-08-07 21:40:35 -03:00
457845758c test: regenerar baselines visuais em ambiente CI-parity (#24)
* test: regenerar baselines visuais em ambiente CI-parity

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

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

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

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

* fix: skip blank testimonials and guard empty portfolio metadata

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

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

* chore: track hardening task status

* fix: restore contact submit state after bfcache

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

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

* test: regenerate visual baselines from CI environment
2026-08-05 23:27:14 -03:00
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
142 changed files with 3950 additions and 615 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.
@@ -30,3 +41,21 @@ History follows Conventional Commit-style subjects, for example `feat: Fase 0
## Security & Configuration ## Security & Configuration
Copy `.env.example`; never commit secrets or production credentials. Development seed credentials are local-only. Validate uploads and authorization through Laravel policies, and run `composer security-audit` after dependency changes. Copy `.env.example`; never commit secrets or production credentials. Development seed credentials are local-only. Validate uploads and authorization through Laravel policies, and run `composer security-audit` after dependency changes.
## Design Context
Amare: refined, humane, precise — Heritage Editorial (see `.impeccable.md`). Trust-first, both private + corporate audiences. Never generic wedding decor (hearts/gold/script) or AI-slop. Real proof only. For design skills, read `.impeccable.md` at project root.
## Agent skills
### Issue tracker
Issues live in Linear, driven through the Linear MCP tools. See `docs/agents/issue-tracker.md`.
### Triage labels
Default vocabulary: `needs-triage`, `needs-info`, `ready-for-agent`, `ready-for-human`, `wontfix`. See `docs/agents/triage-labels.md`.
### Domain docs
Single-context: `CONTEXT.md` at root + `docs/adr/`. See `docs/agents/domain.md`.

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;
@@ -28,6 +31,7 @@ class AppServiceProvider extends ServiceProvider
{ {
$this->configureLivewireTemporaryUploads(); $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')
@@ -63,6 +67,13 @@ class AppServiceProvider extends ServiceProvider
config(['livewire.temporary_file_upload.disk' => 'local']); 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')) {

12
composer.lock generated
View File

@@ -2802,16 +2802,16 @@
}, },
{ {
"name": "league/commonmark", "name": "league/commonmark",
"version": "2.8.3", "version": "2.9.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/thephpleague/commonmark.git", "url": "https://github.com/thephpleague/commonmark.git",
"reference": "1902f60f984235023acbe03db6ad614a37b3c3e7" "reference": "5703d83ba3da3b2e356a5fedc848ed6d8ffb6529"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/thephpleague/commonmark/zipball/1902f60f984235023acbe03db6ad614a37b3c3e7", "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/5703d83ba3da3b2e356a5fedc848ed6d8ffb6529",
"reference": "1902f60f984235023acbe03db6ad614a37b3c3e7", "reference": "5703d83ba3da3b2e356a5fedc848ed6d8ffb6529",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -2848,7 +2848,7 @@
"type": "library", "type": "library",
"extra": { "extra": {
"branch-alias": { "branch-alias": {
"dev-main": "2.9-dev" "dev-main": "2.10-dev"
} }
}, },
"autoload": { "autoload": {
@@ -2905,7 +2905,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-07-12T15:29:16+00:00" "time": "2026-08-03T13:42:31+00:00"
}, },
{ {
"name": "league/config", "name": "league/config",

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 436 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 622 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 499 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 235 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 360 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 330 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 302 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 337 KiB

View File

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

View File

@@ -8,6 +8,7 @@ use App\Models\PortfolioCase;
use App\Models\PortfolioImage; use App\Models\PortfolioImage;
use App\Models\Service; use App\Models\Service;
use App\Models\SiteSetting; use App\Models\SiteSetting;
use App\Support\PublicImageUploadRules;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\File;
@@ -36,6 +37,8 @@ class ContentSeeder extends Seeder
'hero_secondary_cta_label' => 'Conheça nosso olhar', 'hero_secondary_cta_label' => 'Conheça nosso olhar',
'hero_note' => 'Planejamento cuidadoso, comunicação clara e execução segura — do primeiro encontro ao último detalhe.', 'hero_note' => 'Planejamento cuidadoso, comunicação clara e execução segura — do primeiro encontro ao último detalhe.',
'about_summary' => 'Assessoria boutique especializada em experiências memoráveis em São Paulo.', 'about_summary' => 'Assessoria boutique especializada em experiências memoráveis em São Paulo.',
'about_image_path' => $this->copyFixture('about-image.jpg', 'content/about/about-image.jpg'),
'about_image_alt' => 'Mesa de planejamento com caderno, café e guardanapos de pano',
'manifesto_title' => 'Sofisticação que também se traduz em organização.', 'manifesto_title' => 'Sofisticação que também se traduz em organização.',
'manifesto_lead' => 'Um evento memorável não nasce apenas de uma boa estética. Ele depende de decisões bem conduzidas, fornecedores alinhados e atenção constante ao que realmente importa.', 'manifesto_lead' => 'Um evento memorável não nasce apenas de uma boa estética. Ele depende de decisões bem conduzidas, fornecedores alinhados e atenção constante ao que realmente importa.',
'manifesto_body' => 'A Amare combina sensibilidade e precisão para criar encontros coerentes com cada cliente, marca e ocasião — sem fórmulas prontas, excessos ou ruído.', 'manifesto_body' => 'A Amare combina sensibilidade e precisão para criar encontros coerentes com cada cliente, marca e ocasião — sem fórmulas prontas, excessos ou ruído.',
@@ -91,7 +94,7 @@ class ContentSeeder extends Seeder
['slug' => $service['slug']], ['slug' => $service['slug']],
[ [
...$service, ...$service,
'cover_image_path' => $this->copyFixture('service-cover.jpg', 'content/services/'.$service['slug'].'.jpg'), 'cover_image_path' => $this->copyFixture('service-'.$service['slug'].'.jpg', 'content/services/'.$service['slug'].'.jpg'),
'cover_image_alt' => 'Capa do serviço '.$service['title'], 'cover_image_alt' => 'Capa do serviço '.$service['title'],
'published_at' => Carbon::parse(self::SEED_TIMESTAMP), 'published_at' => Carbon::parse(self::SEED_TIMESTAMP),
], ],
@@ -148,7 +151,7 @@ class ContentSeeder extends Seeder
['slug' => $caseData['slug']], ['slug' => $caseData['slug']],
[ [
...$caseData, ...$caseData,
'cover_image_path' => $this->copyFixture('portfolio-cover.jpg', 'content/portfolio/'.$caseData['slug'].'-cover.jpg'), 'cover_image_path' => $this->copyFixture('case-'.$caseData['slug'].'.jpg', 'content/portfolio/'.$caseData['slug'].'-cover.jpg'),
'cover_image_alt' => 'Capa do caso '.$caseData['title'], 'cover_image_alt' => 'Capa do caso '.$caseData['title'],
'is_featured' => true, 'is_featured' => true,
'published_at' => Carbon::parse(self::SEED_TIMESTAMP), 'published_at' => Carbon::parse(self::SEED_TIMESTAMP),
@@ -160,7 +163,7 @@ class ContentSeeder extends Seeder
foreach ([1, 2] as $index) { foreach ([1, 2] as $index) {
PortfolioImage::query()->create([ PortfolioImage::query()->create([
'portfolio_case_id' => $case->id, 'portfolio_case_id' => $case->id,
'path' => $this->copyFixture('gallery.jpg', 'content/portfolio/'.$caseData['slug'].'-gallery-'.$index.'.jpg'), 'path' => $this->copyFixture('gallery-'.$caseData['slug'].'-'.$index.'.jpg', 'content/portfolio/'.$caseData['slug'].'-gallery-'.$index.'.jpg'),
'alt_text' => 'Galeria '.$caseData['title'].' '.$index, 'alt_text' => 'Galeria '.$caseData['title'].' '.$index,
'caption' => $index === 1 ? 'Detalhe da decoração' : null, 'caption' => $index === 1 ? 'Detalhe da decoração' : null,
'sort_order' => $index, 'sort_order' => $index,
@@ -171,8 +174,10 @@ class ContentSeeder extends Seeder
private function copyFixture(string $fixtureName, string $destination): string private function copyFixture(string $fixtureName, string $destination): string
{ {
$source = base_path('tests/fixtures/images/'.$fixtureName); $source = base_path('database/fixtures/images/'.$fixtureName);
Storage::disk('public')->put($destination, File::get($source)); $disk = PublicImageUploadRules::disk();
Storage::disk($disk)->put($destination, File::get($source), 'public');
return $destination; return $destination;
} }

View File

@@ -11,7 +11,6 @@ use App\Models\SiteSetting;
use App\Models\Testimonial; use App\Models\Testimonial;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
/** /**
@@ -44,6 +43,8 @@ class VisualContentSeeder extends Seeder
'hero_secondary_cta_label' => 'Conheça nosso olhar', 'hero_secondary_cta_label' => 'Conheça nosso olhar',
'hero_note' => 'Planejamento cuidadoso, comunicação clara e execução segura — do primeiro encontro ao último detalhe.', 'hero_note' => 'Planejamento cuidadoso, comunicação clara e execução segura — do primeiro encontro ao último detalhe.',
'about_summary' => 'Assessoria boutique especializada em experiências memoráveis em São Paulo.', 'about_summary' => 'Assessoria boutique especializada em experiências memoráveis em São Paulo.',
'about_image_path' => $this->writeSolidJpeg('visual/about/about-image.jpg', 1200, 900, [232, 228, 218]),
'about_image_alt' => 'Imagem editorial da página Sobre',
'manifesto_title' => 'Sofisticação que também se traduz em organização.', 'manifesto_title' => 'Sofisticação que também se traduz em organização.',
'manifesto_lead' => 'Um evento memorável não nasce apenas de uma boa estética. Ele depende de decisões bem conduzidas, fornecedores alinhados e atenção constante ao que realmente importa.', 'manifesto_lead' => 'Um evento memorável não nasce apenas de uma boa estética. Ele depende de decisões bem conduzidas, fornecedores alinhados e atenção constante ao que realmente importa.',
'manifesto_body' => 'A Amare combina sensibilidade e precisão para criar encontros coerentes com cada cliente, marca e ocasião — sem fórmulas prontas, excessos ou ruído.', 'manifesto_body' => 'A Amare combina sensibilidade e precisão para criar encontros coerentes com cada cliente, marca e ocasião — sem fórmulas prontas, excessos ou ruído.',
@@ -58,7 +59,7 @@ class VisualContentSeeder extends Seeder
], ],
'default_meta_title' => 'Amare Assessoria de Eventos', 'default_meta_title' => 'Amare Assessoria de Eventos',
'default_meta_description' => 'Assessoria premium para casamentos e eventos corporativos em São Paulo.', 'default_meta_description' => 'Assessoria premium para casamentos e eventos corporativos em São Paulo.',
'default_og_image_path' => $this->copyFixture('og-default.jpg', 'visual/og/og-default.jpg'), 'default_og_image_path' => $this->writeSolidJpeg('visual/og/og-default.jpg', 1600, 1067, [85, 107, 47]),
'default_og_image_alt' => 'Identidade visual da Amare Assessoria de Eventos', 'default_og_image_alt' => 'Identidade visual da Amare Assessoria de Eventos',
'analytics_enabled' => false, 'analytics_enabled' => false,
'analytics_script' => null, 'analytics_script' => null,
@@ -89,7 +90,7 @@ class VisualContentSeeder extends Seeder
['slug' => $service['slug']], ['slug' => $service['slug']],
[ [
...$service, ...$service,
'cover_image_path' => $this->copyFixture('service-cover.jpg', 'visual/services/'.$service['slug'].'.jpg'), 'cover_image_path' => $this->writeSolidJpeg('visual/services/'.$service['slug'].'.jpg', 1600, 1000, [196, 200, 184]),
'cover_image_alt' => 'Capa do serviço '.$service['title'], 'cover_image_alt' => 'Capa do serviço '.$service['title'],
'published_at' => Carbon::parse(self::SEED_TIMESTAMP), 'published_at' => Carbon::parse(self::SEED_TIMESTAMP),
], ],
@@ -133,7 +134,7 @@ class VisualContentSeeder extends Seeder
['slug' => $caseData['slug']], ['slug' => $caseData['slug']],
[ [
...$caseData, ...$caseData,
'cover_image_path' => $this->copyFixture('portfolio-cover.jpg', 'visual/portfolio/'.$caseData['slug'].'-cover.jpg'), 'cover_image_path' => $this->writeSolidJpeg('visual/portfolio/'.$caseData['slug'].'-cover.jpg', 1600, 1200, [240, 238, 233]),
'cover_image_alt' => 'Capa do caso '.$caseData['title'], 'cover_image_alt' => 'Capa do caso '.$caseData['title'],
'is_featured' => true, 'is_featured' => true,
'published_at' => Carbon::parse(self::SEED_TIMESTAMP), 'published_at' => Carbon::parse(self::SEED_TIMESTAMP),
@@ -144,7 +145,7 @@ class VisualContentSeeder extends Seeder
PortfolioImage::query()->create([ PortfolioImage::query()->create([
'portfolio_case_id' => $case->id, 'portfolio_case_id' => $case->id,
'path' => $this->copyFixture('gallery.jpg', 'visual/portfolio/'.$caseData['slug'].'-gallery-1.jpg'), 'path' => $this->writeSolidJpeg('visual/portfolio/'.$caseData['slug'].'-gallery-1.jpg', 1600, 1200, [228, 226, 221]),
'alt_text' => 'Galeria '.$caseData['title'].' 1', 'alt_text' => 'Galeria '.$caseData['title'].' 1',
'caption' => 'Detalhe da decoração', 'caption' => 'Detalhe da decoração',
'sort_order' => 1, 'sort_order' => 1,
@@ -186,10 +187,21 @@ class VisualContentSeeder extends Seeder
} }
} }
private function copyFixture(string $fixtureName, string $destination): string /**
* @param array{0: int, 1: int, 2: int} $rgb
*/
private function writeSolidJpeg(string $destination, int $width, int $height, array $rgb): string
{ {
$source = base_path('tests/fixtures/images/'.$fixtureName); $image = imagecreatetruecolor($width, $height);
Storage::disk('public')->put($destination, File::get($source)); $color = imagecolorallocate($image, $rgb[0], $rgb[1], $rgb[2]);
imagefilledrectangle($image, 0, 0, $width, $height, $color);
ob_start();
imagejpeg($image, null, 90);
$binary = (string) ob_get_clean();
imagedestroy($image);
Storage::disk('public')->put($destination, $binary);
return $destination; return $destination;
} }

View File

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

51
docs/agents/domain.md Normal file
View File

@@ -0,0 +1,51 @@
# Domain Docs
How the engineering skills should consume this repo's domain documentation when exploring the codebase.
## Before exploring, read these
- **`CONTEXT.md`** at the repo root, or
- **`CONTEXT-MAP.md`** at the repo root if it exists — it points at one `CONTEXT.md` per context. Read each one relevant to the topic.
- **`docs/adr/`** — read ADRs that touch the area you're about to work in. In multi-context repos, also check `src/<context>/docs/adr/` for context-scoped decisions.
If any of these files don't exist, **proceed silently**. Don't flag their absence; don't suggest creating them upfront. The `/domain-modeling` skill (reached via `/grill-with-docs` and `/improve-codebase-architecture`) creates them lazily when terms or decisions actually get resolved.
## File structure
Single-context repo (most repos):
```
/
├── CONTEXT.md
├── docs/adr/
│ ├── 0001-event-sourced-orders.md
│ └── 0002-postgres-for-write-model.md
└── src/
```
Multi-context repo (presence of `CONTEXT-MAP.md` at the root):
```
/
├── CONTEXT-MAP.md
├── docs/adr/ ← system-wide decisions
└── src/
├── ordering/
│ ├── CONTEXT.md
│ └── docs/adr/ ← context-specific decisions
└── billing/
├── CONTEXT.md
└── docs/adr/
```
## Use the glossary's vocabulary
When your output names a domain concept (in an issue title, a refactor proposal, a hypothesis, a test name), use the term as defined in `CONTEXT.md`. Don't drift to synonyms the glossary explicitly avoids.
If the concept you need isn't in the glossary yet, that's a signal — either you're inventing language the project doesn't use (reconsider) or there's a real gap (note it for `/domain-modeling`).
## Flag ADR conflicts
If your output contradicts an existing ADR, surface it explicitly rather than silently overriding:
> _Contradicts ADR-0007 (event-sourced orders) — but worth reopening because…_

View File

@@ -0,0 +1,26 @@
# Issue tracker: Linear
Issues and specs for this repo live in Linear. Use the Linear MCP tools for all operations.
- **Workspace**: maneco-workspace (https://linear.app/maneco-workspace)
- **Team**: Maneco-workspace
- **Issue statuses**: Backlog, Todo, In Progress, Done, Canceled, Duplicate
## Conventions
- **Create an issue**: `save_issue` with `title`, `team` (`Maneco-workspace`), and markdown `description`. Assign via `assignee` ("me" for the current user).
- **Read an issue**: `get_issue` by identifier (e.g. `LIN-123`).
- **List issues**: `list_issues` filtered by `team`, `assignee`, `state`, or `project`.
- **Comment on an issue**: `save_comment` with `issueId` and markdown `body`.
- **Apply / remove labels**: `save_issue` with `labels` (replaces the full set).
- **Move workflow state**: `save_issue` with `state` (e.g. `Todo`, `In Progress`, `Done`, `Canceled`).
- **Link work**: `save_issue` with `project`, `cycle`, `parentId`, `blocks` / `blockedBy` (relations are append-only).
- **Cross-link to a PR**: attach the PR URL via `save_issue` `links`.
## When a skill says "publish to the issue tracker"
Create a Linear issue with `save_issue` on team `Maneco-workspace`.
## When a skill says "fetch the relevant ticket"
Run `get_issue` on the identifier and read the description, state, labels, and assignee.

View File

@@ -0,0 +1,15 @@
# Triage Labels
The skills speak in terms of five canonical triage roles. This file maps those roles to the actual label strings used in this repo's issue tracker.
| Label in mattpocock/skills | Label in our tracker | Meaning |
| -------------------------- | -------------------- | ---------------------------------------- |
| `needs-triage` | `needs-triage` | Maintainer needs to evaluate this issue |
| `needs-info` | `needs-info` | Waiting on reporter for more information |
| `ready-for-agent` | `ready-for-agent` | Fully specified, ready for an AFK agent |
| `ready-for-human` | `ready-for-human` | Requires human implementation |
| `wontfix` | `wontfix` | Will not be actioned |
When a skill mentions a role (e.g. "apply the AFK-ready triage label"), use the corresponding label string from this table.
Edit the right-hand column to match whatever vocabulary you actually use.

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

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

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-06

View File

@@ -0,0 +1,51 @@
## Context
O site público usa Blade/Tailwind e já contém um runtime pequeno em `resources/js/motion.js`: abertura focal da home, `page-open`, reveals com `IntersectionObserver` e progresso do índice. A cobertura e a marcação são parciais, os reveals não têm grupos/direções explícitas e a matriz browser se concentra nas quatro páginas visuais originais. A mudança cruza templates, CSS, JavaScript e testes, mas não envolve estado de aplicação, dados ou dependências.
## Goals / Non-Goals
**Goals:**
- Tornar a coreografia Heritage Editorial consistente em toda página pública visual.
- Manter texto legível durante toda entrada, animando somente `transform` e recorte de mídia.
- Garantir enhancement progressivo: estado final imediato sem JavaScript, observer ou com movimento reduzido.
- Preservar interação, foco, validação e ausência de overflow em desktop/mobile.
- Cobrir marcação, comportamento real, acessibilidade, console e capturas determinísticas.
**Non-Goals:**
- Não criar transições de rota, parallax, animação contínua ou repetição ao voltar no scroll.
- Não mudar copy, dados de clientes, estrutura de layout, CMS, controllers, banco, APIs ou Filament.
- Não adicionar biblioteca de animação nem itens de SPEC.md §4.2.
## Decisions
1. **Contrato declarativo em atributos `data-*`.** Templates usarão `data-motion="page-open"`, `data-motion-beat`, `data-reveal-group`, `data-reveal` e `data-reveal-from="up|left|right"`. CSS define o estado visual e JS apenas ativa classes. Alternativa rejeitada: acoplar seletores a classes de layout, por tornar a coreografia frágil a ajustes visuais.
2. **Enhancement opt-in pelo elemento raiz.** O HTML inicial permanece no estado final; o runtime adiciona `html[data-motion="enhance"]` somente quando movimento é permitido e os recursos necessários existem. Sem JS, sem `IntersectionObserver`, ou em `reduce`, o atributo não é aplicado/removido e todo conteúdo fica final. Alternativa rejeitada: estado inicial oculto no HTML/CSS, pois pode prender conteúdo fora da tela.
3. **Transformação sem fade de texto.** Entradas duram cerca de 500 ms com `cubic-bezier(0.16, 1, 0.3, 1)`, deslocamento vertical de 12 px e lateral de 24 px no desktop/16 px no mobile. Grupos recebem stagger de 90 ms limitado a 270 ms. Mídia pode combinar transform com `clip-path`; texto não usa opacidade. Alternativa rejeitada: fade geral, que reduz contraste durante axe e legibilidade percebida.
4. **Observer de execução única.** Cada reveal intersectado recebe o estado final e é removido do observer. Grupos calculam índice limitado para o delay; depoimentos definem explicitamente `left`/`right` pela coluna, preservando alternância no mobile. Alternativa rejeitada: reanimar em toda rolagem, por criar fadiga e instabilidade.
5. **Feedback separado das entradas.** CTAs, links, navegação e controles recebem transições curtas (100200 ms) em cor/transform/borda. `:focus-visible` continua prioritário; alertas e mensagens de erro não entram no contrato de motion. Alternativa rejeitada: feedback via JS, desnecessário para estados CSS.
6. **Progresso com coalescência por frame.** Eventos de scroll apenas agendam uma atualização por `requestAnimationFrame`; o cálculo existente é preservado. Alternativa rejeitada: calcular em todo evento, que multiplica leituras/escritas durante scroll.
7. **Testes em camadas.** Feature tests comprovam contratos Blade/CSS/JS e renderização de erros; browser tests comprovam entrada, stagger, direções, reduced motion, fallback, interação, overflow e console. Axe/snapshots passam a incluir sobre, contato, privacidade e 404; demais erros ficam em renderização feature.
## Risks / Trade-offs
- [Conteúdo pisca entre estado final e início do enhancement] → inicializar no primeiro módulo Vite, limitar transformações a distâncias pequenas e nunca ocultar texto.
- [Transforms laterais causam overflow horizontal] → limitar distância por breakpoint, manter recorte no contêiner público e testar `scrollWidth` em ambos viewports.
- [Snapshots ficam instáveis] → continuar capturando com `reducedMotion: reduce` e transições desabilitadas.
- [Muitos observers/estilos inline] → usar um único observer, custom property de índice limitada e `unobserve` imediato.
- [Páginas de erro não carregam o runtime em todos os contextos] → o estado final é o baseline seguro; feature tests cobrem os cinco templates.
## Migration Plan
Publicar CSS, JS e templates no mesmo bundle/commit; não há migração de dados. Rollback consiste em reverter esses assets e atributos, sem compatibilidade de schema ou limpeza operacional.
## Open Questions
Nenhuma; direção, alternância mobile, execução única e limites de escopo foram aprovados no plano.

View File

@@ -0,0 +1,31 @@
## Why
O site público já possui uma abertura focal na home, mas o restante da experiência ainda muda de estado de forma desigual entre páginas e seções. WEB-01, WEB-07 e os requisitos transversais de acessibilidade pedem uma coreografia editorial consistente, progressiva e dispensável, sem ocultar conteúdo nem comprometer interação, performance ou movimento reduzido.
## What Changes
- Evoluir o contrato de motion do site público com abertura de página, sequência editorial inicial e reveals direcionais executados uma única vez.
- Aplicar a coreografia à home, serviços, portfólio, detalhe, sobre, contato, privacidade e páginas de erro 404/419/429/500/503.
- Alternar depoimentos entre esquerda e direita, filtrando citações vazias antes de renderizar a sequência.
- Adicionar feedback curto e não bloqueante a links, CTAs, navegação e controles de formulário, preservando foco e mensagens de validação.
- Garantir estado final imediato sem JavaScript, sem `IntersectionObserver` e com `prefers-reduced-motion: reduce`.
- Limitar o progresso/índice da home a uma atualização por frame e ampliar testes de marcação, browser, acessibilidade, console e regressão visual.
- Não objetivos: não criar transições entre rotas, alterar conteúdo ou layout estrutural, adicionar biblioteca de animação, modificar CMS/API/banco/Filament, nem introduzir itens fora do MVP listados em SPEC.md §4.2.
## Capabilities
### New Capabilities
- `public-site-motion`: Coreografia progressiva, direções, timings, execução única, microfeedback, performance e fallbacks do motion público.
### Modified Capabilities
- `design-tokens`: Especificar tokens compartilhados de duração, stagger, distância e easing para motion editorial.
- `public-site-pages`: Cobrir todas as páginas públicas visuais e templates de erro com o contrato compartilhado de abertura e reveal.
- `testimonials`: Filtrar citações vazias e alternar explicitamente a direção de entrada dos depoimentos renderizados.
- `visual-regression`: Ampliar a matriz visual para sobre, contato, privacidade e 404, mantendo capturas determinísticas sem animação.
- `web-accessibility`: Ampliar axe/console e validar conteúdo imediatamente utilizável com movimento reduzido ou enhancement indisponível.
## Impact
Afeta somente templates Blade públicos, `resources/js/motion.js`, tokens/regras CSS e testes Pest/Pest Browser. Não altera APIs, banco, controllers, CMS, dependências, endpoints técnicos (`sitemap.xml`, `robots.txt`) ou Filament.

View File

@@ -0,0 +1,9 @@
## ADDED Requirements
### Requirement: Editorial motion tokens are centralized
The system SHALL define shared public motion tokens for an approximately 500 ms entrance, short interaction feedback, exponential ease-out, 90 ms stagger capped at 270 ms, 12 px vertical distance, and 24 px desktop / 16 px mobile lateral distance. Public motion CSS MUST consume these tokens instead of duplicating arbitrary values.
#### Scenario: Motion runtime uses shared values
- **WHEN** page openings, reveals, or interaction feedback are styled
- **THEN** duration, easing, stagger, and distance MUST be derived from centralized tokens

View File

@@ -0,0 +1,74 @@
## ADDED Requirements
### Requirement: Public pages use shared progressive motion choreography
The public site SHALL expose a declarative motion contract using `data-motion="page-open"`, `data-motion-beat`, `data-reveal-group`, `data-reveal`, and `data-reveal-from="up|left|right"`. Home, services, portfolio index/detail, about, contact, privacy, and branded 404/419/429/500/503 pages MUST use the contract without introducing route transitions.
#### Scenario: Visual public route exposes motion hooks
- **WHEN** a visitor loads any visual public route or branded error page
- **THEN** the rendered page MUST expose a page opening hook and appropriate reveal hooks
#### Scenario: Technical endpoints remain outside motion
- **WHEN** a visitor requests `sitemap.xml` or `robots.txt`
- **THEN** the response MUST NOT depend on the public motion runtime
### Requirement: Editorial entrance timing and direction are consistent
Entrances SHALL use shared tokens for an approximately 500 ms duration, 90 ms stagger capped at 270 ms, 12 px vertical distance, and 24 px desktop / 16 px mobile lateral distance. Text MUST remain fully visible; motion MUST use transforms and MAY use media clipping without opacity fades for text.
#### Scenario: Reveal group computes capped stagger
- **WHEN** more than four reveal items are rendered in one group
- **THEN** the applied delay MUST NOT exceed 270 ms
#### Scenario: Text enters without opacity fade
- **WHEN** page opening or scroll reveal motion runs
- **THEN** text MUST remain visible throughout the transition
### Requirement: Reveals execute once without blocking interaction
Each scroll reveal SHALL activate once, reach its final state, and be removed from observation. Visitors MUST be able to activate links and form controls while entrance motion is running.
#### Scenario: Revealed content leaves observer
- **WHEN** a reveal target intersects the configured viewport threshold
- **THEN** it MUST receive the final state and be unobserved
- **AND** scrolling away and back MUST NOT replay the reveal
#### Scenario: CTA remains interactive during entrance
- **WHEN** a visitor activates a CTA while its entrance is in progress
- **THEN** navigation MUST proceed without waiting for animation completion
### Requirement: Motion enhancement has immediate safe fallbacks
Without JavaScript, without `IntersectionObserver`, or when `prefers-reduced-motion: reduce` is active, the site SHALL render every motion target immediately in its final usable state. Non-essential animation and transition MUST NOT run under reduced motion.
#### Scenario: JavaScript is unavailable
- **WHEN** a public page renders without executing JavaScript
- **THEN** all content MUST be visible and usable in its final state
#### Scenario: IntersectionObserver is unavailable
- **WHEN** JavaScript runs but `IntersectionObserver` is not supported
- **THEN** all reveal targets MUST remain in their final state
#### Scenario: Reduced motion is preferred
- **WHEN** the browser reports `prefers-reduced-motion: reduce`
- **THEN** page openings, reveals, media clips, and non-essential feedback transitions MUST NOT run
### Requirement: Public interaction feedback is short and accessible
CTAs, links, navigation, and form controls SHALL provide short feedback using shared tokens while preserving visible `:focus-visible` indication. Alerts and validation messages MUST NOT be animated by this contract.
#### Scenario: Keyboard focus remains visible
- **WHEN** a visitor focuses an interactive element with the keyboard
- **THEN** its focus indicator MUST remain visible and MUST NOT be displaced by motion feedback
#### Scenario: Validation message appears without motion
- **WHEN** server validation renders an error message
- **THEN** the message MUST appear in its final position without entrance animation
### Requirement: Home progress updates at most once per animation frame
Scroll-driven chapter index and progress updates on the home SHALL be coalesced through `requestAnimationFrame`, with at most one pending update per frame.
#### Scenario: Multiple scroll events occur in one frame
- **WHEN** multiple scroll events fire before the next animation frame
- **THEN** the home progress calculation MUST execute only once for that frame

View File

@@ -0,0 +1,14 @@
## ADDED Requirements
### Requirement: Visual public and error pages expose the shared motion contract
The system SHALL apply the shared progressive opening and reveal contract to home, services, portfolio index/detail, about, contact, privacy, and branded 404/419/429/500/503 pages. The contract MUST NOT change page copy, content ordering, route behavior, or structural layout.
#### Scenario: Institutional page uses shared opening
- **WHEN** a visitor loads `/sobre`, `/contato`, or `/privacidade`
- **THEN** the page MUST expose the shared page opening hook and section reveal hooks
#### Scenario: Error page remains branded and progressively enhanced
- **WHEN** Laravel renders a branded 404, 419, 429, 500, or 503 response
- **THEN** the page MUST expose the shared opening hook
- **AND** all error guidance and navigation MUST remain immediately usable without enhancement

View File

@@ -0,0 +1,43 @@
## MODIFIED Requirements
### Requirement: Testimonials are managed with publication control
The system SHALL allow admins to manage testimonials (SPEC WEB-04) with quote text (including multi-paragraph content), author name, optional context (event type and/or date), optional photo with alt text, sort order, featured flag, and `published_at`. Public rendering MUST preserve paragraph breaks from the stored quote, MUST omit blank/whitespace-only quotes before creating the rendered sequence, and MUST assign alternating `left`/`right` reveal directions according to the remaining rendered column order at desktop and mobile viewports. Testimonials sourced from real clients MUST NOT be published to production without authorization; development seeds MAY include the authorized-pending real quotes marked for review.
#### Scenario: Unpublished testimonial is excluded
- **WHEN** a testimonial has `published_at` null
- **THEN** the `published()` scope MUST exclude it
#### Scenario: Published testimonial is queryable
- **WHEN** an admin sets `published_at` with required quote and author name
- **THEN** the testimonial MUST be included in the `published()` scope
#### Scenario: Assistant cannot manage testimonials
- **WHEN** an assistant attempts to access the testimonials Resource
- **THEN** access MUST be denied with HTTP 403
#### Scenario: Featured testimonials are filterable
- **WHEN** content is queried with featured filter
- **THEN** records with `is_featured` true MUST be retrievable independently of sort order
#### Scenario: Multi-paragraph quotes render as paragraphs
- **GIVEN** a published testimonial whose quote contains blank-line separated paragraphs
- **WHEN** the home testimonials section is rendered
- **THEN** each paragraph MUST appear as distinct block text rather than a single collapsed line
#### Scenario: Blank quotes do not affect alternation
- **GIVEN** the published testimonial collection contains a blank quote between two non-blank quotes
- **WHEN** the home testimonials section is rendered
- **THEN** the blank testimonial MUST be omitted
- **AND** the two rendered testimonials MUST receive alternating `left` and `right` directions based on their rendered order
#### Scenario: Direction alternation remains on mobile
- **WHEN** testimonials are viewed at a mobile viewport
- **THEN** their explicit `left` and `right` directions MUST be preserved with the reduced mobile distance

View File

@@ -0,0 +1,26 @@
## MODIFIED Requirements
### Requirement: Public screens have desktop and mobile visual baselines
The system SHALL keep versioned screenshot baselines for the public screens available in this phase (SPEC §13.5): Home, Serviços, Portfólio, Detalhe do portfólio, Sobre, Contato, Privacidade and branded 404, at 1440×1000 desktop and 390×844 mobile, under the Heritage Editorial identity. A rendering change that alters those screens MUST fail the browser suite until the diff is reviewed and baselines are explicitly updated.
#### Scenario: Unintended visual change fails the suite
- **GIVEN** approved baselines exist
- **WHEN** a code change alters the rendering of a covered screen
- **THEN** the visual assertion MUST fail and report the diff
#### Scenario: Both viewports are covered
- **WHEN** the visual suite runs
- **THEN** each covered screen MUST be asserted at 1440×1000 and 390×844
#### Scenario: Heritage Editorial identity is captured
- **WHEN** approved baselines for the home are reviewed after this change
- **THEN** they MUST reflect EB Garamond typography, olive/paper palette and sharp-edged editorial layout rather than the previous gold/rounded placeholder look
#### Scenario: Motion does not destabilize new baselines
- **WHEN** Sobre, Contato, Privacidade, or 404 is captured
- **THEN** the browser MUST use the final reduced-motion state before asserting the screenshot

View File

@@ -0,0 +1,51 @@
## MODIFIED Requirements
### Requirement: Public routes have no critical or serious accessibility issues
The system SHALL run automated accessibility checks on the public routes covered by the browser suite (SPEC §6.5, §13.8) after the Heritage Editorial motion enhancement. A critical or serious issue MUST fail the suite.
#### Scenario: Critical issue blocks the suite
- **WHEN** the automated accessibility check reports a critical or serious issue on a covered route
- **THEN** the browser suite MUST fail and report the offending rule and selector
#### Scenario: Covered routes are checked
- **WHEN** the accessibility suite runs
- **THEN** home, services listing, portfolio listing, case detail, about, contact, privacy and branded 404 MUST each be checked
### Requirement: Reduced motion preference is honored
The system SHALL suppress non-essential animation and transition when the user agent reports `prefers-reduced-motion: reduce`, including page openings, editorial reveals, image hover scales, interaction feedback and menu transitions. All affected content MUST render in its final visible and interactive state.
#### Scenario: Reduced motion disables transitions
- **GIVEN** the browser reports `prefers-reduced-motion: reduce`
- **WHEN** a public page is loaded
- **THEN** decorative transitions and animations MUST NOT run
- **AND** every motion target MUST be in its final visible and interactive state
### Requirement: Public pages emit no console errors
Covered public routes SHALL load without JavaScript console errors in a real browser (SPEC §13.8, §19), including home, services listing, portfolio listing, case detail, about, contact, privacy, branded 404, and pages that load the mobile navigation or motion runtime.
#### Scenario: Console stays clean on covered routes
- **WHEN** a covered public route is loaded in the browser suite
- **THEN** the console MUST contain no error-level messages
## ADDED Requirements
### Requirement: Motion enhancement failure does not hide or disable content
The public site SHALL remain readable, keyboard-operable, and interactive when JavaScript is disabled or `IntersectionObserver` is unavailable. Motion targets MUST default to their final state without relying on timeout recovery.
#### Scenario: JavaScript disabled keeps public page usable
- **WHEN** a visitor loads a public page with JavaScript disabled
- **THEN** headings, copy, navigation, CTAs, and form controls MUST be visible and operable
#### Scenario: Observer unavailable keeps reveals final
- **WHEN** the motion runtime executes without `IntersectionObserver`
- **THEN** reveal targets MUST remain in the final state

View File

@@ -0,0 +1,24 @@
## 1. Motion contract and runtime
- [x] 1.1 Extend feature tests first for motion tokens, declarative hooks, no-opacity text, safe fallbacks, one-shot observer, capped stagger and requestAnimationFrame coalescing
- [x] 1.2 Add centralized motion tokens and CSS states for page openings, grouped directional reveals, media clips and accessible interaction feedback
- [x] 1.3 Refactor `resources/js/motion.js` to enhance only when supported, initialize page beats/groups, unobserve revealed targets and coalesce progress updates per frame
## 2. Public template coverage
- [x] 2.1 Add failing markup coverage for home, services, portfolio index/detail, about, contact, privacy and branded 404/419/429/500/503 pages
- [x] 2.2 Apply page-open, beat, reveal-group and directional reveal hooks across all visual public templates without changing content or layout structure
- [x] 2.3 Add failing testimonial coverage, filter blank quotes before the rendered sequence and alternate explicit left/right directions on desktop and mobile
## 3. Browser and accessibility coverage
- [x] 3.1 Add browser tests for real opening/reveal state, 90 ms capped stagger, one-shot observation and interaction during motion
- [x] 3.2 Add desktop/mobile tests for directional distance, reduced motion final state, no-JS/observer fallback, horizontal overflow and clean console
- [x] 3.3 Extend axe and console route matrices to about, contact, privacy and branded 404
- [x] 3.4 Extend deterministic desktop/mobile visual snapshots to about, contact, privacy and branded 404 and review the generated diffs
## 4. Verification and delivery
- [x] 4.1 Run focused feature/browser tests, build, Pint, isolated-cache PHPStan and strict OpenSpec validation
- [x] 4.2 Run `composer quality` and visually review desktop/mobile with normal and reduced motion
- [ ] 4.3 Stage only scoped files, commit conventionally, open a PR with screenshots and verification evidence, watch CI to green, merge and remove the worktree

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,11 +96,56 @@
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;
} }
.home-chapters {
counter-reset: home-chapter -1;
}
.home-chapter {
counter-increment: home-chapter;
}
.home-folio {
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--amare-color-accent);
font-size: var(--amare-text-xs);
font-weight: 600;
letter-spacing: 0.14em;
line-height: 1;
text-transform: uppercase;
}
.home-folio--inverse {
color: color-mix(in srgb, var(--amare-color-accent-text) 82%, transparent);
}
.home-folio__separator {
color: var(--amare-color-sage);
}
.home-folio--inverse .home-folio__separator {
color: color-mix(in srgb, var(--amare-color-accent-text) 55%, transparent);
}
.home-folio__number::before {
content: counter(home-chapter, decimal-leading-zero);
font-variant-numeric: tabular-nums;
}
@media (prefers-reduced-motion: no-preference) { @media (prefers-reduced-motion: no-preference) {
.main-nav { .main-nav {
transition: opacity var(--amare-duration-normal) var(--amare-ease-standard); transition: opacity var(--amare-duration-normal) var(--amare-ease-standard);
@@ -98,4 +155,166 @@
body.menu-open { body.menu-open {
overflow: hidden; overflow: hidden;
} }
main {
overflow-x: clip;
}
/* 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) {
:where(a, button, input, textarea, select) {
transition-duration: var(--amare-motion-feedback);
transition-property: color, background-color, border-color, transform;
transition-timing-function: var(--amare-ease-arrival);
}
:where(a, button:not(:disabled)):active {
transform: translateY(1px);
}
/* Transform/clip only — never fade text opacity (axe + WCAG mid-transition). */
html[data-motion="enhance"] [data-motion="page-open"]:not(.is-active) [data-motion-beat] {
transform: translateY(var(--amare-motion-distance-y));
}
html[data-motion="enhance"] [data-motion="page-open"]:not(.is-active) [data-motion-beat="media"] {
clip-path: inset(4% 0 0 0);
}
html[data-motion="enhance"] [data-motion="page-open"].is-active [data-motion-beat] {
transform: none;
clip-path: inset(0 0 0 0);
transition:
transform var(--amare-motion-enter) var(--amare-ease-arrival),
clip-path var(--amare-motion-enter) var(--amare-ease-arrival);
transition-delay: min(
calc(var(--motion-index, 0) * var(--amare-motion-stagger)),
var(--amare-motion-stagger-cap)
);
}
html[data-motion="enhance"] [data-reveal] {
--motion-translate-x: 0;
--motion-translate-y: var(--amare-motion-distance-y);
}
html[data-motion="enhance"] [data-reveal-from="left"] {
--motion-translate-x: calc(-1 * var(--amare-motion-distance-x-mobile));
--motion-translate-y: 0;
}
html[data-motion="enhance"] [data-reveal-from="right"] {
--motion-translate-x: var(--amare-motion-distance-x-mobile);
--motion-translate-y: 0;
}
@media (min-width: 768px) {
html[data-motion="enhance"] [data-reveal-from="left"] {
--motion-translate-x: calc(-1 * var(--amare-motion-distance-x));
}
html[data-motion="enhance"] [data-reveal-from="right"] {
--motion-translate-x: var(--amare-motion-distance-x);
}
}
html[data-motion="enhance"] [data-reveal]:not(.is-revealed) {
transform: translate3d(var(--motion-translate-x), var(--motion-translate-y), 0);
}
html[data-motion="enhance"] [data-reveal][data-reveal-media]:not(.is-revealed) {
clip-path: inset(3% 0 0 0);
}
html[data-motion="enhance"] [data-reveal-group] [data-reveal].is-revealed,
html[data-motion="enhance"] [data-reveal].is-revealed {
transform: none;
clip-path: inset(0 0 0 0);
transition:
transform var(--amare-motion-enter) var(--amare-ease-arrival),
clip-path var(--amare-motion-enter) var(--amare-ease-arrival);
transition-delay: min(
calc(var(--motion-index, 0) * var(--amare-motion-stagger)),
var(--amare-motion-stagger-cap)
);
}
}
} }

View File

@@ -56,7 +56,18 @@
--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);
/* Editorial motion */
--amare-motion-enter: 500ms;
--amare-motion-feedback: 150ms;
--amare-motion-stagger: 90ms;
--amare-motion-stagger-cap: 270ms;
--amare-motion-distance-y: 12px;
--amare-motion-distance-x: 24px;
--amare-motion-distance-x-mobile: 16px;
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
@@ -64,6 +75,14 @@
--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;
--amare-motion-enter: 0.01ms;
--amare-motion-feedback: 0.01ms;
--amare-motion-stagger: 0ms;
--amare-motion-stagger-cap: 0ms;
--amare-motion-distance-y: 0px;
--amare-motion-distance-x: 0px;
--amare-motion-distance-x-mobile: 0px;
} }
*, *,

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));
}); });
navigation.addEventListener('keydown', (event) => {
if (event.key === 'Escape') {
setOpen(false, { returnFocus: true });
return;
}
if (event.key === 'Tab' && !isDesktop() && isMenuOpen()) {
const focusables = navigation.querySelectorAll('a[href], button:not([disabled])');
if (focusables.length === 0) {
return;
}
const first = focusables[0];
const last = focusables[focusables.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
});
window.matchMedia('(min-width: 768px)').addEventListener('change', (event) => { window.matchMedia('(min-width: 768px)').addEventListener('change', (event) => {
if (event.matches) {
setOpen(false); setOpen(false);
if (event.matches) {
navigation.classList.remove('hidden'); navigation.classList.remove('hidden');
navigation.classList.add('flex'); navigation.classList.add('flex');
} else { }
navigation.classList.remove('is-open', 'flex'); });
navigation.classList.add('hidden'); }
document.body.classList.remove('menu-open');
menuButton.setAttribute('aria-expanded', 'false'); document.querySelectorAll('form[data-contact-form]').forEach((form) => {
menuButton.setAttribute('aria-label', 'Abrir menu'); 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();
} }
}); });
}); });
});

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

@@ -0,0 +1,187 @@
const MOTION_QUERY = '(prefers-reduced-motion: reduce)';
function prefersReducedMotion() {
return window.matchMedia(MOTION_QUERY).matches;
}
function enableEnhancement() {
if (prefersReducedMotion() || typeof IntersectionObserver !== 'function') {
document.documentElement.removeAttribute('data-motion');
return false;
}
document.documentElement.dataset.motion = 'enhance';
return true;
}
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 initializeMotionDelays() {
const beatOrder = new Map([
['seal', 0],
['heading', 0],
['title', 1],
['media', 2],
['cta', 3],
]);
document.querySelectorAll('[data-motion="page-open"]').forEach((page) => {
page.querySelectorAll('[data-motion-beat]').forEach((beat, index) => {
const namedIndex = beatOrder.get(beat.dataset.motionBeat);
beat.style.setProperty('--motion-index', String(Math.min(namedIndex ?? index, 3)));
});
});
document.querySelectorAll('[data-reveal-group]').forEach((group) => {
const nodes = Array.from(group.querySelectorAll('[data-reveal]'))
.filter((node) => node.closest('[data-reveal-group]') === group);
nodes.forEach((node, index) => {
node.style.setProperty('--motion-index', String(Math.min(index, 3)));
});
});
}
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^="#"]'));
let framePending = false;
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);
};
const scheduleSync = () => {
if (framePending) {
return;
}
framePending = true;
requestAnimationFrame(() => {
framePending = false;
sync();
});
};
setActive(chapters[0].getAttribute('aria-labelledby') || 'hero-heading');
sync();
window.addEventListener('scroll', scheduleSync, { passive: true });
window.addEventListener('resize', scheduleSync);
}
function bootMotion() {
initializeMotionDelays();
const enhance = enableEnhancement();
activatePageOpen(enhance);
observeReveals(enhance);
setupChapterIndex();
window.matchMedia(MOTION_QUERY).addEventListener('change', (event) => {
if (event.matches) {
document.documentElement.removeAttribute('data-motion');
document.querySelectorAll('[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

@@ -1,20 +1,28 @@
@props([ @props([
'settings', 'settings',
'editorial' => false,
]) ])
<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([
<div class="container-amare space-y-6 text-center"> 'border-t border-amare-border bg-amare-bg-deep py-20',
'home-chapter' => $editorial,
]) data-chapter="final-cta" data-reveal-group>
<div class="container-amare space-y-6 text-center" data-reveal data-reveal-from="up">
@if ($editorial)
<x-home.folio label="Próximo passo" class="justify-center" />
@else
<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> @endif
<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

@@ -0,0 +1,20 @@
@props([
'label',
'tone' => 'default',
])
@php
$tone = $tone === 'inverse' ? 'inverse' : 'default';
@endphp
<p
{{ $attributes->class([
'home-folio',
'home-folio--inverse' => $tone === 'inverse',
]) }}
data-home-folio
>
<span data-home-folio-label>{{ $label }}</span>
<span class="home-folio__separator" aria-hidden="true">·</span>
<span class="home-folio__number" data-home-folio-number aria-hidden="true"></span>
</p>

View File

@@ -2,36 +2,48 @@
'settings', 'settings',
]) ])
<section aria-labelledby="hero-heading" class="border-b border-amare-border bg-amare-bg"> <section
<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"> aria-labelledby="hero-heading"
class="home-chapter border-b border-amare-border bg-amare-bg"
data-chapter="hero"
data-motion="page-open"
>
<div class="container-amare pt-8 md:pt-10">
<x-home.folio label="Capa" data-motion-beat="folio" />
</div>
<div class="container-amare grid gap-12 pb-20 pt-10 md:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)] md:items-center md:pb-28 md:pt-14" data-reveal-group>
<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 +54,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,15 @@
$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="home-chapter 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-group>
<p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent">Manifesto</p> <div class="space-y-5 md:pr-8" data-reveal data-reveal-from="up">
<div class="space-y-6"> <x-home.folio label="Manifesto" />
<h2 id="manifesto-heading" class="max-w-3xl text-3xl font-medium leading-tight text-amare-text md:text-4xl">{{ $title }}</h2> <span class="block h-px w-16 bg-amare-border" aria-hidden="true"></span>
<p class="max-w-52 text-lg leading-snug text-amare-text">Humana no cuidado. Precisa na entrega.</p>
</div>
<div class="space-y-6" data-reveal data-reveal-from="up">
<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,24 @@
$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="home-chapter 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" data-reveal-group>
<div class="space-y-3"> <div class="space-y-4" data-reveal data-reveal-from="up">
<p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent">Método</p> <x-home.folio label="Método" />
<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>
<x-brand.logo
mark
variant="on-light"
class="mt-8 h-8 w-auto opacity-60"
alt=""
aria-hidden="true"
/>
</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 data-reveal-from="up">
<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="home-chapter 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" data-reveal-group>
<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 data-reveal-from="up">
<p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent-text/80">Portfólio</p> <x-home.folio label="Portfólio" tone="inverse" />
<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 data-reveal-from="up">
@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,17 @@
$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="home-chapter 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)]" data-reveal-group>
<div class="space-y-3"> <div class="space-y-4" data-reveal data-reveal-from="up">
<p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent">A Amare</p> <x-home.folio label="A Amare" />
<h2 id="positioning-heading" class="text-3xl font-medium text-amare-text">Presença que organiza o essencial.</h2> <div class="flex items-center gap-4">
<span class="h-px w-10 bg-amare-border" aria-hidden="true"></span>
<p class="text-sm uppercase tracking-[0.12em] text-amare-text-muted">Assessoria boutique · São Paulo</p>
</div> </div>
<div class="space-y-8"> <h2 id="positioning-heading" class="text-headline font-medium text-amare-text">Presença que organiza o essencial.</h2>
</div>
<div class="space-y-8" data-reveal data-reveal-from="up">
<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 +25,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,17 +3,22 @@
]) ])
@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="home-chapter border-b border-amare-border py-20" data-chapter="services">
<div class="container-amare space-y-10"> <div class="container-amare space-y-10" data-reveal-group>
<div class="max-w-2xl space-y-3"> <div class="grid gap-6 md:grid-cols-12 md:items-end" data-reveal data-reveal-from="up">
<p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent">Atuação</p> <div class="space-y-3 md:col-span-4">
<x-home.folio label="Serviços" />
<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> </div>
<div class="space-y-4 md:col-span-6 md:col-start-7">
<p class="max-w-xl text-amare-text-muted">Assessoria sob medida para decisões importantes e celebrações bem conduzidas.</p>
<p class="border-l border-amare-border pl-4 text-sm uppercase tracking-[0.12em] text-amare-accent">Do íntimo ao corporativo</p>
</div>
</div> </div>
<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 data-reveal-from="up">
<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 +27,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

@@ -2,11 +2,18 @@
'testimonials', 'testimonials',
]) ])
@php
$testimonials = $testimonials
->filter(fn ($testimonial): bool => filled(trim((string) $testimonial->quote)))
->values();
@endphp
@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="home-chapter 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" data-reveal-group>
<div class="max-w-2xl space-y-3"> <div class="max-w-2xl space-y-3" data-reveal data-reveal-from="up">
<h2 id="testimonials-heading" class="text-3xl font-semibold text-amare-text">Depoimentos</h2> <x-home.folio label="Depoimentos" />
<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>
@@ -16,13 +23,16 @@
$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 data-reveal-from="{{ $loop->odd ? 'left' : 'right' }}">
<div class="grid grid-cols-[1.5rem_minmax(0,1fr)] gap-2">
<span class="text-3xl leading-none text-amare-sage" aria-hidden="true"></span>
<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 $paragraph)
<p>@if ($index === 0)@endif{{ $paragraph }}@if ($index === count($paragraphs) - 1)@endif</p> <p>{{ $paragraph }}</p>
@endforeach @endforeach
</div> </div>
<footer class="text-sm text-amare-text-muted"> </div>
<footer class="pl-8 text-sm text-amare-text-muted">
<cite class="not-italic font-semibold text-amare-text">{{ $testimonial->author_name }}</cite> <cite class="not-italic font-semibold text-amare-text">{{ $testimonial->author_name }}</cite>
@if (filled($testimonial->context)) @if (filled($testimonial->context))
<span> {{ $testimonial->context }}</span> <span> {{ $testimonial->context }}</span>

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 }}">
@if (filled($pageMeta->canonical))
<link rel="canonical" href="{{ $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 }}">
@if (filled($pageMeta->canonical))
<meta property="og:url" content="{{ $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,12 +1,26 @@
@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')
<div class="container-amare max-w-2xl space-y-6 py-20 text-center"> <div class="container-amare max-w-2xl space-y-6 py-20 text-center" data-motion="page-open" data-reveal-group>
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Erro 404</p> <p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent" data-motion-beat="seal">Erro 404</p>
<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" data-motion-beat="title">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" data-reveal data-reveal-from="up">O endereço que você tentou abrir não existe ou foi movido.</p>
<p> <p data-reveal data-reveal-from="up">
<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" data-motion="page-open" data-reveal-group>
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent" data-motion-beat="seal">Erro 419</p>
<h1 class="text-4xl font-medium tracking-tight text-amare-text" data-motion-beat="title">Sessão expirada</h1>
<p class="text-amare-muted" data-reveal data-reveal-from="up">Sua sessão expirou. Volte e tente enviar novamente.</p>
<p data-reveal data-reveal-from="up">
<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" data-motion="page-open" data-reveal-group>
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent" data-motion-beat="seal">Erro 429</p>
<h1 class="text-4xl font-medium tracking-tight text-amare-text" data-motion-beat="title">Muitas solicitações</h1>
<p class="text-amare-muted" data-reveal data-reveal-from="up">Você enviou muitas solicitações em pouco tempo. Aguarde um instante e tente novamente.</p>
<p data-reveal data-reveal-from="up">
<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,12 +1,26 @@
@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')
<div class="container-amare max-w-2xl space-y-6 py-20 text-center"> <div class="container-amare max-w-2xl space-y-6 py-20 text-center" data-motion="page-open" data-reveal-group>
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Erro 500</p> <p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent" data-motion-beat="seal">Erro 500</p>
<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" data-motion-beat="title">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" data-reveal data-reveal-from="up">Não foi possível concluir o pedido agora. Tente novamente em instantes.</p>
<p> <p data-reveal data-reveal-from="up">
<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" data-motion="page-open" data-reveal-group>
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent" data-motion-beat="seal">Erro 503</p>
<h1 class="text-4xl font-medium tracking-tight text-amare-text" data-motion-beat="title">Em manutenção</h1>
<p class="text-amare-muted" data-reveal data-reveal-from="up">Estamos realizando uma breve manutenção. Tente novamente em instantes.</p>
<p data-reveal data-reveal-from="up">
<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,21 +8,33 @@
$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" data-motion-beat="heading">
<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" data-reveal-group>
@foreach ($principles as $index => $principle) @foreach ($principles as $index => $principle)
<li class="grid grid-cols-[3rem_minmax(0,1fr)] gap-4 border-b border-amare-border pb-4 text-amare-text"> <li class="grid grid-cols-[3rem_minmax(0,1fr)] gap-4 border-b border-amare-border pb-4 text-amare-text" data-reveal data-reveal-from="up">
<span class="text-sm font-semibold uppercase tracking-[0.14em] text-amare-accent">{{ str_pad((string) ($index + 1), 2, '0', STR_PAD_LEFT) }}</span> <span class="text-sm font-semibold uppercase tracking-[0.14em] text-amare-accent">{{ str_pad((string) ($index + 1), 2, '0', STR_PAD_LEFT) }}</span>
<span>{{ $principle }}</span> <span>{{ $principle }}</span>
</li> </li>
@@ -30,4 +42,6 @@
</ul> </ul>
</div> </div>
</div> </div>
<x-home.final-cta :settings="$siteSettings" />
@endsection @endsection

View File

@@ -1,34 +1,277 @@
@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" data-motion="page-open">
<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" data-motion-beat="heading">
<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>
<div class="space-y-4 border-t border-amare-border pt-6 text-amare-muted md:border-t-0 md:border-l md:pt-0 md:pl-10"> <div class="space-y-4 border-t border-amare-border pt-6 text-amare-muted md:border-t-0 md:border-l md:pt-0 md:pl-10" data-reveal-group>
<p>{{ $siteSettings->city ?: 'São Paulo - SP' }}</p> <p data-reveal data-reveal-from="up">{{ $siteSettings->city ?: 'São Paulo - SP' }}</p>
@if ($siteSettings->email) @if ($siteSettings->email)
<p> <p data-reveal data-reveal-from="up">
<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 data-reveal data-reveal-from="up">
<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 data-reveal data-reveal-from="up">
<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" data-reveal-group>
<h2 id="briefing-heading" class="text-2xl font-medium tracking-tight text-amare-text md:text-3xl" data-reveal data-reveal-from="up">Briefing de contato</h2>
<p class="mt-2 max-w-2xl text-amare-muted" data-reveal data-reveal-from="up">
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,37 @@
@extends('layouts.public') @extends('layouts.public')
@section('content') @section('content')
@php
$testimonials = $content->testimonials
->filter(fn ($testimonial): bool => filled(trim((string) $testimonial->quote)))
->values();
$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 ($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" />
<div class="home-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" />
@@ -8,5 +39,6 @@
<x-home.method :settings="$content->settings" /> <x-home.method :settings="$content->settings" />
<x-home.testimonials :testimonials="$content->testimonials" /> <x-home.testimonials :testimonials="$content->testimonials" />
<x-home.positioning :settings="$content->settings" /> <x-home.positioning :settings="$content->settings" />
<x-home.final-cta :settings="$content->settings" /> <x-home.final-cta :settings="$content->settings" editorial />
</div>
@endsection @endsection

View File

@@ -1,20 +1,20 @@
@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" data-motion-beat="heading">
<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" data-reveal-group>
@foreach ($cases as $case) @foreach ($cases as $case)
<article class="space-y-4"> <article class="space-y-4" data-reveal data-reveal-from="up">
@if (filled($case->cover_image_path)) @if (filled($case->cover_image_path))
<a href="{{ route('portfolio.show', $case->slug) }}" class="block overflow-hidden"> <a href="{{ route('portfolio.show', $case->slug) }}" class="block overflow-hidden">
<x-media.image <x-media.image
@@ -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" data-motion-beat="heading">
@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
@@ -22,23 +24,24 @@
loading="eager" loading="eager"
sizes="(max-width: 1024px) 100vw, 1120px" sizes="(max-width: 1024px) 100vw, 1120px"
class="img-editorial aspect-[16/9] w-full object-cover" class="img-editorial aspect-[16/9] w-full object-cover"
data-motion-beat="media"
/> />
@endif @endif
</div> </div>
</div> </div>
<div class="border-b border-amare-border bg-amare-bg-deep"> <div class="border-b border-amare-border bg-amare-bg-deep">
<div class="container-amare grid gap-10 py-16 md:grid-cols-3"> <div class="container-amare grid gap-10 py-16 md:grid-cols-3" data-reveal-group>
<section class="space-y-3"> <section class="space-y-3" data-reveal data-reveal-from="up">
<h2 class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Desafio</h2> <h2 class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Desafio</h2>
<p class="text-amare-muted">{!! nl2br(e($case->challenge)) !!}</p> <p class="text-amare-muted">{!! nl2br(e($case->challenge)) !!}</p>
</section> </section>
<section class="space-y-3"> <section class="space-y-3" data-reveal data-reveal-from="up">
<h2 class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Solução</h2> <h2 class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Solução</h2>
<p class="text-amare-muted">{!! nl2br(e($case->solution)) !!}</p> <p class="text-amare-muted">{!! nl2br(e($case->solution)) !!}</p>
</section> </section>
@if (filled($case->result)) @if (filled($case->result))
<section class="space-y-3"> <section class="space-y-3" data-reveal data-reveal-from="up">
<h2 class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Resultado</h2> <h2 class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Resultado</h2>
<p class="text-amare-muted">{!! nl2br(e($case->result)) !!}</p> <p class="text-amare-muted">{!! nl2br(e($case->result)) !!}</p>
</section> </section>
@@ -50,9 +53,9 @@
<section aria-labelledby="gallery-heading" class="bg-amare-bg"> <section aria-labelledby="gallery-heading" class="bg-amare-bg">
<div class="container-amare space-y-8 py-16"> <div class="container-amare space-y-8 py-16">
<h2 id="gallery-heading" class="text-3xl font-medium text-amare-text">Galeria</h2> <h2 id="gallery-heading" class="text-3xl font-medium text-amare-text">Galeria</h2>
<div class="grid gap-6 md:grid-cols-2"> <div class="grid gap-6 md:grid-cols-2" data-reveal-group>
@foreach ($case->images as $image) @foreach ($case->images as $image)
<figure class="space-y-2"> <figure class="space-y-2" data-reveal data-reveal-from="up" data-reveal-media>
<x-media.image <x-media.image
:path="$image->path" :path="$image->path"
:alt="$image->alt_text" :alt="$image->alt_text"
@@ -69,4 +72,6 @@
</section> </section>
@endif @endif
</article> </article>
<x-home.final-cta :settings="$siteSettings" />
@endsection @endsection

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