Compare commits

..

1 Commits

Author SHA1 Message Date
400c147a74 feat: CTA dos cards e faixa de modalidades enviam ao WhatsApp com mensagem por pacote
Campo whatsapp_message editável por modalidade (Filament) com fallback para
template padrão. Cards da home, faixa 'Conversar com a Amare' e CTA final
da página de detalhe abrem o WhatsApp diretamente quando número configurado,
mantendo briefing como fallback.
2026-08-12 10:11:06 -03:00
62 changed files with 357 additions and 1278 deletions

View File

@@ -1,19 +0,0 @@
## 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

@@ -1,19 +0,0 @@
## 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:
concurrency:
group: ci-${{ gitea.workflow }}-${{ gitea.ref }}
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
@@ -18,9 +18,7 @@ env:
BCRYPT_ROUNDS: 4
CACHE_STORE: database
DB_CONNECTION: pgsql
# 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_HOST: 127.0.0.1
DB_PORT: 5432
DB_DATABASE: amare_test
DB_USERNAME: amare
@@ -42,6 +40,12 @@ jobs:
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd
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 install --no-interaction --prefer-dist
- run: composer pint:check
@@ -70,6 +74,8 @@ jobs:
POSTGRES_DB: amare_test
POSTGRES_USER: amare
POSTGRES_PASSWORD: secret
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U amare -d amare_test"
--health-interval 5s
@@ -85,6 +91,18 @@ jobs:
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd
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: npm ci
- run: npm run build
@@ -108,6 +126,8 @@ jobs:
POSTGRES_DB: amare_test
POSTGRES_USER: amare
POSTGRES_PASSWORD: secret
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U amare -d amare_test"
--health-interval 5s
@@ -123,6 +143,18 @@ jobs:
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd
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: npm ci
- run: npm run build
@@ -139,6 +171,8 @@ jobs:
POSTGRES_DB: amare_test
POSTGRES_USER: amare
POSTGRES_PASSWORD: secret
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U amare -d amare_test"
--health-interval 5s
@@ -154,6 +188,18 @@ jobs:
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd
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: npm ci
- run: npm run build
@@ -166,28 +212,15 @@ jobs:
- name: Run browser tests against FrankenPHP container
run: |
# 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 \
docker run -d --name amare-web \
-e APP_ENV=testing \
-e APP_KEY="${APP_KEY}" \
-e APP_URL=http://amare-web:8000 \
-e APP_URL=http://127.0.0.1:8000 \
-e APP_LOCALE=pt_BR \
-e APP_FALLBACK_LOCALE=pt_BR \
-e APP_TIMEZONE=America/Sao_Paulo \
-e DB_CONNECTION=pgsql \
-e DB_HOST=postgres \
-e DB_HOST=host.docker.internal \
-e DB_PORT=5432 \
-e DB_DATABASE=amare_test \
-e DB_USERNAME=amare \
@@ -195,28 +228,26 @@ jobs:
-e SESSION_DRIVER=database \
-e CACHE_STORE=database \
-e QUEUE_CONNECTION=database \
--add-host=host.docker.internal:host-gateway \
-v "${GITHUB_WORKSPACE}/storage/app/public:/app/storage/app/public" \
-p 8000:8000 \
amare-app:ci
cleanup() { docker rm -f "$WEB_NAME" >/dev/null 2>&1 || true; }
trap cleanup EXIT
for i in $(seq 1 30); do
if curl -fsS http://amare-web:8000/up; then
if curl -fsS http://127.0.0.1:8000/up; then
break
fi
sleep 2
done
curl -fsS http://amare-web:8000/up
APP_URL=http://amare-web:8000 ./vendor/bin/pest --testsuite=Browser
curl -fsS http://127.0.0.1:8000/up
./vendor/bin/pest --testsuite=Browser
- name: Collect failure diagnostics
if: failure()
run: |
mkdir -p artifacts/browser
WEB_NAME="amare-web-${GITHUB_RUN_ID:-$$}"
docker logs "$WEB_NAME" > artifacts/browser/container.log 2>&1 || true
docker logs amare-web > artifacts/browser/container.log 2>&1 || true
cp -R storage/logs artifacts/browser/app-logs 2>/dev/null || true
- name: Upload browser failure artifacts
@@ -238,21 +269,10 @@ jobs:
- name: Verify container healthcheck and storage link
run: |
# 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 \
docker run -d --name amare-health \
-e APP_ENV=production \
-e APP_KEY="${{ env.APP_KEY }}" \
-e APP_URL=http://amare-health:8000 \
-e APP_URL=http://127.0.0.1:8000 \
-e APP_DEBUG=false \
-e DB_CONNECTION=pgsql \
-e DB_HOST=127.0.0.1 \
@@ -260,18 +280,16 @@ jobs:
-e DB_DATABASE=amare \
-e DB_USERNAME=amare \
-e DB_PASSWORD=secret \
-p 8000:8000 \
amare-app:ci
cleanup() { docker rm -f "$HEALTH_NAME" >/dev/null 2>&1 || true; }
trap cleanup EXIT
for i in $(seq 1 30); do
if curl -fsS http://amare-health:8000/up; then
docker exec "$HEALTH_NAME" test -L /app/public/storage
if curl -fsS http://127.0.0.1:8000/up; then
docker exec amare-health test -L /app/public/storage
exit 0
fi
sleep 2
done
docker logs "$HEALTH_NAME"
docker logs amare-health
exit 1

View File

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

View File

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

View File

