Compare commits

...

40 Commits

Author SHA1 Message Date
900b76ad19 merge: integrar main (MAN-139) na branch de modalidades
Some checks failed
CI / static (pull_request) Successful in 2m1s
CI / unit (pull_request) Successful in 4m1s
CI / feature (pull_request) Successful in 3m1s
CI / container (pull_request) Successful in 55s
CI / browser (pull_request) Failing after 2m13s
Resolve conflito em PackageContactLink: mantém forOrientation do main e a mensagem de orientação da Ata.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 23:43:16 -03:00
9ee9b51b0c feat: CTA de orientação das modalidades via WhatsApp
Some checks failed
CI / static (pull_request) Successful in 2m55s
CI / unit (pull_request) Successful in 3m27s
CI / feature (pull_request) Successful in 2m57s
CI / container (pull_request) Successful in 59s
CI / browser (pull_request) Failing after 2m11s
A banda "ainda não sabe" abre wa.me com mensagem de orientação; sem número, cai no briefing.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 23:39:29 -03:00
220143390e feat: alinhar copy das modalidades à Apresentação
Cards e páginas /pacotes passam a usar o texto conceitual oficial, sem preço, VIP ou limites comerciais.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 23:39:29 -03:00
fd3a4eb5f1 feat: CTA orientação WhatsApp para indecisos (MAN-127) (#7)
All checks were successful
CI / static (push) Successful in 2m33s
CI / unit (push) Successful in 3m39s
CI / feature (push) Successful in 2m49s
CI / container (push) Successful in 20s
CI / browser (push) Successful in 4m9s
## Summary

- CTA orientação (banda “Ainda não sabe?”) na home e em `/servicos` abre WhatsApp com mensagem distinta; sem número oficial, cai em `/briefing`.
- Glossário canônico em `SPEC.md` §8.0 (Vertente, Modalidade, Briefing, Contato parceiro, CTA contextual, CTA orientação). Sem `CONTEXT.md` paralelo.
- Linear: https://linear.app/maneco-workspace/issue/MAN-127/ajustar-ctas-consultivos-no-whatsapp-e-briefing-comercial

## Test plan

- [x] Feature: `HomePageContentTest` + `ServicesPageCtaTest` (17 passed)
- [ ] CI Gitea (static, unit, feature, browser, container)
- [ ] Conferir banda de modalidades com e sem `whatsapp_number` no Filament

Reviewed-on: #7
Co-authored-by: manoel freitas <manoel.josefneto@gmail.com>
Co-committed-by: manoel freitas <manoel.josefneto@gmail.com>
2026-08-14 02:35:06 +00:00
3207b7e568 docs: registrar Modalidade vs Service no modelo de domínio
O visitante vê modalidades de casamento; services permanece vertente. Preço e limites ficam fora do registro público.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 23:30:58 -03:00
dfd9763029 feat: jornada pública Casamentos × Corporate (MAN-126) (#5)
All checks were successful
CI / static (push) Successful in 2m40s
CI / unit (push) Successful in 3m41s
CI / feature (push) Successful in 2m56s
CI / container (push) Successful in 17s
CI / browser (push) Successful in 3m20s
Co-authored-by: manoel freitas <manoel.josefneto@gmail.com>
Co-committed-by: manoel freitas <manoel.josefneto@gmail.com>
2026-08-13 18:48:58 +00:00
93b680c172 docs: require Linear issues and auto-ship PRs (#4)
All checks were successful
CI / static (push) Successful in 2m25s
CI / unit (push) Successful in 3m34s
CI / feature (push) Successful in 3m17s
CI / container (push) Successful in 20s
CI / browser (push) Successful in 3m43s
## WHAT

- `AGENTS.md` e `docs/agents/issue-tracker.md`: todo trabalho e todo PR precisam de issue Linear.
- SDLC automático: trabalho feito → testes feitos ou atualizados → pre-commit verde → commit → PR, sem perguntar.
- Templates de PR idênticos no Gitea (`.gitea/PULL_REQUEST_TEMPLATE.md`) e no GitHub (`.github/pull_request_template.md`) com WHAT / WHY / HOW / Linear Issue / Comments.

## WHY

Agentes estavam soltos no tracker e paravam pra perguntar se podiam commitar. O contrato precisa ser o ciclo completo, com issue Linear e template de PR.

## HOW

Docs e templates only. Sem mudança de runtime. Pre-commit (`composer pint:check` + `composer phpstan`) passou.

## Linear Issue

- [MAN-132](https://linear.app/maneco-workspace/issue/MAN-132/exigir-issue-linear-em-todo-trabalho-e-pr)
- [MAN-133](https://linear.app/maneco-workspace/issue/MAN-133/adicionar-pr-template-no-gitea-e-no-github)

## Comments

GitHub é o mirror legado; o PR canônico é este no Gitea.

Reviewed-on: #4
Co-authored-by: manoel freitas <manoel.josefneto@gmail.com>
Co-committed-by: manoel freitas <manoel.josefneto@gmail.com>
2026-08-13 11:46:36 +00:00
ba388942c0 Merge pull request 'fix(about): alinhar layout /sobre ao ritmo editorial' (#3) from fix/about-layout-parity into main
All checks were successful
CI / static (push) Successful in 2m20s
CI / unit (push) Successful in 2m45s
CI / feature (push) Successful in 1m52s
CI / container (push) Successful in 15s
CI / browser (push) Successful in 2m42s
2026-08-12 21:23:28 +00:00
f4c2e4ed2f Merge branch 'main' into fix/about-layout-parity
All checks were successful
CI / static (pull_request) Successful in 2m4s
CI / unit (pull_request) Successful in 2m46s
CI / feature (pull_request) Successful in 3m31s
CI / container (pull_request) Successful in 57s
CI / browser (pull_request) Successful in 5m20s
2026-08-12 21:08:25 +00:00
e2af1f6a23 fix: CTAs de modalidades em /servicos via WhatsApp (#2)
All checks were successful
CI / static (push) Successful in 2m16s
CI / unit (push) Successful in 3m17s
CI / feature (push) Successful in 2m17s
CI / container (push) Successful in 13s
CI / browser (push) Successful in 4m25s
## Summary
- Remove `cta-route="contact"` dos cards de modalidade em `/servicos` para reutilizar `PackageContactLink` (WhatsApp / briefing), alinhado à home.
- Band "Conversar com a Amare" aponta para `/briefing` em vez de `/contato` (jornada de parceiros).
- Feature test de regressão em `ServicesPageCtaTest`.

## Test plan
- [x] pest ServicesPageCtaTest + home package CTA
- [ ] CI verde
- [ ] Com whatsapp_number no CMS: CTA do card em /servicos abre wa.me

Reviewed-on: #2
Co-authored-by: manoel freitas <manoel.josefneto@gmail.com>
Co-committed-by: manoel freitas <manoel.josefneto@gmail.com>
2026-08-12 21:01:41 +00:00
68460e061b fix(about): alinhar layout /sobre ao ritmo editorial do site
All checks were successful
CI / static (pull_request) Successful in 2m2s
CI / unit (pull_request) Successful in 3m13s
CI / feature (pull_request) Successful in 2m38s
CI / container (pull_request) Successful in 53s
CI / browser (pull_request) Successful in 4m50s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 18:01:39 -03:00
fc1c6177d6 feat: remodelar /sobre conforme mock editorial
All checks were successful
CI / static (push) Successful in 2m21s
CI / unit (push) Successful in 3m17s
CI / feature (push) Successful in 2m29s
CI / container (push) Successful in 18s
CI / browser (push) Successful in 3m8s
Co-authored-by: manoel freitas <manoel.josefneto@gmail.com>
Co-committed-by: manoel freitas <manoel.josefneto@gmail.com>
2026-08-12 20:34:13 +00:00
26a68e1823 fix: restaurar deploy-staging via workflow_run (Gitea ≥1.25)
All checks were successful
CI / static (push) Successful in 2m38s
CI / unit (push) Successful in 3m40s
CI / feature (push) Successful in 2m55s
CI / container (push) Successful in 1m11s
CI / browser (push) Successful in 4m22s
Remove job/reusable workflow do CI. Staging volta a ser workflow
separado após CI; 1.24 não implementava o trigger.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 16:01:56 -03:00
ac08b5f064 fix: staging deploy via needs no CI, não workflow_run
Some checks failed
CI / static (push) Successful in 2m35s
CI / unit (push) Successful in 3m36s
CI / feature (push) Successful in 2m31s
CI / container (push) Successful in 1m0s
CI / browser (push) Successful in 4m11s
CI / deploy-staging (push) Failing after 6m19s
workflow_run nunca disparou no Gitea 1.24 com CI multi-job.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 15:33:29 -03:00
91c0d412fa fix: nomes únicos para containers CI no docker.sock compartilhado
All checks were successful
CI / static (push) Successful in 2m14s
CI / unit (push) Successful in 3m50s
CI / feature (push) Successful in 2m48s
CI / container (push) Successful in 1m3s
CI / browser (push) Successful in 3m54s
Cancel/retry deixava amare-health órfão → Conflict no job container.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 15:21:38 -03:00
dd82ca2dca fix: paralelizar CI no act_runner (rede por job, sem :8000 no host)
Some checks failed
CI / static (push) Successful in 2m48s
CI / unit (push) Successful in 3m54s
CI / feature (push) Successful in 2m53s
CI / container (push) Failing after 55s
CI / browser (push) Successful in 4m18s
capacity>1 exige rede isolada por job e containers aninhados via DNS,
sem publish de host ports que colidem entre jobs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 15:12:07 -03:00
87a019109d fix: CI Postgres via hostname, sem bind :5432 no host
Some checks failed
CI / container (push) Waiting to run
CI / static (push) Successful in 1m24s
CI / unit (push) Failing after 1m31s
CI / feature (push) Failing after 1m14s
CI / browser (push) Failing after 2m10s
act_runner em VPS compartilhada falhava com port already
allocated; jobs usam service postgres na rede do job.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 15:02:24 -03:00
1468a23145 fix: remover actions/cache no Gitea Actions
Some checks failed
CI / static (push) Successful in 1m25s
CI / unit (push) Failing after 1s
CI / feature (push) Failing after 1s
CI / browser (push) Failing after 0s
CI / container (push) Failing after 2m38s
Job containers nao alcancam o cache do act_runner
(ETIMEDOUT ~5m). Tira cache steps e Buildx type=gha.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 14:57:12 -03:00
f730e766f5 fix: secrets de registry sem prefixo GITEA_
Some checks failed
CI / unit (push) Waiting to run
CI / static (push) Successful in 6m23s
CI / feature (push) Failing after 0s
CI / browser (push) Failing after 0s
CI / container (push) Has been cancelled
Gitea rejeita nomes de secret reservados GITEA_*; workflows
e runbook passam a REGISTRY_PAT / REGISTRY_USER.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 14:44:04 -03:00
1e215ac3d2 docs: alinhar remotes e registry para Gitea
Some checks failed
CI / unit (push) Has been cancelled
CI / feature (push) Has been cancelled
CI / browser (push) Has been cancelled
CI / container (push) Has been cancelled
CI / static (push) Has been cancelled
Origin e deploy passam a documentar git.hellomanoel.com;
GitHub/GHCR ficam como legado/backup.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 14:35:20 -03:00
716196dbe0 fix: owner path manoel-freitas/amare no registry Gitea
Conta renomeada admin→manoel-freitas; paths de imagem e docs
alinham com repo real no git.hellomanoel.com.
2026-08-12 14:21:24 -03:00
84f6d7c31a ci: migrate GitHub Actions para Gitea Actions
- Move .github/workflows/ para .gitea/workflows/ (desativa CI no GitHub)
- gitea.* contexts no lugar de github.*; GITHUB_TOKEN -> GITEA_PAT/GITEA_REGISTRY_USER
  (GITEA_TOKEN nao publica pacotes OCI, gitea#23642)
- Registry: ghcr.io -> git.hellomanoel.com (container registry da instancia)
- docs/deployment/dokploy.md: prereqs Gitea (Actions, runner, secrets, Dokploy registry)
2026-08-12 13:54:27 -03:00
08c6d62498 Merge pull request #60 from manoel-freitas/feat/package-detail-pages
Some checks failed
CI / static (push) Has been cancelled
CI / unit (push) Has been cancelled
CI / feature (push) Has been cancelled
CI / browser (push) Has been cancelled
CI / container (push) Has been cancelled
feat: páginas de detalhe das modalidades de assessoria
2026-08-12 09:26:22 -03:00
75cce50bd5 Merge origin/main into feat/package-detail-pages 2026-08-12 09:25:11 -03:00
a0963dd57a feat: páginas de detalhe das modalidades de assessoria 2026-08-12 09:18:57 -03:00
77dc592963 Merge pull request #59 from manoel-freitas/feat/services-page-preview
feat: Serviços — página conforme preview (Hero editorial + vertentes + modalidades + compare + corporate)
2026-08-12 09:00:04 -03:00
623e45cbea test: Serviços — cobertura da nova página e atualização da spec 2026-08-12 08:55:29 -03:00
455bc4b7d8 feat: Serviços — página conforme preview com componentes reutilizáveis 2026-08-12 08:55:24 -03:00
3f55180b73 feat: Modalidades — novos campos de preview (tag, subtitle, comparação) 2026-08-12 08:55:20 -03:00
ea630328c9 feat: remover canal de fornecedores e parcerias do briefing da home (#58) 2026-08-12 08:40:28 -03:00
1425d7aaa1 fix: alinhar testes browser ao contrato de CTA home (#57) 2026-08-11 23:19:47 -03:00
62a19147ad Merge pull request #56 from manoel-freitas/feature/daisyui
feat: adota daisyUI 5 com tema amare
2026-08-11 22:54:03 -03:00
90632ada88 Merge remote-tracking branch 'origin/main' into feature/daisyui
# Conflicts:
#	resources/views/components/home/hero.blade.php
2026-08-11 22:45:45 -03:00
ce90cedb18 Merge remote-tracking branch 'origin/main' into feature/daisyui
# Conflicts:
#	resources/views/components/home/final-cta.blade.php
#	resources/views/components/home/hero.blade.php
#	resources/views/layouts/public.blade.php
2026-08-11 22:45:04 -03:00
a7d72f3756 Merge pull request #54 from manoel-freitas/feat/home-photo-hero
feat(home): normalizar hero com componente photo-hero compartilhado
2026-08-11 22:44:31 -03:00
0e0724c1cc test: alinhar contratos de hero ao photo-hero pos-rebase 2026-08-11 22:43:50 -03:00
eca261ceba docs: remove TDD focus, emphasize DRY and YAGNI (#55) 2026-08-11 22:43:13 -03:00
ad29ce54d8 feat(home): normalizar hero com componente photo-hero compartilhado 2026-08-11 22:41:53 -03:00
0d1ca40c11 feat: adota daisyUI 5 com tema amare nos componentes públicos 2026-08-11 22:39:20 -03:00
484ac77ce7 feat: Home editorial conforme preview (header, footer, 8 seções) (#53)
* feat: modalidades de casamento e etapas corporativas

* feat: home editorial e header/footer conforme preview

* docs: proposta openspec home editorial

* test: CTA hero agora ancora #sobre conforme preview

* test: speed browser suite via visit reuse

Collapse route×viewport cartesian products, merge home motion
asserts, skip existing VisualContentSeeder JPEGs, ignore Screenshots.
2026-08-11 18:14:24 -03:00
128 changed files with 4298 additions and 897 deletions

View File

@@ -0,0 +1,19 @@
## WHAT
<!-- What changed? -->
## WHY
<!-- Why this change? -->
## HOW
<!-- How was it implemented? Include verification. -->
## Linear Issue
<!-- Required. Identifier + URL, e.g. MAN-133 https://linear.app/maneco-workspace/issue/MAN-133 -->
## Comments
<!-- Notes for reviewers. Screenshots, follow-ups, out of scope. -->

View File

@@ -6,7 +6,7 @@ on:
pull_request: pull_request:
concurrency: concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }} group: ci-${{ gitea.workflow }}-${{ gitea.ref }}
cancel-in-progress: true cancel-in-progress: true
env: env:
@@ -18,7 +18,9 @@ env:
BCRYPT_ROUNDS: 4 BCRYPT_ROUNDS: 4
CACHE_STORE: database CACHE_STORE: database
DB_CONNECTION: pgsql DB_CONNECTION: pgsql
DB_HOST: 127.0.0.1 # Service hostname on the per-job network (act_runner with empty
# container.network). Do not publish host :5432/:8000 — parallel jobs collide.
DB_HOST: postgres
DB_PORT: 5432 DB_PORT: 5432
DB_DATABASE: amare_test DB_DATABASE: amare_test
DB_USERNAME: amare DB_USERNAME: amare
@@ -40,12 +42,6 @@ jobs:
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd
coverage: none coverage: none
- uses: actions/cache@v5
with:
path: ~/.composer/cache/files
key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }}
restore-keys: composer-${{ runner.os }}-
- run: composer validate --strict - run: composer validate --strict
- run: composer install --no-interaction --prefer-dist - run: composer install --no-interaction --prefer-dist
- run: composer pint:check - run: composer pint:check
@@ -74,8 +70,6 @@ jobs:
POSTGRES_DB: amare_test POSTGRES_DB: amare_test
POSTGRES_USER: amare POSTGRES_USER: amare
POSTGRES_PASSWORD: secret POSTGRES_PASSWORD: secret
ports:
- 5432:5432
options: >- options: >-
--health-cmd "pg_isready -U amare -d amare_test" --health-cmd "pg_isready -U amare -d amare_test"
--health-interval 5s --health-interval 5s
@@ -91,18 +85,6 @@ 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: pcov coverage: pcov
- uses: actions/cache@v5
with:
path: ~/.composer/cache/files
key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }}
restore-keys: composer-${{ runner.os }}-
- uses: actions/cache@v5
with:
path: ~/.npm
key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
restore-keys: npm-${{ runner.os }}-
- run: composer install --no-interaction --prefer-dist - run: composer install --no-interaction --prefer-dist
- run: npm ci - run: npm ci
- run: npm run build - run: npm run build
@@ -126,8 +108,6 @@ jobs:
POSTGRES_DB: amare_test POSTGRES_DB: amare_test
POSTGRES_USER: amare POSTGRES_USER: amare
POSTGRES_PASSWORD: secret POSTGRES_PASSWORD: secret
ports:
- 5432:5432
options: >- options: >-
--health-cmd "pg_isready -U amare -d amare_test" --health-cmd "pg_isready -U amare -d amare_test"
--health-interval 5s --health-interval 5s
@@ -143,18 +123,6 @@ jobs:
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd
coverage: none coverage: none
- uses: actions/cache@v5
with:
path: ~/.composer/cache/files
key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }}
restore-keys: composer-${{ runner.os }}-
- uses: actions/cache@v5
with:
path: ~/.npm
key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
restore-keys: npm-${{ runner.os }}-
- run: composer install --no-interaction --prefer-dist - run: composer install --no-interaction --prefer-dist
- run: npm ci - run: npm ci
- run: npm run build - run: npm run build
@@ -171,8 +139,6 @@ jobs:
POSTGRES_DB: amare_test POSTGRES_DB: amare_test
POSTGRES_USER: amare POSTGRES_USER: amare
POSTGRES_PASSWORD: secret POSTGRES_PASSWORD: secret
ports:
- 5432:5432
options: >- options: >-
--health-cmd "pg_isready -U amare -d amare_test" --health-cmd "pg_isready -U amare -d amare_test"
--health-interval 5s --health-interval 5s
@@ -188,18 +154,6 @@ jobs:
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd
coverage: none coverage: none
- uses: actions/cache@v5
with:
path: ~/.composer/cache/files
key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }}
restore-keys: composer-${{ runner.os }}-
- uses: actions/cache@v5
with:
path: ~/.npm
key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
restore-keys: npm-${{ runner.os }}-
- run: composer install --no-interaction --prefer-dist - run: composer install --no-interaction --prefer-dist
- run: npm ci - run: npm ci
- run: npm run build - run: npm run build
@@ -212,15 +166,28 @@ jobs:
- name: Run browser tests against FrankenPHP container - name: Run browser tests against FrankenPHP container
run: | run: |
docker run -d --name amare-web \ # Join the per-job network (act_runner creates one when
# container.network is empty). No host -p: parallel jobs would
# collide on :8000/:5432; DNS names work on the job network.
# Container --name is global on the shared docker.sock host —
# include run id or leftovers from cancelled jobs Conflict.
JOB_CID="$(hostname)"
JOB_NET="$(docker inspect -f '{{range $k, $_ := .NetworkSettings.Networks}}{{println $k}}{{end}}' "$JOB_CID" | head -n1)"
test -n "$JOB_NET"
WEB_NAME="amare-web-${GITHUB_RUN_ID:-$$}"
docker rm -f "$WEB_NAME" 2>/dev/null || true
docker run -d --name "$WEB_NAME" \
--network "$JOB_NET" \
--network-alias amare-web \
-e APP_ENV=testing \ -e APP_ENV=testing \
-e APP_KEY="${APP_KEY}" \ -e APP_KEY="${APP_KEY}" \
-e APP_URL=http://127.0.0.1:8000 \ -e APP_URL=http://amare-web:8000 \
-e APP_LOCALE=pt_BR \ -e APP_LOCALE=pt_BR \
-e APP_FALLBACK_LOCALE=pt_BR \ -e APP_FALLBACK_LOCALE=pt_BR \
-e APP_TIMEZONE=America/Sao_Paulo \ -e APP_TIMEZONE=America/Sao_Paulo \
-e DB_CONNECTION=pgsql \ -e DB_CONNECTION=pgsql \
-e DB_HOST=host.docker.internal \ -e DB_HOST=postgres \
-e DB_PORT=5432 \ -e DB_PORT=5432 \
-e DB_DATABASE=amare_test \ -e DB_DATABASE=amare_test \
-e DB_USERNAME=amare \ -e DB_USERNAME=amare \
@@ -228,26 +195,28 @@ jobs:
-e SESSION_DRIVER=database \ -e SESSION_DRIVER=database \
-e CACHE_STORE=database \ -e CACHE_STORE=database \
-e QUEUE_CONNECTION=database \ -e QUEUE_CONNECTION=database \
--add-host=host.docker.internal:host-gateway \
-v "${GITHUB_WORKSPACE}/storage/app/public:/app/storage/app/public" \ -v "${GITHUB_WORKSPACE}/storage/app/public:/app/storage/app/public" \
-p 8000:8000 \
amare-app:ci amare-app:ci
cleanup() { docker rm -f "$WEB_NAME" >/dev/null 2>&1 || true; }
trap cleanup EXIT
for i in $(seq 1 30); do for i in $(seq 1 30); do
if curl -fsS http://127.0.0.1:8000/up; then if curl -fsS http://amare-web:8000/up; then
break break
fi fi
sleep 2 sleep 2
done done
curl -fsS http://127.0.0.1:8000/up curl -fsS http://amare-web:8000/up
./vendor/bin/pest --testsuite=Browser APP_URL=http://amare-web:8000 ./vendor/bin/pest --testsuite=Browser
- name: Collect failure diagnostics - name: Collect failure diagnostics
if: failure() if: failure()
run: | run: |
mkdir -p artifacts/browser mkdir -p artifacts/browser
docker logs amare-web > artifacts/browser/container.log 2>&1 || true WEB_NAME="amare-web-${GITHUB_RUN_ID:-$$}"
docker logs "$WEB_NAME" > artifacts/browser/container.log 2>&1 || true
cp -R storage/logs artifacts/browser/app-logs 2>/dev/null || true cp -R storage/logs artifacts/browser/app-logs 2>/dev/null || true
- name: Upload browser failure artifacts - name: Upload browser failure artifacts
@@ -269,10 +238,21 @@ jobs:
- name: Verify container healthcheck and storage link - name: Verify container healthcheck and storage link
run: | run: |
docker run -d --name amare-health \ # Same per-job network as the step container — no host :8000
# publish (collides when capacity > 1). Unique --name: docker.sock
# is shared across jobs; leftovers from cancelled runs Conflict.
JOB_CID="$(hostname)"
JOB_NET="$(docker inspect -f '{{range $k, $_ := .NetworkSettings.Networks}}{{println $k}}{{end}}' "$JOB_CID" | head -n1)"
test -n "$JOB_NET"
HEALTH_NAME="amare-health-${GITHUB_RUN_ID:-$$}"
docker rm -f "$HEALTH_NAME" 2>/dev/null || true
docker run -d --name "$HEALTH_NAME" \
--network "$JOB_NET" \
--network-alias amare-health \
-e APP_ENV=production \ -e APP_ENV=production \
-e APP_KEY="${{ env.APP_KEY }}" \ -e APP_KEY="${{ env.APP_KEY }}" \
-e APP_URL=http://127.0.0.1:8000 \ -e APP_URL=http://amare-health:8000 \
-e APP_DEBUG=false \ -e APP_DEBUG=false \
-e DB_CONNECTION=pgsql \ -e DB_CONNECTION=pgsql \
-e DB_HOST=127.0.0.1 \ -e DB_HOST=127.0.0.1 \
@@ -280,16 +260,18 @@ jobs:
-e DB_DATABASE=amare \ -e DB_DATABASE=amare \
-e DB_USERNAME=amare \ -e DB_USERNAME=amare \
-e DB_PASSWORD=secret \ -e DB_PASSWORD=secret \
-p 8000:8000 \
amare-app:ci amare-app:ci
cleanup() { docker rm -f "$HEALTH_NAME" >/dev/null 2>&1 || true; }
trap cleanup EXIT
for i in $(seq 1 30); do for i in $(seq 1 30); do
if curl -fsS http://127.0.0.1:8000/up; then if curl -fsS http://amare-health:8000/up; then
docker exec amare-health test -L /app/public/storage docker exec "$HEALTH_NAME" test -L /app/public/storage
exit 0 exit 0
fi fi
sleep 2 sleep 2
done done
docker logs amare-health docker logs "$HEALTH_NAME"
exit 1 exit 1

View File

@@ -1,10 +1,16 @@
name: Deploy staging name: Deploy staging
# Requires Gitea >= 1.25 for workflow_run (1.24.x has no trigger match).
on: on:
workflow_run: workflow_run:
workflows: [CI] workflows:
types: [completed] - CI
branches: [main] - ci.yml
types:
- completed
branches:
- main
workflow_dispatch:
permissions: permissions:
contents: read contents: read
@@ -15,27 +21,28 @@ concurrency:
cancel-in-progress: false cancel-in-progress: false
env: env:
REGISTRY: ghcr.io REGISTRY: git.hellomanoel.com
IMAGE_NAME: ${{ github.repository }} IMAGE_NAME: ${{ gitea.repository }}
jobs: jobs:
deploy: deploy:
name: publish-and-deploy-staging name: publish-and-deploy-staging
if: >- if: >-
github.event.workflow_run.conclusion == 'success' && gitea.event_name == 'workflow_dispatch' ||
github.event.workflow_run.event == 'push' && (gitea.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.head_branch == 'main' gitea.event.workflow_run.event == 'push' &&
gitea.event.workflow_run.head_branch == 'main')
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout deployed SHA - name: Checkout deployed SHA
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
ref: ${{ github.event.workflow_run.head_sha }} ref: ${{ gitea.event.workflow_run.head_sha || gitea.sha }}
- name: Set image metadata - name: Set image metadata
id: meta id: meta
run: | run: |
SHA="${{ github.event.workflow_run.head_sha }}" SHA="${{ gitea.event.workflow_run.head_sha || gitea.sha }}"
SHORT_SHA="${SHA:0:7}" SHORT_SHA="${SHA:0:7}"
IMAGE="${REGISTRY}/${IMAGE_NAME}" IMAGE="${REGISTRY}/${IMAGE_NAME}"
IMAGE="$(echo "$IMAGE" | tr '[:upper:]' '[:lower:]')" IMAGE="$(echo "$IMAGE" | tr '[:upper:]' '[:lower:]')"
@@ -43,12 +50,14 @@ jobs:
echo "short_sha=${SHORT_SHA}" >> "$GITHUB_OUTPUT" echo "short_sha=${SHORT_SHA}" >> "$GITHUB_OUTPUT"
echo "image=${IMAGE}" >> "$GITHUB_OUTPUT" echo "image=${IMAGE}" >> "$GITHUB_OUTPUT"
- name: Log in to GHCR - name: Log in to Gitea registry
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
registry: ${{ env.REGISTRY }} registry: ${{ env.REGISTRY }}
username: ${{ github.actor }} # Gitea's GITEA_TOKEN cannot push OCI packages (gitea#23642); a PAT
password: ${{ secrets.GITHUB_TOKEN }} # with read:package/write:package scopes is required instead.
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PAT }}
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
@@ -61,8 +70,6 @@ jobs:
tags: | tags: |
${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.sha }} ${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.sha }}
${{ steps.meta.outputs.image }}:staging ${{ steps.meta.outputs.image }}:staging
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Deploy staging on Dokploy - name: Deploy staging on Dokploy
env: env:

View File

@@ -4,7 +4,7 @@ on:
workflow_dispatch: workflow_dispatch:
inputs: inputs:
sha: sha:
description: Full git SHA already published to GHCR (same digest used by staging) description: Full git SHA already published to the Gitea registry (same digest used by staging)
required: true required: true
type: string type: string
confirm: confirm:
@@ -21,8 +21,8 @@ concurrency:
cancel-in-progress: false cancel-in-progress: false
env: env:
REGISTRY: ghcr.io REGISTRY: git.hellomanoel.com
IMAGE_NAME: ${{ github.repository }} IMAGE_NAME: ${{ gitea.repository }}
jobs: jobs:
promote: promote:
@@ -50,12 +50,14 @@ jobs:
echo "short_sha=${SHORT_SHA}" >> "$GITHUB_OUTPUT" echo "short_sha=${SHORT_SHA}" >> "$GITHUB_OUTPUT"
echo "image=${IMAGE}" >> "$GITHUB_OUTPUT" echo "image=${IMAGE}" >> "$GITHUB_OUTPUT"
- name: Log in to GHCR - name: Log in to Gitea registry
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
registry: ${{ env.REGISTRY }} registry: ${{ env.REGISTRY }}
username: ${{ github.actor }} # Gitea's GITEA_TOKEN cannot push OCI packages (gitea#23642); a PAT
password: ${{ secrets.GITHUB_TOKEN }} # with read:package/write:package scopes is required instead.
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PAT }}
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3

19
.github/pull_request_template.md vendored Normal file
View File

@@ -0,0 +1,19 @@
## WHAT
<!-- What changed? -->
## WHY
<!-- Why this change? -->
## HOW
<!-- How was it implemented? Include verification. -->
## Linear Issue
<!-- Required. Identifier + URL, e.g. MAN-133 https://linear.app/maneco-workspace/issue/MAN-133 -->
## Comments
<!-- Notes for reviewers. Screenshots, follow-ups, out of scope. -->

3
.gitignore vendored
View File

@@ -26,3 +26,6 @@ Homestead.json
Homestead.yaml Homestead.yaml
Thumbs.db Thumbs.db
.worktrees/ .worktrees/
# Pest Browser visual diffs (local/CI artifacts)
/tests/Browser/Screenshots/

View File

