Compare commits

..

1 Commits

Author SHA1 Message Date
63e4c96e3e fix: join Dokploy network so Compose reaches managed Postgres
Migrate could not resolve the database internal hostname because the
Compose stack used an isolated project network. Attach services to the
external dokploy-network used by Dokploy Postgres.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 23:12:06 -03:00
321 changed files with 1400 additions and 15086 deletions

View File

@@ -8,7 +8,7 @@ APP_URL=http://localhost
APP_LOCALE=pt_BR APP_LOCALE=pt_BR
APP_FALLBACK_LOCALE=pt_BR APP_FALLBACK_LOCALE=pt_BR
APP_FAKER_LOCALE=pt_BR APP_FAKER_LOCALE=pt_BR
APP_TIMEZONE=America/Sao_Paulo APP_TIMEZONE=America/Fortaleza
# Freeze application clock outside production (visual regression / deterministic seeds). # Freeze application clock outside production (visual regression / deterministic seeds).
# Example: APP_FROZEN_NOW=2026-03-15T12:00:00-03:00 # Example: APP_FROZEN_NOW=2026-03-15T12:00:00-03:00

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: pull_request:
concurrency: concurrency:
group: ci-${{ gitea.workflow }}-${{ gitea.ref }} group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true cancel-in-progress: true
env: env:
@@ -14,13 +14,11 @@ env:
APP_KEY: base64:NXm/6jIyFcDGHoMKGc5QZuSaq0dRZFYPg1Isuy1fNvE= APP_KEY: base64:NXm/6jIyFcDGHoMKGc5QZuSaq0dRZFYPg1Isuy1fNvE=
APP_LOCALE: pt_BR APP_LOCALE: pt_BR
APP_FALLBACK_LOCALE: pt_BR APP_FALLBACK_LOCALE: pt_BR
APP_TIMEZONE: America/Sao_Paulo APP_TIMEZONE: America/Fortaleza
BCRYPT_ROUNDS: 4 BCRYPT_ROUNDS: 4
CACHE_STORE: database CACHE_STORE: database
DB_CONNECTION: pgsql DB_CONNECTION: pgsql
# Service hostname on the per-job network (act_runner with empty DB_HOST: 127.0.0.1
# container.network). Do not publish host :5432/:8000 — parallel jobs collide.
DB_HOST: postgres
DB_PORT: 5432 DB_PORT: 5432
DB_DATABASE: amare_test DB_DATABASE: amare_test
DB_USERNAME: amare DB_USERNAME: amare
@@ -33,8 +31,7 @@ jobs:
name: static name: static
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v4
- run: cp .env.example .env
- uses: shivammathur/setup-php@v2 - uses: shivammathur/setup-php@v2
with: with:
@@ -42,61 +39,40 @@ jobs:
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd
coverage: none coverage: none
- uses: actions/cache@v4
with:
path: ~/.composer/cache/files
key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }}
restore-keys: composer-${{ runner.os }}-
- run: composer validate --strict - run: composer validate --strict
- run: composer install --no-interaction --prefer-dist - run: composer install --no-interaction --prefer-dist
- run: composer pint:check - run: composer pint:check
- run: composer phpstan - run: composer phpstan
- run: composer audit - run: composer audit
# audit-level=high: package.json has no runtime `dependencies` today (npm
# itself reports prod:1, 0 vulnerabilities), so this is currently a
# vacuous forward guard rather than an active protection. `high` is
# chosen deliberately over `low`/`moderate` so that once a real runtime
# JS dependency is added, the gate flags exploitable issues without
# becoming noisy on every transitive dev-only advisory.
- run: npm audit --omit=dev --audit-level=high
unit: unit:
name: unit name: unit
runs-on: ubuntu-latest runs-on: ubuntu-latest
# Postgres is required here (not just in `feature`) because the
# Domain/Application coverage gate below runs the Feature suite too: the
# App\Application\Queries\Marketing\* classes are only exercised through
# Feature (HTTP) tests today, so a Unit-only coverage run would undercount
# them well under the 80% threshold.
services:
postgres:
image: postgres:17
env:
POSTGRES_DB: amare_test
POSTGRES_USER: amare
POSTGRES_PASSWORD: secret
options: >-
--health-cmd "pg_isready -U amare -d amare_test"
--health-interval 5s
--health-timeout 5s
--health-retries 10
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v4
- run: cp .env.example .env
- uses: shivammathur/setup-php@v2 - uses: shivammathur/setup-php@v2
with: with:
php-version: "8.4" php-version: "8.4"
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd
coverage: pcov coverage: none
- uses: actions/cache@v4
with:
path: ~/.composer/cache/files
key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }}
restore-keys: composer-${{ runner.os }}-
- run: composer install --no-interaction --prefer-dist - run: composer install --no-interaction --prefer-dist
- run: npm ci - run: npm ci
- run: npm run build - run: npm run build
- run: composer test:unit - run: composer test:unit
- run: php artisan migrate --force
# Domain/Application coverage gate (SPEC.md L2351, 80% minimum). Scoped
# via phpunit.coverage.xml rather than editing the project-wide
# phpunit.xml <source> block, which stays covering all of app/ for
# every other test/coverage invocation. app/Domain currently holds only
# the DomainModule placeholder (zero executable lines), so in practice
# this gates app/Application until Domain gains real logic.
- run: composer test:coverage
feature: feature:
name: feature name: feature
@@ -108,14 +84,15 @@ jobs:
POSTGRES_DB: amare_test POSTGRES_DB: amare_test
POSTGRES_USER: amare POSTGRES_USER: amare
POSTGRES_PASSWORD: secret POSTGRES_PASSWORD: secret
ports:
- 5432:5432
options: >- options: >-
--health-cmd "pg_isready -U amare -d amare_test" --health-cmd "pg_isready -U amare -d amare_test"
--health-interval 5s --health-interval 5s
--health-timeout 5s --health-timeout 5s
--health-retries 10 --health-retries 10
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v4
- run: cp .env.example .env
- uses: shivammathur/setup-php@v2 - uses: shivammathur/setup-php@v2
with: with:
@@ -123,6 +100,18 @@ jobs:
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd
coverage: none coverage: none
- uses: actions/cache@v4
with:
path: ~/.composer/cache/files
key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }}
restore-keys: composer-${{ runner.os }}-
- uses: actions/cache@v4
with:
path: ~/.npm
key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
restore-keys: npm-${{ runner.os }}-
- run: composer install --no-interaction --prefer-dist - run: composer install --no-interaction --prefer-dist
- run: npm ci - run: npm ci
- run: npm run build - run: npm run build
@@ -139,14 +128,15 @@ jobs:
POSTGRES_DB: amare_test POSTGRES_DB: amare_test
POSTGRES_USER: amare POSTGRES_USER: amare
POSTGRES_PASSWORD: secret POSTGRES_PASSWORD: secret
ports:
- 5432:5432
options: >- options: >-
--health-cmd "pg_isready -U amare -d amare_test" --health-cmd "pg_isready -U amare -d amare_test"
--health-interval 5s --health-interval 5s
--health-timeout 5s --health-timeout 5s
--health-retries 10 --health-retries 10
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v4
- run: cp .env.example .env
- uses: shivammathur/setup-php@v2 - uses: shivammathur/setup-php@v2
with: with:
@@ -154,40 +144,43 @@ jobs:
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd
coverage: none coverage: none
- uses: actions/cache@v4
with:
path: ~/.composer/cache/files
key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }}
restore-keys: composer-${{ runner.os }}-
- uses: actions/cache@v4
with:
path: ~/.npm
key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
restore-keys: npm-${{ runner.os }}-
- run: composer install --no-interaction --prefer-dist - run: composer install --no-interaction --prefer-dist
- run: npm ci - run: npm ci
- run: npm run build - run: npm run build
- run: npx playwright install chromium --with-deps - run: npx playwright install chromium --with-deps
- run: php artisan migrate --force - run: php artisan migrate --force
- run: php artisan db:seed --class=VisualContentSeeder --force
- run: php artisan storage:link - run: php artisan storage:link
- name: Build application image - name: Build application image
run: docker build -t amare-app:ci . run: docker build -t amare-app:ci .
- name: Run browser tests against FrankenPHP container - name: Run browser tests against FrankenPHP container
env:
APP_FROZEN_NOW: "2026-03-15T12:00:00-03:00"
run: | run: |
# Join the per-job network (act_runner creates one when docker run -d --name amare-web \
# container.network is empty). No host -p: parallel jobs would
# collide on :8000/:5432; DNS names work on the job network.
# Container --name is global on the shared docker.sock host —
# include run id or leftovers from cancelled jobs Conflict.
JOB_CID="$(hostname)"
JOB_NET="$(docker inspect -f '{{range $k, $_ := .NetworkSettings.Networks}}{{println $k}}{{end}}' "$JOB_CID" | head -n1)"
test -n "$JOB_NET"
WEB_NAME="amare-web-${GITHUB_RUN_ID:-$$}"
docker rm -f "$WEB_NAME" 2>/dev/null || true
docker run -d --name "$WEB_NAME" \
--network "$JOB_NET" \
--network-alias amare-web \
-e APP_ENV=testing \ -e APP_ENV=testing \
-e APP_KEY="${APP_KEY}" \ -e APP_KEY="${APP_KEY}" \
-e APP_URL=http://amare-web:8000 \ -e APP_URL=http://127.0.0.1:8000 \
-e APP_LOCALE=pt_BR \ -e APP_LOCALE=pt_BR \
-e APP_FALLBACK_LOCALE=pt_BR \ -e APP_FALLBACK_LOCALE=pt_BR \
-e APP_TIMEZONE=America/Sao_Paulo \ -e APP_TIMEZONE=America/Fortaleza \
-e APP_FROZEN_NOW="${APP_FROZEN_NOW}" \
-e DB_CONNECTION=pgsql \ -e DB_CONNECTION=pgsql \
-e DB_HOST=postgres \ -e DB_HOST=host.docker.internal \
-e DB_PORT=5432 \ -e DB_PORT=5432 \
-e DB_DATABASE=amare_test \ -e DB_DATABASE=amare_test \
-e DB_USERNAME=amare \ -e DB_USERNAME=amare \
@@ -195,29 +188,29 @@ jobs:
-e SESSION_DRIVER=database \ -e SESSION_DRIVER=database \
-e CACHE_STORE=database \ -e CACHE_STORE=database \
-e QUEUE_CONNECTION=database \ -e QUEUE_CONNECTION=database \
--add-host=host.docker.internal:host-gateway \
-v "${GITHUB_WORKSPACE}/storage/app/public:/app/storage/app/public" \ -v "${GITHUB_WORKSPACE}/storage/app/public:/app/storage/app/public" \
-p 8000:8000 \
amare-app:ci amare-app:ci
cleanup() { docker rm -f "$WEB_NAME" >/dev/null 2>&1 || true; }
trap cleanup EXIT
for i in $(seq 1 30); do for i in $(seq 1 30); do
if curl -fsS http://amare-web:8000/up; then if curl -fsS http://127.0.0.1:8000/up; then
break break
fi fi
sleep 2 sleep 2
done done
curl -fsS http://amare-web:8000/up curl -fsS http://127.0.0.1:8000/up
APP_URL=http://amare-web:8000 ./vendor/bin/pest --testsuite=Browser ./vendor/bin/pest --testsuite=Browser
- name: Collect failure diagnostics - name: Collect failure diagnostics
if: failure() if: failure()
run: | run: |
mkdir -p artifacts/browser mkdir -p artifacts/browser
WEB_NAME="amare-web-${GITHUB_RUN_ID:-$$}" docker logs amare-web > artifacts/browser/container.log 2>&1 || true
docker logs "$WEB_NAME" > artifacts/browser/container.log 2>&1 || true
cp -R storage/logs artifacts/browser/app-logs 2>/dev/null || true cp -R storage/logs artifacts/browser/app-logs 2>/dev/null || true
cp -R tests/Browser/Screenshots artifacts/browser/screenshots 2>/dev/null || true
cp -R tests/.pest artifacts/browser/pest 2>/dev/null || true
- name: Upload browser failure artifacts - name: Upload browser failure artifacts
if: failure() if: failure()
@@ -231,28 +224,17 @@ jobs:
name: container name: container
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v4
- name: Build production image - name: Build production image
run: docker build -t amare-app:ci . run: docker build -t amare-app:ci .
- name: Verify container healthcheck and storage link - name: Verify container healthcheck and storage link
run: | run: |
# Same per-job network as the step container — no host :8000 docker run -d --name amare-health \
# publish (collides when capacity > 1). Unique --name: docker.sock
# is shared across jobs; leftovers from cancelled runs Conflict.
JOB_CID="$(hostname)"
JOB_NET="$(docker inspect -f '{{range $k, $_ := .NetworkSettings.Networks}}{{println $k}}{{end}}' "$JOB_CID" | head -n1)"
test -n "$JOB_NET"
HEALTH_NAME="amare-health-${GITHUB_RUN_ID:-$$}"
docker rm -f "$HEALTH_NAME" 2>/dev/null || true
docker run -d --name "$HEALTH_NAME" \
--network "$JOB_NET" \
--network-alias amare-health \
-e APP_ENV=production \ -e APP_ENV=production \
-e APP_KEY="${{ env.APP_KEY }}" \ -e APP_KEY="${{ env.APP_KEY }}" \
-e APP_URL=http://amare-health:8000 \ -e APP_URL=http://127.0.0.1:8000 \
-e APP_DEBUG=false \ -e APP_DEBUG=false \
-e DB_CONNECTION=pgsql \ -e DB_CONNECTION=pgsql \
-e DB_HOST=127.0.0.1 \ -e DB_HOST=127.0.0.1 \
@@ -260,18 +242,16 @@ jobs:
-e DB_DATABASE=amare \ -e DB_DATABASE=amare \
-e DB_USERNAME=amare \ -e DB_USERNAME=amare \
-e DB_PASSWORD=secret \ -e DB_PASSWORD=secret \
-p 8000:8000 \
amare-app:ci amare-app:ci
cleanup() { docker rm -f "$HEALTH_NAME" >/dev/null 2>&1 || true; }
trap cleanup EXIT
for i in $(seq 1 30); do for i in $(seq 1 30); do
if curl -fsS http://amare-health:8000/up; then if curl -fsS http://127.0.0.1:8000/up; then
docker exec "$HEALTH_NAME" test -L /app/public/storage docker exec amare-health test -L /app/public/storage
exit 0 exit 0
fi fi
sleep 2 sleep 2
done done
docker logs "$HEALTH_NAME" docker logs amare-health
exit 1 exit 1

View File

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

View File

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

3
.gitignore vendored
View File

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

View File

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

View File

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

View File

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

View File

