Compare commits
2 Commits
perf/man-1
...
fix/pr23-c
| Author | SHA1 | Date | |
|---|---|---|---|
| 8ae582455c | |||
| 75ab0b0ff5 |
@@ -8,7 +8,7 @@ APP_URL=http://localhost
|
|||||||
APP_LOCALE=pt_BR
|
APP_LOCALE=pt_BR
|
||||||
APP_FALLBACK_LOCALE=pt_BR
|
APP_FALLBACK_LOCALE=pt_BR
|
||||||
APP_FAKER_LOCALE=pt_BR
|
APP_FAKER_LOCALE=pt_BR
|
||||||
APP_TIMEZONE=America/Sao_Paulo
|
APP_TIMEZONE=America/Fortaleza
|
||||||
|
|
||||||
# Freeze application clock outside production (visual regression / deterministic seeds).
|
# Freeze application clock outside production (visual regression / deterministic seeds).
|
||||||
# Example: APP_FROZEN_NOW=2026-03-15T12:00:00-03:00
|
# Example: APP_FROZEN_NOW=2026-03-15T12:00:00-03:00
|
||||||
|
|||||||
46
.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/Sao_Paulo
|
APP_TIMEZONE: America/Fortaleza
|
||||||
BCRYPT_ROUNDS: 4
|
BCRYPT_ROUNDS: 4
|
||||||
CACHE_STORE: database
|
CACHE_STORE: database
|
||||||
DB_CONNECTION: pgsql
|
DB_CONNECTION: pgsql
|
||||||
@@ -51,36 +51,10 @@ 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@v5
|
- uses: actions/checkout@v5
|
||||||
- run: cp .env.example .env
|
- run: cp .env.example .env
|
||||||
@@ -89,7 +63,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
php-version: "8.4"
|
php-version: "8.4"
|
||||||
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd
|
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd
|
||||||
coverage: pcov
|
coverage: none
|
||||||
|
|
||||||
- uses: actions/cache@v5
|
- uses: actions/cache@v5
|
||||||
with:
|
with:
|
||||||
@@ -97,24 +71,10 @@ jobs:
|
|||||||
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
|
||||||
@@ -221,7 +181,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/Sao_Paulo \
|
-e APP_TIMEZONE=America/Fortaleza \
|
||||||
-e APP_FROZEN_NOW="${APP_FROZEN_NOW}" \
|
-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 \
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ try {
|
|||||||
new PDO($dsn, $config["DB_USERNAME"], $config["DB_PASSWORD"], [PDO::ATTR_TIMEOUT => 3]);
|
new PDO($dsn, $config["DB_USERNAME"], $config["DB_PASSWORD"], [PDO::ATTR_TIMEOUT => 3]);
|
||||||
} catch (PDOException $e) {
|
} 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, "\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, "Start it with: docker compose up -d\n");
|
||||||
fwrite(STDERR, "Then retry the push.\n");
|
fwrite(STDERR, "Then retry the push.\n");
|
||||||
exit(1);
|
exit(1);
|
||||||
}
|
}
|
||||||
|
|||||||
27
AGENTS.md
@@ -7,12 +7,11 @@ This is a Laravel 13 application for an event-planning consultancy. Application
|
|||||||
## Build, Test, and Development Commands
|
## Build, Test, and Development Commands
|
||||||
|
|
||||||
- `composer setup` installs PHP and npm dependencies, creates `.env`, migrates, and builds assets.
|
- `composer setup` installs PHP and npm dependencies, creates `.env`, migrates, and builds assets.
|
||||||
- `docker compose up -d postgres` starts the local PostgreSQL service. `docker compose up -d` (no service name) also builds and starts the `app` service — a local FrankenPHP container for parity with staging/production, see README.md.
|
- `docker compose up -d` starts the local PostgreSQL service.
|
||||||
- `composer dev` runs Laravel, the queue listener, logs, and Vite together.
|
- `composer dev` runs Laravel, the queue listener, logs, and Vite together.
|
||||||
- `npm run build` creates the production frontend bundle.
|
- `npm run build` creates the production frontend bundle.
|
||||||
- `composer quality` runs formatting checks, PHPStan level 5, PHP + npm dependency audits, and every test suite.
|
- `composer quality` runs formatting checks, PHPStan level 5, dependency audit, and every test suite.
|
||||||
- `composer test:unit`, `composer test:feature`, or `composer test:browser` run focused suites.
|
- `composer test:unit`, `composer test:feature`, or `composer test:browser` run focused suites.
|
||||||
- `composer test:coverage` runs the Domain/Application coverage gate (80% minimum, scoped via `phpunit.coverage.xml`) used by CI's `unit` job. Requires a coverage driver (`pcov` or `xdebug`); fails with "No code coverage driver available" without one — that's an environment gap, not a broken repo.
|
|
||||||
|
|
||||||
Feature and browser tests require the `amare_test` PostgreSQL database configured in `phpunit.xml`.
|
Feature and browser tests require the `amare_test` PostgreSQL database configured in `phpunit.xml`.
|
||||||
|
|
||||||
@@ -25,7 +24,7 @@ Always work in a git worktree created from the `main` ref — never modify `main
|
|||||||
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.
|
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-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).
|
- `pre-push`: gates on the `amare_test` database (settings parsed from `phpunit.xml`), blocks the push with a `docker compose up -d` hint when Postgres is unreachable, then runs `composer test:unit` and `composer test:feature`. Browser tests are CI-only (FrankenPHP container).
|
||||||
|
|
||||||
## Coding Style & Naming Conventions
|
## Coding Style & Naming Conventions
|
||||||
|
|
||||||
@@ -42,23 +41,3 @@ 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.
|
|
||||||
|
|||||||
81
CLAUDE.md
@@ -1,81 +0,0 @@
|
|||||||
# CLAUDE.md
|
|
||||||
|
|
||||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
||||||
|
|
||||||
@AGENTS.md
|
|
||||||
|
|
||||||
If the import above did not load, read `AGENTS.md` at the repo root now — it is the primary contract.
|
|
||||||
|
|
||||||
`AGENTS.md` (imported above) is the primary contract: structure, commands, style, testing, husky hooks, commit/PR rules. This file records the cross-file architecture and environment facts that are not obvious from any single file.
|
|
||||||
|
|
||||||
## Non-negotiables (repeated from AGENTS.md because breaking them is expensive)
|
|
||||||
|
|
||||||
- Never modify or commit from the primary working tree on `main`. Create a worktree per branch: `git worktree add -b <branch> <path> main`.
|
|
||||||
- Every project-owned PHP file starts with `declare(strict_types=1);` immediately after `<?php`.
|
|
||||||
- Browser tests are CI-only (they run against a FrankenPHP container built by `docker build`, not `artisan serve`).
|
|
||||||
|
|
||||||
## 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.
|
|
||||||
|
|
||||||
## Visual regression — read before touching baselines
|
|
||||||
|
|
||||||
- Baselines are committed `.snap` files under `tests/.pest/snapshots/Browser/VisualRegressionTest/`.
|
|
||||||
- `tests/Browser/Screenshots/` is gitignored — it only holds diff output.
|
|
||||||
- `composer visual:update` is the sanctioned command, but on macOS it writes baselines CI rejects. Use `scripts/test/visual-update-ci.sh`, which runs it inside the Linux runner built from `docker/ci-runner.Dockerfile`.
|
|
||||||
- **Baselines are Linux-parity artifacts.** Pest Browser does not use FrankenPHP or `artisan serve` — it serves the Laravel kernel from an in-process Amp server (`vendor/pestphp/pest-plugin-browser/src/Drivers/LaravelHttpServer.php`), so the FrankenPHP container the `browser` job starts is only a health check. What makes a baseline reproducible is the machine that renders it: Ubuntu 24.04, Playwright's Chromium, and Playwright's font packages (`StableScreenshot` forces `Arial`, which fontconfig resolves to Liberation Sans on Linux). Commit `4578457` exists because macOS renders text differently.
|
|
||||||
|
|
||||||
Determinism relies on three cooperating pieces:
|
|
||||||
|
|
||||||
- `APP_FROZEN_NOW` → `CarbonImmutable::setTestNow()` in `AppServiceProvider::freezeClockWhenConfigured()` (no-op in production).
|
|
||||||
- `Database\Seeders\VisualContentSeeder::FROZEN_NOW` — the value the browser tests and the CI job both pin to.
|
|
||||||
- `Tests\Support\StableScreenshot` — forces Arial, disables transitions/animations, scrolls the page to settle lazy images, and avoids the flaky `networkidle` wait.
|
|
||||||
|
|
||||||
## 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,10 +23,6 @@ RUN npm ci
|
|||||||
COPY vite.config.js ./
|
COPY vite.config.js ./
|
||||||
COPY resources ./resources
|
COPY resources ./resources
|
||||||
COPY public ./public
|
COPY public ./public
|
||||||
# resources/css/filament/admin/theme.css imports Filament's own uncompiled CSS,
|
|
||||||
# so the Vite build needs those files present. Only Filament's subtree is copied
|
|
||||||
# rather than all of vendor/, to keep this stage's context small.
|
|
||||||
COPY --from=composer /app/vendor/filament ./vendor/filament
|
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
FROM dunglas/frankenphp:1-php${PHP_VERSION}-bookworm AS runtime
|
FROM dunglas/frankenphp:1-php${PHP_VERSION}-bookworm AS runtime
|
||||||
|
|||||||
23
README.md
@@ -4,7 +4,7 @@ Aplicação Laravel 13 para assessoria de eventos (Fase 0 — Fundação).
|
|||||||
|
|
||||||
## Requisitos
|
## Requisitos
|
||||||
|
|
||||||
- PHP 8.4 com extensões `pdo_pgsql`, `intl`, `mbstring`, `zip`, `sodium` (canônico do `Dockerfile` e do CI; `composer.json` aceita `^8.3`)
|
- PHP 8.5+ com extensões `pdo_pgsql`, `intl`, `mbstring`, `zip`, `sodium`
|
||||||
- Composer 2.x
|
- Composer 2.x
|
||||||
- Node.js 22+ e npm
|
- Node.js 22+ e npm
|
||||||
- Docker e Docker Compose
|
- Docker e Docker Compose
|
||||||
@@ -18,10 +18,10 @@ cp .env.example .env
|
|||||||
php artisan key:generate
|
php artisan key:generate
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Suba o PostgreSQL (requer o `.env` do passo 1 — o serviço `app` referencia esse arquivo via `env_file`, e o Compose valida todo o `docker-compose.yml` mesmo ao subir só o `postgres`):
|
2. Suba o PostgreSQL:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up -d postgres
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Instale dependências e rode migrations:
|
3. Instale dependências e rode migrations:
|
||||||
@@ -49,24 +49,12 @@ Painel interno: `/admin`
|
|||||||
|
|
||||||
Healthcheck: `GET /up`
|
Healthcheck: `GET /up`
|
||||||
|
|
||||||
## Paridade local com produção (FrankenPHP via Docker Compose)
|
|
||||||
|
|
||||||
Além do fluxo host-side acima, `docker-compose.yml` tem um serviço `app` que builda a mesma imagem FrankenPHP usada em staging/produção (`Dockerfile`), útil para testar o comportamento real do container antes do deploy:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose up -d # sobe postgres + app
|
|
||||||
php artisan migrate # rode migrations (o entrypoint do container não migra sozinho)
|
|
||||||
curl localhost:8000/up
|
|
||||||
```
|
|
||||||
|
|
||||||
O serviço `app` depende de `postgres` estar saudável (`depends_on: condition: service_healthy`) e sobrescreve `DB_HOST`/`DB_PORT` do `.env` para apontar para o serviço `postgres` pelo nome (o padrão `127.0.0.1` do `.env` só funciona para processos rodando no host). Não há job de CI dedicado a este smoke — o job `container` do CI já builda e healthcheca a mesma imagem.
|
|
||||||
|
|
||||||
## Variáveis principais
|
## Variáveis principais
|
||||||
|
|
||||||
| Variável | Valor local |
|
| Variável | Valor local |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `APP_LOCALE` | `pt_BR` |
|
| `APP_LOCALE` | `pt_BR` |
|
||||||
| `APP_TIMEZONE` | `America/Sao_Paulo` |
|
| `APP_TIMEZONE` | `America/Fortaleza` |
|
||||||
| `DB_CONNECTION` | `pgsql` |
|
| `DB_CONNECTION` | `pgsql` |
|
||||||
| `SESSION_DRIVER` | `database` |
|
| `SESSION_DRIVER` | `database` |
|
||||||
| `CACHE_STORE` | `database` |
|
| `CACHE_STORE` | `database` |
|
||||||
@@ -75,7 +63,7 @@ O serviço `app` depende de `postgres` estar saudável (`depends_on: condition:
|
|||||||
## Comandos de qualidade
|
## Comandos de qualidade
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
composer quality # Pint + PHPStan + audit (composer + npm) + testes
|
composer quality # Pint + PHPStan + audit + testes
|
||||||
composer test:unit # Unit + Architecture
|
composer test:unit # Unit + Architecture
|
||||||
composer test:feature # Feature + Livewire + Filament
|
composer test:feature # Feature + Livewire + Filament
|
||||||
composer test:browser # E2E browser
|
composer test:browser # E2E browser
|
||||||
@@ -137,7 +125,6 @@ Após `php artisan db:seed`:
|
|||||||
- [SPEC.md](SPEC.md) — especificação do produto
|
- [SPEC.md](SPEC.md) — especificação do produto
|
||||||
- [docs/adr/](docs/adr/) — ADRs aceitas
|
- [docs/adr/](docs/adr/) — ADRs aceitas
|
||||||
- [docs/conventions/php-strict-types.md](docs/conventions/php-strict-types.md) — convenção de strict types
|
- [docs/conventions/php-strict-types.md](docs/conventions/php-strict-types.md) — convenção de strict types
|
||||||
- [docs/operations/atualizacao-de-conteudo.md](docs/operations/atualizacao-de-conteudo.md) — runbook de atualização de conteúdo do site pelo painel admin
|
|
||||||
- [docs/deployment/dokploy.md](docs/deployment/dokploy.md) — deploy staging/produção no Dokploy + GHCR
|
- [docs/deployment/dokploy.md](docs/deployment/dokploy.md) — deploy staging/produção no Dokploy + GHCR
|
||||||
|
|
||||||
## Deploy (Dokploy)
|
## Deploy (Dokploy)
|
||||||
|
|||||||
112
SPEC.md
@@ -20,7 +20,7 @@
|
|||||||
| Princípio principal | YAGNI — implementar somente o necessário para validar o produto |
|
| Princípio principal | YAGNI — implementar somente o necessário para validar o produto |
|
||||||
| Arquitetura | Monólito modular Laravel |
|
| Arquitetura | Monólito modular Laravel |
|
||||||
| Área interna | Filament |
|
| Área interna | Filament |
|
||||||
| Área pública | Blade + JS vanilla progressivo (Livewire é dependência do Filament, não usada no site público — ver ADR-015) |
|
| Área pública | Livewire + Blade |
|
||||||
| Servidor de aplicação | FrankenPHP em modo regular |
|
| Servidor de aplicação | FrankenPHP em modo regular |
|
||||||
| Banco de dados | PostgreSQL |
|
| Banco de dados | PostgreSQL |
|
||||||
| Testes | Pest, Pest Browser/Playwright e testes visuais |
|
| Testes | Pest, Pest Browser/Playwright e testes visuais |
|
||||||
@@ -194,24 +194,11 @@ Não instalar sistema de permissões granular no MVP.
|
|||||||
|
|
||||||
## 4. Escopo
|
## 4. Escopo
|
||||||
|
|
||||||
> **Recorte vigente do lançamento (ADR-016).** O escopo aprovado para o lançamento de 31/08/2026 é o **site institucional**: Fases 0 e 1. As Fases 2 a 5 — CRM de leads, conversão de lead em evento, eventos, tarefas, fornecedores, orçamento, pagamentos manuais, documentos, dashboard orientado a exceções e auditoria — permanecem especificadas neste documento mas ficam **adiadas**, sem data.
|
|
||||||
>
|
|
||||||
> A §4.1 abaixo descreve o produto completo, não o recorte do lançamento. Os itens marcados como adiados estão fora do que se constrói agora. Ver §18 para a divisão por fase e §23 para as condições de conclusão de cada recorte.
|
|
||||||
|
|
||||||
### 4.1 Incluído no MVP
|
### 4.1 Incluído no MVP
|
||||||
|
|
||||||
No recorte do lançamento (Fases 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 e testes visuais;
|
|
||||||
- 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;
|
||||||
@@ -221,10 +208,12 @@ Adiados para depois do lançamento (Fases 2–5, ver ADR-016):
|
|||||||
- pagamentos inseridos manualmente;
|
- pagamentos inseridos manualmente;
|
||||||
- documentos vinculados a leads e eventos;
|
- documentos vinculados a leads e eventos;
|
||||||
- dashboard orientado a exceções;
|
- dashboard orientado a exceções;
|
||||||
|
- usuários internos e papéis simples;
|
||||||
- notificações internas e por e-mail para novos leads;
|
- notificações internas e por e-mail para novos leads;
|
||||||
- auditoria de ações críticas.
|
- auditoria de ações críticas;
|
||||||
|
- SEO básico;
|
||||||
Adiado **não** é o mesmo que fora do MVP: os itens acima seguem especificados neste documento e continuam sendo o produto pretendido. A §4.2 lista o que **NÃO DEVE** ser implementado em nenhum momento.
|
- acessibilidade e testes visuais;
|
||||||
|
- CI/CD e deploy em contêiner com FrankenPHP.
|
||||||
|
|
||||||
### 4.2 Fora do MVP
|
### 4.2 Fora do MVP
|
||||||
|
|
||||||
@@ -326,8 +315,8 @@ Administração
|
|||||||
|
|
||||||
- Dashboard: `Filament Page` customizada com widgets orientados a exceção.
|
- Dashboard: `Filament Page` customizada com widgets orientados a exceção.
|
||||||
- Detalhe do evento: página customizada do Resource com resumo operacional.
|
- Detalhe do evento: página customizada do Resource com resumo operacional.
|
||||||
- Briefing público: Blade + Controller (`POST /contato`), ver §11.2.
|
- Briefing público: componente Livewire próprio.
|
||||||
- Home: Blade com componentes de design reutilizáveis.
|
- Home: Blade/Livewire com componentes de design reutilizáveis.
|
||||||
|
|
||||||
### 5.4 Decisões de UX YAGNI
|
### 5.4 Decisões de UX YAGNI
|
||||||
|
|
||||||
@@ -1564,7 +1553,7 @@ Constraints de banco devem proteger:
|
|||||||
| Runtime | PHP com versão minor fixada no Docker |
|
| Runtime | PHP com versão minor fixada no Docker |
|
||||||
| Framework | Laravel 13 |
|
| Framework | Laravel 13 |
|
||||||
| Admin | Filament 5 |
|
| Admin | Filament 5 |
|
||||||
| UI pública | Blade + Tailwind + JS vanilla progressivo (sem framework reativo; Livewire 4 confinado ao Filament — ver ADR-015 e §22) |
|
| UI pública | Livewire 4 + Blade + Alpine + Tailwind |
|
||||||
| Banco | PostgreSQL |
|
| Banco | PostgreSQL |
|
||||||
| Servidor | FrankenPHP + Caddy |
|
| Servidor | FrankenPHP + Caddy |
|
||||||
| Assets | Vite |
|
| Assets | Vite |
|
||||||
@@ -1785,21 +1774,21 @@ Usar componentes/Widgets menores e testáveis.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 11. Site público e interatividade
|
## 11. Livewire e site público
|
||||||
|
|
||||||
### 11.1 Estado atual e componentes candidatos
|
### 11.1 Componentes sugeridos
|
||||||
|
|
||||||
O site público **não usa Livewire nem Alpine hoje**. As páginas são Blade renderizado no servidor mais JavaScript vanilla progressivo (`resources/js/app.js` e `resources/js/motion.js`). O `layouts.public` carrega apenas `@vite(['resources/css/app.css', 'resources/js/app.js'])` — nenhum `@livewireScripts`. O pacote `livewire/livewire` existe no projeto apenas como dependência transitiva de `filament/support` e opera somente dentro do painel `/admin`.
|
- `ContactBriefingForm`;
|
||||||
|
- `FeaturedPortfolioCases` se houver necessidade de consulta dinâmica;
|
||||||
|
- `PublishedServices` se houver necessidade de consulta dinâmica.
|
||||||
|
|
||||||
Se surgir a necessidade de consulta dinâmica, os candidatos naturais a Livewire seriam `FeaturedPortfolioCases` e `PublishedServices`. Essa adoção está condicionada ao gatilho registrado em §22.
|
Não transformar todas as seções estáticas em componentes Livewire. Usar Blade quando não houver estado ou interação.
|
||||||
|
|
||||||
Não transformar seções estáticas em componentes Livewire. Usar Blade quando não houver estado ou interação.
|
|
||||||
|
|
||||||
### 11.2 Formulário de briefing
|
### 11.2 Formulário de briefing
|
||||||
|
|
||||||
> **Estado atual:** o formulário é implementado em Blade + Controller (`POST /contato`, `ContactBriefingRequest`), conforme WEB-05, e essa é a abordagem aceita — não um estágio provisório. Os requisitos abaixo valem independentemente da tecnologia; a criação de Lead segue para a Fase 2.
|
> **Estado atual (Fases 0–1):** o formulário é implementado em Blade + Controller (`POST /contato`, `ContactBriefingRequest`), conforme WEB-05. Se a Fase 2 mantiver Blade + Controller, os requisitos abaixo valem para o formulário e seus testes independentemente da tecnologia; a criação de Lead segue para a Fase 2.
|
||||||
|
|
||||||
O formulário deve:
|
O componente deve:
|
||||||
|
|
||||||
- ter estado tipado ou Form Object quando útil;
|
- ter estado tipado ou Form Object quando útil;
|
||||||
- validar no servidor;
|
- validar no servidor;
|
||||||
@@ -1808,16 +1797,15 @@ O formulário deve:
|
|||||||
- preservar acessibilidade;
|
- preservar acessibilidade;
|
||||||
- limpar dados após sucesso;
|
- limpar dados após sucesso;
|
||||||
- evitar exposição de exceção;
|
- evitar exposição de exceção;
|
||||||
- suportar teste de submissão sem navegador (feature test);
|
- suportar teste Livewire sem browser;
|
||||||
- suportar jornada E2E em navegador real.
|
- suportar jornada E2E em navegador real.
|
||||||
|
|
||||||
### 11.3 JavaScript
|
### 11.3 JavaScript
|
||||||
|
|
||||||
- manter o site público em JS vanilla progressivo;
|
- usar Alpine apenas para interações pequenas;
|
||||||
- não introduzir framework SPA;
|
- não introduzir framework SPA;
|
||||||
- não usar dependência JS quando CSS e HTML resolverem;
|
- não usar dependência JS quando CSS/HTML/Livewire resolverem;
|
||||||
- toda interação crítica deve funcionar sem estado global complexo;
|
- toda interação crítica deve funcionar sem estado global complexo.
|
||||||
- Alpine só entra junto com Livewire, se o gatilho de §22 disparar.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -2336,8 +2324,6 @@ Para visual e browser tests:
|
|||||||
|
|
||||||
## 18. Backlog de implementação
|
## 18. Backlog de implementação
|
||||||
|
|
||||||
> **Recorte vigente (ADR-016).** Fases 0 e 1 são o escopo do lançamento e estão concluídas. **Fases 2 a 5 estão adiadas, sem data.** Não iniciar nenhuma delas sem uma decisão nova do responsável pelo produto — a §1.1 manda trabalhar uma fase por vez, e a fase corrente é o acabamento e a publicação do site.
|
|
||||||
|
|
||||||
O agente deve implementar na sequência, salvo instrução explícita.
|
O agente deve implementar na sequência, salvo instrução explícita.
|
||||||
|
|
||||||
### Fase 0 — Fundação
|
### Fase 0 — Fundação
|
||||||
@@ -2349,7 +2335,7 @@ O agente deve implementar na sequência, salvo instrução explícita.
|
|||||||
- [x] Filament instalado e autenticado;
|
- [x] Filament instalado e autenticado;
|
||||||
- [x] Livewire configurado;
|
- [x] Livewire configurado;
|
||||||
- [x] Tailwind/Vite;
|
- [x] Tailwind/Vite;
|
||||||
- [x] FrankenPHP e Docker Compose local;
|
- [~] FrankenPHP e Docker Compose local (imagem pronta; serviço de aplicação local pendente);
|
||||||
- [x] papéis admin/assistant;
|
- [x] papéis admin/assistant;
|
||||||
- [x] Pint;
|
- [x] Pint;
|
||||||
- [x] PHPStan/Larastan;
|
- [x] PHPStan/Larastan;
|
||||||
@@ -2360,11 +2346,11 @@ 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);
|
- [ ] verificação de e-mail e reset seguro (MustVerifyEmail);
|
||||||
- [x] npm audit no `composer quality` e no job `static`;
|
- [ ] npm audit no `composer quality` e no job `static`;
|
||||||
- [x] gate de cobertura `Domain`/`Application` ≥ 80%;
|
- [ ] gate de cobertura `Domain`/`Application` ≥ 80%;
|
||||||
- [x] serviço de aplicação FrankenPHP no Compose local;
|
- [ ] 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`.
|
- [ ] hello-world implantado em staging (critério de saída).
|
||||||
|
|
||||||
> 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.
|
> 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.
|
||||||
|
|
||||||
@@ -2388,7 +2374,7 @@ O agente deve implementar na sequência, salvo instrução explícita.
|
|||||||
|
|
||||||
**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 e site público aprovado visualmente (baselines em `tests/.pest/snapshots/`; aprovação humana do diff visual no PR).
|
||||||
|
|
||||||
### Fase 2 — Leads — ADIADA (ADR-016)
|
### Fase 2 — Leads
|
||||||
|
|
||||||
- [ ] migration/model/factory de Lead;
|
- [ ] migration/model/factory de Lead;
|
||||||
- [ ] LeadActivity;
|
- [ ] LeadActivity;
|
||||||
@@ -2405,7 +2391,7 @@ O agente deve implementar na sequência, salvo instrução explícita.
|
|||||||
|
|
||||||
**Critério de saída:** jornada visitante → lead → tratamento interna totalmente verde.
|
**Critério de saída:** jornada visitante → lead → tratamento interna totalmente verde.
|
||||||
|
|
||||||
### Fase 3 — Eventos e tarefas — ADIADA (ADR-016)
|
### Fase 3 — Eventos e tarefas
|
||||||
|
|
||||||
- [ ] Event;
|
- [ ] Event;
|
||||||
- [ ] EventTask;
|
- [ ] EventTask;
|
||||||
@@ -2420,7 +2406,7 @@ O agente deve implementar na sequência, salvo instrução explícita.
|
|||||||
|
|
||||||
**Critério de saída:** lead é convertido e evento pode ser administrado.
|
**Critério de saída:** lead é convertido e evento pode ser administrado.
|
||||||
|
|
||||||
### Fase 4 — Fornecedores e financeiro — ADIADA (ADR-016)
|
### Fase 4 — Fornecedores e financeiro
|
||||||
|
|
||||||
- [ ] Vendor;
|
- [ ] Vendor;
|
||||||
- [ ] EventVendor;
|
- [ ] EventVendor;
|
||||||
@@ -2436,7 +2422,7 @@ O agente deve implementar na sequência, salvo instrução explícita.
|
|||||||
|
|
||||||
**Critério de saída:** assessora acompanha orçamento e pagamentos sem integração bancária.
|
**Critério de saída:** assessora acompanha orçamento e pagamentos sem integração bancária.
|
||||||
|
|
||||||
### Fase 5 — Documentos, hardening e lançamento — ADIADA (ADR-016)
|
### Fase 5 — Documentos, hardening e lançamento
|
||||||
|
|
||||||
- [ ] documentos privados;
|
- [ ] documentos privados;
|
||||||
- [ ] auditoria completa;
|
- [ ] auditoria completa;
|
||||||
@@ -2514,7 +2500,7 @@ Toda operação financeira deve:
|
|||||||
| ADR | Decisão | Status |
|
| ADR | Decisão | Status |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| ADR-001 | Monólito modular Laravel, sem microserviços | Aceita |
|
| ADR-001 | Monólito modular Laravel, sem microserviços | Aceita |
|
||||||
| ADR-002 | Filament para área interna (Livewire é dependência interna do Filament); site público em Blade — ver ADR-015 | Aceita |
|
| ADR-002 | Filament para área interna e Livewire/Blade para área pública | Aceita |
|
||||||
| ADR-003 | PostgreSQL como único banco transacional | Aceita |
|
| ADR-003 | PostgreSQL como único banco transacional | Aceita |
|
||||||
| ADR-004 | Pagamentos somente manuais | Aceita |
|
| ADR-004 | Pagamentos somente manuais | Aceita |
|
||||||
| ADR-005 | Pest unifica unit, feature, browser e visual | Aceita |
|
| ADR-005 | Pest unifica unit, feature, browser e visual | Aceita |
|
||||||
@@ -2527,8 +2513,6 @@ Toda operação financeira deve:
|
|||||||
| ADR-012 | E-mail transacional via Resend (mailer nativo Laravel) | Aceita |
|
| ADR-012 | E-mail transacional via Resend (mailer nativo Laravel) | Aceita |
|
||||||
| ADR-013 | Design system Heritage Editorial para o site público | Aceita |
|
| ADR-013 | Design system Heritage Editorial para o site público | Aceita |
|
||||||
| ADR-014 | Deploy via Dokploy Compose com imagem imutável por SHA no GHCR | Aceita |
|
| ADR-014 | Deploy via Dokploy Compose com imagem imutável por SHA no 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 |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -2545,7 +2529,6 @@ Toda operação financeira deve:
|
|||||||
| API pública | existe consumidor real e contrato de integração |
|
| API pública | existe consumidor real e contrato de integração |
|
||||||
| Kanban | tabela de leads demonstra limitação frequente observada |
|
| Kanban | tabela de leads demonstra limitação frequente observada |
|
||||||
| Editor de checklist | diferentes tipos de evento exigem manutenção frequente do seed |
|
| Editor de checklist | diferentes tipos de evento exigem manutenção frequente do seed |
|
||||||
| Livewire no site público | portfólio ou serviços exigem consulta ou filtro dinâmico que Blade + JS vanilla não resolvem de forma simples |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -2553,39 +2536,24 @@ Toda operação financeira deve:
|
|||||||
|
|
||||||
O MVP está concluído somente quando:
|
O MVP está concluído somente quando:
|
||||||
|
|
||||||
A ADR-016 divide estas condições em dois recortes. Cada um se fecha por conta própria; o segundo não bloqueia o lançamento.
|
|
||||||
|
|
||||||
### 23.1 Lançamento do site (Fases 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 envia o pedido de proposta de forma segura, com proteção contra abuso e aceite de privacidade registrado;
|
- briefing cria leads de forma segura;
|
||||||
- usuários e Policies estão corretos;
|
|
||||||
- testes unit, feature, 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;
|
||||||
- jornadas E2E das fases correspondentes estão verdes.
|
- testes unit, feature, E2E, visuais e arquitetura estão verdes;
|
||||||
|
- CI bloqueia regressões;
|
||||||
|
- imagem FrankenPHP é reproduzível;
|
||||||
|
- staging e produção usam a mesma imagem promovida;
|
||||||
|
- backup, restauração e monitoramento estão documentados;
|
||||||
|
- nenhum item explicitamente fora do MVP foi introduzido.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,85 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Domain\Contact;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Normalizes a raw phone/WhatsApp number typed into the public briefing
|
|
||||||
* form into a canonical, human-readable Brazilian format.
|
|
||||||
*
|
|
||||||
* The briefing e-mail simply prints the field value in a table, so the
|
|
||||||
* canonical form must stay legible for the staff member reading it —
|
|
||||||
* "(11) 98888-7777" rather than an opaque "11988887777" digit string.
|
|
||||||
*/
|
|
||||||
final class BrazilianPhoneNumber
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Formats to "(DD) 9XXXX-XXXX" for an 11-digit mobile number or
|
|
||||||
* "(DD) XXXX-XXXX" for a 10-digit landline number, stripping a
|
|
||||||
* leading "+55"/"55" country code when present.
|
|
||||||
*
|
|
||||||
* A 10- or 11-digit string is only formatted when it is structurally
|
|
||||||
* plausible as a Brazilian number: the first two digits must be a
|
|
||||||
* possible DDD (`[1-9][1-9]`, since Brazilian area codes run 11-99
|
|
||||||
* and never carry a '0' in either position), and an 11-digit number
|
|
||||||
* must additionally have a '9' as its third digit (mandatory on all
|
|
||||||
* Brazilian mobile numbers since 2012). An explicit "+55" prefix that
|
|
||||||
* leaves no digits for a DDD (e.g. "+55 98888-7777") is treated as a
|
|
||||||
* number missing its area code, not as DDD 55.
|
|
||||||
*
|
|
||||||
* When the digit count does not match either shape, or the shape
|
|
||||||
* fails the checks above (foreign numbers, partial input, extensions,
|
|
||||||
* etc.), the original text is preserved — only whitespace is
|
|
||||||
* collapsed — so no information the recipient might need is
|
|
||||||
* discarded or silently fabricated.
|
|
||||||
*/
|
|
||||||
public static function normalize(string $raw): string
|
|
||||||
{
|
|
||||||
$trimmed = trim($raw);
|
|
||||||
$collapsed = preg_replace('/\s+/', ' ', $trimmed) ?? $trimmed;
|
|
||||||
|
|
||||||
// Only reformat when the text is made exclusively of phone
|
|
||||||
// characters. Anything else — "(WhatsApp)", "falar com João",
|
|
||||||
// a ramal — is information the recipient needs, so it is left
|
|
||||||
// untouched rather than stripped away by the digit extraction.
|
|
||||||
if (preg_match('/^[0-9()+\-.\/ ]+$/', $collapsed) !== 1) {
|
|
||||||
return $collapsed;
|
|
||||||
}
|
|
||||||
|
|
||||||
$digits = preg_replace('/\D+/', '', $collapsed) ?? '';
|
|
||||||
|
|
||||||
if (in_array(strlen($digits), [12, 13], true) && str_starts_with($digits, '55')) {
|
|
||||||
$digits = substr($digits, 2);
|
|
||||||
} elseif (str_starts_with($digits, '55') && preg_match('/^\+\s*55\b/', $collapsed) === 1) {
|
|
||||||
// An explicit "+55" was written, but the total digit count
|
|
||||||
// never reached 12/13, meaning nothing precedes it that could
|
|
||||||
// be a DDD — e.g. "+55 98888-7777" is a mobile number missing
|
|
||||||
// its area code, not DDD 55 with a coincidentally-matching
|
|
||||||
// subscriber number. Guessing a DDD here would fabricate one.
|
|
||||||
return $collapsed;
|
|
||||||
}
|
|
||||||
|
|
||||||
return match (strlen($digits)) {
|
|
||||||
11 => self::isPlausibleDdd($digits) && $digits[2] === '9'
|
|
||||||
? sprintf('(%s) %s-%s', substr($digits, 0, 2), substr($digits, 2, 5), substr($digits, 7))
|
|
||||||
: $collapsed,
|
|
||||||
10 => self::isPlausibleDdd($digits)
|
|
||||||
? sprintf('(%s) %s-%s', substr($digits, 0, 2), substr($digits, 2, 4), substr($digits, 6))
|
|
||||||
: $collapsed,
|
|
||||||
default => $collapsed,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A Brazilian DDD (area code) runs 11-99: the first digit is never
|
|
||||||
* '0' (not a valid leading digit) and the second is never '0' either
|
|
||||||
* (no DDD like "10", "20", "30" exists). This does not check that the
|
|
||||||
* DDD is one of the officially assigned codes — only that its shape
|
|
||||||
* is plausible enough to distinguish it from a foreign number.
|
|
||||||
*/
|
|
||||||
private static function isPlausibleDdd(string $digits): bool
|
|
||||||
{
|
|
||||||
return preg_match('/^[1-9][1-9]/', $digits) === 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,181 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Domain\Contact;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Captures the marketing origin of a visit (SPEC.md WEB-05, "origem de
|
|
||||||
* marketing capturada quando disponível") from raw, untrusted request
|
|
||||||
* data and turns it into a short, human-readable pt-BR label for the
|
|
||||||
* internal briefing e-mail.
|
|
||||||
*
|
|
||||||
* Deliberately framework-free (no Illuminate\Http\Request dependency) so
|
|
||||||
* it stays a pure transformation, mirroring BrazilianPhoneNumber: callers
|
|
||||||
* in the HTTP layer extract the query string / referrer and hand them in
|
|
||||||
* as primitives.
|
|
||||||
*/
|
|
||||||
final class MarketingOrigin
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Session key both the capturing middleware and the controller read
|
|
||||||
* from — kept here so there is exactly one name for the concept.
|
|
||||||
*/
|
|
||||||
public const string SESSION_KEY = 'marketing_origin';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Caps every captured value. This is marketing metadata, not user
|
|
||||||
* content — a crafted query string must not be able to bloat the
|
|
||||||
* session (SPEC.md §12.5 LGPD).
|
|
||||||
*/
|
|
||||||
private const int MAX_LENGTH = 100;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var list<string>
|
|
||||||
*/
|
|
||||||
private const array UTM_KEYS = [
|
|
||||||
'utm_source',
|
|
||||||
'utm_medium',
|
|
||||||
'utm_campaign',
|
|
||||||
'utm_term',
|
|
||||||
'utm_content',
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* pt-BR labels for the UTM keys, in the order they should be
|
|
||||||
* displayed when composing the briefing e-mail row.
|
|
||||||
*
|
|
||||||
* @var array<string, string>
|
|
||||||
*/
|
|
||||||
private const array LABELS = [
|
|
||||||
'utm_source' => 'origem',
|
|
||||||
'utm_medium' => 'mídia',
|
|
||||||
'utm_campaign' => 'campanha',
|
|
||||||
'utm_term' => 'termo',
|
|
||||||
'utm_content' => 'conteúdo',
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reads UTM parameters from the query string, falling back to the
|
|
||||||
* HTTP referrer when none are present. Returns an empty array when
|
|
||||||
* neither is available — capturing nothing is a valid outcome
|
|
||||||
* ("capturada quando disponível").
|
|
||||||
*
|
|
||||||
* A referrer pointing back at this same host is not a marketing
|
|
||||||
* origin — it is the visitor clicking from one internal page to
|
|
||||||
* another (common once the original session has expired and a
|
|
||||||
* fresh one starts mid-visit) — so it is discarded rather than
|
|
||||||
* stored as noise (SPEC.md §12.5, "coletar apenas dados
|
|
||||||
* necessários").
|
|
||||||
*
|
|
||||||
* @param array<string, mixed> $queryParams
|
|
||||||
* @return array<string, string>
|
|
||||||
*/
|
|
||||||
public static function capture(array $queryParams, ?string $referrer, ?string $requestHost): array
|
|
||||||
{
|
|
||||||
$utm = [];
|
|
||||||
|
|
||||||
foreach (self::UTM_KEYS as $key) {
|
|
||||||
$value = $queryParams[$key] ?? null;
|
|
||||||
|
|
||||||
if (is_string($value) && trim($value) !== '') {
|
|
||||||
$utm[$key] = self::sanitize($value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($utm !== []) {
|
|
||||||
return $utm;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (is_string($referrer) && trim($referrer) !== '' && ! self::isSameHost($referrer, $requestHost)) {
|
|
||||||
$origin = self::originOf($referrer);
|
|
||||||
|
|
||||||
if ($origin !== null) {
|
|
||||||
return ['referrer' => self::sanitize($origin)];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reduces a referrer URL to its scheme+host identity, dropping the
|
|
||||||
* path, query string, fragment, and any userinfo. The referrer is
|
|
||||||
* only ever used as a marketing-origin *site* label (SPEC.md WEB-05)
|
|
||||||
* — the query string can carry data that identifies an individual
|
|
||||||
* (e.g. a personalized campaign link's `?email=...`), which would
|
|
||||||
* exceed "coletar apenas dados necessários" (SPEC.md §12.5) once it
|
|
||||||
* lands in the session and the internal briefing e-mail.
|
|
||||||
*/
|
|
||||||
private static function originOf(string $referrer): ?string
|
|
||||||
{
|
|
||||||
$host = parse_url($referrer, PHP_URL_HOST);
|
|
||||||
|
|
||||||
if (! is_string($host) || $host === '') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$scheme = parse_url($referrer, PHP_URL_SCHEME);
|
|
||||||
$prefix = is_string($scheme) && $scheme !== '' ? "{$scheme}://" : '';
|
|
||||||
|
|
||||||
return "{$prefix}{$host}";
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function isSameHost(string $referrer, ?string $requestHost): bool
|
|
||||||
{
|
|
||||||
if ($requestHost === null || $requestHost === '') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$referrerHost = parse_url($referrer, PHP_URL_HOST);
|
|
||||||
|
|
||||||
return is_string($referrerHost) && strcasecmp($referrerHost, $requestHost) === 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Composes the single-row, pt-BR display value for the internal
|
|
||||||
* briefing e-mail. Returns null when nothing was captured, letting
|
|
||||||
* the caller fall back to the same "—" convention already used for
|
|
||||||
* other optional briefing fields.
|
|
||||||
*
|
|
||||||
* Accepts loosely-typed input on purpose: this reads back whatever
|
|
||||||
* was put in the session, so it is treated as untrusted rather than
|
|
||||||
* assumed to still match the shape `capture()` produced.
|
|
||||||
*
|
|
||||||
* @param array<string, mixed> $origin
|
|
||||||
*/
|
|
||||||
public static function describe(array $origin): ?string
|
|
||||||
{
|
|
||||||
if (isset($origin['referrer']) && is_string($origin['referrer']) && $origin['referrer'] !== '') {
|
|
||||||
return $origin['referrer'];
|
|
||||||
}
|
|
||||||
|
|
||||||
$parts = [];
|
|
||||||
|
|
||||||
foreach (self::LABELS as $key => $label) {
|
|
||||||
if (isset($origin[$key]) && is_string($origin[$key]) && $origin[$key] !== '') {
|
|
||||||
$parts[] = "{$label}: {$origin[$key]}";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $parts === [] ? null : implode(' | ', $parts);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Untrusted input (query string, HTTP referrer) that ends up in an
|
|
||||||
* HTML e-mail: strip any markup, drop control characters (also
|
|
||||||
* closes off header-injection-style newline tricks), trim, and cap
|
|
||||||
* the length before it ever reaches storage.
|
|
||||||
*/
|
|
||||||
private static function sanitize(string $value): string
|
|
||||||
{
|
|
||||||
$withoutTags = strip_tags($value);
|
|
||||||
// No /u flag: this is a byte-wise scrub of ASCII control bytes, so
|
|
||||||
// it cannot land mid-sequence in valid UTF-8 (continuation bytes
|
|
||||||
// are all >= 0x80) — unlike the Unicode-mode regex, it never
|
|
||||||
// blanks the whole string just because one byte is malformed.
|
|
||||||
$withoutControlChars = preg_replace('/[\x00-\x1F\x7F]/', '', $withoutTags) ?? '';
|
|
||||||
|
|
||||||
return mb_substr(trim($withoutControlChars), 0, self::MAX_LENGTH);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Filament\Pages\Auth;
|
|
||||||
|
|
||||||
use Filament\Auth\Pages\PasswordReset\RequestPasswordReset as BaseRequestPasswordReset;
|
|
||||||
use Filament\Notifications\Notification;
|
|
||||||
use Illuminate\Support\Facades\Password;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Overrides Filament's stock request-reset page to close an account
|
|
||||||
* enumeration leak (SPEC 12.1 / ADM-01 "reset seguro"). The vendor page
|
|
||||||
* shows a distinguishable danger notification for two statuses that only
|
|
||||||
* ever occur for existing users, letting an attacker tell registered emails
|
|
||||||
* apart from unregistered ones:
|
|
||||||
*
|
|
||||||
* - Password::INVALID_USER — no such user at all.
|
|
||||||
* - Password::RESET_THROTTLED — Illuminate\Auth\Passwords\PasswordBroker
|
|
||||||
* only returns this when a token was already recently created for that
|
|
||||||
* user, which requires the user to exist. Two requests for the same
|
|
||||||
* registered email (allowed by this page's own rate limit of 2) would
|
|
||||||
* otherwise flip from "sent" to "throttled" while an unknown email stays
|
|
||||||
* "sent" both times — the same leak, reached a different way. The
|
|
||||||
* tradeoff: a legitimate user requesting twice sees "sent" again instead
|
|
||||||
* of "please wait", which is an acceptable UX cost on an admin-only panel.
|
|
||||||
*
|
|
||||||
* Existing-but-ineligible users (inactive, or since this panel now requires
|
|
||||||
* a verified email) already resolve to Password::RESET_LINK_SENT with no
|
|
||||||
* mail sent — see the vendor callback in the base class — so only these two
|
|
||||||
* branches need normalizing here.
|
|
||||||
*/
|
|
||||||
class RequestPasswordReset extends BaseRequestPasswordReset
|
|
||||||
{
|
|
||||||
protected function getFailureNotification(string $status): ?Notification
|
|
||||||
{
|
|
||||||
if (in_array($status, [Password::INVALID_USER, Password::RESET_THROTTLED], true)) {
|
|
||||||
return $this->getSentNotification(Password::RESET_LINK_SENT);
|
|
||||||
}
|
|
||||||
|
|
||||||
return parent::getFailureNotification($status);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Filament\Resources\PortfolioCases\Pages;
|
namespace App\Filament\Resources\PortfolioCases\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\PortfolioCases\PortfolioCaseResource;
|
use App\Filament\Resources\PortfolioCases\PortfolioCaseResource;
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Filament\Resources\PortfolioCases\Pages;
|
namespace App\Filament\Resources\PortfolioCases\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\PortfolioCases\PortfolioCaseResource;
|
use App\Filament\Resources\PortfolioCases\PortfolioCaseResource;
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Filament\Resources\PortfolioCases\Pages;
|
namespace App\Filament\Resources\PortfolioCases\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\PortfolioCases\PortfolioCaseResource;
|
use App\Filament\Resources\PortfolioCases\PortfolioCaseResource;
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Filament\Resources\Services\Pages;
|
namespace App\Filament\Resources\Services\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\Services\ServiceResource;
|
use App\Filament\Resources\Services\ServiceResource;
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Filament\Resources\Services\Pages;
|
namespace App\Filament\Resources\Services\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\Services\ServiceResource;
|
use App\Filament\Resources\Services\ServiceResource;
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Filament\Resources\Services\Pages;
|
namespace App\Filament\Resources\Services\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\Services\ServiceResource;
|
use App\Filament\Resources\Services\ServiceResource;
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Filament\Resources\Testimonials\Pages;
|
namespace App\Filament\Resources\Testimonials\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\Testimonials\TestimonialResource;
|
use App\Filament\Resources\Testimonials\TestimonialResource;
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Filament\Resources\Testimonials\Pages;
|
namespace App\Filament\Resources\Testimonials\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\Testimonials\TestimonialResource;
|
use App\Filament\Resources\Testimonials\TestimonialResource;
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Filament\Resources\Testimonials\Pages;
|
namespace App\Filament\Resources\Testimonials\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\Testimonials\TestimonialResource;
|
use App\Filament\Resources\Testimonials\TestimonialResource;
|
||||||
|
|||||||
@@ -1,38 +1,11 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Filament\Resources\Users\Pages;
|
namespace App\Filament\Resources\Users\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\Users\UserResource;
|
use App\Filament\Resources\Users\UserResource;
|
||||||
use App\Models\User;
|
|
||||||
use Filament\Resources\Pages\CreateRecord;
|
use Filament\Resources\Pages\CreateRecord;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
|
|
||||||
class CreateUser extends CreateRecord
|
class CreateUser extends CreateRecord
|
||||||
{
|
{
|
||||||
protected static string $resource = UserResource::class;
|
protected static string $resource = UserResource::class;
|
||||||
|
|
||||||
/**
|
|
||||||
* Verification stays admin-managed only (no self-service verify route is
|
|
||||||
* registered): a user created here by an admin is, by that act, verified.
|
|
||||||
* Without this, `email_verified_at` would stay null forever and
|
|
||||||
* canAccessPanel() would permanently lock the new user out with no
|
|
||||||
* in-app path to recover — the password-reset callback skips notifying
|
|
||||||
* users that fail canAccessPanel() while still reporting success.
|
|
||||||
*
|
|
||||||
* `email_verified_at` is deliberately not added to User's #[Fillable]
|
|
||||||
* list (it isn't a UserForm field either) so it can never be set via
|
|
||||||
* mass assignment from form/API input — `forceFill()` bypasses that
|
|
||||||
* guard here on purpose, after construction.
|
|
||||||
*/
|
|
||||||
protected function handleRecordCreation(array $data): Model
|
|
||||||
{
|
|
||||||
/** @var User $record */
|
|
||||||
$record = new (static::getModel())($data);
|
|
||||||
$record->forceFill(['email_verified_at' => now()]);
|
|
||||||
$record->save();
|
|
||||||
|
|
||||||
return $record;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Filament\Resources\Users\Pages;
|
namespace App\Filament\Resources\Users\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\Users\UserResource;
|
use App\Filament\Resources\Users\UserResource;
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Filament\Resources\Users\Pages;
|
namespace App\Filament\Resources\Users\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\Users\UserResource;
|
use App\Filament\Resources\Users\UserResource;
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
abstract class Controller
|
abstract class Controller
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Http\Controllers\PublicSite;
|
namespace App\Http\Controllers\PublicSite;
|
||||||
|
|
||||||
use App\Domain\Contact\MarketingOrigin;
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Http\Requests\PublicSite\ContactBriefingRequest;
|
use App\Http\Requests\PublicSite\ContactBriefingRequest;
|
||||||
use App\Mail\ContactBriefing;
|
use App\Mail\ContactBriefing;
|
||||||
@@ -35,7 +34,7 @@ final class ContactController extends Controller
|
|||||||
$this->rememberSubmission($validated);
|
$this->rememberSubmission($validated);
|
||||||
|
|
||||||
$settings = SiteSetting::instance();
|
$settings = SiteSetting::instance();
|
||||||
$fields = $this->buildFields($validated, $this->resolveMarketingOrigin($request));
|
$fields = $this->buildFields($validated);
|
||||||
|
|
||||||
$this->dispatchEmails($settings, $validated['nome'], $validated['email'], $fields);
|
$this->dispatchEmails($settings, $validated['nome'], $validated['email'], $fields);
|
||||||
|
|
||||||
@@ -45,7 +44,7 @@ final class ContactController extends Controller
|
|||||||
/**
|
/**
|
||||||
* @param array<string, mixed> $validated
|
* @param array<string, mixed> $validated
|
||||||
*/
|
*/
|
||||||
private function buildFields(array $validated, ?string $marketingOrigin): array
|
private function buildFields(array $validated): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'Nome' => (string) $validated['nome'],
|
'Nome' => (string) $validated['nome'],
|
||||||
@@ -57,32 +56,9 @@ final class ContactController extends Controller
|
|||||||
'Número estimado de convidados' => isset($validated['convidados']) ? (string) $validated['convidados'] : null,
|
'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,
|
'Serviço de interesse' => isset($validated['servico_interesse']) ? (string) $validated['servico_interesse'] : null,
|
||||||
'Mensagem' => (string) $validated['mensagem'],
|
'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
|
* @param array<string, mixed> $validated
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,85 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Http\Middleware;
|
|
||||||
|
|
||||||
use App\Domain\Contact\MarketingOrigin;
|
|
||||||
use Closure;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Support\Facades\Log;
|
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
|
||||||
use Throwable;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Captures the marketing origin (UTM parameters, or the HTTP referrer as
|
|
||||||
* a fallback) on the first public page load of a visit and stores it in
|
|
||||||
* the session, so it can later travel with a contact briefing submission
|
|
||||||
* (SPEC.md WEB-05).
|
|
||||||
*
|
|
||||||
* First *informative* touch wins: nothing is written until a page load
|
|
||||||
* actually carries a UTM parameter or an external referrer, and once
|
|
||||||
* that happens later page views never overwrite it — navigating to
|
|
||||||
* another page without UTM parameters must not erase what arrived with
|
|
||||||
* the visitor. A page load with no signal at all is left unrecorded so a
|
|
||||||
* later, informative page load in the same session can still be
|
|
||||||
* captured.
|
|
||||||
*
|
|
||||||
* Fail-open by construction: any failure here is caught and logged
|
|
||||||
* without request data, and the request proceeds untouched. This path
|
|
||||||
* must never be able to block a page load or, downstream, a conversion.
|
|
||||||
*/
|
|
||||||
final class CaptureMarketingOrigin
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Technical routes that share the `web` middleware group but are never a
|
|
||||||
* human arriving at the site. A crawler fetching `/sitemap.xml?utm_source=…`
|
|
||||||
* would otherwise consume the first-touch slot with traffic that will never
|
|
||||||
* submit a briefing.
|
|
||||||
*
|
|
||||||
* @var list<string>
|
|
||||||
*/
|
|
||||||
private const IGNORED_ROUTES = ['sitemap', 'robots'];
|
|
||||||
|
|
||||||
public function handle(Request $request, Closure $next): Response
|
|
||||||
{
|
|
||||||
if (in_array($request->route()?->getName(), self::IGNORED_ROUTES, true)) {
|
|
||||||
return $next($request);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$this->captureFirstTouch($request);
|
|
||||||
} catch (Throwable $exception) {
|
|
||||||
Log::warning('Falha ao capturar origem de marketing (ignorada; fail-open)', [
|
|
||||||
'exception' => $exception::class,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $next($request);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function captureFirstTouch(Request $request): void
|
|
||||||
{
|
|
||||||
if (! $request->hasSession()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$session = $request->session();
|
|
||||||
|
|
||||||
if ($session->has(MarketingOrigin::SESSION_KEY)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$origin = MarketingOrigin::capture(
|
|
||||||
$request->query(),
|
|
||||||
$request->headers->get('referer'),
|
|
||||||
$request->getHost(),
|
|
||||||
);
|
|
||||||
|
|
||||||
if ($origin === []) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$session->put(MarketingOrigin::SESSION_KEY, $origin);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,26 +4,10 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Http\Requests\PublicSite;
|
namespace App\Http\Requests\PublicSite;
|
||||||
|
|
||||||
use App\Domain\Contact\BrazilianPhoneNumber;
|
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
final class ContactBriefingRequest extends 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>>
|
* @return array<string, array<int, string>>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ use App\Enums\UserRole;
|
|||||||
use Database\Factories\UserFactory;
|
use Database\Factories\UserFactory;
|
||||||
use Filament\Models\Contracts\FilamentUser;
|
use Filament\Models\Contracts\FilamentUser;
|
||||||
use Filament\Panel;
|
use Filament\Panel;
|
||||||
use Illuminate\Auth\MustVerifyEmail;
|
|
||||||
use Illuminate\Contracts\Auth\MustVerifyEmail as MustVerifyEmailContract;
|
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Hidden;
|
use Illuminate\Database\Eloquent\Attributes\Hidden;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
@@ -22,14 +20,14 @@ use Illuminate\Notifications\Notifiable;
|
|||||||
*/
|
*/
|
||||||
#[Fillable(['name', 'email', 'password', 'role', 'is_active'])]
|
#[Fillable(['name', 'email', 'password', 'role', 'is_active'])]
|
||||||
#[Hidden(['password', 'remember_token'])]
|
#[Hidden(['password', 'remember_token'])]
|
||||||
class User extends Authenticatable implements FilamentUser, MustVerifyEmailContract
|
class User extends Authenticatable implements FilamentUser
|
||||||
{
|
{
|
||||||
/** @use HasFactory<UserFactory> */
|
/** @use HasFactory<UserFactory> */
|
||||||
use HasFactory, MustVerifyEmail, Notifiable;
|
use HasFactory, Notifiable;
|
||||||
|
|
||||||
public function canAccessPanel(Panel $panel): bool
|
public function canAccessPanel(Panel $panel): bool
|
||||||
{
|
{
|
||||||
return $this->is_active && $this->hasVerifiedEmail();
|
return $this->is_active;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function isAdmin(): bool
|
public function isAdmin(): bool
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Providers\Filament;
|
namespace App\Providers\Filament;
|
||||||
|
|
||||||
use App\Filament\Pages\Auth\RequestPasswordReset;
|
|
||||||
use Filament\FontProviders\LocalFontProvider;
|
|
||||||
use Filament\Http\Middleware\Authenticate;
|
use Filament\Http\Middleware\Authenticate;
|
||||||
use Filament\Http\Middleware\AuthenticateSession;
|
use Filament\Http\Middleware\AuthenticateSession;
|
||||||
use Filament\Http\Middleware\DisableBladeIconComponents;
|
use Filament\Http\Middleware\DisableBladeIconComponents;
|
||||||
@@ -13,7 +9,7 @@ use Filament\Http\Middleware\DispatchServingFilamentEvent;
|
|||||||
use Filament\Pages\Dashboard;
|
use Filament\Pages\Dashboard;
|
||||||
use Filament\Panel;
|
use Filament\Panel;
|
||||||
use Filament\PanelProvider;
|
use Filament\PanelProvider;
|
||||||
use Filament\View\PanelsRenderHook;
|
use Filament\Support\Colors\Color;
|
||||||
use Filament\Widgets\AccountWidget;
|
use Filament\Widgets\AccountWidget;
|
||||||
use Filament\Widgets\FilamentInfoWidget;
|
use Filament\Widgets\FilamentInfoWidget;
|
||||||
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
|
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
|
||||||
@@ -25,137 +21,6 @@ use Illuminate\View\Middleware\ShareErrorsFromSession;
|
|||||||
|
|
||||||
class AdminPanelProvider extends PanelProvider
|
class AdminPanelProvider extends PanelProvider
|
||||||
{
|
{
|
||||||
/**
|
|
||||||
* Heritage Editorial — Oliva Herança primary ramp.
|
|
||||||
*
|
|
||||||
* Filament needs an explicit 11-stop shade array to preserve exact brand
|
|
||||||
* hexes (a bare string/`Color::hex()` runs the value through
|
|
||||||
* `Color::generatePalette()`, which discards its lightness and maps a
|
|
||||||
* fixed per-shade lightness/chroma table instead — see MAN-118 survey).
|
|
||||||
* Shade 600 pins `#556B2F` (Oliva Herança) and shade 700 pins `#3E5219`
|
|
||||||
* (Oliva Profundo). `Filament\Support\View\Components\ColorMaps\
|
|
||||||
* ButtonComponentColorMap` was used to verify empirically (not assumed)
|
|
||||||
* that a solid primary button resolves to `bg: 600, hover:bg: 500, text:
|
|
||||||
* 50` at 5.42:1 / 4.57:1 contrast (WCAG AA). Shade 50 is a pale olive
|
|
||||||
* tint rather than pure white deliberately: `BadgeComponent`/
|
|
||||||
* `badge.css` apply `bg-color-50` directly as a badge's background
|
|
||||||
* (independent of the button map above), and a pure-white 50 would
|
|
||||||
* render a "primary" badge as an all-but-invisible pill against the
|
|
||||||
* Papel Marfim (`#FBF9F4`) panel body.
|
|
||||||
*
|
|
||||||
* @return array<int, string>
|
|
||||||
*/
|
|
||||||
private function primaryColor(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
50 => '#F3F5EC',
|
|
||||||
100 => '#E7EBDA',
|
|
||||||
200 => '#D4DCBE',
|
|
||||||
300 => '#8B9D77', // Sálvia Silenciosa
|
|
||||||
400 => '#6E8354',
|
|
||||||
500 => '#61763A',
|
|
||||||
600 => '#556B2F', // Oliva Herança
|
|
||||||
700 => '#3E5219', // Oliva Profundo
|
|
||||||
800 => '#2E3D13',
|
|
||||||
900 => '#1F290D',
|
|
||||||
950 => '#141B08',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Heritage Editorial — paper/ink neutral ramp, replacing Filament's
|
|
||||||
* stock Zinc gray so panel chrome (sidebar, topbar, body background,
|
|
||||||
* borders) matches the public site's paper tones instead of true gray.
|
|
||||||
* Anchored at the exact tokens where DESIGN.md defines them (50/100/200
|
|
||||||
* = Papel Marfim/Profundo/Arquivo, 300 = Linha Botânica, 500 = Tinta
|
|
||||||
* Suave, 900 = Tinta Oliva); the remaining stops are interpolated to
|
|
||||||
* keep the ramp monotonic.
|
|
||||||
*
|
|
||||||
* @return array<int, string>
|
|
||||||
*/
|
|
||||||
private function grayColor(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
50 => '#FBF9F4', // Papel Marfim
|
|
||||||
100 => '#F0EEE9', // Papel Profundo
|
|
||||||
200 => '#E4E2DD', // Papel Arquivo
|
|
||||||
300 => '#C5C8B8', // Linha Botânica
|
|
||||||
400 => '#919486',
|
|
||||||
500 => '#5D6155', // Tinta Suave
|
|
||||||
600 => '#43453D',
|
|
||||||
700 => '#32342E',
|
|
||||||
800 => '#252622',
|
|
||||||
900 => '#1B1C19', // Tinta Oliva
|
|
||||||
950 => '#121210',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Semantic ramps built the same way as the primary/gray ramps above:
|
|
||||||
* an explicit 11-stop array (never `Color::hex()`) anchored so the
|
|
||||||
* existing `--amare-color-{success,warning,error}` hex lands exactly on
|
|
||||||
* shade 600 — verified against `ButtonComponentColorMap` to resolve to
|
|
||||||
* `bg: 600` with white text at WCAG AA contrast, same as primary.
|
|
||||||
*
|
|
||||||
* @return array<int, string>
|
|
||||||
*/
|
|
||||||
private function dangerColor(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
50 => '#F7EDED',
|
|
||||||
100 => '#EBD1D1',
|
|
||||||
200 => '#D8A8A8',
|
|
||||||
300 => '#C88484',
|
|
||||||
400 => '#B85F5F',
|
|
||||||
500 => '#A73B3B',
|
|
||||||
600 => '#991B1B',
|
|
||||||
700 => '#7D1616',
|
|
||||||
800 => '#651212',
|
|
||||||
900 => '#4C0E0E',
|
|
||||||
950 => '#3A0A0A',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<int, string>
|
|
||||||
*/
|
|
||||||
private function warningColor(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
50 => '#F6F0EC',
|
|
||||||
100 => '#E9D9CF',
|
|
||||||
200 => '#D6B6A3',
|
|
||||||
300 => '#C4987D',
|
|
||||||
400 => '#B37956',
|
|
||||||
500 => '#A15B30',
|
|
||||||
600 => '#92400E',
|
|
||||||
700 => '#78340B',
|
|
||||||
800 => '#602A09',
|
|
||||||
900 => '#492007',
|
|
||||||
950 => '#371805',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<int, string>
|
|
||||||
*/
|
|
||||||
private function successColor(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
50 => '#ECF3EF',
|
|
||||||
100 => '#D0E0D6',
|
|
||||||
200 => '#A6C4B2',
|
|
||||||
300 => '#81AC91',
|
|
||||||
400 => '#5C9371',
|
|
||||||
500 => '#377B50',
|
|
||||||
600 => '#166534',
|
|
||||||
700 => '#12532B',
|
|
||||||
800 => '#0F4322',
|
|
||||||
900 => '#0B321A',
|
|
||||||
950 => '#082614',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function panel(Panel $panel): Panel
|
public function panel(Panel $panel): Panel
|
||||||
{
|
{
|
||||||
return $panel
|
return $panel
|
||||||
@@ -163,47 +28,9 @@ class AdminPanelProvider extends PanelProvider
|
|||||||
->id('admin')
|
->id('admin')
|
||||||
->path('admin')
|
->path('admin')
|
||||||
->login()
|
->login()
|
||||||
->passwordReset(requestAction: RequestPasswordReset::class)
|
|
||||||
->brandName('Amare Assessoria')
|
|
||||||
->brandLogo(fn (): string => asset('brand/lockup-on-light.webp'))
|
|
||||||
->brandLogoHeight('2rem')
|
|
||||||
->colors([
|
->colors([
|
||||||
'primary' => $this->primaryColor(),
|
'primary' => Color::Amber,
|
||||||
'gray' => $this->grayColor(),
|
|
||||||
'danger' => $this->dangerColor(),
|
|
||||||
'warning' => $this->warningColor(),
|
|
||||||
'success' => $this->successColor(),
|
|
||||||
])
|
])
|
||||||
// Heritage Editorial is a single paper palette by design (see
|
|
||||||
// DESIGN.md) — no dark-mode variant exists, so the switcher is
|
|
||||||
// removed rather than left pointing at an unstyled dark theme.
|
|
||||||
->darkMode(false)
|
|
||||||
// Self-hosted EB Garamond, same family as the public site.
|
|
||||||
// LocalFontProvider is pinned explicitly: HasFont::getFontProvider()
|
|
||||||
// otherwise defaults a custom family to BunnyFontProvider, which
|
|
||||||
// would emit a live request to fonts.bunny.net from the panel.
|
|
||||||
// LocalFontProvider renders no <link>/@font-face itself (no $url
|
|
||||||
// given), so the actual face comes from the render hook below,
|
|
||||||
// reusing the public site's own <x-fonts /> component/manifest.
|
|
||||||
//
|
|
||||||
// The monospace and serif faces are set too, not just the base
|
|
||||||
// (sans) family: Filament exposes them via the separate
|
|
||||||
// ->monoFont()/->serifFont() calls below, and the `KeyValue`
|
|
||||||
// field (used in ManageSiteSettings) renders in the monospace
|
|
||||||
// face directly — left unset, that field would silently fall
|
|
||||||
// back to a system monospace stack instead of EB Garamond,
|
|
||||||
// breaking Heritage Editorial's single-voice typography.
|
|
||||||
->font('EB Garamond', provider: LocalFontProvider::class)
|
|
||||||
->monoFont('EB Garamond', provider: LocalFontProvider::class)
|
|
||||||
->serifFont('EB Garamond', provider: LocalFontProvider::class)
|
|
||||||
->renderHook(
|
|
||||||
PanelsRenderHook::HEAD_END,
|
|
||||||
fn (): string => view('components.fonts')->render(),
|
|
||||||
)
|
|
||||||
// Compiled Filament theme entry — sharp corners, flat surfaces
|
|
||||||
// (see resources/css/filament/admin/theme.css for the full
|
|
||||||
// rationale and the two vendored-CSS exceptions it can't reach).
|
|
||||||
->viteTheme('resources/css/filament/admin/theme.css')
|
|
||||||
->discoverResources(in: app_path('Filament/Resources'), for: 'App\Filament\Resources')
|
->discoverResources(in: app_path('Filament/Resources'), for: 'App\Filament\Resources')
|
||||||
->discoverPages(in: app_path('Filament/Pages'), for: 'App\Filament\Pages')
|
->discoverPages(in: app_path('Filament/Pages'), for: 'App\Filament\Pages')
|
||||||
->pages([
|
->pages([
|
||||||
|
|||||||
@@ -12,16 +12,8 @@ use Throwable;
|
|||||||
|
|
||||||
final class ResponsiveImage
|
final class ResponsiveImage
|
||||||
{
|
{
|
||||||
/**
|
/** @var list<int> */
|
||||||
* 720 sits between the original 480 and 960 because that is where the common
|
public const WIDTHS = [480, 960, 1440];
|
||||||
* mobile viewport lands: 412 CSS px at a 1.75 device pixel ratio asks for
|
|
||||||
* ~721 px, so a full-width image used to jump straight to the 960 variant and
|
|
||||||
* pay for a third more pixels than it drew. Measured on the hero (MAN-109):
|
|
||||||
* 143 KiB at 960 in jpeg against 75 KiB at 720 in webp.
|
|
||||||
*
|
|
||||||
* @var list<int>
|
|
||||||
*/
|
|
||||||
public const WIDTHS = [480, 720, 960, 1440];
|
|
||||||
|
|
||||||
public static function generate(string $path, ?string $disk = null): void
|
public static function generate(string $path, ?string $disk = null): void
|
||||||
{
|
{
|
||||||
@@ -55,26 +47,6 @@ final class ResponsiveImage
|
|||||||
};
|
};
|
||||||
|
|
||||||
$filesystem->put($variantPath, (string) $encoded);
|
$filesystem->put($variantPath, (string) $encoded);
|
||||||
|
|
||||||
// A webp sibling for every variant. The MAN-109 audit measured the
|
|
||||||
// hero as the LCP element on every mobile page, at 143 KiB for a
|
|
||||||
// 960 px jpeg — webp carries the same picture for roughly a third of
|
|
||||||
// that. `x-media.image` offers these through a <source> so a browser
|
|
||||||
// that cannot decode webp still gets the original format.
|
|
||||||
if ($extension === 'webp') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$webp = $manager->read($contents);
|
|
||||||
|
|
||||||
if ($webp->width() > $width) {
|
|
||||||
$webp->scale(width: $width);
|
|
||||||
}
|
|
||||||
|
|
||||||
$filesystem->put(
|
|
||||||
self::webpVariantPath($path, $width),
|
|
||||||
(string) $webp->toWebp(quality: 80)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,13 +55,13 @@ final class ResponsiveImage
|
|||||||
$filesystem = self::filesystem($disk);
|
$filesystem = self::filesystem($disk);
|
||||||
|
|
||||||
foreach (self::WIDTHS as $width) {
|
foreach (self::WIDTHS as $width) {
|
||||||
foreach ([self::variantPath($path, $width), self::webpVariantPath($path, $width)] as $variantPath) {
|
$variantPath = self::variantPath($path, $width);
|
||||||
|
|
||||||
if ($filesystem->exists($variantPath)) {
|
if ($filesystem->exists($variantPath)) {
|
||||||
$filesystem->delete($variantPath);
|
$filesystem->delete($variantPath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
public static function delete(string $path, ?string $disk = null): void
|
public static function delete(string $path, ?string $disk = null): void
|
||||||
{
|
{
|
||||||
@@ -121,17 +93,6 @@ 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}>
|
||||||
*/
|
*/
|
||||||
@@ -154,32 +115,6 @@ final class ResponsiveImage
|
|||||||
return $variants;
|
return $variants;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The webp variants that exist for a path. Empty when the media predates
|
|
||||||
* `media:generate-variants` running with webp support, which is why
|
|
||||||
* `x-media.image` treats the <source> as optional rather than assuming it.
|
|
||||||
*
|
|
||||||
* @return list<array{path: string, width: int}>
|
|
||||||
*/
|
|
||||||
public static function availableWebpVariants(string $path, ?string $disk = null): array
|
|
||||||
{
|
|
||||||
$filesystem = self::filesystem($disk);
|
|
||||||
$variants = [];
|
|
||||||
|
|
||||||
foreach (self::WIDTHS as $width) {
|
|
||||||
$variantPath = self::webpVariantPath($path, $width);
|
|
||||||
|
|
||||||
if ($filesystem->exists($variantPath)) {
|
|
||||||
$variants[] = [
|
|
||||||
'path' => $variantPath,
|
|
||||||
'width' => $width,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $variants;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array{width: int, height: int}|null
|
* @return array{width: int, height: int}|null
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Http\Middleware\CaptureMarketingOrigin;
|
|
||||||
use Illuminate\Foundation\Application;
|
use Illuminate\Foundation\Application;
|
||||||
use Illuminate\Foundation\Configuration\Exceptions;
|
use Illuminate\Foundation\Configuration\Exceptions;
|
||||||
use Illuminate\Foundation\Configuration\Middleware;
|
use Illuminate\Foundation\Configuration\Middleware;
|
||||||
@@ -15,13 +14,6 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
->withMiddleware(function (Middleware $middleware): void {
|
->withMiddleware(function (Middleware $middleware): void {
|
||||||
// Trust Traefik/Dokploy (and local reverse proxies) for X-Forwarded-* headers.
|
// Trust Traefik/Dokploy (and local reverse proxies) for X-Forwarded-* headers.
|
||||||
$middleware->trustProxies(at: '*');
|
$middleware->trustProxies(at: '*');
|
||||||
|
|
||||||
// Public-site request spine only (routes/web.php uses the "web"
|
|
||||||
// group; the Filament admin panel defines its own middleware
|
|
||||||
// stack in AdminPanelProvider and never touches this group).
|
|
||||||
$middleware->web(append: [
|
|
||||||
CaptureMarketingOrigin::class,
|
|
||||||
]);
|
|
||||||
})
|
})
|
||||||
->withExceptions(function (Exceptions $exceptions): void {
|
->withExceptions(function (Exceptions $exceptions): void {
|
||||||
$exceptions->shouldRenderJsonWhen(
|
$exceptions->shouldRenderJsonWhen(
|
||||||
|
|||||||
@@ -65,9 +65,6 @@
|
|||||||
"test:browser": [
|
"test:browser": [
|
||||||
"@php artisan test --testsuite=Browser"
|
"@php artisan test --testsuite=Browser"
|
||||||
],
|
],
|
||||||
"test:coverage": [
|
|
||||||
"vendor/bin/pest -c phpunit.coverage.xml --testsuite=Unit,Architecture,Feature --coverage --min=80"
|
|
||||||
],
|
|
||||||
"pint": [
|
"pint": [
|
||||||
"vendor/bin/pint"
|
"vendor/bin/pint"
|
||||||
],
|
],
|
||||||
@@ -84,7 +81,6 @@
|
|||||||
"@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": [
|
"visual:update": [
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ return [
|
|||||||
|
|
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'timezone' => env('APP_TIMEZONE', 'America/Sao_Paulo'),
|
'timezone' => env('APP_TIMEZONE', 'America/Fortaleza'),
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use Illuminate\Database\Migrations\Migration;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Backfills `email_verified_at` for users that already exist.
|
|
||||||
*
|
|
||||||
* `User` now implements MustVerifyEmail and `canAccessPanel()` requires
|
|
||||||
* `hasVerifiedEmail()`. Every account created before this change has
|
|
||||||
* `email_verified_at` as null, so without this backfill they are locked out of
|
|
||||||
* `/admin` the moment the change deploys — and there is no way back in from
|
|
||||||
* inside the app: no self-service verification route is registered, and the
|
|
||||||
* password-reset flow deliberately skips notifying users that fail
|
|
||||||
* `canAccessPanel()` while still reporting success. Recovery would need shell
|
|
||||||
* access to the container.
|
|
||||||
*
|
|
||||||
* Verifying them is the correct default, not a shortcut. There is no public
|
|
||||||
* registration: every existing account was created by an admin, through the
|
|
||||||
* panel or the tinker snippet in docs/deployment/dokploy.md. That act is the
|
|
||||||
* verification — which is exactly the reasoning CreateUser applies to accounts
|
|
||||||
* created from now on.
|
|
||||||
*/
|
|
||||||
return new class extends Migration
|
|
||||||
{
|
|
||||||
public function up(): void
|
|
||||||
{
|
|
||||||
DB::table('users')
|
|
||||||
->whereNull('email_verified_at')
|
|
||||||
->update(['email_verified_at' => DB::raw('COALESCE(created_at, NOW())')]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function down(): void
|
|
||||||
{
|
|
||||||
// Deliberately not reversed. Nulling these columns would lock every
|
|
||||||
// existing user out of the panel, which is the failure this migration
|
|
||||||
// exists to prevent — and the pre-migration null/non-null split is not
|
|
||||||
// recorded anywhere, so it could not be restored faithfully anyway.
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -11,7 +11,6 @@ use App\Models\SiteSetting;
|
|||||||
use App\Support\PublicImageUploadRules;
|
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;
|
||||||
|
|
||||||
@@ -19,32 +18,8 @@ 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();
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ class DatabaseSeeder extends Seeder
|
|||||||
'password' => Hash::make('password'),
|
'password' => Hash::make('password'),
|
||||||
'role' => UserRole::Admin,
|
'role' => UserRole::Admin,
|
||||||
'is_active' => true,
|
'is_active' => true,
|
||||||
'email_verified_at' => now(),
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -34,7 +33,6 @@ class DatabaseSeeder extends Seeder
|
|||||||
'password' => Hash::make('password'),
|
'password' => Hash::make('password'),
|
||||||
'role' => UserRole::Assistant,
|
'role' => UserRole::Assistant,
|
||||||
'is_active' => true,
|
'is_active' => true,
|
||||||
'email_verified_at' => now(),
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,3 @@
|
|||||||
> **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.
|
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.
|
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!!! ❤️
|
Que você possa transformar o grande dia das noivinhas sempre com essa sua leveza!!! ❤️
|
||||||
|
|||||||
@@ -14,13 +14,7 @@ services:
|
|||||||
restart: "no"
|
restart: "no"
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
# Exec-array form on a single line, deliberately. A folded block scalar
|
command: ["sh", "-c", "php artisan migrate --force --no-interaction && php artisan db:seed --class=ContentSeeder --force; php artisan media:generate-variants --force; true"]
|
||||||
# (`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:
|
networks:
|
||||||
- dokploy-network
|
- dokploy-network
|
||||||
|
|
||||||
|
|||||||
@@ -18,34 +18,5 @@ 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,20 +5,5 @@
|
|||||||
: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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
# Linux runner that reproduces CI's rendering environment for the visual
|
|
||||||
# regression baselines. Not part of the application image and never deployed.
|
|
||||||
#
|
|
||||||
# Why this exists: the baselines are pixel artifacts of the machine that
|
|
||||||
# rendered them. Pest Browser serves the Laravel kernel from an in-process Amp
|
|
||||||
# server (vendor/pestphp/pest-plugin-browser/src/Drivers/LaravelHttpServer.php),
|
|
||||||
# so FrankenPHP is not in the picture — what differs between a developer's Mac
|
|
||||||
# and CI is the OS, the Chromium build and the font stack. Regenerating on
|
|
||||||
# macOS produces baselines CI rejects, which is the whole reason commit
|
|
||||||
# 4578457 exists. Before this file the recipe lived only as a checklist in
|
|
||||||
# tasks.md and the image had to be reconstructed by archaeology.
|
|
||||||
#
|
|
||||||
# Mirrors the `browser` job in .github/workflows/ci.yml: Ubuntu 24.04,
|
|
||||||
# PHP 8.4 with the same extension list, Node 22, and Playwright's own system
|
|
||||||
# dependencies (which is where fonts-liberation comes from — StableScreenshot
|
|
||||||
# forces `Arial`, and on Linux fontconfig resolves that to the
|
|
||||||
# metric-compatible Liberation Sans).
|
|
||||||
#
|
|
||||||
# Driven by scripts/test/visual-update-ci.sh; see that script for usage.
|
|
||||||
|
|
||||||
FROM ubuntu:24.04
|
|
||||||
|
|
||||||
ENV DEBIAN_FRONTEND=noninteractive \
|
|
||||||
PLAYWRIGHT_BROWSERS_PATH=/opt/playwright-browsers
|
|
||||||
|
|
||||||
RUN apt-get update \
|
|
||||||
&& apt-get install -y --no-install-recommends \
|
|
||||||
ca-certificates \
|
|
||||||
curl \
|
|
||||||
git \
|
|
||||||
gnupg \
|
|
||||||
software-properties-common \
|
|
||||||
unzip \
|
|
||||||
&& add-apt-repository -y ppa:ondrej/php \
|
|
||||||
&& apt-get update \
|
|
||||||
&& apt-get install -y --no-install-recommends \
|
|
||||||
php8.4-cli \
|
|
||||||
php8.4-bcmath \
|
|
||||||
php8.4-curl \
|
|
||||||
php8.4-gd \
|
|
||||||
php8.4-intl \
|
|
||||||
php8.4-mbstring \
|
|
||||||
php8.4-pgsql \
|
|
||||||
php8.4-sqlite3 \
|
|
||||||
php8.4-xml \
|
|
||||||
php8.4-zip \
|
|
||||||
&& curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
|
|
||||||
&& apt-get install -y --no-install-recommends nodejs \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
|
||||||
|
|
||||||
# System libraries and fonts only. The browser binary itself is installed at
|
|
||||||
# run time so its revision matches whatever playwright version package-lock
|
|
||||||
# resolves, exactly as CI's `npx playwright install chromium --with-deps` does.
|
|
||||||
RUN npx --yes playwright@1.62 install-deps chromium \
|
|
||||||
&& rm -rf /root/.npm
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
CMD ["bash"]
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
# 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…_
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -67,7 +67,7 @@ APP_URL=https://staging.example.com
|
|||||||
|
|
||||||
APP_LOCALE=pt_BR
|
APP_LOCALE=pt_BR
|
||||||
APP_FALLBACK_LOCALE=pt_BR
|
APP_FALLBACK_LOCALE=pt_BR
|
||||||
APP_TIMEZONE=America/Sao_Paulo
|
APP_TIMEZONE=America/Fortaleza
|
||||||
|
|
||||||
DB_CONNECTION=pgsql
|
DB_CONNECTION=pgsql
|
||||||
DB_HOST=<dokploy-postgres-internal-host> # Internal Host from Dokploy UI (requires dokploy-network)
|
DB_HOST=<dokploy-postgres-internal-host> # Internal Host from Dokploy UI (requires dokploy-network)
|
||||||
@@ -219,11 +219,7 @@ echo (\$valid ? 'ok' : 'invalid').PHP_EOL;
|
|||||||
|
|
||||||
Expected output: `ok`.
|
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 `DatabaseSeeder` or `ContentSeeder` in staging or production: they include local credentials and/or broad demo-content effects. Deployment workflows intentionally remain migrate-only; loading these testimonials is a deliberate manual operation in each environment.
|
||||||
|
|
||||||
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
|
## Backup and restore
|
||||||
|
|
||||||
|
|||||||
@@ -1,409 +0,0 @@
|
|||||||
# 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 | — |
|
|
||||||
|
|
||||||
@@ -1,407 +0,0 @@
|
|||||||
# 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 | — |
|
|
||||||
|
|
||||||
@@ -1,407 +0,0 @@
|
|||||||
# 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 | — |
|
|
||||||
|
|
||||||
@@ -1,207 +0,0 @@
|
|||||||
# 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 16 baselines visuais foram regenerados no runner Linux
|
|
||||||
(`scripts/test/visual-update-ci.sh`), e o diff é imperceptível a 2× de zoom —
|
|
||||||
mesma forma, mesma cor, só menos bytes.
|
|
||||||
|
|
||||||
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 de regressão visual nunca exercitam `srcset` nem `<picture>`: nem
|
|
||||||
`ContentSeeder` nem `VisualContentSeeder` geram variantes, e `media:generate-variants`
|
|
||||||
não roda no runner visual. Naquele ambiente `availableVariants()` volta vazio e o
|
|
||||||
componente renderiza `<img>` puro — foi por isso que os 16 baselines não mudaram
|
|
||||||
com a introdução do `<picture>`. A cobertura do caminho com variantes fica nos
|
|
||||||
testes de feature (`MediaImageComponentTest`), não nos baselines.
|
|
||||||
|
|
||||||
## 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.
|
|
||||||
@@ -1,417 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,23 +1,23 @@
|
|||||||
## 1. Auth and strict types parity
|
## 1. Auth and strict types parity
|
||||||
|
|
||||||
- [x] 1.1 Add `MustVerifyEmail` to `User` and require verified + active in `canAccessPanel`; update seed so admin/assistant are verified; feature tests for unverified denial and verified access
|
- [ ] 1.1 Add `MustVerifyEmail` to `User` and require verified + active in `canAccessPanel`; update seed so admin/assistant are verified; feature tests for unverified denial and verified access
|
||||||
- [x] 1.2 Confirm Filament/Laravel password reset is enabled; add feature tests for registered vs unknown email without account enumeration — required a custom `App\Filament\Pages\Auth\RequestPasswordReset` overriding Filament's stock page, which discloses account existence via a distinguishable danger notification on `Password::INVALID_USER`
|
- [ ] 1.2 Confirm Filament/Laravel password reset is enabled; add feature tests for registered vs unknown email without account enumeration
|
||||||
- [x] 1.3 Add `declare(strict_types=1);` to project-owned PHP files missing it (e.g. `AdminPanelProvider`); architecture/unit regression as needed — 15 files total: `AdminPanelProvider`, `Controller`, and 13 Filament Resource Pages classes
|
- [ ] 1.3 Add `declare(strict_types=1);` to project-owned PHP files missing it (e.g. `AdminPanelProvider`); architecture/unit regression as needed
|
||||||
- [x] 1.4 Run `composer pint`, `composer phpstan`, and `composer test:feature` for auth changes — all green
|
- [ ] 1.4 Run `composer pint`, `composer phpstan`, and `composer test:feature` for auth changes
|
||||||
|
|
||||||
## 2. Local runtime and PHP 8.4 alignment
|
## 2. Local runtime and PHP 8.4 alignment
|
||||||
|
|
||||||
- [x] 2.1 Extend `docker-compose.yml` with FrankenPHP `app` service (build Dockerfile, depend on healthy postgres, publish 8000); document in README
|
- [ ] 2.1 Extend `docker-compose.yml` with FrankenPHP `app` service (build Dockerfile, depend on healthy postgres, publish 8000); document in README
|
||||||
- [x] 2.2 Align README/docs to PHP 8.4 canonical (keep Composer `^8.3`); verify Dockerfile/CI already on 8.4
|
- [ ] 2.2 Align README/docs to PHP 8.4 canonical (keep Composer `^8.3`); verify Dockerfile/CI already on 8.4
|
||||||
- [x] 2.3 Smoke local compose: `docker compose up -d` → `GET /up` returns 200 — run as an isolated `-p fase0smoke` project (separate container names/ports via a `!override` compose overlay, kept outside the repo) so it didn't collide with the `amare-postgres` container already running for a concurrent sibling worktree session. `depends_on: condition: service_healthy` correctly gated `app` on Postgres's healthcheck, `curl localhost:18000/up` returned `200`, and `docker exec ... php artisan migrate --force` succeeded — proving `DB_HOST: postgres` resolves the `app` container to the `postgres` service by Compose's service-name DNS, not just that the image boots. Torn down afterwards (`down -v` + image removal); the shared sibling `amare-postgres` container was untouched throughout.
|
- [ ] 2.3 Smoke local compose: `docker compose up -d` → `GET /up` returns 200
|
||||||
- [x] 2.4 Run `composer quality` after compose/docs changes — pint/phpstan/test:feature all green locally (browser suite is CI-only, per AGENTS.md)
|
- [ ] 2.4 Run `composer quality` after compose/docs changes
|
||||||
|
|
||||||
## 3. Quality gates: npm audit and coverage
|
## 3. Quality gates: npm audit and coverage
|
||||||
|
|
||||||
- [x] 3.1 Add npm audit step to `composer quality` and CI `static` (policy: production deps; document any allowlist) — `npm audit --omit=dev --audit-level=high`, rationale documented inline in `ci.yml`; currently a vacuous forward guard since `package.json` has no runtime `dependencies`
|
- [ ] 3.1 Add npm audit step to `composer quality` and CI `static` (policy: production deps; document any allowlist)
|
||||||
- [x] 3.2 Enable Domain/Application coverage in CI `unit` with 80% fail threshold; exclude views/migrations/framework — scoped via a dedicated `phpunit.coverage.xml` (not the project-wide `phpunit.xml`), run as `Unit,Architecture,Feature` because the `Application/Queries/Marketing` classes are only exercised via Feature/HTTP tests; measured locally with `pcov` at 98.1%, well above the 80% gate
|
- [ ] 3.2 Enable Domain/Application coverage in CI `unit` with 80% fail threshold; exclude views/migrations/framework
|
||||||
- [x] 3.3 Add/adjust unit tests if current Domain/Application coverage is below threshold — no-op: measured coverage (98.1%) already clears 80% with existing Feature-suite coverage of the Marketing queries plus existing `PageMeta`/`HomeContent` unit tests
|
- [ ] 3.3 Add/adjust unit tests if current Domain/Application coverage is below threshold
|
||||||
- [ ] 3.4 Verify CI `static` and `unit` fail appropriately on intentional audit/coverage breakage in a branch experiment or equivalent proof — blocked: no push/PR in this task's scope, so no real CI run exists to break intentionally; defer to a follow-up once a PR is open
|
- [ ] 3.4 Verify CI `static` and `unit` fail appropriately on intentional audit/coverage breakage in a branch experiment or equivalent proof
|
||||||
|
|
||||||
## 4. Staging/production Compose and Dokploy prep
|
## 4. Staging/production Compose and Dokploy prep
|
||||||
|
|
||||||
@@ -38,6 +38,6 @@
|
|||||||
|
|
||||||
- [ ] 6.1 Perform first successful staging deploy of a `main` SHA and capture evidence (workflow URL, smoke output)
|
- [ ] 6.1 Perform first successful staging deploy of a `main` SHA and capture evidence (workflow URL, smoke output)
|
||||||
- [ ] 6.2 Verify rollback to previous SHA works once on staging
|
- [ ] 6.2 Verify rollback to previous SHA works once on staging
|
||||||
- [x] 6.3 Update `SPEC.md` §18 Fase 0 checkboxes only for items with evidence; note remaining deferred items if any — flipped L2338 (FrankenPHP/Compose) and the auth/npm-audit/coverage bullets to `[x]`; left the staging hello-world bullet and the phase exit-criterion line unchecked (Dokploy deploy still failing, out of scope here)
|
- [ ] 6.3 Update `SPEC.md` §18 Fase 0 checkboxes only for items with evidence; note remaining deferred items if any
|
||||||
- [ ] 6.4 Run full `composer quality` and confirm all five CI jobs + staging deploy path green
|
- [ ] 6.4 Run full `composer quality` and confirm all five CI jobs + staging deploy path green
|
||||||
- [ ] 6.5 Report in SPEC §24 format; archive this change only after remaining parity tasks (1–3) also complete
|
- [ ] 6.5 Report in SPEC §24 format; archive this change only after remaining parity tasks (1–3) also complete
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ schema: spec-driven
|
|||||||
context: |
|
context: |
|
||||||
Fonte de verdade: SPEC.md na raiz. Precedência: instrução do dono do produto > SPEC.md > ADRs > testes > convenções.
|
Fonte de verdade: SPEC.md na raiz. Precedência: instrução do dono do produto > SPEC.md > ADRs > testes > convenções.
|
||||||
Produto: plataforma de assessoria de eventos, single-tenant, MVP. UI em pt-BR, timezone America/Sao_Paulo, atuação em São Paulo (capital), BRL.
|
Produto: plataforma de assessoria de eventos, single-tenant, MVP. UI em pt-BR, timezone America/Sao_Paulo, atuação em São Paulo (capital), BRL.
|
||||||
Stack: Laravel 13, Filament 5 (/admin), Blade + Tailwind + JS vanilla progressivo (site público; sem framework reativo — Livewire 4 é dependência do Filament, ver SPEC ADR-015),
|
Stack: Laravel 13, Filament 5 (/admin), Livewire 4 + Blade + Alpine + Tailwind (site público),
|
||||||
PostgreSQL, FrankenPHP regular mode (sem worker mode), Vite, Pest 4 + Pest Browser, database queue.
|
PostgreSQL, FrankenPHP regular mode (sem worker mode), Vite, Pest 4 + Pest Browser, database queue.
|
||||||
Arquitetura: monólito modular. Interface -> Application (Actions/Queries) -> Domain (Enums/VOs) -> Infrastructure.
|
Arquitetura: monólito modular. Interface -> Application (Actions/Queries) -> Domain (Enums/VOs) -> Infrastructure.
|
||||||
Domain não depende de Filament/Livewire. strict_types em todo PHP próprio.
|
Domain não depende de Filament/Livewire. strict_types em todo PHP próprio.
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ The system SHALL keep versioned screenshot baselines for the public screens avai
|
|||||||
|
|
||||||
### Requirement: Visual runs are deterministic
|
### Requirement: Visual runs are deterministic
|
||||||
|
|
||||||
Visual runs SHALL be deterministic per SPEC §13.5: fixed Chromium and Linux image, fixed viewport, timezone `America/Sao_Paulo`, locale `pt-BR`, self-hosted fonts installed/bundled for the suite, frozen clock, deterministic seed (including real testimonial subset and São Paulo settings), animations and transitions disabled, and no dependency on external network.
|
Visual runs SHALL be deterministic per SPEC §13.5: fixed Chromium and Linux image, fixed viewport, timezone `America/Fortaleza`, locale `pt-BR`, self-hosted fonts installed/bundled for the suite, frozen clock, deterministic seed (including real testimonial subset and São Paulo settings), animations and transitions disabled, and no dependency on external network.
|
||||||
|
|
||||||
#### Scenario: Repeated run without code change produces no diff
|
#### Scenario: Repeated run without code change produces no diff
|
||||||
|
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!--
|
|
||||||
Coverage-scoped PHPUnit configuration.
|
|
||||||
|
|
||||||
Used only by the CI `unit` job's coverage gate (composer test:coverage), via
|
|
||||||
`pest -c phpunit.coverage.xml`. Kept separate from phpunit.xml so the
|
|
||||||
project-wide <source> block used by every other test/coverage invocation
|
|
||||||
stays untouched (it still covers all of app/).
|
|
||||||
|
|
||||||
Scope: app/Domain and app/Application only, per SPEC.md L2351. app/Domain
|
|
||||||
currently holds only `DomainModule` (a placeholder with zero executable
|
|
||||||
lines), so in practice this gate measures app/Application until Domain
|
|
||||||
gains real logic.
|
|
||||||
-->
|
|
||||||
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
|
||||||
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
|
|
||||||
bootstrap="vendor/autoload.php"
|
|
||||||
colors="true"
|
|
||||||
>
|
|
||||||
<testsuites>
|
|
||||||
<testsuite name="Unit">
|
|
||||||
<directory>tests/Unit</directory>
|
|
||||||
</testsuite>
|
|
||||||
<testsuite name="Architecture">
|
|
||||||
<directory>tests/Architecture</directory>
|
|
||||||
</testsuite>
|
|
||||||
<testsuite name="Feature">
|
|
||||||
<directory>tests/Feature</directory>
|
|
||||||
</testsuite>
|
|
||||||
</testsuites>
|
|
||||||
<source>
|
|
||||||
<include>
|
|
||||||
<directory>app/Domain</directory>
|
|
||||||
<directory>app/Application</directory>
|
|
||||||
</include>
|
|
||||||
</source>
|
|
||||||
<php>
|
|
||||||
<env name="APP_KEY" value="base64:NXm/6jIyFcDGHoMKGc5QZuSaq0dRZFYPg1Isuy1fNvE="/>
|
|
||||||
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
|
|
||||||
<env name="BCRYPT_ROUNDS" value="4"/>
|
|
||||||
<env name="BROADCAST_CONNECTION" value="null"/>
|
|
||||||
<env name="CACHE_STORE" value="database"/>
|
|
||||||
<env name="DB_CONNECTION" value="pgsql"/>
|
|
||||||
<env name="DB_HOST" value="127.0.0.1"/>
|
|
||||||
<env name="DB_PORT" value="5432"/>
|
|
||||||
<env name="DB_DATABASE" value="amare_test"/>
|
|
||||||
<env name="DB_USERNAME" value="amare"/>
|
|
||||||
<env name="DB_PASSWORD" value="secret"/>
|
|
||||||
<env name="DB_URL" value=""/>
|
|
||||||
<env name="MAIL_MAILER" value="array"/>
|
|
||||||
<env name="QUEUE_CONNECTION" value="database"/>
|
|
||||||
<env name="SESSION_DRIVER" value="database"/>
|
|
||||||
<env name="PULSE_ENABLED" value="false"/>
|
|
||||||
<env name="TELESCOPE_ENABLED" value="false"/>
|
|
||||||
<env name="NIGHTWATCH_ENABLED" value="false"/>
|
|
||||||
</php>
|
|
||||||
</phpunit>
|
|
||||||
|
Before Width: | Height: | Size: 9.0 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 82 KiB |
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 9.8 KiB After Width: | Height: | Size: 41 KiB |
@@ -59,27 +59,6 @@
|
|||||||
|
|
||||||
--ease-amare: var(--amare-ease-standard);
|
--ease-amare: var(--amare-ease-standard);
|
||||||
--default-transition-duration: var(--amare-duration-normal);
|
--default-transition-duration: var(--amare-duration-normal);
|
||||||
|
|
||||||
/* Neutralised, not omitted. Tailwind's Vite plugin shares one context
|
|
||||||
* across every entry in the build, so the Filament theme entry's
|
|
||||||
* `@source app/Filament/**` makes Filament's `shadow-*` usage visible and
|
|
||||||
* Tailwind then emits its DEFAULT --shadow-sm/md/lg into this public
|
|
||||||
* bundle too — verified by diffing the built app.css with and without
|
|
||||||
* that entry. Nothing public uses a shadow utility today, so rendering is
|
|
||||||
* unchanged either way, but leaving real shadow values defined here would
|
|
||||||
* let a future `shadow-sm` on a public element silently violate
|
|
||||||
* DESIGN.md's Tonal Layer Rule, and HeritageEditorialTokensTest only
|
|
||||||
* inspects source files, so it would not catch it. Zeroing them keeps the
|
|
||||||
* rule true in the artifact that actually ships. */
|
|
||||||
--shadow-2xs: 0 0 #0000;
|
|
||||||
--shadow-xs: 0 0 #0000;
|
|
||||||
--shadow-sm: 0 0 #0000;
|
|
||||||
--shadow: 0 0 #0000;
|
|
||||||
--shadow-md: 0 0 #0000;
|
|
||||||
--shadow-lg: 0 0 #0000;
|
|
||||||
--shadow-xl: 0 0 #0000;
|
|
||||||
--shadow-2xl: 0 0 #0000;
|
|
||||||
--shadow-inner: 0 0 #0000;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
|
|||||||
@@ -1,62 +0,0 @@
|
|||||||
@import '../../../../vendor/filament/filament/resources/css/theme.css';
|
|
||||||
|
|
||||||
@source '../../../../app/Filament/**/*';
|
|
||||||
@source '../../../views/filament/**/*';
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Heritage Editorial parity for the admin panel — same rules as
|
|
||||||
* resources/css/tokens.css, expressed through Filament's own theming layer
|
|
||||||
* instead of touching its component CSS. Filament's public site tokens (and
|
|
||||||
* the test that pins them, HeritageEditorialTokensTest) must never gain
|
|
||||||
* shadow-shaped tokens, so this Filament-scoped file is the only legal home
|
|
||||||
* for the "flat surfaces, no card shadows" half of that rule.
|
|
||||||
*/
|
|
||||||
@theme {
|
|
||||||
/* Sharp Edge Rule — no rounded corners anywhere in the panel. Filament's
|
|
||||||
* component CSS overwhelmingly uses `rounded-{sm,md,lg,xl}` (mirroring
|
|
||||||
* tokens.css's own four stops), but ~227 rules across the import graph
|
|
||||||
* use the bare `rounded` utility, which Tailwind resolves against the
|
|
||||||
* suffixless `--radius` key — easy to miss since tokens.css has no
|
|
||||||
* bare-`--amare-radius` equivalent to copy from. */
|
|
||||||
--radius: 0;
|
|
||||||
--radius-sm: 0;
|
|
||||||
--radius-md: 0;
|
|
||||||
--radius-lg: 0;
|
|
||||||
--radius-xl: 0;
|
|
||||||
|
|
||||||
/* Tonal Layer Rule — flat surfaces, no card shadows. */
|
|
||||||
--shadow-2xs: 0 0 #0000;
|
|
||||||
--shadow-xs: 0 0 #0000;
|
|
||||||
--shadow-sm: 0 0 #0000;
|
|
||||||
--shadow: 0 0 #0000;
|
|
||||||
--shadow-md: 0 0 #0000;
|
|
||||||
--shadow-lg: 0 0 #0000;
|
|
||||||
--shadow-xl: 0 0 #0000;
|
|
||||||
--shadow-2xl: 0 0 #0000;
|
|
||||||
--shadow-inner: 0 0 #0000;
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* `@theme` above reaches every *source* Filament component rule that resolves
|
|
||||||
* `rounded-*`/`shadow-*` against `var(--radius-*)`/the default shadow tokens,
|
|
||||||
* because `theme.css` imports the uncompiled CSS for every Filament
|
|
||||||
* sub-package. It cannot reach CSS that was already compiled to literal
|
|
||||||
* values before this build runs. Two such fragments exist:
|
|
||||||
*
|
|
||||||
* - vendor/filament/support/dist/index.css ships vendored Tippy.js tooltip
|
|
||||||
* styles (`.tippy-box` / `.tippy-box[data-theme~="light"]`) with a literal
|
|
||||||
* `border-radius: 4px` and `box-shadow: 0 0 20px ...`. Tooltips render on
|
|
||||||
* every panel page, so they get an explicit override below.
|
|
||||||
* - vendor/filament/forms/dist/index.css also ships vendored noUiSlider,
|
|
||||||
* FilePond, and EasyMDE/CodeMirror CSS with their own hardcoded
|
|
||||||
* radius/shadow rules (verified: `grep -rEo '[A-Za-z]+::make\(' app/Filament`
|
|
||||||
* turns up no slider, FileUpload, MarkdownEditor, or RichEditor field in
|
|
||||||
* this app), so none of those fragments ever render and they're left alone.
|
|
||||||
*/
|
|
||||||
.tippy-box {
|
|
||||||
border-radius: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tippy-box[data-theme~='light'] {
|
|
||||||
box-shadow: 0 0 #0000;
|
|
||||||
}
|
|
||||||
@@ -20,25 +20,9 @@
|
|||||||
$kind = $mark ? 'mark' : 'lockup';
|
$kind = $mark ? 'mark' : 'lockup';
|
||||||
$staticSrc = asset("brand/{$kind}-{$variant}.webp");
|
$staticSrc = asset("brand/{$kind}-{$variant}.webp");
|
||||||
|
|
||||||
$usesUploadedLogo = filled($uploadedPath);
|
$src = filled($uploadedPath)
|
||||||
|
|
||||||
$src = $usesUploadedLogo
|
|
||||||
? \Illuminate\Support\Facades\Storage::disk('public')->url($uploadedPath)
|
? \Illuminate\Support\Facades\Storage::disk('public')->url($uploadedPath)
|
||||||
: $staticSrc;
|
: $staticSrc;
|
||||||
|
|
||||||
// Reserve the box before the image arrives (SPEC §6.4). The intrinsic size
|
|
||||||
// of the shipped assets is known and fixed; an uploaded logo has arbitrary
|
|
||||||
// dimensions, so it gets no attributes rather than wrong ones. The CSS
|
|
||||||
// classes still govern the rendered size in both cases — width/height only
|
|
||||||
// give the browser the aspect ratio to reserve.
|
|
||||||
//
|
|
||||||
// The webp assets are encoded at 3x the largest rendered size (lockup at
|
|
||||||
// h-12 = 48 px, mark at h-8 = 32 px), which is why these are 149x144 and
|
|
||||||
// 191x96 rather than the 512-wide originals kept as png fallbacks. See
|
|
||||||
// tests/Feature/PublicSite/BrandAssetBudgetTest.php.
|
|
||||||
$intrinsic = $usesUploadedLogo
|
|
||||||
? []
|
|
||||||
: ($mark ? ['width' => 191, 'height' => 96] : ['width' => 149, 'height' => 144]);
|
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<img
|
<img
|
||||||
@@ -48,5 +32,5 @@
|
|||||||
'class' => trim('brand-logo '.$class),
|
'class' => trim('brand-logo '.$class),
|
||||||
'decoding' => 'async',
|
'decoding' => 'async',
|
||||||
'loading' => 'eager',
|
'loading' => 'eager',
|
||||||
] + $intrinsic) }}
|
]) }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -60,7 +60,7 @@
|
|||||||
:alt="$settings->default_og_image_alt ?: $settings->brand_name"
|
:alt="$settings->default_og_image_alt ?: $settings->brand_name"
|
||||||
loading="eager"
|
loading="eager"
|
||||||
fetchpriority="high"
|
fetchpriority="high"
|
||||||
sizes="(max-width: 768px) calc(100vw - 3rem), 40vw"
|
sizes="(max-width: 768px) 100vw, 40vw"
|
||||||
class="img-editorial h-full w-full object-cover"
|
class="img-editorial h-full w-full object-cover"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
<x-media.image
|
<x-media.image
|
||||||
:path="$case->cover_image_path"
|
:path="$case->cover_image_path"
|
||||||
:alt="$case->cover_image_alt ?: $case->title"
|
:alt="$case->cover_image_alt ?: $case->title"
|
||||||
sizes="(max-width: 768px) calc(100vw - 3rem), 50vw"
|
sizes="(max-width: 768px) 100vw, 50vw"
|
||||||
class="img-editorial aspect-[4/3] w-full object-cover"
|
class="img-editorial aspect-[4/3] w-full object-cover"
|
||||||
/>
|
/>
|
||||||
@endif
|
@endif
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
@props([
|
@props([
|
||||||
'path',
|
'path',
|
||||||
'alt',
|
'alt',
|
||||||
// No image on the site spans the full viewport: every one of them sits inside
|
'sizes' => '(max-width: 768px) 100vw, 960px',
|
||||||
// `container-amare`, which reserves 1.5rem of padding on each side. Claiming
|
|
||||||
// 100vw made a 412 px viewport at DPR 1.75 ask for 721 px and jump to the
|
|
||||||
// 960 variant for a box it draws at 637 px.
|
|
||||||
'sizes' => '(max-width: 768px) calc(100vw - 3rem), 960px',
|
|
||||||
'loading' => 'lazy',
|
'loading' => 'lazy',
|
||||||
'fetchpriority' => null,
|
'fetchpriority' => null,
|
||||||
'width' => null,
|
'width' => null,
|
||||||
@@ -22,25 +18,15 @@
|
|||||||
$diskName = $disk ?? PublicImageUploadRules::disk();
|
$diskName = $disk ?? PublicImageUploadRules::disk();
|
||||||
$filesystem = Storage::disk($diskName);
|
$filesystem = Storage::disk($diskName);
|
||||||
$src = $filesystem->url($path);
|
$src = $filesystem->url($path);
|
||||||
|
$variants = ResponsiveImage::availableVariants($path, $diskName);
|
||||||
$toSrcset = fn (array $variants): string => collect($variants)
|
$srcset = collect($variants)
|
||||||
->map(fn (array $variant): string => $filesystem->url($variant['path']).' '.$variant['width'].'w')
|
->map(fn (array $variant): string => $filesystem->url($variant['path']).' '.$variant['width'].'w')
|
||||||
->implode(', ');
|
->implode(', ');
|
||||||
|
|
||||||
$variants = ResponsiveImage::availableVariants($path, $diskName);
|
|
||||||
$srcset = $toSrcset($variants);
|
|
||||||
|
|
||||||
if ($srcset === '' && $filesystem->exists($path)) {
|
if ($srcset === '' && $filesystem->exists($path)) {
|
||||||
$srcset = null;
|
$srcset = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Offered ahead of the original format because webp carries the same picture
|
|
||||||
// for roughly a third of the bytes, and the MAN-109 audit found the hero
|
|
||||||
// image to be the LCP element on every page at mobile widths. Media uploaded
|
|
||||||
// before `media:generate-variants` learned to emit webp has no siblings, so
|
|
||||||
// the <source> is skipped rather than pointed at nothing.
|
|
||||||
$webpSrcset = $toSrcset(ResponsiveImage::availableWebpVariants($path, $diskName));
|
|
||||||
|
|
||||||
$dimensions = ($width === null || $height === null)
|
$dimensions = ($width === null || $height === null)
|
||||||
? ResponsiveImage::dimensions($path, $diskName)
|
? ResponsiveImage::dimensions($path, $diskName)
|
||||||
: null;
|
: null;
|
||||||
@@ -50,13 +36,6 @@
|
|||||||
$loadingValue = $loading;
|
$loadingValue = $loading;
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
{{-- `display: contents` keeps <picture> out of the layout: the callers style the
|
|
||||||
<img> with classes like `h-full w-full object-cover` that resolve against the
|
|
||||||
grid or flex parent, and an inline wrapper would break that. --}}
|
|
||||||
@if ($webpSrcset !== '')
|
|
||||||
<picture class="contents">
|
|
||||||
<source type="image/webp" srcset="{{ $webpSrcset }}" sizes="{{ $sizes }}">
|
|
||||||
@endif
|
|
||||||
<img
|
<img
|
||||||
src="{{ $src }}"
|
src="{{ $src }}"
|
||||||
@if ($srcset) srcset="{{ $srcset }}" sizes="{{ $sizes }}" @endif
|
@if ($srcset) srcset="{{ $srcset }}" sizes="{{ $sizes }}" @endif
|
||||||
@@ -68,6 +47,3 @@
|
|||||||
@if ($class) class="{{ $class }}" @endif
|
@if ($class) class="{{ $class }}" @endif
|
||||||
{{ $attributes->except(['path', 'alt', 'sizes', 'loading', 'width', 'height', 'disk', 'class']) }}
|
{{ $attributes->except(['path', 'alt', 'sizes', 'loading', 'width', 'height', 'disk', 'class']) }}
|
||||||
>
|
>
|
||||||
@if ($webpSrcset !== '')
|
|
||||||
</picture>
|
|
||||||
@endif
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
:path="$siteSettings->about_image_path"
|
:path="$siteSettings->about_image_path"
|
||||||
:alt="$siteSettings->about_image_alt ?: $siteSettings->brand_name"
|
:alt="$siteSettings->about_image_alt ?: $siteSettings->brand_name"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
sizes="(max-width: 768px) calc(100vw - 3rem), 50vw"
|
sizes="(max-width: 768px) 100vw, 50vw"
|
||||||
class="img-editorial aspect-[4/3] w-full object-cover"
|
class="img-editorial aspect-[4/3] w-full object-cover"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
<x-media.image
|
<x-media.image
|
||||||
:path="$case->cover_image_path"
|
:path="$case->cover_image_path"
|
||||||
:alt="$case->cover_image_alt ?: $case->title"
|
:alt="$case->cover_image_alt ?: $case->title"
|
||||||
sizes="(max-width: 768px) calc(100vw - 3rem), 50vw"
|
sizes="(max-width: 768px) 100vw, 50vw"
|
||||||
class="img-editorial aspect-[4/3] w-full object-cover transition-transform duration-(--amare-duration-slow) ease-(--amare-ease-standard) motion-safe:hover:scale-[1.02]"
|
class="img-editorial aspect-[4/3] w-full object-cover transition-transform duration-(--amare-duration-slow) ease-(--amare-ease-standard) motion-safe:hover:scale-[1.02]"
|
||||||
/>
|
/>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
:path="$case->cover_image_path"
|
:path="$case->cover_image_path"
|
||||||
:alt="$case->cover_image_alt ?: $case->title"
|
:alt="$case->cover_image_alt ?: $case->title"
|
||||||
loading="eager"
|
loading="eager"
|
||||||
sizes="(max-width: 1024px) calc(100vw - 3rem), 1120px"
|
sizes="(max-width: 1024px) 100vw, 1120px"
|
||||||
class="img-editorial aspect-[16/9] w-full object-cover"
|
class="img-editorial aspect-[16/9] w-full object-cover"
|
||||||
data-motion-beat="media"
|
data-motion-beat="media"
|
||||||
/>
|
/>
|
||||||
@@ -59,7 +59,7 @@
|
|||||||
<x-media.image
|
<x-media.image
|
||||||
:path="$image->path"
|
:path="$image->path"
|
||||||
:alt="$image->alt_text"
|
:alt="$image->alt_text"
|
||||||
sizes="(max-width: 768px) calc(100vw - 3rem), 50vw"
|
sizes="(max-width: 768px) 100vw, 50vw"
|
||||||
class="img-editorial aspect-[4/3] w-full object-cover"
|
class="img-editorial aspect-[4/3] w-full object-cover"
|
||||||
/>
|
/>
|
||||||
@if (filled($image->caption))
|
@if (filled($image->caption))
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
<x-media.image
|
<x-media.image
|
||||||
:path="$service->cover_image_path"
|
:path="$service->cover_image_path"
|
||||||
:alt="$service->cover_image_alt ?: $service->title"
|
:alt="$service->cover_image_alt ?: $service->title"
|
||||||
sizes="(max-width: 768px) calc(100vw - 3rem), 40vw"
|
sizes="(max-width: 768px) 100vw, 40vw"
|
||||||
class="img-editorial aspect-[16/10] w-full object-cover"
|
class="img-editorial aspect-[16/10] w-full object-cover"
|
||||||
/>
|
/>
|
||||||
@endif
|
@endif
|
||||||
|
|||||||
@@ -1,137 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
#
|
|
||||||
# Lighthouse run against the production image, reproducing the measurement
|
|
||||||
# behind MAN-109. Not a CI gate — SPEC.md §14.1 pins the five blocking jobs and
|
|
||||||
# §22 governs when new capability is added. This is the recipe so the numbers
|
|
||||||
# can be reproduced instead of remembered.
|
|
||||||
#
|
|
||||||
# Usage:
|
|
||||||
# scripts/perf/lighthouse.sh [output-dir]
|
|
||||||
#
|
|
||||||
# Environment:
|
|
||||||
# BASE_URL origin to audit (default http://127.0.0.1:8000)
|
|
||||||
# TARGET label for the output subtree (default local)
|
|
||||||
# RUNS runs per page and preset (default 3)
|
|
||||||
# PRESETS space-separated preset list (default "mobile desktop")
|
|
||||||
#
|
|
||||||
# Lighthouse LCP moves by a few tenths of a second between runs on the same
|
|
||||||
# build, so a single run cannot support a before/after comparison. Every page
|
|
||||||
# is audited RUNS times per preset and the run holding the median LCP is the
|
|
||||||
# one reported — averaging across runs would describe a page that never
|
|
||||||
# existed.
|
|
||||||
#
|
|
||||||
# Expects a site already answering on $BASE_URL. To raise one from scratch:
|
|
||||||
#
|
|
||||||
# docker build -t amare-app:ci .
|
|
||||||
# docker run -d --name amare-web -p 8000:8000 \
|
|
||||||
# -e APP_ENV=production -e APP_DEBUG=false -e APP_KEY="$APP_KEY" \
|
|
||||||
# -e DB_CONNECTION=pgsql -e DB_HOST=host.docker.internal -e DB_PORT=5432 \
|
|
||||||
# -e DB_DATABASE=amare -e DB_USERNAME=amare -e DB_PASSWORD=secret \
|
|
||||||
# -e SESSION_DRIVER=database -e CACHE_STORE=database -e QUEUE_CONNECTION=database \
|
|
||||||
# --add-host=host.docker.internal:host-gateway \
|
|
||||||
# -v "$(pwd)/storage/app/public:/app/storage/app/public" \
|
|
||||||
# amare-app:ci
|
|
||||||
#
|
|
||||||
# Seed content and generate the responsive variants first, and run both from
|
|
||||||
# the host rather than inside the container. ContentSeeder guards itself with
|
|
||||||
# an allow-list of local/staging/testing (database/seeders/ContentSeeder.php),
|
|
||||||
# so under the container's APP_ENV=production it is a deliberate no-op and
|
|
||||||
# Lighthouse would end up measuring empty pages. Skipping
|
|
||||||
# `media:generate-variants` inflates LCP by roughly 2.5 s on the home page,
|
|
||||||
# because the originals are served at full size:
|
|
||||||
#
|
|
||||||
# php artisan db:seed --class=ContentSeeder --force
|
|
||||||
# php artisan media:generate-variants
|
|
||||||
#
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
BASE_URL="${BASE_URL:-http://127.0.0.1:8000}"
|
|
||||||
BASE_URL="${BASE_URL%/}"
|
|
||||||
TARGET="${TARGET:-local}"
|
|
||||||
RUNS="${RUNS:-3}"
|
|
||||||
PRESETS="${PRESETS:-mobile desktop}"
|
|
||||||
OUT_DIR="${1:-storage/app/lighthouse}/${TARGET}"
|
|
||||||
|
|
||||||
# Lighthouse needs a Chrome binary. Playwright's is already on disk after
|
|
||||||
# `npx playwright install chromium`; fall back to a system Chrome.
|
|
||||||
if [[ -z "${CHROME_PATH:-}" ]]; then
|
|
||||||
PLAYWRIGHT_CHROME=$(find "${HOME}/Library/Caches/ms-playwright" "${HOME}/.cache/ms-playwright" \
|
|
||||||
-maxdepth 3 -name 'Google Chrome for Testing' -type f 2>/dev/null | head -1 || true)
|
|
||||||
|
|
||||||
if [[ -n "${PLAYWRIGHT_CHROME}" ]]; then
|
|
||||||
export CHROME_PATH="${PLAYWRIGHT_CHROME}"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
mkdir -p "${OUT_DIR}"
|
|
||||||
|
|
||||||
PAGES=(
|
|
||||||
"/:home"
|
|
||||||
"/servicos:servicos"
|
|
||||||
"/portfolio:portfolio"
|
|
||||||
"/portfolio/casamento-ana-lucas:portfolio-detalhe"
|
|
||||||
"/sobre:sobre"
|
|
||||||
"/contato:contato"
|
|
||||||
)
|
|
||||||
|
|
||||||
# A route that answers 404 still produces a Lighthouse report, and the error
|
|
||||||
# page is light enough to score well — the heaviest route on the site would be
|
|
||||||
# reported as excellent and nobody would notice. Refuse to measure anything
|
|
||||||
# that is not a 200 before spending minutes on the audit.
|
|
||||||
echo "preflight against ${BASE_URL}"
|
|
||||||
|
|
||||||
for entry in "${PAGES[@]}"; do
|
|
||||||
path="${entry%%:*}"
|
|
||||||
code="$(curl -sS -o /dev/null -w '%{http_code}' --max-time 20 "${BASE_URL}${path}" 2>/dev/null || true)"
|
|
||||||
|
|
||||||
if [[ "${code}" != "200" ]]; then
|
|
||||||
echo "preflight failed: ${BASE_URL}${path} answered HTTP ${code:-<none>}, expected 200" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo " ok ${path}"
|
|
||||||
done
|
|
||||||
|
|
||||||
for preset in ${PRESETS}; do
|
|
||||||
# The mobile preset is Lighthouse's default and rejects an explicit
|
|
||||||
# --preset flag, so only desktop is passed through.
|
|
||||||
preset_flags=()
|
|
||||||
if [[ "${preset}" != "mobile" ]]; then
|
|
||||||
preset_flags+=("--preset=${preset}")
|
|
||||||
fi
|
|
||||||
|
|
||||||
for entry in "${PAGES[@]}"; do
|
|
||||||
path="${entry%%:*}"
|
|
||||||
name="${entry##*:}"
|
|
||||||
|
|
||||||
for run in $(seq 1 "${RUNS}"); do
|
|
||||||
echo "auditing ${name} ${preset} run ${run}/${RUNS} (${BASE_URL}${path})"
|
|
||||||
|
|
||||||
# Default preset: simulated mobile throttling, 150 ms RTT,
|
|
||||||
# ~1.6 Mbps, 4x CPU slowdown.
|
|
||||||
# ${array[@]+...} keeps `set -u` from treating an empty array as
|
|
||||||
# unbound, which bash 3.2 (the macOS system bash) still does.
|
|
||||||
npx --yes lighthouse@12 "${BASE_URL}${path}" \
|
|
||||||
--quiet \
|
|
||||||
${preset_flags[@]+"${preset_flags[@]}"} \
|
|
||||||
--output=json --output=html \
|
|
||||||
--output-path="${OUT_DIR}/${name}-${preset}-run${run}" \
|
|
||||||
--chrome-flags="--headless=new --no-sandbox"
|
|
||||||
done
|
|
||||||
done
|
|
||||||
done
|
|
||||||
|
|
||||||
echo
|
|
||||||
echo "reports written to ${OUT_DIR}"
|
|
||||||
|
|
||||||
# The seeder is recorded because it decides the byte weight of every hero
|
|
||||||
# image: a before/after comparison across different fixtures measures nothing.
|
|
||||||
node scripts/perf/summarize-lighthouse.mjs "${OUT_DIR}" \
|
|
||||||
--target="${TARGET}" \
|
|
||||||
--base-url="${BASE_URL}" \
|
|
||||||
--commit="$(git rev-parse --short HEAD 2>/dev/null || echo unknown)$(git diff --quiet HEAD 2>/dev/null || echo '+alterações não commitadas')" \
|
|
||||||
--seeder="${SEEDER:-ContentSeeder}" \
|
|
||||||
> "${OUT_DIR}/summary.md"
|
|
||||||
|
|
||||||
echo "summary written to ${OUT_DIR}/summary.md"
|
|
||||||
echo "SPEC.md §6.6 targets: LCP <= 2.5s, CLS <= 0.1, INP <= 200ms, 0 console errors"
|
|
||||||
@@ -1,249 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
//
|
|
||||||
// Turns a directory of Lighthouse JSON reports into one reviewable markdown
|
|
||||||
// summary. Written because the numbers behind MAN-109 previously survived only
|
|
||||||
// as a hand-typed table in a Linear comment: `storage/app/lighthouse` is
|
|
||||||
// gitignored, so there was nothing to diff a later run against.
|
|
||||||
//
|
|
||||||
// For each page and preset the run holding the median LCP is the one reported.
|
|
||||||
// Metrics are never averaged across runs — every figure in the table comes
|
|
||||||
// from the same single navigation, so the LCP phase breakdown adds up.
|
|
||||||
//
|
|
||||||
// Usage:
|
|
||||||
// node scripts/perf/summarize-lighthouse.mjs <report-dir> [--target=local]
|
|
||||||
// [--base-url=...] [--commit=...] [--seeder=...]
|
|
||||||
|
|
||||||
import { readdirSync, readFileSync } from 'node:fs';
|
|
||||||
import { join } from 'node:path';
|
|
||||||
|
|
||||||
const [dir, ...rest] = process.argv.slice(2);
|
|
||||||
|
|
||||||
if (!dir) {
|
|
||||||
console.error('usage: summarize-lighthouse.mjs <report-dir> [--target=...]');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const flags = new Map(
|
|
||||||
rest
|
|
||||||
.filter((argument) => argument.startsWith('--'))
|
|
||||||
.map((argument) => {
|
|
||||||
const [key, ...value] = argument.replace(/^--/, '').split('=');
|
|
||||||
|
|
||||||
return [key, value.join('=')];
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const ms = (value) => (typeof value === 'number' ? `${(value / 1000).toFixed(2)} s` : '—');
|
|
||||||
const kib = (value) => (typeof value === 'number' ? `${Math.round(value / 1024)} KiB` : '—');
|
|
||||||
const score = (value) => (typeof value === 'number' ? Math.round(value * 100) : '—');
|
|
||||||
const numeric = (audit) => (typeof audit?.numericValue === 'number' ? audit.numericValue : null);
|
|
||||||
|
|
||||||
/** Every nested details table in an audit, flattened. */
|
|
||||||
function nestedItems(audit) {
|
|
||||||
const items = audit?.details?.items ?? [];
|
|
||||||
|
|
||||||
return items.flatMap((item) => (Array.isArray(item?.items) ? item.items : [item]));
|
|
||||||
}
|
|
||||||
|
|
||||||
function lcpElement(lhr) {
|
|
||||||
const item = nestedItems(lhr.audits?.['largest-contentful-paint-element']).find((entry) => entry?.node);
|
|
||||||
const snippet = item?.node?.snippet ?? item?.node?.selector ?? null;
|
|
||||||
|
|
||||||
return snippet ? snippet.replace(/\s+/g, ' ').slice(0, 160) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function lcpPhases(lhr) {
|
|
||||||
return nestedItems(lhr.audits?.['largest-contentful-paint-element'])
|
|
||||||
.filter((entry) => typeof entry?.phase === 'string')
|
|
||||||
.map((entry) => ({ phase: entry.phase, timing: entry.timing ?? null }));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Requests that finished before LCP, heaviest first — the contention evidence. */
|
|
||||||
function requestsBeforeLcp(lhr, lcp) {
|
|
||||||
const requests = lhr.audits?.['network-requests']?.details?.items ?? [];
|
|
||||||
|
|
||||||
return requests
|
|
||||||
.filter((request) => typeof request.networkEndTime === 'number' && (lcp === null || request.networkEndTime <= lcp + 50))
|
|
||||||
.filter((request) => (request.transferSize ?? 0) > 1024)
|
|
||||||
.sort((a, b) => (b.transferSize ?? 0) - (a.transferSize ?? 0))
|
|
||||||
.slice(0, 8)
|
|
||||||
.map((request) => ({
|
|
||||||
url: String(request.url ?? '').replace(/^https?:\/\/[^/]+/, ''),
|
|
||||||
type: request.resourceType ?? '—',
|
|
||||||
transferSize: request.transferSize ?? null,
|
|
||||||
endTime: request.networkEndTime ?? null,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Failing LH12 insights, with whatever savings estimate they carry. */
|
|
||||||
function insights(lhr) {
|
|
||||||
return Object.entries(lhr.audits ?? {})
|
|
||||||
.filter(([id, audit]) => id.endsWith('-insight') && typeof audit.score === 'number' && audit.score < 1)
|
|
||||||
.map(([id, audit]) => ({
|
|
||||||
id,
|
|
||||||
title: audit.title ?? id,
|
|
||||||
lcpSavings: audit.metricSavings?.LCP ?? null,
|
|
||||||
byteSavings: audit.details?.overallSavingsBytes ?? null,
|
|
||||||
}))
|
|
||||||
.sort((a, b) => (b.lcpSavings ?? 0) - (a.lcpSavings ?? 0));
|
|
||||||
}
|
|
||||||
|
|
||||||
const reports = new Map();
|
|
||||||
|
|
||||||
for (const file of readdirSync(dir).filter((name) => name.endsWith('.report.json')).sort()) {
|
|
||||||
const match = /^(.+)-(mobile|desktop)-run(\d+)\.report\.json$/.exec(file);
|
|
||||||
|
|
||||||
if (!match) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [, page, preset] = match;
|
|
||||||
const key = `${page}::${preset}`;
|
|
||||||
const lhr = JSON.parse(readFileSync(join(dir, file), 'utf8'));
|
|
||||||
|
|
||||||
if (!reports.has(key)) {
|
|
||||||
reports.set(key, []);
|
|
||||||
}
|
|
||||||
|
|
||||||
reports.get(key).push({ file, lhr });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (reports.size === 0) {
|
|
||||||
console.error(`no Lighthouse JSON reports found in ${dir}`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The run whose LCP is the median — reported whole, never blended. */
|
|
||||||
function medianRun(runs) {
|
|
||||||
const sorted = [...runs].sort(
|
|
||||||
(a, b) => (numeric(a.lhr.audits?.['largest-contentful-paint']) ?? Infinity)
|
|
||||||
- (numeric(b.lhr.audits?.['largest-contentful-paint']) ?? Infinity),
|
|
||||||
);
|
|
||||||
|
|
||||||
return sorted[Math.floor((sorted.length - 1) / 2)];
|
|
||||||
}
|
|
||||||
|
|
||||||
const rows = [];
|
|
||||||
const details = [];
|
|
||||||
|
|
||||||
for (const [key, runs] of [...reports.entries()].sort()) {
|
|
||||||
const [page, preset] = key.split('::');
|
|
||||||
const chosen = medianRun(runs);
|
|
||||||
const { lhr } = chosen;
|
|
||||||
const lcp = numeric(lhr.audits?.['largest-contentful-paint']);
|
|
||||||
const spread = runs
|
|
||||||
.map((run) => numeric(run.lhr.audits?.['largest-contentful-paint']))
|
|
||||||
.filter((value) => value !== null)
|
|
||||||
.sort((a, b) => a - b);
|
|
||||||
|
|
||||||
rows.push({
|
|
||||||
page,
|
|
||||||
preset,
|
|
||||||
performance: score(lhr.categories?.performance?.score),
|
|
||||||
accessibility: score(lhr.categories?.accessibility?.score),
|
|
||||||
bestPractices: score(lhr.categories?.['best-practices']?.score),
|
|
||||||
seo: score(lhr.categories?.seo?.score),
|
|
||||||
lcp,
|
|
||||||
cls: numeric(lhr.audits?.['cumulative-layout-shift']),
|
|
||||||
tbt: numeric(lhr.audits?.['total-blocking-time']),
|
|
||||||
fcp: numeric(lhr.audits?.['first-contentful-paint']),
|
|
||||||
ttfb: numeric(lhr.audits?.['server-response-time']),
|
|
||||||
runs: runs.length,
|
|
||||||
spread: spread.length > 1 ? [spread[0], spread[spread.length - 1]] : null,
|
|
||||||
file: chosen.file,
|
|
||||||
});
|
|
||||||
|
|
||||||
details.push({
|
|
||||||
page,
|
|
||||||
preset,
|
|
||||||
element: lcpElement(lhr),
|
|
||||||
phases: lcpPhases(lhr),
|
|
||||||
requests: requestsBeforeLcp(lhr, lcp),
|
|
||||||
insights: insights(lhr),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const first = reports.values().next().value[0].lhr;
|
|
||||||
|
|
||||||
const out = [];
|
|
||||||
|
|
||||||
out.push(`# Lighthouse — ${flags.get('target') ?? 'local'}`);
|
|
||||||
out.push('');
|
|
||||||
out.push(`- Origem: \`${flags.get('base-url') ?? '—'}\``);
|
|
||||||
out.push(`- Lighthouse: ${first.lighthouseVersion ?? '—'}`);
|
|
||||||
// Which Chrome ran the audit is part of the measurement: the stable system
|
|
||||||
// Chrome and Playwright's Chromium do not produce interchangeable numbers.
|
|
||||||
out.push(`- Navegador: \`${first.environment?.hostUserAgent ?? '—'}\``);
|
|
||||||
out.push(`- Coletado em: ${first.fetchTime ?? '—'}`);
|
|
||||||
out.push(`- Execuções por página/preset: ${rows[0]?.runs ?? '—'} (reportada a de LCP mediano)`);
|
|
||||||
|
|
||||||
if (flags.get('commit')) {
|
|
||||||
out.push(`- Commit: \`${flags.get('commit')}\``);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (flags.get('seeder')) {
|
|
||||||
out.push(`- Seeder: \`${flags.get('seeder')}\``);
|
|
||||||
}
|
|
||||||
|
|
||||||
out.push('');
|
|
||||||
out.push('Metas SPEC §6.6: LCP ≤ 2,5 s · CLS ≤ 0,1 · INP ≤ 200 ms · zero erro de console.');
|
|
||||||
out.push('');
|
|
||||||
out.push('| página | preset | perf | a11y | BP | SEO | LCP | FCP | CLS | TBT | TTFB servidor | LCP min–max |');
|
|
||||||
out.push('|---|---|---|---|---|---|---|---|---|---|---|---|');
|
|
||||||
|
|
||||||
for (const row of rows) {
|
|
||||||
const spread = row.spread ? `${ms(row.spread[0])} – ${ms(row.spread[1])}` : '—';
|
|
||||||
|
|
||||||
out.push(
|
|
||||||
`| ${row.page} | ${row.preset} | ${row.performance} | ${row.accessibility} | ${row.bestPractices} `
|
|
||||||
+ `| ${row.seo} | ${ms(row.lcp)} | ${ms(row.fcp)} | ${typeof row.cls === 'number' ? row.cls.toFixed(3) : '—'} `
|
|
||||||
+ `| ${typeof row.tbt === 'number' ? `${Math.round(row.tbt)} ms` : '—'} `
|
|
||||||
+ `| ${typeof row.ttfb === 'number' ? `${Math.round(row.ttfb)} ms` : '—'} | ${spread} |`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
out.push('');
|
|
||||||
out.push('## Decomposição do LCP');
|
|
||||||
|
|
||||||
for (const detail of details) {
|
|
||||||
out.push('');
|
|
||||||
out.push(`### ${detail.page} — ${detail.preset}`);
|
|
||||||
out.push('');
|
|
||||||
out.push(`Elemento de LCP: ${detail.element ? `\`${detail.element}\`` : '—'}`);
|
|
||||||
|
|
||||||
if (detail.phases.length > 0) {
|
|
||||||
out.push('');
|
|
||||||
out.push('| fase | tempo |');
|
|
||||||
out.push('|---|---|');
|
|
||||||
|
|
||||||
for (const phase of detail.phases) {
|
|
||||||
out.push(`| ${phase.phase} | ${ms(phase.timing)} |`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (detail.requests.length > 0) {
|
|
||||||
out.push('');
|
|
||||||
out.push('Requests concluídas até o LCP, mais pesadas primeiro:');
|
|
||||||
out.push('');
|
|
||||||
out.push('| recurso | tipo | transferido | fim |');
|
|
||||||
out.push('|---|---|---|---|');
|
|
||||||
|
|
||||||
for (const request of detail.requests) {
|
|
||||||
out.push(`| \`${request.url}\` | ${request.type} | ${kib(request.transferSize)} | ${ms(request.endTime)} |`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (detail.insights.length > 0) {
|
|
||||||
out.push('');
|
|
||||||
out.push('| insight com falha | ganho estimado de LCP | bytes |');
|
|
||||||
out.push('|---|---|---|');
|
|
||||||
|
|
||||||
for (const insight of detail.insights) {
|
|
||||||
out.push(`| ${insight.title} | ${ms(insight.lcpSavings)} | ${kib(insight.byteSavings)} |`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
out.push('');
|
|
||||||
|
|
||||||
console.log(out.join('\n'));
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
#
|
|
||||||
# Regenerates the visual regression baselines inside a Linux container that
|
|
||||||
# matches CI's rendering environment.
|
|
||||||
#
|
|
||||||
# `composer visual:update` run on macOS writes baselines CI will reject: the
|
|
||||||
# snapshots are pixels, and the Chromium build plus the font stack differ
|
|
||||||
# between the two systems. Pest Browser serves the application from an
|
|
||||||
# in-process Amp server, so no FrankenPHP container is involved — the only
|
|
||||||
# thing that has to match is the machine running the browser.
|
|
||||||
#
|
|
||||||
# SPEC.md §1.1 and openspec/specs/visual-regression/spec.md require the diff to
|
|
||||||
# be reviewed by a human before merge. This script updates baselines; it does
|
|
||||||
# not approve them.
|
|
||||||
#
|
|
||||||
# Usage:
|
|
||||||
# scripts/test/visual-update-ci.sh [--assert] [-- <extra pest args>]
|
|
||||||
#
|
|
||||||
# --assert run the suite in assertion mode instead of updating, to
|
|
||||||
# confirm the freshly written baselines actually pass
|
|
||||||
#
|
|
||||||
# Environment:
|
|
||||||
# DB_HOST host reachable from inside the container (default host.docker.internal)
|
|
||||||
# DB_PORT PostgreSQL port on that host (default 5433)
|
|
||||||
# DB_DATABASE database for the run (default amare_test)
|
|
||||||
# IMAGE runner image tag (default amare-ci-runner:local)
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
MODE="update"
|
|
||||||
if [[ "${1:-}" == "--assert" ]]; then
|
|
||||||
MODE="assert"
|
|
||||||
shift
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ "${1:-}" == "--" ]]; then
|
|
||||||
shift
|
|
||||||
fi
|
|
||||||
|
|
||||||
IMAGE="${IMAGE:-amare-ci-runner:local}"
|
|
||||||
DB_HOST="${DB_HOST:-host.docker.internal}"
|
|
||||||
DB_PORT="${DB_PORT:-5433}"
|
|
||||||
DB_DATABASE="${DB_DATABASE:-amare_test}"
|
|
||||||
DB_USERNAME="${DB_USERNAME:-amare}"
|
|
||||||
DB_PASSWORD="${DB_PASSWORD:-secret}"
|
|
||||||
|
|
||||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
|
||||||
|
|
||||||
if ! docker image inspect "${IMAGE}" >/dev/null 2>&1; then
|
|
||||||
echo "building ${IMAGE}"
|
|
||||||
docker build -f "${REPO_ROOT}/docker/ci-runner.Dockerfile" -t "${IMAGE}" "${REPO_ROOT}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
PEST_FLAGS="--testsuite=Browser"
|
|
||||||
if [[ "${MODE}" == "update" ]]; then
|
|
||||||
PEST_FLAGS="${PEST_FLAGS} --update-snapshots"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# node_modules and the Playwright browser cache live in named volumes rather
|
|
||||||
# than in the bind mount: the host copy is built for macOS and its native
|
|
||||||
# binaries (rollup, esbuild, playwright) would not run here. Both volumes are
|
|
||||||
# reused across runs so only the first one pays the install.
|
|
||||||
docker run --rm \
|
|
||||||
-v "${REPO_ROOT}:/app" \
|
|
||||||
-v amare-ci-node-modules:/app/node_modules \
|
|
||||||
-v amare-ci-playwright:/opt/playwright-browsers \
|
|
||||||
--add-host=host.docker.internal:host-gateway \
|
|
||||||
-e DB_CONNECTION=pgsql \
|
|
||||||
-e DB_HOST="${DB_HOST}" \
|
|
||||||
-e DB_PORT="${DB_PORT}" \
|
|
||||||
-e DB_DATABASE="${DB_DATABASE}" \
|
|
||||||
-e DB_USERNAME="${DB_USERNAME}" \
|
|
||||||
-e DB_PASSWORD="${DB_PASSWORD}" \
|
|
||||||
-e PEST_FLAGS="${PEST_FLAGS}" \
|
|
||||||
-e PEST_EXTRA="$*" \
|
|
||||||
"${IMAGE}" \
|
|
||||||
bash -euo pipefail -c '
|
|
||||||
# composer install is skipped when vendor/ came in through the bind
|
|
||||||
# mount: the dependencies are pure PHP, so the host copy is valid here.
|
|
||||||
if [ ! -f vendor/autoload.php ]; then
|
|
||||||
composer install --no-interaction --prefer-dist
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ ! -x node_modules/.bin/playwright ]; then
|
|
||||||
npm ci
|
|
||||||
fi
|
|
||||||
|
|
||||||
npx playwright install chromium
|
|
||||||
npm run build
|
|
||||||
php artisan migrate --force
|
|
||||||
php artisan db:seed --class=VisualContentSeeder --force
|
|
||||||
php artisan storage:link --force
|
|
||||||
|
|
||||||
php artisan test ${PEST_FLAGS} ${PEST_EXTRA}
|
|
||||||
'
|
|
||||||
92
tasks.md
@@ -1,19 +1,79 @@
|
|||||||
# Fix visual regression baselines (CI-parity)
|
# Frontend Audit — Amare site
|
||||||
|
|
||||||
- [x] 1. Worktree `fix/visual-baselines-ci` a partir de `origin/main` (.worktrees/visual-baselines-ci @ 0aee15e)
|
## T0 — Baseline
|
||||||
- [x] 2. Postgres `amare_test` up (amare-postgres reutilizado, DB criado)
|
- [x] Rodar `composer quality` p/ registrar estado verde atual (110 passed após composer install)
|
||||||
- [x] 3. Build `amare-app:ci` + container amare-web com env idêntica ao job browser (APP_FROZEN_NOW etc.) — /up ok
|
- [x] Registrado estado baseline; Lighthouse final consolidado no T6
|
||||||
- [x] 4. Build imagem amare-ci-runner (ubuntu:24.04 + PHP 8.4 + node 22 + playwright deps)
|
- [x] Registrar achados baseline (audit completo em frontend-audit.md + relatório subagent)
|
||||||
- [x] 5. Regenerar 16 snapshots via `php artisan test --testsuite=Browser --update-snapshots` dentro do ci-runner (network host)
|
|
||||||
- [x] 6. Verificar: re-run Browser suite verde (35/35)
|
|
||||||
- [x] 7. Commit .snap, push, PR #24, CI verde; bump commonmark 2.9.0 p/ auditoria; merged
|
|
||||||
|
|
||||||
# PR #23 — cadência editorial à home (sync + merge)
|
## T1 — Formulário de contato (e-mail, sem CRM)
|
||||||
|
- [x] Form em /contato (campos, LGPD obrigatório)
|
||||||
|
- [x] Rota POST /contato + validação + honeypot + throttle
|
||||||
|
- [x] Anti duplo-envio, estados sucesso/erro/rede, preservação de dados
|
||||||
|
- [x] E-mail via MAIL_MAILER (Resend prod / log local)
|
||||||
|
- [x] A11y do form (labels, erros ligados)
|
||||||
|
- [x] Testes feature + atualizar PublicPagesTest — Feature suite: 97 passed
|
||||||
|
|
||||||
- [x] 1. Reset worktree home-editorial-cadence p/ origin/feat/home-editorial-cadence (8ae5824, incl. reconcile)
|
## T2 — A11y/navegação
|
||||||
- [x] 2. Merge origin/main (PR #24): resolver conflitos home snaps (theirs); 14 snaps não-home via auto-merge
|
- [x] Menu mobile: Escape, retorno de foco, fallback sem-JS (app.js + noscript)
|
||||||
- [x] 3. Rebuild amare-app:ci com código PR #23; container amare-web recriado
|
- [x] Touch targets ≥44px (header, footer, CTAs, capítulos)
|
||||||
- [x] 4. Regenerar home desktop/mobile snaps no ci-runner (--update-snapshots); commit d9751b1
|
- [x] Footer: ul/li + address
|
||||||
- [x] 5. Verificação: Browser suite 37 passed (156 assertions) — inclui HomeEditorialCadenceTest/MotionTest
|
|
||||||
- [x] 6. Push; CI 5/5 verde (run 31230461189)
|
## T3 — SEO/metadados
|
||||||
- [x] 7. Merge squash PR #23 (commit 42b282c1); worktree/branch/remota limpos
|
- [x] Títulos "Página · Amare" (PageMeta::withBrandSuffix)
|
||||||
|
- [x] og:locale, og:site_name, Twitter cards
|
||||||
|
- [x] Favicon real + link, theme-color
|
||||||
|
- [x] Metadados corretos em 404/500 (PageMeta::forErrorPage)
|
||||||
|
- [x] noindex fora de produção (robots meta condicional)
|
||||||
|
|
||||||
|
## T4 — Conteúdo/posicionamento
|
||||||
|
- [x] Remover welcome.blade.php, home/cases, home/proof
|
||||||
|
- [x] Dedup heading final-CTA vs h1 contato; dedup disclaimers
|
||||||
|
- [x] CTA consistente mobile/desktop
|
||||||
|
- [x] Corrigir copy "Em breve..." nas listas
|
||||||
|
- [x] Rebalancear copy corporativo/social
|
||||||
|
|
||||||
|
## T5 — Performance
|
||||||
|
- [x] fetchpriority + dimensões hero (LCP)
|
||||||
|
- [x] Guarda CLS no media/image
|
||||||
|
- [x] Cache manifest de fontes
|
||||||
|
- [x] Escape JSON-LD
|
||||||
|
|
||||||
|
## T6 — Validação final
|
||||||
|
- [x] pint, phpstan, testes unit/feature/architecture — `composer quality` verde (135 testes)
|
||||||
|
- [x] Browser tests + visual regression (18 passaram; 8 baselines `.snap` atualizadas com `--update-snapshots`)
|
||||||
|
- [x] Lighthouse final (home mobile: A11y 100, Best Practices 100, SEO 69 só por noindex intencional fora de prod; contato desktop: A11y 100, BP 100)
|
||||||
|
- [x] E2E form: submit → sucesso role=status; 2 e-mails (novo briefing + confirmação) logados via MAIL_MAILER=log; fila drenada
|
||||||
|
- [x] Mobile: sem overflow horizontal nas 7 rotas (390px); menu mobile abre/fecha + Escape + retorno de foco
|
||||||
|
- [x] Relatório de entrega
|
||||||
|
|
||||||
|
## T7 — Hardening Public Blades
|
||||||
|
|
||||||
|
- [x] Chunk 1: CSS overflow resilience (`overflow-wrap` body + `[data-chapter-index]` wrap safety, browser test)
|
||||||
|
- [x] Chunk 2: Hero + final-CTA fallbacks + `leading-tight` (TDD)
|
||||||
|
- [x] Chunk 3a: Skip blank-quote testimonials (TDD)
|
||||||
|
- [x] Chunk 3b: Guard empty `event_type` + `break-words` meta line (TDD)
|
||||||
|
- [x] Chunk 4: Extend `PageMeta::forErrorPage` + branded 419/429/503 error views (TDD)
|
||||||
|
- [x] Chunk 5: `app.js` defensive hardening — bfcache submit reset, "Enviando…" label swap, aria-busy, mobile-menu focus trap + Escape
|
||||||
|
- [x] Chunk 6: Quality gates — Pint passed, PHPStan clean, 104 feature tests passed (587 assertions), Vite build succeeded
|
||||||
|
|
||||||
|
### Deviations from plan
|
||||||
|
|
||||||
|
- `PageMeta::forErrorPage` landed on `main` during implementation; rebased and extended it for 419/429/503.
|
||||||
|
- Contact form landed on `main`; submit-state browser coverage was restored.
|
||||||
|
- `event_type: null` violates NOT NULL column — test uses `''` instead (guard covers both).
|
||||||
|
|
||||||
|
# Browser CI Gate — estabilidade e diagnosticabilidade
|
||||||
|
|
||||||
|
## BG — Causa raiz
|
||||||
|
- [x] Diagnosticar 3 runs: mismatch visual (8 testes), acessibilidade (1), timeout networkidle 5s + overflow real
|
||||||
|
- [x] Reproduzir local: `waitForLoadState('networkidle')` fragil (conexões longas), default 5s
|
||||||
|
- [x] Reproduzir warning `file_get_contents(.env)` em TODO teste (CI sem .env)
|
||||||
|
|
||||||
|
## BG — Fixes
|
||||||
|
- [x] Helper `StableScreenshot` (tests/Support/): drop networkidle → readyState + fonts + settle
|
||||||
|
- [x] Exportar PNGs standalone (diff/expected/actual) no mismatch — visíveis nos artifacts
|
||||||
|
- [x] CI: `cp .env.example .env` nos jobs de teste; checkout/cache v5
|
||||||
|
- [x] Gitignore tests/Browser/Screenshots
|
||||||
|
- [x] Unit test MismatchScreenshotExporter; pint/phpstan/unit/feature verdes
|
||||||
|
- [x] Push + PR + CI browser verde (PR #15 merge f09ea83, run 31098941038)
|
||||||
|
- [x] Refresh baselines de CI se necessário (não necessário — browser passou com baselines atuais)
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ foreach ($screens as $screen => $path) {
|
|||||||
'reducedMotion' => 'reduce',
|
'reducedMotion' => 'reduce',
|
||||||
])
|
])
|
||||||
->withLocale('pt-BR')
|
->withLocale('pt-BR')
|
||||||
->withTimezone('America/Sao_Paulo')
|
->withTimezone('America/Fortaleza')
|
||||||
->resize($width, $height),
|
->resize($width, $height),
|
||||||
resetScroll: $path === '/',
|
resetScroll: $path === '/',
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,10 +5,8 @@ declare(strict_types=1);
|
|||||||
namespace Tests\Feature\Auth;
|
namespace Tests\Feature\Auth;
|
||||||
|
|
||||||
use App\Enums\UserRole;
|
use App\Enums\UserRole;
|
||||||
use App\Filament\Resources\Users\Pages\CreateUser;
|
|
||||||
use App\Filament\Resources\Users\Pages\ListUsers;
|
use App\Filament\Resources\Users\Pages\ListUsers;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Database\Seeders\DatabaseSeeder;
|
|
||||||
use Filament\Auth\Pages\Login;
|
use Filament\Auth\Pages\Login;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Livewire\Livewire;
|
use Livewire\Livewire;
|
||||||
@@ -65,21 +63,6 @@ class InternalAuthenticationTest extends TestCase
|
|||||||
$this->assertGuest();
|
$this->assertGuest();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_unverified_user_is_denied_panel_access(): void
|
|
||||||
{
|
|
||||||
$unverifiedUser = User::factory()->admin()->unverified()->create([
|
|
||||||
'email' => 'unverified@example.com',
|
|
||||||
]);
|
|
||||||
|
|
||||||
Livewire::test(Login::class)
|
|
||||||
->set('data.email', $unverifiedUser->email)
|
|
||||||
->set('data.password', 'password')
|
|
||||||
->call('authenticate')
|
|
||||||
->assertHasFormErrors(['email']);
|
|
||||||
|
|
||||||
$this->assertGuest();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_assistant_cannot_access_user_management(): void
|
public function test_assistant_cannot_access_user_management(): void
|
||||||
{
|
{
|
||||||
$assistant = User::factory()->assistant()->create();
|
$assistant = User::factory()->assistant()->create();
|
||||||
@@ -104,72 +87,4 @@ class InternalAuthenticationTest extends TestCase
|
|||||||
{
|
{
|
||||||
$this->assertSame(['admin', 'assistant'], array_column(UserRole::cases(), 'value'));
|
$this->assertSame(['admin', 'assistant'], array_column(UserRole::cases(), 'value'));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Regression test for the admin-managed-verification decision: a user
|
|
||||||
* created through the panel's own CreateUser form (not the factory,
|
|
||||||
* which sets email_verified_at directly) must come out verified and
|
|
||||||
* able to log in — UserForm has no email_verified_at field, so without
|
|
||||||
* CreateUser::handleRecordCreation setting it explicitly, every
|
|
||||||
* admin-created user would be permanently locked out with no in-app
|
|
||||||
* recovery path (the password-reset callback silently skips users that
|
|
||||||
* fail canAccessPanel()).
|
|
||||||
*/
|
|
||||||
public function test_admin_created_user_is_verified_and_can_log_in(): void
|
|
||||||
{
|
|
||||||
$admin = User::factory()->admin()->create();
|
|
||||||
|
|
||||||
$this->actingAs($admin);
|
|
||||||
|
|
||||||
Livewire::test(CreateUser::class)
|
|
||||||
->set('data.name', 'Novo Assistente')
|
|
||||||
->set('data.email', 'novo-assistente@example.com')
|
|
||||||
->set('data.role', UserRole::Assistant->value)
|
|
||||||
->set('data.is_active', true)
|
|
||||||
->set('data.password', 'password')
|
|
||||||
->call('create')
|
|
||||||
->assertHasNoFormErrors();
|
|
||||||
|
|
||||||
$created = User::query()->where('email', 'novo-assistente@example.com')->firstOrFail();
|
|
||||||
|
|
||||||
$this->assertTrue($created->hasVerifiedEmail());
|
|
||||||
|
|
||||||
// Log the creating admin back out — Login::mount() redirects an
|
|
||||||
// already-authenticated user away instead of rendering the form.
|
|
||||||
auth()->logout();
|
|
||||||
|
|
||||||
Livewire::test(Login::class)
|
|
||||||
->set('data.email', $created->email)
|
|
||||||
->set('data.password', 'password')
|
|
||||||
->call('authenticate')
|
|
||||||
->assertHasNoFormErrors()
|
|
||||||
->assertRedirect(route('filament.admin.pages.dashboard'));
|
|
||||||
|
|
||||||
$this->assertAuthenticatedAs($created);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Regression test for DatabaseSeeder's local admin/assistant: both must
|
|
||||||
* come out verified. `User::query()->updateOrCreate(...)` alone would
|
|
||||||
* silently drop `email_verified_at` — it isn't in User's #[Fillable]
|
|
||||||
* list — but `php artisan db:seed` (which is what `$this->seed()` below
|
|
||||||
* runs, and what `composer setup` runs too) wraps seeder execution in
|
|
||||||
* `Illuminate\Database\Eloquent\Model::unguarded()`
|
|
||||||
* (see Illuminate\Database\Console\Seeds\SeedCommand), which lifts mass-
|
|
||||||
* assignment guarding for the duration of the seed. That's what makes
|
|
||||||
* DatabaseSeeder's plain `updateOrCreate([...], ['email_verified_at' =>
|
|
||||||
* now(), ...])` work without a `forceFill()` — confirmed by exercising
|
|
||||||
* the actual `db:seed` path here rather than calling the seeder's
|
|
||||||
* `updateOrCreate` call inline.
|
|
||||||
*/
|
|
||||||
public function test_seeded_local_admin_and_assistant_are_verified(): void
|
|
||||||
{
|
|
||||||
$this->seed(DatabaseSeeder::class);
|
|
||||||
|
|
||||||
$admin = User::query()->where('email', 'admin@amare.local')->firstOrFail();
|
|
||||||
$assistant = User::query()->where('email', 'assistant@amare.local')->firstOrFail();
|
|
||||||
|
|
||||||
$this->assertTrue($admin->hasVerifiedEmail());
|
|
||||||
$this->assertTrue($assistant->hasVerifiedEmail());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,92 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Tests\Feature\Auth;
|
|
||||||
|
|
||||||
use App\Filament\Pages\Auth\RequestPasswordReset;
|
|
||||||
use App\Models\User;
|
|
||||||
use Filament\Auth\Notifications\ResetPassword;
|
|
||||||
use Filament\Notifications\Notification as FilamentNotification;
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
||||||
use Illuminate\Support\Facades\Notification as MailNotification;
|
|
||||||
use Illuminate\Support\Facades\Password;
|
|
||||||
use Livewire\Livewire;
|
|
||||||
use Tests\TestCase;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* SPEC 12.1 / ADM-01 "reset seguro": the request-reset page must not let a
|
|
||||||
* caller distinguish a registered email from an unregistered one. Filament's
|
|
||||||
* stock page fails this two ways — a distinguishable danger notification for
|
|
||||||
* both Password::INVALID_USER (no such user) and Password::RESET_THROTTLED
|
|
||||||
* (only ever returned for a user that exists, per
|
|
||||||
* Illuminate\Auth\Passwords\PasswordBroker::sendResetLink). App\Filament\
|
|
||||||
* Pages\Auth\RequestPasswordReset normalizes both to the same "sent"
|
|
||||||
* response. Assertions for the two emails live in separate test methods
|
|
||||||
* because the page's own rate limiter allows only 2 requests per IP before
|
|
||||||
* an unrelated "throttled" notification kicks in.
|
|
||||||
*/
|
|
||||||
class PasswordResetRequestTest extends TestCase
|
|
||||||
{
|
|
||||||
use RefreshDatabase;
|
|
||||||
|
|
||||||
public function test_reset_request_for_registered_email_sends_notification_and_reports_success(): void
|
|
||||||
{
|
|
||||||
MailNotification::fake();
|
|
||||||
|
|
||||||
$user = User::factory()->admin()->create([
|
|
||||||
'email' => 'registered@example.com',
|
|
||||||
]);
|
|
||||||
|
|
||||||
Livewire::test(RequestPasswordReset::class)
|
|
||||||
->set('data.email', $user->email)
|
|
||||||
->call('request')
|
|
||||||
->assertNotified($this->expectedSentNotification());
|
|
||||||
|
|
||||||
MailNotification::assertSentTo($user, ResetPassword::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_reset_request_for_unknown_email_reports_the_same_success_response(): void
|
|
||||||
{
|
|
||||||
MailNotification::fake();
|
|
||||||
|
|
||||||
Livewire::test(RequestPasswordReset::class)
|
|
||||||
->set('data.email', 'nobody@example.com')
|
|
||||||
->call('request')
|
|
||||||
->assertNotified($this->expectedSentNotification());
|
|
||||||
|
|
||||||
MailNotification::assertNothingSent();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_repeated_reset_requests_for_a_registered_email_keep_reporting_success(): void
|
|
||||||
{
|
|
||||||
MailNotification::fake();
|
|
||||||
|
|
||||||
$user = User::factory()->admin()->create([
|
|
||||||
'email' => 'registered-repeat@example.com',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$page = Livewire::test(RequestPasswordReset::class);
|
|
||||||
|
|
||||||
// First request creates a reset token; the second, within Laravel's
|
|
||||||
// default 60s broker throttle, would surface Password::RESET_THROTTLED
|
|
||||||
// instead of Password::RESET_LINK_SENT without the override above.
|
|
||||||
// The email field is re-set before each call because a successful
|
|
||||||
// `request()` resets the form (see the base page's `$this->form->fill()`).
|
|
||||||
$page->set('data.email', $user->email)
|
|
||||||
->call('request')
|
|
||||||
->assertNotified(Password::RESET_LINK_SENT);
|
|
||||||
|
|
||||||
$page->set('data.email', $user->email)
|
|
||||||
->call('request')
|
|
||||||
->assertNotified(Password::RESET_LINK_SENT);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function expectedSentNotification(): FilamentNotification
|
|
||||||
{
|
|
||||||
return FilamentNotification::make()
|
|
||||||
->title(__(Password::RESET_LINK_SENT))
|
|
||||||
->body(__('filament-panels::auth/pages/password-reset/request-password-reset.notifications.sent.body'))
|
|
||||||
->success();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Tests\Feature\Auth;
|
|
||||||
|
|
||||||
use App\Models\User;
|
|
||||||
use Filament\Panel;
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
use Tests\TestCase;
|
|
||||||
|
|
||||||
class VerifyExistingUserEmailsMigrationTest extends TestCase
|
|
||||||
{
|
|
||||||
use RefreshDatabase;
|
|
||||||
|
|
||||||
public function test_it_verifies_users_that_predate_must_verify_email(): void
|
|
||||||
{
|
|
||||||
$user = User::factory()->unverified()->create();
|
|
||||||
|
|
||||||
$this->assertFalse($user->hasVerifiedEmail(), 'Precondition: the user must start unverified.');
|
|
||||||
$this->assertFalse(
|
|
||||||
$user->canAccessPanel(app(Panel::class)),
|
|
||||||
'Precondition: an unverified user must be denied the panel, otherwise this migration guards nothing.'
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->runBackfillMigration();
|
|
||||||
|
|
||||||
$this->assertTrue($user->fresh()->hasVerifiedEmail());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_it_leaves_already_verified_users_untouched(): void
|
|
||||||
{
|
|
||||||
$verifiedAt = now()->subMonth()->startOfSecond();
|
|
||||||
$user = User::factory()->create(['email_verified_at' => $verifiedAt]);
|
|
||||||
|
|
||||||
$this->runBackfillMigration();
|
|
||||||
|
|
||||||
$this->assertTrue(
|
|
||||||
$verifiedAt->equalTo($user->fresh()->email_verified_at),
|
|
||||||
'The backfill must not move an existing verification timestamp.'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_it_uses_created_at_rather_than_the_migration_run_time(): void
|
|
||||||
{
|
|
||||||
$createdAt = now()->subYear()->startOfSecond();
|
|
||||||
|
|
||||||
$user = User::factory()->unverified()->create();
|
|
||||||
DB::table('users')->where('id', $user->id)->update(['created_at' => $createdAt]);
|
|
||||||
|
|
||||||
$this->runBackfillMigration();
|
|
||||||
|
|
||||||
$this->assertTrue(
|
|
||||||
$createdAt->equalTo($user->fresh()->email_verified_at),
|
|
||||||
'Verification should be dated to when the account was created, not to when the migration ran.'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The migration is an anonymous class, so it is required and invoked
|
|
||||||
* directly: RefreshDatabase has already run every migration before the
|
|
||||||
* test body executes, so re-running it through Artisan would be a no-op.
|
|
||||||
*/
|
|
||||||
private function runBackfillMigration(): void
|
|
||||||
{
|
|
||||||
$migration = require database_path('migrations/2026_08_10_120000_verify_existing_user_emails.php');
|
|
||||||
|
|
||||||
$migration->up();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Tests\Feature\Deployment;
|
|
||||||
|
|
||||||
use Illuminate\Support\Facades\Artisan;
|
|
||||||
use Tests\TestCase;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The `migrate` one-shot service in docker-compose.deploy.yml chains artisan
|
|
||||||
* calls with `&&`, and every other service waits on it via
|
|
||||||
* `condition: service_completed_successfully`. An unknown option makes artisan
|
|
||||||
* exit non-zero, the one-shot fails, and web/queue/scheduler never start — the
|
|
||||||
* whole stack stays down with no application-level error to read.
|
|
||||||
*
|
|
||||||
* That is exactly how `media:generate-variants --force` took staging down: the
|
|
||||||
* command defines no options at all.
|
|
||||||
*/
|
|
||||||
class DeployComposeArtisanCommandsTest extends TestCase
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* A folded (`>`) or literal (`|`) block scalar preserves the newline before
|
|
||||||
* any line indented deeper than the first one. `sh -c` then receives a
|
|
||||||
* multi-line string and aborts with `sh: 2: Syntax error: "&&" unexpected`
|
|
||||||
* before running a single command — the `migrate` one-shot fails, and
|
|
||||||
* because every other service waits on
|
|
||||||
* `condition: service_completed_successfully`, the whole stack stays down.
|
|
||||||
*
|
|
||||||
* This has now broken staging twice: fixed once in 5949fad by switching to
|
|
||||||
* the exec-array form, then reintroduced by 58f24a6. Hence a guard.
|
|
||||||
*/
|
|
||||||
public function test_deploy_compose_never_uses_a_block_scalar_for_command(): void
|
|
||||||
{
|
|
||||||
$contents = (string) file_get_contents(base_path('docker-compose.deploy.yml'));
|
|
||||||
|
|
||||||
preg_match_all('/^\s*command:\s*([>|][-+]?\d*)\s*$/m', $contents, $matches);
|
|
||||||
|
|
||||||
$this->assertSame(
|
|
||||||
[],
|
|
||||||
$matches[1],
|
|
||||||
'docker-compose.deploy.yml declares `command:` as a YAML block scalar ('.implode(', ', $matches[1]).'). '
|
|
||||||
.'Folding keeps the newline before every line indented deeper than the first, so `sh -c` gets a multi-line '
|
|
||||||
.'string and dies with `sh: 2: Syntax error: "&&" unexpected` before running anything. '
|
|
||||||
.'Use the single-line exec-array form: command: ["sh", "-c", "a && b && c"].'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_every_artisan_call_in_the_deploy_compose_is_valid(): void
|
|
||||||
{
|
|
||||||
$invocations = $this->artisanInvocationsInDeployCompose();
|
|
||||||
|
|
||||||
$this->assertNotEmpty(
|
|
||||||
$invocations,
|
|
||||||
'Expected docker-compose.deploy.yml to invoke artisan; the parser found nothing, so this guard is not actually checking anything.'
|
|
||||||
);
|
|
||||||
|
|
||||||
$registered = Artisan::all();
|
|
||||||
|
|
||||||
foreach ($invocations as [$name, $options]) {
|
|
||||||
$this->assertArrayHasKey(
|
|
||||||
$name,
|
|
||||||
$registered,
|
|
||||||
"docker-compose.deploy.yml calls `php artisan {$name}`, which is not a registered command."
|
|
||||||
);
|
|
||||||
|
|
||||||
$command = $registered[$name];
|
|
||||||
|
|
||||||
// Options like --no-interaction and --env belong to the console
|
|
||||||
// application rather than the command, and only appear in the
|
|
||||||
// command's definition once the two are merged.
|
|
||||||
$command->mergeApplicationDefinition();
|
|
||||||
|
|
||||||
$definition = $command->getDefinition();
|
|
||||||
|
|
||||||
foreach ($options as $option) {
|
|
||||||
$this->assertTrue(
|
|
||||||
$definition->hasOption($option),
|
|
||||||
"docker-compose.deploy.yml calls `php artisan {$name} --{$option}`, but that command defines no `--{$option}` option. "
|
|
||||||
.'Artisan exits non-zero on an unknown option, which fails the migrate one-shot and prevents the whole stack from starting.'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return list<array{0: string, 1: list<string>}>
|
|
||||||
*/
|
|
||||||
private function artisanInvocationsInDeployCompose(): array
|
|
||||||
{
|
|
||||||
// Read the file as text rather than parsing YAML: the commands are
|
|
||||||
// folded block scalars, so a call can wrap across lines, and the repo
|
|
||||||
// ships no YAML parser. Collapsing whitespace makes the wrapping
|
|
||||||
// irrelevant and keeps the guard dependency-free.
|
|
||||||
$contents = (string) file_get_contents(base_path('docker-compose.deploy.yml'));
|
|
||||||
$flattened = (string) preg_replace('/\s+/', ' ', $contents);
|
|
||||||
|
|
||||||
preg_match_all('/php artisan ([\w:.-]+)((?: --[\w-]+(?:=\S+)?)*)/', $flattened, $matches, PREG_SET_ORDER);
|
|
||||||
|
|
||||||
$invocations = [];
|
|
||||||
|
|
||||||
foreach ($matches as $match) {
|
|
||||||
preg_match_all('/--([\w-]+)/', $match[2], $optionMatches);
|
|
||||||
|
|
||||||
$invocations[] = [$match[1], $optionMatches[1]];
|
|
||||||
}
|
|
||||||
|
|
||||||
return $invocations;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,196 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Tests\Feature\Filament;
|
|
||||||
|
|
||||||
use App\Providers\Filament\AdminPanelProvider;
|
|
||||||
use Filament\Facades\Filament;
|
|
||||||
use Filament\FontProviders\LocalFontProvider;
|
|
||||||
use Filament\Support\Colors\Color;
|
|
||||||
use ReflectionMethod;
|
|
||||||
use Tests\TestCase;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pins the Heritage Editorial parity applied to the admin panel (MAN-118)
|
|
||||||
* the same way HeritageEditorialTokensTest pins the public site's tokens, so
|
|
||||||
* a future edit to AdminPanelProvider/the Filament theme entry cannot
|
|
||||||
* silently drift back toward Filament's stock Amber/Zinc/dark-mode defaults.
|
|
||||||
*/
|
|
||||||
class AdminPanelBrandParityTest extends TestCase
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @return array<int, string>
|
|
||||||
*/
|
|
||||||
private function invokeColorRamp(string $method): array
|
|
||||||
{
|
|
||||||
$reflection = new ReflectionMethod(AdminPanelProvider::class, $method);
|
|
||||||
$reflection->setAccessible(true);
|
|
||||||
|
|
||||||
return $reflection->invoke(new AdminPanelProvider($this->app));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_primary_ramp_pins_oliva_heranca_and_oliva_profundo(): void
|
|
||||||
{
|
|
||||||
$primary = $this->invokeColorRamp('primaryColor');
|
|
||||||
|
|
||||||
$this->assertSame('#556B2F', $primary[600]);
|
|
||||||
$this->assertSame('#3E5219', $primary[700]);
|
|
||||||
$this->assertSame('#8B9D77', $primary[300]);
|
|
||||||
|
|
||||||
// Deliberately NOT pure white: BadgeComponent/badge.css apply
|
|
||||||
// `bg-color-50` directly as a badge's background, and a pure-white
|
|
||||||
// 50 would render a "primary" badge as an invisible pill against
|
|
||||||
// the Papel Marfim (#FBF9F4) panel body.
|
|
||||||
$this->assertSame('#F3F5EC', $primary[50]);
|
|
||||||
$this->assertNotSame('#FFFFFF', $primary[50]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_gray_ramp_pins_the_paper_and_ink_tokens(): void
|
|
||||||
{
|
|
||||||
$gray = $this->invokeColorRamp('grayColor');
|
|
||||||
|
|
||||||
$this->assertSame('#FBF9F4', $gray[50]);
|
|
||||||
$this->assertSame('#F0EEE9', $gray[100]);
|
|
||||||
$this->assertSame('#E4E2DD', $gray[200]);
|
|
||||||
$this->assertSame('#C5C8B8', $gray[300]);
|
|
||||||
$this->assertSame('#5D6155', $gray[500]);
|
|
||||||
$this->assertSame('#1B1C19', $gray[900]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_semantic_ramps_pin_the_existing_amare_hexes_at_shade_600(): void
|
|
||||||
{
|
|
||||||
$this->assertSame('#991B1B', $this->invokeColorRamp('dangerColor')[600]);
|
|
||||||
$this->assertSame('#92400E', $this->invokeColorRamp('warningColor')[600]);
|
|
||||||
$this->assertSame('#166534', $this->invokeColorRamp('successColor')[600]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_oliva_heranca_and_tinta_oliva_pairs_stay_wcag_aa(): void
|
|
||||||
{
|
|
||||||
$primary = $this->invokeColorRamp('primaryColor');
|
|
||||||
$gray = $this->invokeColorRamp('grayColor');
|
|
||||||
|
|
||||||
// White CTA text on the Oliva Herança solid-button background.
|
|
||||||
$this->assertGreaterThanOrEqual(
|
|
||||||
Color::WCAG_AA_TEXT,
|
|
||||||
Color::calculateContrastRatio($primary[600], '#FFFFFF'),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Tinta Oliva body copy on the Papel Marfim panel background.
|
|
||||||
$this->assertGreaterThanOrEqual(
|
|
||||||
Color::WCAG_AA_TEXT,
|
|
||||||
Color::calculateContrastRatio($gray[900], $gray[50]),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Badge text (BadgeComponent resolves this to shade 500 for this
|
|
||||||
// ramp) against the badge's own bg-color-50 surface.
|
|
||||||
$this->assertGreaterThanOrEqual(
|
|
||||||
Color::WCAG_AA_TEXT,
|
|
||||||
Color::calculateContrastRatio($primary[50], $primary[500]),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_admin_panel_disables_dark_mode(): void
|
|
||||||
{
|
|
||||||
$this->assertFalse(Filament::getPanel('admin')->hasDarkMode());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_admin_panel_uses_the_amare_brand_name(): void
|
|
||||||
{
|
|
||||||
$this->assertSame('Amare Assessoria', Filament::getPanel('admin')->getBrandName());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_admin_panel_self_hosts_eb_garamond_via_local_font_provider(): void
|
|
||||||
{
|
|
||||||
$panel = Filament::getPanel('admin');
|
|
||||||
|
|
||||||
// Passed bare, not pre-quoted: Filament's own base layout wraps
|
|
||||||
// getFontFamily() in single quotes itself
|
|
||||||
// (`--font-family: '{!! filament()->getFontFamily() !!}';`), so a
|
|
||||||
// value of "'EB Garamond'" here would render as the broken
|
|
||||||
// `--font-family: ''EB Garamond'';` and silently fail to parse as a
|
|
||||||
// font-family, falling back to ui-sans-serif — verified against the
|
|
||||||
// actual rendered /admin/login output below, not assumed.
|
|
||||||
$this->assertSame('EB Garamond', $panel->getFontFamily());
|
|
||||||
$this->assertSame(LocalFontProvider::class, $panel->getFontProvider());
|
|
||||||
|
|
||||||
// font-mono / font-serif utilities (e.g. KeyValue in
|
|
||||||
// ManageSiteSettings) must not silently fall back to a system stack.
|
|
||||||
$this->assertSame('EB Garamond', $panel->getMonoFontFamily());
|
|
||||||
$this->assertSame(LocalFontProvider::class, $panel->getMonoFontProvider());
|
|
||||||
$this->assertSame('EB Garamond', $panel->getSerifFontFamily());
|
|
||||||
$this->assertSame(LocalFontProvider::class, $panel->getSerifFontProvider());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_admin_login_page_renders_the_self_hosted_face_and_brand(): void
|
|
||||||
{
|
|
||||||
// The only assertion that actually exercises ->viteTheme(), the
|
|
||||||
// HEAD_END render hook, and ->brandLogo() end to end — the panel
|
|
||||||
// config getters above can't catch a broken manifest reference, a
|
|
||||||
// render hook that throws, or a brandLogo closure that fails outside
|
|
||||||
// a real request. Requires `npm run build` to have populated
|
|
||||||
// public/build (CI's `test` job does this before running tests).
|
|
||||||
$response = $this->get('/admin/login');
|
|
||||||
|
|
||||||
$response->assertOk();
|
|
||||||
|
|
||||||
// Filament's base layout emits `--font-family: '<value>';` — a
|
|
||||||
// single-quoted value, since it does the wrapping itself.
|
|
||||||
$response->assertSee("--font-family: 'EB Garamond';", escape: false);
|
|
||||||
$response->assertSee("--mono-font-family: 'EB Garamond';", escape: false);
|
|
||||||
$response->assertSee("--serif-font-family: 'EB Garamond';", escape: false);
|
|
||||||
$response->assertSee('brand/lockup-on-light.webp', escape: false);
|
|
||||||
|
|
||||||
// The actual requirement behind pinning LocalFontProvider: no
|
|
||||||
// external font request leaks into the panel's <head>.
|
|
||||||
$response->assertDontSee('fonts.bunny.net');
|
|
||||||
$response->assertDontSee('fonts.googleapis.com');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_admin_panel_is_wired_to_its_own_vite_theme_entry(): void
|
|
||||||
{
|
|
||||||
$this->assertSame(
|
|
||||||
'resources/css/filament/admin/theme.css',
|
|
||||||
Filament::getPanel('admin')->getViteTheme(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_admin_theme_entry_zeroes_radius_and_shadow_tokens(): void
|
|
||||||
{
|
|
||||||
$theme = (string) file_get_contents(resource_path('css/filament/admin/theme.css'));
|
|
||||||
|
|
||||||
$this->assertStringContainsString(
|
|
||||||
"@import '../../../../vendor/filament/filament/resources/css/theme.css'",
|
|
||||||
$theme,
|
|
||||||
);
|
|
||||||
$this->assertStringContainsString('--radius: 0', $theme);
|
|
||||||
$this->assertStringContainsString('--radius-sm: 0', $theme);
|
|
||||||
$this->assertStringContainsString('--radius-md: 0', $theme);
|
|
||||||
$this->assertStringContainsString('--radius-lg: 0', $theme);
|
|
||||||
$this->assertStringContainsString('--radius-xl: 0', $theme);
|
|
||||||
$this->assertStringContainsString('--shadow: 0 0 #0000', $theme);
|
|
||||||
$this->assertStringContainsString('--shadow-sm: 0 0 #0000', $theme);
|
|
||||||
$this->assertStringContainsString('--shadow-md: 0 0 #0000', $theme);
|
|
||||||
$this->assertStringContainsString('--shadow-lg: 0 0 #0000', $theme);
|
|
||||||
|
|
||||||
// Manual override for the vendored Tippy.js tooltip CSS that ships
|
|
||||||
// already-compiled in vendor/filament/support/dist/index.css and
|
|
||||||
// therefore never sees the @theme overrides above.
|
|
||||||
$this->assertStringContainsString('.tippy-box', $theme);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_vite_builds_the_admin_theme_entry(): void
|
|
||||||
{
|
|
||||||
$vite = (string) file_get_contents(base_path('vite.config.js'));
|
|
||||||
|
|
||||||
$this->assertStringContainsString("'resources/css/filament/admin/theme.css'", $vite);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_admin_panel_brand_logo_reuses_the_public_lockup_asset(): void
|
|
||||||
{
|
|
||||||
$panel = Filament::getPanel('admin');
|
|
||||||
|
|
||||||
$this->assertSame(asset('brand/lockup-on-light.webp'), $panel->getBrandLogo());
|
|
||||||
$this->assertFileExists(base_path('public/brand/lockup-on-light.webp'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,263 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Tests\Feature\Marketing;
|
|
||||||
|
|
||||||
use App\Models\PortfolioCase;
|
|
||||||
use App\Models\PortfolioImage;
|
|
||||||
use App\Models\Service;
|
|
||||||
use App\Models\SiteSetting;
|
|
||||||
use App\Models\Testimonial;
|
|
||||||
use Database\Seeders\ContentSeeder;
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
||||||
use Illuminate\Support\Facades\Storage;
|
|
||||||
use PHPUnit\Framework\Attributes\DataProvider;
|
|
||||||
use Tests\TestCase;
|
|
||||||
|
|
||||||
class ContentSeederProductionGatingTest extends TestCase
|
|
||||||
{
|
|
||||||
use RefreshDatabase;
|
|
||||||
|
|
||||||
public function test_content_seeder_is_a_noop_on_an_empty_production_database(): void
|
|
||||||
{
|
|
||||||
Storage::fake('public');
|
|
||||||
$this->clearContentTables();
|
|
||||||
|
|
||||||
$this->app->detectEnvironment(fn (): string => 'production');
|
|
||||||
|
|
||||||
$this->runContentSeeder();
|
|
||||||
|
|
||||||
$this->assertSame(0, SiteSetting::query()->count());
|
|
||||||
$this->assertSame(0, Service::query()->count());
|
|
||||||
$this->assertSame(0, PortfolioCase::query()->count());
|
|
||||||
$this->assertSame(0, PortfolioImage::query()->count());
|
|
||||||
$this->assertSame(0, Testimonial::query()->count());
|
|
||||||
|
|
||||||
Storage::disk('public')->assertMissing('content/about/about-image.jpg');
|
|
||||||
Storage::disk('public')->assertMissing('content/og/og-default.jpg');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_content_seeder_does_not_clobber_owner_edited_content_in_production(): void
|
|
||||||
{
|
|
||||||
Storage::fake('public');
|
|
||||||
|
|
||||||
$siteSetting = SiteSetting::query()->create([
|
|
||||||
'brand_name' => 'Nome escolhido pela dona',
|
|
||||||
'hero_eyebrow' => 'Eyebrow original',
|
|
||||||
'hero_title' => 'Título original',
|
|
||||||
'hero_subtitle' => 'Subtítulo original',
|
|
||||||
'hero_cta_label' => 'CTA original',
|
|
||||||
'hero_secondary_cta_label' => 'CTA secundário original',
|
|
||||||
'hero_note' => 'Nota original',
|
|
||||||
'about_summary' => 'Resumo original',
|
|
||||||
'manifesto_title' => 'Manifesto original',
|
|
||||||
'manifesto_lead' => 'Lead original',
|
|
||||||
'manifesto_body' => 'Corpo original',
|
|
||||||
'method_intro' => 'Intro original',
|
|
||||||
'method_steps' => [['title' => 'Passo único', 'body' => 'Descrição']],
|
|
||||||
'principles' => ['Princípio único'],
|
|
||||||
'email' => 'dona@amare.example',
|
|
||||||
'phone' => '(11) 90000-0000',
|
|
||||||
'city' => 'Fortaleza - CE',
|
|
||||||
'social_links' => ['instagram' => 'https://instagram.com/dona'],
|
|
||||||
'default_meta_title' => 'Meta original',
|
|
||||||
'default_meta_description' => 'Meta descrição original',
|
|
||||||
'analytics_enabled' => true,
|
|
||||||
'analytics_script' => '<script>console.log("dona")</script>',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$service = Service::query()->create([
|
|
||||||
'title' => 'Serviço editado pela dona',
|
|
||||||
'slug' => 'casamentos',
|
|
||||||
'summary' => 'Resumo editado',
|
|
||||||
'description' => 'Descrição editada',
|
|
||||||
'sort_order' => 99,
|
|
||||||
'is_featured' => false,
|
|
||||||
'published_at' => null,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$case = PortfolioCase::query()->create([
|
|
||||||
'title' => 'Caso despublicado pela dona',
|
|
||||||
'slug' => 'casamento-ana-lucas',
|
|
||||||
'summary' => 'Resumo editado',
|
|
||||||
'event_type' => 'Casamento',
|
|
||||||
'city' => 'Fortaleza',
|
|
||||||
'venue' => 'Local editado',
|
|
||||||
'event_date' => '2024-01-01',
|
|
||||||
'challenge' => 'Desafio editado',
|
|
||||||
'solution' => 'Solução editada',
|
|
||||||
'result' => 'Resultado editado',
|
|
||||||
'cover_image_path' => 'content/portfolio/dona-cover.jpg',
|
|
||||||
'cover_image_alt' => 'Capa editada',
|
|
||||||
'is_featured' => false,
|
|
||||||
'sort_order' => 99,
|
|
||||||
'published_at' => null,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$image = PortfolioImage::query()->create([
|
|
||||||
'portfolio_case_id' => $case->id,
|
|
||||||
'path' => 'content/portfolio/foto-real-da-dona.jpg',
|
|
||||||
'alt_text' => 'Foto real enviada pela dona',
|
|
||||||
'caption' => 'Momento real',
|
|
||||||
'sort_order' => 1,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$testimonial = Testimonial::query()->create([
|
|
||||||
'quote' => 'Depoimento real não relacionado.',
|
|
||||||
'author_name' => 'Jeniffer e Maick',
|
|
||||||
'context' => 'Casamento · contexto original',
|
|
||||||
'sort_order' => 1,
|
|
||||||
'is_featured' => false,
|
|
||||||
'published_at' => null,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$siteSettingBefore = $siteSetting->fresh()?->getAttributes();
|
|
||||||
$serviceBefore = $service->fresh()?->getAttributes();
|
|
||||||
$caseBefore = $case->fresh()?->getAttributes();
|
|
||||||
$imageBefore = $image->fresh()?->getAttributes();
|
|
||||||
$testimonialBefore = $testimonial->fresh()?->getAttributes();
|
|
||||||
$countsBefore = $this->contentCounts();
|
|
||||||
|
|
||||||
$this->app->detectEnvironment(fn (): string => 'production');
|
|
||||||
|
|
||||||
// Run twice: production gating must be stable across repeated deploys.
|
|
||||||
$this->runContentSeeder();
|
|
||||||
$this->runContentSeeder();
|
|
||||||
|
|
||||||
// Compare against counts captured just above, not hardcoded literals:
|
|
||||||
// this table may carry rows created by other tests in the same
|
|
||||||
// process (RefreshDatabase isolates per test method, not per model
|
|
||||||
// globally), so the meaningful assertion is "the seeder created
|
|
||||||
// nothing", not "there is exactly one row".
|
|
||||||
$this->assertSame($countsBefore, $this->contentCounts());
|
|
||||||
|
|
||||||
$this->assertSame($siteSettingBefore, $siteSetting->fresh()?->getAttributes());
|
|
||||||
$this->assertSame($serviceBefore, $service->fresh()?->getAttributes());
|
|
||||||
$this->assertSame($caseBefore, $case->fresh()?->getAttributes());
|
|
||||||
$this->assertSame($imageBefore, $image->fresh()?->getAttributes());
|
|
||||||
$this->assertSame($testimonialBefore, $testimonial->fresh()?->getAttributes());
|
|
||||||
|
|
||||||
Storage::disk('public')->assertMissing('content/about/about-image.jpg');
|
|
||||||
Storage::disk('public')->assertMissing('content/og/og-default.jpg');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, array{0: string}>
|
|
||||||
*/
|
|
||||||
public static function allowListedEnvironments(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'local' => ['local'],
|
|
||||||
'staging' => ['staging'],
|
|
||||||
'testing' => ['testing'],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
#[DataProvider('allowListedEnvironments')]
|
|
||||||
public function test_content_seeder_still_seeds_demo_content_on_allow_listed_environments(string $environment): void
|
|
||||||
{
|
|
||||||
Storage::fake('public');
|
|
||||||
$this->clearContentTables();
|
|
||||||
|
|
||||||
$this->app->detectEnvironment(fn (): string => $environment);
|
|
||||||
|
|
||||||
$this->runContentSeeder();
|
|
||||||
|
|
||||||
$this->assertSame(1, SiteSetting::query()->count());
|
|
||||||
$this->assertSame(3, Service::query()->count());
|
|
||||||
$this->assertSame(3, PortfolioCase::query()->count());
|
|
||||||
$this->assertSame(5, Testimonial::query()->count());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, array{0: string}>
|
|
||||||
*/
|
|
||||||
public static function nonAllowListedEnvironments(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'production' => ['production'],
|
|
||||||
'empty string' => [''],
|
|
||||||
'mixed case Production' => ['Production'],
|
|
||||||
'upper case PRODUCTION' => ['PRODUCTION'],
|
|
||||||
'trailing whitespace' => ['staging '],
|
|
||||||
'unrecognized arbitrary value' => ['whatever-typo'],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The guard is an allow-list of known-safe environments, not a deny-list
|
|
||||||
* of the single literal 'production'. Any value that is not exactly
|
|
||||||
* 'local', 'staging', or 'testing' — including a blank APP_ENV, a
|
|
||||||
* different case, stray whitespace, or a plain typo — must fail closed
|
|
||||||
* (no-op) rather than fail open (seed/overwrite data).
|
|
||||||
*/
|
|
||||||
#[DataProvider('nonAllowListedEnvironments')]
|
|
||||||
public function test_content_seeder_is_a_noop_on_any_non_allow_listed_environment(string $environment): void
|
|
||||||
{
|
|
||||||
Storage::fake('public');
|
|
||||||
$this->clearContentTables();
|
|
||||||
|
|
||||||
$this->app->detectEnvironment(fn (): string => $environment);
|
|
||||||
|
|
||||||
$this->runContentSeeder();
|
|
||||||
|
|
||||||
$this->assertSame(0, SiteSetting::query()->count());
|
|
||||||
$this->assertSame(0, Service::query()->count());
|
|
||||||
$this->assertSame(0, PortfolioCase::query()->count());
|
|
||||||
$this->assertSame(0, PortfolioImage::query()->count());
|
|
||||||
$this->assertSame(0, Testimonial::query()->count());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Explicit clean slate for scenarios whose assertions depend on an
|
|
||||||
* absolute, empty starting state ("empty production database" / "fresh
|
|
||||||
* demo seed produces exactly N records").
|
|
||||||
*
|
|
||||||
* This compensates for a pre-existing, out-of-scope test-isolation gap in
|
|
||||||
* this suite: RefreshDatabase is expected to roll back every test's
|
|
||||||
* writes, but a `SiteSetting` row created by `HomePageTest` (via
|
|
||||||
* `SiteSetting::instance()`) has been observed to survive into
|
|
||||||
* whichever test runs next when the full Feature suite executes
|
|
||||||
* sequentially (reproduced with a minimal probe test asserting
|
|
||||||
* `SiteSetting::query()->count() === 0`, which passes under `--filter`
|
|
||||||
* but fails as part of `--testsuite=Feature`). Prime suspect: `tests/Pest.php`
|
|
||||||
* applies `RefreshDatabase` suite-wide via
|
|
||||||
* `pest()->extend(TestCase::class)->use(RefreshDatabase::class)->in('Feature')`
|
|
||||||
* while several class-based tests (e.g. `HomePageTest`) also declare
|
|
||||||
* `use RefreshDatabase;` themselves — double application is worth
|
|
||||||
* checking first. Not fixed here: it is unrelated to MAN-103 and would
|
|
||||||
* grow this diff well beyond the seeder-gating fix.
|
|
||||||
*/
|
|
||||||
private function clearContentTables(): void
|
|
||||||
{
|
|
||||||
PortfolioImage::query()->delete();
|
|
||||||
PortfolioCase::query()->delete();
|
|
||||||
Service::query()->delete();
|
|
||||||
Testimonial::query()->delete();
|
|
||||||
SiteSetting::query()->delete();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{siteSettings: int, services: int, portfolioCases: int, portfolioImages: int, testimonials: int}
|
|
||||||
*/
|
|
||||||
private function contentCounts(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'siteSettings' => SiteSetting::query()->count(),
|
|
||||||
'services' => Service::query()->count(),
|
|
||||||
'portfolioCases' => PortfolioCase::query()->count(),
|
|
||||||
'portfolioImages' => PortfolioImage::query()->count(),
|
|
||||||
'testimonials' => Testimonial::query()->count(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
private function runContentSeeder(): void
|
|
||||||
{
|
|
||||||
$this->artisan('db:seed', [
|
|
||||||
'--class' => ContentSeeder::class,
|
|
||||||
'--force' => true,
|
|
||||||
'--no-interaction' => true,
|
|
||||||
])->assertExitCode(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Tests\Feature\PublicSite;
|
|
||||||
|
|
||||||
use Tests\TestCase;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The brand logo is eager-loaded into the header and footer of every public
|
|
||||||
* page, so its weight lands on the critical path of the whole site. It used to
|
|
||||||
* ship at 512 px wide — 82 KiB of lockup plus 41 KiB of mark to render at 48
|
|
||||||
* and 32 px — which the MAN-109 Lighthouse audit measured as the largest
|
|
||||||
* avoidable block ahead of the LCP element.
|
|
||||||
*
|
|
||||||
* A byte budget rather than a CI performance gate: SPEC §14.1 pins the five
|
|
||||||
* blocking jobs and §22 governs when new capability is added, but nothing stops
|
|
||||||
* a future asset swap from silently undoing this. The budget is deliberately
|
|
||||||
* loose — roughly double what the current encoding costs — so it catches a
|
|
||||||
* regression in kind (a full-size original dropped back in) and not normal
|
|
||||||
* re-encoding noise.
|
|
||||||
*/
|
|
||||||
class BrandAssetBudgetTest extends TestCase
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Max transfer size per shipped webp, in bytes.
|
|
||||||
*/
|
|
||||||
private const WEBP_BUDGET = 30 * 1024;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Intrinsic size the assets are encoded at: 3x the largest rendered box
|
|
||||||
* (lockup at h-12 = 48 px, mark at h-8 = 32 px). The brand.logo component
|
|
||||||
* declares these as width/height to reserve the layout box, so a re-encode
|
|
||||||
* at a different size has to update both or the reserved box lies.
|
|
||||||
*
|
|
||||||
* @var array<string, array{width: int, height: int}>
|
|
||||||
*/
|
|
||||||
private const INTRINSIC = [
|
|
||||||
'lockup-on-light' => ['width' => 149, 'height' => 144],
|
|
||||||
'lockup-on-dark' => ['width' => 149, 'height' => 144],
|
|
||||||
'mark-on-light' => ['width' => 191, 'height' => 96],
|
|
||||||
'mark-on-dark' => ['width' => 191, 'height' => 96],
|
|
||||||
];
|
|
||||||
|
|
||||||
public function test_brand_webp_assets_stay_within_their_byte_budget(): void
|
|
||||||
{
|
|
||||||
foreach (array_keys(self::INTRINSIC) as $name) {
|
|
||||||
$path = public_path("brand/{$name}.webp");
|
|
||||||
|
|
||||||
$this->assertFileExists($path);
|
|
||||||
$this->assertLessThanOrEqual(
|
|
||||||
self::WEBP_BUDGET,
|
|
||||||
filesize($path),
|
|
||||||
"public/brand/{$name}.webp is over the critical-path budget of "
|
|
||||||
.(self::WEBP_BUDGET / 1024).' KiB. It is eager-loaded on every public page; '
|
|
||||||
.'re-encode it at the rendered size instead of raising the budget.'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_brand_webp_assets_match_the_dimensions_the_component_declares(): void
|
|
||||||
{
|
|
||||||
$component = (string) file_get_contents(
|
|
||||||
resource_path('views/components/brand/logo.blade.php')
|
|
||||||
);
|
|
||||||
|
|
||||||
foreach (self::INTRINSIC as $name => $expected) {
|
|
||||||
$size = getimagesize(public_path("brand/{$name}.webp"));
|
|
||||||
|
|
||||||
$this->assertIsArray($size, "public/brand/{$name}.webp is not a readable image.");
|
|
||||||
$this->assertSame($expected['width'], $size[0], "public/brand/{$name}.webp width changed.");
|
|
||||||
$this->assertSame($expected['height'], $size[1], "public/brand/{$name}.webp height changed.");
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ([['width' => 191, 'height' => 96], ['width' => 149, 'height' => 144]] as $pair) {
|
|
||||||
$this->assertStringContainsString(
|
|
||||||
"'width' => {$pair['width']}, 'height' => {$pair['height']}",
|
|
||||||
$component,
|
|
||||||
'brand/logo.blade.php must reserve the box using the real intrinsic size of the shipped asset.'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,254 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Tests\Feature\PublicSite;
|
|
||||||
|
|
||||||
use App\Domain\Contact\MarketingOrigin;
|
|
||||||
use App\Mail\ContactBriefing;
|
|
||||||
use App\Mail\ContactBriefingConfirmation;
|
|
||||||
use App\Models\SiteSetting;
|
|
||||||
use Illuminate\Foundation\Http\Middleware\PreventRequestForgery;
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
||||||
use Illuminate\Routing\Middleware\ThrottleRequests;
|
|
||||||
use Illuminate\Support\Facades\Mail;
|
|
||||||
use Tests\TestCase;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Covers MAN-105 — simple marketing-origin tracking that rides along
|
|
||||||
* with the briefing e-mail (SPEC.md WEB-05). No Lead model, no CRM
|
|
||||||
* surface: the origin never outlives the session/e-mail round trip.
|
|
||||||
*/
|
|
||||||
class ContactBriefingOriginTest extends TestCase
|
|
||||||
{
|
|
||||||
use RefreshDatabase;
|
|
||||||
|
|
||||||
protected function setUp(): void
|
|
||||||
{
|
|
||||||
parent::setUp();
|
|
||||||
|
|
||||||
$this->withoutMiddleware([ThrottleRequests::class, PreventRequestForgery::class]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $overrides
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
private function validPayload(array $overrides = []): array
|
|
||||||
{
|
|
||||||
return array_merge([
|
|
||||||
'nome' => 'Maria Silva',
|
|
||||||
'email' => 'maria@example.com',
|
|
||||||
'telefone' => '(11) 98888-7777',
|
|
||||||
'tipo_evento' => 'Casamento',
|
|
||||||
'data_periodo' => 'novembro de 2027',
|
|
||||||
'cidade' => 'São Paulo',
|
|
||||||
'convidados' => '120',
|
|
||||||
'servico_interesse' => 'Planejamento completo',
|
|
||||||
'mensagem' => 'Queremos um casamento ao ar livre para 120 convidados.',
|
|
||||||
'privacidade' => '1',
|
|
||||||
'empresa' => '',
|
|
||||||
], $overrides);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_utm_parameters_present_on_arrival_reach_the_briefing_email(): void
|
|
||||||
{
|
|
||||||
SiteSetting::instance();
|
|
||||||
Mail::fake();
|
|
||||||
|
|
||||||
$this->get('/contato?utm_source=instagram&utm_medium=social&utm_campaign=lancamento-2026')
|
|
||||||
->assertOk();
|
|
||||||
|
|
||||||
$this->post(route('contact.store'), $this->validPayload())
|
|
||||||
->assertRedirect(route('contact'))
|
|
||||||
->assertSessionHas('status', 'briefing-sent');
|
|
||||||
|
|
||||||
Mail::assertQueued(ContactBriefing::class, function (ContactBriefing $mail): bool {
|
|
||||||
$origin = $mail->fields['Origem de marketing'];
|
|
||||||
|
|
||||||
return $origin === 'origem: instagram | mídia: social | campanha: lancamento-2026';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_no_utm_or_referrer_still_submits_and_omits_the_origin(): void
|
|
||||||
{
|
|
||||||
SiteSetting::instance();
|
|
||||||
Mail::fake();
|
|
||||||
|
|
||||||
// No GET beforehand: posting cold, exactly like a direct/no-JS submission.
|
|
||||||
$response = $this->post(route('contact.store'), $this->validPayload());
|
|
||||||
|
|
||||||
$response
|
|
||||||
->assertRedirect(route('contact'))
|
|
||||||
->assertSessionHas('status', 'briefing-sent');
|
|
||||||
|
|
||||||
Mail::assertQueued(ContactBriefing::class, function (ContactBriefing $mail): bool {
|
|
||||||
return array_key_exists('Origem de marketing', $mail->fields)
|
|
||||||
&& $mail->fields['Origem de marketing'] === null;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_referrer_only_visit_records_only_the_referring_site_as_origin(): void
|
|
||||||
{
|
|
||||||
SiteSetting::instance();
|
|
||||||
Mail::fake();
|
|
||||||
|
|
||||||
// The query string is a personalized campaign link (carries an
|
|
||||||
// identifiable e-mail/subscriber id) — only the site identity may
|
|
||||||
// be captured as marketing origin, never the query string itself.
|
|
||||||
$this->withHeader('referer', 'https://mail.example.com/click?email=maria%40example.com&subscriber_id=42')
|
|
||||||
->get('/contato')
|
|
||||||
->assertOk();
|
|
||||||
|
|
||||||
$this->post(route('contact.store'), $this->validPayload())
|
|
||||||
->assertRedirect(route('contact'))
|
|
||||||
->assertSessionHas('status', 'briefing-sent');
|
|
||||||
|
|
||||||
Mail::assertQueued(ContactBriefing::class, function (ContactBriefing $mail): bool {
|
|
||||||
return $mail->fields['Origem de marketing'] === 'https://mail.example.com';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_first_touch_wins_when_later_navigation_has_no_utm(): void
|
|
||||||
{
|
|
||||||
SiteSetting::instance();
|
|
||||||
Mail::fake();
|
|
||||||
|
|
||||||
$this->get('/contato?utm_source=google&utm_medium=cpc')
|
|
||||||
->assertOk();
|
|
||||||
|
|
||||||
// Navigate elsewhere with no UTM parameters at all before submitting.
|
|
||||||
$this->get('/servicos')->assertOk();
|
|
||||||
$this->get('/sobre')->assertOk();
|
|
||||||
|
|
||||||
$this->post(route('contact.store'), $this->validPayload())
|
|
||||||
->assertRedirect(route('contact'))
|
|
||||||
->assertSessionHas('status', 'briefing-sent');
|
|
||||||
|
|
||||||
Mail::assertQueued(ContactBriefing::class, function (ContactBriefing $mail): bool {
|
|
||||||
return $mail->fields['Origem de marketing'] === 'origem: google | mídia: cpc';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_first_touch_wins_when_later_navigation_carries_a_competing_utm(): void
|
|
||||||
{
|
|
||||||
SiteSetting::instance();
|
|
||||||
Mail::fake();
|
|
||||||
|
|
||||||
$this->get('/contato?utm_source=google&utm_medium=cpc')
|
|
||||||
->assertOk();
|
|
||||||
|
|
||||||
// A second page load with its own, different UTM parameters must
|
|
||||||
// not overwrite what was captured on first touch.
|
|
||||||
$this->get('/contato?utm_source=facebook&utm_medium=social')
|
|
||||||
->assertOk();
|
|
||||||
|
|
||||||
$this->post(route('contact.store'), $this->validPayload())
|
|
||||||
->assertRedirect(route('contact'))
|
|
||||||
->assertSessionHas('status', 'briefing-sent');
|
|
||||||
|
|
||||||
Mail::assertQueued(ContactBriefing::class, function (ContactBriefing $mail): bool {
|
|
||||||
return $mail->fields['Origem de marketing'] === 'origem: google | mídia: cpc';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_confirmation_email_never_exposes_the_marketing_origin(): void
|
|
||||||
{
|
|
||||||
$settings = SiteSetting::instance();
|
|
||||||
Mail::fake();
|
|
||||||
|
|
||||||
$this->get('/contato?utm_source=instagram&utm_campaign=segredo-interno')
|
|
||||||
->assertOk();
|
|
||||||
|
|
||||||
$this->post(route('contact.store'), $this->validPayload())
|
|
||||||
->assertRedirect(route('contact'))
|
|
||||||
->assertSessionHas('status', 'briefing-sent');
|
|
||||||
|
|
||||||
Mail::assertQueued(ContactBriefingConfirmation::class, function (ContactBriefingConfirmation $mail): bool {
|
|
||||||
$rendered = $mail->render();
|
|
||||||
|
|
||||||
return ! str_contains($rendered, 'instagram') && ! str_contains($rendered, 'segredo-interno');
|
|
||||||
});
|
|
||||||
|
|
||||||
// The internal briefing mail is the only one carrying the origin.
|
|
||||||
Mail::assertQueued(ContactBriefing::class, function (ContactBriefing $mail) use ($settings): bool {
|
|
||||||
return $mail->hasTo($settings->email)
|
|
||||||
&& str_contains((string) $mail->fields['Origem de marketing'], 'segredo-interno');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_fail_open_when_stored_marketing_origin_is_corrupted(): void
|
|
||||||
{
|
|
||||||
SiteSetting::instance();
|
|
||||||
Mail::fake();
|
|
||||||
|
|
||||||
// Simulates the session value having been tampered with or
|
|
||||||
// corrupted (e.g. by an unrelated bug) into something that is not
|
|
||||||
// the array shape the controller expects. The acceptance
|
|
||||||
// criterion is that this never blocks the submission.
|
|
||||||
$response = $this->withSession([MarketingOrigin::SESSION_KEY => 'lixo'])
|
|
||||||
->post(route('contact.store'), $this->validPayload());
|
|
||||||
|
|
||||||
$response
|
|
||||||
->assertRedirect(route('contact'))
|
|
||||||
->assertSessionHas('status', 'briefing-sent');
|
|
||||||
|
|
||||||
Mail::assertQueued(ContactBriefing::class, function (ContactBriefing $mail): bool {
|
|
||||||
return array_key_exists('Origem de marketing', $mail->fields)
|
|
||||||
&& $mail->fields['Origem de marketing'] === null;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_hostile_marketing_origin_values_are_neutralised(): void
|
|
||||||
{
|
|
||||||
SiteSetting::instance();
|
|
||||||
Mail::fake();
|
|
||||||
|
|
||||||
$overlongCampaign = str_repeat('a', 300);
|
|
||||||
|
|
||||||
$this->get('/contato?'.http_build_query([
|
|
||||||
'utm_source' => '<script>alert(1)</script>',
|
|
||||||
'utm_campaign' => $overlongCampaign,
|
|
||||||
]))->assertOk();
|
|
||||||
|
|
||||||
$this->post(route('contact.store'), $this->validPayload())
|
|
||||||
->assertRedirect(route('contact'))
|
|
||||||
->assertSessionHas('status', 'briefing-sent');
|
|
||||||
|
|
||||||
$capturedOrigin = null;
|
|
||||||
|
|
||||||
Mail::assertQueued(ContactBriefing::class, function (ContactBriefing $mail) use (&$capturedOrigin): bool {
|
|
||||||
$capturedOrigin = $mail->fields['Origem de marketing'];
|
|
||||||
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
self::assertIsString($capturedOrigin);
|
|
||||||
self::assertStringNotContainsString('<script', $capturedOrigin);
|
|
||||||
self::assertStringNotContainsString('<', $capturedOrigin);
|
|
||||||
self::assertStringNotContainsString('>', $capturedOrigin);
|
|
||||||
|
|
||||||
self::assertMatchesRegularExpression('/campanha: (a+)/', $capturedOrigin);
|
|
||||||
preg_match('/campanha: (a+)/', $capturedOrigin, $matches);
|
|
||||||
self::assertSame(100, mb_strlen($matches[1]));
|
|
||||||
|
|
||||||
// Rendering into the HTML e-mail must not reintroduce markup either.
|
|
||||||
Mail::assertQueued(ContactBriefing::class, function (ContactBriefing $mail): bool {
|
|
||||||
$rendered = $mail->render();
|
|
||||||
|
|
||||||
return ! str_contains($rendered, '<script>alert(1)</script>');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_technical_routes_do_not_consume_the_first_touch_slot(): void
|
|
||||||
{
|
|
||||||
$this->get('/sitemap.xml?utm_source=crawler&utm_medium=bot')->assertOk();
|
|
||||||
$this->get('/robots.txt?utm_source=crawler')->assertOk();
|
|
||||||
|
|
||||||
$this->assertNull(session(MarketingOrigin::SESSION_KEY));
|
|
||||||
|
|
||||||
$this->get('/?utm_source=instagram&utm_medium=perfil')->assertOk();
|
|
||||||
|
|
||||||
$this->assertSame('instagram', session(MarketingOrigin::SESSION_KEY)['utm_source'] ?? null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -89,31 +89,6 @@ class ContactBriefingTest extends TestCase
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_submission_normalizes_email_and_phone_before_reaching_mailables(): void
|
|
||||||
{
|
|
||||||
$settings = SiteSetting::instance();
|
|
||||||
Mail::fake();
|
|
||||||
|
|
||||||
$response = $this->post(route('contact.store'), $this->validPayload([
|
|
||||||
'email' => ' Maria.Silva@EXAMPLE.com ',
|
|
||||||
'telefone' => '+55 (11) 98888-7777',
|
|
||||||
]));
|
|
||||||
|
|
||||||
$response
|
|
||||||
->assertRedirect(route('contact'))
|
|
||||||
->assertSessionHas('status', 'briefing-sent');
|
|
||||||
|
|
||||||
Mail::assertQueued(ContactBriefing::class, function (ContactBriefing $mail) use ($settings): bool {
|
|
||||||
return $mail->hasTo($settings->email)
|
|
||||||
&& $mail->fields['E-mail'] === 'maria.silva@example.com'
|
|
||||||
&& $mail->fields['Telefone/WhatsApp'] === '(11) 98888-7777';
|
|
||||||
});
|
|
||||||
|
|
||||||
Mail::assertQueued(ContactBriefingConfirmation::class, function (ContactBriefingConfirmation $mail): bool {
|
|
||||||
return $mail->hasTo('maria.silva@example.com');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_honeypot_submission_is_dropped_without_sending_emails(): void
|
public function test_honeypot_submission_is_dropped_without_sending_emails(): void
|
||||||
{
|
{
|
||||||
SiteSetting::instance();
|
SiteSetting::instance();
|
||||||
|
|||||||
@@ -1,82 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Tests\Feature\PublicSite;
|
|
||||||
|
|
||||||
use Tests\TestCase;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Bunny serves every EB Garamond weight as both woff2 and woff, and the fonts
|
|
||||||
* plugin emitted an `@font-face` rule for each — woff2 first, woff second. Two
|
|
||||||
* rules with the same family, weight, style and unicode-range mean the later
|
|
||||||
* one wins, so browsers rendered from the woff files and discarded the
|
|
||||||
* preloaded woff2.
|
|
||||||
*
|
|
||||||
* The MAN-109 audit measured the cost on the home page: 88 KiB of woff at
|
|
||||||
* VeryHigh priority, ahead of the LCP image, plus 74 KiB of preloaded woff2
|
|
||||||
* that was fetched and never used. The `amare:fonts-woff2-only` plugin in
|
|
||||||
* vite.config.js strips the woff rules; these assertions fail if a plugin
|
|
||||||
* upgrade brings them back.
|
|
||||||
*
|
|
||||||
* Reads the build output because that is where the defect lived — the source
|
|
||||||
* config looked correct the whole time. The `feature` job runs `npm run build`
|
|
||||||
* before the suite (.github/workflows/ci.yml).
|
|
||||||
*/
|
|
||||||
class FontDeliveryTest extends TestCase
|
|
||||||
{
|
|
||||||
public function test_build_ships_woff2_only(): void
|
|
||||||
{
|
|
||||||
$manifestPath = public_path('build/fonts-manifest.json');
|
|
||||||
|
|
||||||
$this->assertFileExists(
|
|
||||||
$manifestPath,
|
|
||||||
'Run `npm run build` before this suite — the font manifest is a build artifact.'
|
|
||||||
);
|
|
||||||
|
|
||||||
$manifest = json_decode((string) file_get_contents($manifestPath), true);
|
|
||||||
|
|
||||||
$this->assertIsArray($manifest);
|
|
||||||
$this->assertNotEmpty($manifest['preloads'] ?? [], 'The critical font weights must still be preloaded.');
|
|
||||||
|
|
||||||
foreach ($manifest['preloads'] as $preload) {
|
|
||||||
$this->assertStringEndsWith('.woff2', (string) ($preload['file'] ?? ''));
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($manifest['families'] ?? [] as $alias => $family) {
|
|
||||||
foreach ($family['variants'] ?? [] as $key => $variant) {
|
|
||||||
$this->assertNotEmpty($variant['files'] ?? [], "{$alias} {$key} lost every font file.");
|
|
||||||
|
|
||||||
foreach ($variant['files'] as $file) {
|
|
||||||
$this->assertSame(
|
|
||||||
'woff2',
|
|
||||||
$file['format'] ?? null,
|
|
||||||
"{$alias} {$key} declares a {$file['format']} file. Only woff2 is served — a second "
|
|
||||||
.'format with the same family, weight and unicode-range overrides the woff2 rule and '
|
|
||||||
.'doubles the font traffic on the critical path.'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$stylesheet = $manifest['style']['file'] ?? null;
|
|
||||||
$this->assertIsString($stylesheet);
|
|
||||||
|
|
||||||
$css = (string) file_get_contents(public_path('build/'.$stylesheet));
|
|
||||||
|
|
||||||
$this->assertStringContainsString('format("woff2")', $css);
|
|
||||||
$this->assertStringNotContainsString('format("woff")', $css);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_no_woff_files_are_emitted_into_the_build(): void
|
|
||||||
{
|
|
||||||
$emitted = glob(public_path('build/assets/*.woff')) ?: [];
|
|
||||||
|
|
||||||
$this->assertSame(
|
|
||||||
[],
|
|
||||||
array_map('basename', $emitted),
|
|
||||||
'The build emitted legacy woff files. They are never requested by a browser that supports woff2, '
|
|
||||||
.'which is every browser this site targets.'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -49,25 +49,6 @@ class HeritageEditorialTokensTest extends TestCase
|
|||||||
$this->assertStringContainsString('font-family: var(--amare-font-serif)', $appCss);
|
$this->assertStringContainsString('font-family: var(--amare-font-serif)', $appCss);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_public_theme_mapping_neutralises_shadow_tokens(): void
|
|
||||||
{
|
|
||||||
$appCss = (string) file_get_contents(resource_path('css/app.css'));
|
|
||||||
|
|
||||||
// Tailwind's Vite plugin shares one context across every build entry, so
|
|
||||||
// the Filament theme entry makes Filament's `shadow-*` usage visible and
|
|
||||||
// Tailwind emits its DEFAULT shadow values into the public bundle too.
|
|
||||||
// The public theme must pin them to transparent, or DESIGN.md's Tonal
|
|
||||||
// Layer Rule ("flat surfaces, no card shadows") stops being true in the
|
|
||||||
// artifact that actually ships.
|
|
||||||
foreach (['--shadow-2xs', '--shadow-xs', '--shadow-sm', '--shadow', '--shadow-md', '--shadow-lg', '--shadow-xl', '--shadow-2xl', '--shadow-inner'] as $token) {
|
|
||||||
$this->assertMatchesRegularExpression(
|
|
||||||
'/'.preg_quote($token, '/').':\s*0 0 #0000\s*;/',
|
|
||||||
$appCss,
|
|
||||||
"resources/css/app.css must pin {$token} to a transparent value so no public element can render a card shadow."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_vite_self_hosts_eb_garamond(): void
|
public function test_vite_self_hosts_eb_garamond(): void
|
||||||
{
|
{
|
||||||
$vite = (string) file_get_contents(base_path('vite.config.js'));
|
$vite = (string) file_get_contents(base_path('vite.config.js'));
|
||||||
|
|||||||