@@ -18,7 +18,19 @@ Feature and browser tests require the `amare_test` PostgreSQL database configure
## Worktrees ## 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. 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`. Include the Linear identifier in the branch name (e.g. `docs/man-132-…`). When the SDLC gate below is green, commit and open the PR without asking. Watch CI until green, then merge. Clean up the worktree with `git worktree remove` after merge.
## Agent SDLC (do not ask)
Never ask whether to commit or open a PR. After each complete slice of work, ship it:
1. **Work is done** in a Linear-linked worktree.
2. **Tests are written or updated** for the changed layer. Skip new tests only when the change has no runtime impact (docs, templates, static config).
3. **Pre-commit passes** (`composer pint:check` and `composer phpstan`). Do not `--no-verify`. If the hook fails, fix and rerun.
4. **Commit** with Conventional Commits. Cite the Linear identifier.
5. **Push and open the Gitea PR** (`tea pulls create`) using `.gitea/PULL_REQUEST_TEMPLATE.md` (WHAT / WHY / HOW / Linear Issue / Comments). Attach the PR URL on the Linear issue via `save_issue` `links`. GitHub is the legacy mirror only.
Do not wait for "pode commitar?" or "abre o PR?".
## Git Hooks (husky) ## Git Hooks (husky)
@@ -31,13 +43,17 @@ Hooks live in `.husky/` and auto-install on any plain `npm install` via the `pre
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.
## Design Principles: DRY & YAGNI
Write for the problem at hand, not an imagined future. **DRY**: extract and reuse a piece of logic as soon as it is genuinely duplicated in more than one place — but not before. **YAGNI**: do not add abstraction, configurability, or layers speculatively; add them only when a concrete requirement demands it. Prefer the simplest thing that solves the current requirement. Duplication that appears once is not yet a reason to abstract — wait for a second real occurrence before generalizing. This repo already encodes YAGNI in `openspec/config.yaml` (no generic repositories / `BaseService`); keep that spirit in new code. Avoid over-engineering and avoid premature extraction.
## Testing Guidelines ## Testing Guidelines
Tests use Pest 4; browser coverage uses Pest Browser/Playwright. Name files by behavior, ending in `Test.php`, and add tests in the suite matching the changed layer. Feature tests use `RefreshDatabase`. Add architecture coverage for dependency-boundary changes. No numeric coverage threshold is enforced, but changed behavior must have regression coverage. Tests use Pest 4; browser coverage uses Pest Browser/Playwright. Tests are verification, not a design driver — write them to cover behavior you've already implemented, matching the layer you changed. Name files by behavior, ending in `Test.php`, and add tests in the suite matching the changed layer. Feature tests use `RefreshDatabase`. Add architecture coverage for dependency-boundary changes. No numeric coverage threshold is enforced; add regression tests where a bug was fixed or behavior is non-obvious, without making tests a front-loaded design ceremony.
## Commit & Pull Request Guidelines ## Commit & Pull Request Guidelines
History follows Conventional Commit-style subjects, for example `feat: Fase 0 — Fundação`. Use `<type>: <imperative summary>` (`feat`, `fix`, `docs`, `test`, `chore`) and keep commits focused. Pull requests should explain scope, link the relevant issue or OpenSpec requirement, list verification commands, and include screenshots for UI changes. Ensure all CI jobs pass. History follows Conventional Commit-style subjects, for example `feat: Fase 0 — Fundação`. Use `<type>: <imperative summary>` (`feat`, `fix`, `docs`, `test`, `chore`) and keep commits focused. Fill the PR template (WHAT / WHY / HOW / Linear Issue / Comments). **Linear Issue is required** (identifier + URL). An OpenSpec change is extra context, not a substitute. Include verification commands and screenshots for UI changes. Ensure all CI jobs pass. After pre-commit is green, commit and open the PR — do not ask.
## Security & Configuration ## Security & Configuration
@@ -55,10 +71,12 @@ The design system lives in `DESIGN.md` (palette, typography, layout, do's and do
Issues live in Linear, driven through the Linear MCP tools. See `docs/agents/issue-tracker.md` for workspace, team, and tool conventions. The repo ships no `.mcp.json`, so the Linear MCP has to be enabled for the session before those tools exist — if it isn't, report that instead of silently falling back to another tracker. Issues live in Linear, driven through the Linear MCP tools. See `docs/agents/issue-tracker.md` for workspace, team, and tool conventions. The repo ships no `.mcp.json`, so the Linear MCP has to be enabled for the session before those tools exist — if it isn't, report that instead of silently falling back to another tracker.
**Every piece of work and every PR must be tied to a Linear issue.** Do not create a branch, worktree, or PR until an issue exists (identifier like `MAN-132`). If the user did not give one, search Linear first; if none fits, create it with `save_issue` on team `Maneco-workspace` before starting. Put the identifier in the branch name. Cite identifier + URL in the PR body. After opening the PR, attach the PR URL on the issue via `save_issue` `links`. Do not start untracked work.
### Triage labels ### Triage labels
Default vocabulary: `needs-triage`, `needs-info`, `ready-for-agent`, `ready-for-human`, `wontfix`. See `docs/agents/triage-labels.md`. Default vocabulary: `needs-triage`, `needs-info`, `ready-for-agent`, `ready-for-human`, `wontfix`. See `docs/agents/triage-labels.md`.
### Domain docs ### Domain docs
Single-context repo. There is no `CONTEXT.md` — the domain is documented in `SPEC.md` (§8 is the domain model and database schema) and `PRODUCT.md`, with current capabilities described per-capability under `openspec/specs/`. `docs/adr/README.md` is an index only: ADR-001 through ADR-010 are decided in `SPEC.md` §21, and there are no standalone ADR files. `docs/agents/domain.md` describes the generic `CONTEXT.md`/`CONTEXT-MAP.md` layout that the engineering skills look for and instructs them to proceed silently when it's absent, which is the case here. Single-context repo. There is no `CONTEXT.md` on purpose — **`SPEC.md` §8.0 is the ubiquitous-language SSOT** (domain model/schema continue in §8.1+). `PRODUCT.md` covers positioning; capabilities live under `openspec/specs/`. `docs/adr/README.md` is an index only: ADR-001 through ADR-010 are decided in `SPEC.md` §21. `docs/agents/domain.md` tells skills to use `SPEC.md` §8 instead of inventing a parallel `CONTEXT.md`.

View File

@@ -14,17 +14,22 @@ If the import above did not load, read `AGENTS.md` at the repo root now — it i
- Every project-owned PHP file starts with `declare(strict_types=1);` immediately after `<?php`. - Every project-owned PHP file starts with `declare(strict_types=1);` immediately after `<?php`.
- Browser tests are CI-only (they run against a FrankenPHP container built by `docker build`, not `artisan serve`). - Browser tests are CI-only (they run against a FrankenPHP container built by `docker build`, not `artisan serve`).
## Design principles: DRY & YAGNI (not TDD)
Write for the problem at hand, not an imagined future. **DRY**: extract and reuse only once logic is genuinely duplicated in more than one place. **YAGNI**: no speculative abstraction, configurability, or layers — add them only when a concrete requirement demands it. Prefer the simplest thing that solves the current requirement; avoid over-engineering and premature extraction. Tests are verification, not a design driver: write them to cover behavior already implemented, not as a front-loaded TDD ceremony.
## Environment note ## Environment note
PHP and Composer are **not on PATH** in this environment, and `vendor/` and `node_modules/` are absent. Every `composer …` / `php artisan …` command in `AGENTS.md` and `README.md` assumes a PHP 8.4+ runtime with Composer 2 installed. Verify the toolchain before promising a command ran. PHP and Composer are **not on PATH** in this environment, and `vendor/` and `node_modules/` are absent. Every `composer …` / `php artisan …` command in `AGENTS.md` and `README.md` assumes a PHP 8.4+ runtime with Composer 2 installed. Verify the toolchain before promising a command ran.
## Git remote auth — two GitHub accounts ## Git remotes — Gitea origin, GitHub legacy
`origin` is `git@github.com:manoel-freitas/amore-site.git`, owned by the **`manoel-freitas`** account. The machine's default SSH identity is a different account (`manoel-freitas-neto`) that cannot see this repo, so pushes fail with `ERROR: Repository not found.` — an access error that reads like a missing repo. `origin` is `git@git.hellomanoel.com:manoel-freitas/amare.git` (self-hosted Gitea). CI/CD and the container registry live there (`git.hellomanoel.com`). Verify SSH with `ssh -T git@git.hellomanoel.com` → should greet `Hi there, manoel-freitas!`.
- Correct key: `~/.ssh/id_github_pessoal`. Verify with `ssh -i ~/.ssh/id_github_pessoal -o IdentitiesOnly=yes -T git@github.com` → should greet `Hi manoel-freitas!`. The remote named `github` is the legacy mirror `git@github.com:manoel-freitas/amore-site.git`. Push there only when intentionally syncing the backup. That GitHub account still needs `~/.ssh/id_github_pessoal` (or an equivalent key) when the machine's default identity is a different GitHub user (`manoel-freitas-neto`) that cannot see the repo.
- The repo has `core.sshCommand = ssh -i ~/.ssh/id_github_pessoal -o IdentitiesOnly=yes` set locally, so plain `git push` works. If that config is lost, restore it instead of editing the remote URL.
- **`gh` authenticates separately**, by token rather than SSH key. As of 2026-08-10 it is logged in as `manoel-freitas`, so `gh pr create` / `gh repo view` work. Confirm with `gh auth status` before assuming: if it reports `manoel-freitas-neto`, that account cannot see this repo and every `gh` call fails on it. Recovering needs an interactive `gh auth login` (or `gh auth switch` with both accounts added), so ask the user to run it. - Prefer plain `git push` / `git push origin <branch>` against Gitea.
- **`gh`** talks to GitHub only. Use the Gitea web UI or API for PRs on `amare`. If you still need `gh` against the legacy remote, confirm `gh auth status` shows `manoel-freitas`.
## Request spine for the public site ## Request spine for the public site
@@ -62,7 +67,7 @@ Site-wide content is a singleton row reached via `SiteSetting::instance()`. Publ
- **Livewire/Filament temp uploads are pinned to the `local` disk** when `FILESYSTEM_DISK=r2`, because the S3 driver would make the browser PUT straight to R2 and hit CORS. Final media still lands on `r2` via `App\Support\PublicImageUploadRules`. Set `LIVEWIRE_TEMPORARY_FILE_UPLOAD_DISK` explicitly to override. - **Livewire/Filament temp uploads are pinned to the `local` disk** when `FILESYSTEM_DISK=r2`, because the S3 driver would make the browser PUT straight to R2 and hit CORS. Final media still lands on `r2` via `App\Support\PublicImageUploadRules`. Set `LIVEWIRE_TEMPORARY_FILE_UPLOAD_DISK` explicitly to override.
- **Contact form is rate limited**: named limiter `contact-briefing`, 5/min per IP, registered in `AppServiceProvider` and applied in `routes/web.php`. - **Contact form is rate limited**: named limiter `contact-briefing`, 5/min per IP, registered in `AppServiceProvider` and applied in `routes/web.php`.
- **Filament 5 nested resource layout**: resources are split into `app/Filament/Resources/<Resource>/{Pages,Schemas,Tables,RelationManagers}` rather than a flat resource class. Follow the existing shape in `Resources/PortfolioCases/`. - **Filament 5 nested resource layout**: resources are split into `app/Filament/Resources/<Resource>/{Pages,Schemas,Tables,RelationManagers}` rather than a flat resource class. Follow the existing shape in `Resources/PortfolioCases/`.
- **Everything user-facing is pt-BR**: routes are `/servicos`, `/portfolio`, `/portfolio/{slug}`, `/sobre`, `/privacidade`, `/contato`. `APP_LOCALE=pt_BR`, `APP_TIMEZONE=America/Sao_Paulo` (`config/app.php:68`). - **Everything user-facing is pt-BR**: routes are `/servicos`, `/pacotes/{slug}`, `/portfolio`, `/portfolio/{slug}`, `/sobre`, `/briefing`, `/contato`, `/privacidade`. `APP_LOCALE=pt_BR`, `APP_TIMEZONE=America/Sao_Paulo` (`config/app.php:68`). Glossário canônico: `SPEC.md` §8.0.
- **Design tokens** live in `resources/css/tokens.css` (Heritage Editorial; see `DESIGN.md`). `tests/Feature/PublicSite/HeritageEditorialTokensTest.php` reads that file and asserts the exact hex values, `EB Garamond`, zero border radii, `--amare-container-max: 1120px`, and the *absence* of shadow tokens — so any token edit is a deliberate test change too. Motion lives in `resources/js/motion.js` and is asserted by `tests/Feature/PublicSite/MotionMarkupTest.php` + `tests/Browser/MotionTest.php`. - **Design tokens** live in `resources/css/tokens.css` (Heritage Editorial; see `DESIGN.md`). `tests/Feature/PublicSite/HeritageEditorialTokensTest.php` reads that file and asserts the exact hex values, `EB Garamond`, zero border radii, `--amare-container-max: 1120px`, and the *absence* of shadow tokens — so any token edit is a deliberate test change too. Motion lives in `resources/js/motion.js` and is asserted by `tests/Feature/PublicSite/MotionMarkupTest.php` + `tests/Browser/MotionTest.php`.
## Navigating the normative docs ## Navigating the normative docs

View File

@@ -37,7 +37,7 @@ No lançamento, o site público premium e o CMS interno formam a superfície ent
## Operating Context ## Operating Context
- Atuação em São Paulo, capital. - Atuação em São Paulo, capital.
- Serviços: casamentos, eventos corporativos e celebrações. - Serviços: casamentos, eventos corporativos e celebrações. No site, casamento aparece como três **modalidades** (Essenza, Conduzione, Grand Jour); preço e limites comerciais ficam na proposta, não na página.
- Operação realizada por assessora principal e equipe pequena. - Operação realizada por assessora principal e equipe pequena.
- Jornada de lançamento: descoberta no site, briefing por e-mail e contato comercial fora da plataforma. - Jornada de lançamento: descoberta no site, briefing por e-mail e contato comercial fora da plataforma.
- Jornada futura prevista: qualificação do lead, pipeline comercial, conversão em evento, checklist, fornecedores, orçamento, pagamentos manuais e documentos. - Jornada futura prevista: qualificação do lead, pipeline comercial, conversão em evento, checklist, fornecedores, orçamento, pagamentos manuais e documentos.

View File

@@ -138,10 +138,10 @@ Após `php artisan db:seed`:
- [docs/adr/](docs/adr/) — ADRs aceitas - [docs/adr/](docs/adr/) — ADRs aceitas
- [docs/conventions/php-strict-types.md](docs/conventions/php-strict-types.md) — convenção de strict types - [docs/conventions/php-strict-types.md](docs/conventions/php-strict-types.md) — convenção de strict types
- [docs/operations/atualizacao-de-conteudo.md](docs/operations/atualizacao-de-conteudo.md) — runbook de atualização de conteúdo do site pelo painel admin - [docs/operations/atualizacao-de-conteudo.md](docs/operations/atualizacao-de-conteudo.md) — runbook de atualização de conteúdo do site pelo painel admin
- [docs/deployment/dokploy.md](docs/deployment/dokploy.md) — deploy staging/produção no Dokploy + GHCR - [docs/deployment/dokploy.md](docs/deployment/dokploy.md) — deploy staging/produção no Dokploy + registry Gitea
## Deploy (Dokploy) ## Deploy (Dokploy)
Staging publica automaticamente após CI verde em `main` (imagem GHCR por SHA + alias `:staging`). Produção promove a **mesma digest** com workflow manual `Promote production` (sem rebuild). Staging publica automaticamente após CI verde em `main` (imagem no registry Gitea por SHA + alias `:staging`). Produção promove a **mesma digest** com workflow manual `Promote production` (sem rebuild).
Ver runbook completo: [docs/deployment/dokploy.md](docs/deployment/dokploy.md). Ver runbook completo: [docs/deployment/dokploy.md](docs/deployment/dokploy.md).

73
SPEC.md
View File

@@ -283,11 +283,15 @@ Uma funcionalidade fora do MVP só poderá entrar quando:
| Método | Rota | Nome sugerido | Finalidade | | Método | Rota | Nome sugerido | Finalidade |
|---|---|---|---| |---|---|---|---|
| GET | `/` | `home` | Home editorial | | GET | `/` | `home` | Home editorial |
| GET | `/servicos` | `services.index` | Lista de serviços publicados | | GET | `/servicos` | `services.index` | Lista de serviços / modalidades publicadas |
| GET | `/pacotes/{slug}` | `packages.show` | Detalhe de uma modalidade de casamento |
| GET | `/portfolio` | `portfolio.index` | Lista de casos publicados | | GET | `/portfolio` | `portfolio.index` | Lista de casos publicados |
| GET | `/portfolio/{slug}` | `portfolio.show` | Detalhe de caso | | GET | `/portfolio/{slug}` | `portfolio.show` | Detalhe de caso |
| GET | `/sobre` | `about` | História, método e credenciais | | GET | `/sobre` | `about` | História, método e credenciais |
| GET | `/contato` | `contact` | Briefing de contato | | GET | `/briefing` | `briefing` | Briefing comercial (pedido de proposta) |
| POST | `/briefing` | `briefing.store` | Envio do briefing comercial |
| GET | `/contato` | `contact` | Contato de parceiro / fornecedor (não é briefing) |
| POST | `/contato` | `contact.store` | Envio da consulta de parceiro |
| GET | `/privacidade` | `privacy` | Política de privacidade | | GET | `/privacidade` | `privacy` | Política de privacidade |
| GET | `/sitemap.xml` | `sitemap` | Sitemap público | | GET | `/sitemap.xml` | `sitemap` | Sitemap público |
| GET | `/robots.txt` | `robots` | Política de crawling | | GET | `/robots.txt` | `robots` | Política de crawling |
@@ -324,7 +328,8 @@ Administração
- Dashboard: `Filament Page` customizada com widgets orientados a exceção. - Dashboard: `Filament Page` customizada com widgets orientados a exceção.
- Detalhe do evento: página customizada do Resource com resumo operacional. - Detalhe do evento: página customizada do Resource com resumo operacional.
- Briefing público: Blade + Controller (`POST /contato`), ver §11.2. - Briefing comercial: Blade + Controller (`POST /briefing`), ver §11.2.
- Contato de parceiro: Blade + Controller (`POST /contato`) — canal separado do briefing.
- Home: Blade com componentes de design reutilizáveis. - Home: Blade com componentes de design reutilizáveis.
### 5.4 Decisões de UX YAGNI ### 5.4 Decisões de UX YAGNI
@@ -1243,6 +1248,35 @@ Auditoria não precisa ser um event sourcing. Registrar apenas operações crít
## 8. Modelo de domínio e banco de dados ## 8. Modelo de domínio e banco de dados
Esta seção é a **fonte única de verdade** do vocabulário de domínio do produto. Issues Linear, OpenSpec, código e copy devem usar estes termos. Não criar glossário paralelo em `CONTEXT.md`.
### 8.0 Linguagem ubíqua (glossário)
**Vertente**:
Frente de marca da Amare — **Casamentos** ou **Corporate**.
_Avoid_: linha de negócio, vertical de produto (em copy pública), “área”
**Modalidade**:
Pacote/formato de acompanhamento de casamento gerenciado no CMS (`WeddingPackage`: Essenza, Conduzione, Grand Jour).
_Avoid_: pacote (em copy pública preferir “modalidade”), serviço genérico, plano
**Briefing**:
Pedido comercial de um potencial cliente descrevendo o evento e solicitando atendimento/proposta. Rota canônica: `/briefing`.
_Avoid_: contato (quando o sentido é pedido comercial), formulário de parceiro
**Contato parceiro**:
Consulta de fornecedor ou proposta de parceria. Rota canônica: `/contato`. Não mistura com Briefing.
_Avoid_: briefing, lead comercial
**CTA contextual**:
Link de conversão de uma Modalidade escolhida — WhatsApp com mensagem que nomeia a modalidade, ou fallback para `/briefing?servico_interesse=` quando não há número oficial válido.
**CTA orientação**:
Link de conversão para visitante que ainda não escolheu Modalidade — WhatsApp com mensagem de orientação humana (sem quiz), ou fallback para `/briefing` quando não há número oficial válido.
**Boutique**:
Conceito de posicionamento (cuidado, especialização, múltiplos serviços sob a mesma marca) — não é entidade de domínio nem rota.
### 8.1 Módulos ### 8.1 Módulos
| Módulo | Responsabilidade | | Módulo | Responsabilidade |
@@ -1254,6 +1288,12 @@ Auditoria não precisa ser um event sourcing. Registrar apenas operações crít
| Finance | Orçamento e pagamentos manuais | | Finance | Orçamento e pagamentos manuais |
| Identity | Usuários, acesso e auditoria | | Identity | Usuários, acesso e auditoria |
Vocabulário público vs persistência (Marketing):
- **Modalidade** é o nome público de cada oferta de acompanhamento de casamento. O visitante vê o nome próprio (Essenza, Conduzione, Grand Jour) e a tag de momento (Completa, Parcial, Final). Persistida em `wedding_packages`.
- **Vertente** é o contexto da marca (Casamentos vs Corporate). A tabela `services` descreve vertentes do catálogo, não as três modalidades.
- **Pacote**, no sentido de SKU com preço e limites operacionais, não é superfície pública. Preço, equipe no dia, teto de fornecedores, quilometragem e ferramentas de terceiros (incluindo gestão de convidados) ficam na proposta, não no registro público da modalidade.
### 8.2 Tabelas obrigatórias ### 8.2 Tabelas obrigatórias
#### `users` #### `users`
@@ -1314,6 +1354,27 @@ Singleton:
- timestamps; - timestamps;
- soft delete opcional somente se recuperação for necessária. - soft delete opcional somente se recuperação for necessária.
#### `wedding_packages`
Modalidades públicas de casamento (não confundir com `services`):
- `id`;
- `name`;
- `slug` unique;
- `level` (rótulo de momento, ex. `01 / COMPLETA`);
- `tag` nullable (ex. Assessoria completa);
- `subtitle` nullable;
- `summary`;
- `scope_items` jsonb (bullets de conceito nos cards);
- `cta_label`;
- `compare_heading` nullable;
- `compare_summary` nullable;
- campos editoriais da página `/pacotes/{slug}` (eyebrow, título, hero, benefícios, incluso em alto nível, audiência, CTA final, meta) — todos opcionais salvo o que a publicação exigir;
- `sort_order` integer indexed;
- `published_at` timestamp nullable indexed;
- timestamps;
- **sem** colunas de preço, centavos, equipe, limites de fornecedores/convidados/km ou ferramentas de terceiros.
#### `portfolio_cases` #### `portfolio_cases`
- `id`; - `id`;
@@ -1795,7 +1856,7 @@ Não transformar seções estáticas em componentes Livewire. Usar Blade quando
### 11.2 Formulário de briefing ### 11.2 Formulário de briefing
> **Estado atual:** o formulário é implementado em Blade + Controller (`POST /contato`, `ContactBriefingRequest`), conforme WEB-05, e essa é a abordagem aceita — não um estágio provisório. Os requisitos abaixo valem independentemente da tecnologia; a criação de Lead segue para a Fase 2. > **Estado atual:** o formulário de **Briefing** é Blade + Controller (`GET/POST /briefing`, `ContactBriefingRequest`), conforme WEB-05. A rota `/contato` é o canal de **Contato parceiro** (consulta não persistente), separado do briefing. A criação de Lead comercial persistente segue para a Fase 2.
O formulário deve: O formulário deve:
@@ -2073,7 +2134,7 @@ quality → Pint check + PHPStan/Larastan + audits + testes
### 14.3 Branches e ambientes ### 14.3 Branches e ambientes
- PR: testes e preview opcional; - PR: testes e preview opcional;
- `main`: build imutável por SHA publicado no GHCR e deploy automático em staging via Dokploy; - `main`: build imutável por SHA publicado no registry Gitea (`git.hellomanoel.com`) e deploy automático em staging via Dokploy;
- staging: Dokploy Compose executa migração, healthcheck `/up` e smoke pós-deploy (`/up`, `/`, `/admin/login`); - staging: Dokploy Compose executa migração, healthcheck `/up` e smoke pós-deploy (`/up`, `/`, `/admin/login`);
- produção: promoção da mesma imagem aprovada, sem rebuild (retag do digest em `:production`); - produção: promoção da mesma imagem aprovada, sem rebuild (retag do digest em `:production`);
- produção requer aprovação humana explícita no MVP (`workflow_dispatch` com confirmação); - produção requer aprovação humana explícita no MVP (`workflow_dispatch` com confirmação);
@@ -2487,7 +2548,7 @@ Toda operação financeira deve:
| ADR-011 | Cloudflare R2 (S3-compatible) como storage de objetos em produção | 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-012 | E-mail transacional via Resend (mailer nativo Laravel) | Aceita |
| ADR-013 | Design system Heritage Editorial para o site público | 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 | | ADR-014 | Deploy via Dokploy Compose com imagem imutável por SHA no registry Gitea | Aceita |
| ADR-015 | Site público permanece Blade + JS vanilla; Livewire e Alpine ficam restritos ao Filament até o gatilho de §22. Emenda o texto da ADR-002 | Aceita | | ADR-015 | Site público permanece Blade + JS vanilla; Livewire e Alpine ficam restritos ao Filament até o gatilho de §22. Emenda o texto da ADR-002 | Aceita |
| ADR-016 | Lançamento de 31/08/2026 entrega apenas o site institucional (Fases 01); Fases 25 seguem especificadas e adiadas, sem data | Aceita | | ADR-016 | Lançamento de 31/08/2026 entrega apenas o site institucional (Fases 01); Fases 25 seguem especificadas e adiadas, sem data | Aceita |

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Application\Data;
use App\Models\PortfolioCase;
use App\Models\SiteSetting;
use Illuminate\Database\Eloquent\Collection;
final readonly class AboutContent
{
/**
* @param Collection<int, PortfolioCase> $featuredCases
*/
public function __construct(
public SiteSetting $settings,
public Collection $featuredCases,
) {}
}

View File

@@ -17,13 +17,13 @@ final readonly class HomeContent
* @param Collection<int, Service> $featuredServices * @param Collection<int, Service> $featuredServices
* @param Collection<int, PortfolioCase> $featuredCases * @param Collection<int, PortfolioCase> $featuredCases
* @param Collection<int, Testimonial> $testimonials * @param Collection<int, Testimonial> $testimonials
* @param Collection<int, WeddingPackage> $weddingPackages * @param Collection<int, WeddingPackage> $packages
*/ */
public function __construct( public function __construct(
public SiteSetting $settings, public SiteSetting $settings,
public Collection $featuredServices, public Collection $featuredServices,
public Collection $featuredCases, public Collection $featuredCases,
public Collection $testimonials, public Collection $testimonials,
public Collection $weddingPackages, public Collection $packages,
) {} ) {}
} }

View File

@@ -6,6 +6,7 @@ namespace App\Application\Data;
use App\Models\PortfolioCase; use App\Models\PortfolioCase;
use App\Models\SiteSetting; use App\Models\SiteSetting;
use App\Models\WeddingPackage;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
final readonly class PageMeta final readonly class PageMeta
@@ -84,6 +85,43 @@ final readonly class PageMeta
); );
} }
/**
* @param array<string, mixed>|null $jsonLd
*/
public static function forPackage(
WeddingPackage $package,
string $canonical,
SiteSetting $settings,
?array $jsonLd = null,
): self {
$title = filled($package->meta_title)
? (string) $package->meta_title
: (filled($package->title_line) ? (string) $package->title_line : (string) $package->name);
$description = filled($package->meta_description)
? (string) $package->meta_description
: (filled($package->summary) ? (string) $package->summary : self::defaultDescription($settings));
$ogImageUrl = filled($package->hero_image_path)
? url(Storage::disk('public')->url((string) $package->hero_image_path))
: self::defaultOgImageUrl($settings);
$ogImageAlt = filled($package->hero_image_alt)
? (string) $package->hero_image_alt
: $settings->default_og_image_alt;
return new self(
title: $title,
description: $description,
canonical: $canonical,
ogType: 'article',
ogImageUrl: $ogImageUrl,
ogImageAlt: $ogImageAlt,
jsonLd: $jsonLd,
siteName: (string) $settings->brand_name,
);
}
/** /**
* Build the metadata for branded error pages. * Build the metadata for branded error pages.
* *

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Application\Queries\Marketing;
use App\Models\WeddingPackage;
final class FindPublishedWeddingPackageBySlug
{
public function __invoke(string $slug): ?WeddingPackage
{
return WeddingPackage::query()
->published()
->where('slug', $slug)
->first();
}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Application\Queries\Marketing;
use App\Application\Data\AboutContent;
use App\Models\PortfolioCase;
use App\Models\SiteSetting;
final class GetAboutContent
{
public function __invoke(): AboutContent
{
return new AboutContent(
settings: SiteSetting::instance(),
featuredCases: PortfolioCase::query()
->published()
->where('is_featured', true)
->orderBy('sort_order')
->limit(6)
->get(),
);
}
}

View File

@@ -32,7 +32,7 @@ final class GetHomeContent
->published() ->published()
->orderBy('sort_order') ->orderBy('sort_order')
->get(), ->get(),
weddingPackages: WeddingPackage::query() packages: WeddingPackage::query()
->published() ->published()
->orderBy('sort_order') ->orderBy('sort_order')
->get(), ->get(),

View File

@@ -4,7 +4,10 @@ declare(strict_types=1);
namespace App\Application\Queries\Marketing; namespace App\Application\Queries\Marketing;
use App\Domain\Marketing\PortfolioVertical;
use App\Models\PortfolioCase; use App\Models\PortfolioCase;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
final class GetPublishedPortfolioCases final class GetPublishedPortfolioCases
@@ -12,12 +15,37 @@ final class GetPublishedPortfolioCases
/** /**
* @return Collection<int, PortfolioCase> * @return Collection<int, PortfolioCase>
*/ */
public function __invoke(): Collection public function __invoke(?PortfolioVertical $vertical = null): Collection
{ {
return PortfolioCase::query() return $this->baseQuery($vertical)->get();
}
/**
* @return LengthAwarePaginator<int, PortfolioCase>
*/
public function paginate(?PortfolioVertical $vertical = null, int $perPage = 9): LengthAwarePaginator
{
return $this->baseQuery($vertical)->paginate($perPage)->withQueryString();
}
/**
* @return Builder<PortfolioCase>
*/
private function baseQuery(?PortfolioVertical $vertical): Builder
{
$query = PortfolioCase::query()
->published() ->published()
->with(['images']) ->with(['images'])
->orderBy('sort_order') ->orderBy('sort_order');
->get();
if ($vertical !== null) {
$query->where(function (Builder $builder) use ($vertical): void {
foreach ($vertical->eventTypePatterns() as $pattern) {
$builder->orWhere('event_type', 'ilike', $pattern);
}
});
}
return $query;
} }
} }

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Application\Queries\Marketing; namespace App\Application\Queries\Marketing;
use App\Models\PortfolioCase; use App\Models\PortfolioCase;
use App\Models\WeddingPackage;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
final class GetSitemapEntries final class GetSitemapEntries
@@ -38,6 +39,21 @@ final class GetSitemapEntries
]; ];
} }
$packages = WeddingPackage::query()
->published()
->orderBy('sort_order')
->get(['slug', 'updated_at']);
foreach ($packages as $package) {
/** @var Carbon|null $updatedAt */
$updatedAt = $package->updated_at;
$entries[] = [
'loc' => route('packages.show', $package->slug),
'lastmod' => $updatedAt?->toAtomString(),
];
}
return $entries; return $entries;
} }
} }

View File

@@ -63,6 +63,9 @@ final class MediaGenerateVariantsCommand extends Command
if ($settings && filled($settings->about_image_path)) { if ($settings && filled($settings->about_image_path)) {
$paths[] = (string) $settings->about_image_path; $paths[] = (string) $settings->about_image_path;
} }
if ($settings && filled($settings->founder_image_path)) {
$paths[] = (string) $settings->founder_image_path;
}
if ($settings && filled($settings->hero_image_path)) { if ($settings && filled($settings->hero_image_path)) {
$paths[] = (string) $settings->hero_image_path; $paths[] = (string) $settings->hero_image_path;
} }

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Domain\Marketing;
/**
* Closed catalog of icon keys available for wedding modality detail
* sections (benefits strip and included items).
*
* Keys are stable kebab-case identifiers rendered as inline SVGs by the
* public Blade components. Admin selection is constrained to this catalog
* so arbitrary SVG markup can never reach the public page.
*/
final class PackageIconCatalog
{
/**
* @return array<string, string> map of icon_key => pt-BR label
*/
public static function labels(): array
{
return [
'calendar' => 'Calendário',
'checklist' => 'Checklist',
'users' => 'Casais',
'map' => 'Mapa e fornecedores',
'heart' => 'Cuidado',
'spark' => 'Detalhes',
];
}
/**
* @return list<string>
*/
public static function keys(): array
{
return array_keys(self::labels());
}
public static function isValid(string $iconKey): bool
{
return isset(self::labels()[$iconKey]);
}
public static function labelFor(string $iconKey): ?string
{
return self::labels()[$iconKey] ?? null;
}
}

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Domain\Marketing;
/**
* Public portfolio verticals used to separate Casamentos and Corporate proof.
*
* Matching is intentionally tolerant of free-text `event_type` values already
* stored in the CMS (e.g. "Casamento", "Mini wedding", "Corporativo").
*/
enum PortfolioVertical: string
{
case Casamentos = 'casamentos';
case Corporate = 'corporate';
public function label(): string
{
return match ($this) {
self::Casamentos => 'Casamentos',
self::Corporate => 'Corporate',
};
}
/**
* @return list<string>
*/
public function eventTypePatterns(): array
{
return match ($this) {
self::Casamentos => ['%casamento%', '%wedding%', '%social%'],
self::Corporate => ['%corporat%', '%empresa%', '%business%'],
};
}
public static function tryFromQuery(?string $value): ?self
{
if ($value === null || $value === '') {
return null;
}
return self::tryFrom($value);
}
}

View File

@@ -212,6 +212,26 @@ class ManageSiteSettings extends Page
->reorderable() ->reorderable()
->columnSpanFull(), ->columnSpanFull(),
]), ]),
Section::make('Corporate')
->description('Etapas apresentadas na frente Amare Corporate da home.')
->schema([
Repeater::make('corporate_steps')
->label('Etapas do serviço corporativo')
->schema([
TextInput::make('title')
->label('Título')
->required()
->maxLength(255),
Textarea::make('body')
->label('Descrição')
->required()
->rows(2),
])
->defaultItems(3)
->maxItems(6)
->reorderable()
->columnSpanFull(),
]),
Section::make('Princípios') Section::make('Princípios')
->schema([ ->schema([
TagsInput::make('principles') TagsInput::make('principles')
@@ -248,8 +268,10 @@ class ManageSiteSettings extends Page
->columns(2), ->columns(2),
Section::make('Página Sobre') Section::make('Página Sobre')
->schema([ ->schema([
PublicImageUploadRules::fileUpload('about_image_path', 'Imagem da página Sobre', 'content/about'), PublicImageUploadRules::fileUpload('about_image_path', 'Imagem do hero / Sobre', 'content/about'),
PublicImageUploadRules::altTextField('about_image_alt', 'about_image_path'), PublicImageUploadRules::altTextField('about_image_alt', 'about_image_path'),
PublicImageUploadRules::fileUpload('founder_image_path', 'Foto da Michele', 'content/about/founder'),
PublicImageUploadRules::altTextField('founder_image_alt', 'founder_image_path'),
]) ])
->columns(2), ->columns(2),
Section::make('SEO padrão') Section::make('SEO padrão')

View File

@@ -5,15 +5,9 @@ declare(strict_types=1);
namespace App\Filament\Resources\WeddingPackages\Pages; namespace App\Filament\Resources\WeddingPackages\Pages;
use App\Filament\Resources\WeddingPackages\WeddingPackageResource; use App\Filament\Resources\WeddingPackages\WeddingPackageResource;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord; use Filament\Resources\Pages\EditRecord;
class EditWeddingPackage extends EditRecord class EditWeddingPackage extends EditRecord
{ {
protected static string $resource = WeddingPackageResource::class; protected static string $resource = WeddingPackageResource::class;
protected function getHeaderActions(): array
{
return [DeleteAction::make()];
}
} }

View File

@@ -14,6 +14,8 @@ class ListWeddingPackages extends ListRecords
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [CreateAction::make()]; return [
CreateAction::make(),
];
} }
} }

View File

@@ -0,0 +1,193 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\WeddingPackages\Schemas;
use App\Domain\Marketing\PackageIconCatalog;
use App\Support\PublicImageUploadRules;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
class WeddingPackageForm
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
Section::make('Identificação')
->schema([
TextInput::make('name')
->label('Nome da modalidade')
->required()
->maxLength(255)
->live(onBlur: true)
->afterStateUpdated(function (?string $state, callable $set, callable $get): void {
if (blank($get('slug'))) {
$set('slug', str($state)->slug()->toString());
}
}),
TextInput::make('slug')
->label('Slug')
->required()
->maxLength(255)
->unique(ignoreRecord: true),
TextInput::make('level')
->label('Número / tipo (ex.: 01 / COMPLETA)')
->required()
->maxLength(255),
TextInput::make('tag')
->label('Etiqueta curta (ex.: Assessoria completa)')
->maxLength(255),
TextInput::make('subtitle')
->label('Frase de apoio')
->maxLength(255),
Textarea::make('summary')
->label('Resumo')
->required()
->rows(4),
Repeater::make('scope_items')
->label('O que está incluído')
->schema([
TextInput::make('item')
->label('Item')
->required()
->maxLength(255),
])
->defaultItems(4)
->minItems(1)
->addActionLabel('Adicionar item')
->required(),
TextInput::make('cta_label')
->label('Texto do botão')
->required()
->maxLength(255),
TextInput::make('compare_heading')
->label('Comparação — título (leitura rápida)')
->maxLength(255),
TextInput::make('compare_summary')
->label('Comparação — resumo (leitura rápida)')
->maxLength(255),
TextInput::make('sort_order')
->label('Ordem')
->numeric()
->default(0)
->required(),
DateTimePicker::make('published_at')
->label('Publicado em')
->seconds(false),
])
->columns(2),
Section::make('Hero da página')
->description('Dados de abertura da página da modalidade.')
->schema([
TextInput::make('eyebrow')
->label('Eyebrow (ex.: Assessoria)')
->maxLength(255),
TextInput::make('title_line')
->label('Título (linha principal)')
->maxLength(255),
TextInput::make('title_emphasis')
->label('Título (ênfase em itálico)')
->maxLength(255),
Textarea::make('hero_lead')
->label('Texto de abertura')
->rows(3),
PublicImageUploadRules::fileUpload('hero_image_path', 'Imagem do hero'),
PublicImageUploadRules::altTextField('hero_image_alt', 'hero_image_path'),
])
->columns(2),
Section::make('Diferenciais')
->description('Faixa escura com os valores da modalidade.')
->schema([
Repeater::make('benefits')
->label('Diferenciais')
->schema([
Select::make('icon_key')
->label('Ícone')
->options(PackageIconCatalog::labels())
->required(),
TextInput::make('label')
->label('Rótulo')
->required()
->maxLength(255),
])
->defaultItems(4)
->minItems(1)
->addActionLabel('Adicionar diferencial'),
])
->columns(1),
Section::make('O que está incluso')
->schema([
Repeater::make('included_items')
->label('Itens inclusos')
->schema([
Select::make('icon_key')
->label('Ícone')
->options(PackageIconCatalog::labels())
->required(),
TextInput::make('title')
->label('Título')
->required()
->maxLength(255),
Textarea::make('description')
->label('Descrição')
->rows(2)
->maxLength(500),
])
->defaultItems(6)
->minItems(1)
->addActionLabel('Adicionar item'),
])
->columns(1),
Section::make('Para quem é este pacote')
->schema([
TextInput::make('audience_heading')
->label('Título da seção')
->maxLength(255),
Textarea::make('audience_intro')
->label('Texto de introdução')
->rows(3),
Repeater::make('audience_points')
->label('Público')
->schema([
TextInput::make('point')
->label('Ponto')
->required()
->maxLength(255),
])
->defaultItems(4)
->minItems(1)
->addActionLabel('Adicionar ponto'),
PublicImageUploadRules::fileUpload('audience_image_path', 'Imagem da seção'),
PublicImageUploadRules::altTextField('audience_image_alt', 'audience_image_path'),
])
->columns(2),
Section::make('CTA final')
->schema([
TextInput::make('final_cta_heading')
->label('Título do CTA final')
->maxLength(255),
Textarea::make('final_cta_body')
->label('Texto do CTA final')
->rows(3),
])
->columns(2),
Section::make('SEO')
->schema([
TextInput::make('meta_title')
->label('Meta title')
->maxLength(255),
Textarea::make('meta_description')
->label('Meta description')
->rows(3),
])
->columns(2),
]);
}
}

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\WeddingPackages\Tables;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteAction;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
class WeddingPackagesTable
{
public static function configure(Table $table): Table
{
return $table
->columns([
TextColumn::make('name')
->label('Nome')
->searchable()
->sortable(),
TextColumn::make('level')
->label('Número / tipo')
->searchable(),
TextColumn::make('slug')
->label('Slug')
->searchable(),
TextColumn::make('published_at')
->label('Publicado em')
->dateTime()
->sortable(),
TextColumn::make('sort_order')
->label('Ordem')
->sortable(),
])
->defaultSort('sort_order')
->filters([
//
])
->recordActions([
EditAction::make(),
DeleteAction::make()
->requiresConfirmation(),
])
->toolbarActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
]),
]);
}
}

View File

@@ -7,16 +7,14 @@ namespace App\Filament\Resources\WeddingPackages;
use App\Filament\Resources\WeddingPackages\Pages\CreateWeddingPackage; use App\Filament\Resources\WeddingPackages\Pages\CreateWeddingPackage;
use App\Filament\Resources\WeddingPackages\Pages\EditWeddingPackage; use App\Filament\Resources\WeddingPackages\Pages\EditWeddingPackage;
use App\Filament\Resources\WeddingPackages\Pages\ListWeddingPackages; use App\Filament\Resources\WeddingPackages\Pages\ListWeddingPackages;
use App\Filament\Resources\WeddingPackages\Schemas\WeddingPackageForm;
use App\Filament\Resources\WeddingPackages\Tables\WeddingPackagesTable;
use App\Models\WeddingPackage; use App\Models\WeddingPackage;
use App\Policies\WeddingPackagePolicy; use App\Policies\WeddingPackagePolicy;
use BackedEnum; use BackedEnum;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\TagsInput;
use Filament\Forms\Components\TextInput;
use Filament\Resources\Resource; use Filament\Resources\Resource;
use Filament\Schemas\Schema; use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon; use Filament\Support\Icons\Heroicon;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table; use Filament\Tables\Table;
use UnitEnum; use UnitEnum;
@@ -26,32 +24,39 @@ class WeddingPackageResource extends Resource
protected static ?string $policy = WeddingPackagePolicy::class; protected static ?string $policy = WeddingPackagePolicy::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedHeart; protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedRectangleStack;
protected static ?string $navigationLabel = 'Modalidades de casamento'; protected static ?string $navigationLabel = 'Modalidades';
protected static ?string $modelLabel = 'modalidade';
protected static ?string $pluralModelLabel = 'modalidades';
protected static string|UnitEnum|null $navigationGroup = 'Conteúdo do site'; protected static string|UnitEnum|null $navigationGroup = 'Conteúdo do site';
protected static ?int $navigationSort = 3;
public static function form(Schema $schema): Schema public static function form(Schema $schema): Schema
{ {
return $schema->components([ return WeddingPackageForm::configure($schema);
TextInput::make('name')->label('Nome')->required()->maxLength(120),
TextInput::make('level')->label('Nível')->required()->maxLength(120),
TextInput::make('summary')->label('Resumo')->required()->maxLength(255),
TagsInput::make('scope_items')->label('Itens de escopo')->required()->columnSpanFull(),
TextInput::make('cta_label')->label('CTA')->required()->maxLength(120),
TextInput::make('sort_order')->label('Ordem')->numeric()->required()->default(0),
DateTimePicker::make('published_at')->label('Publicado em')->seconds(false),
]);
} }
public static function table(Table $table): Table public static function table(Table $table): Table
{ {
return $table->columns([TextColumn::make('name')->label('Nome')->searchable(), TextColumn::make('level')->label('Nível'), TextColumn::make('sort_order')->label('Ordem')->sortable(), TextColumn::make('published_at')->label('Publicado em')->dateTime()->sortable()])->defaultSort('sort_order'); return WeddingPackagesTable::configure($table);
}
public static function getRelations(): array
{
return [];
} }
public static function getPages(): array public static function getPages(): array
{ {
return ['index' => ListWeddingPackages::route('/'), 'create' => CreateWeddingPackage::route('/create'), 'edit' => EditWeddingPackage::route('/{record}/edit')]; return [
'index' => ListWeddingPackages::route('/'),
'create' => CreateWeddingPackage::route('/create'),
'edit' => EditWeddingPackage::route('/{record}/edit'),
];
} }
} }

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\PublicSite;
use App\Application\Data\PageMeta;
use App\Application\Queries\Marketing\FindPublishedWeddingPackageBySlug;
use App\Http\Controllers\Controller;
use App\Models\SiteSetting;
use Illuminate\Contracts\View\View;
use Illuminate\Http\Response;
final class PackageController extends Controller
{
public function show(string $slug, FindPublishedWeddingPackageBySlug $findPublishedWeddingPackageBySlug): View|Response
{
$package = $findPublishedWeddingPackageBySlug($slug);
if ($package === null) {
abort(404);
}
$settings = SiteSetting::instance();
$canonical = route('packages.show', $package->slug);
return view('pages.packages.show', [
'package' => $package,
'siteSettings' => $settings,
'pageMeta' => PageMeta::forPackage(
package: $package,
canonical: $canonical,
settings: $settings,
jsonLd: [
'@context' => 'https://schema.org',
'@type' => 'Article',
'headline' => $package->name,
'description' => $package->summary,
'url' => $canonical,
'datePublished' => $package->published_at?->toAtomString(),
'dateModified' => $package->updated_at?->toAtomString(),
],
),
]);
}
}

View File

@@ -5,17 +5,20 @@ declare(strict_types=1);
namespace App\Http\Controllers\PublicSite; namespace App\Http\Controllers\PublicSite;
use App\Application\Data\PageMeta; use App\Application\Data\PageMeta;
use App\Application\Queries\Marketing\GetAboutContent;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\SiteSetting; use App\Models\SiteSetting;
use Illuminate\Contracts\View\View; use Illuminate\Contracts\View\View;
final class PageController extends Controller final class PageController extends Controller
{ {
public function about(): View public function about(GetAboutContent $getAboutContent): View
{ {
$settings = SiteSetting::instance(); $content = $getAboutContent();
$settings = $content->settings;
return view('pages.about', [ return view('pages.about', [
'content' => $content,
'siteSettings' => $settings, 'siteSettings' => $settings,
'pageMeta' => PageMeta::forPage( 'pageMeta' => PageMeta::forPage(
canonical: route('about'), canonical: route('about'),

View File

@@ -6,33 +6,35 @@ namespace App\Http\Controllers\PublicSite;
use App\Application\Data\PageMeta; use App\Application\Data\PageMeta;
use App\Application\Queries\Marketing\FindPublishedPortfolioCaseBySlug; use App\Application\Queries\Marketing\FindPublishedPortfolioCaseBySlug;
use App\Application\Queries\Marketing\GetPublishedPortfolioCases;
use App\Domain\Marketing\PortfolioVertical;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\PortfolioCase;
use App\Models\SiteSetting; use App\Models\SiteSetting;
use Illuminate\Contracts\View\View; use Illuminate\Contracts\View\View;
use Illuminate\Http\Request;
use Illuminate\Http\Response; use Illuminate\Http\Response;
use Illuminate\Pagination\LengthAwarePaginator;
final class PortfolioController extends Controller final class PortfolioController extends Controller
{ {
public function index(): View public function index(Request $request, GetPublishedPortfolioCases $getPublishedPortfolioCases): View
{ {
$settings = SiteSetting::instance(); $settings = SiteSetting::instance();
$vertical = PortfolioVertical::tryFromQuery($request->query('vertente'));
/** @var LengthAwarePaginator<int, PortfolioCase> $cases */ $cases = $getPublishedPortfolioCases->paginate($vertical);
$cases = PortfolioCase::query()
->published()
->with(['images'])
->orderBy('sort_order')
->paginate(9);
return view('pages.portfolio.index', [ return view('pages.portfolio.index', [
'cases' => $cases, 'cases' => $cases,
'vertical' => $vertical,
'siteSettings' => $settings, 'siteSettings' => $settings,
'pageMeta' => PageMeta::forPage( 'pageMeta' => PageMeta::forPage(
canonical: route('portfolio.index'), canonical: route('portfolio.index', array_filter([
'vertente' => $vertical?->value,
])),
settings: $settings, settings: $settings,
title: PageMeta::withBrandSuffix('Portfólio', $settings), title: PageMeta::withBrandSuffix(
$vertical === null ? 'Portfólio' : 'Portfólio — '.$vertical->label(),
$settings,
),
description: 'Casos reais de eventos conduzidos pela '.$settings->brand_name.'.', description: 'Casos reais de eventos conduzidos pela '.$settings->brand_name.'.',
), ),
]); ]);

View File

@@ -12,12 +12,15 @@ use Illuminate\Database\Eloquent\Model;
/** /**
* @property array<string, string|null> $social_links * @property array<string, string|null> $social_links
* @property list<array{title?: string, body?: string}>|null $method_steps * @property list<array{title?: string, body?: string}>|null $method_steps
* @property list<array{title?: string, body?: string}>|null $corporate_steps
* @property list<string>|null $principles * @property list<string>|null $principles
* @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_path
* @property string|null $about_image_alt * @property string|null $about_image_alt
* @property string|null $founder_image_path
* @property string|null $founder_image_alt
* @property string|null $hero_image_path * @property string|null $hero_image_path
* @property string|null $hero_image_alt * @property string|null $hero_image_alt
* @property string|null $services_hero_image_path * @property string|null $services_hero_image_path
@@ -46,11 +49,14 @@ use Illuminate\Database\Eloquent\Model;
'about_summary', 'about_summary',
'about_image_path', 'about_image_path',
'about_image_alt', 'about_image_alt',
'founder_image_path',
'founder_image_alt',
'manifesto_title', 'manifesto_title',
'manifesto_lead', 'manifesto_lead',
'manifesto_body', 'manifesto_body',
'method_intro', 'method_intro',
'method_steps', 'method_steps',
'corporate_steps',
'principles', 'principles',
'email', 'email',
'phone', 'phone',
@@ -83,6 +89,7 @@ class SiteSetting extends Model
'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.',
'method_intro' => 'Clareza em cada etapa. Tranquilidade durante todo o processo.', 'method_intro' => 'Clareza em cada etapa. Tranquilidade durante todo o processo.',
'method_steps' => self::defaultMethodSteps(), 'method_steps' => self::defaultMethodSteps(),
'corporate_steps' => self::defaultCorporateSteps(),
'principles' => self::defaultPrinciples(), 'principles' => self::defaultPrinciples(),
'email' => 'amareassessoriaeventos@gmail.com', 'email' => 'amareassessoriaeventos@gmail.com',
'phone' => '(11) 99999-9999', 'phone' => '(11) 99999-9999',
@@ -120,6 +127,27 @@ class SiteSetting extends Model
]; ];
} }
/**
* @return list<array{title: string, body: string}>
*/
public static function defaultCorporateSteps(): array
{
return [
[
'title' => 'Planejamento',
'body' => 'Estruturação de escopo, cronograma e prioridades.',
],
[
'title' => 'Produção',
'body' => 'Coordenação dos elementos necessários para colocar o evento de pé.',
],
[
'title' => 'Execução',
'body' => 'Condução e acompanhamento do evento conforme o projeto aprovado.',
],
];
}
/** /**
* @return list<string> * @return list<string>
*/ */
@@ -141,6 +169,7 @@ class SiteSetting extends Model
return [ return [
'social_links' => 'array', 'social_links' => 'array',
'method_steps' => 'array', 'method_steps' => 'array',
'corporate_steps' => 'array',
'principles' => 'array', 'principles' => 'array',
'analytics_enabled' => 'boolean', 'analytics_enabled' => 'boolean',
]; ];

View File

@@ -12,17 +12,70 @@ use Illuminate\Database\Eloquent\Attributes\UsePolicy;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
/** /**
* @property string $name * @property string $name
* @property string|null $slug
* @property string $level * @property string $level
* @property string|null $tag
* @property string|null $subtitle
* @property string $summary * @property string $summary
* @property list<string> $scope_items * @property list<string> $scope_items
* @property string $cta_label * @property string $cta_label
* @property string|null $compare_heading
* @property string|null $compare_summary
* @property string|null $eyebrow
* @property string|null $title_line
* @property string|null $title_emphasis
* @property string|null $hero_lead
* @property string|null $hero_image_path
* @property string|null $hero_image_alt
* @property list<array{icon_key: string, label: string}>|null $benefits
* @property list<array{icon_key: string, title: string, description: string}>|null $included_items
* @property string|null $audience_heading
* @property string|null $audience_intro
* @property list<string>|null $audience_points
* @property string|null $audience_image_path
* @property string|null $audience_image_alt
* @property string|null $final_cta_heading
* @property string|null $final_cta_body
* @property string|null $meta_title
* @property string|null $meta_description
* @property int $sort_order * @property int $sort_order
* @property Carbon|null $published_at * @property Carbon|null $published_at
*/ */
#[Fillable(['name', 'level', 'summary', 'scope_items', 'cta_label', 'sort_order', 'published_at'])] #[Fillable([
'name',
'slug',
'level',
'tag',
'subtitle',
'summary',
'scope_items',
'cta_label',
'compare_heading',
'compare_summary',
'sort_order',
'published_at',
'eyebrow',
'title_line',
'title_emphasis',
'hero_lead',
'hero_image_path',
'hero_image_alt',
'benefits',
'included_items',
'audience_heading',
'audience_intro',
'audience_points',
'audience_image_path',
'audience_image_alt',
'final_cta_heading',
'final_cta_body',
'meta_title',
'meta_description',
])]
#[UsePolicy(WeddingPackagePolicy::class)] #[UsePolicy(WeddingPackagePolicy::class)]
class WeddingPackage extends Model class WeddingPackage extends Model
{ {
@@ -31,9 +84,25 @@ class WeddingPackage extends Model
use HasPublication; use HasPublication;
protected static function booted(): void
{
static::saving(function (WeddingPackage $weddingPackage): void {
if (blank($weddingPackage->slug) && filled($weddingPackage->name)) {
$weddingPackage->slug = Str::slug($weddingPackage->name);
}
});
}
/** @return array<string, string|class-string> */ /** @return array<string, string|class-string> */
protected function casts(): array protected function casts(): array
{ {
return ['scope_items' => 'array', 'sort_order' => 'integer', 'published_at' => 'datetime']; return [
'scope_items' => 'array',
'benefits' => 'array',
'included_items' => 'array',
'audience_points' => 'array',
'sort_order' => 'integer',
'published_at' => 'datetime',
];
} }
} }

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Support;
use App\Models\SiteSetting;
final class PackageContactLink
{
public const ORIENTATION_MESSAGE = 'Olá, ainda não sei qual modalidade de assessoria faz sentido para o meu casamento e gostaria de orientação.';
/**
* Resolve the contextual contact link for a wedding package modality.
*
* @return array{href: string, isWhatsapp: bool}
*/
public static function for(SiteSetting $settings, string $packageName): array
{
return self::resolve(
$settings,
'Olá, gostaria de conversar sobre a modalidade '.$packageName.' para meu casamento.',
['servico_interesse' => $packageName],
);
}
/**
* WhatsApp (or briefing fallback) when the visitor does not know which modality to choose.
*
* @return array{href: string, isWhatsapp: bool}
*/
public static function forOrientation(SiteSetting $settings): array
{
return self::resolve($settings, self::ORIENTATION_MESSAGE, []);
}
/**
* @param array<string, string> $briefingQuery
* @return array{href: string, isWhatsapp: bool}
*/
private static function resolve(SiteSetting $settings, string $whatsappMessage, array $briefingQuery): array
{
$digits = preg_replace('/\D/', '', (string) $settings->whatsapp_number);
$isWhatsapp = strlen($digits) >= 10;
return [
'href' => $isWhatsapp
? 'https://wa.me/'.$digits.'?text='.rawurlencode($whatsappMessage)
: route('briefing', $briefingQuery),
'isWhatsapp' => $isWhatsapp,
];
}
}

View File

@@ -6,6 +6,7 @@ namespace Database\Factories;
use App\Models\WeddingPackage; use App\Models\WeddingPackage;
use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
/** @extends Factory<WeddingPackage> */ /** @extends Factory<WeddingPackage> */
class WeddingPackageFactory extends Factory class WeddingPackageFactory extends Factory
@@ -15,14 +16,36 @@ class WeddingPackageFactory extends Factory
/** @return array<string, mixed> */ /** @return array<string, mixed> */
public function definition(): array public function definition(): array
{ {
$name = fake()->unique()->words(2, true);
return [ return [
'name' => fake()->unique()->words(2, true), 'name' => $name,
'slug' => Str::slug($name),
'level' => 'Assessoria', 'level' => 'Assessoria',
'tag' => fake()->words(2, true),
'subtitle' => fake()->sentence(),
'summary' => fake()->sentence(), 'summary' => fake()->sentence(),
'scope_items' => [fake()->sentence()], 'scope_items' => [fake()->sentence()],
'cta_label' => 'Conversar sobre esta modalidade', 'cta_label' => 'Conversar sobre esta modalidade',
'compare_heading' => fake()->words(3, true),
'compare_summary' => fake()->sentence(),
'sort_order' => 0, 'sort_order' => 0,
'published_at' => null, 'published_at' => null,
'eyebrow' => 'Assessoria',
'title_line' => fake()->words(2, true),
'title_emphasis' => fake()->word(),
'hero_lead' => fake()->paragraph(),
'benefits' => [
['icon_key' => 'checklist', 'label' => fake()->word()],
],
'included_items' => [
['icon_key' => 'checklist', 'title' => fake()->word(), 'description' => fake()->sentence()],
],
'audience_heading' => 'Para quem é este pacote',
'audience_intro' => fake()->paragraph(),
'audience_points' => [fake()->sentence()],
'final_cta_heading' => 'Vamos conversar?',
'final_cta_body' => fake()->paragraph(),
]; ];
} }

View File

@@ -0,0 +1,24 @@
<?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->jsonb('corporate_steps')->nullable()->after('method_steps');
});
}
public function down(): void
{
Schema::table('site_settings', function (Blueprint $table): void {
$table->dropColumn('corporate_steps');
});
}
};

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('founder_image_path')->nullable()->after('about_image_alt');
$table->string('founder_image_alt')->nullable()->after('founder_image_path');
});
}
public function down(): void
{
Schema::table('site_settings', function (Blueprint $table): void {
$table->dropColumn(['founder_image_path', 'founder_image_alt']);
});
}
};

View File

@@ -0,0 +1,27 @@
<?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('wedding_packages', function (Blueprint $table): void {
$table->string('tag')->nullable();
$table->string('subtitle')->nullable();
$table->string('compare_heading')->nullable();
$table->string('compare_summary')->nullable();
});
}
public function down(): void
{
Schema::table('wedding_packages', function (Blueprint $table): void {
$table->dropColumn(['tag', 'subtitle', 'compare_heading', 'compare_summary']);
});
}
};

View File

@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
return new class extends Migration
{
public function up(): void
{
Schema::table('wedding_packages', function (Blueprint $table): void {
$table->string('slug')->nullable()->unique()->after('name');
$table->string('eyebrow')->nullable()->after('level');
$table->string('title_line')->nullable()->after('eyebrow');
$table->string('title_emphasis')->nullable()->after('title_line');
$table->text('hero_lead')->nullable()->after('title_emphasis');
$table->string('hero_image_path')->nullable()->after('hero_lead');
$table->string('hero_image_alt')->nullable()->after('hero_image_path');
$table->jsonb('benefits')->nullable()->after('hero_image_alt');
$table->jsonb('included_items')->nullable()->after('benefits');
$table->string('audience_heading')->nullable()->after('included_items');
$table->text('audience_intro')->nullable()->after('audience_heading');
$table->jsonb('audience_points')->nullable()->after('audience_intro');
$table->string('audience_image_path')->nullable()->after('audience_points');
$table->string('audience_image_alt')->nullable()->after('audience_image_path');
$table->string('final_cta_heading')->nullable()->after('audience_image_alt');
$table->text('final_cta_body')->nullable()->after('final_cta_heading');
$table->string('meta_title')->nullable()->after('final_cta_body');
$table->text('meta_description')->nullable()->after('meta_title');
});
$this->backfillSlugs();
}
public function down(): void
{
Schema::table('wedding_packages', function (Blueprint $table): void {
$table->dropUnique(['slug']);
$table->dropColumn([
'slug',
'eyebrow',
'title_line',
'title_emphasis',
'hero_lead',
'hero_image_path',
'hero_image_alt',
'benefits',
'included_items',
'audience_heading',
'audience_intro',
'audience_points',
'audience_image_path',
'audience_image_alt',
'final_cta_heading',
'final_cta_body',
'meta_title',
'meta_description',
]);
});
}
private function backfillSlugs(): void
{
$rows = DB::table('wedding_packages')->whereNull('slug')->get(['id', 'name']);
foreach ($rows as $row) {
$slug = Str::slug($row->name);
$candidate = $slug;
$suffix = 2;
while (DB::table('wedding_packages')->where('slug', $candidate)->exists()) {
$candidate = "{$slug}-{$suffix}";
$suffix++;
}
DB::table('wedding_packages')->where('id', $row->id)->update(['slug' => $candidate]);
}
}
};

View File

@@ -8,7 +8,6 @@ use App\Models\PortfolioCase;
use App\Models\PortfolioImage; use App\Models\PortfolioImage;
use App\Models\Service; use App\Models\Service;
use App\Models\SiteSetting; use App\Models\SiteSetting;
use App\Models\WeddingPackage;
use App\Support\PublicImageUploadRules; use App\Support\PublicImageUploadRules;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
@@ -48,9 +47,9 @@ class ContentSeeder extends Seeder
$this->seedSiteSettings(); $this->seedSiteSettings();
$this->seedServices(); $this->seedServices();
$this->seedWeddingPackages();
$this->seedPortfolioCases(); $this->seedPortfolioCases();
$this->call(TestimonialsSeeder::class); $this->call(TestimonialsSeeder::class);
$this->call(WeddingPackagesSeeder::class);
} }
private function seedSiteSettings(): void private function seedSiteSettings(): void
@@ -72,11 +71,14 @@ class ContentSeeder extends Seeder
'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_path' => $this->copyFixture('about-image.jpg', 'content/about/about-image.jpg'),
'about_image_alt' => 'Mesa de planejamento com caderno, café e guardanapos de pano', 'about_image_alt' => 'Mesa de planejamento com caderno, café e guardanapos de pano',
'founder_image_path' => $this->copyFixture('about-image.jpg', 'content/about/founder/michele.jpg'),
'founder_image_alt' => 'Michele, da Amare',
'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.',
'method_intro' => 'Clareza em cada etapa. Tranquilidade durante todo o processo.', 'method_intro' => 'Clareza em cada etapa. Tranquilidade durante todo o processo.',
'method_steps' => SiteSetting::defaultMethodSteps(), 'method_steps' => SiteSetting::defaultMethodSteps(),
'corporate_steps' => SiteSetting::defaultCorporateSteps(),
'principles' => SiteSetting::defaultPrinciples(), 'principles' => SiteSetting::defaultPrinciples(),
'email' => 'amareassessoriaeventos@gmail.com', 'email' => 'amareassessoriaeventos@gmail.com',
'phone' => '(11) 99999-9999', 'phone' => '(11) 99999-9999',
@@ -205,19 +207,6 @@ class ContentSeeder extends Seeder
} }
} }
private function seedWeddingPackages(): void
{
$packages = [
['name' => 'Essenza', 'level' => 'Planejamento essencial', 'summary' => 'Direção para decisões centrais e um caminho de organização claro.', 'scope_items' => ['Leitura do contexto e prioridades', 'Direção de escopo e cronograma', 'Orientação para os próximos passos'], 'sort_order' => 1],
['name' => 'Conduzione', 'level' => 'Assessoria de planejamento', 'summary' => 'Acompanhamento próximo para construir escolhas coerentes até o grande dia.', 'scope_items' => ['Planejamento e cronograma integrado', 'Coordenação de fornecedores', 'Alinhamentos e decisões de produção'], 'sort_order' => 2],
['name' => 'Grand Jour', 'level' => 'Assessoria completa', 'summary' => 'Presença da escuta inicial à execução atenta de toda a celebração.', 'scope_items' => ['Direção completa do projeto', 'Produção e coordenação de fornecedores', 'Execução presencial no evento'], 'sort_order' => 3],
];
foreach ($packages as $package) {
WeddingPackage::query()->updateOrCreate(['name' => $package['name']], [...$package, 'cta_label' => 'Conversar sobre esta modalidade', 'published_at' => Carbon::parse(self::SEED_TIMESTAMP)]);
}
}
private function copyFixture(string $fixtureName, string $destination): string private function copyFixture(string $fixtureName, string $destination): string
{ {
$source = base_path('database/fixtures/images/'.$fixtureName); $source = base_path('database/fixtures/images/'.$fixtureName);

View File

@@ -30,6 +30,7 @@ class VisualContentSeeder extends Seeder
$this->seedServices(); $this->seedServices();
$this->seedPortfolioCases(); $this->seedPortfolioCases();
$this->seedTestimonials(); $this->seedTestimonials();
$this->call(WeddingPackagesSeeder::class);
} }
private function seedSiteSettings(): void private function seedSiteSettings(): void
@@ -51,11 +52,14 @@ class VisualContentSeeder extends Seeder
'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_path' => $this->writeSolidJpeg('visual/about/about-image.jpg', 1200, 900, [232, 228, 218]),
'about_image_alt' => 'Imagem editorial da página Sobre', 'about_image_alt' => 'Imagem editorial da página Sobre',
'founder_image_path' => $this->writeSolidJpeg('visual/about/founder-michele.jpg', 900, 1200, [210, 205, 192]),
'founder_image_alt' => 'Michele, da Amare',
'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.',
'method_intro' => 'Clareza em cada etapa. Tranquilidade durante todo o processo.', 'method_intro' => 'Clareza em cada etapa. Tranquilidade durante todo o processo.',
'method_steps' => SiteSetting::defaultMethodSteps(), 'method_steps' => SiteSetting::defaultMethodSteps(),
'corporate_steps' => SiteSetting::defaultCorporateSteps(),
'principles' => SiteSetting::defaultPrinciples(), 'principles' => SiteSetting::defaultPrinciples(),
'email' => 'amareassessoriaeventos@gmail.com', 'email' => 'amareassessoriaeventos@gmail.com',
'phone' => '(11) 99999-9999', 'phone' => '(11) 99999-9999',
@@ -198,16 +202,22 @@ class VisualContentSeeder extends Seeder
*/ */
private function writeSolidJpeg(string $destination, int $width, int $height, array $rgb): string private function writeSolidJpeg(string $destination, int $width, int $height, array $rgb): string
{ {
$disk = Storage::disk('public');
if ($disk->exists($destination)) {
return $destination;
}
$image = imagecreatetruecolor($width, $height); $image = imagecreatetruecolor($width, $height);
$color = imagecolorallocate($image, $rgb[0], $rgb[1], $rgb[2]); $color = imagecolorallocate($image, $rgb[0], $rgb[1], $rgb[2]);
imagefilledrectangle($image, 0, 0, $width, $height, $color); imagefilledrectangle($image, 0, 0, $width, $height, $color);
ob_start(); ob_start();
imagejpeg($image, null, 90); imagejpeg($image, null, 80);
$binary = (string) ob_get_clean(); $binary = (string) ob_get_clean();
imagedestroy($image); imagedestroy($image);
Storage::disk('public')->put($destination, $binary); $disk->put($destination, $binary);
return $destination; return $destination;
} }

View File

@@ -0,0 +1,180 @@
<?php
declare(strict_types=1);
namespace Database\Seeders;
use App\Models\WeddingPackage;
use Illuminate\Database\Seeder;
use Illuminate\Support\Carbon;
/**
* Wedding package modalities shown on the home page, services page and
* `/pacotes/{slug}`. Public copy follows "Apresentação dos serviços Amare"
* (conceito e momento). Commercial limits, price and third-party tools stay
* off this seeder and off the public site.
*/
class WeddingPackagesSeeder extends Seeder
{
private const PUBLISHED_AT = '2026-08-11 00:00:00';
public function run(): void
{
$packages = [
[
'name' => 'Essenza',
'slug' => 'essenza',
'level' => '01 / COMPLETA',
'tag' => 'Assessoria completa',
'subtitle' => 'Do primeiro planejamento ao grande dia.',
'summary' => 'Ideal para casais que desejam contar com a Amare desde o início da organização ou que ainda estão estruturando etapas importantes do casamento.',
'scope_items' => [
'Estruturação e acompanhamento do planejamento',
'Organização de cronogramas, prazos e prioridades',
'Orientação na contratação e gestão de fornecedores',
'Acompanhamento das etapas do projeto',
'Organização e conferência de contratos e informações',
'Alinhamento entre casal, fornecedores e demais envolvidos',
'Construção do cronograma do grande dia',
'Coordenação e acompanhamento da execução do evento',
],
'cta_label' => 'Quero conhecer a Essenza',
'compare_heading' => 'Começar com a Amare',
'compare_summary' => 'Acompanhamento do primeiro planejamento ao grande dia.',
'sort_order' => 1,
'eyebrow' => 'Assessoria',
'title_line' => 'Assessoria',
'title_emphasis' => 'Completa',
'hero_lead' => 'A assessoria completa acompanha o projeto de forma estratégica e próxima, desde a construção do planejamento até a execução do evento.',
'benefits' => [
['icon_key' => 'calendar', 'label' => 'Planejamento organizado'],
['icon_key' => 'checklist', 'label' => 'Decisões mais seguras'],
['icon_key' => 'users', 'label' => 'Fornecedores alinhados'],
['icon_key' => 'heart', 'label' => 'Grande dia com leveza'],
],
'included_items' => [
['icon_key' => 'checklist', 'title' => 'Estruturação do planejamento', 'description' => 'Organização do projeto desde o início, no ritmo do casal.'],
['icon_key' => 'calendar', 'title' => 'Cronogramas e prioridades', 'description' => 'Prazos e etapas visíveis para decidir com calma.'],
['icon_key' => 'map', 'title' => 'Contratação e gestão de fornecedores', 'description' => 'Orientação e acompanhamento de quem entra no projeto.'],
['icon_key' => 'users', 'title' => 'Alinhamento entre envolvidos', 'description' => 'Casal, fornecedores e demais partes na mesma conversa.'],
['icon_key' => 'spark', 'title' => 'Contratos e informações', 'description' => 'Conferência do que foi combinado antes do grande dia.'],
['icon_key' => 'heart', 'title' => 'Coordenação da execução', 'description' => 'Acompanhamento do evento para o casal viver o momento.'],
],
'audience_heading' => 'Para quem é esta modalidade',
'audience_intro' => 'Para quem deseja ter ao lado uma profissional conduzindo o processo como um todo, com organização, estratégia e tranquilidade.',
'audience_points' => [
'Casais no início da organização',
'Quem ainda está estruturando etapas importantes',
'Quem quer acompanhamento do planejamento à execução',
],
'final_cta_heading' => 'Vamos nos conhecer?',
'final_cta_body' => 'Antes de definir qualquer caminho, queremos conhecer vocês. Basta entrar em contato para uma conversa.',
],
[
'name' => 'Conduzione',
'slug' => 'conduzione',
'level' => '02 / PARCIAL',
'tag' => 'Assessoria parcial',
'subtitle' => 'Para quem já começou, mas quer seguir acompanhado.',
'summary' => 'Indicada para casais que já iniciaram o planejamento e possuem parte dos fornecedores ou decisões definidas, mas desejam organizar o que falta e conduzir as próximas etapas.',
'scope_items' => [
'Diagnóstico do planejamento já realizado',
'Organização das etapas pendentes',
'Gestão e acompanhamento dos fornecedores já contratados',
'Orientação sobre as próximas decisões',
'Acompanhamento de prazos e informações',
'Comunicação e alinhamento com fornecedores',
'Organização do cronograma do evento',
'Coordenação da execução no grande dia',
],
'cta_label' => 'Quero conhecer a Conduzione',
'compare_heading' => 'Trazer a Amare para o caminho',
'compare_summary' => 'O planejamento existe; a Amare organiza o que falta e conduz as próximas etapas.',
'sort_order' => 2,
'eyebrow' => 'Assessoria',
'title_line' => 'Assessoria',
'title_emphasis' => 'Parcial',
'hero_lead' => 'A Amare entra no momento em que vocês estão, compreende o que já foi construído e assume a gestão das etapas necessárias dentro do planejamento.',
'benefits' => [
['icon_key' => 'checklist', 'label' => 'Diagnóstico do que já existe'],
['icon_key' => 'calendar', 'label' => 'Pendências organizadas'],
['icon_key' => 'users', 'label' => 'Fornecedores acompanhados'],
['icon_key' => 'heart', 'label' => 'Próximas decisões com clareza'],
],
'included_items' => [
['icon_key' => 'checklist', 'title' => 'Diagnóstico do planejamento', 'description' => 'Um olhar profissional sobre o que o casal já construiu.'],
['icon_key' => 'calendar', 'title' => 'Etapas pendentes', 'description' => 'O que falta ganha ordem, prazo e prioridade.'],
['icon_key' => 'map', 'title' => 'Fornecedores já contratados', 'description' => 'Gestão e comunicação com quem já está no projeto.'],
['icon_key' => 'spark', 'title' => 'Próximas decisões', 'description' => 'Orientação para seguir sem retrabalho.'],
['icon_key' => 'users', 'title' => 'Cronograma do evento', 'description' => 'A operação do grande dia começa a ficar visível.'],
['icon_key' => 'heart', 'title' => 'Coordenação no grande dia', 'description' => 'Execução acompanhada para o casal viver o momento.'],
],
'audience_heading' => 'Para quem é esta modalidade',
'audience_intro' => 'Para quem já deu os primeiros passos, mas deseja seguir com mais segurança, organização e tranquilidade.',
'audience_points' => [
'Casais que já iniciaram o planejamento',
'Quem já tem parte dos fornecedores ou decisões definidas',
'Quem quer condução profissional nas etapas que faltam',
],
'final_cta_heading' => 'Vamos organizar o que falta?',
'final_cta_body' => 'Conte onde o planejamento está. A Amare entra nesse momento e conduz as próximas etapas.',
],
[
'name' => 'Grand Jour',
'slug' => 'grand-jour',
'level' => '03 / FINAL',
'tag' => 'Assessoria final',
'subtitle' => 'Para quem já planejou e precisa de alguém para assumir a operação.',
'summary' => 'Indicada para casais que estão na reta final dos preparativos e já possuem a maior parte do casamento definida.',
'scope_items' => [
'Imersão no planejamento existente',
'Conferência das informações e contratos dos fornecedores',
'Alinhamento e comunicação com os profissionais envolvidos',
'Organização do cronograma final',
'Orientação sobre os últimos detalhes',
'Coordenação dos fornecedores no evento',
'Acompanhamento da montagem e dos momentos do casamento',
'Gestão da operação durante o grande dia',
],
'cta_label' => 'Quero conhecer a Grand Jour',
'compare_heading' => 'Entregar a operação para a Amare',
'compare_summary' => 'O projeto está pronto; o foco passa a ser alinhamento e execução.',
'sort_order' => 3,
'eyebrow' => 'Assessoria',
'title_line' => 'Assessoria',
'title_emphasis' => 'Final',
'hero_lead' => 'A Amare assume a condução operacional, mergulha no planejamento construído pelo casal e transforma as informações em uma execução organizada e alinhada.',
'benefits' => [
['icon_key' => 'checklist', 'label' => 'Imersão no planejamento'],
['icon_key' => 'users', 'label' => 'Fornecedores alinhados'],
['icon_key' => 'calendar', 'label' => 'Cronograma final'],
['icon_key' => 'heart', 'label' => 'Operação no grande dia'],
],
'included_items' => [
['icon_key' => 'checklist', 'title' => 'Imersão no planejamento', 'description' => 'A Amare mergulha no que o casal já decidiu.'],
['icon_key' => 'map', 'title' => 'Contratos e informações', 'description' => 'Conferência do combinado com cada profissional.'],
['icon_key' => 'users', 'title' => 'Alinhamento dos envolvidos', 'description' => 'Comunicação centralizada na reta final.'],
['icon_key' => 'calendar', 'title' => 'Cronograma final', 'description' => 'A operação do grande dia ganha ordem.'],
['icon_key' => 'spark', 'title' => 'Últimos detalhes', 'description' => 'Orientação para fechar o que ainda falta.'],
['icon_key' => 'heart', 'title' => 'Gestão da operação', 'description' => 'Montagem, momentos e condução no dia do casamento.'],
],
'audience_heading' => 'Para quem é esta modalidade',
'audience_intro' => 'Porque, na reta final, vocês não precisam de mais tarefas. Precisam de alguém que assuma a condução.',
'audience_points' => [
'Casais na reta final dos preparativos',
'Quem já definiu a maior parte do casamento',
'Quem quer entregar a operação do grande dia',
],
'final_cta_heading' => 'Vamos assumir a operação?',
'final_cta_body' => 'Conte a data e o que já está definido. A Amare entra para conduzir o grande dia.',
],
];
foreach ($packages as $package) {
WeddingPackage::query()->updateOrCreate(
['name' => $package['name']],
[...$package, 'published_at' => Carbon::parse(self::PUBLISHED_AT)],
);
}
}
}

View File

@@ -1,6 +1,6 @@
# Shared Compose for Dokploy staging and production. # Shared Compose for Dokploy staging and production.
# Both stacks use the same file with different env: # Both stacks use the same file with different env:
# APP_IMAGE=ghcr.io/<owner>/<repo> # APP_IMAGE=git.hellomanoel.com/manoel-freitas/amare
# IMAGE_TAG=staging|production|<git-sha> # IMAGE_TAG=staging|production|<git-sha>
# PostgreSQL is a separate Dokploy database service (not defined here). # PostgreSQL is a separate Dokploy database service (not defined here).
# Traefik/Dokploy domains should target service `web` port 8000. # Traefik/Dokploy domains should target service `web` port 8000.

View File

@@ -1,51 +1,28 @@
# Domain Docs # Domain Docs
How the engineering skills should consume this repo's domain documentation when exploring the codebase. How engineering skills should consume this repo's domain documentation.
## Before exploring, read these ## Single source of truth
- **`CONTEXT.md`** at the repo root, or **`SPEC.md` §8** (especially **§8.0 Linguagem ubíqua**) is the glossary and domain model for Amare. There is no root `CONTEXT.md` on purpose — do not create one. Prefer `SPEC.md` over Linear copy or OpenSpec prose when terms conflict.
- **`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. Also useful:
## File structure - **`PRODUCT.md`** — positioning and audience (not the glossary)
- **`openspec/specs/`** — current capability contracts
- **`docs/adr/README.md`** — index only; ADR-001010 live in `SPEC.md` §21
Single-context repo (most repos): ## Before exploring
``` 1. Read `SPEC.md` §8.0 for vocabulary; skim §5.1 for public routes and §8.2+ for persistence shape when relevant.
/ 2. If a skill expects `CONTEXT.md` / `CONTEXT-MAP.md` and finds neither, **proceed using `SPEC.md` §8** — do not invent a parallel glossary file.
├── 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 ## 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. When output names a domain concept (issue title, refactor, hypothesis, test name), use the term as defined in `SPEC.md` §8.0. Don't drift to synonyms the glossary explicitly avoids (`_Avoid_`).
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`). If the concept isn't in §8.0 yet, either you're inventing language the project doesn't use (reconsider) or there's a real gap — resolve it by updating `SPEC.md` §8.0 via `/grill-with-docs` / domain-modeling, not by adding `CONTEXT.md`.
## Flag ADR conflicts ## Flag ADR conflicts
If your output contradicts an existing ADR, surface it explicitly rather than silently overriding: If output contradicts an ADR in `SPEC.md` §21, surface it explicitly rather than silently overriding.
> _Contradicts ADR-0007 (event-sourced orders) — but worth reopening because…_