@@ -7,76 +7,26 @@ This is a Laravel 13 application for an event-planning consultancy. Application
## Build, Test, and Development Commands ## Build, Test, and Development Commands
- `composer setup` installs PHP and npm dependencies, creates `.env`, migrates, and builds assets. - `composer setup` installs PHP and npm dependencies, creates `.env`, migrates, and builds assets.
- `docker compose up -d postgres` starts the local PostgreSQL service. `docker compose up -d` (no service name) also builds and starts the `app` service — a local FrankenPHP container for parity with staging/production, see README.md. - `docker compose up -d` starts the local PostgreSQL service.
- `composer dev` runs Laravel, the queue listener, logs, and Vite together. - `composer dev` runs Laravel, the queue listener, logs, and Vite together.
- `npm run build` creates the production frontend bundle. - `npm run build` creates the production frontend bundle.
- `composer quality` runs formatting checks, PHPStan level 5, PHP + npm dependency audits, and every test suite. - `composer quality` runs formatting checks, PHPStan level 5, dependency audit, and every test suite.
- `composer test:unit`, `composer test:feature`, or `composer test:browser` run focused suites. - `composer test:unit`, `composer test:feature`, or `composer test:browser` run focused suites.
- `composer test:coverage` runs the Domain/Application coverage gate (80% minimum, scoped via `phpunit.coverage.xml`) used by CI's `unit` job. Requires a coverage driver (`pcov` or `xdebug`); fails with "No code coverage driver available" without one — that's an environment gap, not a broken repo.
Feature and browser tests require the `amare_test` PostgreSQL database configured in `phpunit.xml`. Feature and browser tests require the `amare_test` PostgreSQL database configured in `phpunit.xml`.
## Worktrees
Always work in a git worktree created from the `main` ref — never modify `main` directly and never commit from the primary working tree. Create a dedicated worktree per feature/branch with `git worktree add -b <branch> <path> main`. Include the Linear identifier in the branch name (e.g. `docs/man-132-…`). When the SDLC gate below is green, commit and open the PR without asking. Watch CI until green, then merge. Clean up the worktree with `git worktree remove` after merge.
## Agent SDLC (do not ask)
Never ask whether to commit or open a PR. After each complete slice of work, ship it:
1. **Work is done** in a Linear-linked worktree.
2. **Tests are written or updated** for the changed layer. Skip new tests only when the change has no runtime impact (docs, templates, static config).
3. **Pre-commit passes** (`composer pint:check` and `composer phpstan`). Do not `--no-verify`. If the hook fails, fix and rerun.
4. **Commit** with Conventional Commits. Cite the Linear identifier.
5. **Push and open the Gitea PR** (`tea pulls create`) using `.gitea/PULL_REQUEST_TEMPLATE.md` (WHAT / WHY / HOW / Linear Issue / Comments). Attach the PR URL on the Linear issue via `save_issue` `links`. GitHub is the legacy mirror only.
Do not wait for "pode commitar?" or "abre o PR?".
## Git Hooks (husky)
Hooks live in `.husky/` and auto-install on any plain `npm install` via the `prepare` script. Note `composer setup` runs `npm install --ignore-scripts`, which skips hook installation — after setup, run `npm install` once (or `npx husky`) to activate hooks.
- `pre-commit`: runs `composer pint:check` and `composer phpstan`.
- `pre-push`: gates on the `amare_test` database (settings parsed from `phpunit.xml`), blocks the push with a `docker compose up -d postgres` hint when Postgres is unreachable, then runs `composer test:unit` and `composer test:feature`. Browser tests are CI-only (FrankenPHP container).
## Coding Style & Naming Conventions ## Coding Style & Naming Conventions
Follow PSR-4 and Laravel conventions: PascalCase classes, camelCase methods, and snake_case database columns. Use four spaces (two in YAML, except four in Compose files), LF endings, and UTF-8 as defined by `.editorconfig`. Every project-owned PHP file must place `declare(strict_types=1);` immediately after `<?php`. Keep domain code independent of Filament and Livewire. Run `composer pint` to format and `composer phpstan` before review. Follow PSR-4 and Laravel conventions: PascalCase classes, camelCase methods, and snake_case database columns. Use four spaces (two in YAML, except four in Compose files), LF endings, and UTF-8 as defined by `.editorconfig`. Every project-owned PHP file must place `declare(strict_types=1);` immediately after `<?php`. Keep domain code independent of Filament and Livewire. Run `composer pint` to format and `composer phpstan` before review.
## Design Principles: DRY & YAGNI
Write for the problem at hand, not an imagined future. **DRY**: extract and reuse a piece of logic as soon as it is genuinely duplicated in more than one place — but not before. **YAGNI**: do not add abstraction, configurability, or layers speculatively; add them only when a concrete requirement demands it. Prefer the simplest thing that solves the current requirement. Duplication that appears once is not yet a reason to abstract — wait for a second real occurrence before generalizing. This repo already encodes YAGNI in `openspec/config.yaml` (no generic repositories / `BaseService`); keep that spirit in new code. Avoid over-engineering and avoid premature extraction.
## Testing Guidelines ## Testing Guidelines
Tests use Pest 4; browser coverage uses Pest Browser/Playwright. Tests are verification, not a design driver — write them to cover behavior you've already implemented, matching the layer you changed. Name files by behavior, ending in `Test.php`, and add tests in the suite matching the changed layer. Feature tests use `RefreshDatabase`. Add architecture coverage for dependency-boundary changes. No numeric coverage threshold is enforced; add regression tests where a bug was fixed or behavior is non-obvious, without making tests a front-loaded design ceremony. Tests use Pest 4; browser coverage uses Pest Browser/Playwright. Name files by behavior, ending in `Test.php`, and add tests in the suite matching the changed layer. Feature tests use `RefreshDatabase`. Add architecture coverage for dependency-boundary changes. No numeric coverage threshold is enforced, but changed behavior must have regression coverage.
## Commit & Pull Request Guidelines ## Commit & Pull Request Guidelines
History follows Conventional Commit-style subjects, for example `feat: Fase 0 — Fundação`. Use `<type>: <imperative summary>` (`feat`, `fix`, `docs`, `test`, `chore`) and keep commits focused. 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 ## Security & Configuration
Copy `.env.example`; never commit secrets or production credentials. Development seed credentials are local-only. Validate uploads and authorization through Laravel policies, and run `composer security-audit` after dependency changes. Copy `.env.example`; never commit secrets or production credentials. Development seed credentials are local-only. Validate uploads and authorization through Laravel policies, and run `composer security-audit` after dependency changes.
## Design Context
Amare: refined, humane, precise — Heritage Editorial. Trust-first, both private + corporate audiences. Never generic wedding decor (hearts/gold/script) or AI-slop. Real proof only.
The design system lives in `DESIGN.md` (palette, typography, layout, do's and don'ts) and positioning in `PRODUCT.md`; tokens are implemented in `resources/css/tokens.css` and asserted by `tests/Feature/PublicSite/HeritageEditorialTokensTest.php`. Per-surface briefs live in `.impeccable/surfaces/`. The Impeccable skill itself is vendored at `.github/skills/impeccable/SKILL.md` — its setup step reads `PRODUCT.md`, `DESIGN.md`, and the matching surface brief.
## Agent skills
### Issue tracker
Issues live in Linear, driven through the Linear MCP tools. See `docs/agents/issue-tracker.md` for workspace, team, and tool conventions. The repo ships no `.mcp.json`, so the Linear MCP has to be enabled for the session before those tools exist — if it isn't, report that instead of silently falling back to another tracker.
**Every piece of work and every PR must be tied to a Linear issue.** Do not create a branch, worktree, or PR until an issue exists (identifier like `MAN-132`). If the user did not give one, search Linear first; if none fits, create it with `save_issue` on team `Maneco-workspace` before starting. Put the identifier in the branch name. Cite identifier + URL in the PR body. After opening the PR, attach the PR URL on the issue via `save_issue` `links`. Do not start untracked work.
### Triage labels
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` — 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

@@ -1,77 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
@AGENTS.md
If the import above did not load, read `AGENTS.md` at the repo root now — it is the primary contract.
`AGENTS.md` (imported above) is the primary contract: structure, commands, style, testing, husky hooks, commit/PR rules. This file records the cross-file architecture and environment facts that are not obvious from any single file.
## Non-negotiables (repeated from AGENTS.md because breaking them is expensive)
- Never modify or commit from the primary working tree on `main`. Create a worktree per branch: `git worktree add -b <branch> <path> main`.
- Every project-owned PHP file starts with `declare(strict_types=1);` immediately after `<?php`.
- Browser tests are CI-only (they run against a FrankenPHP container built by `docker build`, not `artisan serve`).
## Design principles: DRY & YAGNI (not TDD)
Write for the problem at hand, not an imagined future. **DRY**: extract and reuse only once logic is genuinely duplicated in more than one place. **YAGNI**: no speculative abstraction, configurability, or layers — add them only when a concrete requirement demands it. Prefer the simplest thing that solves the current requirement; avoid over-engineering and premature extraction. Tests are verification, not a design driver: write them to cover behavior already implemented, not as a front-loaded TDD ceremony.
## Environment note
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
`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!`.
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`.
## Request spine for the public site
Adding or changing a public page follows one path — controllers never query models directly:
```
routes/web.php
→ App\Http\Controllers\PublicSite\*Controller (thin; injects a query object)
→ App\Application\Queries\Marketing\* (invokable, final; owns all Eloquent access)
→ App\Application\Data\* (DTO: HomeContent, PageMeta)
→ resources/views/pages/*.blade.php
```
`HomeController` + `GetHomeContent` together show the shape. Page-level SEO is built with `PageMeta::forPage(canonical:, settings:, jsonLd:)`.
`AppServiceProvider::boot()` registers a **View composer on `layouts.public`** that auto-injects `siteSettings` and `pageMeta` when the view didn't supply them — new pages do not have to pass them manually.
Site-wide content is a singleton row reached via `SiteSetting::instance()`. Publication state comes from the `HasPublication` concern (`->published()` scope).
## Architecture boundary — what is actually enforced
`tests/Architecture/DomainBoundariesTest.php` enforces only:
- `App\Domain` uses strict types and never `dd`/`dump`/`die`.
- `App\Domain` never depends on `App\Filament` or `App\Livewire`.
`App\Application` is **not** covered by that rule. `app/Domain/` currently holds a single placeholder (`DomainModule.php`); business reads live in `app/Application/Queries`. Extend the arch test when you add a boundary.
## Deterministic test support
`APP_FROZEN_NOW` configures `CarbonImmutable::setTestNow()` through `AppServiceProvider::freezeClockWhenConfigured()` outside production. `VisualContentSeeder` provides deterministic image/content fixtures for tests that explicitly need them. Neither setting is a browser-CI global default.
## Other things that bite
- **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`, `/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
- `SPEC.md` is the product source of truth and is ~2600 lines. **Never read it whole**`grep -n '^## ' SPEC.md` and read the numbered section you need (e.g. 7 functional requirements, 8 domain model/DB, 9 technical architecture, 13 test strategy, 15 FrankenPHP deploy).
- `openspec/` is the channel for planned change: `openspec/specs/<capability>/spec.md` for current capabilities, `openspec/changes/<change>/{proposal,design,tasks}.md` for in-flight work. `openspec/config.yaml` holds the precedence rule (product owner > `SPEC.md` > ADRs > tests > conventions) and repo-wide constraints (YAGNI, money as BIGINT centavos, no generic repositories/BaseService).
- `PRODUCT.md` for positioning, `DESIGN.md` for the design system, `docs/conventions/php-strict-types.md`, `docs/deployment/dokploy.md` for the deploy runbook.

View File

@@ -73,7 +73,7 @@ Estrutura base usa grade central de 12 colunas no desktop e 4 no mobile, com lar
Composições editoriais alternam colunas assimétricas, imagens em diferentes proporções e blocos de leitura curtos. Formulários, listas e navegação mantêm alinhamento estrito. Ritmo estrutural usa múltiplos de `8px`; ajustes tipográficos usam `4px`. Composições editoriais alternam colunas assimétricas, imagens em diferentes proporções e blocos de leitura curtos. Formulários, listas e navegação mantêm alinhamento estrito. Ritmo estrutural usa múltiplos de `8px`; ajustes tipográficos usam `4px`.
Desktop aceita spreads, conteúdo deslocado e relações 5/7 ou 4/5. Nas aberturas narrativas com fotografia, o spread padrão reserva 5/12 para o texto e 7/12 para a mídia; a coluna de leitura não passa de 470px, e o título mantém quebra normal. Mobile lineariza a ordem para texto seguido da fotografia sem esconder conteúdo essencial e mantém CTA acessível. Fotografia pode ocupar viewport amplo, desde que texto e ação permaneçam legíveis. Desktop aceita spreads, conteúdo deslocado e relações 5/7 ou 4/5. Mobile lineariza ordem sem esconder conteúdo essencial, mantém CTA acessível e troca spreads por sequências verticais. Fotografia pode ocupar viewport amplo, desde que texto e ação permaneçam legíveis.
**The Editorial Rhythm Rule.** Seções alternam imagem, texto, densidade e silêncio; repetição de grades idênticas por toda página é proibida. **The Editorial Rhythm Rule.** Seções alternam imagem, texto, densidade e silêncio; repetição de grades idênticas por toda página é proibida.

View File

@@ -23,10 +23,6 @@ RUN npm ci
COPY vite.config.js ./ COPY vite.config.js ./
COPY resources ./resources COPY resources ./resources
COPY public ./public COPY public ./public
# resources/css/filament/admin/theme.css imports Filament's own uncompiled CSS,
# so the Vite build needs those files present. Only Filament's subtree is copied
# rather than all of vendor/, to keep this stage's context small.
COPY --from=composer /app/vendor/filament ./vendor/filament
RUN npm run build RUN npm run build
FROM dunglas/frankenphp:1-php${PHP_VERSION}-bookworm AS runtime FROM dunglas/frankenphp:1-php${PHP_VERSION}-bookworm AS runtime

View File

@@ -14,42 +14,36 @@ web
## Product Purpose ## Product Purpose
No recorte de lançamento, a Amare Assessoria usa o site institucional e seu CMS para apresentar a marca, publicar conteúdo e converter visitantes em pedidos de proposta por e-mail. O produto futuro continua planejado como uma única plataforma para conduzir leads e a operação dos eventos, mas essa evolução não faz parte do lançamento de 31/08/2026. A Amare Assessoria usa uma única plataforma para apresentar sua marca, converter visitantes em oportunidades e conduzir a operação dos eventos. O produto deve reduzir planilhas e informações dispersas, preservar o fluxo de trabalho da equipe e deixar claro o que exige atenção.
Sucesso no lançamento significa: Sucesso significa:
- visitantes qualificados concluírem o briefing; - visitantes qualificados concluírem o briefing;
- a assessora atualizar os conteúdos essenciais sem depender de desenvolvimento;
- o site equilibrar eventos sociais e corporativos com conteúdo real e autorizado;
- acessibilidade, desempenho, jornadas críticas e deploy serem verificados antes da publicação.
Nas fases futuras, sucesso também significará:
- leads avançarem pelo pipeline sem depender de planilhas externas; - leads avançarem pelo pipeline sem depender de planilhas externas;
- leads conquistados virarem eventos sem redigitação; - leads conquistados virarem eventos sem redigitação;
- pendências reais do dia aparecerem com clareza; - pendências reais do dia aparecerem com clareza;
- tarefas, fornecedores, orçamento, pagamentos manuais e documentos permanecerem organizados por evento. - tarefas, fornecedores, orçamento, pagamentos manuais e documentos permanecerem organizados por evento;
- jornadas críticas, acessibilidade e qualidade visual serem verificadas antes de cada deploy.
## Positioning ## Positioning
No lançamento, o site público premium e o CMS interno formam a superfície entregue: a mesma aplicação transmite profissionalismo, capta a demanda por e-mail e permite manter o conteúdo essencial. A operação interna completa permanece como direção futura para transformar leads em eventos e orientar a equipe pelas pendências e exceções que precisam de ação, sem exigir troca radical de processo. Site público premium e operação interna formam um fluxo único. A mesma plataforma que transmite profissionalismo e capta a demanda transforma o lead em evento e orienta a equipe pelas pendências e exceções que precisam de ação, sem exigir troca radical de processo.
## Operating Context ## Operating Context
- Atuação em São Paulo, capital. - Atuação em São Paulo, capital.
- Serviços: casamentos, eventos corporativos e celebrações. - Serviços: casamentos, eventos corporativos e celebrações.
- Operação realizada por assessora principal e equipe pequena. - Operação realizada por assessora principal e equipe pequena.
- Jornada de lançamento: descoberta no site, briefing por e-mail e contato comercial fora da plataforma. - Jornada prevista: descoberta no site, briefing, qualificação do lead, pipeline comercial, conversão em evento, checklist, fornecedores, orçamento, pagamentos manuais e documentos.
- Jornada futura prevista: qualificação do lead, pipeline comercial, conversão em evento, checklist, fornecedores, orçamento, pagamentos manuais e documentos.
- Interface pública em português do Brasil; dados financeiros em reais. - Interface pública em português do Brasil; dados financeiros em reais.
- Área interna acessível por administradora e assistentes com papéis simples. - Área interna acessível por administradora e assistentes com papéis simples.
## Capabilities and Constraints ## Capabilities and Constraints
- Produto single-tenant para a operação da Amare; não é SaaS para várias assessorias. - Produto single-tenant para a operação da Amare; não é SaaS para várias assessorias.
- O lançamento inclui site público responsivo, CMS em Filament, briefing enviado por e-mail sem persistência de Lead, usuários internos simples, SEO básico, acessibilidade automatizada e deploy em contêiner. - Duas superfícies integradas: site público responsivo com CMS e área interna operacional em Filament.
- CRM de leads, conversão em evento, tarefas, fornecedores, orçamento, pagamentos manuais, documentos, dashboard orientado a exceções, notificações e auditoria permanecem especificados para as Fases 2 a 5, adiadas sem data pela ADR-016. - MVP inclui briefing, CRM de leads, conversão em evento, tarefas, fornecedores, orçamento, pagamentos manuais, documentos, dashboard orientado a exceções, notificações, auditoria, SEO básico e deploy automatizado.
- O produto futuro mantém duas superfícies integradas: site público responsivo com CMS e área interna operacional em Filament; no lançamento, a área interna cobre apenas CMS, configurações e usuários. - Implementação atual cobre fundação, site público e CMS; módulos operacionais posteriores ainda fazem parte do escopo planejado.
- Não inclui portal ou login para cliente final, aplicativo nativo, pagamentos online, RSVP, gestão de convidados, assinatura eletrônica, integração oficial com WhatsApp, multi-tenancy ou PWA offline avançada. - Não inclui portal ou login para cliente final, aplicativo nativo, pagamentos online, RSVP, gestão de convidados, assinatura eletrônica, integração oficial com WhatsApp, multi-tenancy ou PWA offline avançada.
- Contas internas usam os papéis `admin` e `assistant`; permissões granulares ficam fora do MVP. - Contas internas usam os papéis `admin` e `assistant`; permissões granulares ficam fora do MVP.
- Fatos ainda em aberto: processo real de qualificação comercial, política de retenção LGPD para leads perdidos, canais oficiais, domínio e infraestrutura de produção. - Fatos ainda em aberto: processo real de qualificação comercial, política de retenção LGPD para leads perdidos, canais oficiais, domínio e infraestrutura de produção.
@@ -64,7 +58,7 @@ No lançamento, o site público premium e o CMS interno formam a superfície ent
## Evidence on Hand ## Evidence on Hand
- `SPEC.md` define escopo, personas, jornadas, métricas, requisitos e decisões do MVP. - `SPEC.md` define escopo, personas, jornadas, métricas, requisitos e decisões do MVP.
- Código atual contém site público, CMS, estrutura de acessibilidade, testes browser e conteúdo demonstrativo. - Código atual contém site público, CMS, estrutura de acessibilidade, testes visuais e conteúdo demonstrativo.
- Textos, fotografias, portfólio, depoimentos, contatos e perfis sociais presentes nos seeders são dados fictícios de desenvolvimento e não constituem prova comercial. - Textos, fotografias, portfólio, depoimentos, contatos e perfis sociais presentes nos seeders são dados fictícios de desenvolvimento e não constituem prova comercial.
- Ainda não há logo, fotografia proprietária, depoimentos autorizados, cases reais, imprensa, credenciais, CNPJ ou texto jurídico definitivo confirmados no repositório. Trabalho futuro não deve inventá-los. - Ainda não há logo, fotografia proprietária, depoimentos autorizados, cases reais, imprensa, credenciais, CNPJ ou texto jurídico definitivo confirmados no repositório. Trabalho futuro não deve inventá-los.
@@ -74,7 +68,7 @@ No lançamento, o site público premium e o CMS interno formam a superfície ent
2. **Uma informação, um fluxo:** conduzir o dado do briefing ao evento sem redigitação ou planilhas paralelas. 2. **Uma informação, um fluxo:** conduzir o dado do briefing ao evento sem redigitação ou planilhas paralelas.
3. **Adoção sem ruptura:** organizar o trabalho existente da equipe sem impor processo desnecessariamente complexo. 3. **Adoção sem ruptura:** organizar o trabalho existente da equipe sem impor processo desnecessariamente complexo.
4. **Confiança sustentada por fatos:** usar somente conteúdo, resultados e provas reais e autorizados. 4. **Confiança sustentada por fatos:** usar somente conteúdo, resultados e provas reais e autorizados.
5. **Qualidade verificável:** tratar acessibilidade, desempenho, motion e jornadas críticas como critérios de entrega. 5. **Qualidade verificável:** tratar acessibilidade, desempenho, testes visuais e jornadas críticas como critérios de entrega.
## Accessibility & Inclusion ## Accessibility & Inclusion

View File