@@ -18,19 +18,7 @@ Feature and browser tests require the `amare_test` PostgreSQL database configure
## 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`. 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?".
Always work in a git worktree created from the `main` ref — never modify `main` directly and never commit from the primary working tree. Create a dedicated worktree per feature/branch with `git worktree add -b <branch> <path> main`. On finishing work, create a PR, watch CI until green, then merge it. Clean up the worktree with `git worktree remove` after merge.
## Git Hooks (husky)
@@ -53,7 +41,7 @@ Tests use Pest 4; browser coverage uses Pest Browser/Playwright. Tests are verif
## 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. 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.
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.
## Security & Configuration
@@ -71,12 +59,10 @@ 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.
**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
Default vocabulary: `needs-triage`, `needs-info`, `ready-for-agent`, `ready-for-human`, `wontfix`. See `docs/agents/triage-labels.md`.
### Domain docs
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`.
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.

View File

@@ -22,14 +22,13 @@ Write for the problem at hand, not an imagined future. **DRY**: extract and reus
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 remotes — Gitea origin, GitHub legacy
## Git remote auth — two GitHub accounts
`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!`.
`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.
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.
- 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`.
- 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 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.
## Request spine for the public site
@@ -67,7 +66,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.
- **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/`.
- **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.
- **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`).
- **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

View File

@@ -138,10 +138,10 @@ Após `php artisan db:seed`:
- [docs/adr/](docs/adr/) — ADRs aceitas
- [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/deployment/dokploy.md](docs/deployment/dokploy.md) — deploy staging/produção no Dokploy + registry Gitea
- [docs/deployment/dokploy.md](docs/deployment/dokploy.md) — deploy staging/produção no Dokploy + GHCR
## Deploy (Dokploy)
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).
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).
Ver runbook completo: [docs/deployment/dokploy.md](docs/deployment/dokploy.md).

46
SPEC.md
View File

@@ -283,15 +283,11 @@ Uma funcionalidade fora do MVP só poderá entrar quando:
| Método | Rota | Nome sugerido | Finalidade |
|---|---|---|---|
| GET | `/` | `home` | Home editorial |
| GET | `/servicos` | `services.index` | Lista de serviços / modalidades publicadas |
| GET | `/pacotes/{slug}` | `packages.show` | Detalhe de uma modalidade de casamento |
| GET | `/servicos` | `services.index` | Lista de serviços publicados |
| GET | `/portfolio` | `portfolio.index` | Lista de casos publicados |
| GET | `/portfolio/{slug}` | `portfolio.show` | Detalhe de caso |
| GET | `/sobre` | `about` | História, método e credenciais |
| 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 | `/contato` | `contact` | Briefing de contato |
| GET | `/privacidade` | `privacy` | Política de privacidade |
| GET | `/sitemap.xml` | `sitemap` | Sitemap público |
| GET | `/robots.txt` | `robots` | Política de crawling |
@@ -328,8 +324,7 @@ Administração
- Dashboard: `Filament Page` customizada com widgets orientados a exceção.
- Detalhe do evento: página customizada do Resource com resumo operacional.
- Briefing comercial: Blade + Controller (`POST /briefing`), ver §11.2.
- Contato de parceiro: Blade + Controller (`POST /contato`) — canal separado do briefing.
- Briefing público: Blade + Controller (`POST /contato`), ver §11.2.
- Home: Blade com componentes de design reutilizáveis.
### 5.4 Decisões de UX YAGNI
@@ -1248,35 +1243,6 @@ Auditoria não precisa ser um event sourcing. Registrar apenas operações crít
## 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
| Módulo | Responsabilidade |
@@ -1829,7 +1795,7 @@ Não transformar seções estáticas em componentes Livewire. Usar Blade quando
### 11.2 Formulário de briefing
> **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.
> **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.
O formulário deve:
@@ -2107,7 +2073,7 @@ quality → Pint check + PHPStan/Larastan + audits + testes
### 14.3 Branches e ambientes
- PR: testes e preview opcional;
- `main`: build imutável por SHA publicado no registry Gitea (`git.hellomanoel.com`) e deploy automático em staging via Dokploy;
- `main`: build imutável por SHA publicado no GHCR e deploy automático em staging via Dokploy;
- 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 requer aprovação humana explícita no MVP (`workflow_dispatch` com confirmação);
@@ -2521,7 +2487,7 @@ Toda operação financeira deve:
| ADR-011 | Cloudflare R2 (S3-compatible) como storage de objetos em produção | Aceita |
| ADR-012 | E-mail transacional via Resend (mailer nativo Laravel) | Aceita |
| ADR-013 | Design system Heritage Editorial para o site público | Aceita |
| ADR-014 | Deploy via Dokploy Compose com imagem imutável por SHA no registry Gitea | Aceita |
| ADR-014 | Deploy via Dokploy Compose com imagem imutável por SHA no GHCR | 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 |

View File

@@ -1,20 +0,0 @@
<?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

@@ -1,25 +0,0 @@
<?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

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

View File

@@ -63,9 +63,6 @@ final class MediaGenerateVariantsCommand extends Command
if ($settings && filled($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)) {
$paths[] = (string) $settings->hero_image_path;
}

View File

@@ -1,45 +0,0 @@
<?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

@@ -268,10 +268,8 @@ class ManageSiteSettings extends Page
->columns(2),
Section::make('Página Sobre')
->schema([
PublicImageUploadRules::fileUpload('about_image_path', 'Imagem do hero / Sobre', 'content/about'),
PublicImageUploadRules::fileUpload('about_image_path', 'Imagem da página Sobre', 'content/about'),
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),
Section::make('SEO padrão')

View File

@@ -67,6 +67,10 @@ class WeddingPackageForm
->label('Texto do botão')
->required()
->maxLength(255),
Textarea::make('whatsapp_message')
->label('Mensagem do WhatsApp (deixe vazio para usar o padrão)')
->helperText('Texto enviado ao cliente ao tocar no botão de contato. O nome da modalidade é citado automaticamente se vazio.')
->rows(3),
TextInput::make('compare_heading')
->label('Comparação — título (leitura rápida)')
->maxLength(255),

View File

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

View File

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

View File

@@ -19,8 +19,6 @@ use Illuminate\Database\Eloquent\Model;
* @property string|null $default_og_image_alt
* @property string|null $about_image_path
* @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_alt
* @property string|null $services_hero_image_path
@@ -49,8 +47,6 @@ use Illuminate\Database\Eloquent\Model;
'about_summary',
'about_image_path',
'about_image_alt',
'founder_image_path',
'founder_image_alt',
'manifesto_title',
'manifesto_lead',
'manifesto_body',

View File

@@ -23,6 +23,7 @@ use Illuminate\Support\Str;
* @property string $summary
* @property list<string> $scope_items
* @property string $cta_label
* @property string|null $whatsapp_message
* @property string|null $compare_heading
* @property string|null $compare_summary
* @property string|null $eyebrow
@@ -54,6 +55,7 @@ use Illuminate\Support\Str;
'summary',
'scope_items',
'cta_label',
'whatsapp_message',
'compare_heading',
'compare_summary',
'sort_order',

View File

@@ -8,45 +8,50 @@ use App\Models\SiteSetting;
final class PackageContactLink
{
private const ORIENTATION_MESSAGE = 'Olá, ainda não sei qual modalidade de acompanhamento combina com o meu casamento e gostaria de orientação da Amare.';
/**
* Resolve the contextual contact link for a wedding package modality.
*
* Uses the package-specific WhatsApp message when provided; otherwise
* falls back to the default template with the modality name.
*
* @return array{href: string, isWhatsapp: bool}
*/
public static function for(SiteSetting $settings, string $packageName): array
public static function for(SiteSetting $settings, string $packageName, ?string $customMessage = null): array
{
return self::resolve(
$message = filled($customMessage)
? $customMessage
: 'Olá, gostaria de conversar sobre a modalidade '.$packageName.' para meu casamento.';
return self::build($settings, $message, $packageName);
}
/**
* Resolve a generic contact link (no specific modality), used by the
* "Conversar com a Amare" guidance band.
*
* @return array{href: string, isWhatsapp: bool}
*/
public static function generic(SiteSetting $settings): array
{
return self::build(
$settings,
'Olá, gostaria de conversar sobre a modalidade '.$packageName.' para meu casamento.',
['servico_interesse' => $packageName],
'Olá, ainda estou decidindo a modalidade ideal para o meu casamento. Podemos conversar?',
null,
);
}
/**
* Resolve the orientation CTA for visitors who have not chosen a modality yet.
*
* @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
private static function build(SiteSetting $settings, string $message, ?string $packageName): 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),
? 'https://wa.me/'.$digits.'?text='.rawurlencode($message)
: route('briefing', $packageName !== null ? ['servico_interesse' => $packageName] : []),
'isWhatsapp' => $isWhatsapp,
];
}

View File

@@ -1,25 +0,0 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('site_settings', function (Blueprint $table): void {
$table->string('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,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('wedding_packages', function (Blueprint $table): void {
$table->text('whatsapp_message')->nullable()->after('cta_label');
});
}
public function down(): void
{
Schema::table('wedding_packages', function (Blueprint $table): void {
$table->dropColumn('whatsapp_message');
});
}
};

View File

@@ -71,8 +71,6 @@ class ContentSeeder extends Seeder
'about_summary' => 'Assessoria boutique especializada em experiências memoráveis em São Paulo.',
'about_image_path' => $this->copyFixture('about-image.jpg', 'content/about/about-image.jpg'),
'about_image_alt' => 'Mesa de planejamento com caderno, café e guardanapos de pano',
'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_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.',

View File

@@ -52,8 +52,6 @@ class VisualContentSeeder extends Seeder
'about_summary' => 'Assessoria boutique especializada em experiências memoráveis em São Paulo.',
'about_image_path' => $this->writeSolidJpeg('visual/about/about-image.jpg', 1200, 900, [232, 228, 218]),
'about_image_alt' => 'Imagem editorial da página Sobre',
'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_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.',

View File

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

View File

@@ -1,28 +1,51 @@
# Domain Docs
How engineering skills should consume this repo's domain documentation.
How the engineering skills should consume this repo's domain documentation when exploring the codebase.
## Single source of truth
## Before exploring, read these
**`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.md`** at the repo root, or
- **`CONTEXT-MAP.md`** at the repo root if it exists — it points at one `CONTEXT.md` per context. Read each one relevant to the topic.
- **`docs/adr/`** — read ADRs that touch the area you're about to work in. In multi-context repos, also check `src/<context>/docs/adr/` for context-scoped decisions.
Also useful:
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.
- **`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
## File structure
## Before exploring
Single-context repo (most repos):
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
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_`).
When your output names a domain concept (in an issue title, a refactor proposal, a hypothesis, a test name), use the term as defined in `CONTEXT.md`. Don't drift to synonyms the glossary explicitly avoids.
If the concept 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`.
If the concept you need isn't in the glossary yet, that's a signal — either you're inventing language the project doesn't use (reconsider) or there's a real gap (note it for `/domain-modeling`).
## Flag ADR conflicts
If output contradicts an ADR in `SPEC.md` §21, surface it explicitly rather than silently overriding.
If your output contradicts an existing ADR, surface it explicitly rather than silently overriding:
> _Contradicts ADR-0007 (event-sourced orders) — but worth reopening because…_