View File

@@ -17,6 +17,16 @@ Issues and specs for this repo live in Linear. Use the Linear MCP tools for all
- **Link work**: `save_issue` with `project`, `cycle`, `parentId`, `blocks` / `blockedBy` (relations are append-only). - **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`. - **Cross-link to a PR**: attach the PR URL via `save_issue` `links`.
## Required: issue before work and before PR
Every branch, worktree, and pull request in this repo must be tied to a Linear issue.
1. **Starting work:** require an identifier (e.g. `MAN-132`). If the user did not give one, search Linear. If none fits, create the issue with `save_issue` on team `Maneco-workspace` *before* creating a branch or worktree.
2. **Branch:** include the identifier (`docs/man-132-…`, `feat/man-126-…`).
3. **PR:** cite identifier + URL in the body. An OpenSpec change is extra context, not a substitute.
4. **After the PR exists:** attach the PR URL on the issue via `save_issue` `links`. Do this as part of the automatic SDLC in `AGENTS.md` — do not ask before opening the PR.
5. If Linear MCP is unavailable, stop and report — do not start untracked work.
## When a skill says "publish to the issue tracker" ## When a skill says "publish to the issue tracker"
Create a Linear issue with `save_issue` on team `Maneco-workspace`. Create a Linear issue with `save_issue` on team `Maneco-workspace`.

View File

@@ -1,11 +1,11 @@
# Deploy Dokploy (staging → production) # Deploy Dokploy (staging → production)
Runbook for operating Amare on a VPS with Dokploy connected to GitHub, publishing immutable images to GHCR. Runbook for operating Amare on a VPS with Dokploy connected to Gitea (git.hellomanoel.com), publishing immutable images to Gitea's container registry.
## Architecture ## Architecture
``` ```
CI (main) → build FrankenPHP image → GHCR :<sha> + :staging CI (main) → build FrankenPHP image → git.hellomanoel.com registry :<sha> + :staging
→ Dokploy staging compose.deploy → Dokploy staging compose.deploy
→ smoke /up / /admin/login → smoke /up / /admin/login
@@ -18,7 +18,7 @@ Promote (manual) → retag same digest as :production (no rebuild)
|---|---| |---|---|
| Compose file | [`docker-compose.deploy.yml`](../../docker-compose.deploy.yml) | | Compose file | [`docker-compose.deploy.yml`](../../docker-compose.deploy.yml) |
| Processes | `migrate` (one-shot) → `web` / `queue` / `scheduler` | | Processes | `migrate` (one-shot) → `web` / `queue` / `scheduler` |
| Image | `ghcr.io/<owner>/<repo>:<sha>` (+ aliases `:staging`, `:production`) | | Image | `git.hellomanoel.com/manoel-freitas/amare:<sha>` (+ aliases `:staging`, `:production`) |
| Database | Dokploy PostgreSQL **per environment** (not in the app image) | | Database | Dokploy PostgreSQL **per environment** (not in the app image) |
| Media | Cloudflare R2 (`FILESYSTEM_DISK=r2`), separate buckets per environment | | Media | Cloudflare R2 (`FILESYSTEM_DISK=r2`), separate buckets per environment |
| Mail | Resend (`MAIL_MAILER=resend`) | | Mail | Resend (`MAIL_MAILER=resend`) |
@@ -26,11 +26,14 @@ Promote (manual) → retag same digest as :production (no rebuild)
## Prerequisites (manual) ## Prerequisites (manual)
1. Dokploy installed on the VPS; GitHub provider connected. 1. Gitea repository `manoel-freitas/amare` at `https://git.hellomanoel.com`; **Repository Actions enabled** in repo settings; a registered **Gitea Actions runner** (see [docs.gitea.com usage/actions/quickstart](https://docs.gitea.com/usage/actions/quickstart)) with an `ubuntu-latest` label.
2. GHCR registry in Dokploy (`ghcr.io`) with a PAT that can **read** packages (`read:packages`). Prefer a dedicated bot/token; do not store write tokens on the VPS. 2. Dokploy installed on the VPS.
3. Two PostgreSQL services in Dokploy (staging + production), private (no public port). 3. Container registry in Dokploy (`git.hellomanoel.com`) with a PAT that can **read** packages (`read:package`). Prefer a dedicated bot/token; do not store write tokens on the VPS. The **write** PAT (`write:package`) lives only in Gitea repo secrets for the pipeline.
4. Two R2 buckets (or prefixes) and Resend credentials for each environment as needed. 4. Two PostgreSQL services in Dokploy (staging + production), private (no public port).
5. Domains (or temporary Dokploy/traefik.me hosts) pointing at the VPS with TLS. 5. Two R2 buckets (or prefixes) and Resend credentials for each environment as needed.
6. Domains (or temporary Dokploy/traefik.me hosts) pointing at the VPS with TLS.
Note: Gitea's `GITEA_TOKEN` cannot push OCI packages ([gitea#23642](https://github.com/go-gitea/gitea/issues/23642)); registry authentication in the workflows uses a PAT (`REGISTRY_PAT`), not the token. Secret names must not use the reserved `GITEA_` prefix (Gitea rejects them as invalid).
## Create Compose stacks ## Create Compose stacks
@@ -44,7 +47,7 @@ Create **two** Dokploy Compose services (same repo, same compose path):
Dokploy Environment for each stack must set: Dokploy Environment for each stack must set:
```bash ```bash
APP_IMAGE=ghcr.io/<owner>/<repo> APP_IMAGE=git.hellomanoel.com/manoel-freitas/amare
IMAGE_TAG=staging # or production IMAGE_TAG=staging # or production
``` ```
@@ -52,11 +55,11 @@ Point Dokploy domain(s) at service **`web`**, port **`8000`**. Do not publish Po
Compose services must join the external Docker network `dokploy-network` (declared in `docker-compose.deploy.yml`) so they can resolve the Dokploy-managed Postgres internal host (e.g. `amare-stg-pez43e`). Set `DB_HOST` to that **Internal Host** from the Dokploy database UI — not a public hostname. Compose services must join the external Docker network `dokploy-network` (declared in `docker-compose.deploy.yml`) so they can resolve the Dokploy-managed Postgres internal host (e.g. `amare-stg-pez43e`). Set `DB_HOST` to that **Internal Host** from the Dokploy database UI — not a public hostname.
Source can be GitHub (so Dokploy clones the compose file) or Raw paste of `docker-compose.deploy.yml`. Prefer GitHub + fixed compose path so updates stay in sync with `main`. Source can be Gitea (so Dokploy clones the compose file) or Raw paste of `docker-compose.deploy.yml`. Prefer Gitea + fixed compose path so updates stay in sync with `main`.
## Required Laravel env (Dokploy only) ## Required Laravel env (Dokploy only)
Set these in Dokploy Environment UI (written to `.env` next to the compose file). **Never** put them in GitHub Actions secrets or image layers. Set these in Dokploy Environment UI (written to `.env` next to the compose file). **Never** put them in Gitea Actions secrets or image layers.
```env ```env
APP_NAME=Amare APP_NAME=Amare
@@ -120,9 +123,9 @@ Upload path does not need R2 CORS with the local temp-disk default. Still useful
Also enable public access / custom domain for `R2_URL` so `<img>` URLs work after save. Also enable public access / custom domain for `R2_URL` so `<img>` URLs work after save.
## GitHub Actions secrets ## Gitea Actions secrets
Repository secrets used by workflows: Repository secrets (Gitea → Settings → Actions → Secrets) used by workflows:
| Secret | Purpose | | Secret | Purpose |
|---|---| |---|---|
@@ -132,32 +135,48 @@ Repository secrets used by workflows:
| `DOKPLOY_PRODUCTION_COMPOSE_ID` | Production **Compose** service id (not an Application id) | | `DOKPLOY_PRODUCTION_COMPOSE_ID` | Production **Compose** service id (not an Application id) |
| `STAGING_URL` | Public origin for staging smoke (e.g. `https://staging.example.com`) | | `STAGING_URL` | Public origin for staging smoke (e.g. `https://staging.example.com`) |
| `PRODUCTION_URL` | Public origin for production smoke | | `PRODUCTION_URL` | Public origin for production smoke |
| `REGISTRY_PAT` | Personal Access Token with `read:package` + `write:package` scopes — used to push images to `git.hellomanoel.com` (do not name secrets `GITEA_*`; that prefix is reserved) |
| `REGISTRY_USER` | Gitea username that owns `REGISTRY_PAT` (`manoel-freitas`) |
HTTP 404 from `compose.deploy` usually means the compose id is wrong (Application id instead of Compose) or `DOKPLOY_URL` still includes `/api`. HTTP 404 from `compose.deploy` usually means the compose id is wrong (Application id instead of Compose) or `DOKPLOY_URL` still includes `/api`.
`GITHUB_TOKEN` (automatic) publishes to GHCR with `packages:write`. No Laravel/`APP_KEY`/DB/R2/Resend secrets belong in GitHub for this pipeline. The automatic `GITEA_TOKEN` runs workflows but **cannot push OCI packages** ([gitea#23642](https://github.com/go-gitea/gitea/issues/23642)); registry authentication therefore uses `REGISTRY_PAT` + `REGISTRY_USER`. No Laravel/`APP_KEY`/DB/R2/Resend secrets belong in Gitea for this pipeline.
## Workflows ## Workflows
Workflows live in [`.gitea/workflows/`](../../.gitea/workflows/).
`actions/cache` and Docker Buildx `type=gha` are **not** used: the act_runner cache server is not reachable from job containers by default (`getCacheEntry` ETIMEDOUT). Re-enable only after configuring a reachable `cache.host`/`external_server` on the runner.
CI Postgres services must **not** publish host port `5432` (use service hostname `postgres` on the job network). Publishing `5432:5432` on a shared VPS runner fails with `Bind for 0.0.0.0:5432 failed: port is already allocated` when another job/orphan still holds the port.
Parallel CI jobs need the act_runner `config.yaml` to keep `container.network` **empty** (per-job Docker network + service DNS) and `runner.capacity` ≥ 2. Setting `network: bridge` puts every job on the default bridge and makes parallel Postgres collide. Nested app containers (browser/container jobs) must join that job network by name and must **not** publish host `:8000`. On the current 1 vCPU / ~4 GiB VPS, `capacity: 2` is the safe ceiling.
### Staging (automatic) ### Staging (automatic)
[`.github/workflows/deploy-staging.yml`](../../.github/workflows/deploy-staging.yml) [`.gitea/workflows/deploy-staging.yml`](../../.gitea/workflows/deploy-staging.yml) — separate workflow, triggered by `workflow_run` when **CI** completes on `main`.
1. Waits for workflow `CI` success on push to `main`. Requires **Gitea ≥ 1.25** (`workflow_run` is not implemented as an Actions trigger in 1.24.x). Match both workflow display name `CI` and file id `ci.yml`.
2. Builds once; pushes `:<full-sha>` and `:staging`.
1. Waits for workflow `CI` success (`workflow_run`) on push to `main`.
2. Builds once; pushes `:<full-sha>` and `:staging` to the Gitea registry.
3. Calls Dokploy `compose.deploy` and polls until done. 3. Calls Dokploy `compose.deploy` and polls until done.
4. Runs [`scripts/deploy/smoke.sh`](../../scripts/deploy/smoke.sh) against `STAGING_URL`. 4. Runs [`scripts/deploy/smoke.sh`](../../scripts/deploy/smoke.sh) against `STAGING_URL`.
Manual re-deploy: **Actions → Deploy staging → Run workflow** (`workflow_dispatch`).
`DOKPLOY_API_KEY` must be the **plaintext** key from Dokploy → Profile → API (starts like `amare…`). Do not paste the hashed `apikey.key` column from Postgres — that yields HTTP 401.
### Production (manual) ### Production (manual)
[`.github/workflows/promote-production.yml`](../../.github/workflows/promote-production.yml) [`.gitea/workflows/promote-production.yml`](../../.gitea/workflows/promote-production.yml)
1. Operator runs **Actions → Promote production**. 1. Operator runs **Gitea → Actions → Promote production**.
2. Inputs: full `sha` already on GHCR; `confirm` must be exactly `PRODUCTION`. 2. Inputs: full `sha` already in the Gitea registry; `confirm` must be exactly `PRODUCTION`.
3. Retags the **same digest** as `:production` (no rebuild). 3. Retags the **same digest** as `:production` (no rebuild).
4. Deploys production compose + smoke. 4. Deploys production compose + smoke.
Private repos on GitHub Free do not get Environment required reviewers; human approval is the explicit `workflow_dispatch` + confirmation string. GitHub Pro Environment reviewers are optional later. Human approval is the explicit `workflow_dispatch` + confirmation string (Gitea does not support GitHub Environment required reviewers).
## First admin and authorized production seeding ## First admin and authorized production seeding
@@ -256,10 +275,10 @@ No rebuild. Move the environment alias to a previous SHA digest and redeploy.
### Staging ### Staging
```bash ```bash
# Locally or in a one-off Actions shell with GHCR login # Locally or in a one-off Actions shell with Gitea registry login
docker buildx imagetools create \ docker buildx imagetools create \
--tag ghcr.io/<owner>/<repo>:staging \ --tag git.hellomanoel.com/manoel-freitas/amare:staging \
ghcr.io/<owner>/<repo>:<previous-sha> git.hellomanoel.com/manoel-freitas/amare:<previous-sha>
# Then trigger Dokploy deploy (UI Deploy, or): # Then trigger Dokploy deploy (UI Deploy, or):
DOKPLOY_URL=... DOKPLOY_API_KEY=... DOKPLOY_COMPOSE_ID=... \ DOKPLOY_URL=... DOKPLOY_API_KEY=... DOKPLOY_COMPOSE_ID=... \
@@ -297,7 +316,7 @@ Expects HTTP 200 for `/up`, `/`, and `/admin/login`.
## Local validation of Compose ## Local validation of Compose
```bash ```bash
APP_IMAGE=ghcr.io/<owner>/<repo> IMAGE_TAG=staging \ APP_IMAGE=git.hellomanoel.com/manoel-freitas/amare IMAGE_TAG=staging \
docker compose -f docker-compose.deploy.yml config docker compose -f docker-compose.deploy.yml config
``` ```

View File

@@ -0,0 +1,36 @@
# Design — Home editorial conforme preview
Fonte de verdade visual: modelo estrutural validado (`preview(1).html`) + tokens `resources/css/tokens.css` (inalterados; assertados por `HeritageEditorialTokensTest`). Paleta do preview já mapeada para os utilitários `amare-*`.
## Estrutura da home (8 capítulos)
| Capítulo | Id/âncora | Componente | Conteúdo |
|---|---|---|---|
| Hero | — | `x-home.hero` | Eyebrow fixo, h1 fixo, lede fixo, CTAs "Conhecer a Amare" (`#sobre`) + "Enviar briefing" (`#contato`), arte `hero_image_path` ou placeholder rotulado "Fotografia hero" |
| 01 — A Amare | `#sobre` | `x-home.manifesto` | Editorial-split 0.8fr/1.2fr; statement fixo + placeholder institucional |
| 02 — Duas vertentes | `#vertentes` | `x-home.vertentes` | 2 cards (Casamentos/Corporate) com overlay gradiente 43%, CTAs para âncoras |
| 03 — Amare Casamentos | `#casamentos` | `x-home.packages` | Grid 3 colunas com bordas (Eyebrow/título/descrição/items/CTA primário → `contact`), bloco de orientação accent-deep + nota de nomenclatura |
| 04 — Amare Corporate | `#corporate` | `x-home.corporate` | Etapas `corporate_steps` (número 44px, bordas) + placeholder "Portfólio Corporate / Conteúdo em construção" |
| 05 — Portfólio | `#portfolio` | `x-home.portfolio` | Grid 1.2fr/0.8fr/0.8fr, 1 foto alta (row-span-2, 634px) + 4; cases reais ou labels "FOTO 0X" |
| 06 — Depoimentos | `#depoimentos` | `x-home.testimonials` | 3 cards brancos com borda; sem carrossel; placeholders até completar 3 |
| 07 — Briefing | `#contato` | `x-home.briefing` | Formulário completo reusando `contact.store` (underline inputs, labels uppercase 12px, honeypot `#empresa`, checkbox LGPD) |
## Padrões visuais
- Seções: `home-chapter border-b border-amare-border bg-amare-bg`, padding `py-16 md:py-24`, `container-amare`, `data-reveal-group` + blocos `data-reveal data-reveal-from="up"`.
- Eyebrow: `text-xs font-bold uppercase tracking-[0.16em] text-amare-accent-deep`.
- h2 `clamp(2.375rem,5vw,4rem)`, h3 `clamp(1.5625rem,2.7vw,2.125rem)`, lede `clamp(1.1875rem,2vw,1.5rem)` max-w 760px, `leading-[1.55]`.
- Botão primário: `min-h-[48px] bg-amare-accent text-amare-accent-text uppercase text-xs font-bold tracking-[0.09em]`, hover accent-deep; contorno: `border-amare-accent text-amare-accent-deep`; em bloco escuro: `border-amare-accent-text text-amare-accent-text`.
- Header: sticky 74px, `bg-amare-bg/95 backdrop-blur-sm`, nav 13px uppercase `tracking-[0.08em]`, botão "Conte seu evento" → `contact`.
- Footer: `py-14 md:py-[58px]`, marca + "Assessoria & produção de eventos • São Paulo", social links do settings ou placeholder "Instagram · WhatsApp · E-mail · LinkedIn (quando confirmado)".
## Acessibilidade
- Âncoras `aria-labelledby` por seção; foco/ordem tab preservados; skip-link intacto.
- `prefers-reduced-motion`: blocos globais em tokens.css (0.01ms) — nenhum motion extra adicionado.
- Erros de validação do briefing com `role="alert"` e `aria-describedby`; honeypot oculto.
- Contrastes: accent `#556B2F` sobre bg `#FBF9F4` ≈ 4.6:1; accent-text branco sobre accent ≈ 6.9:1; muted `#5D6155` sobre bg ≈ 5.4:1 (dentro do AA).
## Fora de escopo visual
Copy final institucional (placeholders), acervo fotográfico autorizado, canal de fornecedores (pós-MVP), folios ornamentais, carrossel, parallax.

View File

@@ -0,0 +1,33 @@
# Home editorial conforme preview estrutural
## Why
A home atual não reflete a direção editorial aprovada para o site (Dossiê Editorial do Evento). O cliente validou um modelo estrutural (`preview(1).html`) que demonstra hierarquia, jornada e diferenciação de fluxos: hero editorial, editorial-split institucional, duas vertentes (Casamentos/Corporate), modalidades de casamento com orientação humana, frente corporativa honesta sobre portfólio em construção, portfólio curado, depoimentos sem carrossel e briefing comercial único. A home também é a porta de conversão primária (briefing), então a apresentação deve construir confiança antes de pedir conversão.
## What Changes
- **Home em 8 seções** (componentes `x-home.*`): hero assimétrico (1.1fr/0.9fr, arte rotulada até acervo autorizado), `01 — A Amare` (editorial-split), `02 — Duas vertentes` (2 cards), `03 — Amare Casamentos` (grid de modalidades com bordas + bloco de orientação em accent-deep), `04 — Amare Corporate` (etapas + placeholder "Conteúdo em construção"), `05 — Portfólio` (1 foto alta + 4), `06 — Depoimentos` (3 cards, sem carrossel), `07 — Briefing comercial` (formulário completo reaproveitando `contact.store`).
- **Novo modelo `WeddingPackage`** (Modalidades): migration, model com publication, factory, policy, seeder com copy exata do preview (Essenza/Conduzione/Grand Jour) e recurso Filament "Modalidades" com repeater de itens.
- **`SiteSetting.corporate_steps`**: JSON de 3 etapas (Planejamento/Produção/Execução), editável via repeater no `ManageSiteSettings`, semeadas nos dois seeders.
- **Header/footer globais** restilizados conforme preview (barra 74px, nav uppercase 13px, botão "Conte seu evento", footer editorial), preservando menu mobile, skip-link, logo e links legais.
- Motion mantém convenções existentes (`data-motion="page-open"`, `data-motion-beat`, `data-reveal`), respeitando `prefers-reduced-motion` global.
## Non-Goals
- Copy final institucional onde o preview declara placeholder (ex.: "Aqui entra a apresentação institucional da marca e da Michele…") — permanece placeholder.
- Fotografias autorizadas da Amare (acervo ainda em organização) — imagens permanecem rotuladas.
- Canal/forma de fornecedores e parcerias (pós-MVP, caixa tracejada) — não misturar ao briefing comercial.
- Carrossel de depoimentos, parallax, scroll-jacking ou folios ornamentais.
- Alteração de tokens do design system (assertados por `HeritageEditorialTokensTest`).
## Capabilities
### New Capabilities
- `wedding-packages`: cadastro de modalidades de casamento (Essenza/Conduzione/Grand Jour) com itens, ordenação e publicação via Filament; exibidas na home na seção `03 — Amare Casamentos`.
### Modified Capabilities
- `public-site-pages`: home recomposta em 8 capítulos com âncoras (`#sobre`, `#vertentes`, `#casamentos`, `#corporate`, `#portfolio`, `#depoimentos`, `#contato`); header/footer globais restilizados; briefing da home reutiliza o fluxo `contact.store` existente.
- `content-media`: heróis continuam usando `hero_image_path` com fallback tipográfico rotulado; portfólio usa `featuredCases` reais ou labels provisórios.
- `design-tokens`: nenhum valor alterado; novos componentes usam os tokens existentes via utilitários `amare-*`.

View File

@@ -0,0 +1,22 @@
# Wedding Packages
## Purpose
Cadastro editorial das modalidades de acompanhamento de casamento oferecidas pela Amare (Essenza, Conduzione, Grand Jour). Fonte única de verdade para a seção `03 — Amare Casamentos` da home.
## Functional Requirements
- FR-1: Um pacote contém `eyebrow` (ex.: "01 / COMPLETA"), `title` (ex.: "Essenza"), `description`, lista ordenada de `items` (o que está incluído) e `sort_order`.
- FR-2: Publicação via `published_at` (modelo usa `HasPublication`); pacotes rascunho nunca aparecem na home.
- FR-3: A home renderiza pacotes publicados ordenados por `sort_order`, cada um com CTA primário "Quero conhecer a {title}" apontando para o briefing (`route('contact')`).
- FR-4: O recurso Filament "Modalidades" (grupo "Conteúdo do site") permite criar/editar/excluir pacotes e ajustar itens via repeater; acesso restrito a admins (`WeddingPackagePolicy`).
## Non-Requirements
- Nenhum vínculo com checkout/pagamento nesta capacidade.
- Nomenclaturas exibidas são provisórias até confirmação com a cliente (nota visível na home).
## Data
- Tabela `wedding_packages` (migration `2026_08_11_000000`): `eyebrow string`, `title string`, `description text`, `items json`, `sort_order int`, `published_at timestamp nullable`, timestamps.
- Seed padrão (ambos os seeders): Essenza `01 / COMPLETA`, Conduzione `02 / PARCIAL`, Grand Jour `03 / FINAL`, com copy exata do preview estrutural.

View File

@@ -0,0 +1,18 @@
# Tasks — Home editorial conforme preview
## Concluído
- [x] **T0** Worktree `.worktrees/home-redesign` (branch `feat/home-redesign`) a partir de `main`.
- [x] **T1** `WeddingPackage` (model + migration `2026_08_11_000000` + factory + policy + seeder + recurso Filament "Modalidades"): 3 modalidades semeadas com copy exata do preview; seeders `ContentSeeder`/`VisualContentSeeder` passam a chamar `WeddingPackagesSeeder`.
- [x] **T2** `SiteSetting.corporate_steps` (migration `2026_08_11_010000`, `defaultCorporateSteps()`, casts/fillable/docblock, repeater em `ManageSiteSettings`, seeders atualizados).
- [x] **T3** Header/footer globais (`layouts/public.blade.php`) restilizados conforme preview; menu mobile, skip-link, logo e links legais preservados.
- [x] **T4** Home recomposta em 8 seções (`x-home.*`: hero, manifesto/sobre, vertentes, packages, corporate, portfolio, testimonials, briefing); `HomeContent` ganha `packages`; `GetHomeContent` busca pacotes publicados por `sort_order`.
- [x] **T5** Motion via convenções existentes (`data-reveal`, `data-motion-beat`, `data-motion="page-open"`); `prefers-reduced-motion` global respeitado; sem carrossel/parallax.
- [x] **T6** Testes: `HomePageContentTest` reescrito (8 seções, packages, corporate steps, briefing form, placeholders), `HomePageTest`, `GetHomeContentTest` (packages), `ImmersivePhotoHeroTest`/`MotionMarkupTest` atualizados para o novo contrato, `HomeEditorialCadenceTest` reescrito para o layout assimétrico. **178 feature + 42 unit verdes**.
- [x] **T7** `composer pint` + `phpstan` limpos; 2 commits convencionais; PR #53 aberto.
## Pendente
- [ ] **T8** Proposta openspec (este change) revisada e arquivada após merge.
- [ ] Merge do PR #53 após CI verde; limpeza do worktree (`git worktree remove`).
- [ ] Follow-up pós-merge: confirmar nomenclaturas das modalidades com a cliente (nota na home: "Nomenclaturas exibidas conforme materiais/reunião; confirmar versão final antes da publicação.").

View File

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

View File

@@ -0,0 +1,96 @@
## Context
`WeddingPackage` já cobre cards ordenados + CTA WhatsApp/briefing (change `restructure-home-weddings-corporate`, capability ainda só no delta completo). Campos atuais: `name`, `level`, `summary`, `scope_items` (json), `cta_label`, `sort_order`, `published_at`. Sem slug, sem corpo de detalhe, sem imagens. Portfólio (`portfolio.show`, `FindPublishedPortfolioCaseBySlug`, PageMeta, sitemap) é o padrão de detalhe a espelhar. Mock visual: `/home/manoelfreitas/Downloads/amare-assessoria-completa.html`. Tokens: `DESIGN.md` / `resources/css/tokens.css` (Heritage Editorial) — não Cormorant/Inter do HTML estático.
## Goals / Non-Goals
**Goals:**
- Detalhe CMS-driven `/pacotes/{slug}` para as 3 modalidades, publish-gated.
- Composição mock → tokens do projeto (olive accent, EB Garamond via stack existente, bg cream).
- CTA final = mesmo canal contextual dos cards (`wa.me` ou `/briefing?servico_interesse=`).
- SEO mínimo: meta por registro, canonical, OG image (hero ou default), entrada no sitemap.
- Admin Filament editável sem deploy de copy.
**Non-Goals:**
- Rota índice `/pacotes`; substituir cards da home; WhatsApp Business API; preços; multi-idioma; Livewire público; gallery multi-imagem além de hero + audience.
## Decisions
### Estender `WeddingPackage`, não criar modelo novo
Uma entidade comercial já existe e é o que home/serviços já consomem. Detalhe é projeção do mesmo agregado. Modelo paralelo geraria sync de publicação/slug e viola YAGNI.
### Slug único + publicação por `published_at`
Igual portfólio: URL estável, scope `published()`, query Application `FindPublishedWeddingPackageBySlug`. Draft/inexistente → 404 HTTP, sem vazar campos internos.
### Campos de detalhe (additive)
| Campo | Uso |
|-------|-----|
| `slug` | string unique |
| `eyebrow` | ex. "Assessoria" |
| `title_line` | parte romana do H1 |
| `title_emphasis` | parte itálica do H1 |
| `hero_lead` | parágrafo do hero |
| `hero_image_path` / `hero_image_alt` | foto hero |
| `benefits` | jsonb `[{icon_key,label}]` — faixa escura |
| `included_items` | jsonb `[{icon_key,title,description}]` — grid 3 col |
| `audience_heading` | default editorial se vazio ok |
| `audience_intro` | texto |
| `audience_points` | jsonb `string[]` checklist |
| `audience_image_path` / `audience_image_alt` | foto 50/50 |
| `final_cta_heading` / `final_cta_body` | bloco final |
| `meta_title` / `meta_description` | SEO opcional |
Manter `summary` + `scope_items` para cards. Não migrar cards para `included_items` neste ciclo.
### Catálogo fechado de ícones (`PackageIconCatalog`)
Select Filament → `icon_key` string. Map PHP/Blade para SVGs inline (ou partials). Sem upload de SVG arbitrário (XSS/ops). Chaves mínimas cobrem mock (ex.: calendar, checklist, users, map, heart, spark — nomes estáveis kebab).
### Hero de pacote vs `photo-hero` genérico
Mock exige H1 bipartido (linha + ênfase itálica), divisor vertical, CTA outline no hero e foto. Preferir componente dedicado `x-public.package-hero` (ou estender `photo-hero` só se API ficar genérica sem branching feio). Seções: `package-benefits`, `package-included`, `package-audience`, reutilizar `final-cta` se encaixar.
### CTA WhatsApp
Reutilizar VO/helper já usado nos cards (digits de `SiteSetting.whatsapp_number`, mensagem com nome da modalidade). Mesma regra de fallback briefing. Não duplicar lógica de normalização.
### Links a partir dos cards
Card: título/área → `route('packages.show', $package)` quando slug presente; botão CTA continua WhatsApp/briefing. Sem quebrar testes de CTA existentes.
### Imagens
`PublicImageUploadRules` + disk público existente + `x-media.image`. Paths nullable; se hero ausente, layout degrada (omitir slot de imagem).
### Camada Application
- `FindPublishedWeddingPackageBySlug`
- Estender `GetSitemapEntries` com slugs de pacotes publicados
- PageMeta no controller (espelhar PortfolioCaseController)
Sem repository genérico / BaseAction.
## Risks / Trade-offs
- [Capability `wedding-packages` ainda não está em `openspec/specs/`] → delta ADDED nesta change; ao arquivar, consolidar com requirements de cards do change completo ou arquivar ambos em ordem.
- [Copy das 3 modalidades incompleta] → seeder: Essenza = mock; Conduzione/Grand Jour estrutura paralela adaptada; admin pode editar.
- [Ícones insuficientes no catálogo] → adicionar chave no map + opção Filament; sem free-text SVG.
- [Cards sem slug durante migrate] → migration backfill slug a partir do name; seeder garante os 3 oficiais.
- [Duplicação visual mock vs tokens] → testes de tokens existentes + asserts de classes/roles; não copiar hex do HTML.
## Migration Plan
1. Deploy migration additive + código + assets Blade juntos.
2. Rodar seeder/update das 3 modalidades (slug + detalhe).
3. Verificar `/pacotes/essenza` (etc.), 404 draft, sitemap, CTA WA.
4. Rollback: remover rota/views; colunas additive podem permanecer; unpublish pacotes se necessário.
## Open Questions
- Nenhuma bloqueante (decisões de produto já confirmadas: detalhe-only, extend model, 3 modalidades, WA CTA).
- WEB-ID dedicado a package detail: não existe no SPEC; rastrear via WEB-02 + padrão WEB-03 até SPEC ganhar ID explícito.

View File

@@ -0,0 +1,32 @@
## Why
Modalidades de casamento (Essenza, Conduzione, Grand Jour) existem no CMS e na home/serviços só como cards com CTA WhatsApp. Não há página de detalhe: o visitante não consegue ler o que está incluso, para quem é o pacote ou prova visual editorial antes de converter. O mock aprovado (`amare-assessoria-completa.html`) define a jornada; falta rota CMS-driven `/pacotes/{slug}` alinhada a WEB-02/WEB-03 e ao padrão de detalhe do portfólio.
## What Changes
- Estender `WeddingPackage` com slug estável, campos editoriais de detalhe (hero, benefícios, itens inclusos, audiência, imagens, SEO opcional) sem remover `summary`/`scope_items` dos cards.
- Expor rota pública `packages.show` em `/pacotes/{slug}`: só publicados; rascunho → 404 (mesmo contrato de `portfolio.show`).
- Renderizar página de detalhe Heritage Editorial espelhando o mock (hero split, faixa de benefícios, grid “O que está incluso”, bloco audiência, CTA final WhatsApp contextual com fallback briefing).
- Incluir slugs publicados no sitemap; meta title/description/canonical/OG por pacote.
- Filament: seções de detalhe + catálogo fechado de ícones SVG; seeder preenche as 3 modalidades.
- Cards na home e em `/servicos` passam a linkar para o detalhe (navegação secundária); CTA primário permanece WhatsApp/briefing.
## Capabilities
### New Capabilities
- `wedding-packages`: modalidades publicáveis com detalhe por slug (baseline ainda não arquivada em `openspec/specs/`; esta change define o contrato de detalhe e reafirma cards/CTA já entregues).
### Modified Capabilities
- `public-site-pages`: incluir rota `packages.show` (`/pacotes/{slug}`) e regra de 404 para modalidade não publicada; cards de modalidade podem apontar ao detalhe.
- `public-seo`: sitemap e metadados de página para modalidades publicadas.
- `service-catalog`: listagem de serviços/casamentos referencia o detalhe da modalidade quando existir slug publicado.
## Impact
Migration/model/factory/seeder `WeddingPackage`; query `FindPublishedWeddingPackageBySlug`; controller + rota + view Blade; componentes de seção; `GetSitemapEntries` / PageMeta; Filament `WeddingPackageResource`; links em partials de packages (home/serviços); testes feature (200/404, sitemap, CTA, conteúdo). Atende extensão de WEB-01/WEB-02 e o padrão de detalhe de WEB-03. Sem page builder, sem índice `/pacotes`, sem integração oficial WhatsApp, sem CRM (SPEC.md §4.2).
## Não objetivos
- Índice de pacotes, substituir `/servicos`, builder genérico, preços dinâmicos, checkout, multi-idioma, Livewire no site público, inventar prova ou cases Corporate (SPEC.md §4.2).

View File

@@ -0,0 +1,84 @@
## MODIFIED Requirements
### Requirement: Every public page emits title, description and canonical
The system SHALL render a unique `<title>`, a `<meta name="description">` and a `<link rel="canonical">` on every public route (SPEC §6.6, §19). Portfolio cases and wedding modalities MUST use `meta_title`/`meta_description` when filled and fall back to title/name and summary/hero lead otherwise. Pages without page-level metadata MUST fall back to `default_meta_title` and `default_meta_description` from `site_settings`.
#### Scenario: Page-level metadata overrides defaults
- **GIVEN** a published case with `meta_title` and `meta_description` filled
- **WHEN** a visitor loads the case detail
- **THEN** the rendered title and description MUST use the case values
#### Scenario: Wedding modality metadata overrides defaults
- **GIVEN** a published wedding modality with `meta_title` and `meta_description` filled
- **WHEN** a visitor loads `/pacotes/{slug}`
- **THEN** the rendered title and description MUST use the modality values
#### Scenario: Missing metadata falls back to site defaults
- **GIVEN** a published case without `meta_title`
- **WHEN** a visitor loads the case detail
- **THEN** the rendered title MUST be derived from the case title
- **AND** the description MUST fall back to the case summary or the site default
#### Scenario: Canonical points to the absolute route URL
- **WHEN** any public page is rendered
- **THEN** the canonical URL MUST be the absolute URL of that route without query parameters
### Requirement: Open Graph metadata is emitted for sharing
The system SHALL emit Open Graph tags (`og:title`, `og:description`, `og:type`, `og:url`, `og:image`) on public pages. The image MUST use the page cover/hero image when available and `default_og_image_path` from `site_settings` otherwise.
#### Scenario: Case detail uses its cover as OG image
- **GIVEN** a published case with a cover image
- **WHEN** the case detail is rendered
- **THEN** `og:image` MUST reference the case cover image URL
#### Scenario: Wedding modality detail uses hero as OG image
- **GIVEN** a published wedding modality with a hero image
- **WHEN** `/pacotes/{slug}` is rendered
- **THEN** `og:image` MUST reference the modality hero image URL
#### Scenario: Pages without cover use the default OG image
- **WHEN** a page without its own image is rendered
- **THEN** `og:image` MUST reference `default_og_image_path`
### Requirement: Sitemap and robots are served by the application
The system SHALL serve `/sitemap.xml` listing the home, institutional routes, the services listing, the portfolio listing, every published case slug, and every published wedding modality slug with its last modification date. `/robots.txt` MUST be served by an application route referencing the sitemap URL.
#### Scenario: Sitemap contains only published slugs
- **GIVEN** one published case and one draft case
- **WHEN** `/sitemap.xml` is requested
- **THEN** the response MUST include the published slug
- **AND** MUST NOT include the draft slug
#### Scenario: Sitemap contains published wedding modality slugs
- **GIVEN** one published wedding modality and one draft wedding modality
- **WHEN** `/sitemap.xml` is requested
- **THEN** the response MUST include `/pacotes/{published-slug}`
- **AND** MUST NOT include the draft modality slug
#### Scenario: Newly published case enters the sitemap
- **WHEN** an admin publishes a case
- **THEN** the case slug MUST appear in `/sitemap.xml` on the next request
#### Scenario: Newly published wedding modality enters the sitemap
- **WHEN** an admin publishes a wedding modality
- **THEN** the modality package URL MUST appear in `/sitemap.xml` on the next request
#### Scenario: Robots references the sitemap
- **WHEN** `/robots.txt` is requested
- **THEN** the response MUST be `text/plain`
- **AND** MUST contain the absolute `/sitemap.xml` URL

View File

@@ -0,0 +1,57 @@
## MODIFIED Requirements
### Requirement: Public routes serve published content without authentication
The system SHALL expose the public routes of SPEC §5.1 plus the additive package detail route: `home` (`/`), `services.index` (`/servicos`), `packages.show` (`/pacotes/{slug}`), `portfolio.index` (`/portfolio`), `portfolio.show` (`/portfolio/{slug}`), `about` (`/sobre`), `contact` (`/contato`) and `privacy` (`/privacidade`). Every public route MUST respond without authentication and MUST NOT expose unpublished content, internal fields, or internal notes (SPEC §19).
#### Scenario: Guest reaches every public route
- **WHEN** an unauthenticated visitor requests any public route listed above that has published content where required
- **THEN** the response status MUST be 200
- **AND** no redirect to `/admin/login` MUST occur
#### Scenario: Unpublished content is invisible
- **GIVEN** a service, portfolio case, wedding modality, or testimonial with `published_at` null
- **WHEN** a visitor loads the corresponding public listing or home section
- **THEN** the record MUST NOT appear in the rendered output
#### Scenario: Unpublished case detail returns 404
- **GIVEN** a portfolio case saved as draft
- **WHEN** a visitor requests `/portfolio/{slug}` for that case
- **THEN** the response status MUST be 404
#### Scenario: Unpublished wedding modality detail returns 404
- **GIVEN** a wedding modality saved as draft
- **WHEN** a visitor requests `/pacotes/{slug}` for that modality
- **THEN** the response status MUST be 404
#### Scenario: Published case detail becomes reachable
- **GIVEN** a portfolio case saved as draft
- **WHEN** an admin fills the required fields and publishes the case
- **THEN** `/portfolio/{slug}` MUST respond 200
- **AND** the case MUST appear in the `/portfolio` listing
#### Scenario: Published wedding modality detail becomes reachable
- **GIVEN** a wedding modality saved as draft with required detail fields
- **WHEN** an admin publishes the modality
- **THEN** `/pacotes/{slug}` MUST respond 200
## ADDED Requirements
### Requirement: Wedding modality detail uses Heritage Editorial public layout
The system SHALL render `/pacotes/{slug}` with the public layout (header/footer shared), Heritage Editorial tokens, and section order: package hero (eyebrow, bipartite title, lead, outline secondary actions as designed, hero media), dark benefit strip, included-items grid, audience split (media + checklist), final conversion CTA. The page MUST be server-rendered Blade without a public Livewire dependency. Missing optional images MUST NOT break the page (omit or degrade the media slot).
#### Scenario: Detail page section order
- **GIVEN** a published modality with complete detail content
- **WHEN** a visitor loads `/pacotes/{slug}`
- **THEN** the document MUST include the hero, benefits, included items, audience and final CTA regions in that order
- **AND** MUST use the public layout chrome
#### Scenario: Detail page has no console errors
- **WHEN** the modality detail is loaded in a real browser at desktop and mobile viewports
- **THEN** the browser console MUST contain no JavaScript errors

View File

@@ -0,0 +1,9 @@
## ADDED Requirements
### Requirement: Services wedding section links to modality detail
When the services page renders published wedding modalities, each modality card SHALL expose a link to `/pacotes/{slug}` for published records that have a slug, while keeping the existing WhatsApp/briefing conversion CTA.
#### Scenario: Services card navigates to package detail
- **GIVEN** at least one published wedding modality with a slug
- **WHEN** a visitor loads `/servicos`
- **THEN** the wedding modalities region MUST include a link to that modality's `/pacotes/{slug}` URL

View File

@@ -0,0 +1,58 @@
## ADDED Requirements
### Requirement: Wedding modalities are managed as publishable ordered content
The system SHALL let an admin manage `WeddingPackage` records with name, unique slug, level, summary, ordered scope items, CTA label, sort order and `published_at`. Only published records SHALL appear in public wedding card sections, in ascending `sort_order`.
#### Scenario: Published modalities follow CMS order
- **GIVEN** published and draft wedding modalities with different sort orders
- **WHEN** a visitor loads the home or services page
- **THEN** only published modalities appear in ascending `sort_order`
#### Scenario: Official initial modalities are available
- **WHEN** the content seeder runs
- **THEN** Essenza, Conduzione and Grand Jour are created as the official initial modalities with stable unique slugs
### Requirement: Wedding modality CTA has a contextual channel and conversion fallback
The system SHALL build a WhatsApp deeplink containing the selected modality when `whatsapp_number` is valid. When it is missing or invalid, the CTA SHALL point to `/briefing` with the modality prefilled as `servico_interesse`. The same rule MUST apply on modality cards and on the modality detail page final CTA.
#### Scenario: Valid WhatsApp setting creates contextual link
- **GIVEN** a valid official WhatsApp number
- **WHEN** a visitor activates a wedding modality CTA on a card or detail page
- **THEN** the link targets `wa.me` with a URL-encoded message naming that modality
#### Scenario: Missing WhatsApp setting preserves briefing conversion
- **GIVEN** no valid official WhatsApp number
- **WHEN** a visitor activates a wedding modality CTA on a card or detail page
- **THEN** the visitor reaches `/briefing` with `servico_interesse` prefilled
### Requirement: Wedding modality detail content is CMS-managed
The system SHALL store per-modality detail fields: eyebrow, bipartite title (`title_line`, `title_emphasis`), hero lead, optional hero image with alt, ordered benefits (`icon_key`, label), ordered included items (`icon_key`, title, description), audience heading/intro/points and optional audience image with alt, final CTA heading/body, and optional `meta_title` / `meta_description`. Icon keys MUST come from a closed catalog enforced in admin validation. `summary` and `scope_items` MUST remain available for card surfaces.
#### Scenario: Admin can edit detail sections
- **WHEN** an admin opens a WeddingPackage in Filament
- **THEN** the form MUST expose detail sections for hero, benefits, included items, audience, final CTA and SEO
- **AND** icon fields MUST offer only catalog keys
#### Scenario: Seeder fills detail for official modalities
- **WHEN** the content seeder runs
- **THEN** each official modality MUST have slug and non-empty hero lead plus at least one benefit and one included item suitable for public render
### Requirement: Published modality detail page is publicly reachable by slug
The system SHALL expose `packages.show` at `/pacotes/{slug}` without authentication. Only modalities with non-null `published_at` in the past or present MUST resolve. Draft or unknown slugs MUST return HTTP 404 without exposing internal fields.
#### Scenario: Published modality detail returns 200
- **GIVEN** a published wedding modality with slug `essenza`
- **WHEN** a visitor requests `/pacotes/essenza`
- **THEN** the response status MUST be 200
- **AND** the page MUST render hero title parts, benefits, included items, audience block and final CTA using CMS values
#### Scenario: Draft modality detail returns 404
- **GIVEN** a wedding modality saved as draft
- **WHEN** a visitor requests `/pacotes/{slug}` for that modality
- **THEN** the response status MUST be 404
#### Scenario: Cards link to the detail page
- **GIVEN** a published modality with a slug
- **WHEN** a visitor views the home or services wedding cards
- **THEN** a navigation control MUST link to `/pacotes/{slug}`
- **AND** the primary conversion CTA MUST remain the WhatsApp or briefing channel

View File

@@ -0,0 +1,29 @@
## 1. Data model and domain catalog
- [x] 1.1 Migration additive em `wedding_packages`: `slug` unique (backfill a partir de `name`), campos de detalhe (eyebrow, title_line, title_emphasis, hero_lead, hero_image_*, benefits jsonb, included_items jsonb, audience_*, final_cta_*, meta_*), atualizar model `$fillable`/casts e factory. Evidência: `database/migrations/2026_08_12_120000_extend_wedding_packages_for_detail_pages.php` (roda limpo), `app/Models/WeddingPackage.php` (fillable+casts+booted slug), `database/factories/WeddingPackageFactory.php` (slug + campos de detalhe).
- [x] 1.2 `PackageIconCatalog` (chaves fechadas + labels pt-BR) e validação de `icon_key` em benefits/included_items. Evidência: `app/Domain/Marketing/PackageIconCatalog.php` (6 chaves: calendar/checklist/users/map/heart/spark), Select icon_key em `WeddingPackageForm` usa `PackageIconCatalog::labels()`.
- [x] 1.3 Feature/unit: factory published com slug; scope published inalterado; icon_key inválido rejeitado na validação admin/request path usado pelo Filament. Evidência: `WeddingPackageTest` (4 testes: scope published + seeder, factory slug autofill, icon catalog aceita/rejeita `paw`).
## 2. Application + public route
- [x] 2.1 Query `FindPublishedWeddingPackageBySlug` + teste 200/null para draft. Evidência: `app/Application/Queries/Marketing/FindPublishedWeddingPackageBySlug.php`; `FindPublishedWeddingPackageBySlugTest` (retorna published, null para draft/inexistente).
- [x] 2.2 `PackageController@show`, rota nomeada `packages.show` `/pacotes/{slug}`, PageMeta (meta_title/description fallback name/summary|hero_lead, OG hero). Evidência: `app/Http/Controllers/PublicSite/PackageController.php`, `routes/web.php` (`packages.show`), `PageMeta::forPackage` (title=meta_title??title_line??name, description=meta_description??summary, ogImageUrl=hero_image_path).
- [x] 2.3 Estender `GetSitemapEntries` com pacotes publicados; teste sitemap inclui/exclui draft. Evidência: `GetSitemapEntries` appends pacotes published; `SitemapTest::test_sitemap_includes_only_published_packages` (inclui `essenza`, exclui `rascunho-pacote`).
## 3. Blade detail (Heritage Editorial)
- [x] 3.1 View `pages/packages/show` + componentes `package-hero`, `package-benefits`, `package-included`, `package-audience`; layout `layouts.public`; tokens DESIGN.md (sem hex do mock). Evidência: `resources/views/pages/packages/show.blade.php` + `resources/views/components/public/package-{hero,benefits,included,audience}.blade.php` + `resources/views/components/package/icon.blade.php` (SVG de catálogo); classes `amare-*` e `text-hero-spread` (tokens), sem hex do mock.
- [x] 3.2 CTA final reutiliza builder WhatsApp/briefing dos cards; teste feature assert `wa.me` / `briefing?servico_interesse`. Evidência: `app/Support/PackageContactLink::for()` (helper compartilhado), `package-final-cta.blade.php` usa builder; `PackageDetailTest` (tests 4-5: wa.me com número, briefing fallback).
- [x] 3.3 Feature: render de seções a partir do CMS; 404 draft; guest sem auth. Evidência: `PackageDetailTest` (5 testes: render de seções CMS assertSee, 404 draft, 404 slug inexistente, CTA wa.me/briefing).
## 4. Filament + seeder + card links
- [x] 4.1 `WeddingPackageResource`: seções Hero, Benefícios, Inclusos, Audiência, CTA final, SEO; FileUpload imagens; Select icon_key do catálogo. Evidência: `app/Filament/Resources/WeddingPackages/Schemas/WeddingPackageForm.php` (sections Identificação/Hero/Diferenciais/Inclusos/Audiência/CTA final/SEO; `PublicImageUploadRules::fileUpload/altTextField`; `Select icon_key` options `PackageIconCatalog::labels()`); `WeddingPackagesTable` ganhou coluna `slug`.
- [x] 4.2 Seeder/update Essenza (copy mock), Conduzione e Grand Jour (estrutura paralela) com slugs `essenza`, `conduzione`, `grand-jour`. Evidência: `database/seeders/WeddingPackagesSeeder.php` (3 pacotes com slug + campos de detalhe completos); `WeddingPackageTest::test_content_seeder_creates_official_wedding_modalities` verde.
- [x] 4.3 Home + `/servicos` cards: link secundário para `packages.show`; CTA primário WA intacto; testes de regressão de CTA. Evidência: `home/packages.blade.php` + `home/wedding-packages.blade.php` + `services/index.blade.php` (link 'Conhecer esta modalidade' + CTA primário refatorado via `PackageContactLink`); `HomePageContentTest` (22 testes: 143 assertions, incl. href packages.show).
## 5. Verification gate
- [x] 5.1 `composer pint:check` + `composer phpstan` no escopo alterado. Evidência: `vendor/bin/pint --test` passou (fix `ordered_imports` em `routes/web.php`); `composer phpstan` (level 5, `--memory-limit=1G --debug`) = No errors.
- [x] 5.2 `composer test:unit` + `composer test:feature` verdes (incl. novos testes de pacote). Evidência: `test:unit` 42 passed (186 assertions); `test:feature` 193 passed (1182 assertions); `PackageDetailTest` (5), `FindPublishedWeddingPackageBySlugTest`, `SitemapTest` (3) verdes.
- [x] 5.3 `openspec validate package-detail-pages` e marcar tasks só com evidência. Evidência: `openspec validate package-detail-pages` = "Change 'package-detail-pages' is valid"; tasks 1.1-5.3 marcadas acima com referências.

View File

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

View File

@@ -0,0 +1,27 @@
# Design: remodel About page
## Context
Mock `amare-sobre(1).html` define composição e copy. O site já tem Heritage Editorial (`tokens.css`, `DESIGN.md`), `about_image_*` no CMS, e `PortfolioCase` featured na home.
## Decisions
1. **Hero** reusa `about_image_path` com layout split próprio (`x-about.hero`), não o full-viewport `x-public.photo-hero`. Fallback tonal quando sem imagem.
2. **Michele** usa `founder_image_*` novos; sem path → coluna tonal, sem request de imagem inventada.
3. **Portfólio** lê até 6 casos `published` + `is_featured` via `GetAboutContent` (mesma regra da home).
4. **Copy** de pilares/Michele/processo/CTA fixa do mock; `about_summary` alimenta lead/SEO.
5. **Visual**: estrutura do mock; radius 0, EB Garamond, botões `btn` existentes — desvios tipográficos/pill do HTML são descartados.
## Spine
```
PageController::about
→ GetAboutContent
→ AboutContent
→ pages/about.blade.php + x-about.*
```
## Risks
- Testes que acoplam `/sobre` a `data-photo-hero` full-vh precisam atualizar o contrato.
- Home positioning continua usando `about_image_path` — não reaproveitar esse campo como foto da Michele.

View File

@@ -0,0 +1,26 @@
# Remodel página Sobre conforme mock aprovado
## Why
A rota `/sobre` ainda usa abertura `photo-hero` full-viewport + lista de princípios. O cliente validou o mock `amare-sobre(1).html` com jornada editorial própria: hero split, pilares, bloco Michele, processo, portfólio em destaque e CTA. Sem essa remodelação, a página institucional fica desalinhada da home e do material aprovado.
## What Changes
- `/sobre` recomposta em seções `x-about.*` espelhando a ordem do mock (hero, pilares, Michele, processo, portfólio masonry, CTA).
- Novo campo CMS `founder_image_path` / `founder_image_alt` no singleton `site_settings` para a foto da Michele; hero continua em `about_image_path`.
- Query `GetAboutContent` + DTO `AboutContent` (featured cases ≤6) no spine Application.
- Tokens Heritage Editorial do repo (sem Inter/Cormorant, sem pills do HTML estático).
## Non-Goals
- CMS para copy dos pilares/Michele/processo.
- Replicar header/footer do HTML estático.
- Alterar tokens globais assertados por `HeritageEditorialTokensTest`.
- Gerar fotografia da Michele (apenas upload).
## Capabilities
### Modified Capabilities
- `public-site-pages`: `/sobre` com composição do mock; motion contract preservado.
- `site-settings`: campos `founder_image_path` e `founder_image_alt` administráveis no Filament.

View File

@@ -0,0 +1,32 @@
## ADDED Requirements
### Requirement: About page follows approved editorial composition
The `/sobre` route SHALL render the Heritage Editorial public layout with this section order: split hero (eyebrow “Sobre nós”, title “Sobre a Amare”, lead from `about_summary` or editorial default, CTA to `#michele`), three pillars, founder block `#michele` (Michele), process (“Como trabalhamos”), featured portfolio masonry (up to six published featured cases), and a final proposal CTA. The page MUST use shared motion markers (`data-motion="page-open"`, `data-reveal*`) without route transitions. Principles list MUST NOT appear on `/sobre` (home may still show them). Contact, privacy and error surfaces remain unchanged by this requirement.
#### Scenario: Visitor sees the mock section order
- **WHEN** a visitor loads `/sobre`
- **THEN** the response MUST include the headings for Sobre a Amare, the three pillars, Conheça Michele, Como trabalhamos, Portfólio em destaque, and the proposal CTA copy
- **AND** MUST NOT render the numbered principles list formerly used on About
#### Scenario: Founder photo comes from CMS when configured
- **GIVEN** `site_settings.founder_image_path` is set with alt text
- **WHEN** a visitor loads `/sobre`
- **THEN** the Michele section MUST render that image with the configured alt
- **AND** MUST use eager loading only for the about hero image, not invent a founder asset path when unset
#### Scenario: About hero uses about_image with tonal fallback
- **WHEN** `about_image_path` is configured
- **THEN** `/sobre` MUST render a split editorial hero with that media (`loading="eager"` and `fetchpriority="high"`)
- **WHEN** `about_image_path` is empty
- **THEN** `/sobre` MUST render a tonal hero fallback without an empty image request
#### Scenario: Featured portfolio tiles use real cases
- **GIVEN** published featured portfolio cases exist
- **WHEN** a visitor loads `/sobre`
- **THEN** the masonry MUST link to those cases (or the portfolio index)
- **AND** MUST NOT invent decorative photography when no cases exist (tonal slots allowed)

View File

@@ -0,0 +1,47 @@
## MODIFIED Requirements
### Requirement: Site settings singleton is manageable by admin only
The system SHALL persist site-wide settings in a `site_settings` table as a typed singleton (SPEC WEB-06, §8.2). Fields MUST include brand name, optional logo path and logo alt text, hero copy (eyebrow, title, subtitle, primary CTA label, optional secondary CTA label, optional hero note), manifesto copy (title, lead, body), method steps (structured typed data for four editorial steps), principles (structured typed list), about summary, optional about hero image path and alt text, optional founder image path and alt text (Michele portrait for `/sobre`), contact email/phone/city, social links (jsonb), default meta title/description, default OG image path and alt text, and optional analytics fields disabled by default.
#### Scenario: Admin updates site settings
- **WHEN** an admin saves the site settings form in Filament
- **THEN** the singleton record is updated
- **AND** labels and validation messages are in pt-BR
#### Scenario: Founder image upload requires alt text
- **WHEN** an admin uploads a founder image without alt text
- **THEN** validation MUST fail with a pt-BR error message
- **AND** alt text MUST remain optional when no founder image is present
#### Scenario: Assistant cannot access site settings
- **WHEN** an assistant navigates to site settings in Filament
- **THEN** access MUST be denied with HTTP 403
#### Scenario: Default OG image requires alt text
- **WHEN** an admin uploads a default OG image without alt text
- **THEN** validation MUST fail with a pt-BR error message
- **AND** alt text MUST remain optional when no default OG image is present
#### Scenario: Logo upload requires alt text
- **WHEN** an admin uploads a brand logo without alt text
- **THEN** validation MUST fail with a pt-BR error message
- **AND** alt text MUST remain optional when no logo is uploaded
#### Scenario: Singleton avoids generic key-value store
- **WHEN** site settings are stored
- **THEN** the system MUST use typed columns on `site_settings`
- **AND** MUST NOT introduce a generic key/value configuration table
#### Scenario: Editorial defaults remain available when optional fields are empty
- **GIVEN** manifesto, method steps or principles fields are empty
- **WHEN** the home is rendered
- **THEN** the page MUST still render those sections using safe editorial defaults
- **AND** MUST NOT error

View File

@@ -0,0 +1,8 @@
# Tasks: remodel-about-page
- [x] 1. Migration + SiteSetting fillable/PHPDoc para `founder_image_path` / `founder_image_alt`
- [x] 2. Filament ManageSiteSettings: labels hero Sobre + upload Michele; media variants + seeders
- [x] 3. AboutContent DTO + GetAboutContent; PageController injeta query
- [x] 4. Componentes `x-about.*` + reescrever `pages/about.blade.php`
- [x] 5. Feature tests About + atualizar ImmersivePhotoHero / Media / Motion / PublicPages / Filament alt
- [ ] 6. PR verde

View File

@@ -67,10 +67,13 @@ The home page SHALL render, in order: header/navigation, hero, manifesto, featur
### Requirement: Listing and detail pages exist for catalog content ### Requirement: Listing and detail pages exist for catalog content
The system SHALL render a services listing (WEB-02) and a portfolio listing plus case detail (WEB-03) using the Heritage Editorial visual language. The case detail MUST present summary, event type, optional city/venue/date, challenge, solution, optional result, cover image and the ordered gallery. Portfolio listings and galleries MUST use alternating editorial image proportions on desktop while retaining DOM order and a single-column readable sequence on mobile. The system SHALL render a services listing (WEB-02) and a portfolio listing plus case detail (WEB-03) using the Heritage Editorial visual language. The case detail MUST present summary, event type, optional city/venue/date, challenge, solution, optional result, cover image and the ordered gallery. Portfolio listings and galleries MUST use alternating editorial image proportions on desktop while retaining DOM order and a single-column readable sequence on mobile.
#### Scenario: Services listing shows published services #### Scenario: Services listing renders editorial sections with published modalities
- **WHEN** a visitor loads `/servicos` - **WHEN** a visitor loads `/servicos`
- **THEN** every published service MUST be listed with title and summary in `sort_order` - **THEN** the page MUST present the two vertentes (Amare Casamentos and Amare Corporate) with CTAs to their sections
- **AND** every published wedding modality MUST be rendered in `sort_order` with tag, name, subtitle, summary and scope
- **AND** a quick-read comparison of the modalities MUST use each modality's `compare_heading` and `compare_summary`
- **AND** unpublished modalities MUST NOT be rendered
- **AND** the listing MUST use the public editorial layout (not an unrelated visual system) - **AND** the listing MUST use the public editorial layout (not an unrelated visual system)
#### Scenario: Gallery respects stored order #### Scenario: Gallery respects stored order

View File

@@ -2,18 +2,18 @@
## Purpose ## Purpose
Define immutable staging and production promotion through GHCR and Dokploy Compose, including migration, health, smoke, rollback, and backup evidence. Define immutable staging and production promotion through the Gitea container registry (`git.hellomanoel.com`) and Dokploy Compose, including migration, health, smoke, rollback, and backup evidence.
## Requirements ## Requirements
### Requirement: Staging deploys an immutable application image by commit SHA ### Requirement: Staging deploys an immutable application image by commit SHA
The system SHALL deploy staging from a single FrankenPHP application image tagged with the Git commit SHA and published to GHCR (SPEC §14.3, §15.2). Web, queue worker, and scheduler processes MUST use that same image digest. Rebuilds per process on the staging host MUST NOT be the promotion path. The system SHALL deploy staging from a single FrankenPHP application image tagged with the Git commit SHA and published to the Gitea container registry (SPEC §14.3, §15.2). Web, queue worker, and scheduler processes MUST use that same image digest. Rebuilds per process on the staging host MUST NOT be the promotion path.
#### Scenario: Same image serves all application processes #### Scenario: Same image serves all application processes
- **WHEN** a staging deployment is promoted for commit SHA `abc123` - **WHEN** a staging deployment is promoted for commit SHA `abc123`
- **THEN** web, queue, and scheduler MUST run from `ghcr.io/<owner>/<repo>:abc123` (or equivalent digest) - **THEN** web, queue, and scheduler MUST run from `git.hellomanoel.com/<owner>/<repo>:abc123` (or equivalent digest)
- **AND** MUST NOT rebuild distinct images per process - **AND** MUST NOT rebuild distinct images per process
#### Scenario: CI publishes the image before Dokploy deploy #### Scenario: CI publishes the image before Dokploy deploy
@@ -69,7 +69,7 @@ Production SHALL be promoted from an already-published SHA-tagged image without
#### Scenario: Operator promotes a staging-approved SHA to production #### Scenario: Operator promotes a staging-approved SHA to production
- **WHEN** the operator confirms promotion of commit SHA `abc123` - **WHEN** the operator confirms promotion of commit SHA `abc123`
- **THEN** production web, queue, and scheduler MUST run the same digest previously published as `ghcr.io/<owner>/<repo>:abc123` - **THEN** production web, queue, and scheduler MUST run the same digest previously published as `git.hellomanoel.com/<owner>/<repo>:abc123`
- **AND** MUST NOT rebuild from source for that promotion - **AND** MUST NOT rebuild from source for that promotion
### Requirement: Database backups exist before production cutover ### Requirement: Database backups exist before production cutover

13
package-lock.json generated
View File

@@ -1,5 +1,5 @@
{ {
"name": "site-amare", "name": "daisyui",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
@@ -7,6 +7,7 @@
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.0.0", "@tailwindcss/vite": "^4.0.0",
"concurrently": "^9.0.1", "concurrently": "^9.0.1",
"daisyui": "^5.7.16",
"husky": "^9.1.7", "husky": "^9.1.7",
"laravel-vite-plugin": "^3.1", "laravel-vite-plugin": "^3.1",
"playwright": "^1.62.0", "playwright": "^1.62.0",
@@ -790,6 +791,16 @@
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1" "url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
} }
}, },
"node_modules/daisyui": {
"version": "5.7.16",
"resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.7.16.tgz",
"integrity": "sha512-V9CJxvrWIXTDuP/0tpijjWaKaKJNYo2IrULJfTWs3/DjW9rPqswk1n8NCLQRLrTutmlG2Ech7nkJDpCnF+6Dzw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/saadeghi/daisyui?sponsor=1"
}
},
"node_modules/detect-libc": { "node_modules/detect-libc": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",

View File

@@ -10,6 +10,7 @@
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.0.0", "@tailwindcss/vite": "^4.0.0",
"concurrently": "^9.0.1", "concurrently": "^9.0.1",
"daisyui": "^5.7.16",
"husky": "^9.1.7", "husky": "^9.1.7",
"laravel-vite-plugin": "^3.1", "laravel-vite-plugin": "^3.1",
"playwright": "^1.62.0", "playwright": "^1.62.0",

View File

@@ -1,6 +1,46 @@
@import 'tailwindcss'; @import 'tailwindcss';
@import './tokens.css'; @import './tokens.css';
@plugin "daisyui" {
themes: false;
}
@plugin "daisyui/theme" {
name: "amare";
default: true;
color-scheme: light;
--color-base-100: var(--amare-color-bg);
--color-base-200: var(--amare-color-bg-deep);
--color-base-300: var(--amare-color-bg-archive);
--color-base-content: var(--amare-color-text);
--color-primary: var(--amare-color-accent);
--color-primary-content: var(--amare-color-accent-text);
--color-secondary: var(--amare-color-sage);
--color-secondary-content: var(--amare-color-text);
--color-accent: var(--amare-color-accent-deep);
--color-accent-content: var(--amare-color-accent-text);
--color-neutral: var(--amare-color-muted);
--color-neutral-content: var(--amare-color-accent-text);
--color-info: #2563eb;
--color-info-content: #ffffff;
--color-success: var(--amare-color-success);
--color-success-content: var(--amare-color-accent-text);
--color-warning: var(--amare-color-warning);
--color-warning-content: var(--amare-color-accent-text);
--color-error: var(--amare-color-error);
--color-error-content: var(--amare-color-accent-text);
--radius-selector: 0;
--radius-field: 0;
--radius-box: 0;
--size-selector: 0.25rem;
--size-field: 0.25rem;
--border: 1px;
--depth: 0;
--noise: 0;
}
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php'; @source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
@source '../../storage/framework/views/*.php'; @source '../../storage/framework/views/*.php';
@source '../views/**/*.blade.php'; @source '../views/**/*.blade.php';
@@ -94,7 +134,8 @@
overflow-wrap: break-word; overflow-wrap: break-word;
} }
[id$='-heading'] { [id$='-heading'],
.home-chapter[id] {
scroll-margin-top: 5.5rem; scroll-margin-top: 5.5rem;
} }
@@ -227,3 +268,11 @@
} }
} }
} }
/* daisyUI emits unlayered; match focus ring to the amare accent. */
.input:focus,
.select:focus,
.textarea:focus,
.checkbox:focus-visible {
--input-color: var(--amare-color-accent);
}