@@ -4,7 +4,7 @@ Aplicação Laravel 13 para assessoria de eventos (Fase 0 — Fundação).
## Requisitos ## Requisitos
- PHP 8.4 com extensões `pdo_pgsql`, `intl`, `mbstring`, `zip`, `sodium` (canônico do `Dockerfile` e do CI; `composer.json` aceita `^8.3`) - PHP 8.5+ com extensões `pdo_pgsql`, `intl`, `mbstring`, `zip`, `sodium`
- Composer 2.x - Composer 2.x
- Node.js 22+ e npm - Node.js 22+ e npm
- Docker e Docker Compose - Docker e Docker Compose
@@ -18,10 +18,10 @@ cp .env.example .env
php artisan key:generate php artisan key:generate
``` ```
2. Suba o PostgreSQL (requer o `.env` do passo 1 — o serviço `app` referencia esse arquivo via `env_file`, e o Compose valida todo o `docker-compose.yml` mesmo ao subir só o `postgres`): 2. Suba o PostgreSQL:
```bash ```bash
docker compose up -d postgres docker compose up -d
``` ```
3. Instale dependências e rode migrations: 3. Instale dependências e rode migrations:
@@ -49,24 +49,12 @@ Painel interno: `/admin`
Healthcheck: `GET /up` Healthcheck: `GET /up`
## Paridade local com produção (FrankenPHP via Docker Compose)
Além do fluxo host-side acima, `docker-compose.yml` tem um serviço `app` que builda a mesma imagem FrankenPHP usada em staging/produção (`Dockerfile`), útil para testar o comportamento real do container antes do deploy:
```bash
docker compose up -d # sobe postgres + app
php artisan migrate # rode migrations (o entrypoint do container não migra sozinho)
curl localhost:8000/up
```
O serviço `app` depende de `postgres` estar saudável (`depends_on: condition: service_healthy`) e sobrescreve `DB_HOST`/`DB_PORT` do `.env` para apontar para o serviço `postgres` pelo nome (o padrão `127.0.0.1` do `.env` só funciona para processos rodando no host). Não há job de CI dedicado a este smoke — o job `container` do CI já builda e healthcheca a mesma imagem.
## Variáveis principais ## Variáveis principais
| Variável | Valor local | | Variável | Valor local |
|---|---| |---|---|
| `APP_LOCALE` | `pt_BR` | | `APP_LOCALE` | `pt_BR` |
| `APP_TIMEZONE` | `America/Sao_Paulo` | | `APP_TIMEZONE` | `America/Fortaleza` |
| `DB_CONNECTION` | `pgsql` | | `DB_CONNECTION` | `pgsql` |
| `SESSION_DRIVER` | `database` | | `SESSION_DRIVER` | `database` |
| `CACHE_STORE` | `database` | | `CACHE_STORE` | `database` |
@@ -75,7 +63,7 @@ O serviço `app` depende de `postgres` estar saudável (`depends_on: condition:
## Comandos de qualidade ## Comandos de qualidade
```bash ```bash
composer quality # Pint + PHPStan + audit (composer + npm) + testes composer quality # Pint + PHPStan + audit + testes
composer test:unit # Unit + Architecture composer test:unit # Unit + Architecture
composer test:feature # Feature + Livewire + Filament composer test:feature # Feature + Livewire + Filament
composer test:browser # E2E browser composer test:browser # E2E browser
@@ -137,11 +125,10 @@ Após `php artisan db:seed`:
- [SPEC.md](SPEC.md) — especificação do produto - [SPEC.md](SPEC.md) — especificação do produto
- [docs/adr/](docs/adr/) — ADRs aceitas - [docs/adr/](docs/adr/) — ADRs aceitas
- [docs/conventions/php-strict-types.md](docs/conventions/php-strict-types.md) — convenção de strict types - [docs/conventions/php-strict-types.md](docs/conventions/php-strict-types.md) — convenção de strict types
- [docs/operations/atualizacao-de-conteudo.md](docs/operations/atualizacao-de-conteudo.md) — runbook de atualização de conteúdo do site pelo painel admin - [docs/deployment/dokploy.md](docs/deployment/dokploy.md) — deploy staging/produção no Dokploy + GHCR
- [docs/deployment/dokploy.md](docs/deployment/dokploy.md) — deploy staging/produção no Dokploy + registry Gitea
## Deploy (Dokploy) ## Deploy (Dokploy)
Staging publica automaticamente após CI verde em `main` (imagem 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). Ver runbook completo: [docs/deployment/dokploy.md](docs/deployment/dokploy.md).

259
SPEC.md
View File

@@ -14,16 +14,15 @@
| Estágio | MVP | | Estágio | MVP |
| Status da especificação | Aprovada para implementação | | Status da especificação | Aprovada para implementação |
| Idioma da interface | Português do Brasil (`pt-BR`) | | Idioma da interface | Português do Brasil (`pt-BR`) |
| Timezone padrão | `America/Sao_Paulo` | | Timezone padrão | `America/Fortaleza` |
| Cidade de atuação | São Paulo (capital) |
| Moeda | BRL, sem conversão entre moedas | | Moeda | BRL, sem conversão entre moedas |
| Princípio principal | YAGNI — implementar somente o necessário para validar o produto | | Princípio principal | YAGNI — implementar somente o necessário para validar o produto |
| Arquitetura | Monólito modular Laravel | | Arquitetura | Monólito modular Laravel |
| Área interna | Filament | | Área interna | Filament |
| Área pública | Blade + JS vanilla progressivo (Livewire é dependência do Filament, não usada no site público — ver ADR-015) | | Área pública | Livewire + Blade |
| Servidor de aplicação | FrankenPHP em modo regular | | Servidor de aplicação | FrankenPHP em modo regular |
| Banco de dados | PostgreSQL | | Banco de dados | PostgreSQL |
| Testes | Pest e Pest Browser/Playwright | | Testes | Pest, Pest Browser/Playwright e testes visuais |
### 0.1 Vocabulário normativo ### 0.1 Vocabulário normativo
@@ -45,8 +44,6 @@ Em caso de conflito, seguir esta ordem:
O agente **NÃO DEVE** alterar silenciosamente uma decisão deste documento. Uma alteração de escopo ou arquitetura deve atualizar esta especificação ou criar uma ADR. O agente **NÃO DEVE** alterar silenciosamente uma decisão deste documento. Uma alteração de escopo ou arquitetura deve atualizar esta especificação ou criar uma ADR.
Mudanças incrementais são planejadas em `openspec/changes/` e, após arquivadas, este documento DEVE ser revalidado para incorporar decisões ratificadas (back-sync). Este arquivo permanece a fonte de verdade do produto.
--- ---
## 1. Contrato de operação para agentes ## 1. Contrato de operação para agentes
@@ -80,6 +77,7 @@ O agente:
- NÃO DEVE colocar regras financeiras diretamente em views, Resources, Models observers ou callbacks de formulário. - NÃO DEVE colocar regras financeiras diretamente em views, Resources, Models observers ou callbacks de formulário.
- NÃO DEVE usar `float` para dinheiro. - NÃO DEVE usar `float` para dinheiro.
- NÃO DEVE persistir status derivados que possam ser calculados corretamente a partir dos dados fonte. - NÃO DEVE persistir status derivados que possam ser calculados corretamente a partir dos dados fonte.
- NÃO DEVE atualizar snapshots visuais apenas para fazer o CI passar sem revisar o diff.
### 1.2 Entrega incremental ### 1.2 Entrega incremental
@@ -134,6 +132,7 @@ Uma assessora adotará o sistema quando ele:
| Operação | Tarefas vencidas sem responsável | `0` | | Operação | Tarefas vencidas sem responsável | `0` |
| Financeiro | Itens de orçamento sem valor ou status | Menos de 5% por evento ativo | | Financeiro | Itens de orçamento sem valor ou status | Menos de 5% por evento ativo |
| Qualidade | Jornadas E2E críticas passando | 100% antes de deploy | | Qualidade | Jornadas E2E críticas passando | 100% antes de deploy |
| Visual | Snapshots aprovados | 100% |
| Confiabilidade | Erros não tratados | Alerta imediato e tendência decrescente | | Confiabilidade | Erros não tratados | Alerta imediato e tendência decrescente |
--- ---
@@ -192,24 +191,11 @@ Não instalar sistema de permissões granular no MVP.
## 4. Escopo ## 4. Escopo
> **Recorte vigente do lançamento (ADR-016).** O escopo aprovado para o lançamento de 31/08/2026 é o **site institucional**: Fases 0 e 1. As Fases 2 a 5 — CRM de leads, conversão de lead em evento, eventos, tarefas, fornecedores, orçamento, pagamentos manuais, documentos, dashboard orientado a exceções e auditoria — permanecem especificadas neste documento mas ficam **adiadas**, sem data.
>
> A §4.1 abaixo descreve o produto completo, não o recorte do lançamento. Os itens marcados como adiados estão fora do que se constrói agora. Ver §18 para a divisão por fase e §23 para as condições de conclusão de cada recorte.
### 4.1 Incluído no MVP ### 4.1 Incluído no MVP
No recorte do lançamento (Fases 01):
- site público; - site público;
- CMS interno do site; - CMS interno do site;
- formulário de briefing; - formulário de briefing;
- usuários internos e papéis simples;
- SEO básico;
- acessibilidade automatizada;
- CI/CD e deploy em contêiner com FrankenPHP.
Adiados para depois do lançamento (Fases 25, ver ADR-016):
- CRM de leads; - CRM de leads;
- conversão de lead em evento; - conversão de lead em evento;
- cadastro e visão consolidada de eventos; - cadastro e visão consolidada de eventos;
@@ -219,10 +205,12 @@ Adiados para depois do lançamento (Fases 25, ver ADR-016):
- pagamentos inseridos manualmente; - pagamentos inseridos manualmente;
- documentos vinculados a leads e eventos; - documentos vinculados a leads e eventos;
- dashboard orientado a exceções; - dashboard orientado a exceções;
- usuários internos e papéis simples;
- notificações internas e por e-mail para novos leads; - notificações internas e por e-mail para novos leads;
- auditoria de ações críticas. - auditoria de ações críticas;
- SEO básico;
Adiado **não** é o mesmo que fora do MVP: os itens acima seguem especificados neste documento e continuam sendo o produto pretendido. A §4.2 lista o que **NÃO DEVE** ser implementado em nenhum momento. - acessibilidade e testes visuais;
- CI/CD e deploy em contêiner com FrankenPHP.
### 4.2 Fora do MVP ### 4.2 Fora do MVP
@@ -324,8 +312,8 @@ Administração
- Dashboard: `Filament Page` customizada com widgets orientados a exceção. - Dashboard: `Filament Page` customizada com widgets orientados a exceção.
- Detalhe do evento: página customizada do Resource com resumo operacional. - Detalhe do evento: página customizada do Resource com resumo operacional.
- Briefing público: Blade + Controller (`POST /contato`), ver §11.2. - Briefing público: componente Livewire próprio.
- Home: Blade com componentes de design reutilizáveis. - Home: Blade/Livewire com componentes de design reutilizáveis.
### 5.4 Decisões de UX YAGNI ### 5.4 Decisões de UX YAGNI
@@ -361,14 +349,13 @@ A home DEVE conter, nesta ordem aproximada:
1. header e navegação; 1. header e navegação;
2. hero com proposta de valor e CTA; 2. hero com proposta de valor e CTA;
3. manifesto da marca; 3. prova visual por eventos em destaque;
4. resumo dos serviços em destaque; 4. resumo dos serviços;
5. casos selecionados do portfólio; 5. método de trabalho;
6. método de trabalho (4 passos); 6. casos selecionados;
7. depoimentos; 7. depoimentos;
8. posicionamento/perfil; 8. CTA final para briefing;
9. CTA final para briefing; 9. footer com contato, redes e links legais.
10. footer com contato, redes e links legais.
A ordem pode variar apenas se a revisão de UX justificar a mudança. A ordem pode variar apenas se a revisão de UX justificar a mudança.
@@ -387,15 +374,6 @@ Centralizar tokens de:
Não espalhar valores visuais arbitrários por componentes. Não espalhar valores visuais arbitrários por componentes.
O MVP adota o sistema de design **Heritage Editorial** (ver DESIGN.md e ADR-013):
- tipografia serifada auto-hospedada (EB Garamond) com escala de display a label;
- paleta papel/oliva/sálvia/tinta com superfícies tonais; hierarquia sem sombras de card;
- raio de borda zero para superfícies interativas e de conteúdo;
- largura de container 1120px e ritmo de espaçamento de 8px;
- `prefers-reduced-motion` respeitado;
- contraste WCAG AA (tinta sobre papel e oliva sobre papel).
### 6.4 Requisitos de mídia ### 6.4 Requisitos de mídia
- Imagens públicas DEVERÃO possuir texto alternativo. - Imagens públicas DEVERÃO possuir texto alternativo.
@@ -405,7 +383,6 @@ O MVP adota o sistema de design **Heritage Editorial** (ver DESIGN.md e ADR-013)
- Dimensões DEVERÃO ser reservadas para evitar layout shift. - Dimensões DEVERÃO ser reservadas para evitar layout shift.
- Não armazenar blobs de imagem no PostgreSQL. - Não armazenar blobs de imagem no PostgreSQL.
- Não depender do disco efêmero do contêiner em produção. - Não depender do disco efêmero do contêiner em produção.
- O logotipo da marca DEVE possuir texto alternativo e variantes claro/escuro; `site_settings.logo_path` sobrescreve o asset padrão quando preenchido.
### 6.5 Acessibilidade ### 6.5 Acessibilidade
@@ -557,10 +534,12 @@ Campos públicos:
- validar no servidor; - validar no servidor;
- usar honeypot e rate limiting; - usar honeypot e rate limiting;
- impedir duplo envio acidental; - impedir duplo envio acidental;
- criar Lead com status `new`;
- registrar origem `website`;
- notificar administradores;
- exibir sucesso sem revelar dados internos;
- enviar e-mail de confirmação quando o serviço de e-mail estiver configurado; - enviar e-mail de confirmação quando o serviço de e-mail estiver configurado;
- exibir sucesso sem revelar dados internos. - falha no e-mail não pode apagar o lead já criado.
> **Estado atual (Fases 01):** o formulário usa Blade + Controller e envia apenas e-mails informativos (para a assessoria e confirmação ao visitante), sem criar Lead. A criação de Lead (status `new`, origem `website`), a notificação aos administradores e o registro de aceite de privacidade entram na **Fase 2**, quando o formulário passa a criar o Lead via `CaptureWebsiteLead`. A falha de e-mail não pode apagar dados já criados.
**Aceite:** **Aceite:**
@@ -1274,19 +1253,10 @@ Singleton:
- `id` bigint PK; - `id` bigint PK;
- `brand_name`; - `brand_name`;
- `logo_path` nullable;
- `logo_alt` nullable;
- `hero_eyebrow` nullable; - `hero_eyebrow` nullable;
- `hero_title`; - `hero_title`;
- `hero_subtitle`; - `hero_subtitle`;
- `hero_cta_label`; - `hero_cta_label`;
- `hero_cta_secondary_label` nullable;
- `hero_note` nullable;
- `manifesto_title`;
- `manifesto_lead`;
- `manifesto_body`;
- `method_steps` jsonb (4 passos tipados);
- `principles` jsonb (lista tipada);
- `about_summary` nullable; - `about_summary` nullable;
- `email`; - `email`;
- `phone`; - `phone`;
@@ -1295,8 +1265,7 @@ Singleton:
- `default_meta_title`; - `default_meta_title`;
- `default_meta_description`; - `default_meta_description`;
- `default_og_image_path` nullable; - `default_og_image_path` nullable;
- `default_og_image_alt` nullable; - analytics fields nullable;
- `analytics_enabled` boolean default false;
- timestamps. - timestamps.
#### `services` #### `services`
@@ -1562,12 +1531,12 @@ Constraints de banco devem proteger:
| Runtime | PHP com versão minor fixada no Docker | | Runtime | PHP com versão minor fixada no Docker |
| Framework | Laravel 13 | | Framework | Laravel 13 |
| Admin | Filament 5 | | Admin | Filament 5 |
| UI pública | Blade + Tailwind + JS vanilla progressivo (sem framework reativo; Livewire 4 confinado ao Filament — ver ADR-015 e §22) | | UI pública | Livewire 4 + Blade + Alpine + Tailwind |
| Banco | PostgreSQL | | Banco | PostgreSQL |
| Servidor | FrankenPHP + Caddy | | Servidor | FrankenPHP + Caddy |
| Assets | Vite | | Assets | Vite |
| Testes | Pest 4 + Pest Browser/Playwright | | Testes | Pest 4 + Pest Browser/Playwright |
| Arquivos | Laravel Filesystem + Cloudflare R2 (S3-compatible) em produção | | Arquivos | Laravel Filesystem + storage S3-compatible em produção |
| Fila | Database queue | | Fila | Database queue |
| Scheduler | Laravel Scheduler em processo separado | | Scheduler | Laravel Scheduler em processo separado |
@@ -1783,21 +1752,19 @@ Usar componentes/Widgets menores e testáveis.
--- ---
## 11. Site público e interatividade ## 11. Livewire e site público
### 11.1 Estado atual e componentes candidatos ### 11.1 Componentes sugeridos
O site público **não usa Livewire nem Alpine hoje**. As páginas são Blade renderizado no servidor mais JavaScript vanilla progressivo (`resources/js/app.js` e `resources/js/motion.js`). O `layouts.public` carrega apenas `@vite(['resources/css/app.css', 'resources/js/app.js'])` — nenhum `@livewireScripts`. O pacote `livewire/livewire` existe no projeto apenas como dependência transitiva de `filament/support` e opera somente dentro do painel `/admin`. - `ContactBriefingForm`;
- `FeaturedPortfolioCases` se houver necessidade de consulta dinâmica;
- `PublishedServices` se houver necessidade de consulta dinâmica.
Se surgir a necessidade de consulta dinâmica, os candidatos naturais a Livewire seriam `FeaturedPortfolioCases` e `PublishedServices`. Essa adoção está condicionada ao gatilho registrado em §22. Não transformar todas as seções estáticas em componentes Livewire. Usar Blade quando não houver estado ou interação.
Não transformar seções estáticas em componentes Livewire. Usar Blade quando não houver estado ou interação.
### 11.2 Formulário de briefing ### 11.2 Formulário de briefing
> **Estado atual:** o formulário é implementado em Blade + Controller (`POST /contato`, `ContactBriefingRequest`), conforme WEB-05, e essa é a abordagem aceita — não um estágio provisório. Os requisitos abaixo valem independentemente da tecnologia; a criação de Lead segue para a Fase 2. O componente deve:
O formulário deve:
- ter estado tipado ou Form Object quando útil; - ter estado tipado ou Form Object quando útil;
- validar no servidor; - validar no servidor;
@@ -1806,16 +1773,15 @@ O formulário deve:
- preservar acessibilidade; - preservar acessibilidade;
- limpar dados após sucesso; - limpar dados após sucesso;
- evitar exposição de exceção; - evitar exposição de exceção;
- suportar teste de submissão sem navegador (feature test); - suportar teste Livewire sem browser;
- suportar jornada E2E em navegador real. - suportar jornada E2E em navegador real.
### 11.3 JavaScript ### 11.3 JavaScript
- manter o site público em JS vanilla progressivo; - usar Alpine apenas para interações pequenas;
- não introduzir framework SPA; - não introduzir framework SPA;
- não usar dependência JS quando CSS e HTML resolverem; - não usar dependência JS quando CSS/HTML/Livewire resolverem;
- toda interação crítica deve funcionar sem estado global complexo; - toda interação crítica deve funcionar sem estado global complexo.
- Alpine só entra junto com Livewire, se o gatilho de §22 disparar.
--- ---
@@ -1899,7 +1865,7 @@ A pirâmide deve possuir:
1. muitos testes unitários rápidos; 1. muitos testes unitários rápidos;
2. testes feature/integration suficientes para Laravel, PostgreSQL, Livewire e Filament; 2. testes feature/integration suficientes para Laravel, PostgreSQL, Livewire e Filament;
3. poucos testes E2E cobrindo jornadas críticas; 3. poucos testes E2E cobrindo jornadas críticas;
4. testes E2E, de acessibilidade, motion e smoke nas jornadas públicas mais importantes. 4. testes visuais determinísticos nas telas mais importantes.
A distribuição é por intenção, não por percentual rígido. A distribuição é por intenção, não por percentual rígido.
@@ -1981,7 +1947,40 @@ Em falha, CI deve publicar:
- logs do browser; - logs do browser;
- HTML report quando disponível. - HTML report quando disponível.
### 13.5 Testes de arquitetura ### 13.5 Regressão visual
Usar `assertScreenshotMatches()` ou API equivalente do Pest Browser.
Snapshots obrigatórios:
| Tela | Desktop | Mobile |
|---|---:|---:|
| Home | 1440×1000 | 390×844 |
| Serviços | 1440×1000 | 390×844 |
| Portfólio | 1440×1000 | 390×844 |
| Detalhe do portfólio | 1440×1000 | 390×844 |
| Briefing vazio | 1280×900 | 390×844 |
| Briefing com erros | 1280×900 | 390×844 |
| Briefing sucesso | 1280×900 | 390×844 |
| Login | 1280×900 | Opcional |
| Dashboard | 1440×1000 | Não obrigatório |
| Detalhe do evento | 1440×1000 | Não obrigatório |
Determinismo obrigatório:
- Chromium e imagem Linux fixos;
- viewport fixo;
- timezone `America/Fortaleza`;
- locale `pt-BR`;
- fontes instaladas na imagem;
- relógio congelado;
- seed determinístico;
- animações e transições desabilitadas;
- dados dinâmicos mascarados quando necessário.
Atualização de baseline deve usar comando explícito e revisão humana do diff.
### 13.6 Testes de arquitetura
Criar regras Pest Architecture: Criar regras Pest Architecture:
@@ -1998,14 +1997,14 @@ arch()
Adicionar regras conforme a estrutura real, sem tornar a suíte excessivamente frágil. Adicionar regras conforme a estrutura real, sem tornar a suíte excessivamente frágil.
### 13.6 Cobertura ### 13.7 Cobertura
- meta de 80% para `Domain` e `Application`; - meta de 80% para `Domain` e `Application`;
- não medir views, migrations, código gerado e framework; - não medir views, migrations, código gerado e framework;
- cobertura não substitui critérios de aceite; - cobertura não substitui critérios de aceite;
- queda de cobertura em módulo crítico deve bloquear merge. - queda de cobertura em módulo crítico deve bloquear merge.
### 13.7 Acessibilidade automatizada ### 13.8 Acessibilidade automatizada
Smoke/browser tests devem verificar: Smoke/browser tests devem verificar:
@@ -2023,7 +2022,7 @@ Revisão manual mínima antes do lançamento:
- zoom a 200%; - zoom a 200%;
- leitor de tela nos fluxos de briefing e login. - leitor de tela nos fluxos de briefing e login.
### 13.8 Comandos padronizados ### 13.9 Comandos padronizados
O projeto deve expor scripts equivalentes: O projeto deve expor scripts equivalentes:
@@ -2033,6 +2032,7 @@ composer test:feature
composer test:browser composer test:browser
composer test composer test
composer quality composer quality
composer visual:update
``` ```
Composição esperada: Composição esperada:
@@ -2040,7 +2040,7 @@ Composição esperada:
```text ```text
test:unit → Unit + Architecture test:unit → Unit + Architecture
test:feature → Feature + Livewire + Filament test:feature → Feature + Livewire + Filament
test:browser → E2E + acessibilidade + motion + smoke test:browser → E2E + visual + acessibilidade + smoke
quality → Pint check + PHPStan/Larastan + audits + testes quality → Pint check + PHPStan/Larastan + audits + testes
``` ```
@@ -2052,10 +2052,10 @@ quality → Pint check + PHPStan/Larastan + audits + testes
| Job | Responsabilidade | Bloqueia merge | | Job | Responsabilidade | Bloqueia merge |
|---|---|---:| |---|---|---:|
| `static` | Pint, PHPStan/Larastan, Composer validate, Composer e npm audit | Sim | | `static` | Pint, PHPStan/Larastan, Composer validate e audits | Sim |
| `unit` | Unitários, arquitetura e cobertura | Sim | | `unit` | Unitários, arquitetura e cobertura | Sim |
| `feature` | PostgreSQL, migrations, Livewire, Filament e integração | Sim | | `feature` | PostgreSQL, migrations, Livewire, Filament e integração | Sim |
| `browser` | Vite, FrankenPHP, E2E, smoke, acessibilidade e motion | Sim | | `browser` | Vite, FrankenPHP, E2E, smoke, acessibilidade e visual | Sim |
| `container` | Build da imagem final e healthcheck | Sim | | `container` | Build da imagem final e healthcheck | Sim |
### 14.2 Regras do pipeline ### 14.2 Regras do pipeline
@@ -2073,11 +2073,11 @@ quality → Pint check + PHPStan/Larastan + audits + testes
### 14.3 Branches e ambientes ### 14.3 Branches e ambientes
- PR: testes e preview opcional; - PR: testes e preview opcional;
- `main`: build imutável por SHA publicado no registry Gitea (`git.hellomanoel.com`) e deploy automático em staging via Dokploy; - `main`: build imutável por SHA e deploy automático em staging;
- staging: Dokploy Compose executa migração, healthcheck `/up` e smoke pós-deploy (`/up`, `/`, `/admin/login`); - staging: migration, cache warmup e smoke pós-deploy;
- produção: promoção da mesma imagem aprovada, sem rebuild (retag do digest em `:production`); - produção: promoção da mesma imagem aprovada, sem rebuild;
- produção requer aprovação humana explícita no MVP (`workflow_dispatch` com confirmação); - produção requer aprovação humana explícita no MVP;
- rollback usa imagem anterior (SHA anterior, sem rebuild); - rollback usa imagem anterior;
- migrations devem ser backward-compatible quando possível. - migrations devem ser backward-compatible quando possível.
### 14.4 Definition of Done ### 14.4 Definition of Done
@@ -2089,6 +2089,7 @@ Uma história só está concluída quando:
- PHPStan/Larastan está verde; - PHPStan/Larastan está verde;
- unit e feature tests cobrem regras relevantes; - unit e feature tests cobrem regras relevantes;
- E2E foi criado ou atualizado para fluxo crítico; - E2E foi criado ou atualizado para fluxo crítico;
- snapshot foi criado ou revisado para UI coberta;
- acessibilidade automatizada não possui issues críticas/sérias; - acessibilidade automatizada não possui issues críticas/sérias;
- migration foi testada; - migration foi testada;
- autorização foi verificada; - autorização foi verificada;
@@ -2158,7 +2159,7 @@ APP_DEBUG=false
APP_URL APP_URL
APP_LOCALE=pt_BR APP_LOCALE=pt_BR
APP_FALLBACK_LOCALE=pt_BR APP_FALLBACK_LOCALE=pt_BR
APP_TIMEZONE=America/Sao_Paulo APP_TIMEZONE=America/Fortaleza
DB_CONNECTION=pgsql DB_CONNECTION=pgsql
DB_HOST DB_HOST
@@ -2171,16 +2172,19 @@ CACHE_STORE=database ou file conforme ambiente
QUEUE_CONNECTION=database QUEUE_CONNECTION=database
SESSION_DRIVER=database ou cookie conforme decisão SESSION_DRIVER=database ou cookie conforme decisão
FILESYSTEM_DISK=r2 em produção FILESYSTEM_DISK=s3 em produção
R2_ACCESS_KEY_ID AWS_ACCESS_KEY_ID
R2_SECRET_ACCESS_KEY AWS_SECRET_ACCESS_KEY
R2_BUCKET AWS_DEFAULT_REGION
R2_ENDPOINT AWS_BUCKET
R2_URL (domínio próprio opcional) AWS_ENDPOINT opcional
AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_DEFAULT_REGION, AWS_BUCKET, AWS_ENDPOINT, AWS_USE_PATH_STYLE_ENDPOINT opcionais, apenas se o disk s3 for usado AWS_USE_PATH_STYLE_ENDPOINT opcional
MAIL_MAILER=resend em produção (mailer nativo Laravel) MAIL_MAILER
RESEND_API_KEY MAIL_HOST
MAIL_PORT
MAIL_USERNAME
MAIL_PASSWORD
MAIL_FROM_ADDRESS MAIL_FROM_ADDRESS
MAIL_FROM_NAME MAIL_FROM_NAME
``` ```
@@ -2275,7 +2279,7 @@ O seed deve criar:
- configurações do site; - configurações do site;
- 3 serviços; - 3 serviços;
- 3 casos de portfólio; - 3 casos de portfólio;
- depoimentos reais: os 5 casais de `depoimentos.md` (Jeniffer e Maick, Quesia e Jhonata, Milena e Weslley, Raquel e Pedro, Victoria e Pedro), preservando texto e datas; autores fictícios de demonstração removidos; em produção permanecem não publicados até autorização explícita de publicação; - 3 depoimentos;
- leads em estados variados; - leads em estados variados;
- 2 eventos futuros e 1 concluído; - 2 eventos futuros e 1 concluído;
- tarefas vencidas e futuras; - tarefas vencidas e futuras;
@@ -2299,8 +2303,6 @@ Para visual e browser tests:
## 18. Backlog de implementação ## 18. Backlog de implementação
> **Recorte vigente (ADR-016).** Fases 0 e 1 são o escopo do lançamento e estão concluídas. **Fases 2 a 5 estão adiadas, sem data.** Não iniciar nenhuma delas sem uma decisão nova do responsável pelo produto — a §1.1 manda trabalhar uma fase por vez, e a fase corrente é o acabamento e a publicação do site.
O agente deve implementar na sequência, salvo instrução explícita. O agente deve implementar na sequência, salvo instrução explícita.
### Fase 0 — Fundação ### Fase 0 — Fundação
@@ -2323,13 +2325,6 @@ O agente deve implementar na sequência, salvo instrução explícita.
- [x] design tokens mínimos; - [x] design tokens mínimos;
- [x] healthcheck; - [x] healthcheck;
- [x] seed de admin local. - [x] seed de admin local.
- [x] verificação de e-mail e reset seguro (MustVerifyEmail);
- [x] npm audit no `composer quality` e no job `static`;
- [x] gate de cobertura `Domain`/`Application` ≥ 80%;
- [x] serviço de aplicação FrankenPHP no Compose local;
- [x] hello-world implantado em staging (critério de saída) — run [31395107465](https://github.com/manoel-freitas/amore-site/actions/runs/31395107465) em `7e68c0e`, com `Dokploy deployment succeeded` e smoke verde em `/up`, `/` e `/admin/login`.
> Os itens pendentes acima são tratados pela mudança OpenSpec `complete-foundation-parity`; o critério de saída da fase só é atingido com staging implantado.
**Critério de saída:** pipeline verde e hello-world implantado em staging. **Critério de saída:** pipeline verde e hello-world implantado em staging.
@@ -2346,11 +2341,12 @@ O agente deve implementar na sequência, salvo instrução explícita.
- [x] privacidade; - [x] privacidade;
- [x] SEO; - [x] SEO;
- [x] mídia otimizada; - [x] mídia otimizada;
- [x] snapshots desktop/mobile;
- [x] testes de acessibilidade. - [x] testes de acessibilidade.
**Critério de saída:** conteúdo gerenciável no Filament, testes browser funcionais, de acessibilidade e motion aprovados, e revisão humana do site público no PR. **Critério de saída:** conteúdo gerenciável no Filament e site público aprovado visualmente (baselines em `tests/.pest/snapshots/`; aprovação humana do diff visual no PR).
### Fase 2 — Leads — ADIADA (ADR-016) ### Fase 2 — Leads
- [ ] migration/model/factory de Lead; - [ ] migration/model/factory de Lead;
- [ ] LeadActivity; - [ ] LeadActivity;
@@ -2367,7 +2363,7 @@ O agente deve implementar na sequência, salvo instrução explícita.
**Critério de saída:** jornada visitante → lead → tratamento interna totalmente verde. **Critério de saída:** jornada visitante → lead → tratamento interna totalmente verde.
### Fase 3 — Eventos e tarefas — ADIADA (ADR-016) ### Fase 3 — Eventos e tarefas
- [ ] Event; - [ ] Event;
- [ ] EventTask; - [ ] EventTask;
@@ -2382,7 +2378,7 @@ O agente deve implementar na sequência, salvo instrução explícita.
**Critério de saída:** lead é convertido e evento pode ser administrado. **Critério de saída:** lead é convertido e evento pode ser administrado.
### Fase 4 — Fornecedores e financeiro — ADIADA (ADR-016) ### Fase 4 — Fornecedores e financeiro
- [ ] Vendor; - [ ] Vendor;
- [ ] EventVendor; - [ ] EventVendor;
@@ -2398,7 +2394,7 @@ O agente deve implementar na sequência, salvo instrução explícita.
**Critério de saída:** assessora acompanha orçamento e pagamentos sem integração bancária. **Critério de saída:** assessora acompanha orçamento e pagamentos sem integração bancária.
### Fase 5 — Documentos, hardening e lançamento — ADIADA (ADR-016) ### Fase 5 — Documentos, hardening e lançamento
- [ ] documentos privados; - [ ] documentos privados;
- [ ] auditoria completa; - [ ] auditoria completa;
@@ -2459,7 +2455,8 @@ Toda operação financeira deve:
| Risco | Mitigação obrigatória | | Risco | Mitigação obrigatória |
|---|---| |---|---|
| Filament concentrar domínio | Resources finos, Actions e Queries testáveis | | Filament concentrar domínio | Resources finos, Actions e Queries testáveis |
| Site parecer genérico | design tokens, conteúdo real, fotografia e revisão humana | | Site parecer genérico | design tokens, conteúdo real, fotografia e visual tests |
| Snapshots instáveis | contêiner fixo, relógio/dados/fontes determinísticos |
| Escopo crescer | lista de não objetivos e mudança somente com hipótese real | | Escopo crescer | lista de não objetivos e mudança somente com hipótese real |
| Financeiro virar contabilidade | limitar a orçamento e pagamentos manuais | | Financeiro virar contabilidade | limitar a orçamento e pagamentos manuais |
| Worker mode vazar estado | manter modo regular até benchmark e ADR | | Worker mode vazar estado | manter modo regular até benchmark e ADR |
@@ -2475,21 +2472,15 @@ Toda operação financeira deve:
| ADR | Decisão | Status | | ADR | Decisão | Status |
|---|---|---| |---|---|---|
| ADR-001 | Monólito modular Laravel, sem microserviços | Aceita | | ADR-001 | Monólito modular Laravel, sem microserviços | Aceita |
| ADR-002 | Filament para área interna (Livewire é dependência interna do Filament); site público em Blade — ver ADR-015 | Aceita | | ADR-002 | Filament para área interna e Livewire/Blade para área pública | Aceita |
| ADR-003 | PostgreSQL como único banco transacional | Aceita | | ADR-003 | PostgreSQL como único banco transacional | Aceita |
| ADR-004 | Pagamentos somente manuais | Aceita | | ADR-004 | Pagamentos somente manuais | Aceita |
| ADR-005 | Pest unifica unit, feature e browser | Aceita | | ADR-005 | Pest unifica unit, feature, browser e visual | Aceita |
| ADR-006 | FrankenPHP regular mode; worker mode adiado | Aceita | | ADR-006 | FrankenPHP regular mode; worker mode adiado | Aceita |
| ADR-007 | Single-tenant; SaaS e portal do cliente adiados | Aceita | | ADR-007 | Single-tenant; SaaS e portal do cliente adiados | Aceita |
| ADR-008 | Database queue; Redis adiado | Aceita | | ADR-008 | Database queue; Redis adiado | Aceita |
| ADR-009 | Dinheiro em BRL armazenado como centavos inteiros | Aceita | | ADR-009 | Dinheiro em BRL armazenado como centavos inteiros | Aceita |
| ADR-010 | Home com estrutura fixa e CMS tipado, sem page builder | Aceita | | ADR-010 | Home com estrutura fixa e CMS tipado, sem page builder | Aceita |
| ADR-011 | Cloudflare R2 (S3-compatible) como storage de objetos em produção | Aceita |
| ADR-012 | E-mail transacional via Resend (mailer nativo Laravel) | Aceita |
| ADR-013 | Design system Heritage Editorial para o site público | Aceita |
| ADR-014 | Deploy via Dokploy Compose com imagem imutável por SHA no registry Gitea | Aceita |
| ADR-015 | Site público permanece Blade + JS vanilla; Livewire e Alpine ficam restritos ao Filament até o gatilho de §22. Emenda o texto da ADR-002 | Aceita |
| ADR-016 | Lançamento de 31/08/2026 entrega apenas o site institucional (Fases 01); Fases 25 seguem especificadas e adiadas, sem data | Aceita |
--- ---
@@ -2506,7 +2497,6 @@ Toda operação financeira deve:
| API pública | existe consumidor real e contrato de integração | | API pública | existe consumidor real e contrato de integração |
| Kanban | tabela de leads demonstra limitação frequente observada | | Kanban | tabela de leads demonstra limitação frequente observada |
| Editor de checklist | diferentes tipos de evento exigem manutenção frequente do seed | | Editor de checklist | diferentes tipos de evento exigem manutenção frequente do seed |
| Livewire no site público | portfólio ou serviços exigem consulta ou filtro dinâmico que Blade + JS vanilla não resolvem de forma simples |
--- ---
@@ -2514,39 +2504,24 @@ Toda operação financeira deve:
O MVP está concluído somente quando: O MVP está concluído somente quando:
A ADR-016 divide estas condições em dois recortes. Cada um se fecha por conta própria; o segundo não bloqueia o lançamento.
### 23.1 Lançamento do site (Fases 01)
O lançamento está concluído somente quando:
- site público está publicado e visualmente aprovado; - site público está publicado e visualmente aprovado;
- assessora edita os conteúdos essenciais sem desenvolvedor; - assessora edita os conteúdos essenciais sem desenvolvedor;
- briefing envia o pedido de proposta de forma segura, com proteção contra abuso e aceite de privacidade registrado; - briefing cria leads de forma segura;
- usuários e Policies estão corretos;
- testes unit, feature, browser funcionais e arquitetura estão verdes;
- CI bloqueia regressões;
- imagem FrankenPHP é reproduzível;
- staging e produção usam a mesma imagem promovida;
- backup, restauração e monitoramento estão documentados;
- nenhum item explicitamente fora do MVP (§4.2) foi introduzido.
Note a diferença em relação à versão anterior desta seção: o critério do briefing é **enviar o pedido**, não "criar leads". Criar Lead é Fase 2 e está adiado; o formulário atual envia e-mail e não persiste nada.
### 23.2 Produto completo (Fases 25, adiado)
Além de tudo em §23.1:
- pipeline e próxima ação funcionam; - pipeline e próxima ação funcionam;
- briefing cria leads de forma segura e persistente;
- lead é convertido uma única vez em evento; - lead é convertido uma única vez em evento;
- checklist é criado automaticamente; - checklist é criado automaticamente;
- evento possui visão consolidada; - evento possui visão consolidada;
- fornecedores podem ser cadastrados e vinculados; - fornecedores podem ser cadastrados e vinculados;
- orçamento e pagamentos manuais possuem totais consistentes; - orçamento e pagamentos manuais possuem totais consistentes;
- dashboard mostra pendências do dia; - dashboard mostra pendências do dia;
- usuários e Policies estão corretos;
- auditoria registra operações críticas; - auditoria registra operações críticas;
- jornadas E2E das fases correspondentes estão verdes. - testes unit, feature, E2E, visuais e arquitetura estão verdes;
- CI bloqueia regressões;
- imagem FrankenPHP é reproduzível;
- staging e produção usam a mesma imagem promovida;
- backup, restauração e monitoramento estão documentados;
- nenhum item explicitamente fora do MVP foi introduzido.
--- ---

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

@@ -8,7 +8,6 @@ use App\Models\PortfolioCase;
use App\Models\Service; use App\Models\Service;
use App\Models\SiteSetting; use App\Models\SiteSetting;
use App\Models\Testimonial; use App\Models\Testimonial;
use App\Models\WeddingPackage;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
final readonly class HomeContent final readonly class HomeContent
@@ -17,13 +16,11 @@ final readonly class HomeContent
* @param Collection<int, Service> $featuredServices * @param Collection<int, Service> $featuredServices
* @param Collection<int, PortfolioCase> $featuredCases * @param Collection<int, PortfolioCase> $featuredCases
* @param Collection<int, Testimonial> $testimonials * @param Collection<int, Testimonial> $testimonials
* @param Collection<int, WeddingPackage> $packages
*/ */
public function __construct( public function __construct(
public SiteSetting $settings, public SiteSetting $settings,
public Collection $featuredServices, public Collection $featuredServices,
public Collection $featuredCases, public Collection $featuredCases,
public Collection $testimonials, public Collection $testimonials,
public Collection $packages,
) {} ) {}
} }

View File

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

View File

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

View File

@@ -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

@@ -9,7 +9,6 @@ use App\Models\PortfolioCase;
use App\Models\Service; use App\Models\Service;
use App\Models\SiteSetting; use App\Models\SiteSetting;
use App\Models\Testimonial; use App\Models\Testimonial;
use App\Models\WeddingPackage;
final class GetHomeContent final class GetHomeContent
{ {
@@ -32,10 +31,6 @@ final class GetHomeContent
->published() ->published()
->orderBy('sort_order') ->orderBy('sort_order')
->get(), ->get(),
packages: WeddingPackage::query()
->published()
->orderBy('sort_order')
->get(),
); );
} }
} }

View File

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

View File

@@ -29,7 +29,6 @@ final class MediaGenerateVariantsCommand extends Command
foreach ($paths as $path) { foreach ($paths as $path) {
if (! Storage::disk($disk)->exists($path)) { if (! Storage::disk($disk)->exists($path)) {
ResponsiveImage::forgetMetadata($path, $disk);
$this->warn("Missing file: {$path}"); $this->warn("Missing file: {$path}");
$skipped++; $skipped++;
@@ -57,24 +56,6 @@ final class MediaGenerateVariantsCommand extends Command
if ($settings && filled($settings->default_og_image_path)) { if ($settings && filled($settings->default_og_image_path)) {
$paths[] = (string) $settings->default_og_image_path; $paths[] = (string) $settings->default_og_image_path;
} }
if ($settings && filled($settings->hero_image_path)) {
$paths[] = (string) $settings->hero_image_path;
}
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;
}
if ($settings && filled($settings->services_hero_image_path)) {
$paths[] = (string) $settings->services_hero_image_path;
}
if ($settings && filled($settings->portfolio_hero_image_path)) {
$paths[] = (string) $settings->portfolio_hero_image_path;
}
foreach (Service::query()->whereNotNull('cover_image_path')->pluck('cover_image_path') as $path) { foreach (Service::query()->whereNotNull('cover_image_path')->pluck('cover_image_path') as $path) {
$paths[] = (string) $path; $paths[] = (string) $path;

View File

@@ -1,85 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Domain\Contact;
/**
* Normalizes a raw phone/WhatsApp number typed into the public briefing
* form into a canonical, human-readable Brazilian format.
*
* The briefing e-mail simply prints the field value in a table, so the
* canonical form must stay legible for the staff member reading it
* "(11) 98888-7777" rather than an opaque "11988887777" digit string.
*/
final class BrazilianPhoneNumber
{
/**
* Formats to "(DD) 9XXXX-XXXX" for an 11-digit mobile number or
* "(DD) XXXX-XXXX" for a 10-digit landline number, stripping a
* leading "+55"/"55" country code when present.
*
* A 10- or 11-digit string is only formatted when it is structurally
* plausible as a Brazilian number: the first two digits must be a
* possible DDD (`[1-9][1-9]`, since Brazilian area codes run 11-99
* and never carry a '0' in either position), and an 11-digit number
* must additionally have a '9' as its third digit (mandatory on all
* Brazilian mobile numbers since 2012). An explicit "+55" prefix that
* leaves no digits for a DDD (e.g. "+55 98888-7777") is treated as a
* number missing its area code, not as DDD 55.
*
* When the digit count does not match either shape, or the shape
* fails the checks above (foreign numbers, partial input, extensions,
* etc.), the original text is preserved only whitespace is
* collapsed so no information the recipient might need is
* discarded or silently fabricated.
*/
public static function normalize(string $raw): string
{
$trimmed = trim($raw);
$collapsed = preg_replace('/\s+/', ' ', $trimmed) ?? $trimmed;
// Only reformat when the text is made exclusively of phone
// characters. Anything else — "(WhatsApp)", "falar com João",
// a ramal — is information the recipient needs, so it is left
// untouched rather than stripped away by the digit extraction.
if (preg_match('/^[0-9()+\-.\/ ]+$/', $collapsed) !== 1) {
return $collapsed;
}
$digits = preg_replace('/\D+/', '', $collapsed) ?? '';
if (in_array(strlen($digits), [12, 13], true) && str_starts_with($digits, '55')) {
$digits = substr($digits, 2);
} elseif (str_starts_with($digits, '55') && preg_match('/^\+\s*55\b/', $collapsed) === 1) {
// An explicit "+55" was written, but the total digit count
// never reached 12/13, meaning nothing precedes it that could
// be a DDD — e.g. "+55 98888-7777" is a mobile number missing
// its area code, not DDD 55 with a coincidentally-matching
// subscriber number. Guessing a DDD here would fabricate one.
return $collapsed;
}
return match (strlen($digits)) {
11 => self::isPlausibleDdd($digits) && $digits[2] === '9'
? sprintf('(%s) %s-%s', substr($digits, 0, 2), substr($digits, 2, 5), substr($digits, 7))
: $collapsed,
10 => self::isPlausibleDdd($digits)
? sprintf('(%s) %s-%s', substr($digits, 0, 2), substr($digits, 2, 4), substr($digits, 6))
: $collapsed,
default => $collapsed,
};
}
/**
* A Brazilian DDD (area code) runs 11-99: the first digit is never
* '0' (not a valid leading digit) and the second is never '0' either
* (no DDD like "10", "20", "30" exists). This does not check that the
* DDD is one of the officially assigned codes only that its shape
* is plausible enough to distinguish it from a foreign number.
*/
private static function isPlausibleDdd(string $digits): bool
{
return preg_match('/^[1-9][1-9]/', $digits) === 1;
}
}

View File

@@ -1,181 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Domain\Contact;
/**
* Captures the marketing origin of a visit (SPEC.md WEB-05, "origem de
* marketing capturada quando disponível") from raw, untrusted request
* data and turns it into a short, human-readable pt-BR label for the
* internal briefing e-mail.
*
* Deliberately framework-free (no Illuminate\Http\Request dependency) so
* it stays a pure transformation, mirroring BrazilianPhoneNumber: callers
* in the HTTP layer extract the query string / referrer and hand them in
* as primitives.
*/
final class MarketingOrigin
{
/**
* Session key both the capturing middleware and the controller read
* from kept here so there is exactly one name for the concept.
*/
public const string SESSION_KEY = 'marketing_origin';
/**
* Caps every captured value. This is marketing metadata, not user
* content a crafted query string must not be able to bloat the
* session (SPEC.md §12.5 LGPD).
*/
private const int MAX_LENGTH = 100;
/**
* @var list<string>
*/
private const array UTM_KEYS = [
'utm_source',
'utm_medium',
'utm_campaign',
'utm_term',
'utm_content',
];
/**
* pt-BR labels for the UTM keys, in the order they should be
* displayed when composing the briefing e-mail row.
*
* @var array<string, string>
*/
private const array LABELS = [
'utm_source' => 'origem',
'utm_medium' => 'mídia',
'utm_campaign' => 'campanha',
'utm_term' => 'termo',
'utm_content' => 'conteúdo',
];
/**
* Reads UTM parameters from the query string, falling back to the
* HTTP referrer when none are present. Returns an empty array when
* neither is available capturing nothing is a valid outcome
* ("capturada quando disponível").
*
* A referrer pointing back at this same host is not a marketing
* origin it is the visitor clicking from one internal page to
* another (common once the original session has expired and a
* fresh one starts mid-visit) so it is discarded rather than
* stored as noise (SPEC.md §12.5, "coletar apenas dados
* necessários").
*
* @param array<string, mixed> $queryParams
* @return array<string, string>
*/
public static function capture(array $queryParams, ?string $referrer, ?string $requestHost): array
{
$utm = [];
foreach (self::UTM_KEYS as $key) {
$value = $queryParams[$key] ?? null;
if (is_string($value) && trim($value) !== '') {
$utm[$key] = self::sanitize($value);
}
}
if ($utm !== []) {
return $utm;
}
if (is_string($referrer) && trim($referrer) !== '' && ! self::isSameHost($referrer, $requestHost)) {
$origin = self::originOf($referrer);
if ($origin !== null) {
return ['referrer' => self::sanitize($origin)];
}
}
return [];
}
/**
* Reduces a referrer URL to its scheme+host identity, dropping the
* path, query string, fragment, and any userinfo. The referrer is
* only ever used as a marketing-origin *site* label (SPEC.md WEB-05)
* the query string can carry data that identifies an individual
* (e.g. a personalized campaign link's `?email=...`), which would
* exceed "coletar apenas dados necessários" (SPEC.md §12.5) once it
* lands in the session and the internal briefing e-mail.
*/
private static function originOf(string $referrer): ?string
{
$host = parse_url($referrer, PHP_URL_HOST);
if (! is_string($host) || $host === '') {
return null;
}
$scheme = parse_url($referrer, PHP_URL_SCHEME);
$prefix = is_string($scheme) && $scheme !== '' ? "{$scheme}://" : '';
return "{$prefix}{$host}";
}
private static function isSameHost(string $referrer, ?string $requestHost): bool
{
if ($requestHost === null || $requestHost === '') {
return false;
}
$referrerHost = parse_url($referrer, PHP_URL_HOST);
return is_string($referrerHost) && strcasecmp($referrerHost, $requestHost) === 0;
}
/**
* Composes the single-row, pt-BR display value for the internal
* briefing e-mail. Returns null when nothing was captured, letting
* the caller fall back to the same "" convention already used for
* other optional briefing fields.
*
* Accepts loosely-typed input on purpose: this reads back whatever
* was put in the session, so it is treated as untrusted rather than
* assumed to still match the shape `capture()` produced.
*
* @param array<string, mixed> $origin
*/
public static function describe(array $origin): ?string
{
if (isset($origin['referrer']) && is_string($origin['referrer']) && $origin['referrer'] !== '') {
return $origin['referrer'];
}
$parts = [];
foreach (self::LABELS as $key => $label) {
if (isset($origin[$key]) && is_string($origin[$key]) && $origin[$key] !== '') {
$parts[] = "{$label}: {$origin[$key]}";
}
}
return $parts === [] ? null : implode(' | ', $parts);
}
/**
* Untrusted input (query string, HTTP referrer) that ends up in an
* HTML e-mail: strip any markup, drop control characters (also
* closes off header-injection-style newline tricks), trim, and cap
* the length before it ever reaches storage.
*/
private static function sanitize(string $value): string
{
$withoutTags = strip_tags($value);
// No /u flag: this is a byte-wise scrub of ASCII control bytes, so
// it cannot land mid-sequence in valid UTF-8 (continuation bytes
// are all >= 0x80) — unlike the Unicode-mode regex, it never
// blanks the whole string just because one byte is malformed.
$withoutControlChars = preg_replace('/[\x00-\x1F\x7F]/', '', $withoutTags) ?? '';
return mb_substr(trim($withoutControlChars), 0, self::MAX_LENGTH);
}
}

View File

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

View File

@@ -1,43 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Filament\Pages\Auth;
use Filament\Auth\Pages\PasswordReset\RequestPasswordReset as BaseRequestPasswordReset;
use Filament\Notifications\Notification;
use Illuminate\Support\Facades\Password;
/**
* Overrides Filament's stock request-reset page to close an account
* enumeration leak (SPEC 12.1 / ADM-01 "reset seguro"). The vendor page
* shows a distinguishable danger notification for two statuses that only
* ever occur for existing users, letting an attacker tell registered emails
* apart from unregistered ones:
*
* - Password::INVALID_USER no such user at all.
* - Password::RESET_THROTTLED Illuminate\Auth\Passwords\PasswordBroker
* only returns this when a token was already recently created for that
* user, which requires the user to exist. Two requests for the same
* registered email (allowed by this page's own rate limit of 2) would
* otherwise flip from "sent" to "throttled" while an unknown email stays
* "sent" both times the same leak, reached a different way. The
* tradeoff: a legitimate user requesting twice sees "sent" again instead
* of "please wait", which is an acceptable UX cost on an admin-only panel.
*
* Existing-but-ineligible users (inactive, or since this panel now requires
* a verified email) already resolve to Password::RESET_LINK_SENT with no
* mail sent see the vendor callback in the base class so only these two
* branches need normalizing here.
*/
class RequestPasswordReset extends BaseRequestPasswordReset
{
protected function getFailureNotification(string $status): ?Notification
{
if (in_array($status, [Password::INVALID_USER, Password::RESET_THROTTLED], true)) {
return $this->getSentNotification(Password::RESET_LINK_SENT);
}
return parent::getFailureNotification($status);
}
}

View File

@@ -160,24 +160,11 @@ class ManageSiteSettings extends Page
Textarea::make('hero_note') Textarea::make('hero_note')
->label('Nota do hero') ->label('Nota do hero')
->rows(2), ->rows(2),
PublicImageUploadRules::fileUpload('hero_image_path', 'Imagem do hero', 'content/home'),
PublicImageUploadRules::altTextField('hero_image_alt', 'hero_image_path'),
Textarea::make('about_summary') Textarea::make('about_summary')
->label('Resumo institucional') ->label('Resumo institucional')
->rows(3), ->rows(3),
]) ])
->columns(2), ->columns(2),
Section::make('Imagens das aberturas')
->description('Fotos reais e autorizadas para as primeiras dobras. Sem imagem, cada rota mantém a abertura tonal.')
->schema([
PublicImageUploadRules::fileUpload('hero_image_path', 'Imagem da home', 'content/heroes'),
PublicImageUploadRules::altTextField('hero_image_alt', 'hero_image_path'),
PublicImageUploadRules::fileUpload('services_hero_image_path', 'Imagem de Serviços', 'content/heroes'),
PublicImageUploadRules::altTextField('services_hero_image_alt', 'services_hero_image_path'),
PublicImageUploadRules::fileUpload('portfolio_hero_image_path', 'Imagem de Portfólio', 'content/heroes'),
PublicImageUploadRules::altTextField('portfolio_hero_image_alt', 'portfolio_hero_image_path'),
])
->columns(2),
Section::make('Manifesto editorial') Section::make('Manifesto editorial')
->schema([ ->schema([
TextInput::make('manifesto_title') TextInput::make('manifesto_title')
@@ -212,26 +199,6 @@ class ManageSiteSettings extends Page
->reorderable() ->reorderable()
->columnSpanFull(), ->columnSpanFull(),
]), ]),
Section::make('Corporate')
->description('Etapas apresentadas na frente Amare Corporate da home.')
->schema([
Repeater::make('corporate_steps')
->label('Etapas do serviço corporativo')
->schema([
TextInput::make('title')
->label('Título')
->required()
->maxLength(255),
Textarea::make('body')
->label('Descrição')
->required()
->rows(2),
])
->defaultItems(3)
->maxItems(6)
->reorderable()
->columnSpanFull(),
]),
Section::make('Princípios') Section::make('Princípios')
->schema([ ->schema([
TagsInput::make('principles') TagsInput::make('principles')
@@ -251,11 +218,6 @@ class ManageSiteSettings extends Page
->label('Telefone') ->label('Telefone')
->required() ->required()
->maxLength(255), ->maxLength(255),
TextInput::make('whatsapp_number')
->label('WhatsApp oficial para as modalidades')
->tel()
->maxLength(40)
->helperText('Use o número com DDD. Sem um número válido, as CTAs levam ao briefing.'),
TextInput::make('city') TextInput::make('city')
->label('Cidade') ->label('Cidade')
->maxLength(255), ->maxLength(255),
@@ -266,14 +228,6 @@ class ManageSiteSettings extends Page
->addActionLabel('Adicionar rede'), ->addActionLabel('Adicionar rede'),
]) ])
->columns(2), ->columns(2),
Section::make('Página Sobre')
->schema([
PublicImageUploadRules::fileUpload('about_image_path', 'Imagem do hero / 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') Section::make('SEO padrão')
->schema([ ->schema([
TextInput::make('default_meta_title') TextInput::make('default_meta_title')

View File

@@ -1,7 +1,5 @@
<?php <?php
declare(strict_types=1);
namespace App\Filament\Resources\PortfolioCases\Pages; namespace App\Filament\Resources\PortfolioCases\Pages;
use App\Filament\Resources\PortfolioCases\PortfolioCaseResource; use App\Filament\Resources\PortfolioCases\PortfolioCaseResource;

View File

@@ -1,7 +1,5 @@
<?php <?php
declare(strict_types=1);
namespace App\Filament\Resources\PortfolioCases\Pages; namespace App\Filament\Resources\PortfolioCases\Pages;
use App\Filament\Resources\PortfolioCases\PortfolioCaseResource; use App\Filament\Resources\PortfolioCases\PortfolioCaseResource;

View File

@@ -1,7 +1,5 @@
<?php <?php
declare(strict_types=1);
namespace App\Filament\Resources\PortfolioCases\Pages; namespace App\Filament\Resources\PortfolioCases\Pages;
use App\Filament\Resources\PortfolioCases\PortfolioCaseResource; use App\Filament\Resources\PortfolioCases\PortfolioCaseResource;

View File

@@ -1,7 +1,5 @@
<?php <?php
declare(strict_types=1);
namespace App\Filament\Resources\Services\Pages; namespace App\Filament\Resources\Services\Pages;
use App\Filament\Resources\Services\ServiceResource; use App\Filament\Resources\Services\ServiceResource;

View File

@@ -1,7 +1,5 @@
<?php <?php
declare(strict_types=1);
namespace App\Filament\Resources\Services\Pages; namespace App\Filament\Resources\Services\Pages;
use App\Filament\Resources\Services\ServiceResource; use App\Filament\Resources\Services\ServiceResource;

View File

@@ -1,7 +1,5 @@
<?php <?php
declare(strict_types=1);
namespace App\Filament\Resources\Services\Pages; namespace App\Filament\Resources\Services\Pages;
use App\Filament\Resources\Services\ServiceResource; use App\Filament\Resources\Services\ServiceResource;

View File

@@ -1,7 +1,5 @@
<?php <?php
declare(strict_types=1);
namespace App\Filament\Resources\Testimonials\Pages; namespace App\Filament\Resources\Testimonials\Pages;
use App\Filament\Resources\Testimonials\TestimonialResource; use App\Filament\Resources\Testimonials\TestimonialResource;

View File

@@ -1,7 +1,5 @@
<?php <?php
declare(strict_types=1);
namespace App\Filament\Resources\Testimonials\Pages; namespace App\Filament\Resources\Testimonials\Pages;
use App\Filament\Resources\Testimonials\TestimonialResource; use App\Filament\Resources\Testimonials\TestimonialResource;

View File

@@ -1,7 +1,5 @@
<?php <?php
declare(strict_types=1);
namespace App\Filament\Resources\Testimonials\Pages; namespace App\Filament\Resources\Testimonials\Pages;
use App\Filament\Resources\Testimonials\TestimonialResource; use App\Filament\Resources\Testimonials\TestimonialResource;

View File

@@ -1,38 +1,11 @@
<?php <?php
declare(strict_types=1);
namespace App\Filament\Resources\Users\Pages; namespace App\Filament\Resources\Users\Pages;
use App\Filament\Resources\Users\UserResource; use App\Filament\Resources\Users\UserResource;
use App\Models\User;
use Filament\Resources\Pages\CreateRecord; use Filament\Resources\Pages\CreateRecord;
use Illuminate\Database\Eloquent\Model;
class CreateUser extends CreateRecord class CreateUser extends CreateRecord
{ {
protected static string $resource = UserResource::class; protected static string $resource = UserResource::class;
/**
* Verification stays admin-managed only (no self-service verify route is
* registered): a user created here by an admin is, by that act, verified.
* Without this, `email_verified_at` would stay null forever and
* canAccessPanel() would permanently lock the new user out with no
* in-app path to recover the password-reset callback skips notifying
* users that fail canAccessPanel() while still reporting success.
*
* `email_verified_at` is deliberately not added to User's #[Fillable]
* list (it isn't a UserForm field either) so it can never be set via
* mass assignment from form/API input `forceFill()` bypasses that
* guard here on purpose, after construction.
*/
protected function handleRecordCreation(array $data): Model
{
/** @var User $record */
$record = new (static::getModel())($data);
$record->forceFill(['email_verified_at' => now()]);
$record->save();
return $record;
}
} }

View File

@@ -1,7 +1,5 @@
<?php <?php
declare(strict_types=1);
namespace App\Filament\Resources\Users\Pages; namespace App\Filament\Resources\Users\Pages;
use App\Filament\Resources\Users\UserResource; use App\Filament\Resources\Users\UserResource;

View File

@@ -1,7 +1,5 @@
<?php <?php
declare(strict_types=1);
namespace App\Filament\Resources\Users\Pages; namespace App\Filament\Resources\Users\Pages;
use App\Filament\Resources\Users\UserResource; use App\Filament\Resources\Users\UserResource;

View File

@@ -1,13 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\WeddingPackages\Pages;
use App\Filament\Resources\WeddingPackages\WeddingPackageResource;
use Filament\Resources\Pages\CreateRecord;
class CreateWeddingPackage extends CreateRecord
{
protected static string $resource = WeddingPackageResource::class;
}

View File

@@ -1,13 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\WeddingPackages\Pages;
use App\Filament\Resources\WeddingPackages\WeddingPackageResource;
use Filament\Resources\Pages\EditRecord;
class EditWeddingPackage extends EditRecord
{
protected static string $resource = WeddingPackageResource::class;
}

View File

@@ -1,21 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\WeddingPackages\Pages;
use App\Filament\Resources\WeddingPackages\WeddingPackageResource;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
class ListWeddingPackages extends ListRecords
{
protected static string $resource = WeddingPackageResource::class;
protected function getHeaderActions(): array
{
return [
CreateAction::make(),
];
}
}

View File

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

View File

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

View File

@@ -1,62 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\WeddingPackages;
use App\Filament\Resources\WeddingPackages\Pages\CreateWeddingPackage;
use App\Filament\Resources\WeddingPackages\Pages\EditWeddingPackage;
use App\Filament\Resources\WeddingPackages\Pages\ListWeddingPackages;
use App\Filament\Resources\WeddingPackages\Schemas\WeddingPackageForm;
use App\Filament\Resources\WeddingPackages\Tables\WeddingPackagesTable;
use App\Models\WeddingPackage;
use App\Policies\WeddingPackagePolicy;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
use UnitEnum;
class WeddingPackageResource extends Resource
{
protected static ?string $model = WeddingPackage::class;
protected static ?string $policy = WeddingPackagePolicy::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedRectangleStack;
protected static ?string $navigationLabel = 'Modalidades';
protected static ?string $modelLabel = 'modalidade';
protected static ?string $pluralModelLabel = 'modalidades';
protected static string|UnitEnum|null $navigationGroup = 'Conteúdo do site';
protected static ?int $navigationSort = 3;
public static function form(Schema $schema): Schema
{
return WeddingPackageForm::configure($schema);
}
public static function table(Table $table): Table
{
return WeddingPackagesTable::configure($table);
}
public static function getRelations(): array
{
return [];
}
public static function getPages(): array
{
return [
'index' => ListWeddingPackages::route('/'),
'create' => CreateWeddingPackage::route('/create'),
'edit' => EditWeddingPackage::route('/{record}/edit'),
];
}
}

View File

@@ -1,7 +1,5 @@
<?php <?php
declare(strict_types=1);
namespace App\Http\Controllers; namespace App\Http\Controllers;
abstract class Controller abstract class Controller

View File

@@ -1,140 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\PublicSite;
use App\Domain\Contact\MarketingOrigin;
use App\Http\Controllers\Controller;
use App\Http\Requests\PublicSite\ContactBriefingRequest;
use App\Mail\ContactBriefing;
use App\Mail\ContactBriefingConfirmation;
use App\Models\SiteSetting;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Throwable;
final class ContactController extends Controller
{
private const string DUPLICATE_SESSION_KEY = 'contact_briefing_hash';
public function store(ContactBriefingRequest $request): RedirectResponse
{
if (filled($request->input('empresa'))) {
return $this->success();
}
$validated = $request->validated();
unset($validated['privacidade']);
if ($this->isDuplicate($validated)) {
return $this->success();
}
$this->rememberSubmission($validated);
$settings = SiteSetting::instance();
$fields = $this->buildFields($validated, $this->resolveMarketingOrigin($request));
$this->dispatchEmails($settings, $validated['nome'], $validated['email'], $fields);
return $this->success();
}
/**
* @param array<string, mixed> $validated
*/
private function buildFields(array $validated, ?string $marketingOrigin): array
{
return [
'Nome' => (string) $validated['nome'],
'E-mail' => (string) $validated['email'],
'Telefone/WhatsApp' => (string) $validated['telefone'],
'Tipo de evento' => (string) $validated['tipo_evento'],
'Data ou período desejado' => isset($validated['data_periodo']) ? (string) $validated['data_periodo'] : null,
'Cidade' => (string) $validated['cidade'],
'Número estimado de convidados' => isset($validated['convidados']) ? (string) $validated['convidados'] : null,
'Serviço de interesse' => isset($validated['servico_interesse']) ? (string) $validated['servico_interesse'] : null,
'Mensagem' => (string) $validated['mensagem'],
'Origem de marketing' => $marketingOrigin,
];
}
/**
* Reads the origin captured on arrival (SPEC.md WEB-05) so it can
* ride along with the internal briefing e-mail only never the
* confirmation sent to the visitor. Fail-open: any problem reading
* or interpreting the session value must never block the
* submission, so it degrades to "not captured" instead.
*/
private function resolveMarketingOrigin(ContactBriefingRequest $request): ?string
{
try {
$origin = $request->session()->get(MarketingOrigin::SESSION_KEY, []);
return is_array($origin) ? MarketingOrigin::describe($origin) : null;
} catch (Throwable $exception) {
Log::warning('Falha ao ler origem de marketing armazenada (ignorada; fail-open)', [
'exception' => $exception::class,
]);
return null;
}
}
/**
* @param array<string, mixed> $validated
*/
private function isDuplicate(array $validated): bool
{
$hash = $this->hash($validated);
return session()->get(self::DUPLICATE_SESSION_KEY) === $hash;
}
/**
* @param array<string, mixed> $validated
*/
private function rememberSubmission(array $validated): void
{
session()->put(self::DUPLICATE_SESSION_KEY, $this->hash($validated));
}
/**
* @param array<string, mixed> $validated
*/
private function hash(array $validated): string
{
return hash('sha256', serialize($validated));
}
/**
* @param array<string, mixed> $fields
*/
private function dispatchEmails(SiteSetting $settings, string $name, string $email, array $fields): void
{
$attempt = function () use ($settings, $name, $email, $fields): void {
if (filled($settings->email)) {
Mail::to($settings->email)->send(new ContactBriefing($fields));
}
if (filled(config('mail.default'))) {
Mail::to($email)->send(new ContactBriefingConfirmation($name, (string) $settings->brand_name));
}
};
try {
$attempt();
} catch (Throwable $exception) {
Log::error('Falha ao enviar briefing de contato', [
'exception' => $exception,
]);
}
}
private function success(): RedirectResponse
{
return redirect()->route('briefing')->with('status', 'briefing-sent');
}
}

View File

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

View File

@@ -5,25 +5,22 @@ declare(strict_types=1);
namespace App\Http\Controllers\PublicSite; namespace App\Http\Controllers\PublicSite;
use App\Application\Data\PageMeta; use App\Application\Data\PageMeta;
use App\Application\Queries\Marketing\GetAboutContent;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\SiteSetting; use App\Models\SiteSetting;
use Illuminate\Contracts\View\View; use Illuminate\Contracts\View\View;
final class PageController extends Controller final class PageController extends Controller
{ {
public function about(GetAboutContent $getAboutContent): View public function about(): View
{ {
$content = $getAboutContent(); $settings = SiteSetting::instance();
$settings = $content->settings;
return view('pages.about', [ return view('pages.about', [
'content' => $content,
'siteSettings' => $settings, 'siteSettings' => $settings,
'pageMeta' => PageMeta::forPage( 'pageMeta' => PageMeta::forPage(
canonical: route('about'), canonical: route('about'),
settings: $settings, settings: $settings,
title: PageMeta::withBrandSuffix('Sobre', $settings), title: 'Sobre',
description: $settings->about_summary ?: ('Conheça a '.$settings->brand_name.'.'), description: $settings->about_summary ?: ('Conheça a '.$settings->brand_name.'.'),
), ),
]); ]);
@@ -38,7 +35,7 @@ final class PageController extends Controller
'pageMeta' => PageMeta::forPage( 'pageMeta' => PageMeta::forPage(
canonical: route('privacy'), canonical: route('privacy'),
settings: $settings, settings: $settings,
title: PageMeta::withBrandSuffix('Política de privacidade', $settings), title: 'Política de privacidade',
description: 'Política de privacidade da '.$settings->brand_name.'.', description: 'Política de privacidade da '.$settings->brand_name.'.',
), ),
]); ]);
@@ -48,24 +45,14 @@ final class PageController extends Controller
{ {
$settings = SiteSetting::instance(); $settings = SiteSetting::instance();
return view('pages.partners', [ return view('pages.contact', [
'siteSettings' => $settings, 'siteSettings' => $settings,
'pageMeta' => PageMeta::forPage( 'pageMeta' => PageMeta::forPage(
canonical: route('contact'), canonical: route('contact'),
settings: $settings, settings: $settings,
title: PageMeta::withBrandSuffix('Contato', $settings), title: 'Contato',
description: 'Fale com a '.$settings->brand_name.'.', description: 'Fale com a '.$settings->brand_name.'.',
), ),
]); ]);
} }
public function briefing(): View
{
$settings = SiteSetting::instance();
return view('pages.contact', [
'siteSettings' => $settings,
'pageMeta' => PageMeta::forPage(canonical: route('briefing'), settings: $settings, title: PageMeta::withBrandSuffix('Briefing', $settings), description: 'Solicite uma proposta à '.$settings->brand_name.'.'),
]);
}
} }

View File

@@ -1,66 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\PublicSite;
use App\Http\Controllers\Controller;
use App\Http\Requests\PublicSite\PartnerInquiryRequest;
use App\Mail\PartnerInquiry;
use App\Models\SiteSetting;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Throwable;
final class PartnerInquiryController extends Controller
{
private const string DUPLICATE_SESSION_KEY = 'partner_inquiry_hash';
public function store(PartnerInquiryRequest $request): RedirectResponse
{
if (filled($request->input('website'))) {
return $this->success();
}
$validated = $request->validated();
if (session()->get(self::DUPLICATE_SESSION_KEY) === $this->hash($validated)) {
return $this->success();
}
session()->put(self::DUPLICATE_SESSION_KEY, $this->hash($validated));
try {
$settings = SiteSetting::instance();
if (filled($settings->email)) {
Mail::to($settings->email)->send(new PartnerInquiry([
'Nome' => $validated['nome'],
'Nome profissional ou empresa' => $validated['empresa'] ?? null,
'E-mail' => $validated['email'],
'Atuação ou serviço' => $validated['atuacao'],
'Área de atendimento' => $validated['area_atendimento'],
'Mensagem' => $validated['mensagem'],
'Portfólio ou redes' => $validated['portfolio_redes'] ?? null,
'Telefone' => $validated['telefone'] ?? null,
]));
}
} catch (Throwable $exception) {
Log::error('Falha ao enviar consulta de parceiro', ['exception' => $exception::class]);
}
return $this->success();
}
/** @param array<string, mixed> $validated */
private function hash(array $validated): string
{
return hash('sha256', serialize($validated));
}
private function success(): RedirectResponse
{
return redirect()->route('contact')->with('status', 'partner-inquiry-sent');
}
}

View File

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

View File

@@ -8,7 +8,6 @@ use App\Application\Data\PageMeta;
use App\Application\Queries\Marketing\GetPublishedServices; use App\Application\Queries\Marketing\GetPublishedServices;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\SiteSetting; use App\Models\SiteSetting;
use App\Models\WeddingPackage;
use Illuminate\Contracts\View\View; use Illuminate\Contracts\View\View;
final class ServiceController extends Controller final class ServiceController extends Controller
@@ -20,12 +19,11 @@ final class ServiceController extends Controller
return view('pages.services.index', [ return view('pages.services.index', [
'services' => $services, 'services' => $services,
'weddingPackages' => WeddingPackage::query()->published()->orderBy('sort_order')->get(),
'siteSettings' => $settings, 'siteSettings' => $settings,
'pageMeta' => PageMeta::forPage( 'pageMeta' => PageMeta::forPage(
canonical: route('services.index'), canonical: route('services.index'),
settings: $settings, settings: $settings,
title: PageMeta::withBrandSuffix('Serviços', $settings), title: 'Serviços',
description: 'Conheça os serviços de assessoria de eventos da '.$settings->brand_name.'.', description: 'Conheça os serviços de assessoria de eventos da '.$settings->brand_name.'.',
), ),
]); ]);

View File

@@ -1,85 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use App\Domain\Contact\MarketingOrigin;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\Response;
use Throwable;
/**
* Captures the marketing origin (UTM parameters, or the HTTP referrer as
* a fallback) on the first public page load of a visit and stores it in
* the session, so it can later travel with a contact briefing submission
* (SPEC.md WEB-05).
*
* First *informative* touch wins: nothing is written until a page load
* actually carries a UTM parameter or an external referrer, and once
* that happens later page views never overwrite it navigating to
* another page without UTM parameters must not erase what arrived with
* the visitor. A page load with no signal at all is left unrecorded so a
* later, informative page load in the same session can still be
* captured.
*
* Fail-open by construction: any failure here is caught and logged
* without request data, and the request proceeds untouched. This path
* must never be able to block a page load or, downstream, a conversion.
*/
final class CaptureMarketingOrigin
{
/**
* Technical routes that share the `web` middleware group but are never a
* human arriving at the site. A crawler fetching `/sitemap.xml?utm_source=…`
* would otherwise consume the first-touch slot with traffic that will never
* submit a briefing.
*
* @var list<string>
*/
private const IGNORED_ROUTES = ['sitemap', 'robots'];
public function handle(Request $request, Closure $next): Response
{
if (in_array($request->route()?->getName(), self::IGNORED_ROUTES, true)) {
return $next($request);
}
try {
$this->captureFirstTouch($request);
} catch (Throwable $exception) {
Log::warning('Falha ao capturar origem de marketing (ignorada; fail-open)', [
'exception' => $exception::class,
]);
}
return $next($request);
}
private function captureFirstTouch(Request $request): void
{
if (! $request->hasSession()) {
return;
}
$session = $request->session();
if ($session->has(MarketingOrigin::SESSION_KEY)) {
return;
}
$origin = MarketingOrigin::capture(
$request->query(),
$request->headers->get('referer'),
$request->getHost(),
);
if ($origin === []) {
return;
}
$session->put(MarketingOrigin::SESSION_KEY, $origin);
}
}

View File

@@ -1,73 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\PublicSite;
use App\Domain\Contact\BrazilianPhoneNumber;
use Illuminate\Foundation\Http\FormRequest;
final class ContactBriefingRequest extends FormRequest
{
/**
* Normaliza e-mail e telefone antes da validação (SPEC.md §12.3),
* mantendo o valor legível para quem recebe o briefing por e-mail.
*/
protected function prepareForValidation(): void
{
$email = $this->input('email');
$telefone = $this->input('telefone');
$this->merge([
'email' => is_string($email) ? mb_strtolower(trim($email)) : $email,
'telefone' => is_string($telefone) ? BrazilianPhoneNumber::normalize($telefone) : $telefone,
]);
}
/**
* @return array<string, array<int, string>>
*/
public function rules(): array
{
return [
'nome' => ['required', 'string', 'max:120'],
'email' => ['required', 'email', 'max:254'],
'telefone' => ['required', 'string', 'max:40'],
'tipo_evento' => ['required', 'string', 'max:80'],
'data_periodo' => ['nullable', 'string', 'max:80'],
'cidade' => ['required', 'string', 'max:80'],
'convidados' => ['nullable', 'integer', 'min:1', 'max:100000'],
'servico_interesse' => ['nullable', 'string', 'max:120'],
'mensagem' => ['required', 'string', 'max:3000'],
'privacidade' => ['accepted'],
];
}
/**
* @return array<string, string>
*/
public function messages(): array
{
return [
'nome.required' => 'Informe seu nome completo.',
'nome.max' => 'O nome deve ter no máximo :max caracteres.',
'email.required' => 'Informe seu e-mail.',
'email.email' => 'Informe um e-mail válido.',
'email.max' => 'O e-mail deve ter no máximo :max caracteres.',
'telefone.required' => 'Informe um telefone ou WhatsApp.',
'telefone.max' => 'O telefone deve ter no máximo :max caracteres.',
'tipo_evento.required' => 'Selecione o tipo de evento.',
'tipo_evento.max' => 'O tipo de evento deve ter no máximo :max caracteres.',
'data_periodo.max' => 'A data ou período deve ter no máximo :max caracteres.',
'cidade.required' => 'Informe a cidade do evento.',
'cidade.max' => 'A cidade deve ter no máximo :max caracteres.',
'convidados.integer' => 'Informe um número de convidados válido.',
'convidados.min' => 'O número de convidados deve ser maior que zero.',
'convidados.max' => 'O número de convidados informado é inválido.',
'servico_interesse.max' => 'O serviço deve ter no máximo :max caracteres.',
'mensagem.required' => 'Conte brevemente o que você precisa.',
'mensagem.max' => 'A mensagem deve ter no máximo :max caracteres.',
'privacidade.accepted' => 'Você precisa aceitar a política de privacidade.',
];
}
}

View File

@@ -1,32 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\PublicSite;
use Illuminate\Foundation\Http\FormRequest;
final class PartnerInquiryRequest extends FormRequest
{
protected function prepareForValidation(): void
{
$email = $this->input('email');
$this->merge(['email' => is_string($email) ? mb_strtolower(trim($email)) : $email]);
}
/** @return array<string, array<int, string>> */
public function rules(): array
{
return [
'nome' => ['required', 'string', 'max:120'],
'empresa' => ['nullable', 'string', 'max:160'],
'email' => ['required', 'email', 'max:254'],
'atuacao' => ['required', 'string', 'max:120'],
'area_atendimento' => ['required', 'string', 'max:160'],
'mensagem' => ['required', 'string', 'max:3000'],
'portfolio_redes' => ['nullable', 'string', 'max:500'],
'telefone' => ['nullable', 'string', 'max:40'],
];
}
}

View File

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

View File

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

View File

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

View File

@@ -12,21 +12,10 @@ use Illuminate\Database\Eloquent\Model;
/** /**
* @property array<string, string|null> $social_links * @property array<string, string|null> $social_links
* @property list<array{title?: string, body?: string}>|null $method_steps * @property list<array{title?: string, body?: string}>|null $method_steps
* @property list<array{title?: string, body?: string}>|null $corporate_steps
* @property list<string>|null $principles * @property list<string>|null $principles
* @property bool $analytics_enabled * @property bool $analytics_enabled
* @property string|null $default_og_image_path * @property string|null $default_og_image_path
* @property string|null $default_og_image_alt * @property string|null $default_og_image_alt
* @property string|null $about_image_path
* @property string|null $about_image_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
* @property string|null $services_hero_image_alt
* @property string|null $portfolio_hero_image_path
* @property string|null $portfolio_hero_image_alt
* @property string|null $logo_path * @property string|null $logo_path
* @property string|null $logo_alt * @property string|null $logo_alt
*/ */
@@ -40,27 +29,15 @@ use Illuminate\Database\Eloquent\Model;
'hero_cta_label', 'hero_cta_label',
'hero_secondary_cta_label', 'hero_secondary_cta_label',
'hero_note', 'hero_note',
'hero_image_path',
'hero_image_alt',
'services_hero_image_path',
'services_hero_image_alt',
'portfolio_hero_image_path',
'portfolio_hero_image_alt',
'about_summary', 'about_summary',
'about_image_path',
'about_image_alt',
'founder_image_path',
'founder_image_alt',
'manifesto_title', 'manifesto_title',
'manifesto_lead', 'manifesto_lead',
'manifesto_body', 'manifesto_body',
'method_intro', 'method_intro',
'method_steps', 'method_steps',
'corporate_steps',
'principles', 'principles',
'email', 'email',
'phone', 'phone',
'whatsapp_number',
'city', 'city',
'social_links', 'social_links',
'default_meta_title', 'default_meta_title',
@@ -89,11 +66,9 @@ class SiteSetting extends Model
'manifesto_body' => 'A Amare combina sensibilidade e precisão para criar encontros coerentes com cada cliente, marca e ocasião — sem fórmulas prontas, excessos ou ruído.', 'manifesto_body' => 'A Amare combina sensibilidade e precisão para criar encontros coerentes com cada cliente, marca e ocasião — sem fórmulas prontas, excessos ou ruído.',
'method_intro' => 'Clareza em cada etapa. Tranquilidade durante todo o processo.', 'method_intro' => 'Clareza em cada etapa. Tranquilidade durante todo o processo.',
'method_steps' => self::defaultMethodSteps(), 'method_steps' => self::defaultMethodSteps(),
'corporate_steps' => self::defaultCorporateSteps(),
'principles' => self::defaultPrinciples(), 'principles' => self::defaultPrinciples(),
'email' => 'amareassessoriaeventos@gmail.com', 'email' => 'amareassessoriaeventos@gmail.com',
'phone' => '(11) 99999-9999', 'phone' => '(11) 99999-9999',
'whatsapp_number' => null,
'city' => 'São Paulo - SP', 'city' => 'São Paulo - SP',
'social_links' => [], 'social_links' => [],
'default_meta_title' => 'Amare Assessoria de Eventos', 'default_meta_title' => 'Amare Assessoria de Eventos',
@@ -127,27 +102,6 @@ class SiteSetting extends Model
]; ];
} }
/**
* @return list<array{title: string, body: string}>
*/
public static function defaultCorporateSteps(): array
{
return [
[
'title' => 'Planejamento',
'body' => 'Estruturação de escopo, cronograma e prioridades.',
],
[
'title' => 'Produção',
'body' => 'Coordenação dos elementos necessários para colocar o evento de pé.',
],
[
'title' => 'Execução',
'body' => 'Condução e acompanhamento do evento conforme o projeto aprovado.',
],
];
}
/** /**
* @return list<string> * @return list<string>
*/ */
@@ -169,7 +123,6 @@ class SiteSetting extends Model
return [ return [
'social_links' => 'array', 'social_links' => 'array',
'method_steps' => 'array', 'method_steps' => 'array',
'corporate_steps' => 'array',
'principles' => 'array', 'principles' => 'array',
'analytics_enabled' => 'boolean', 'analytics_enabled' => 'boolean',
]; ];

View File

@@ -8,8 +8,6 @@ use App\Enums\UserRole;
use Database\Factories\UserFactory; use Database\Factories\UserFactory;
use Filament\Models\Contracts\FilamentUser; use Filament\Models\Contracts\FilamentUser;
use Filament\Panel; use Filament\Panel;
use Illuminate\Auth\MustVerifyEmail;
use Illuminate\Contracts\Auth\MustVerifyEmail as MustVerifyEmailContract;
use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden; use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -22,14 +20,14 @@ use Illuminate\Notifications\Notifiable;
*/ */
#[Fillable(['name', 'email', 'password', 'role', 'is_active'])] #[Fillable(['name', 'email', 'password', 'role', 'is_active'])]
#[Hidden(['password', 'remember_token'])] #[Hidden(['password', 'remember_token'])]
class User extends Authenticatable implements FilamentUser, MustVerifyEmailContract class User extends Authenticatable implements FilamentUser
{ {
/** @use HasFactory<UserFactory> */ /** @use HasFactory<UserFactory> */
use HasFactory, MustVerifyEmail, Notifiable; use HasFactory, Notifiable;
public function canAccessPanel(Panel $panel): bool public function canAccessPanel(Panel $panel): bool
{ {
return $this->is_active && $this->hasVerifiedEmail(); return $this->is_active;
} }
public function isAdmin(): bool public function isAdmin(): bool

View File

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

View File

@@ -1,36 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Policies;
use App\Models\User;
use App\Models\WeddingPackage;
class WeddingPackagePolicy
{
public function viewAny(User $user): bool
{
return $user->isAdmin();
}
public function view(User $user, WeddingPackage $package): bool
{
return $user->isAdmin();
}
public function create(User $user): bool
{
return $user->isAdmin();
}
public function update(User $user, WeddingPackage $package): bool
{
return $user->isAdmin();
}
public function delete(User $user, WeddingPackage $package): bool
{
return $user->isAdmin();
}
}

View File

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

View File

@@ -1,11 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace App\Providers\Filament; namespace App\Providers\Filament;
use App\Filament\Pages\Auth\RequestPasswordReset;
use Filament\FontProviders\LocalFontProvider;
use Filament\Http\Middleware\Authenticate; use Filament\Http\Middleware\Authenticate;
use Filament\Http\Middleware\AuthenticateSession; use Filament\Http\Middleware\AuthenticateSession;
use Filament\Http\Middleware\DisableBladeIconComponents; use Filament\Http\Middleware\DisableBladeIconComponents;
@@ -13,7 +9,7 @@ use Filament\Http\Middleware\DispatchServingFilamentEvent;
use Filament\Pages\Dashboard; use Filament\Pages\Dashboard;
use Filament\Panel; use Filament\Panel;
use Filament\PanelProvider; use Filament\PanelProvider;
use Filament\View\PanelsRenderHook; use Filament\Support\Colors\Color;
use Filament\Widgets\AccountWidget; use Filament\Widgets\AccountWidget;
use Filament\Widgets\FilamentInfoWidget; use Filament\Widgets\FilamentInfoWidget;
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse; use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
@@ -25,137 +21,6 @@ use Illuminate\View\Middleware\ShareErrorsFromSession;
class AdminPanelProvider extends PanelProvider class AdminPanelProvider extends PanelProvider
{ {
/**
* Heritage Editorial Oliva Herança primary ramp.
*
* Filament needs an explicit 11-stop shade array to preserve exact brand
* hexes (a bare string/`Color::hex()` runs the value through
* `Color::generatePalette()`, which discards its lightness and maps a
* fixed per-shade lightness/chroma table instead see MAN-118 survey).
* Shade 600 pins `#556B2F` (Oliva Herança) and shade 700 pins `#3E5219`
* (Oliva Profundo). `Filament\Support\View\Components\ColorMaps\
* ButtonComponentColorMap` was used to verify empirically (not assumed)
* that a solid primary button resolves to `bg: 600, hover:bg: 500, text:
* 50` at 5.42:1 / 4.57:1 contrast (WCAG AA). Shade 50 is a pale olive
* tint rather than pure white deliberately: `BadgeComponent`/
* `badge.css` apply `bg-color-50` directly as a badge's background
* (independent of the button map above), and a pure-white 50 would
* render a "primary" badge as an all-but-invisible pill against the
* Papel Marfim (`#FBF9F4`) panel body.
*
* @return array<int, string>
*/
private function primaryColor(): array
{
return [
50 => '#F3F5EC',
100 => '#E7EBDA',
200 => '#D4DCBE',
300 => '#8B9D77', // Sálvia Silenciosa
400 => '#6E8354',
500 => '#61763A',
600 => '#556B2F', // Oliva Herança
700 => '#3E5219', // Oliva Profundo
800 => '#2E3D13',
900 => '#1F290D',
950 => '#141B08',
];
}
/**
* Heritage Editorial paper/ink neutral ramp, replacing Filament's
* stock Zinc gray so panel chrome (sidebar, topbar, body background,
* borders) matches the public site's paper tones instead of true gray.
* Anchored at the exact tokens where DESIGN.md defines them (50/100/200
* = Papel Marfim/Profundo/Arquivo, 300 = Linha Botânica, 500 = Tinta
* Suave, 900 = Tinta Oliva); the remaining stops are interpolated to
* keep the ramp monotonic.
*
* @return array<int, string>
*/
private function grayColor(): array
{
return [
50 => '#FBF9F4', // Papel Marfim
100 => '#F0EEE9', // Papel Profundo
200 => '#E4E2DD', // Papel Arquivo
300 => '#C5C8B8', // Linha Botânica
400 => '#919486',
500 => '#5D6155', // Tinta Suave
600 => '#43453D',
700 => '#32342E',
800 => '#252622',
900 => '#1B1C19', // Tinta Oliva
950 => '#121210',
];
}
/**
* Semantic ramps built the same way as the primary/gray ramps above:
* an explicit 11-stop array (never `Color::hex()`) anchored so the
* existing `--amare-color-{success,warning,error}` hex lands exactly on
* shade 600 verified against `ButtonComponentColorMap` to resolve to
* `bg: 600` with white text at WCAG AA contrast, same as primary.
*
* @return array<int, string>
*/
private function dangerColor(): array
{
return [
50 => '#F7EDED',
100 => '#EBD1D1',
200 => '#D8A8A8',
300 => '#C88484',
400 => '#B85F5F',
500 => '#A73B3B',
600 => '#991B1B',
700 => '#7D1616',
800 => '#651212',
900 => '#4C0E0E',
950 => '#3A0A0A',
];
}
/**
* @return array<int, string>
*/
private function warningColor(): array
{
return [
50 => '#F6F0EC',
100 => '#E9D9CF',
200 => '#D6B6A3',
300 => '#C4987D',
400 => '#B37956',
500 => '#A15B30',
600 => '#92400E',
700 => '#78340B',
800 => '#602A09',
900 => '#492007',
950 => '#371805',
];
}
/**
* @return array<int, string>
*/
private function successColor(): array
{
return [
50 => '#ECF3EF',
100 => '#D0E0D6',
200 => '#A6C4B2',
300 => '#81AC91',
400 => '#5C9371',
500 => '#377B50',
600 => '#166534',
700 => '#12532B',
800 => '#0F4322',
900 => '#0B321A',
950 => '#082614',
];
}
public function panel(Panel $panel): Panel public function panel(Panel $panel): Panel
{ {
return $panel return $panel
@@ -163,47 +28,9 @@ class AdminPanelProvider extends PanelProvider
->id('admin') ->id('admin')
->path('admin') ->path('admin')
->login() ->login()
->passwordReset(requestAction: RequestPasswordReset::class)
->brandName('Amare Assessoria')
->brandLogo(fn (): string => asset('brand/lockup-on-light.webp'))
->brandLogoHeight('2rem')
->colors([ ->colors([
'primary' => $this->primaryColor(), 'primary' => Color::Amber,
'gray' => $this->grayColor(),
'danger' => $this->dangerColor(),
'warning' => $this->warningColor(),
'success' => $this->successColor(),
]) ])
// Heritage Editorial is a single paper palette by design (see
// DESIGN.md) — no dark-mode variant exists, so the switcher is
// removed rather than left pointing at an unstyled dark theme.
->darkMode(false)
// Self-hosted EB Garamond, same family as the public site.
// LocalFontProvider is pinned explicitly: HasFont::getFontProvider()
// otherwise defaults a custom family to BunnyFontProvider, which
// would emit a live request to fonts.bunny.net from the panel.
// LocalFontProvider renders no <link>/@font-face itself (no $url
// given), so the actual face comes from the render hook below,
// reusing the public site's own <x-fonts /> component/manifest.
//
// The monospace and serif faces are set too, not just the base
// (sans) family: Filament exposes them via the separate
// ->monoFont()/->serifFont() calls below, and the `KeyValue`
// field (used in ManageSiteSettings) renders in the monospace
// face directly — left unset, that field would silently fall
// back to a system monospace stack instead of EB Garamond,
// breaking Heritage Editorial's single-voice typography.
->font('EB Garamond', provider: LocalFontProvider::class)
->monoFont('EB Garamond', provider: LocalFontProvider::class)
->serifFont('EB Garamond', provider: LocalFontProvider::class)
->renderHook(
PanelsRenderHook::HEAD_END,
fn (): string => view('components.fonts')->render(),
)
// Compiled Filament theme entry — sharp corners, flat surfaces
// (see resources/css/filament/admin/theme.css for the full
// rationale and the two vendored-CSS exceptions it can't reach).
->viteTheme('resources/css/filament/admin/theme.css')
->discoverResources(in: app_path('Filament/Resources'), for: 'App\Filament\Resources') ->discoverResources(in: app_path('Filament/Resources'), for: 'App\Filament\Resources')
->discoverPages(in: app_path('Filament/Pages'), for: 'App\Filament\Pages') ->discoverPages(in: app_path('Filament/Pages'), for: 'App\Filament\Pages')
->pages([ ->pages([

View File

@@ -1,28 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Support;
use App\Models\SiteSetting;
final class PackageContactLink
{
/**
* Resolve the contextual contact link for a wedding package modality.
*
* @return array{href: string, isWhatsapp: bool}
*/
public static function for(SiteSetting $settings, string $packageName): array
{
$digits = preg_replace('/\D/', '', (string) $settings->whatsapp_number);
$isWhatsapp = strlen($digits) >= 10;
return [
'href' => $isWhatsapp
? 'https://wa.me/'.$digits.'?text='.rawurlencode('Olá, gostaria de conversar sobre a modalidade '.$packageName.' para meu casamento.')
: route('briefing', ['servico_interesse' => $packageName]),
'isWhatsapp' => $isWhatsapp,
];
}
}

View File

@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Support; namespace App\Support;
use Illuminate\Contracts\Filesystem\Filesystem; use Illuminate\Contracts\Filesystem\Filesystem;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Intervention\Image\Drivers\Gd\Driver; use Intervention\Image\Drivers\Gd\Driver;
use Intervention\Image\ImageManager; use Intervention\Image\ImageManager;
@@ -13,23 +12,13 @@ use Throwable;
final class ResponsiveImage final class ResponsiveImage
{ {
/** /** @var list<int> */
* 720 sits between the original 480 and 960 because that is where the common public const WIDTHS = [480, 960, 1440];
* mobile viewport lands: 412 CSS px at a 1.75 device pixel ratio asks for
* ~721 px, so a full-width image used to jump straight to the 960 variant and
* pay for a third more pixels than it drew. Measured on the hero (MAN-109):
* 143 KiB at 960 in jpeg against 75 KiB at 720 in webp.
*
* @var list<int>
*/
public const WIDTHS = [480, 720, 960, 1440];
public static function generate(string $path, ?string $disk = null): void public static function generate(string $path, ?string $disk = null): void
{ {
self::forgetMetadata($path, $disk);
$filesystem = self::filesystem($disk); $filesystem = self::filesystem($disk);
try {
if (! $filesystem->exists($path)) { if (! $filesystem->exists($path)) {
return; return;
} }
@@ -58,85 +47,40 @@ final class ResponsiveImage
}; };
$filesystem->put($variantPath, (string) $encoded); $filesystem->put($variantPath, (string) $encoded);
// A webp sibling for every variant. The MAN-109 audit measured the
// hero as the LCP element on every mobile page, at 143 KiB for a
// 960 px jpeg — webp carries the same picture for roughly a third of
// that. `x-media.image` offers these through a <source> so a browser
// that cannot decode webp still gets the original format.
if ($extension === 'webp') {
continue;
} }
$webp = $manager->read($contents);
if ($webp->width() > $width) {
$webp->scale(width: $width);
}
$filesystem->put(
self::webpVariantPath($path, $width),
(string) $webp->toWebp(quality: 80)
);
}
} finally {
self::forgetMetadata($path, $disk);
}
self::metadata($path, $disk);
} }
public static function deleteVariants(string $path, ?string $disk = null): void public static function deleteVariants(string $path, ?string $disk = null): void
{ {
self::forgetMetadata($path, $disk);
$filesystem = self::filesystem($disk); $filesystem = self::filesystem($disk);
try {
foreach (self::WIDTHS as $width) { foreach (self::WIDTHS as $width) {
foreach ([self::variantPath($path, $width), self::webpVariantPath($path, $width)] as $variantPath) { $variantPath = self::variantPath($path, $width);
if ($filesystem->exists($variantPath)) { if ($filesystem->exists($variantPath)) {
$filesystem->delete($variantPath); $filesystem->delete($variantPath);
} }
} }
} }
} finally {
self::forgetMetadata($path, $disk);
}
}
public static function delete(string $path, ?string $disk = null): void public static function delete(string $path, ?string $disk = null): void
{ {
self::forgetMetadata($path, $disk);
$filesystem = self::filesystem($disk); $filesystem = self::filesystem($disk);
try {
self::deleteVariants($path, $disk); self::deleteVariants($path, $disk);
if ($filesystem->exists($path)) { if ($filesystem->exists($path)) {
$filesystem->delete($path); $filesystem->delete($path);
} }
} finally {
self::forgetMetadata($path, $disk);
}
} }
public static function replace(string $previousPath, string $newPath, ?string $disk = null): void public static function replace(string $previousPath, string $newPath, ?string $disk = null): void
{ {
self::forgetMetadata($previousPath, $disk);
self::forgetMetadata($newPath, $disk);
try {
if ($previousPath !== '' && $previousPath !== $newPath) { if ($previousPath !== '' && $previousPath !== $newPath) {
self::delete($previousPath, $disk); self::delete($previousPath, $disk);
} }
self::generate($newPath, $disk); self::generate($newPath, $disk);
} finally {
self::forgetMetadata($previousPath, $disk);
self::forgetMetadata($newPath, $disk);
}
self::metadata($newPath, $disk);
} }
public static function variantPath(string $path, int $width): string public static function variantPath(string $path, int $width): string
@@ -149,35 +93,26 @@ final class ResponsiveImage
return $directory === '' ? $variantName : $directory.'/'.$variantName; return $directory === '' ? $variantName : $directory.'/'.$variantName;
} }
/**
* The webp sibling of a variant, named by appending rather than replacing the
* extension. Uploads are stored under a UUID so a collision is already
* unlikely, but `photo-480.jpg.webp` cannot collide with the webp variant of
* a `photo.png` the way `photo-480.webp` would.
*/
public static function webpVariantPath(string $path, int $width): string
{
return self::variantPath($path, $width).'.webp';
}
/** /**
* @return list<array{path: string, width: int}> * @return list<array{path: string, width: int}>
*/ */
public static function availableVariants(string $path, ?string $disk = null): array public static function availableVariants(string $path, ?string $disk = null): array
{ {
return self::metadata($path, $disk)['variants'] ?? []; $filesystem = self::filesystem($disk);
$variants = [];
foreach (self::WIDTHS as $width) {
$variantPath = self::variantPath($path, $width);
if ($filesystem->exists($variantPath)) {
$variants[] = [
'path' => $variantPath,
'width' => $width,
];
}
} }
/** return $variants;
* The webp variants that exist for a path. Empty when the media predates
* `media:generate-variants` running with webp support, which is why
* `x-media.image` treats the <source> as optional rather than assuming it.
*
* @return list<array{path: string, width: int}>
*/
public static function availableWebpVariants(string $path, ?string $disk = null): array
{
return self::metadata($path, $disk)['webp_variants'] ?? [];
} }
/** /**
@@ -185,38 +120,13 @@ final class ResponsiveImage
*/ */
public static function dimensions(string $path, ?string $disk = null): ?array public static function dimensions(string $path, ?string $disk = null): ?array
{ {
$metadata = self::metadata($path, $disk);
return $metadata === null ? null : [
'width' => $metadata['width'],
'height' => $metadata['height'],
];
}
/**
* @return array{
* width: int,
* height: int,
* variants: list<array{path: string, width: int}>,
* webp_variants: list<array{path: string, width: int}>
* }|null
*/
public static function metadata(string $path, ?string $disk = null): ?array
{
$key = self::metadataCacheKey($path, $disk);
$cached = self::cachedMetadata($key);
if (is_array($cached)) {
return $cached;
}
try {
$filesystem = self::filesystem($disk); $filesystem = self::filesystem($disk);
if (! $filesystem->exists($path)) { if (! $filesystem->exists($path)) {
return null; return null;
} }
try {
$contents = $filesystem->get($path); $contents = $filesystem->get($path);
if ($contents === null) { if ($contents === null) {
@@ -224,88 +134,18 @@ final class ResponsiveImage
} }
$image = (new ImageManager(new Driver))->read($contents); $image = (new ImageManager(new Driver))->read($contents);
$metadata = [
return [
'width' => $image->width(), 'width' => $image->width(),
'height' => $image->height(), 'height' => $image->height(),
'variants' => self::existingVariants($filesystem, $path, false),
'webp_variants' => self::existingVariants($filesystem, $path, true),
]; ];
self::storeMetadata($key, $metadata);
return $metadata;
} catch (Throwable) { } catch (Throwable) {
return null; return null;
} }
} }
public static function forgetMetadata(string $path, ?string $disk = null): void
{
try {
Cache::forget(self::metadataCacheKey($path, $disk));
} catch (Throwable) {
// Cache availability must not block public media rendering or cleanup.
}
}
private static function filesystem(?string $disk): Filesystem private static function filesystem(?string $disk): Filesystem
{ {
return Storage::disk(self::diskName($disk)); return Storage::disk($disk ?? PublicImageUploadRules::disk());
}
/**
* @return list<array{path: string, width: int}>
*/
private static function existingVariants(Filesystem $filesystem, string $path, bool $webp): array
{
$variants = [];
foreach (self::WIDTHS as $width) {
$variantPath = $webp
? self::webpVariantPath($path, $width)
: self::variantPath($path, $width);
if ($filesystem->exists($variantPath)) {
$variants[] = ['path' => $variantPath, 'width' => $width];
}
}
return $variants;
}
private static function metadataCacheKey(string $path, ?string $disk): string
{
return 'responsive-image:metadata:'.sha1(self::diskName($disk).'|'.$path);
}
/**
* @return array<string, mixed>|null
*/
private static function cachedMetadata(string $key): ?array
{
try {
$cached = Cache::get($key);
return is_array($cached) ? $cached : null;
} catch (Throwable) {
return null;
}
}
/**
* @param array<string, mixed> $metadata
*/
private static function storeMetadata(string $key, array $metadata): void
{
try {
Cache::forever($key, $metadata);
} catch (Throwable) {
// The uncached result remains safe to use for this request.
}
}
private static function diskName(?string $disk): string
{
return $disk ?? PublicImageUploadRules::disk();
} }
} }

View File

@@ -1,6 +1,5 @@
<?php <?php
use App\Http\Middleware\CaptureMarketingOrigin;
use Illuminate\Foundation\Application; use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware; use Illuminate\Foundation\Configuration\Middleware;
@@ -15,13 +14,6 @@ return Application::configure(basePath: dirname(__DIR__))
->withMiddleware(function (Middleware $middleware): void { ->withMiddleware(function (Middleware $middleware): void {
// Trust Traefik/Dokploy (and local reverse proxies) for X-Forwarded-* headers. // Trust Traefik/Dokploy (and local reverse proxies) for X-Forwarded-* headers.
$middleware->trustProxies(at: '*'); $middleware->trustProxies(at: '*');
// Public-site request spine only (routes/web.php uses the "web"
// group; the Filament admin panel defines its own middleware
// stack in AdminPanelProvider and never touches this group).
$middleware->web(append: [
CaptureMarketingOrigin::class,
]);
}) })
->withExceptions(function (Exceptions $exceptions): void { ->withExceptions(function (Exceptions $exceptions): void {
$exceptions->shouldRenderJsonWhen( $exceptions->shouldRenderJsonWhen(

View File

@@ -65,9 +65,6 @@
"test:browser": [ "test:browser": [
"@php artisan test --testsuite=Browser" "@php artisan test --testsuite=Browser"
], ],
"test:coverage": [
"vendor/bin/pest -c phpunit.coverage.xml --testsuite=Unit,Architecture,Feature --coverage --min=80"
],
"pint": [ "pint": [
"vendor/bin/pint" "vendor/bin/pint"
], ],
@@ -84,9 +81,11 @@
"@pint:check", "@pint:check",
"@phpstan", "@phpstan",
"composer audit --no-interaction", "composer audit --no-interaction",
"npm audit --omit=dev --audit-level=high",
"@test" "@test"
], ],
"visual:update": [
"@php artisan test --testsuite=Browser --update-snapshots"
],
"post-autoload-dump": [ "post-autoload-dump": [
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump", "Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
"@php artisan package:discover --ansi", "@php artisan package:discover --ansi",

12
composer.lock generated
View File

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

View File

@@ -65,7 +65,7 @@ return [
| |
*/ */
'timezone' => env('APP_TIMEZONE', 'America/Sao_Paulo'), 'timezone' => env('APP_TIMEZONE', 'America/Fortaleza'),
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
@@ -73,7 +73,7 @@ return [
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| |
| When set outside production, the application clock is frozen for | When set outside production, the application clock is frozen for
| deterministic time-dependent tests or seeded content. | deterministic rendering (visual regression / seeded content).
| |
*/ */

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 436 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 622 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 499 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 235 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 360 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 330 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 302 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 337 KiB

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

View File

@@ -1,42 +0,0 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
/**
* Backfills `email_verified_at` for users that already exist.
*
* `User` now implements MustVerifyEmail and `canAccessPanel()` requires
* `hasVerifiedEmail()`. Every account created before this change has
* `email_verified_at` as null, so without this backfill they are locked out of
* `/admin` the moment the change deploys and there is no way back in from
* inside the app: no self-service verification route is registered, and the
* password-reset flow deliberately skips notifying users that fail
* `canAccessPanel()` while still reporting success. Recovery would need shell
* access to the container.
*
* Verifying them is the correct default, not a shortcut. There is no public
* registration: every existing account was created by an admin, through the
* panel or the tinker snippet in docs/deployment/dokploy.md. That act is the
* verification which is exactly the reasoning CreateUser applies to accounts
* created from now on.
*/
return new class extends Migration
{
public function up(): void
{
DB::table('users')
->whereNull('email_verified_at')
->update(['email_verified_at' => DB::raw('COALESCE(created_at, NOW())')]);
}
public function down(): void
{
// Deliberately not reversed. Nulling these columns would lock every
// existing user out of the panel, which is the failure this migration
// exists to prevent — and the pre-migration null/non-null split is not
// recorded anywhere, so it could not be restored faithfully anyway.
}
};

View File

@@ -1,36 +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('hero_image_path')->nullable()->after('hero_note');
$table->string('hero_image_alt')->nullable()->after('hero_image_path');
$table->string('services_hero_image_path')->nullable()->after('hero_image_alt');
$table->string('services_hero_image_alt')->nullable()->after('services_hero_image_path');
$table->string('portfolio_hero_image_path')->nullable()->after('services_hero_image_alt');
$table->string('portfolio_hero_image_alt')->nullable()->after('portfolio_hero_image_path');
});
}
public function down(): void
{
Schema::table('site_settings', function (Blueprint $table): void {
$table->dropColumn([
'hero_image_path',
'hero_image_alt',
'services_hero_image_path',
'services_hero_image_alt',
'portfolio_hero_image_path',
'portfolio_hero_image_alt',
]);
});
}
};

View File

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

View File

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

View File

@@ -1,30 +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::create('wedding_packages', function (Blueprint $table): void {
$table->id();
$table->string('name');
$table->string('level');
$table->string('summary');
$table->jsonb('scope_items');
$table->string('cta_label');
$table->integer('sort_order')->default(0)->index();
$table->timestamp('published_at')->nullable()->index();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('wedding_packages');
}
};

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

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

View File

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

View File

@@ -8,10 +8,9 @@ use App\Models\PortfolioCase;
use App\Models\PortfolioImage; use App\Models\PortfolioImage;
use App\Models\Service; use App\Models\Service;
use App\Models\SiteSetting; use App\Models\SiteSetting;
use App\Support\PublicImageUploadRules; use App\Models\Testimonial;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
@@ -19,37 +18,12 @@ class ContentSeeder extends Seeder
{ {
private const SEED_TIMESTAMP = '2026-01-15 10:00:00'; private const SEED_TIMESTAMP = '2026-01-15 10:00:00';
/**
* Demo/fixture content is meant for local dev, automated testing, and
* staging visual review only. It must never overwrite owner-edited
* SiteSetting, Service, and PortfolioCase records, never re-upload
* fixture images to the production storage disk, and never auto-publish
* the fictional portfolio cases or (via TestimonialsSeeder) the real
* testimonials.
*
* This is deliberately an allow-list of the known-safe environments
* ('local', 'staging', 'testing') rather than a deny-list of
* 'production'. APP_ENV is a free-text value hand-typed into the
* Dokploy environment UI with no validation a blank value, a typo, or
* an unexpected casing (e.g. '', 'Production', 'staginng') must fail
* closed (skip seeding) rather than fail open (seed/overwrite
* production data). Only the three recognized values run this seeder;
* everything else, including 'production' itself, is a no-op.
*
* Publishing testimonials in production remains a deliberate, manually
* triggered step see docs/deployment/dokploy.md.
*/
public function run(): void public function run(): void
{ {
if (! App::environment(['local', 'staging', 'testing'])) {
return;
}
$this->seedSiteSettings(); $this->seedSiteSettings();
$this->seedServices(); $this->seedServices();
$this->seedPortfolioCases(); $this->seedPortfolioCases();
$this->call(TestimonialsSeeder::class); $this->seedTestimonials();
$this->call(WeddingPackagesSeeder::class);
} }
private function seedSiteSettings(): void private function seedSiteSettings(): void
@@ -62,23 +36,12 @@ class ContentSeeder extends Seeder
'hero_cta_label' => 'Solicitar proposta', 'hero_cta_label' => 'Solicitar proposta',
'hero_secondary_cta_label' => 'Conheça nosso olhar', 'hero_secondary_cta_label' => 'Conheça nosso olhar',
'hero_note' => 'Planejamento cuidadoso, comunicação clara e execução segura — do primeiro encontro ao último detalhe.', 'hero_note' => 'Planejamento cuidadoso, comunicação clara e execução segura — do primeiro encontro ao último detalhe.',
'hero_image_path' => $this->copyFixture('case-casamento-ana-lucas.jpg', 'content/heroes/home.jpg'),
'hero_image_alt' => 'Celebração ao ar livre em São Paulo',
'services_hero_image_path' => $this->copyFixture('service-eventos-corporativos.jpg', 'content/heroes/services.jpg'),
'services_hero_image_alt' => 'Mesa preparada para um evento corporativo',
'portfolio_hero_image_path' => $this->copyFixture('case-lancamento-verano.jpg', 'content/heroes/portfolio.jpg'),
'portfolio_hero_image_alt' => 'Ambientação de um evento de lançamento',
'about_summary' => 'Assessoria boutique especializada em experiências memoráveis em São Paulo.', 'about_summary' => 'Assessoria boutique especializada em experiências memoráveis em São Paulo.',
'about_image_path' => $this->copyFixture('about-image.jpg', 'content/about/about-image.jpg'),
'about_image_alt' => 'Mesa de planejamento com caderno, café e guardanapos de pano',
'founder_image_path' => $this->copyFixture('about-image.jpg', 'content/about/founder/michele.jpg'),
'founder_image_alt' => 'Michele, da Amare',
'manifesto_title' => 'Sofisticação que também se traduz em organização.', 'manifesto_title' => 'Sofisticação que também se traduz em organização.',
'manifesto_lead' => 'Um evento memorável não nasce apenas de uma boa estética. Ele depende de decisões bem conduzidas, fornecedores alinhados e atenção constante ao que realmente importa.', 'manifesto_lead' => 'Um evento memorável não nasce apenas de uma boa estética. Ele depende de decisões bem conduzidas, fornecedores alinhados e atenção constante ao que realmente importa.',
'manifesto_body' => 'A Amare combina sensibilidade e precisão para criar encontros coerentes com cada cliente, marca e ocasião — sem fórmulas prontas, excessos ou ruído.', 'manifesto_body' => 'A Amare combina sensibilidade e precisão para criar encontros coerentes com cada cliente, marca e ocasião — sem fórmulas prontas, excessos ou ruído.',
'method_intro' => 'Clareza em cada etapa. Tranquilidade durante todo o processo.', 'method_intro' => 'Clareza em cada etapa. Tranquilidade durante todo o processo.',
'method_steps' => SiteSetting::defaultMethodSteps(), 'method_steps' => SiteSetting::defaultMethodSteps(),
'corporate_steps' => SiteSetting::defaultCorporateSteps(),
'principles' => SiteSetting::defaultPrinciples(), 'principles' => SiteSetting::defaultPrinciples(),
'email' => 'amareassessoriaeventos@gmail.com', 'email' => 'amareassessoriaeventos@gmail.com',
'phone' => '(11) 99999-9999', 'phone' => '(11) 99999-9999',
@@ -129,7 +92,7 @@ class ContentSeeder extends Seeder
['slug' => $service['slug']], ['slug' => $service['slug']],
[ [
...$service, ...$service,
'cover_image_path' => $this->copyFixture('service-'.$service['slug'].'.jpg', 'content/services/'.$service['slug'].'.jpg'), 'cover_image_path' => $this->copyFixture('service-cover.jpg', 'content/services/'.$service['slug'].'.jpg'),
'cover_image_alt' => 'Capa do serviço '.$service['title'], 'cover_image_alt' => 'Capa do serviço '.$service['title'],
'published_at' => Carbon::parse(self::SEED_TIMESTAMP), 'published_at' => Carbon::parse(self::SEED_TIMESTAMP),
], ],
@@ -186,7 +149,7 @@ class ContentSeeder extends Seeder
['slug' => $caseData['slug']], ['slug' => $caseData['slug']],
[ [
...$caseData, ...$caseData,
'cover_image_path' => $this->copyFixture('case-'.$caseData['slug'].'.jpg', 'content/portfolio/'.$caseData['slug'].'-cover.jpg'), 'cover_image_path' => $this->copyFixture('portfolio-cover.jpg', 'content/portfolio/'.$caseData['slug'].'-cover.jpg'),
'cover_image_alt' => 'Capa do caso '.$caseData['title'], 'cover_image_alt' => 'Capa do caso '.$caseData['title'],
'is_featured' => true, 'is_featured' => true,
'published_at' => Carbon::parse(self::SEED_TIMESTAMP), 'published_at' => Carbon::parse(self::SEED_TIMESTAMP),
@@ -198,7 +161,7 @@ class ContentSeeder extends Seeder
foreach ([1, 2] as $index) { foreach ([1, 2] as $index) {
PortfolioImage::query()->create([ PortfolioImage::query()->create([
'portfolio_case_id' => $case->id, 'portfolio_case_id' => $case->id,
'path' => $this->copyFixture('gallery-'.$caseData['slug'].'-'.$index.'.jpg', 'content/portfolio/'.$caseData['slug'].'-gallery-'.$index.'.jpg'), 'path' => $this->copyFixture('gallery.jpg', 'content/portfolio/'.$caseData['slug'].'-gallery-'.$index.'.jpg'),
'alt_text' => 'Galeria '.$caseData['title'].' '.$index, 'alt_text' => 'Galeria '.$caseData['title'].' '.$index,
'caption' => $index === 1 ? 'Detalhe da decoração' : null, 'caption' => $index === 1 ? 'Detalhe da decoração' : null,
'sort_order' => $index, 'sort_order' => $index,
@@ -207,12 +170,71 @@ class ContentSeeder extends Seeder
} }
} }
private function seedTestimonials(): void
{
// Real couples from depoimentos.md. Production publication still requires
// explicit couple authorization before setting published_at outside local/demo seeds.
$testimonials = [
[
'quote' => "Mi, quero agradecer você e a sua equipe por todo empenho, atenção, vocês são abençoadas.\n\nEra nítida sua preocupação em garantir que todos os detalhes planejados desta comemoração, fossem atendidos.\n\nQue você possa transformar o grande dia das noivinhas sempre com essa sua leveza!!!\n\nMuito obrigada!",
'author_name' => 'Jeniffer e Maick',
'context' => 'Casamento · 06/12/2025',
'sort_order' => 1,
'is_featured' => true,
],
[
'quote' => "Mi, eu não tenho palavras pra agradecer você e tudo que você fez por mim e por nós na realização desse sonho. Eu tô ainda extasiada com tudo que aconteceu hoje; mas tenho certeza que sem a sua ajuda, muita coisa não aconteceria.\n\nObrigada por tudo !",
'author_name' => 'Quesia e Jhonata',
'context' => 'Casamento · 21/12/2025',
'sort_order' => 2,
'is_featured' => true,
],
[
'quote' => 'Que equipe!! Que equipe maravilhosa!! Obrigado pelo empenho de fazer tudo como eu queria!! Obrigado por se esforçar tanto e vir de tão longe pra realizar meu sonho!! Incríveis!!',
'author_name' => 'Milena e Weslley',
'context' => 'Casamento · 13/02/2026',
'sort_order' => 3,
'is_featured' => false,
],
[
'quote' => "Gostaríamos de agradecer por todo o acompanhamento e dedicação durante a realização do nosso casamento. Foi um dia muito especial e inesquecível para nós.\n\nDesde o início, conseguimos conduzir tudo aquilo que estávamos planejando, dentro dos horários que estipulamos, o que foi ótimo, e no grande dia sua equipe nos recebeu e tratou com muito carinho, atenção e cuidado, o que fez toda a diferença para vivermos esse momento com mais tranquilidade.\n\nTambém adoramos as sugestões e ideias para as fotos, que deixaram os registros ainda mais bonitos e espontâneos, porque não iríamos lembrar de quais poses fazer na hora.\n\nObrigada por fazer parte de um momento tão importante das nossas vidas. Desejamos muito sucesso e que muitos outros casais possam viver dias especiais através do trabalho da AMARE.",
'author_name' => 'Raquel e Pedro',
'context' => 'Casamento · 09/05/2026',
'sort_order' => 4,
'is_featured' => false,
],
[
'quote' => "Miiii, meu amor… você e sua equipe foram impecáveis.\n\nSuperou todas as nossas expectativas. Somos eternamente gratos por fazer nosso dia acontecer muito melhor do que imaginávamos.\n\nSempre muito atenciosa e paciente.\n\nAdoramos te conhecer e estamos muito felizes em termos escolhido você para assessorar nosso dia.",
'author_name' => 'Victoria e Pedro',
'context' => 'Casamento · 24/06/2026',
'sort_order' => 5,
'is_featured' => false,
],
];
$keepAuthors = array_column($testimonials, 'author_name');
Testimonial::query()
->whereNotIn('author_name', $keepAuthors)
->delete();
foreach ($testimonials as $testimonial) {
Testimonial::query()->updateOrCreate(
['author_name' => $testimonial['author_name']],
[
...$testimonial,
'photo_path' => null,
'photo_alt' => null,
'published_at' => Carbon::parse(self::SEED_TIMESTAMP),
],
);
}
}
private function copyFixture(string $fixtureName, string $destination): string private function copyFixture(string $fixtureName, string $destination): string
{ {
$source = base_path('database/fixtures/images/'.$fixtureName); $source = base_path('tests/fixtures/images/'.$fixtureName);
$disk = PublicImageUploadRules::disk(); Storage::disk('public')->put($destination, File::get($source));
Storage::disk($disk)->put($destination, File::get($source), 'public');
return $destination; return $destination;
} }

View File

@@ -23,7 +23,6 @@ class DatabaseSeeder extends Seeder
'password' => Hash::make('password'), 'password' => Hash::make('password'),
'role' => UserRole::Admin, 'role' => UserRole::Admin,
'is_active' => true, 'is_active' => true,
'email_verified_at' => now(),
], ],
); );
@@ -34,7 +33,6 @@ class DatabaseSeeder extends Seeder
'password' => Hash::make('password'), 'password' => Hash::make('password'),
'role' => UserRole::Assistant, 'role' => UserRole::Assistant,
'is_active' => true, 'is_active' => true,
'email_verified_at' => now(),
], ],
); );

View File

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

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