View File

@@ -17,16 +17,6 @@ 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).
- **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"
Create a Linear issue with `save_issue` on team `Maneco-workspace`.

View File

@@ -1,11 +1,11 @@
# Deploy Dokploy (staging → production)
Runbook for operating Amare on a VPS with Dokploy connected to Gitea (git.hellomanoel.com), publishing immutable images to Gitea's container registry.
Runbook for operating Amare on a VPS with Dokploy connected to GitHub, publishing immutable images to GHCR.
## Architecture
```
CI (main) → build FrankenPHP image → git.hellomanoel.com registry :<sha> + :staging
CI (main) → build FrankenPHP image → GHCR :<sha> + :staging
→ Dokploy staging compose.deploy
→ 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) |
| Processes | `migrate` (one-shot) → `web` / `queue` / `scheduler` |
| Image | `git.hellomanoel.com/manoel-freitas/amare:<sha>` (+ aliases `:staging`, `:production`) |
| Image | `ghcr.io/<owner>/<repo>:<sha>` (+ aliases `:staging`, `:production`) |
| Database | Dokploy PostgreSQL **per environment** (not in the app image) |
| Media | Cloudflare R2 (`FILESYSTEM_DISK=r2`), separate buckets per environment |
| Mail | Resend (`MAIL_MAILER=resend`) |
@@ -26,14 +26,11 @@ Promote (manual) → retag same digest as :production (no rebuild)
## Prerequisites (manual)
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. Dokploy installed on the VPS.
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 PostgreSQL services in Dokploy (staging + production), private (no public port).
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).
1. Dokploy installed on the VPS; GitHub provider connected.
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.
3. Two PostgreSQL services in Dokploy (staging + production), private (no public port).
4. Two R2 buckets (or prefixes) and Resend credentials for each environment as needed.
5. Domains (or temporary Dokploy/traefik.me hosts) pointing at the VPS with TLS.
## Create Compose stacks
@@ -47,7 +44,7 @@ Create **two** Dokploy Compose services (same repo, same compose path):
Dokploy Environment for each stack must set:
```bash
APP_IMAGE=git.hellomanoel.com/manoel-freitas/amare
APP_IMAGE=ghcr.io/<owner>/<repo>
IMAGE_TAG=staging # or production
```
@@ -55,11 +52,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.
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`.
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`.
## Required Laravel env (Dokploy only)
Set these in Dokploy Environment UI (written to `.env` next to the compose file). **Never** put them in Gitea Actions secrets or image layers.
Set these in Dokploy Environment UI (written to `.env` next to the compose file). **Never** put them in GitHub Actions secrets or image layers.
```env
APP_NAME=Amare
@@ -123,9 +120,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.
## Gitea Actions secrets
## GitHub Actions secrets
Repository secrets (Gitea → Settings → Actions → Secrets) used by workflows:
Repository secrets used by workflows:
| Secret | Purpose |
|---|---|
@@ -135,48 +132,32 @@ Repository secrets (Gitea → Settings → Actions → Secrets) used by workflow
| `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`) |
| `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`.
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.
`GITHUB_TOKEN` (automatic) publishes to GHCR with `packages:write`. No Laravel/`APP_KEY`/DB/R2/Resend secrets belong in GitHub for this pipeline.
## Workflows
Workflows 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)
[`.gitea/workflows/deploy-staging.yml`](../../.gitea/workflows/deploy-staging.yml) — separate workflow, triggered by `workflow_run` when **CI** completes on `main`.
[`.github/workflows/deploy-staging.yml`](../../.github/workflows/deploy-staging.yml)
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`.
1. Waits for workflow `CI` success (`workflow_run`) on push to `main`.
2. Builds once; pushes `:<full-sha>` and `:staging` to the Gitea registry.
1. Waits for workflow `CI` success on push to `main`.
2. Builds once; pushes `:<full-sha>` and `:staging`.
3. Calls Dokploy `compose.deploy` and polls until done.
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)
[`.gitea/workflows/promote-production.yml`](../../.gitea/workflows/promote-production.yml)
[`.github/workflows/promote-production.yml`](../../.github/workflows/promote-production.yml)
1. Operator runs **Gitea → Actions → Promote production**.
2. Inputs: full `sha` already in the Gitea registry; `confirm` must be exactly `PRODUCTION`.
1. Operator runs **Actions → Promote production**.
2. Inputs: full `sha` already on GHCR; `confirm` must be exactly `PRODUCTION`.
3. Retags the **same digest** as `:production` (no rebuild).
4. Deploys production compose + smoke.
Human approval is the explicit `workflow_dispatch` + confirmation string (Gitea does not support GitHub Environment required reviewers).
Private repos on GitHub Free do not get Environment required reviewers; human approval is the explicit `workflow_dispatch` + confirmation string. GitHub Pro Environment reviewers are optional later.
## First admin and authorized production seeding
@@ -275,10 +256,10 @@ No rebuild. Move the environment alias to a previous SHA digest and redeploy.
### Staging
```bash
# Locally or in a one-off Actions shell with Gitea registry login
# Locally or in a one-off Actions shell with GHCR login
docker buildx imagetools create \
--tag git.hellomanoel.com/manoel-freitas/amare:staging \
git.hellomanoel.com/manoel-freitas/amare:<previous-sha>
--tag ghcr.io/<owner>/<repo>:staging \
ghcr.io/<owner>/<repo>:<previous-sha>
# Then trigger Dokploy deploy (UI Deploy, or):
DOKPLOY_URL=... DOKPLOY_API_KEY=... DOKPLOY_COMPOSE_ID=... \
@@ -316,7 +297,7 @@ Expects HTTP 200 for `/up`, `/`, and `/admin/login`.
## Local validation of Compose
```bash
APP_IMAGE=git.hellomanoel.com/manoel-freitas/amare IMAGE_TAG=staging \
APP_IMAGE=ghcr.io/<owner>/<repo> IMAGE_TAG=staging \
docker compose -f docker-compose.deploy.yml config
```