View File

@@ -0,0 +1,16 @@
@props([
'settings',
])
<section aria-labelledby="about-cta-heading" class="border-b border-amare-border bg-amare-bg-deep py-20 md:py-24" data-about-cta data-reveal-group>
<div class="container-amare max-w-4xl space-y-6" data-reveal data-reveal-from="up">
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Próximo passo</p>
<h2 id="about-cta-heading" class="text-headline font-medium text-amare-text">Vamos criar algo inesquecível juntos?</h2>
<p class="max-w-2xl text-amare-muted">Conte com a Amare para transformar seu evento em uma experiência organizada, cuidadosa e memorável.</p>
<div>
<a href="{{ route('briefing') }}" class="btn btn-primary text-sm font-semibold uppercase tracking-[0.12em]">
{{ $settings->hero_cta_label ?: 'Solicitar proposta' }}
</a>
</div>
</div>
</section>

View File

@@ -0,0 +1,40 @@
@props([
'settings',
])
@php
$hasImage = filled($settings->founder_image_path);
@endphp
<section id="michele" aria-labelledby="founder-heading" class="border-b border-amare-border bg-amare-bg" data-about-founder>
<div class="grid min-h-[500px] lg:grid-cols-12" data-reveal-group>
@if ($hasImage)
<div class="min-h-[420px] overflow-hidden bg-amare-bg-deep max-lg:aspect-[4/5] lg:col-span-6 lg:min-h-full" data-reveal-media>
<x-media.image
:path="$settings->founder_image_path"
:alt="$settings->founder_image_alt ?: 'Michele, da Amare'"
sizes="(max-width: 1023px) 100vw, 50vw"
class="img-editorial h-full min-h-[420px] w-full object-cover object-[center_35%] lg:min-h-full"
/>
</div>
@else
<div class="flex min-h-[420px] items-center justify-center bg-amare-bg-deep lg:col-span-6 lg:min-h-full" data-founder-tonal>
<p class="px-4 text-center text-xs font-semibold uppercase tracking-[0.16em] text-amare-muted">Retrato da Michele</p>
</div>
@endif
<div class="flex flex-col justify-center px-6 py-16 lg:col-span-6 lg:px-[clamp(3rem,6vw,7rem)]" data-reveal data-reveal-from="up">
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">A pessoa por trás da experiência</p>
<h2 id="founder-heading" class="mt-3 text-headline font-medium text-amare-text">Conheça Michele</h2>
<p class="mt-5 max-w-[520px] text-lg leading-relaxed text-amare-text">Michele representa o olhar humano da Amare: escuta, organização e presença durante todo o processo de construção do evento.</p>
<p class="mt-3.5 max-w-[520px] text-lg leading-relaxed text-amare-text-muted">Seu papel é transformar necessidades e escolhas em um planejamento claro, cuidando dos detalhes sem perder de vista a experiência de quem contrata e de quem participa.</p>
<div class="mt-8 flex max-w-[500px] flex-col items-start justify-between gap-4 border border-amare-border bg-amare-bg-deep px-5 py-4 sm:flex-row sm:items-center">
<div>
<strong class="block text-[2rem] font-normal italic text-amare-text">Michele</strong>
<small class="text-xs font-semibold uppercase tracking-[0.08em] text-amare-muted">Assessoria e produção de eventos</small>
</div>
<a href="{{ route('briefing') }}" class="btn btn-outline shrink-0 text-xs font-bold uppercase tracking-[0.11em]">Falar com a Michele</a>
</div>
</div>
</div>
</section>

