Compare commits
75 Commits
fix/seed-t
...
feat/man-1
| Author | SHA1 | Date | |
|---|---|---|---|
| 444153bf3d | |||
| 290585d713 | |||
| 8965a7c2fd | |||
| 8decba693a | |||
| dfd9763029 | |||
| 93b680c172 | |||
| ba388942c0 | |||
| f4c2e4ed2f | |||
| e2af1f6a23 | |||
| 68460e061b | |||
| fc1c6177d6 | |||
| 26a68e1823 | |||
| ac08b5f064 | |||
| 91c0d412fa | |||
| dd82ca2dca | |||
| 87a019109d | |||
| 1468a23145 | |||
| f730e766f5 | |||
| 1e215ac3d2 | |||
| 716196dbe0 | |||
| 84f6d7c31a | |||
| 08c6d62498 | |||
| 75cce50bd5 | |||
| a0963dd57a | |||
| 77dc592963 | |||
| 623e45cbea | |||
| 455bc4b7d8 | |||
| 3f55180b73 | |||
| ea630328c9 | |||
| 1425d7aaa1 | |||
| 62a19147ad | |||
| 90632ada88 | |||
| ce90cedb18 | |||
| a7d72f3756 | |||
| 0e0724c1cc | |||
| eca261ceba | |||
| ad29ce54d8 | |||
| 0d1ca40c11 | |||
| 484ac77ce7 | |||
| d0e508b237 | |||
| 70cc65227a | |||
| 213d6843e7 | |||
| f8765add85 | |||
| 168a21e459 | |||
| 7902b35393 | |||
| 28e25b4464 | |||
| 8afb925e17 | |||
| 338e3c9adb | |||
| 0ecae4c7d6 | |||
| 2e43fdeb04 | |||
| cc4b40b5d8 | |||
| 63c0269489 | |||
| 7e68c0e37f | |||
| c84c347dfd | |||
| 37296f758f | |||
| 49fbc0fdfa | |||
| 2e2860396e | |||
| 54cd6ba0da | |||
| 4001cd0e5d | |||
| f5dd28089c | |||
| eed8240487 | |||
| fc3d5c444f | |||
| e9b4534df9 | |||
| 949f6ae19f | |||
| 58f24a6d5a | |||
| 4061662c4b | |||
| 42b282c1f2 | |||
| 457845758c | |||
| 0aee15e848 | |||
| b372eaea4f | |||
| 7380998e8e | |||
| 4b440b7e5b | |||
| 5949fad9b1 | |||
| d054d04ab9 | |||
| 946939ba94 |
@@ -8,7 +8,7 @@ APP_URL=http://localhost
|
||||
APP_LOCALE=pt_BR
|
||||
APP_FALLBACK_LOCALE=pt_BR
|
||||
APP_FAKER_LOCALE=pt_BR
|
||||
APP_TIMEZONE=America/Fortaleza
|
||||
APP_TIMEZONE=America/Sao_Paulo
|
||||
|
||||
# Freeze application clock outside production (visual regression / deterministic seeds).
|
||||
# Example: APP_FROZEN_NOW=2026-03-15T12:00:00-03:00
|
||||
|
||||
19
.gitea/PULL_REQUEST_TEMPLATE.md
Normal file
19
.gitea/PULL_REQUEST_TEMPLATE.md
Normal file
@@ -0,0 +1,19 @@
|
||||
## WHAT
|
||||
|
||||
<!-- What changed? -->
|
||||
|
||||
## WHY
|
||||
|
||||
<!-- Why this change? -->
|
||||
|
||||
## HOW
|
||||
|
||||
<!-- How was it implemented? Include verification. -->
|
||||
|
||||
## Linear Issue
|
||||
|
||||
<!-- Required. Identifier + URL, e.g. MAN-133 https://linear.app/maneco-workspace/issue/MAN-133 -->
|
||||
|
||||
## Comments
|
||||
|
||||
<!-- Notes for reviewers. Screenshots, follow-ups, out of scope. -->
|
||||
@@ -6,7 +6,7 @@ on:
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||
group: ci-${{ gitea.workflow }}-${{ gitea.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
@@ -14,11 +14,13 @@ env:
|
||||
APP_KEY: base64:NXm/6jIyFcDGHoMKGc5QZuSaq0dRZFYPg1Isuy1fNvE=
|
||||
APP_LOCALE: pt_BR
|
||||
APP_FALLBACK_LOCALE: pt_BR
|
||||
APP_TIMEZONE: America/Fortaleza
|
||||
APP_TIMEZONE: America/Sao_Paulo
|
||||
BCRYPT_ROUNDS: 4
|
||||
CACHE_STORE: database
|
||||
DB_CONNECTION: pgsql
|
||||
DB_HOST: 127.0.0.1
|
||||
# Service hostname on the per-job network (act_runner with empty
|
||||
# container.network). Do not publish host :5432/:8000 — parallel jobs collide.
|
||||
DB_HOST: postgres
|
||||
DB_PORT: 5432
|
||||
DB_DATABASE: amare_test
|
||||
DB_USERNAME: amare
|
||||
@@ -40,21 +42,39 @@ jobs:
|
||||
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd
|
||||
coverage: none
|
||||
|
||||
- uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.composer/cache/files
|
||||
key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }}
|
||||
restore-keys: composer-${{ runner.os }}-
|
||||
|
||||
- run: composer validate --strict
|
||||
- run: composer install --no-interaction --prefer-dist
|
||||
- run: composer pint:check
|
||||
- run: composer phpstan
|
||||
- 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:
|
||||
name: unit
|
||||
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:
|
||||
- uses: actions/checkout@v5
|
||||
- run: cp .env.example .env
|
||||
@@ -63,18 +83,20 @@ jobs:
|
||||
with:
|
||||
php-version: "8.4"
|
||||
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd
|
||||
coverage: none
|
||||
|
||||
- uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.composer/cache/files
|
||||
key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }}
|
||||
restore-keys: composer-${{ runner.os }}-
|
||||
coverage: pcov
|
||||
|
||||
- run: composer install --no-interaction --prefer-dist
|
||||
- run: npm ci
|
||||
- run: npm run build
|
||||
- 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:
|
||||
name: feature
|
||||
@@ -86,8 +108,6 @@ jobs:
|
||||
POSTGRES_DB: amare_test
|
||||
POSTGRES_USER: amare
|
||||
POSTGRES_PASSWORD: secret
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U amare -d amare_test"
|
||||
--health-interval 5s
|
||||
@@ -103,18 +123,6 @@ jobs:
|
||||
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd
|
||||
coverage: none
|
||||
|
||||
- uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.composer/cache/files
|
||||
key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }}
|
||||
restore-keys: composer-${{ runner.os }}-
|
||||
|
||||
- uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
|
||||
restore-keys: npm-${{ runner.os }}-
|
||||
|
||||
- run: composer install --no-interaction --prefer-dist
|
||||
- run: npm ci
|
||||
- run: npm run build
|
||||
@@ -131,8 +139,6 @@ jobs:
|
||||
POSTGRES_DB: amare_test
|
||||
POSTGRES_USER: amare
|
||||
POSTGRES_PASSWORD: secret
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U amare -d amare_test"
|
||||
--health-interval 5s
|
||||
@@ -148,43 +154,40 @@ jobs:
|
||||
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd
|
||||
coverage: none
|
||||
|
||||
- uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.composer/cache/files
|
||||
key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }}
|
||||
restore-keys: composer-${{ runner.os }}-
|
||||
|
||||
- uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
|
||||
restore-keys: npm-${{ runner.os }}-
|
||||
|
||||
- run: composer install --no-interaction --prefer-dist
|
||||
- run: npm ci
|
||||
- run: npm run build
|
||||
- run: npx playwright install chromium --with-deps
|
||||
- run: php artisan migrate --force
|
||||
- run: php artisan db:seed --class=VisualContentSeeder --force
|
||||
- run: php artisan storage:link
|
||||
|
||||
- name: Build application image
|
||||
run: docker build -t amare-app:ci .
|
||||
|
||||
- name: Run browser tests against FrankenPHP container
|
||||
env:
|
||||
APP_FROZEN_NOW: "2026-03-15T12:00:00-03:00"
|
||||
run: |
|
||||
docker run -d --name amare-web \
|
||||
# Join the per-job network (act_runner creates one when
|
||||
# container.network is empty). No host -p: parallel jobs would
|
||||
# collide on :8000/:5432; DNS names work on the job network.
|
||||
# Container --name is global on the shared docker.sock host —
|
||||
# include run id or leftovers from cancelled jobs Conflict.
|
||||
JOB_CID="$(hostname)"
|
||||
JOB_NET="$(docker inspect -f '{{range $k, $_ := .NetworkSettings.Networks}}{{println $k}}{{end}}' "$JOB_CID" | head -n1)"
|
||||
test -n "$JOB_NET"
|
||||
WEB_NAME="amare-web-${GITHUB_RUN_ID:-$$}"
|
||||
docker rm -f "$WEB_NAME" 2>/dev/null || true
|
||||
|
||||
docker run -d --name "$WEB_NAME" \
|
||||
--network "$JOB_NET" \
|
||||
--network-alias amare-web \
|
||||
-e APP_ENV=testing \
|
||||
-e APP_KEY="${APP_KEY}" \
|
||||
-e APP_URL=http://127.0.0.1:8000 \
|
||||
-e APP_URL=http://amare-web:8000 \
|
||||
-e APP_LOCALE=pt_BR \
|
||||
-e APP_FALLBACK_LOCALE=pt_BR \
|
||||
-e APP_TIMEZONE=America/Fortaleza \
|
||||
-e APP_FROZEN_NOW="${APP_FROZEN_NOW}" \
|
||||
-e APP_TIMEZONE=America/Sao_Paulo \
|
||||
-e DB_CONNECTION=pgsql \
|
||||
-e DB_HOST=host.docker.internal \
|
||||
-e DB_HOST=postgres \
|
||||
-e DB_PORT=5432 \
|
||||
-e DB_DATABASE=amare_test \
|
||||
-e DB_USERNAME=amare \
|
||||
@@ -192,29 +195,29 @@ jobs:
|
||||
-e SESSION_DRIVER=database \
|
||||
-e CACHE_STORE=database \
|
||||
-e QUEUE_CONNECTION=database \
|
||||
--add-host=host.docker.internal:host-gateway \
|
||||
-v "${GITHUB_WORKSPACE}/storage/app/public:/app/storage/app/public" \
|
||||
-p 8000:8000 \
|
||||
amare-app:ci
|
||||
|
||||
cleanup() { docker rm -f "$WEB_NAME" >/dev/null 2>&1 || true; }
|
||||
trap cleanup EXIT
|
||||
|
||||
for i in $(seq 1 30); do
|
||||
if curl -fsS http://127.0.0.1:8000/up; then
|
||||
if curl -fsS http://amare-web:8000/up; then
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
curl -fsS http://127.0.0.1:8000/up
|
||||
./vendor/bin/pest --testsuite=Browser
|
||||
curl -fsS http://amare-web:8000/up
|
||||
APP_URL=http://amare-web:8000 ./vendor/bin/pest --testsuite=Browser
|
||||
|
||||
- name: Collect failure diagnostics
|
||||
if: failure()
|
||||
run: |
|
||||
mkdir -p artifacts/browser
|
||||
docker logs amare-web > artifacts/browser/container.log 2>&1 || true
|
||||
WEB_NAME="amare-web-${GITHUB_RUN_ID:-$$}"
|
||||
docker logs "$WEB_NAME" > artifacts/browser/container.log 2>&1 || true
|
||||
cp -R storage/logs artifacts/browser/app-logs 2>/dev/null || true
|
||||
cp -R 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
|
||||
if: failure()
|
||||
@@ -235,10 +238,21 @@ jobs:
|
||||
|
||||
- name: Verify container healthcheck and storage link
|
||||
run: |
|
||||
docker run -d --name amare-health \
|
||||
# Same per-job network as the step container — no host :8000
|
||||
# publish (collides when capacity > 1). Unique --name: docker.sock
|
||||
# is shared across jobs; leftovers from cancelled runs Conflict.
|
||||
JOB_CID="$(hostname)"
|
||||
JOB_NET="$(docker inspect -f '{{range $k, $_ := .NetworkSettings.Networks}}{{println $k}}{{end}}' "$JOB_CID" | head -n1)"
|
||||
test -n "$JOB_NET"
|
||||
HEALTH_NAME="amare-health-${GITHUB_RUN_ID:-$$}"
|
||||
docker rm -f "$HEALTH_NAME" 2>/dev/null || true
|
||||
|
||||
docker run -d --name "$HEALTH_NAME" \
|
||||
--network "$JOB_NET" \
|
||||
--network-alias amare-health \
|
||||
-e APP_ENV=production \
|
||||
-e APP_KEY="${{ env.APP_KEY }}" \
|
||||
-e APP_URL=http://127.0.0.1:8000 \
|
||||
-e APP_URL=http://amare-health:8000 \
|
||||
-e APP_DEBUG=false \
|
||||
-e DB_CONNECTION=pgsql \
|
||||
-e DB_HOST=127.0.0.1 \
|
||||
@@ -246,16 +260,18 @@ jobs:
|
||||
-e DB_DATABASE=amare \
|
||||
-e DB_USERNAME=amare \
|
||||
-e DB_PASSWORD=secret \
|
||||
-p 8000:8000 \
|
||||
amare-app:ci
|
||||
|
||||
cleanup() { docker rm -f "$HEALTH_NAME" >/dev/null 2>&1 || true; }
|
||||
trap cleanup EXIT
|
||||
|
||||
for i in $(seq 1 30); do
|
||||
if curl -fsS http://127.0.0.1:8000/up; then
|
||||
docker exec amare-health test -L /app/public/storage
|
||||
if curl -fsS http://amare-health:8000/up; then
|
||||
docker exec "$HEALTH_NAME" test -L /app/public/storage
|
||||
exit 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
docker logs amare-health
|
||||
docker logs "$HEALTH_NAME"
|
||||
exit 1
|
||||
@@ -1,10 +1,16 @@
|
||||
name: Deploy staging
|
||||
|
||||
# Requires Gitea >= 1.25 for workflow_run (1.24.x has no trigger match).
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: [CI]
|
||||
types: [completed]
|
||||
branches: [main]
|
||||
workflows:
|
||||
- CI
|
||||
- ci.yml
|
||||
types:
|
||||
- completed
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -15,27 +21,28 @@ concurrency:
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
REGISTRY: git.hellomanoel.com
|
||||
IMAGE_NAME: ${{ gitea.repository }}
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: publish-and-deploy-staging
|
||||
if: >-
|
||||
github.event.workflow_run.conclusion == 'success' &&
|
||||
github.event.workflow_run.event == 'push' &&
|
||||
github.event.workflow_run.head_branch == 'main'
|
||||
gitea.event_name == 'workflow_dispatch' ||
|
||||
(gitea.event.workflow_run.conclusion == 'success' &&
|
||||
gitea.event.workflow_run.event == 'push' &&
|
||||
gitea.event.workflow_run.head_branch == 'main')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout deployed SHA
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.workflow_run.head_sha }}
|
||||
ref: ${{ gitea.event.workflow_run.head_sha || gitea.sha }}
|
||||
|
||||
- name: Set image metadata
|
||||
id: meta
|
||||
run: |
|
||||
SHA="${{ github.event.workflow_run.head_sha }}"
|
||||
SHA="${{ gitea.event.workflow_run.head_sha || gitea.sha }}"
|
||||
SHORT_SHA="${SHA:0:7}"
|
||||
IMAGE="${REGISTRY}/${IMAGE_NAME}"
|
||||
IMAGE="$(echo "$IMAGE" | tr '[:upper:]' '[:lower:]')"
|
||||
@@ -43,12 +50,14 @@ jobs:
|
||||
echo "short_sha=${SHORT_SHA}" >> "$GITHUB_OUTPUT"
|
||||
echo "image=${IMAGE}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Log in to GHCR
|
||||
- name: Log in to Gitea registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Gitea's GITEA_TOKEN cannot push OCI packages (gitea#23642); a PAT
|
||||
# with read:package/write:package scopes is required instead.
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.REGISTRY_PAT }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
@@ -61,8 +70,6 @@ jobs:
|
||||
tags: |
|
||||
${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.sha }}
|
||||
${{ steps.meta.outputs.image }}:staging
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Deploy staging on Dokploy
|
||||
env:
|
||||
@@ -4,7 +4,7 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
sha:
|
||||
description: Full git SHA already published to GHCR (same digest used by staging)
|
||||
description: Full git SHA already published to the Gitea registry (same digest used by staging)
|
||||
required: true
|
||||
type: string
|
||||
confirm:
|
||||
@@ -21,8 +21,8 @@ concurrency:
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
REGISTRY: git.hellomanoel.com
|
||||
IMAGE_NAME: ${{ gitea.repository }}
|
||||
|
||||
jobs:
|
||||
promote:
|
||||
@@ -50,12 +50,14 @@ jobs:
|
||||
echo "short_sha=${SHORT_SHA}" >> "$GITHUB_OUTPUT"
|
||||
echo "image=${IMAGE}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Log in to GHCR
|
||||
- name: Log in to Gitea registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Gitea's GITEA_TOKEN cannot push OCI packages (gitea#23642); a PAT
|
||||
# with read:package/write:package scopes is required instead.
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.REGISTRY_PAT }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
19
.github/pull_request_template.md
vendored
Normal file
19
.github/pull_request_template.md
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
## WHAT
|
||||
|
||||
<!-- What changed? -->
|
||||
|
||||
## WHY
|
||||
|
||||
<!-- Why this change? -->
|
||||
|
||||
## HOW
|
||||
|
||||
<!-- How was it implemented? Include verification. -->
|
||||
|
||||
## Linear Issue
|
||||
|
||||
<!-- Required. Identifier + URL, e.g. MAN-133 https://linear.app/maneco-workspace/issue/MAN-133 -->
|
||||
|
||||
## Comments
|
||||
|
||||
<!-- Notes for reviewers. Screenshots, follow-ups, out of scope. -->
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -18,7 +18,6 @@
|
||||
/public/fonts-manifest.dev.json
|
||||
/public/hot
|
||||
/public/storage
|
||||
/tests/Browser/Screenshots
|
||||
/storage/*.key
|
||||
/storage/pail
|
||||
/vendor
|
||||
@@ -27,3 +26,6 @@ Homestead.json
|
||||
Homestead.yaml
|
||||
Thumbs.db
|
||||
.worktrees/
|
||||
|
||||
# Pest Browser visual diffs (local/CI artifacts)
|
||||
/tests/Browser/Screenshots/
|
||||
|
||||
@@ -24,7 +24,7 @@ try {
|
||||
new PDO($dsn, $config["DB_USERNAME"], $config["DB_PASSWORD"], [PDO::ATTR_TIMEOUT => 3]);
|
||||
} catch (PDOException $e) {
|
||||
fwrite(STDERR, "\033[31mPostgreSQL is unreachable on {$config["DB_HOST"]}:{$config["DB_PORT"]} (db: {$config["DB_DATABASE"]}).\033[0m\n");
|
||||
fwrite(STDERR, "Start it with: docker compose up -d\n");
|
||||
fwrite(STDERR, "Start it with: docker compose up -d postgres\n");
|
||||
fwrite(STDERR, "Then retry the push.\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
51
AGENTS.md
51
AGENTS.md
@@ -7,37 +7,76 @@ This is a Laravel 13 application for an event-planning consultancy. Application
|
||||
## Build, Test, and Development Commands
|
||||
|
||||
- `composer setup` installs PHP and npm dependencies, creates `.env`, migrates, and builds assets.
|
||||
- `docker compose up -d` starts the local PostgreSQL service.
|
||||
- `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.
|
||||
- `composer dev` runs Laravel, the queue listener, logs, and Vite together.
|
||||
- `npm run build` creates the production frontend bundle.
|
||||
- `composer quality` runs formatting checks, PHPStan level 5, dependency audit, and every test suite.
|
||||
- `composer quality` runs formatting checks, PHPStan level 5, PHP + npm dependency audits, and every test suite.
|
||||
- `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`.
|
||||
|
||||
## Worktrees
|
||||
|
||||
Always work in a git worktree created from the `main` ref — never modify `main` directly and never commit from the primary working tree. Create a dedicated worktree per feature/branch with `git worktree add -b <branch> <path> main`. On finishing work, create a PR, watch CI until green, then merge it. Clean up the worktree with `git worktree remove` after merge.
|
||||
Always work in a git worktree created from the `main` ref — never modify `main` directly and never commit from the primary working tree. Create a dedicated worktree per feature/branch with `git worktree add -b <branch> <path> main`. Include the Linear identifier in the branch name (e.g. `docs/man-132-…`). When the SDLC gate below is green, commit and open the PR without asking. Watch CI until green, then merge. Clean up the worktree with `git worktree remove` after merge.
|
||||
|
||||
## Agent SDLC (do not ask)
|
||||
|
||||
Never ask whether to commit or open a PR. After each complete slice of work, ship it:
|
||||
|
||||
1. **Work is done** in a Linear-linked worktree.
|
||||
2. **Tests are written or updated** for the changed layer. Skip new tests only when the change has no runtime impact (docs, templates, static config).
|
||||
3. **Pre-commit passes** (`composer pint:check` and `composer phpstan`). Do not `--no-verify`. If the hook fails, fix and rerun.
|
||||
4. **Commit** with Conventional Commits. Cite the Linear identifier.
|
||||
5. **Push and open the Gitea PR** (`tea pulls create`) using `.gitea/PULL_REQUEST_TEMPLATE.md` (WHAT / WHY / HOW / Linear Issue / Comments). Attach the PR URL on the Linear issue via `save_issue` `links`. GitHub is the legacy mirror only.
|
||||
|
||||
Do not wait for "pode commitar?" or "abre o PR?".
|
||||
|
||||
## Git Hooks (husky)
|
||||
|
||||
Hooks live in `.husky/` and auto-install on any plain `npm install` via the `prepare` script. Note `composer setup` runs `npm install --ignore-scripts`, which skips hook installation — after setup, run `npm install` once (or `npx husky`) to activate hooks.
|
||||
|
||||
- `pre-commit`: runs `composer pint:check` and `composer phpstan`.
|
||||
- `pre-push`: gates on the `amare_test` database (settings parsed from `phpunit.xml`), blocks the push with a `docker compose up -d` hint when Postgres is unreachable, then runs `composer test:unit` and `composer test:feature`. Browser tests are CI-only (FrankenPHP container).
|
||||
- `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
|
||||
|
||||
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
|
||||
|
||||
Tests use Pest 4; browser coverage uses Pest Browser/Playwright. Name files by behavior, ending in `Test.php`, and add tests in the suite matching the changed layer. Feature tests use `RefreshDatabase`. Add architecture coverage for dependency-boundary changes. No numeric coverage threshold is enforced, but changed behavior must have regression coverage.
|
||||
Tests use Pest 4; browser coverage uses Pest Browser/Playwright. Tests are verification, not a design driver — write them to cover behavior you've already implemented, matching the layer you changed. Name files by behavior, ending in `Test.php`, and add tests in the suite matching the changed layer. Feature tests use `RefreshDatabase`. Add architecture coverage for dependency-boundary changes. No numeric coverage threshold is enforced; add regression tests where a bug was fixed or behavior is non-obvious, without making tests a front-loaded design ceremony.
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
|
||||
History follows Conventional Commit-style subjects, for example `feat: Fase 0 — Fundação`. Use `<type>: <imperative summary>` (`feat`, `fix`, `docs`, `test`, `chore`) and keep commits focused. Pull requests should explain scope, link the relevant issue or OpenSpec requirement, list verification commands, and include screenshots for UI changes. Ensure all CI jobs pass.
|
||||
History follows Conventional Commit-style subjects, for example `feat: Fase 0 — Fundação`. Use `<type>: <imperative summary>` (`feat`, `fix`, `docs`, `test`, `chore`) and keep commits focused. Fill the PR template (WHAT / WHY / HOW / Linear Issue / Comments). **Linear Issue is required** (identifier + URL). An OpenSpec change is extra context, not a substitute. Include verification commands and screenshots for UI changes. Ensure all CI jobs pass. After pre-commit is green, commit and open the PR — do not ask.
|
||||
|
||||
## Security & Configuration
|
||||
|
||||
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.
|
||||
|
||||
77
CLAUDE.md
Normal file
77
CLAUDE.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# 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.
|
||||
@@ -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`.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
**The Editorial Rhythm Rule.** Seções alternam imagem, texto, densidade e silêncio; repetição de grades idênticas por toda página é proibida.
|
||||
|
||||
|
||||
@@ -23,6 +23,10 @@ RUN npm ci
|
||||
COPY vite.config.js ./
|
||||
COPY resources ./resources
|
||||
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
|
||||
|
||||
FROM dunglas/frankenphp:1-php${PHP_VERSION}-bookworm AS runtime
|
||||
|
||||
28
PRODUCT.md
28
PRODUCT.md
@@ -14,36 +14,42 @@ web
|
||||
|
||||
## Product Purpose
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
Sucesso significa:
|
||||
Sucesso no lançamento significa:
|
||||
|
||||
- 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 conquistados virarem eventos sem redigitação;
|
||||
- pendências reais do dia aparecerem com clareza;
|
||||
- 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.
|
||||
- tarefas, fornecedores, orçamento, pagamentos manuais e documentos permanecerem organizados por evento.
|
||||
|
||||
## Positioning
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## Operating Context
|
||||
|
||||
- Atuação em São Paulo, capital.
|
||||
- Serviços: casamentos, eventos corporativos e celebrações.
|
||||
- Operação realizada por assessora principal e equipe pequena.
|
||||
- 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 de lançamento: descoberta no site, briefing por e-mail e contato comercial fora da plataforma.
|
||||
- Jornada futura prevista: qualificação do lead, pipeline comercial, conversão em evento, checklist, fornecedores, orçamento, pagamentos manuais e documentos.
|
||||
- Interface pública em português do Brasil; dados financeiros em reais.
|
||||
- Área interna acessível por administradora e assistentes com papéis simples.
|
||||
|
||||
## Capabilities and Constraints
|
||||
|
||||
- Produto single-tenant para a operação da Amare; não é SaaS para várias assessorias.
|
||||
- Duas superfícies integradas: site público responsivo com CMS e área interna operacional em Filament.
|
||||
- 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.
|
||||
- Implementação atual cobre fundação, site público e CMS; módulos operacionais posteriores ainda fazem parte do escopo planejado.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
@@ -58,7 +64,7 @@ Site público premium e operação interna formam um fluxo único. A mesma plata
|
||||
## Evidence on Hand
|
||||
|
||||
- `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 visuais e conteúdo demonstrativo.
|
||||
- Código atual contém site público, CMS, estrutura de acessibilidade, testes browser 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.
|
||||
- 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.
|
||||
|
||||
@@ -68,7 +74,7 @@ Site público premium e operação interna formam um fluxo único. A mesma plata
|
||||
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.
|
||||
4. **Confiança sustentada por fatos:** usar somente conteúdo, resultados e provas reais e autorizados.
|
||||
5. **Qualidade verificável:** tratar acessibilidade, desempenho, testes visuais e jornadas críticas como critérios de entrega.
|
||||
5. **Qualidade verificável:** tratar acessibilidade, desempenho, motion e jornadas críticas como critérios de entrega.
|
||||
|
||||
## Accessibility & Inclusion
|
||||
|
||||
|
||||
27
README.md
27
README.md
@@ -4,7 +4,7 @@ Aplicação Laravel 13 para assessoria de eventos (Fase 0 — Fundação).
|
||||
|
||||
## Requisitos
|
||||
|
||||
- PHP 8.5+ com extensões `pdo_pgsql`, `intl`, `mbstring`, `zip`, `sodium`
|
||||
- PHP 8.4 com extensões `pdo_pgsql`, `intl`, `mbstring`, `zip`, `sodium` (canônico do `Dockerfile` e do CI; `composer.json` aceita `^8.3`)
|
||||
- Composer 2.x
|
||||
- Node.js 22+ e npm
|
||||
- Docker e Docker Compose
|
||||
@@ -18,10 +18,10 @@ cp .env.example .env
|
||||
php artisan key:generate
|
||||
```
|
||||
|
||||
2. Suba o PostgreSQL:
|
||||
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`):
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
docker compose up -d postgres
|
||||
```
|
||||
|
||||
3. Instale dependências e rode migrations:
|
||||
@@ -49,12 +49,24 @@ Painel interno: `/admin`
|
||||
|
||||
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ável | Valor local |
|
||||
|---|---|
|
||||
| `APP_LOCALE` | `pt_BR` |
|
||||
| `APP_TIMEZONE` | `America/Fortaleza` |
|
||||
| `APP_TIMEZONE` | `America/Sao_Paulo` |
|
||||
| `DB_CONNECTION` | `pgsql` |
|
||||
| `SESSION_DRIVER` | `database` |
|
||||
| `CACHE_STORE` | `database` |
|
||||
@@ -63,7 +75,7 @@ Healthcheck: `GET /up`
|
||||
## Comandos de qualidade
|
||||
|
||||
```bash
|
||||
composer quality # Pint + PHPStan + audit + testes
|
||||
composer quality # Pint + PHPStan + audit (composer + npm) + testes
|
||||
composer test:unit # Unit + Architecture
|
||||
composer test:feature # Feature + Livewire + Filament
|
||||
composer test:browser # E2E browser
|
||||
@@ -125,10 +137,11 @@ Após `php artisan db:seed`:
|
||||
- [SPEC.md](SPEC.md) — especificação do produto
|
||||
- [docs/adr/](docs/adr/) — ADRs aceitas
|
||||
- [docs/conventions/php-strict-types.md](docs/conventions/php-strict-types.md) — convenção de strict types
|
||||
- [docs/deployment/dokploy.md](docs/deployment/dokploy.md) — deploy staging/produção no Dokploy + GHCR
|
||||
- [docs/operations/atualizacao-de-conteudo.md](docs/operations/atualizacao-de-conteudo.md) — runbook de atualização de conteúdo do site pelo painel admin
|
||||
- [docs/deployment/dokploy.md](docs/deployment/dokploy.md) — deploy staging/produção no Dokploy + registry Gitea
|
||||
|
||||
## Deploy (Dokploy)
|
||||
|
||||
Staging publica automaticamente após CI verde em `main` (imagem GHCR por SHA + alias `:staging`). Produção promove a **mesma digest** com workflow manual `Promote production` (sem rebuild).
|
||||
Staging publica automaticamente após CI verde em `main` (imagem no registry Gitea por SHA + alias `:staging`). Produção promove a **mesma digest** com workflow manual `Promote production` (sem rebuild).
|
||||
|
||||
Ver runbook completo: [docs/deployment/dokploy.md](docs/deployment/dokploy.md).
|
||||
|
||||
177
SPEC.md
177
SPEC.md
@@ -20,10 +20,10 @@
|
||||
| Princípio principal | YAGNI — implementar somente o necessário para validar o produto |
|
||||
| Arquitetura | Monólito modular Laravel |
|
||||
| Área interna | Filament |
|
||||
| Área pública | Livewire + Blade |
|
||||
| Área pública | Blade + JS vanilla progressivo (Livewire é dependência do Filament, não usada no site público — ver ADR-015) |
|
||||
| Servidor de aplicação | FrankenPHP em modo regular |
|
||||
| Banco de dados | PostgreSQL |
|
||||
| Testes | Pest, Pest Browser/Playwright e testes visuais |
|
||||
| Testes | Pest e Pest Browser/Playwright |
|
||||
|
||||
### 0.1 Vocabulário normativo
|
||||
|
||||
@@ -80,7 +80,6 @@ O agente:
|
||||
- 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 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
|
||||
|
||||
@@ -135,7 +134,6 @@ Uma assessora adotará o sistema quando ele:
|
||||
| Operação | Tarefas vencidas sem responsável | `0` |
|
||||
| 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 |
|
||||
| Visual | Snapshots aprovados | 100% |
|
||||
| Confiabilidade | Erros não tratados | Alerta imediato e tendência decrescente |
|
||||
|
||||
---
|
||||
@@ -194,11 +192,24 @@ Não instalar sistema de permissões granular no MVP.
|
||||
|
||||
## 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
|
||||
|
||||
No recorte do lançamento (Fases 0–1):
|
||||
|
||||
- site público;
|
||||
- CMS interno do site;
|
||||
- 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 2–5, ver ADR-016):
|
||||
|
||||
- CRM de leads;
|
||||
- conversão de lead em evento;
|
||||
- cadastro e visão consolidada de eventos;
|
||||
@@ -208,12 +219,10 @@ Não instalar sistema de permissões granular no MVP.
|
||||
- pagamentos inseridos manualmente;
|
||||
- documentos vinculados a leads e eventos;
|
||||
- dashboard orientado a exceções;
|
||||
- usuários internos e papéis simples;
|
||||
- notificações internas e por e-mail para novos leads;
|
||||
- auditoria de ações críticas;
|
||||
- SEO básico;
|
||||
- acessibilidade e testes visuais;
|
||||
- CI/CD e deploy em contêiner com FrankenPHP.
|
||||
- auditoria de ações críticas.
|
||||
|
||||
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.
|
||||
|
||||
### 4.2 Fora do MVP
|
||||
|
||||
@@ -315,8 +324,8 @@ Administração
|
||||
|
||||
- Dashboard: `Filament Page` customizada com widgets orientados a exceção.
|
||||
- Detalhe do evento: página customizada do Resource com resumo operacional.
|
||||
- Briefing público: componente Livewire próprio.
|
||||
- Home: Blade/Livewire com componentes de design reutilizáveis.
|
||||
- Briefing público: Blade + Controller (`POST /contato`), ver §11.2.
|
||||
- Home: Blade com componentes de design reutilizáveis.
|
||||
|
||||
### 5.4 Decisões de UX YAGNI
|
||||
|
||||
@@ -1553,7 +1562,7 @@ Constraints de banco devem proteger:
|
||||
| Runtime | PHP com versão minor fixada no Docker |
|
||||
| Framework | Laravel 13 |
|
||||
| Admin | Filament 5 |
|
||||
| UI pública | Livewire 4 + Blade + Alpine + Tailwind |
|
||||
| UI pública | Blade + Tailwind + JS vanilla progressivo (sem framework reativo; Livewire 4 confinado ao Filament — ver ADR-015 e §22) |
|
||||
| Banco | PostgreSQL |
|
||||
| Servidor | FrankenPHP + Caddy |
|
||||
| Assets | Vite |
|
||||
@@ -1774,21 +1783,21 @@ Usar componentes/Widgets menores e testáveis.
|
||||
|
||||
---
|
||||
|
||||
## 11. Livewire e site público
|
||||
## 11. Site público e interatividade
|
||||
|
||||
### 11.1 Componentes sugeridos
|
||||
### 11.1 Estado atual e componentes candidatos
|
||||
|
||||
- `ContactBriefingForm`;
|
||||
- `FeaturedPortfolioCases` se houver necessidade de consulta dinâmica;
|
||||
- `PublishedServices` se houver necessidade de consulta dinâmica.
|
||||
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`.
|
||||
|
||||
Não transformar todas as seções estáticas em componentes Livewire. Usar Blade quando não houver estado ou interação.
|
||||
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 seções estáticas em componentes Livewire. Usar Blade quando não houver estado ou interação.
|
||||
|
||||
### 11.2 Formulário de briefing
|
||||
|
||||
> **Estado atual (Fases 0–1):** o formulário é implementado em Blade + Controller (`POST /contato`, `ContactBriefingRequest`), conforme WEB-05. Se a Fase 2 mantiver Blade + Controller, os requisitos abaixo valem para o formulário e seus testes independentemente da tecnologia; a criação de Lead segue para a Fase 2.
|
||||
> **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;
|
||||
- validar no servidor;
|
||||
@@ -1797,15 +1806,16 @@ O componente deve:
|
||||
- preservar acessibilidade;
|
||||
- limpar dados após sucesso;
|
||||
- evitar exposição de exceção;
|
||||
- suportar teste Livewire sem browser;
|
||||
- suportar teste de submissão sem navegador (feature test);
|
||||
- suportar jornada E2E em navegador real.
|
||||
|
||||
### 11.3 JavaScript
|
||||
|
||||
- usar Alpine apenas para interações pequenas;
|
||||
- manter o site público em JS vanilla progressivo;
|
||||
- não introduzir framework SPA;
|
||||
- não usar dependência JS quando CSS/HTML/Livewire resolverem;
|
||||
- toda interação crítica deve funcionar sem estado global complexo.
|
||||
- não usar dependência JS quando CSS e HTML resolverem;
|
||||
- toda interação crítica deve funcionar sem estado global complexo;
|
||||
- Alpine só entra junto com Livewire, se o gatilho de §22 disparar.
|
||||
|
||||
---
|
||||
|
||||
@@ -1889,7 +1899,7 @@ A pirâmide deve possuir:
|
||||
1. muitos testes unitários rápidos;
|
||||
2. testes feature/integration suficientes para Laravel, PostgreSQL, Livewire e Filament;
|
||||
3. poucos testes E2E cobrindo jornadas críticas;
|
||||
4. testes visuais determinísticos nas telas mais importantes.
|
||||
4. testes E2E, de acessibilidade, motion e smoke nas jornadas públicas mais importantes.
|
||||
|
||||
A distribuição é por intenção, não por percentual rígido.
|
||||
|
||||
@@ -1971,40 +1981,7 @@ Em falha, CI deve publicar:
|
||||
- logs do browser;
|
||||
- HTML report quando disponível.
|
||||
|
||||
### 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/Sao_Paulo`;
|
||||
- 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
|
||||
### 13.5 Testes de arquitetura
|
||||
|
||||
Criar regras Pest Architecture:
|
||||
|
||||
@@ -2021,14 +1998,14 @@ arch()
|
||||
|
||||
Adicionar regras conforme a estrutura real, sem tornar a suíte excessivamente frágil.
|
||||
|
||||
### 13.7 Cobertura
|
||||
### 13.6 Cobertura
|
||||
|
||||
- meta de 80% para `Domain` e `Application`;
|
||||
- não medir views, migrations, código gerado e framework;
|
||||
- cobertura não substitui critérios de aceite;
|
||||
- queda de cobertura em módulo crítico deve bloquear merge.
|
||||
|
||||
### 13.8 Acessibilidade automatizada
|
||||
### 13.7 Acessibilidade automatizada
|
||||
|
||||
Smoke/browser tests devem verificar:
|
||||
|
||||
@@ -2046,7 +2023,7 @@ Revisão manual mínima antes do lançamento:
|
||||
- zoom a 200%;
|
||||
- leitor de tela nos fluxos de briefing e login.
|
||||
|
||||
### 13.9 Comandos padronizados
|
||||
### 13.8 Comandos padronizados
|
||||
|
||||
O projeto deve expor scripts equivalentes:
|
||||
|
||||
@@ -2056,7 +2033,6 @@ composer test:feature
|
||||
composer test:browser
|
||||
composer test
|
||||
composer quality
|
||||
composer visual:update
|
||||
```
|
||||
|
||||
Composição esperada:
|
||||
@@ -2064,7 +2040,7 @@ Composição esperada:
|
||||
```text
|
||||
test:unit → Unit + Architecture
|
||||
test:feature → Feature + Livewire + Filament
|
||||
test:browser → E2E + visual + acessibilidade + smoke
|
||||
test:browser → E2E + acessibilidade + motion + smoke
|
||||
quality → Pint check + PHPStan/Larastan + audits + testes
|
||||
```
|
||||
|
||||
@@ -2079,7 +2055,7 @@ quality → Pint check + PHPStan/Larastan + audits + testes
|
||||
| `static` | Pint, PHPStan/Larastan, Composer validate, Composer e npm audit | Sim |
|
||||
| `unit` | Unitários, arquitetura e cobertura | Sim |
|
||||
| `feature` | PostgreSQL, migrations, Livewire, Filament e integração | Sim |
|
||||
| `browser` | Vite, FrankenPHP, E2E, smoke, acessibilidade e visual | Sim |
|
||||
| `browser` | Vite, FrankenPHP, E2E, smoke, acessibilidade e motion | Sim |
|
||||
| `container` | Build da imagem final e healthcheck | Sim |
|
||||
|
||||
### 14.2 Regras do pipeline
|
||||
@@ -2097,7 +2073,7 @@ quality → Pint check + PHPStan/Larastan + audits + testes
|
||||
### 14.3 Branches e ambientes
|
||||
|
||||
- PR: testes e preview opcional;
|
||||
- `main`: build imutável por SHA publicado no GHCR e deploy automático em staging via Dokploy;
|
||||
- `main`: build imutável por SHA publicado no registry Gitea (`git.hellomanoel.com`) e deploy automático em staging via Dokploy;
|
||||
- staging: Dokploy Compose executa migração, healthcheck `/up` e smoke pós-deploy (`/up`, `/`, `/admin/login`);
|
||||
- produção: promoção da mesma imagem aprovada, sem rebuild (retag do digest em `:production`);
|
||||
- produção requer aprovação humana explícita no MVP (`workflow_dispatch` com confirmação);
|
||||
@@ -2113,7 +2089,6 @@ Uma história só está concluída quando:
|
||||
- PHPStan/Larastan está verde;
|
||||
- unit e feature tests cobrem regras relevantes;
|
||||
- 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;
|
||||
- migration foi testada;
|
||||
- autorização foi verificada;
|
||||
@@ -2324,6 +2299,8 @@ Para visual e browser tests:
|
||||
|
||||
## 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.
|
||||
|
||||
### Fase 0 — Fundação
|
||||
@@ -2335,7 +2312,7 @@ O agente deve implementar na sequência, salvo instrução explícita.
|
||||
- [x] Filament instalado e autenticado;
|
||||
- [x] Livewire configurado;
|
||||
- [x] Tailwind/Vite;
|
||||
- [~] FrankenPHP e Docker Compose local (imagem pronta; serviço de aplicação local pendente);
|
||||
- [x] FrankenPHP e Docker Compose local;
|
||||
- [x] papéis admin/assistant;
|
||||
- [x] Pint;
|
||||
- [x] PHPStan/Larastan;
|
||||
@@ -2346,11 +2323,11 @@ O agente deve implementar na sequência, salvo instrução explícita.
|
||||
- [x] design tokens mínimos;
|
||||
- [x] healthcheck;
|
||||
- [x] seed de admin local.
|
||||
- [ ] verificação de e-mail e reset seguro (MustVerifyEmail);
|
||||
- [ ] npm audit no `composer quality` e no job `static`;
|
||||
- [ ] gate de cobertura `Domain`/`Application` ≥ 80%;
|
||||
- [ ] serviço de aplicação FrankenPHP no Compose local;
|
||||
- [ ] hello-world implantado em staging (critério de saída).
|
||||
- [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.
|
||||
|
||||
@@ -2369,12 +2346,11 @@ O agente deve implementar na sequência, salvo instrução explícita.
|
||||
- [x] privacidade;
|
||||
- [x] SEO;
|
||||
- [x] mídia otimizada;
|
||||
- [x] snapshots desktop/mobile;
|
||||
- [x] testes de acessibilidade.
|
||||
|
||||
**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).
|
||||
**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.
|
||||
|
||||
### Fase 2 — Leads
|
||||
### Fase 2 — Leads — ADIADA (ADR-016)
|
||||
|
||||
- [ ] migration/model/factory de Lead;
|
||||
- [ ] LeadActivity;
|
||||
@@ -2391,7 +2367,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.
|
||||
|
||||
### Fase 3 — Eventos e tarefas
|
||||
### Fase 3 — Eventos e tarefas — ADIADA (ADR-016)
|
||||
|
||||
- [ ] Event;
|
||||
- [ ] EventTask;
|
||||
@@ -2406,7 +2382,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.
|
||||
|
||||
### Fase 4 — Fornecedores e financeiro
|
||||
### Fase 4 — Fornecedores e financeiro — ADIADA (ADR-016)
|
||||
|
||||
- [ ] Vendor;
|
||||
- [ ] EventVendor;
|
||||
@@ -2422,7 +2398,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.
|
||||
|
||||
### Fase 5 — Documentos, hardening e lançamento
|
||||
### Fase 5 — Documentos, hardening e lançamento — ADIADA (ADR-016)
|
||||
|
||||
- [ ] documentos privados;
|
||||
- [ ] auditoria completa;
|
||||
@@ -2483,8 +2459,7 @@ Toda operação financeira deve:
|
||||
| Risco | Mitigação obrigatória |
|
||||
|---|---|
|
||||
| Filament concentrar domínio | Resources finos, Actions e Queries testáveis |
|
||||
| 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 |
|
||||
| Site parecer genérico | design tokens, conteúdo real, fotografia e revisão humana |
|
||||
| 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 |
|
||||
| Worker mode vazar estado | manter modo regular até benchmark e ADR |
|
||||
@@ -2500,10 +2475,10 @@ Toda operação financeira deve:
|
||||
| ADR | Decisão | Status |
|
||||
|---|---|---|
|
||||
| ADR-001 | Monólito modular Laravel, sem microserviços | Aceita |
|
||||
| ADR-002 | Filament para área interna e Livewire/Blade para área pública | Aceita |
|
||||
| ADR-002 | Filament para área interna (Livewire é dependência interna do Filament); site público em Blade — ver ADR-015 | Aceita |
|
||||
| ADR-003 | PostgreSQL como único banco transacional | Aceita |
|
||||
| ADR-004 | Pagamentos somente manuais | Aceita |
|
||||
| ADR-005 | Pest unifica unit, feature, browser e visual | Aceita |
|
||||
| ADR-005 | Pest unifica unit, feature e browser | Aceita |
|
||||
| ADR-006 | FrankenPHP regular mode; worker mode adiado | Aceita |
|
||||
| ADR-007 | Single-tenant; SaaS e portal do cliente adiados | Aceita |
|
||||
| ADR-008 | Database queue; Redis adiado | Aceita |
|
||||
@@ -2512,7 +2487,9 @@ Toda operação financeira deve:
|
||||
| ADR-011 | Cloudflare R2 (S3-compatible) como storage de objetos em produção | Aceita |
|
||||
| ADR-012 | E-mail transacional via Resend (mailer nativo Laravel) | Aceita |
|
||||
| ADR-013 | Design system Heritage Editorial para o site público | Aceita |
|
||||
| ADR-014 | Deploy via Dokploy Compose com imagem imutável por SHA no GHCR | Aceita |
|
||||
| ADR-014 | Deploy via Dokploy Compose com imagem imutável por SHA no registry Gitea | Aceita |
|
||||
| ADR-015 | Site público permanece Blade + JS vanilla; Livewire e Alpine ficam restritos ao Filament até o gatilho de §22. Emenda o texto da ADR-002 | Aceita |
|
||||
| ADR-016 | Lançamento de 31/08/2026 entrega apenas o site institucional (Fases 0–1); Fases 2–5 seguem especificadas e adiadas, sem data | Aceita |
|
||||
|
||||
---
|
||||
|
||||
@@ -2529,6 +2506,7 @@ Toda operação financeira deve:
|
||||
| API pública | existe consumidor real e contrato de integração |
|
||||
| Kanban | tabela de leads demonstra limitação frequente observada |
|
||||
| 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 |
|
||||
|
||||
---
|
||||
|
||||
@@ -2536,24 +2514,39 @@ Toda operação financeira deve:
|
||||
|
||||
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 0–1)
|
||||
|
||||
O lançamento está concluído somente quando:
|
||||
|
||||
- site público está publicado e visualmente aprovado;
|
||||
- assessora edita os conteúdos essenciais sem desenvolvedor;
|
||||
- briefing cria leads de forma segura;
|
||||
- briefing envia o pedido de proposta de forma segura, com proteção contra abuso e aceite de privacidade registrado;
|
||||
- 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 2–5, adiado)
|
||||
|
||||
Além de tudo em §23.1:
|
||||
|
||||
- pipeline e próxima ação funcionam;
|
||||
- briefing cria leads de forma segura e persistente;
|
||||
- lead é convertido uma única vez em evento;
|
||||
- checklist é criado automaticamente;
|
||||
- evento possui visão consolidada;
|
||||
- fornecedores podem ser cadastrados e vinculados;
|
||||
- orçamento e pagamentos manuais possuem totais consistentes;
|
||||
- dashboard mostra pendências do dia;
|
||||
- usuários e Policies estão corretos;
|
||||
- auditoria registra operações críticas;
|
||||
- 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.
|
||||
- jornadas E2E das fases correspondentes estão verdes.
|
||||
|
||||
---
|
||||
|
||||
|
||||
20
app/Application/Data/AboutContent.php
Normal file
20
app/Application/Data/AboutContent.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Application\Data;
|
||||
|
||||
use App\Models\PortfolioCase;
|
||||
use App\Models\SiteSetting;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
|
||||
final readonly class AboutContent
|
||||
{
|
||||
/**
|
||||
* @param Collection<int, PortfolioCase> $featuredCases
|
||||
*/
|
||||
public function __construct(
|
||||
public SiteSetting $settings,
|
||||
public Collection $featuredCases,
|
||||
) {}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ use App\Models\PortfolioCase;
|
||||
use App\Models\Service;
|
||||
use App\Models\SiteSetting;
|
||||
use App\Models\Testimonial;
|
||||
use App\Models\WeddingPackage;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
|
||||
final readonly class HomeContent
|
||||
@@ -16,11 +17,13 @@ final readonly class HomeContent
|
||||
* @param Collection<int, Service> $featuredServices
|
||||
* @param Collection<int, PortfolioCase> $featuredCases
|
||||
* @param Collection<int, Testimonial> $testimonials
|
||||
* @param Collection<int, WeddingPackage> $packages
|
||||
*/
|
||||
public function __construct(
|
||||
public SiteSetting $settings,
|
||||
public Collection $featuredServices,
|
||||
public Collection $featuredCases,
|
||||
public Collection $testimonials,
|
||||
public Collection $packages,
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace App\Application\Data;
|
||||
|
||||
use App\Models\PortfolioCase;
|
||||
use App\Models\SiteSetting;
|
||||
use App\Models\WeddingPackage;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
final readonly class PageMeta
|
||||
@@ -84,6 +85,43 @@ final readonly class PageMeta
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $jsonLd
|
||||
*/
|
||||
public static function forPackage(
|
||||
WeddingPackage $package,
|
||||
string $canonical,
|
||||
SiteSetting $settings,
|
||||
?array $jsonLd = null,
|
||||
): self {
|
||||
$title = filled($package->meta_title)
|
||||
? (string) $package->meta_title
|
||||
: (filled($package->title_line) ? (string) $package->title_line : (string) $package->name);
|
||||
|
||||
$description = filled($package->meta_description)
|
||||
? (string) $package->meta_description
|
||||
: (filled($package->summary) ? (string) $package->summary : self::defaultDescription($settings));
|
||||
|
||||
$ogImageUrl = filled($package->hero_image_path)
|
||||
? url(Storage::disk('public')->url((string) $package->hero_image_path))
|
||||
: self::defaultOgImageUrl($settings);
|
||||
|
||||
$ogImageAlt = filled($package->hero_image_alt)
|
||||
? (string) $package->hero_image_alt
|
||||
: $settings->default_og_image_alt;
|
||||
|
||||
return new self(
|
||||
title: $title,
|
||||
description: $description,
|
||||
canonical: $canonical,
|
||||
ogType: 'article',
|
||||
ogImageUrl: $ogImageUrl,
|
||||
ogImageAlt: $ogImageAlt,
|
||||
jsonLd: $jsonLd,
|
||||
siteName: (string) $settings->brand_name,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the metadata for branded error pages.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Application\Queries\Marketing;
|
||||
|
||||
use App\Models\WeddingPackage;
|
||||
|
||||
final class FindPublishedWeddingPackageBySlug
|
||||
{
|
||||
public function __invoke(string $slug): ?WeddingPackage
|
||||
{
|
||||
return WeddingPackage::query()
|
||||
->published()
|
||||
->where('slug', $slug)
|
||||
->first();
|
||||
}
|
||||
}
|
||||
25
app/Application/Queries/Marketing/GetAboutContent.php
Normal file
25
app/Application/Queries/Marketing/GetAboutContent.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Application\Queries\Marketing;
|
||||
|
||||
use App\Application\Data\AboutContent;
|
||||
use App\Models\PortfolioCase;
|
||||
use App\Models\SiteSetting;
|
||||
|
||||
final class GetAboutContent
|
||||
{
|
||||
public function __invoke(): AboutContent
|
||||
{
|
||||
return new AboutContent(
|
||||
settings: SiteSetting::instance(),
|
||||
featuredCases: PortfolioCase::query()
|
||||
->published()
|
||||
->where('is_featured', true)
|
||||
->orderBy('sort_order')
|
||||
->limit(6)
|
||||
->get(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ use App\Models\PortfolioCase;
|
||||
use App\Models\Service;
|
||||
use App\Models\SiteSetting;
|
||||
use App\Models\Testimonial;
|
||||
use App\Models\WeddingPackage;
|
||||
|
||||
final class GetHomeContent
|
||||
{
|
||||
@@ -31,6 +32,10 @@ final class GetHomeContent
|
||||
->published()
|
||||
->orderBy('sort_order')
|
||||
->get(),
|
||||
packages: WeddingPackage::query()
|
||||
->published()
|
||||
->orderBy('sort_order')
|
||||
->get(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,10 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Application\Queries\Marketing;
|
||||
|
||||
use App\Domain\Marketing\PortfolioVertical;
|
||||
use App\Models\PortfolioCase;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
|
||||
final class GetPublishedPortfolioCases
|
||||
@@ -12,12 +15,37 @@ final class GetPublishedPortfolioCases
|
||||
/**
|
||||
* @return Collection<int, PortfolioCase>
|
||||
*/
|
||||
public function __invoke(): Collection
|
||||
public function __invoke(?PortfolioVertical $vertical = null): Collection
|
||||
{
|
||||
return PortfolioCase::query()
|
||||
return $this->baseQuery($vertical)->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return LengthAwarePaginator<int, PortfolioCase>
|
||||
*/
|
||||
public function paginate(?PortfolioVertical $vertical = null, int $perPage = 9): LengthAwarePaginator
|
||||
{
|
||||
return $this->baseQuery($vertical)->paginate($perPage)->withQueryString();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Builder<PortfolioCase>
|
||||
*/
|
||||
private function baseQuery(?PortfolioVertical $vertical): Builder
|
||||
{
|
||||
$query = PortfolioCase::query()
|
||||
->published()
|
||||
->with(['images'])
|
||||
->orderBy('sort_order')
|
||||
->get();
|
||||
->orderBy('sort_order');
|
||||
|
||||
if ($vertical !== null) {
|
||||
$query->where(function (Builder $builder) use ($vertical): void {
|
||||
foreach ($vertical->eventTypePatterns() as $pattern) {
|
||||
$builder->orWhere('event_type', 'ilike', $pattern);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Application\Queries\Marketing;
|
||||
|
||||
use App\Models\PortfolioCase;
|
||||
use App\Models\WeddingPackage;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
final class GetSitemapEntries
|
||||
@@ -38,6 +39,21 @@ final class GetSitemapEntries
|
||||
];
|
||||
}
|
||||
|
||||
$packages = WeddingPackage::query()
|
||||
->published()
|
||||
->orderBy('sort_order')
|
||||
->get(['slug', 'updated_at']);
|
||||
|
||||
foreach ($packages as $package) {
|
||||
/** @var Carbon|null $updatedAt */
|
||||
$updatedAt = $package->updated_at;
|
||||
|
||||
$entries[] = [
|
||||
'loc' => route('packages.show', $package->slug),
|
||||
'lastmod' => $updatedAt?->toAtomString(),
|
||||
];
|
||||
}
|
||||
|
||||
return $entries;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ final class MediaGenerateVariantsCommand extends Command
|
||||
|
||||
foreach ($paths as $path) {
|
||||
if (! Storage::disk($disk)->exists($path)) {
|
||||
ResponsiveImage::forgetMetadata($path, $disk);
|
||||
$this->warn("Missing file: {$path}");
|
||||
$skipped++;
|
||||
|
||||
@@ -56,9 +57,24 @@ final class MediaGenerateVariantsCommand extends Command
|
||||
if ($settings && filled($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) {
|
||||
$paths[] = (string) $path;
|
||||
|
||||
85
app/Domain/Contact/BrazilianPhoneNumber.php
Normal file
85
app/Domain/Contact/BrazilianPhoneNumber.php
Normal file
@@ -0,0 +1,85 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
181
app/Domain/Contact/MarketingOrigin.php
Normal file
181
app/Domain/Contact/MarketingOrigin.php
Normal file
@@ -0,0 +1,181 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
49
app/Domain/Marketing/PackageIconCatalog.php
Normal file
49
app/Domain/Marketing/PackageIconCatalog.php
Normal file
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Marketing;
|
||||
|
||||
/**
|
||||
* Closed catalog of icon keys available for wedding modality detail
|
||||
* sections (benefits strip and included items).
|
||||
*
|
||||
* Keys are stable kebab-case identifiers rendered as inline SVGs by the
|
||||
* public Blade components. Admin selection is constrained to this catalog
|
||||
* so arbitrary SVG markup can never reach the public page.
|
||||
*/
|
||||
final class PackageIconCatalog
|
||||
{
|
||||
/**
|
||||
* @return array<string, string> map of icon_key => pt-BR label
|
||||
*/
|
||||
public static function labels(): array
|
||||
{
|
||||
return [
|
||||
'calendar' => 'Calendário',
|
||||
'checklist' => 'Checklist',
|
||||
'users' => 'Casais',
|
||||
'map' => 'Mapa e fornecedores',
|
||||
'heart' => 'Cuidado',
|
||||
'spark' => 'Detalhes',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function keys(): array
|
||||
{
|
||||
return array_keys(self::labels());
|
||||
}
|
||||
|
||||
public static function isValid(string $iconKey): bool
|
||||
{
|
||||
return isset(self::labels()[$iconKey]);
|
||||
}
|
||||
|
||||
public static function labelFor(string $iconKey): ?string
|
||||
{
|
||||
return self::labels()[$iconKey] ?? null;
|
||||
}
|
||||
}
|
||||
45
app/Domain/Marketing/PortfolioVertical.php
Normal file
45
app/Domain/Marketing/PortfolioVertical.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Marketing;
|
||||
|
||||
/**
|
||||
* Public portfolio verticals used to separate Casamentos and Corporate proof.
|
||||
*
|
||||
* Matching is intentionally tolerant of free-text `event_type` values already
|
||||
* stored in the CMS (e.g. "Casamento", "Mini wedding", "Corporativo").
|
||||
*/
|
||||
enum PortfolioVertical: string
|
||||
{
|
||||
case Casamentos = 'casamentos';
|
||||
case Corporate = 'corporate';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Casamentos => 'Casamentos',
|
||||
self::Corporate => 'Corporate',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public function eventTypePatterns(): array
|
||||
{
|
||||
return match ($this) {
|
||||
self::Casamentos => ['%casamento%', '%wedding%', '%social%'],
|
||||
self::Corporate => ['%corporat%', '%empresa%', '%business%'],
|
||||
};
|
||||
}
|
||||
|
||||
public static function tryFromQuery(?string $value): ?self
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return self::tryFrom($value);
|
||||
}
|
||||
}
|
||||
43
app/Filament/Pages/Auth/RequestPasswordReset.php
Normal file
43
app/Filament/Pages/Auth/RequestPasswordReset.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
@@ -160,11 +160,24 @@ class ManageSiteSettings extends Page
|
||||
Textarea::make('hero_note')
|
||||
->label('Nota do hero')
|
||||
->rows(2),
|
||||
PublicImageUploadRules::fileUpload('hero_image_path', 'Imagem do hero', 'content/home'),
|
||||
PublicImageUploadRules::altTextField('hero_image_alt', 'hero_image_path'),
|
||||
Textarea::make('about_summary')
|
||||
->label('Resumo institucional')
|
||||
->rows(3),
|
||||
])
|
||||
->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')
|
||||
->schema([
|
||||
TextInput::make('manifesto_title')
|
||||
@@ -199,6 +212,26 @@ class ManageSiteSettings extends Page
|
||||
->reorderable()
|
||||
->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')
|
||||
->schema([
|
||||
TagsInput::make('principles')
|
||||
@@ -218,6 +251,11 @@ class ManageSiteSettings extends Page
|
||||
->label('Telefone')
|
||||
->required()
|
||||
->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')
|
||||
->label('Cidade')
|
||||
->maxLength(255),
|
||||
@@ -230,8 +268,10 @@ class ManageSiteSettings extends Page
|
||||
->columns(2),
|
||||
Section::make('Página Sobre')
|
||||
->schema([
|
||||
PublicImageUploadRules::fileUpload('about_image_path', 'Imagem da página Sobre', 'content/about'),
|
||||
PublicImageUploadRules::fileUpload('about_image_path', 'Imagem do hero / Sobre', 'content/about'),
|
||||
PublicImageUploadRules::altTextField('about_image_alt', 'about_image_path'),
|
||||
PublicImageUploadRules::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')
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\PortfolioCases\Pages;
|
||||
|
||||
use App\Filament\Resources\PortfolioCases\PortfolioCaseResource;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\PortfolioCases\Pages;
|
||||
|
||||
use App\Filament\Resources\PortfolioCases\PortfolioCaseResource;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\PortfolioCases\Pages;
|
||||
|
||||
use App\Filament\Resources\PortfolioCases\PortfolioCaseResource;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\Services\Pages;
|
||||
|
||||
use App\Filament\Resources\Services\ServiceResource;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\Services\Pages;
|
||||
|
||||
use App\Filament\Resources\Services\ServiceResource;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\Services\Pages;
|
||||
|
||||
use App\Filament\Resources\Services\ServiceResource;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\Testimonials\Pages;
|
||||
|
||||
use App\Filament\Resources\Testimonials\TestimonialResource;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\Testimonials\Pages;
|
||||
|
||||
use App\Filament\Resources\Testimonials\TestimonialResource;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\Testimonials\Pages;
|
||||
|
||||
use App\Filament\Resources\Testimonials\TestimonialResource;
|
||||
|
||||
@@ -1,11 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\Users\Pages;
|
||||
|
||||
use App\Filament\Resources\Users\UserResource;
|
||||
use App\Models\User;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class CreateUser extends CreateRecord
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\Users\Pages;
|
||||
|
||||
use App\Filament\Resources\Users\UserResource;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\Users\Pages;
|
||||
|
||||
use App\Filament\Resources\Users\UserResource;
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<?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;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?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;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\WeddingPackages\Schemas;
|
||||
|
||||
use App\Domain\Marketing\PackageIconCatalog;
|
||||
use App\Support\PublicImageUploadRules;
|
||||
use Filament\Forms\Components\DateTimePicker;
|
||||
use Filament\Forms\Components\Repeater;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
|
||||
class WeddingPackageForm
|
||||
{
|
||||
public static function configure(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
Section::make('Identificação')
|
||||
->schema([
|
||||
TextInput::make('name')
|
||||
->label('Nome da modalidade')
|
||||
->required()
|
||||
->maxLength(255)
|
||||
->live(onBlur: true)
|
||||
->afterStateUpdated(function (?string $state, callable $set, callable $get): void {
|
||||
if (blank($get('slug'))) {
|
||||
$set('slug', str($state)->slug()->toString());
|
||||
}
|
||||
}),
|
||||
TextInput::make('slug')
|
||||
->label('Slug')
|
||||
->required()
|
||||
->maxLength(255)
|
||||
->unique(ignoreRecord: true),
|
||||
TextInput::make('level')
|
||||
->label('Número / tipo (ex.: 01 / COMPLETA)')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
TextInput::make('tag')
|
||||
->label('Etiqueta curta (ex.: Assessoria completa)')
|
||||
->maxLength(255),
|
||||
TextInput::make('subtitle')
|
||||
->label('Frase de apoio')
|
||||
->maxLength(255),
|
||||
Textarea::make('summary')
|
||||
->label('Resumo')
|
||||
->required()
|
||||
->rows(4),
|
||||
Repeater::make('scope_items')
|
||||
->label('O que está incluído')
|
||||
->schema([
|
||||
TextInput::make('item')
|
||||
->label('Item')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
])
|
||||
->defaultItems(4)
|
||||
->minItems(1)
|
||||
->addActionLabel('Adicionar item')
|
||||
->required(),
|
||||
TextInput::make('cta_label')
|
||||
->label('Texto do botão')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
TextInput::make('compare_heading')
|
||||
->label('Comparação — título (leitura rápida)')
|
||||
->maxLength(255),
|
||||
TextInput::make('compare_summary')
|
||||
->label('Comparação — resumo (leitura rápida)')
|
||||
->maxLength(255),
|
||||
TextInput::make('sort_order')
|
||||
->label('Ordem')
|
||||
->numeric()
|
||||
->default(0)
|
||||
->required(),
|
||||
DateTimePicker::make('published_at')
|
||||
->label('Publicado em')
|
||||
->seconds(false),
|
||||
])
|
||||
->columns(2),
|
||||
Section::make('Hero da página')
|
||||
->description('Dados de abertura da página da modalidade.')
|
||||
->schema([
|
||||
TextInput::make('eyebrow')
|
||||
->label('Eyebrow (ex.: Assessoria)')
|
||||
->maxLength(255),
|
||||
TextInput::make('title_line')
|
||||
->label('Título (linha principal)')
|
||||
->maxLength(255),
|
||||
TextInput::make('title_emphasis')
|
||||
->label('Título (ênfase em itálico)')
|
||||
->maxLength(255),
|
||||
Textarea::make('hero_lead')
|
||||
->label('Texto de abertura')
|
||||
->rows(3),
|
||||
PublicImageUploadRules::fileUpload('hero_image_path', 'Imagem do hero'),
|
||||
PublicImageUploadRules::altTextField('hero_image_alt', 'hero_image_path'),
|
||||
])
|
||||
->columns(2),
|
||||
Section::make('Diferenciais')
|
||||
->description('Faixa escura com os valores da modalidade.')
|
||||
->schema([
|
||||
Repeater::make('benefits')
|
||||
->label('Diferenciais')
|
||||
->schema([
|
||||
Select::make('icon_key')
|
||||
->label('Ícone')
|
||||
->options(PackageIconCatalog::labels())
|
||||
->required(),
|
||||
TextInput::make('label')
|
||||
->label('Rótulo')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
])
|
||||
->defaultItems(4)
|
||||
->minItems(1)
|
||||
->addActionLabel('Adicionar diferencial'),
|
||||
])
|
||||
->columns(1),
|
||||
Section::make('O que está incluso')
|
||||
->schema([
|
||||
Repeater::make('included_items')
|
||||
->label('Itens inclusos')
|
||||
->schema([
|
||||
Select::make('icon_key')
|
||||
->label('Ícone')
|
||||
->options(PackageIconCatalog::labels())
|
||||
->required(),
|
||||
TextInput::make('title')
|
||||
->label('Título')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
Textarea::make('description')
|
||||
->label('Descrição')
|
||||
->rows(2)
|
||||
->maxLength(500),
|
||||
])
|
||||
->defaultItems(6)
|
||||
->minItems(1)
|
||||
->addActionLabel('Adicionar item'),
|
||||
])
|
||||
->columns(1),
|
||||
Section::make('Para quem é este pacote')
|
||||
->schema([
|
||||
TextInput::make('audience_heading')
|
||||
->label('Título da seção')
|
||||
->maxLength(255),
|
||||
Textarea::make('audience_intro')
|
||||
->label('Texto de introdução')
|
||||
->rows(3),
|
||||
Repeater::make('audience_points')
|
||||
->label('Público')
|
||||
->schema([
|
||||
TextInput::make('point')
|
||||
->label('Ponto')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
])
|
||||
->defaultItems(4)
|
||||
->minItems(1)
|
||||
->addActionLabel('Adicionar ponto'),
|
||||
PublicImageUploadRules::fileUpload('audience_image_path', 'Imagem da seção'),
|
||||
PublicImageUploadRules::altTextField('audience_image_alt', 'audience_image_path'),
|
||||
])
|
||||
->columns(2),
|
||||
Section::make('CTA final')
|
||||
->schema([
|
||||
TextInput::make('final_cta_heading')
|
||||
->label('Título do CTA final')
|
||||
->maxLength(255),
|
||||
Textarea::make('final_cta_body')
|
||||
->label('Texto do CTA final')
|
||||
->rows(3),
|
||||
])
|
||||
->columns(2),
|
||||
Section::make('SEO')
|
||||
->schema([
|
||||
TextInput::make('meta_title')
|
||||
->label('Meta title')
|
||||
->maxLength(255),
|
||||
Textarea::make('meta_description')
|
||||
->label('Meta description')
|
||||
->rows(3),
|
||||
])
|
||||
->columns(2),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\WeddingPackages\Tables;
|
||||
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Actions\DeleteBulkAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class WeddingPackagesTable
|
||||
{
|
||||
public static function configure(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
TextColumn::make('name')
|
||||
->label('Nome')
|
||||
->searchable()
|
||||
->sortable(),
|
||||
TextColumn::make('level')
|
||||
->label('Número / tipo')
|
||||
->searchable(),
|
||||
TextColumn::make('slug')
|
||||
->label('Slug')
|
||||
->searchable(),
|
||||
TextColumn::make('published_at')
|
||||
->label('Publicado em')
|
||||
->dateTime()
|
||||
->sortable(),
|
||||
TextColumn::make('sort_order')
|
||||
->label('Ordem')
|
||||
->sortable(),
|
||||
])
|
||||
->defaultSort('sort_order')
|
||||
->filters([
|
||||
//
|
||||
])
|
||||
->recordActions([
|
||||
EditAction::make(),
|
||||
DeleteAction::make()
|
||||
->requiresConfirmation(),
|
||||
])
|
||||
->toolbarActions([
|
||||
BulkActionGroup::make([
|
||||
DeleteBulkAction::make(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
abstract class Controller
|
||||
|
||||
@@ -4,6 +4,7 @@ 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;
|
||||
@@ -34,7 +35,7 @@ final class ContactController extends Controller
|
||||
$this->rememberSubmission($validated);
|
||||
|
||||
$settings = SiteSetting::instance();
|
||||
$fields = $this->buildFields($validated);
|
||||
$fields = $this->buildFields($validated, $this->resolveMarketingOrigin($request));
|
||||
|
||||
$this->dispatchEmails($settings, $validated['nome'], $validated['email'], $fields);
|
||||
|
||||
@@ -44,7 +45,7 @@ final class ContactController extends Controller
|
||||
/**
|
||||
* @param array<string, mixed> $validated
|
||||
*/
|
||||
private function buildFields(array $validated): array
|
||||
private function buildFields(array $validated, ?string $marketingOrigin): array
|
||||
{
|
||||
return [
|
||||
'Nome' => (string) $validated['nome'],
|
||||
@@ -56,9 +57,32 @@ final class ContactController extends Controller
|
||||
'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
|
||||
*/
|
||||
@@ -111,6 +135,6 @@ final class ContactController extends Controller
|
||||
|
||||
private function success(): RedirectResponse
|
||||
{
|
||||
return redirect()->route('contact')->with('status', 'briefing-sent');
|
||||
return redirect()->route('briefing')->with('status', 'briefing-sent');
|
||||
}
|
||||
}
|
||||
|
||||
46
app/Http/Controllers/PublicSite/PackageController.php
Normal file
46
app/Http/Controllers/PublicSite/PackageController.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\PublicSite;
|
||||
|
||||
use App\Application\Data\PageMeta;
|
||||
use App\Application\Queries\Marketing\FindPublishedWeddingPackageBySlug;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\SiteSetting;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Http\Response;
|
||||
|
||||
final class PackageController extends Controller
|
||||
{
|
||||
public function show(string $slug, FindPublishedWeddingPackageBySlug $findPublishedWeddingPackageBySlug): View|Response
|
||||
{
|
||||
$package = $findPublishedWeddingPackageBySlug($slug);
|
||||
|
||||
if ($package === null) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$settings = SiteSetting::instance();
|
||||
$canonical = route('packages.show', $package->slug);
|
||||
|
||||
return view('pages.packages.show', [
|
||||
'package' => $package,
|
||||
'siteSettings' => $settings,
|
||||
'pageMeta' => PageMeta::forPackage(
|
||||
package: $package,
|
||||
canonical: $canonical,
|
||||
settings: $settings,
|
||||
jsonLd: [
|
||||
'@context' => 'https://schema.org',
|
||||
'@type' => 'Article',
|
||||
'headline' => $package->name,
|
||||
'description' => $package->summary,
|
||||
'url' => $canonical,
|
||||
'datePublished' => $package->published_at?->toAtomString(),
|
||||
'dateModified' => $package->updated_at?->toAtomString(),
|
||||
],
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -5,17 +5,20 @@ declare(strict_types=1);
|
||||
namespace App\Http\Controllers\PublicSite;
|
||||
|
||||
use App\Application\Data\PageMeta;
|
||||
use App\Application\Queries\Marketing\GetAboutContent;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\SiteSetting;
|
||||
use Illuminate\Contracts\View\View;
|
||||
|
||||
final class PageController extends Controller
|
||||
{
|
||||
public function about(): View
|
||||
public function about(GetAboutContent $getAboutContent): View
|
||||
{
|
||||
$settings = SiteSetting::instance();
|
||||
$content = $getAboutContent();
|
||||
$settings = $content->settings;
|
||||
|
||||
return view('pages.about', [
|
||||
'content' => $content,
|
||||
'siteSettings' => $settings,
|
||||
'pageMeta' => PageMeta::forPage(
|
||||
canonical: route('about'),
|
||||
@@ -45,7 +48,7 @@ final class PageController extends Controller
|
||||
{
|
||||
$settings = SiteSetting::instance();
|
||||
|
||||
return view('pages.contact', [
|
||||
return view('pages.partners', [
|
||||
'siteSettings' => $settings,
|
||||
'pageMeta' => PageMeta::forPage(
|
||||
canonical: route('contact'),
|
||||
@@ -55,4 +58,14 @@ final class PageController extends Controller
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
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.'.'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
66
app/Http/Controllers/PublicSite/PartnerInquiryController.php
Normal file
66
app/Http/Controllers/PublicSite/PartnerInquiryController.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?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');
|
||||
}
|
||||
}
|
||||
@@ -6,33 +6,35 @@ namespace App\Http\Controllers\PublicSite;
|
||||
|
||||
use App\Application\Data\PageMeta;
|
||||
use App\Application\Queries\Marketing\FindPublishedPortfolioCaseBySlug;
|
||||
use App\Application\Queries\Marketing\GetPublishedPortfolioCases;
|
||||
use App\Domain\Marketing\PortfolioVertical;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\PortfolioCase;
|
||||
use App\Models\SiteSetting;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
|
||||
final class PortfolioController extends Controller
|
||||
{
|
||||
public function index(): View
|
||||
public function index(Request $request, GetPublishedPortfolioCases $getPublishedPortfolioCases): View
|
||||
{
|
||||
$settings = SiteSetting::instance();
|
||||
|
||||
/** @var LengthAwarePaginator<int, PortfolioCase> $cases */
|
||||
$cases = PortfolioCase::query()
|
||||
->published()
|
||||
->with(['images'])
|
||||
->orderBy('sort_order')
|
||||
->paginate(9);
|
||||
$vertical = PortfolioVertical::tryFromQuery($request->query('vertente'));
|
||||
$cases = $getPublishedPortfolioCases->paginate($vertical);
|
||||
|
||||
return view('pages.portfolio.index', [
|
||||
'cases' => $cases,
|
||||
'vertical' => $vertical,
|
||||
'siteSettings' => $settings,
|
||||
'pageMeta' => PageMeta::forPage(
|
||||
canonical: route('portfolio.index'),
|
||||
canonical: route('portfolio.index', array_filter([
|
||||
'vertente' => $vertical?->value,
|
||||
])),
|
||||
settings: $settings,
|
||||
title: PageMeta::withBrandSuffix('Portfólio', $settings),
|
||||
title: PageMeta::withBrandSuffix(
|
||||
$vertical === null ? 'Portfólio' : 'Portfólio — '.$vertical->label(),
|
||||
$settings,
|
||||
),
|
||||
description: 'Casos reais de eventos conduzidos pela '.$settings->brand_name.'.',
|
||||
),
|
||||
]);
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Application\Data\PageMeta;
|
||||
use App\Application\Queries\Marketing\GetPublishedServices;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\SiteSetting;
|
||||
use App\Models\WeddingPackage;
|
||||
use Illuminate\Contracts\View\View;
|
||||
|
||||
final class ServiceController extends Controller
|
||||
@@ -19,6 +20,7 @@ final class ServiceController extends Controller
|
||||
|
||||
return view('pages.services.index', [
|
||||
'services' => $services,
|
||||
'weddingPackages' => WeddingPackage::query()->published()->orderBy('sort_order')->get(),
|
||||
'siteSettings' => $settings,
|
||||
'pageMeta' => PageMeta::forPage(
|
||||
canonical: route('services.index'),
|
||||
|
||||
85
app/Http/Middleware/CaptureMarketingOrigin.php
Normal file
85
app/Http/Middleware/CaptureMarketingOrigin.php
Normal file
@@ -0,0 +1,85 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,26 @@ 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>>
|
||||
*/
|
||||
|
||||
32
app/Http/Requests/PublicSite/PartnerInquiryRequest.php
Normal file
32
app/Http/Requests/PublicSite/PartnerInquiryRequest.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
31
app/Mail/PartnerInquiry.php
Normal file
31
app/Mail/PartnerInquiry.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?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');
|
||||
}
|
||||
}
|
||||
@@ -12,12 +12,21 @@ use Illuminate\Database\Eloquent\Model;
|
||||
/**
|
||||
* @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 $corporate_steps
|
||||
* @property list<string>|null $principles
|
||||
* @property bool $analytics_enabled
|
||||
* @property string|null $default_og_image_path
|
||||
* @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_alt
|
||||
*/
|
||||
@@ -31,17 +40,27 @@ use Illuminate\Database\Eloquent\Model;
|
||||
'hero_cta_label',
|
||||
'hero_secondary_cta_label',
|
||||
'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_image_path',
|
||||
'about_image_alt',
|
||||
'founder_image_path',
|
||||
'founder_image_alt',
|
||||
'manifesto_title',
|
||||
'manifesto_lead',
|
||||
'manifesto_body',
|
||||
'method_intro',
|
||||
'method_steps',
|
||||
'corporate_steps',
|
||||
'principles',
|
||||
'email',
|
||||
'phone',
|
||||
'whatsapp_number',
|
||||
'city',
|
||||
'social_links',
|
||||
'default_meta_title',
|
||||
@@ -70,9 +89,11 @@ 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.',
|
||||
'method_intro' => 'Clareza em cada etapa. Tranquilidade durante todo o processo.',
|
||||
'method_steps' => self::defaultMethodSteps(),
|
||||
'corporate_steps' => self::defaultCorporateSteps(),
|
||||
'principles' => self::defaultPrinciples(),
|
||||
'email' => 'amareassessoriaeventos@gmail.com',
|
||||
'phone' => '(11) 99999-9999',
|
||||
'whatsapp_number' => null,
|
||||
'city' => 'São Paulo - SP',
|
||||
'social_links' => [],
|
||||
'default_meta_title' => 'Amare Assessoria de Eventos',
|
||||
@@ -106,6 +127,27 @@ class SiteSetting extends Model
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{title: string, body: string}>
|
||||
*/
|
||||
public static function defaultCorporateSteps(): array
|
||||
{
|
||||
return [
|
||||
[
|
||||
'title' => 'Planejamento',
|
||||
'body' => 'Estruturação de escopo, cronograma e prioridades.',
|
||||
],
|
||||
[
|
||||
'title' => 'Produção',
|
||||
'body' => 'Coordenação dos elementos necessários para colocar o evento de pé.',
|
||||
],
|
||||
[
|
||||
'title' => 'Execução',
|
||||
'body' => 'Condução e acompanhamento do evento conforme o projeto aprovado.',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
@@ -127,6 +169,7 @@ class SiteSetting extends Model
|
||||
return [
|
||||
'social_links' => 'array',
|
||||
'method_steps' => 'array',
|
||||
'corporate_steps' => 'array',
|
||||
'principles' => 'array',
|
||||
'analytics_enabled' => 'boolean',
|
||||
];
|
||||
|
||||
@@ -8,6 +8,8 @@ use App\Enums\UserRole;
|
||||
use Database\Factories\UserFactory;
|
||||
use Filament\Models\Contracts\FilamentUser;
|
||||
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\Hidden;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
@@ -20,14 +22,14 @@ use Illuminate\Notifications\Notifiable;
|
||||
*/
|
||||
#[Fillable(['name', 'email', 'password', 'role', 'is_active'])]
|
||||
#[Hidden(['password', 'remember_token'])]
|
||||
class User extends Authenticatable implements FilamentUser
|
||||
class User extends Authenticatable implements FilamentUser, MustVerifyEmailContract
|
||||
{
|
||||
/** @use HasFactory<UserFactory> */
|
||||
use HasFactory, Notifiable;
|
||||
use HasFactory, MustVerifyEmail, Notifiable;
|
||||
|
||||
public function canAccessPanel(Panel $panel): bool
|
||||
{
|
||||
return $this->is_active;
|
||||
return $this->is_active && $this->hasVerifiedEmail();
|
||||
}
|
||||
|
||||
public function isAdmin(): bool
|
||||
|
||||
108
app/Models/WeddingPackage.php
Normal file
108
app/Models/WeddingPackage.php
Normal file
@@ -0,0 +1,108 @@
|
||||
<?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',
|
||||
];
|
||||
}
|
||||
}
|
||||
36
app/Policies/WeddingPackagePolicy.php
Normal file
36
app/Policies/WeddingPackagePolicy.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?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();
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,10 @@ class AppServiceProvider extends ServiceProvider
|
||||
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
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Providers\Filament;
|
||||
|
||||
use App\Filament\Pages\Auth\RequestPasswordReset;
|
||||
use Filament\FontProviders\LocalFontProvider;
|
||||
use Filament\Http\Middleware\Authenticate;
|
||||
use Filament\Http\Middleware\AuthenticateSession;
|
||||
use Filament\Http\Middleware\DisableBladeIconComponents;
|
||||
@@ -9,7 +13,7 @@ use Filament\Http\Middleware\DispatchServingFilamentEvent;
|
||||
use Filament\Pages\Dashboard;
|
||||
use Filament\Panel;
|
||||
use Filament\PanelProvider;
|
||||
use Filament\Support\Colors\Color;
|
||||
use Filament\View\PanelsRenderHook;
|
||||
use Filament\Widgets\AccountWidget;
|
||||
use Filament\Widgets\FilamentInfoWidget;
|
||||
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
|
||||
@@ -21,6 +25,137 @@ use Illuminate\View\Middleware\ShareErrorsFromSession;
|
||||
|
||||
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
|
||||
{
|
||||
return $panel
|
||||
@@ -28,9 +163,47 @@ class AdminPanelProvider extends PanelProvider
|
||||
->id('admin')
|
||||
->path('admin')
|
||||
->login()
|
||||
->passwordReset(requestAction: RequestPasswordReset::class)
|
||||
->brandName('Amare Assessoria')
|
||||
->brandLogo(fn (): string => asset('brand/lockup-on-light.webp'))
|
||||
->brandLogoHeight('2rem')
|
||||
->colors([
|
||||
'primary' => Color::Amber,
|
||||
'primary' => $this->primaryColor(),
|
||||
'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')
|
||||
->discoverPages(in: app_path('Filament/Pages'), for: 'App\Filament\Pages')
|
||||
->pages([
|
||||
|
||||
28
app/Support/PackageContactLink.php
Normal file
28
app/Support/PackageContactLink.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Support;
|
||||
|
||||
use Illuminate\Contracts\Filesystem\Filesystem;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Intervention\Image\Drivers\Gd\Driver;
|
||||
use Intervention\Image\ImageManager;
|
||||
@@ -12,75 +13,130 @@ use Throwable;
|
||||
|
||||
final class ResponsiveImage
|
||||
{
|
||||
/** @var list<int> */
|
||||
public const WIDTHS = [480, 960, 1440];
|
||||
/**
|
||||
* 720 sits between the original 480 and 960 because that is where the common
|
||||
* 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
|
||||
{
|
||||
self::forgetMetadata($path, $disk);
|
||||
$filesystem = self::filesystem($disk);
|
||||
|
||||
if (! $filesystem->exists($path)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$manager = new ImageManager(new Driver);
|
||||
$contents = $filesystem->get($path);
|
||||
|
||||
if ($contents === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$extension = strtolower(pathinfo($path, PATHINFO_EXTENSION));
|
||||
|
||||
foreach (self::WIDTHS as $width) {
|
||||
$variantPath = self::variantPath($path, $width);
|
||||
$variant = $manager->read($contents);
|
||||
|
||||
if ($variant->width() > $width) {
|
||||
$variant->scale(width: $width);
|
||||
try {
|
||||
if (! $filesystem->exists($path)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$encoded = match ($extension) {
|
||||
'png' => $variant->toPng(),
|
||||
'webp' => $variant->toWebp(quality: 82),
|
||||
default => $variant->toJpeg(quality: 82),
|
||||
};
|
||||
$manager = new ImageManager(new Driver);
|
||||
$contents = $filesystem->get($path);
|
||||
|
||||
$filesystem->put($variantPath, (string) $encoded);
|
||||
if ($contents === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$extension = strtolower(pathinfo($path, PATHINFO_EXTENSION));
|
||||
|
||||
foreach (self::WIDTHS as $width) {
|
||||
$variantPath = self::variantPath($path, $width);
|
||||
$variant = $manager->read($contents);
|
||||
|
||||
if ($variant->width() > $width) {
|
||||
$variant->scale(width: $width);
|
||||
}
|
||||
|
||||
$encoded = match ($extension) {
|
||||
'png' => $variant->toPng(),
|
||||
'webp' => $variant->toWebp(quality: 82),
|
||||
default => $variant->toJpeg(quality: 82),
|
||||
};
|
||||
|
||||
$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
|
||||
{
|
||||
self::forgetMetadata($path, $disk);
|
||||
$filesystem = self::filesystem($disk);
|
||||
|
||||
foreach (self::WIDTHS as $width) {
|
||||
$variantPath = self::variantPath($path, $width);
|
||||
|
||||
if ($filesystem->exists($variantPath)) {
|
||||
$filesystem->delete($variantPath);
|
||||
try {
|
||||
foreach (self::WIDTHS as $width) {
|
||||
foreach ([self::variantPath($path, $width), self::webpVariantPath($path, $width)] as $variantPath) {
|
||||
if ($filesystem->exists($variantPath)) {
|
||||
$filesystem->delete($variantPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
self::forgetMetadata($path, $disk);
|
||||
}
|
||||
}
|
||||
|
||||
public static function delete(string $path, ?string $disk = null): void
|
||||
{
|
||||
self::forgetMetadata($path, $disk);
|
||||
$filesystem = self::filesystem($disk);
|
||||
|
||||
self::deleteVariants($path, $disk);
|
||||
try {
|
||||
self::deleteVariants($path, $disk);
|
||||
|
||||
if ($filesystem->exists($path)) {
|
||||
$filesystem->delete($path);
|
||||
if ($filesystem->exists($path)) {
|
||||
$filesystem->delete($path);
|
||||
}
|
||||
} finally {
|
||||
self::forgetMetadata($path, $disk);
|
||||
}
|
||||
}
|
||||
|
||||
public static function replace(string $previousPath, string $newPath, ?string $disk = null): void
|
||||
{
|
||||
if ($previousPath !== '' && $previousPath !== $newPath) {
|
||||
self::delete($previousPath, $disk);
|
||||
self::forgetMetadata($previousPath, $disk);
|
||||
self::forgetMetadata($newPath, $disk);
|
||||
|
||||
try {
|
||||
if ($previousPath !== '' && $previousPath !== $newPath) {
|
||||
self::delete($previousPath, $disk);
|
||||
}
|
||||
|
||||
self::generate($newPath, $disk);
|
||||
} finally {
|
||||
self::forgetMetadata($previousPath, $disk);
|
||||
self::forgetMetadata($newPath, $disk);
|
||||
}
|
||||
|
||||
self::generate($newPath, $disk);
|
||||
self::metadata($newPath, $disk);
|
||||
}
|
||||
|
||||
public static function variantPath(string $path, int $width): string
|
||||
@@ -93,26 +149,35 @@ final class ResponsiveImage
|
||||
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}>
|
||||
*/
|
||||
public static function availableVariants(string $path, ?string $disk = null): array
|
||||
{
|
||||
$filesystem = self::filesystem($disk);
|
||||
$variants = [];
|
||||
return self::metadata($path, $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'] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -120,13 +185,38 @@ final class ResponsiveImage
|
||||
*/
|
||||
public static function dimensions(string $path, ?string $disk = null): ?array
|
||||
{
|
||||
$filesystem = self::filesystem($disk);
|
||||
$metadata = self::metadata($path, $disk);
|
||||
|
||||
if (! $filesystem->exists($path)) {
|
||||
return null;
|
||||
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);
|
||||
|
||||
if (! $filesystem->exists($path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$contents = $filesystem->get($path);
|
||||
|
||||
if ($contents === null) {
|
||||
@@ -134,18 +224,88 @@ final class ResponsiveImage
|
||||
}
|
||||
|
||||
$image = (new ImageManager(new Driver))->read($contents);
|
||||
|
||||
return [
|
||||
$metadata = [
|
||||
'width' => $image->width(),
|
||||
'height' => $image->height(),
|
||||
'variants' => self::existingVariants($filesystem, $path, false),
|
||||
'webp_variants' => self::existingVariants($filesystem, $path, true),
|
||||
];
|
||||
|
||||
self::storeMetadata($key, $metadata);
|
||||
|
||||
return $metadata;
|
||||
} catch (Throwable) {
|
||||
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
|
||||
{
|
||||
return Storage::disk($disk ?? PublicImageUploadRules::disk());
|
||||
return Storage::disk(self::diskName($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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Middleware\CaptureMarketingOrigin;
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Foundation\Configuration\Exceptions;
|
||||
use Illuminate\Foundation\Configuration\Middleware;
|
||||
@@ -14,6 +15,13 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
->withMiddleware(function (Middleware $middleware): void {
|
||||
// Trust Traefik/Dokploy (and local reverse proxies) for X-Forwarded-* headers.
|
||||
$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 {
|
||||
$exceptions->shouldRenderJsonWhen(
|
||||
|
||||
@@ -65,6 +65,9 @@
|
||||
"test:browser": [
|
||||
"@php artisan test --testsuite=Browser"
|
||||
],
|
||||
"test:coverage": [
|
||||
"vendor/bin/pest -c phpunit.coverage.xml --testsuite=Unit,Architecture,Feature --coverage --min=80"
|
||||
],
|
||||
"pint": [
|
||||
"vendor/bin/pint"
|
||||
],
|
||||
@@ -81,11 +84,9 @@
|
||||
"@pint:check",
|
||||
"@phpstan",
|
||||
"composer audit --no-interaction",
|
||||
"npm audit --omit=dev --audit-level=high",
|
||||
"@test"
|
||||
],
|
||||
"visual:update": [
|
||||
"@php artisan test --testsuite=Browser --update-snapshots"
|
||||
],
|
||||
"post-autoload-dump": [
|
||||
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
|
||||
"@php artisan package:discover --ansi",
|
||||
|
||||
12
composer.lock
generated
12
composer.lock
generated
@@ -2802,16 +2802,16 @@
|
||||
},
|
||||
{
|
||||
"name": "league/commonmark",
|
||||
"version": "2.8.3",
|
||||
"version": "2.9.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/thephpleague/commonmark.git",
|
||||
"reference": "1902f60f984235023acbe03db6ad614a37b3c3e7"
|
||||
"reference": "5703d83ba3da3b2e356a5fedc848ed6d8ffb6529"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/thephpleague/commonmark/zipball/1902f60f984235023acbe03db6ad614a37b3c3e7",
|
||||
"reference": "1902f60f984235023acbe03db6ad614a37b3c3e7",
|
||||
"url": "https://api.github.com/repos/thephpleague/commonmark/zipball/5703d83ba3da3b2e356a5fedc848ed6d8ffb6529",
|
||||
"reference": "5703d83ba3da3b2e356a5fedc848ed6d8ffb6529",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -2848,7 +2848,7 @@
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-main": "2.9-dev"
|
||||
"dev-main": "2.10-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
@@ -2905,7 +2905,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-07-12T15:29:16+00:00"
|
||||
"time": "2026-08-03T13:42:31+00:00"
|
||||
},
|
||||
{
|
||||
"name": "league/config",
|
||||
|
||||
@@ -65,7 +65,7 @@ return [
|
||||
|
|
||||
*/
|
||||
|
||||
'timezone' => env('APP_TIMEZONE', 'America/Fortaleza'),
|
||||
'timezone' => env('APP_TIMEZONE', 'America/Sao_Paulo'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
@@ -73,7 +73,7 @@ return [
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When set outside production, the application clock is frozen for
|
||||
| deterministic rendering (visual regression / seeded content).
|
||||
| deterministic time-dependent tests or seeded content.
|
||||
|
|
||||
*/
|
||||
|
||||
|
||||
56
database/factories/WeddingPackageFactory.php
Normal file
56
database/factories/WeddingPackageFactory.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?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()]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?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.
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
<?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',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('site_settings', function (Blueprint $table): void {
|
||||
$table->jsonb('corporate_steps')->nullable()->after('method_steps');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('site_settings', function (Blueprint $table): void {
|
||||
$table->dropColumn('corporate_steps');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('site_settings', function (Blueprint $table): void {
|
||||
$table->string('whatsapp_number')->nullable()->after('phone');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('site_settings', function (Blueprint $table): void {
|
||||
$table->dropColumn('whatsapp_number');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
<?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');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('site_settings', function (Blueprint $table): void {
|
||||
$table->string('founder_image_path')->nullable()->after('about_image_alt');
|
||||
$table->string('founder_image_alt')->nullable()->after('founder_image_path');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('site_settings', function (Blueprint $table): void {
|
||||
$table->dropColumn(['founder_image_path', 'founder_image_alt']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('wedding_packages', function (Blueprint $table): void {
|
||||
$table->string('tag')->nullable();
|
||||
$table->string('subtitle')->nullable();
|
||||
$table->string('compare_heading')->nullable();
|
||||
$table->string('compare_summary')->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('wedding_packages', function (Blueprint $table): void {
|
||||
$table->dropColumn(['tag', 'subtitle', 'compare_heading', 'compare_summary']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('wedding_packages', function (Blueprint $table): void {
|
||||
$table->string('slug')->nullable()->unique()->after('name');
|
||||
$table->string('eyebrow')->nullable()->after('level');
|
||||
$table->string('title_line')->nullable()->after('eyebrow');
|
||||
$table->string('title_emphasis')->nullable()->after('title_line');
|
||||
$table->text('hero_lead')->nullable()->after('title_emphasis');
|
||||
$table->string('hero_image_path')->nullable()->after('hero_lead');
|
||||
$table->string('hero_image_alt')->nullable()->after('hero_image_path');
|
||||
$table->jsonb('benefits')->nullable()->after('hero_image_alt');
|
||||
$table->jsonb('included_items')->nullable()->after('benefits');
|
||||
$table->string('audience_heading')->nullable()->after('included_items');
|
||||
$table->text('audience_intro')->nullable()->after('audience_heading');
|
||||
$table->jsonb('audience_points')->nullable()->after('audience_intro');
|
||||
$table->string('audience_image_path')->nullable()->after('audience_points');
|
||||
$table->string('audience_image_alt')->nullable()->after('audience_image_path');
|
||||
$table->string('final_cta_heading')->nullable()->after('audience_image_alt');
|
||||
$table->text('final_cta_body')->nullable()->after('final_cta_heading');
|
||||
$table->string('meta_title')->nullable()->after('final_cta_body');
|
||||
$table->text('meta_description')->nullable()->after('meta_title');
|
||||
});
|
||||
|
||||
$this->backfillSlugs();
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('wedding_packages', function (Blueprint $table): void {
|
||||
$table->dropUnique(['slug']);
|
||||
$table->dropColumn([
|
||||
'slug',
|
||||
'eyebrow',
|
||||
'title_line',
|
||||
'title_emphasis',
|
||||
'hero_lead',
|
||||
'hero_image_path',
|
||||
'hero_image_alt',
|
||||
'benefits',
|
||||
'included_items',
|
||||
'audience_heading',
|
||||
'audience_intro',
|
||||
'audience_points',
|
||||
'audience_image_path',
|
||||
'audience_image_alt',
|
||||
'final_cta_heading',
|
||||
'final_cta_body',
|
||||
'meta_title',
|
||||
'meta_description',
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
private function backfillSlugs(): void
|
||||
{
|
||||
$rows = DB::table('wedding_packages')->whereNull('slug')->get(['id', 'name']);
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$slug = Str::slug($row->name);
|
||||
$candidate = $slug;
|
||||
$suffix = 2;
|
||||
|
||||
while (DB::table('wedding_packages')->where('slug', $candidate)->exists()) {
|
||||
$candidate = "{$slug}-{$suffix}";
|
||||
$suffix++;
|
||||
}
|
||||
|
||||
DB::table('wedding_packages')->where('id', $row->id)->update(['slug' => $candidate]);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -11,6 +11,7 @@ use App\Models\SiteSetting;
|
||||
use App\Support\PublicImageUploadRules;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
@@ -18,12 +19,37 @@ class ContentSeeder extends Seeder
|
||||
{
|
||||
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
|
||||
{
|
||||
if (! App::environment(['local', 'staging', 'testing'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->seedSiteSettings();
|
||||
$this->seedServices();
|
||||
$this->seedPortfolioCases();
|
||||
$this->call(TestimonialsSeeder::class);
|
||||
$this->call(WeddingPackagesSeeder::class);
|
||||
}
|
||||
|
||||
private function seedSiteSettings(): void
|
||||
@@ -36,14 +62,23 @@ class ContentSeeder extends Seeder
|
||||
'hero_cta_label' => 'Solicitar proposta',
|
||||
'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_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_image_path' => $this->copyFixture('about-image.jpg', 'content/about/about-image.jpg'),
|
||||
'about_image_alt' => 'Mesa de planejamento com caderno, café e guardanapos de pano',
|
||||
'founder_image_path' => $this->copyFixture('about-image.jpg', 'content/about/founder/michele.jpg'),
|
||||
'founder_image_alt' => 'Michele, da Amare',
|
||||
'manifesto_title' => 'Sofisticação que também se traduz em organização.',
|
||||
'manifesto_lead' => 'Um evento memorável não nasce apenas de uma boa estética. Ele depende de decisões bem conduzidas, fornecedores alinhados e atenção constante ao que realmente importa.',
|
||||
'manifesto_body' => 'A Amare combina sensibilidade e precisão para criar encontros coerentes com cada cliente, marca e ocasião — sem fórmulas prontas, excessos ou ruído.',
|
||||
'method_intro' => 'Clareza em cada etapa. Tranquilidade durante todo o processo.',
|
||||
'method_steps' => SiteSetting::defaultMethodSteps(),
|
||||
'corporate_steps' => SiteSetting::defaultCorporateSteps(),
|
||||
'principles' => SiteSetting::defaultPrinciples(),
|
||||
'email' => 'amareassessoriaeventos@gmail.com',
|
||||
'phone' => '(11) 99999-9999',
|
||||
|
||||
@@ -23,6 +23,7 @@ class DatabaseSeeder extends Seeder
|
||||
'password' => Hash::make('password'),
|
||||
'role' => UserRole::Admin,
|
||||
'is_active' => true,
|
||||
'email_verified_at' => now(),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -33,6 +34,7 @@ class DatabaseSeeder extends Seeder
|
||||
'password' => Hash::make('password'),
|
||||
'role' => UserRole::Assistant,
|
||||
'is_active' => true,
|
||||
'email_verified_at' => now(),
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
/**
|
||||
* Deterministic content for visual regression. Keep separate from ContentSeeder demo data.
|
||||
* Deterministic content and image fixtures. Keep separate from ContentSeeder demo data.
|
||||
*/
|
||||
class VisualContentSeeder extends Seeder
|
||||
{
|
||||
@@ -30,6 +30,7 @@ class VisualContentSeeder extends Seeder
|
||||
$this->seedServices();
|
||||
$this->seedPortfolioCases();
|
||||
$this->seedTestimonials();
|
||||
$this->call(WeddingPackagesSeeder::class);
|
||||
}
|
||||
|
||||
private function seedSiteSettings(): void
|
||||
@@ -42,14 +43,23 @@ class VisualContentSeeder extends Seeder
|
||||
'hero_cta_label' => 'Solicitar proposta',
|
||||
'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_image_path' => $this->writeSolidJpeg('visual/heroes/home.jpg', 1600, 1100, [218, 224, 205]),
|
||||
'hero_image_alt' => 'Imagem editorial da home',
|
||||
'services_hero_image_path' => $this->writeSolidJpeg('visual/heroes/services.jpg', 1600, 1100, [196, 200, 184]),
|
||||
'services_hero_image_alt' => 'Imagem editorial de Serviços',
|
||||
'portfolio_hero_image_path' => $this->writeSolidJpeg('visual/heroes/portfolio.jpg', 1600, 1100, [228, 226, 221]),
|
||||
'portfolio_hero_image_alt' => 'Imagem editorial de Portfólio',
|
||||
'about_summary' => 'Assessoria boutique especializada em experiências memoráveis em São Paulo.',
|
||||
'about_image_path' => $this->writeSolidJpeg('visual/about/about-image.jpg', 1200, 900, [232, 228, 218]),
|
||||
'about_image_alt' => 'Imagem editorial da página Sobre',
|
||||
'founder_image_path' => $this->writeSolidJpeg('visual/about/founder-michele.jpg', 900, 1200, [210, 205, 192]),
|
||||
'founder_image_alt' => 'Michele, da Amare',
|
||||
'manifesto_title' => 'Sofisticação que também se traduz em organização.',
|
||||
'manifesto_lead' => 'Um evento memorável não nasce apenas de uma boa estética. Ele depende de decisões bem conduzidas, fornecedores alinhados e atenção constante ao que realmente importa.',
|
||||
'manifesto_body' => 'A Amare combina sensibilidade e precisão para criar encontros coerentes com cada cliente, marca e ocasião — sem fórmulas prontas, excessos ou ruído.',
|
||||
'method_intro' => 'Clareza em cada etapa. Tranquilidade durante todo o processo.',
|
||||
'method_steps' => SiteSetting::defaultMethodSteps(),
|
||||
'corporate_steps' => SiteSetting::defaultCorporateSteps(),
|
||||
'principles' => SiteSetting::defaultPrinciples(),
|
||||
'email' => 'amareassessoriaeventos@gmail.com',
|
||||
'phone' => '(11) 99999-9999',
|
||||
@@ -192,16 +202,22 @@ class VisualContentSeeder extends Seeder
|
||||
*/
|
||||
private function writeSolidJpeg(string $destination, int $width, int $height, array $rgb): string
|
||||
{
|
||||
$disk = Storage::disk('public');
|
||||
|
||||
if ($disk->exists($destination)) {
|
||||
return $destination;
|
||||
}
|
||||
|
||||
$image = imagecreatetruecolor($width, $height);
|
||||
$color = imagecolorallocate($image, $rgb[0], $rgb[1], $rgb[2]);
|
||||
imagefilledrectangle($image, 0, 0, $width, $height, $color);
|
||||
|
||||
ob_start();
|
||||
imagejpeg($image, null, 90);
|
||||
imagejpeg($image, null, 80);
|
||||
$binary = (string) ob_get_clean();
|
||||
imagedestroy($image);
|
||||
|
||||
Storage::disk('public')->put($destination, $binary);
|
||||
$disk->put($destination, $binary);
|
||||
|
||||
return $destination;
|
||||
}
|
||||
|
||||
175
database/seeders/WeddingPackagesSeeder.php
Normal file
175
database/seeders/WeddingPackagesSeeder.php
Normal file
@@ -0,0 +1,175 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\WeddingPackage;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* Wedding package modalities shown on the home page (03 — Amare Casamentos)
|
||||
* and the services page. Copy is approved from the structural model preview
|
||||
* (preview(2).html) and remains editable via the Filament resource. Detail
|
||||
* copy follows the complete-assessoria preview
|
||||
* (amare-assessoria-completa.html) adapted to each modality.
|
||||
*/
|
||||
class WeddingPackagesSeeder extends Seeder
|
||||
{
|
||||
private const PUBLISHED_AT = '2026-08-11 00:00:00';
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$packages = [
|
||||
[
|
||||
'name' => 'Essenza',
|
||||
'slug' => 'essenza',
|
||||
'level' => '01 / COMPLETA',
|
||||
'tag' => 'Assessoria completa',
|
||||
'subtitle' => 'Do planejamento ao grande dia.',
|
||||
'summary' => 'Para casais que desejam contar com a Amare desde o início ou ainda estão estruturando etapas importantes.',
|
||||
'scope_items' => [
|
||||
'Estruturação do planejamento',
|
||||
'Cronogramas, prazos e prioridades',
|
||||
'Orientação e gestão de fornecedores',
|
||||
'Alinhamento entre os envolvidos',
|
||||
'Coordenação da execução',
|
||||
],
|
||||
'cta_label' => 'Quero conhecer a Essenza',
|
||||
'compare_heading' => 'Começar com a Amare',
|
||||
'compare_summary' => 'Acompanhamento mais amplo ao longo do planejamento.',
|
||||
'sort_order' => 1,
|
||||
'eyebrow' => 'Assessoria',
|
||||
'title_line' => 'Assessoria',
|
||||
'title_emphasis' => 'Completa',
|
||||
'hero_lead' => 'Do planejamento ao grande dia, com calma, precisão e cuidado.',
|
||||
'benefits' => [
|
||||
['icon_key' => 'calendar', 'label' => 'Organização integral'],
|
||||
['icon_key' => 'checklist', 'label' => 'Planejamento personalizado'],
|
||||
['icon_key' => 'users', 'label' => 'Fornecedores selecionados'],
|
||||
['icon_key' => 'heart', 'label' => 'Experiência leve e inesquecível'],
|
||||
],
|
||||
'included_items' => [
|
||||
['icon_key' => 'heart', 'title' => 'Escuta e briefing', 'description' => 'Cada casal é único; começamos por ouvir.'],
|
||||
['icon_key' => 'checklist', 'title' => 'Planejamento completo', 'description' => 'Cada etapa mapeada com clareza.'],
|
||||
['icon_key' => 'map', 'title' => 'Gestão de fornecedores', 'description' => 'Contatos e prazos sob cuidado.'],
|
||||
['icon_key' => 'calendar', 'title' => 'Acompanhamento contínuo', 'description' => 'Perto do casal em cada decisão.'],
|
||||
['icon_key' => 'spark', 'title' => 'Produção e logística', 'description' => 'Dia a dia do evento organizado nos detalhes.'],
|
||||
['icon_key' => 'users', 'title' => 'Dia do evento', 'description' => 'Coordenação total para o casal viver o momento.'],
|
||||
],
|
||||
'audience_heading' => 'Para quem é este pacote',
|
||||
'audience_intro' => 'Para quem quer uma assessoria completa, do início ao fim.',
|
||||
'audience_points' => [
|
||||
'Casamentos',
|
||||
'Aniversários',
|
||||
'Eventos sociais e celebrações',
|
||||
'Eventos corporativos',
|
||||
],
|
||||
'final_cta_heading' => 'Pronta para começar?',
|
||||
'final_cta_body' => 'Conte seu evento para a Amare e receba uma proposta.',
|
||||
],
|
||||
[
|
||||
'name' => 'Conduzione',
|
||||
'slug' => 'conduzione',
|
||||
'level' => '02 / PARCIAL',
|
||||
'tag' => 'Assessoria parcial',
|
||||
'subtitle' => 'Para quem já começou e quer seguir acompanhado.',
|
||||
'summary' => 'Para casais que já tomaram decisões e contrataram parte dos fornecedores, mas precisam estruturar o que falta.',
|
||||
'scope_items' => [
|
||||
'Diagnóstico do planejamento existente',
|
||||
'Organização das pendências',
|
||||
'Gestão dos fornecedores contratados',
|
||||
'Orientação para próximas decisões',
|
||||
'Coordenação da execução',
|
||||
],
|
||||
'cta_label' => 'Quero conhecer a Conduzione',
|
||||
'compare_heading' => 'Trazer a Amare para o caminho',
|
||||
'compare_summary' => 'O planejamento existe, mas ainda há decisões e gestão pela frente.',
|
||||
'sort_order' => 2,
|
||||
'eyebrow' => 'Assessoria',
|
||||
'title_line' => 'Assessoria',
|
||||
'title_emphasis' => 'Parcial',
|
||||
'hero_lead' => 'Para retomar o planejamento, organizar o que falta e seguir com acompanhamento profissional.',
|
||||
'benefits' => [
|
||||
['icon_key' => 'checklist', 'label' => 'Diagnóstico completo'],
|
||||
['icon_key' => 'calendar', 'label' => 'Organização integral'],
|
||||
['icon_key' => 'users', 'label' => 'Fornecedores selecionados'],
|
||||
['icon_key' => 'heart', 'label' => 'Experiência leve e inesquecível'],
|
||||
],
|
||||
'included_items' => [
|
||||
['icon_key' => 'checklist', 'title' => 'Diagnóstico do planejamento', 'description' => 'Um olhar profissional sobre o que já existe.'],
|
||||
['icon_key' => 'calendar', 'title' => 'Organização das pendências', 'description' => 'Prioridades claras, etapa a etapa.'],
|
||||
['icon_key' => 'map', 'title' => 'Gestão de fornecedores', 'description' => 'Contatos e prazos sob cuidado.'],
|
||||
['icon_key' => 'spark', 'title' => 'Gestão dos próximos passos', 'description' => 'Um plano para chegar tranquilo ao grande dia.'],
|
||||
['icon_key' => 'users', 'title' => 'Acompanhamento contínuo', 'description' => 'Perto do casal em cada decisão.'],
|
||||
['icon_key' => 'heart', 'title' => 'Dia do evento', 'description' => 'Coordenação total para o casal viver o momento.'],
|
||||
],
|
||||
'audience_heading' => 'Para quem é este pacote',
|
||||
'audience_intro' => 'Para quem já começou a planejar e quer retomar as rédeas com acompanhamento profissional.',
|
||||
'audience_points' => [
|
||||
'Casamentos em andamento',
|
||||
'Casais com pouco tempo disponível',
|
||||
'Quem precisa priorizar pendências',
|
||||
'Quem busca fornecedores confiáveis',
|
||||
],
|
||||
'final_cta_heading' => 'Pronta para retomar?',
|
||||
'final_cta_body' => 'Conte onde seu planejamento está e receba uma proposta.',
|
||||
],
|
||||
[
|
||||
'name' => 'Grand Jour',
|
||||
'slug' => 'grand-jour',
|
||||
'level' => '03 / FINAL',
|
||||
'tag' => 'Assessoria final',
|
||||
'subtitle' => 'O planejamento está pronto. Agora é hora de viver.',
|
||||
'summary' => 'Para casais na reta final que precisam de uma equipe para assumir alinhamentos, cronograma e operação.',
|
||||
'scope_items' => [
|
||||
'Imersão no planejamento existente',
|
||||
'Conferência das informações',
|
||||
'Alinhamento dos fornecedores',
|
||||
'Organização do cronograma final',
|
||||
'Gestão da operação do evento',
|
||||
],
|
||||
'cta_label' => 'Quero conhecer a Grand Jour',
|
||||
'compare_heading' => 'Entregar a operação para a Amare',
|
||||
'compare_summary' => 'O projeto está pronto e o foco passa a ser alinhamento e execução.',
|
||||
'sort_order' => 3,
|
||||
'eyebrow' => 'Assessoria',
|
||||
'title_line' => 'Assessoria',
|
||||
'title_emphasis' => 'Final',
|
||||
'hero_lead' => 'Para assumir os alinhamentos e a operação quando o grande dia está próximo.',
|
||||
'benefits' => [
|
||||
['icon_key' => 'checklist', 'label' => 'Imersão no que já existe'],
|
||||
['icon_key' => 'users', 'label' => 'Alinhamento de fornecedores'],
|
||||
['icon_key' => 'calendar', 'label' => 'Cronograma final'],
|
||||
['icon_key' => 'heart', 'label' => 'Operação com leveza'],
|
||||
],
|
||||
'included_items' => [
|
||||
['icon_key' => 'checklist', 'title' => 'Imersão no planejamento', 'description' => 'Entendemos cada decisão já tomada.'],
|
||||
['icon_key' => 'map', 'title' => 'Alinhamento de fornecedores', 'description' => 'Contatos e prazos sob cuidado.'],
|
||||
['icon_key' => 'calendar', 'title' => 'Cronograma final', 'description' => 'Cada etapa posicionada na reta final.'],
|
||||
['icon_key' => 'spark', 'title' => 'Ensaios e detalhes', 'description' => 'Produção e logística nos detalhes.'],
|
||||
['icon_key' => 'users', 'title' => 'Operação do evento', 'description' => 'Gestão da equipe e do dia a dia.'],
|
||||
['icon_key' => 'heart', 'title' => 'Dia do evento', 'description' => 'Coordenação total para o casal viver o momento.'],
|
||||
],
|
||||
'audience_heading' => 'Para quem é este pacote',
|
||||
'audience_intro' => 'Para casais já planejados que querem chegar ao grande dia com equipe e operação no lugar.',
|
||||
'audience_points' => [
|
||||
'Casamentos já planejados',
|
||||
'Casais na reta final',
|
||||
'Quem precisa de operação no dia',
|
||||
'Quem quer viver o momento com leveza',
|
||||
],
|
||||
'final_cta_heading' => 'Pronta para o grande dia?',
|
||||
'final_cta_body' => 'Conte a data do seu evento e receba uma proposta.',
|
||||
],
|
||||
];
|
||||
|
||||
foreach ($packages as $package) {
|
||||
WeddingPackage::query()->updateOrCreate(
|
||||
['name' => $package['name']],
|
||||
[...$package, 'published_at' => Carbon::parse(self::PUBLISHED_AT)],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,12 @@
|
||||
> **Status deste arquivo:** material bruto histórico, não consumido por
|
||||
> nenhum código do site. Os cinco depoimentos abaixo foram transcritos
|
||||
> manualmente para dentro de `database/seeders/TestimonialsSeeder.php` e,
|
||||
> em produção, o conteúdo real vive no banco de dados, editável apenas
|
||||
> pela tela **Depoimentos** do painel administrativo (`/admin`). Editar
|
||||
> este arquivo não tem nenhum efeito no site publicado. Mantido apenas como
|
||||
> referência histórica de onde o conteúdo original veio. Ver
|
||||
> `docs/operations/atualizacao-de-conteudo.md` para o fluxo de edição real.
|
||||
|
||||
Mi, quero agradecer você e a sua equipe por todo empenho, atenção, vocês são abençoadas.
|
||||
Era nítida sua preocupação em garantir que todos os detalhes planejados desta comemoração, fossem atendidos.
|
||||
Que você possa transformar o grande dia das noivinhas sempre com essa sua leveza!!! ❤️
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Shared Compose for Dokploy staging and production.
|
||||
# Both stacks use the same file with different env:
|
||||
# APP_IMAGE=ghcr.io/<owner>/<repo>
|
||||
# APP_IMAGE=git.hellomanoel.com/manoel-freitas/amare
|
||||
# IMAGE_TAG=staging|production|<git-sha>
|
||||
# PostgreSQL is a separate Dokploy database service (not defined here).
|
||||
# Traefik/Dokploy domains should target service `web` port 8000.
|
||||
@@ -14,12 +14,13 @@ services:
|
||||
restart: "no"
|
||||
env_file:
|
||||
- .env
|
||||
command: >
|
||||
sh -c
|
||||
"php artisan migrate --force --no-interaction
|
||||
&& (php artisan db:seed --class=ContentSeeder --force --no-interaction 2>&1
|
||||
&& php artisan media:generate-variants --force 2>&1) || true
|
||||
&& echo 'migrate+seed done'"
|
||||
# Exec-array form on a single line, deliberately. A folded block scalar
|
||||
# (`command: >`) keeps the newline before any line indented deeper than the
|
||||
# first, so `sh -c` receives a multi-line string and dies with
|
||||
# `sh: 2: Syntax error: "&&" unexpected` before running anything. That broke
|
||||
# every staging deploy from 58f24a6 onward, and had already broken them once
|
||||
# before 5949fad. Keep this on one line; do not reformat it for width.
|
||||
command: ["sh", "-c", "php artisan migrate --force --no-interaction && php artisan db:seed --class=ContentSeeder --force --no-interaction && php artisan media:generate-variants"]
|
||||
networks:
|
||||
- dokploy-network
|
||||
|
||||
|
||||
@@ -18,5 +18,34 @@ services:
|
||||
retries: 10
|
||||
start_period: 10s
|
||||
|
||||
# Local parity with the FrankenPHP image used on staging/production.
|
||||
# Manual smoke test (not run in CI — the `container` job already builds and
|
||||
# health-checks the same Dockerfile-based image):
|
||||
# docker compose up -d
|
||||
# curl localhost:8000/up
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: amare-app
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
# Override host-side .env defaults: DB_HOST=127.0.0.1 only resolves for
|
||||
# processes running on the host, not for this container reaching the
|
||||
# `postgres` service by its Compose service name. DB_PORT is also
|
||||
# forced back to Postgres's internal container port (5432) — .env may
|
||||
# have DB_PORT remapped for host-side tooling (e.g. to avoid a local
|
||||
# port clash), but that remapping only applies to the published host
|
||||
# port, never to container-to-container traffic.
|
||||
DB_HOST: postgres
|
||||
DB_PORT: "5432"
|
||||
ports:
|
||||
- "8000:8000"
|
||||
|
||||
volumes:
|
||||
amare_postgres_data:
|
||||
|
||||
@@ -5,5 +5,20 @@
|
||||
:8000 {
|
||||
root * /app/public
|
||||
encode gzip zstd
|
||||
|
||||
# Vite emits content-hashed filenames, so a build asset URL never changes
|
||||
# meaning. Same for uploaded media: PublicImageUploadRules stores every
|
||||
# upload under a fresh UUID (and ResponsiveImage derives its variants from
|
||||
# that name), so replacing an image produces a new URL rather than new bytes
|
||||
# at the old one. Both are safe to pin for a year.
|
||||
@immutable path /build/* /storage/*
|
||||
header @immutable Cache-Control "public, max-age=31536000, immutable"
|
||||
|
||||
# Brand assets ship inside the image under stable filenames, so a rebrand
|
||||
# reuses the same URL. One day plus Caddy's ETag revalidation keeps repeat
|
||||
# views cheap without pinning an outdated logo in browsers.
|
||||
@brand path /brand/*
|
||||
header @brand Cache-Control "public, max-age=86400"
|
||||
|
||||
php_server
|
||||
}
|
||||
|
||||
51
docs/agents/domain.md
Normal file
51
docs/agents/domain.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# Domain Docs
|
||||
|
||||
How the engineering skills should consume this repo's domain documentation when exploring the codebase.
|
||||
|
||||
## Before exploring, read these
|
||||
|
||||
- **`CONTEXT.md`** at the repo root, or
|
||||
- **`CONTEXT-MAP.md`** at the repo root if it exists — it points at one `CONTEXT.md` per context. Read each one relevant to the topic.
|
||||
- **`docs/adr/`** — read ADRs that touch the area you're about to work in. In multi-context repos, also check `src/<context>/docs/adr/` for context-scoped decisions.
|
||||
|
||||
If any of these files don't exist, **proceed silently**. Don't flag their absence; don't suggest creating them upfront. The `/domain-modeling` skill (reached via `/grill-with-docs` and `/improve-codebase-architecture`) creates them lazily when terms or decisions actually get resolved.
|
||||
|
||||
## File structure
|
||||
|
||||
Single-context repo (most repos):
|
||||
|
||||
```
|
||||
/
|
||||
├── CONTEXT.md
|
||||
├── docs/adr/
|
||||
│ ├── 0001-event-sourced-orders.md
|
||||
│ └── 0002-postgres-for-write-model.md
|
||||
└── src/
|
||||
```
|
||||
|
||||
Multi-context repo (presence of `CONTEXT-MAP.md` at the root):
|
||||
|
||||
```
|
||||
/
|
||||
├── CONTEXT-MAP.md
|
||||
├── docs/adr/ ← system-wide decisions
|
||||
└── src/
|
||||
├── ordering/
|
||||
│ ├── CONTEXT.md
|
||||
│ └── docs/adr/ ← context-specific decisions
|
||||
└── billing/
|
||||
├── CONTEXT.md
|
||||
└── docs/adr/
|
||||
```
|
||||
|
||||
## Use the glossary's vocabulary
|
||||
|
||||
When your output names a domain concept (in an issue title, a refactor proposal, a hypothesis, a test name), use the term as defined in `CONTEXT.md`. Don't drift to synonyms the glossary explicitly avoids.
|
||||
|
||||
If the concept you need isn't in the glossary yet, that's a signal — either you're inventing language the project doesn't use (reconsider) or there's a real gap (note it for `/domain-modeling`).
|
||||
|
||||
## Flag ADR conflicts
|
||||
|
||||
If your output contradicts an existing ADR, surface it explicitly rather than silently overriding:
|
||||
|
||||
> _Contradicts ADR-0007 (event-sourced orders) — but worth reopening because…_
|
||||
36
docs/agents/issue-tracker.md
Normal file
36
docs/agents/issue-tracker.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# Issue tracker: Linear
|
||||
|
||||
Issues and specs for this repo live in Linear. Use the Linear MCP tools for all operations.
|
||||
|
||||
- **Workspace**: maneco-workspace (https://linear.app/maneco-workspace)
|
||||
- **Team**: Maneco-workspace
|
||||
- **Issue statuses**: Backlog, Todo, In Progress, Done, Canceled, Duplicate
|
||||
|
||||
## Conventions
|
||||
|
||||
- **Create an issue**: `save_issue` with `title`, `team` (`Maneco-workspace`), and markdown `description`. Assign via `assignee` ("me" for the current user).
|
||||
- **Read an issue**: `get_issue` by identifier (e.g. `LIN-123`).
|
||||
- **List issues**: `list_issues` filtered by `team`, `assignee`, `state`, or `project`.
|
||||
- **Comment on an issue**: `save_comment` with `issueId` and markdown `body`.
|
||||
- **Apply / remove labels**: `save_issue` with `labels` (replaces the full set).
|
||||
- **Move workflow state**: `save_issue` with `state` (e.g. `Todo`, `In Progress`, `Done`, `Canceled`).
|
||||
- **Link work**: `save_issue` with `project`, `cycle`, `parentId`, `blocks` / `blockedBy` (relations are append-only).
|
||||
- **Cross-link to a PR**: attach the PR URL via `save_issue` `links`.
|
||||
|
||||
## Required: issue before work and before PR
|
||||
|
||||
Every branch, worktree, and pull request in this repo must be tied to a Linear issue.
|
||||
|
||||
1. **Starting work:** require an identifier (e.g. `MAN-132`). If the user did not give one, search Linear. If none fits, create the issue with `save_issue` on team `Maneco-workspace` *before* creating a branch or worktree.
|
||||
2. **Branch:** include the identifier (`docs/man-132-…`, `feat/man-126-…`).
|
||||
3. **PR:** cite identifier + URL in the body. An OpenSpec change is extra context, not a substitute.
|
||||
4. **After the PR exists:** attach the PR URL on the issue via `save_issue` `links`. Do this as part of the automatic SDLC in `AGENTS.md` — do not ask before opening the PR.
|
||||
5. If Linear MCP is unavailable, stop and report — do not start untracked work.
|
||||
|
||||
## When a skill says "publish to the issue tracker"
|
||||
|
||||
Create a Linear issue with `save_issue` on team `Maneco-workspace`.
|
||||
|
||||
## When a skill says "fetch the relevant ticket"
|
||||
|
||||
Run `get_issue` on the identifier and read the description, state, labels, and assignee.
|
||||
15
docs/agents/triage-labels.md
Normal file
15
docs/agents/triage-labels.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# Triage Labels
|
||||
|
||||
The skills speak in terms of five canonical triage roles. This file maps those roles to the actual label strings used in this repo's issue tracker.
|
||||
|
||||
| Label in mattpocock/skills | Label in our tracker | Meaning |
|
||||
| -------------------------- | -------------------- | ---------------------------------------- |
|
||||
| `needs-triage` | `needs-triage` | Maintainer needs to evaluate this issue |
|
||||
| `needs-info` | `needs-info` | Waiting on reporter for more information |
|
||||
| `ready-for-agent` | `ready-for-agent` | Fully specified, ready for an AFK agent |
|
||||
| `ready-for-human` | `ready-for-human` | Requires human implementation |
|
||||
| `wontfix` | `wontfix` | Will not be actioned |
|
||||
|
||||
When a skill mentions a role (e.g. "apply the AFK-ready triage label"), use the corresponding label string from this table.
|
||||
|
||||
Edit the right-hand column to match whatever vocabulary you actually use.
|
||||
@@ -1,11 +1,11 @@
|
||||
# Deploy Dokploy (staging → production)
|
||||
|
||||
Runbook for operating Amare on a VPS with Dokploy connected to GitHub, publishing immutable images to GHCR.
|
||||
Runbook for operating Amare on a VPS with Dokploy connected to Gitea (git.hellomanoel.com), publishing immutable images to Gitea's container registry.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
CI (main) → build FrankenPHP image → GHCR :<sha> + :staging
|
||||
CI (main) → build FrankenPHP image → git.hellomanoel.com registry :<sha> + :staging
|
||||
→ Dokploy staging compose.deploy
|
||||
→ smoke /up / /admin/login
|
||||
|
||||
@@ -18,7 +18,7 @@ Promote (manual) → retag same digest as :production (no rebuild)
|
||||
|---|---|
|
||||
| Compose file | [`docker-compose.deploy.yml`](../../docker-compose.deploy.yml) |
|
||||
| Processes | `migrate` (one-shot) → `web` / `queue` / `scheduler` |
|
||||
| Image | `ghcr.io/<owner>/<repo>:<sha>` (+ aliases `:staging`, `:production`) |
|
||||
| Image | `git.hellomanoel.com/manoel-freitas/amare:<sha>` (+ aliases `:staging`, `:production`) |
|
||||
| Database | Dokploy PostgreSQL **per environment** (not in the app image) |
|
||||
| Media | Cloudflare R2 (`FILESYSTEM_DISK=r2`), separate buckets per environment |
|
||||
| Mail | Resend (`MAIL_MAILER=resend`) |
|
||||
@@ -26,11 +26,14 @@ Promote (manual) → retag same digest as :production (no rebuild)
|
||||
|
||||
## Prerequisites (manual)
|
||||
|
||||
1. Dokploy installed on the VPS; GitHub provider connected.
|
||||
2. GHCR registry in Dokploy (`ghcr.io`) with a PAT that can **read** packages (`read:packages`). Prefer a dedicated bot/token; do not store write tokens on the VPS.
|
||||
3. Two PostgreSQL services in Dokploy (staging + production), private (no public port).
|
||||
4. Two R2 buckets (or prefixes) and Resend credentials for each environment as needed.
|
||||
5. Domains (or temporary Dokploy/traefik.me hosts) pointing at the VPS with TLS.
|
||||
1. Gitea repository `manoel-freitas/amare` at `https://git.hellomanoel.com`; **Repository Actions enabled** in repo settings; a registered **Gitea Actions runner** (see [docs.gitea.com usage/actions/quickstart](https://docs.gitea.com/usage/actions/quickstart)) with an `ubuntu-latest` label.
|
||||
2. Dokploy installed on the VPS.
|
||||
3. Container registry in Dokploy (`git.hellomanoel.com`) with a PAT that can **read** packages (`read:package`). Prefer a dedicated bot/token; do not store write tokens on the VPS. The **write** PAT (`write:package`) lives only in Gitea repo secrets for the pipeline.
|
||||
4. Two PostgreSQL services in Dokploy (staging + production), private (no public port).
|
||||
5. Two R2 buckets (or prefixes) and Resend credentials for each environment as needed.
|
||||
6. Domains (or temporary Dokploy/traefik.me hosts) pointing at the VPS with TLS.
|
||||
|
||||
Note: Gitea's `GITEA_TOKEN` cannot push OCI packages ([gitea#23642](https://github.com/go-gitea/gitea/issues/23642)); registry authentication in the workflows uses a PAT (`REGISTRY_PAT`), not the token. Secret names must not use the reserved `GITEA_` prefix (Gitea rejects them as invalid).
|
||||
|
||||
## Create Compose stacks
|
||||
|
||||
@@ -44,7 +47,7 @@ Create **two** Dokploy Compose services (same repo, same compose path):
|
||||
Dokploy Environment for each stack must set:
|
||||
|
||||
```bash
|
||||
APP_IMAGE=ghcr.io/<owner>/<repo>
|
||||
APP_IMAGE=git.hellomanoel.com/manoel-freitas/amare
|
||||
IMAGE_TAG=staging # or production
|
||||
```
|
||||
|
||||
@@ -52,11 +55,11 @@ Point Dokploy domain(s) at service **`web`**, port **`8000`**. Do not publish Po
|
||||
|
||||
Compose services must join the external Docker network `dokploy-network` (declared in `docker-compose.deploy.yml`) so they can resolve the Dokploy-managed Postgres internal host (e.g. `amare-stg-pez43e`). Set `DB_HOST` to that **Internal Host** from the Dokploy database UI — not a public hostname.
|
||||
|
||||
Source can be GitHub (so Dokploy clones the compose file) or Raw paste of `docker-compose.deploy.yml`. Prefer GitHub + fixed compose path so updates stay in sync with `main`.
|
||||
Source can be Gitea (so Dokploy clones the compose file) or Raw paste of `docker-compose.deploy.yml`. Prefer Gitea + fixed compose path so updates stay in sync with `main`.
|
||||
|
||||
## Required Laravel env (Dokploy only)
|
||||
|
||||
Set these in Dokploy Environment UI (written to `.env` next to the compose file). **Never** put them in GitHub Actions secrets or image layers.
|
||||
Set these in Dokploy Environment UI (written to `.env` next to the compose file). **Never** put them in Gitea Actions secrets or image layers.
|
||||
|
||||
```env
|
||||
APP_NAME=Amare
|
||||
@@ -67,7 +70,7 @@ APP_URL=https://staging.example.com
|
||||
|
||||
APP_LOCALE=pt_BR
|
||||
APP_FALLBACK_LOCALE=pt_BR
|
||||
APP_TIMEZONE=America/Fortaleza
|
||||
APP_TIMEZONE=America/Sao_Paulo
|
||||
|
||||
DB_CONNECTION=pgsql
|
||||
DB_HOST=<dokploy-postgres-internal-host> # Internal Host from Dokploy UI (requires dokploy-network)
|
||||
@@ -120,9 +123,9 @@ Upload path does not need R2 CORS with the local temp-disk default. Still useful
|
||||
|
||||
Also enable public access / custom domain for `R2_URL` so `<img>` URLs work after save.
|
||||
|
||||
## GitHub Actions secrets
|
||||
## Gitea Actions secrets
|
||||
|
||||
Repository secrets used by workflows:
|
||||
Repository secrets (Gitea → Settings → Actions → Secrets) used by workflows:
|
||||
|
||||
| Secret | Purpose |
|
||||
|---|---|
|
||||
@@ -132,32 +135,48 @@ Repository secrets used by workflows:
|
||||
| `DOKPLOY_PRODUCTION_COMPOSE_ID` | Production **Compose** service id (not an Application id) |
|
||||
| `STAGING_URL` | Public origin for staging smoke (e.g. `https://staging.example.com`) |
|
||||
| `PRODUCTION_URL` | Public origin for production smoke |
|
||||
| `REGISTRY_PAT` | Personal Access Token with `read:package` + `write:package` scopes — used to push images to `git.hellomanoel.com` (do not name secrets `GITEA_*`; that prefix is reserved) |
|
||||
| `REGISTRY_USER` | Gitea username that owns `REGISTRY_PAT` (`manoel-freitas`) |
|
||||
|
||||
HTTP 404 from `compose.deploy` usually means the compose id is wrong (Application id instead of Compose) or `DOKPLOY_URL` still includes `/api`.
|
||||
|
||||
`GITHUB_TOKEN` (automatic) publishes to GHCR with `packages:write`. No Laravel/`APP_KEY`/DB/R2/Resend secrets belong in GitHub for this pipeline.
|
||||
The automatic `GITEA_TOKEN` runs workflows but **cannot push OCI packages** ([gitea#23642](https://github.com/go-gitea/gitea/issues/23642)); registry authentication therefore uses `REGISTRY_PAT` + `REGISTRY_USER`. No Laravel/`APP_KEY`/DB/R2/Resend secrets belong in Gitea for this pipeline.
|
||||
|
||||
## Workflows
|
||||
|
||||
Workflows live in [`.gitea/workflows/`](../../.gitea/workflows/).
|
||||
|
||||
`actions/cache` and Docker Buildx `type=gha` are **not** used: the act_runner cache server is not reachable from job containers by default (`getCacheEntry` ETIMEDOUT). Re-enable only after configuring a reachable `cache.host`/`external_server` on the runner.
|
||||
|
||||
CI Postgres services must **not** publish host port `5432` (use service hostname `postgres` on the job network). Publishing `5432:5432` on a shared VPS runner fails with `Bind for 0.0.0.0:5432 failed: port is already allocated` when another job/orphan still holds the port.
|
||||
|
||||
Parallel CI jobs need the act_runner `config.yaml` to keep `container.network` **empty** (per-job Docker network + service DNS) and `runner.capacity` ≥ 2. Setting `network: bridge` puts every job on the default bridge and makes parallel Postgres collide. Nested app containers (browser/container jobs) must join that job network by name and must **not** publish host `:8000`. On the current 1 vCPU / ~4 GiB VPS, `capacity: 2` is the safe ceiling.
|
||||
|
||||
### Staging (automatic)
|
||||
|
||||
[`.github/workflows/deploy-staging.yml`](../../.github/workflows/deploy-staging.yml)
|
||||
[`.gitea/workflows/deploy-staging.yml`](../../.gitea/workflows/deploy-staging.yml) — separate workflow, triggered by `workflow_run` when **CI** completes on `main`.
|
||||
|
||||
1. Waits for workflow `CI` success on push to `main`.
|
||||
2. Builds once; pushes `:<full-sha>` and `:staging`.
|
||||
Requires **Gitea ≥ 1.25** (`workflow_run` is not implemented as an Actions trigger in 1.24.x). Match both workflow display name `CI` and file id `ci.yml`.
|
||||
|
||||
1. Waits for workflow `CI` success (`workflow_run`) on push to `main`.
|
||||
2. Builds once; pushes `:<full-sha>` and `:staging` to the Gitea registry.
|
||||
3. Calls Dokploy `compose.deploy` and polls until done.
|
||||
4. Runs [`scripts/deploy/smoke.sh`](../../scripts/deploy/smoke.sh) against `STAGING_URL`.
|
||||
|
||||
Manual re-deploy: **Actions → Deploy staging → Run workflow** (`workflow_dispatch`).
|
||||
|
||||
`DOKPLOY_API_KEY` must be the **plaintext** key from Dokploy → Profile → API (starts like `amare…`). Do not paste the hashed `apikey.key` column from Postgres — that yields HTTP 401.
|
||||
|
||||
### Production (manual)
|
||||
|
||||
[`.github/workflows/promote-production.yml`](../../.github/workflows/promote-production.yml)
|
||||
[`.gitea/workflows/promote-production.yml`](../../.gitea/workflows/promote-production.yml)
|
||||
|
||||
1. Operator runs **Actions → Promote production**.
|
||||
2. Inputs: full `sha` already on GHCR; `confirm` must be exactly `PRODUCTION`.
|
||||
1. Operator runs **Gitea → Actions → Promote production**.
|
||||
2. Inputs: full `sha` already in the Gitea registry; `confirm` must be exactly `PRODUCTION`.
|
||||
3. Retags the **same digest** as `:production` (no rebuild).
|
||||
4. Deploys production compose + smoke.
|
||||
|
||||
Private repos on GitHub Free do not get Environment required reviewers; human approval is the explicit `workflow_dispatch` + confirmation string. GitHub Pro Environment reviewers are optional later.
|
||||
Human approval is the explicit `workflow_dispatch` + confirmation string (Gitea does not support GitHub Environment required reviewers).
|
||||
|
||||
## First admin and authorized production seeding
|
||||
|
||||
@@ -219,7 +238,11 @@ echo (\$valid ? 'ok' : 'invalid').PHP_EOL;
|
||||
|
||||
Expected output: `ok`.
|
||||
|
||||
Do not run `DatabaseSeeder` or `ContentSeeder` in staging or production: they include local credentials and/or broad demo-content effects. Deployment workflows intentionally remain migrate-only; loading these testimonials is a deliberate manual operation in each environment.
|
||||
The `migrate` service in `docker-compose.deploy.yml` runs `php artisan db:seed --class=ContentSeeder --force --no-interaction` on every deploy, in both stacks. `ContentSeeder::run()` guards itself with an **allow-list** — `App::environment(['local', 'staging', 'testing'])` — and returns immediately (exit code 0, no side effects) unless `APP_ENV` is exactly one of those three values. In **staging** (`APP_ENV=staging`) the guard matches, so `ContentSeeder` still seeds its demo content on every deploy — this is required for visual review and is expected behavior, not a bug. In **production** (`APP_ENV=production`), and for any blank, mistyped, or unexpectedly cased `APP_ENV` value in any stack, the guard does not match, so the step is a deliberate no-op: it never overwrites SiteSetting/Service/PortfolioCase records edited in Filament, never re-uploads fixture images to the production storage disk, and never auto-publishes the fictional portfolio cases. Note the tradeoff this implies: if staging's `APP_ENV` is ever typo'd away from exactly `staging`, demo content silently stops being (re)seeded there too — check the Dokploy environment value first if a staging deploy stops refreshing demo content.
|
||||
|
||||
Do not run bare `DatabaseSeeder` in staging or production: it also creates the `admin@amare.local` / `password` local-dev credentials.
|
||||
|
||||
Publishing the five real testimonials is a separate, deliberate, manually triggered step **only in production** — `ContentSeeder` calls `TestimonialsSeeder` internally, but that call is skipped in production by the same allow-list guard, so the command above is the only path that publishes testimonials there. In **staging**, this is not manual: because the allow-list guard matches `staging`, `ContentSeeder` calls `TestimonialsSeeder` automatically on every deploy, auto-publishing/re-publishing the five canonical testimonials each time (consistent with staging's role as a demo/preview environment).
|
||||
|
||||
## Backup and restore
|
||||
|
||||
@@ -252,10 +275,10 @@ No rebuild. Move the environment alias to a previous SHA digest and redeploy.
|
||||
### Staging
|
||||
|
||||
```bash
|
||||
# Locally or in a one-off Actions shell with GHCR login
|
||||
# Locally or in a one-off Actions shell with Gitea registry login
|
||||
docker buildx imagetools create \
|
||||
--tag ghcr.io/<owner>/<repo>:staging \
|
||||
ghcr.io/<owner>/<repo>:<previous-sha>
|
||||
--tag git.hellomanoel.com/manoel-freitas/amare:staging \
|
||||
git.hellomanoel.com/manoel-freitas/amare:<previous-sha>
|
||||
|
||||
# Then trigger Dokploy deploy (UI Deploy, or):
|
||||
DOKPLOY_URL=... DOKPLOY_API_KEY=... DOKPLOY_COMPOSE_ID=... \
|
||||
@@ -293,7 +316,7 @@ Expects HTTP 200 for `/up`, `/`, and `/admin/login`.
|
||||
## Local validation of Compose
|
||||
|
||||
```bash
|
||||
APP_IMAGE=ghcr.io/<owner>/<repo> IMAGE_TAG=staging \
|
||||
APP_IMAGE=git.hellomanoel.com/manoel-freitas/amare IMAGE_TAG=staging \
|
||||
docker compose -f docker-compose.deploy.yml config
|
||||
```
|
||||
|
||||
|
||||
409
docs/evidence/lighthouse/2026-08-10-local-antes.md
Normal file
409
docs/evidence/lighthouse/2026-08-10-local-antes.md
Normal file
@@ -0,0 +1,409 @@
|
||||
# Lighthouse — local
|
||||
|
||||
- Origem: `http://127.0.0.1:8000`
|
||||
- Lighthouse: 12.8.2
|
||||
- Navegador: `Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/151.0.0.0 Safari/537.36`
|
||||
- Coletado em: 2026-08-10T15:55:03.101Z
|
||||
- Execuções por página/preset: 3 (reportada a de LCP mediano)
|
||||
- Commit: `2e43fde`
|
||||
- Seeder: `ContentSeeder`
|
||||
|
||||
Metas SPEC §6.6: LCP ≤ 2,5 s · CLS ≤ 0,1 · INP ≤ 200 ms · zero erro de console.
|
||||
|
||||
| página | preset | perf | a11y | BP | SEO | LCP | FCP | CLS | TBT | TTFB servidor | LCP min–max |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| contato | desktop | 100 | 100 | 100 | 100 | 0.53 s | 0.37 s | 0.000 | 0 ms | 55 ms | 0.53 s – 0.53 s |
|
||||
| contato | mobile | 97 | 100 | 100 | 100 | 2.55 s | 1.50 s | 0.000 | 0 ms | 97 ms | 2.55 s – 2.56 s |
|
||||
| home | desktop | 99 | 100 | 100 | 100 | 0.87 s | 0.37 s | 0.000 | 0 ms | 75 ms | 0.87 s – 0.87 s |
|
||||
| home | mobile | 83 | 100 | 100 | 100 | 4.58 s | 1.51 s | 0.000 | 0 ms | 217 ms | 4.36 s – 4.58 s |
|
||||
| portfolio-detalhe | desktop | 99 | 100 | 100 | 100 | 0.97 s | 0.37 s | 0.000 | 0 ms | 89 ms | 0.97 s – 0.97 s |
|
||||
| portfolio-detalhe | mobile | 87 | 100 | 100 | 100 | 3.98 s | 1.51 s | 0.000 | 0 ms | 126 ms | 3.98 s – 4.05 s |
|
||||
| portfolio | desktop | 99 | 100 | 100 | 100 | 0.83 s | 0.37 s | 0.000 | 0 ms | 187 ms | 0.63 s – 0.83 s |
|
||||
| portfolio | mobile | 87 | 100 | 100 | 100 | 3.98 s | 1.51 s | 0.000 | 0 ms | 108 ms | 3.91 s – 4.20 s |
|
||||
| servicos | desktop | 100 | 100 | 100 | 100 | 0.77 s | 0.37 s | 0.000 | 0 ms | 124 ms | 0.77 s – 0.77 s |
|
||||
| servicos | mobile | 89 | 100 | 100 | 100 | 3.68 s | 1.51 s | 0.000 | 0 ms | 78 ms | 3.61 s – 3.68 s |
|
||||
| sobre | desktop | 100 | 100 | 100 | 100 | 0.61 s | 0.37 s | 0.000 | 0 ms | 48 ms | 0.57 s – 0.61 s |
|
||||
| sobre | mobile | 94 | 100 | 100 | 100 | 3.01 s | 1.51 s | 0.000 | 0 ms | 133 ms | 2.87 s – 3.01 s |
|
||||
|
||||
## Decomposição do LCP
|
||||
|
||||
### contato — desktop
|
||||
|
||||
Elemento de LCP: `<h1 class="text-headline font-medium tracking-tight text-amare-text">`
|
||||
|
||||
| fase | tempo |
|
||||
|---|---|
|
||||
| TTFB | 0.12 s |
|
||||
| Load Delay | 0.00 s |
|
||||
| Load Time | 0.00 s |
|
||||
| Render Delay | 0.41 s |
|
||||
|
||||
Requests concluídas até o LCP, mais pesadas primeiro:
|
||||
|
||||
| recurso | tipo | transferido | fim |
|
||||
|---|---|---|---|
|
||||
| `/brand/lockup-on-light.webp` | Image | 83 KiB | 0.07 s |
|
||||
| `/build/assets/eb-garamond-600-normal-xCe6GkOx.woff` | Font | 30 KiB | 0.08 s |
|
||||
| `/build/assets/eb-garamond-500-normal-DZU8xF9o.woff` | Font | 30 KiB | 0.08 s |
|
||||
| `/build/assets/eb-garamond-400-normal-maQnHeKB.woff` | Font | 28 KiB | 0.08 s |
|
||||
| `/build/assets/eb-garamond-600-normal-DHwxsLHv.woff2` | Font | 25 KiB | 0.07 s |
|
||||
| `/build/assets/eb-garamond-500-normal-DehAIUv0.woff2` | Font | 25 KiB | 0.06 s |
|
||||
| `/build/assets/eb-garamond-400-normal-BCNrxLz_.woff2` | Font | 24 KiB | 0.06 s |
|
||||
| `/build/assets/app-Cswasu1n.css` | Stylesheet | 10 KiB | 0.06 s |
|
||||
|
||||
| insight com falha | ganho estimado de LCP | bytes |
|
||||
|---|---|---|
|
||||
| Improve image delivery | 0.10 s | — |
|
||||
| Use efficient cache lifetimes | 0.00 s | — |
|
||||
| Network dependency tree | 0.00 s | — |
|
||||
| Render blocking requests | 0.00 s | — |
|
||||
|
||||
### contato — mobile
|
||||
|
||||
Elemento de LCP: `<h1 class="text-headline font-medium tracking-tight text-amare-text">`
|
||||
|
||||
| fase | tempo |
|
||||
|---|---|
|
||||
| TTFB | 0.45 s |
|
||||
| Load Delay | 0.00 s |
|
||||
| Load Time | 0.00 s |
|
||||
| Render Delay | 2.10 s |
|
||||
|
||||
Requests concluídas até o LCP, mais pesadas primeiro:
|
||||
|
||||
| recurso | tipo | transferido | fim |
|
||||
|---|---|---|---|
|
||||
| `/brand/lockup-on-light.webp` | Image | 83 KiB | 0.11 s |
|
||||
| `/build/assets/eb-garamond-600-normal-xCe6GkOx.woff` | Font | 30 KiB | 0.12 s |
|
||||
| `/build/assets/eb-garamond-500-normal-DZU8xF9o.woff` | Font | 30 KiB | 0.12 s |
|
||||
| `/build/assets/eb-garamond-400-normal-maQnHeKB.woff` | Font | 28 KiB | 0.12 s |
|
||||
| `/build/assets/eb-garamond-600-normal-DHwxsLHv.woff2` | Font | 25 KiB | 0.11 s |
|
||||
| `/build/assets/eb-garamond-500-normal-DehAIUv0.woff2` | Font | 25 KiB | 0.11 s |
|
||||
| `/build/assets/eb-garamond-400-normal-BCNrxLz_.woff2` | Font | 24 KiB | 0.11 s |
|
||||
| `/build/assets/app-Cswasu1n.css` | Stylesheet | 10 KiB | 0.11 s |
|
||||
|
||||
| insight com falha | ganho estimado de LCP | bytes |
|
||||
|---|---|---|
|
||||
| Render blocking requests | 0.60 s | — |
|
||||
| Improve image delivery | 0.45 s | — |
|
||||
| Use efficient cache lifetimes | 0.15 s | — |
|
||||
| Network dependency tree | 0.00 s | — |
|
||||
|
||||
### home — desktop
|
||||
|
||||
Elemento de LCP: `<img src="http://127.0.0.1:8000/storage/content/og/og-default-960.jpg" srcset="/storage/content/og/og-default-480.jpg 480w, /storage/content/og/og-defaul…" size`
|
||||
|
||||
| fase | tempo |
|
||||
|---|---|
|
||||
| TTFB | 0.12 s |
|
||||
| Load Delay | 0.45 s |
|
||||
| Load Time | 0.03 s |
|
||||
| Render Delay | 0.27 s |
|
||||
|
||||
Requests concluídas até o LCP, mais pesadas primeiro:
|
||||
|
||||
| recurso | tipo | transferido | fim |
|
||||
|---|---|---|---|
|
||||
| `/storage/content/og/og-default-960.jpg` | Image | 143 KiB | 0.09 s |
|
||||
| `/storage/content/portfolio/casamento-ana-lucas-cover-960.jpg` | Image | 142 KiB | 0.10 s |
|
||||
| `/storage/content/portfolio/lancamento-verano-cover-960.jpg` | Image | 106 KiB | 0.10 s |
|
||||
| `/storage/content/portfolio/mini-wedding-marina-cover-960.jpg` | Image | 100 KiB | 0.10 s |
|
||||
| `/brand/lockup-on-light.webp` | Image | 83 KiB | 0.08 s |
|
||||
| `/brand/mark-on-light.webp` | Image | 42 KiB | 0.08 s |
|
||||
| `/build/assets/eb-garamond-600-normal-xCe6GkOx.woff` | Font | 30 KiB | 0.09 s |
|
||||
| `/build/assets/eb-garamond-500-normal-DZU8xF9o.woff` | Font | 30 KiB | 0.09 s |
|
||||
|
||||
| insight com falha | ganho estimado de LCP | bytes |
|
||||
|---|---|---|
|
||||
| Improve image delivery | 0.25 s | — |
|
||||
| Use efficient cache lifetimes | 0.05 s | — |
|
||||
| Network dependency tree | 0.00 s | — |
|
||||
| Render blocking requests | 0.00 s | — |
|
||||
|
||||
### home — mobile
|
||||
|
||||
Elemento de LCP: `<img src="http://127.0.0.1:8000/storage/content/og/og-default-960.jpg" srcset="/storage/content/og/og-default-480.jpg 480w, /storage/content/og/og-defaul…" size`
|
||||
|
||||
| fase | tempo |
|
||||
|---|---|
|
||||
| TTFB | 0.45 s |
|
||||
| Load Delay | 3.30 s |
|
||||
| Load Time | 0.11 s |
|
||||
| Render Delay | 0.72 s |
|
||||
|
||||
Requests concluídas até o LCP, mais pesadas primeiro:
|
||||
|
||||
| recurso | tipo | transferido | fim |
|
||||
|---|---|---|---|
|
||||
| `/storage/content/og/og-default-960.jpg` | Image | 143 KiB | 0.23 s |
|
||||
| `/storage/content/portfolio/casamento-ana-lucas-cover-960.jpg` | Image | 142 KiB | 0.25 s |
|
||||
| `/storage/content/portfolio/lancamento-verano-cover-960.jpg` | Image | 106 KiB | 0.25 s |
|
||||
| `/storage/content/portfolio/mini-wedding-marina-cover-960.jpg` | Image | 100 KiB | 0.25 s |
|
||||
| `/brand/lockup-on-light.webp` | Image | 83 KiB | 0.23 s |
|
||||
| `/brand/mark-on-light.webp` | Image | 42 KiB | 0.23 s |
|
||||
| `/build/assets/eb-garamond-600-normal-xCe6GkOx.woff` | Font | 30 KiB | 0.24 s |
|
||||
| `/build/assets/eb-garamond-500-normal-DZU8xF9o.woff` | Font | 30 KiB | 0.24 s |
|
||||
|
||||
| insight com falha | ganho estimado de LCP | bytes |
|
||||
|---|---|---|
|
||||
| Improve image delivery | 1.35 s | — |
|
||||
| Use efficient cache lifetimes | 0.30 s | — |
|
||||
| Network dependency tree | 0.00 s | — |
|
||||
| Render blocking requests | 0.00 s | — |
|
||||
|
||||
### portfolio-detalhe — desktop
|
||||
|
||||
Elemento de LCP: `<img src="http://127.0.0.1:8000/storage/content/portfolio/casamento-ana-lucas-cover-…" srcset="/storage/content/portfolio/casamento-ana-lucas-cover-480.jpg 480w`
|
||||
|
||||
| fase | tempo |
|
||||
|---|---|
|
||||
| TTFB | 0.12 s |
|
||||
| Load Delay | 0.54 s |
|
||||
| Load Time | 0.04 s |
|
||||
| Render Delay | 0.26 s |
|
||||
|
||||
Requests concluídas até o LCP, mais pesadas primeiro:
|
||||
|
||||
| recurso | tipo | transferido | fim |
|
||||
|---|---|---|---|
|
||||
| `/storage/content/portfolio/casamento-ana-lucas-cover-1440.jpg` | Image | 287 KiB | 0.10 s |
|
||||
| `/storage/content/portfolio/casamento-ana-lucas-gallery-1-960.jpg` | Image | 209 KiB | 0.11 s |
|
||||
| `/storage/content/portfolio/casamento-ana-lucas-gallery-2-960.jpg` | Image | 172 KiB | 0.11 s |
|
||||
| `/brand/lockup-on-light.webp` | Image | 83 KiB | 0.10 s |
|
||||
| `/build/assets/eb-garamond-600-normal-xCe6GkOx.woff` | Font | 30 KiB | 0.10 s |
|
||||
| `/build/assets/eb-garamond-500-normal-DZU8xF9o.woff` | Font | 30 KiB | 0.11 s |
|
||||
| `/build/assets/eb-garamond-400-normal-maQnHeKB.woff` | Font | 28 KiB | 0.10 s |
|
||||
| `/build/assets/eb-garamond-600-normal-DHwxsLHv.woff2` | Font | 25 KiB | 0.10 s |
|
||||
|
||||
| insight com falha | ganho estimado de LCP | bytes |
|
||||
|---|---|---|
|
||||
| Improve image delivery | 0.20 s | — |
|
||||
| Use efficient cache lifetimes | 0.05 s | — |
|
||||
| LCP request discovery | 0.00 s | — |
|
||||
| Network dependency tree | 0.00 s | — |
|
||||
| Render blocking requests | 0.00 s | — |
|
||||
|
||||
### portfolio-detalhe — mobile
|
||||
|
||||
Elemento de LCP: `<img src="http://127.0.0.1:8000/storage/content/portfolio/casamento-ana-lucas-cover-…" srcset="/storage/content/portfolio/casamento-ana-lucas-cover-480.jpg 480w`
|
||||
|
||||
| fase | tempo |
|
||||
|---|---|
|
||||
| TTFB | 0.45 s |
|
||||
| Load Delay | 2.62 s |
|
||||
| Load Time | 0.06 s |
|
||||
| Render Delay | 0.85 s |
|
||||
|
||||
Requests concluídas até o LCP, mais pesadas primeiro:
|
||||
|
||||
| recurso | tipo | transferido | fim |
|
||||
|---|---|---|---|
|
||||
| `/storage/content/portfolio/casamento-ana-lucas-gallery-1-960.jpg` | Image | 209 KiB | 0.16 s |
|
||||
| `/storage/content/portfolio/casamento-ana-lucas-gallery-2-960.jpg` | Image | 172 KiB | 0.16 s |
|
||||
| `/storage/content/portfolio/casamento-ana-lucas-cover-960.jpg` | Image | 142 KiB | 0.14 s |
|
||||
| `/brand/lockup-on-light.webp` | Image | 83 KiB | 0.14 s |
|
||||
| `/build/assets/eb-garamond-600-normal-xCe6GkOx.woff` | Font | 30 KiB | 0.15 s |
|
||||
| `/build/assets/eb-garamond-500-normal-DZU8xF9o.woff` | Font | 30 KiB | 0.15 s |
|
||||
| `/build/assets/eb-garamond-400-normal-maQnHeKB.woff` | Font | 28 KiB | 0.15 s |
|
||||
| `/build/assets/eb-garamond-600-normal-DHwxsLHv.woff2` | Font | 25 KiB | 0.14 s |
|
||||
|
||||
| insight com falha | ganho estimado de LCP | bytes |
|
||||
|---|---|---|
|
||||
| Improve image delivery | 1.20 s | — |
|
||||
| Use efficient cache lifetimes | 0.15 s | — |
|
||||
| LCP request discovery | 0.00 s | — |
|
||||
| Network dependency tree | 0.00 s | — |
|
||||
| Render blocking requests | 0.00 s | — |
|
||||
|
||||
### portfolio — desktop
|
||||
|
||||
Elemento de LCP: `<img src="http://127.0.0.1:8000/storage/content/portfolio/casamento-ana-lucas-cover-…" srcset="/storage/content/portfolio/casamento-ana-lucas-cover-480.jpg 480w`
|
||||
|
||||
| fase | tempo |
|
||||
|---|---|
|
||||
| TTFB | 0.19 s |
|
||||
| Load Delay | 0.51 s |
|
||||
| Load Time | 0.01 s |
|
||||
| Render Delay | 0.12 s |
|
||||
|
||||
Requests concluídas até o LCP, mais pesadas primeiro:
|
||||
|
||||
| recurso | tipo | transferido | fim |
|
||||
|---|---|---|---|
|
||||
| `/storage/content/portfolio/casamento-ana-lucas-cover-960.jpg` | Image | 142 KiB | 0.23 s |
|
||||
| `/storage/content/portfolio/lancamento-verano-cover-960.jpg` | Image | 106 KiB | 0.23 s |
|
||||
| `/storage/content/portfolio/mini-wedding-marina-cover-960.jpg` | Image | 100 KiB | 0.23 s |
|
||||
| `/brand/lockup-on-light.webp` | Image | 83 KiB | 0.20 s |
|
||||
| `/build/assets/eb-garamond-600-normal-xCe6GkOx.woff` | Font | 30 KiB | 0.23 s |
|
||||
| `/build/assets/eb-garamond-500-normal-DZU8xF9o.woff` | Font | 30 KiB | 0.23 s |
|
||||
| `/build/assets/eb-garamond-400-normal-maQnHeKB.woff` | Font | 28 KiB | 0.22 s |
|
||||
| `/build/assets/eb-garamond-600-normal-DHwxsLHv.woff2` | Font | 25 KiB | 0.21 s |
|
||||
|
||||
| insight com falha | ganho estimado de LCP | bytes |
|
||||
|---|---|---|
|
||||
| Improve image delivery | 0.25 s | — |
|
||||
| Use efficient cache lifetimes | 0.00 s | — |
|
||||
| LCP request discovery | 0.00 s | — |
|
||||
| Network dependency tree | 0.00 s | — |
|
||||
| Render blocking requests | 0.00 s | — |
|
||||
|
||||
### portfolio — mobile
|
||||
|
||||
Elemento de LCP: `<img src="http://127.0.0.1:8000/storage/content/portfolio/casamento-ana-lucas-cover-…" srcset="/storage/content/portfolio/casamento-ana-lucas-cover-480.jpg 480w`
|
||||
|
||||
| fase | tempo |
|
||||
|---|---|
|
||||
| TTFB | 0.45 s |
|
||||
| Load Delay | 2.68 s |
|
||||
| Load Time | 0.09 s |
|
||||
| Render Delay | 0.76 s |
|
||||
|
||||
Requests concluídas até o LCP, mais pesadas primeiro:
|
||||
|
||||
| recurso | tipo | transferido | fim |
|
||||
|---|---|---|---|
|
||||
| `/storage/content/portfolio/casamento-ana-lucas-cover-960.jpg` | Image | 142 KiB | 0.13 s |
|
||||
| `/storage/content/portfolio/lancamento-verano-cover-960.jpg` | Image | 106 KiB | 0.13 s |
|
||||
| `/storage/content/portfolio/mini-wedding-marina-cover-960.jpg` | Image | 100 KiB | 0.13 s |
|
||||
| `/brand/lockup-on-light.webp` | Image | 83 KiB | 0.12 s |
|
||||
| `/build/assets/eb-garamond-600-normal-xCe6GkOx.woff` | Font | 30 KiB | 0.13 s |
|
||||
| `/build/assets/eb-garamond-500-normal-DZU8xF9o.woff` | Font | 30 KiB | 0.13 s |
|
||||
| `/build/assets/eb-garamond-400-normal-maQnHeKB.woff` | Font | 28 KiB | 0.13 s |
|
||||
| `/build/assets/eb-garamond-600-normal-DHwxsLHv.woff2` | Font | 25 KiB | 0.12 s |
|
||||
|
||||
| insight com falha | ganho estimado de LCP | bytes |
|
||||
|---|---|---|
|
||||
| Improve image delivery | 1.35 s | — |
|
||||
| Use efficient cache lifetimes | 0.15 s | — |
|
||||
| LCP request discovery | 0.00 s | — |
|
||||
| Network dependency tree | 0.00 s | — |
|
||||
| Render blocking requests | 0.00 s | — |
|
||||
|
||||
### servicos — desktop
|
||||
|
||||
Elemento de LCP: `<img src="http://127.0.0.1:8000/storage/content/services/casamentos-960.jpg" srcset="/storage/content/services/casamentos-480.jpg 480w, /storage/content/servic…`
|
||||
|
||||
| fase | tempo |
|
||||
|---|---|
|
||||
| TTFB | 0.13 s |
|
||||
| Load Delay | 0.49 s |
|
||||
| Load Time | 0.03 s |
|
||||
| Render Delay | 0.12 s |
|
||||
|
||||
Requests concluídas até o LCP, mais pesadas primeiro:
|
||||
|
||||
| recurso | tipo | transferido | fim |
|
||||
|---|---|---|---|
|
||||
| `/storage/content/services/casamentos-960.jpg` | Image | 107 KiB | 0.15 s |
|
||||
| `/storage/content/services/eventos-corporativos-960.jpg` | Image | 100 KiB | 0.15 s |
|
||||
| `/storage/content/services/celebracoes-intimistas-960.jpg` | Image | 83 KiB | 0.15 s |
|
||||
| `/brand/lockup-on-light.webp` | Image | 83 KiB | 0.14 s |
|
||||
| `/build/assets/eb-garamond-600-normal-xCe6GkOx.woff` | Font | 30 KiB | 0.15 s |
|
||||
| `/build/assets/eb-garamond-500-normal-DZU8xF9o.woff` | Font | 30 KiB | 0.15 s |
|
||||
| `/build/assets/eb-garamond-400-normal-maQnHeKB.woff` | Font | 28 KiB | 0.15 s |
|
||||
| `/build/assets/eb-garamond-600-normal-DHwxsLHv.woff2` | Font | 25 KiB | 0.14 s |
|
||||
|
||||
| insight com falha | ganho estimado de LCP | bytes |
|
||||
|---|---|---|
|
||||
| Improve image delivery | 0.20 s | — |
|
||||
| Use efficient cache lifetimes | 0.05 s | — |
|
||||
| LCP request discovery | 0.00 s | — |
|
||||
| Network dependency tree | 0.00 s | — |
|
||||
| Render blocking requests | 0.00 s | — |
|
||||
|
||||
### servicos — mobile
|
||||
|
||||
Elemento de LCP: `<img src="http://127.0.0.1:8000/storage/content/services/casamentos-960.jpg" srcset="/storage/content/services/casamentos-480.jpg 480w, /storage/content/servic…`
|
||||
|
||||
| fase | tempo |
|
||||
|---|---|
|
||||
| TTFB | 0.45 s |
|
||||
| Load Delay | 2.22 s |
|
||||
| Load Time | 0.07 s |
|
||||
| Render Delay | 0.94 s |
|
||||
|
||||
Requests concluídas até o LCP, mais pesadas primeiro:
|
||||
|
||||
| recurso | tipo | transferido | fim |
|
||||
|---|---|---|---|
|
||||
| `/storage/content/services/casamentos-960.jpg` | Image | 107 KiB | 0.12 s |
|
||||
| `/storage/content/services/eventos-corporativos-960.jpg` | Image | 100 KiB | 0.12 s |
|
||||
| `/storage/content/services/celebracoes-intimistas-960.jpg` | Image | 83 KiB | 0.12 s |
|
||||
| `/brand/lockup-on-light.webp` | Image | 83 KiB | 0.09 s |
|
||||
| `/build/assets/eb-garamond-600-normal-xCe6GkOx.woff` | Font | 30 KiB | 0.12 s |
|
||||
| `/build/assets/eb-garamond-500-normal-DZU8xF9o.woff` | Font | 30 KiB | 0.12 s |
|
||||
| `/build/assets/eb-garamond-400-normal-maQnHeKB.woff` | Font | 28 KiB | 0.12 s |
|
||||
| `/build/assets/eb-garamond-600-normal-DHwxsLHv.woff2` | Font | 25 KiB | 0.09 s |
|
||||
|
||||
| insight com falha | ganho estimado de LCP | bytes |
|
||||
|---|---|---|
|
||||
| Improve image delivery | 1.20 s | — |
|
||||
| Use efficient cache lifetimes | 0.15 s | — |
|
||||
| LCP request discovery | 0.00 s | — |
|
||||
| Network dependency tree | 0.00 s | — |
|
||||
| Render blocking requests | 0.00 s | — |
|
||||
|
||||
### sobre — desktop
|
||||
|
||||
Elemento de LCP: `<img src="http://127.0.0.1:8000/storage/content/about/about-image-960.jpg" srcset="/storage/content/about/about-image-480.jpg 480w, /storage/content/about/ab…" `
|
||||
|
||||
| fase | tempo |
|
||||
|---|---|
|
||||
| TTFB | 0.12 s |
|
||||
| Load Delay | 0.25 s |
|
||||
| Load Time | 0.01 s |
|
||||
| Render Delay | 0.22 s |
|
||||
|
||||
Requests concluídas até o LCP, mais pesadas primeiro:
|
||||
|
||||
| recurso | tipo | transferido | fim |
|
||||
|---|---|---|---|
|
||||
| `/brand/lockup-on-light.webp` | Image | 83 KiB | 0.06 s |
|
||||
| `/storage/content/about/about-image-960.jpg` | Image | 60 KiB | 0.07 s |
|
||||
| `/build/assets/eb-garamond-600-normal-xCe6GkOx.woff` | Font | 30 KiB | 0.06 s |
|
||||
| `/build/assets/eb-garamond-500-normal-DZU8xF9o.woff` | Font | 30 KiB | 0.06 s |
|
||||
| `/build/assets/eb-garamond-400-normal-maQnHeKB.woff` | Font | 28 KiB | 0.06 s |
|
||||
| `/build/assets/eb-garamond-600-normal-DHwxsLHv.woff2` | Font | 25 KiB | 0.06 s |
|
||||
| `/build/assets/eb-garamond-500-normal-DehAIUv0.woff2` | Font | 25 KiB | 0.05 s |
|
||||
| `/build/assets/eb-garamond-400-normal-BCNrxLz_.woff2` | Font | 24 KiB | 0.05 s |
|
||||
|
||||
| insight com falha | ganho estimado de LCP | bytes |
|
||||
|---|---|---|
|
||||
| Improve image delivery | 0.10 s | — |
|
||||
| Use efficient cache lifetimes | 0.05 s | — |
|
||||
| LCP request discovery | 0.00 s | — |
|
||||
| Network dependency tree | 0.00 s | — |
|
||||
| Render blocking requests | 0.00 s | — |
|
||||
|
||||
### sobre — mobile
|
||||
|
||||
Elemento de LCP: `<img src="http://127.0.0.1:8000/storage/content/about/about-image-960.jpg" srcset="/storage/content/about/about-image-480.jpg 480w, /storage/content/about/ab…" `
|
||||
|
||||
| fase | tempo |
|
||||
|---|---|
|
||||
| TTFB | 0.45 s |
|
||||
| Load Delay | 1.94 s |
|
||||
| Load Time | 0.07 s |
|
||||
| Render Delay | 0.54 s |
|
||||
|
||||
Requests concluídas até o LCP, mais pesadas primeiro:
|
||||
|
||||
| recurso | tipo | transferido | fim |
|
||||
|---|---|---|---|
|
||||
| `/brand/lockup-on-light.webp` | Image | 83 KiB | 0.14 s |
|
||||
| `/storage/content/about/about-image-960.jpg` | Image | 60 KiB | 0.16 s |
|
||||
| `/build/assets/eb-garamond-600-normal-xCe6GkOx.woff` | Font | 30 KiB | 0.15 s |
|
||||
| `/build/assets/eb-garamond-500-normal-DZU8xF9o.woff` | Font | 30 KiB | 0.15 s |
|
||||
| `/build/assets/eb-garamond-400-normal-maQnHeKB.woff` | Font | 28 KiB | 0.16 s |
|
||||
| `/build/assets/eb-garamond-600-normal-DHwxsLHv.woff2` | Font | 25 KiB | 0.14 s |
|
||||
| `/build/assets/eb-garamond-500-normal-DehAIUv0.woff2` | Font | 25 KiB | 0.14 s |
|
||||
| `/build/assets/eb-garamond-400-normal-BCNrxLz_.woff2` | Font | 24 KiB | 0.14 s |
|
||||
|
||||
| insight com falha | ganho estimado de LCP | bytes |
|
||||
|---|---|---|
|
||||
| Improve image delivery | 0.90 s | — |
|
||||
| Use efficient cache lifetimes | 0.30 s | — |
|
||||
| LCP request discovery | 0.00 s | — |
|
||||
| Network dependency tree | 0.00 s | — |
|
||||
| Render blocking requests | 0.00 s | — |
|
||||
|
||||
407
docs/evidence/lighthouse/2026-08-10-local-depois.md
Normal file
407
docs/evidence/lighthouse/2026-08-10-local-depois.md
Normal file
@@ -0,0 +1,407 @@
|
||||
# Lighthouse — local-sizes
|
||||
|
||||
- Origem: `http://127.0.0.1:8000`
|
||||
- Lighthouse: 12.8.2
|
||||
- Navegador: `Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/151.0.0.0 Safari/537.36`
|
||||
- Coletado em: 2026-08-10T17:38:33.741Z
|
||||
- Execuções por página/preset: 3 (reportada a de LCP mediano)
|
||||
- Commit: `9ab2beb` (o script gravou `65919d4`, o HEAD no momento da coleta; a árvore medida é a que virou `9ab2beb`)
|
||||
- Seeder: `ContentSeeder`
|
||||
|
||||
Metas SPEC §6.6: LCP ≤ 2,5 s · CLS ≤ 0,1 · INP ≤ 200 ms · zero erro de console.
|
||||
|
||||
| página | preset | perf | a11y | BP | SEO | LCP | FCP | CLS | TBT | TTFB servidor | LCP min–max |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| contato | desktop | 100 | 100 | 100 | 100 | 0.36 s | 0.25 s | 0.000 | 0 ms | 91 ms | 0.36 s – 0.37 s |
|
||||
| contato | mobile | 100 | 100 | 100 | 100 | 1.50 s | 0.92 s | 0.000 | 0 ms | 66 ms | 1.50 s – 1.51 s |
|
||||
| home | desktop | 100 | 100 | 100 | 100 | 0.53 s | 0.25 s | 0.000 | 0 ms | 110 ms | 0.53 s – 0.54 s |
|
||||
| home | mobile | 98 | 100 | 100 | 100 | 2.49 s | 0.92 s | 0.000 | 0 ms | 77 ms | 2.48 s – 2.57 s |
|
||||
| portfolio-detalhe | desktop | 100 | 100 | 100 | 100 | 0.65 s | 0.25 s | 0.000 | 0 ms | 81 ms | 0.65 s – 0.66 s |
|
||||
| portfolio-detalhe | mobile | 99 | 100 | 100 | 100 | 2.18 s | 0.90 s | 0.000 | 0 ms | 77 ms | 2.18 s – 2.18 s |
|
||||
| portfolio | desktop | 100 | 100 | 100 | 100 | 0.36 s | 0.25 s | 0.000 | 0 ms | 72 ms | 0.36 s – 0.49 s |
|
||||
| portfolio | mobile | 100 | 100 | 100 | 100 | 1.58 s | 0.91 s | 0.000 | 0 ms | 71 ms | 1.58 s – 1.58 s |
|
||||
| servicos | desktop | 100 | 100 | 100 | 100 | 0.36 s | 0.24 s | 0.000 | 0 ms | 59 ms | 0.36 s – 0.38 s |
|
||||
| servicos | mobile | 99 | 100 | 100 | 100 | 2.03 s | 0.90 s | 0.000 | 0 ms | 62 ms | 1.58 s – 2.03 s |
|
||||
| sobre | desktop | 100 | 100 | 100 | 100 | 0.36 s | 0.25 s | 0.000 | 0 ms | 51 ms | 0.36 s – 0.37 s |
|
||||
| sobre | mobile | 100 | 100 | 100 | 100 | 1.58 s | 0.91 s | 0.000 | 0 ms | 93 ms | 1.58 s – 1.58 s |
|
||||
|
||||
## Decomposição do LCP
|
||||
|
||||
### contato — desktop
|
||||
|
||||
Elemento de LCP: `<h1 class="text-headline font-medium tracking-tight text-amare-text">`
|
||||
|
||||
| fase | tempo |
|
||||
|---|---|
|
||||
| TTFB | 0.12 s |
|
||||
| Load Delay | 0.00 s |
|
||||
| Load Time | 0.00 s |
|
||||
| Render Delay | 0.24 s |
|
||||
|
||||
Requests concluídas até o LCP, mais pesadas primeiro:
|
||||
|
||||
| recurso | tipo | transferido | fim |
|
||||
|---|---|---|---|
|
||||
| `/build/assets/eb-garamond-600-normal-DHwxsLHv.woff2` | Font | 25 KiB | 0.10 s |
|
||||
| `/build/assets/eb-garamond-500-normal-DehAIUv0.woff2` | Font | 25 KiB | 0.10 s |
|
||||
| `/build/assets/eb-garamond-400-normal-BCNrxLz_.woff2` | Font | 24 KiB | 0.10 s |
|
||||
| `/brand/lockup-on-light.webp` | Image | 14 KiB | 0.10 s |
|
||||
| `/build/assets/app-8wBXH8kj.css` | Stylesheet | 10 KiB | 0.10 s |
|
||||
| `/contato` | Document | 5 KiB | 0.09 s |
|
||||
| `/build/assets/app-Cr9w0NCu.js` | Script | 2 KiB | 0.10 s |
|
||||
|
||||
| insight com falha | ganho estimado de LCP | bytes |
|
||||
|---|---|---|
|
||||
| Use efficient cache lifetimes | 0.00 s | — |
|
||||
| Improve image delivery | 0.00 s | — |
|
||||
| Network dependency tree | 0.00 s | — |
|
||||
| Render blocking requests | 0.00 s | — |
|
||||
|
||||
### contato — mobile
|
||||
|
||||
Elemento de LCP: `<h1 class="text-headline font-medium tracking-tight text-amare-text">`
|
||||
|
||||
| fase | tempo |
|
||||
|---|---|
|
||||
| TTFB | 0.45 s |
|
||||
| Load Delay | 0.00 s |
|
||||
| Load Time | 0.00 s |
|
||||
| Render Delay | 1.05 s |
|
||||
|
||||
Requests concluídas até o LCP, mais pesadas primeiro:
|
||||
|
||||
| recurso | tipo | transferido | fim |
|
||||
|---|---|---|---|
|
||||
| `/build/assets/eb-garamond-600-normal-DHwxsLHv.woff2` | Font | 25 KiB | 0.08 s |
|
||||
| `/build/assets/eb-garamond-500-normal-DehAIUv0.woff2` | Font | 25 KiB | 0.07 s |
|
||||
| `/build/assets/eb-garamond-400-normal-BCNrxLz_.woff2` | Font | 24 KiB | 0.07 s |
|
||||
| `/brand/lockup-on-light.webp` | Image | 14 KiB | 0.08 s |
|
||||
| `/build/assets/app-8wBXH8kj.css` | Stylesheet | 10 KiB | 0.08 s |
|
||||
| `/contato` | Document | 5 KiB | 0.07 s |
|
||||
| `/build/assets/app-Cr9w0NCu.js` | Script | 2 KiB | 0.08 s |
|
||||
|
||||
| insight com falha | ganho estimado de LCP | bytes |
|
||||
|---|---|---|
|
||||
| Use efficient cache lifetimes | 0.00 s | — |
|
||||
| Improve image delivery | 0.00 s | — |
|
||||
| Network dependency tree | 0.00 s | — |
|
||||
| Render blocking requests | 0.00 s | — |
|
||||
|
||||
### home — desktop
|
||||
|
||||
Elemento de LCP: `<img src="http://127.0.0.1:8000/storage/content/og/og-default-720.jpg.webp" srcset="/storage/content/og/og-default-480.jpg 480w, /storage/content/og/og-defaul…"`
|
||||
|
||||
| fase | tempo |
|
||||
|---|---|
|
||||
| TTFB | 0.12 s |
|
||||
| Load Delay | 0.25 s |
|
||||
| Load Time | 0.03 s |
|
||||
| Render Delay | 0.12 s |
|
||||
|
||||
Requests concluídas até o LCP, mais pesadas primeiro:
|
||||
|
||||
| recurso | tipo | transferido | fim |
|
||||
|---|---|---|---|
|
||||
| `/storage/content/og/og-default-720.jpg.webp` | Image | 74 KiB | 0.13 s |
|
||||
| `/storage/content/portfolio/casamento-ana-lucas-cover-720.jpg.webp` | Image | 67 KiB | 0.14 s |
|
||||
| `/storage/content/portfolio/lancamento-verano-cover-720.jpg.webp` | Image | 45 KiB | 0.14 s |
|
||||
| `/storage/content/portfolio/mini-wedding-marina-cover-720.jpg.webp` | Image | 43 KiB | 0.14 s |
|
||||
| `/build/assets/eb-garamond-600-normal-DHwxsLHv.woff2` | Font | 25 KiB | 0.12 s |
|
||||
| `/build/assets/eb-garamond-500-normal-DehAIUv0.woff2` | Font | 25 KiB | 0.12 s |
|
||||
| `/build/assets/eb-garamond-400-normal-BCNrxLz_.woff2` | Font | 24 KiB | 0.12 s |
|
||||
| `/brand/lockup-on-light.webp` | Image | 14 KiB | 0.12 s |
|
||||
|
||||
| insight com falha | ganho estimado de LCP | bytes |
|
||||
|---|---|---|
|
||||
| Improve image delivery | 0.10 s | — |
|
||||
| Use efficient cache lifetimes | 0.00 s | — |
|
||||
| Network dependency tree | 0.00 s | — |
|
||||
| Render blocking requests | 0.00 s | — |
|
||||
|
||||
### home — mobile
|
||||
|
||||
Elemento de LCP: `<img src="http://127.0.0.1:8000/storage/content/og/og-default-720.jpg.webp" srcset="/storage/content/og/og-default-480.jpg 480w, /storage/content/og/og-defaul…"`
|
||||
|
||||
| fase | tempo |
|
||||
|---|---|
|
||||
| TTFB | 0.45 s |
|
||||
| Load Delay | 1.24 s |
|
||||
| Load Time | 0.10 s |
|
||||
| Render Delay | 0.69 s |
|
||||
|
||||
Requests concluídas até o LCP, mais pesadas primeiro:
|
||||
|
||||
| recurso | tipo | transferido | fim |
|
||||
|---|---|---|---|
|
||||
| `/storage/content/og/og-default-720.jpg.webp` | Image | 74 KiB | 0.09 s |
|
||||
| `/storage/content/portfolio/casamento-ana-lucas-cover-720.jpg.webp` | Image | 67 KiB | 0.10 s |
|
||||
| `/storage/content/portfolio/lancamento-verano-cover-720.jpg.webp` | Image | 45 KiB | 0.10 s |
|
||||
| `/storage/content/portfolio/mini-wedding-marina-cover-720.jpg.webp` | Image | 43 KiB | 0.10 s |
|
||||
| `/build/assets/eb-garamond-600-normal-DHwxsLHv.woff2` | Font | 25 KiB | 0.09 s |
|
||||
| `/build/assets/eb-garamond-500-normal-DehAIUv0.woff2` | Font | 25 KiB | 0.09 s |
|
||||
| `/build/assets/eb-garamond-400-normal-BCNrxLz_.woff2` | Font | 24 KiB | 0.09 s |
|
||||
| `/brand/lockup-on-light.webp` | Image | 14 KiB | 0.09 s |
|
||||
|
||||
| insight com falha | ganho estimado de LCP | bytes |
|
||||
|---|---|---|
|
||||
| Improve image delivery | 0.45 s | — |
|
||||
| Use efficient cache lifetimes | 0.00 s | — |
|
||||
| Network dependency tree | 0.00 s | — |
|
||||
| Render blocking requests | 0.00 s | — |
|
||||
|
||||
### portfolio-detalhe — desktop
|
||||
|
||||
Elemento de LCP: `<img src="http://127.0.0.1:8000/storage/content/portfolio/casamento-ana-lucas-cover-…" srcset="/storage/content/portfolio/casamento-ana-lucas-cover-480.jpg 480w`
|
||||
|
||||
| fase | tempo |
|
||||
|---|---|
|
||||
| TTFB | 0.12 s |
|
||||
| Load Delay | 0.28 s |
|
||||
| Load Time | 0.03 s |
|
||||
| Render Delay | 0.22 s |
|
||||
|
||||
Requests concluídas até o LCP, mais pesadas primeiro:
|
||||
|
||||
| recurso | tipo | transferido | fim |
|
||||
|---|---|---|---|
|
||||
| `/storage/content/portfolio/casamento-ana-lucas-cover-1440.jpg.webp` | Image | 210 KiB | 0.09 s |
|
||||
| `/storage/content/portfolio/casamento-ana-lucas-gallery-1-720.jpg.webp` | Image | 105 KiB | 0.10 s |
|
||||
| `/storage/content/portfolio/casamento-ana-lucas-gallery-2-720.jpg.webp` | Image | 88 KiB | 0.10 s |
|
||||
| `/build/assets/eb-garamond-600-normal-DHwxsLHv.woff2` | Font | 25 KiB | 0.09 s |
|
||||
| `/build/assets/eb-garamond-500-normal-DehAIUv0.woff2` | Font | 25 KiB | 0.09 s |
|
||||
| `/build/assets/eb-garamond-400-normal-BCNrxLz_.woff2` | Font | 24 KiB | 0.09 s |
|
||||
| `/brand/lockup-on-light.webp` | Image | 14 KiB | 0.09 s |
|
||||
| `/build/assets/app-8wBXH8kj.css` | Stylesheet | 10 KiB | 0.09 s |
|
||||
|
||||
| insight com falha | ganho estimado de LCP | bytes |
|
||||
|---|---|---|
|
||||
| Improve image delivery | 0.10 s | — |
|
||||
| Use efficient cache lifetimes | 0.00 s | — |
|
||||
| LCP request discovery | 0.00 s | — |
|
||||
| Network dependency tree | 0.00 s | — |
|
||||
| Render blocking requests | 0.00 s | — |
|
||||
|
||||
### portfolio-detalhe — mobile
|
||||
|
||||
Elemento de LCP: `<img src="http://127.0.0.1:8000/storage/content/portfolio/casamento-ana-lucas-cover-…" srcset="/storage/content/portfolio/casamento-ana-lucas-cover-480.jpg 480w`
|
||||
|
||||
| fase | tempo |
|
||||
|---|---|
|
||||
| TTFB | 0.45 s |
|
||||
| Load Delay | 1.11 s |
|
||||
| Load Time | 0.04 s |
|
||||
| Render Delay | 0.57 s |
|
||||
|
||||
Requests concluídas até o LCP, mais pesadas primeiro:
|
||||
|
||||
| recurso | tipo | transferido | fim |
|
||||
|---|---|---|---|
|
||||
| `/storage/content/portfolio/casamento-ana-lucas-gallery-1-720.jpg.webp` | Image | 105 KiB | 0.09 s |
|
||||
| `/storage/content/portfolio/casamento-ana-lucas-gallery-2-720.jpg.webp` | Image | 88 KiB | 0.09 s |
|
||||
| `/storage/content/portfolio/casamento-ana-lucas-cover-720.jpg.webp` | Image | 67 KiB | 0.09 s |
|
||||
| `/build/assets/eb-garamond-600-normal-DHwxsLHv.woff2` | Font | 25 KiB | 0.08 s |
|
||||
| `/build/assets/eb-garamond-500-normal-DehAIUv0.woff2` | Font | 25 KiB | 0.08 s |
|
||||
| `/build/assets/eb-garamond-400-normal-BCNrxLz_.woff2` | Font | 24 KiB | 0.08 s |
|
||||
| `/brand/lockup-on-light.webp` | Image | 14 KiB | 0.09 s |
|
||||
| `/build/assets/app-8wBXH8kj.css` | Stylesheet | 10 KiB | 0.09 s |
|
||||
|
||||
| insight com falha | ganho estimado de LCP | bytes |
|
||||
|---|---|---|
|
||||
| Improve image delivery | 0.45 s | — |
|
||||
| Use efficient cache lifetimes | 0.00 s | — |
|
||||
| LCP request discovery | 0.00 s | — |
|
||||
| Network dependency tree | 0.00 s | — |
|
||||
| Render blocking requests | 0.00 s | — |
|
||||
|
||||
### portfolio — desktop
|
||||
|
||||
Elemento de LCP: `<img src="http://127.0.0.1:8000/storage/content/portfolio/casamento-ana-lucas-cover-…" srcset="/storage/content/portfolio/casamento-ana-lucas-cover-480.jpg 480w`
|
||||
|
||||
| fase | tempo |
|
||||
|---|---|
|
||||
| TTFB | 0.12 s |
|
||||
| Load Delay | 0.14 s |
|
||||
| Load Time | 0.01 s |
|
||||
| Render Delay | 0.09 s |
|
||||
|
||||
Requests concluídas até o LCP, mais pesadas primeiro:
|
||||
|
||||
| recurso | tipo | transferido | fim |
|
||||
|---|---|---|---|
|
||||
| `/storage/content/portfolio/casamento-ana-lucas-cover-720.jpg.webp` | Image | 67 KiB | 0.09 s |
|
||||
| `/storage/content/portfolio/lancamento-verano-cover-720.jpg.webp` | Image | 45 KiB | 0.09 s |
|
||||
| `/storage/content/portfolio/mini-wedding-marina-cover-720.jpg.webp` | Image | 43 KiB | 0.09 s |
|
||||
| `/build/assets/eb-garamond-600-normal-DHwxsLHv.woff2` | Font | 25 KiB | 0.08 s |
|
||||
| `/build/assets/eb-garamond-500-normal-DehAIUv0.woff2` | Font | 25 KiB | 0.08 s |
|
||||
| `/build/assets/eb-garamond-400-normal-BCNrxLz_.woff2` | Font | 24 KiB | 0.08 s |
|
||||
| `/brand/lockup-on-light.webp` | Image | 14 KiB | 0.08 s |
|
||||
| `/build/assets/app-8wBXH8kj.css` | Stylesheet | 10 KiB | 0.08 s |
|
||||
|
||||
| insight com falha | ganho estimado de LCP | bytes |
|
||||
|---|---|---|
|
||||
| Use efficient cache lifetimes | 0.00 s | — |
|
||||
| Improve image delivery | 0.00 s | — |
|
||||
| LCP request discovery | 0.00 s | — |
|
||||
| Network dependency tree | 0.00 s | — |
|
||||
| Render blocking requests | 0.00 s | — |
|
||||
|
||||
### portfolio — mobile
|
||||
|
||||
Elemento de LCP: `<img src="http://127.0.0.1:8000/storage/content/portfolio/casamento-ana-lucas-cover-…" srcset="/storage/content/portfolio/casamento-ana-lucas-cover-480.jpg 480w`
|
||||
|
||||
| fase | tempo |
|
||||
|---|---|
|
||||
| TTFB | 0.45 s |
|
||||
| Load Delay | 0.66 s |
|
||||
| Load Time | 0.04 s |
|
||||
| Render Delay | 0.42 s |
|
||||
|
||||
Requests concluídas até o LCP, mais pesadas primeiro:
|
||||
|
||||
| recurso | tipo | transferido | fim |
|
||||
|---|---|---|---|
|
||||
| `/storage/content/portfolio/casamento-ana-lucas-cover-720.jpg.webp` | Image | 67 KiB | 0.09 s |
|
||||
| `/storage/content/portfolio/lancamento-verano-cover-720.jpg.webp` | Image | 45 KiB | 0.09 s |
|
||||
| `/storage/content/portfolio/mini-wedding-marina-cover-720.jpg.webp` | Image | 43 KiB | 0.09 s |
|
||||
| `/build/assets/eb-garamond-600-normal-DHwxsLHv.woff2` | Font | 25 KiB | 0.08 s |
|
||||
| `/build/assets/eb-garamond-500-normal-DehAIUv0.woff2` | Font | 25 KiB | 0.08 s |
|
||||
| `/build/assets/eb-garamond-400-normal-BCNrxLz_.woff2` | Font | 24 KiB | 0.08 s |
|
||||
| `/brand/lockup-on-light.webp` | Image | 14 KiB | 0.08 s |
|
||||
| `/build/assets/app-8wBXH8kj.css` | Stylesheet | 10 KiB | 0.08 s |
|
||||
|
||||
| insight com falha | ganho estimado de LCP | bytes |
|
||||
|---|---|---|
|
||||
| Use efficient cache lifetimes | 0.00 s | — |
|
||||
| Improve image delivery | 0.00 s | — |
|
||||
| LCP request discovery | 0.00 s | — |
|
||||
| Network dependency tree | 0.00 s | — |
|
||||
| Render blocking requests | 0.00 s | — |
|
||||
|
||||
### servicos — desktop
|
||||
|
||||
Elemento de LCP: `<img src="http://127.0.0.1:8000/storage/content/services/casamentos-720.jpg.webp" srcset="/storage/content/services/casamentos-480.jpg 480w, /storage/content/se`
|
||||
|
||||
| fase | tempo |
|
||||
|---|---|
|
||||
| TTFB | 0.12 s |
|
||||
| Load Delay | 0.14 s |
|
||||
| Load Time | 0.01 s |
|
||||
| Render Delay | 0.10 s |
|
||||
|
||||
Requests concluídas até o LCP, mais pesadas primeiro:
|
||||
|
||||
| recurso | tipo | transferido | fim |
|
||||
|---|---|---|---|
|
||||
| `/storage/content/services/casamentos-720.jpg.webp` | Image | 49 KiB | 0.08 s |
|
||||
| `/storage/content/services/eventos-corporativos-720.jpg.webp` | Image | 39 KiB | 0.08 s |
|
||||
| `/storage/content/services/celebracoes-intimistas-720.jpg.webp` | Image | 28 KiB | 0.08 s |
|
||||
| `/build/assets/eb-garamond-600-normal-DHwxsLHv.woff2` | Font | 25 KiB | 0.07 s |
|
||||
| `/build/assets/eb-garamond-500-normal-DehAIUv0.woff2` | Font | 25 KiB | 0.06 s |
|
||||
| `/build/assets/eb-garamond-400-normal-BCNrxLz_.woff2` | Font | 24 KiB | 0.06 s |
|
||||
| `/brand/lockup-on-light.webp` | Image | 14 KiB | 0.07 s |
|
||||
| `/build/assets/app-8wBXH8kj.css` | Stylesheet | 10 KiB | 0.07 s |
|
||||
|
||||
| insight com falha | ganho estimado de LCP | bytes |
|
||||
|---|---|---|
|
||||
| Use efficient cache lifetimes | 0.00 s | — |
|
||||
| Improve image delivery | 0.00 s | — |
|
||||
| LCP request discovery | 0.00 s | — |
|
||||
| Network dependency tree | 0.00 s | — |
|
||||
| Render blocking requests | 0.00 s | — |
|
||||
|
||||
### servicos — mobile
|
||||
|
||||
Elemento de LCP: `<img src="http://127.0.0.1:8000/storage/content/services/casamentos-720.jpg.webp" srcset="/storage/content/services/casamentos-480.jpg 480w, /storage/content/se`
|
||||
|
||||
| fase | tempo |
|
||||
|---|---|
|
||||
| TTFB | 0.45 s |
|
||||
| Load Delay | 1.08 s |
|
||||
| Load Time | 0.07 s |
|
||||
| Render Delay | 0.43 s |
|
||||
|
||||
Requests concluídas até o LCP, mais pesadas primeiro:
|
||||
|
||||
| recurso | tipo | transferido | fim |
|
||||
|---|---|---|---|
|
||||
| `/storage/content/services/casamentos-720.jpg.webp` | Image | 49 KiB | 0.08 s |
|
||||
| `/storage/content/services/eventos-corporativos-720.jpg.webp` | Image | 39 KiB | 0.08 s |
|
||||
| `/storage/content/services/celebracoes-intimistas-720.jpg.webp` | Image | 28 KiB | 0.08 s |
|
||||
| `/build/assets/eb-garamond-600-normal-DHwxsLHv.woff2` | Font | 25 KiB | 0.07 s |
|
||||
| `/build/assets/eb-garamond-500-normal-DehAIUv0.woff2` | Font | 25 KiB | 0.07 s |
|
||||
| `/build/assets/eb-garamond-400-normal-BCNrxLz_.woff2` | Font | 24 KiB | 0.07 s |
|
||||
| `/brand/lockup-on-light.webp` | Image | 14 KiB | 0.07 s |
|
||||
| `/build/assets/app-8wBXH8kj.css` | Stylesheet | 10 KiB | 0.07 s |
|
||||
|
||||
| insight com falha | ganho estimado de LCP | bytes |
|
||||
|---|---|---|
|
||||
| Improve image delivery | 0.45 s | — |
|
||||
| Use efficient cache lifetimes | 0.15 s | — |
|
||||
| LCP request discovery | 0.00 s | — |
|
||||
| Network dependency tree | 0.00 s | — |
|
||||
| Render blocking requests | 0.00 s | — |
|
||||
|
||||
### sobre — desktop
|
||||
|
||||
Elemento de LCP: `<img src="http://127.0.0.1:8000/storage/content/about/about-image-720.jpg.webp" srcset="/storage/content/about/about-image-480.jpg 480w, /storage/content/about/`
|
||||
|
||||
| fase | tempo |
|
||||
|---|---|
|
||||
| TTFB | 0.12 s |
|
||||
| Load Delay | 0.11 s |
|
||||
| Load Time | 0.01 s |
|
||||
| Render Delay | 0.12 s |
|
||||
|
||||
Requests concluídas até o LCP, mais pesadas primeiro:
|
||||
|
||||
| recurso | tipo | transferido | fim |
|
||||
|---|---|---|---|
|
||||
| `/build/assets/eb-garamond-600-normal-DHwxsLHv.woff2` | Font | 25 KiB | 0.06 s |
|
||||
| `/build/assets/eb-garamond-500-normal-DehAIUv0.woff2` | Font | 25 KiB | 0.06 s |
|
||||
| `/build/assets/eb-garamond-400-normal-BCNrxLz_.woff2` | Font | 24 KiB | 0.06 s |
|
||||
| `/storage/content/about/about-image-720.jpg.webp` | Image | 22 KiB | 0.07 s |
|
||||
| `/brand/lockup-on-light.webp` | Image | 14 KiB | 0.06 s |
|
||||
| `/build/assets/app-8wBXH8kj.css` | Stylesheet | 10 KiB | 0.06 s |
|
||||
| `/sobre` | Document | 5 KiB | 0.05 s |
|
||||
| `/build/assets/app-Cr9w0NCu.js` | Script | 2 KiB | 0.06 s |
|
||||
|
||||
| insight com falha | ganho estimado de LCP | bytes |
|
||||
|---|---|---|
|
||||
| Use efficient cache lifetimes | 0.00 s | — |
|
||||
| Improve image delivery | 0.00 s | — |
|
||||
| LCP request discovery | 0.00 s | — |
|
||||
| Network dependency tree | 0.00 s | — |
|
||||
| Render blocking requests | 0.00 s | — |
|
||||
|
||||
### sobre — mobile
|
||||
|
||||
Elemento de LCP: `<img src="http://127.0.0.1:8000/storage/content/about/about-image-720.jpg.webp" srcset="/storage/content/about/about-image-480.jpg 480w, /storage/content/about/`
|
||||
|
||||
| fase | tempo |
|
||||
|---|---|
|
||||
| TTFB | 0.45 s |
|
||||
| Load Delay | 0.77 s |
|
||||
| Load Time | 0.02 s |
|
||||
| Render Delay | 0.33 s |
|
||||
|
||||
Requests concluídas até o LCP, mais pesadas primeiro:
|
||||
|
||||
| recurso | tipo | transferido | fim |
|
||||
|---|---|---|---|
|
||||
| `/build/assets/eb-garamond-600-normal-DHwxsLHv.woff2` | Font | 25 KiB | 0.10 s |
|
||||
| `/build/assets/eb-garamond-500-normal-DehAIUv0.woff2` | Font | 25 KiB | 0.10 s |
|
||||
| `/build/assets/eb-garamond-400-normal-BCNrxLz_.woff2` | Font | 24 KiB | 0.10 s |
|
||||
| `/storage/content/about/about-image-720.jpg.webp` | Image | 22 KiB | 0.11 s |
|
||||
| `/brand/lockup-on-light.webp` | Image | 14 KiB | 0.10 s |
|
||||
| `/build/assets/app-8wBXH8kj.css` | Stylesheet | 10 KiB | 0.10 s |
|
||||
| `/sobre` | Document | 5 KiB | 0.09 s |
|
||||
| `/build/assets/app-Cr9w0NCu.js` | Script | 2 KiB | 0.10 s |
|
||||
|
||||
| insight com falha | ganho estimado de LCP | bytes |
|
||||
|---|---|---|
|
||||
| Use efficient cache lifetimes | 0.00 s | — |
|
||||
| Improve image delivery | 0.00 s | — |
|
||||
| LCP request discovery | 0.00 s | — |
|
||||
| Network dependency tree | 0.00 s | — |
|
||||
| Render blocking requests | 0.00 s | — |
|
||||
|
||||
@@ -0,0 +1,407 @@
|
||||
# Lighthouse — local-pos
|
||||
|
||||
- Origem: `http://127.0.0.1:8000`
|
||||
- Lighthouse: 12.8.2
|
||||
- Navegador: `Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/151.0.0.0 Safari/537.36`
|
||||
- Coletado em: 2026-08-10T16:20:48.372Z
|
||||
- Execuções por página/preset: 3 (reportada a de LCP mediano)
|
||||
- Commit: `65919d4` (o script gravou `2e43fde`, o HEAD no momento da coleta; a árvore medida é a que virou `65919d4`)
|
||||
- Seeder: `ContentSeeder`
|
||||
|
||||
Metas SPEC §6.6: LCP ≤ 2,5 s · CLS ≤ 0,1 · INP ≤ 200 ms · zero erro de console.
|
||||
|
||||
| página | preset | perf | a11y | BP | SEO | LCP | FCP | CLS | TBT | TTFB servidor | LCP min–max |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| contato | desktop | 100 | 100 | 100 | 100 | 0.36 s | 0.25 s | 0.000 | 0 ms | 20 ms | 0.36 s – 0.37 s |
|
||||
| contato | mobile | 100 | 100 | 100 | 100 | 1.51 s | 0.92 s | 0.000 | 0 ms | 46 ms | 1.50 s – 1.51 s |
|
||||
| home | desktop | 100 | 100 | 100 | 100 | 0.67 s | 0.25 s | 0.000 | 0 ms | 87 ms | 0.67 s – 0.67 s |
|
||||
| home | mobile | 92 | 100 | 100 | 100 | 3.39 s | 0.92 s | 0.000 | 0 ms | 88 ms | 3.38 s – 3.46 s |
|
||||
| portfolio-detalhe | desktop | 100 | 100 | 100 | 100 | 0.79 s | 0.25 s | 0.000 | 0 ms | 74 ms | 0.79 s – 0.79 s |
|
||||
| portfolio-detalhe | mobile | 95 | 100 | 100 | 100 | 3.01 s | 0.91 s | 0.000 | 0 ms | 89 ms | 2.93 s – 3.01 s |
|
||||
| portfolio | desktop | 100 | 100 | 100 | 100 | 0.65 s | 0.25 s | 0.000 | 0 ms | 73 ms | 0.37 s – 0.65 s |
|
||||
| portfolio | mobile | 100 | 100 | 100 | 100 | 1.58 s | 0.91 s | 0.000 | 0 ms | 69 ms | 1.58 s – 3.24 s |
|
||||
| servicos | desktop | 100 | 100 | 100 | 100 | 0.36 s | 0.25 s | 0.000 | 0 ms | 121 ms | 0.36 s – 0.59 s |
|
||||
| servicos | mobile | 97 | 100 | 100 | 100 | 2.63 s | 0.90 s | 0.000 | 0 ms | 73 ms | 1.58 s – 2.63 s |
|
||||
| sobre | desktop | 100 | 100 | 100 | 100 | 0.37 s | 0.25 s | 0.000 | 0 ms | 97 ms | 0.36 s – 0.45 s |
|
||||
| sobre | mobile | 100 | 100 | 100 | 100 | 1.58 s | 0.91 s | 0.000 | 0 ms | 48 ms | 1.58 s – 1.96 s |
|
||||
|
||||
## Decomposição do LCP
|
||||
|
||||
### contato — desktop
|
||||
|
||||
Elemento de LCP: `<h1 class="text-headline font-medium tracking-tight text-amare-text">`
|
||||
|
||||
| fase | tempo |
|
||||
|---|---|
|
||||
| TTFB | 0.12 s |
|
||||
| Load Delay | 0.00 s |
|
||||
| Load Time | 0.00 s |
|
||||
| Render Delay | 0.24 s |
|
||||
|
||||
Requests concluídas até o LCP, mais pesadas primeiro:
|
||||
|
||||
| recurso | tipo | transferido | fim |
|
||||
|---|---|---|---|
|
||||
| `/build/assets/eb-garamond-600-normal-DHwxsLHv.woff2` | Font | 25 KiB | 0.03 s |
|
||||
| `/build/assets/eb-garamond-500-normal-DehAIUv0.woff2` | Font | 25 KiB | 0.03 s |
|
||||
| `/build/assets/eb-garamond-400-normal-BCNrxLz_.woff2` | Font | 24 KiB | 0.03 s |
|
||||
| `/brand/lockup-on-light.webp` | Image | 14 KiB | 0.03 s |
|
||||
| `/build/assets/app-Cswasu1n.css` | Stylesheet | 10 KiB | 0.03 s |
|
||||
| `/contato` | Document | 5 KiB | 0.02 s |
|
||||
| `/build/assets/app-Cr9w0NCu.js` | Script | 2 KiB | 0.03 s |
|
||||
|
||||
| insight com falha | ganho estimado de LCP | bytes |
|
||||
|---|---|---|
|
||||
| Use efficient cache lifetimes | 0.00 s | — |
|
||||
| Improve image delivery | 0.00 s | — |
|
||||
| Network dependency tree | 0.00 s | — |
|
||||
| Render blocking requests | 0.00 s | — |
|
||||
|
||||
### contato — mobile
|
||||
|
||||
Elemento de LCP: `<h1 class="text-headline font-medium tracking-tight text-amare-text">`
|
||||
|
||||
| fase | tempo |
|
||||
|---|---|
|
||||
| TTFB | 0.45 s |
|
||||
| Load Delay | 0.00 s |
|
||||
| Load Time | 0.00 s |
|
||||
| Render Delay | 1.05 s |
|
||||
|
||||
Requests concluídas até o LCP, mais pesadas primeiro:
|
||||
|
||||
| recurso | tipo | transferido | fim |
|
||||
|---|---|---|---|
|
||||
| `/build/assets/eb-garamond-600-normal-DHwxsLHv.woff2` | Font | 25 KiB | 0.06 s |
|
||||
| `/build/assets/eb-garamond-500-normal-DehAIUv0.woff2` | Font | 25 KiB | 0.05 s |
|
||||
| `/build/assets/eb-garamond-400-normal-BCNrxLz_.woff2` | Font | 24 KiB | 0.05 s |
|
||||
| `/brand/lockup-on-light.webp` | Image | 14 KiB | 0.06 s |
|
||||
| `/build/assets/app-Cswasu1n.css` | Stylesheet | 10 KiB | 0.06 s |
|
||||
| `/contato` | Document | 5 KiB | 0.05 s |
|
||||
| `/build/assets/app-Cr9w0NCu.js` | Script | 2 KiB | 0.06 s |
|
||||
|
||||
| insight com falha | ganho estimado de LCP | bytes |
|
||||
|---|---|---|
|
||||
| Use efficient cache lifetimes | 0.00 s | — |
|
||||
| Improve image delivery | 0.00 s | — |
|
||||
| Network dependency tree | 0.00 s | — |
|
||||
| Render blocking requests | 0.00 s | — |
|
||||
|
||||
### home — desktop
|
||||
|
||||
Elemento de LCP: `<img src="http://127.0.0.1:8000/storage/content/og/og-default-960.jpg" srcset="/storage/content/og/og-default-480.jpg 480w, /storage/content/og/og-defaul…" size`
|
||||
|
||||
| fase | tempo |
|
||||
|---|---|
|
||||
| TTFB | 0.12 s |
|
||||
| Load Delay | 0.33 s |
|
||||
| Load Time | 0.03 s |
|
||||
| Render Delay | 0.19 s |
|
||||
|
||||
Requests concluídas até o LCP, mais pesadas primeiro:
|
||||
|
||||
| recurso | tipo | transferido | fim |
|
||||
|---|---|---|---|
|
||||
| `/storage/content/og/og-default-960.jpg` | Image | 143 KiB | 0.10 s |
|
||||
| `/storage/content/portfolio/casamento-ana-lucas-cover-960.jpg` | Image | 142 KiB | 0.11 s |
|
||||
| `/storage/content/portfolio/lancamento-verano-cover-960.jpg` | Image | 106 KiB | 0.11 s |
|
||||
| `/storage/content/portfolio/mini-wedding-marina-cover-960.jpg` | Image | 100 KiB | 0.11 s |
|
||||
| `/build/assets/eb-garamond-600-normal-DHwxsLHv.woff2` | Font | 25 KiB | 0.10 s |
|
||||
| `/build/assets/eb-garamond-500-normal-DehAIUv0.woff2` | Font | 25 KiB | 0.09 s |
|
||||
| `/build/assets/eb-garamond-400-normal-BCNrxLz_.woff2` | Font | 24 KiB | 0.10 s |
|
||||
| `/brand/lockup-on-light.webp` | Image | 14 KiB | 0.10 s |
|
||||
|
||||
| insight com falha | ganho estimado de LCP | bytes |
|
||||
|---|---|---|
|
||||
| Improve image delivery | 0.15 s | — |
|
||||
| Use efficient cache lifetimes | 0.00 s | — |
|
||||
| Network dependency tree | 0.00 s | — |
|
||||
| Render blocking requests | 0.00 s | — |
|
||||
|
||||
### home — mobile
|
||||
|
||||
Elemento de LCP: `<img src="http://127.0.0.1:8000/storage/content/og/og-default-960.jpg" srcset="/storage/content/og/og-default-480.jpg 480w, /storage/content/og/og-defaul…" size`
|
||||
|
||||
| fase | tempo |
|
||||
|---|---|
|
||||
| TTFB | 0.45 s |
|
||||
| Load Delay | 1.88 s |
|
||||
| Load Time | 0.17 s |
|
||||
| Render Delay | 0.88 s |
|
||||
|
||||
Requests concluídas até o LCP, mais pesadas primeiro:
|
||||
|
||||
| recurso | tipo | transferido | fim |
|
||||
|---|---|---|---|
|
||||
| `/storage/content/og/og-default-960.jpg` | Image | 143 KiB | 0.10 s |
|
||||
| `/storage/content/portfolio/casamento-ana-lucas-cover-960.jpg` | Image | 142 KiB | 0.12 s |
|
||||
| `/storage/content/portfolio/lancamento-verano-cover-960.jpg` | Image | 106 KiB | 0.12 s |
|
||||
| `/storage/content/portfolio/mini-wedding-marina-cover-960.jpg` | Image | 100 KiB | 0.12 s |
|
||||
| `/build/assets/eb-garamond-600-normal-DHwxsLHv.woff2` | Font | 25 KiB | 0.10 s |
|
||||
| `/build/assets/eb-garamond-500-normal-DehAIUv0.woff2` | Font | 25 KiB | 0.10 s |
|
||||
| `/build/assets/eb-garamond-400-normal-BCNrxLz_.woff2` | Font | 24 KiB | 0.10 s |
|
||||
| `/brand/lockup-on-light.webp` | Image | 14 KiB | 0.10 s |
|
||||
|
||||
| insight com falha | ganho estimado de LCP | bytes |
|
||||
|---|---|---|
|
||||
| Improve image delivery | 0.90 s | — |
|
||||
| Use efficient cache lifetimes | 0.15 s | — |
|
||||
| Network dependency tree | 0.00 s | — |
|
||||
| Render blocking requests | 0.00 s | — |
|
||||
|
||||
### portfolio-detalhe — desktop
|
||||
|
||||
Elemento de LCP: `<img src="http://127.0.0.1:8000/storage/content/portfolio/casamento-ana-lucas-cover-…" srcset="/storage/content/portfolio/casamento-ana-lucas-cover-480.jpg 480w`
|
||||
|
||||
| fase | tempo |
|
||||
|---|---|
|
||||
| TTFB | 0.12 s |
|
||||
| Load Delay | 0.43 s |
|
||||
| Load Time | 0.03 s |
|
||||
| Render Delay | 0.21 s |
|
||||
|
||||
Requests concluídas até o LCP, mais pesadas primeiro:
|
||||
|
||||
| recurso | tipo | transferido | fim |
|
||||
|---|---|---|---|
|
||||
| `/storage/content/portfolio/casamento-ana-lucas-cover-1440.jpg` | Image | 287 KiB | 0.08 s |
|
||||
| `/storage/content/portfolio/casamento-ana-lucas-gallery-1-960.jpg` | Image | 209 KiB | 0.09 s |
|
||||
| `/storage/content/portfolio/casamento-ana-lucas-gallery-2-960.jpg` | Image | 172 KiB | 0.09 s |
|
||||
| `/build/assets/eb-garamond-600-normal-DHwxsLHv.woff2` | Font | 25 KiB | 0.08 s |
|
||||
| `/build/assets/eb-garamond-500-normal-DehAIUv0.woff2` | Font | 25 KiB | 0.08 s |
|
||||
| `/build/assets/eb-garamond-400-normal-BCNrxLz_.woff2` | Font | 24 KiB | 0.08 s |
|
||||
| `/brand/lockup-on-light.webp` | Image | 14 KiB | 0.08 s |
|
||||
| `/build/assets/app-Cswasu1n.css` | Stylesheet | 10 KiB | 0.08 s |
|
||||
|
||||
| insight com falha | ganho estimado de LCP | bytes |
|
||||
|---|---|---|
|
||||
| Improve image delivery | 0.10 s | — |
|
||||
| Use efficient cache lifetimes | 0.00 s | — |
|
||||
| LCP request discovery | 0.00 s | — |
|
||||
| Network dependency tree | 0.00 s | — |
|
||||
| Render blocking requests | 0.00 s | — |
|
||||
|
||||
### portfolio-detalhe — mobile
|
||||
|
||||
Elemento de LCP: `<img src="http://127.0.0.1:8000/storage/content/portfolio/casamento-ana-lucas-cover-…" srcset="/storage/content/portfolio/casamento-ana-lucas-cover-480.jpg 480w`
|
||||
|
||||
| fase | tempo |
|
||||
|---|---|
|
||||
| TTFB | 0.45 s |
|
||||
| Load Delay | 1.63 s |
|
||||
| Load Time | 0.06 s |
|
||||
| Render Delay | 0.87 s |
|
||||
|
||||
Requests concluídas até o LCP, mais pesadas primeiro:
|
||||
|
||||
| recurso | tipo | transferido | fim |
|
||||
|---|---|---|---|
|
||||
| `/storage/content/portfolio/casamento-ana-lucas-gallery-1-960.jpg` | Image | 209 KiB | 0.11 s |
|
||||
| `/storage/content/portfolio/casamento-ana-lucas-gallery-2-960.jpg` | Image | 172 KiB | 0.11 s |
|
||||
| `/storage/content/portfolio/casamento-ana-lucas-cover-960.jpg` | Image | 142 KiB | 0.10 s |
|
||||
| `/build/assets/eb-garamond-600-normal-DHwxsLHv.woff2` | Font | 25 KiB | 0.10 s |
|
||||
| `/build/assets/eb-garamond-500-normal-DehAIUv0.woff2` | Font | 25 KiB | 0.10 s |
|
||||
| `/build/assets/eb-garamond-400-normal-BCNrxLz_.woff2` | Font | 24 KiB | 0.10 s |
|
||||
| `/brand/lockup-on-light.webp` | Image | 14 KiB | 0.10 s |
|
||||
| `/build/assets/app-Cswasu1n.css` | Stylesheet | 10 KiB | 0.10 s |
|
||||
|
||||
| insight com falha | ganho estimado de LCP | bytes |
|
||||
|---|---|---|
|
||||
| Improve image delivery | 0.90 s | — |
|
||||
| Use efficient cache lifetimes | 0.15 s | — |
|
||||
| LCP request discovery | 0.00 s | — |
|
||||
| Network dependency tree | 0.00 s | — |
|
||||
| Render blocking requests | 0.00 s | — |
|
||||
|
||||
### portfolio — desktop
|
||||
|
||||
Elemento de LCP: `<img src="http://127.0.0.1:8000/storage/content/portfolio/casamento-ana-lucas-cover-…" srcset="/storage/content/portfolio/casamento-ana-lucas-cover-480.jpg 480w`
|
||||
|
||||
| fase | tempo |
|
||||
|---|---|
|
||||
| TTFB | 0.12 s |
|
||||
| Load Delay | 0.28 s |
|
||||
| Load Time | 0.05 s |
|
||||
| Render Delay | 0.19 s |
|
||||
|
||||
Requests concluídas até o LCP, mais pesadas primeiro:
|
||||
|
||||
| recurso | tipo | transferido | fim |
|
||||
|---|---|---|---|
|
||||
| `/storage/content/portfolio/casamento-ana-lucas-cover-960.jpg` | Image | 142 KiB | 0.10 s |
|
||||
| `/storage/content/portfolio/lancamento-verano-cover-960.jpg` | Image | 106 KiB | 0.10 s |
|
||||
| `/storage/content/portfolio/mini-wedding-marina-cover-960.jpg` | Image | 100 KiB | 0.10 s |
|
||||
| `/build/assets/eb-garamond-600-normal-DHwxsLHv.woff2` | Font | 25 KiB | 0.08 s |
|
||||
| `/build/assets/eb-garamond-500-normal-DehAIUv0.woff2` | Font | 25 KiB | 0.08 s |
|
||||
| `/build/assets/eb-garamond-400-normal-BCNrxLz_.woff2` | Font | 24 KiB | 0.08 s |
|
||||
| `/brand/lockup-on-light.webp` | Image | 14 KiB | 0.08 s |
|
||||
| `/build/assets/app-Cswasu1n.css` | Stylesheet | 10 KiB | 0.08 s |
|
||||
|
||||
| insight com falha | ganho estimado de LCP | bytes |
|
||||
|---|---|---|
|
||||
| Improve image delivery | 0.20 s | — |
|
||||
| Use efficient cache lifetimes | 0.00 s | — |
|
||||
| LCP request discovery | 0.00 s | — |
|
||||
| Network dependency tree | 0.00 s | — |
|
||||
| Render blocking requests | 0.00 s | — |
|
||||
|
||||
### portfolio — mobile
|
||||
|
||||
Elemento de LCP: `<img src="http://127.0.0.1:8000/storage/content/portfolio/casamento-ana-lucas-cover-…" srcset="/storage/content/portfolio/casamento-ana-lucas-cover-480.jpg 480w`
|
||||
|
||||
| fase | tempo |
|
||||
|---|---|
|
||||
| TTFB | 0.45 s |
|
||||
| Load Delay | 0.64 s |
|
||||
| Load Time | 0.03 s |
|
||||
| Render Delay | 0.46 s |
|
||||
|
||||
Requests concluídas até o LCP, mais pesadas primeiro:
|
||||
|
||||
| recurso | tipo | transferido | fim |
|
||||
|---|---|---|---|
|
||||
| `/storage/content/portfolio/casamento-ana-lucas-cover-960.jpg` | Image | 142 KiB | 0.09 s |
|
||||
| `/storage/content/portfolio/lancamento-verano-cover-960.jpg` | Image | 106 KiB | 0.09 s |
|
||||
| `/storage/content/portfolio/mini-wedding-marina-cover-960.jpg` | Image | 100 KiB | 0.09 s |
|
||||
| `/build/assets/eb-garamond-600-normal-DHwxsLHv.woff2` | Font | 25 KiB | 0.08 s |
|
||||
| `/build/assets/eb-garamond-500-normal-DehAIUv0.woff2` | Font | 25 KiB | 0.08 s |
|
||||
| `/build/assets/eb-garamond-400-normal-BCNrxLz_.woff2` | Font | 24 KiB | 0.08 s |
|
||||
| `/brand/lockup-on-light.webp` | Image | 14 KiB | 0.08 s |
|
||||
| `/build/assets/app-Cswasu1n.css` | Stylesheet | 10 KiB | 0.08 s |
|
||||
|
||||
| insight com falha | ganho estimado de LCP | bytes |
|
||||
|---|---|---|
|
||||
| Use efficient cache lifetimes | 0.00 s | — |
|
||||
| Improve image delivery | 0.00 s | — |
|
||||
| LCP request discovery | 0.00 s | — |
|
||||
| Network dependency tree | 0.00 s | — |
|
||||
| Render blocking requests | 0.00 s | — |
|
||||
|
||||
### servicos — desktop
|
||||
|
||||
Elemento de LCP: `<img src="http://127.0.0.1:8000/storage/content/services/casamentos-960.jpg" srcset="/storage/content/services/casamentos-480.jpg 480w, /storage/content/servic…`
|
||||
|
||||
| fase | tempo |
|
||||
|---|---|
|
||||
| TTFB | 0.12 s |
|
||||
| Load Delay | 0.18 s |
|
||||
| Load Time | 0.01 s |
|
||||
| Render Delay | 0.06 s |
|
||||
|
||||
Requests concluídas até o LCP, mais pesadas primeiro:
|
||||
|
||||
| recurso | tipo | transferido | fim |
|
||||
|---|---|---|---|
|
||||
| `/storage/content/services/casamentos-960.jpg` | Image | 107 KiB | 0.14 s |
|
||||
| `/storage/content/services/eventos-corporativos-960.jpg` | Image | 100 KiB | 0.14 s |
|
||||
| `/storage/content/services/celebracoes-intimistas-960.jpg` | Image | 83 KiB | 0.14 s |
|
||||
| `/build/assets/eb-garamond-600-normal-DHwxsLHv.woff2` | Font | 25 KiB | 0.13 s |
|
||||
| `/build/assets/eb-garamond-500-normal-DehAIUv0.woff2` | Font | 25 KiB | 0.13 s |
|
||||
| `/build/assets/eb-garamond-400-normal-BCNrxLz_.woff2` | Font | 24 KiB | 0.13 s |
|
||||
| `/brand/lockup-on-light.webp` | Image | 14 KiB | 0.13 s |
|
||||
| `/build/assets/app-Cswasu1n.css` | Stylesheet | 10 KiB | 0.13 s |
|
||||
|
||||
| insight com falha | ganho estimado de LCP | bytes |
|
||||
|---|---|---|
|
||||
| Use efficient cache lifetimes | 0.00 s | — |
|
||||
| Improve image delivery | 0.00 s | — |
|
||||
| LCP request discovery | 0.00 s | — |
|
||||
| Network dependency tree | 0.00 s | — |
|
||||
| Render blocking requests | 0.00 s | — |
|
||||
|
||||
### servicos — mobile
|
||||
|
||||
Elemento de LCP: `<img src="http://127.0.0.1:8000/storage/content/services/casamentos-960.jpg" srcset="/storage/content/services/casamentos-480.jpg 480w, /storage/content/servic…`
|
||||
|
||||
| fase | tempo |
|
||||
|---|---|
|
||||
| TTFB | 0.45 s |
|
||||
| Load Delay | 1.50 s |
|
||||
| Load Time | 0.11 s |
|
||||
| Render Delay | 0.56 s |
|
||||
|
||||
Requests concluídas até o LCP, mais pesadas primeiro:
|
||||
|
||||
| recurso | tipo | transferido | fim |
|
||||
|---|---|---|---|
|
||||
| `/storage/content/services/casamentos-960.jpg` | Image | 107 KiB | 0.09 s |
|
||||
| `/storage/content/services/eventos-corporativos-960.jpg` | Image | 100 KiB | 0.09 s |
|
||||
| `/storage/content/services/celebracoes-intimistas-960.jpg` | Image | 83 KiB | 0.09 s |
|
||||
| `/build/assets/eb-garamond-600-normal-DHwxsLHv.woff2` | Font | 25 KiB | 0.08 s |
|
||||
| `/build/assets/eb-garamond-500-normal-DehAIUv0.woff2` | Font | 25 KiB | 0.08 s |
|
||||
| `/build/assets/eb-garamond-400-normal-BCNrxLz_.woff2` | Font | 24 KiB | 0.08 s |
|
||||
| `/brand/lockup-on-light.webp` | Image | 14 KiB | 0.08 s |
|
||||
| `/build/assets/app-Cswasu1n.css` | Stylesheet | 10 KiB | 0.08 s |
|
||||
|
||||
| insight com falha | ganho estimado de LCP | bytes |
|
||||
|---|---|---|
|
||||
| Improve image delivery | 0.60 s | — |
|
||||
| Use efficient cache lifetimes | 0.00 s | — |
|
||||
| LCP request discovery | 0.00 s | — |
|
||||
| Network dependency tree | 0.00 s | — |
|
||||
| Render blocking requests | 0.00 s | — |
|
||||
|
||||
### sobre — desktop
|
||||
|
||||
Elemento de LCP: `<img src="http://127.0.0.1:8000/storage/content/about/about-image-960.jpg" srcset="/storage/content/about/about-image-480.jpg 480w, /storage/content/about/ab…" `
|
||||
|
||||
| fase | tempo |
|
||||
|---|---|
|
||||
| TTFB | 0.13 s |
|
||||
| Load Delay | 0.15 s |
|
||||
| Load Time | 0.00 s |
|
||||
| Render Delay | 0.09 s |
|
||||
|
||||
Requests concluídas até o LCP, mais pesadas primeiro:
|
||||
|
||||
| recurso | tipo | transferido | fim |
|
||||
|---|---|---|---|
|
||||
| `/storage/content/about/about-image-960.jpg` | Image | 60 KiB | 0.12 s |
|
||||
| `/build/assets/eb-garamond-600-normal-DHwxsLHv.woff2` | Font | 25 KiB | 0.11 s |
|
||||
| `/build/assets/eb-garamond-500-normal-DehAIUv0.woff2` | Font | 25 KiB | 0.10 s |
|
||||
| `/build/assets/eb-garamond-400-normal-BCNrxLz_.woff2` | Font | 24 KiB | 0.10 s |
|
||||
| `/brand/lockup-on-light.webp` | Image | 14 KiB | 0.11 s |
|
||||
| `/build/assets/app-Cswasu1n.css` | Stylesheet | 10 KiB | 0.11 s |
|
||||
| `/sobre` | Document | 4 KiB | 0.10 s |
|
||||
| `/build/assets/app-Cr9w0NCu.js` | Script | 2 KiB | 0.11 s |
|
||||
|
||||
| insight com falha | ganho estimado de LCP | bytes |
|
||||
|---|---|---|
|
||||
| Use efficient cache lifetimes | 0.00 s | — |
|
||||
| Improve image delivery | 0.00 s | — |
|
||||
| LCP request discovery | 0.00 s | — |
|
||||
| Network dependency tree | 0.00 s | — |
|
||||
| Render blocking requests | 0.00 s | — |
|
||||
|
||||
### sobre — mobile
|
||||
|
||||
Elemento de LCP: `<img src="http://127.0.0.1:8000/storage/content/about/about-image-960.jpg" srcset="/storage/content/about/about-image-480.jpg 480w, /storage/content/about/ab…" `
|
||||
|
||||
| fase | tempo |
|
||||
|---|---|
|
||||
| TTFB | 0.45 s |
|
||||
| Load Delay | 0.57 s |
|
||||
| Load Time | 0.10 s |
|
||||
| Render Delay | 0.46 s |
|
||||
|
||||
Requests concluídas até o LCP, mais pesadas primeiro:
|
||||
|
||||
| recurso | tipo | transferido | fim |
|
||||
|---|---|---|---|
|
||||
| `/storage/content/about/about-image-960.jpg` | Image | 60 KiB | 0.07 s |
|
||||
| `/build/assets/eb-garamond-600-normal-DHwxsLHv.woff2` | Font | 25 KiB | 0.06 s |
|
||||
| `/build/assets/eb-garamond-500-normal-DehAIUv0.woff2` | Font | 25 KiB | 0.06 s |
|
||||
| `/build/assets/eb-garamond-400-normal-BCNrxLz_.woff2` | Font | 24 KiB | 0.06 s |
|
||||
| `/brand/lockup-on-light.webp` | Image | 14 KiB | 0.06 s |
|
||||
| `/build/assets/app-Cswasu1n.css` | Stylesheet | 10 KiB | 0.06 s |
|
||||
| `/sobre` | Document | 4 KiB | 0.05 s |
|
||||
| `/build/assets/app-Cr9w0NCu.js` | Script | 2 KiB | 0.06 s |
|
||||
|
||||
| insight com falha | ganho estimado de LCP | bytes |
|
||||
|---|---|---|
|
||||
| Use efficient cache lifetimes | 0.00 s | — |
|
||||
| Improve image delivery | 0.00 s | — |
|
||||
| LCP request discovery | 0.00 s | — |
|
||||
| Network dependency tree | 0.00 s | — |
|
||||
| Render blocking requests | 0.00 s | — |
|
||||
|
||||
205
docs/evidence/lighthouse/README.md
Normal file
205
docs/evidence/lighthouse/README.md
Normal file
@@ -0,0 +1,205 @@
|
||||
# Lighthouse — MAN-109
|
||||
|
||||
Medições versionadas porque a rodada anterior (PR #34) sobreviveu apenas como
|
||||
uma tabela digitada à mão num comentário do Linear: `storage/app/lighthouse` é
|
||||
gitignored, então não havia contra o quê comparar. Os relatórios brutos pesam
|
||||
~48 MB por passada e continuam fora do repositório; o que fica versionado são os
|
||||
resumos, que já carregam a decomposição do LCP e a lista de requests até o LCP.
|
||||
|
||||
## Como reproduzir
|
||||
|
||||
```bash
|
||||
# Sobe a imagem de produção. O seed roda no host, não no container:
|
||||
# ContentSeeder é no-op fora de local/staging/testing (database/seeders/ContentSeeder.php:44),
|
||||
# então sob APP_ENV=production ele não semeia nada e o Lighthouse mede páginas vazias.
|
||||
docker build -t amare-app:man109 .
|
||||
php artisan migrate --force
|
||||
php artisan db:seed --class=ContentSeeder --force
|
||||
php artisan media:generate-variants # sem isso o LCP da home infla ~2,5 s
|
||||
docker run -d --name amare-web -p 8000:8000 -e APP_ENV=production ... amare-app:man109
|
||||
|
||||
TARGET=local bash scripts/perf/lighthouse.sh storage/app/lighthouse
|
||||
```
|
||||
|
||||
Cada página é auditada 3 vezes por preset e o relatório de LCP mediano é o
|
||||
reportado — nunca a média. O LCP se move alguns décimos entre execuções na mesma
|
||||
build, e uma única execução não sustenta comparação.
|
||||
|
||||
## Resultado
|
||||
|
||||
Preset mobile padrão do Lighthouse 12.8.2: throttling simulado de 150 ms de RTT,
|
||||
~1,6 Mbps, CPU 4× mais lenta. Chrome estável do sistema (não o Chromium do
|
||||
Playwright), imagem de produção local, seeder `ContentSeeder`.
|
||||
|
||||
Antes: commit `2e43fde`. As colunas intermediárias mostram cada correção
|
||||
isoladamente, medida com uma passada completa antes da seguinte entrar.
|
||||
|
||||
| página | preset | antes | + fontes e marca | + webp e 720w | + sizes correto | perf antes → depois | bytes antes → depois |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| contato | desktop | 0.53 s | 0.36 s | 0.37 s | **0.36 s** | 100 → 100 | 262 → 106 KiB |
|
||||
| contato | mobile | 2.55 s | 1.51 s | 1.51 s | **1.50 s** | 97 → 100 | 262 → 106 KiB |
|
||||
| home | desktop | 0.87 s | 0.67 s | 0.53 s | **0.53 s** | 99 → 100 | 798 → 347 KiB |
|
||||
| home | mobile | 4.58 s | 3.39 s | 2.87 s | **2.49 s** | 83 → 98 | 798 → 347 KiB |
|
||||
| portfolio | desktop | 0.83 s | 0.65 s | 0.37 s | **0.36 s** | 99 → 100 | 609 → 259 KiB |
|
||||
| portfolio | mobile | 3.98 s | 1.58 s | 2.64 s | **1.58 s** | 87 → 100 | 609 → 259 KiB |
|
||||
| portfolio-detalhe | desktop | 0.97 s | 0.79 s | 0.66 s | **0.65 s** | 99 → 100 | 930 → 508 KiB |
|
||||
| portfolio-detalhe | mobile | 3.98 s | 3.01 s | 2.71 s | **2.18 s** | 87 → 99 | 785 → 365 KiB |
|
||||
| servicos | desktop | 0.77 s | 0.36 s | 0.37 s | **0.36 s** | 100 → 100 | 552 → 222 KiB |
|
||||
| servicos | mobile | 3.68 s | 2.63 s | 1.58 s | **2.03 s** | 89 → 99 | 552 → 222 KiB |
|
||||
| sobre | desktop | 0.61 s | 0.37 s | 0.37 s | **0.36 s** | 100 → 100 | 322 → 127 KiB |
|
||||
| sobre | mobile | 3.01 s | 1.58 s | 1.60 s | **1.58 s** | 94 → 100 | 322 → 127 KiB |
|
||||
|
||||
FCP cai de 1,51 s para 0,91 s em todas as páginas no mobile. Acessibilidade,
|
||||
boas práticas e SEO marcam 100 em todas as páginas nos dois presets, antes e
|
||||
depois. CLS é 0,000 e TBT é 0 ms em todas — as metas de §6.6 para essas três
|
||||
métricas já passavam e continuam passando.
|
||||
|
||||
**Contra a meta de LCP ≤ 2,5 s da §6.6: todas as páginas passam nos dois
|
||||
presets.** Desktop com folga (máximo 0,65 s). No mobile o pior caso é a home a
|
||||
2,49 s, ou seja **em cima da linha** — a pior das três execuções dela deu 2,57 s.
|
||||
Tratar a home como aprovada por margem, não com folga.
|
||||
|
||||
Duas colunas intermediárias merecem leitura cuidadosa em vez de conclusão:
|
||||
|
||||
- `portfolio` mobile aparece pior na coluna do webp (2,64 s) do que na anterior
|
||||
(1,58 s). É variância, não regressão: as três execuções daquela passada foram
|
||||
1,59 / 2,64 / 2,78 s. A página é a mais instável do conjunto e a mediana pulou
|
||||
de ponta. Na passada final as três deram 1,58 s.
|
||||
- `servicos` mobile sobe de 1,58 s para 2,03 s da terceira para a quarta coluna,
|
||||
pelo mesmo motivo (1,58 / 1,58 / 2,03).
|
||||
|
||||
É exatamente por isso que o script roda três vezes e reporta a mediana; ainda
|
||||
assim, diferenças abaixo de meio segundo entre passadas não devem ser lidas como
|
||||
efeito de uma correção.
|
||||
|
||||
Ressalva: a medição é local, então latência de origem e TLS não entram, e o
|
||||
throttling de rede é simulado. Trate o LCP como piso, não como valor de campo.
|
||||
|
||||
## O que cada correção comprou
|
||||
|
||||
**Fontes servidas em dobro — 88 KiB fora do caminho crítico.** Bunny entrega
|
||||
cada peso de EB Garamond em woff2 e woff, e o plugin de fontes emitia uma regra
|
||||
`@font-face` para cada, woff2 primeiro e woff depois. Duas regras com a mesma
|
||||
família, peso, estilo e unicode-range fazem a **última** vencer: o navegador
|
||||
renderizava a partir dos woff e descartava os woff2 pré-carregados.
|
||||
|
||||
A prova está no log de rede da home antes da correção: 3 woff em prioridade
|
||||
`VeryHigh` (88 KiB) — a prioridade mais alta da página, à frente do elemento de
|
||||
LCP — somados a 3 woff2 em `High` (74 KiB) que só foram baixados porque estavam
|
||||
em `<link rel="preload">`. 162 KiB de tráfego de fonte para 74 KiB de fonte útil.
|
||||
|
||||
woff2 é suportado por todo navegador que este site atende desde 2016, então as
|
||||
regras woff não eram fallback e sim peso morto. O plugin `amare:fonts-woff2-only`
|
||||
em `vite.config.js` remove as regras do CSS e do manifest e tira os arquivos do
|
||||
bundle. É o que derruba o FCP de 1,51 s para 0,91 s em todas as páginas.
|
||||
|
||||
**Ativos de marca reencodados — 102 KiB fora do caminho crítico.** O logotipo
|
||||
era servido a 512 px de largura para renderizar em 48 px (lockup, cabeçalho e
|
||||
rodapé) e 32 px (mark, home): 84 KiB + 42 KiB com `loading="eager"` em todas as
|
||||
páginas. Reencodados a 3× do maior render — lockup 149×144 (14 KiB) e mark
|
||||
191×96 (10 KiB) —, mantendo os mesmos nomes de arquivo para não invalidar cache.
|
||||
As variantes `on-dark` foram reencodadas junto por consistência; nenhuma view as
|
||||
usa hoje.
|
||||
|
||||
Isto absorve MAN-122: os ativos de marca foram reencodados sem alterar a forma
|
||||
ou a cor percebida, reduzindo apenas o peso transferido.
|
||||
|
||||
Os arquivos versionados aqui são: `2026-08-10-local-antes.md` (commit `2e43fde`),
|
||||
`2026-08-10-local-etapa-fontes-e-marca.md` (passada intermediária) e
|
||||
`2026-08-10-local-depois.md` (estado final).
|
||||
|
||||
**Variantes WebP nas imagens de conteúdo.** Com fontes e marca resolvidas, o
|
||||
elemento de LCP de toda página no mobile era a imagem do hero, e o Load Delay de
|
||||
1,88 s era contenção de banda pura: 143 KiB de JPEG q82 a 960 px, com as três
|
||||
capas do portfólio somando outros 348 KiB. `ResponsiveImage::generate()` passa a
|
||||
escrever uma variante `.webp` ao lado de cada variante no formato original, e
|
||||
`x-media.image` a oferece num `<source type="image/webp">`. O `<img>` continua
|
||||
apontando para o formato original, então nada quebra em quem não decodifica webp,
|
||||
e mídia antiga sem irmãos webp renderiza `<img>` puro como antes.
|
||||
|
||||
**`sizes` que descreve a realidade.** Nenhuma imagem do site ocupa a viewport
|
||||
inteira: todas ficam dentro de `container-amare`, que reserva 1,5rem de padding
|
||||
de cada lado. Declarar `100vw` fazia uma viewport de 412 px em DPR 1,75 pedir
|
||||
721 px e pular para a variante de 960 para desenhar uma caixa de 637 px — errar
|
||||
por um pixel custava um terço a mais de bytes. Com `calc(100vw - 3rem)` a home
|
||||
passa a usar a variante de 720 (**74 KiB**, contra 143 KiB no início).
|
||||
|
||||
Foi também por isso que 720 entrou em `ResponsiveImage::WIDTHS`: sem ela o salto
|
||||
de 480 para 960 é grande demais para a viewport mobile mais comum.
|
||||
|
||||
Decomposição final do LCP da home no mobile:
|
||||
|
||||
| fase | tempo |
|
||||
|---|---|
|
||||
| TTFB | 0,45 s |
|
||||
| Load Delay | 1,24 s |
|
||||
| Load Time | 0,10 s |
|
||||
| Render Delay | 0,69 s |
|
||||
|
||||
## O ganho de imagem só aparece depois do deploy regenerar as variantes
|
||||
|
||||
Toda a medição acima usou `FILESYSTEM_DISK=local`. Em staging e produção o disco
|
||||
é `r2`, e lá as variantes `.webp` e a largura 720 **ainda não existem** para a
|
||||
mídia já publicada. Até `media:generate-variants` rodar, `availableWebpVariants()`
|
||||
volta vazio, o `<source>` é omitido e o `srcset` do formato original perde a
|
||||
entrada de 720w — ou seja, nenhum dos dois ganhos de imagem aparece.
|
||||
|
||||
O serviço `migrate` do `docker-compose.deploy.yml` roda
|
||||
`php artisan media:generate-variants` sem condição a cada deploy, depois de
|
||||
`migrate` e do seed, e o comando só pula um caminho quando o arquivo original não
|
||||
existe (`MediaGenerateVariantsCommand`) — regenera mesmo quando já há variantes.
|
||||
Então o primeiro deploy desta branch produz as variantes novas por conta própria.
|
||||
|
||||
Enquanto isso não acontece, há um custo sem contrapartida: `x-media.image` faz
|
||||
agora **8 chamadas `exists()` por imagem** (4 larguras × 2 formatos) contra o
|
||||
object store, no lugar de 3. Na home são ~32 round trips remotos por request em
|
||||
vez de ~12. Isso agrava a hipótese não validada abaixo em vez de melhorá-la, e é
|
||||
mais um motivo para cachear esses metadados.
|
||||
|
||||
## Se a home precisar de mais folga
|
||||
|
||||
O Load Delay de 1,24 s ainda é contenção: as três capas do portfólio somam
|
||||
155 KiB e, embora sejam `loading="lazy"` e prioridade `Low`, o navegador as busca
|
||||
porque entram no limiar de lazy loading da viewport emulada. Os levers restantes,
|
||||
em ordem de custo:
|
||||
|
||||
1. Baixar a qualidade webp de 80 para 75 (medido: 109 → 92 KiB na imagem do
|
||||
hero a 960 px). Barato, mas mexe na qualidade de imagem de uma marca cujo
|
||||
posicionamento é acabamento editorial — decisão de produto, não de engenharia.
|
||||
2. Reduzir o Render Delay de 0,69 s, que agora é a segunda maior fatia e é
|
||||
trabalho de main thread, não de rede.
|
||||
3. FrankenPHP worker mode para o TTFB de 0,45 s. Tem gatilho objetivo em
|
||||
SPEC §22 e não deve ser puxado antes dele.
|
||||
|
||||
## Cobertura que este trabalho não tem
|
||||
|
||||
Os testes browser não exercitam `srcset` nem `<picture>` com variantes geradas:
|
||||
nem `ContentSeeder` nem `VisualContentSeeder` geram variantes. Nesses cenários,
|
||||
`availableVariants()` volta vazio e o componente renderiza `<img>` puro. A
|
||||
cobertura do caminho com variantes fica nos testes de feature
|
||||
(`MediaImageComponentTest`).
|
||||
|
||||
## Staging
|
||||
|
||||
Não medido. A origem de staging responde `303` para
|
||||
`blocked.teams.cloudflare.com` a partir da rede corporativa da Creditas
|
||||
("O conteúdo deste site viola a Política de Segurança da Informação"), inclusive
|
||||
em `/up`. O preflight de HTTP 200 do script barra a execução antes de gastar
|
||||
minutos auditando páginas de bloqueio.
|
||||
|
||||
Duas hipóteses seguem **não validadas** porque só existem com
|
||||
`FILESYSTEM_DISK=r2`, que é configuração de staging e produção:
|
||||
|
||||
- `x-media.image` chama `ResponsiveImage::availableVariants()`,
|
||||
`availableWebpVariants()` (8 `exists()` somados) e `ResponsiveImage::dimensions()`
|
||||
(que baixa o arquivo inteiro) a cada render, sem cache. No disco `local` são
|
||||
leituras de filesystem; no `r2` são ~9 round trips remotos por imagem no lado
|
||||
servidor, ~36 na home. Teste discriminante: comparar o TTFB de `/contato`
|
||||
(zero imagens) com o de `/portfolio` (N imagens). Se o TTFB escalar com a
|
||||
contagem de imagens, está confirmado. **Este é o item mais urgente da lista**,
|
||||
porque as variantes webp multiplicaram o número de chamadas.
|
||||
- A mídia vem de `R2_URL`, uma origem cross-origin, e não há `preconnect` no
|
||||
`<head>` — DNS e TLS entram antes do LCP.
|
||||
|
||||
Para medir: rodar `TARGET=staging BASE_URL=<origem> bash scripts/perf/lighthouse.sh`
|
||||
de uma rede sem o filtro corporativo.
|
||||
BIN
docs/evidence/public-site-motion/about-desktop.png
Normal file
BIN
docs/evidence/public-site-motion/about-desktop.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 170 KiB |
BIN
docs/evidence/public-site-motion/contact-mobile.png
Normal file
BIN
docs/evidence/public-site-motion/contact-mobile.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 155 KiB |
417
docs/operations/atualizacao-de-conteudo.md
Normal file
417
docs/operations/atualizacao-de-conteudo.md
Normal file
@@ -0,0 +1,417 @@
|
||||
# Atualização de conteúdo do site
|
||||
|
||||
Guia para quem cuida do conteúdo do site (textos, fotos, casos de portfólio,
|
||||
depoimentos, serviços e dados de contato) sem precisar mexer em código.
|
||||
|
||||
O site é editado por um painel interno chamado **"admin"** — é uma tela web,
|
||||
parecida com um formulário, onde cada bloco do site (textos da home, casos de
|
||||
portfólio, depoimentos, etc.) vira uma "página" que você edita e salva.
|
||||
|
||||
> Antes de qualquer coisa, leia a seção **"A regra mais importante: o que
|
||||
> torna algo visível no site"** — ela explica um comportamento que engana
|
||||
> quase todo mundo na primeira vez.
|
||||
|
||||
## Sumário
|
||||
|
||||
- [Como entrar no painel](#como-entrar-no-painel)
|
||||
- [A regra mais importante: o que torna algo visível no site](#a-regra-mais-importante-o-que-torna-algo-visível-no-site)
|
||||
- [Editar os textos da home e das páginas institucionais](#editar-os-textos-da-home-e-das-páginas-institucionais)
|
||||
- [Casos de portfólio: criar, editar, publicar e despublicar](#casos-de-portfólio-criar-editar-publicar-e-despublicar)
|
||||
- [Depoimentos: adicionar e publicar](#depoimentos-adicionar-e-publicar)
|
||||
- [Serviços: editar](#serviços-editar)
|
||||
- [Contato: telefone, e-mail e redes sociais](#contato-telefone-e-mail-e-redes-sociais)
|
||||
- [Imagens: formatos aceitos, tamanho e texto alternativo](#imagens-formatos-aceitos-tamanho-e-texto-alternativo)
|
||||
- [Usuários: papéis, ativar e desativar contas](#usuários-papéis-ativar-e-desativar-contas)
|
||||
- [O que NÃO mexer — e com quem falar](#o-que-não-mexer--e-com-quem-falar)
|
||||
- [Seções da home que somem por completo se ficarem vazias](#seções-da-home-que-somem-por-completo-se-ficarem-vazias)
|
||||
- [Sobre o arquivo depoimentos.md na raiz do repositório](#sobre-o-arquivo-depoimentosmd-na-raiz-do-repositório)
|
||||
|
||||
## Como entrar no painel
|
||||
|
||||
1. Acesse `/admin` no domínio do site (ex.: `https://seusite.com.br/admin`).
|
||||
2. Se você não estiver logada, o painel mostra uma tela de login pedindo
|
||||
e-mail e senha.
|
||||
3. Faça login com o e-mail e senha da sua conta.
|
||||
|
||||
**Se você não conseguir entrar:**
|
||||
|
||||
- **Esqueceu a senha:** hoje o painel **não tem** um link de "esqueci minha
|
||||
senha" self-service. Só uma pessoa com perfil **Administrador** consegue
|
||||
trocar a senha de outra conta (em Usuários → editar a conta → campo
|
||||
"Senha"). Se você é a única Administradora e está travada fora do painel,
|
||||
vai precisar pedir para quem desenvolve o site trocar a senha diretamente
|
||||
no banco de dados.
|
||||
- **Mensagem de acesso negado / conta inativa:** sua conta pode estar com o
|
||||
campo "Ativo" desligado. Só uma Administradora pode reativar isso em
|
||||
Usuários (veja [O que NÃO mexer](#o-que-não-mexer--e-com-quem-falar) sobre
|
||||
quem pode mexer em contas de usuário).
|
||||
- **Perfil "Assistente" sem acesso a nada:** hoje, contas com o papel
|
||||
"Assistente" conseguem fazer login, mas não conseguem ver nem editar
|
||||
**nenhum** conteúdo — nem Configurações, nem Portfólio, nem Depoimentos,
|
||||
nada. Isso é uma limitação atual do sistema, não um erro seu. Se você
|
||||
precisa dar acesso de edição a alguém que não seja Administrador, avise
|
||||
quem desenvolve o site — hoje não existe um meio-termo (“pode editar mas
|
||||
não pode tudo”), só "Administrador com acesso total" ou "Assistente sem
|
||||
acesso a nada".
|
||||
|
||||
## A regra mais importante: o que torna algo visível no site
|
||||
|
||||
Duas coisas controlam se algo aparece no site. Entender essas duas evita
|
||||
99% das dúvidas de "publiquei e não apareceu" ou "não publiquei e apareceu".
|
||||
|
||||
### 1. O campo "Publicado em" não é um agendamento
|
||||
|
||||
Em Serviços, Portfólio e Depoimentos existe um campo chamado **"Publicado
|
||||
em"**. A intuição normal seria pensar "se eu colocar uma data futura, ele só
|
||||
aparece nessa data" — **isso está errado**. Assim que você preenche esse
|
||||
campo com **qualquer** data/hora (passada, presente ou futura) e salva, o
|
||||
item fica visível no site **imediatamente**. Não existe agendamento.
|
||||
|
||||
- **Para publicar:** preencha "Publicado em" com uma data qualquer e salve.
|
||||
- **Para despublicar:** apague o conteúdo desse campo (deixe em branco) e
|
||||
salve. Colocar uma data no futuro **não** esconde o item — ele continua
|
||||
visível.
|
||||
|
||||
### 2. Home precisa de DOIS interruptores; páginas de listagem só de um
|
||||
|
||||
- Nas páginas de listagem completas — `/servicos` (todos os serviços) e
|
||||
`/portfolio` (todos os casos) — basta o item estar **publicado** (campo
|
||||
"Publicado em" preenchido) para aparecer.
|
||||
- Já nos blocos de **Serviços** e **Portfólio dentro da home** (a página
|
||||
inicial), o item só aparece se **as duas coisas** forem verdadeiras ao
|
||||
mesmo tempo:
|
||||
1. Estiver **publicado** ("Publicado em" preenchido), **e**
|
||||
2. Tiver o interruptor **"Destaque"** ligado.
|
||||
|
||||
Publicar sozinho não é suficiente para aparecer na home — só coloca o item
|
||||
na listagem completa. Você precisa também ligar "Destaque" se quiser que
|
||||
apareça na home.
|
||||
|
||||
- **Depoimentos são a exceção**: o bloco de Depoimentos na home mostra
|
||||
**todo** depoimento publicado, independente do interruptor "Destaque". Em
|
||||
Depoimentos, "Destaque" só serve para filtrar a tabela dentro do painel —
|
||||
não muda nada no que aparece para quem visita o site.
|
||||
|
||||
## Editar os textos da home e das páginas institucionais
|
||||
|
||||
No menu lateral, vá em **Configurações** (dentro do grupo "Conteúdo do
|
||||
site"). É uma página só, dividida em seções. Edite o que quiser e clique em
|
||||
salvar no final.
|
||||
|
||||
**Diferente de Portfólio, Depoimentos e Serviços, aqui não existe "Publicado
|
||||
em".** Tudo o que você salva em Configurações entra no ar imediatamente —
|
||||
não há passo extra de publicação nem como "rascunhar" uma mudança antes de
|
||||
ela aparecer no site.
|
||||
|
||||
- **Marca e hero** — nome da marca, logo (+ texto alternativo, obrigatório
|
||||
se houver logo), textos do topo da home ("hero"): eyebrow (frase pequena
|
||||
acima do título), título, subtítulo, texto dos dois botões de chamada
|
||||
(CTA), nota de rodapé do hero, e o **resumo institucional**
|
||||
(`about_summary`). **Atenção:** nome da marca, título do hero, subtítulo
|
||||
e texto do botão principal são **obrigatórios** — o formulário não deixa
|
||||
salvar em branco. Já o eyebrow, o botão secundário e a nota do hero podem
|
||||
ficar em branco (o site simplesmente não mostra essa parte se estiver
|
||||
vazia).
|
||||
- O **resumo institucional** é usado em **dois lugares** e se comporta
|
||||
diferente em cada um: na seção "A Amare" da home, se ficar em branco o
|
||||
site mostra uma frase padrão fixa no lugar; já na página **Sobre**
|
||||
(`/sobre`), esse mesmo texto aparece como o parágrafo principal e, se
|
||||
ficar em branco, **fica realmente vazio** ali (sem frase padrão). O
|
||||
formulário **deixa salvar em branco sem avisar** — ou seja, não dá erro
|
||||
nenhum, o parágrafo só fica vazio silenciosamente na página Sobre até
|
||||
alguém notar. Por isso, nunca deixe o resumo institucional em branco;
|
||||
depois de editar, confira a página `/sobre` para garantir que o texto
|
||||
apareceu.
|
||||
- A página **Sobre** também tem uma frase fixa no código
|
||||
("A [nome da marca] atua em [cidade] com foco em planejamento
|
||||
completo...") que **não é editável pelo painel** — ela só muda se você
|
||||
editar Nome da marca ou Cidade (que entram nessa frase), o resto do
|
||||
texto é fixo. Para mudar essa frase, é preciso pedir para quem
|
||||
desenvolve o site.
|
||||
- **Manifesto editorial** — título, texto de abertura ("lead") e corpo do
|
||||
bloco "Manifesto" da home. Diferente de outros textos, se você deixar
|
||||
esses campos em branco o site **não** esconde a seção — ele mostra um
|
||||
texto padrão fixo no lugar. Ou seja: em branco aqui não é "some", é
|
||||
"volta ao texto genérico".
|
||||
- **Método** — introdução do bloco "Método" e até 4 passos (título +
|
||||
descrição cada), reordenáveis arrastando. Se você apagar todos os passos,
|
||||
o site mostra passos padrão pré-definidos em vez de ficar vazio.
|
||||
- **Princípios** — lista de princípios (tags) mostrada na seção
|
||||
institucional. Se ficar vazia, o site mostra uma lista padrão.
|
||||
- **Página Sobre** — imagem da página "Sobre" (+ texto alternativo,
|
||||
obrigatório se houver imagem).
|
||||
- **SEO padrão** — título e descrição que aparecem quando o site é
|
||||
compartilhado ou aparece no Google (título e descrição são obrigatórios),
|
||||
e a imagem padrão de compartilhamento (que também é reaproveitada como a
|
||||
imagem à direita no hero da home — ao trocar essa imagem, ela muda em
|
||||
dois lugares ao mesmo tempo).
|
||||
- **Analytics** — **não mexa aqui**, veja [O que NÃO mexer](#o-que-não-mexer--e-com-quem-falar).
|
||||
|
||||
## Casos de portfólio: criar, editar, publicar e despublicar
|
||||
|
||||
No menu, vá em **Portfólio**.
|
||||
|
||||
### Criar ou editar um caso
|
||||
|
||||
Clique em "Novo" (ou abra um caso existente) e preencha:
|
||||
|
||||
- **Título** e **Slug** — o slug é o pedacinho do endereço na internet
|
||||
(ex.: `casamento-joana-pedro`). Se você deixar o slug em branco ao criar,
|
||||
ele é gerado automaticamente a partir do título. **Depois de publicado,
|
||||
evite mudar o slug** — isso quebra qualquer link já compartilhado para
|
||||
aquele caso (redes sociais, WhatsApp, Google). Se precisar mesmo mudar,
|
||||
veja [O que NÃO mexer](#o-que-não-mexer--e-com-quem-falar).
|
||||
- **Resumo** — texto curto usado nas listagens.
|
||||
- **Tipo de evento**, **Cidade**, **Local**, **Data do evento**.
|
||||
- **Desafio**, **Solução**, **Resultado** — o texto do "case" propriamente
|
||||
dito (Desafio e Solução são obrigatórios; Resultado é opcional).
|
||||
- **Imagem de capa** (+ texto alternativo, obrigatório se houver imagem).
|
||||
**Atenção:** diferente de quase toda outra imagem do painel, a capa de um
|
||||
caso de portfólio é **sempre obrigatória** por trás dos panos — mas o
|
||||
formulário **não bloqueia** o salvamento se você esquecer de anexá-la.
|
||||
Se você tentar salvar um caso novo sem imagem de capa, o painel não avisa
|
||||
"campo obrigatório": o salvamento simplesmente falha com um erro técnico
|
||||
feio (erro de banco de dados), não a mensagem amigável que você vê para
|
||||
título, resumo etc. Sempre anexe a imagem de capa antes de salvar um caso
|
||||
novo.
|
||||
- **Ordem** — número usado para ordenar os casos nas listagens.
|
||||
- **Destaque** — liga/desliga a aparição desse caso na home (ver regra das
|
||||
[duas chaves](#a-regra-mais-importante-o-que-torna-algo-visível-no-site)).
|
||||
- **Publicado em** — data de publicação (ver
|
||||
[regra de publicação](#a-regra-mais-importante-o-que-torna-algo-visível-no-site)).
|
||||
- **Meta title** / **Meta description** — título e descrição específicos
|
||||
desse caso para compartilhamento e Google (opcionais; se em branco, usa
|
||||
o padrão configurado em Configurações).
|
||||
|
||||
### Galeria de fotos do caso
|
||||
|
||||
Depois de salvar o caso, abra-o novamente e procure a aba **"Galeria"**.
|
||||
Ali você adiciona quantas fotos quiser, cada uma com:
|
||||
|
||||
- **Imagem** (obrigatória para criar o item da galeria). **Atenção:** assim
|
||||
como a capa do caso, o formulário **não bloqueia** o salvamento se você
|
||||
esquecer a imagem — ele deixa parecer que deu certo até você clicar em
|
||||
salvar, e aí falha com um erro técnico de banco de dados em vez de avisar
|
||||
"campo obrigatório". Sempre anexe a imagem antes de salvar um item da
|
||||
galeria.
|
||||
- **Texto alternativo** — **obrigatório assim que você anexa uma imagem**.
|
||||
O sistema não deixa salvar uma foto sem essa descrição.
|
||||
- **Legenda** (opcional) — texto que aparece junto da foto.
|
||||
- **Ordem** — dá para arrastar as fotos na tabela para reordenar.
|
||||
|
||||
### Publicar e despublicar um caso
|
||||
|
||||
- **Publicar:** preencha "Publicado em" com qualquer data e salve. Aparece
|
||||
imediatamente em `/portfolio` e na página própria do caso. Para também
|
||||
aparecer na home, ligue "Destaque".
|
||||
- **Despublicar:** apague o conteúdo de "Publicado em" e salve. Colocar
|
||||
uma data futura **não** esconde o caso.
|
||||
|
||||
**"Excluir" não é a mesma coisa que despublicar.** Cada linha da tabela de
|
||||
Portfólio (e também cada foto dentro da aba "Galeria") tem um botão
|
||||
**"Excluir"**, além do botão de editar. Diferente de despublicar, Excluir
|
||||
**apaga o caso para sempre**: não existe lixeira, não existe desfazer, e ao
|
||||
excluir um caso todas as fotos da galeria dele são apagadas junto
|
||||
automaticamente. Selecionar várias linhas na tabela também libera uma ação
|
||||
de exclusão em massa, que apaga todas de uma vez com uma única confirmação.
|
||||
Para esconder um caso do site, **sempre** use "apagar o Publicado em" — só
|
||||
use Excluir quando tiver certeza de que quer destruir o registro
|
||||
definitivamente.
|
||||
|
||||
## Depoimentos: adicionar e publicar
|
||||
|
||||
No menu, vá em **Depoimentos**.
|
||||
|
||||
- **Depoimento** — o texto do casal. Se você separar o texto em parágrafos
|
||||
com uma linha em branco entre eles, o site respeita essa quebra e mostra
|
||||
cada parágrafo separadamente.
|
||||
- **Nome do autor** — ex.: "Jeniffer e Maick".
|
||||
- **Contexto** — texto livre mostrado como "— contexto", ex.:
|
||||
`Casamento · 06/12/2025`.
|
||||
- **Foto** (+ texto alternativo, obrigatório se houver foto) — atenção: a
|
||||
foto **existe no formulário mas hoje não aparece** na home; o campo é
|
||||
preenchido para o futuro, mas visualmente ainda não é exibido.
|
||||
- **Ordem** — ordena os depoimentos no bloco da home.
|
||||
- **Destaque** — **não afeta o site público**. Serve só para filtrar a
|
||||
tabela de depoimentos dentro do painel.
|
||||
- **Publicado em** — este é o único interruptor que importa para
|
||||
depoimentos aparecerem na home. Preencha e salve para publicar; apague e
|
||||
salve para despublicar. Não existe listagem própria de depoimentos fora
|
||||
da home.
|
||||
|
||||
**Atenção ao botão "Excluir"** em cada linha da tabela (e à exclusão em
|
||||
massa ao selecionar várias linhas): ele é diferente de despublicar e apaga
|
||||
o depoimento para sempre, sem lixeira e sem desfazer. Para tirar um
|
||||
depoimento do ar, apague o "Publicado em" — não use Excluir a menos que
|
||||
queira apagar o registro definitivamente.
|
||||
|
||||
## Serviços: editar
|
||||
|
||||
No menu, vá em **Serviços**.
|
||||
|
||||
- **Título** e **Slug** (mesmo comportamento de auto-geração e mesmo
|
||||
cuidado ao mudar depois de publicado que o portfólio).
|
||||
- **Resumo** e **Descrição**.
|
||||
- **Imagem de capa** (+ texto alternativo, obrigatório se houver imagem).
|
||||
- **Ordem**, **Destaque** e **Publicado em** funcionam exatamente como em
|
||||
portfólio: publicado aparece em `/servicos`; publicado **e** destaque
|
||||
aparece também na home.
|
||||
|
||||
**Atenção ao botão "Excluir"** em cada linha da tabela (e à exclusão em
|
||||
massa ao selecionar várias linhas): ele é diferente de despublicar e apaga
|
||||
o serviço para sempre, sem lixeira e sem desfazer. Para tirar um serviço do
|
||||
ar, apague o "Publicado em" — não use Excluir a menos que queira apagar o
|
||||
registro definitivamente.
|
||||
|
||||
## Contato: telefone, e-mail e redes sociais
|
||||
|
||||
Esses campos ficam em **Configurações → seção "Contato"**.
|
||||
|
||||
- **E-mail** e **Telefone** são obrigatórios — o formulário não deixa
|
||||
salvá-los em branco. Eles aparecem no rodapé do site e na página
|
||||
"Contato" como links clicáveis (e-mail abre o programa de e-mail;
|
||||
telefone abre o discador do celular). **Importante:** esse mesmo e-mail
|
||||
é também o endereço para onde o site envia toda mensagem enviada pelo
|
||||
formulário de contato em `/contato` (o "briefing" que um visitante
|
||||
preenche e envia). Ou seja, esse campo não é só um link de exibição —
|
||||
ele precisa ser uma caixa de entrada de verdade, monitorada, porque é
|
||||
para lá que vão os pedidos de orçamento e contato de clientes em
|
||||
potencial. Trocar esse e-mail por um endereço que ninguém acompanha faz
|
||||
o site parar de avisar sobre novos contatos, sem nenhum erro aparecer em
|
||||
lugar nenhum do painel.
|
||||
- **Importante sobre WhatsApp:** hoje existe **um único campo de
|
||||
telefone**, e ele vira apenas um link `tel:` (ligação), **não** um botão
|
||||
de WhatsApp. Se o número cadastrado for um número de WhatsApp, quem
|
||||
clicar vai abrir o discador, não o WhatsApp. Se você precisa de um botão
|
||||
específico de WhatsApp no site, isso é um pedido para quem desenvolve o
|
||||
site — hoje o campo não faz isso sozinho.
|
||||
- **Cidade** — opcional, aparece na página de Contato.
|
||||
- **Redes sociais** — lista de "Rede" + "URL" (ex.: `instagram` →
|
||||
`https://instagram.com/suaempresa`). Adicione quantas quiser pelo botão
|
||||
"Adicionar rede"; para remover uma, apague a linha inteira.
|
||||
|
||||
## Imagens: formatos aceitos, tamanho e texto alternativo
|
||||
|
||||
Vale para **toda** imagem enviada em qualquer tela do painel (logo, capas,
|
||||
galeria de portfólio, fotos de depoimento, imagem da página Sobre, imagem
|
||||
de SEO):
|
||||
|
||||
- **Formatos aceitos:** JPG, JPEG, PNG ou WEBP. Qualquer outro formato
|
||||
(ex.: HEIC direto do iPhone, PDF, GIF) é recusado com uma mensagem de
|
||||
erro — converta a imagem antes de enviar. **HEIC é o caso mais comum**,
|
||||
porque é o formato padrão das fotos tiradas no iPhone. Duas formas
|
||||
simples de resolver:
|
||||
- **Fotos novas:** no iPhone, vá em Ajustes → Câmera → Formatos e
|
||||
escolha "Mais Compatível" — a partir daí, novas fotos já são salvas em
|
||||
JPEG em vez de HEIC.
|
||||
- **Fotos que já estão em HEIC:** envie a foto para você mesma por
|
||||
WhatsApp (ex.: em "Mensagens salvas" ou num grupo/conversa qualquer) e
|
||||
baixe a versão recebida — o WhatsApp converte a imagem para JPEG
|
||||
automaticamente ao enviar.
|
||||
- **Tamanho máximo:** 10 MB por arquivo. Acima disso, o envio é recusado.
|
||||
- **Texto alternativo é obrigatório sempre que houver imagem.** Isso não é
|
||||
burocracia: é o texto que leitores de tela usam para descrever a imagem
|
||||
para pessoas com deficiência visual, e também ajuda o Google a entender
|
||||
do que se trata a foto. Sem uma imagem, o campo de texto alternativo
|
||||
pode ficar em branco; assim que você anexa uma imagem, o sistema passa a
|
||||
exigir o texto.
|
||||
- **Exceção: a imagem de capa de um caso de portfólio e a imagem de um item
|
||||
da galeria não são realmente opcionais**, ao contrário de toda outra
|
||||
imagem do painel (logo, imagem da página Sobre, foto de depoimento,
|
||||
imagem de SEO). Nesses dois casos específicos, o formulário não impede
|
||||
você de salvar sem imagem, mas o salvamento falha de qualquer forma com
|
||||
um erro técnico em vez de uma mensagem amigável de "campo obrigatório"
|
||||
(ver detalhes nas seções [Casos de
|
||||
portfólio](#casos-de-portfólio-criar-editar-publicar-e-despublicar) e
|
||||
[Galeria de fotos do caso](#galeria-de-fotos-do-caso)). Sempre anexe uma
|
||||
imagem antes de salvar um caso de portfólio ou um item de galeria.
|
||||
- Você **não** precisa fazer nada além de enviar a imagem normalmente — o
|
||||
sistema gera sozinho as versões menores usadas em celulares e tablets.
|
||||
Não existe um botão ou comando manual que você precise rodar depois de
|
||||
subir uma foto.
|
||||
|
||||
## Usuários: papéis, ativar e desativar contas
|
||||
|
||||
No menu, vá em **Usuários**. Esta tela só aparece, e só pode ser editada,
|
||||
por contas com perfil **Administrador** — se você consegue ver este menu e
|
||||
seguir o resto deste guia, você é Administradora.
|
||||
|
||||
Ao criar ou editar uma conta, os campos são:
|
||||
|
||||
- **Nome** e **E-mail** — identificação da pessoa; o e-mail também é o
|
||||
usado para fazer login.
|
||||
- **Papel** — **Administrador** ou **Assistente**. Como já visto em [Como
|
||||
entrar no painel](#como-entrar-no-painel), hoje não existe meio-termo:
|
||||
Administrador tem acesso total, e Assistente consegue fazer login mas
|
||||
não vê nem edita **nenhum** conteúdo (nem Portfólio, nem Configurações,
|
||||
nada). Trocar o Papel de alguém para Assistente remove todo o acesso
|
||||
dela imediatamente.
|
||||
- **Ativo** — desligar este interruptor bloqueia o login dessa conta por
|
||||
completo, mesmo com e-mail e senha corretos.
|
||||
- **Senha** — só é preciso preencher ao criar uma conta nova ou quando
|
||||
você realmente quer trocar a senha de alguém; deixando em branco ao
|
||||
editar uma conta existente, a senha atual não muda.
|
||||
|
||||
**Cuidado ao editar a sua própria conta.** O painel não impede uma
|
||||
Administradora de trocar o próprio Papel para Assistente ou de desligar o
|
||||
próprio "Ativo". Se isso acontecer, você perde o acesso imediatamente e,
|
||||
como visto em [Como entrar no painel](#como-entrar-no-painel), hoje não
|
||||
existe "esqueci minha senha" nem qualquer forma de uma Administradora
|
||||
reverter isso sozinha — só sobra pedir para quem desenvolve o site mexer
|
||||
diretamente no banco de dados. Ao mexer no seu próprio usuário, confira
|
||||
duas vezes o que está mudando antes de salvar.
|
||||
|
||||
## O que NÃO mexer — e com quem falar
|
||||
|
||||
- **Seção "Analytics" em Configurações** (o interruptor e o campo de
|
||||
script). É um campo técnico que injeta código de rastreamento no site.
|
||||
Mexer errado aqui pode quebrar o carregamento do site inteiro. Peça para
|
||||
quem desenvolve o site fazer essa alteração.
|
||||
- **Slug de serviços e casos de portfólio**, depois de publicados. Mudar
|
||||
quebra links já compartilhados (redes sociais, WhatsApp, resultados de
|
||||
busca do Google). Se for realmente necessário mudar, avise quem
|
||||
desenvolve o site para avaliar redirecionamento.
|
||||
- **Usuários e permissões** (menu "Usuários") — só para quem **não** é
|
||||
Administradora: essa tela só existe para contas com perfil
|
||||
**Administrador**, então se você não consegue nem ver o menu "Usuários",
|
||||
precisa de uma conta nova, de desativar alguém, ou de trocar uma senha,
|
||||
peça a uma Administradora — ou, na ausência de uma, a quem desenvolve o
|
||||
site. Se você **é** Administradora, essa tela é sua e está descrita em
|
||||
[Usuários: papéis, ativar e desativar
|
||||
contas](#usuários-papéis-ativar-e-desativar-contas).
|
||||
- **Qualquer coisa fora do painel `/admin`** — arquivos de código, banco
|
||||
de dados, comandos de terminal. Nada disso deve ser mexido para uma
|
||||
atualização de conteúdo do dia a dia; se alguém pedir para você rodar um
|
||||
comando técnico para "gerar imagens" ou algo do tipo, isso é tarefa de
|
||||
engenharia, não de edição de conteúdo — fale com quem desenvolve o site.
|
||||
|
||||
## Seções da home que somem por completo se ficarem vazias
|
||||
|
||||
Estas três seções da home **desaparecem inteiramente** (sem nenhum aviso ou
|
||||
espaço reservado) se não houver nenhum item que atenda aos critérios
|
||||
abaixo:
|
||||
|
||||
| Seção da home | Desaparece quando... |
|
||||
|---|---|
|
||||
| **Serviços** | zero serviços estiverem, ao mesmo tempo, publicados **e** com "Destaque" ligado |
|
||||
| **Portfólio** | zero casos estiverem, ao mesmo tempo, publicados **e** com "Destaque" ligado |
|
||||
| **Depoimentos** | zero depoimentos estiverem publicados (o "Destaque" não importa aqui) |
|
||||
|
||||
Se você despublicar o último item de uma dessas categorias — ou esquecer de
|
||||
ligar "Destaque" em qualquer serviço/caso — a home simplesmente fica sem
|
||||
aquele bloco, sem mensagem de erro em lugar nenhum. Se um bloco "sumiu" da
|
||||
home, o primeiro lugar para checar é exatamente essa combinação de
|
||||
publicado + destaque.
|
||||
|
||||
## Sobre o arquivo `depoimentos.md` na raiz do repositório
|
||||
|
||||
Existe um arquivo chamado `depoimentos.md` na raiz do projeto com os cinco
|
||||
depoimentos reais originais, copiados manualmente de onde vieram
|
||||
(WhatsApp/redes sociais). Ele foi o material bruto usado, uma única vez,
|
||||
para digitar os depoimentos dentro do sistema (e é citado em documentos
|
||||
técnicos antigos como a origem desse conteúdo) — mas **hoje nenhum código
|
||||
do site lê esse arquivo**. Editar, corrigir ou apagar `depoimentos.md` **não
|
||||
muda nada** no site: o texto que aparece de verdade para quem visita o site
|
||||
vive no banco de dados, e é editado exclusivamente pela tela **Depoimentos**
|
||||
descrita [acima](#depoimentos-adicionar-e-publicar). Trate esse arquivo como
|
||||
material histórico de referência, não como fonte de conteúdo.
|
||||
BIN
docs/screenshots/home-editorial-cadence-desktop.webp
Normal file
BIN
docs/screenshots/home-editorial-cadence-desktop.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 137 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user