View File

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

View File

@@ -1,27 +0,0 @@
# 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

@@ -1,26 +0,0 @@
# 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

@@ -1,32 +0,0 @@
## 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

@@ -1,47 +0,0 @@
## 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

@@ -1,8 +0,0 @@
# 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

@@ -2,18 +2,18 @@
## Purpose
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.
Define immutable staging and production promotion through GHCR and Dokploy Compose, including migration, health, smoke, rollback, and backup evidence.
## Requirements
### 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 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.
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.
#### Scenario: Same image serves all application processes
- **WHEN** a staging deployment is promoted for commit SHA `abc123`
- **THEN** web, queue, and scheduler MUST run from `git.hellomanoel.com/<owner>/<repo>:abc123` (or equivalent digest)
- **THEN** web, queue, and scheduler MUST run from `ghcr.io/<owner>/<repo>:abc123` (or equivalent digest)
- **AND** MUST NOT rebuild distinct images per process
#### 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
- **WHEN** the operator confirms promotion of commit SHA `abc123`
- **THEN** production web, queue, and scheduler MUST run the same digest previously published as `git.hellomanoel.com/<owner>/<repo>:abc123`
- **THEN** production web, queue, and scheduler MUST run the same digest previously published as `ghcr.io/<owner>/<repo>:abc123`
- **AND** MUST NOT rebuild from source for that promotion
### Requirement: Database backups exist before production cutover

View File

@@ -134,8 +134,7 @@
overflow-wrap: break-word;
}
[id$='-heading'],
.home-chapter[id] {
[id$='-heading'] {
scroll-margin-top: 5.5rem;
}

View File

@@ -1,16 +0,0 @@
@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

@@ -1,40 +0,0 @@
@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

@@ -1,50 +0,0 @@
@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

@@ -1,31 +0,0 @@
<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

@@ -1,64 +0,0 @@
@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

@@ -1,30 +0,0 @@
<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

@@ -15,9 +15,9 @@
])
@php
$orientationLink = \App\Support\PackageContactLink::forOrientation($settings);
$bandHref = $bandCtaHref ?? $orientationLink['href'];
$bandIsWhatsapp = $bandCtaHref === null && $orientationLink['isWhatsapp'];
$bandLink = $bandCtaHref
? ['href' => $bandCtaHref, 'isWhatsapp' => false]
: \App\Support\PackageContactLink::generic($settings);
@endphp
<section
aria-labelledby="packages-heading"
@@ -54,7 +54,7 @@
</ul>
</div>
<div class="space-y-5">
@php($contactLink = $ctaRoute ? null : \App\Support\PackageContactLink::for($settings, $package->name))
@php($contactLink = $ctaRoute ? null : \App\Support\PackageContactLink::for($settings, $package->name, $package->whatsapp_message))
<a
href="{{ $ctaRoute ? route($ctaRoute) : $contactLink['href'] }}"
@if ($ctaRoute === null && $contactLink['isWhatsapp']) target="_blank" rel="noopener noreferrer" @endif
@@ -80,8 +80,8 @@
<p class="text-amare-accent-text/80">{{ $bandBody }}</p>
</div>
<a
href="{{ $bandHref }}"
@if ($bandIsWhatsapp) target="_blank" rel="noopener noreferrer" @endif
href="{{ $bandLink['href'] }}"
@if ($bandLink['isWhatsapp']) 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