View File

@@ -0,0 +1,50 @@
@props([
'settings',
])
@php
$lead = $settings->about_summary ?: 'Eventos que refletem propósito, conectam pessoas e criam memórias.';
$hasImage = filled($settings->about_image_path);
@endphp
<section aria-labelledby="about-hero-heading" class="border-b border-amare-border bg-amare-bg" data-about-hero data-motion="page-open">
@if ($hasImage)
<div class="grid min-h-[440px] lg:grid-cols-12">
<div class="flex min-w-0 items-center bg-amare-bg px-6 py-16 lg:col-span-5 lg:px-[clamp(3rem,6vw,7rem)]" data-reveal-group>
<div class="w-full max-w-[470px] space-y-6">
<p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent" data-motion-beat="seal">Sobre nós</p>
<h1 id="about-hero-heading" class="max-w-[470px] whitespace-normal break-normal text-hero-spread font-medium text-amare-text" data-motion-beat="title">Sobre a Amare</h1>
<p class="max-w-[450px] text-[clamp(1.25rem,2vw,1.55rem)] italic leading-snug text-amare-text-muted" data-motion-beat="lede">{{ $lead }}</p>
<span class="block h-9 w-px bg-amare-accent" aria-hidden="true"></span>
<p class="max-w-[430px] text-lg leading-relaxed text-amare-text">A Amare une sensibilidade, organização e atenção aos detalhes para criar experiências bem planejadas, do primeiro encontro à execução do evento.</p>
<div data-motion-beat="cta">
<a href="#michele" class="btn btn-outline text-xs font-bold uppercase tracking-[0.11em]">Conhecer mais</a>
</div>
</div>
</div>
<div class="min-h-[360px] overflow-hidden bg-amare-bg-deep max-lg:aspect-[4/5] lg:col-span-7 lg:min-h-0" data-motion-beat="media" data-reveal-media>
<x-media.image
:path="$settings->about_image_path"
:alt="$settings->about_image_alt ?: 'Sobre a Amare'"
loading="eager"
fetchpriority="high"
sizes="(max-width: 1023px) 100vw, 58vw"
class="img-editorial h-full min-h-[360px] w-full object-cover lg:min-h-[440px]"
/>
</div>
</div>
@else
<div class="container-amare py-16 md:py-24" data-tonal-hero>
<div class="max-w-[470px] space-y-5" data-motion-beat="heading">
<p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent" data-motion-beat="seal">Sobre nós</p>
<h1 id="about-hero-heading" class="text-headline font-medium tracking-tight text-amare-text" data-motion-beat="title">Sobre a Amare</h1>
<p class="text-lg italic leading-snug text-amare-text-muted" data-motion-beat="lede">{{ $lead }}</p>
<span class="block h-9 w-px bg-amare-accent" aria-hidden="true"></span>
<p class="text-lg leading-relaxed text-amare-text">A Amare une sensibilidade, organização e atenção aos detalhes para criar experiências bem planejadas, do primeiro encontro à execução do evento.</p>
<div data-motion-beat="cta">
<a href="#michele" class="btn btn-outline text-xs font-bold uppercase tracking-[0.11em]">Conhecer mais</a>
</div>
</div>
</div>
@endif
</section>

