Compare commits
60 Commits
feat/dokpl
...
feat/full-
| Author | SHA1 | Date | |
|---|---|---|---|
| f7fdf3930c | |||
| d2d1b436fa | |||
| d044911d24 | |||
| 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 | |||
| db4453b165 | |||
| 946939ba94 | |||
| 10a1f0dc7c | |||
| c04790ee1f | |||
| edf1c48050 | |||
| 3c711a74f3 | |||
| 88d63f349d | |||
| e0390ff333 | |||
| 321f0cee11 | |||
| 1d23e16625 | |||
| 33788af172 | |||
| 119711d6f0 | |||
| f09ea83071 | |||
| 2fe262ab59 | |||
| 018b43e24f | |||
| 9fac784f6a | |||
| 8a12b7d82c | |||
| af8484c74e | |||
| 35bc5a59d9 | |||
| 27711ad0c2 | |||
| af31738021 | |||
| 286522d8a2 | |||
| 3e68192cf3 | |||
| 1a31dd1cf6 | |||
| 9060036024 | |||
| 7f4ea01f4b | |||
| cf1589c916 |
@@ -2,12 +2,13 @@ APP_NAME=Amare
|
|||||||
APP_ENV=local
|
APP_ENV=local
|
||||||
APP_KEY=
|
APP_KEY=
|
||||||
APP_DEBUG=true
|
APP_DEBUG=true
|
||||||
|
# Staging/production: set APP_URL to the public HTTPS origin (e.g. https://staging.example.com).
|
||||||
APP_URL=http://localhost
|
APP_URL=http://localhost
|
||||||
|
|
||||||
APP_LOCALE=pt_BR
|
APP_LOCALE=pt_BR
|
||||||
APP_FALLBACK_LOCALE=pt_BR
|
APP_FALLBACK_LOCALE=pt_BR
|
||||||
APP_FAKER_LOCALE=pt_BR
|
APP_FAKER_LOCALE=pt_BR
|
||||||
APP_TIMEZONE=America/Fortaleza
|
APP_TIMEZONE=America/Sao_Paulo
|
||||||
|
|
||||||
# Freeze application clock outside production (visual regression / deterministic seeds).
|
# Freeze application clock outside production (visual regression / deterministic seeds).
|
||||||
# Example: APP_FROZEN_NOW=2026-03-15T12:00:00-03:00
|
# Example: APP_FROZEN_NOW=2026-03-15T12:00:00-03:00
|
||||||
@@ -38,6 +39,10 @@ SESSION_LIFETIME=120
|
|||||||
SESSION_ENCRYPT=false
|
SESSION_ENCRYPT=false
|
||||||
SESSION_PATH=/
|
SESSION_PATH=/
|
||||||
SESSION_DOMAIN=null
|
SESSION_DOMAIN=null
|
||||||
|
# Staging/production behind HTTPS (Dokploy Traefik): SESSION_SECURE_COOKIE=true
|
||||||
|
SESSION_SECURE_COOKIE=false
|
||||||
|
SESSION_HTTP_ONLY=true
|
||||||
|
SESSION_SAME_SITE=lax
|
||||||
|
|
||||||
BROADCAST_CONNECTION=log
|
BROADCAST_CONNECTION=log
|
||||||
FILESYSTEM_DISK=local
|
FILESYSTEM_DISK=local
|
||||||
|
|||||||
78
.github/workflows/ci.yml
vendored
@@ -14,7 +14,7 @@ env:
|
|||||||
APP_KEY: base64:NXm/6jIyFcDGHoMKGc5QZuSaq0dRZFYPg1Isuy1fNvE=
|
APP_KEY: base64:NXm/6jIyFcDGHoMKGc5QZuSaq0dRZFYPg1Isuy1fNvE=
|
||||||
APP_LOCALE: pt_BR
|
APP_LOCALE: pt_BR
|
||||||
APP_FALLBACK_LOCALE: pt_BR
|
APP_FALLBACK_LOCALE: pt_BR
|
||||||
APP_TIMEZONE: America/Fortaleza
|
APP_TIMEZONE: America/Sao_Paulo
|
||||||
BCRYPT_ROUNDS: 4
|
BCRYPT_ROUNDS: 4
|
||||||
CACHE_STORE: database
|
CACHE_STORE: database
|
||||||
DB_CONNECTION: pgsql
|
DB_CONNECTION: pgsql
|
||||||
@@ -31,7 +31,8 @@ jobs:
|
|||||||
name: static
|
name: static
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
|
- run: cp .env.example .env
|
||||||
|
|
||||||
- uses: shivammathur/setup-php@v2
|
- uses: shivammathur/setup-php@v2
|
||||||
with:
|
with:
|
||||||
@@ -39,7 +40,7 @@ jobs:
|
|||||||
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd
|
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd
|
||||||
coverage: none
|
coverage: none
|
||||||
|
|
||||||
- uses: actions/cache@v4
|
- uses: actions/cache@v5
|
||||||
with:
|
with:
|
||||||
path: ~/.composer/cache/files
|
path: ~/.composer/cache/files
|
||||||
key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }}
|
key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }}
|
||||||
@@ -50,29 +51,70 @@ jobs:
|
|||||||
- run: composer pint:check
|
- run: composer pint:check
|
||||||
- run: composer phpstan
|
- run: composer phpstan
|
||||||
- run: composer audit
|
- run: composer audit
|
||||||
|
# audit-level=high: package.json has no runtime `dependencies` today (npm
|
||||||
|
# itself reports prod:1, 0 vulnerabilities), so this is currently a
|
||||||
|
# vacuous forward guard rather than an active protection. `high` is
|
||||||
|
# chosen deliberately over `low`/`moderate` so that once a real runtime
|
||||||
|
# JS dependency is added, the gate flags exploitable issues without
|
||||||
|
# becoming noisy on every transitive dev-only advisory.
|
||||||
|
- run: npm audit --omit=dev --audit-level=high
|
||||||
|
|
||||||
unit:
|
unit:
|
||||||
name: unit
|
name: unit
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
# Postgres is required here (not just in `feature`) because the
|
||||||
|
# Domain/Application coverage gate below runs the Feature suite too: the
|
||||||
|
# App\Application\Queries\Marketing\* classes are only exercised through
|
||||||
|
# Feature (HTTP) tests today, so a Unit-only coverage run would undercount
|
||||||
|
# them well under the 80% threshold.
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:17
|
||||||
|
env:
|
||||||
|
POSTGRES_DB: amare_test
|
||||||
|
POSTGRES_USER: amare
|
||||||
|
POSTGRES_PASSWORD: secret
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
|
options: >-
|
||||||
|
--health-cmd "pg_isready -U amare -d amare_test"
|
||||||
|
--health-interval 5s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 10
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
|
- run: cp .env.example .env
|
||||||
|
|
||||||
- uses: shivammathur/setup-php@v2
|
- uses: shivammathur/setup-php@v2
|
||||||
with:
|
with:
|
||||||
php-version: "8.4"
|
php-version: "8.4"
|
||||||
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd
|
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd
|
||||||
coverage: none
|
coverage: pcov
|
||||||
|
|
||||||
- uses: actions/cache@v4
|
- uses: actions/cache@v5
|
||||||
with:
|
with:
|
||||||
path: ~/.composer/cache/files
|
path: ~/.composer/cache/files
|
||||||
key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }}
|
key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }}
|
||||||
restore-keys: composer-${{ runner.os }}-
|
restore-keys: composer-${{ runner.os }}-
|
||||||
|
|
||||||
|
- uses: actions/cache@v5
|
||||||
|
with:
|
||||||
|
path: ~/.npm
|
||||||
|
key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
|
||||||
|
restore-keys: npm-${{ runner.os }}-
|
||||||
|
|
||||||
- run: composer install --no-interaction --prefer-dist
|
- run: composer install --no-interaction --prefer-dist
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
- run: npm run build
|
- run: npm run build
|
||||||
- run: composer test:unit
|
- run: composer test:unit
|
||||||
|
- run: php artisan migrate --force
|
||||||
|
# Domain/Application coverage gate (SPEC.md L2351, 80% minimum). Scoped
|
||||||
|
# via phpunit.coverage.xml rather than editing the project-wide
|
||||||
|
# phpunit.xml <source> block, which stays covering all of app/ for
|
||||||
|
# every other test/coverage invocation. app/Domain currently holds only
|
||||||
|
# the DomainModule placeholder (zero executable lines), so in practice
|
||||||
|
# this gates app/Application until Domain gains real logic.
|
||||||
|
- run: composer test:coverage
|
||||||
|
|
||||||
feature:
|
feature:
|
||||||
name: feature
|
name: feature
|
||||||
@@ -92,7 +134,8 @@ jobs:
|
|||||||
--health-timeout 5s
|
--health-timeout 5s
|
||||||
--health-retries 10
|
--health-retries 10
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
|
- run: cp .env.example .env
|
||||||
|
|
||||||
- uses: shivammathur/setup-php@v2
|
- uses: shivammathur/setup-php@v2
|
||||||
with:
|
with:
|
||||||
@@ -100,13 +143,13 @@ jobs:
|
|||||||
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd
|
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd
|
||||||
coverage: none
|
coverage: none
|
||||||
|
|
||||||
- uses: actions/cache@v4
|
- uses: actions/cache@v5
|
||||||
with:
|
with:
|
||||||
path: ~/.composer/cache/files
|
path: ~/.composer/cache/files
|
||||||
key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }}
|
key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }}
|
||||||
restore-keys: composer-${{ runner.os }}-
|
restore-keys: composer-${{ runner.os }}-
|
||||||
|
|
||||||
- uses: actions/cache@v4
|
- uses: actions/cache@v5
|
||||||
with:
|
with:
|
||||||
path: ~/.npm
|
path: ~/.npm
|
||||||
key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
|
key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
|
||||||
@@ -136,7 +179,8 @@ jobs:
|
|||||||
--health-timeout 5s
|
--health-timeout 5s
|
||||||
--health-retries 10
|
--health-retries 10
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
|
- run: cp .env.example .env
|
||||||
|
|
||||||
- uses: shivammathur/setup-php@v2
|
- uses: shivammathur/setup-php@v2
|
||||||
with:
|
with:
|
||||||
@@ -144,13 +188,13 @@ jobs:
|
|||||||
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd
|
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd
|
||||||
coverage: none
|
coverage: none
|
||||||
|
|
||||||
- uses: actions/cache@v4
|
- uses: actions/cache@v5
|
||||||
with:
|
with:
|
||||||
path: ~/.composer/cache/files
|
path: ~/.composer/cache/files
|
||||||
key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }}
|
key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }}
|
||||||
restore-keys: composer-${{ runner.os }}-
|
restore-keys: composer-${{ runner.os }}-
|
||||||
|
|
||||||
- uses: actions/cache@v4
|
- uses: actions/cache@v5
|
||||||
with:
|
with:
|
||||||
path: ~/.npm
|
path: ~/.npm
|
||||||
key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
|
key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
|
||||||
@@ -161,15 +205,12 @@ jobs:
|
|||||||
- run: npm run build
|
- run: npm run build
|
||||||
- run: npx playwright install chromium --with-deps
|
- run: npx playwright install chromium --with-deps
|
||||||
- run: php artisan migrate --force
|
- run: php artisan migrate --force
|
||||||
- run: php artisan db:seed --class=VisualContentSeeder --force
|
|
||||||
- run: php artisan storage:link
|
- run: php artisan storage:link
|
||||||
|
|
||||||
- name: Build application image
|
- name: Build application image
|
||||||
run: docker build -t amare-app:ci .
|
run: docker build -t amare-app:ci .
|
||||||
|
|
||||||
- name: Run browser tests against FrankenPHP container
|
- name: Run browser tests against FrankenPHP container
|
||||||
env:
|
|
||||||
APP_FROZEN_NOW: "2026-03-15T12:00:00-03:00"
|
|
||||||
run: |
|
run: |
|
||||||
docker run -d --name amare-web \
|
docker run -d --name amare-web \
|
||||||
-e APP_ENV=testing \
|
-e APP_ENV=testing \
|
||||||
@@ -177,8 +218,7 @@ jobs:
|
|||||||
-e APP_URL=http://127.0.0.1:8000 \
|
-e APP_URL=http://127.0.0.1:8000 \
|
||||||
-e APP_LOCALE=pt_BR \
|
-e APP_LOCALE=pt_BR \
|
||||||
-e APP_FALLBACK_LOCALE=pt_BR \
|
-e APP_FALLBACK_LOCALE=pt_BR \
|
||||||
-e APP_TIMEZONE=America/Fortaleza \
|
-e APP_TIMEZONE=America/Sao_Paulo \
|
||||||
-e APP_FROZEN_NOW="${APP_FROZEN_NOW}" \
|
|
||||||
-e DB_CONNECTION=pgsql \
|
-e DB_CONNECTION=pgsql \
|
||||||
-e DB_HOST=host.docker.internal \
|
-e DB_HOST=host.docker.internal \
|
||||||
-e DB_PORT=5432 \
|
-e DB_PORT=5432 \
|
||||||
@@ -209,8 +249,6 @@ jobs:
|
|||||||
mkdir -p artifacts/browser
|
mkdir -p artifacts/browser
|
||||||
docker logs amare-web > artifacts/browser/container.log 2>&1 || true
|
docker logs amare-web > artifacts/browser/container.log 2>&1 || true
|
||||||
cp -R storage/logs artifacts/browser/app-logs 2>/dev/null || true
|
cp -R storage/logs artifacts/browser/app-logs 2>/dev/null || true
|
||||||
cp -R tests/Browser/Screenshots artifacts/browser/screenshots 2>/dev/null || true
|
|
||||||
cp -R tests/.pest artifacts/browser/pest 2>/dev/null || true
|
|
||||||
|
|
||||||
- name: Upload browser failure artifacts
|
- name: Upload browser failure artifacts
|
||||||
if: failure()
|
if: failure()
|
||||||
@@ -224,7 +262,7 @@ jobs:
|
|||||||
name: container
|
name: container
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Build production image
|
- name: Build production image
|
||||||
run: docker build -t amare-app:ci .
|
run: docker build -t amare-app:ci .
|
||||||
|
|||||||
82
.github/workflows/deploy-staging.yml
vendored
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
name: Deploy staging
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_run:
|
||||||
|
workflows: [CI]
|
||||||
|
types: [completed]
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: deploy-staging
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: ghcr.io
|
||||||
|
IMAGE_NAME: ${{ github.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'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout deployed SHA
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event.workflow_run.head_sha }}
|
||||||
|
|
||||||
|
- name: Set image metadata
|
||||||
|
id: meta
|
||||||
|
run: |
|
||||||
|
SHA="${{ github.event.workflow_run.head_sha }}"
|
||||||
|
SHORT_SHA="${SHA:0:7}"
|
||||||
|
IMAGE="${REGISTRY}/${IMAGE_NAME}"
|
||||||
|
IMAGE="$(echo "$IMAGE" | tr '[:upper:]' '[:lower:]')"
|
||||||
|
echo "sha=${SHA}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "short_sha=${SHORT_SHA}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "image=${IMAGE}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Log in to GHCR
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Build and push SHA + staging tags
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
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:
|
||||||
|
DOKPLOY_URL: ${{ secrets.DOKPLOY_URL }}
|
||||||
|
DOKPLOY_API_KEY: ${{ secrets.DOKPLOY_API_KEY }}
|
||||||
|
DOKPLOY_COMPOSE_ID: ${{ secrets.DOKPLOY_STAGING_COMPOSE_ID }}
|
||||||
|
DEPLOY_TITLE: "staging ${{ steps.meta.outputs.short_sha }}"
|
||||||
|
run: |
|
||||||
|
chmod +x scripts/deploy/dokploy-deploy.sh
|
||||||
|
./scripts/deploy/dokploy-deploy.sh
|
||||||
|
|
||||||
|
- name: Smoke staging
|
||||||
|
env:
|
||||||
|
SMOKE_BASE_URL: ${{ secrets.STAGING_URL }}
|
||||||
|
run: |
|
||||||
|
chmod +x scripts/deploy/smoke.sh
|
||||||
|
./scripts/deploy/smoke.sh
|
||||||
84
.github/workflows/promote-production.yml
vendored
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
name: Promote production
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
sha:
|
||||||
|
description: Full git SHA already published to GHCR (same digest used by staging)
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
confirm:
|
||||||
|
description: Type PRODUCTION to confirm promotion of the given SHA
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: deploy-production
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: ghcr.io
|
||||||
|
IMAGE_NAME: ${{ github.repository }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
promote:
|
||||||
|
name: promote-production
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Guard confirmation
|
||||||
|
run: |
|
||||||
|
if [ "${{ inputs.confirm }}" != "PRODUCTION" ]; then
|
||||||
|
echo "Confirmation must be exactly PRODUCTION" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Checkout repository scripts
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set image metadata
|
||||||
|
id: meta
|
||||||
|
run: |
|
||||||
|
SHA="${{ inputs.sha }}"
|
||||||
|
SHORT_SHA="${SHA:0:7}"
|
||||||
|
IMAGE="${REGISTRY}/${IMAGE_NAME}"
|
||||||
|
IMAGE="$(echo "$IMAGE" | tr '[:upper:]' '[:lower:]')"
|
||||||
|
echo "sha=${SHA}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "short_sha=${SHORT_SHA}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "image=${IMAGE}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Log in to GHCR
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Point :production at existing SHA digest (no rebuild)
|
||||||
|
run: |
|
||||||
|
docker buildx imagetools create \
|
||||||
|
--tag "${{ steps.meta.outputs.image }}:production" \
|
||||||
|
"${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.sha }}"
|
||||||
|
|
||||||
|
- name: Deploy production on Dokploy
|
||||||
|
env:
|
||||||
|
DOKPLOY_URL: ${{ secrets.DOKPLOY_URL }}
|
||||||
|
DOKPLOY_API_KEY: ${{ secrets.DOKPLOY_API_KEY }}
|
||||||
|
DOKPLOY_COMPOSE_ID: ${{ secrets.DOKPLOY_PRODUCTION_COMPOSE_ID }}
|
||||||
|
DEPLOY_TITLE: "production ${{ steps.meta.outputs.short_sha }}"
|
||||||
|
run: |
|
||||||
|
chmod +x scripts/deploy/dokploy-deploy.sh
|
||||||
|
./scripts/deploy/dokploy-deploy.sh
|
||||||
|
|
||||||
|
- name: Smoke production
|
||||||
|
env:
|
||||||
|
SMOKE_BASE_URL: ${{ secrets.PRODUCTION_URL }}
|
||||||
|
run: |
|
||||||
|
chmod +x scripts/deploy/smoke.sh
|
||||||
|
./scripts/deploy/smoke.sh
|
||||||
3
.husky/pre-commit
Executable file
@@ -0,0 +1,3 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
|
||||||
|
composer pint:check && composer phpstan
|
||||||
33
.husky/pre-push
Executable file
@@ -0,0 +1,33 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
|
||||||
|
# Abort push when the test database is unreachable, so broken code never
|
||||||
|
# reaches CI. Test DB settings come from phpunit.xml.
|
||||||
|
php -r '
|
||||||
|
$xml = @simplexml_load_file("phpunit.xml");
|
||||||
|
if ($xml === false) {
|
||||||
|
fwrite(STDERR, "phpunit.xml not found — aborting pre-push.\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
$defaults = ["DB_HOST" => "127.0.0.1", "DB_PORT" => "5432", "DB_DATABASE" => "amare_test", "DB_USERNAME" => "amare", "DB_PASSWORD" => "secret"];
|
||||||
|
$env = [];
|
||||||
|
foreach ($xml->php->env as $node) {
|
||||||
|
$name = (string) $node["name"];
|
||||||
|
if (isset($defaults[$name])) {
|
||||||
|
$env[$name] = (string) $node["value"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$config = array_merge($defaults, $env);
|
||||||
|
|
||||||
|
$dsn = sprintf("pgsql:host=%s;port=%s;dbname=%s", $config["DB_HOST"], $config["DB_PORT"], $config["DB_DATABASE"]);
|
||||||
|
try {
|
||||||
|
new PDO($dsn, $config["DB_USERNAME"], $config["DB_PASSWORD"], [PDO::ATTR_TIMEOUT => 3]);
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
fwrite(STDERR, "\033[31mPostgreSQL is unreachable on {$config["DB_HOST"]}:{$config["DB_PORT"]} (db: {$config["DB_DATABASE"]}).\033[0m\n");
|
||||||
|
fwrite(STDERR, "Start it with: docker compose up -d postgres\n");
|
||||||
|
fwrite(STDERR, "Then retry the push.\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
' || exit 1
|
||||||
|
|
||||||
|
composer test:unit && composer test:feature
|
||||||
@@ -12,5 +12,6 @@ related_targets: ["resources/views/pages/services/index.blade.php","resources/vi
|
|||||||
- **Action, proof, and constraints:** ação principal é enviar briefing inicial. Prova disponível: cinco depoimentos reais de casamentos, com nomes e datas; publicação depende de autorização final. Não inventar cases corporativos, credenciais ou resultados. Fotografias permanecem ilustrativas e marcadas até chegada de acervo autorizado.
|
- **Action, proof, and constraints:** ação principal é enviar briefing inicial. Prova disponível: cinco depoimentos reais de casamentos, com nomes e datas; publicação depende de autorização final. Não inventar cases corporativos, credenciais ou resultados. Fotografias permanecem ilustrativas e marcadas até chegada de acervo autorizado.
|
||||||
- **Chosen direction:** “Dossiê Editorial do Evento”. Home funciona como capa e índice; serviços viram capítulos, portfólio vira cadernos de caso, sobre vira perfil editorial e contato vira ficha de briefing. Sistema visual global segue Heritage Editorial em DESIGN.md.
|
- **Chosen direction:** “Dossiê Editorial do Evento”. Home funciona como capa e índice; serviços viram capítulos, portfólio vira cadernos de caso, sobre vira perfil editorial e contato vira ficha de briefing. Sistema visual global segue Heritage Editorial em DESIGN.md.
|
||||||
- **Memorable moment:** coração facetado atua como selo editorial enquanto índice discreto acompanha capítulos e deixa serviço, método e próximo passo visíveis sem transformar página em dashboard.
|
- **Memorable moment:** coração facetado atua como selo editorial enquanto índice discreto acompanha capítulos e deixa serviço, método e próximo passo visíveis sem transformar página em dashboard.
|
||||||
|
- **Motion thesis (Dossiê vivo):** Home recebe abertura autoral (selo → título → recorte de imagem → CTAs, ≤800ms). Índice lateral no desktop e linha de progresso no mobile acompanham capítulos. Serviços, portfólio e sobre usam abertura curta e revelações discretas; contato, privacidade e erros permanecem quase estáticos. Sem parallax, loops, bounce ou scroll-jacking. `prefers-reduced-motion` entrega estado final imediato.
|
||||||
- **Responsive and interaction:** spreads assimétricos no desktop; sequência linear no mobile. CTAs recorrentes, navegação clara, formulário com loading, erro e sucesso, foco visível e suporte a movimento reduzido.
|
- **Responsive and interaction:** spreads assimétricos no desktop; sequência linear no mobile. CTAs recorrentes, navegação clara, formulário com loading, erro e sucesso, foco visível e suporte a movimento reduzido.
|
||||||
- **Unresolved:** logo transparente ou vetorial; fotografias autorizadas; WhatsApp, e-mail e Instagram oficiais; textos jurídicos; autorização dos depoimentos; provas reais de eventos corporativos.
|
- **Unresolved:** logo transparente ou vetorial; fotografias autorizadas; WhatsApp, e-mail e Instagram oficiais; textos jurídicos; autorização dos depoimentos; provas reais de eventos corporativos.
|
||||||
|
|||||||
36
AGENTS.md
@@ -7,14 +7,26 @@ This is a Laravel 13 application for an event-planning consultancy. Application
|
|||||||
## Build, Test, and Development Commands
|
## Build, Test, and Development Commands
|
||||||
|
|
||||||
- `composer setup` installs PHP and npm dependencies, creates `.env`, migrates, and builds assets.
|
- `composer setup` installs PHP and npm dependencies, creates `.env`, migrates, and builds assets.
|
||||||
- `docker compose up -d` 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.
|
- `composer dev` runs Laravel, the queue listener, logs, and Vite together.
|
||||||
- `npm run build` creates the production frontend bundle.
|
- `npm run build` creates the production frontend bundle.
|
||||||
- `composer quality` runs formatting checks, PHPStan level 5, 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:unit`, `composer test:feature`, or `composer test:browser` run focused suites.
|
||||||
|
- `composer test:coverage` runs the Domain/Application coverage gate (80% minimum, scoped via `phpunit.coverage.xml`) used by CI's `unit` job. Requires a coverage driver (`pcov` or `xdebug`); fails with "No code coverage driver available" without one — that's an environment gap, not a broken repo.
|
||||||
|
|
||||||
Feature and browser tests require the `amare_test` PostgreSQL database configured in `phpunit.xml`.
|
Feature and browser tests require the `amare_test` PostgreSQL database configured in `phpunit.xml`.
|
||||||
|
|
||||||
|
## Worktrees
|
||||||
|
|
||||||
|
Always work in a git worktree created from the `main` ref — never modify `main` directly and never commit from the primary working tree. Create a dedicated worktree per feature/branch with `git worktree add -b <branch> <path> main`. On finishing work, create a PR, watch CI until green, then merge it. Clean up the worktree with `git worktree remove` after merge.
|
||||||
|
|
||||||
|
## Git Hooks (husky)
|
||||||
|
|
||||||
|
Hooks live in `.husky/` and auto-install on any plain `npm install` via the `prepare` script. Note `composer setup` runs `npm install --ignore-scripts`, which skips hook installation — after setup, run `npm install` once (or `npx husky`) to activate hooks.
|
||||||
|
|
||||||
|
- `pre-commit`: runs `composer pint:check` and `composer phpstan`.
|
||||||
|
- `pre-push`: gates on the `amare_test` database (settings parsed from `phpunit.xml`), blocks the push with a `docker compose up -d postgres` hint when Postgres is unreachable, then runs `composer test:unit` and `composer test:feature`. Browser tests are CI-only (FrankenPHP container).
|
||||||
|
|
||||||
## Coding Style & Naming Conventions
|
## Coding Style & Naming Conventions
|
||||||
|
|
||||||
Follow PSR-4 and Laravel conventions: PascalCase classes, camelCase methods, and snake_case database columns. Use four spaces (two in YAML, except four in Compose files), LF endings, and UTF-8 as defined by `.editorconfig`. Every project-owned PHP file must place `declare(strict_types=1);` immediately after `<?php`. Keep domain code independent of Filament and Livewire. Run `composer pint` to format and `composer phpstan` before review.
|
Follow PSR-4 and Laravel conventions: PascalCase classes, camelCase methods, and snake_case database columns. Use four spaces (two in YAML, except four in Compose files), LF endings, and UTF-8 as defined by `.editorconfig`. Every project-owned PHP file must place `declare(strict_types=1);` immediately after `<?php`. Keep domain code independent of Filament and Livewire. Run `composer pint` to format and `composer phpstan` before review.
|
||||||
@@ -30,3 +42,23 @@ History follows Conventional Commit-style subjects, for example `feat: Fase 0
|
|||||||
## Security & Configuration
|
## Security & Configuration
|
||||||
|
|
||||||
Copy `.env.example`; never commit secrets or production credentials. Development seed credentials are local-only. Validate uploads and authorization through Laravel policies, and run `composer security-audit` after dependency changes.
|
Copy `.env.example`; never commit secrets or production credentials. Development seed credentials are local-only. Validate uploads and authorization through Laravel policies, and run `composer security-audit` after dependency changes.
|
||||||
|
|
||||||
|
## Design Context
|
||||||
|
|
||||||
|
Amare: refined, humane, precise — Heritage Editorial. 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.
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|||||||
72
CLAUDE.md
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
# 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`).
|
||||||
|
|
||||||
|
## 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 remote auth — two GitHub accounts
|
||||||
|
|
||||||
|
`origin` is `git@github.com:manoel-freitas/amore-site.git`, owned by the **`manoel-freitas`** account. The machine's default SSH identity is a different account (`manoel-freitas-neto`) that cannot see this repo, so pushes fail with `ERROR: Repository not found.` — an access error that reads like a missing repo.
|
||||||
|
|
||||||
|
- Correct key: `~/.ssh/id_github_pessoal`. Verify with `ssh -i ~/.ssh/id_github_pessoal -o IdentitiesOnly=yes -T git@github.com` → should greet `Hi manoel-freitas!`.
|
||||||
|
- The repo has `core.sshCommand = ssh -i ~/.ssh/id_github_pessoal -o IdentitiesOnly=yes` set locally, so plain `git push` works. If that config is lost, restore it instead of editing the remote URL.
|
||||||
|
- **`gh` authenticates separately**, by token rather than SSH key. As of 2026-08-10 it is logged in as `manoel-freitas`, so `gh pr create` / `gh repo view` work. Confirm with `gh auth status` before assuming: if it reports `manoel-freitas-neto`, that account cannot see this repo and every `gh` call fails on it. Recovering needs an interactive `gh auth login` (or `gh auth switch` with both accounts added), so ask the user to run it.
|
||||||
|
|
||||||
|
## Request spine for the public site
|
||||||
|
|
||||||
|
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.
|
||||||
@@ -23,6 +23,10 @@ RUN npm ci
|
|||||||
COPY vite.config.js ./
|
COPY vite.config.js ./
|
||||||
COPY resources ./resources
|
COPY resources ./resources
|
||||||
COPY public ./public
|
COPY public ./public
|
||||||
|
# resources/css/filament/admin/theme.css imports Filament's own uncompiled CSS,
|
||||||
|
# so the Vite build needs those files present. Only Filament's subtree is copied
|
||||||
|
# rather than all of vendor/, to keep this stage's context small.
|
||||||
|
COPY --from=composer /app/vendor/filament ./vendor/filament
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
FROM dunglas/frankenphp:1-php${PHP_VERSION}-bookworm AS runtime
|
FROM dunglas/frankenphp:1-php${PHP_VERSION}-bookworm AS runtime
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ Site público premium e operação interna formam um fluxo único. A mesma plata
|
|||||||
## Evidence on Hand
|
## Evidence on Hand
|
||||||
|
|
||||||
- `SPEC.md` define escopo, personas, jornadas, métricas, requisitos e decisões do MVP.
|
- `SPEC.md` define escopo, personas, jornadas, métricas, requisitos e decisões do MVP.
|
||||||
- Código atual contém site público, CMS, estrutura de acessibilidade, testes 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.
|
- Textos, fotografias, portfólio, depoimentos, contatos e perfis sociais presentes nos seeders são dados fictícios de desenvolvimento e não constituem prova comercial.
|
||||||
- Ainda não há logo, fotografia proprietária, depoimentos autorizados, cases reais, imprensa, credenciais, CNPJ ou texto jurídico definitivo confirmados no repositório. Trabalho futuro não deve inventá-los.
|
- Ainda não há logo, fotografia proprietária, depoimentos autorizados, cases reais, imprensa, credenciais, CNPJ ou texto jurídico definitivo confirmados no repositório. Trabalho futuro não deve inventá-los.
|
||||||
|
|
||||||
@@ -68,7 +68,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.
|
2. **Uma informação, um fluxo:** conduzir o dado do briefing ao evento sem redigitação ou planilhas paralelas.
|
||||||
3. **Adoção sem ruptura:** organizar o trabalho existente da equipe sem impor processo desnecessariamente complexo.
|
3. **Adoção sem ruptura:** organizar o trabalho existente da equipe sem impor processo desnecessariamente complexo.
|
||||||
4. **Confiança sustentada por fatos:** usar somente conteúdo, resultados e provas reais e autorizados.
|
4. **Confiança sustentada por fatos:** usar somente conteúdo, resultados e provas reais e autorizados.
|
||||||
5. **Qualidade verificável:** tratar acessibilidade, desempenho, 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
|
## Accessibility & Inclusion
|
||||||
|
|
||||||
|
|||||||
30
README.md
@@ -4,7 +4,7 @@ Aplicação Laravel 13 para assessoria de eventos (Fase 0 — Fundação).
|
|||||||
|
|
||||||
## Requisitos
|
## 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
|
- Composer 2.x
|
||||||
- Node.js 22+ e npm
|
- Node.js 22+ e npm
|
||||||
- Docker e Docker Compose
|
- Docker e Docker Compose
|
||||||
@@ -18,10 +18,10 @@ cp .env.example .env
|
|||||||
php artisan key:generate
|
php artisan key:generate
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Suba o PostgreSQL:
|
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
|
```bash
|
||||||
docker compose up -d
|
docker compose up -d postgres
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Instale dependências e rode migrations:
|
3. Instale dependências e rode migrations:
|
||||||
@@ -49,12 +49,24 @@ Painel interno: `/admin`
|
|||||||
|
|
||||||
Healthcheck: `GET /up`
|
Healthcheck: `GET /up`
|
||||||
|
|
||||||
|
## Paridade local com produção (FrankenPHP via Docker Compose)
|
||||||
|
|
||||||
|
Além do fluxo host-side acima, `docker-compose.yml` tem um serviço `app` que builda a mesma imagem FrankenPHP usada em staging/produção (`Dockerfile`), útil para testar o comportamento real do container antes do deploy:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d # sobe postgres + app
|
||||||
|
php artisan migrate # rode migrations (o entrypoint do container não migra sozinho)
|
||||||
|
curl localhost:8000/up
|
||||||
|
```
|
||||||
|
|
||||||
|
O serviço `app` depende de `postgres` estar saudável (`depends_on: condition: service_healthy`) e sobrescreve `DB_HOST`/`DB_PORT` do `.env` para apontar para o serviço `postgres` pelo nome (o padrão `127.0.0.1` do `.env` só funciona para processos rodando no host). Não há job de CI dedicado a este smoke — o job `container` do CI já builda e healthcheca a mesma imagem.
|
||||||
|
|
||||||
## Variáveis principais
|
## Variáveis principais
|
||||||
|
|
||||||
| Variável | Valor local |
|
| Variável | Valor local |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `APP_LOCALE` | `pt_BR` |
|
| `APP_LOCALE` | `pt_BR` |
|
||||||
| `APP_TIMEZONE` | `America/Fortaleza` |
|
| `APP_TIMEZONE` | `America/Sao_Paulo` |
|
||||||
| `DB_CONNECTION` | `pgsql` |
|
| `DB_CONNECTION` | `pgsql` |
|
||||||
| `SESSION_DRIVER` | `database` |
|
| `SESSION_DRIVER` | `database` |
|
||||||
| `CACHE_STORE` | `database` |
|
| `CACHE_STORE` | `database` |
|
||||||
@@ -63,7 +75,7 @@ Healthcheck: `GET /up`
|
|||||||
## Comandos de qualidade
|
## Comandos de qualidade
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
composer quality # Pint + PHPStan + audit + testes
|
composer quality # Pint + PHPStan + audit (composer + npm) + testes
|
||||||
composer test:unit # Unit + Architecture
|
composer test:unit # Unit + Architecture
|
||||||
composer test:feature # Feature + Livewire + Filament
|
composer test:feature # Feature + Livewire + Filament
|
||||||
composer test:browser # E2E browser
|
composer test:browser # E2E browser
|
||||||
@@ -125,3 +137,11 @@ Após `php artisan db:seed`:
|
|||||||
- [SPEC.md](SPEC.md) — especificação do produto
|
- [SPEC.md](SPEC.md) — especificação do produto
|
||||||
- [docs/adr/](docs/adr/) — ADRs aceitas
|
- [docs/adr/](docs/adr/) — ADRs aceitas
|
||||||
- [docs/conventions/php-strict-types.md](docs/conventions/php-strict-types.md) — convenção de strict types
|
- [docs/conventions/php-strict-types.md](docs/conventions/php-strict-types.md) — convenção de strict types
|
||||||
|
- [docs/operations/atualizacao-de-conteudo.md](docs/operations/atualizacao-de-conteudo.md) — runbook de atualização de conteúdo do site pelo painel admin
|
||||||
|
- [docs/deployment/dokploy.md](docs/deployment/dokploy.md) — deploy staging/produção no Dokploy + GHCR
|
||||||
|
|
||||||
|
## 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).
|
||||||
|
|
||||||
|
Ver runbook completo: [docs/deployment/dokploy.md](docs/deployment/dokploy.md).
|
||||||
|
|||||||
259
SPEC.md
@@ -14,15 +14,16 @@
|
|||||||
| Estágio | MVP |
|
| Estágio | MVP |
|
||||||
| Status da especificação | Aprovada para implementação |
|
| Status da especificação | Aprovada para implementação |
|
||||||
| Idioma da interface | Português do Brasil (`pt-BR`) |
|
| Idioma da interface | Português do Brasil (`pt-BR`) |
|
||||||
| Timezone padrão | `America/Fortaleza` |
|
| Timezone padrão | `America/Sao_Paulo` |
|
||||||
|
| Cidade de atuação | São Paulo (capital) |
|
||||||
| Moeda | BRL, sem conversão entre moedas |
|
| Moeda | BRL, sem conversão entre moedas |
|
||||||
| Princípio principal | YAGNI — implementar somente o necessário para validar o produto |
|
| Princípio principal | YAGNI — implementar somente o necessário para validar o produto |
|
||||||
| Arquitetura | Monólito modular Laravel |
|
| Arquitetura | Monólito modular Laravel |
|
||||||
| Área interna | Filament |
|
| Á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 |
|
| Servidor de aplicação | FrankenPHP em modo regular |
|
||||||
| Banco de dados | PostgreSQL |
|
| Banco de dados | PostgreSQL |
|
||||||
| Testes | Pest, Pest Browser/Playwright e testes visuais |
|
| Testes | Pest e Pest Browser/Playwright |
|
||||||
|
|
||||||
### 0.1 Vocabulário normativo
|
### 0.1 Vocabulário normativo
|
||||||
|
|
||||||
@@ -44,6 +45,8 @@ Em caso de conflito, seguir esta ordem:
|
|||||||
|
|
||||||
O agente **NÃO DEVE** alterar silenciosamente uma decisão deste documento. Uma alteração de escopo ou arquitetura deve atualizar esta especificação ou criar uma ADR.
|
O agente **NÃO DEVE** alterar silenciosamente uma decisão deste documento. Uma alteração de escopo ou arquitetura deve atualizar esta especificação ou criar uma ADR.
|
||||||
|
|
||||||
|
Mudanças incrementais são planejadas em `openspec/changes/` e, após arquivadas, este documento DEVE ser revalidado para incorporar decisões ratificadas (back-sync). Este arquivo permanece a fonte de verdade do produto.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. Contrato de operação para agentes
|
## 1. Contrato de operação para agentes
|
||||||
@@ -77,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 colocar regras financeiras diretamente em views, Resources, Models observers ou callbacks de formulário.
|
||||||
- NÃO DEVE usar `float` para dinheiro.
|
- NÃO DEVE usar `float` para dinheiro.
|
||||||
- NÃO DEVE persistir status derivados que possam ser calculados corretamente a partir dos dados fonte.
|
- NÃO DEVE persistir status derivados que possam ser calculados corretamente a partir dos dados fonte.
|
||||||
- NÃO DEVE atualizar snapshots visuais apenas para fazer o CI passar sem revisar o diff.
|
|
||||||
|
|
||||||
### 1.2 Entrega incremental
|
### 1.2 Entrega incremental
|
||||||
|
|
||||||
@@ -132,7 +134,6 @@ Uma assessora adotará o sistema quando ele:
|
|||||||
| Operação | Tarefas vencidas sem responsável | `0` |
|
| Operação | Tarefas vencidas sem responsável | `0` |
|
||||||
| Financeiro | Itens de orçamento sem valor ou status | Menos de 5% por evento ativo |
|
| Financeiro | Itens de orçamento sem valor ou status | Menos de 5% por evento ativo |
|
||||||
| Qualidade | Jornadas E2E críticas passando | 100% antes de deploy |
|
| Qualidade | Jornadas E2E críticas passando | 100% antes de deploy |
|
||||||
| Visual | Snapshots aprovados | 100% |
|
|
||||||
| Confiabilidade | Erros não tratados | Alerta imediato e tendência decrescente |
|
| Confiabilidade | Erros não tratados | Alerta imediato e tendência decrescente |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -191,11 +192,24 @@ Não instalar sistema de permissões granular no MVP.
|
|||||||
|
|
||||||
## 4. Escopo
|
## 4. Escopo
|
||||||
|
|
||||||
|
> **Recorte vigente do lançamento (ADR-016).** O escopo aprovado para o lançamento de 31/08/2026 é o **site institucional**: Fases 0 e 1. As Fases 2 a 5 — CRM de leads, conversão de lead em evento, eventos, tarefas, fornecedores, orçamento, pagamentos manuais, documentos, dashboard orientado a exceções e auditoria — permanecem especificadas neste documento mas ficam **adiadas**, sem data.
|
||||||
|
>
|
||||||
|
> A §4.1 abaixo descreve o produto completo, não o recorte do lançamento. Os itens marcados como adiados estão fora do que se constrói agora. Ver §18 para a divisão por fase e §23 para as condições de conclusão de cada recorte.
|
||||||
|
|
||||||
### 4.1 Incluído no MVP
|
### 4.1 Incluído no MVP
|
||||||
|
|
||||||
|
No recorte do lançamento (Fases 0–1):
|
||||||
|
|
||||||
- site público;
|
- site público;
|
||||||
- CMS interno do site;
|
- CMS interno do site;
|
||||||
- formulário de briefing;
|
- formulário de briefing;
|
||||||
|
- usuários internos e papéis simples;
|
||||||
|
- SEO básico;
|
||||||
|
- acessibilidade automatizada;
|
||||||
|
- CI/CD e deploy em contêiner com FrankenPHP.
|
||||||
|
|
||||||
|
Adiados para depois do lançamento (Fases 2–5, ver ADR-016):
|
||||||
|
|
||||||
- CRM de leads;
|
- CRM de leads;
|
||||||
- conversão de lead em evento;
|
- conversão de lead em evento;
|
||||||
- cadastro e visão consolidada de eventos;
|
- cadastro e visão consolidada de eventos;
|
||||||
@@ -205,12 +219,10 @@ Não instalar sistema de permissões granular no MVP.
|
|||||||
- pagamentos inseridos manualmente;
|
- pagamentos inseridos manualmente;
|
||||||
- documentos vinculados a leads e eventos;
|
- documentos vinculados a leads e eventos;
|
||||||
- dashboard orientado a exceções;
|
- dashboard orientado a exceções;
|
||||||
- usuários internos e papéis simples;
|
|
||||||
- notificações internas e por e-mail para novos leads;
|
- notificações internas e por e-mail para novos leads;
|
||||||
- auditoria de ações críticas;
|
- auditoria de ações críticas.
|
||||||
- SEO básico;
|
|
||||||
- acessibilidade e testes visuais;
|
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.
|
||||||
- CI/CD e deploy em contêiner com FrankenPHP.
|
|
||||||
|
|
||||||
### 4.2 Fora do MVP
|
### 4.2 Fora do MVP
|
||||||
|
|
||||||
@@ -312,8 +324,8 @@ Administração
|
|||||||
|
|
||||||
- Dashboard: `Filament Page` customizada com widgets orientados a exceção.
|
- Dashboard: `Filament Page` customizada com widgets orientados a exceção.
|
||||||
- Detalhe do evento: página customizada do Resource com resumo operacional.
|
- Detalhe do evento: página customizada do Resource com resumo operacional.
|
||||||
- Briefing público: componente Livewire próprio.
|
- Briefing público: Blade + Controller (`POST /contato`), ver §11.2.
|
||||||
- Home: Blade/Livewire com componentes de design reutilizáveis.
|
- Home: Blade com componentes de design reutilizáveis.
|
||||||
|
|
||||||
### 5.4 Decisões de UX YAGNI
|
### 5.4 Decisões de UX YAGNI
|
||||||
|
|
||||||
@@ -349,13 +361,14 @@ A home DEVE conter, nesta ordem aproximada:
|
|||||||
|
|
||||||
1. header e navegação;
|
1. header e navegação;
|
||||||
2. hero com proposta de valor e CTA;
|
2. hero com proposta de valor e CTA;
|
||||||
3. prova visual por eventos em destaque;
|
3. manifesto da marca;
|
||||||
4. resumo dos serviços;
|
4. resumo dos serviços em destaque;
|
||||||
5. método de trabalho;
|
5. casos selecionados do portfólio;
|
||||||
6. casos selecionados;
|
6. método de trabalho (4 passos);
|
||||||
7. depoimentos;
|
7. depoimentos;
|
||||||
8. CTA final para briefing;
|
8. posicionamento/perfil;
|
||||||
9. footer com contato, redes e links legais.
|
9. CTA final para briefing;
|
||||||
|
10. footer com contato, redes e links legais.
|
||||||
|
|
||||||
A ordem pode variar apenas se a revisão de UX justificar a mudança.
|
A ordem pode variar apenas se a revisão de UX justificar a mudança.
|
||||||
|
|
||||||
@@ -374,6 +387,15 @@ Centralizar tokens de:
|
|||||||
|
|
||||||
Não espalhar valores visuais arbitrários por componentes.
|
Não espalhar valores visuais arbitrários por componentes.
|
||||||
|
|
||||||
|
O MVP adota o sistema de design **Heritage Editorial** (ver DESIGN.md e ADR-013):
|
||||||
|
|
||||||
|
- tipografia serifada auto-hospedada (EB Garamond) com escala de display a label;
|
||||||
|
- paleta papel/oliva/sálvia/tinta com superfícies tonais; hierarquia sem sombras de card;
|
||||||
|
- raio de borda zero para superfícies interativas e de conteúdo;
|
||||||
|
- largura de container 1120px e ritmo de espaçamento de 8px;
|
||||||
|
- `prefers-reduced-motion` respeitado;
|
||||||
|
- contraste WCAG AA (tinta sobre papel e oliva sobre papel).
|
||||||
|
|
||||||
### 6.4 Requisitos de mídia
|
### 6.4 Requisitos de mídia
|
||||||
|
|
||||||
- Imagens públicas DEVERÃO possuir texto alternativo.
|
- Imagens públicas DEVERÃO possuir texto alternativo.
|
||||||
@@ -383,6 +405,7 @@ Não espalhar valores visuais arbitrários por componentes.
|
|||||||
- Dimensões DEVERÃO ser reservadas para evitar layout shift.
|
- Dimensões DEVERÃO ser reservadas para evitar layout shift.
|
||||||
- Não armazenar blobs de imagem no PostgreSQL.
|
- Não armazenar blobs de imagem no PostgreSQL.
|
||||||
- Não depender do disco efêmero do contêiner em produção.
|
- Não depender do disco efêmero do contêiner em produção.
|
||||||
|
- O logotipo da marca DEVE possuir texto alternativo e variantes claro/escuro; `site_settings.logo_path` sobrescreve o asset padrão quando preenchido.
|
||||||
|
|
||||||
### 6.5 Acessibilidade
|
### 6.5 Acessibilidade
|
||||||
|
|
||||||
@@ -534,12 +557,10 @@ Campos públicos:
|
|||||||
- validar no servidor;
|
- validar no servidor;
|
||||||
- usar honeypot e rate limiting;
|
- usar honeypot e rate limiting;
|
||||||
- impedir duplo envio acidental;
|
- impedir duplo envio acidental;
|
||||||
- criar Lead com status `new`;
|
|
||||||
- registrar origem `website`;
|
|
||||||
- notificar administradores;
|
|
||||||
- exibir sucesso sem revelar dados internos;
|
|
||||||
- enviar e-mail de confirmação quando o serviço de e-mail estiver configurado;
|
- enviar e-mail de confirmação quando o serviço de e-mail estiver configurado;
|
||||||
- falha no e-mail não pode apagar o lead já criado.
|
- exibir sucesso sem revelar dados internos.
|
||||||
|
|
||||||
|
> **Estado atual (Fases 0–1):** o formulário usa Blade + Controller e envia apenas e-mails informativos (para a assessoria e confirmação ao visitante), sem criar Lead. A criação de Lead (status `new`, origem `website`), a notificação aos administradores e o registro de aceite de privacidade entram na **Fase 2**, quando o formulário passa a criar o Lead via `CaptureWebsiteLead`. A falha de e-mail não pode apagar dados já criados.
|
||||||
|
|
||||||
**Aceite:**
|
**Aceite:**
|
||||||
|
|
||||||
@@ -1253,10 +1274,19 @@ Singleton:
|
|||||||
|
|
||||||
- `id` bigint PK;
|
- `id` bigint PK;
|
||||||
- `brand_name`;
|
- `brand_name`;
|
||||||
|
- `logo_path` nullable;
|
||||||
|
- `logo_alt` nullable;
|
||||||
- `hero_eyebrow` nullable;
|
- `hero_eyebrow` nullable;
|
||||||
- `hero_title`;
|
- `hero_title`;
|
||||||
- `hero_subtitle`;
|
- `hero_subtitle`;
|
||||||
- `hero_cta_label`;
|
- `hero_cta_label`;
|
||||||
|
- `hero_cta_secondary_label` nullable;
|
||||||
|
- `hero_note` nullable;
|
||||||
|
- `manifesto_title`;
|
||||||
|
- `manifesto_lead`;
|
||||||
|
- `manifesto_body`;
|
||||||
|
- `method_steps` jsonb (4 passos tipados);
|
||||||
|
- `principles` jsonb (lista tipada);
|
||||||
- `about_summary` nullable;
|
- `about_summary` nullable;
|
||||||
- `email`;
|
- `email`;
|
||||||
- `phone`;
|
- `phone`;
|
||||||
@@ -1265,7 +1295,8 @@ Singleton:
|
|||||||
- `default_meta_title`;
|
- `default_meta_title`;
|
||||||
- `default_meta_description`;
|
- `default_meta_description`;
|
||||||
- `default_og_image_path` nullable;
|
- `default_og_image_path` nullable;
|
||||||
- analytics fields nullable;
|
- `default_og_image_alt` nullable;
|
||||||
|
- `analytics_enabled` boolean default false;
|
||||||
- timestamps.
|
- timestamps.
|
||||||
|
|
||||||
#### `services`
|
#### `services`
|
||||||
@@ -1531,12 +1562,12 @@ Constraints de banco devem proteger:
|
|||||||
| Runtime | PHP com versão minor fixada no Docker |
|
| Runtime | PHP com versão minor fixada no Docker |
|
||||||
| Framework | Laravel 13 |
|
| Framework | Laravel 13 |
|
||||||
| Admin | Filament 5 |
|
| Admin | Filament 5 |
|
||||||
| UI pública | 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 |
|
| Banco | PostgreSQL |
|
||||||
| Servidor | FrankenPHP + Caddy |
|
| Servidor | FrankenPHP + Caddy |
|
||||||
| Assets | Vite |
|
| Assets | Vite |
|
||||||
| Testes | Pest 4 + Pest Browser/Playwright |
|
| Testes | Pest 4 + Pest Browser/Playwright |
|
||||||
| Arquivos | Laravel Filesystem + storage S3-compatible em produção |
|
| Arquivos | Laravel Filesystem + Cloudflare R2 (S3-compatible) em produção |
|
||||||
| Fila | Database queue |
|
| Fila | Database queue |
|
||||||
| Scheduler | Laravel Scheduler em processo separado |
|
| Scheduler | Laravel Scheduler em processo separado |
|
||||||
|
|
||||||
@@ -1752,19 +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`;
|
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`.
|
||||||
- `FeaturedPortfolioCases` se houver necessidade de consulta dinâmica;
|
|
||||||
- `PublishedServices` se houver necessidade de consulta dinâmica.
|
|
||||||
|
|
||||||
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
|
### 11.2 Formulário de briefing
|
||||||
|
|
||||||
O componente deve:
|
> **Estado atual:** o formulário é implementado em Blade + Controller (`POST /contato`, `ContactBriefingRequest`), conforme WEB-05, e essa é a abordagem aceita — não um estágio provisório. Os requisitos abaixo valem independentemente da tecnologia; a criação de Lead segue para a Fase 2.
|
||||||
|
|
||||||
|
O formulário deve:
|
||||||
|
|
||||||
- ter estado tipado ou Form Object quando útil;
|
- ter estado tipado ou Form Object quando útil;
|
||||||
- validar no servidor;
|
- validar no servidor;
|
||||||
@@ -1773,15 +1806,16 @@ O componente deve:
|
|||||||
- preservar acessibilidade;
|
- preservar acessibilidade;
|
||||||
- limpar dados após sucesso;
|
- limpar dados após sucesso;
|
||||||
- evitar exposição de exceção;
|
- evitar exposição de exceção;
|
||||||
- suportar teste Livewire sem browser;
|
- suportar teste de submissão sem navegador (feature test);
|
||||||
- suportar jornada E2E em navegador real.
|
- suportar jornada E2E em navegador real.
|
||||||
|
|
||||||
### 11.3 JavaScript
|
### 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 introduzir framework SPA;
|
||||||
- não usar dependência JS quando CSS/HTML/Livewire resolverem;
|
- não usar dependência JS quando CSS e HTML resolverem;
|
||||||
- toda interação crítica deve funcionar sem estado global complexo.
|
- toda interação crítica deve funcionar sem estado global complexo;
|
||||||
|
- Alpine só entra junto com Livewire, se o gatilho de §22 disparar.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -1865,7 +1899,7 @@ A pirâmide deve possuir:
|
|||||||
1. muitos testes unitários rápidos;
|
1. muitos testes unitários rápidos;
|
||||||
2. testes feature/integration suficientes para Laravel, PostgreSQL, Livewire e Filament;
|
2. testes feature/integration suficientes para Laravel, PostgreSQL, Livewire e Filament;
|
||||||
3. poucos testes E2E cobrindo jornadas críticas;
|
3. poucos testes E2E cobrindo jornadas críticas;
|
||||||
4. testes 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.
|
A distribuição é por intenção, não por percentual rígido.
|
||||||
|
|
||||||
@@ -1947,40 +1981,7 @@ Em falha, CI deve publicar:
|
|||||||
- logs do browser;
|
- logs do browser;
|
||||||
- HTML report quando disponível.
|
- HTML report quando disponível.
|
||||||
|
|
||||||
### 13.5 Regressão visual
|
### 13.5 Testes de arquitetura
|
||||||
|
|
||||||
Usar `assertScreenshotMatches()` ou API equivalente do Pest Browser.
|
|
||||||
|
|
||||||
Snapshots obrigatórios:
|
|
||||||
|
|
||||||
| Tela | Desktop | Mobile |
|
|
||||||
|---|---:|---:|
|
|
||||||
| Home | 1440×1000 | 390×844 |
|
|
||||||
| Serviços | 1440×1000 | 390×844 |
|
|
||||||
| Portfólio | 1440×1000 | 390×844 |
|
|
||||||
| Detalhe do portfólio | 1440×1000 | 390×844 |
|
|
||||||
| Briefing vazio | 1280×900 | 390×844 |
|
|
||||||
| Briefing com erros | 1280×900 | 390×844 |
|
|
||||||
| Briefing sucesso | 1280×900 | 390×844 |
|
|
||||||
| Login | 1280×900 | Opcional |
|
|
||||||
| Dashboard | 1440×1000 | Não obrigatório |
|
|
||||||
| Detalhe do evento | 1440×1000 | Não obrigatório |
|
|
||||||
|
|
||||||
Determinismo obrigatório:
|
|
||||||
|
|
||||||
- Chromium e imagem Linux fixos;
|
|
||||||
- viewport fixo;
|
|
||||||
- timezone `America/Fortaleza`;
|
|
||||||
- locale `pt-BR`;
|
|
||||||
- fontes instaladas na imagem;
|
|
||||||
- relógio congelado;
|
|
||||||
- seed determinístico;
|
|
||||||
- animações e transições desabilitadas;
|
|
||||||
- dados dinâmicos mascarados quando necessário.
|
|
||||||
|
|
||||||
Atualização de baseline deve usar comando explícito e revisão humana do diff.
|
|
||||||
|
|
||||||
### 13.6 Testes de arquitetura
|
|
||||||
|
|
||||||
Criar regras Pest Architecture:
|
Criar regras Pest Architecture:
|
||||||
|
|
||||||
@@ -1997,14 +1998,14 @@ arch()
|
|||||||
|
|
||||||
Adicionar regras conforme a estrutura real, sem tornar a suíte excessivamente frágil.
|
Adicionar regras conforme a estrutura real, sem tornar a suíte excessivamente frágil.
|
||||||
|
|
||||||
### 13.7 Cobertura
|
### 13.6 Cobertura
|
||||||
|
|
||||||
- meta de 80% para `Domain` e `Application`;
|
- meta de 80% para `Domain` e `Application`;
|
||||||
- não medir views, migrations, código gerado e framework;
|
- não medir views, migrations, código gerado e framework;
|
||||||
- cobertura não substitui critérios de aceite;
|
- cobertura não substitui critérios de aceite;
|
||||||
- queda de cobertura em módulo crítico deve bloquear merge.
|
- queda de cobertura em módulo crítico deve bloquear merge.
|
||||||
|
|
||||||
### 13.8 Acessibilidade automatizada
|
### 13.7 Acessibilidade automatizada
|
||||||
|
|
||||||
Smoke/browser tests devem verificar:
|
Smoke/browser tests devem verificar:
|
||||||
|
|
||||||
@@ -2022,7 +2023,7 @@ Revisão manual mínima antes do lançamento:
|
|||||||
- zoom a 200%;
|
- zoom a 200%;
|
||||||
- leitor de tela nos fluxos de briefing e login.
|
- leitor de tela nos fluxos de briefing e login.
|
||||||
|
|
||||||
### 13.9 Comandos padronizados
|
### 13.8 Comandos padronizados
|
||||||
|
|
||||||
O projeto deve expor scripts equivalentes:
|
O projeto deve expor scripts equivalentes:
|
||||||
|
|
||||||
@@ -2032,7 +2033,6 @@ composer test:feature
|
|||||||
composer test:browser
|
composer test:browser
|
||||||
composer test
|
composer test
|
||||||
composer quality
|
composer quality
|
||||||
composer visual:update
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Composição esperada:
|
Composição esperada:
|
||||||
@@ -2040,7 +2040,7 @@ Composição esperada:
|
|||||||
```text
|
```text
|
||||||
test:unit → Unit + Architecture
|
test:unit → Unit + Architecture
|
||||||
test:feature → Feature + Livewire + Filament
|
test:feature → Feature + Livewire + Filament
|
||||||
test:browser → E2E + visual + acessibilidade + smoke
|
test:browser → E2E + acessibilidade + motion + smoke
|
||||||
quality → Pint check + PHPStan/Larastan + audits + testes
|
quality → Pint check + PHPStan/Larastan + audits + testes
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -2052,10 +2052,10 @@ quality → Pint check + PHPStan/Larastan + audits + testes
|
|||||||
|
|
||||||
| Job | Responsabilidade | Bloqueia merge |
|
| Job | Responsabilidade | Bloqueia merge |
|
||||||
|---|---|---:|
|
|---|---|---:|
|
||||||
| `static` | Pint, PHPStan/Larastan, Composer validate e audits | Sim |
|
| `static` | Pint, PHPStan/Larastan, Composer validate, Composer e npm audit | Sim |
|
||||||
| `unit` | Unitários, arquitetura e cobertura | Sim |
|
| `unit` | Unitários, arquitetura e cobertura | Sim |
|
||||||
| `feature` | PostgreSQL, migrations, Livewire, Filament e integração | Sim |
|
| `feature` | PostgreSQL, migrations, Livewire, Filament e integração | Sim |
|
||||||
| `browser` | Vite, FrankenPHP, E2E, smoke, acessibilidade e visual | Sim |
|
| `browser` | Vite, FrankenPHP, E2E, smoke, acessibilidade e motion | Sim |
|
||||||
| `container` | Build da imagem final e healthcheck | Sim |
|
| `container` | Build da imagem final e healthcheck | Sim |
|
||||||
|
|
||||||
### 14.2 Regras do pipeline
|
### 14.2 Regras do pipeline
|
||||||
@@ -2073,11 +2073,11 @@ quality → Pint check + PHPStan/Larastan + audits + testes
|
|||||||
### 14.3 Branches e ambientes
|
### 14.3 Branches e ambientes
|
||||||
|
|
||||||
- PR: testes e preview opcional;
|
- PR: testes e preview opcional;
|
||||||
- `main`: build imutável por SHA e deploy automático em staging;
|
- `main`: build imutável por SHA publicado no GHCR e deploy automático em staging via Dokploy;
|
||||||
- staging: migration, cache warmup e smoke pós-deploy;
|
- staging: Dokploy Compose executa migração, healthcheck `/up` e smoke pós-deploy (`/up`, `/`, `/admin/login`);
|
||||||
- produção: promoção da mesma imagem aprovada, sem rebuild;
|
- produção: promoção da mesma imagem aprovada, sem rebuild (retag do digest em `:production`);
|
||||||
- produção requer aprovação humana explícita no MVP;
|
- produção requer aprovação humana explícita no MVP (`workflow_dispatch` com confirmação);
|
||||||
- rollback usa imagem anterior;
|
- rollback usa imagem anterior (SHA anterior, sem rebuild);
|
||||||
- migrations devem ser backward-compatible quando possível.
|
- migrations devem ser backward-compatible quando possível.
|
||||||
|
|
||||||
### 14.4 Definition of Done
|
### 14.4 Definition of Done
|
||||||
@@ -2089,7 +2089,6 @@ Uma história só está concluída quando:
|
|||||||
- PHPStan/Larastan está verde;
|
- PHPStan/Larastan está verde;
|
||||||
- unit e feature tests cobrem regras relevantes;
|
- unit e feature tests cobrem regras relevantes;
|
||||||
- E2E foi criado ou atualizado para fluxo crítico;
|
- E2E foi criado ou atualizado para fluxo crítico;
|
||||||
- snapshot foi criado ou revisado para UI coberta;
|
|
||||||
- acessibilidade automatizada não possui issues críticas/sérias;
|
- acessibilidade automatizada não possui issues críticas/sérias;
|
||||||
- migration foi testada;
|
- migration foi testada;
|
||||||
- autorização foi verificada;
|
- autorização foi verificada;
|
||||||
@@ -2159,7 +2158,7 @@ APP_DEBUG=false
|
|||||||
APP_URL
|
APP_URL
|
||||||
APP_LOCALE=pt_BR
|
APP_LOCALE=pt_BR
|
||||||
APP_FALLBACK_LOCALE=pt_BR
|
APP_FALLBACK_LOCALE=pt_BR
|
||||||
APP_TIMEZONE=America/Fortaleza
|
APP_TIMEZONE=America/Sao_Paulo
|
||||||
|
|
||||||
DB_CONNECTION=pgsql
|
DB_CONNECTION=pgsql
|
||||||
DB_HOST
|
DB_HOST
|
||||||
@@ -2172,19 +2171,16 @@ CACHE_STORE=database ou file conforme ambiente
|
|||||||
QUEUE_CONNECTION=database
|
QUEUE_CONNECTION=database
|
||||||
SESSION_DRIVER=database ou cookie conforme decisão
|
SESSION_DRIVER=database ou cookie conforme decisão
|
||||||
|
|
||||||
FILESYSTEM_DISK=s3 em produção
|
FILESYSTEM_DISK=r2 em produção
|
||||||
AWS_ACCESS_KEY_ID
|
R2_ACCESS_KEY_ID
|
||||||
AWS_SECRET_ACCESS_KEY
|
R2_SECRET_ACCESS_KEY
|
||||||
AWS_DEFAULT_REGION
|
R2_BUCKET
|
||||||
AWS_BUCKET
|
R2_ENDPOINT
|
||||||
AWS_ENDPOINT opcional
|
R2_URL (domínio próprio opcional)
|
||||||
AWS_USE_PATH_STYLE_ENDPOINT opcional
|
AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_DEFAULT_REGION, AWS_BUCKET, AWS_ENDPOINT, AWS_USE_PATH_STYLE_ENDPOINT — opcionais, apenas se o disk s3 for usado
|
||||||
|
|
||||||
MAIL_MAILER
|
MAIL_MAILER=resend em produção (mailer nativo Laravel)
|
||||||
MAIL_HOST
|
RESEND_API_KEY
|
||||||
MAIL_PORT
|
|
||||||
MAIL_USERNAME
|
|
||||||
MAIL_PASSWORD
|
|
||||||
MAIL_FROM_ADDRESS
|
MAIL_FROM_ADDRESS
|
||||||
MAIL_FROM_NAME
|
MAIL_FROM_NAME
|
||||||
```
|
```
|
||||||
@@ -2279,7 +2275,7 @@ O seed deve criar:
|
|||||||
- configurações do site;
|
- configurações do site;
|
||||||
- 3 serviços;
|
- 3 serviços;
|
||||||
- 3 casos de portfólio;
|
- 3 casos de portfólio;
|
||||||
- 3 depoimentos;
|
- depoimentos reais: os 5 casais de `depoimentos.md` (Jeniffer e Maick, Quesia e Jhonata, Milena e Weslley, Raquel e Pedro, Victoria e Pedro), preservando texto e datas; autores fictícios de demonstração removidos; em produção permanecem não publicados até autorização explícita de publicação;
|
||||||
- leads em estados variados;
|
- leads em estados variados;
|
||||||
- 2 eventos futuros e 1 concluído;
|
- 2 eventos futuros e 1 concluído;
|
||||||
- tarefas vencidas e futuras;
|
- tarefas vencidas e futuras;
|
||||||
@@ -2303,6 +2299,8 @@ Para visual e browser tests:
|
|||||||
|
|
||||||
## 18. Backlog de implementação
|
## 18. Backlog de implementação
|
||||||
|
|
||||||
|
> **Recorte vigente (ADR-016).** Fases 0 e 1 são o escopo do lançamento e estão concluídas. **Fases 2 a 5 estão adiadas, sem data.** Não iniciar nenhuma delas sem uma decisão nova do responsável pelo produto — a §1.1 manda trabalhar uma fase por vez, e a fase corrente é o acabamento e a publicação do site.
|
||||||
|
|
||||||
O agente deve implementar na sequência, salvo instrução explícita.
|
O agente deve implementar na sequência, salvo instrução explícita.
|
||||||
|
|
||||||
### Fase 0 — Fundação
|
### Fase 0 — Fundação
|
||||||
@@ -2325,6 +2323,13 @@ O agente deve implementar na sequência, salvo instrução explícita.
|
|||||||
- [x] design tokens mínimos;
|
- [x] design tokens mínimos;
|
||||||
- [x] healthcheck;
|
- [x] healthcheck;
|
||||||
- [x] seed de admin local.
|
- [x] seed de admin local.
|
||||||
|
- [x] verificação de e-mail e reset seguro (MustVerifyEmail);
|
||||||
|
- [x] npm audit no `composer quality` e no job `static`;
|
||||||
|
- [x] gate de cobertura `Domain`/`Application` ≥ 80%;
|
||||||
|
- [x] serviço de aplicação FrankenPHP no Compose local;
|
||||||
|
- [x] hello-world implantado em staging (critério de saída) — run [31395107465](https://github.com/manoel-freitas/amore-site/actions/runs/31395107465) em `7e68c0e`, com `Dokploy deployment succeeded` e smoke verde em `/up`, `/` e `/admin/login`.
|
||||||
|
|
||||||
|
> Os itens pendentes acima são tratados pela mudança OpenSpec `complete-foundation-parity`; o critério de saída da fase só é atingido com staging implantado.
|
||||||
|
|
||||||
**Critério de saída:** pipeline verde e hello-world implantado em staging.
|
**Critério de saída:** pipeline verde e hello-world implantado em staging.
|
||||||
|
|
||||||
@@ -2341,12 +2346,11 @@ O agente deve implementar na sequência, salvo instrução explícita.
|
|||||||
- [x] privacidade;
|
- [x] privacidade;
|
||||||
- [x] SEO;
|
- [x] SEO;
|
||||||
- [x] mídia otimizada;
|
- [x] mídia otimizada;
|
||||||
- [x] snapshots desktop/mobile;
|
|
||||||
- [x] testes de acessibilidade.
|
- [x] testes de acessibilidade.
|
||||||
|
|
||||||
**Critério de saída:** conteúdo gerenciável no Filament 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;
|
- [ ] migration/model/factory de Lead;
|
||||||
- [ ] LeadActivity;
|
- [ ] LeadActivity;
|
||||||
@@ -2363,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.
|
**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;
|
- [ ] Event;
|
||||||
- [ ] EventTask;
|
- [ ] EventTask;
|
||||||
@@ -2378,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.
|
**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;
|
- [ ] Vendor;
|
||||||
- [ ] EventVendor;
|
- [ ] EventVendor;
|
||||||
@@ -2394,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.
|
**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;
|
- [ ] documentos privados;
|
||||||
- [ ] auditoria completa;
|
- [ ] auditoria completa;
|
||||||
@@ -2455,8 +2459,7 @@ Toda operação financeira deve:
|
|||||||
| Risco | Mitigação obrigatória |
|
| Risco | Mitigação obrigatória |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Filament concentrar domínio | Resources finos, Actions e Queries testáveis |
|
| Filament concentrar domínio | Resources finos, Actions e Queries testáveis |
|
||||||
| Site parecer genérico | design tokens, conteúdo real, fotografia e visual tests |
|
| Site parecer genérico | design tokens, conteúdo real, fotografia e revisão humana |
|
||||||
| Snapshots instáveis | contêiner fixo, relógio/dados/fontes determinísticos |
|
|
||||||
| Escopo crescer | lista de não objetivos e mudança somente com hipótese real |
|
| Escopo crescer | lista de não objetivos e mudança somente com hipótese real |
|
||||||
| Financeiro virar contabilidade | limitar a orçamento e pagamentos manuais |
|
| Financeiro virar contabilidade | limitar a orçamento e pagamentos manuais |
|
||||||
| Worker mode vazar estado | manter modo regular até benchmark e ADR |
|
| Worker mode vazar estado | manter modo regular até benchmark e ADR |
|
||||||
@@ -2472,15 +2475,21 @@ Toda operação financeira deve:
|
|||||||
| ADR | Decisão | Status |
|
| ADR | Decisão | Status |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| ADR-001 | Monólito modular Laravel, sem microserviços | Aceita |
|
| ADR-001 | Monólito modular Laravel, sem microserviços | Aceita |
|
||||||
| ADR-002 | Filament para área interna 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-003 | PostgreSQL como único banco transacional | Aceita |
|
||||||
| ADR-004 | Pagamentos somente manuais | Aceita |
|
| ADR-004 | Pagamentos somente manuais | Aceita |
|
||||||
| ADR-005 | Pest unifica unit, feature, browser e visual | Aceita |
|
| ADR-005 | Pest unifica unit, feature e browser | Aceita |
|
||||||
| ADR-006 | FrankenPHP regular mode; worker mode adiado | Aceita |
|
| ADR-006 | FrankenPHP regular mode; worker mode adiado | Aceita |
|
||||||
| ADR-007 | Single-tenant; SaaS e portal do cliente adiados | Aceita |
|
| ADR-007 | Single-tenant; SaaS e portal do cliente adiados | Aceita |
|
||||||
| ADR-008 | Database queue; Redis adiado | Aceita |
|
| ADR-008 | Database queue; Redis adiado | Aceita |
|
||||||
| ADR-009 | Dinheiro em BRL armazenado como centavos inteiros | Aceita |
|
| ADR-009 | Dinheiro em BRL armazenado como centavos inteiros | Aceita |
|
||||||
| ADR-010 | Home com estrutura fixa e CMS tipado, sem page builder | Aceita |
|
| ADR-010 | Home com estrutura fixa e CMS tipado, sem page builder | Aceita |
|
||||||
|
| ADR-011 | Cloudflare R2 (S3-compatible) como storage de objetos em produção | Aceita |
|
||||||
|
| ADR-012 | E-mail transacional via Resend (mailer nativo Laravel) | Aceita |
|
||||||
|
| ADR-013 | Design system Heritage Editorial para o site público | Aceita |
|
||||||
|
| ADR-014 | Deploy via Dokploy Compose com imagem imutável por SHA no GHCR | Aceita |
|
||||||
|
| ADR-015 | Site público permanece Blade + JS vanilla; Livewire e Alpine ficam restritos ao Filament até o gatilho de §22. Emenda o texto da ADR-002 | Aceita |
|
||||||
|
| ADR-016 | Lançamento de 31/08/2026 entrega apenas o site institucional (Fases 0–1); Fases 2–5 seguem especificadas e adiadas, sem data | Aceita |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -2497,6 +2506,7 @@ Toda operação financeira deve:
|
|||||||
| API pública | existe consumidor real e contrato de integração |
|
| API pública | existe consumidor real e contrato de integração |
|
||||||
| Kanban | tabela de leads demonstra limitação frequente observada |
|
| Kanban | tabela de leads demonstra limitação frequente observada |
|
||||||
| Editor de checklist | diferentes tipos de evento exigem manutenção frequente do seed |
|
| Editor de checklist | diferentes tipos de evento exigem manutenção frequente do seed |
|
||||||
|
| Livewire no site público | portfólio ou serviços exigem consulta ou filtro dinâmico que Blade + JS vanilla não resolvem de forma simples |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -2504,24 +2514,39 @@ Toda operação financeira deve:
|
|||||||
|
|
||||||
O MVP está concluído somente quando:
|
O MVP está concluído somente quando:
|
||||||
|
|
||||||
|
A ADR-016 divide estas condições em dois recortes. Cada um se fecha por conta própria; o segundo não bloqueia o lançamento.
|
||||||
|
|
||||||
|
### 23.1 Lançamento do site (Fases 0–1)
|
||||||
|
|
||||||
|
O lançamento está concluído somente quando:
|
||||||
|
|
||||||
- site público está publicado e visualmente aprovado;
|
- site público está publicado e visualmente aprovado;
|
||||||
- assessora edita os conteúdos essenciais sem desenvolvedor;
|
- assessora edita os conteúdos essenciais sem desenvolvedor;
|
||||||
- briefing 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, 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 (§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;
|
- pipeline e próxima ação funcionam;
|
||||||
|
- briefing cria leads de forma segura e persistente;
|
||||||
- lead é convertido uma única vez em evento;
|
- lead é convertido uma única vez em evento;
|
||||||
- checklist é criado automaticamente;
|
- checklist é criado automaticamente;
|
||||||
- evento possui visão consolidada;
|
- evento possui visão consolidada;
|
||||||
- fornecedores podem ser cadastrados e vinculados;
|
- fornecedores podem ser cadastrados e vinculados;
|
||||||
- orçamento e pagamentos manuais possuem totais consistentes;
|
- orçamento e pagamentos manuais possuem totais consistentes;
|
||||||
- dashboard mostra pendências do dia;
|
- dashboard mostra pendências do dia;
|
||||||
- usuários e Policies estão corretos;
|
|
||||||
- auditoria registra operações críticas;
|
- auditoria registra operações críticas;
|
||||||
- testes unit, feature, E2E, visuais e arquitetura estão verdes;
|
- jornadas E2E das fases correspondentes 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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ final readonly class PageMeta
|
|||||||
public ?string $ogImageUrl = null,
|
public ?string $ogImageUrl = null,
|
||||||
public ?string $ogImageAlt = null,
|
public ?string $ogImageAlt = null,
|
||||||
public ?array $jsonLd = null,
|
public ?array $jsonLd = null,
|
||||||
|
public string $siteName = '',
|
||||||
|
public string $robots = 'index, follow',
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -44,6 +46,7 @@ final readonly class PageMeta
|
|||||||
ogImageUrl: $ogImageUrl ?? self::defaultOgImageUrl($settings),
|
ogImageUrl: $ogImageUrl ?? self::defaultOgImageUrl($settings),
|
||||||
ogImageAlt: $ogImageAlt ?? $settings->default_og_image_alt,
|
ogImageAlt: $ogImageAlt ?? $settings->default_og_image_alt,
|
||||||
jsonLd: $jsonLd,
|
jsonLd: $jsonLd,
|
||||||
|
siteName: (string) $settings->brand_name,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,9 +80,53 @@ final readonly class PageMeta
|
|||||||
ogImageUrl: $ogImageUrl,
|
ogImageUrl: $ogImageUrl,
|
||||||
ogImageAlt: $ogImageAlt,
|
ogImageAlt: $ogImageAlt,
|
||||||
jsonLd: $jsonLd,
|
jsonLd: $jsonLd,
|
||||||
|
siteName: (string) $settings->brand_name,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the metadata for branded error pages.
|
||||||
|
*
|
||||||
|
* Error pages carry no canonical, are excluded from search indexes and use
|
||||||
|
* the page name suffixed with the brand name as their title.
|
||||||
|
*/
|
||||||
|
public static function forErrorPage(
|
||||||
|
SiteSetting $settings,
|
||||||
|
int $status = 404,
|
||||||
|
): self {
|
||||||
|
[$title, $description] = match ($status) {
|
||||||
|
500 => ['Algo deu errado', 'Não foi possível concluir o pedido. Tente novamente em instantes.'],
|
||||||
|
419 => ['Sessão expirada', 'Sua sessão expirou. Volte e tente enviar novamente.'],
|
||||||
|
429 => ['Muitas solicitações', 'Você enviou muitas solicitações em pouco tempo. Aguarde um instante e tente novamente.'],
|
||||||
|
503 => ['Em manutenção', 'Estamos realizando uma breve manutenção. Tente novamente em instantes.'],
|
||||||
|
default => ['Página não encontrada', 'A página que você procura não existe ou foi movida.'],
|
||||||
|
};
|
||||||
|
|
||||||
|
return new self(
|
||||||
|
title: trim($title).' - '.$settings->brand_name,
|
||||||
|
description: $description,
|
||||||
|
canonical: '',
|
||||||
|
robots: 'noindex, nofollow',
|
||||||
|
siteName: (string) $settings->brand_name,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append the brand name to a page title when it is not already present.
|
||||||
|
*/
|
||||||
|
public static function withBrandSuffix(string $title, SiteSetting $settings): string
|
||||||
|
{
|
||||||
|
$brand = filled($settings->default_meta_title)
|
||||||
|
? (string) $settings->default_meta_title
|
||||||
|
: (string) $settings->brand_name;
|
||||||
|
|
||||||
|
if (str_contains($title, $brand)) {
|
||||||
|
return $title;
|
||||||
|
}
|
||||||
|
|
||||||
|
return trim($title).' - '.$brand;
|
||||||
|
}
|
||||||
|
|
||||||
private static function defaultTitle(SiteSetting $settings): string
|
private static function defaultTitle(SiteSetting $settings): string
|
||||||
{
|
{
|
||||||
return filled($settings->default_meta_title)
|
return filled($settings->default_meta_title)
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ final class MediaGenerateVariantsCommand extends Command
|
|||||||
|
|
||||||
foreach ($paths as $path) {
|
foreach ($paths as $path) {
|
||||||
if (! Storage::disk($disk)->exists($path)) {
|
if (! Storage::disk($disk)->exists($path)) {
|
||||||
|
ResponsiveImage::forgetMetadata($path, $disk);
|
||||||
$this->warn("Missing file: {$path}");
|
$this->warn("Missing file: {$path}");
|
||||||
$skipped++;
|
$skipped++;
|
||||||
|
|
||||||
@@ -56,6 +57,21 @@ final class MediaGenerateVariantsCommand extends Command
|
|||||||
if ($settings && filled($settings->default_og_image_path)) {
|
if ($settings && filled($settings->default_og_image_path)) {
|
||||||
$paths[] = (string) $settings->default_og_image_path;
|
$paths[] = (string) $settings->default_og_image_path;
|
||||||
}
|
}
|
||||||
|
if ($settings && filled($settings->hero_image_path)) {
|
||||||
|
$paths[] = (string) $settings->hero_image_path;
|
||||||
|
}
|
||||||
|
if ($settings && filled($settings->about_image_path)) {
|
||||||
|
$paths[] = (string) $settings->about_image_path;
|
||||||
|
}
|
||||||
|
if ($settings && filled($settings->hero_image_path)) {
|
||||||
|
$paths[] = (string) $settings->hero_image_path;
|
||||||
|
}
|
||||||
|
if ($settings && filled($settings->services_hero_image_path)) {
|
||||||
|
$paths[] = (string) $settings->services_hero_image_path;
|
||||||
|
}
|
||||||
|
if ($settings && filled($settings->portfolio_hero_image_path)) {
|
||||||
|
$paths[] = (string) $settings->portfolio_hero_image_path;
|
||||||
|
}
|
||||||
|
|
||||||
foreach (Service::query()->whereNotNull('cover_image_path')->pluck('cover_image_path') as $path) {
|
foreach (Service::query()->whereNotNull('cover_image_path')->pluck('cover_image_path') as $path) {
|
||||||
$paths[] = (string) $path;
|
$paths[] = (string) $path;
|
||||||
|
|||||||
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
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,8 @@ use BackedEnum;
|
|||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Actions\ActionGroup;
|
use Filament\Actions\ActionGroup;
|
||||||
use Filament\Forms\Components\KeyValue;
|
use Filament\Forms\Components\KeyValue;
|
||||||
|
use Filament\Forms\Components\Repeater;
|
||||||
|
use Filament\Forms\Components\TagsInput;
|
||||||
use Filament\Forms\Components\Textarea;
|
use Filament\Forms\Components\Textarea;
|
||||||
use Filament\Forms\Components\TextInput;
|
use Filament\Forms\Components\TextInput;
|
||||||
use Filament\Forms\Components\Toggle;
|
use Filament\Forms\Components\Toggle;
|
||||||
@@ -135,6 +137,8 @@ class ManageSiteSettings extends Page
|
|||||||
->label('Nome da marca')
|
->label('Nome da marca')
|
||||||
->required()
|
->required()
|
||||||
->maxLength(255),
|
->maxLength(255),
|
||||||
|
PublicImageUploadRules::fileUpload('logo_path', 'Logo da marca', 'content/logo'),
|
||||||
|
PublicImageUploadRules::altTextField('logo_alt', 'logo_path', 'Texto alternativo do logo'),
|
||||||
TextInput::make('hero_eyebrow')
|
TextInput::make('hero_eyebrow')
|
||||||
->label('Eyebrow do hero')
|
->label('Eyebrow do hero')
|
||||||
->maxLength(255),
|
->maxLength(255),
|
||||||
@@ -147,14 +151,75 @@ class ManageSiteSettings extends Page
|
|||||||
->required()
|
->required()
|
||||||
->rows(3),
|
->rows(3),
|
||||||
TextInput::make('hero_cta_label')
|
TextInput::make('hero_cta_label')
|
||||||
->label('Texto do CTA')
|
->label('Texto do CTA principal')
|
||||||
->required()
|
->required()
|
||||||
->maxLength(255),
|
->maxLength(255),
|
||||||
|
TextInput::make('hero_secondary_cta_label')
|
||||||
|
->label('Texto do CTA secundário')
|
||||||
|
->maxLength(255),
|
||||||
|
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')
|
Textarea::make('about_summary')
|
||||||
->label('Resumo institucional')
|
->label('Resumo institucional')
|
||||||
->rows(3),
|
->rows(3),
|
||||||
])
|
])
|
||||||
->columns(2),
|
->columns(2),
|
||||||
|
Section::make('Imagens das aberturas')
|
||||||
|
->description('Fotos reais e autorizadas para as primeiras dobras. Sem imagem, cada rota mantém a abertura tonal.')
|
||||||
|
->schema([
|
||||||
|
PublicImageUploadRules::fileUpload('hero_image_path', 'Imagem da home', 'content/heroes'),
|
||||||
|
PublicImageUploadRules::altTextField('hero_image_alt', 'hero_image_path'),
|
||||||
|
PublicImageUploadRules::fileUpload('services_hero_image_path', 'Imagem de Serviços', 'content/heroes'),
|
||||||
|
PublicImageUploadRules::altTextField('services_hero_image_alt', 'services_hero_image_path'),
|
||||||
|
PublicImageUploadRules::fileUpload('portfolio_hero_image_path', 'Imagem de Portfólio', 'content/heroes'),
|
||||||
|
PublicImageUploadRules::altTextField('portfolio_hero_image_alt', 'portfolio_hero_image_path'),
|
||||||
|
])
|
||||||
|
->columns(2),
|
||||||
|
Section::make('Manifesto editorial')
|
||||||
|
->schema([
|
||||||
|
TextInput::make('manifesto_title')
|
||||||
|
->label('Título do manifesto')
|
||||||
|
->maxLength(255),
|
||||||
|
Textarea::make('manifesto_lead')
|
||||||
|
->label('Lead do manifesto')
|
||||||
|
->rows(3),
|
||||||
|
Textarea::make('manifesto_body')
|
||||||
|
->label('Corpo do manifesto')
|
||||||
|
->rows(4),
|
||||||
|
]),
|
||||||
|
Section::make('Método')
|
||||||
|
->schema([
|
||||||
|
Textarea::make('method_intro')
|
||||||
|
->label('Introdução do método')
|
||||||
|
->rows(2),
|
||||||
|
Repeater::make('method_steps')
|
||||||
|
->label('Passos do método')
|
||||||
|
->schema([
|
||||||
|
TextInput::make('title')
|
||||||
|
->label('Título')
|
||||||
|
->required()
|
||||||
|
->maxLength(255),
|
||||||
|
Textarea::make('body')
|
||||||
|
->label('Descrição')
|
||||||
|
->required()
|
||||||
|
->rows(2),
|
||||||
|
])
|
||||||
|
->defaultItems(0)
|
||||||
|
->maxItems(4)
|
||||||
|
->reorderable()
|
||||||
|
->columnSpanFull(),
|
||||||
|
]),
|
||||||
|
Section::make('Princípios')
|
||||||
|
->schema([
|
||||||
|
TagsInput::make('principles')
|
||||||
|
->label('Princípios')
|
||||||
|
->placeholder('Adicionar princípio')
|
||||||
|
->helperText('Até quatro princípios editoriais.')
|
||||||
|
->columnSpanFull(),
|
||||||
|
]),
|
||||||
Section::make('Contato')
|
Section::make('Contato')
|
||||||
->schema([
|
->schema([
|
||||||
TextInput::make('email')
|
TextInput::make('email')
|
||||||
@@ -176,6 +241,12 @@ class ManageSiteSettings extends Page
|
|||||||
->addActionLabel('Adicionar rede'),
|
->addActionLabel('Adicionar rede'),
|
||||||
])
|
])
|
||||||
->columns(2),
|
->columns(2),
|
||||||
|
Section::make('Página Sobre')
|
||||||
|
->schema([
|
||||||
|
PublicImageUploadRules::fileUpload('about_image_path', 'Imagem da página Sobre', 'content/about'),
|
||||||
|
PublicImageUploadRules::altTextField('about_image_alt', 'about_image_path'),
|
||||||
|
])
|
||||||
|
->columns(2),
|
||||||
Section::make('SEO padrão')
|
Section::make('SEO padrão')
|
||||||
->schema([
|
->schema([
|
||||||
TextInput::make('default_meta_title')
|
TextInput::make('default_meta_title')
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Filament\Resources\PortfolioCases\Pages;
|
namespace App\Filament\Resources\PortfolioCases\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\PortfolioCases\PortfolioCaseResource;
|
use App\Filament\Resources\PortfolioCases\PortfolioCaseResource;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Filament\Resources\PortfolioCases\Pages;
|
namespace App\Filament\Resources\PortfolioCases\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\PortfolioCases\PortfolioCaseResource;
|
use App\Filament\Resources\PortfolioCases\PortfolioCaseResource;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Filament\Resources\PortfolioCases\Pages;
|
namespace App\Filament\Resources\PortfolioCases\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\PortfolioCases\PortfolioCaseResource;
|
use App\Filament\Resources\PortfolioCases\PortfolioCaseResource;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Filament\Resources\Services\Pages;
|
namespace App\Filament\Resources\Services\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\Services\ServiceResource;
|
use App\Filament\Resources\Services\ServiceResource;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Filament\Resources\Services\Pages;
|
namespace App\Filament\Resources\Services\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\Services\ServiceResource;
|
use App\Filament\Resources\Services\ServiceResource;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Filament\Resources\Services\Pages;
|
namespace App\Filament\Resources\Services\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\Services\ServiceResource;
|
use App\Filament\Resources\Services\ServiceResource;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Filament\Resources\Testimonials\Pages;
|
namespace App\Filament\Resources\Testimonials\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\Testimonials\TestimonialResource;
|
use App\Filament\Resources\Testimonials\TestimonialResource;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Filament\Resources\Testimonials\Pages;
|
namespace App\Filament\Resources\Testimonials\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\Testimonials\TestimonialResource;
|
use App\Filament\Resources\Testimonials\TestimonialResource;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Filament\Resources\Testimonials\Pages;
|
namespace App\Filament\Resources\Testimonials\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\Testimonials\TestimonialResource;
|
use App\Filament\Resources\Testimonials\TestimonialResource;
|
||||||
|
|||||||
@@ -1,11 +1,38 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Filament\Resources\Users\Pages;
|
namespace App\Filament\Resources\Users\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\Users\UserResource;
|
use App\Filament\Resources\Users\UserResource;
|
||||||
|
use App\Models\User;
|
||||||
use Filament\Resources\Pages\CreateRecord;
|
use Filament\Resources\Pages\CreateRecord;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
class CreateUser extends CreateRecord
|
class CreateUser extends CreateRecord
|
||||||
{
|
{
|
||||||
protected static string $resource = UserResource::class;
|
protected static string $resource = UserResource::class;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verification stays admin-managed only (no self-service verify route is
|
||||||
|
* registered): a user created here by an admin is, by that act, verified.
|
||||||
|
* Without this, `email_verified_at` would stay null forever and
|
||||||
|
* canAccessPanel() would permanently lock the new user out with no
|
||||||
|
* in-app path to recover — the password-reset callback skips notifying
|
||||||
|
* users that fail canAccessPanel() while still reporting success.
|
||||||
|
*
|
||||||
|
* `email_verified_at` is deliberately not added to User's #[Fillable]
|
||||||
|
* list (it isn't a UserForm field either) so it can never be set via
|
||||||
|
* mass assignment from form/API input — `forceFill()` bypasses that
|
||||||
|
* guard here on purpose, after construction.
|
||||||
|
*/
|
||||||
|
protected function handleRecordCreation(array $data): Model
|
||||||
|
{
|
||||||
|
/** @var User $record */
|
||||||
|
$record = new (static::getModel())($data);
|
||||||
|
$record->forceFill(['email_verified_at' => now()]);
|
||||||
|
$record->save();
|
||||||
|
|
||||||
|
return $record;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Filament\Resources\Users\Pages;
|
namespace App\Filament\Resources\Users\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\Users\UserResource;
|
use App\Filament\Resources\Users\UserResource;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Filament\Resources\Users\Pages;
|
namespace App\Filament\Resources\Users\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\Users\UserResource;
|
use App\Filament\Resources\Users\UserResource;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
abstract class Controller
|
abstract class Controller
|
||||||
|
|||||||
140
app/Http/Controllers/PublicSite/ContactController.php
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\PublicSite;
|
||||||
|
|
||||||
|
use App\Domain\Contact\MarketingOrigin;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\PublicSite\ContactBriefingRequest;
|
||||||
|
use App\Mail\ContactBriefing;
|
||||||
|
use App\Mail\ContactBriefingConfirmation;
|
||||||
|
use App\Models\SiteSetting;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Support\Facades\Mail;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
final class ContactController extends Controller
|
||||||
|
{
|
||||||
|
private const string DUPLICATE_SESSION_KEY = 'contact_briefing_hash';
|
||||||
|
|
||||||
|
public function store(ContactBriefingRequest $request): RedirectResponse
|
||||||
|
{
|
||||||
|
if (filled($request->input('empresa'))) {
|
||||||
|
return $this->success();
|
||||||
|
}
|
||||||
|
|
||||||
|
$validated = $request->validated();
|
||||||
|
unset($validated['privacidade']);
|
||||||
|
|
||||||
|
if ($this->isDuplicate($validated)) {
|
||||||
|
return $this->success();
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->rememberSubmission($validated);
|
||||||
|
|
||||||
|
$settings = SiteSetting::instance();
|
||||||
|
$fields = $this->buildFields($validated, $this->resolveMarketingOrigin($request));
|
||||||
|
|
||||||
|
$this->dispatchEmails($settings, $validated['nome'], $validated['email'], $fields);
|
||||||
|
|
||||||
|
return $this->success();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $validated
|
||||||
|
*/
|
||||||
|
private function buildFields(array $validated, ?string $marketingOrigin): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'Nome' => (string) $validated['nome'],
|
||||||
|
'E-mail' => (string) $validated['email'],
|
||||||
|
'Telefone/WhatsApp' => (string) $validated['telefone'],
|
||||||
|
'Tipo de evento' => (string) $validated['tipo_evento'],
|
||||||
|
'Data ou período desejado' => isset($validated['data_periodo']) ? (string) $validated['data_periodo'] : null,
|
||||||
|
'Cidade' => (string) $validated['cidade'],
|
||||||
|
'Número estimado de convidados' => isset($validated['convidados']) ? (string) $validated['convidados'] : null,
|
||||||
|
'Serviço de interesse' => isset($validated['servico_interesse']) ? (string) $validated['servico_interesse'] : null,
|
||||||
|
'Mensagem' => (string) $validated['mensagem'],
|
||||||
|
'Origem de marketing' => $marketingOrigin,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the origin captured on arrival (SPEC.md WEB-05) so it can
|
||||||
|
* ride along with the internal briefing e-mail only — never the
|
||||||
|
* confirmation sent to the visitor. Fail-open: any problem reading
|
||||||
|
* or interpreting the session value must never block the
|
||||||
|
* submission, so it degrades to "not captured" instead.
|
||||||
|
*/
|
||||||
|
private function resolveMarketingOrigin(ContactBriefingRequest $request): ?string
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$origin = $request->session()->get(MarketingOrigin::SESSION_KEY, []);
|
||||||
|
|
||||||
|
return is_array($origin) ? MarketingOrigin::describe($origin) : null;
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
Log::warning('Falha ao ler origem de marketing armazenada (ignorada; fail-open)', [
|
||||||
|
'exception' => $exception::class,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $validated
|
||||||
|
*/
|
||||||
|
private function isDuplicate(array $validated): bool
|
||||||
|
{
|
||||||
|
$hash = $this->hash($validated);
|
||||||
|
|
||||||
|
return session()->get(self::DUPLICATE_SESSION_KEY) === $hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $validated
|
||||||
|
*/
|
||||||
|
private function rememberSubmission(array $validated): void
|
||||||
|
{
|
||||||
|
session()->put(self::DUPLICATE_SESSION_KEY, $this->hash($validated));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $validated
|
||||||
|
*/
|
||||||
|
private function hash(array $validated): string
|
||||||
|
{
|
||||||
|
return hash('sha256', serialize($validated));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $fields
|
||||||
|
*/
|
||||||
|
private function dispatchEmails(SiteSetting $settings, string $name, string $email, array $fields): void
|
||||||
|
{
|
||||||
|
$attempt = function () use ($settings, $name, $email, $fields): void {
|
||||||
|
if (filled($settings->email)) {
|
||||||
|
Mail::to($settings->email)->send(new ContactBriefing($fields));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filled(config('mail.default'))) {
|
||||||
|
Mail::to($email)->send(new ContactBriefingConfirmation($name, (string) $settings->brand_name));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
$attempt();
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
Log::error('Falha ao enviar briefing de contato', [
|
||||||
|
'exception' => $exception,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function success(): RedirectResponse
|
||||||
|
{
|
||||||
|
return redirect()->route('contact')->with('status', 'briefing-sent');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,7 +20,7 @@ final class PageController extends Controller
|
|||||||
'pageMeta' => PageMeta::forPage(
|
'pageMeta' => PageMeta::forPage(
|
||||||
canonical: route('about'),
|
canonical: route('about'),
|
||||||
settings: $settings,
|
settings: $settings,
|
||||||
title: 'Sobre',
|
title: PageMeta::withBrandSuffix('Sobre', $settings),
|
||||||
description: $settings->about_summary ?: ('Conheça a '.$settings->brand_name.'.'),
|
description: $settings->about_summary ?: ('Conheça a '.$settings->brand_name.'.'),
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
@@ -35,7 +35,7 @@ final class PageController extends Controller
|
|||||||
'pageMeta' => PageMeta::forPage(
|
'pageMeta' => PageMeta::forPage(
|
||||||
canonical: route('privacy'),
|
canonical: route('privacy'),
|
||||||
settings: $settings,
|
settings: $settings,
|
||||||
title: 'Política de privacidade',
|
title: PageMeta::withBrandSuffix('Política de privacidade', $settings),
|
||||||
description: 'Política de privacidade da '.$settings->brand_name.'.',
|
description: 'Política de privacidade da '.$settings->brand_name.'.',
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
@@ -50,7 +50,7 @@ final class PageController extends Controller
|
|||||||
'pageMeta' => PageMeta::forPage(
|
'pageMeta' => PageMeta::forPage(
|
||||||
canonical: route('contact'),
|
canonical: route('contact'),
|
||||||
settings: $settings,
|
settings: $settings,
|
||||||
title: 'Contato',
|
title: PageMeta::withBrandSuffix('Contato', $settings),
|
||||||
description: 'Fale com a '.$settings->brand_name.'.',
|
description: 'Fale com a '.$settings->brand_name.'.',
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ final class PortfolioController extends Controller
|
|||||||
'pageMeta' => PageMeta::forPage(
|
'pageMeta' => PageMeta::forPage(
|
||||||
canonical: route('portfolio.index'),
|
canonical: route('portfolio.index'),
|
||||||
settings: $settings,
|
settings: $settings,
|
||||||
title: 'Portfólio',
|
title: PageMeta::withBrandSuffix('Portfólio', $settings),
|
||||||
description: 'Casos reais de eventos conduzidos pela '.$settings->brand_name.'.',
|
description: 'Casos reais de eventos conduzidos pela '.$settings->brand_name.'.',
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ final class ServiceController extends Controller
|
|||||||
'pageMeta' => PageMeta::forPage(
|
'pageMeta' => PageMeta::forPage(
|
||||||
canonical: route('services.index'),
|
canonical: route('services.index'),
|
||||||
settings: $settings,
|
settings: $settings,
|
||||||
title: 'Serviços',
|
title: PageMeta::withBrandSuffix('Serviços', $settings),
|
||||||
description: 'Conheça os serviços de assessoria de eventos da '.$settings->brand_name.'.',
|
description: 'Conheça os serviços de assessoria de eventos da '.$settings->brand_name.'.',
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
|
|||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
73
app/Http/Requests/PublicSite/ContactBriefingRequest.php
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests\PublicSite;
|
||||||
|
|
||||||
|
use App\Domain\Contact\BrazilianPhoneNumber;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
final class ContactBriefingRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Normaliza e-mail e telefone antes da validação (SPEC.md §12.3),
|
||||||
|
* mantendo o valor legível para quem recebe o briefing por e-mail.
|
||||||
|
*/
|
||||||
|
protected function prepareForValidation(): void
|
||||||
|
{
|
||||||
|
$email = $this->input('email');
|
||||||
|
$telefone = $this->input('telefone');
|
||||||
|
|
||||||
|
$this->merge([
|
||||||
|
'email' => is_string($email) ? mb_strtolower(trim($email)) : $email,
|
||||||
|
'telefone' => is_string($telefone) ? BrazilianPhoneNumber::normalize($telefone) : $telefone,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, array<int, string>>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'nome' => ['required', 'string', 'max:120'],
|
||||||
|
'email' => ['required', 'email', 'max:254'],
|
||||||
|
'telefone' => ['required', 'string', 'max:40'],
|
||||||
|
'tipo_evento' => ['required', 'string', 'max:80'],
|
||||||
|
'data_periodo' => ['nullable', 'string', 'max:80'],
|
||||||
|
'cidade' => ['required', 'string', 'max:80'],
|
||||||
|
'convidados' => ['nullable', 'integer', 'min:1', 'max:100000'],
|
||||||
|
'servico_interesse' => ['nullable', 'string', 'max:120'],
|
||||||
|
'mensagem' => ['required', 'string', 'max:3000'],
|
||||||
|
'privacidade' => ['accepted'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public function messages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'nome.required' => 'Informe seu nome completo.',
|
||||||
|
'nome.max' => 'O nome deve ter no máximo :max caracteres.',
|
||||||
|
'email.required' => 'Informe seu e-mail.',
|
||||||
|
'email.email' => 'Informe um e-mail válido.',
|
||||||
|
'email.max' => 'O e-mail deve ter no máximo :max caracteres.',
|
||||||
|
'telefone.required' => 'Informe um telefone ou WhatsApp.',
|
||||||
|
'telefone.max' => 'O telefone deve ter no máximo :max caracteres.',
|
||||||
|
'tipo_evento.required' => 'Selecione o tipo de evento.',
|
||||||
|
'tipo_evento.max' => 'O tipo de evento deve ter no máximo :max caracteres.',
|
||||||
|
'data_periodo.max' => 'A data ou período deve ter no máximo :max caracteres.',
|
||||||
|
'cidade.required' => 'Informe a cidade do evento.',
|
||||||
|
'cidade.max' => 'A cidade deve ter no máximo :max caracteres.',
|
||||||
|
'convidados.integer' => 'Informe um número de convidados válido.',
|
||||||
|
'convidados.min' => 'O número de convidados deve ser maior que zero.',
|
||||||
|
'convidados.max' => 'O número de convidados informado é inválido.',
|
||||||
|
'servico_interesse.max' => 'O serviço deve ter no máximo :max caracteres.',
|
||||||
|
'mensagem.required' => 'Conte brevemente o que você precisa.',
|
||||||
|
'mensagem.max' => 'A mensagem deve ter no máximo :max caracteres.',
|
||||||
|
'privacidade.accepted' => 'Você precisa aceitar a política de privacidade.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
40
app/Mail/ContactBriefing.php
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Mail;
|
||||||
|
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Mail\Mailable;
|
||||||
|
use Illuminate\Mail\Mailables\Content;
|
||||||
|
use Illuminate\Mail\Mailables\Envelope;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
|
final class ContactBriefing extends Mailable implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Queueable;
|
||||||
|
use SerializesModels;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $fields
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public readonly array $fields,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function envelope(): Envelope
|
||||||
|
{
|
||||||
|
return new Envelope(
|
||||||
|
subject: 'Novo briefing de contato — Amare Assessoria',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function content(): Content
|
||||||
|
{
|
||||||
|
return new Content(
|
||||||
|
html: 'emails.contact-briefing',
|
||||||
|
text: 'emails.contact-briefing-text',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
38
app/Mail/ContactBriefingConfirmation.php
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Mail;
|
||||||
|
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Mail\Mailable;
|
||||||
|
use Illuminate\Mail\Mailables\Content;
|
||||||
|
use Illuminate\Mail\Mailables\Envelope;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
|
final class ContactBriefingConfirmation extends Mailable implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Queueable;
|
||||||
|
use SerializesModels;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public readonly string $name,
|
||||||
|
public readonly string $brandName,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function envelope(): Envelope
|
||||||
|
{
|
||||||
|
return new Envelope(
|
||||||
|
subject: 'Recebemos sua mensagem — '.$this->brandName,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function content(): Content
|
||||||
|
{
|
||||||
|
return new Content(
|
||||||
|
html: 'emails.contact-briefing-confirmation',
|
||||||
|
text: 'emails.contact-briefing-confirmation-text',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,17 +11,47 @@ use Illuminate\Database\Eloquent\Model;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @property array<string, string|null> $social_links
|
* @property array<string, string|null> $social_links
|
||||||
|
* @property list<array{title?: string, body?: string}>|null $method_steps
|
||||||
|
* @property list<string>|null $principles
|
||||||
* @property bool $analytics_enabled
|
* @property bool $analytics_enabled
|
||||||
* @property string|null $default_og_image_path
|
* @property string|null $default_og_image_path
|
||||||
* @property string|null $default_og_image_alt
|
* @property string|null $default_og_image_alt
|
||||||
|
* @property string|null $about_image_path
|
||||||
|
* @property string|null $about_image_alt
|
||||||
|
* @property string|null $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
|
||||||
*/
|
*/
|
||||||
#[Fillable([
|
#[Fillable([
|
||||||
'brand_name',
|
'brand_name',
|
||||||
|
'logo_path',
|
||||||
|
'logo_alt',
|
||||||
'hero_eyebrow',
|
'hero_eyebrow',
|
||||||
'hero_title',
|
'hero_title',
|
||||||
'hero_subtitle',
|
'hero_subtitle',
|
||||||
'hero_cta_label',
|
'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_summary',
|
||||||
|
'about_image_path',
|
||||||
|
'about_image_alt',
|
||||||
|
'manifesto_title',
|
||||||
|
'manifesto_lead',
|
||||||
|
'manifesto_body',
|
||||||
|
'method_intro',
|
||||||
|
'method_steps',
|
||||||
|
'principles',
|
||||||
'email',
|
'email',
|
||||||
'phone',
|
'phone',
|
||||||
'city',
|
'city',
|
||||||
@@ -40,21 +70,67 @@ class SiteSetting extends Model
|
|||||||
{
|
{
|
||||||
return static::query()->firstOrCreate([], [
|
return static::query()->firstOrCreate([], [
|
||||||
'brand_name' => 'Amare Assessoria',
|
'brand_name' => 'Amare Assessoria',
|
||||||
'hero_eyebrow' => 'Assessoria de eventos',
|
'hero_eyebrow' => 'Assessoria e produção de eventos · São Paulo',
|
||||||
'hero_title' => 'Celebrações com propósito',
|
'hero_title' => 'Celebrações com propósito',
|
||||||
'hero_subtitle' => 'Planejamento completo para casamentos e eventos corporativos.',
|
'hero_subtitle' => 'Planejamento completo para casamentos e eventos corporativos.',
|
||||||
'hero_cta_label' => 'Solicitar orçamento',
|
'hero_cta_label' => 'Solicitar proposta',
|
||||||
'about_summary' => 'Assessoria boutique em Fortaleza.',
|
'hero_secondary_cta_label' => 'Conheça nosso olhar',
|
||||||
'email' => 'contato@amare.local',
|
'hero_note' => 'Planejamento cuidadoso, comunicação clara e execução segura — do primeiro encontro ao último detalhe.',
|
||||||
'phone' => '(85) 99999-9999',
|
'about_summary' => 'Assessoria boutique em São Paulo - SP.',
|
||||||
'city' => 'Fortaleza, CE',
|
'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' => self::defaultMethodSteps(),
|
||||||
|
'principles' => self::defaultPrinciples(),
|
||||||
|
'email' => 'amareassessoriaeventos@gmail.com',
|
||||||
|
'phone' => '(11) 99999-9999',
|
||||||
|
'city' => 'São Paulo - SP',
|
||||||
'social_links' => [],
|
'social_links' => [],
|
||||||
'default_meta_title' => 'Amare Assessoria de Eventos',
|
'default_meta_title' => 'Amare Assessoria de Eventos',
|
||||||
'default_meta_description' => 'Assessoria premium para casamentos e eventos corporativos.',
|
'default_meta_description' => 'Assessoria premium para casamentos e eventos corporativos em São Paulo.',
|
||||||
'analytics_enabled' => false,
|
'analytics_enabled' => false,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array{title: string, body: string}>
|
||||||
|
*/
|
||||||
|
public static function defaultMethodSteps(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
[
|
||||||
|
'title' => 'Escuta',
|
||||||
|
'body' => 'Entendimento do contexto, das prioridades, do público e do que o evento precisa comunicar.',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'title' => 'Direção',
|
||||||
|
'body' => 'Definição de escopo, próximos passos, responsabilidades e critérios para orientar decisões.',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'title' => 'Produção',
|
||||||
|
'body' => 'Coordenação de cronograma, fornecedores, detalhes, alinhamentos e contingências.',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'title' => 'Execução',
|
||||||
|
'body' => 'Presença atenta no evento para que o planejado aconteça com ritmo, cuidado e segurança.',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public static function defaultPrinciples(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'Personalização sem complicação desnecessária',
|
||||||
|
'Comunicação clara e decisões bem orientadas',
|
||||||
|
'Atenção à experiência de clientes e convidados',
|
||||||
|
'Execução responsável do início ao fim',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, string|class-string>
|
* @return array<string, string|class-string>
|
||||||
*/
|
*/
|
||||||
@@ -62,6 +138,8 @@ class SiteSetting extends Model
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'social_links' => 'array',
|
'social_links' => 'array',
|
||||||
|
'method_steps' => 'array',
|
||||||
|
'principles' => 'array',
|
||||||
'analytics_enabled' => 'boolean',
|
'analytics_enabled' => 'boolean',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ use App\Enums\UserRole;
|
|||||||
use Database\Factories\UserFactory;
|
use Database\Factories\UserFactory;
|
||||||
use Filament\Models\Contracts\FilamentUser;
|
use Filament\Models\Contracts\FilamentUser;
|
||||||
use Filament\Panel;
|
use Filament\Panel;
|
||||||
|
use Illuminate\Auth\MustVerifyEmail;
|
||||||
|
use Illuminate\Contracts\Auth\MustVerifyEmail as MustVerifyEmailContract;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Hidden;
|
use Illuminate\Database\Eloquent\Attributes\Hidden;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
@@ -20,14 +22,14 @@ use Illuminate\Notifications\Notifiable;
|
|||||||
*/
|
*/
|
||||||
#[Fillable(['name', 'email', 'password', 'role', 'is_active'])]
|
#[Fillable(['name', 'email', 'password', 'role', 'is_active'])]
|
||||||
#[Hidden(['password', 'remember_token'])]
|
#[Hidden(['password', 'remember_token'])]
|
||||||
class User extends Authenticatable implements FilamentUser
|
class User extends Authenticatable implements FilamentUser, MustVerifyEmailContract
|
||||||
{
|
{
|
||||||
/** @use HasFactory<UserFactory> */
|
/** @use HasFactory<UserFactory> */
|
||||||
use HasFactory, Notifiable;
|
use HasFactory, MustVerifyEmail, Notifiable;
|
||||||
|
|
||||||
public function canAccessPanel(Panel $panel): bool
|
public function canAccessPanel(Panel $panel): bool
|
||||||
{
|
{
|
||||||
return $this->is_active;
|
return $this->is_active && $this->hasVerifiedEmail();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function isAdmin(): bool
|
public function isAdmin(): bool
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ namespace App\Providers;
|
|||||||
use App\Application\Data\PageMeta;
|
use App\Application\Data\PageMeta;
|
||||||
use App\Models\SiteSetting;
|
use App\Models\SiteSetting;
|
||||||
use Carbon\CarbonImmutable;
|
use Carbon\CarbonImmutable;
|
||||||
|
use Illuminate\Cache\RateLimiting\Limit;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
use Illuminate\Support\Facades\View;
|
use Illuminate\Support\Facades\View;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
use Illuminate\View\View as ViewInstance;
|
use Illuminate\View\View as ViewInstance;
|
||||||
@@ -26,7 +29,9 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
*/
|
*/
|
||||||
public function boot(): void
|
public function boot(): void
|
||||||
{
|
{
|
||||||
|
$this->configureLivewireTemporaryUploads();
|
||||||
$this->freezeClockWhenConfigured();
|
$this->freezeClockWhenConfigured();
|
||||||
|
$this->configureRateLimiters();
|
||||||
|
|
||||||
View::composer('layouts.public', function (ViewInstance $view): void {
|
View::composer('layouts.public', function (ViewInstance $view): void {
|
||||||
$settings = $view->offsetExists('siteSettings')
|
$settings = $view->offsetExists('siteSettings')
|
||||||
@@ -46,6 +51,29 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep Livewire/Filament temp uploads on the local disk.
|
||||||
|
*
|
||||||
|
* When FILESYSTEM_DISK=r2, Livewire would otherwise use the S3 driver and
|
||||||
|
* browser-PUT straight to R2 (CORS). Final media still uses the r2 disk via
|
||||||
|
* PublicImageUploadRules. Explicit LIVEWIRE_TEMPORARY_FILE_UPLOAD_DISK wins.
|
||||||
|
*/
|
||||||
|
private function configureLivewireTemporaryUploads(): void
|
||||||
|
{
|
||||||
|
if (filled(config('livewire.temporary_file_upload.disk'))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
config(['livewire.temporary_file_upload.disk' => 'local']);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function configureRateLimiters(): void
|
||||||
|
{
|
||||||
|
RateLimiter::for('contact-briefing', function (Request $request): Limit {
|
||||||
|
return Limit::perMinute(5)->by($request->ip().'|contact-briefing');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private function freezeClockWhenConfigured(): void
|
private function freezeClockWhenConfigured(): void
|
||||||
{
|
{
|
||||||
if ($this->app->environment('production')) {
|
if ($this->app->environment('production')) {
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Providers\Filament;
|
namespace App\Providers\Filament;
|
||||||
|
|
||||||
|
use App\Filament\Pages\Auth\RequestPasswordReset;
|
||||||
|
use Filament\FontProviders\LocalFontProvider;
|
||||||
use Filament\Http\Middleware\Authenticate;
|
use Filament\Http\Middleware\Authenticate;
|
||||||
use Filament\Http\Middleware\AuthenticateSession;
|
use Filament\Http\Middleware\AuthenticateSession;
|
||||||
use Filament\Http\Middleware\DisableBladeIconComponents;
|
use Filament\Http\Middleware\DisableBladeIconComponents;
|
||||||
@@ -9,7 +13,7 @@ use Filament\Http\Middleware\DispatchServingFilamentEvent;
|
|||||||
use Filament\Pages\Dashboard;
|
use Filament\Pages\Dashboard;
|
||||||
use Filament\Panel;
|
use Filament\Panel;
|
||||||
use Filament\PanelProvider;
|
use Filament\PanelProvider;
|
||||||
use Filament\Support\Colors\Color;
|
use Filament\View\PanelsRenderHook;
|
||||||
use Filament\Widgets\AccountWidget;
|
use Filament\Widgets\AccountWidget;
|
||||||
use Filament\Widgets\FilamentInfoWidget;
|
use Filament\Widgets\FilamentInfoWidget;
|
||||||
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
|
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
|
||||||
@@ -21,6 +25,137 @@ use Illuminate\View\Middleware\ShareErrorsFromSession;
|
|||||||
|
|
||||||
class AdminPanelProvider extends PanelProvider
|
class AdminPanelProvider extends PanelProvider
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* Heritage Editorial — Oliva Herança primary ramp.
|
||||||
|
*
|
||||||
|
* Filament needs an explicit 11-stop shade array to preserve exact brand
|
||||||
|
* hexes (a bare string/`Color::hex()` runs the value through
|
||||||
|
* `Color::generatePalette()`, which discards its lightness and maps a
|
||||||
|
* fixed per-shade lightness/chroma table instead — see MAN-118 survey).
|
||||||
|
* Shade 600 pins `#556B2F` (Oliva Herança) and shade 700 pins `#3E5219`
|
||||||
|
* (Oliva Profundo). `Filament\Support\View\Components\ColorMaps\
|
||||||
|
* ButtonComponentColorMap` was used to verify empirically (not assumed)
|
||||||
|
* that a solid primary button resolves to `bg: 600, hover:bg: 500, text:
|
||||||
|
* 50` at 5.42:1 / 4.57:1 contrast (WCAG AA). Shade 50 is a pale olive
|
||||||
|
* tint rather than pure white deliberately: `BadgeComponent`/
|
||||||
|
* `badge.css` apply `bg-color-50` directly as a badge's background
|
||||||
|
* (independent of the button map above), and a pure-white 50 would
|
||||||
|
* render a "primary" badge as an all-but-invisible pill against the
|
||||||
|
* Papel Marfim (`#FBF9F4`) panel body.
|
||||||
|
*
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
private function primaryColor(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
50 => '#F3F5EC',
|
||||||
|
100 => '#E7EBDA',
|
||||||
|
200 => '#D4DCBE',
|
||||||
|
300 => '#8B9D77', // Sálvia Silenciosa
|
||||||
|
400 => '#6E8354',
|
||||||
|
500 => '#61763A',
|
||||||
|
600 => '#556B2F', // Oliva Herança
|
||||||
|
700 => '#3E5219', // Oliva Profundo
|
||||||
|
800 => '#2E3D13',
|
||||||
|
900 => '#1F290D',
|
||||||
|
950 => '#141B08',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Heritage Editorial — paper/ink neutral ramp, replacing Filament's
|
||||||
|
* stock Zinc gray so panel chrome (sidebar, topbar, body background,
|
||||||
|
* borders) matches the public site's paper tones instead of true gray.
|
||||||
|
* Anchored at the exact tokens where DESIGN.md defines them (50/100/200
|
||||||
|
* = Papel Marfim/Profundo/Arquivo, 300 = Linha Botânica, 500 = Tinta
|
||||||
|
* Suave, 900 = Tinta Oliva); the remaining stops are interpolated to
|
||||||
|
* keep the ramp monotonic.
|
||||||
|
*
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
private function grayColor(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
50 => '#FBF9F4', // Papel Marfim
|
||||||
|
100 => '#F0EEE9', // Papel Profundo
|
||||||
|
200 => '#E4E2DD', // Papel Arquivo
|
||||||
|
300 => '#C5C8B8', // Linha Botânica
|
||||||
|
400 => '#919486',
|
||||||
|
500 => '#5D6155', // Tinta Suave
|
||||||
|
600 => '#43453D',
|
||||||
|
700 => '#32342E',
|
||||||
|
800 => '#252622',
|
||||||
|
900 => '#1B1C19', // Tinta Oliva
|
||||||
|
950 => '#121210',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Semantic ramps built the same way as the primary/gray ramps above:
|
||||||
|
* an explicit 11-stop array (never `Color::hex()`) anchored so the
|
||||||
|
* existing `--amare-color-{success,warning,error}` hex lands exactly on
|
||||||
|
* shade 600 — verified against `ButtonComponentColorMap` to resolve to
|
||||||
|
* `bg: 600` with white text at WCAG AA contrast, same as primary.
|
||||||
|
*
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
private function dangerColor(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
50 => '#F7EDED',
|
||||||
|
100 => '#EBD1D1',
|
||||||
|
200 => '#D8A8A8',
|
||||||
|
300 => '#C88484',
|
||||||
|
400 => '#B85F5F',
|
||||||
|
500 => '#A73B3B',
|
||||||
|
600 => '#991B1B',
|
||||||
|
700 => '#7D1616',
|
||||||
|
800 => '#651212',
|
||||||
|
900 => '#4C0E0E',
|
||||||
|
950 => '#3A0A0A',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
private function warningColor(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
50 => '#F6F0EC',
|
||||||
|
100 => '#E9D9CF',
|
||||||
|
200 => '#D6B6A3',
|
||||||
|
300 => '#C4987D',
|
||||||
|
400 => '#B37956',
|
||||||
|
500 => '#A15B30',
|
||||||
|
600 => '#92400E',
|
||||||
|
700 => '#78340B',
|
||||||
|
800 => '#602A09',
|
||||||
|
900 => '#492007',
|
||||||
|
950 => '#371805',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
private function successColor(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
50 => '#ECF3EF',
|
||||||
|
100 => '#D0E0D6',
|
||||||
|
200 => '#A6C4B2',
|
||||||
|
300 => '#81AC91',
|
||||||
|
400 => '#5C9371',
|
||||||
|
500 => '#377B50',
|
||||||
|
600 => '#166534',
|
||||||
|
700 => '#12532B',
|
||||||
|
800 => '#0F4322',
|
||||||
|
900 => '#0B321A',
|
||||||
|
950 => '#082614',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
public function panel(Panel $panel): Panel
|
public function panel(Panel $panel): Panel
|
||||||
{
|
{
|
||||||
return $panel
|
return $panel
|
||||||
@@ -28,9 +163,47 @@ class AdminPanelProvider extends PanelProvider
|
|||||||
->id('admin')
|
->id('admin')
|
||||||
->path('admin')
|
->path('admin')
|
||||||
->login()
|
->login()
|
||||||
|
->passwordReset(requestAction: RequestPasswordReset::class)
|
||||||
|
->brandName('Amare Assessoria')
|
||||||
|
->brandLogo(fn (): string => asset('brand/lockup-on-light.webp'))
|
||||||
|
->brandLogoHeight('2rem')
|
||||||
->colors([
|
->colors([
|
||||||
'primary' => 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')
|
->discoverResources(in: app_path('Filament/Resources'), for: 'App\Filament\Resources')
|
||||||
->discoverPages(in: app_path('Filament/Pages'), for: 'App\Filament\Pages')
|
->discoverPages(in: app_path('Filament/Pages'), for: 'App\Filament\Pages')
|
||||||
->pages([
|
->pages([
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace App\Support;
|
namespace App\Support;
|
||||||
|
|
||||||
use Illuminate\Contracts\Filesystem\Filesystem;
|
use Illuminate\Contracts\Filesystem\Filesystem;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Intervention\Image\Drivers\Gd\Driver;
|
use Intervention\Image\Drivers\Gd\Driver;
|
||||||
use Intervention\Image\ImageManager;
|
use Intervention\Image\ImageManager;
|
||||||
@@ -12,75 +13,130 @@ use Throwable;
|
|||||||
|
|
||||||
final class ResponsiveImage
|
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
|
public static function generate(string $path, ?string $disk = null): void
|
||||||
{
|
{
|
||||||
|
self::forgetMetadata($path, $disk);
|
||||||
$filesystem = self::filesystem($disk);
|
$filesystem = self::filesystem($disk);
|
||||||
|
|
||||||
if (! $filesystem->exists($path)) {
|
try {
|
||||||
return;
|
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$encoded = match ($extension) {
|
$manager = new ImageManager(new Driver);
|
||||||
'png' => $variant->toPng(),
|
$contents = $filesystem->get($path);
|
||||||
'webp' => $variant->toWebp(quality: 82),
|
|
||||||
default => $variant->toJpeg(quality: 82),
|
|
||||||
};
|
|
||||||
|
|
||||||
$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
|
public static function deleteVariants(string $path, ?string $disk = null): void
|
||||||
{
|
{
|
||||||
|
self::forgetMetadata($path, $disk);
|
||||||
$filesystem = self::filesystem($disk);
|
$filesystem = self::filesystem($disk);
|
||||||
|
|
||||||
foreach (self::WIDTHS as $width) {
|
try {
|
||||||
$variantPath = self::variantPath($path, $width);
|
foreach (self::WIDTHS as $width) {
|
||||||
|
foreach ([self::variantPath($path, $width), self::webpVariantPath($path, $width)] as $variantPath) {
|
||||||
if ($filesystem->exists($variantPath)) {
|
if ($filesystem->exists($variantPath)) {
|
||||||
$filesystem->delete($variantPath);
|
$filesystem->delete($variantPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
self::forgetMetadata($path, $disk);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function delete(string $path, ?string $disk = null): void
|
public static function delete(string $path, ?string $disk = null): void
|
||||||
{
|
{
|
||||||
|
self::forgetMetadata($path, $disk);
|
||||||
$filesystem = self::filesystem($disk);
|
$filesystem = self::filesystem($disk);
|
||||||
|
|
||||||
self::deleteVariants($path, $disk);
|
try {
|
||||||
|
self::deleteVariants($path, $disk);
|
||||||
|
|
||||||
if ($filesystem->exists($path)) {
|
if ($filesystem->exists($path)) {
|
||||||
$filesystem->delete($path);
|
$filesystem->delete($path);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
self::forgetMetadata($path, $disk);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function replace(string $previousPath, string $newPath, ?string $disk = null): void
|
public static function replace(string $previousPath, string $newPath, ?string $disk = null): void
|
||||||
{
|
{
|
||||||
if ($previousPath !== '' && $previousPath !== $newPath) {
|
self::forgetMetadata($previousPath, $disk);
|
||||||
self::delete($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
|
public static function variantPath(string $path, int $width): string
|
||||||
@@ -93,26 +149,35 @@ final class ResponsiveImage
|
|||||||
return $directory === '' ? $variantName : $directory.'/'.$variantName;
|
return $directory === '' ? $variantName : $directory.'/'.$variantName;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The webp sibling of a variant, named by appending rather than replacing the
|
||||||
|
* extension. Uploads are stored under a UUID so a collision is already
|
||||||
|
* unlikely, but `photo-480.jpg.webp` cannot collide with the webp variant of
|
||||||
|
* a `photo.png` the way `photo-480.webp` would.
|
||||||
|
*/
|
||||||
|
public static function webpVariantPath(string $path, int $width): string
|
||||||
|
{
|
||||||
|
return self::variantPath($path, $width).'.webp';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return list<array{path: string, width: int}>
|
* @return list<array{path: string, width: int}>
|
||||||
*/
|
*/
|
||||||
public static function availableVariants(string $path, ?string $disk = null): array
|
public static function availableVariants(string $path, ?string $disk = null): array
|
||||||
{
|
{
|
||||||
$filesystem = self::filesystem($disk);
|
return self::metadata($path, $disk)['variants'] ?? [];
|
||||||
$variants = [];
|
}
|
||||||
|
|
||||||
foreach (self::WIDTHS as $width) {
|
/**
|
||||||
$variantPath = self::variantPath($path, $width);
|
* The webp variants that exist for a path. Empty when the media predates
|
||||||
|
* `media:generate-variants` running with webp support, which is why
|
||||||
if ($filesystem->exists($variantPath)) {
|
* `x-media.image` treats the <source> as optional rather than assuming it.
|
||||||
$variants[] = [
|
*
|
||||||
'path' => $variantPath,
|
* @return list<array{path: string, width: int}>
|
||||||
'width' => $width,
|
*/
|
||||||
];
|
public static function availableWebpVariants(string $path, ?string $disk = null): array
|
||||||
}
|
{
|
||||||
}
|
return self::metadata($path, $disk)['webp_variants'] ?? [];
|
||||||
|
|
||||||
return $variants;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -120,13 +185,38 @@ final class ResponsiveImage
|
|||||||
*/
|
*/
|
||||||
public static function dimensions(string $path, ?string $disk = null): ?array
|
public static function dimensions(string $path, ?string $disk = null): ?array
|
||||||
{
|
{
|
||||||
$filesystem = self::filesystem($disk);
|
$metadata = self::metadata($path, $disk);
|
||||||
|
|
||||||
if (! $filesystem->exists($path)) {
|
return $metadata === null ? null : [
|
||||||
return 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 {
|
try {
|
||||||
|
$filesystem = self::filesystem($disk);
|
||||||
|
|
||||||
|
if (! $filesystem->exists($path)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
$contents = $filesystem->get($path);
|
$contents = $filesystem->get($path);
|
||||||
|
|
||||||
if ($contents === null) {
|
if ($contents === null) {
|
||||||
@@ -134,18 +224,88 @@ final class ResponsiveImage
|
|||||||
}
|
}
|
||||||
|
|
||||||
$image = (new ImageManager(new Driver))->read($contents);
|
$image = (new ImageManager(new Driver))->read($contents);
|
||||||
|
$metadata = [
|
||||||
return [
|
|
||||||
'width' => $image->width(),
|
'width' => $image->width(),
|
||||||
'height' => $image->height(),
|
'height' => $image->height(),
|
||||||
|
'variants' => self::existingVariants($filesystem, $path, false),
|
||||||
|
'webp_variants' => self::existingVariants($filesystem, $path, true),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
self::storeMetadata($key, $metadata);
|
||||||
|
|
||||||
|
return $metadata;
|
||||||
} catch (Throwable) {
|
} catch (Throwable) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function forgetMetadata(string $path, ?string $disk = null): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
Cache::forget(self::metadataCacheKey($path, $disk));
|
||||||
|
} catch (Throwable) {
|
||||||
|
// Cache availability must not block public media rendering or cleanup.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static function filesystem(?string $disk): Filesystem
|
private static function filesystem(?string $disk): Filesystem
|
||||||
{
|
{
|
||||||
return Storage::disk($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
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Middleware\CaptureMarketingOrigin;
|
||||||
use Illuminate\Foundation\Application;
|
use Illuminate\Foundation\Application;
|
||||||
use Illuminate\Foundation\Configuration\Exceptions;
|
use Illuminate\Foundation\Configuration\Exceptions;
|
||||||
use Illuminate\Foundation\Configuration\Middleware;
|
use Illuminate\Foundation\Configuration\Middleware;
|
||||||
@@ -12,7 +13,15 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
health: '/up',
|
health: '/up',
|
||||||
)
|
)
|
||||||
->withMiddleware(function (Middleware $middleware): void {
|
->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 {
|
->withExceptions(function (Exceptions $exceptions): void {
|
||||||
$exceptions->shouldRenderJsonWhen(
|
$exceptions->shouldRenderJsonWhen(
|
||||||
|
|||||||
@@ -65,6 +65,9 @@
|
|||||||
"test:browser": [
|
"test:browser": [
|
||||||
"@php artisan test --testsuite=Browser"
|
"@php artisan test --testsuite=Browser"
|
||||||
],
|
],
|
||||||
|
"test:coverage": [
|
||||||
|
"vendor/bin/pest -c phpunit.coverage.xml --testsuite=Unit,Architecture,Feature --coverage --min=80"
|
||||||
|
],
|
||||||
"pint": [
|
"pint": [
|
||||||
"vendor/bin/pint"
|
"vendor/bin/pint"
|
||||||
],
|
],
|
||||||
@@ -81,11 +84,9 @@
|
|||||||
"@pint:check",
|
"@pint:check",
|
||||||
"@phpstan",
|
"@phpstan",
|
||||||
"composer audit --no-interaction",
|
"composer audit --no-interaction",
|
||||||
|
"npm audit --omit=dev --audit-level=high",
|
||||||
"@test"
|
"@test"
|
||||||
],
|
],
|
||||||
"visual:update": [
|
|
||||||
"@php artisan test --testsuite=Browser --update-snapshots"
|
|
||||||
],
|
|
||||||
"post-autoload-dump": [
|
"post-autoload-dump": [
|
||||||
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
|
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
|
||||||
"@php artisan package:discover --ansi",
|
"@php artisan package:discover --ansi",
|
||||||
|
|||||||
12
composer.lock
generated
@@ -2802,16 +2802,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "league/commonmark",
|
"name": "league/commonmark",
|
||||||
"version": "2.8.3",
|
"version": "2.9.0",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/thephpleague/commonmark.git",
|
"url": "https://github.com/thephpleague/commonmark.git",
|
||||||
"reference": "1902f60f984235023acbe03db6ad614a37b3c3e7"
|
"reference": "5703d83ba3da3b2e356a5fedc848ed6d8ffb6529"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/thephpleague/commonmark/zipball/1902f60f984235023acbe03db6ad614a37b3c3e7",
|
"url": "https://api.github.com/repos/thephpleague/commonmark/zipball/5703d83ba3da3b2e356a5fedc848ed6d8ffb6529",
|
||||||
"reference": "1902f60f984235023acbe03db6ad614a37b3c3e7",
|
"reference": "5703d83ba3da3b2e356a5fedc848ed6d8ffb6529",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -2848,7 +2848,7 @@
|
|||||||
"type": "library",
|
"type": "library",
|
||||||
"extra": {
|
"extra": {
|
||||||
"branch-alias": {
|
"branch-alias": {
|
||||||
"dev-main": "2.9-dev"
|
"dev-main": "2.10-dev"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"autoload": {
|
"autoload": {
|
||||||
@@ -2905,7 +2905,7 @@
|
|||||||
"type": "tidelift"
|
"type": "tidelift"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2026-07-12T15:29:16+00:00"
|
"time": "2026-08-03T13:42:31+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "league/config",
|
"name": "league/config",
|
||||||
|
|||||||
@@ -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
|
| When set outside production, the application clock is frozen for
|
||||||
| deterministic rendering (visual regression / seeded content).
|
| deterministic time-dependent tests or seeded content.
|
||||||
|
|
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ class PortfolioCaseFactory extends Factory
|
|||||||
'slug' => str($title)->slug()->toString(),
|
'slug' => str($title)->slug()->toString(),
|
||||||
'summary' => fake()->sentence(),
|
'summary' => fake()->sentence(),
|
||||||
'event_type' => 'Casamento',
|
'event_type' => 'Casamento',
|
||||||
'city' => 'Fortaleza',
|
'city' => 'São Paulo',
|
||||||
'venue' => fake()->company(),
|
'venue' => fake()->company(),
|
||||||
'event_date' => fake()->date(),
|
'event_date' => fake()->date(),
|
||||||
'challenge' => fake()->paragraph(),
|
'challenge' => fake()->paragraph(),
|
||||||
|
|||||||
BIN
database/fixtures/images/about-image.jpg
Normal file
|
After Width: | Height: | Size: 151 KiB |
BIN
database/fixtures/images/case-casamento-ana-lucas.jpg
Normal file
|
After Width: | Height: | Size: 436 KiB |
BIN
database/fixtures/images/case-lancamento-verano.jpg
Normal file
|
After Width: | Height: | Size: 254 KiB |
BIN
database/fixtures/images/case-mini-wedding-marina.jpg
Normal file
|
After Width: | Height: | Size: 258 KiB |
BIN
database/fixtures/images/gallery-casamento-ana-lucas-1.jpg
Normal file
|
After Width: | Height: | Size: 622 KiB |
BIN
database/fixtures/images/gallery-casamento-ana-lucas-2.jpg
Normal file
|
After Width: | Height: | Size: 499 KiB |
BIN
database/fixtures/images/gallery-lancamento-verano-1.jpg
Normal file
|
After Width: | Height: | Size: 244 KiB |
BIN
database/fixtures/images/gallery-lancamento-verano-2.jpg
Normal file
|
After Width: | Height: | Size: 235 KiB |
BIN
database/fixtures/images/gallery-mini-wedding-marina-1.jpg
Normal file
|
After Width: | Height: | Size: 239 KiB |
BIN
database/fixtures/images/gallery-mini-wedding-marina-2.jpg
Normal file
|
After Width: | Height: | Size: 236 KiB |
BIN
database/fixtures/images/og-default.jpg
Normal file
|
After Width: | Height: | Size: 360 KiB |
BIN
database/fixtures/images/service-casamentos.jpg
Normal file
|
After Width: | Height: | Size: 330 KiB |
BIN
database/fixtures/images/service-celebracoes-intimistas.jpg
Normal file
|
After Width: | Height: | Size: 302 KiB |
BIN
database/fixtures/images/service-eventos-corporativos.jpg
Normal file
|
After Width: | Height: | Size: 337 KiB |
@@ -0,0 +1,44 @@
|
|||||||
|
<?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('logo_path')->nullable()->after('brand_name');
|
||||||
|
$table->string('logo_alt')->nullable()->after('logo_path');
|
||||||
|
$table->string('hero_secondary_cta_label')->nullable()->after('hero_cta_label');
|
||||||
|
$table->text('hero_note')->nullable()->after('hero_secondary_cta_label');
|
||||||
|
$table->string('manifesto_title')->nullable()->after('about_summary');
|
||||||
|
$table->text('manifesto_lead')->nullable()->after('manifesto_title');
|
||||||
|
$table->text('manifesto_body')->nullable()->after('manifesto_lead');
|
||||||
|
$table->text('method_intro')->nullable()->after('manifesto_body');
|
||||||
|
$table->jsonb('method_steps')->nullable()->after('method_intro');
|
||||||
|
$table->jsonb('principles')->nullable()->after('method_steps');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('site_settings', function (Blueprint $table): void {
|
||||||
|
$table->dropColumn([
|
||||||
|
'logo_path',
|
||||||
|
'logo_alt',
|
||||||
|
'hero_secondary_cta_label',
|
||||||
|
'hero_note',
|
||||||
|
'manifesto_title',
|
||||||
|
'manifesto_lead',
|
||||||
|
'manifesto_body',
|
||||||
|
'method_intro',
|
||||||
|
'method_steps',
|
||||||
|
'principles',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('site_settings', function (Blueprint $table): void {
|
||||||
|
$table->string('about_image_path')->nullable()->after('about_summary');
|
||||||
|
$table->string('about_image_alt')->nullable()->after('about_image_path');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('site_settings', function (Blueprint $table): void {
|
||||||
|
$table->dropColumn(['about_image_path', 'about_image_alt']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -8,9 +8,10 @@ use App\Models\PortfolioCase;
|
|||||||
use App\Models\PortfolioImage;
|
use App\Models\PortfolioImage;
|
||||||
use App\Models\Service;
|
use App\Models\Service;
|
||||||
use App\Models\SiteSetting;
|
use App\Models\SiteSetting;
|
||||||
use App\Models\Testimonial;
|
use App\Support\PublicImageUploadRules;
|
||||||
use Illuminate\Database\Seeder;
|
use Illuminate\Database\Seeder;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Facades\App;
|
||||||
use Illuminate\Support\Facades\File;
|
use Illuminate\Support\Facades\File;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
@@ -18,31 +19,71 @@ class ContentSeeder extends Seeder
|
|||||||
{
|
{
|
||||||
private const SEED_TIMESTAMP = '2026-01-15 10:00:00';
|
private const SEED_TIMESTAMP = '2026-01-15 10:00:00';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Demo/fixture content is meant for local dev, automated testing, and
|
||||||
|
* staging visual review only. It must never overwrite owner-edited
|
||||||
|
* SiteSetting, Service, and PortfolioCase records, never re-upload
|
||||||
|
* fixture images to the production storage disk, and never auto-publish
|
||||||
|
* the fictional portfolio cases or (via TestimonialsSeeder) the real
|
||||||
|
* testimonials.
|
||||||
|
*
|
||||||
|
* This is deliberately an allow-list of the known-safe environments
|
||||||
|
* ('local', 'staging', 'testing') rather than a deny-list of
|
||||||
|
* 'production'. APP_ENV is a free-text value hand-typed into the
|
||||||
|
* Dokploy environment UI with no validation — a blank value, a typo, or
|
||||||
|
* an unexpected casing (e.g. '', 'Production', 'staginng') must fail
|
||||||
|
* closed (skip seeding) rather than fail open (seed/overwrite
|
||||||
|
* production data). Only the three recognized values run this seeder;
|
||||||
|
* everything else, including 'production' itself, is a no-op.
|
||||||
|
*
|
||||||
|
* Publishing testimonials in production remains a deliberate, manually
|
||||||
|
* triggered step — see docs/deployment/dokploy.md.
|
||||||
|
*/
|
||||||
public function run(): void
|
public function run(): void
|
||||||
{
|
{
|
||||||
|
if (! App::environment(['local', 'staging', 'testing'])) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$this->seedSiteSettings();
|
$this->seedSiteSettings();
|
||||||
$this->seedServices();
|
$this->seedServices();
|
||||||
$this->seedPortfolioCases();
|
$this->seedPortfolioCases();
|
||||||
$this->seedTestimonials();
|
$this->call(TestimonialsSeeder::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function seedSiteSettings(): void
|
private function seedSiteSettings(): void
|
||||||
{
|
{
|
||||||
SiteSetting::query()->updateOrCreate([], [
|
SiteSetting::query()->updateOrCreate([], [
|
||||||
'brand_name' => 'Amare Assessoria',
|
'brand_name' => 'Amare Assessoria',
|
||||||
'hero_eyebrow' => 'Assessoria de eventos',
|
'hero_eyebrow' => 'Assessoria e produção de eventos · São Paulo',
|
||||||
'hero_title' => 'Celebrações com propósito',
|
'hero_title' => 'Celebrações com propósito',
|
||||||
'hero_subtitle' => 'Planejamento completo para casamentos e eventos corporativos em Fortaleza.',
|
'hero_subtitle' => 'Planejamento completo para casamentos e eventos corporativos em São Paulo.',
|
||||||
'hero_cta_label' => 'Solicitar orçamento',
|
'hero_cta_label' => 'Solicitar proposta',
|
||||||
'about_summary' => 'Assessoria boutique especializada em experiências memoráveis.',
|
'hero_secondary_cta_label' => 'Conheça nosso olhar',
|
||||||
'email' => 'contato@amare.local',
|
'hero_note' => 'Planejamento cuidadoso, comunicação clara e execução segura — do primeiro encontro ao último detalhe.',
|
||||||
'phone' => '(85) 99999-9999',
|
'hero_image_path' => $this->copyFixture('case-casamento-ana-lucas.jpg', 'content/heroes/home.jpg'),
|
||||||
'city' => 'Fortaleza, CE',
|
'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',
|
||||||
|
'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(),
|
||||||
|
'principles' => SiteSetting::defaultPrinciples(),
|
||||||
|
'email' => 'amareassessoriaeventos@gmail.com',
|
||||||
|
'phone' => '(11) 99999-9999',
|
||||||
|
'city' => 'São Paulo - SP',
|
||||||
'social_links' => [
|
'social_links' => [
|
||||||
'instagram' => 'https://instagram.com/amare',
|
'instagram' => 'https://instagram.com/amare',
|
||||||
],
|
],
|
||||||
'default_meta_title' => 'Amare Assessoria de Eventos',
|
'default_meta_title' => 'Amare Assessoria de Eventos',
|
||||||
'default_meta_description' => 'Assessoria premium para casamentos e eventos corporativos.',
|
'default_meta_description' => 'Assessoria premium para casamentos e eventos corporativos em São Paulo.',
|
||||||
'default_og_image_path' => $this->copyFixture('og-default.jpg', 'content/og/og-default.jpg'),
|
'default_og_image_path' => $this->copyFixture('og-default.jpg', 'content/og/og-default.jpg'),
|
||||||
'default_og_image_alt' => 'Identidade visual da Amare Assessoria de Eventos',
|
'default_og_image_alt' => 'Identidade visual da Amare Assessoria de Eventos',
|
||||||
'analytics_enabled' => false,
|
'analytics_enabled' => false,
|
||||||
@@ -84,7 +125,7 @@ class ContentSeeder extends Seeder
|
|||||||
['slug' => $service['slug']],
|
['slug' => $service['slug']],
|
||||||
[
|
[
|
||||||
...$service,
|
...$service,
|
||||||
'cover_image_path' => $this->copyFixture('service-cover.jpg', 'content/services/'.$service['slug'].'.jpg'),
|
'cover_image_path' => $this->copyFixture('service-'.$service['slug'].'.jpg', 'content/services/'.$service['slug'].'.jpg'),
|
||||||
'cover_image_alt' => 'Capa do serviço '.$service['title'],
|
'cover_image_alt' => 'Capa do serviço '.$service['title'],
|
||||||
'published_at' => Carbon::parse(self::SEED_TIMESTAMP),
|
'published_at' => Carbon::parse(self::SEED_TIMESTAMP),
|
||||||
],
|
],
|
||||||
@@ -98,9 +139,9 @@ class ContentSeeder extends Seeder
|
|||||||
[
|
[
|
||||||
'title' => 'Casamento Ana e Lucas',
|
'title' => 'Casamento Ana e Lucas',
|
||||||
'slug' => 'casamento-ana-lucas',
|
'slug' => 'casamento-ana-lucas',
|
||||||
'summary' => 'Cerimônia ao ar livre em Fortaleza.',
|
'summary' => 'Cerimônia ao ar livre em São Paulo.',
|
||||||
'event_type' => 'Casamento',
|
'event_type' => 'Casamento',
|
||||||
'city' => 'Fortaleza',
|
'city' => 'São Paulo',
|
||||||
'venue' => 'Espaço Jardim Atlântico',
|
'venue' => 'Espaço Jardim Atlântico',
|
||||||
'event_date' => '2025-11-20',
|
'event_date' => '2025-11-20',
|
||||||
'challenge' => 'Integrar cerimônia e recepção em áreas distintas.',
|
'challenge' => 'Integrar cerimônia e recepção em áreas distintas.',
|
||||||
@@ -113,7 +154,7 @@ class ContentSeeder extends Seeder
|
|||||||
'slug' => 'lancamento-verano',
|
'slug' => 'lancamento-verano',
|
||||||
'summary' => 'Evento corporativo de lançamento de coleção.',
|
'summary' => 'Evento corporativo de lançamento de coleção.',
|
||||||
'event_type' => 'Corporativo',
|
'event_type' => 'Corporativo',
|
||||||
'city' => 'Fortaleza',
|
'city' => 'São Paulo',
|
||||||
'venue' => 'Centro de Convenções',
|
'venue' => 'Centro de Convenções',
|
||||||
'event_date' => '2025-09-10',
|
'event_date' => '2025-09-10',
|
||||||
'challenge' => 'Ativar marca em ambiente multiestação.',
|
'challenge' => 'Ativar marca em ambiente multiestação.',
|
||||||
@@ -124,10 +165,10 @@ class ContentSeeder extends Seeder
|
|||||||
[
|
[
|
||||||
'title' => 'Mini wedding Marina',
|
'title' => 'Mini wedding Marina',
|
||||||
'slug' => 'mini-wedding-marina',
|
'slug' => 'mini-wedding-marina',
|
||||||
'summary' => 'Celebração intimista à beira-mar.',
|
'summary' => 'Celebração intimista em São Paulo.',
|
||||||
'event_type' => 'Mini wedding',
|
'event_type' => 'Mini wedding',
|
||||||
'city' => 'Caucaia',
|
'city' => 'São Paulo',
|
||||||
'venue' => 'Pousada da Praia',
|
'venue' => 'Espaço intimista',
|
||||||
'event_date' => '2025-06-02',
|
'event_date' => '2025-06-02',
|
||||||
'challenge' => 'Clima e logística em área externa.',
|
'challenge' => 'Clima e logística em área externa.',
|
||||||
'solution' => 'Plano B estruturado e fornecedores locais alinhados.',
|
'solution' => 'Plano B estruturado e fornecedores locais alinhados.',
|
||||||
@@ -141,7 +182,7 @@ class ContentSeeder extends Seeder
|
|||||||
['slug' => $caseData['slug']],
|
['slug' => $caseData['slug']],
|
||||||
[
|
[
|
||||||
...$caseData,
|
...$caseData,
|
||||||
'cover_image_path' => $this->copyFixture('portfolio-cover.jpg', 'content/portfolio/'.$caseData['slug'].'-cover.jpg'),
|
'cover_image_path' => $this->copyFixture('case-'.$caseData['slug'].'.jpg', 'content/portfolio/'.$caseData['slug'].'-cover.jpg'),
|
||||||
'cover_image_alt' => 'Capa do caso '.$caseData['title'],
|
'cover_image_alt' => 'Capa do caso '.$caseData['title'],
|
||||||
'is_featured' => true,
|
'is_featured' => true,
|
||||||
'published_at' => Carbon::parse(self::SEED_TIMESTAMP),
|
'published_at' => Carbon::parse(self::SEED_TIMESTAMP),
|
||||||
@@ -153,7 +194,7 @@ class ContentSeeder extends Seeder
|
|||||||
foreach ([1, 2] as $index) {
|
foreach ([1, 2] as $index) {
|
||||||
PortfolioImage::query()->create([
|
PortfolioImage::query()->create([
|
||||||
'portfolio_case_id' => $case->id,
|
'portfolio_case_id' => $case->id,
|
||||||
'path' => $this->copyFixture('gallery.jpg', 'content/portfolio/'.$caseData['slug'].'-gallery-'.$index.'.jpg'),
|
'path' => $this->copyFixture('gallery-'.$caseData['slug'].'-'.$index.'.jpg', 'content/portfolio/'.$caseData['slug'].'-gallery-'.$index.'.jpg'),
|
||||||
'alt_text' => 'Galeria '.$caseData['title'].' '.$index,
|
'alt_text' => 'Galeria '.$caseData['title'].' '.$index,
|
||||||
'caption' => $index === 1 ? 'Detalhe da decoração' : null,
|
'caption' => $index === 1 ? 'Detalhe da decoração' : null,
|
||||||
'sort_order' => $index,
|
'sort_order' => $index,
|
||||||
@@ -162,52 +203,12 @@ class ContentSeeder extends Seeder
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function seedTestimonials(): void
|
|
||||||
{
|
|
||||||
$testimonials = [
|
|
||||||
[
|
|
||||||
'quote' => 'A Amare transformou nosso casamento em uma experiência inesquecível.',
|
|
||||||
'author_name' => 'Ana Souza',
|
|
||||||
'context' => 'Noiva',
|
|
||||||
'sort_order' => 1,
|
|
||||||
'is_featured' => true,
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'quote' => 'Profissionalismo do início ao fim no lançamento da nossa coleção.',
|
|
||||||
'author_name' => 'Marcos Lima',
|
|
||||||
'context' => 'Diretor de marketing',
|
|
||||||
'sort_order' => 2,
|
|
||||||
'is_featured' => true,
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'quote' => 'Cuidaram de cada detalhe com sensibilidade e precisão.',
|
|
||||||
'author_name' => 'Marina Costa',
|
|
||||||
'context' => 'Anfitriã',
|
|
||||||
'sort_order' => 3,
|
|
||||||
'is_featured' => false,
|
|
||||||
],
|
|
||||||
];
|
|
||||||
|
|
||||||
foreach ($testimonials as $testimonial) {
|
|
||||||
Testimonial::query()->updateOrCreate(
|
|
||||||
[
|
|
||||||
'author_name' => $testimonial['author_name'],
|
|
||||||
'quote' => $testimonial['quote'],
|
|
||||||
],
|
|
||||||
[
|
|
||||||
...$testimonial,
|
|
||||||
'photo_path' => $this->copyFixture('testimonial.jpg', 'content/testimonials/'.str($testimonial['author_name'])->slug().'.jpg'),
|
|
||||||
'photo_alt' => 'Foto de '.$testimonial['author_name'],
|
|
||||||
'published_at' => Carbon::parse(self::SEED_TIMESTAMP),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function copyFixture(string $fixtureName, string $destination): string
|
private function copyFixture(string $fixtureName, string $destination): string
|
||||||
{
|
{
|
||||||
$source = base_path('tests/fixtures/images/'.$fixtureName);
|
$source = base_path('database/fixtures/images/'.$fixtureName);
|
||||||
Storage::disk('public')->put($destination, File::get($source));
|
$disk = PublicImageUploadRules::disk();
|
||||||
|
|
||||||
|
Storage::disk($disk)->put($destination, File::get($source), 'public');
|
||||||
|
|
||||||
return $destination;
|
return $destination;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ class DatabaseSeeder extends Seeder
|
|||||||
'password' => Hash::make('password'),
|
'password' => Hash::make('password'),
|
||||||
'role' => UserRole::Admin,
|
'role' => UserRole::Admin,
|
||||||
'is_active' => true,
|
'is_active' => true,
|
||||||
|
'email_verified_at' => now(),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -33,6 +34,7 @@ class DatabaseSeeder extends Seeder
|
|||||||
'password' => Hash::make('password'),
|
'password' => Hash::make('password'),
|
||||||
'role' => UserRole::Assistant,
|
'role' => UserRole::Assistant,
|
||||||
'is_active' => true,
|
'is_active' => true,
|
||||||
|
'email_verified_at' => now(),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
70
database/seeders/TestimonialsSeeder.php
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use App\Models\Testimonial;
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class TestimonialsSeeder extends Seeder
|
||||||
|
{
|
||||||
|
private const PUBLISHED_AT = '2026-08-05 00:00:00';
|
||||||
|
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
$testimonials = [
|
||||||
|
[
|
||||||
|
'quote' => "Mi, quero agradecer você e a sua equipe por todo empenho, atenção, vocês são abençoadas.\n\nEra nítida sua preocupação em garantir que todos os detalhes planejados desta comemoração, fossem atendidos.\n\nQue você possa transformar o grande dia das noivinhas sempre com essa sua leveza!!!\n\nMuito obrigada!",
|
||||||
|
'author_name' => 'Jeniffer e Maick',
|
||||||
|
'context' => 'Casamento · 06/12/2025',
|
||||||
|
'sort_order' => 1,
|
||||||
|
'is_featured' => true,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'quote' => "Mi, eu não tenho palavras pra agradecer você e tudo que você fez por mim e por nós na realização desse sonho. Eu tô ainda extasiada com tudo que aconteceu hoje; mas tenho certeza que sem a sua ajuda, muita coisa não aconteceria.\n\nObrigada por tudo !",
|
||||||
|
'author_name' => 'Quesia e Jhonata',
|
||||||
|
'context' => 'Casamento · 21/12/2025',
|
||||||
|
'sort_order' => 2,
|
||||||
|
'is_featured' => true,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'quote' => 'Que equipe!! Que equipe maravilhosa!! Obrigado pelo empenho de fazer tudo como eu queria!! Obrigado por se esforçar tanto e vir de tão longe pra realizar meu sonho!! Incríveis!!',
|
||||||
|
'author_name' => 'Milena e Weslley',
|
||||||
|
'context' => 'Casamento · 13/02/2026',
|
||||||
|
'sort_order' => 3,
|
||||||
|
'is_featured' => false,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'quote' => "Gostaríamos de agradecer por todo o acompanhamento e dedicação durante a realização do nosso casamento. Foi um dia muito especial e inesquecível para nós.\n\nDesde o início, conseguimos conduzir tudo aquilo que estávamos planejando, dentro dos horários que estipulamos, o que foi ótimo, e no grande dia sua equipe nos recebeu e tratou com muito carinho, atenção e cuidado, o que fez toda a diferença para vivermos esse momento com mais tranquilidade.\n\nTambém adoramos as sugestões e ideias para as fotos, que deixaram os registros ainda mais bonitos e espontâneos, porque não iríamos lembrar de quais poses fazer na hora.\n\nObrigada por fazer parte de um momento tão importante das nossas vidas. Desejamos muito sucesso e que muitos outros casais possam viver dias especiais através do trabalho da AMARE.",
|
||||||
|
'author_name' => 'Raquel e Pedro',
|
||||||
|
'context' => 'Casamento · 09/05/2026',
|
||||||
|
'sort_order' => 4,
|
||||||
|
'is_featured' => false,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'quote' => "Miiii, meu amor… você e sua equipe foram impecáveis.\n\nSuperou todas as nossas expectativas. Somos eternamente gratos por fazer nosso dia acontecer muito melhor do que imaginávamos.\n\nSempre muito atenciosa e paciente.\n\nAdoramos te conhecer e estamos muito felizes em termos escolhido você para assessorar nosso dia.",
|
||||||
|
'author_name' => 'Victoria e Pedro',
|
||||||
|
'context' => 'Casamento · 24/06/2026',
|
||||||
|
'sort_order' => 5,
|
||||||
|
'is_featured' => false,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
DB::transaction(function () use ($testimonials): void {
|
||||||
|
foreach ($testimonials as $testimonial) {
|
||||||
|
Testimonial::query()->updateOrCreate(
|
||||||
|
['author_name' => $testimonial['author_name']],
|
||||||
|
[
|
||||||
|
'quote' => $testimonial['quote'],
|
||||||
|
'context' => $testimonial['context'],
|
||||||
|
'sort_order' => $testimonial['sort_order'],
|
||||||
|
'is_featured' => $testimonial['is_featured'],
|
||||||
|
'published_at' => self::PUBLISHED_AT,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,11 +11,10 @@ use App\Models\SiteSetting;
|
|||||||
use App\Models\Testimonial;
|
use App\Models\Testimonial;
|
||||||
use Illuminate\Database\Seeder;
|
use Illuminate\Database\Seeder;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Facades\File;
|
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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
|
class VisualContentSeeder extends Seeder
|
||||||
{
|
{
|
||||||
@@ -37,20 +36,36 @@ class VisualContentSeeder extends Seeder
|
|||||||
{
|
{
|
||||||
SiteSetting::query()->updateOrCreate([], [
|
SiteSetting::query()->updateOrCreate([], [
|
||||||
'brand_name' => 'Amare Assessoria',
|
'brand_name' => 'Amare Assessoria',
|
||||||
'hero_eyebrow' => 'Assessoria de eventos',
|
'hero_eyebrow' => 'Assessoria e produção de eventos · São Paulo',
|
||||||
'hero_title' => 'Celebrações com propósito',
|
'hero_title' => 'Celebrações com propósito',
|
||||||
'hero_subtitle' => 'Planejamento completo para casamentos e eventos corporativos em Fortaleza.',
|
'hero_subtitle' => 'Planejamento completo para casamentos e eventos corporativos em São Paulo.',
|
||||||
'hero_cta_label' => 'Solicitar orçamento',
|
'hero_cta_label' => 'Solicitar proposta',
|
||||||
'about_summary' => 'Assessoria boutique especializada em experiências memoráveis.',
|
'hero_secondary_cta_label' => 'Conheça nosso olhar',
|
||||||
'email' => 'contato@amare.local',
|
'hero_note' => 'Planejamento cuidadoso, comunicação clara e execução segura — do primeiro encontro ao último detalhe.',
|
||||||
'phone' => '(85) 99999-9999',
|
'hero_image_path' => $this->writeSolidJpeg('visual/heroes/home.jpg', 1600, 1100, [218, 224, 205]),
|
||||||
'city' => 'Fortaleza, CE',
|
'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',
|
||||||
|
'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(),
|
||||||
|
'principles' => SiteSetting::defaultPrinciples(),
|
||||||
|
'email' => 'amareassessoriaeventos@gmail.com',
|
||||||
|
'phone' => '(11) 99999-9999',
|
||||||
|
'city' => 'São Paulo - SP',
|
||||||
'social_links' => [
|
'social_links' => [
|
||||||
'instagram' => 'https://instagram.com/amare',
|
'instagram' => 'https://instagram.com/amare',
|
||||||
],
|
],
|
||||||
'default_meta_title' => 'Amare Assessoria de Eventos',
|
'default_meta_title' => 'Amare Assessoria de Eventos',
|
||||||
'default_meta_description' => 'Assessoria premium para casamentos e eventos corporativos.',
|
'default_meta_description' => 'Assessoria premium para casamentos e eventos corporativos em São Paulo.',
|
||||||
'default_og_image_path' => $this->copyFixture('og-default.jpg', 'visual/og/og-default.jpg'),
|
'default_og_image_path' => $this->writeSolidJpeg('visual/og/og-default.jpg', 1600, 1067, [85, 107, 47]),
|
||||||
'default_og_image_alt' => 'Identidade visual da Amare Assessoria de Eventos',
|
'default_og_image_alt' => 'Identidade visual da Amare Assessoria de Eventos',
|
||||||
'analytics_enabled' => false,
|
'analytics_enabled' => false,
|
||||||
'analytics_script' => null,
|
'analytics_script' => null,
|
||||||
@@ -81,7 +96,7 @@ class VisualContentSeeder extends Seeder
|
|||||||
['slug' => $service['slug']],
|
['slug' => $service['slug']],
|
||||||
[
|
[
|
||||||
...$service,
|
...$service,
|
||||||
'cover_image_path' => $this->copyFixture('service-cover.jpg', 'visual/services/'.$service['slug'].'.jpg'),
|
'cover_image_path' => $this->writeSolidJpeg('visual/services/'.$service['slug'].'.jpg', 1600, 1000, [196, 200, 184]),
|
||||||
'cover_image_alt' => 'Capa do serviço '.$service['title'],
|
'cover_image_alt' => 'Capa do serviço '.$service['title'],
|
||||||
'published_at' => Carbon::parse(self::SEED_TIMESTAMP),
|
'published_at' => Carbon::parse(self::SEED_TIMESTAMP),
|
||||||
],
|
],
|
||||||
@@ -95,9 +110,9 @@ class VisualContentSeeder extends Seeder
|
|||||||
[
|
[
|
||||||
'title' => 'Casamento Ana e Lucas',
|
'title' => 'Casamento Ana e Lucas',
|
||||||
'slug' => 'casamento-ana-lucas',
|
'slug' => 'casamento-ana-lucas',
|
||||||
'summary' => 'Cerimônia ao ar livre em Fortaleza.',
|
'summary' => 'Cerimônia ao ar livre em São Paulo.',
|
||||||
'event_type' => 'Casamento',
|
'event_type' => 'Casamento',
|
||||||
'city' => 'Fortaleza',
|
'city' => 'São Paulo',
|
||||||
'venue' => 'Espaço Jardim Atlântico',
|
'venue' => 'Espaço Jardim Atlântico',
|
||||||
'event_date' => '2025-11-20',
|
'event_date' => '2025-11-20',
|
||||||
'challenge' => 'Integrar cerimônia e recepção em áreas distintas.',
|
'challenge' => 'Integrar cerimônia e recepção em áreas distintas.',
|
||||||
@@ -110,7 +125,7 @@ class VisualContentSeeder extends Seeder
|
|||||||
'slug' => 'lancamento-verano',
|
'slug' => 'lancamento-verano',
|
||||||
'summary' => 'Evento corporativo de lançamento de coleção.',
|
'summary' => 'Evento corporativo de lançamento de coleção.',
|
||||||
'event_type' => 'Corporativo',
|
'event_type' => 'Corporativo',
|
||||||
'city' => 'Fortaleza',
|
'city' => 'São Paulo',
|
||||||
'venue' => 'Centro de Convenções',
|
'venue' => 'Centro de Convenções',
|
||||||
'event_date' => '2025-09-10',
|
'event_date' => '2025-09-10',
|
||||||
'challenge' => 'Ativar marca em ambiente multiestação.',
|
'challenge' => 'Ativar marca em ambiente multiestação.',
|
||||||
@@ -125,7 +140,7 @@ class VisualContentSeeder extends Seeder
|
|||||||
['slug' => $caseData['slug']],
|
['slug' => $caseData['slug']],
|
||||||
[
|
[
|
||||||
...$caseData,
|
...$caseData,
|
||||||
'cover_image_path' => $this->copyFixture('portfolio-cover.jpg', 'visual/portfolio/'.$caseData['slug'].'-cover.jpg'),
|
'cover_image_path' => $this->writeSolidJpeg('visual/portfolio/'.$caseData['slug'].'-cover.jpg', 1600, 1200, [240, 238, 233]),
|
||||||
'cover_image_alt' => 'Capa do caso '.$caseData['title'],
|
'cover_image_alt' => 'Capa do caso '.$caseData['title'],
|
||||||
'is_featured' => true,
|
'is_featured' => true,
|
||||||
'published_at' => Carbon::parse(self::SEED_TIMESTAMP),
|
'published_at' => Carbon::parse(self::SEED_TIMESTAMP),
|
||||||
@@ -136,7 +151,7 @@ class VisualContentSeeder extends Seeder
|
|||||||
|
|
||||||
PortfolioImage::query()->create([
|
PortfolioImage::query()->create([
|
||||||
'portfolio_case_id' => $case->id,
|
'portfolio_case_id' => $case->id,
|
||||||
'path' => $this->copyFixture('gallery.jpg', 'visual/portfolio/'.$caseData['slug'].'-gallery-1.jpg'),
|
'path' => $this->writeSolidJpeg('visual/portfolio/'.$caseData['slug'].'-gallery-1.jpg', 1600, 1200, [228, 226, 221]),
|
||||||
'alt_text' => 'Galeria '.$caseData['title'].' 1',
|
'alt_text' => 'Galeria '.$caseData['title'].' 1',
|
||||||
'caption' => 'Detalhe da decoração',
|
'caption' => 'Detalhe da decoração',
|
||||||
'sort_order' => 1,
|
'sort_order' => 1,
|
||||||
@@ -146,26 +161,53 @@ class VisualContentSeeder extends Seeder
|
|||||||
|
|
||||||
private function seedTestimonials(): void
|
private function seedTestimonials(): void
|
||||||
{
|
{
|
||||||
Testimonial::query()->updateOrCreate(
|
Testimonial::query()->whereNotIn('author_name', [
|
||||||
|
'Jeniffer e Maick',
|
||||||
|
'Quesia e Jhonata',
|
||||||
|
])->delete();
|
||||||
|
|
||||||
|
foreach ([
|
||||||
[
|
[
|
||||||
'author_name' => 'Ana Souza',
|
'quote' => "Mi, quero agradecer você e a sua equipe por todo empenho, atenção, vocês são abençoadas.\n\nEra nítida sua preocupação em garantir que todos os detalhes planejados desta comemoração, fossem atendidos.\n\nQue você possa transformar o grande dia das noivinhas sempre com essa sua leveza!!!\n\nMuito obrigada!",
|
||||||
'quote' => 'A Amare transformou nosso casamento em uma experiência inesquecível.',
|
'author_name' => 'Jeniffer e Maick',
|
||||||
],
|
'context' => 'Casamento · 06/12/2025',
|
||||||
[
|
|
||||||
'context' => 'Noiva',
|
|
||||||
'sort_order' => 1,
|
'sort_order' => 1,
|
||||||
'is_featured' => true,
|
|
||||||
'photo_path' => $this->copyFixture('testimonial.jpg', 'visual/testimonials/ana-souza.jpg'),
|
|
||||||
'photo_alt' => 'Foto de Ana Souza',
|
|
||||||
'published_at' => Carbon::parse(self::SEED_TIMESTAMP),
|
|
||||||
],
|
],
|
||||||
);
|
[
|
||||||
|
'quote' => "Mi, eu não tenho palavras pra agradecer você e tudo que você fez por mim e por nós na realização desse sonho. Eu tô ainda extasiada com tudo que aconteceu hoje; mas tenho certeza que sem a sua ajuda, muita coisa não aconteceria.\n\nObrigada por tudo !",
|
||||||
|
'author_name' => 'Quesia e Jhonata',
|
||||||
|
'context' => 'Casamento · 21/12/2025',
|
||||||
|
'sort_order' => 2,
|
||||||
|
],
|
||||||
|
] as $testimonial) {
|
||||||
|
Testimonial::query()->updateOrCreate(
|
||||||
|
['author_name' => $testimonial['author_name']],
|
||||||
|
[
|
||||||
|
...$testimonial,
|
||||||
|
'is_featured' => true,
|
||||||
|
'photo_path' => null,
|
||||||
|
'photo_alt' => null,
|
||||||
|
'published_at' => Carbon::parse(self::SEED_TIMESTAMP),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function copyFixture(string $fixtureName, string $destination): string
|
/**
|
||||||
|
* @param array{0: int, 1: int, 2: int} $rgb
|
||||||
|
*/
|
||||||
|
private function writeSolidJpeg(string $destination, int $width, int $height, array $rgb): string
|
||||||
{
|
{
|
||||||
$source = base_path('tests/fixtures/images/'.$fixtureName);
|
$image = imagecreatetruecolor($width, $height);
|
||||||
Storage::disk('public')->put($destination, File::get($source));
|
$color = imagecolorallocate($image, $rgb[0], $rgb[1], $rgb[2]);
|
||||||
|
imagefilledrectangle($image, 0, 0, $width, $height, $color);
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
imagejpeg($image, null, 90);
|
||||||
|
$binary = (string) ob_get_clean();
|
||||||
|
imagedestroy($image);
|
||||||
|
|
||||||
|
Storage::disk('public')->put($destination, $binary);
|
||||||
|
|
||||||
return $destination;
|
return $destination;
|
||||||
}
|
}
|
||||||
|
|||||||
39
depoimentos.md
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
> **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!!! ❤️
|
||||||
|
Muito obrigada!
|
||||||
|
Jeniffer e Maick
|
||||||
|
Casamento - 06/12/2025
|
||||||
|
----
|
||||||
|
Mi, eu não tenho palavras pra agradecer você e tudo que você fez por mim e por nós na realização desse sonho. Eu tô ainda extasiada com tudo que aconteceu hoje; mas tenho certeza que sem a sua ajuda, muita coisa não aconteceria.
|
||||||
|
Obrigada por tudo !
|
||||||
|
Quesia e Jhonata
|
||||||
|
casamento - 21/12/2025
|
||||||
|
----
|
||||||
|
@_amareassessoria
|
||||||
|
Que equipe!! Que equipe maravilhosa!! Obrigado pelo empenho de fazer tudo como eu queria!! Obrigado por se esforçar tanto e vir de tão longe pra realizar meu sonho!! Incríveis!! 😍😍
|
||||||
|
Milena e Weslley
|
||||||
|
casamento - 13/02/2026
|
||||||
|
---
|
||||||
|
Gostaríamos de agradecer por todo o acompanhamento e dedicação durante a realização do nosso casamento. Foi um dia muito especial e inesquecível para nós.
|
||||||
|
Desde o início, conseguimos conduzir tudo aquilo que estávamos planejando, dentro dos horários que estipulamos, o que foi ótimo, e no grande dia sua equipe nos recebeu e tratou com muito carinho, atenção e cuidado, o que fez toda a diferença para vivermos esse momento com mais tranquilidade.
|
||||||
|
Também adoramos as sugestões e ideias para as fotos, que deixaram os registros ainda mais bonitos e espontâneos, porque não iríamos lembrar de quais poses fazer na hora.
|
||||||
|
Obrigada por fazer parte de um momento tão importante das nossas vidas. Desejamos muito sucesso e que muitos outros casais possam viver dias especiais através do trabalho da AMARE.
|
||||||
|
Raquel e Pedro
|
||||||
|
casamento - 09/05/2026
|
||||||
|
----
|
||||||
|
Miiii, meu amor… você e sua equipe foram impecáveis.
|
||||||
|
Superou todas as nossas expectativas. Somos eternamente gratos por fazer nosso dia acontecer muito melhor do que imaginávamos.
|
||||||
|
Sempre muito atenciosa e paciente.
|
||||||
|
Adoramos te conhecer e estamos muito felizes em termos escolhido você para assessorar nosso dia. ♥️🙏🏻
|
||||||
|
Victoria e Pedro
|
||||||
|
casamento -24/06/2026
|
||||||
79
docker-compose.deploy.yml
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
# Shared Compose for Dokploy staging and production.
|
||||||
|
# Both stacks use the same file with different env:
|
||||||
|
# APP_IMAGE=ghcr.io/<owner>/<repo>
|
||||||
|
# IMAGE_TAG=staging|production|<git-sha>
|
||||||
|
# PostgreSQL is a separate Dokploy database service (not defined here).
|
||||||
|
# Traefik/Dokploy domains should target service `web` port 8000.
|
||||||
|
# App services join dokploy-network so they can resolve Dokploy-managed
|
||||||
|
# Postgres internal hosts (e.g. amare-stg-pez43e).
|
||||||
|
|
||||||
|
services:
|
||||||
|
migrate:
|
||||||
|
image: ${APP_IMAGE}:${IMAGE_TAG}
|
||||||
|
pull_policy: always
|
||||||
|
restart: "no"
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
# 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
|
||||||
|
|
||||||
|
web:
|
||||||
|
image: ${APP_IMAGE}:${IMAGE_TAG}
|
||||||
|
pull_policy: always
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
depends_on:
|
||||||
|
migrate:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
expose:
|
||||||
|
- "8000"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8000/up"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
start_period: 40s
|
||||||
|
retries: 3
|
||||||
|
networks:
|
||||||
|
- dokploy-network
|
||||||
|
|
||||||
|
queue:
|
||||||
|
image: ${APP_IMAGE}:${IMAGE_TAG}
|
||||||
|
pull_policy: always
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
depends_on:
|
||||||
|
migrate:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
command: ["php", "artisan", "queue:work", "--sleep=2", "--tries=3", "--max-time=3600"]
|
||||||
|
stop_grace_period: 60s
|
||||||
|
stop_signal: SIGTERM
|
||||||
|
networks:
|
||||||
|
- dokploy-network
|
||||||
|
|
||||||
|
scheduler:
|
||||||
|
image: ${APP_IMAGE}:${IMAGE_TAG}
|
||||||
|
pull_policy: always
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
depends_on:
|
||||||
|
migrate:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
command: ["php", "artisan", "schedule:work"]
|
||||||
|
stop_grace_period: 30s
|
||||||
|
stop_signal: SIGTERM
|
||||||
|
networks:
|
||||||
|
- dokploy-network
|
||||||
|
|
||||||
|
networks:
|
||||||
|
dokploy-network:
|
||||||
|
external: true
|
||||||
@@ -18,5 +18,34 @@ services:
|
|||||||
retries: 10
|
retries: 10
|
||||||
start_period: 10s
|
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:
|
volumes:
|
||||||
amare_postgres_data:
|
amare_postgres_data:
|
||||||
|
|||||||
@@ -5,5 +5,20 @@
|
|||||||
:8000 {
|
:8000 {
|
||||||
root * /app/public
|
root * /app/public
|
||||||
encode gzip zstd
|
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
|
php_server
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,5 +7,8 @@ Mesma imagem, comandos distintos:
|
|||||||
| web | `frankenphp run --config /etc/caddy/Caddyfile` |
|
| web | `frankenphp run --config /etc/caddy/Caddyfile` |
|
||||||
| queue | `php artisan queue:work --sleep=2 --tries=3` |
|
| queue | `php artisan queue:work --sleep=2 --tries=3` |
|
||||||
| scheduler | `php artisan schedule:work` |
|
| scheduler | `php artisan schedule:work` |
|
||||||
|
| migrate | `php artisan migrate --force` (one-shot no Compose de deploy) |
|
||||||
|
|
||||||
FrankenPHP em **modo regular** (ADR-006). Worker mode proibido no MVP.
|
FrankenPHP em **modo regular** (ADR-006). Worker mode proibido no MVP.
|
||||||
|
|
||||||
|
Deploy Dokploy (staging/produção): ver [`docker-compose.deploy.yml`](../docker-compose.deploy.yml) e [docs/deployment/dokploy.md](../docs/deployment/dokploy.md).
|
||||||
|
|||||||
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…_
|
||||||
26
docs/agents/issue-tracker.md
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# Issue tracker: Linear
|
||||||
|
|
||||||
|
Issues and specs for this repo live in Linear. Use the Linear MCP tools for all operations.
|
||||||
|
|
||||||
|
- **Workspace**: maneco-workspace (https://linear.app/maneco-workspace)
|
||||||
|
- **Team**: Maneco-workspace
|
||||||
|
- **Issue statuses**: Backlog, Todo, In Progress, Done, Canceled, Duplicate
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
- **Create an issue**: `save_issue` with `title`, `team` (`Maneco-workspace`), and markdown `description`. Assign via `assignee` ("me" for the current user).
|
||||||
|
- **Read an issue**: `get_issue` by identifier (e.g. `LIN-123`).
|
||||||
|
- **List issues**: `list_issues` filtered by `team`, `assignee`, `state`, or `project`.
|
||||||
|
- **Comment on an issue**: `save_comment` with `issueId` and markdown `body`.
|
||||||
|
- **Apply / remove labels**: `save_issue` with `labels` (replaces the full set).
|
||||||
|
- **Move workflow state**: `save_issue` with `state` (e.g. `Todo`, `In Progress`, `Done`, `Canceled`).
|
||||||
|
- **Link work**: `save_issue` with `project`, `cycle`, `parentId`, `blocks` / `blockedBy` (relations are append-only).
|
||||||
|
- **Cross-link to a PR**: attach the PR URL via `save_issue` `links`.
|
||||||
|
|
||||||
|
## When a skill says "publish to the issue tracker"
|
||||||
|
|
||||||
|
Create a Linear issue with `save_issue` on team `Maneco-workspace`.
|
||||||
|
|
||||||
|
## When a skill says "fetch the relevant ticket"
|
||||||
|
|
||||||
|
Run `get_issue` on the identifier and read the description, state, labels, and assignee.
|
||||||
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.
|
||||||
304
docs/deployment/dokploy.md
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
# Deploy Dokploy (staging → production)
|
||||||
|
|
||||||
|
Runbook for operating Amare on a VPS with Dokploy connected to GitHub, publishing immutable images to GHCR.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
CI (main) → build FrankenPHP image → GHCR :<sha> + :staging
|
||||||
|
→ Dokploy staging compose.deploy
|
||||||
|
→ smoke /up / /admin/login
|
||||||
|
|
||||||
|
Promote (manual) → retag same digest as :production (no rebuild)
|
||||||
|
→ Dokploy production compose.deploy
|
||||||
|
→ smoke
|
||||||
|
```
|
||||||
|
|
||||||
|
| Piece | Detail |
|
||||||
|
|---|---|
|
||||||
|
| 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`) |
|
||||||
|
| 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`) |
|
||||||
|
| Proxy | Dokploy Traefik → service `web` port `8000` |
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
## Create Compose stacks
|
||||||
|
|
||||||
|
Create **two** Dokploy Compose services (same repo, same compose path):
|
||||||
|
|
||||||
|
| Stack | Compose path | `IMAGE_TAG` | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| staging | `docker-compose.deploy.yml` | `staging` | Auto-deployed after CI on `main` |
|
||||||
|
| production | `docker-compose.deploy.yml` | `production` | Manual promotion only |
|
||||||
|
|
||||||
|
Dokploy Environment for each stack must set:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
APP_IMAGE=ghcr.io/<owner>/<repo>
|
||||||
|
IMAGE_TAG=staging # or production
|
||||||
|
```
|
||||||
|
|
||||||
|
Point Dokploy domain(s) at service **`web`**, port **`8000`**. Do not publish PostgreSQL or host ports for app processes.
|
||||||
|
|
||||||
|
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`.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
```env
|
||||||
|
APP_NAME=Amare
|
||||||
|
APP_ENV=staging # or production
|
||||||
|
APP_KEY=base64:... # unique per environment — generate with php artisan key:generate --show
|
||||||
|
APP_DEBUG=false
|
||||||
|
APP_URL=https://staging.example.com
|
||||||
|
|
||||||
|
APP_LOCALE=pt_BR
|
||||||
|
APP_FALLBACK_LOCALE=pt_BR
|
||||||
|
APP_TIMEZONE=America/Sao_Paulo
|
||||||
|
|
||||||
|
DB_CONNECTION=pgsql
|
||||||
|
DB_HOST=<dokploy-postgres-internal-host> # Internal Host from Dokploy UI (requires dokploy-network)
|
||||||
|
DB_PORT=5432
|
||||||
|
DB_DATABASE=amare_staging
|
||||||
|
DB_USERNAME=...
|
||||||
|
DB_PASSWORD=...
|
||||||
|
|
||||||
|
SESSION_DRIVER=database
|
||||||
|
SESSION_SECURE_COOKIE=true
|
||||||
|
SESSION_HTTP_ONLY=true
|
||||||
|
SESSION_SAME_SITE=lax
|
||||||
|
CACHE_STORE=database
|
||||||
|
QUEUE_CONNECTION=database
|
||||||
|
|
||||||
|
FILESYSTEM_DISK=r2
|
||||||
|
R2_ACCESS_KEY_ID=...
|
||||||
|
R2_SECRET_ACCESS_KEY=...
|
||||||
|
R2_BUCKET=...
|
||||||
|
R2_ENDPOINT=https://<account_id>.r2.cloudflarestorage.com
|
||||||
|
R2_URL=https://media-staging.example.com
|
||||||
|
|
||||||
|
MAIL_MAILER=resend
|
||||||
|
RESEND_API_KEY=...
|
||||||
|
MAIL_FROM_ADDRESS=noreply@example.com
|
||||||
|
MAIL_FROM_NAME=Amare
|
||||||
|
|
||||||
|
LOG_LEVEL=warning
|
||||||
|
```
|
||||||
|
|
||||||
|
Trusted proxies are configured in `bootstrap/app.php` so Traefik `X-Forwarded-*` headers work for HTTPS cookies and URLs.
|
||||||
|
|
||||||
|
Livewire temporary uploads default to the **local** disk in `AppServiceProvider` (even when `FILESYSTEM_DISK=r2`), so Filament does not browser-PUT to R2. Final media still lands on R2 via `PublicImageUploadRules`. Optional override: `LIVEWIRE_TEMPORARY_FILE_UPLOAD_DISK` in Dokploy `.env` (`env_file` accepts any key — does not need a compose `environment:` entry).
|
||||||
|
|
||||||
|
### R2 CORS (public/media reads from JS)
|
||||||
|
|
||||||
|
Upload path does not need R2 CORS with the local temp-disk default. Still useful if the browser fetches R2 URLs cross-origin from JS. Origin must match `APP_URL` exactly; include `AllowedHeaders`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"AllowedOrigins": ["https://hellomanoel.com"],
|
||||||
|
"AllowedMethods": ["GET", "PUT", "POST", "HEAD"],
|
||||||
|
"AllowedHeaders": ["*"],
|
||||||
|
"ExposeHeaders": ["ETag", "Content-Type"],
|
||||||
|
"MaxAgeSeconds": 3600
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
Also enable public access / custom domain for `R2_URL` so `<img>` URLs work after save.
|
||||||
|
|
||||||
|
## GitHub Actions secrets
|
||||||
|
|
||||||
|
Repository secrets used by workflows:
|
||||||
|
|
||||||
|
| Secret | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `DOKPLOY_URL` | Panel origin **without** `/api` (e.g. `https://panel.example.com`). Do not use the OpenAPI base URL that ends in `/api` — that yields `/api/api/...` and 404s. |
|
||||||
|
| `DOKPLOY_API_KEY` | API key from Dokploy profile → API/CLI |
|
||||||
|
| `DOKPLOY_STAGING_COMPOSE_ID` | Staging **Compose** service id (not an Application id) |
|
||||||
|
| `DOKPLOY_PRODUCTION_COMPOSE_ID` | Production **Compose** service id (not an Application id) |
|
||||||
|
| `STAGING_URL` | Public origin for staging smoke (e.g. `https://staging.example.com`) |
|
||||||
|
| `PRODUCTION_URL` | Public origin for production smoke |
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Workflows
|
||||||
|
|
||||||
|
### Staging (automatic)
|
||||||
|
|
||||||
|
[`.github/workflows/deploy-staging.yml`](../../.github/workflows/deploy-staging.yml)
|
||||||
|
|
||||||
|
1. Waits for workflow `CI` success on push to `main`.
|
||||||
|
2. Builds once; pushes `:<full-sha>` and `:staging`.
|
||||||
|
3. Calls Dokploy `compose.deploy` and polls until done.
|
||||||
|
4. Runs [`scripts/deploy/smoke.sh`](../../scripts/deploy/smoke.sh) against `STAGING_URL`.
|
||||||
|
|
||||||
|
### Production (manual)
|
||||||
|
|
||||||
|
[`.github/workflows/promote-production.yml`](../../.github/workflows/promote-production.yml)
|
||||||
|
|
||||||
|
1. Operator runs **Actions → Promote production**.
|
||||||
|
2. Inputs: full `sha` already on GHCR; `confirm` must be exactly `PRODUCTION`.
|
||||||
|
3. Retags the **same digest** as `:production` (no rebuild).
|
||||||
|
4. Deploys production compose + smoke.
|
||||||
|
|
||||||
|
Private repos on GitHub Free do not get Environment required reviewers; human approval is the explicit `workflow_dispatch` + confirmation string. GitHub Pro Environment reviewers are optional later.
|
||||||
|
|
||||||
|
## First admin and authorized production seeding
|
||||||
|
|
||||||
|
Seed credentials are local-only. For staging/production:
|
||||||
|
|
||||||
|
FrankenPHP sets `XDG_CONFIG_HOME=/config` (Caddy). PsySH/tinker then tries `/config/psysh`, which `appuser` cannot write — you get `Writing to directory /config/psysh is not allowed.` Override that env for the one-shot command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# From Dokploy → staging/production → Open terminal on `web` (or one-off run)
|
||||||
|
XDG_CONFIG_HOME=/tmp php artisan tinker --execute="
|
||||||
|
\$user = \\App\\Models\\User::query()->updateOrCreate(
|
||||||
|
['email' => 'admin@example.com'],
|
||||||
|
[
|
||||||
|
'name' => 'Admin',
|
||||||
|
'password' => 'use-a-strong-password',
|
||||||
|
'role' => 'admin',
|
||||||
|
'is_active' => true,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
\$user->forceFill(['email_verified_at' => now()])->save();
|
||||||
|
echo \$user->email.PHP_EOL;
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
Plain password is enough: `User` casts `password` to `hashed` (and skips re-hash when value already hashed). `email_verified_at` is not mass-assignable — use `forceFill` as above.
|
||||||
|
|
||||||
|
Confirm:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
XDG_CONFIG_HOME=/tmp php artisan tinker --execute="echo \\App\\Models\\User::query()->where('email', 'admin@example.com')->exists() ? 'ok' : 'missing';"
|
||||||
|
```
|
||||||
|
|
||||||
|
Never reuse `admin@amare.local` / `password`.
|
||||||
|
|
||||||
|
### Load authorized testimonials after migrations
|
||||||
|
|
||||||
|
After migrations, manually load the five authorized testimonials in **staging**, then repeat in **production**. From Dokploy, open a terminal on the environment's `web` service (or run an equivalent one-off process):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php artisan db:seed --class='Database\Seeders\TestimonialsSeeder' --force --no-interaction
|
||||||
|
```
|
||||||
|
|
||||||
|
This seeder is safe to rerun: it overwrites canonical source-owned fields, preserves curated photo fields, and leaves unrelated testimonials unchanged. The five records are published with the approved deterministic timestamp. Upsert keys on `author_name` (no unique DB constraint); keep one row per couple before/after running.
|
||||||
|
|
||||||
|
Optional verification:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
XDG_CONFIG_HOME=/tmp php artisan tinker --execute="
|
||||||
|
\$expected = collect(['Jeniffer e Maick', 'Quesia e Jhonata', 'Milena e Weslley', 'Raquel e Pedro', 'Victoria e Pedro']);
|
||||||
|
\$rows = \\App\\Models\\Testimonial::query()->whereIn('author_name', \$expected)->get(['author_name', 'published_at'])->groupBy('author_name');
|
||||||
|
\$valid = \$expected->every(function (string \$author) use (\$rows): bool {
|
||||||
|
\$matches = \$rows->get(\$author, collect());
|
||||||
|
return \$matches->count() === 1
|
||||||
|
&& \$matches->first()->published_at?->format('Y-m-d H:i:s') === '2026-08-05 00:00:00';
|
||||||
|
});
|
||||||
|
echo (\$valid ? 'ok' : 'invalid').PHP_EOL;
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected output: `ok`.
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
Policy (SPEC §16.3): daily PostgreSQL backup, retention ≥ 14 days, RPO ≤ 24h, RTO ≤ 4h.
|
||||||
|
|
||||||
|
### Configure (Dokploy)
|
||||||
|
|
||||||
|
1. Settings → Destinations: add S3-compatible destination (AWS S3, R2, etc.).
|
||||||
|
2. Open each PostgreSQL service → Backup:
|
||||||
|
- Destination: the S3 destination
|
||||||
|
- Schedule: cron e.g. `0 3 * * *`
|
||||||
|
- Prefix: `amare/staging` or `amare/production`
|
||||||
|
- Enabled: on
|
||||||
|
3. Click **Test** and verify the object appears in the bucket.
|
||||||
|
4. Prefer Dokploy alerts/webhooks for backup failure if configured.
|
||||||
|
|
||||||
|
### Restore (staging rehearsal before first production promote)
|
||||||
|
|
||||||
|
1. Create a scratch database or restore into a disposable Postgres service.
|
||||||
|
2. Database → Backup → **Restore**: pick destination + backup file + target database name.
|
||||||
|
3. Point a temporary compose env at the restored DB and confirm `/up` + `/admin/login`.
|
||||||
|
4. Document the timestamp of the successful rehearsal.
|
||||||
|
|
||||||
|
Do **not** promote to production until staging restore has been proven once.
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
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
|
||||||
|
docker buildx imagetools create \
|
||||||
|
--tag ghcr.io/<owner>/<repo>:staging \
|
||||||
|
ghcr.io/<owner>/<repo>:<previous-sha>
|
||||||
|
|
||||||
|
# Then trigger Dokploy deploy (UI Deploy, or):
|
||||||
|
DOKPLOY_URL=... DOKPLOY_API_KEY=... DOKPLOY_COMPOSE_ID=... \
|
||||||
|
./scripts/deploy/dokploy-deploy.sh
|
||||||
|
|
||||||
|
SMOKE_BASE_URL=https://staging.example.com ./scripts/deploy/smoke.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### Production
|
||||||
|
|
||||||
|
Same pattern with `:production` tag and production compose id / URL. Prefer re-running **Promote production** with the previous SHA and confirmation `PRODUCTION`.
|
||||||
|
|
||||||
|
If a migration is not backward-compatible, fix forward with a new SHA; keep migrations reversible when possible.
|
||||||
|
|
||||||
|
## Smoke checks
|
||||||
|
|
||||||
|
```bash
|
||||||
|
SMOKE_BASE_URL=https://staging.example.com ./scripts/deploy/smoke.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Expects HTTP 200 for `/up`, `/`, and `/admin/login`.
|
||||||
|
|
||||||
|
## Domain checklist before production promote
|
||||||
|
|
||||||
|
- [ ] Final hostname DNS → VPS
|
||||||
|
- [ ] Dokploy TLS certificate issued
|
||||||
|
- [ ] `APP_URL` matches public HTTPS origin
|
||||||
|
- [ ] `SESSION_SECURE_COOKIE=true`
|
||||||
|
- [ ] Staging smoke green on the SHA to promote
|
||||||
|
- [ ] Staging backup + restore rehearsed
|
||||||
|
- [ ] Production Postgres backup schedule enabled
|
||||||
|
- [ ] Production R2 bucket + Resend domain ready
|
||||||
|
- [ ] First admin created without seed
|
||||||
|
|
||||||
|
## Local validation of Compose
|
||||||
|
|
||||||
|
```bash
|
||||||
|
APP_IMAGE=ghcr.io/<owner>/<repo> IMAGE_TAG=staging \
|
||||||
|
docker compose -f docker-compose.deploy.yml config
|
||||||
|
```
|
||||||
|
|
||||||
|
Requires a `.env` file present (Dokploy creates it from Environment UI). For local config checks, an empty `.env` is enough.
|
||||||
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
@@ -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
@@ -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
|
After Width: | Height: | Size: 170 KiB |
BIN
docs/evidence/public-site-motion/contact-mobile.png
Normal file
|
After Width: | Height: | Size: 155 KiB |
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
|
After Width: | Height: | Size: 137 KiB |
BIN
docs/screenshots/home-editorial-cadence-mobile.webp
Normal file
|
After Width: | Height: | Size: 192 KiB |
81
docs/superpowers/plans/2026-08-11-full-bleed-home-hero.md
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
# Hero full-bleed da home Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Fazer a fotografia configurada do hero da home preencher toda a coluna direita abaixo do header, preservando a leitura editorial e o fallback sem foto.
|
||||||
|
|
||||||
|
**Architecture:** `x-home.hero` continuará a renderizar as duas variantes. A variante com imagem usará uma grade full-width com a coluna textual ancorada à régua global e a mídia como segundo trilho sem container; a variante tonal permanece contida. Os testes de feature e browser documentam os contratos de markup, tamanho e responsividade.
|
||||||
|
|
||||||
|
**Tech Stack:** Laravel Blade, Tailwind CSS 4 utilities, Pest feature/browser tests, Vite.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Chunk 1: Layout e contratos do hero
|
||||||
|
|
||||||
|
### Task 1: Cobrir o spread full-bleed
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `tests/Feature/PublicSite/ImmersivePhotoHeroTest.php`
|
||||||
|
- Modify: `tests/Browser/HomeEditorialCadenceTest.php`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Escrever as expectativas de feature para a variante com foto**
|
||||||
|
|
||||||
|
Exigir `data-split-hero`, `data-hero-content`, `data-motion="page-open"`, `data-reveal-group`, `aria-labelledby="hero-heading"`, `loading="eager"`, `fetchpriority="high"` e `sizes="(max-width: 767px) 100vw, 55vw"`. Com `hero_image_path` nulo, exigir `data-tonal-hero`, os mesmos atributos de motion/semântica, ausência de `data-split-hero` e ausência de `<img` dentro de `[data-tonal-hero]`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Estender a verificação browser de geometria**
|
||||||
|
|
||||||
|
No viewport 1440×1000, obter os retângulos de `.site-header`, `[data-chapter="hero"]`, `[data-hero-content]` e `[data-split-hero]`; esperar, com tolerância de 1px, que `hero.top === header.bottom`, `media.top === hero.top`, `media.bottom === hero.bottom`, `media.right === innerWidth` e `hero.height === innerHeight - header.height`. No viewport 390×844, comparar `data-split-hero` com `[data-hero-content]`; esperar que a mídia inicie depois do conteúdo, que `width / height` fique entre 0,79 e 0,81, e que não exista overflow horizontal. Antes das duas visitas de fallback, zerar `hero_image_path`; numa visita com reduced motion, esperar conteúdo visível. Em outra com `reducedMotion: no-preference` e `IntersectionObserver` desabilitado antes da navegação, esperar ausência de `data-motion` em `document.documentElement`, cada `[data-motion="page-open"]` com `.is-active`, cada `[data-reveal]` com `.is-revealed` e `aria-labelledby="hero-heading"`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Executar os testes focados e confirmar a falha inicial**
|
||||||
|
|
||||||
|
Run: `php artisan test tests/Feature/PublicSite/ImmersivePhotoHeroTest.php && php artisan test tests/Browser/HomeEditorialCadenceTest.php`
|
||||||
|
|
||||||
|
Expected: falha nas novas expectativas de geometria/markup até a alteração do componente.
|
||||||
|
|
||||||
|
### Task 2: Implementar a grade full-bleed
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `resources/views/components/home/hero.blade.php`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Separar a variante com imagem do container compartilhado**
|
||||||
|
|
||||||
|
Substituir `min-h-[100dvh]` do `<section>` por `md:h-[calc(100dvh-5rem)]` quando `hero_image_path` estiver preenchido e renderizar um wrapper `grid min-h-[calc(100dvh-5rem)] md:h-full md:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]`. A coluna de texto deve manter `data-reveal-group`, receber `data-hero-content`, ter `padding-left: max(var(--amare-container-padding), calc((100vw - var(--amare-container-max)) / 2 + var(--amare-container-padding)))`, padding direito fluido e centralização vertical.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Tornar a mídia contínua**
|
||||||
|
|
||||||
|
Aplicar ao bloco `data-split-hero` `overflow-hidden`, `bg-amare-bg-deep`, `md:min-h-full`; abaixo de `md`, aplicar `aspect-[4/5]`. Manter `data-motion-beat="media"`, `data-reveal-media`, `object-cover`, path/alt CMS e mudar `sizes` para `(max-width: 767px) 100vw, 55vw`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Preservar o fallback**
|
||||||
|
|
||||||
|
Manter o wrapper tonal existente para hero sem imagem, com `data-tonal-hero`, `data-motion="page-open"` e `data-reveal-group`; não renderizar o bloco de mídia nessa variante.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Executar os testes focados e confirmar a passagem**
|
||||||
|
|
||||||
|
Run: `php artisan test tests/Feature/PublicSite/ImmersivePhotoHeroTest.php && php artisan test tests/Browser/HomeEditorialCadenceTest.php`
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
### Task 3: Verificar e registrar
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `resources/views/components/home/hero.blade.php`
|
||||||
|
- Modify: `tests/Feature/PublicSite/ImmersivePhotoHeroTest.php`
|
||||||
|
- Modify: `tests/Browser/HomeEditorialCadenceTest.php`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Formatar e executar as verificações proporcionais**
|
||||||
|
|
||||||
|
Run: `composer pint && composer phpstan && composer test:feature && composer test:browser && npm run build`
|
||||||
|
|
||||||
|
Expected: todos os comandos passam.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Inspecionar o diff e registrar**
|
||||||
|
|
||||||
|
Run: `git diff --check && git status --short`
|
||||||
|
|
||||||
|
Expected: apenas os três arquivos de implementação/testes e a documentação deste plano/especificação aparecem como escopo.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
Run: `git add resources/views/components/home/hero.blade.php tests/Feature/PublicSite/ImmersivePhotoHeroTest.php tests/Browser/HomeEditorialCadenceTest.php docs/superpowers && git commit -m "feat(home): tornar hero fotográfico full-bleed"`
|
||||||
|
|
||||||
|
Expected: commit focado, sem artefatos de build ou screenshots.
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# Hero da home com fotografia full-bleed
|
||||||
|
|
||||||
|
## Objetivo
|
||||||
|
|
||||||
|
Dar à fotografia do hero da home a mesma presença contínua da referência aprovada: no desktop, ela preenche integralmente a coluna direita abaixo do header; o conteúdo editorial permanece concentrado na coluna esquerda.
|
||||||
|
|
||||||
|
## Escopo
|
||||||
|
|
||||||
|
- Alterar somente o componente `resources/views/components/home/hero.blade.php` e seus testes diretos.
|
||||||
|
- Quando `hero_image_path` existir, usar uma grade externa `0.9fr / 1.1fr` a partir de `md` (768px), com altura exata `calc(100dvh - 5rem)`: o espaço restante depois do header desktop. A mídia deve chegar ao topo e à base da seção, sem padding vertical ou lateral de container.
|
||||||
|
- Manter o texto, CTAs, imagem CMS, `alt`, carregamento eager, `fetchpriority`, responsividade de imagem e atributos de motion existentes.
|
||||||
|
- Abaixo de `md`, manter a ordem texto seguido de imagem, sem overflow horizontal, e fixar a mídia em `aspect-ratio: 4 / 5` com `object-cover`.
|
||||||
|
- Quando não houver imagem configurada, manter a abertura tonal, tipográfica e contida já existente, sem área vazia para mídia.
|
||||||
|
|
||||||
|
## Fora de escopo
|
||||||
|
|
||||||
|
- Não alterar conteúdo do CMS, imagens, Open Graph, outras rotas públicas, header, animações, tokens globais ou dependências.
|
||||||
|
- Não criar ou publicar mídia nova.
|
||||||
|
|
||||||
|
## Estrutura
|
||||||
|
|
||||||
|
O componente continuará sendo a única unidade de layout do hero. A variante com foto terá um wrapper full-width em desktop. A coluna textual terá `data-hero-content`, margem esquerda `max(var(--amare-container-padding), calc((100vw - var(--amare-container-max)) / 2 + var(--amare-container-padding)))`, a mesma régua esquerda de `container-amare`, e padding direito fluido para não comprimir a leitura. O bloco de mídia ocupará os 55% da grade, receberá `data-split-hero` e `data-reveal-media`, e deixará de ficar limitado pelo container. O atributo `sizes` será `(max-width: 767px) 100vw, 55vw`. A variante sem foto permanece no wrapper atual.
|
||||||
|
|
||||||
|
## Critérios de aceitação
|
||||||
|
|
||||||
|
- Em desktop, `data-split-hero` inicia sob o header e mede `calc(100dvh - 5rem)`, sem margens externas de container.
|
||||||
|
- A foto usa `object-cover`, `loading="eager"`, `fetchpriority="high"`, path/alt próprios do hero e `sizes="(max-width: 767px) 100vw, 55vw"`.
|
||||||
|
- Em mobile, o conteúdo essencial continua acessível antes da foto, sem overflow nem texto recortado.
|
||||||
|
- O fallback sem foto continua com `data-tonal-hero`, sem `data-split-hero` e sem mídia renderizada.
|
||||||
|
- `data-motion="page-open"`, `data-reveal-group`, beats existentes, motion reduzido, CTAs e semântica atual continuam funcionais.
|
||||||
|
- Testes verificam os contratos full-bleed da variante com foto, a ausência de mídia no fallback, e a continuidade de motion/semântica.
|
||||||
262
frontend-audit.md
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
Audite e melhore integralmente este projeto do site da **Amare**, incluindo todas as rotas públicas, componentes compartilhados, formulários, navegação, conteúdo, metadados e comportamento responsivo.
|
||||||
|
|
||||||
|
Você está autorizado a executar o projeto, inspecionar o repositório e modificar diretamente o código. Não entregue somente recomendações: implemente as correções necessárias e valide o resultado.
|
||||||
|
|
||||||
|
## Objetivo
|
||||||
|
|
||||||
|
Deixar o site tecnicamente sólido, visualmente refinado e pronto para apresentação à cliente e posterior lançamento, corrigindo problemas de:
|
||||||
|
|
||||||
|
* UI e UX;
|
||||||
|
* responsividade;
|
||||||
|
* posicionamento e conteúdo;
|
||||||
|
* acessibilidade;
|
||||||
|
* conversão;
|
||||||
|
* SEO;
|
||||||
|
* performance;
|
||||||
|
* robustez;
|
||||||
|
* qualidade e manutenção do código.
|
||||||
|
|
||||||
|
## Contexto do produto
|
||||||
|
|
||||||
|
A Amare é uma empresa de assessoria, produção e organização de eventos em São Paulo.
|
||||||
|
|
||||||
|
Ela atende:
|
||||||
|
|
||||||
|
* casamentos;
|
||||||
|
* eventos sociais e celebrações particulares;
|
||||||
|
* eventos corporativos.
|
||||||
|
|
||||||
|
O site não deve transmitir que a empresa trabalha exclusivamente com casamentos.
|
||||||
|
|
||||||
|
A experiência precisa equilibrar:
|
||||||
|
|
||||||
|
* emoção, proximidade, sensibilidade e sofisticação para eventos sociais;
|
||||||
|
* organização, segurança, método, clareza e credibilidade para eventos corporativos.
|
||||||
|
|
||||||
|
O resultado deve ser editorial, contemporâneo, humano, elegante e profissional, sem parecer excessivamente romântico nem corporativo demais.
|
||||||
|
|
||||||
|
Preview de referência:
|
||||||
|
|
||||||
|
`https://amare.preview.hellomanoel.com/`
|
||||||
|
|
||||||
|
Considere os documentos existentes no repositório, especialmente `AGENTS.md`, `README.md`, `SPEC.md`, `DESIGN.md` e equivalentes, como contexto autoritativo do projeto.
|
||||||
|
|
||||||
|
## Direção visual
|
||||||
|
|
||||||
|
Preserve a identidade visual existente quando ela funcionar corretamente:
|
||||||
|
|
||||||
|
* fundos off-white ou bege claro;
|
||||||
|
* verde oliva;
|
||||||
|
* composição editorial;
|
||||||
|
* fotografias em destaque;
|
||||||
|
* espaços em branco generosos;
|
||||||
|
* EB Garamond em títulos e destaques;
|
||||||
|
* elementos minimalistas;
|
||||||
|
* animações discretas;
|
||||||
|
* aparência refinada e atemporal.
|
||||||
|
|
||||||
|
Não preserve decisões que prejudiquem usabilidade, contraste, legibilidade, acessibilidade, responsividade ou performance.
|
||||||
|
|
||||||
|
Avalie especialmente:
|
||||||
|
|
||||||
|
* uso excessivo de Garamond em textos pequenos, menus, botões e formulários;
|
||||||
|
* verdes claros com contraste insuficiente;
|
||||||
|
* CTAs discretos demais;
|
||||||
|
* espaços vazios excessivos no celular;
|
||||||
|
* imagens que reforcem somente o posicionamento de casamento;
|
||||||
|
* falta de equilíbrio entre conteúdo social e corporativo;
|
||||||
|
* inconsistência tipográfica ou de espaçamento;
|
||||||
|
* aparência de template genérico de casamento.
|
||||||
|
|
||||||
|
## Escopo da auditoria
|
||||||
|
|
||||||
|
Analise todas as rotas encontradas no código e corrija problemas relacionados a:
|
||||||
|
|
||||||
|
### Produto e conteúdo
|
||||||
|
|
||||||
|
* clareza da proposta de valor;
|
||||||
|
* equilíbrio entre eventos sociais e corporativos;
|
||||||
|
* hierarquia das informações;
|
||||||
|
* conteúdo genérico, redundante ou sem função;
|
||||||
|
* coerência entre títulos, textos, imagens e CTAs;
|
||||||
|
* clareza dos serviços;
|
||||||
|
* confiança e credibilidade;
|
||||||
|
* caminho até contato ou solicitação de proposta.
|
||||||
|
|
||||||
|
Não invente história, números, clientes, prêmios, depoimentos, equipe, serviços, telefone, e-mail ou qualquer informação não confirmada.
|
||||||
|
|
||||||
|
### UI e experiência
|
||||||
|
|
||||||
|
* navegação;
|
||||||
|
* header e footer;
|
||||||
|
* menu mobile;
|
||||||
|
* hierarquia visual;
|
||||||
|
* tipografia;
|
||||||
|
* espaçamento;
|
||||||
|
* grids;
|
||||||
|
* CTAs;
|
||||||
|
* formulários;
|
||||||
|
* estados interativos;
|
||||||
|
* consistência entre páginas;
|
||||||
|
* experiência mobile-first;
|
||||||
|
* ausência de overflow, cortes, sobreposições ou distorções;
|
||||||
|
* adaptação real do layout ao celular, e não apenas redução da versão desktop.
|
||||||
|
|
||||||
|
### Acessibilidade
|
||||||
|
|
||||||
|
Use WCAG 2.2 AA como referência prática.
|
||||||
|
|
||||||
|
Corrija problemas de:
|
||||||
|
|
||||||
|
* HTML semântico;
|
||||||
|
* hierarquia de headings;
|
||||||
|
* navegação por teclado;
|
||||||
|
* foco visível;
|
||||||
|
* contraste;
|
||||||
|
* nomes acessíveis;
|
||||||
|
* labels;
|
||||||
|
* mensagens de erro;
|
||||||
|
* áreas de toque;
|
||||||
|
* textos alternativos;
|
||||||
|
* menu mobile;
|
||||||
|
* overlays;
|
||||||
|
* preferência por redução de movimento;
|
||||||
|
* uso correto de links e botões.
|
||||||
|
|
||||||
|
Prefira HTML semântico a ARIA desnecessária.
|
||||||
|
|
||||||
|
### Formulários e conversão
|
||||||
|
|
||||||
|
Garanta, quando aplicável:
|
||||||
|
|
||||||
|
* campos e obrigatoriedade claros;
|
||||||
|
* validação adequada;
|
||||||
|
* mensagens de erro específicas;
|
||||||
|
* estado de envio;
|
||||||
|
* prevenção de envio duplicado;
|
||||||
|
* estados de sucesso e falha;
|
||||||
|
* tratamento de erro de rede;
|
||||||
|
* preservação dos dados após erros recuperáveis;
|
||||||
|
* proteção antispam simples;
|
||||||
|
* privacidade e LGPD;
|
||||||
|
* links e mensagens de WhatsApp corretos;
|
||||||
|
* ausência de segredos expostos no cliente.
|
||||||
|
|
||||||
|
Quando algum dado real não estiver disponível, use configuração ou variável de ambiente e documente a pendência.
|
||||||
|
|
||||||
|
### SEO
|
||||||
|
|
||||||
|
Corrija, quando aplicável:
|
||||||
|
|
||||||
|
* títulos exclusivos por rota;
|
||||||
|
* meta descriptions;
|
||||||
|
* canonical;
|
||||||
|
* Open Graph;
|
||||||
|
* Twitter cards;
|
||||||
|
* sitemap;
|
||||||
|
* robots;
|
||||||
|
* favicon;
|
||||||
|
* idioma;
|
||||||
|
* headings;
|
||||||
|
* URLs;
|
||||||
|
* links internos;
|
||||||
|
* página 404;
|
||||||
|
* metadados sociais;
|
||||||
|
* indexação distinta entre preview e produção.
|
||||||
|
|
||||||
|
Não crie dados estruturados com informações não confirmadas.
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
|
||||||
|
Corrija problemas relevantes relacionados a:
|
||||||
|
|
||||||
|
* imagens;
|
||||||
|
* tamanhos responsivos;
|
||||||
|
* formatos modernos;
|
||||||
|
* lazy loading;
|
||||||
|
* LCP;
|
||||||
|
* CLS;
|
||||||
|
* INP;
|
||||||
|
* fontes;
|
||||||
|
* scripts desnecessários;
|
||||||
|
* hidratação excessiva;
|
||||||
|
* JavaScript evitável;
|
||||||
|
* componentes client-side sem necessidade;
|
||||||
|
* animações custosas;
|
||||||
|
* dependências pesadas;
|
||||||
|
* carregamento de conteúdo abaixo da dobra.
|
||||||
|
|
||||||
|
Preserve a qualidade visual das fotografias.
|
||||||
|
|
||||||
|
### Qualidade técnica
|
||||||
|
|
||||||
|
Corrija:
|
||||||
|
|
||||||
|
* erros de TypeScript;
|
||||||
|
* erros de lint;
|
||||||
|
* erros de build;
|
||||||
|
* warnings de hidratação;
|
||||||
|
* erros de console;
|
||||||
|
* requisições quebradas;
|
||||||
|
* links inválidos;
|
||||||
|
* rotas órfãs;
|
||||||
|
* componentes duplicados quando a consolidação simplificar o projeto;
|
||||||
|
* tratamento de erros ausente;
|
||||||
|
* tipagem insegura;
|
||||||
|
* complexidade desnecessária diretamente relacionada ao escopo.
|
||||||
|
|
||||||
|
## Prioridades
|
||||||
|
|
||||||
|
Considere:
|
||||||
|
|
||||||
|
* **P0:** bloqueia funcionamento, segurança, uso ou lançamento;
|
||||||
|
* **P1:** prejudica significativamente experiência, conversão, acessibilidade, SEO, performance ou credibilidade;
|
||||||
|
* **P2:** refinamento não essencial para o lançamento.
|
||||||
|
|
||||||
|
Implemente todos os itens P0 e P1 que possam ser resolvidos com as informações existentes.
|
||||||
|
|
||||||
|
Itens dependentes de dados reais da cliente devem permanecer como pendências explícitas, sem conteúdo fictício.
|
||||||
|
|
||||||
|
## Restrições
|
||||||
|
|
||||||
|
* Preserve a stack e a arquitetura existentes quando forem adequadas.
|
||||||
|
* Prefira mudanças simples, localizadas e fáceis de manter.
|
||||||
|
* Não reescreva o projeto sem necessidade concreta.
|
||||||
|
* Não adicione bibliotecas quando a solução atual for suficiente.
|
||||||
|
* Não implemente pagamentos, CRM, área do cliente, chat, contratos avançados ou funcionalidades fora do MVP.
|
||||||
|
* Não altere arquivos não relacionados sem justificativa.
|
||||||
|
* Preserve alterações locais preexistentes.
|
||||||
|
* Não silencie problemas com `any`, `eslint-disable`, casts inseguros ou desativação de validações.
|
||||||
|
* Não reduza acessibilidade para preservar estética.
|
||||||
|
* Não deixe mocks ou soluções temporárias como implementação final.
|
||||||
|
|
||||||
|
## Critérios de conclusão
|
||||||
|
|
||||||
|
A tarefa estará concluída quando:
|
||||||
|
|
||||||
|
* todas as rotas públicas existentes tiverem sido auditadas;
|
||||||
|
* todos os problemas P0 e P1 solucionáveis tiverem sido corrigidos;
|
||||||
|
* o site funcionar corretamente em mobile e desktop;
|
||||||
|
* não houver overflow horizontal, sobreposição ou componentes quebrados;
|
||||||
|
* menu, navegação, links, CTAs e formulários funcionarem;
|
||||||
|
* o posicionamento social e corporativo estiver claro;
|
||||||
|
* a acessibilidade essencial estiver atendida;
|
||||||
|
* os metadados e fundamentos de SEO estiverem corretos;
|
||||||
|
* os principais problemas de performance tiverem sido tratados;
|
||||||
|
* erros causados ou revelados pelas alterações tiverem sido resolvidos;
|
||||||
|
* lint, TypeScript, testes e build disponíveis tiverem sido executados com sucesso.
|
||||||
|
|
||||||
|
Não declare uma validação como aprovada sem executá-la.
|
||||||
|
|
||||||
|
## Entrega final
|
||||||
|
|
||||||
|
Ao concluir, apresente:
|
||||||
|
|
||||||
|
1. rotas auditadas;
|
||||||
|
2. problemas principais encontrados;
|
||||||
|
3. alterações implementadas;
|
||||||
|
4. arquivos modificados;
|
||||||
|
5. comandos executados e seus resultados;
|
||||||
|
6. decisões e trade-offs relevantes;
|
||||||
|
7. informações que dependem da cliente;
|
||||||
|
8. itens P2 mantidos fora do escopo.
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-08-02
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# Notes — recreate-public-frontend
|
||||||
|
|
||||||
|
## Remaining unresolved (do not invent)
|
||||||
|
|
||||||
|
- **Official contacts:** production e-mail is `amareassessoriaeventos@gmail.com`; WhatsApp and Instagram handles still placeholder until the owner confirms.
|
||||||
|
- **Testimonial authorization:** five real couples from `depoimentos.md` are seeded for local/demo/visual; production publish still requires explicit couple authorization before `published_at` goes live.
|
||||||
|
- **Authorized photography:** public images remain fixture/demo with editorial disclosure notes until Amare supplies an authorized portfolio archive.
|
||||||
|
- **WEB-05 briefing form:** `/contato` stays presentation-only (channels + CTA); lead capture is a future change.
|
||||||
|
|
||||||
|
## Adaptation note
|
||||||
|
|
||||||
|
Mockup `amare-home-editorial.html` was single-page with anchors. Implementation keeps Laravel multipage routes; home carries the editorial narrative, internal pages are chapters with the same Heritage Editorial grammar.
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
O site público da Fase 1 já entrega rotas, CMS, SEO, mídia responsiva, regressão visual e acessibilidade. A identidade visual, porém, ainda é o placeholder da fundação: Instrument Sans, acento ouro, raios arredondados e cartões com sombra. `DESIGN.md` (“Heritage Editorial”), o mockup `amare-home-editorial.html`, o logo fornecido (coração facetado) e `depoimentos.md` (cinco casais reais) definem a marca a materializar.
|
||||||
|
|
||||||
|
Restrições que condicionam o desenho:
|
||||||
|
|
||||||
|
- Arquitetura multipágina Laravel/Blade/CMS já aprovada; o mockup HTML é single-page com âncoras — adaptar, não portar literalmente.
|
||||||
|
- Contato permanece placeholder (WEB-05 fora de escopo); CTAs levam a `/contato` sem criar leads.
|
||||||
|
- Fontes self-hosted via Vite (determinismo visual); sem Google Fonts CDN.
|
||||||
|
- Imagens públicas via disco configurado + variantes; Unsplash do mockup não entra no app.
|
||||||
|
- `PRODUCT.md`: São Paulo capital; seeders atuais ainda usam Fortaleza e depoimentos fictícios.
|
||||||
|
- Change paralela `complete-foundation-parity` não bloqueia nem é bloqueada por esta.
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
|
||||||
|
- Materializar Heritage Editorial em todas as rotas públicas (home, serviços, portfólio, caso, sobre, contato, privacidade, 404, 500).
|
||||||
|
- Home como capa do “Dossiê Editorial do Evento”: hero, manifesto, serviços, portfólio, método (4 passos), depoimentos reais, perfil Amare, CTA final.
|
||||||
|
- Tokens centralizados alinhados a `DESIGN.md`; EB Garamond única família; radius 0; elevação por campos tonais.
|
||||||
|
- Logo oficial otimizado (selo + lockup) em fundos claros/escuros, geometria preservada.
|
||||||
|
- Cinco depoimentos de `depoimentos.md` no CMS/seed, multipárrafo, com nota de autorização.
|
||||||
|
- Manter publicação dinâmica, paginação, eager loading, SEO, axe, teclado, contraste AA e baselines determinísticas.
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
|
||||||
|
- Briefing funcional / lead (WEB-05), WhatsApp automatizado, inventar provas corporativas.
|
||||||
|
- Redesign do Filament, page builder, single-page navigation como modelo primário.
|
||||||
|
- Fotografia proprietária real (permanece ilustrativa e marcada até acervo autorizado).
|
||||||
|
- Staging/deploy/auth parity (`complete-foundation-parity`).
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### D1 — Multipágina editorial, não single-page literal
|
||||||
|
|
||||||
|
Preservar rotas do SPEC §5.1. Home concentra a narrativa do mockup; páginas internas herdam a mesma gramática (eyebrow, títulos, linhas 1px, spreads assimétricos no desktop, sequência linear no mobile). Header usa links de rota (não `#âncoras` como navegação primária), com CTA “Solicitar proposta” → `contact`.
|
||||||
|
|
||||||
|
*Alternativas:* home quase idêntica com âncoras (rejeitada: conflita com SEO/CMS/rotas já testadas); portar HTML estático (rejeitada: perde CMS e determinismo).
|
||||||
|
|
||||||
|
### D2 — Tokens Heritage Editorial como única fonte visual
|
||||||
|
|
||||||
|
Reescrever `resources/css/tokens.css` e o mapeamento `@theme` em `app.css`:
|
||||||
|
|
||||||
|
| Papel | Token | Valor |
|
||||||
|
|-------|-------|-------|
|
||||||
|
| Fundo | `--amare-color-bg` | `#FBF9F4` (Papel Marfim) |
|
||||||
|
| Fundo profundo | `--amare-color-bg-deep` | `#F0EEE9` |
|
||||||
|
| Arquivo | `--amare-color-bg-archive` | `#E4E2DD` |
|
||||||
|
| Oliva | `--amare-color-accent` | `#556B2F` |
|
||||||
|
| Oliva profunda | `--amare-color-accent-deep` | `#3E5219` |
|
||||||
|
| Sálvia | `--amare-color-sage` | `#8B9D77` |
|
||||||
|
| Tinta | `--amare-color-text` | `#1B1C19` |
|
||||||
|
| Tinta suave | `--amare-color-muted` | `#5D6155` |
|
||||||
|
| Linha | `--amare-color-border` | `#C5C8B8` |
|
||||||
|
| Radius | `--amare-radius-*` | `0` |
|
||||||
|
| Container | `--amare-container-max` | `1120px` |
|
||||||
|
| Sombra | removida / não usada em conteúdo | — |
|
||||||
|
|
||||||
|
Tipografia: EB Garamond (400/500/600) self-hosted via Vite; escala display/headline/title/body/label conforme `DESIGN.md`. Componentes públicos deixam de usar `rounded-*` e shadows de cartão.
|
||||||
|
|
||||||
|
*Alternativa:* CSS inline do mockup (rejeitada: foge de `design-tokens` e quebra Tailwind/`@theme`).
|
||||||
|
|
||||||
|
### D3 — Composição da home e omissão de seções vazias
|
||||||
|
|
||||||
|
Ordem canônica:
|
||||||
|
|
||||||
|
1. Hero (settings + imagem OG/hero se houver)
|
||||||
|
2. Manifesto (copy de settings)
|
||||||
|
3. Serviços em destaque (lista editorial, não grid de cartões)
|
||||||
|
4. Portfólio em destaque (bloco escuro oliva; funde proof+cases atuais)
|
||||||
|
5. Método (4 passos: Escuta, Direção, Produção, Execução)
|
||||||
|
6. Depoimentos publicados
|
||||||
|
7. Perfil / posicionamento Amare (about + princípios)
|
||||||
|
8. CTA final → `/contato`
|
||||||
|
|
||||||
|
Seções alimentadas por collections (serviços, casos, depoimentos) **omitidas** quando vazias. Manifesto, método, perfil e CTA final permanecem (copy de settings / defaults editoriais). Um único `h1` no hero; demais seções usam `h2`/`h3`.
|
||||||
|
|
||||||
|
### D4 — Contato continua presentation-only
|
||||||
|
|
||||||
|
`/contato` e o CTA da home mostram canais de `site_settings` (e-mail, telefone, cidade, sociais). Nenhum `<form>` funcional, nenhum lead. O mockup de formulário serve só como referência visual futura para WEB-05; nesta change o bloco de contato da home é CTA editorial + link para a página de contato, não formulário embutido.
|
||||||
|
|
||||||
|
### D5 — Extensão mínima tipada de `site_settings`
|
||||||
|
|
||||||
|
Novos campos tipados (não key/value genérico):
|
||||||
|
|
||||||
|
- `logo_path` / `logo_alt` (opcional; fallback para lockup estático em `public/`)
|
||||||
|
- `hero_secondary_cta_label` (opcional)
|
||||||
|
- `hero_note` (texto curto sob CTAs)
|
||||||
|
- `manifesto_title`, `manifesto_lead`, `manifesto_body`
|
||||||
|
- `method_intro` (opcional; passos estruturados em JSON tipado ou colunas `method_step_{1..4}_{title,body}` — preferir JSONB `method_steps` validado no Filament)
|
||||||
|
- `principles` (JSONB lista de até 4 strings) **ou** quatro colunas `principle_1..4`
|
||||||
|
- Manter `about_summary`, hero atual, contato, SEO, analytics
|
||||||
|
|
||||||
|
Filament `ManageSiteSettings` ganha seções editoriais em pt-BR. Defaults no seeder alinhados ao mockup + São Paulo.
|
||||||
|
|
||||||
|
*Alternativa:* hardcode de manifesto/método nas Blade (rejeitada parcialmente: método/princípios podem ter default no view, mas copy institucional deve ser editável como o hero).
|
||||||
|
|
||||||
|
### D6 — Logo: ativo estático + campo CMS opcional
|
||||||
|
|
||||||
|
1. Converter/otimizar o PNG fornecido para WebP/SVG derivados em `public/brand/` (selo coração + lockup completo), com versões para fundo claro (oliva) e fundo escuro (papel/branco).
|
||||||
|
2. Componente `<x-brand.logo>` escolhe variante por contexto (`on-dark` / `on-light`) e expõe `alt` acessível.
|
||||||
|
3. Se `logo_path` em settings estiver preenchido, usa o upload; senão, o estático versionado.
|
||||||
|
|
||||||
|
Não redesenhar o coração facetado; não inventar polígonos decorativos genéricos.
|
||||||
|
|
||||||
|
### D7 — Depoimentos reais multipárrafo
|
||||||
|
|
||||||
|
- Seedar os 5 casais de `depoimentos.md` em `quote` (texto completo com quebras `\n\n`), `author_name`, `context` (ex.: `Casamento · 06/12/2025`), `sort_order`, `is_featured`, `published_at` conforme ambiente.
|
||||||
|
- Blade renderiza parágrafos a partir de quebras de linha; tipografia editorial (aspas, offset).
|
||||||
|
- Nota discreta no markup/admin: autorização final dos casais antes de publicação em produção.
|
||||||
|
- Remover depoimentos fictícios dos seeders de demo/visual ou substituí-los pelos reais (visual seeder usa subset determinístico, tipicamente 2 featured).
|
||||||
|
|
||||||
|
### D8 — Navegação responsiva com JS mínimo
|
||||||
|
|
||||||
|
`resources/js/app.js` ganha toggle de menu mobile (aria-expanded, `menu-open`, fechar ao navegar), espelhando o mockup. Sem framework novo. `prefers-reduced-motion` continua a anular transições não essenciais. Hover de imagem (scale leve) só quando motion permitido.
|
||||||
|
|
||||||
|
### D9 — Páginas internas como capítulos
|
||||||
|
|
||||||
|
| Rota | Tratamento |
|
||||||
|
|------|------------|
|
||||||
|
| `/servicos` | Lista editorial (número + nome + resumo), não cartões |
|
||||||
|
| `/portfolio` | Grade assimétrica / stack com captions; fundo pode usar papel profundo |
|
||||||
|
| `/portfolio/{slug}` | Caderno de caso: metadados, desafio/solução/resultado, galeria |
|
||||||
|
| `/sobre` | Perfil editorial + princípios |
|
||||||
|
| `/contato` | Canais + CTA textual (sem form) |
|
||||||
|
| `/privacidade` | Tipografia editorial sobre papel |
|
||||||
|
| 404/500 | Mesma linguagem; 500 sem internals |
|
||||||
|
|
||||||
|
### D10 — Testes e baselines
|
||||||
|
|
||||||
|
- Atualizar feature tests de home (ordem de seções, omissão, CTA → contact, `data-testid="home-primary-cta"`).
|
||||||
|
- Browser: axe nas rotas cobertas; Tab até CTA; console limpo.
|
||||||
|
- `composer visual:update` após aprovação visual local; timezone permanece `America/Fortaleza` (SPEC); copy de cidade pública passa a São Paulo.
|
||||||
|
- Fotos fixture locais continuam; filtro CSS de saturação contida via classe utilitária, não via URL externa.
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- **Regeneração de 8+ snapshots** → risco de ruído no PR; mitigar com seeder visual estável e revisão humana do diff.
|
||||||
|
- **EB Garamond em forms/UI** → legibilidade de labels uppercase; mitigar com letter-spacing e peso 600 conforme DESIGN.md; validar contraste AA.
|
||||||
|
- **Depoimentos longos** → layout quebra em mobile; mitigar com tipografia responsiva e subset featured na home.
|
||||||
|
- **Autorização de depoimentos** → risco legal/reputacional; mitigar com nota explícita e `published_at` null até autorização.
|
||||||
|
- **Campos novos em site_settings** → migração + Filament; mitigar com defaults e nullable.
|
||||||
|
- **Paridade com mockup single-page** → expectativa visual vs rotas; documentar adaptação multipágina na proposta e no surface brief.
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
1. Migrar tokens/fontes/logo estático (sem breaking de rotas).
|
||||||
|
2. Migrar `site_settings` (colunas novas nullable + backfill de defaults).
|
||||||
|
3. Atualizar seeders (SP + depoimentos reais).
|
||||||
|
4. Trocar layout e páginas; manter contratos de testes passando incrementalmente.
|
||||||
|
5. Regenerar baselines com `composer visual:update`.
|
||||||
|
6. Rollback: reverter deploy/commit; migração down remove colunas novas; assets estáticos são aditivos.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- Formato exato de `method_steps` / `principles` (JSONB vs colunas) — default recomendado: JSONB validado no Filament.
|
||||||
|
- WhatsApp/e-mail/Instagram oficiais ainda ausentes — settings continuam placeholder até o dono informar.
|
||||||
|
- Subconjunto de depoimentos na home (2 vs 5) — default: featured first, até 2 na home estilo mockup; listagem completa só se houver página dedicada (não há); home mostra todos published featured ou os N primeiros por `sort_order` (cap 2–3 para ritmo editorial).
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
O site público já existe com rotas, CMS e gates de qualidade, mas a identidade visual ainda é o placeholder da Fase 0 (Instrument Sans, ouro, cantos arredondados, cartões genéricos). `DESIGN.md` e o mockup editorial já definem o sistema Heritage Editorial; `depoimentos.md` e o logo fornecido finalmente permitem prova e marca reais. Sem recriar o frontend agora, o critério de saída da Fase 1 (“site público aprovado visualmente”) permanece ligado a uma UI que não representa a marca, e a Fase 2 (WEB-05) herdaria um shell genérico.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- Substituir tokens, tipografia e composição do site público pelo sistema **Heritage Editorial** de `DESIGN.md` (EB Garamond, oliva/papel, cantos retos, campos tonais, sem sombras de cartão SaaS).
|
||||||
|
- Adaptar o mockup `amare-home-editorial.html` à arquitetura **multipágina** Laravel/CMS existente: home como capa editorial; serviços, portfólio, caso, sobre, contato, privacidade e erros como capítulos coerentes.
|
||||||
|
- Incorporar o logo fornecido (coração facetado + lockup) como ativo otimizado com variantes para fundos claros/escuros, sem redesenhar a geometria.
|
||||||
|
- Seedar e renderizar os **cinco depoimentos reais** de `depoimentos.md` (texto, casal, data/contexto), com autorização final como requisito de publicação.
|
||||||
|
- Reestruturar a home na narrativa editorial: hero → manifesto → serviços → portfólio → método → depoimentos → perfil Amare → CTA final (WEB-01).
|
||||||
|
- Atualizar layout público (header/footer, navegação responsiva, selo), componentes Blade e páginas internas para a mesma linguagem visual.
|
||||||
|
- Estender `site_settings` o mínimo necessário para copy editorial (manifesto, nota do hero, CTA secundário, passos do método, princípios) mantendo singleton tipado (WEB-06).
|
||||||
|
- Regenerar baselines visuais desktop/mobile e manter axe, teclado, landmarks, SEO e publicação dinâmica (SPEC §6.5, §13.5, §13.8).
|
||||||
|
- Atualizar seeders/conteúdo demonstrativo para São Paulo e marcar fotografias ilustrativas até existir acervo autorizado.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
Conforme [SPEC.md §4.2](../../SPEC.md) e decisões desta proposta:
|
||||||
|
|
||||||
|
- Formulário funcional de briefing / criação de lead (WEB-05) — permanece placeholder em `/contato`; change futura `build-lead-capture`.
|
||||||
|
- Integração WhatsApp, portal do cliente, page builder, i18n, PWA.
|
||||||
|
- Inventar cases corporativos, credenciais, números, imprensa ou provas não autorizadas.
|
||||||
|
- Redesign do painel Filament / área interna.
|
||||||
|
- Deploy staging / paridade de fundação — change paralela `complete-foundation-parity`, independente desta.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
<!-- Nenhuma capability nova: a recriação altera requisitos de capabilities já existentes. -->
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
- `design-tokens`: substituir tokens placeholder (Instrument Sans, ouro, raios, sombras) pelos tokens Heritage Editorial (EB Garamond, oliva/papel, radius 0, elevação tonal).
|
||||||
|
- `public-site-pages`: reestruturar home editorial e páginas públicas para a composição “Dossiê Editorial do Evento”, preservando rotas e publicação.
|
||||||
|
- `site-settings`: campos tipados adicionais para copy editorial da home (manifesto, CTAs, método, princípios, logo).
|
||||||
|
- `testimonials`: suporte a depoimentos multipárrafo reais com contexto/data e regra de autorização antes da publicação.
|
||||||
|
- `content-media`: logo da marca e tratamento editorial de imagens demonstrativas (marcação, filtros contidos) sem inventar acervo.
|
||||||
|
- `visual-regression`: baselines regeneradas sob a nova identidade; determinismo e cobertura de telas mantidos.
|
||||||
|
- `web-accessibility`: preservação/reforço de landmarks, foco, teclado, contraste AA e movimento reduzido após a troca tipográfica/cromática.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- **Altera**: `resources/css/tokens.css`, `resources/css/app.css`, `vite.config.js`, `resources/views/layouts/public.blade.php`, `resources/views/pages/**`, `resources/views/components/home/**`, `resources/js/app.js`, seeders (`ContentSeeder`, `VisualContentSeeder`), possivelmente migração + model/Filament de `site_settings`, testes Feature/Browser e snapshots em `tests/.pest/snapshots/`.
|
||||||
|
- **Cria**: ativo de logo em `public/` (ou storage CMS), componentes Blade de marca/manifesto/positioning, campos tipados novos em `site_settings` se necessário.
|
||||||
|
- **Depende de**: specs atuais `design-tokens`, `public-site-pages`, `site-settings`, `testimonials`, `content-media`, `visual-regression`, `web-accessibility`, `public-seo`, `service-catalog`, `portfolio-cases`.
|
||||||
|
- **Independente de**: `complete-foundation-parity` (auth/staging/coverage).
|
||||||
|
- **Risco**: regeneração ampla de snapshots; mitigado por seed determinístico, fontes self-hosted e `composer visual:update` com revisão humana. Depoimentos reais exigem confirmação de autorização antes de publicar em produção.
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Brand logo assets are available to the public layout
|
||||||
|
|
||||||
|
The system SHALL provide optimized Amare brand logo assets derived from the official faceted-heart lockup for use in the public header, footer and institutional pages. Assets MUST preserve the original geometry, include an accessible text alternative, and provide variants suitable for light and dark tonal fields. When `site_settings.logo_path` is present, the uploaded logo MUST be used; otherwise the versioned static brand asset MUST be used.
|
||||||
|
|
||||||
|
#### Scenario: Header renders brand mark with alt text
|
||||||
|
|
||||||
|
- **WHEN** any public page is rendered
|
||||||
|
- **THEN** the brand mark image or equivalent MUST expose accessible alternative text identifying Amare Assessoria
|
||||||
|
|
||||||
|
#### Scenario: Dark portfolio field uses a legible logo variant
|
||||||
|
|
||||||
|
- **WHEN** the brand mark is rendered on an olive-deep or otherwise dark public surface
|
||||||
|
- **THEN** the chosen logo variant MUST remain legible against that background
|
||||||
|
|
||||||
|
#### Scenario: Uploaded logo overrides static fallback
|
||||||
|
|
||||||
|
- **GIVEN** an admin has saved `logo_path` and `logo_alt` in site settings
|
||||||
|
- **WHEN** the public layout renders the brand mark
|
||||||
|
- **THEN** the uploaded logo MUST be used instead of the static fallback
|
||||||
|
|
||||||
|
### Requirement: Editorial image treatment remains self-hosted and deterministic
|
||||||
|
|
||||||
|
Public photography SHALL continue to use validated self-hosted uploads and responsive variants. Decorative saturation/contrast treatment for editorial mood MUST be applied via CSS on self-hosted images and MUST NOT introduce external image CDN dependencies that break deterministic visual tests.
|
||||||
|
|
||||||
|
#### Scenario: Public pages do not depend on external stock hosts
|
||||||
|
|
||||||
|
- **WHEN** the visual or browser suite loads covered public routes
|
||||||
|
- **THEN** content images MUST resolve from the application media disk or static fixtures
|
||||||
|
- **AND** MUST NOT require network access to third-party stock hosts
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Design tokens are centralized for the public site
|
||||||
|
|
||||||
|
The system SHALL define design tokens in a single source consumed by the public site layout and components. Tokens MUST implement the Heritage Editorial system from `DESIGN.md`: typography family EB Garamond (self-hosted), font scale (display/headline/title/body/label), spacing on an 8px rhythm, border radius `0` for interactive and content surfaces, container max width `1120px`, paper/olive/sage/ink color roles, transition duration/easing, and MUST NOT rely on card shadows as a hierarchy mechanism for regular content.
|
||||||
|
|
||||||
|
#### Scenario: Public layout uses shared Heritage Editorial tokens
|
||||||
|
|
||||||
|
- **WHEN** a public page is rendered
|
||||||
|
- **THEN** visual properties MUST be derived from the centralized token definitions rather than arbitrary inline values
|
||||||
|
- **AND** the primary typeface MUST be EB Garamond (or the declared serif fallback stack)
|
||||||
|
- **AND** public content surfaces MUST use `0` border radius from tokens
|
||||||
|
|
||||||
|
#### Scenario: Palette commits paper and olive regions
|
||||||
|
|
||||||
|
- **WHEN** the public site is rendered
|
||||||
|
- **THEN** background regions MUST use paper ivory / paper deep / olive deep tokens rather than pure white card stacks on a white page
|
||||||
|
- **AND** primary interactive emphasis MUST use olive heritage (`#556B2F`) / olive deep (`#3E5219`) tokens
|
||||||
|
|
||||||
|
### Requirement: Public site respects reduced motion preference
|
||||||
|
|
||||||
|
The system SHALL honor `prefers-reduced-motion` by disabling or minimizing non-essential animations and transitions on the public site, including image hover scales and menu transitions.
|
||||||
|
|
||||||
|
#### Scenario: User prefers reduced motion
|
||||||
|
|
||||||
|
- **WHEN** a visitor has `prefers-reduced-motion: reduce` enabled
|
||||||
|
- **THEN** the public site MUST NOT play non-essential motion effects
|
||||||
|
|
||||||
|
### Requirement: Public site meets baseline accessibility contrast
|
||||||
|
|
||||||
|
The system SHALL use Heritage Editorial color combinations that meet WCAG AA contrast requirements for text and interactive elements. Long-form text MUST use ink on paper; sage MUST NOT replace reading color when contrast would fall below AA.
|
||||||
|
|
||||||
|
#### Scenario: Primary text is readable
|
||||||
|
|
||||||
|
- **WHEN** primary body text is rendered on its background color
|
||||||
|
- **THEN** the contrast ratio MUST meet WCAG AA minimums
|
||||||
|
|
||||||
|
#### Scenario: Olive on paper interactive text is readable
|
||||||
|
|
||||||
|
- **WHEN** primary buttons or links use olive tokens on paper backgrounds (or paper text on olive)
|
||||||
|
- **THEN** the contrast ratio MUST meet WCAG AA minimums
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Elevation comes from tonal fields not card shadows
|
||||||
|
|
||||||
|
The public site SHALL express hierarchy through tonal paper fields, 1px botanical rules, and editorial overlap. Regular content components MUST NOT use short grey SaaS card shadows.
|
||||||
|
|
||||||
|
#### Scenario: Content cards omit drop shadows
|
||||||
|
|
||||||
|
- **WHEN** home services, testimonials, or portfolio items are rendered
|
||||||
|
- **THEN** they MUST NOT depend on `--amare-shadow-*` card elevation for hierarchy
|
||||||
|
- **AND** separation MUST come from borders, tonal backgrounds, or whitespace
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Home renders the editorial structure from CMS content
|
||||||
|
|
||||||
|
The home page SHALL render, in order: header/navigation, hero, manifesto, featured services summary, featured portfolio selection, working method (four steps), testimonials, Amare positioning/profile, final contact CTA, and footer with contact, social links and legal links (WEB-01). Hero copy, brand name, manifesto, method and principles MUST come from `site_settings` (with editorial defaults when optional fields are empty); services, cases and testimonials MUST come from published records. The home MUST follow the Heritage Editorial composition (asymmetric spreads on desktop, linear sequence on mobile) rather than rounded card grids.
|
||||||
|
|
||||||
|
#### Scenario: Published content is displayed in configured order
|
||||||
|
|
||||||
|
- **GIVEN** published services, cases and testimonials exist
|
||||||
|
- **WHEN** a visitor loads the home
|
||||||
|
- **THEN** the published content MUST be displayed following the `sort_order` and featured flags
|
||||||
|
- **AND** the hero MUST show the values stored in `site_settings`
|
||||||
|
- **AND** the manifesto, method and positioning sections MUST be present
|
||||||
|
|
||||||
|
#### Scenario: CTA leads to the contact placeholder page
|
||||||
|
|
||||||
|
- **WHEN** a visitor activates the primary or final CTA on the home
|
||||||
|
- **THEN** the visitor MUST be taken to the `contact` route
|
||||||
|
- **AND** no lead record MUST be created
|
||||||
|
|
||||||
|
#### Scenario: Empty catalog sections are omitted
|
||||||
|
|
||||||
|
- **GIVEN** no published services, cases or testimonials
|
||||||
|
- **WHEN** a visitor loads the home
|
||||||
|
- **THEN** the response MUST be 200
|
||||||
|
- **AND** the services, portfolio and testimonials sections MUST be omitted instead of rendering empty containers
|
||||||
|
- **AND** hero, manifesto, method, positioning and final CTA MUST still render
|
||||||
|
|
||||||
|
#### Scenario: Home has no console errors
|
||||||
|
|
||||||
|
- **WHEN** the home is loaded in a real browser at desktop and mobile viewports
|
||||||
|
- **THEN** the browser console MUST contain no JavaScript errors
|
||||||
|
|
||||||
|
### Requirement: Listing and detail pages exist for catalog content
|
||||||
|
|
||||||
|
The system SHALL render a services listing (WEB-02) and a portfolio listing plus case detail (WEB-03) using the Heritage Editorial visual language. The case detail MUST present summary, event type, optional city/venue/date, challenge, solution, optional result, cover image and the ordered gallery.
|
||||||
|
|
||||||
|
#### Scenario: Services listing shows published services
|
||||||
|
|
||||||
|
- **WHEN** a visitor loads `/servicos`
|
||||||
|
- **THEN** every published service MUST be listed with title and summary in `sort_order`
|
||||||
|
- **AND** the listing MUST use the public editorial layout (not an unrelated visual system)
|
||||||
|
|
||||||
|
#### Scenario: Gallery respects stored order
|
||||||
|
|
||||||
|
- **GIVEN** a published case with multiple gallery images
|
||||||
|
- **WHEN** a visitor loads the case detail
|
||||||
|
- **THEN** the images MUST be rendered ordered by `sort_order`
|
||||||
|
|
||||||
|
#### Scenario: Listings paginate open-ended growth
|
||||||
|
|
||||||
|
- **WHEN** the number of published cases exceeds the page size
|
||||||
|
- **THEN** `/portfolio` MUST paginate instead of rendering all records
|
||||||
|
|
||||||
|
### Requirement: Institutional and error pages have brand identity
|
||||||
|
|
||||||
|
The system SHALL provide the Sobre and Política de privacidade pages and branded error pages (WEB-07) using the Heritage Editorial public layout, including the brand mark when available. The 404 page MUST use the public layout, and the 500 page MUST NOT expose stack traces or internal details when `APP_DEBUG` is false.
|
||||||
|
|
||||||
|
#### Scenario: Unknown URL renders branded 404
|
||||||
|
|
||||||
|
- **WHEN** a visitor requests a non-existent public URL
|
||||||
|
- **THEN** the response status MUST be 404
|
||||||
|
- **AND** the page MUST use the public layout and offer navigation back to the home
|
||||||
|
|
||||||
|
#### Scenario: Server error hides internals in production
|
||||||
|
|
||||||
|
- **GIVEN** `APP_DEBUG` is false
|
||||||
|
- **WHEN** an unhandled exception occurs on a public route
|
||||||
|
- **THEN** the response MUST be a generic branded error page
|
||||||
|
- **AND** MUST NOT contain a stack trace, file path, or environment variable
|
||||||
|
|
||||||
|
### Requirement: Contact page presents contact data as briefing placeholder
|
||||||
|
|
||||||
|
The `contact` route SHALL render the contact page using `site_settings` (e-mail, phone, city, social links) so the home CTA has a valid destination before the briefing form exists. The page MUST NOT create leads and MUST NOT submit a functional briefing form in this change.
|
||||||
|
|
||||||
|
#### Scenario: Contact page shows configured contact data
|
||||||
|
|
||||||
|
- **WHEN** a visitor loads `/contato`
|
||||||
|
- **THEN** the e-mail and phone stored in `site_settings` MUST be displayed
|
||||||
|
- **AND** no lead record MUST be created
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Public header exposes brand mark and responsive navigation
|
||||||
|
|
||||||
|
The public layout SHALL render the Amare brand mark (faceted-heart logo lockup or configured logo), primary route navigation, and a contact CTA. On narrow viewports the navigation MUST be operable via a disclosure control with accessible name and `aria-expanded` state.
|
||||||
|
|
||||||
|
#### Scenario: Desktop header shows navigation and CTA
|
||||||
|
|
||||||
|
- **WHEN** a visitor loads any public page at a desktop viewport
|
||||||
|
- **THEN** the header MUST include brand mark, links to home/services/portfolio/about/contact, and a contact CTA
|
||||||
|
|
||||||
|
#### Scenario: Mobile menu toggles accessibly
|
||||||
|
|
||||||
|
- **WHEN** a visitor activates the menu button on a narrow viewport
|
||||||
|
- **THEN** the primary navigation MUST become available
|
||||||
|
- **AND** the control MUST expose an updated `aria-expanded` value
|
||||||
|
- **AND** activating a navigation link MUST close the menu
|
||||||
|
|
||||||
|
### Requirement: Demonstrative photography is labeled until authorized assets exist
|
||||||
|
|
||||||
|
When public pages render illustrative/demo photography that is not an authorized Amare asset, the system SHALL mark that imagery as demonstrative in visible copy or accessible labeling so visitors are not misled.
|
||||||
|
|
||||||
|
#### Scenario: Portfolio demo imagery is disclosed
|
||||||
|
|
||||||
|
- **WHEN** the home or portfolio renders placeholder photography
|
||||||
|
- **THEN** a visible note or equivalent disclosure MUST indicate the imagery is illustrative pending authorized assets
|
||||||