View File

@@ -8,7 +8,7 @@
@else
<ol class="grid gap-8 border-t border-amare-border pt-6 md:grid-cols-3">
@foreach ($packages as $package)
@php($contactLink = \App\Support\PackageContactLink::for($settings, $package->name))
@php($contactLink = \App\Support\PackageContactLink::for($settings, $package->name, $package->whatsapp_message))
<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>
@endforeach
</ol>

View File

@@ -3,10 +3,11 @@
'heading' => null,
'body' => null,
'packageName' => null,
'packageMessage' => null,
])
@php
$link = \App\Support\PackageContactLink::for($settings, $packageName ?? '');
$link = \App\Support\PackageContactLink::for($settings, $packageName ?? '', $packageMessage);
@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>

View File

@@ -58,8 +58,8 @@
<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] 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>
<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="{{ 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') }}" 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">Início</a>
<a href="{{ route('services.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">Serviços</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('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="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>
@@ -73,6 +73,7 @@
<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">
Conte seu evento
</a>
</a>
<button
type="button"
@@ -110,11 +111,10 @@
$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="container-amare flex flex-col gap-8 py-14 md:flex-row md:items-end md:justify-between md:py-[58px]">
<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>
<p class="text-sm text-amare-muted">Assessoria &amp; produção de eventos &bull; São Paulo</p>
</div>
<p class="text-sm text-amare-muted md:text-right">
@@ -128,21 +128,12 @@
</p>
</div>
<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">
<a href="{{ url('/#casamentos') }}" class="inline-flex min-h-11 items-center transition-colors hover:text-amare-accent">Casamentos</a>
<a href="{{ url('/#corporate') }}" class="inline-flex min-h-11 items-center transition-colors hover:text-amare-accent">Corporate</a>
<a href="{{ route('services.index') }}" class="inline-flex min-h-11 items-center transition-colors hover:text-amare-accent">Serviços</a>
<a href="{{ route('portfolio.index') }}" class="inline-flex min-h-11 items-center transition-colors hover:text-amare-accent">Portfólio</a>
<a href="{{ route('about') }}" class="inline-flex min-h-11 items-center transition-colors hover:text-amare-accent">Sobre</a>
<a href="{{ route('briefing') }}" class="inline-flex min-h-11 items-center transition-colors hover:text-amare-accent">Briefing</a>
<a href="{{ route('contact') }}" class="inline-flex min-h-11 items-center transition-colors hover:text-amare-accent">Parcerias</a>
<a href="{{ route('privacy') }}" class="inline-flex min-h-11 items-center transition-colors hover:text-amare-accent">Privacidade</a>
</nav>
</div>
<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">
<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>
</footer>

View File

@@ -1,10 +1,29 @@
@extends('layouts.public')
@section('content')
<x-about.hero :settings="$content->settings" />
<x-about.pillars />
<x-about.founder :settings="$content->settings" />
<x-about.process />
<x-about.portfolio :cases="$content->featuredCases" />
<x-about.cta :settings="$content->settings" />
@php
$principles = filled($siteSettings->principles)
? $siteSettings->principles
: \App\Models\SiteSetting::defaultPrinciples();
$city = $siteSettings->city ?: 'São Paulo - SP';
@endphp
<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

View File

@@ -31,5 +31,6 @@
:heading="$package->final_cta_heading"
:body="$package->final_cta_body"
:package-name="$package->name"
:package-message="$package->whatsapp_message"
/>
@endsection

View File

@@ -5,38 +5,9 @@
<div class="border-b border-amare-border bg-amare-bg-deep">
<div class="container-amare space-y-10 py-16 md:py-24">
<nav aria-label="Vertentes do portfólio" class="flex flex-wrap gap-x-6 gap-y-2 border-b border-amare-border pb-6 text-sm">
<a
href="{{ route('portfolio.index') }}"
@class([
'inline-flex min-h-11 items-center uppercase tracking-[0.08em] transition-colors',
'font-semibold text-amare-accent' => $vertical === null,
'text-amare-muted hover:text-amare-accent' => $vertical !== null,
])
>Todos</a>
@foreach (\App\Domain\Marketing\PortfolioVertical::cases() as $option)
<a
href="{{ route('portfolio.index', ['vertente' => $option->value]) }}"
@class([
'inline-flex min-h-11 items-center uppercase tracking-[0.08em] transition-colors',
'font-semibold text-amare-accent' => $vertical === $option,
'text-amare-muted hover:text-amare-accent' => $vertical !== $option,
])
>{{ $option->label() }}</a>
@endforeach
</nav>
@if ($cases->isEmpty())
@if ($vertical === \App\Domain\Marketing\PortfolioVertical::Corporate)
<div class="flex min-h-[280px] flex-col items-center justify-center gap-4 bg-amare-bg p-10 text-center">
<p class="text-xs font-bold uppercase tracking-[0.16em] text-amare-accent-deep">Portfólio Corporate</p>
<h2 class="text-[clamp(1.5625rem,2.7vw,2.125rem)] font-medium leading-tight text-amare-text">Conteúdo em construção</h2>
<p class="max-w-lg text-amare-text-muted">Ainda não publicamos cases corporativos autorizados. Em vez de inventar prova, mantemos este espaço pronto para projetos reais.</p>
<a href="{{ route('briefing') }}" class="btn btn-outline mt-2 text-xs font-bold uppercase tracking-[0.09em]">Falar sobre um evento corporativo</a>
</div>
@else
<p class="text-amare-muted">Novos casos serão publicados assim que o acervo estiver organizado. Enquanto isso, fale conosco para conhecer o nosso trabalho.</p>
@endif
@else
@php
$hasPrioritizedImage = false;
@@ -82,9 +53,6 @@
@endphp
@endif
<div class="space-y-2 border-t border-amare-border pt-4">
@if (filled($case->event_type))
<p class="text-xs font-bold uppercase tracking-[0.16em] text-amare-accent-deep">{{ $case->event_type }}</p>
@endif
<h2 class="text-2xl font-medium text-amare-text">
<a href="{{ route('portfolio.show', $case->slug) }}" class="transition-colors hover:text-amare-accent">{{ $case->title }}</a>
</h2>

View File

@@ -47,6 +47,8 @@
:show-intro="false"
kicker="tag"
:show-subtitle="true"
cta-route="contact"
:band-cta-href="route('contact')"
band-body="Conte um pouco sobre o casamento. A Amare entende o momento de vocês e orienta o melhor formato de acompanhamento sem depender de um quiz automático."
:show-note="false"
/>

View File

@@ -5,7 +5,7 @@
# DOKPLOY_API_KEY x-api-key value
# DOKPLOY_COMPOSE_ID target compose id
# Optional env:
# DEPLOY_TITLE deployment title (default: Gitea deploy)
# DEPLOY_TITLE deployment title (default: GitHub deploy)
# DEPLOY_TIMEOUT_SEC total wait seconds (default: 900)
# DEPLOY_POLL_SEC poll interval (default: 10)
@@ -20,7 +20,7 @@ DOKPLOY_URL="${DOKPLOY_URL%/}"
DOKPLOY_URL="${DOKPLOY_URL%/api}"
DOKPLOY_URL="${DOKPLOY_URL%/}"
DEPLOY_TITLE="${DEPLOY_TITLE:-Gitea deploy}"
DEPLOY_TITLE="${DEPLOY_TITLE:-GitHub deploy}"
DEPLOY_TIMEOUT_SEC="${DEPLOY_TIMEOUT_SEC:-900}"
DEPLOY_POLL_SEC="${DEPLOY_POLL_SEC:-10}"

View File

@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace Tests\Feature\Application\Queries\Marketing;
use App\Application\Queries\Marketing\GetPublishedPortfolioCases;
use App\Domain\Marketing\PortfolioVertical;
use App\Models\PortfolioCase;
use App\Models\PortfolioImage;
use Illuminate\Foundation\Testing\RefreshDatabase;
@@ -59,26 +58,4 @@ class GetPublishedPortfolioCasesTest extends TestCase
$this->assertTrue($cases->get(0)?->relationLoaded('images'));
$this->assertCount(2, $cases->get(0)?->images ?? []);
}
public function test_filters_published_cases_by_portfolio_vertical(): void
{
PortfolioCase::factory()->published()->create([
'title' => 'Wedding Case',
'event_type' => 'Mini wedding',
'sort_order' => 10,
]);
PortfolioCase::factory()->published()->create([
'title' => 'Corporate Case',
'event_type' => 'Evento corporativo',
'sort_order' => 20,
]);
$weddings = (new GetPublishedPortfolioCases)(PortfolioVertical::Casamentos);
$corporate = (new GetPublishedPortfolioCases)(PortfolioVertical::Corporate);
$this->assertCount(1, $weddings);
$this->assertSame('Wedding Case', $weddings->first()?->title);
$this->assertCount(1, $corporate);
$this->assertSame('Corporate Case', $corporate->first()?->title);
}
}