View File

@@ -0,0 +1,31 @@
<section aria-label="Pilares da Amare" class="border-b border-amare-border bg-amare-bg" data-about-pillars>
<div class="container-amare py-16 md:py-24" data-reveal-group>
<div class="grid border-l border-t border-amare-border md:grid-cols-3">
<article class="flex flex-col gap-4 border-b border-r border-amare-border p-8 text-center md:p-10" data-reveal data-reveal-from="up">
<svg class="mx-auto h-[34px] w-[34px] text-amare-muted" viewBox="0 0 48 48" fill="none" stroke="currentColor" aria-hidden="true">
<circle cx="18" cy="16" r="6"/>
<circle cx="30" cy="16" r="6"/>
<path d="M7 37c1-8 5-12 11-12s10 4 11 12M21 37c1-8 5-12 11-12 5 0 9 4 10 12"/>
</svg>
<h3 class="text-base font-medium uppercase tracking-[0.05em] text-amare-text">Atendimento próximo</h3>
<p class="mx-auto max-w-[280px] text-amare-text-muted">Escuta atenta para compreender prioridades, contexto e expectativas de cada evento.</p>
</article>
<article class="flex flex-col gap-4 border-b border-r border-amare-border p-8 text-center md:p-10" data-reveal data-reveal-from="up">
<svg class="mx-auto h-[34px] w-[34px] text-amare-muted" viewBox="0 0 48 48" fill="none" stroke="currentColor" aria-hidden="true">
<rect x="10" y="7" width="28" height="34" rx="2"/>
<path d="M17 15h14M17 22h14M17 29h8M29 29h2"/>
</svg>
<h3 class="text-base font-medium uppercase tracking-[0.05em] text-amare-text">Planejamento minucioso</h3>
<p class="mx-auto max-w-[280px] text-amare-text-muted">Organização clara das etapas para transformar decisões em uma execução consistente.</p>
</article>
<article class="flex flex-col gap-4 border-b border-r border-amare-border p-8 text-center md:p-10" data-reveal data-reveal-from="up">
<svg class="mx-auto h-[34px] w-[34px] text-amare-muted" viewBox="0 0 48 48" fill="none" stroke="currentColor" aria-hidden="true">
<path d="M24 40C10 32 8 20 8 12c8 0 14 4 16 11 2-7 8-11 16-11 0 8-2 20-16 28Z"/>
<path d="M24 23v17"/>
</svg>
<h3 class="text-base font-medium uppercase tracking-[0.05em] text-amare-text">Execução tranquila</h3>
<p class="mx-auto max-w-[280px] text-amare-text-muted">Cuidado com a operação para que anfitriões e convidados possam viver o momento.</p>
</article>
</div>
</div>
</section>

View File

@@ -0,0 +1,64 @@
@props([
'cases',
])
@php
$slots = collect([
['label' => 'Eventos sociais', 'tall' => true],
['label' => 'Celebrações', 'tall' => false],
['label' => 'Detalhes', 'tall' => false],
['label' => 'Sociais', 'tall' => false],
['label' => 'Corporativo', 'tall' => false],
['label' => 'Produção', 'tall' => false],
]);
$cases = $cases->take(6)->values();
@endphp
<section id="portfolio" aria-labelledby="about-portfolio-heading" class="border-b border-amare-border bg-amare-bg" data-about-portfolio>
<div class="container-amare py-16 md:py-24" data-reveal-group>
<div class="grid gap-10 lg:grid-cols-12 lg:items-end" data-reveal data-reveal-from="up">
<div class="lg:col-span-5">
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Seleção de trabalhos</p>
<h2 id="about-portfolio-heading" class="mt-3 text-headline font-medium text-amare-text">Portfólio em destaque</h2>
</div>
<div class="lg:col-span-6 lg:col-start-7">
<p class="max-w-xl text-amare-text-muted">Uma composição de eventos sociais e corporativos para apresentar a versatilidade da Amare.</p>
<a href="{{ route('portfolio.index') }}" class="btn btn-ghost mt-5 min-h-0 px-0 text-xs font-bold uppercase tracking-[0.09em] text-amare-accent hover:bg-transparent hover:text-amare-accent-deep">
<span class="border-b border-amare-accent pb-1">Ver portfólio completo</span>
</a>
</div>
</div>
<div class="mt-12 grid grid-cols-1 gap-[14px] sm:grid-cols-2 lg:grid-cols-[1.2fr_0.8fr_0.8fr]" aria-label="Projetos em destaque" data-editorial-portfolio data-reveal-group>
@foreach ($slots as $index => $slot)
@php
$case = $cases->get($index);
@endphp
<div
@class([
'overflow-hidden bg-amare-bg-deep',
'sm:col-span-2 sm:min-h-[420px] lg:col-span-1 lg:row-span-2 lg:min-h-[634px]' => $slot['tall'],
'min-h-[220px] sm:min-h-[310px]' => ! $slot['tall'],
])
data-reveal
data-reveal-from="up"
>
@if ($case && filled($case->cover_image_path))
<a href="{{ route('portfolio.show', $case) }}" class="block h-full w-full" aria-label="{{ $case->title }}">
<x-media.image
:path="$case->cover_image_path"
:alt="$case->cover_image_alt ?: $case->title"
sizes="(max-width: 1023px) 100vw, 33vw"
class="img-editorial h-full w-full object-cover transition-opacity hover:opacity-90"
/>
</a>
@else
<div class="flex h-full w-full items-center justify-center">
<p class="px-4 text-center text-xs font-semibold uppercase tracking-[0.16em] text-amare-muted">{{ $slot['label'] }}</p>
</div>
@endif
</div>
@endforeach
</div>
</div>
</section>

View File

@@ -0,0 +1,30 @@
<section aria-labelledby="process-heading" class="border-b border-amare-border bg-amare-bg" data-about-process>
<div class="container-amare py-16 md:py-24" data-reveal-group>
<h2 id="process-heading" class="mb-10 text-center text-headline font-medium text-amare-text" data-reveal data-reveal-from="up">Como trabalhamos</h2>
<div class="grid border-l border-t border-amare-border md:grid-cols-3">
<article class="flex flex-col gap-3 border-b border-r border-amare-border p-8 text-center md:p-10" data-reveal data-reveal-from="up">
<svg class="mx-auto h-[34px] w-[34px] text-amare-muted" viewBox="0 0 48 48" fill="none" stroke="currentColor" aria-hidden="true">
<path d="M15 29c-5-4-6-12-2-17 5-7 17-7 22 0 4 6 2 14-4 18-2 1-3 4-3 7H20c0-4-2-6-5-8Z"/>
<path d="M20 41h8"/>
</svg>
<h3 class="text-base font-medium uppercase tracking-[0.04em] text-amare-text">Escuta e entendimento</h3>
<p class="mx-auto max-w-[280px] text-amare-text-muted">Começamos pelo contexto do evento, suas prioridades, estilo e expectativas.</p>
</article>
<article class="flex flex-col gap-3 border-b border-r border-amare-border p-8 text-center md:p-10" data-reveal data-reveal-from="up">
<svg class="mx-auto h-[34px] w-[34px] text-amare-muted" viewBox="0 0 48 48" fill="none" stroke="currentColor" aria-hidden="true">
<rect x="8" y="11" width="32" height="29" rx="2"/>
<path d="M15 7v8M33 7v8M8 20h32M15 26h4M23 26h4M31 26h3M15 33h4M23 33h4"/>
</svg>
<h3 class="text-base font-medium uppercase tracking-[0.04em] text-amare-text">Planejamento e curadoria</h3>
<p class="mx-auto max-w-[280px] text-amare-text-muted">Organizamos etapas, fornecedores e decisões para manter cada frente alinhada.</p>
</article>
<article class="flex flex-col gap-3 border-b border-r border-amare-border p-8 text-center md:p-10" data-reveal data-reveal-from="up">
<svg class="mx-auto h-[34px] w-[34px] text-amare-muted" viewBox="0 0 48 48" fill="none" stroke="currentColor" aria-hidden="true">
<path d="M24 39S8 30 8 18c0-6 4-10 10-10 4 0 7 2 9 6 2-4 5-6 9-6 6 0 10 4 10 10 0 12-22 21-22 21Z"/>
</svg>
<h3 class="text-base font-medium uppercase tracking-[0.04em] text-amare-text">Execução e experiência</h3>
<p class="mx-auto max-w-[280px] text-amare-text-muted">Coordenamos a operação para que o evento aconteça com fluidez e presença.</p>
</article>
</div>
</div>
</section>

View File

@@ -0,0 +1,246 @@
@props([])
<section
aria-labelledby="briefing-heading"
class="home-chapter border-b border-amare-border bg-amare-bg"
data-chapter="contato"
id="contato"
>
<div class="container-amare grid gap-12 py-16 md:py-24 lg:grid-cols-[0.8fr_1.2fr] lg:gap-[80px]" data-reveal-group>
<div class="flex flex-col gap-6" data-reveal data-reveal-from="up">
<div class="space-y-4">
<p class="text-xs font-bold uppercase tracking-[0.16em] text-amare-accent-deep">07 Briefing comercial</p>
<h2 id="briefing-heading" class="text-[clamp(2.375rem,5vw,4rem)] font-medium leading-[1.05] tracking-[-0.02em] text-amare-text">Conte sobre o evento que você está imaginando.</h2>
</div>
<p class="max-w-[760px] text-[clamp(1.1875rem,2vw,1.5rem)] leading-[1.55] text-amare-text-muted">Este formulário é para potenciais clientes. Ele continua existindo mesmo com os CTAs dos pacotes: são portas de entrada complementares.</p>
<p class="max-w-xl text-sm text-amare-muted">Para quem sabe a modalidade, WhatsApp contextual. Para quem quer contextualizar o projeto, briefing. Para fornecedores/parcerias, outro fluxo.</p>
</div>
<form
method="POST"
action="{{ route('briefing.store') }}"
class="flex flex-col gap-[18px]"
data-contact-form
data-reveal
data-reveal-from="up"
>
@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="alert alert-error border border-amare-error/40">
<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-[18px] sm:grid-cols-2">
<div>
<label for="briefing-nome" class="mb-2 block text-xs font-bold uppercase tracking-[0.1em] text-amare-accent-deep">Nome *</label>
<input
type="text"
id="briefing-nome"
name="nome"
value="{{ old('nome') }}"
required
autocomplete="name"
maxlength="120"
placeholder="Seu nome"
class="input w-full text-amare-text placeholder:text-amare-muted/60 @error('nome') input-error @enderror"
@error('nome') aria-invalid="true" aria-describedby="briefing-nome-error" @enderror
>
@error('nome')
<p id="briefing-nome-error" class="mt-2 text-sm text-amare-error">{{ $message }}</p>
@enderror
</div>
<div>
<label for="briefing-email" class="mb-2 block text-xs font-bold uppercase tracking-[0.1em] text-amare-accent-deep">E-mail *</label>
<input
type="email"
id="briefing-email"
name="email"
value="{{ old('email') }}"
required
autocomplete="email"
maxlength="254"
placeholder="voce@email.com"
class="input w-full text-amare-text placeholder:text-amare-muted/60 @error('email') input-error @enderror"
@error('email') aria-invalid="true" aria-describedby="briefing-email-error" @enderror
>
@error('email')
<p id="briefing-email-error" class="mt-2 text-sm text-amare-error">{{ $message }}</p>
@enderror
</div>
<div>
<label for="briefing-telefone" class="mb-2 block text-xs font-bold uppercase tracking-[0.1em] text-amare-accent-deep">Telefone/WhatsApp *</label>
<input
type="tel"
id="briefing-telefone"
name="telefone"
value="{{ old('telefone') }}"
required
autocomplete="tel"
maxlength="40"
placeholder="(11) 90000-0000"
class="input w-full text-amare-text placeholder:text-amare-muted/60 @error('telefone') input-error @enderror"
@error('telefone') aria-invalid="true" aria-describedby="briefing-telefone-error" @enderror
>
@error('telefone')
<p id="briefing-telefone-error" class="mt-2 text-sm text-amare-error">{{ $message }}</p>
@enderror
</div>
<div>
<label for="briefing-tipo_evento" class="mb-2 block text-xs font-bold uppercase tracking-[0.1em] text-amare-accent-deep">Tipo de evento *</label>
<select
id="briefing-tipo_evento"
name="tipo_evento"
required
class="select w-full text-amare-text @error('tipo_evento') select-error @enderror"
@error('tipo_evento') aria-invalid="true" aria-describedby="briefing-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="briefing-tipo_evento-error" class="mt-2 text-sm text-amare-error">{{ $message }}</p>
@enderror
</div>
<div>
<label for="briefing-data_periodo" class="mb-2 block text-xs font-bold uppercase tracking-[0.1em] text-amare-accent-deep">Data ou período desejado</label>
<input
type="text"
id="briefing-data_periodo"
name="data_periodo"
value="{{ old('data_periodo') }}"
maxlength="80"
placeholder="Ex.: novembro de 2027"
class="input w-full text-amare-text placeholder:text-amare-muted/60 @error('data_periodo') input-error @enderror"
@error('data_periodo') aria-invalid="true" aria-describedby="briefing-data_periodo-error" @enderror
>
@error('data_periodo')
<p id="briefing-data_periodo-error" class="mt-2 text-sm text-amare-error">{{ $message }}</p>
@enderror
</div>
<div>
<label for="briefing-cidade" class="mb-2 block text-xs font-bold uppercase tracking-[0.1em] text-amare-accent-deep">Cidade do evento *</label>
<input
type="text"
id="briefing-cidade"
name="cidade"
value="{{ old('cidade') }}"
required
maxlength="80"
placeholder="Ex.: São Paulo"
class="input w-full text-amare-text placeholder:text-amare-muted/60 @error('cidade') input-error @enderror"
@error('cidade') aria-invalid="true" aria-describedby="briefing-cidade-error" @enderror
>
@error('cidade')
<p id="briefing-cidade-error" class="mt-2 text-sm text-amare-error">{{ $message }}</p>
@enderror
</div>
<div>
<label for="briefing-convidados" class="mb-2 block text-xs font-bold uppercase tracking-[0.1em] text-amare-accent-deep">Número estimado de convidados</label>
<input
type="number"
id="briefing-convidados"
name="convidados"
value="{{ old('convidados') }}"
min="1"
max="100000"
inputmode="numeric"
autocomplete="off"
placeholder="Ex.: 120"
class="input w-full text-amare-text placeholder:text-amare-muted/60 @error('convidados') input-error @enderror"
@error('convidados') aria-invalid="true" aria-describedby="briefing-convidados-error" @enderror
>
@error('convidados')
<p id="briefing-convidados-error" class="mt-2 text-sm text-amare-error">{{ $message }}</p>
@enderror
</div>
<div>
<label for="briefing-servico_interesse" class="mb-2 block text-xs font-bold uppercase tracking-[0.1em] text-amare-accent-deep">Serviço de interesse</label>
<input
type="text"
id="briefing-servico_interesse"
name="servico_interesse"
value="{{ old('servico_interesse') }}"
maxlength="120"
placeholder="Ex.: planejamento completo"
class="input w-full text-amare-text placeholder:text-amare-muted/60 @error('servico_interesse') input-error @enderror"
@error('servico_interesse') aria-invalid="true" aria-describedby="briefing-servico_interesse-error" @enderror
>
@error('servico_interesse')
<p id="briefing-servico_interesse-error" class="mt-2 text-sm text-amare-error">{{ $message }}</p>
@enderror
</div>
</div>
<div>
<label for="briefing-mensagem" class="mb-2 block text-xs font-bold uppercase tracking-[0.1em] text-amare-accent-deep">Mensagem / principal preocupação *</label>
<textarea
id="briefing-mensagem"
name="mensagem"
required
rows="5"
maxlength="3000"
placeholder="O que já está definido? Em que ponto você precisa de ajuda?"
class="textarea w-full min-h-[110px] text-amare-text placeholder:text-amare-muted/60 @error('mensagem') textarea-error @enderror"
@error('mensagem') aria-invalid="true" aria-describedby="briefing-mensagem-error" @enderror
>{{ old('mensagem') }}</textarea>
@error('mensagem')
<p id="briefing-mensagem-error" class="mt-2 text-sm text-amare-error">{{ $message }}</p>
@enderror
</div>
<div>
<label for="briefing-privacidade" class="flex items-start gap-3 py-2">
<input
type="checkbox"
id="briefing-privacidade"
name="privacidade"
value="1"
required
@checked(old('privacidade'))
class="checkbox checkbox-primary mt-1 shrink-0"
@error('privacidade') aria-invalid="true" aria-describedby="briefing-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="briefing-privacidade-error" class="mt-2 text-sm text-amare-error">{{ $message }}</p>
@enderror
</div>
<div class="flex flex-col items-start gap-4 pt-2">
<button
type="submit"
data-submit-button
class="btn btn-primary text-xs font-bold uppercase tracking-[0.09em]"
>
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>
</div>
</section>

View File

@@ -1,3 +1,61 @@
<section id="corporate" aria-labelledby="corporate-heading" class="home-chapter border-b border-amare-accent-deep bg-amare-accent-deep py-24 text-amare-accent-text" data-chapter="corporate"> @props([
<div class="container-amare grid gap-8 md:grid-cols-12" data-reveal-group><div class="md:col-span-5" data-reveal data-reveal-from="left"><p class="text-xs font-semibold uppercase tracking-[.14em] text-amare-accent-text">Corporate</p><h2 id="corporate-heading" class="mt-3 text-headline font-medium">Encontros que pedem intenção, método e presença.</h2></div><div class="space-y-6 md:col-span-5 md:col-start-7" data-reveal data-reveal-from="up"><p class="text-amare-accent-text/80">A Amare conduz projetos corporativos com escuta do contexto, clareza de direção e atenção à experiência de cada pessoa presente.</p><a href="{{ route('briefing', ['tipo_evento' => 'Evento corporativo']) }}" class="inline-flex min-h-11 items-center text-sm font-semibold"><span class="border-b border-amare-accent-text pb-1">Iniciar uma conversa</span></a></div></div> 'settings',
'eyebrow' => '04 — Amare Corporate',
'heading' => 'Projetos corporativos tratados como experiência, não apenas operação.',
'meta' => 'No MVP, esta frente pode apresentar claramente o que a Amare oferece mesmo sem inventar um portfólio corporativo que ainda não existe.',
'steps' => null,
'showMeta' => true,
'ctaLabel' => 'Falar sobre um evento corporativo',
'ctaHref' => '#contato',
'showAside' => true,
])
@php
$steps = $steps ?? ($settings->corporate_steps ?? []);
@endphp
<section
aria-labelledby="corporate-heading"
class="home-chapter border-b border-amare-border bg-amare-bg"
data-chapter="corporate"
id="corporate"
>
<div class="container-amare grid gap-12 py-16 md:py-24 @if ($showAside) lg:grid-cols-2 lg:gap-[70px] @endif" data-reveal-group>
<div class="flex flex-col gap-8">
<div class="space-y-4" data-reveal data-reveal-from="up">
<p class="text-xs font-bold uppercase tracking-[0.16em] text-amare-accent-deep">{{ $eyebrow }}</p>
<h2 id="corporate-heading" class="text-[clamp(2.375rem,5vw,4rem)] font-medium leading-[1.05] tracking-[-0.02em] text-amare-text">{{ $heading }}</h2>
</div>
@if ($showMeta && filled($meta))
<p class="max-w-[760px] text-[clamp(1.1875rem,2vw,1.5rem)] leading-[1.55] text-amare-text-muted" data-reveal data-reveal-from="up">
{{ $meta }}
</p>
@endif
<div class="border-t border-amare-border" data-reveal data-reveal-from="up">
@foreach ($steps as $index => $step)
<div class="flex gap-8 border-b border-amare-border py-[23px]">
<p class="w-[44px] shrink-0 text-xs font-bold uppercase tracking-[0.16em] text-amare-accent">{{ str_pad((string) ($index + 1), 2, '0', STR_PAD_LEFT) }}</p>
<div class="space-y-1">
<h3 class="text-xl font-medium text-amare-text">{{ $step['title'] }}</h3>
<p class="text-amare-text-muted">{{ $step['body'] }}</p>
</div>
</div>
@endforeach
</div>
<a href="{{ $ctaHref }}" class="btn btn-primary mt-7 self-start text-xs font-bold uppercase tracking-[0.09em]" data-reveal data-reveal-from="up">
{{ $ctaLabel }}
</a>
</div>
@if ($showAside)
<div class="flex min-h-[420px] flex-col items-center justify-center gap-4 bg-amare-bg-deep p-10 text-center" data-reveal data-reveal-from="up">
<p class="text-xs font-bold uppercase tracking-[0.16em] text-amare-accent-deep">Portfólio Corporate</p>
<h3 class="text-[clamp(1.5625rem,2.7vw,2.125rem)] font-medium leading-tight text-amare-text">Conteúdo em construção</h3>
<p class="max-w-sm text-amare-text-muted">Em vez de usar fotografias genéricas como se fossem cases, a página assume com elegância que o portfólio será construído com projetos reais.</p>
</div>
@endif
</div>
</section> </section>

View File

@@ -1,6 +1,7 @@
@props([ @props([
'settings', 'settings',
'editorial' => false, 'editorial' => false,
'ctaHref' => null,
]) ])
<section aria-labelledby="final-cta-heading" @class([ <section aria-labelledby="final-cta-heading" @class([
@@ -17,8 +18,8 @@
</p> </p>
<div> <div>
<a <a
href="{{ route('briefing') }}" href="{{ $ctaHref ?? route('briefing') }}"
class="inline-flex min-h-11 items-center bg-amare-accent px-5 py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-accent-text transition-colors duration-(--amare-duration-normal) ease-(--amare-ease-standard) hover:bg-amare-accent-deep" class="btn btn-primary text-sm font-semibold uppercase tracking-[0.12em]"
> >
{{ $settings->hero_cta_label ?: 'Solicitar proposta' }} {{ $settings->hero_cta_label ?: 'Solicitar proposta' }}
</a> </a>

View File

@@ -3,32 +3,28 @@
]) ])
<x-public.photo-hero <x-public.photo-hero
@class(['home-chapter'])
data-chapter="hero"
:image-path="$settings->hero_image_path" :image-path="$settings->hero_image_path"
:image-alt="$settings->hero_image_alt" :image-alt="$settings->hero_image_alt ?: $settings->brand_name"
:eyebrow="$settings->hero_eyebrow" :eyebrow="$settings->hero_eyebrow"
:title="$settings->hero_title ?: 'Celebrações com propósito'" :title="$settings->hero_title ?: 'Celebrações com propósito'"
:summary="$settings->hero_subtitle" :summary="$settings->hero_subtitle"
heading-id="hero-heading"
brand-mark brand-mark
full-height-fallback
class="home-chapter"
data-chapter="hero"
> >
<div class="space-y-5"> <div class="flex flex-wrap items-center gap-4">
<div class="flex flex-wrap items-center gap-4"> <a href="{{ route('contact') }}" data-testid="home-primary-cta" class="btn btn-primary text-xs font-bold uppercase tracking-[0.09em]">
<a href="{{ route('briefing') }}" data-testid="home-primary-cta" class="inline-flex min-h-11 items-center bg-amare-accent px-5 py-3 text-sm font-semibold text-amare-accent-text transition-colors duration-(--amare-duration-normal) ease-(--amare-ease-standard) hover:bg-amare-accent-hover"> {{ $settings->hero_cta_label ?: 'Solicitar proposta' }}
{{ $settings->hero_cta_label ?: 'Solicitar proposta' }} </a>
@if (filled($settings->hero_secondary_cta_label))
<a href="{{ route('portfolio.index') }}" class="btn btn-ghost min-h-0 px-0 text-xs font-bold uppercase tracking-[0.09em] text-amare-accent hover:bg-transparent hover:text-amare-accent-deep">
<span class="border-b border-amare-accent pb-1">{{ $settings->hero_secondary_cta_label }}</span>
</a> </a>
@if (filled($settings->hero_secondary_cta_label))
<a href="{{ route('portfolio.index') }}" class="inline-flex min-h-11 items-center text-sm font-semibold text-amare-accent transition-colors hover:text-amare-accent-deep">
<span class="border-b border-amare-accent pb-1">{{ $settings->hero_secondary_cta_label }}</span>
</a>
@endif
</div>
@if (filled($settings->hero_note))
<p class="max-w-[470px] text-sm text-amare-text-muted">{{ $settings->hero_note }}</p>
@endif @endif
</div> </div>
@if (filled($settings->hero_note))
<p class="mt-4 max-w-xl text-sm text-amare-text-muted">{{ $settings->hero_note }}</p>
@endif
</x-public.photo-hero> </x-public.photo-hero>

View File

@@ -1,22 +1,24 @@
@props([ @props([])
'settings',
])
@php <section
$title = $settings->manifesto_title ?: 'Sofisticação que também se traduz em organização.'; aria-labelledby="manifesto-heading"
$lead = $settings->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.'; class="home-chapter border-b border-amare-border bg-amare-bg"
$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.'; data-chapter="sobre"
@endphp id="sobre"
>
<section id="amare" aria-labelledby="manifesto-heading" class="home-chapter border-b border-amare-border bg-amare-bg-deep py-24 md:py-32" data-chapter="manifesto"> <div class="container-amare grid gap-10 py-16 md:grid-cols-[0.8fr_1.2fr] md:gap-[90px] md:py-24" data-reveal-group>
<div class="container-amare grid gap-10 md:grid-cols-12" data-reveal-group> <div data-reveal data-reveal-from="up">
<div class="space-y-5 md:col-span-4 md:pt-20" data-reveal data-reveal-from="left"> <p class="text-xs font-bold uppercase tracking-[0.16em] text-amare-accent-deep">01 A Amare</p>
<p class="max-w-52 text-lg leading-snug text-amare-text">Humana no cuidado. Precisa na entrega.</p>
</div> </div>
<div class="space-y-6 md:col-span-7 md:col-start-6" data-reveal data-reveal-from="up">
<h2 id="manifesto-heading" class="max-w-3xl text-headline font-medium text-amare-text">{{ $title }}</h2> <div class="flex flex-col gap-8" data-reveal data-reveal-from="up">
<p class="max-w-2xl text-xl leading-relaxed text-amare-text">{{ $lead }}</p> <h2 id="manifesto-heading" class="text-[clamp(1.875rem,4vw,3.25rem)] font-medium leading-[1.15] tracking-[-0.02em] text-amare-text">
<p class="max-w-2xl text-amare-text-muted">{{ $body }}</p> Mais do que organizar um evento, a Amare conduz pessoas, decisões e detalhes para que cada experiência aconteça com clareza e tranquilidade.
</h2>
<p class="max-w-[760px] text-[clamp(1.1875rem,2vw,1.5rem)] leading-[1.55] text-amare-text-muted">
Aqui entra a apresentação institucional da marca e da Michele, com texto real aprovado pela cliente. A home deve construir confiança antes de pedir uma conversão.
</p>
</div> </div>
</div> </div>
</section> </section>

View File

@@ -0,0 +1,94 @@
@props([
'packages',
'settings',
'eyebrow' => '03 — Amare Casamentos',
'heading' => 'Um acompanhamento para cada momento.',
'intro' => 'As modalidades são pontos de partida. O site apresenta o conceito e ajuda o casal a reconhecer seu momento; detalhes operacionais e comerciais ficam para a conversa e proposta.',
'kicker' => 'level',
'showSubtitle' => false,
'showIntro' => true,
'showNote' => true,
'ctaRoute' => null,
'bandCtaHref' => null,
'bandBody' => 'Conte um pouco sobre o seu casamento. A Amare entende o momento de vocês e orienta o melhor formato de acompanhamento sem depender de um quiz automático.',
'note' => 'Nomenclaturas exibidas conforme materiais/reunião; confirmar versão final antes da publicação.',
])
<section
aria-labelledby="packages-heading"
class="home-chapter border-b border-amare-border bg-amare-bg"
data-chapter="casamentos"
id="casamentos"
>
<div class="container-amare py-16 md:py-24" data-reveal-group>
<div class="flex flex-col gap-6 md:flex-row md:items-end md:justify-between" data-reveal data-reveal-from="up">
<div class="max-w-2xl space-y-4">
<p class="text-xs font-bold uppercase tracking-[0.16em] text-amare-accent-deep">{{ $eyebrow }}</p>
<h2 id="packages-heading" class="text-[clamp(2.375rem,5vw,4rem)] font-medium leading-[1.05] tracking-[-0.02em] text-amare-text">{{ $heading }}</h2>
</div>
@if ($showIntro && filled($intro))
<p class="max-w-[600px] text-amare-text-muted">{{ $intro }}</p>
@endif
</div>
@if ($packages->isNotEmpty())
<div class="mt-12 grid border-l border-t border-amare-border md:mt-16 md:grid-cols-3">
@foreach ($packages as $package)
<article class="flex min-h-[480px] flex-col justify-between gap-8 border-b border-r border-amare-border p-8 md:p-10" data-reveal data-reveal-from="up">
<div class="space-y-5">
<p class="text-xs font-bold uppercase tracking-[0.16em] text-amare-accent">{{ $kicker === 'tag' ? $package->tag : $package->level }}</p>
<h3 class="text-[clamp(1.5625rem,2.7vw,2.125rem)] font-medium leading-tight text-amare-text">{{ $package->name }}</h3>
@if ($showSubtitle && filled($package->subtitle))
<p class="text-[clamp(1.0625rem,1.7vw,1.25rem)] font-medium leading-snug text-amare-text">{{ $package->subtitle }}</p>
@endif
<p class="text-amare-text-muted">{{ $package->summary }}</p>
<ul class="space-y-3 text-amare-text-muted">
@foreach ($package->scope_items as $item)
<li class="flex gap-3"><span aria-hidden="true" class="text-amare-accent">&bull;</span><span>{{ $item }}</span></li>
@endforeach
</ul>
</div>
<div class="space-y-5">
@php($contactLink = $ctaRoute ? null : \App\Support\PackageContactLink::for($settings, $package->name))
<a
href="{{ $ctaRoute ? route($ctaRoute) : $contactLink['href'] }}"
@if ($ctaRoute === null && $contactLink['isWhatsapp']) target="_blank" rel="noopener noreferrer" @endif
class="btn btn-primary text-xs font-bold uppercase tracking-[0.09em]"
>
{{ $package->cta_label }}
</a>
<a
href="{{ route('packages.show', $package->slug) }}"
class="btn btn-ghost min-h-0 px-0 text-xs font-semibold uppercase tracking-[0.09em] text-amare-accent hover:bg-transparent hover:text-amare-accent-deep"
>
<span class="border-b border-amare-accent pb-1">Conhecer esta modalidade</span>
</a>
</div>
</article>
@endforeach
</div>
@endif
@php($orientationLink = \App\Support\PackageContactLink::forOrientation($settings))
@php($bandHref = filled($bandCtaHref) ? $bandCtaHref : $orientationLink['href'])
@php($bandIsWhatsapp = ! filled($bandCtaHref) && $orientationLink['isWhatsapp'])
<div class="mt-6 flex flex-col justify-between gap-8 bg-amare-accent-deep p-8 text-amare-accent-text md:flex-row md:items-center md:p-12" data-reveal data-reveal-from="up">
<div class="max-w-xl space-y-3">
<h3 class="text-[clamp(1.5625rem,2.7vw,2.125rem)] font-medium leading-tight">Ainda não sabe qual modalidade é ideal?</h3>
<p class="text-amare-accent-text/80">{{ $bandBody }}</p>
</div>
<a
data-testid="package-guidance-cta"
href="{{ $bandHref }}"
@if ($bandIsWhatsapp) target="_blank" rel="noopener noreferrer" @endif
class="inline-flex min-h-[48px] items-center justify-center self-start border border-amare-accent-text px-6 text-xs font-bold uppercase tracking-[0.09em] text-amare-accent-text transition-colors hover:bg-amare-accent-text hover:text-amare-accent-deep md:self-auto"
>
Conversar com a Amare
</a>
</div>
@if ($showNote && filled($note))
<p class="mt-6 text-sm text-amare-muted">{{ $note }}</p>
@endif
</div>
</section>

View File

@@ -2,67 +2,55 @@
'cases', 'cases',
]) ])
<section id="portfolio" aria-labelledby="portfolio-heading" class="home-chapter border-b border-amare-accent-deep bg-amare-accent-deep py-24 text-amare-accent-text" data-chapter="portfolio"> <section
<div class="container-amare space-y-10" data-reveal-group> aria-labelledby="portfolio-heading"
<div class="grid gap-4 md:grid-cols-12" data-reveal data-reveal-from="up"> class="home-chapter border-b border-amare-border bg-amare-bg"
<div class="md:col-span-7 md:col-start-5 space-y-3"> data-chapter="portfolio"
<h2 id="portfolio-heading" class="text-headline font-medium">Celebrações que ganham forma com intenção.</h2> id="portfolio"
<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 class="container-amare py-16 md:py-24" data-reveal-group>
</div> <div class="flex flex-col gap-6 md:flex-row md:items-end md:justify-between" data-reveal data-reveal-from="up">
<h2 id="portfolio-heading" class="max-w-2xl text-[clamp(2.375rem,5vw,4rem)] font-medium leading-[1.05] tracking-[-0.02em] text-amare-text">Trabalhos que carregam a assinatura Amare.</h2>
@if ($cases->isNotEmpty()) <p class="max-w-[600px] text-amare-text-muted">Priorizar fotografias reais e uma curadoria pequena. O portfólio não precisa explicar tudo: deve provar cuidado, execução e consistência visual.</p>
<div class="grid gap-8 md:grid-cols-12 md:gap-x-8 md:gap-y-14" data-editorial-portfolio>
@foreach ($cases as $case)
@php
$isFeature = $loop->first;
$isOffset = ! $isFeature && $loop->even;
@endphp
<article
@class([
'space-y-4 border-t border-amare-accent-text/30 pt-4',
'md:col-span-7' => $isFeature,
'md:col-span-5 md:col-start-8 md:pt-12' => $isOffset,
'md:col-span-5' => ! $isFeature && ! $isOffset,
])
data-editorial-portfolio-item="{{ $isFeature ? 'feature' : ($isOffset ? 'offset' : 'standard') }}"
data-reveal
data-reveal-from="up"
>
@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) calc(100vw - 3rem), 50vw"
@class([
'img-editorial w-full object-cover',
'aspect-[5/4]' => $isFeature,
'aspect-[4/5]' => $isOffset,
'aspect-[4/3]' => ! $isFeature && ! $isOffset,
])
/>
@endif
<div class="space-y-2">
<h3 class="text-2xl font-medium">{{ $case->title }}</h3>
<p class="text-amare-accent-text/80">{{ $case->summary }}</p>
<a href="{{ route('portfolio.show', $case->slug) }}" class="inline-flex min-h-11 items-center text-sm font-semibold transition-colors hover:text-amare-accent-text">
<span class="border-b border-amare-accent-text pb-1">Ver caso</span>
</a>
</div>
</article>
@endforeach
</div>
@else
<p class="max-w-2xl border-t border-amare-accent-text/30 pt-5 text-amare-accent-text/80">Nosso acervo de eventos reais e autorizados está em preparação. Em breve, esta página reunirá recortes que contam melhor o trabalho da Amare.</p>
@endif
<div class="flex flex-col gap-4 border-t border-amare-accent-text/30 pt-5 md:flex-row md:items-end md:justify-between">
<p class="max-w-2xl text-sm text-amare-accent-text/75">
Imagens demonstrativas enquanto o acervo autorizado da Amare está em organização.
</p>
<a href="{{ route('portfolio.index') }}" class="inline-flex min-h-11 items-center text-sm font-semibold transition-colors hover:text-amare-accent-text">
<span class="border-b border-amare-accent-text pb-1">Conhecer o portfólio</span>
</a>
</div>
</div> </div>
</section>
@php
$slots = collect([
['label' => 'FOTO DE CASAMENTO 01', 'tall' => true],
['label' => 'FOTO 02', 'tall' => false],
['label' => 'FOTO 03', 'tall' => false],
['label' => 'FOTO 04', 'tall' => false],
['label' => 'FOTO 05', 'tall' => false],
]);
$cases = $cases->take(5)->values();
@endphp
<div class="mt-12 grid grid-cols-1 gap-[14px] sm:grid-cols-2 lg:grid-cols-[1.2fr_0.8fr_0.8fr]" data-editorial-portfolio data-reveal-group>
@foreach ($slots as $index => $slot)
@php
$case = $cases->get($index);
@endphp
<div
@class([
'overflow-hidden bg-amare-bg-deep',
'sm:col-span-2 sm:min-h-[420px] lg:col-span-1 lg:row-span-2 lg:min-h-[634px]' => $slot['tall'],
'min-h-[220px] sm:min-h-[310px]' => ! $slot['tall'],
])
@if ($slot['tall']) data-editorial-portfolio-item="feature" @endif
data-reveal
data-reveal-from="up"
>
@if ($case && filled($case->cover_image_path))
<a href="{{ route('portfolio.show', $case) }}" class="block h-full w-full" aria-label="{{ $case->title }}">
<x-media.image :path="$case->cover_image_path" :alt="$case->cover_image_alt ?: $case->title" sizes="(max-width: 1023px) 100vw, 33vw" class="img-editorial h-full w-full object-cover transition-opacity hover:opacity-90" />
</a>
@else
<div class="flex h-full w-full items-center justify-center">
<p class="px-4 text-center text-xs font-semibold uppercase tracking-[0.16em] text-amare-muted">{{ $slot['label'] }}</p>
</div>
@endif
</div>
@endforeach
</div>
</div>
</section>