View File

@@ -1,145 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\PublicSite;
use App\Filament\Pages\ManageSiteSettings;
use App\Models\PortfolioCase;
use App\Models\SiteSetting;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Livewire\Livewire;
use Tests\TestCase;
class AboutPageContentTest extends TestCase
{
use RefreshDatabase;
public function test_about_page_renders_mock_section_order_without_principles_list(): void
{
SiteSetting::instance()->update([
'about_summary' => 'Eventos que refletem propósito, conectam pessoas e criam memórias.',
'principles' => ['Princípio que não deve aparecer no sobre'],
]);
$this->get(route('about'))
->assertOk()
->assertSee('data-motion="page-open"', false)
->assertSee('Sobre nós')
->assertSee('Sobre a Amare')
->assertSee('Eventos que refletem propósito, conectam pessoas e criam memórias.')
->assertSee('Atendimento próximo')
->assertSee('Planejamento minucioso')
->assertSee('Execução tranquila')
->assertSee('id="michele"', false)
->assertSee('Conheça Michele')
->assertSee('Como trabalhamos')
->assertSee('Escuta e entendimento')
->assertSee('Portfólio em destaque')
->assertSee('Vamos criar algo inesquecível juntos?')
->assertDontSee('Princípio que não deve aparecer no sobre')
->assertDontSee('Personalização sem complicação desnecessária');
}
public function test_about_layout_matches_site_editorial_rhythm(): void
{
SiteSetting::instance()->update([
'about_image_path' => 'content/heroes/about.jpg',
'about_image_alt' => 'Estúdio Amare',
]);
$html = $this->get(route('about'))->assertOk()->getContent();
$this->assertMatchesRegularExpression(
'/data-about-hero[\s\S]*?lg:grid-cols-12[\s\S]*?lg:col-span-5[\s\S]*?text-hero-spread[\s\S]*?lg:col-span-7/',
$html,
);
$this->assertStringNotContainsString('data-photo-hero', $html);
$this->assertDoesNotMatchRegularExpression(
'/data-about-hero[\s\S]*?min-h-\[calc\(100dvh-5rem\)\]/',
$html,
);
$this->assertMatchesRegularExpression('/data-about-pillars[\s\S]*?py-16 md:py-24/', $html);
$this->assertMatchesRegularExpression('/data-about-process[\s\S]*?py-16 md:py-24/', $html);
$this->assertMatchesRegularExpression('/data-about-portfolio[\s\S]*?data-editorial-portfolio/', $html);
$this->assertDoesNotMatchRegularExpression('/data-about-portfolio[\s\S]*?rounded-full/', $html);
$this->assertMatchesRegularExpression('/<(?:section)[^>]*(?:data-about-cta[^>]*bg-amare-bg-deep|bg-amare-bg-deep[^>]*data-about-cta)/', $html);
$this->assertStringContainsString('Próximo passo', $html);
}
public function test_about_renders_founder_image_from_cms_when_configured(): void
{
SiteSetting::instance()->update([
'founder_image_path' => 'content/about/founder/michele.jpg',
'founder_image_alt' => 'Michele, da Amare',
]);
$this->get(route('about'))
->assertOk()
->assertSee('content/about/founder/michele.jpg', false)
->assertSee('Michele, da Amare')
->assertSee('data-about-founder', false);
}
public function test_about_omits_founder_image_request_when_unset(): void
{
SiteSetting::instance()->update([
'founder_image_path' => null,
'founder_image_alt' => null,
]);
$html = $this->get(route('about'))->assertOk()->getContent();
$this->assertStringContainsString('data-about-founder', $html);
$this->assertStringNotContainsString('content/about/founder/', $html);
$this->assertStringContainsString('data-founder-tonal', $html);
}
public function test_about_portfolio_links_featured_cases(): void
{
$case = PortfolioCase::factory()->published()->create([
'title' => 'Casamento Ana e Lucas',
'slug' => 'casamento-ana-lucas',
'is_featured' => true,
'cover_image_path' => 'content/cases/ana-lucas.jpg',
'cover_image_alt' => 'Cerimônia ao ar livre',
'sort_order' => 1,
]);
PortfolioCase::factory()->published()->create([
'title' => 'Caso não destacado',
'slug' => 'nao-destacado',
'is_featured' => false,
'cover_image_path' => 'content/cases/other.jpg',
'sort_order' => 2,
]);
$this->get(route('about'))
->assertOk()
->assertSee(route('portfolio.show', $case), false)
->assertSee('Cerimônia ao ar livre')
->assertDontSee(route('portfolio.show', 'nao-destacado'), false);
}
public function test_founder_image_upload_requires_alt_text(): void
{
Storage::fake('public');
$this->actingAs(User::factory()->admin()->create());
Livewire::test(ManageSiteSettings::class)
->set('data.founder_image_path', [UploadedFile::fake()->create('michele.jpg', 100, 'image/jpeg')])
->set('data.founder_image_alt', null)
->call('save')
->assertHasFormErrors(['founder_image_alt' => 'required']);
Livewire::test(ManageSiteSettings::class)
->set('data.founder_image_path', null)
->set('data.founder_image_alt', null)
->call('save')
->assertHasNoFormErrors();
}
}