View File

@@ -34,7 +34,7 @@
@endforeach @endforeach
</ul> </ul>
<p> <p>
<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"> <a href="{{ route('about') }}" class="btn btn-ghost min-h-0 px-0 text-sm font-semibold text-amare-accent hover:bg-transparent hover:text-amare-accent-deep">
<span class="border-b border-amare-accent pb-1">Conhecer a Amare</span> <span class="border-b border-amare-accent pb-1">Conhecer a Amare</span>
</a> </a>
</p> </p>

View File

@@ -26,7 +26,7 @@
</ol> </ol>
<p> <p>
<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"> <a href="{{ route('services.index') }}" class="btn btn-ghost min-h-0 px-0 text-sm font-semibold text-amare-accent hover:bg-transparent hover:text-amare-accent-deep">
<span class="border-b border-amare-accent pb-1">Ver todos os serviços</span> <span class="border-b border-amare-accent pb-1">Ver todos os serviços</span>
</a> </a>
</p> </p>

View File

@@ -3,46 +3,52 @@
]) ])
@php @php
$testimonials = $testimonials $quotes = collect($testimonials)
->filter(fn ($testimonial): bool => filled(trim((string) $testimonial->quote))) ->filter(fn ($testimonial) => filled($testimonial->quote) && filled(trim((string) $testimonial->quote)))
->take(3)
->values(); ->values();
$cards = $quotes->map(fn ($testimonial) => [
'quote' => $testimonial->quote,
'author' => $testimonial->author_name,
'context' => $testimonial->context,
])->all();
while (count($cards) < 3) {
$cards[] = [
'quote' => 'Depoimento real aprovado pela cliente entra aqui.',
'author' => 'Nome e data reais — aguardando autorização',
'context' => null,
];
}
@endphp @endphp
@if ($testimonials->isNotEmpty()) <section
<section id="depoimentos" aria-labelledby="testimonials-heading" class="home-chapter border-b border-amare-border bg-amare-bg py-24" data-chapter="testimonials"> aria-labelledby="testimonials-heading"
<div class="container-amare space-y-10" data-reveal-group> class="home-chapter border-b border-amare-border bg-amare-bg"
<div class="max-w-2xl space-y-3 md:ml-[16.666667%]" data-reveal data-reveal-from="up"> data-chapter="depoimentos"
<h2 id="testimonials-heading" class="text-3xl font-medium text-amare-text">Depoimentos</h2> id="depoimentos"
<p class="text-amare-text-muted">Quem celebrou com a Amare conta como foi a experiência.</p> >
</div> <div class="container-amare py-16 md:py-24" data-reveal-group>
<div class="space-y-4" data-reveal data-reveal-from="up">
<div class="grid gap-6 md:grid-cols-12"> <p class="text-xs font-bold uppercase tracking-[0.16em] text-amare-accent-deep">06 Depoimentos</p>
@foreach ($testimonials as $testimonial) <h2 id="testimonials-heading" class="text-[clamp(2.375rem,5vw,4rem)] font-medium leading-[1.05] tracking-[-0.02em] text-amare-text">Confiança construída na experiência.</h2>
@php
$paragraphs = preg_split('/\n\s*\n/', trim((string) $testimonial->quote)) ?: [];
$paragraphs = array_values(array_filter(array_map('trim', $paragraphs), fn (string $p): bool => $p !== ''));
@endphp
<blockquote @class([
'space-y-4 border-t border-amare-border pt-4 md:col-span-5' => $loop->odd,
'space-y-4 border-t border-amare-border pt-4 md:col-span-5 md:col-start-7 md:mt-16' => $loop->even,
]) 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">
@foreach ($paragraphs as $paragraph)
<p>{{ $paragraph }}</p>
@endforeach
</div>
</div>
<footer class="pl-8 text-sm text-amare-text-muted">
<cite class="not-italic font-semibold text-amare-text">{{ $testimonial->author_name }}</cite>
@if (filled($testimonial->context))
<span> {{ $testimonial->context }}</span>
@endif
</footer>
</blockquote>
@endforeach
</div>
</div> </div>
</section>
@endif <div class="mt-12 grid gap-6 md:mt-16 md:grid-cols-3">
@foreach ($cards as $card)
<figure class="flex flex-col justify-between gap-6 border border-amare-border bg-white p-8" data-reveal data-reveal-from="up">
<blockquote class="text-lg leading-[1.55] text-amare-text">
<p>{{ $card['quote'] }}</p>
</blockquote>
<figcaption class="text-sm text-amare-muted">
@if (filled($card['context']))
<p class="font-semibold text-amare-text">{{ $card['author'] }}</p>
<p>{{ $card['context'] }}</p>
@else
<p>{{ $card['author'] }}</p>
@endif
</figcaption>
</figure>
@endforeach
</div>
</div>
</section>

View File

@@ -0,0 +1,50 @@
@props([
'eyebrow' => '02 — Duas vertentes, uma mesma forma de cuidar',
'heading' => 'Como podemos acompanhar você?',
'cards' => [
[
'label' => 'Amare Casamentos',
'title' => 'Planejamento e condução para viver o processo com mais leveza.',
'body' => 'Do início da organização à reta final, modalidades pensadas para diferentes momentos do casal.',
'href' => '#casamentos',
'cta' => 'Conhecer casamentos',
],
[
'label' => 'Amare Corporate',
'title' => 'Eventos empresariais com organização, intenção e execução segura.',
'body' => 'Uma frente própria para projetos corporativos, com linguagem e necessidades diferentes das celebrações sociais.',
'href' => '#corporate',
'cta' => 'Conhecer Corporate',
],
],
])
<section
aria-labelledby="vertentes-heading"
class="home-chapter border-b border-amare-border bg-amare-bg"
data-chapter="vertentes"
id="vertentes"
>
<div class="container-amare py-16 md:py-24" data-reveal-group>
<div class="max-w-3xl space-y-4" data-reveal data-reveal-from="up">
<p class="text-xs font-bold uppercase tracking-[0.16em] text-amare-accent-deep">{{ $eyebrow }}</p>
<h2 id="vertentes-heading" class="text-[clamp(2.375rem,5vw,4rem)] font-medium leading-[1.05] tracking-[-0.02em] text-amare-text">{{ $heading }}</h2>
</div>
<div class="mt-12 grid gap-6 md:mt-16 md:grid-cols-2 md:gap-[24px]">
@foreach ($cards as $card)
<article class="relative flex min-h-[420px] flex-col justify-end bg-amare-bg-deep p-8 md:p-10" data-reveal data-reveal-from="up">
<div aria-hidden="true" class="pointer-events-none absolute inset-x-0 top-0 h-[43%] bg-gradient-to-b from-amare-bg to-transparent"></div>
<div class="relative flex flex-col gap-5">
<p class="text-xs font-bold uppercase tracking-[0.16em] text-amare-accent-deep">{{ $card['label'] }}</p>
<h3 class="text-[clamp(1.5625rem,2.7vw,2.125rem)] font-medium leading-tight text-amare-text">{{ $card['title'] }}</h3>
<p class="text-amare-text-muted">{{ $card['body'] }}</p>
<a href="{{ $card['href'] }}" class="btn btn-outline mt-2 self-start text-xs font-bold uppercase tracking-[0.09em]">
{{ $card['cta'] }}
</a>
</div>
</article>
@endforeach
</div>
</div>
</section>

View File

@@ -8,9 +8,8 @@
@else @else
<ol class="grid gap-8 border-t border-amare-border pt-6 md:grid-cols-3"> <ol class="grid gap-8 border-t border-amare-border pt-6 md:grid-cols-3">
@foreach ($packages as $package) @foreach ($packages as $package)
@php($digits = preg_replace('/\D/', '', (string) $settings->whatsapp_number)) @php($contactLink = \App\Support\PackageContactLink::for($settings, $package->name))
@php($href = strlen((string) $digits) >= 10 ? 'https://wa.me/'.$digits.'?text='.rawurlencode('Olá, gostaria de conversar sobre a modalidade '.$package->name.' para meu casamento.') : route('briefing', ['servico_interesse' => $package->name])) <li class="space-y-5 border-t border-amare-border pt-4" data-reveal data-reveal-from="up"><p class="text-xs font-semibold uppercase tracking-[.14em] text-amare-accent">{{ $package->level }}</p><h3 class="text-3xl font-medium">{{ $package->name }}</h3><p class="text-amare-muted">{{ $package->summary }}</p><ul class="space-y-2 text-sm text-amare-text-muted">@foreach ($package->scope_items as $item)<li class="border-l border-amare-border pl-3">{{ $item }}</li>@endforeach</ul><div class="space-y-2"><a href="{{ $contactLink['href'] }}" @if ($contactLink['isWhatsapp']) target="_blank" rel="noopener" @endif class="btn btn-ghost min-h-0 px-0 text-sm font-semibold text-amare-accent hover:bg-transparent hover:text-amare-accent-deep"><span class="border-b border-amare-accent pb-1">{{ $package->cta_label }}</span></a><a href="{{ route('packages.show', $package->slug) }}" class="btn btn-ghost min-h-0 px-0 text-sm font-semibold text-amare-accent hover:bg-transparent hover:text-amare-accent-deep"><span class="border-b border-amare-accent pb-1">Conhecer esta modalidade</span></a></div></li>
<li class="space-y-5 border-t border-amare-border pt-4" data-reveal data-reveal-from="up"><p class="text-xs font-semibold uppercase tracking-[.14em] text-amare-accent">{{ $package->level }}</p><h3 class="text-3xl font-medium">{{ $package->name }}</h3><p class="text-amare-muted">{{ $package->summary }}</p><ul class="space-y-2 text-sm text-amare-text-muted">@foreach ($package->scope_items as $item)<li class="border-l border-amare-border pl-3">{{ $item }}</li>@endforeach</ul><a href="{{ $href }}" @if (str_starts_with($href, 'https://wa.me/')) target="_blank" rel="noopener" @endif class="inline-flex min-h-11 items-center text-sm font-semibold text-amare-accent hover:text-amare-accent-deep"><span class="border-b border-amare-accent pb-1">{{ $package->cta_label }}</span></a></li>
@endforeach @endforeach
</ol> </ol>
@endif @endif

View File

@@ -0,0 +1,32 @@
@props(['icon' => null])
<svg {{ $attributes->merge(['class' => 'h-6 w-6']) }} viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="1.2" aria-hidden="true">
@switch($icon)
@case('calendar')
<rect x="6" y="9" width="36" height="32" />
<path d="M6 17h36M16 4v8M32 4v8" />
<path d="M14 25h6M14 33h6M28 25h6M28 33h6" />
@break
@case('checklist')
<path d="M8 14l4 4 8-9M8 25l4 4 8-9M8 36l4 4 8-9" />
<path d="M28 14h12M28 25h12M28 36h12" />
@break
@case('users')
<circle cx="17" cy="14" r="6" />
<path d="M5 40c0-7 5.4-12 12-12s12 5 12 12" />
<circle cx="33" cy="16" r="5" />
<path d="M31 29c4.5 1.5 8 5.5 8.7 11" />
@break
@case('map')
<path d="M24 6c-6.5 0-12 5.4-12 12.2 0 9 12 21.8 12 21.8s12-12.8 12-21.8C36 11.4 30.5 6 24 6z" />
<circle cx="24" cy="18" r="4" />
@break
@case('heart')
<path d="M24 40C13 32 5 25 5 16.4 5 10.7 9.5 6 15.2 6c3.4 0 6.4 1.9 8.8 4.7C26.4 7.9 29.4 6 32.8 6 38.5 6 43 10.7 43 16.4c0 8.6-8 15.6-19 23.6z" />
@break
@case('spark')
<path d="M24 6l3.4 11.6L39 21l-11.6 3.4L24 36l-3.4-11.6L9 21l11.6-3.4z" />
<path d="M37 34l1.8 6.2L45 42l-6.2 1.8L37 50l-1.8-6.2L29 42l6.2-1.8z" />
@break
@endswitch
</svg>

View File

@@ -0,0 +1,40 @@
@props([
'imagePath' => null,
'imageAlt' => null,
'heading' => null,
'intro' => null,
'points' => [],
])
@if ($heading || count($points) > 0)
<section aria-labelledby="audience-heading" class="border-b border-amare-border bg-amare-bg-deep" data-reveal-group>
<div class="grid min-h-[390px] lg:grid-cols-2">
<div class="min-h-[300px] bg-amare-bg lg:min-h-0" data-reveal-media data-reveal-from="left">
@if ($imagePath)
<x-media.image :path="$imagePath" :alt="$imageAlt ?: $heading" loading="lazy" sizes="(max-width: 1023px) 100vw, 50vw" class="img-editorial h-full w-full object-cover" />
@endif
</div>
<div class="flex items-center bg-amare-bg-deep px-6 py-16 lg:px-[clamp(3rem,6vw,6rem)]">
<div class="w-full max-w-[430px]" data-reveal data-reveal-from="right">
@if ($heading)
<h2 id="audience-heading" class="text-3xl font-medium uppercase tracking-[0.05em]">{{ $heading }}</h2>
<div class="mt-4 h-px w-12 bg-amare-border" aria-hidden="true"></div>
@endif
@if ($intro)
<p class="mt-6 text-lg text-amare-text-muted">{{ $intro }}</p>
@endif
@if (count($points) > 0)
<ul class="mt-8 space-y-3">
@foreach ($points as $point)
<li class="flex items-start gap-3">
<span aria-hidden="true" class="text-amare-accent"></span>
<span>{{ $point }}</span>
</li>
@endforeach
</ul>
@endif
</div>
</div>
</div>
</section>
@endif

View File

@@ -0,0 +1,14 @@
@props(['benefits' => []])
@if (count($benefits) > 0)
<section aria-label="Diferenciais da assessoria" class="border-b border-amare-border bg-amare-accent-deep text-amare-accent-text" data-reveal-group>
<div class="grid grid-cols-2 md:grid-cols-4">
@foreach ($benefits as $benefit)
<div class="flex min-h-[132px] flex-col items-center justify-center gap-3 px-4 py-8 text-center" data-reveal data-reveal-from="up">
<x-package.icon :icon="$benefit['icon_key']" class="h-9 w-9" />
<span class="max-w-[180px] text-sm uppercase tracking-[0.06em]">{{ $benefit['label'] }}</span>
</div>
@endforeach
</div>
</section>
@endif

View File

@@ -0,0 +1,24 @@
@props([
'settings',
'heading' => null,
'body' => null,
'packageName' => null,
])
@php
$link = \App\Support\PackageContactLink::for($settings, $packageName ?? '');
@endphp
<section aria-labelledby="final-cta-heading" class="border-b border-amare-border bg-amare-bg py-20 md:py-24" data-chapter="final-cta" data-reveal-group>
<div class="container-amare max-w-3xl space-y-6 py-4 text-center" data-reveal data-reveal-from="up">
@if ($heading)
<h2 id="final-cta-heading" class="text-headline font-medium text-amare-text">{{ $heading }}</h2>
@endif
@if ($body)
<p class="mx-auto max-w-2xl text-amare-muted">{{ $body }}</p>
@endif
<div class="pt-4">
<a href="{{ $link['href'] }}" @if ($link['isWhatsapp']) target="_blank" rel="noopener noreferrer" @endif class="btn btn-primary text-sm font-semibold uppercase tracking-[0.12em]">Solicitar proposta</a>
</div>
</div>
</section>

View File

@@ -0,0 +1,42 @@
@props([
'imagePath' => null,
'imageAlt' => null,
'eyebrow' => null,
'titleLine' => null,
'titleEmphasis' => null,
'lead' => null,
'headingId' => 'package-hero-heading',
])
<section aria-labelledby="{{ $headingId }}" {{ $attributes->class(['border-b border-amare-border bg-amare-bg']) }} data-motion="page-open">
<div class="grid min-h-[calc(100dvh-5rem)] lg:h-[calc(100dvh-5rem)] lg:min-h-0 lg:grid-cols-12" data-photo-hero>
<div class="flex min-w-0 items-center bg-amare-bg px-6 py-16 lg:col-span-5 lg:px-[clamp(3rem,6vw,7rem)]" data-hero-content data-reveal-group>
<div class="w-full max-w-[470px] space-y-7">
@if ($eyebrow)
<div class="flex items-center gap-4" data-motion-beat="seal">
<x-brand.logo mark variant="on-light" class="h-8 w-auto" />
<p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent">{{ $eyebrow }}</p>
</div>
@endif
<h1 id="{{ $headingId }}" class="max-w-[470px] whitespace-normal break-normal text-hero-spread font-medium text-amare-text" data-motion-beat="title">
{{ $titleLine }}
@if ($titleEmphasis)
<em class="mt-4 block text-[0.7em] italic text-amare-accent-deep">{{ $titleEmphasis }}</em>
@endif
</h1>
<div class="h-[42px] w-px bg-amare-accent" aria-hidden="true"></div>
@if ($lead)
<p class="max-w-[470px] text-lg text-amare-text-muted">{{ $lead }}</p>
@endif
<div data-motion-beat="cta">
{{ $slot }}
</div>
</div>
</div>
<div data-split-hero data-motion-beat="media" class="min-h-[56dvh] overflow-hidden bg-amare-bg-deep max-lg:aspect-[4/5] lg:col-span-7 lg:min-h-0" data-reveal-media>
@if ($imagePath)
<x-media.image :path="$imagePath" :alt="$imageAlt ?: $titleLine" loading="eager" fetchpriority="high" sizes="(max-width: 1023px) 100vw, 58vw" class="img-editorial h-full w-full object-cover" />
@endif
</div>
</div>
</section>

View File

@@ -0,0 +1,20 @@
@props(['items' => []])
@if (count($items) > 0)
<section aria-labelledby="included-heading" class="border-b border-amare-border bg-amare-bg" data-reveal-group>
<div class="container-amare py-16 md:py-24">
<p class="text-center text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Modalidade</p>
<h2 id="included-heading" class="mt-3 text-center text-3xl font-medium md:text-4xl">O que está incluso</h2>
<div class="mx-auto mt-4 h-px w-14 bg-amare-border" aria-hidden="true"></div>
<div class="mt-12 grid gap-y-10 md:grid-cols-3 md:gap-x-6 md:gap-y-0">
@foreach ($items as $item)
<div class="text-center md:border-l md:border-amare-border md:first:border-l-0 md:px-10" data-reveal data-reveal-from="up">
<x-package.icon :icon="$item['icon_key']" class="mx-auto h-10 w-10 text-amare-accent-deep" />
<h3 class="mt-4 text-lg font-medium uppercase tracking-[0.05em]">{{ $item['title'] }}</h3>
<p class="mx-auto mt-3 max-w-[310px] text-amare-text-muted">{{ $item['description'] }}</p>
</div>
@endforeach
</div>
</div>
</section>
@endif

View File

@@ -20,7 +20,7 @@
<h1 class="text-4xl font-medium tracking-tight text-amare-text" data-motion-beat="title">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" data-reveal data-reveal-from="up">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 data-reveal data-reveal-from="up"> <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"> <a href="{{ route('home') }}" class="btn btn-primary text-sm font-semibold uppercase tracking-[0.12em]">
Voltar para a home Voltar para a home
</a> </a>
</p> </p>

View File

@@ -20,7 +20,7 @@
<h1 class="text-4xl font-medium tracking-tight text-amare-text" data-motion-beat="title">Sessão expirada</h1> <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 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"> <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"> <a href="{{ route('home') }}" class="btn btn-primary text-sm font-semibold uppercase tracking-[0.12em]">
Voltar para a home Voltar para a home
</a> </a>
</p> </p>

View File

@@ -20,7 +20,7 @@
<h1 class="text-4xl font-medium tracking-tight text-amare-text" data-motion-beat="title">Muitas solicitações</h1> <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 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"> <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"> <a href="{{ route('home') }}" class="btn btn-primary text-sm font-semibold uppercase tracking-[0.12em]">
Voltar para a home Voltar para a home
</a> </a>
</p> </p>

View File

@@ -20,7 +20,7 @@
<h1 class="text-4xl font-medium tracking-tight text-amare-text" data-motion-beat="title">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" data-reveal data-reveal-from="up">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 data-reveal data-reveal-from="up"> <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"> <a href="{{ route('home') }}" class="btn btn-primary text-sm font-semibold uppercase tracking-[0.12em]">
Voltar para a home Voltar para a home
</a> </a>
</p> </p>

View File

@@ -20,7 +20,7 @@
<h1 class="text-4xl font-medium tracking-tight text-amare-text" data-motion-beat="title">Em manutenção</h1> <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 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"> <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"> <a href="{{ route('home') }}" class="btn btn-primary text-sm font-semibold uppercase tracking-[0.12em]">
Voltar para a home Voltar para a home
</a> </a>
</p> </p>

View File

@@ -1,5 +1,5 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}"> <html lang="{{ str_replace('_', '-', app()->getLocale()) }}" data-theme="amare">
<head> <head>
<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">
@@ -55,28 +55,28 @@
Ir para o conteúdo Ir para o conteúdo
</a> </a>
<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 bg-amare-bg/95 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] md:h-[74px] md:py-0">
<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> <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') }}#amare" 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="{{ url('/#casamentos') }}" class="btn btn-ghost min-h-0 px-0 py-3 text-[13px] font-semibold uppercase tracking-[0.08em] text-amare-muted hover:text-amare-accent md:px-3">Casamentos</a>
<a href="{{ route('home') }}#casamentos" 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">Casamentos</a> <a href="{{ url('/#corporate') }}" class="btn btn-ghost min-h-0 px-0 py-3 text-[13px] font-semibold uppercase tracking-[0.08em] text-amare-muted hover:text-amare-accent md:px-3">Corporate</a>
<a href="{{ route('home') }}#corporate" 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">Corporate</a> <a href="{{ route('portfolio.index') }}" class="btn btn-ghost min-h-0 px-0 py-3 text-[13px] font-semibold uppercase tracking-[0.08em] text-amare-muted hover:text-amare-accent md:px-3">Portfólio</a>
<a href="{{ route('home') }}#portfolio" 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="btn btn-ghost min-h-0 px-0 py-3 text-[13px] font-semibold uppercase tracking-[0.08em] text-amare-muted hover:text-amare-accent md:px-3">Amare</a>
<a href="{{ route('briefing') }}" 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> <a href="{{ route('briefing') }}" class="btn btn-ghost min-h-0 px-0 py-3 text-[13px] font-semibold uppercase tracking-[0.08em] text-amare-muted 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">
<x-brand.logo variant="on-light" class="h-10 w-auto md:h-12" /> <x-brand.logo variant="on-light" class="h-10 w-auto md:h-11" />
</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('briefing') }}" 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"> <a href="{{ route('briefing') }}" class="btn btn-outline hidden min-h-0 px-6 text-xs font-bold uppercase tracking-[0.09em] md:inline-flex">
Solicitar proposta Conte seu evento
</a> </a>
<button <button
type="button" type="button"
class="menu-button inline-flex h-11 w-11 items-center justify-center border border-amare-border text-amare-text md:hidden" class="menu-button btn btn-square btn-ghost 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"
@@ -97,58 +97,52 @@
@yield('content') @yield('content')
</main> </main>
<footer class="border-t border-amare-border bg-amare-bg-deep"> <footer class="border-t border-amare-border bg-amare-bg">
<div class="container-amare grid gap-10 py-12 md:grid-cols-[minmax(0,1.4fr)_repeat(2,minmax(0,1fr))]"> @php
<div class="space-y-4"> $footerSocialLabel = static function (string $network): string {
<x-brand.logo variant="on-light" class="h-12 w-auto" /> return match ($network) {
<p class="max-w-md text-amare-muted"> 'instagram' => 'Instagram',
{{ $siteSettings->about_summary ?: 'Assessoria boutique em São Paulo - SP.' }} 'whatsapp' => 'WhatsApp',
'linkedin' => 'LinkedIn',
default => ucfirst($network),
};
};
$footerSocials = collect($siteSettings->social_links ?? [])->filter(static fn ($url) => filled($url));
@endphp
<div class="container-amare flex flex-col gap-10 py-14 md:py-[58px]">
<div class="flex flex-col gap-8 md:flex-row md:items-end md:justify-between">
<div class="space-y-3">
<x-brand.logo variant="on-light" class="h-10 w-auto" />
<p class="text-sm text-amare-muted">Boutique de assessoria &amp; produção &bull; São Paulo</p>
</div>
<p class="text-sm text-amare-muted md:text-right">
@if ($footerSocials->isEmpty())
Instagram &middot; WhatsApp &middot; E-mail &middot; LinkedIn (quando confirmado)
@else
@foreach ($footerSocials as $network => $url)
<a href="{{ $url }}" class="inline-flex min-h-11 items-center transition-colors hover:text-amare-accent" rel="noopener noreferrer" target="_blank">{{ $footerSocialLabel((string) $network) }}</a>@unless ($loop->last) &nbsp;&middot;&nbsp; @endunless
@endforeach
@endif
</p> </p>
</div> </div>
<div class="text-sm"> <nav aria-label="Rodapé" class="flex flex-col gap-4 border-t border-amare-border pt-8 text-sm text-amare-muted md:flex-row md:flex-wrap md:items-center md:gap-x-6 md:gap-y-2">
<h2 class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-text">Navegação</h2> <a href="{{ url('/#casamentos') }}" class="inline-flex min-h-11 items-center transition-colors hover:text-amare-accent">Casamentos</a>
<ul class="mt-3 space-y-3"> <a href="{{ url('/#corporate') }}" class="inline-flex min-h-11 items-center transition-colors hover:text-amare-accent">Corporate</a>
<li><a href="{{ route('home') }}#casamentos" class="inline-flex min-h-11 items-center text-amare-muted transition-colors hover:text-amare-accent">Casamentos</a></li> <a href="{{ route('services.index') }}" class="inline-flex min-h-11 items-center transition-colors hover:text-amare-accent">Serviços</a>
<li><a href="{{ route('home') }}#corporate" class="inline-flex min-h-11 items-center text-amare-muted transition-colors hover:text-amare-accent">Corporate</a></li> <a href="{{ route('portfolio.index') }}" class="inline-flex min-h-11 items-center transition-colors hover:text-amare-accent">Portfólio</a>
<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> <a href="{{ route('about') }}" class="inline-flex min-h-11 items-center transition-colors hover:text-amare-accent">Sobre</a>
<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> <a href="{{ route('briefing') }}" class="inline-flex min-h-11 items-center transition-colors hover:text-amare-accent">Briefing</a>
</ul> <a href="{{ route('contact') }}" class="inline-flex min-h-11 items-center transition-colors hover:text-amare-accent">Parcerias</a>
</div> <a href="{{ route('privacy') }}" class="inline-flex min-h-11 items-center transition-colors hover:text-amare-accent">Privacidade</a>
</nav>
<div class="text-sm">
<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)
<p class="text-amare-muted">{{ $siteSettings->city }}</p>
@endif
@if ($siteSettings->email)
<p>
<a href="mailto:{{ $siteSettings->email }}" class="inline-flex max-w-full min-h-11 items-center text-amare-muted transition-colors hover:text-amare-accent"><span class="min-w-0 break-words">{{ $siteSettings->email }}</span></a>
</p>
@endif
@if ($siteSettings->phone)
<p>
<a href="tel:{{ preg_replace('/\D/', '', (string) $siteSettings->phone) }}" class="inline-flex max-w-full min-h-11 items-center text-amare-muted transition-colors hover:text-amare-accent"><span class="min-w-0 break-words">{{ $siteSettings->phone }}</span></a>
</p>
@endif
@foreach ($siteSettings->social_links ?? [] as $network => $url)
@if (filled($url))
<p>
<a href="{{ $url }}" class="inline-flex max-w-full min-h-11 items-center text-amare-muted transition-colors hover:text-amare-accent" rel="noopener noreferrer" target="_blank"><span class="min-w-0 break-words">{{ ucfirst((string) $network) }}</span></a>
</p>
@endif
@endforeach
</address>
</div>
</div> </div>
<div class="border-t border-amare-border"> <div class="border-t border-amare-border">
<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>
<a href="{{ route('privacy') }}" class="inline-flex min-h-11 items-center transition-colors hover:text-amare-accent">Política de privacidade</a>
</p>
</div> </div>
</div> </div>
</footer> </footer>

View File

@@ -1,29 +1,10 @@
@extends('layouts.public') @extends('layouts.public')
@section('content') @section('content')
@php <x-about.hero :settings="$content->settings" />
$principles = filled($siteSettings->principles) <x-about.pillars />
? $siteSettings->principles <x-about.founder :settings="$content->settings" />
: \App\Models\SiteSetting::defaultPrinciples(); <x-about.process />
$city = $siteSettings->city ?: 'São Paulo - SP'; <x-about.portfolio :cases="$content->featuredCases" />
@endphp <x-about.cta :settings="$content->settings" />
<x-public.photo-hero :image-path="$siteSettings->about_image_path" :image-alt="$siteSettings->about_image_alt" eyebrow="A Amare" title="Humana no cuidado. Precisa na entrega." :summary="$siteSettings->about_summary">
<p class="max-w-xl text-amare-text-muted">A {{ $siteSettings->brand_name }} atua em {{ $city }} com foco em planejamento completo, presença no dia do evento e uma condução serena do início ao fim.</p>
</x-public.photo-hero>
<div class="border-b border-amare-border bg-amare-bg">
<div class="container-amare py-16 md:py-24">
<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)
<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>{{ $principle }}</span>
</li>
@endforeach
</ul>
</div>
</div>
<x-home.final-cta :settings="$siteSettings" />
@endsection @endsection

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