View File

@@ -213,26 +213,45 @@ class HomePageContentTest extends TestCase
false,
)
->assertSee('target="_blank"', false)
->assertDontSee(route('briefing', ['servico_interesse' => 'Grand Jour']), false)
->assertSee(
'https://wa.me/5511988887777?text='.rawurlencode('Olá, ainda não sei qual modalidade de acompanhamento combina com o meu casamento e gostaria de orientação da Amare.'),
false,
);
->assertDontSee(route('briefing', ['servico_interesse' => 'Grand Jour']), false);
}
public function test_orientation_cta_falls_back_to_briefing_without_whatsapp_number(): void
public function test_package_cta_uses_custom_whatsapp_message_when_provided(): void
{
SiteSetting::instance()->update(['whatsapp_number' => null]);
SiteSetting::instance()->update(['whatsapp_number' => '+55 11 98888-7777']);
WeddingPackage::factory()->published()->create([
'name' => 'Grand Jour',
'cta_label' => 'Quero conhecer a Grand Jour',
'whatsapp_message' => 'Olá, quero saber mais sobre a modalidade Grand Jour para o meu casamento em julho.',
]);
$response = $this->get(route('home'));
$response
->assertOk()
->assertSee('href="'.route('briefing').'"', false)
->assertDontSee(
'Olá, ainda não sei qual modalidade de acompanhamento combina com o meu casamento e gostaria de orientação da Amare.',
->assertSee(
'https://wa.me/5511988887777?text='.rawurlencode('Olá, quero saber mais sobre a modalidade Grand Jour para o meu casamento em julho.'),
false,
);
)
->assertDontSee('gostaria de conversar sobre a modalidade Grand Jour para meu casamento', false);
}
public function test_guidance_band_uses_whatsapp_when_a_number_is_configured(): void
{
SiteSetting::instance()->update(['whatsapp_number' => '+55 11 98888-7777']);
WeddingPackage::factory()->published()->create();
$response = $this->get(route('home'));
$response
->assertOk()
->assertSee(
'https://wa.me/5511988887777?text='.rawurlencode('Olá, ainda estou decidindo a modalidade ideal para o meu casamento. Podemos conversar?'),
false,
)
->assertSee('Conversar com a Amare');
}
public function test_corporate_steps_and_placeholder_render_from_settings(): void

View File

@@ -80,14 +80,6 @@ class ImmersivePhotoHeroTest extends TestCase
->assertDontSee('data-split-hero', false)
->assertDontSee('content/heroes/home.jpg', false);
}
if ($route === route('about')) {
$response
->assertSee('data-motion="page-open"', false)
->assertSee('data-about-hero', false)
->assertDontSee('data-photo-hero', false)
->assertDontSee('min-h-[calc(100dvh-5rem)]', false);
}
}
}
@@ -107,7 +99,7 @@ class ImmersivePhotoHeroTest extends TestCase
'cover_image_alt' => 'Cerimônia ao ar livre',
]);
foreach ([route('services.index'), route('portfolio.index'), route('portfolio.show', $case->slug)] as $route) {
foreach ([route('services.index'), route('portfolio.index'), route('portfolio.show', $case->slug), route('about')] as $route) {
$this->get($route)
->assertOk()
->assertSee('data-photo-hero', false)
@@ -115,15 +107,6 @@ class ImmersivePhotoHeroTest extends TestCase
->assertSee('fetchpriority="high"', false)
->assertSee('sizes="(max-width: 1023px) 100vw, 58vw"', false);
}
$this->get(route('about'))
->assertOk()
->assertSee('data-about-hero', false)
->assertSee('content/heroes/about.jpg', false)
->assertSee('loading="eager"', false)
->assertSee('fetchpriority="high"', false)
->assertDontSee('data-photo-hero', false)
->assertDontSee('data-tonal-hero', false);
}
public function test_hero_upload_requires_alt_text_only_when_an_image_is_uploaded(): void

View File

@@ -100,6 +100,26 @@ class PackageDetailTest extends TestCase
->assertDontSee(route('briefing', ['servico_interesse' => 'Essenza']), false);
}
public function test_package_final_cta_uses_custom_whatsapp_message_when_provided(): void
{
SiteSetting::instance()->update(['whatsapp_number' => '+55 11 98888-7777']);
$package = WeddingPackage::factory()->published()->create([
'name' => 'Essenza',
'whatsapp_message' => 'Olá, tenho interesse na assessoria Essenza. Podem me passar mais detalhes?',
]);
$response = $this->get(route('packages.show', $package->slug));
$response
->assertOk()
->assertSee(
'https://wa.me/5511988887777?text='.rawurlencode('Olá, tenho interesse na assessoria Essenza. Podem me passar mais detalhes?'),
false,
)
->assertDontSee('gostaria de conversar sobre a modalidade Essenza para meu casamento', false);
}
public function test_package_cta_falls_back_to_briefing_without_whatsapp_number(): void
{
SiteSetting::instance();

View File

@@ -1,60 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\PublicSite;
use App\Models\PortfolioCase;
use App\Models\SiteSetting;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class PortfolioVerticalFilterTest extends TestCase
{
use RefreshDatabase;
public function test_portfolio_index_filters_by_vertical_and_shows_corporate_empty_state(): void
{
SiteSetting::instance();
PortfolioCase::factory()->published()->create([
'title' => 'Casamento Jardim',
'slug' => 'casamento-jardim',
'event_type' => 'Casamento',
'sort_order' => 10,
]);
PortfolioCase::factory()->published()->create([
'title' => 'Convenção Anual',
'slug' => 'convencao-anual',
'event_type' => 'Corporativo',
'sort_order' => 20,
]);
$this->get(route('portfolio.index'))
->assertOk()
->assertSee('Casamento Jardim')
->assertSee('Convenção Anual')
->assertSee('Vertentes do portfólio')
->assertSee('Casamentos')
->assertSee('Corporate');
$this->get(route('portfolio.index', ['vertente' => 'casamentos']))
->assertOk()
->assertSee('Casamento Jardim')
->assertDontSee('Convenção Anual');
$this->get(route('portfolio.index', ['vertente' => 'corporate']))
->assertOk()
->assertSee('Convenção Anual')
->assertDontSee('Casamento Jardim');
PortfolioCase::query()->where('slug', 'convencao-anual')->update(['published_at' => null]);
$this->get(route('portfolio.index', ['vertente' => 'corporate']))
->assertOk()
->assertSee('Portfólio Corporate')
->assertSee('Conteúdo em construção')
->assertDontSee('Convenção Anual');
}
}

View File

@@ -54,8 +54,7 @@ class PublicLayoutSeoTest extends TestCase
->assertSee('Amare Brand')
->assertSee('hello@amare.test')
->assertSee('(11) 91111-1111')
->assertSee('Privacidade')
->assertSee(route('privacy'), false)
->assertSee('Política de privacidade')
->assertSee('https://instagram.com/amare', false);
}
}

View File

@@ -1,43 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\PublicSite;
use App\Models\SiteSetting;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class PublicNavigationVerticalsTest extends TestCase
{
use RefreshDatabase;
public function test_header_and_footer_expose_casamentos_and_corporate_without_implying_weddings_only(): void
{
SiteSetting::instance();
$response = $this->get(route('about'))->assertOk();
$homeCasamentos = url('/#casamentos');
$homeCorporate = url('/#corporate');
$response
->assertSee('id="main-nav"', false)
->assertSee('href="'.$homeCasamentos.'"', false)
->assertSee('>Casamentos</a>', false)
->assertSee('href="'.$homeCorporate.'"', false)
->assertSee('>Corporate</a>', false)
->assertSee('href="'.route('portfolio.index').'"', false)
->assertSee('href="'.route('about').'"', false)
->assertSee('href="'.route('services.index').'"', false)
->assertSee('href="'.route('privacy').'"', false)
->assertSee('aria-label="Rodapé"', false)
->assertSeeInOrder([
'aria-label="Rodapé"',
'href="'.$homeCasamentos.'"',
'Casamentos',
'href="'.$homeCorporate.'"',
'Corporate',
], false);
}
}

View File

@@ -196,11 +196,9 @@ class PublicPagesTest extends TestCase
$this->get(route('about'))
->assertOk()
->assertSee('Sobre a Amare boutique')
->assertSee('Sobre a Amare')
->assertSee('Conheça Michele')
->assertSee('São Paulo - SP')
->assertDontSee('Fortaleza')
->assertSee('data-tonal-hero', false)
->assertSee('data-about-hero', false);
->assertSee('data-tonal-hero', false);
$this->get(route('privacy'))
->assertOk()

View File

@@ -1,67 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\PublicSite;
use App\Models\SiteSetting;
use App\Models\WeddingPackage;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ServicesPageCtaTest extends TestCase
{
use RefreshDatabase;
public function test_package_cta_uses_whatsapp_when_a_number_is_configured(): void
{
SiteSetting::instance()->update(['whatsapp_number' => '+55 11 98888-7777']);
WeddingPackage::factory()->published()->create([
'name' => 'Grand Jour',
'cta_label' => 'Quero conhecer a Grand Jour',
]);
$response = $this->get(route('services.index'));
$response
->assertOk()
->assertSee(
'https://wa.me/5511988887777?text='.rawurlencode('Olá, gostaria de conversar sobre a modalidade Grand Jour para meu casamento.'),
false,
)
->assertSee('target="_blank"', false)
->assertDontSee(route('briefing', ['servico_interesse' => 'Grand Jour']), false);
}
public function test_package_cta_falls_back_to_briefing_without_whatsapp_number(): void
{
SiteSetting::instance()->update(['whatsapp_number' => null]);
WeddingPackage::factory()->published()->create([
'name' => 'Essenza',
'cta_label' => 'Quero conhecer a Essenza',
]);
$response = $this->get(route('services.index'));
$response
->assertOk()
->assertSee('href="'.route('briefing', ['servico_interesse' => 'Essenza']).'"', false)
->assertDontSee('wa.me/', false);
}
public function test_orientation_cta_uses_distinct_whatsapp_message_when_a_number_is_configured(): void
{
SiteSetting::instance()->update(['whatsapp_number' => '+55 11 98888-7777']);
$response = $this->get(route('services.index'));
$response
->assertOk()
->assertSee(
'https://wa.me/5511988887777?text='.rawurlencode('Olá, ainda não sei qual modalidade de acompanhamento combina com o meu casamento e gostaria de orientação da Amare.'),
false,
);
}
}