Compare commits
41 Commits
_base-main
...
fix/man-10
| Author | SHA1 | Date | |
|---|---|---|---|
| 2e43fdeb04 | |||
| cc4b40b5d8 | |||
| 63c0269489 | |||
| 7e68c0e37f | |||
| c84c347dfd | |||
| 37296f758f | |||
| 49fbc0fdfa | |||
| 2e2860396e | |||
| 54cd6ba0da | |||
| 4001cd0e5d | |||
| f5dd28089c | |||
| eed8240487 | |||
| fc3d5c444f | |||
| e9b4534df9 | |||
| 949f6ae19f | |||
| 58f24a6d5a | |||
| 4061662c4b | |||
| 42b282c1f2 | |||
| 457845758c | |||
| 0aee15e848 | |||
| b372eaea4f | |||
| 7380998e8e | |||
| 4b440b7e5b | |||
| 5949fad9b1 | |||
| d054d04ab9 | |||
| db4453b165 | |||
| 946939ba94 | |||
| 10a1f0dc7c | |||
| c04790ee1f | |||
| edf1c48050 | |||
| 3c711a74f3 | |||
| 88d63f349d | |||
| e0390ff333 | |||
| 321f0cee11 | |||
| 1d23e16625 | |||
| 33788af172 | |||
| 119711d6f0 | |||
| f09ea83071 | |||
| 2fe262ab59 | |||
| 018b43e24f | |||
| 9fac784f6a |
@@ -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/Fortaleza
|
APP_TIMEZONE=America/Sao_Paulo
|
||||||
|
|
||||||
# Freeze application clock outside production (visual regression / deterministic seeds).
|
# Freeze application clock outside production (visual regression / deterministic seeds).
|
||||||
# Example: APP_FROZEN_NOW=2026-03-15T12:00:00-03:00
|
# Example: APP_FROZEN_NOW=2026-03-15T12:00:00-03:00
|
||||||
|
|||||||
72
.github/workflows/ci.yml
vendored
@@ -14,7 +14,7 @@ env:
|
|||||||
APP_KEY: base64:NXm/6jIyFcDGHoMKGc5QZuSaq0dRZFYPg1Isuy1fNvE=
|
APP_KEY: base64:NXm/6jIyFcDGHoMKGc5QZuSaq0dRZFYPg1Isuy1fNvE=
|
||||||
APP_LOCALE: pt_BR
|
APP_LOCALE: pt_BR
|
||||||
APP_FALLBACK_LOCALE: pt_BR
|
APP_FALLBACK_LOCALE: pt_BR
|
||||||
APP_TIMEZONE: America/Fortaleza
|
APP_TIMEZONE: America/Sao_Paulo
|
||||||
BCRYPT_ROUNDS: 4
|
BCRYPT_ROUNDS: 4
|
||||||
CACHE_STORE: database
|
CACHE_STORE: database
|
||||||
DB_CONNECTION: pgsql
|
DB_CONNECTION: pgsql
|
||||||
@@ -31,7 +31,8 @@ jobs:
|
|||||||
name: static
|
name: static
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
|
- run: cp .env.example .env
|
||||||
|
|
||||||
- uses: shivammathur/setup-php@v2
|
- uses: shivammathur/setup-php@v2
|
||||||
with:
|
with:
|
||||||
@@ -39,7 +40,7 @@ jobs:
|
|||||||
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd
|
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd
|
||||||
coverage: none
|
coverage: none
|
||||||
|
|
||||||
- uses: actions/cache@v4
|
- uses: actions/cache@v5
|
||||||
with:
|
with:
|
||||||
path: ~/.composer/cache/files
|
path: ~/.composer/cache/files
|
||||||
key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }}
|
key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }}
|
||||||
@@ -50,29 +51,70 @@ jobs:
|
|||||||
- run: composer pint:check
|
- run: composer pint:check
|
||||||
- run: composer phpstan
|
- run: composer phpstan
|
||||||
- run: composer audit
|
- run: composer audit
|
||||||
|
# audit-level=high: package.json has no runtime `dependencies` today (npm
|
||||||
|
# itself reports prod:1, 0 vulnerabilities), so this is currently a
|
||||||
|
# vacuous forward guard rather than an active protection. `high` is
|
||||||
|
# chosen deliberately over `low`/`moderate` so that once a real runtime
|
||||||
|
# JS dependency is added, the gate flags exploitable issues without
|
||||||
|
# becoming noisy on every transitive dev-only advisory.
|
||||||
|
- run: npm audit --omit=dev --audit-level=high
|
||||||
|
|
||||||
unit:
|
unit:
|
||||||
name: unit
|
name: unit
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
# Postgres is required here (not just in `feature`) because the
|
||||||
|
# Domain/Application coverage gate below runs the Feature suite too: the
|
||||||
|
# App\Application\Queries\Marketing\* classes are only exercised through
|
||||||
|
# Feature (HTTP) tests today, so a Unit-only coverage run would undercount
|
||||||
|
# them well under the 80% threshold.
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:17
|
||||||
|
env:
|
||||||
|
POSTGRES_DB: amare_test
|
||||||
|
POSTGRES_USER: amare
|
||||||
|
POSTGRES_PASSWORD: secret
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
|
options: >-
|
||||||
|
--health-cmd "pg_isready -U amare -d amare_test"
|
||||||
|
--health-interval 5s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 10
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
|
- run: cp .env.example .env
|
||||||
|
|
||||||
- uses: shivammathur/setup-php@v2
|
- uses: shivammathur/setup-php@v2
|
||||||
with:
|
with:
|
||||||
php-version: "8.4"
|
php-version: "8.4"
|
||||||
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd
|
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd
|
||||||
coverage: none
|
coverage: pcov
|
||||||
|
|
||||||
- uses: actions/cache@v4
|
- uses: actions/cache@v5
|
||||||
with:
|
with:
|
||||||
path: ~/.composer/cache/files
|
path: ~/.composer/cache/files
|
||||||
key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }}
|
key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }}
|
||||||
restore-keys: composer-${{ runner.os }}-
|
restore-keys: composer-${{ runner.os }}-
|
||||||
|
|
||||||
|
- uses: actions/cache@v5
|
||||||
|
with:
|
||||||
|
path: ~/.npm
|
||||||
|
key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
|
||||||
|
restore-keys: npm-${{ runner.os }}-
|
||||||
|
|
||||||
- run: composer install --no-interaction --prefer-dist
|
- run: composer install --no-interaction --prefer-dist
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
- run: npm run build
|
- run: npm run build
|
||||||
- run: composer test:unit
|
- run: composer test:unit
|
||||||
|
- run: php artisan migrate --force
|
||||||
|
# Domain/Application coverage gate (SPEC.md L2351, 80% minimum). Scoped
|
||||||
|
# via phpunit.coverage.xml rather than editing the project-wide
|
||||||
|
# phpunit.xml <source> block, which stays covering all of app/ for
|
||||||
|
# every other test/coverage invocation. app/Domain currently holds only
|
||||||
|
# the DomainModule placeholder (zero executable lines), so in practice
|
||||||
|
# this gates app/Application until Domain gains real logic.
|
||||||
|
- run: composer test:coverage
|
||||||
|
|
||||||
feature:
|
feature:
|
||||||
name: feature
|
name: feature
|
||||||
@@ -92,7 +134,8 @@ jobs:
|
|||||||
--health-timeout 5s
|
--health-timeout 5s
|
||||||
--health-retries 10
|
--health-retries 10
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
|
- run: cp .env.example .env
|
||||||
|
|
||||||
- uses: shivammathur/setup-php@v2
|
- uses: shivammathur/setup-php@v2
|
||||||
with:
|
with:
|
||||||
@@ -100,13 +143,13 @@ jobs:
|
|||||||
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd
|
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd
|
||||||
coverage: none
|
coverage: none
|
||||||
|
|
||||||
- uses: actions/cache@v4
|
- uses: actions/cache@v5
|
||||||
with:
|
with:
|
||||||
path: ~/.composer/cache/files
|
path: ~/.composer/cache/files
|
||||||
key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }}
|
key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }}
|
||||||
restore-keys: composer-${{ runner.os }}-
|
restore-keys: composer-${{ runner.os }}-
|
||||||
|
|
||||||
- uses: actions/cache@v4
|
- uses: actions/cache@v5
|
||||||
with:
|
with:
|
||||||
path: ~/.npm
|
path: ~/.npm
|
||||||
key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
|
key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
|
||||||
@@ -136,7 +179,8 @@ jobs:
|
|||||||
--health-timeout 5s
|
--health-timeout 5s
|
||||||
--health-retries 10
|
--health-retries 10
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
|
- run: cp .env.example .env
|
||||||
|
|
||||||
- uses: shivammathur/setup-php@v2
|
- uses: shivammathur/setup-php@v2
|
||||||
with:
|
with:
|
||||||
@@ -144,13 +188,13 @@ jobs:
|
|||||||
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd
|
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd
|
||||||
coverage: none
|
coverage: none
|
||||||
|
|
||||||
- uses: actions/cache@v4
|
- uses: actions/cache@v5
|
||||||
with:
|
with:
|
||||||
path: ~/.composer/cache/files
|
path: ~/.composer/cache/files
|
||||||
key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }}
|
key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }}
|
||||||
restore-keys: composer-${{ runner.os }}-
|
restore-keys: composer-${{ runner.os }}-
|
||||||
|
|
||||||
- uses: actions/cache@v4
|
- uses: actions/cache@v5
|
||||||
with:
|
with:
|
||||||
path: ~/.npm
|
path: ~/.npm
|
||||||
key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
|
key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
|
||||||
@@ -177,7 +221,7 @@ jobs:
|
|||||||
-e APP_URL=http://127.0.0.1:8000 \
|
-e APP_URL=http://127.0.0.1:8000 \
|
||||||
-e APP_LOCALE=pt_BR \
|
-e APP_LOCALE=pt_BR \
|
||||||
-e APP_FALLBACK_LOCALE=pt_BR \
|
-e APP_FALLBACK_LOCALE=pt_BR \
|
||||||
-e APP_TIMEZONE=America/Fortaleza \
|
-e APP_TIMEZONE=America/Sao_Paulo \
|
||||||
-e APP_FROZEN_NOW="${APP_FROZEN_NOW}" \
|
-e 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 \
|
||||||
@@ -224,7 +268,7 @@ jobs:
|
|||||||
name: container
|
name: container
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Build production image
|
- name: Build production image
|
||||||
run: docker build -t amare-app:ci .
|
run: docker build -t amare-app:ci .
|
||||||
|
|||||||
1
.gitignore
vendored
@@ -18,6 +18,7 @@
|
|||||||
/public/fonts-manifest.dev.json
|
/public/fonts-manifest.dev.json
|
||||||
/public/hot
|
/public/hot
|
||||||
/public/storage
|
/public/storage
|
||||||
|
/tests/Browser/Screenshots
|
||||||
/storage/*.key
|
/storage/*.key
|
||||||
/storage/pail
|
/storage/pail
|
||||||
/vendor
|
/vendor
|
||||||
|
|||||||
@@ -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\n");
|
fwrite(STDERR, "Start it with: docker compose up -d postgres\n");
|
||||||
fwrite(STDERR, "Then retry the push.\n");
|
fwrite(STDERR, "Then retry the push.\n");
|
||||||
exit(1);
|
exit(1);
|
||||||
}
|
}
|
||||||
|
|||||||
31
AGENTS.md
@@ -7,20 +7,25 @@ This is a Laravel 13 application for an event-planning consultancy. Application
|
|||||||
## Build, Test, and Development Commands
|
## Build, Test, and Development Commands
|
||||||
|
|
||||||
- `composer setup` installs PHP and npm dependencies, creates `.env`, migrates, and builds assets.
|
- `composer setup` installs PHP and npm dependencies, creates `.env`, migrates, and builds assets.
|
||||||
- `docker compose up -d` starts the local PostgreSQL service.
|
- `docker compose up -d postgres` starts the local PostgreSQL service. `docker compose up -d` (no service name) also builds and starts the `app` service — a local FrankenPHP container for parity with staging/production, see README.md.
|
||||||
- `composer dev` runs Laravel, the queue listener, logs, and Vite together.
|
- `composer dev` runs Laravel, the queue listener, logs, and Vite together.
|
||||||
- `npm run build` creates the production frontend bundle.
|
- `npm run build` creates the production frontend bundle.
|
||||||
- `composer quality` runs formatting checks, PHPStan level 5, dependency audit, and every test suite.
|
- `composer quality` runs formatting checks, PHPStan level 5, PHP + npm dependency audits, and every test suite.
|
||||||
- `composer test:unit`, `composer test:feature`, or `composer test:browser` run focused suites.
|
- `composer test:unit`, `composer test:feature`, or `composer test:browser` run focused suites.
|
||||||
|
- `composer test:coverage` runs the Domain/Application coverage gate (80% minimum, scoped via `phpunit.coverage.xml`) used by CI's `unit` job. Requires a coverage driver (`pcov` or `xdebug`); fails with "No code coverage driver available" without one — that's an environment gap, not a broken repo.
|
||||||
|
|
||||||
Feature and browser tests require the `amare_test` PostgreSQL database configured in `phpunit.xml`.
|
Feature and browser tests require the `amare_test` PostgreSQL database configured in `phpunit.xml`.
|
||||||
|
|
||||||
|
## Worktrees
|
||||||
|
|
||||||
|
Always work in a git worktree created from the `main` ref — never modify `main` directly and never commit from the primary working tree. Create a dedicated worktree per feature/branch with `git worktree add -b <branch> <path> main`. On finishing work, create a PR, watch CI until green, then merge it. Clean up the worktree with `git worktree remove` after merge.
|
||||||
|
|
||||||
## Git Hooks (husky)
|
## Git Hooks (husky)
|
||||||
|
|
||||||
Hooks live in `.husky/` and auto-install on any plain `npm install` via the `prepare` script. Note `composer setup` runs `npm install --ignore-scripts`, which skips hook installation — after setup, run `npm install` once (or `npx husky`) to activate hooks.
|
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` hint when Postgres is unreachable, then runs `composer test:unit` and `composer test:feature`. Browser tests are CI-only (FrankenPHP container).
|
- `pre-push`: gates on the `amare_test` database (settings parsed from `phpunit.xml`), blocks the push with a `docker compose up -d postgres` hint when Postgres is unreachable, then runs `composer test:unit` and `composer test:feature`. Browser tests are CI-only (FrankenPHP container).
|
||||||
|
|
||||||
## Coding Style & Naming Conventions
|
## Coding Style & Naming Conventions
|
||||||
|
|
||||||
@@ -37,3 +42,23 @@ History follows Conventional Commit-style subjects, for example `feat: Fase 0
|
|||||||
## Security & Configuration
|
## Security & Configuration
|
||||||
|
|
||||||
Copy `.env.example`; never commit secrets or production credentials. Development seed credentials are local-only. Validate uploads and authorization through Laravel policies, and run `composer security-audit` after dependency changes.
|
Copy `.env.example`; never commit secrets or production credentials. Development seed credentials are local-only. Validate uploads and authorization through Laravel policies, and run `composer security-audit` after dependency changes.
|
||||||
|
|
||||||
|
## Design Context
|
||||||
|
|
||||||
|
Amare: refined, humane, precise — Heritage Editorial. Trust-first, both private + corporate audiences. Never generic wedding decor (hearts/gold/script) or AI-slop. Real proof only.
|
||||||
|
|
||||||
|
The design system lives in `DESIGN.md` (palette, typography, layout, do's and don'ts) and positioning in `PRODUCT.md`; tokens are implemented in `resources/css/tokens.css` and asserted by `tests/Feature/PublicSite/HeritageEditorialTokensTest.php`. Per-surface briefs live in `.impeccable/surfaces/`. The Impeccable skill itself is vendored at `.github/skills/impeccable/SKILL.md` — its setup step reads `PRODUCT.md`, `DESIGN.md`, and the matching surface brief.
|
||||||
|
|
||||||
|
## Agent skills
|
||||||
|
|
||||||
|
### Issue tracker
|
||||||
|
|
||||||
|
Issues live in Linear, driven through the Linear MCP tools. See `docs/agents/issue-tracker.md` for workspace, team, and tool conventions. The repo ships no `.mcp.json`, so the Linear MCP has to be enabled for the session before those tools exist — if it isn't, report that instead of silently falling back to another tracker.
|
||||||
|
|
||||||
|
### Triage labels
|
||||||
|
|
||||||
|
Default vocabulary: `needs-triage`, `needs-info`, `ready-for-agent`, `ready-for-human`, `wontfix`. See `docs/agents/triage-labels.md`.
|
||||||
|
|
||||||
|
### Domain docs
|
||||||
|
|
||||||
|
Single-context repo. There is no `CONTEXT.md` — the domain is documented in `SPEC.md` (§8 is the domain model and database schema) and `PRODUCT.md`, with current capabilities described per-capability under `openspec/specs/`. `docs/adr/README.md` is an index only: ADR-001 through ADR-010 are decided in `SPEC.md` §21, and there are no standalone ADR files. `docs/agents/domain.md` describes the generic `CONTEXT.md`/`CONTEXT-MAP.md` layout that the engineering skills look for and instructs them to proceed silently when it's absent, which is the case here.
|
||||||
|
|||||||
81
CLAUDE.md
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
# 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.
|
||||||
|
- Regenerate with `composer visual:update`.
|
||||||
|
- **Baselines are CI-parity artifacts.** CI runs the browser suite against a `docker build`-produced FrankenPHP container (see the `browser` job in `.github/workflows/ci.yml`), so baselines regenerated on macOS against a local server will be rejected by CI. Commit `4578457` exists because of this.
|
||||||
|
|
||||||
|
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,6 +23,10 @@ RUN npm ci
|
|||||||
COPY vite.config.js ./
|
COPY vite.config.js ./
|
||||||
COPY resources ./resources
|
COPY resources ./resources
|
||||||
COPY public ./public
|
COPY public ./public
|
||||||
|
# resources/css/filament/admin/theme.css imports Filament's own uncompiled CSS,
|
||||||
|
# so the Vite build needs those files present. Only Filament's subtree is copied
|
||||||
|
# rather than all of vendor/, to keep this stage's context small.
|
||||||
|
COPY --from=composer /app/vendor/filament ./vendor/filament
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
FROM dunglas/frankenphp:1-php${PHP_VERSION}-bookworm AS runtime
|
FROM dunglas/frankenphp:1-php${PHP_VERSION}-bookworm AS runtime
|
||||||
|
|||||||
23
README.md
@@ -4,7 +4,7 @@ Aplicação Laravel 13 para assessoria de eventos (Fase 0 — Fundação).
|
|||||||
|
|
||||||
## Requisitos
|
## Requisitos
|
||||||
|
|
||||||
- PHP 8.5+ com extensões `pdo_pgsql`, `intl`, `mbstring`, `zip`, `sodium`
|
- PHP 8.4 com extensões `pdo_pgsql`, `intl`, `mbstring`, `zip`, `sodium` (canônico do `Dockerfile` e do CI; `composer.json` aceita `^8.3`)
|
||||||
- Composer 2.x
|
- Composer 2.x
|
||||||
- Node.js 22+ e npm
|
- Node.js 22+ e npm
|
||||||
- Docker e Docker Compose
|
- Docker e Docker Compose
|
||||||
@@ -18,10 +18,10 @@ cp .env.example .env
|
|||||||
php artisan key:generate
|
php artisan key:generate
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Suba o PostgreSQL:
|
2. Suba o PostgreSQL (requer o `.env` do passo 1 — o serviço `app` referencia esse arquivo via `env_file`, e o Compose valida todo o `docker-compose.yml` mesmo ao subir só o `postgres`):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up -d
|
docker compose up -d postgres
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Instale dependências e rode migrations:
|
3. Instale dependências e rode migrations:
|
||||||
@@ -49,12 +49,24 @@ Painel interno: `/admin`
|
|||||||
|
|
||||||
Healthcheck: `GET /up`
|
Healthcheck: `GET /up`
|
||||||
|
|
||||||
|
## Paridade local com produção (FrankenPHP via Docker Compose)
|
||||||
|
|
||||||
|
Além do fluxo host-side acima, `docker-compose.yml` tem um serviço `app` que builda a mesma imagem FrankenPHP usada em staging/produção (`Dockerfile`), útil para testar o comportamento real do container antes do deploy:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d # sobe postgres + app
|
||||||
|
php artisan migrate # rode migrations (o entrypoint do container não migra sozinho)
|
||||||
|
curl localhost:8000/up
|
||||||
|
```
|
||||||
|
|
||||||
|
O serviço `app` depende de `postgres` estar saudável (`depends_on: condition: service_healthy`) e sobrescreve `DB_HOST`/`DB_PORT` do `.env` para apontar para o serviço `postgres` pelo nome (o padrão `127.0.0.1` do `.env` só funciona para processos rodando no host). Não há job de CI dedicado a este smoke — o job `container` do CI já builda e healthcheca a mesma imagem.
|
||||||
|
|
||||||
## Variáveis principais
|
## Variáveis principais
|
||||||
|
|
||||||
| Variável | Valor local |
|
| Variável | Valor local |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `APP_LOCALE` | `pt_BR` |
|
| `APP_LOCALE` | `pt_BR` |
|
||||||
| `APP_TIMEZONE` | `America/Fortaleza` |
|
| `APP_TIMEZONE` | `America/Sao_Paulo` |
|
||||||
| `DB_CONNECTION` | `pgsql` |
|
| `DB_CONNECTION` | `pgsql` |
|
||||||
| `SESSION_DRIVER` | `database` |
|
| `SESSION_DRIVER` | `database` |
|
||||||
| `CACHE_STORE` | `database` |
|
| `CACHE_STORE` | `database` |
|
||||||
@@ -63,7 +75,7 @@ Healthcheck: `GET /up`
|
|||||||
## Comandos de qualidade
|
## Comandos de qualidade
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
composer quality # Pint + PHPStan + audit + testes
|
composer quality # Pint + PHPStan + audit (composer + npm) + testes
|
||||||
composer test:unit # Unit + Architecture
|
composer test:unit # Unit + Architecture
|
||||||
composer test:feature # Feature + Livewire + Filament
|
composer test:feature # Feature + Livewire + Filament
|
||||||
composer test:browser # E2E browser
|
composer test:browser # E2E browser
|
||||||
@@ -125,6 +137,7 @@ 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)
|
||||||
|
|||||||
200
SPEC.md
@@ -14,12 +14,13 @@
|
|||||||
| Estágio | MVP |
|
| Estágio | MVP |
|
||||||
| Status da especificação | Aprovada para implementação |
|
| Status da especificação | Aprovada para implementação |
|
||||||
| Idioma da interface | Português do Brasil (`pt-BR`) |
|
| Idioma da interface | Português do Brasil (`pt-BR`) |
|
||||||
| Timezone padrão | `America/Fortaleza` |
|
| Timezone padrão | `America/Sao_Paulo` |
|
||||||
|
| Cidade de atuação | São Paulo (capital) |
|
||||||
| Moeda | BRL, sem conversão entre moedas |
|
| Moeda | BRL, sem conversão entre moedas |
|
||||||
| Princípio principal | YAGNI — implementar somente o necessário para validar o produto |
|
| Princípio principal | YAGNI — implementar somente o necessário para validar o produto |
|
||||||
| Arquitetura | Monólito modular Laravel |
|
| Arquitetura | Monólito modular Laravel |
|
||||||
| Área interna | Filament |
|
| Área interna | Filament |
|
||||||
| Área pública | Livewire + Blade |
|
| Área pública | Blade + JS vanilla progressivo (Livewire é dependência do Filament, não usada no site público — ver ADR-015) |
|
||||||
| Servidor de aplicação | FrankenPHP em modo regular |
|
| Servidor de aplicação | FrankenPHP em modo regular |
|
||||||
| Banco de dados | PostgreSQL |
|
| Banco de dados | PostgreSQL |
|
||||||
| Testes | Pest, Pest Browser/Playwright e testes visuais |
|
| Testes | Pest, Pest Browser/Playwright e testes visuais |
|
||||||
@@ -44,6 +45,8 @@ Em caso de conflito, seguir esta ordem:
|
|||||||
|
|
||||||
O agente **NÃO DEVE** alterar silenciosamente uma decisão deste documento. Uma alteração de escopo ou arquitetura deve atualizar esta especificação ou criar uma ADR.
|
O agente **NÃO DEVE** alterar silenciosamente uma decisão deste documento. Uma alteração de escopo ou arquitetura deve atualizar esta especificação ou criar uma ADR.
|
||||||
|
|
||||||
|
Mudanças incrementais são planejadas em `openspec/changes/` e, após arquivadas, este documento DEVE ser revalidado para incorporar decisões ratificadas (back-sync). Este arquivo permanece a fonte de verdade do produto.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. Contrato de operação para agentes
|
## 1. Contrato de operação para agentes
|
||||||
@@ -191,11 +194,24 @@ Não instalar sistema de permissões granular no MVP.
|
|||||||
|
|
||||||
## 4. Escopo
|
## 4. Escopo
|
||||||
|
|
||||||
|
> **Recorte vigente do lançamento (ADR-016).** O escopo aprovado para o lançamento de 31/08/2026 é o **site institucional**: Fases 0 e 1. As Fases 2 a 5 — CRM de leads, conversão de lead em evento, eventos, tarefas, fornecedores, orçamento, pagamentos manuais, documentos, dashboard orientado a exceções e auditoria — permanecem especificadas neste documento mas ficam **adiadas**, sem data.
|
||||||
|
>
|
||||||
|
> A §4.1 abaixo descreve o produto completo, não o recorte do lançamento. Os itens marcados como adiados estão fora do que se constrói agora. Ver §18 para a divisão por fase e §23 para as condições de conclusão de cada recorte.
|
||||||
|
|
||||||
### 4.1 Incluído no MVP
|
### 4.1 Incluído no MVP
|
||||||
|
|
||||||
|
No recorte do lançamento (Fases 0–1):
|
||||||
|
|
||||||
- site público;
|
- site público;
|
||||||
- CMS interno do site;
|
- CMS interno do site;
|
||||||
- formulário de briefing;
|
- formulário de briefing;
|
||||||
|
- usuários internos e papéis simples;
|
||||||
|
- SEO básico;
|
||||||
|
- acessibilidade 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;
|
||||||
@@ -205,12 +221,10 @@ Não instalar sistema de permissões granular no MVP.
|
|||||||
- pagamentos inseridos manualmente;
|
- pagamentos inseridos manualmente;
|
||||||
- documentos vinculados a leads e eventos;
|
- documentos vinculados a leads e eventos;
|
||||||
- dashboard orientado a exceções;
|
- dashboard orientado a exceções;
|
||||||
- usuários internos e papéis simples;
|
|
||||||
- notificações internas e por e-mail para novos leads;
|
- notificações internas e por e-mail para novos leads;
|
||||||
- auditoria de ações críticas;
|
- auditoria de ações críticas.
|
||||||
- SEO básico;
|
|
||||||
- acessibilidade e testes visuais;
|
Adiado **não** é o mesmo que fora do MVP: os itens acima seguem especificados neste documento e continuam sendo o produto pretendido. A §4.2 lista o que **NÃO DEVE** ser implementado em nenhum momento.
|
||||||
- CI/CD e deploy em contêiner com FrankenPHP.
|
|
||||||
|
|
||||||
### 4.2 Fora do MVP
|
### 4.2 Fora do MVP
|
||||||
|
|
||||||
@@ -312,8 +326,8 @@ Administração
|
|||||||
|
|
||||||
- Dashboard: `Filament Page` customizada com widgets orientados a exceção.
|
- Dashboard: `Filament Page` customizada com widgets orientados a exceção.
|
||||||
- Detalhe do evento: página customizada do Resource com resumo operacional.
|
- Detalhe do evento: página customizada do Resource com resumo operacional.
|
||||||
- Briefing público: componente Livewire próprio.
|
- Briefing público: Blade + Controller (`POST /contato`), ver §11.2.
|
||||||
- Home: Blade/Livewire com componentes de design reutilizáveis.
|
- Home: Blade com componentes de design reutilizáveis.
|
||||||
|
|
||||||
### 5.4 Decisões de UX YAGNI
|
### 5.4 Decisões de UX YAGNI
|
||||||
|
|
||||||
@@ -349,13 +363,14 @@ A home DEVE conter, nesta ordem aproximada:
|
|||||||
|
|
||||||
1. header e navegação;
|
1. header e navegação;
|
||||||
2. hero com proposta de valor e CTA;
|
2. hero com proposta de valor e CTA;
|
||||||
3. prova visual por eventos em destaque;
|
3. manifesto da marca;
|
||||||
4. resumo dos serviços;
|
4. resumo dos serviços em destaque;
|
||||||
5. método de trabalho;
|
5. casos selecionados do portfólio;
|
||||||
6. casos selecionados;
|
6. método de trabalho (4 passos);
|
||||||
7. depoimentos;
|
7. depoimentos;
|
||||||
8. CTA final para briefing;
|
8. posicionamento/perfil;
|
||||||
9. footer com contato, redes e links legais.
|
9. CTA final para briefing;
|
||||||
|
10. footer com contato, redes e links legais.
|
||||||
|
|
||||||
A ordem pode variar apenas se a revisão de UX justificar a mudança.
|
A ordem pode variar apenas se a revisão de UX justificar a mudança.
|
||||||
|
|
||||||
@@ -374,6 +389,15 @@ Centralizar tokens de:
|
|||||||
|
|
||||||
Não espalhar valores visuais arbitrários por componentes.
|
Não espalhar valores visuais arbitrários por componentes.
|
||||||
|
|
||||||
|
O MVP adota o sistema de design **Heritage Editorial** (ver DESIGN.md e ADR-013):
|
||||||
|
|
||||||
|
- tipografia serifada auto-hospedada (EB Garamond) com escala de display a label;
|
||||||
|
- paleta papel/oliva/sálvia/tinta com superfícies tonais; hierarquia sem sombras de card;
|
||||||
|
- raio de borda zero para superfícies interativas e de conteúdo;
|
||||||
|
- largura de container 1120px e ritmo de espaçamento de 8px;
|
||||||
|
- `prefers-reduced-motion` respeitado;
|
||||||
|
- contraste WCAG AA (tinta sobre papel e oliva sobre papel).
|
||||||
|
|
||||||
### 6.4 Requisitos de mídia
|
### 6.4 Requisitos de mídia
|
||||||
|
|
||||||
- Imagens públicas DEVERÃO possuir texto alternativo.
|
- Imagens públicas DEVERÃO possuir texto alternativo.
|
||||||
@@ -383,6 +407,7 @@ Não espalhar valores visuais arbitrários por componentes.
|
|||||||
- Dimensões DEVERÃO ser reservadas para evitar layout shift.
|
- Dimensões DEVERÃO ser reservadas para evitar layout shift.
|
||||||
- Não armazenar blobs de imagem no PostgreSQL.
|
- Não armazenar blobs de imagem no PostgreSQL.
|
||||||
- Não depender do disco efêmero do contêiner em produção.
|
- Não depender do disco efêmero do contêiner em produção.
|
||||||
|
- O logotipo da marca DEVE possuir texto alternativo e variantes claro/escuro; `site_settings.logo_path` sobrescreve o asset padrão quando preenchido.
|
||||||
|
|
||||||
### 6.5 Acessibilidade
|
### 6.5 Acessibilidade
|
||||||
|
|
||||||
@@ -534,12 +559,10 @@ Campos públicos:
|
|||||||
- validar no servidor;
|
- validar no servidor;
|
||||||
- usar honeypot e rate limiting;
|
- usar honeypot e rate limiting;
|
||||||
- impedir duplo envio acidental;
|
- impedir duplo envio acidental;
|
||||||
- criar Lead com status `new`;
|
|
||||||
- registrar origem `website`;
|
|
||||||
- notificar administradores;
|
|
||||||
- exibir sucesso sem revelar dados internos;
|
|
||||||
- enviar e-mail de confirmação quando o serviço de e-mail estiver configurado;
|
- enviar e-mail de confirmação quando o serviço de e-mail estiver configurado;
|
||||||
- falha no e-mail não pode apagar o lead já criado.
|
- exibir sucesso sem revelar dados internos.
|
||||||
|
|
||||||
|
> **Estado atual (Fases 0–1):** o formulário usa Blade + Controller e envia apenas e-mails informativos (para a assessoria e confirmação ao visitante), sem criar Lead. A criação de Lead (status `new`, origem `website`), a notificação aos administradores e o registro de aceite de privacidade entram na **Fase 2**, quando o formulário passa a criar o Lead via `CaptureWebsiteLead`. A falha de e-mail não pode apagar dados já criados.
|
||||||
|
|
||||||
**Aceite:**
|
**Aceite:**
|
||||||
|
|
||||||
@@ -1253,10 +1276,19 @@ Singleton:
|
|||||||
|
|
||||||
- `id` bigint PK;
|
- `id` bigint PK;
|
||||||
- `brand_name`;
|
- `brand_name`;
|
||||||
|
- `logo_path` nullable;
|
||||||
|
- `logo_alt` nullable;
|
||||||
- `hero_eyebrow` nullable;
|
- `hero_eyebrow` nullable;
|
||||||
- `hero_title`;
|
- `hero_title`;
|
||||||
- `hero_subtitle`;
|
- `hero_subtitle`;
|
||||||
- `hero_cta_label`;
|
- `hero_cta_label`;
|
||||||
|
- `hero_cta_secondary_label` nullable;
|
||||||
|
- `hero_note` nullable;
|
||||||
|
- `manifesto_title`;
|
||||||
|
- `manifesto_lead`;
|
||||||
|
- `manifesto_body`;
|
||||||
|
- `method_steps` jsonb (4 passos tipados);
|
||||||
|
- `principles` jsonb (lista tipada);
|
||||||
- `about_summary` nullable;
|
- `about_summary` nullable;
|
||||||
- `email`;
|
- `email`;
|
||||||
- `phone`;
|
- `phone`;
|
||||||
@@ -1265,7 +1297,8 @@ Singleton:
|
|||||||
- `default_meta_title`;
|
- `default_meta_title`;
|
||||||
- `default_meta_description`;
|
- `default_meta_description`;
|
||||||
- `default_og_image_path` nullable;
|
- `default_og_image_path` nullable;
|
||||||
- analytics fields nullable;
|
- `default_og_image_alt` nullable;
|
||||||
|
- `analytics_enabled` boolean default false;
|
||||||
- timestamps.
|
- timestamps.
|
||||||
|
|
||||||
#### `services`
|
#### `services`
|
||||||
@@ -1531,12 +1564,12 @@ Constraints de banco devem proteger:
|
|||||||
| Runtime | PHP com versão minor fixada no Docker |
|
| Runtime | PHP com versão minor fixada no Docker |
|
||||||
| Framework | Laravel 13 |
|
| Framework | Laravel 13 |
|
||||||
| Admin | Filament 5 |
|
| Admin | Filament 5 |
|
||||||
| UI pública | Livewire 4 + Blade + Alpine + Tailwind |
|
| UI pública | Blade + Tailwind + JS vanilla progressivo (sem framework reativo; Livewire 4 confinado ao Filament — ver ADR-015 e §22) |
|
||||||
| Banco | PostgreSQL |
|
| Banco | PostgreSQL |
|
||||||
| Servidor | FrankenPHP + Caddy |
|
| Servidor | FrankenPHP + Caddy |
|
||||||
| Assets | Vite |
|
| Assets | Vite |
|
||||||
| Testes | Pest 4 + Pest Browser/Playwright |
|
| Testes | Pest 4 + Pest Browser/Playwright |
|
||||||
| Arquivos | Laravel Filesystem + storage S3-compatible em produção |
|
| Arquivos | Laravel Filesystem + Cloudflare R2 (S3-compatible) em produção |
|
||||||
| Fila | Database queue |
|
| Fila | Database queue |
|
||||||
| Scheduler | Laravel Scheduler em processo separado |
|
| Scheduler | Laravel Scheduler em processo separado |
|
||||||
|
|
||||||
@@ -1752,19 +1785,21 @@ Usar componentes/Widgets menores e testáveis.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 11. Livewire e site público
|
## 11. Site público e interatividade
|
||||||
|
|
||||||
### 11.1 Componentes sugeridos
|
### 11.1 Estado atual e componentes candidatos
|
||||||
|
|
||||||
- `ContactBriefingForm`;
|
O site público **não usa Livewire nem Alpine hoje**. As páginas são Blade renderizado no servidor mais JavaScript vanilla progressivo (`resources/js/app.js` e `resources/js/motion.js`). O `layouts.public` carrega apenas `@vite(['resources/css/app.css', 'resources/js/app.js'])` — nenhum `@livewireScripts`. O pacote `livewire/livewire` existe no projeto apenas como dependência transitiva de `filament/support` e opera somente dentro do painel `/admin`.
|
||||||
- `FeaturedPortfolioCases` se houver necessidade de consulta dinâmica;
|
|
||||||
- `PublishedServices` se houver necessidade de consulta dinâmica.
|
|
||||||
|
|
||||||
Não transformar todas as seções estáticas em componentes Livewire. Usar Blade quando não houver estado ou interação.
|
Se surgir a necessidade de consulta dinâmica, os candidatos naturais a Livewire seriam `FeaturedPortfolioCases` e `PublishedServices`. Essa adoção está condicionada ao gatilho registrado em §22.
|
||||||
|
|
||||||
|
Não transformar seções estáticas em componentes Livewire. Usar Blade quando não houver estado ou interação.
|
||||||
|
|
||||||
### 11.2 Formulário de briefing
|
### 11.2 Formulário de briefing
|
||||||
|
|
||||||
O componente deve:
|
> **Estado atual:** o formulário é implementado em Blade + Controller (`POST /contato`, `ContactBriefingRequest`), conforme WEB-05, e essa é a abordagem aceita — não um estágio provisório. Os requisitos abaixo valem independentemente da tecnologia; a criação de Lead segue para a Fase 2.
|
||||||
|
|
||||||
|
O formulário deve:
|
||||||
|
|
||||||
- ter estado tipado ou Form Object quando útil;
|
- ter estado tipado ou Form Object quando útil;
|
||||||
- validar no servidor;
|
- validar no servidor;
|
||||||
@@ -1773,15 +1808,16 @@ O componente deve:
|
|||||||
- preservar acessibilidade;
|
- preservar acessibilidade;
|
||||||
- limpar dados após sucesso;
|
- limpar dados após sucesso;
|
||||||
- evitar exposição de exceção;
|
- evitar exposição de exceção;
|
||||||
- suportar teste Livewire sem browser;
|
- suportar teste de submissão sem navegador (feature test);
|
||||||
- suportar jornada E2E em navegador real.
|
- suportar jornada E2E em navegador real.
|
||||||
|
|
||||||
### 11.3 JavaScript
|
### 11.3 JavaScript
|
||||||
|
|
||||||
- usar Alpine apenas para interações pequenas;
|
- manter o site público em JS vanilla progressivo;
|
||||||
- não introduzir framework SPA;
|
- não introduzir framework SPA;
|
||||||
- não usar dependência JS quando CSS/HTML/Livewire resolverem;
|
- não usar dependência JS quando CSS e HTML resolverem;
|
||||||
- toda interação crítica deve funcionar sem estado global complexo.
|
- toda interação crítica deve funcionar sem estado global complexo;
|
||||||
|
- Alpine só entra junto com Livewire, se o gatilho de §22 disparar.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -1970,7 +2006,7 @@ Determinismo obrigatório:
|
|||||||
|
|
||||||
- Chromium e imagem Linux fixos;
|
- Chromium e imagem Linux fixos;
|
||||||
- viewport fixo;
|
- viewport fixo;
|
||||||
- timezone `America/Fortaleza`;
|
- timezone `America/Sao_Paulo`;
|
||||||
- locale `pt-BR`;
|
- locale `pt-BR`;
|
||||||
- fontes instaladas na imagem;
|
- fontes instaladas na imagem;
|
||||||
- relógio congelado;
|
- relógio congelado;
|
||||||
@@ -2052,7 +2088,7 @@ quality → Pint check + PHPStan/Larastan + audits + testes
|
|||||||
|
|
||||||
| Job | Responsabilidade | Bloqueia merge |
|
| Job | Responsabilidade | Bloqueia merge |
|
||||||
|---|---|---:|
|
|---|---|---:|
|
||||||
| `static` | Pint, PHPStan/Larastan, Composer validate e audits | Sim |
|
| `static` | Pint, PHPStan/Larastan, Composer validate, Composer e npm audit | Sim |
|
||||||
| `unit` | Unitários, arquitetura e cobertura | Sim |
|
| `unit` | Unitários, arquitetura e cobertura | Sim |
|
||||||
| `feature` | PostgreSQL, migrations, Livewire, Filament e integração | Sim |
|
| `feature` | PostgreSQL, migrations, Livewire, Filament e integração | Sim |
|
||||||
| `browser` | Vite, FrankenPHP, E2E, smoke, acessibilidade e visual | Sim |
|
| `browser` | Vite, FrankenPHP, E2E, smoke, acessibilidade e visual | Sim |
|
||||||
@@ -2073,11 +2109,11 @@ quality → Pint check + PHPStan/Larastan + audits + testes
|
|||||||
### 14.3 Branches e ambientes
|
### 14.3 Branches e ambientes
|
||||||
|
|
||||||
- PR: testes e preview opcional;
|
- PR: testes e preview opcional;
|
||||||
- `main`: build imutável por SHA e deploy automático em staging;
|
- `main`: build imutável por SHA publicado no GHCR e deploy automático em staging via Dokploy;
|
||||||
- staging: migration, cache warmup e smoke pós-deploy;
|
- staging: Dokploy Compose executa migração, healthcheck `/up` e smoke pós-deploy (`/up`, `/`, `/admin/login`);
|
||||||
- produção: promoção da mesma imagem aprovada, sem rebuild;
|
- produção: promoção da mesma imagem aprovada, sem rebuild (retag do digest em `:production`);
|
||||||
- produção requer aprovação humana explícita no MVP;
|
- produção requer aprovação humana explícita no MVP (`workflow_dispatch` com confirmação);
|
||||||
- rollback usa imagem anterior;
|
- rollback usa imagem anterior (SHA anterior, sem rebuild);
|
||||||
- migrations devem ser backward-compatible quando possível.
|
- migrations devem ser backward-compatible quando possível.
|
||||||
|
|
||||||
### 14.4 Definition of Done
|
### 14.4 Definition of Done
|
||||||
@@ -2159,7 +2195,7 @@ APP_DEBUG=false
|
|||||||
APP_URL
|
APP_URL
|
||||||
APP_LOCALE=pt_BR
|
APP_LOCALE=pt_BR
|
||||||
APP_FALLBACK_LOCALE=pt_BR
|
APP_FALLBACK_LOCALE=pt_BR
|
||||||
APP_TIMEZONE=America/Fortaleza
|
APP_TIMEZONE=America/Sao_Paulo
|
||||||
|
|
||||||
DB_CONNECTION=pgsql
|
DB_CONNECTION=pgsql
|
||||||
DB_HOST
|
DB_HOST
|
||||||
@@ -2172,19 +2208,16 @@ CACHE_STORE=database ou file conforme ambiente
|
|||||||
QUEUE_CONNECTION=database
|
QUEUE_CONNECTION=database
|
||||||
SESSION_DRIVER=database ou cookie conforme decisão
|
SESSION_DRIVER=database ou cookie conforme decisão
|
||||||
|
|
||||||
FILESYSTEM_DISK=s3 em produção
|
FILESYSTEM_DISK=r2 em produção
|
||||||
AWS_ACCESS_KEY_ID
|
R2_ACCESS_KEY_ID
|
||||||
AWS_SECRET_ACCESS_KEY
|
R2_SECRET_ACCESS_KEY
|
||||||
AWS_DEFAULT_REGION
|
R2_BUCKET
|
||||||
AWS_BUCKET
|
R2_ENDPOINT
|
||||||
AWS_ENDPOINT opcional
|
R2_URL (domínio próprio opcional)
|
||||||
AWS_USE_PATH_STYLE_ENDPOINT opcional
|
AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_DEFAULT_REGION, AWS_BUCKET, AWS_ENDPOINT, AWS_USE_PATH_STYLE_ENDPOINT — opcionais, apenas se o disk s3 for usado
|
||||||
|
|
||||||
MAIL_MAILER
|
MAIL_MAILER=resend em produção (mailer nativo Laravel)
|
||||||
MAIL_HOST
|
RESEND_API_KEY
|
||||||
MAIL_PORT
|
|
||||||
MAIL_USERNAME
|
|
||||||
MAIL_PASSWORD
|
|
||||||
MAIL_FROM_ADDRESS
|
MAIL_FROM_ADDRESS
|
||||||
MAIL_FROM_NAME
|
MAIL_FROM_NAME
|
||||||
```
|
```
|
||||||
@@ -2279,7 +2312,7 @@ O seed deve criar:
|
|||||||
- configurações do site;
|
- configurações do site;
|
||||||
- 3 serviços;
|
- 3 serviços;
|
||||||
- 3 casos de portfólio;
|
- 3 casos de portfólio;
|
||||||
- 3 depoimentos;
|
- depoimentos reais: os 5 casais de `depoimentos.md` (Jeniffer e Maick, Quesia e Jhonata, Milena e Weslley, Raquel e Pedro, Victoria e Pedro), preservando texto e datas; autores fictícios de demonstração removidos; em produção permanecem não publicados até autorização explícita de publicação;
|
||||||
- leads em estados variados;
|
- leads em estados variados;
|
||||||
- 2 eventos futuros e 1 concluído;
|
- 2 eventos futuros e 1 concluído;
|
||||||
- tarefas vencidas e futuras;
|
- tarefas vencidas e futuras;
|
||||||
@@ -2303,6 +2336,8 @@ Para visual e browser tests:
|
|||||||
|
|
||||||
## 18. Backlog de implementação
|
## 18. Backlog de implementação
|
||||||
|
|
||||||
|
> **Recorte vigente (ADR-016).** Fases 0 e 1 são o escopo do lançamento e estão concluídas. **Fases 2 a 5 estão adiadas, sem data.** Não iniciar nenhuma delas sem uma decisão nova do responsável pelo produto — a §1.1 manda trabalhar uma fase por vez, e a fase corrente é o acabamento e a publicação do site.
|
||||||
|
|
||||||
O agente deve implementar na sequência, salvo instrução explícita.
|
O agente deve implementar na sequência, salvo instrução explícita.
|
||||||
|
|
||||||
### Fase 0 — Fundação
|
### Fase 0 — Fundação
|
||||||
@@ -2325,6 +2360,13 @@ O agente deve implementar na sequência, salvo instrução explícita.
|
|||||||
- [x] design tokens mínimos;
|
- [x] design tokens mínimos;
|
||||||
- [x] healthcheck;
|
- [x] healthcheck;
|
||||||
- [x] seed de admin local.
|
- [x] seed de admin local.
|
||||||
|
- [x] verificação de e-mail e reset seguro (MustVerifyEmail);
|
||||||
|
- [x] npm audit no `composer quality` e no job `static`;
|
||||||
|
- [x] gate de cobertura `Domain`/`Application` ≥ 80%;
|
||||||
|
- [x] serviço de aplicação FrankenPHP no Compose local;
|
||||||
|
- [x] hello-world implantado em staging (critério de saída) — run [31395107465](https://github.com/manoel-freitas/amore-site/actions/runs/31395107465) em `7e68c0e`, com `Dokploy deployment succeeded` e smoke verde em `/up`, `/` e `/admin/login`.
|
||||||
|
|
||||||
|
> Os itens pendentes acima são tratados pela mudança OpenSpec `complete-foundation-parity`; o critério de saída da fase só é atingido com staging implantado.
|
||||||
|
|
||||||
**Critério de saída:** pipeline verde e hello-world implantado em staging.
|
**Critério de saída:** pipeline verde e hello-world implantado em staging.
|
||||||
|
|
||||||
@@ -2346,7 +2388,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
|
### Fase 2 — Leads — ADIADA (ADR-016)
|
||||||
|
|
||||||
- [ ] migration/model/factory de Lead;
|
- [ ] migration/model/factory de Lead;
|
||||||
- [ ] LeadActivity;
|
- [ ] LeadActivity;
|
||||||
@@ -2363,7 +2405,7 @@ O agente deve implementar na sequência, salvo instrução explícita.
|
|||||||
|
|
||||||
**Critério de saída:** jornada visitante → lead → tratamento interna totalmente verde.
|
**Critério de saída:** jornada visitante → lead → tratamento interna totalmente verde.
|
||||||
|
|
||||||
### Fase 3 — Eventos e tarefas
|
### Fase 3 — Eventos e tarefas — ADIADA (ADR-016)
|
||||||
|
|
||||||
- [ ] Event;
|
- [ ] Event;
|
||||||
- [ ] EventTask;
|
- [ ] EventTask;
|
||||||
@@ -2378,7 +2420,7 @@ O agente deve implementar na sequência, salvo instrução explícita.
|
|||||||
|
|
||||||
**Critério de saída:** lead é convertido e evento pode ser administrado.
|
**Critério de saída:** lead é convertido e evento pode ser administrado.
|
||||||
|
|
||||||
### Fase 4 — Fornecedores e financeiro
|
### Fase 4 — Fornecedores e financeiro — ADIADA (ADR-016)
|
||||||
|
|
||||||
- [ ] Vendor;
|
- [ ] Vendor;
|
||||||
- [ ] EventVendor;
|
- [ ] EventVendor;
|
||||||
@@ -2394,7 +2436,7 @@ O agente deve implementar na sequência, salvo instrução explícita.
|
|||||||
|
|
||||||
**Critério de saída:** assessora acompanha orçamento e pagamentos sem integração bancária.
|
**Critério de saída:** assessora acompanha orçamento e pagamentos sem integração bancária.
|
||||||
|
|
||||||
### Fase 5 — Documentos, hardening e lançamento
|
### Fase 5 — Documentos, hardening e lançamento — ADIADA (ADR-016)
|
||||||
|
|
||||||
- [ ] documentos privados;
|
- [ ] documentos privados;
|
||||||
- [ ] auditoria completa;
|
- [ ] auditoria completa;
|
||||||
@@ -2472,7 +2514,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 e Livewire/Blade para área pública | Aceita |
|
| ADR-002 | Filament para área interna (Livewire é dependência interna do Filament); site público em Blade — ver ADR-015 | Aceita |
|
||||||
| ADR-003 | PostgreSQL como único banco transacional | Aceita |
|
| ADR-003 | PostgreSQL como único banco transacional | Aceita |
|
||||||
| ADR-004 | Pagamentos somente manuais | Aceita |
|
| ADR-004 | Pagamentos somente manuais | Aceita |
|
||||||
| ADR-005 | Pest unifica unit, feature, browser e visual | Aceita |
|
| ADR-005 | Pest unifica unit, feature, browser e visual | Aceita |
|
||||||
@@ -2481,6 +2523,12 @@ Toda operação financeira deve:
|
|||||||
| ADR-008 | Database queue; Redis adiado | Aceita |
|
| ADR-008 | Database queue; Redis adiado | Aceita |
|
||||||
| ADR-009 | Dinheiro em BRL armazenado como centavos inteiros | Aceita |
|
| ADR-009 | Dinheiro em BRL armazenado como centavos inteiros | Aceita |
|
||||||
| ADR-010 | Home com estrutura fixa e CMS tipado, sem page builder | Aceita |
|
| ADR-010 | Home com estrutura fixa e CMS tipado, sem page builder | Aceita |
|
||||||
|
| ADR-011 | Cloudflare R2 (S3-compatible) como storage de objetos em produção | Aceita |
|
||||||
|
| ADR-012 | E-mail transacional via Resend (mailer nativo Laravel) | Aceita |
|
||||||
|
| ADR-013 | Design system Heritage Editorial para o site público | Aceita |
|
||||||
|
| ADR-014 | Deploy via Dokploy Compose com imagem imutável por SHA no GHCR | Aceita |
|
||||||
|
| ADR-015 | Site público permanece Blade + JS vanilla; Livewire e Alpine ficam restritos ao Filament até o gatilho de §22. Emenda o texto da ADR-002 | Aceita |
|
||||||
|
| ADR-016 | Lançamento de 31/08/2026 entrega apenas o site institucional (Fases 0–1); Fases 2–5 seguem especificadas e adiadas, sem data | Aceita |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -2497,6 +2545,7 @@ Toda operação financeira deve:
|
|||||||
| API pública | existe consumidor real e contrato de integração |
|
| API pública | existe consumidor real e contrato de integração |
|
||||||
| Kanban | tabela de leads demonstra limitação frequente observada |
|
| Kanban | tabela de leads demonstra limitação frequente observada |
|
||||||
| Editor de checklist | diferentes tipos de evento exigem manutenção frequente do seed |
|
| Editor de checklist | diferentes tipos de evento exigem manutenção frequente do seed |
|
||||||
|
| Livewire no site público | portfólio ou serviços exigem consulta ou filtro dinâmico que Blade + JS vanilla não resolvem de forma simples |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -2504,24 +2553,39 @@ Toda operação financeira deve:
|
|||||||
|
|
||||||
O MVP está concluído somente quando:
|
O MVP está concluído somente quando:
|
||||||
|
|
||||||
|
A ADR-016 divide estas condições em dois recortes. Cada um se fecha por conta própria; o segundo não bloqueia o lançamento.
|
||||||
|
|
||||||
|
### 23.1 Lançamento do site (Fases 0–1)
|
||||||
|
|
||||||
|
O lançamento está concluído somente quando:
|
||||||
|
|
||||||
- site público está publicado e visualmente aprovado;
|
- site público está publicado e visualmente aprovado;
|
||||||
- assessora edita os conteúdos essenciais sem desenvolvedor;
|
- assessora edita os conteúdos essenciais sem desenvolvedor;
|
||||||
- briefing cria leads de forma segura;
|
- briefing envia o pedido de proposta de forma segura, com proteção contra abuso e aceite de privacidade registrado;
|
||||||
|
- usuários e Policies estão corretos;
|
||||||
|
- testes unit, feature, visuais e arquitetura estão verdes;
|
||||||
|
- CI bloqueia regressões;
|
||||||
|
- imagem FrankenPHP é reproduzível;
|
||||||
|
- staging e produção usam a mesma imagem promovida;
|
||||||
|
- backup, restauração e monitoramento estão documentados;
|
||||||
|
- nenhum item explicitamente fora do MVP (§4.2) foi introduzido.
|
||||||
|
|
||||||
|
Note a diferença em relação à versão anterior desta seção: o critério do briefing é **enviar o pedido**, não "criar leads". Criar Lead é Fase 2 e está adiado; o formulário atual envia e-mail e não persiste nada.
|
||||||
|
|
||||||
|
### 23.2 Produto completo (Fases 2–5, adiado)
|
||||||
|
|
||||||
|
Além de tudo em §23.1:
|
||||||
|
|
||||||
- pipeline e próxima ação funcionam;
|
- pipeline e próxima ação funcionam;
|
||||||
|
- briefing cria leads de forma segura e persistente;
|
||||||
- lead é convertido uma única vez em evento;
|
- lead é convertido uma única vez em evento;
|
||||||
- checklist é criado automaticamente;
|
- checklist é criado automaticamente;
|
||||||
- evento possui visão consolidada;
|
- evento possui visão consolidada;
|
||||||
- fornecedores podem ser cadastrados e vinculados;
|
- fornecedores podem ser cadastrados e vinculados;
|
||||||
- orçamento e pagamentos manuais possuem totais consistentes;
|
- orçamento e pagamentos manuais possuem totais consistentes;
|
||||||
- dashboard mostra pendências do dia;
|
- dashboard mostra pendências do dia;
|
||||||
- usuários e Policies estão corretos;
|
|
||||||
- auditoria registra operações críticas;
|
- auditoria registra operações críticas;
|
||||||
- testes unit, feature, E2E, visuais e arquitetura estão verdes;
|
- jornadas E2E das fases correspondentes estão verdes.
|
||||||
- CI bloqueia regressões;
|
|
||||||
- imagem FrankenPHP é reproduzível;
|
|
||||||
- staging e produção usam a mesma imagem promovida;
|
|
||||||
- backup, restauração e monitoramento estão documentados;
|
|
||||||
- nenhum item explicitamente fora do MVP foi introduzido.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ final readonly class PageMeta
|
|||||||
public ?string $ogImageUrl = null,
|
public ?string $ogImageUrl = null,
|
||||||
public ?string $ogImageAlt = null,
|
public ?string $ogImageAlt = null,
|
||||||
public ?array $jsonLd = null,
|
public ?array $jsonLd = null,
|
||||||
|
public string $siteName = '',
|
||||||
|
public string $robots = 'index, follow',
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -44,6 +46,7 @@ final readonly class PageMeta
|
|||||||
ogImageUrl: $ogImageUrl ?? self::defaultOgImageUrl($settings),
|
ogImageUrl: $ogImageUrl ?? self::defaultOgImageUrl($settings),
|
||||||
ogImageAlt: $ogImageAlt ?? $settings->default_og_image_alt,
|
ogImageAlt: $ogImageAlt ?? $settings->default_og_image_alt,
|
||||||
jsonLd: $jsonLd,
|
jsonLd: $jsonLd,
|
||||||
|
siteName: (string) $settings->brand_name,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,9 +80,53 @@ final readonly class PageMeta
|
|||||||
ogImageUrl: $ogImageUrl,
|
ogImageUrl: $ogImageUrl,
|
||||||
ogImageAlt: $ogImageAlt,
|
ogImageAlt: $ogImageAlt,
|
||||||
jsonLd: $jsonLd,
|
jsonLd: $jsonLd,
|
||||||
|
siteName: (string) $settings->brand_name,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the metadata for branded error pages.
|
||||||
|
*
|
||||||
|
* Error pages carry no canonical, are excluded from search indexes and use
|
||||||
|
* the page name suffixed with the brand name as their title.
|
||||||
|
*/
|
||||||
|
public static function forErrorPage(
|
||||||
|
SiteSetting $settings,
|
||||||
|
int $status = 404,
|
||||||
|
): self {
|
||||||
|
[$title, $description] = match ($status) {
|
||||||
|
500 => ['Algo deu errado', 'Não foi possível concluir o pedido. Tente novamente em instantes.'],
|
||||||
|
419 => ['Sessão expirada', 'Sua sessão expirou. Volte e tente enviar novamente.'],
|
||||||
|
429 => ['Muitas solicitações', 'Você enviou muitas solicitações em pouco tempo. Aguarde um instante e tente novamente.'],
|
||||||
|
503 => ['Em manutenção', 'Estamos realizando uma breve manutenção. Tente novamente em instantes.'],
|
||||||
|
default => ['Página não encontrada', 'A página que você procura não existe ou foi movida.'],
|
||||||
|
};
|
||||||
|
|
||||||
|
return new self(
|
||||||
|
title: trim($title).' - '.$settings->brand_name,
|
||||||
|
description: $description,
|
||||||
|
canonical: '',
|
||||||
|
robots: 'noindex, nofollow',
|
||||||
|
siteName: (string) $settings->brand_name,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append the brand name to a page title when it is not already present.
|
||||||
|
*/
|
||||||
|
public static function withBrandSuffix(string $title, SiteSetting $settings): string
|
||||||
|
{
|
||||||
|
$brand = filled($settings->default_meta_title)
|
||||||
|
? (string) $settings->default_meta_title
|
||||||
|
: (string) $settings->brand_name;
|
||||||
|
|
||||||
|
if (str_contains($title, $brand)) {
|
||||||
|
return $title;
|
||||||
|
}
|
||||||
|
|
||||||
|
return trim($title).' - '.$brand;
|
||||||
|
}
|
||||||
|
|
||||||
private static function defaultTitle(SiteSetting $settings): string
|
private static function defaultTitle(SiteSetting $settings): string
|
||||||
{
|
{
|
||||||
return filled($settings->default_meta_title)
|
return filled($settings->default_meta_title)
|
||||||
|
|||||||
@@ -56,6 +56,9 @@ final class MediaGenerateVariantsCommand extends Command
|
|||||||
if ($settings && filled($settings->default_og_image_path)) {
|
if ($settings && filled($settings->default_og_image_path)) {
|
||||||
$paths[] = (string) $settings->default_og_image_path;
|
$paths[] = (string) $settings->default_og_image_path;
|
||||||
}
|
}
|
||||||
|
if ($settings && filled($settings->about_image_path)) {
|
||||||
|
$paths[] = (string) $settings->about_image_path;
|
||||||
|
}
|
||||||
|
|
||||||
foreach (Service::query()->whereNotNull('cover_image_path')->pluck('cover_image_path') as $path) {
|
foreach (Service::query()->whereNotNull('cover_image_path')->pluck('cover_image_path') as $path) {
|
||||||
$paths[] = (string) $path;
|
$paths[] = (string) $path;
|
||||||
|
|||||||
85
app/Domain/Contact/BrazilianPhoneNumber.php
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Domain\Contact;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizes a raw phone/WhatsApp number typed into the public briefing
|
||||||
|
* form into a canonical, human-readable Brazilian format.
|
||||||
|
*
|
||||||
|
* The briefing e-mail simply prints the field value in a table, so the
|
||||||
|
* canonical form must stay legible for the staff member reading it —
|
||||||
|
* "(11) 98888-7777" rather than an opaque "11988887777" digit string.
|
||||||
|
*/
|
||||||
|
final class BrazilianPhoneNumber
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Formats to "(DD) 9XXXX-XXXX" for an 11-digit mobile number or
|
||||||
|
* "(DD) XXXX-XXXX" for a 10-digit landline number, stripping a
|
||||||
|
* leading "+55"/"55" country code when present.
|
||||||
|
*
|
||||||
|
* A 10- or 11-digit string is only formatted when it is structurally
|
||||||
|
* plausible as a Brazilian number: the first two digits must be a
|
||||||
|
* possible DDD (`[1-9][1-9]`, since Brazilian area codes run 11-99
|
||||||
|
* and never carry a '0' in either position), and an 11-digit number
|
||||||
|
* must additionally have a '9' as its third digit (mandatory on all
|
||||||
|
* Brazilian mobile numbers since 2012). An explicit "+55" prefix that
|
||||||
|
* leaves no digits for a DDD (e.g. "+55 98888-7777") is treated as a
|
||||||
|
* number missing its area code, not as DDD 55.
|
||||||
|
*
|
||||||
|
* When the digit count does not match either shape, or the shape
|
||||||
|
* fails the checks above (foreign numbers, partial input, extensions,
|
||||||
|
* etc.), the original text is preserved — only whitespace is
|
||||||
|
* collapsed — so no information the recipient might need is
|
||||||
|
* discarded or silently fabricated.
|
||||||
|
*/
|
||||||
|
public static function normalize(string $raw): string
|
||||||
|
{
|
||||||
|
$trimmed = trim($raw);
|
||||||
|
$collapsed = preg_replace('/\s+/', ' ', $trimmed) ?? $trimmed;
|
||||||
|
|
||||||
|
// Only reformat when the text is made exclusively of phone
|
||||||
|
// characters. Anything else — "(WhatsApp)", "falar com João",
|
||||||
|
// a ramal — is information the recipient needs, so it is left
|
||||||
|
// untouched rather than stripped away by the digit extraction.
|
||||||
|
if (preg_match('/^[0-9()+\-.\/ ]+$/', $collapsed) !== 1) {
|
||||||
|
return $collapsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
$digits = preg_replace('/\D+/', '', $collapsed) ?? '';
|
||||||
|
|
||||||
|
if (in_array(strlen($digits), [12, 13], true) && str_starts_with($digits, '55')) {
|
||||||
|
$digits = substr($digits, 2);
|
||||||
|
} elseif (str_starts_with($digits, '55') && preg_match('/^\+\s*55\b/', $collapsed) === 1) {
|
||||||
|
// An explicit "+55" was written, but the total digit count
|
||||||
|
// never reached 12/13, meaning nothing precedes it that could
|
||||||
|
// be a DDD — e.g. "+55 98888-7777" is a mobile number missing
|
||||||
|
// its area code, not DDD 55 with a coincidentally-matching
|
||||||
|
// subscriber number. Guessing a DDD here would fabricate one.
|
||||||
|
return $collapsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
return match (strlen($digits)) {
|
||||||
|
11 => self::isPlausibleDdd($digits) && $digits[2] === '9'
|
||||||
|
? sprintf('(%s) %s-%s', substr($digits, 0, 2), substr($digits, 2, 5), substr($digits, 7))
|
||||||
|
: $collapsed,
|
||||||
|
10 => self::isPlausibleDdd($digits)
|
||||||
|
? sprintf('(%s) %s-%s', substr($digits, 0, 2), substr($digits, 2, 4), substr($digits, 6))
|
||||||
|
: $collapsed,
|
||||||
|
default => $collapsed,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Brazilian DDD (area code) runs 11-99: the first digit is never
|
||||||
|
* '0' (not a valid leading digit) and the second is never '0' either
|
||||||
|
* (no DDD like "10", "20", "30" exists). This does not check that the
|
||||||
|
* DDD is one of the officially assigned codes — only that its shape
|
||||||
|
* is plausible enough to distinguish it from a foreign number.
|
||||||
|
*/
|
||||||
|
private static function isPlausibleDdd(string $digits): bool
|
||||||
|
{
|
||||||
|
return preg_match('/^[1-9][1-9]/', $digits) === 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
181
app/Domain/Contact/MarketingOrigin.php
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Domain\Contact;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Captures the marketing origin of a visit (SPEC.md WEB-05, "origem de
|
||||||
|
* marketing capturada quando disponível") from raw, untrusted request
|
||||||
|
* data and turns it into a short, human-readable pt-BR label for the
|
||||||
|
* internal briefing e-mail.
|
||||||
|
*
|
||||||
|
* Deliberately framework-free (no Illuminate\Http\Request dependency) so
|
||||||
|
* it stays a pure transformation, mirroring BrazilianPhoneNumber: callers
|
||||||
|
* in the HTTP layer extract the query string / referrer and hand them in
|
||||||
|
* as primitives.
|
||||||
|
*/
|
||||||
|
final class MarketingOrigin
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Session key both the capturing middleware and the controller read
|
||||||
|
* from — kept here so there is exactly one name for the concept.
|
||||||
|
*/
|
||||||
|
public const string SESSION_KEY = 'marketing_origin';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Caps every captured value. This is marketing metadata, not user
|
||||||
|
* content — a crafted query string must not be able to bloat the
|
||||||
|
* session (SPEC.md §12.5 LGPD).
|
||||||
|
*/
|
||||||
|
private const int MAX_LENGTH = 100;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
private const array UTM_KEYS = [
|
||||||
|
'utm_source',
|
||||||
|
'utm_medium',
|
||||||
|
'utm_campaign',
|
||||||
|
'utm_term',
|
||||||
|
'utm_content',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* pt-BR labels for the UTM keys, in the order they should be
|
||||||
|
* displayed when composing the briefing e-mail row.
|
||||||
|
*
|
||||||
|
* @var array<string, string>
|
||||||
|
*/
|
||||||
|
private const array LABELS = [
|
||||||
|
'utm_source' => 'origem',
|
||||||
|
'utm_medium' => 'mídia',
|
||||||
|
'utm_campaign' => 'campanha',
|
||||||
|
'utm_term' => 'termo',
|
||||||
|
'utm_content' => 'conteúdo',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads UTM parameters from the query string, falling back to the
|
||||||
|
* HTTP referrer when none are present. Returns an empty array when
|
||||||
|
* neither is available — capturing nothing is a valid outcome
|
||||||
|
* ("capturada quando disponível").
|
||||||
|
*
|
||||||
|
* A referrer pointing back at this same host is not a marketing
|
||||||
|
* origin — it is the visitor clicking from one internal page to
|
||||||
|
* another (common once the original session has expired and a
|
||||||
|
* fresh one starts mid-visit) — so it is discarded rather than
|
||||||
|
* stored as noise (SPEC.md §12.5, "coletar apenas dados
|
||||||
|
* necessários").
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $queryParams
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public static function capture(array $queryParams, ?string $referrer, ?string $requestHost): array
|
||||||
|
{
|
||||||
|
$utm = [];
|
||||||
|
|
||||||
|
foreach (self::UTM_KEYS as $key) {
|
||||||
|
$value = $queryParams[$key] ?? null;
|
||||||
|
|
||||||
|
if (is_string($value) && trim($value) !== '') {
|
||||||
|
$utm[$key] = self::sanitize($value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($utm !== []) {
|
||||||
|
return $utm;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_string($referrer) && trim($referrer) !== '' && ! self::isSameHost($referrer, $requestHost)) {
|
||||||
|
$origin = self::originOf($referrer);
|
||||||
|
|
||||||
|
if ($origin !== null) {
|
||||||
|
return ['referrer' => self::sanitize($origin)];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reduces a referrer URL to its scheme+host identity, dropping the
|
||||||
|
* path, query string, fragment, and any userinfo. The referrer is
|
||||||
|
* only ever used as a marketing-origin *site* label (SPEC.md WEB-05)
|
||||||
|
* — the query string can carry data that identifies an individual
|
||||||
|
* (e.g. a personalized campaign link's `?email=...`), which would
|
||||||
|
* exceed "coletar apenas dados necessários" (SPEC.md §12.5) once it
|
||||||
|
* lands in the session and the internal briefing e-mail.
|
||||||
|
*/
|
||||||
|
private static function originOf(string $referrer): ?string
|
||||||
|
{
|
||||||
|
$host = parse_url($referrer, PHP_URL_HOST);
|
||||||
|
|
||||||
|
if (! is_string($host) || $host === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$scheme = parse_url($referrer, PHP_URL_SCHEME);
|
||||||
|
$prefix = is_string($scheme) && $scheme !== '' ? "{$scheme}://" : '';
|
||||||
|
|
||||||
|
return "{$prefix}{$host}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function isSameHost(string $referrer, ?string $requestHost): bool
|
||||||
|
{
|
||||||
|
if ($requestHost === null || $requestHost === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$referrerHost = parse_url($referrer, PHP_URL_HOST);
|
||||||
|
|
||||||
|
return is_string($referrerHost) && strcasecmp($referrerHost, $requestHost) === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composes the single-row, pt-BR display value for the internal
|
||||||
|
* briefing e-mail. Returns null when nothing was captured, letting
|
||||||
|
* the caller fall back to the same "—" convention already used for
|
||||||
|
* other optional briefing fields.
|
||||||
|
*
|
||||||
|
* Accepts loosely-typed input on purpose: this reads back whatever
|
||||||
|
* was put in the session, so it is treated as untrusted rather than
|
||||||
|
* assumed to still match the shape `capture()` produced.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $origin
|
||||||
|
*/
|
||||||
|
public static function describe(array $origin): ?string
|
||||||
|
{
|
||||||
|
if (isset($origin['referrer']) && is_string($origin['referrer']) && $origin['referrer'] !== '') {
|
||||||
|
return $origin['referrer'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$parts = [];
|
||||||
|
|
||||||
|
foreach (self::LABELS as $key => $label) {
|
||||||
|
if (isset($origin[$key]) && is_string($origin[$key]) && $origin[$key] !== '') {
|
||||||
|
$parts[] = "{$label}: {$origin[$key]}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $parts === [] ? null : implode(' | ', $parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Untrusted input (query string, HTTP referrer) that ends up in an
|
||||||
|
* HTML e-mail: strip any markup, drop control characters (also
|
||||||
|
* closes off header-injection-style newline tricks), trim, and cap
|
||||||
|
* the length before it ever reaches storage.
|
||||||
|
*/
|
||||||
|
private static function sanitize(string $value): string
|
||||||
|
{
|
||||||
|
$withoutTags = strip_tags($value);
|
||||||
|
// No /u flag: this is a byte-wise scrub of ASCII control bytes, so
|
||||||
|
// it cannot land mid-sequence in valid UTF-8 (continuation bytes
|
||||||
|
// are all >= 0x80) — unlike the Unicode-mode regex, it never
|
||||||
|
// blanks the whole string just because one byte is malformed.
|
||||||
|
$withoutControlChars = preg_replace('/[\x00-\x1F\x7F]/', '', $withoutTags) ?? '';
|
||||||
|
|
||||||
|
return mb_substr(trim($withoutControlChars), 0, self::MAX_LENGTH);
|
||||||
|
}
|
||||||
|
}
|
||||||
43
app/Filament/Pages/Auth/RequestPasswordReset.php
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Pages\Auth;
|
||||||
|
|
||||||
|
use Filament\Auth\Pages\PasswordReset\RequestPasswordReset as BaseRequestPasswordReset;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Illuminate\Support\Facades\Password;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Overrides Filament's stock request-reset page to close an account
|
||||||
|
* enumeration leak (SPEC 12.1 / ADM-01 "reset seguro"). The vendor page
|
||||||
|
* shows a distinguishable danger notification for two statuses that only
|
||||||
|
* ever occur for existing users, letting an attacker tell registered emails
|
||||||
|
* apart from unregistered ones:
|
||||||
|
*
|
||||||
|
* - Password::INVALID_USER — no such user at all.
|
||||||
|
* - Password::RESET_THROTTLED — Illuminate\Auth\Passwords\PasswordBroker
|
||||||
|
* only returns this when a token was already recently created for that
|
||||||
|
* user, which requires the user to exist. Two requests for the same
|
||||||
|
* registered email (allowed by this page's own rate limit of 2) would
|
||||||
|
* otherwise flip from "sent" to "throttled" while an unknown email stays
|
||||||
|
* "sent" both times — the same leak, reached a different way. The
|
||||||
|
* tradeoff: a legitimate user requesting twice sees "sent" again instead
|
||||||
|
* of "please wait", which is an acceptable UX cost on an admin-only panel.
|
||||||
|
*
|
||||||
|
* Existing-but-ineligible users (inactive, or since this panel now requires
|
||||||
|
* a verified email) already resolve to Password::RESET_LINK_SENT with no
|
||||||
|
* mail sent — see the vendor callback in the base class — so only these two
|
||||||
|
* branches need normalizing here.
|
||||||
|
*/
|
||||||
|
class RequestPasswordReset extends BaseRequestPasswordReset
|
||||||
|
{
|
||||||
|
protected function getFailureNotification(string $status): ?Notification
|
||||||
|
{
|
||||||
|
if (in_array($status, [Password::INVALID_USER, Password::RESET_THROTTLED], true)) {
|
||||||
|
return $this->getSentNotification(Password::RESET_LINK_SENT);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parent::getFailureNotification($status);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -228,6 +228,12 @@ class ManageSiteSettings extends Page
|
|||||||
->addActionLabel('Adicionar rede'),
|
->addActionLabel('Adicionar rede'),
|
||||||
])
|
])
|
||||||
->columns(2),
|
->columns(2),
|
||||||
|
Section::make('Página Sobre')
|
||||||
|
->schema([
|
||||||
|
PublicImageUploadRules::fileUpload('about_image_path', 'Imagem da página Sobre', 'content/about'),
|
||||||
|
PublicImageUploadRules::altTextField('about_image_alt', 'about_image_path'),
|
||||||
|
])
|
||||||
|
->columns(2),
|
||||||
Section::make('SEO padrão')
|
Section::make('SEO padrão')
|
||||||
->schema([
|
->schema([
|
||||||
TextInput::make('default_meta_title')
|
TextInput::make('default_meta_title')
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Filament\Resources\PortfolioCases\Pages;
|
namespace App\Filament\Resources\PortfolioCases\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\PortfolioCases\PortfolioCaseResource;
|
use App\Filament\Resources\PortfolioCases\PortfolioCaseResource;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Filament\Resources\PortfolioCases\Pages;
|
namespace App\Filament\Resources\PortfolioCases\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\PortfolioCases\PortfolioCaseResource;
|
use App\Filament\Resources\PortfolioCases\PortfolioCaseResource;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Filament\Resources\PortfolioCases\Pages;
|
namespace App\Filament\Resources\PortfolioCases\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\PortfolioCases\PortfolioCaseResource;
|
use App\Filament\Resources\PortfolioCases\PortfolioCaseResource;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Filament\Resources\Services\Pages;
|
namespace App\Filament\Resources\Services\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\Services\ServiceResource;
|
use App\Filament\Resources\Services\ServiceResource;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Filament\Resources\Services\Pages;
|
namespace App\Filament\Resources\Services\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\Services\ServiceResource;
|
use App\Filament\Resources\Services\ServiceResource;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Filament\Resources\Services\Pages;
|
namespace App\Filament\Resources\Services\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\Services\ServiceResource;
|
use App\Filament\Resources\Services\ServiceResource;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Filament\Resources\Testimonials\Pages;
|
namespace App\Filament\Resources\Testimonials\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\Testimonials\TestimonialResource;
|
use App\Filament\Resources\Testimonials\TestimonialResource;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Filament\Resources\Testimonials\Pages;
|
namespace App\Filament\Resources\Testimonials\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\Testimonials\TestimonialResource;
|
use App\Filament\Resources\Testimonials\TestimonialResource;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Filament\Resources\Testimonials\Pages;
|
namespace App\Filament\Resources\Testimonials\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\Testimonials\TestimonialResource;
|
use App\Filament\Resources\Testimonials\TestimonialResource;
|
||||||
|
|||||||
@@ -1,11 +1,38 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Filament\Resources\Users\Pages;
|
namespace App\Filament\Resources\Users\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\Users\UserResource;
|
use App\Filament\Resources\Users\UserResource;
|
||||||
|
use App\Models\User;
|
||||||
use Filament\Resources\Pages\CreateRecord;
|
use Filament\Resources\Pages\CreateRecord;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
class CreateUser extends CreateRecord
|
class CreateUser extends CreateRecord
|
||||||
{
|
{
|
||||||
protected static string $resource = UserResource::class;
|
protected static string $resource = UserResource::class;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verification stays admin-managed only (no self-service verify route is
|
||||||
|
* registered): a user created here by an admin is, by that act, verified.
|
||||||
|
* Without this, `email_verified_at` would stay null forever and
|
||||||
|
* canAccessPanel() would permanently lock the new user out with no
|
||||||
|
* in-app path to recover — the password-reset callback skips notifying
|
||||||
|
* users that fail canAccessPanel() while still reporting success.
|
||||||
|
*
|
||||||
|
* `email_verified_at` is deliberately not added to User's #[Fillable]
|
||||||
|
* list (it isn't a UserForm field either) so it can never be set via
|
||||||
|
* mass assignment from form/API input — `forceFill()` bypasses that
|
||||||
|
* guard here on purpose, after construction.
|
||||||
|
*/
|
||||||
|
protected function handleRecordCreation(array $data): Model
|
||||||
|
{
|
||||||
|
/** @var User $record */
|
||||||
|
$record = new (static::getModel())($data);
|
||||||
|
$record->forceFill(['email_verified_at' => now()]);
|
||||||
|
$record->save();
|
||||||
|
|
||||||
|
return $record;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Filament\Resources\Users\Pages;
|
namespace App\Filament\Resources\Users\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\Users\UserResource;
|
use App\Filament\Resources\Users\UserResource;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Filament\Resources\Users\Pages;
|
namespace App\Filament\Resources\Users\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\Users\UserResource;
|
use App\Filament\Resources\Users\UserResource;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
abstract class Controller
|
abstract class Controller
|
||||||
|
|||||||
140
app/Http/Controllers/PublicSite/ContactController.php
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\PublicSite;
|
||||||
|
|
||||||
|
use App\Domain\Contact\MarketingOrigin;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\PublicSite\ContactBriefingRequest;
|
||||||
|
use App\Mail\ContactBriefing;
|
||||||
|
use App\Mail\ContactBriefingConfirmation;
|
||||||
|
use App\Models\SiteSetting;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Support\Facades\Mail;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
final class ContactController extends Controller
|
||||||
|
{
|
||||||
|
private const string DUPLICATE_SESSION_KEY = 'contact_briefing_hash';
|
||||||
|
|
||||||
|
public function store(ContactBriefingRequest $request): RedirectResponse
|
||||||
|
{
|
||||||
|
if (filled($request->input('empresa'))) {
|
||||||
|
return $this->success();
|
||||||
|
}
|
||||||
|
|
||||||
|
$validated = $request->validated();
|
||||||
|
unset($validated['privacidade']);
|
||||||
|
|
||||||
|
if ($this->isDuplicate($validated)) {
|
||||||
|
return $this->success();
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->rememberSubmission($validated);
|
||||||
|
|
||||||
|
$settings = SiteSetting::instance();
|
||||||
|
$fields = $this->buildFields($validated, $this->resolveMarketingOrigin($request));
|
||||||
|
|
||||||
|
$this->dispatchEmails($settings, $validated['nome'], $validated['email'], $fields);
|
||||||
|
|
||||||
|
return $this->success();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $validated
|
||||||
|
*/
|
||||||
|
private function buildFields(array $validated, ?string $marketingOrigin): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'Nome' => (string) $validated['nome'],
|
||||||
|
'E-mail' => (string) $validated['email'],
|
||||||
|
'Telefone/WhatsApp' => (string) $validated['telefone'],
|
||||||
|
'Tipo de evento' => (string) $validated['tipo_evento'],
|
||||||
|
'Data ou período desejado' => isset($validated['data_periodo']) ? (string) $validated['data_periodo'] : null,
|
||||||
|
'Cidade' => (string) $validated['cidade'],
|
||||||
|
'Número estimado de convidados' => isset($validated['convidados']) ? (string) $validated['convidados'] : null,
|
||||||
|
'Serviço de interesse' => isset($validated['servico_interesse']) ? (string) $validated['servico_interesse'] : null,
|
||||||
|
'Mensagem' => (string) $validated['mensagem'],
|
||||||
|
'Origem de marketing' => $marketingOrigin,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the origin captured on arrival (SPEC.md WEB-05) so it can
|
||||||
|
* ride along with the internal briefing e-mail only — never the
|
||||||
|
* confirmation sent to the visitor. Fail-open: any problem reading
|
||||||
|
* or interpreting the session value must never block the
|
||||||
|
* submission, so it degrades to "not captured" instead.
|
||||||
|
*/
|
||||||
|
private function resolveMarketingOrigin(ContactBriefingRequest $request): ?string
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$origin = $request->session()->get(MarketingOrigin::SESSION_KEY, []);
|
||||||
|
|
||||||
|
return is_array($origin) ? MarketingOrigin::describe($origin) : null;
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
Log::warning('Falha ao ler origem de marketing armazenada (ignorada; fail-open)', [
|
||||||
|
'exception' => $exception::class,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $validated
|
||||||
|
*/
|
||||||
|
private function isDuplicate(array $validated): bool
|
||||||
|
{
|
||||||
|
$hash = $this->hash($validated);
|
||||||
|
|
||||||
|
return session()->get(self::DUPLICATE_SESSION_KEY) === $hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $validated
|
||||||
|
*/
|
||||||
|
private function rememberSubmission(array $validated): void
|
||||||
|
{
|
||||||
|
session()->put(self::DUPLICATE_SESSION_KEY, $this->hash($validated));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $validated
|
||||||
|
*/
|
||||||
|
private function hash(array $validated): string
|
||||||
|
{
|
||||||
|
return hash('sha256', serialize($validated));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $fields
|
||||||
|
*/
|
||||||
|
private function dispatchEmails(SiteSetting $settings, string $name, string $email, array $fields): void
|
||||||
|
{
|
||||||
|
$attempt = function () use ($settings, $name, $email, $fields): void {
|
||||||
|
if (filled($settings->email)) {
|
||||||
|
Mail::to($settings->email)->send(new ContactBriefing($fields));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filled(config('mail.default'))) {
|
||||||
|
Mail::to($email)->send(new ContactBriefingConfirmation($name, (string) $settings->brand_name));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
$attempt();
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
Log::error('Falha ao enviar briefing de contato', [
|
||||||
|
'exception' => $exception,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function success(): RedirectResponse
|
||||||
|
{
|
||||||
|
return redirect()->route('contact')->with('status', 'briefing-sent');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,7 +20,7 @@ final class PageController extends Controller
|
|||||||
'pageMeta' => PageMeta::forPage(
|
'pageMeta' => PageMeta::forPage(
|
||||||
canonical: route('about'),
|
canonical: route('about'),
|
||||||
settings: $settings,
|
settings: $settings,
|
||||||
title: 'Sobre',
|
title: PageMeta::withBrandSuffix('Sobre', $settings),
|
||||||
description: $settings->about_summary ?: ('Conheça a '.$settings->brand_name.'.'),
|
description: $settings->about_summary ?: ('Conheça a '.$settings->brand_name.'.'),
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
@@ -35,7 +35,7 @@ final class PageController extends Controller
|
|||||||
'pageMeta' => PageMeta::forPage(
|
'pageMeta' => PageMeta::forPage(
|
||||||
canonical: route('privacy'),
|
canonical: route('privacy'),
|
||||||
settings: $settings,
|
settings: $settings,
|
||||||
title: 'Política de privacidade',
|
title: PageMeta::withBrandSuffix('Política de privacidade', $settings),
|
||||||
description: 'Política de privacidade da '.$settings->brand_name.'.',
|
description: 'Política de privacidade da '.$settings->brand_name.'.',
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
@@ -50,7 +50,7 @@ final class PageController extends Controller
|
|||||||
'pageMeta' => PageMeta::forPage(
|
'pageMeta' => PageMeta::forPage(
|
||||||
canonical: route('contact'),
|
canonical: route('contact'),
|
||||||
settings: $settings,
|
settings: $settings,
|
||||||
title: 'Contato',
|
title: PageMeta::withBrandSuffix('Contato', $settings),
|
||||||
description: 'Fale com a '.$settings->brand_name.'.',
|
description: 'Fale com a '.$settings->brand_name.'.',
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ final class PortfolioController extends Controller
|
|||||||
'pageMeta' => PageMeta::forPage(
|
'pageMeta' => PageMeta::forPage(
|
||||||
canonical: route('portfolio.index'),
|
canonical: route('portfolio.index'),
|
||||||
settings: $settings,
|
settings: $settings,
|
||||||
title: 'Portfólio',
|
title: PageMeta::withBrandSuffix('Portfólio', $settings),
|
||||||
description: 'Casos reais de eventos conduzidos pela '.$settings->brand_name.'.',
|
description: 'Casos reais de eventos conduzidos pela '.$settings->brand_name.'.',
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ final class ServiceController extends Controller
|
|||||||
'pageMeta' => PageMeta::forPage(
|
'pageMeta' => PageMeta::forPage(
|
||||||
canonical: route('services.index'),
|
canonical: route('services.index'),
|
||||||
settings: $settings,
|
settings: $settings,
|
||||||
title: 'Serviços',
|
title: PageMeta::withBrandSuffix('Serviços', $settings),
|
||||||
description: 'Conheça os serviços de assessoria de eventos da '.$settings->brand_name.'.',
|
description: 'Conheça os serviços de assessoria de eventos da '.$settings->brand_name.'.',
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
|
|||||||
85
app/Http/Middleware/CaptureMarketingOrigin.php
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use App\Domain\Contact\MarketingOrigin;
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Captures the marketing origin (UTM parameters, or the HTTP referrer as
|
||||||
|
* a fallback) on the first public page load of a visit and stores it in
|
||||||
|
* the session, so it can later travel with a contact briefing submission
|
||||||
|
* (SPEC.md WEB-05).
|
||||||
|
*
|
||||||
|
* First *informative* touch wins: nothing is written until a page load
|
||||||
|
* actually carries a UTM parameter or an external referrer, and once
|
||||||
|
* that happens later page views never overwrite it — navigating to
|
||||||
|
* another page without UTM parameters must not erase what arrived with
|
||||||
|
* the visitor. A page load with no signal at all is left unrecorded so a
|
||||||
|
* later, informative page load in the same session can still be
|
||||||
|
* captured.
|
||||||
|
*
|
||||||
|
* Fail-open by construction: any failure here is caught and logged
|
||||||
|
* without request data, and the request proceeds untouched. This path
|
||||||
|
* must never be able to block a page load or, downstream, a conversion.
|
||||||
|
*/
|
||||||
|
final class CaptureMarketingOrigin
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Technical routes that share the `web` middleware group but are never a
|
||||||
|
* human arriving at the site. A crawler fetching `/sitemap.xml?utm_source=…`
|
||||||
|
* would otherwise consume the first-touch slot with traffic that will never
|
||||||
|
* submit a briefing.
|
||||||
|
*
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
private const IGNORED_ROUTES = ['sitemap', 'robots'];
|
||||||
|
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
if (in_array($request->route()?->getName(), self::IGNORED_ROUTES, true)) {
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->captureFirstTouch($request);
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
Log::warning('Falha ao capturar origem de marketing (ignorada; fail-open)', [
|
||||||
|
'exception' => $exception::class,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function captureFirstTouch(Request $request): void
|
||||||
|
{
|
||||||
|
if (! $request->hasSession()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$session = $request->session();
|
||||||
|
|
||||||
|
if ($session->has(MarketingOrigin::SESSION_KEY)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$origin = MarketingOrigin::capture(
|
||||||
|
$request->query(),
|
||||||
|
$request->headers->get('referer'),
|
||||||
|
$request->getHost(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($origin === []) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$session->put(MarketingOrigin::SESSION_KEY, $origin);
|
||||||
|
}
|
||||||
|
}
|
||||||
73
app/Http/Requests/PublicSite/ContactBriefingRequest.php
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests\PublicSite;
|
||||||
|
|
||||||
|
use App\Domain\Contact\BrazilianPhoneNumber;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
final class ContactBriefingRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Normaliza e-mail e telefone antes da validação (SPEC.md §12.3),
|
||||||
|
* mantendo o valor legível para quem recebe o briefing por e-mail.
|
||||||
|
*/
|
||||||
|
protected function prepareForValidation(): void
|
||||||
|
{
|
||||||
|
$email = $this->input('email');
|
||||||
|
$telefone = $this->input('telefone');
|
||||||
|
|
||||||
|
$this->merge([
|
||||||
|
'email' => is_string($email) ? mb_strtolower(trim($email)) : $email,
|
||||||
|
'telefone' => is_string($telefone) ? BrazilianPhoneNumber::normalize($telefone) : $telefone,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, array<int, string>>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'nome' => ['required', 'string', 'max:120'],
|
||||||
|
'email' => ['required', 'email', 'max:254'],
|
||||||
|
'telefone' => ['required', 'string', 'max:40'],
|
||||||
|
'tipo_evento' => ['required', 'string', 'max:80'],
|
||||||
|
'data_periodo' => ['nullable', 'string', 'max:80'],
|
||||||
|
'cidade' => ['required', 'string', 'max:80'],
|
||||||
|
'convidados' => ['nullable', 'integer', 'min:1', 'max:100000'],
|
||||||
|
'servico_interesse' => ['nullable', 'string', 'max:120'],
|
||||||
|
'mensagem' => ['required', 'string', 'max:3000'],
|
||||||
|
'privacidade' => ['accepted'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public function messages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'nome.required' => 'Informe seu nome completo.',
|
||||||
|
'nome.max' => 'O nome deve ter no máximo :max caracteres.',
|
||||||
|
'email.required' => 'Informe seu e-mail.',
|
||||||
|
'email.email' => 'Informe um e-mail válido.',
|
||||||
|
'email.max' => 'O e-mail deve ter no máximo :max caracteres.',
|
||||||
|
'telefone.required' => 'Informe um telefone ou WhatsApp.',
|
||||||
|
'telefone.max' => 'O telefone deve ter no máximo :max caracteres.',
|
||||||
|
'tipo_evento.required' => 'Selecione o tipo de evento.',
|
||||||
|
'tipo_evento.max' => 'O tipo de evento deve ter no máximo :max caracteres.',
|
||||||
|
'data_periodo.max' => 'A data ou período deve ter no máximo :max caracteres.',
|
||||||
|
'cidade.required' => 'Informe a cidade do evento.',
|
||||||
|
'cidade.max' => 'A cidade deve ter no máximo :max caracteres.',
|
||||||
|
'convidados.integer' => 'Informe um número de convidados válido.',
|
||||||
|
'convidados.min' => 'O número de convidados deve ser maior que zero.',
|
||||||
|
'convidados.max' => 'O número de convidados informado é inválido.',
|
||||||
|
'servico_interesse.max' => 'O serviço deve ter no máximo :max caracteres.',
|
||||||
|
'mensagem.required' => 'Conte brevemente o que você precisa.',
|
||||||
|
'mensagem.max' => 'A mensagem deve ter no máximo :max caracteres.',
|
||||||
|
'privacidade.accepted' => 'Você precisa aceitar a política de privacidade.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
40
app/Mail/ContactBriefing.php
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Mail;
|
||||||
|
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Mail\Mailable;
|
||||||
|
use Illuminate\Mail\Mailables\Content;
|
||||||
|
use Illuminate\Mail\Mailables\Envelope;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
|
final class ContactBriefing extends Mailable implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Queueable;
|
||||||
|
use SerializesModels;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $fields
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public readonly array $fields,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function envelope(): Envelope
|
||||||
|
{
|
||||||
|
return new Envelope(
|
||||||
|
subject: 'Novo briefing de contato — Amare Assessoria',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function content(): Content
|
||||||
|
{
|
||||||
|
return new Content(
|
||||||
|
html: 'emails.contact-briefing',
|
||||||
|
text: 'emails.contact-briefing-text',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
38
app/Mail/ContactBriefingConfirmation.php
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Mail;
|
||||||
|
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Mail\Mailable;
|
||||||
|
use Illuminate\Mail\Mailables\Content;
|
||||||
|
use Illuminate\Mail\Mailables\Envelope;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
|
final class ContactBriefingConfirmation extends Mailable implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Queueable;
|
||||||
|
use SerializesModels;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public readonly string $name,
|
||||||
|
public readonly string $brandName,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function envelope(): Envelope
|
||||||
|
{
|
||||||
|
return new Envelope(
|
||||||
|
subject: 'Recebemos sua mensagem — '.$this->brandName,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function content(): Content
|
||||||
|
{
|
||||||
|
return new Content(
|
||||||
|
html: 'emails.contact-briefing-confirmation',
|
||||||
|
text: 'emails.contact-briefing-confirmation-text',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,8 @@ use Illuminate\Database\Eloquent\Model;
|
|||||||
* @property bool $analytics_enabled
|
* @property bool $analytics_enabled
|
||||||
* @property string|null $default_og_image_path
|
* @property string|null $default_og_image_path
|
||||||
* @property string|null $default_og_image_alt
|
* @property string|null $default_og_image_alt
|
||||||
|
* @property string|null $about_image_path
|
||||||
|
* @property string|null $about_image_alt
|
||||||
* @property string|null $logo_path
|
* @property string|null $logo_path
|
||||||
* @property string|null $logo_alt
|
* @property string|null $logo_alt
|
||||||
*/
|
*/
|
||||||
@@ -30,6 +32,8 @@ use Illuminate\Database\Eloquent\Model;
|
|||||||
'hero_secondary_cta_label',
|
'hero_secondary_cta_label',
|
||||||
'hero_note',
|
'hero_note',
|
||||||
'about_summary',
|
'about_summary',
|
||||||
|
'about_image_path',
|
||||||
|
'about_image_alt',
|
||||||
'manifesto_title',
|
'manifesto_title',
|
||||||
'manifesto_lead',
|
'manifesto_lead',
|
||||||
'manifesto_body',
|
'manifesto_body',
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ use App\Enums\UserRole;
|
|||||||
use Database\Factories\UserFactory;
|
use Database\Factories\UserFactory;
|
||||||
use Filament\Models\Contracts\FilamentUser;
|
use Filament\Models\Contracts\FilamentUser;
|
||||||
use Filament\Panel;
|
use Filament\Panel;
|
||||||
|
use Illuminate\Auth\MustVerifyEmail;
|
||||||
|
use Illuminate\Contracts\Auth\MustVerifyEmail as MustVerifyEmailContract;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Hidden;
|
use Illuminate\Database\Eloquent\Attributes\Hidden;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
@@ -20,14 +22,14 @@ use Illuminate\Notifications\Notifiable;
|
|||||||
*/
|
*/
|
||||||
#[Fillable(['name', 'email', 'password', 'role', 'is_active'])]
|
#[Fillable(['name', 'email', 'password', 'role', 'is_active'])]
|
||||||
#[Hidden(['password', 'remember_token'])]
|
#[Hidden(['password', 'remember_token'])]
|
||||||
class User extends Authenticatable implements FilamentUser
|
class User extends Authenticatable implements FilamentUser, MustVerifyEmailContract
|
||||||
{
|
{
|
||||||
/** @use HasFactory<UserFactory> */
|
/** @use HasFactory<UserFactory> */
|
||||||
use HasFactory, Notifiable;
|
use HasFactory, MustVerifyEmail, Notifiable;
|
||||||
|
|
||||||
public function canAccessPanel(Panel $panel): bool
|
public function canAccessPanel(Panel $panel): bool
|
||||||
{
|
{
|
||||||
return $this->is_active;
|
return $this->is_active && $this->hasVerifiedEmail();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function isAdmin(): bool
|
public function isAdmin(): bool
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ namespace App\Providers;
|
|||||||
use App\Application\Data\PageMeta;
|
use App\Application\Data\PageMeta;
|
||||||
use App\Models\SiteSetting;
|
use App\Models\SiteSetting;
|
||||||
use Carbon\CarbonImmutable;
|
use Carbon\CarbonImmutable;
|
||||||
|
use Illuminate\Cache\RateLimiting\Limit;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
use Illuminate\Support\Facades\View;
|
use Illuminate\Support\Facades\View;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
use Illuminate\View\View as ViewInstance;
|
use Illuminate\View\View as ViewInstance;
|
||||||
@@ -28,6 +31,7 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
{
|
{
|
||||||
$this->configureLivewireTemporaryUploads();
|
$this->configureLivewireTemporaryUploads();
|
||||||
$this->freezeClockWhenConfigured();
|
$this->freezeClockWhenConfigured();
|
||||||
|
$this->configureRateLimiters();
|
||||||
|
|
||||||
View::composer('layouts.public', function (ViewInstance $view): void {
|
View::composer('layouts.public', function (ViewInstance $view): void {
|
||||||
$settings = $view->offsetExists('siteSettings')
|
$settings = $view->offsetExists('siteSettings')
|
||||||
@@ -63,6 +67,13 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
config(['livewire.temporary_file_upload.disk' => 'local']);
|
config(['livewire.temporary_file_upload.disk' => 'local']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function configureRateLimiters(): void
|
||||||
|
{
|
||||||
|
RateLimiter::for('contact-briefing', function (Request $request): Limit {
|
||||||
|
return Limit::perMinute(5)->by($request->ip().'|contact-briefing');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private function freezeClockWhenConfigured(): void
|
private function freezeClockWhenConfigured(): void
|
||||||
{
|
{
|
||||||
if ($this->app->environment('production')) {
|
if ($this->app->environment('production')) {
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Providers\Filament;
|
namespace App\Providers\Filament;
|
||||||
|
|
||||||
|
use App\Filament\Pages\Auth\RequestPasswordReset;
|
||||||
|
use Filament\FontProviders\LocalFontProvider;
|
||||||
use Filament\Http\Middleware\Authenticate;
|
use Filament\Http\Middleware\Authenticate;
|
||||||
use Filament\Http\Middleware\AuthenticateSession;
|
use Filament\Http\Middleware\AuthenticateSession;
|
||||||
use Filament\Http\Middleware\DisableBladeIconComponents;
|
use Filament\Http\Middleware\DisableBladeIconComponents;
|
||||||
@@ -9,7 +13,7 @@ use Filament\Http\Middleware\DispatchServingFilamentEvent;
|
|||||||
use Filament\Pages\Dashboard;
|
use Filament\Pages\Dashboard;
|
||||||
use Filament\Panel;
|
use Filament\Panel;
|
||||||
use Filament\PanelProvider;
|
use Filament\PanelProvider;
|
||||||
use Filament\Support\Colors\Color;
|
use Filament\View\PanelsRenderHook;
|
||||||
use Filament\Widgets\AccountWidget;
|
use Filament\Widgets\AccountWidget;
|
||||||
use Filament\Widgets\FilamentInfoWidget;
|
use Filament\Widgets\FilamentInfoWidget;
|
||||||
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
|
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
|
||||||
@@ -21,6 +25,137 @@ use Illuminate\View\Middleware\ShareErrorsFromSession;
|
|||||||
|
|
||||||
class AdminPanelProvider extends PanelProvider
|
class AdminPanelProvider extends PanelProvider
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* Heritage Editorial — Oliva Herança primary ramp.
|
||||||
|
*
|
||||||
|
* Filament needs an explicit 11-stop shade array to preserve exact brand
|
||||||
|
* hexes (a bare string/`Color::hex()` runs the value through
|
||||||
|
* `Color::generatePalette()`, which discards its lightness and maps a
|
||||||
|
* fixed per-shade lightness/chroma table instead — see MAN-118 survey).
|
||||||
|
* Shade 600 pins `#556B2F` (Oliva Herança) and shade 700 pins `#3E5219`
|
||||||
|
* (Oliva Profundo). `Filament\Support\View\Components\ColorMaps\
|
||||||
|
* ButtonComponentColorMap` was used to verify empirically (not assumed)
|
||||||
|
* that a solid primary button resolves to `bg: 600, hover:bg: 500, text:
|
||||||
|
* 50` at 5.42:1 / 4.57:1 contrast (WCAG AA). Shade 50 is a pale olive
|
||||||
|
* tint rather than pure white deliberately: `BadgeComponent`/
|
||||||
|
* `badge.css` apply `bg-color-50` directly as a badge's background
|
||||||
|
* (independent of the button map above), and a pure-white 50 would
|
||||||
|
* render a "primary" badge as an all-but-invisible pill against the
|
||||||
|
* Papel Marfim (`#FBF9F4`) panel body.
|
||||||
|
*
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
private function primaryColor(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
50 => '#F3F5EC',
|
||||||
|
100 => '#E7EBDA',
|
||||||
|
200 => '#D4DCBE',
|
||||||
|
300 => '#8B9D77', // Sálvia Silenciosa
|
||||||
|
400 => '#6E8354',
|
||||||
|
500 => '#61763A',
|
||||||
|
600 => '#556B2F', // Oliva Herança
|
||||||
|
700 => '#3E5219', // Oliva Profundo
|
||||||
|
800 => '#2E3D13',
|
||||||
|
900 => '#1F290D',
|
||||||
|
950 => '#141B08',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Heritage Editorial — paper/ink neutral ramp, replacing Filament's
|
||||||
|
* stock Zinc gray so panel chrome (sidebar, topbar, body background,
|
||||||
|
* borders) matches the public site's paper tones instead of true gray.
|
||||||
|
* Anchored at the exact tokens where DESIGN.md defines them (50/100/200
|
||||||
|
* = Papel Marfim/Profundo/Arquivo, 300 = Linha Botânica, 500 = Tinta
|
||||||
|
* Suave, 900 = Tinta Oliva); the remaining stops are interpolated to
|
||||||
|
* keep the ramp monotonic.
|
||||||
|
*
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
private function grayColor(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
50 => '#FBF9F4', // Papel Marfim
|
||||||
|
100 => '#F0EEE9', // Papel Profundo
|
||||||
|
200 => '#E4E2DD', // Papel Arquivo
|
||||||
|
300 => '#C5C8B8', // Linha Botânica
|
||||||
|
400 => '#919486',
|
||||||
|
500 => '#5D6155', // Tinta Suave
|
||||||
|
600 => '#43453D',
|
||||||
|
700 => '#32342E',
|
||||||
|
800 => '#252622',
|
||||||
|
900 => '#1B1C19', // Tinta Oliva
|
||||||
|
950 => '#121210',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Semantic ramps built the same way as the primary/gray ramps above:
|
||||||
|
* an explicit 11-stop array (never `Color::hex()`) anchored so the
|
||||||
|
* existing `--amare-color-{success,warning,error}` hex lands exactly on
|
||||||
|
* shade 600 — verified against `ButtonComponentColorMap` to resolve to
|
||||||
|
* `bg: 600` with white text at WCAG AA contrast, same as primary.
|
||||||
|
*
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
private function dangerColor(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
50 => '#F7EDED',
|
||||||
|
100 => '#EBD1D1',
|
||||||
|
200 => '#D8A8A8',
|
||||||
|
300 => '#C88484',
|
||||||
|
400 => '#B85F5F',
|
||||||
|
500 => '#A73B3B',
|
||||||
|
600 => '#991B1B',
|
||||||
|
700 => '#7D1616',
|
||||||
|
800 => '#651212',
|
||||||
|
900 => '#4C0E0E',
|
||||||
|
950 => '#3A0A0A',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
private function warningColor(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
50 => '#F6F0EC',
|
||||||
|
100 => '#E9D9CF',
|
||||||
|
200 => '#D6B6A3',
|
||||||
|
300 => '#C4987D',
|
||||||
|
400 => '#B37956',
|
||||||
|
500 => '#A15B30',
|
||||||
|
600 => '#92400E',
|
||||||
|
700 => '#78340B',
|
||||||
|
800 => '#602A09',
|
||||||
|
900 => '#492007',
|
||||||
|
950 => '#371805',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
private function successColor(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
50 => '#ECF3EF',
|
||||||
|
100 => '#D0E0D6',
|
||||||
|
200 => '#A6C4B2',
|
||||||
|
300 => '#81AC91',
|
||||||
|
400 => '#5C9371',
|
||||||
|
500 => '#377B50',
|
||||||
|
600 => '#166534',
|
||||||
|
700 => '#12532B',
|
||||||
|
800 => '#0F4322',
|
||||||
|
900 => '#0B321A',
|
||||||
|
950 => '#082614',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
public function panel(Panel $panel): Panel
|
public function panel(Panel $panel): Panel
|
||||||
{
|
{
|
||||||
return $panel
|
return $panel
|
||||||
@@ -28,9 +163,47 @@ class AdminPanelProvider extends PanelProvider
|
|||||||
->id('admin')
|
->id('admin')
|
||||||
->path('admin')
|
->path('admin')
|
||||||
->login()
|
->login()
|
||||||
|
->passwordReset(requestAction: RequestPasswordReset::class)
|
||||||
|
->brandName('Amare Assessoria')
|
||||||
|
->brandLogo(fn (): string => asset('brand/lockup-on-light.webp'))
|
||||||
|
->brandLogoHeight('2rem')
|
||||||
->colors([
|
->colors([
|
||||||
'primary' => Color::Amber,
|
'primary' => $this->primaryColor(),
|
||||||
|
'gray' => $this->grayColor(),
|
||||||
|
'danger' => $this->dangerColor(),
|
||||||
|
'warning' => $this->warningColor(),
|
||||||
|
'success' => $this->successColor(),
|
||||||
])
|
])
|
||||||
|
// Heritage Editorial is a single paper palette by design (see
|
||||||
|
// DESIGN.md) — no dark-mode variant exists, so the switcher is
|
||||||
|
// removed rather than left pointing at an unstyled dark theme.
|
||||||
|
->darkMode(false)
|
||||||
|
// Self-hosted EB Garamond, same family as the public site.
|
||||||
|
// LocalFontProvider is pinned explicitly: HasFont::getFontProvider()
|
||||||
|
// otherwise defaults a custom family to BunnyFontProvider, which
|
||||||
|
// would emit a live request to fonts.bunny.net from the panel.
|
||||||
|
// LocalFontProvider renders no <link>/@font-face itself (no $url
|
||||||
|
// given), so the actual face comes from the render hook below,
|
||||||
|
// reusing the public site's own <x-fonts /> component/manifest.
|
||||||
|
//
|
||||||
|
// The monospace and serif faces are set too, not just the base
|
||||||
|
// (sans) family: Filament exposes them via the separate
|
||||||
|
// ->monoFont()/->serifFont() calls below, and the `KeyValue`
|
||||||
|
// field (used in ManageSiteSettings) renders in the monospace
|
||||||
|
// face directly — left unset, that field would silently fall
|
||||||
|
// back to a system monospace stack instead of EB Garamond,
|
||||||
|
// breaking Heritage Editorial's single-voice typography.
|
||||||
|
->font('EB Garamond', provider: LocalFontProvider::class)
|
||||||
|
->monoFont('EB Garamond', provider: LocalFontProvider::class)
|
||||||
|
->serifFont('EB Garamond', provider: LocalFontProvider::class)
|
||||||
|
->renderHook(
|
||||||
|
PanelsRenderHook::HEAD_END,
|
||||||
|
fn (): string => view('components.fonts')->render(),
|
||||||
|
)
|
||||||
|
// Compiled Filament theme entry — sharp corners, flat surfaces
|
||||||
|
// (see resources/css/filament/admin/theme.css for the full
|
||||||
|
// rationale and the two vendored-CSS exceptions it can't reach).
|
||||||
|
->viteTheme('resources/css/filament/admin/theme.css')
|
||||||
->discoverResources(in: app_path('Filament/Resources'), for: 'App\Filament\Resources')
|
->discoverResources(in: app_path('Filament/Resources'), for: 'App\Filament\Resources')
|
||||||
->discoverPages(in: app_path('Filament/Pages'), for: 'App\Filament\Pages')
|
->discoverPages(in: app_path('Filament/Pages'), for: 'App\Filament\Pages')
|
||||||
->pages([
|
->pages([
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Middleware\CaptureMarketingOrigin;
|
||||||
use Illuminate\Foundation\Application;
|
use Illuminate\Foundation\Application;
|
||||||
use Illuminate\Foundation\Configuration\Exceptions;
|
use Illuminate\Foundation\Configuration\Exceptions;
|
||||||
use Illuminate\Foundation\Configuration\Middleware;
|
use Illuminate\Foundation\Configuration\Middleware;
|
||||||
@@ -14,6 +15,13 @@ 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,6 +65,9 @@
|
|||||||
"test:browser": [
|
"test:browser": [
|
||||||
"@php artisan test --testsuite=Browser"
|
"@php artisan test --testsuite=Browser"
|
||||||
],
|
],
|
||||||
|
"test:coverage": [
|
||||||
|
"vendor/bin/pest -c phpunit.coverage.xml --testsuite=Unit,Architecture,Feature --coverage --min=80"
|
||||||
|
],
|
||||||
"pint": [
|
"pint": [
|
||||||
"vendor/bin/pint"
|
"vendor/bin/pint"
|
||||||
],
|
],
|
||||||
@@ -81,6 +84,7 @@
|
|||||||
"@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": [
|
||||||
|
|||||||
12
composer.lock
generated
@@ -2802,16 +2802,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "league/commonmark",
|
"name": "league/commonmark",
|
||||||
"version": "2.8.3",
|
"version": "2.9.0",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/thephpleague/commonmark.git",
|
"url": "https://github.com/thephpleague/commonmark.git",
|
||||||
"reference": "1902f60f984235023acbe03db6ad614a37b3c3e7"
|
"reference": "5703d83ba3da3b2e356a5fedc848ed6d8ffb6529"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/thephpleague/commonmark/zipball/1902f60f984235023acbe03db6ad614a37b3c3e7",
|
"url": "https://api.github.com/repos/thephpleague/commonmark/zipball/5703d83ba3da3b2e356a5fedc848ed6d8ffb6529",
|
||||||
"reference": "1902f60f984235023acbe03db6ad614a37b3c3e7",
|
"reference": "5703d83ba3da3b2e356a5fedc848ed6d8ffb6529",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -2848,7 +2848,7 @@
|
|||||||
"type": "library",
|
"type": "library",
|
||||||
"extra": {
|
"extra": {
|
||||||
"branch-alias": {
|
"branch-alias": {
|
||||||
"dev-main": "2.9-dev"
|
"dev-main": "2.10-dev"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"autoload": {
|
"autoload": {
|
||||||
@@ -2905,7 +2905,7 @@
|
|||||||
"type": "tidelift"
|
"type": "tidelift"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2026-07-12T15:29:16+00:00"
|
"time": "2026-08-03T13:42:31+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "league/config",
|
"name": "league/config",
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ return [
|
|||||||
|
|
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'timezone' => env('APP_TIMEZONE', 'America/Fortaleza'),
|
'timezone' => env('APP_TIMEZONE', 'America/Sao_Paulo'),
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
|
|||||||
BIN
database/fixtures/images/about-image.jpg
Normal file
|
After Width: | Height: | Size: 151 KiB |
BIN
database/fixtures/images/case-casamento-ana-lucas.jpg
Normal file
|
After Width: | Height: | Size: 436 KiB |
BIN
database/fixtures/images/case-lancamento-verano.jpg
Normal file
|
After Width: | Height: | Size: 254 KiB |
BIN
database/fixtures/images/case-mini-wedding-marina.jpg
Normal file
|
After Width: | Height: | Size: 258 KiB |
BIN
database/fixtures/images/gallery-casamento-ana-lucas-1.jpg
Normal file
|
After Width: | Height: | Size: 622 KiB |
BIN
database/fixtures/images/gallery-casamento-ana-lucas-2.jpg
Normal file
|
After Width: | Height: | Size: 499 KiB |
BIN
database/fixtures/images/gallery-lancamento-verano-1.jpg
Normal file
|
After Width: | Height: | Size: 244 KiB |
BIN
database/fixtures/images/gallery-lancamento-verano-2.jpg
Normal file
|
After Width: | Height: | Size: 235 KiB |
BIN
database/fixtures/images/gallery-mini-wedding-marina-1.jpg
Normal file
|
After Width: | Height: | Size: 239 KiB |
BIN
database/fixtures/images/gallery-mini-wedding-marina-2.jpg
Normal file
|
After Width: | Height: | Size: 236 KiB |
BIN
database/fixtures/images/og-default.jpg
Normal file
|
After Width: | Height: | Size: 360 KiB |
BIN
database/fixtures/images/service-casamentos.jpg
Normal file
|
After Width: | Height: | Size: 330 KiB |
BIN
database/fixtures/images/service-celebracoes-intimistas.jpg
Normal file
|
After Width: | Height: | Size: 302 KiB |
BIN
database/fixtures/images/service-eventos-corporativos.jpg
Normal file
|
After Width: | Height: | Size: 337 KiB |
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('site_settings', function (Blueprint $table): void {
|
||||||
|
$table->string('about_image_path')->nullable()->after('about_summary');
|
||||||
|
$table->string('about_image_alt')->nullable()->after('about_image_path');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('site_settings', function (Blueprint $table): void {
|
||||||
|
$table->dropColumn(['about_image_path', 'about_image_alt']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backfills `email_verified_at` for users that already exist.
|
||||||
|
*
|
||||||
|
* `User` now implements MustVerifyEmail and `canAccessPanel()` requires
|
||||||
|
* `hasVerifiedEmail()`. Every account created before this change has
|
||||||
|
* `email_verified_at` as null, so without this backfill they are locked out of
|
||||||
|
* `/admin` the moment the change deploys — and there is no way back in from
|
||||||
|
* inside the app: no self-service verification route is registered, and the
|
||||||
|
* password-reset flow deliberately skips notifying users that fail
|
||||||
|
* `canAccessPanel()` while still reporting success. Recovery would need shell
|
||||||
|
* access to the container.
|
||||||
|
*
|
||||||
|
* Verifying them is the correct default, not a shortcut. There is no public
|
||||||
|
* registration: every existing account was created by an admin, through the
|
||||||
|
* panel or the tinker snippet in docs/deployment/dokploy.md. That act is the
|
||||||
|
* verification — which is exactly the reasoning CreateUser applies to accounts
|
||||||
|
* created from now on.
|
||||||
|
*/
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
DB::table('users')
|
||||||
|
->whereNull('email_verified_at')
|
||||||
|
->update(['email_verified_at' => DB::raw('COALESCE(created_at, NOW())')]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
// Deliberately not reversed. Nulling these columns would lock every
|
||||||
|
// existing user out of the panel, which is the failure this migration
|
||||||
|
// exists to prevent — and the pre-migration null/non-null split is not
|
||||||
|
// recorded anywhere, so it could not be restored faithfully anyway.
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -8,8 +8,10 @@ use App\Models\PortfolioCase;
|
|||||||
use App\Models\PortfolioImage;
|
use App\Models\PortfolioImage;
|
||||||
use App\Models\Service;
|
use App\Models\Service;
|
||||||
use App\Models\SiteSetting;
|
use App\Models\SiteSetting;
|
||||||
|
use App\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;
|
||||||
|
|
||||||
@@ -17,8 +19,32 @@ 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();
|
||||||
@@ -36,6 +62,8 @@ class ContentSeeder extends Seeder
|
|||||||
'hero_secondary_cta_label' => 'Conheça nosso olhar',
|
'hero_secondary_cta_label' => 'Conheça nosso olhar',
|
||||||
'hero_note' => 'Planejamento cuidadoso, comunicação clara e execução segura — do primeiro encontro ao último detalhe.',
|
'hero_note' => 'Planejamento cuidadoso, comunicação clara e execução segura — do primeiro encontro ao último detalhe.',
|
||||||
'about_summary' => 'Assessoria boutique especializada em experiências memoráveis em São Paulo.',
|
'about_summary' => 'Assessoria boutique especializada em experiências memoráveis em São Paulo.',
|
||||||
|
'about_image_path' => $this->copyFixture('about-image.jpg', 'content/about/about-image.jpg'),
|
||||||
|
'about_image_alt' => 'Mesa de planejamento com caderno, café e guardanapos de pano',
|
||||||
'manifesto_title' => 'Sofisticação que também se traduz em organização.',
|
'manifesto_title' => 'Sofisticação que também se traduz em organização.',
|
||||||
'manifesto_lead' => 'Um evento memorável não nasce apenas de uma boa estética. Ele depende de decisões bem conduzidas, fornecedores alinhados e atenção constante ao que realmente importa.',
|
'manifesto_lead' => 'Um evento memorável não nasce apenas de uma boa estética. Ele depende de decisões bem conduzidas, fornecedores alinhados e atenção constante ao que realmente importa.',
|
||||||
'manifesto_body' => 'A Amare combina sensibilidade e precisão para criar encontros coerentes com cada cliente, marca e ocasião — sem fórmulas prontas, excessos ou ruído.',
|
'manifesto_body' => 'A Amare combina sensibilidade e precisão para criar encontros coerentes com cada cliente, marca e ocasião — sem fórmulas prontas, excessos ou ruído.',
|
||||||
@@ -91,7 +119,7 @@ class ContentSeeder extends Seeder
|
|||||||
['slug' => $service['slug']],
|
['slug' => $service['slug']],
|
||||||
[
|
[
|
||||||
...$service,
|
...$service,
|
||||||
'cover_image_path' => $this->copyFixture('service-cover.jpg', 'content/services/'.$service['slug'].'.jpg'),
|
'cover_image_path' => $this->copyFixture('service-'.$service['slug'].'.jpg', 'content/services/'.$service['slug'].'.jpg'),
|
||||||
'cover_image_alt' => 'Capa do serviço '.$service['title'],
|
'cover_image_alt' => 'Capa do serviço '.$service['title'],
|
||||||
'published_at' => Carbon::parse(self::SEED_TIMESTAMP),
|
'published_at' => Carbon::parse(self::SEED_TIMESTAMP),
|
||||||
],
|
],
|
||||||
@@ -148,7 +176,7 @@ class ContentSeeder extends Seeder
|
|||||||
['slug' => $caseData['slug']],
|
['slug' => $caseData['slug']],
|
||||||
[
|
[
|
||||||
...$caseData,
|
...$caseData,
|
||||||
'cover_image_path' => $this->copyFixture('portfolio-cover.jpg', 'content/portfolio/'.$caseData['slug'].'-cover.jpg'),
|
'cover_image_path' => $this->copyFixture('case-'.$caseData['slug'].'.jpg', 'content/portfolio/'.$caseData['slug'].'-cover.jpg'),
|
||||||
'cover_image_alt' => 'Capa do caso '.$caseData['title'],
|
'cover_image_alt' => 'Capa do caso '.$caseData['title'],
|
||||||
'is_featured' => true,
|
'is_featured' => true,
|
||||||
'published_at' => Carbon::parse(self::SEED_TIMESTAMP),
|
'published_at' => Carbon::parse(self::SEED_TIMESTAMP),
|
||||||
@@ -160,7 +188,7 @@ class ContentSeeder extends Seeder
|
|||||||
foreach ([1, 2] as $index) {
|
foreach ([1, 2] as $index) {
|
||||||
PortfolioImage::query()->create([
|
PortfolioImage::query()->create([
|
||||||
'portfolio_case_id' => $case->id,
|
'portfolio_case_id' => $case->id,
|
||||||
'path' => $this->copyFixture('gallery.jpg', 'content/portfolio/'.$caseData['slug'].'-gallery-'.$index.'.jpg'),
|
'path' => $this->copyFixture('gallery-'.$caseData['slug'].'-'.$index.'.jpg', 'content/portfolio/'.$caseData['slug'].'-gallery-'.$index.'.jpg'),
|
||||||
'alt_text' => 'Galeria '.$caseData['title'].' '.$index,
|
'alt_text' => 'Galeria '.$caseData['title'].' '.$index,
|
||||||
'caption' => $index === 1 ? 'Detalhe da decoração' : null,
|
'caption' => $index === 1 ? 'Detalhe da decoração' : null,
|
||||||
'sort_order' => $index,
|
'sort_order' => $index,
|
||||||
@@ -171,8 +199,10 @@ class ContentSeeder extends Seeder
|
|||||||
|
|
||||||
private function copyFixture(string $fixtureName, string $destination): string
|
private function copyFixture(string $fixtureName, string $destination): string
|
||||||
{
|
{
|
||||||
$source = base_path('tests/fixtures/images/'.$fixtureName);
|
$source = base_path('database/fixtures/images/'.$fixtureName);
|
||||||
Storage::disk('public')->put($destination, File::get($source));
|
$disk = PublicImageUploadRules::disk();
|
||||||
|
|
||||||
|
Storage::disk($disk)->put($destination, File::get($source), 'public');
|
||||||
|
|
||||||
return $destination;
|
return $destination;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ class DatabaseSeeder extends Seeder
|
|||||||
'password' => Hash::make('password'),
|
'password' => Hash::make('password'),
|
||||||
'role' => UserRole::Admin,
|
'role' => UserRole::Admin,
|
||||||
'is_active' => true,
|
'is_active' => true,
|
||||||
|
'email_verified_at' => now(),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -33,6 +34,7 @@ class DatabaseSeeder extends Seeder
|
|||||||
'password' => Hash::make('password'),
|
'password' => Hash::make('password'),
|
||||||
'role' => UserRole::Assistant,
|
'role' => UserRole::Assistant,
|
||||||
'is_active' => true,
|
'is_active' => true,
|
||||||
|
'email_verified_at' => now(),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ use App\Models\SiteSetting;
|
|||||||
use App\Models\Testimonial;
|
use App\Models\Testimonial;
|
||||||
use Illuminate\Database\Seeder;
|
use Illuminate\Database\Seeder;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Facades\File;
|
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -44,6 +43,8 @@ class VisualContentSeeder extends Seeder
|
|||||||
'hero_secondary_cta_label' => 'Conheça nosso olhar',
|
'hero_secondary_cta_label' => 'Conheça nosso olhar',
|
||||||
'hero_note' => 'Planejamento cuidadoso, comunicação clara e execução segura — do primeiro encontro ao último detalhe.',
|
'hero_note' => 'Planejamento cuidadoso, comunicação clara e execução segura — do primeiro encontro ao último detalhe.',
|
||||||
'about_summary' => 'Assessoria boutique especializada em experiências memoráveis em São Paulo.',
|
'about_summary' => 'Assessoria boutique especializada em experiências memoráveis em São Paulo.',
|
||||||
|
'about_image_path' => $this->writeSolidJpeg('visual/about/about-image.jpg', 1200, 900, [232, 228, 218]),
|
||||||
|
'about_image_alt' => 'Imagem editorial da página Sobre',
|
||||||
'manifesto_title' => 'Sofisticação que também se traduz em organização.',
|
'manifesto_title' => 'Sofisticação que também se traduz em organização.',
|
||||||
'manifesto_lead' => 'Um evento memorável não nasce apenas de uma boa estética. Ele depende de decisões bem conduzidas, fornecedores alinhados e atenção constante ao que realmente importa.',
|
'manifesto_lead' => 'Um evento memorável não nasce apenas de uma boa estética. Ele depende de decisões bem conduzidas, fornecedores alinhados e atenção constante ao que realmente importa.',
|
||||||
'manifesto_body' => 'A Amare combina sensibilidade e precisão para criar encontros coerentes com cada cliente, marca e ocasião — sem fórmulas prontas, excessos ou ruído.',
|
'manifesto_body' => 'A Amare combina sensibilidade e precisão para criar encontros coerentes com cada cliente, marca e ocasião — sem fórmulas prontas, excessos ou ruído.',
|
||||||
@@ -58,7 +59,7 @@ class VisualContentSeeder extends Seeder
|
|||||||
],
|
],
|
||||||
'default_meta_title' => 'Amare Assessoria de Eventos',
|
'default_meta_title' => 'Amare Assessoria de Eventos',
|
||||||
'default_meta_description' => 'Assessoria premium para casamentos e eventos corporativos em São Paulo.',
|
'default_meta_description' => 'Assessoria premium para casamentos e eventos corporativos em São Paulo.',
|
||||||
'default_og_image_path' => $this->copyFixture('og-default.jpg', 'visual/og/og-default.jpg'),
|
'default_og_image_path' => $this->writeSolidJpeg('visual/og/og-default.jpg', 1600, 1067, [85, 107, 47]),
|
||||||
'default_og_image_alt' => 'Identidade visual da Amare Assessoria de Eventos',
|
'default_og_image_alt' => 'Identidade visual da Amare Assessoria de Eventos',
|
||||||
'analytics_enabled' => false,
|
'analytics_enabled' => false,
|
||||||
'analytics_script' => null,
|
'analytics_script' => null,
|
||||||
@@ -89,7 +90,7 @@ class VisualContentSeeder extends Seeder
|
|||||||
['slug' => $service['slug']],
|
['slug' => $service['slug']],
|
||||||
[
|
[
|
||||||
...$service,
|
...$service,
|
||||||
'cover_image_path' => $this->copyFixture('service-cover.jpg', 'visual/services/'.$service['slug'].'.jpg'),
|
'cover_image_path' => $this->writeSolidJpeg('visual/services/'.$service['slug'].'.jpg', 1600, 1000, [196, 200, 184]),
|
||||||
'cover_image_alt' => 'Capa do serviço '.$service['title'],
|
'cover_image_alt' => 'Capa do serviço '.$service['title'],
|
||||||
'published_at' => Carbon::parse(self::SEED_TIMESTAMP),
|
'published_at' => Carbon::parse(self::SEED_TIMESTAMP),
|
||||||
],
|
],
|
||||||
@@ -133,7 +134,7 @@ class VisualContentSeeder extends Seeder
|
|||||||
['slug' => $caseData['slug']],
|
['slug' => $caseData['slug']],
|
||||||
[
|
[
|
||||||
...$caseData,
|
...$caseData,
|
||||||
'cover_image_path' => $this->copyFixture('portfolio-cover.jpg', 'visual/portfolio/'.$caseData['slug'].'-cover.jpg'),
|
'cover_image_path' => $this->writeSolidJpeg('visual/portfolio/'.$caseData['slug'].'-cover.jpg', 1600, 1200, [240, 238, 233]),
|
||||||
'cover_image_alt' => 'Capa do caso '.$caseData['title'],
|
'cover_image_alt' => 'Capa do caso '.$caseData['title'],
|
||||||
'is_featured' => true,
|
'is_featured' => true,
|
||||||
'published_at' => Carbon::parse(self::SEED_TIMESTAMP),
|
'published_at' => Carbon::parse(self::SEED_TIMESTAMP),
|
||||||
@@ -144,7 +145,7 @@ class VisualContentSeeder extends Seeder
|
|||||||
|
|
||||||
PortfolioImage::query()->create([
|
PortfolioImage::query()->create([
|
||||||
'portfolio_case_id' => $case->id,
|
'portfolio_case_id' => $case->id,
|
||||||
'path' => $this->copyFixture('gallery.jpg', 'visual/portfolio/'.$caseData['slug'].'-gallery-1.jpg'),
|
'path' => $this->writeSolidJpeg('visual/portfolio/'.$caseData['slug'].'-gallery-1.jpg', 1600, 1200, [228, 226, 221]),
|
||||||
'alt_text' => 'Galeria '.$caseData['title'].' 1',
|
'alt_text' => 'Galeria '.$caseData['title'].' 1',
|
||||||
'caption' => 'Detalhe da decoração',
|
'caption' => 'Detalhe da decoração',
|
||||||
'sort_order' => 1,
|
'sort_order' => 1,
|
||||||
@@ -186,10 +187,21 @@ class VisualContentSeeder extends Seeder
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function copyFixture(string $fixtureName, string $destination): string
|
/**
|
||||||
|
* @param array{0: int, 1: int, 2: int} $rgb
|
||||||
|
*/
|
||||||
|
private function writeSolidJpeg(string $destination, int $width, int $height, array $rgb): string
|
||||||
{
|
{
|
||||||
$source = base_path('tests/fixtures/images/'.$fixtureName);
|
$image = imagecreatetruecolor($width, $height);
|
||||||
Storage::disk('public')->put($destination, File::get($source));
|
$color = imagecolorallocate($image, $rgb[0], $rgb[1], $rgb[2]);
|
||||||
|
imagefilledrectangle($image, 0, 0, $width, $height, $color);
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
imagejpeg($image, null, 90);
|
||||||
|
$binary = (string) ob_get_clean();
|
||||||
|
imagedestroy($image);
|
||||||
|
|
||||||
|
Storage::disk('public')->put($destination, $binary);
|
||||||
|
|
||||||
return $destination;
|
return $destination;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,12 @@
|
|||||||
|
> **Status deste arquivo:** material bruto histórico, não consumido por
|
||||||
|
> nenhum código do site. Os cinco depoimentos abaixo foram transcritos
|
||||||
|
> manualmente para dentro de `database/seeders/TestimonialsSeeder.php` e,
|
||||||
|
> em produção, o conteúdo real vive no banco de dados, editável apenas
|
||||||
|
> pela tela **Depoimentos** do painel administrativo (`/admin`). Editar
|
||||||
|
> este arquivo não tem nenhum efeito no site publicado. Mantido apenas como
|
||||||
|
> referência histórica de onde o conteúdo original veio. Ver
|
||||||
|
> `docs/operations/atualizacao-de-conteudo.md` para o fluxo de edição real.
|
||||||
|
|
||||||
Mi, quero agradecer você e a sua equipe por todo empenho, atenção, vocês são abençoadas.
|
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,7 +14,13 @@ services:
|
|||||||
restart: "no"
|
restart: "no"
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
command: ["php", "artisan", "migrate", "--force", "--no-interaction"]
|
# Exec-array form on a single line, deliberately. A folded block scalar
|
||||||
|
# (`command: >`) keeps the newline before any line indented deeper than the
|
||||||
|
# first, so `sh -c` receives a multi-line string and dies with
|
||||||
|
# `sh: 2: Syntax error: "&&" unexpected` before running anything. That broke
|
||||||
|
# every staging deploy from 58f24a6 onward, and had already broken them once
|
||||||
|
# before 5949fad. Keep this on one line; do not reformat it for width.
|
||||||
|
command: ["sh", "-c", "php artisan migrate --force --no-interaction && php artisan db:seed --class=ContentSeeder --force --no-interaction && php artisan media:generate-variants"]
|
||||||
networks:
|
networks:
|
||||||
- dokploy-network
|
- dokploy-network
|
||||||
|
|
||||||
|
|||||||
@@ -18,5 +18,34 @@ services:
|
|||||||
retries: 10
|
retries: 10
|
||||||
start_period: 10s
|
start_period: 10s
|
||||||
|
|
||||||
|
# Local parity with the FrankenPHP image used on staging/production.
|
||||||
|
# Manual smoke test (not run in CI — the `container` job already builds and
|
||||||
|
# health-checks the same Dockerfile-based image):
|
||||||
|
# docker compose up -d
|
||||||
|
# curl localhost:8000/up
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: amare-app
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
# Override host-side .env defaults: DB_HOST=127.0.0.1 only resolves for
|
||||||
|
# processes running on the host, not for this container reaching the
|
||||||
|
# `postgres` service by its Compose service name. DB_PORT is also
|
||||||
|
# forced back to Postgres's internal container port (5432) — .env may
|
||||||
|
# have DB_PORT remapped for host-side tooling (e.g. to avoid a local
|
||||||
|
# port clash), but that remapping only applies to the published host
|
||||||
|
# port, never to container-to-container traffic.
|
||||||
|
DB_HOST: postgres
|
||||||
|
DB_PORT: "5432"
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
amare_postgres_data:
|
amare_postgres_data:
|
||||||
|
|||||||
@@ -5,5 +5,20 @@
|
|||||||
:8000 {
|
:8000 {
|
||||||
root * /app/public
|
root * /app/public
|
||||||
encode gzip zstd
|
encode gzip zstd
|
||||||
|
|
||||||
|
# Vite emits content-hashed filenames, so a build asset URL never changes
|
||||||
|
# meaning. Same for uploaded media: PublicImageUploadRules stores every
|
||||||
|
# upload under a fresh UUID (and ResponsiveImage derives its variants from
|
||||||
|
# that name), so replacing an image produces a new URL rather than new bytes
|
||||||
|
# at the old one. Both are safe to pin for a year.
|
||||||
|
@immutable path /build/* /storage/*
|
||||||
|
header @immutable Cache-Control "public, max-age=31536000, immutable"
|
||||||
|
|
||||||
|
# Brand assets ship inside the image under stable filenames, so a rebrand
|
||||||
|
# reuses the same URL. One day plus Caddy's ETag revalidation keeps repeat
|
||||||
|
# views cheap without pinning an outdated logo in browsers.
|
||||||
|
@brand path /brand/*
|
||||||
|
header @brand Cache-Control "public, max-age=86400"
|
||||||
|
|
||||||
php_server
|
php_server
|
||||||
}
|
}
|
||||||
|
|||||||
51
docs/agents/domain.md
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
# Domain Docs
|
||||||
|
|
||||||
|
How the engineering skills should consume this repo's domain documentation when exploring the codebase.
|
||||||
|
|
||||||
|
## Before exploring, read these
|
||||||
|
|
||||||
|
- **`CONTEXT.md`** at the repo root, or
|
||||||
|
- **`CONTEXT-MAP.md`** at the repo root if it exists — it points at one `CONTEXT.md` per context. Read each one relevant to the topic.
|
||||||
|
- **`docs/adr/`** — read ADRs that touch the area you're about to work in. In multi-context repos, also check `src/<context>/docs/adr/` for context-scoped decisions.
|
||||||
|
|
||||||
|
If any of these files don't exist, **proceed silently**. Don't flag their absence; don't suggest creating them upfront. The `/domain-modeling` skill (reached via `/grill-with-docs` and `/improve-codebase-architecture`) creates them lazily when terms or decisions actually get resolved.
|
||||||
|
|
||||||
|
## File structure
|
||||||
|
|
||||||
|
Single-context repo (most repos):
|
||||||
|
|
||||||
|
```
|
||||||
|
/
|
||||||
|
├── CONTEXT.md
|
||||||
|
├── docs/adr/
|
||||||
|
│ ├── 0001-event-sourced-orders.md
|
||||||
|
│ └── 0002-postgres-for-write-model.md
|
||||||
|
└── src/
|
||||||
|
```
|
||||||
|
|
||||||
|
Multi-context repo (presence of `CONTEXT-MAP.md` at the root):
|
||||||
|
|
||||||
|
```
|
||||||
|
/
|
||||||
|
├── CONTEXT-MAP.md
|
||||||
|
├── docs/adr/ ← system-wide decisions
|
||||||
|
└── src/
|
||||||
|
├── ordering/
|
||||||
|
│ ├── CONTEXT.md
|
||||||
|
│ └── docs/adr/ ← context-specific decisions
|
||||||
|
└── billing/
|
||||||
|
├── CONTEXT.md
|
||||||
|
└── docs/adr/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Use the glossary's vocabulary
|
||||||
|
|
||||||
|
When your output names a domain concept (in an issue title, a refactor proposal, a hypothesis, a test name), use the term as defined in `CONTEXT.md`. Don't drift to synonyms the glossary explicitly avoids.
|
||||||
|
|
||||||
|
If the concept you need isn't in the glossary yet, that's a signal — either you're inventing language the project doesn't use (reconsider) or there's a real gap (note it for `/domain-modeling`).
|
||||||
|
|
||||||
|
## Flag ADR conflicts
|
||||||
|
|
||||||
|
If your output contradicts an existing ADR, surface it explicitly rather than silently overriding:
|
||||||
|
|
||||||
|
> _Contradicts ADR-0007 (event-sourced orders) — but worth reopening because…_
|
||||||
26
docs/agents/issue-tracker.md
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# Issue tracker: Linear
|
||||||
|
|
||||||
|
Issues and specs for this repo live in Linear. Use the Linear MCP tools for all operations.
|
||||||
|
|
||||||
|
- **Workspace**: maneco-workspace (https://linear.app/maneco-workspace)
|
||||||
|
- **Team**: Maneco-workspace
|
||||||
|
- **Issue statuses**: Backlog, Todo, In Progress, Done, Canceled, Duplicate
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
- **Create an issue**: `save_issue` with `title`, `team` (`Maneco-workspace`), and markdown `description`. Assign via `assignee` ("me" for the current user).
|
||||||
|
- **Read an issue**: `get_issue` by identifier (e.g. `LIN-123`).
|
||||||
|
- **List issues**: `list_issues` filtered by `team`, `assignee`, `state`, or `project`.
|
||||||
|
- **Comment on an issue**: `save_comment` with `issueId` and markdown `body`.
|
||||||
|
- **Apply / remove labels**: `save_issue` with `labels` (replaces the full set).
|
||||||
|
- **Move workflow state**: `save_issue` with `state` (e.g. `Todo`, `In Progress`, `Done`, `Canceled`).
|
||||||
|
- **Link work**: `save_issue` with `project`, `cycle`, `parentId`, `blocks` / `blockedBy` (relations are append-only).
|
||||||
|
- **Cross-link to a PR**: attach the PR URL via `save_issue` `links`.
|
||||||
|
|
||||||
|
## When a skill says "publish to the issue tracker"
|
||||||
|
|
||||||
|
Create a Linear issue with `save_issue` on team `Maneco-workspace`.
|
||||||
|
|
||||||
|
## When a skill says "fetch the relevant ticket"
|
||||||
|
|
||||||
|
Run `get_issue` on the identifier and read the description, state, labels, and assignee.
|
||||||
15
docs/agents/triage-labels.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# Triage Labels
|
||||||
|
|
||||||
|
The skills speak in terms of five canonical triage roles. This file maps those roles to the actual label strings used in this repo's issue tracker.
|
||||||
|
|
||||||
|
| Label in mattpocock/skills | Label in our tracker | Meaning |
|
||||||
|
| -------------------------- | -------------------- | ---------------------------------------- |
|
||||||
|
| `needs-triage` | `needs-triage` | Maintainer needs to evaluate this issue |
|
||||||
|
| `needs-info` | `needs-info` | Waiting on reporter for more information |
|
||||||
|
| `ready-for-agent` | `ready-for-agent` | Fully specified, ready for an AFK agent |
|
||||||
|
| `ready-for-human` | `ready-for-human` | Requires human implementation |
|
||||||
|
| `wontfix` | `wontfix` | Will not be actioned |
|
||||||
|
|
||||||
|
When a skill mentions a role (e.g. "apply the AFK-ready triage label"), use the corresponding label string from this table.
|
||||||
|
|
||||||
|
Edit the right-hand column to match whatever vocabulary you actually use.
|
||||||
@@ -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/Fortaleza
|
APP_TIMEZONE=America/Sao_Paulo
|
||||||
|
|
||||||
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,7 +219,11 @@ echo (\$valid ? 'ok' : 'invalid').PHP_EOL;
|
|||||||
|
|
||||||
Expected output: `ok`.
|
Expected output: `ok`.
|
||||||
|
|
||||||
Do not run `DatabaseSeeder` or `ContentSeeder` in staging or production: they include local credentials and/or broad demo-content effects. Deployment workflows intentionally remain migrate-only; loading these testimonials is a deliberate manual operation in each environment.
|
The `migrate` service in `docker-compose.deploy.yml` runs `php artisan db:seed --class=ContentSeeder --force --no-interaction` on every deploy, in both stacks. `ContentSeeder::run()` guards itself with an **allow-list** — `App::environment(['local', 'staging', 'testing'])` — and returns immediately (exit code 0, no side effects) unless `APP_ENV` is exactly one of those three values. In **staging** (`APP_ENV=staging`) the guard matches, so `ContentSeeder` still seeds its demo content on every deploy — this is required for visual review and is expected behavior, not a bug. In **production** (`APP_ENV=production`), and for any blank, mistyped, or unexpectedly cased `APP_ENV` value in any stack, the guard does not match, so the step is a deliberate no-op: it never overwrites SiteSetting/Service/PortfolioCase records edited in Filament, never re-uploads fixture images to the production storage disk, and never auto-publishes the fictional portfolio cases. Note the tradeoff this implies: if staging's `APP_ENV` is ever typo'd away from exactly `staging`, demo content silently stops being (re)seeded there too — check the Dokploy environment value first if a staging deploy stops refreshing demo content.
|
||||||
|
|
||||||
|
Do not run bare `DatabaseSeeder` in staging or production: it also creates the `admin@amare.local` / `password` local-dev credentials.
|
||||||
|
|
||||||
|
Publishing the five real testimonials is a separate, deliberate, manually triggered step **only in production** — `ContentSeeder` calls `TestimonialsSeeder` internally, but that call is skipped in production by the same allow-list guard, so the command above is the only path that publishes testimonials there. In **staging**, this is not manual: because the allow-list guard matches `staging`, `ContentSeeder` calls `TestimonialsSeeder` automatically on every deploy, auto-publishing/re-publishing the five canonical testimonials each time (consistent with staging's role as a demo/preview environment).
|
||||||
|
|
||||||
## Backup and restore
|
## Backup and restore
|
||||||
|
|
||||||
|
|||||||
BIN
docs/evidence/public-site-motion/about-desktop.png
Normal file
|
After Width: | Height: | Size: 170 KiB |
BIN
docs/evidence/public-site-motion/contact-mobile.png
Normal file
|
After Width: | Height: | Size: 155 KiB |
417
docs/operations/atualizacao-de-conteudo.md
Normal file
@@ -0,0 +1,417 @@
|
|||||||
|
# Atualização de conteúdo do site
|
||||||
|
|
||||||
|
Guia para quem cuida do conteúdo do site (textos, fotos, casos de portfólio,
|
||||||
|
depoimentos, serviços e dados de contato) sem precisar mexer em código.
|
||||||
|
|
||||||
|
O site é editado por um painel interno chamado **"admin"** — é uma tela web,
|
||||||
|
parecida com um formulário, onde cada bloco do site (textos da home, casos de
|
||||||
|
portfólio, depoimentos, etc.) vira uma "página" que você edita e salva.
|
||||||
|
|
||||||
|
> Antes de qualquer coisa, leia a seção **"A regra mais importante: o que
|
||||||
|
> torna algo visível no site"** — ela explica um comportamento que engana
|
||||||
|
> quase todo mundo na primeira vez.
|
||||||
|
|
||||||
|
## Sumário
|
||||||
|
|
||||||
|
- [Como entrar no painel](#como-entrar-no-painel)
|
||||||
|
- [A regra mais importante: o que torna algo visível no site](#a-regra-mais-importante-o-que-torna-algo-visível-no-site)
|
||||||
|
- [Editar os textos da home e das páginas institucionais](#editar-os-textos-da-home-e-das-páginas-institucionais)
|
||||||
|
- [Casos de portfólio: criar, editar, publicar e despublicar](#casos-de-portfólio-criar-editar-publicar-e-despublicar)
|
||||||
|
- [Depoimentos: adicionar e publicar](#depoimentos-adicionar-e-publicar)
|
||||||
|
- [Serviços: editar](#serviços-editar)
|
||||||
|
- [Contato: telefone, e-mail e redes sociais](#contato-telefone-e-mail-e-redes-sociais)
|
||||||
|
- [Imagens: formatos aceitos, tamanho e texto alternativo](#imagens-formatos-aceitos-tamanho-e-texto-alternativo)
|
||||||
|
- [Usuários: papéis, ativar e desativar contas](#usuários-papéis-ativar-e-desativar-contas)
|
||||||
|
- [O que NÃO mexer — e com quem falar](#o-que-não-mexer--e-com-quem-falar)
|
||||||
|
- [Seções da home que somem por completo se ficarem vazias](#seções-da-home-que-somem-por-completo-se-ficarem-vazias)
|
||||||
|
- [Sobre o arquivo depoimentos.md na raiz do repositório](#sobre-o-arquivo-depoimentosmd-na-raiz-do-repositório)
|
||||||
|
|
||||||
|
## Como entrar no painel
|
||||||
|
|
||||||
|
1. Acesse `/admin` no domínio do site (ex.: `https://seusite.com.br/admin`).
|
||||||
|
2. Se você não estiver logada, o painel mostra uma tela de login pedindo
|
||||||
|
e-mail e senha.
|
||||||
|
3. Faça login com o e-mail e senha da sua conta.
|
||||||
|
|
||||||
|
**Se você não conseguir entrar:**
|
||||||
|
|
||||||
|
- **Esqueceu a senha:** hoje o painel **não tem** um link de "esqueci minha
|
||||||
|
senha" self-service. Só uma pessoa com perfil **Administrador** consegue
|
||||||
|
trocar a senha de outra conta (em Usuários → editar a conta → campo
|
||||||
|
"Senha"). Se você é a única Administradora e está travada fora do painel,
|
||||||
|
vai precisar pedir para quem desenvolve o site trocar a senha diretamente
|
||||||
|
no banco de dados.
|
||||||
|
- **Mensagem de acesso negado / conta inativa:** sua conta pode estar com o
|
||||||
|
campo "Ativo" desligado. Só uma Administradora pode reativar isso em
|
||||||
|
Usuários (veja [O que NÃO mexer](#o-que-não-mexer--e-com-quem-falar) sobre
|
||||||
|
quem pode mexer em contas de usuário).
|
||||||
|
- **Perfil "Assistente" sem acesso a nada:** hoje, contas com o papel
|
||||||
|
"Assistente" conseguem fazer login, mas não conseguem ver nem editar
|
||||||
|
**nenhum** conteúdo — nem Configurações, nem Portfólio, nem Depoimentos,
|
||||||
|
nada. Isso é uma limitação atual do sistema, não um erro seu. Se você
|
||||||
|
precisa dar acesso de edição a alguém que não seja Administrador, avise
|
||||||
|
quem desenvolve o site — hoje não existe um meio-termo (“pode editar mas
|
||||||
|
não pode tudo”), só "Administrador com acesso total" ou "Assistente sem
|
||||||
|
acesso a nada".
|
||||||
|
|
||||||
|
## A regra mais importante: o que torna algo visível no site
|
||||||
|
|
||||||
|
Duas coisas controlam se algo aparece no site. Entender essas duas evita
|
||||||
|
99% das dúvidas de "publiquei e não apareceu" ou "não publiquei e apareceu".
|
||||||
|
|
||||||
|
### 1. O campo "Publicado em" não é um agendamento
|
||||||
|
|
||||||
|
Em Serviços, Portfólio e Depoimentos existe um campo chamado **"Publicado
|
||||||
|
em"**. A intuição normal seria pensar "se eu colocar uma data futura, ele só
|
||||||
|
aparece nessa data" — **isso está errado**. Assim que você preenche esse
|
||||||
|
campo com **qualquer** data/hora (passada, presente ou futura) e salva, o
|
||||||
|
item fica visível no site **imediatamente**. Não existe agendamento.
|
||||||
|
|
||||||
|
- **Para publicar:** preencha "Publicado em" com uma data qualquer e salve.
|
||||||
|
- **Para despublicar:** apague o conteúdo desse campo (deixe em branco) e
|
||||||
|
salve. Colocar uma data no futuro **não** esconde o item — ele continua
|
||||||
|
visível.
|
||||||
|
|
||||||
|
### 2. Home precisa de DOIS interruptores; páginas de listagem só de um
|
||||||
|
|
||||||
|
- Nas páginas de listagem completas — `/servicos` (todos os serviços) e
|
||||||
|
`/portfolio` (todos os casos) — basta o item estar **publicado** (campo
|
||||||
|
"Publicado em" preenchido) para aparecer.
|
||||||
|
- Já nos blocos de **Serviços** e **Portfólio dentro da home** (a página
|
||||||
|
inicial), o item só aparece se **as duas coisas** forem verdadeiras ao
|
||||||
|
mesmo tempo:
|
||||||
|
1. Estiver **publicado** ("Publicado em" preenchido), **e**
|
||||||
|
2. Tiver o interruptor **"Destaque"** ligado.
|
||||||
|
|
||||||
|
Publicar sozinho não é suficiente para aparecer na home — só coloca o item
|
||||||
|
na listagem completa. Você precisa também ligar "Destaque" se quiser que
|
||||||
|
apareça na home.
|
||||||
|
|
||||||
|
- **Depoimentos são a exceção**: o bloco de Depoimentos na home mostra
|
||||||
|
**todo** depoimento publicado, independente do interruptor "Destaque". Em
|
||||||
|
Depoimentos, "Destaque" só serve para filtrar a tabela dentro do painel —
|
||||||
|
não muda nada no que aparece para quem visita o site.
|
||||||
|
|
||||||
|
## Editar os textos da home e das páginas institucionais
|
||||||
|
|
||||||
|
No menu lateral, vá em **Configurações** (dentro do grupo "Conteúdo do
|
||||||
|
site"). É uma página só, dividida em seções. Edite o que quiser e clique em
|
||||||
|
salvar no final.
|
||||||
|
|
||||||
|
**Diferente de Portfólio, Depoimentos e Serviços, aqui não existe "Publicado
|
||||||
|
em".** Tudo o que você salva em Configurações entra no ar imediatamente —
|
||||||
|
não há passo extra de publicação nem como "rascunhar" uma mudança antes de
|
||||||
|
ela aparecer no site.
|
||||||
|
|
||||||
|
- **Marca e hero** — nome da marca, logo (+ texto alternativo, obrigatório
|
||||||
|
se houver logo), textos do topo da home ("hero"): eyebrow (frase pequena
|
||||||
|
acima do título), título, subtítulo, texto dos dois botões de chamada
|
||||||
|
(CTA), nota de rodapé do hero, e o **resumo institucional**
|
||||||
|
(`about_summary`). **Atenção:** nome da marca, título do hero, subtítulo
|
||||||
|
e texto do botão principal são **obrigatórios** — o formulário não deixa
|
||||||
|
salvar em branco. Já o eyebrow, o botão secundário e a nota do hero podem
|
||||||
|
ficar em branco (o site simplesmente não mostra essa parte se estiver
|
||||||
|
vazia).
|
||||||
|
- O **resumo institucional** é usado em **dois lugares** e se comporta
|
||||||
|
diferente em cada um: na seção "A Amare" da home, se ficar em branco o
|
||||||
|
site mostra uma frase padrão fixa no lugar; já na página **Sobre**
|
||||||
|
(`/sobre`), esse mesmo texto aparece como o parágrafo principal e, se
|
||||||
|
ficar em branco, **fica realmente vazio** ali (sem frase padrão). O
|
||||||
|
formulário **deixa salvar em branco sem avisar** — ou seja, não dá erro
|
||||||
|
nenhum, o parágrafo só fica vazio silenciosamente na página Sobre até
|
||||||
|
alguém notar. Por isso, nunca deixe o resumo institucional em branco;
|
||||||
|
depois de editar, confira a página `/sobre` para garantir que o texto
|
||||||
|
apareceu.
|
||||||
|
- A página **Sobre** também tem uma frase fixa no código
|
||||||
|
("A [nome da marca] atua em [cidade] com foco em planejamento
|
||||||
|
completo...") que **não é editável pelo painel** — ela só muda se você
|
||||||
|
editar Nome da marca ou Cidade (que entram nessa frase), o resto do
|
||||||
|
texto é fixo. Para mudar essa frase, é preciso pedir para quem
|
||||||
|
desenvolve o site.
|
||||||
|
- **Manifesto editorial** — título, texto de abertura ("lead") e corpo do
|
||||||
|
bloco "Manifesto" da home. Diferente de outros textos, se você deixar
|
||||||
|
esses campos em branco o site **não** esconde a seção — ele mostra um
|
||||||
|
texto padrão fixo no lugar. Ou seja: em branco aqui não é "some", é
|
||||||
|
"volta ao texto genérico".
|
||||||
|
- **Método** — introdução do bloco "Método" e até 4 passos (título +
|
||||||
|
descrição cada), reordenáveis arrastando. Se você apagar todos os passos,
|
||||||
|
o site mostra passos padrão pré-definidos em vez de ficar vazio.
|
||||||
|
- **Princípios** — lista de princípios (tags) mostrada na seção
|
||||||
|
institucional. Se ficar vazia, o site mostra uma lista padrão.
|
||||||
|
- **Página Sobre** — imagem da página "Sobre" (+ texto alternativo,
|
||||||
|
obrigatório se houver imagem).
|
||||||
|
- **SEO padrão** — título e descrição que aparecem quando o site é
|
||||||
|
compartilhado ou aparece no Google (título e descrição são obrigatórios),
|
||||||
|
e a imagem padrão de compartilhamento (que também é reaproveitada como a
|
||||||
|
imagem à direita no hero da home — ao trocar essa imagem, ela muda em
|
||||||
|
dois lugares ao mesmo tempo).
|
||||||
|
- **Analytics** — **não mexa aqui**, veja [O que NÃO mexer](#o-que-não-mexer--e-com-quem-falar).
|
||||||
|
|
||||||
|
## Casos de portfólio: criar, editar, publicar e despublicar
|
||||||
|
|
||||||
|
No menu, vá em **Portfólio**.
|
||||||
|
|
||||||
|
### Criar ou editar um caso
|
||||||
|
|
||||||
|
Clique em "Novo" (ou abra um caso existente) e preencha:
|
||||||
|
|
||||||
|
- **Título** e **Slug** — o slug é o pedacinho do endereço na internet
|
||||||
|
(ex.: `casamento-joana-pedro`). Se você deixar o slug em branco ao criar,
|
||||||
|
ele é gerado automaticamente a partir do título. **Depois de publicado,
|
||||||
|
evite mudar o slug** — isso quebra qualquer link já compartilhado para
|
||||||
|
aquele caso (redes sociais, WhatsApp, Google). Se precisar mesmo mudar,
|
||||||
|
veja [O que NÃO mexer](#o-que-não-mexer--e-com-quem-falar).
|
||||||
|
- **Resumo** — texto curto usado nas listagens.
|
||||||
|
- **Tipo de evento**, **Cidade**, **Local**, **Data do evento**.
|
||||||
|
- **Desafio**, **Solução**, **Resultado** — o texto do "case" propriamente
|
||||||
|
dito (Desafio e Solução são obrigatórios; Resultado é opcional).
|
||||||
|
- **Imagem de capa** (+ texto alternativo, obrigatório se houver imagem).
|
||||||
|
**Atenção:** diferente de quase toda outra imagem do painel, a capa de um
|
||||||
|
caso de portfólio é **sempre obrigatória** por trás dos panos — mas o
|
||||||
|
formulário **não bloqueia** o salvamento se você esquecer de anexá-la.
|
||||||
|
Se você tentar salvar um caso novo sem imagem de capa, o painel não avisa
|
||||||
|
"campo obrigatório": o salvamento simplesmente falha com um erro técnico
|
||||||
|
feio (erro de banco de dados), não a mensagem amigável que você vê para
|
||||||
|
título, resumo etc. Sempre anexe a imagem de capa antes de salvar um caso
|
||||||
|
novo.
|
||||||
|
- **Ordem** — número usado para ordenar os casos nas listagens.
|
||||||
|
- **Destaque** — liga/desliga a aparição desse caso na home (ver regra das
|
||||||
|
[duas chaves](#a-regra-mais-importante-o-que-torna-algo-visível-no-site)).
|
||||||
|
- **Publicado em** — data de publicação (ver
|
||||||
|
[regra de publicação](#a-regra-mais-importante-o-que-torna-algo-visível-no-site)).
|
||||||
|
- **Meta title** / **Meta description** — título e descrição específicos
|
||||||
|
desse caso para compartilhamento e Google (opcionais; se em branco, usa
|
||||||
|
o padrão configurado em Configurações).
|
||||||
|
|
||||||
|
### Galeria de fotos do caso
|
||||||
|
|
||||||
|
Depois de salvar o caso, abra-o novamente e procure a aba **"Galeria"**.
|
||||||
|
Ali você adiciona quantas fotos quiser, cada uma com:
|
||||||
|
|
||||||
|
- **Imagem** (obrigatória para criar o item da galeria). **Atenção:** assim
|
||||||
|
como a capa do caso, o formulário **não bloqueia** o salvamento se você
|
||||||
|
esquecer a imagem — ele deixa parecer que deu certo até você clicar em
|
||||||
|
salvar, e aí falha com um erro técnico de banco de dados em vez de avisar
|
||||||
|
"campo obrigatório". Sempre anexe a imagem antes de salvar um item da
|
||||||
|
galeria.
|
||||||
|
- **Texto alternativo** — **obrigatório assim que você anexa uma imagem**.
|
||||||
|
O sistema não deixa salvar uma foto sem essa descrição.
|
||||||
|
- **Legenda** (opcional) — texto que aparece junto da foto.
|
||||||
|
- **Ordem** — dá para arrastar as fotos na tabela para reordenar.
|
||||||
|
|
||||||
|
### Publicar e despublicar um caso
|
||||||
|
|
||||||
|
- **Publicar:** preencha "Publicado em" com qualquer data e salve. Aparece
|
||||||
|
imediatamente em `/portfolio` e na página própria do caso. Para também
|
||||||
|
aparecer na home, ligue "Destaque".
|
||||||
|
- **Despublicar:** apague o conteúdo de "Publicado em" e salve. Colocar
|
||||||
|
uma data futura **não** esconde o caso.
|
||||||
|
|
||||||
|
**"Excluir" não é a mesma coisa que despublicar.** Cada linha da tabela de
|
||||||
|
Portfólio (e também cada foto dentro da aba "Galeria") tem um botão
|
||||||
|
**"Excluir"**, além do botão de editar. Diferente de despublicar, Excluir
|
||||||
|
**apaga o caso para sempre**: não existe lixeira, não existe desfazer, e ao
|
||||||
|
excluir um caso todas as fotos da galeria dele são apagadas junto
|
||||||
|
automaticamente. Selecionar várias linhas na tabela também libera uma ação
|
||||||
|
de exclusão em massa, que apaga todas de uma vez com uma única confirmação.
|
||||||
|
Para esconder um caso do site, **sempre** use "apagar o Publicado em" — só
|
||||||
|
use Excluir quando tiver certeza de que quer destruir o registro
|
||||||
|
definitivamente.
|
||||||
|
|
||||||
|
## Depoimentos: adicionar e publicar
|
||||||
|
|
||||||
|
No menu, vá em **Depoimentos**.
|
||||||
|
|
||||||
|
- **Depoimento** — o texto do casal. Se você separar o texto em parágrafos
|
||||||
|
com uma linha em branco entre eles, o site respeita essa quebra e mostra
|
||||||
|
cada parágrafo separadamente.
|
||||||
|
- **Nome do autor** — ex.: "Jeniffer e Maick".
|
||||||
|
- **Contexto** — texto livre mostrado como "— contexto", ex.:
|
||||||
|
`Casamento · 06/12/2025`.
|
||||||
|
- **Foto** (+ texto alternativo, obrigatório se houver foto) — atenção: a
|
||||||
|
foto **existe no formulário mas hoje não aparece** na home; o campo é
|
||||||
|
preenchido para o futuro, mas visualmente ainda não é exibido.
|
||||||
|
- **Ordem** — ordena os depoimentos no bloco da home.
|
||||||
|
- **Destaque** — **não afeta o site público**. Serve só para filtrar a
|
||||||
|
tabela de depoimentos dentro do painel.
|
||||||
|
- **Publicado em** — este é o único interruptor que importa para
|
||||||
|
depoimentos aparecerem na home. Preencha e salve para publicar; apague e
|
||||||
|
salve para despublicar. Não existe listagem própria de depoimentos fora
|
||||||
|
da home.
|
||||||
|
|
||||||
|
**Atenção ao botão "Excluir"** em cada linha da tabela (e à exclusão em
|
||||||
|
massa ao selecionar várias linhas): ele é diferente de despublicar e apaga
|
||||||
|
o depoimento para sempre, sem lixeira e sem desfazer. Para tirar um
|
||||||
|
depoimento do ar, apague o "Publicado em" — não use Excluir a menos que
|
||||||
|
queira apagar o registro definitivamente.
|
||||||
|
|
||||||
|
## Serviços: editar
|
||||||
|
|
||||||
|
No menu, vá em **Serviços**.
|
||||||
|
|
||||||
|
- **Título** e **Slug** (mesmo comportamento de auto-geração e mesmo
|
||||||
|
cuidado ao mudar depois de publicado que o portfólio).
|
||||||
|
- **Resumo** e **Descrição**.
|
||||||
|
- **Imagem de capa** (+ texto alternativo, obrigatório se houver imagem).
|
||||||
|
- **Ordem**, **Destaque** e **Publicado em** funcionam exatamente como em
|
||||||
|
portfólio: publicado aparece em `/servicos`; publicado **e** destaque
|
||||||
|
aparece também na home.
|
||||||
|
|
||||||
|
**Atenção ao botão "Excluir"** em cada linha da tabela (e à exclusão em
|
||||||
|
massa ao selecionar várias linhas): ele é diferente de despublicar e apaga
|
||||||
|
o serviço para sempre, sem lixeira e sem desfazer. Para tirar um serviço do
|
||||||
|
ar, apague o "Publicado em" — não use Excluir a menos que queira apagar o
|
||||||
|
registro definitivamente.
|
||||||
|
|
||||||
|
## Contato: telefone, e-mail e redes sociais
|
||||||
|
|
||||||
|
Esses campos ficam em **Configurações → seção "Contato"**.
|
||||||
|
|
||||||
|
- **E-mail** e **Telefone** são obrigatórios — o formulário não deixa
|
||||||
|
salvá-los em branco. Eles aparecem no rodapé do site e na página
|
||||||
|
"Contato" como links clicáveis (e-mail abre o programa de e-mail;
|
||||||
|
telefone abre o discador do celular). **Importante:** esse mesmo e-mail
|
||||||
|
é também o endereço para onde o site envia toda mensagem enviada pelo
|
||||||
|
formulário de contato em `/contato` (o "briefing" que um visitante
|
||||||
|
preenche e envia). Ou seja, esse campo não é só um link de exibição —
|
||||||
|
ele precisa ser uma caixa de entrada de verdade, monitorada, porque é
|
||||||
|
para lá que vão os pedidos de orçamento e contato de clientes em
|
||||||
|
potencial. Trocar esse e-mail por um endereço que ninguém acompanha faz
|
||||||
|
o site parar de avisar sobre novos contatos, sem nenhum erro aparecer em
|
||||||
|
lugar nenhum do painel.
|
||||||
|
- **Importante sobre WhatsApp:** hoje existe **um único campo de
|
||||||
|
telefone**, e ele vira apenas um link `tel:` (ligação), **não** um botão
|
||||||
|
de WhatsApp. Se o número cadastrado for um número de WhatsApp, quem
|
||||||
|
clicar vai abrir o discador, não o WhatsApp. Se você precisa de um botão
|
||||||
|
específico de WhatsApp no site, isso é um pedido para quem desenvolve o
|
||||||
|
site — hoje o campo não faz isso sozinho.
|
||||||
|
- **Cidade** — opcional, aparece na página de Contato.
|
||||||
|
- **Redes sociais** — lista de "Rede" + "URL" (ex.: `instagram` →
|
||||||
|
`https://instagram.com/suaempresa`). Adicione quantas quiser pelo botão
|
||||||
|
"Adicionar rede"; para remover uma, apague a linha inteira.
|
||||||
|
|
||||||
|
## Imagens: formatos aceitos, tamanho e texto alternativo
|
||||||
|
|
||||||
|
Vale para **toda** imagem enviada em qualquer tela do painel (logo, capas,
|
||||||
|
galeria de portfólio, fotos de depoimento, imagem da página Sobre, imagem
|
||||||
|
de SEO):
|
||||||
|
|
||||||
|
- **Formatos aceitos:** JPG, JPEG, PNG ou WEBP. Qualquer outro formato
|
||||||
|
(ex.: HEIC direto do iPhone, PDF, GIF) é recusado com uma mensagem de
|
||||||
|
erro — converta a imagem antes de enviar. **HEIC é o caso mais comum**,
|
||||||
|
porque é o formato padrão das fotos tiradas no iPhone. Duas formas
|
||||||
|
simples de resolver:
|
||||||
|
- **Fotos novas:** no iPhone, vá em Ajustes → Câmera → Formatos e
|
||||||
|
escolha "Mais Compatível" — a partir daí, novas fotos já são salvas em
|
||||||
|
JPEG em vez de HEIC.
|
||||||
|
- **Fotos que já estão em HEIC:** envie a foto para você mesma por
|
||||||
|
WhatsApp (ex.: em "Mensagens salvas" ou num grupo/conversa qualquer) e
|
||||||
|
baixe a versão recebida — o WhatsApp converte a imagem para JPEG
|
||||||
|
automaticamente ao enviar.
|
||||||
|
- **Tamanho máximo:** 10 MB por arquivo. Acima disso, o envio é recusado.
|
||||||
|
- **Texto alternativo é obrigatório sempre que houver imagem.** Isso não é
|
||||||
|
burocracia: é o texto que leitores de tela usam para descrever a imagem
|
||||||
|
para pessoas com deficiência visual, e também ajuda o Google a entender
|
||||||
|
do que se trata a foto. Sem uma imagem, o campo de texto alternativo
|
||||||
|
pode ficar em branco; assim que você anexa uma imagem, o sistema passa a
|
||||||
|
exigir o texto.
|
||||||
|
- **Exceção: a imagem de capa de um caso de portfólio e a imagem de um item
|
||||||
|
da galeria não são realmente opcionais**, ao contrário de toda outra
|
||||||
|
imagem do painel (logo, imagem da página Sobre, foto de depoimento,
|
||||||
|
imagem de SEO). Nesses dois casos específicos, o formulário não impede
|
||||||
|
você de salvar sem imagem, mas o salvamento falha de qualquer forma com
|
||||||
|
um erro técnico em vez de uma mensagem amigável de "campo obrigatório"
|
||||||
|
(ver detalhes nas seções [Casos de
|
||||||
|
portfólio](#casos-de-portfólio-criar-editar-publicar-e-despublicar) e
|
||||||
|
[Galeria de fotos do caso](#galeria-de-fotos-do-caso)). Sempre anexe uma
|
||||||
|
imagem antes de salvar um caso de portfólio ou um item de galeria.
|
||||||
|
- Você **não** precisa fazer nada além de enviar a imagem normalmente — o
|
||||||
|
sistema gera sozinho as versões menores usadas em celulares e tablets.
|
||||||
|
Não existe um botão ou comando manual que você precise rodar depois de
|
||||||
|
subir uma foto.
|
||||||
|
|
||||||
|
## Usuários: papéis, ativar e desativar contas
|
||||||
|
|
||||||
|
No menu, vá em **Usuários**. Esta tela só aparece, e só pode ser editada,
|
||||||
|
por contas com perfil **Administrador** — se você consegue ver este menu e
|
||||||
|
seguir o resto deste guia, você é Administradora.
|
||||||
|
|
||||||
|
Ao criar ou editar uma conta, os campos são:
|
||||||
|
|
||||||
|
- **Nome** e **E-mail** — identificação da pessoa; o e-mail também é o
|
||||||
|
usado para fazer login.
|
||||||
|
- **Papel** — **Administrador** ou **Assistente**. Como já visto em [Como
|
||||||
|
entrar no painel](#como-entrar-no-painel), hoje não existe meio-termo:
|
||||||
|
Administrador tem acesso total, e Assistente consegue fazer login mas
|
||||||
|
não vê nem edita **nenhum** conteúdo (nem Portfólio, nem Configurações,
|
||||||
|
nada). Trocar o Papel de alguém para Assistente remove todo o acesso
|
||||||
|
dela imediatamente.
|
||||||
|
- **Ativo** — desligar este interruptor bloqueia o login dessa conta por
|
||||||
|
completo, mesmo com e-mail e senha corretos.
|
||||||
|
- **Senha** — só é preciso preencher ao criar uma conta nova ou quando
|
||||||
|
você realmente quer trocar a senha de alguém; deixando em branco ao
|
||||||
|
editar uma conta existente, a senha atual não muda.
|
||||||
|
|
||||||
|
**Cuidado ao editar a sua própria conta.** O painel não impede uma
|
||||||
|
Administradora de trocar o próprio Papel para Assistente ou de desligar o
|
||||||
|
próprio "Ativo". Se isso acontecer, você perde o acesso imediatamente e,
|
||||||
|
como visto em [Como entrar no painel](#como-entrar-no-painel), hoje não
|
||||||
|
existe "esqueci minha senha" nem qualquer forma de uma Administradora
|
||||||
|
reverter isso sozinha — só sobra pedir para quem desenvolve o site mexer
|
||||||
|
diretamente no banco de dados. Ao mexer no seu próprio usuário, confira
|
||||||
|
duas vezes o que está mudando antes de salvar.
|
||||||
|
|
||||||
|
## O que NÃO mexer — e com quem falar
|
||||||
|
|
||||||
|
- **Seção "Analytics" em Configurações** (o interruptor e o campo de
|
||||||
|
script). É um campo técnico que injeta código de rastreamento no site.
|
||||||
|
Mexer errado aqui pode quebrar o carregamento do site inteiro. Peça para
|
||||||
|
quem desenvolve o site fazer essa alteração.
|
||||||
|
- **Slug de serviços e casos de portfólio**, depois de publicados. Mudar
|
||||||
|
quebra links já compartilhados (redes sociais, WhatsApp, resultados de
|
||||||
|
busca do Google). Se for realmente necessário mudar, avise quem
|
||||||
|
desenvolve o site para avaliar redirecionamento.
|
||||||
|
- **Usuários e permissões** (menu "Usuários") — só para quem **não** é
|
||||||
|
Administradora: essa tela só existe para contas com perfil
|
||||||
|
**Administrador**, então se você não consegue nem ver o menu "Usuários",
|
||||||
|
precisa de uma conta nova, de desativar alguém, ou de trocar uma senha,
|
||||||
|
peça a uma Administradora — ou, na ausência de uma, a quem desenvolve o
|
||||||
|
site. Se você **é** Administradora, essa tela é sua e está descrita em
|
||||||
|
[Usuários: papéis, ativar e desativar
|
||||||
|
contas](#usuários-papéis-ativar-e-desativar-contas).
|
||||||
|
- **Qualquer coisa fora do painel `/admin`** — arquivos de código, banco
|
||||||
|
de dados, comandos de terminal. Nada disso deve ser mexido para uma
|
||||||
|
atualização de conteúdo do dia a dia; se alguém pedir para você rodar um
|
||||||
|
comando técnico para "gerar imagens" ou algo do tipo, isso é tarefa de
|
||||||
|
engenharia, não de edição de conteúdo — fale com quem desenvolve o site.
|
||||||
|
|
||||||
|
## Seções da home que somem por completo se ficarem vazias
|
||||||
|
|
||||||
|
Estas três seções da home **desaparecem inteiramente** (sem nenhum aviso ou
|
||||||
|
espaço reservado) se não houver nenhum item que atenda aos critérios
|
||||||
|
abaixo:
|
||||||
|
|
||||||
|
| Seção da home | Desaparece quando... |
|
||||||
|
|---|---|
|
||||||
|
| **Serviços** | zero serviços estiverem, ao mesmo tempo, publicados **e** com "Destaque" ligado |
|
||||||
|
| **Portfólio** | zero casos estiverem, ao mesmo tempo, publicados **e** com "Destaque" ligado |
|
||||||
|
| **Depoimentos** | zero depoimentos estiverem publicados (o "Destaque" não importa aqui) |
|
||||||
|
|
||||||
|
Se você despublicar o último item de uma dessas categorias — ou esquecer de
|
||||||
|
ligar "Destaque" em qualquer serviço/caso — a home simplesmente fica sem
|
||||||
|
aquele bloco, sem mensagem de erro em lugar nenhum. Se um bloco "sumiu" da
|
||||||
|
home, o primeiro lugar para checar é exatamente essa combinação de
|
||||||
|
publicado + destaque.
|
||||||
|
|
||||||
|
## Sobre o arquivo `depoimentos.md` na raiz do repositório
|
||||||
|
|
||||||
|
Existe um arquivo chamado `depoimentos.md` na raiz do projeto com os cinco
|
||||||
|
depoimentos reais originais, copiados manualmente de onde vieram
|
||||||
|
(WhatsApp/redes sociais). Ele foi o material bruto usado, uma única vez,
|
||||||
|
para digitar os depoimentos dentro do sistema (e é citado em documentos
|
||||||
|
técnicos antigos como a origem desse conteúdo) — mas **hoje nenhum código
|
||||||
|
do site lê esse arquivo**. Editar, corrigir ou apagar `depoimentos.md` **não
|
||||||
|
muda nada** no site: o texto que aparece de verdade para quem visita o site
|
||||||
|
vive no banco de dados, e é editado exclusivamente pela tela **Depoimentos**
|
||||||
|
descrita [acima](#depoimentos-adicionar-e-publicar). Trate esse arquivo como
|
||||||
|
material histórico de referência, não como fonte de conteúdo.
|
||||||
BIN
docs/screenshots/home-editorial-cadence-desktop.webp
Normal file
|
After Width: | Height: | Size: 137 KiB |
BIN
docs/screenshots/home-editorial-cadence-mobile.webp
Normal file
|
After Width: | Height: | Size: 192 KiB |
262
frontend-audit.md
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
Audite e melhore integralmente este projeto do site da **Amare**, incluindo todas as rotas públicas, componentes compartilhados, formulários, navegação, conteúdo, metadados e comportamento responsivo.
|
||||||
|
|
||||||
|
Você está autorizado a executar o projeto, inspecionar o repositório e modificar diretamente o código. Não entregue somente recomendações: implemente as correções necessárias e valide o resultado.
|
||||||
|
|
||||||
|
## Objetivo
|
||||||
|
|
||||||
|
Deixar o site tecnicamente sólido, visualmente refinado e pronto para apresentação à cliente e posterior lançamento, corrigindo problemas de:
|
||||||
|
|
||||||
|
* UI e UX;
|
||||||
|
* responsividade;
|
||||||
|
* posicionamento e conteúdo;
|
||||||
|
* acessibilidade;
|
||||||
|
* conversão;
|
||||||
|
* SEO;
|
||||||
|
* performance;
|
||||||
|
* robustez;
|
||||||
|
* qualidade e manutenção do código.
|
||||||
|
|
||||||
|
## Contexto do produto
|
||||||
|
|
||||||
|
A Amare é uma empresa de assessoria, produção e organização de eventos em São Paulo.
|
||||||
|
|
||||||
|
Ela atende:
|
||||||
|
|
||||||
|
* casamentos;
|
||||||
|
* eventos sociais e celebrações particulares;
|
||||||
|
* eventos corporativos.
|
||||||
|
|
||||||
|
O site não deve transmitir que a empresa trabalha exclusivamente com casamentos.
|
||||||
|
|
||||||
|
A experiência precisa equilibrar:
|
||||||
|
|
||||||
|
* emoção, proximidade, sensibilidade e sofisticação para eventos sociais;
|
||||||
|
* organização, segurança, método, clareza e credibilidade para eventos corporativos.
|
||||||
|
|
||||||
|
O resultado deve ser editorial, contemporâneo, humano, elegante e profissional, sem parecer excessivamente romântico nem corporativo demais.
|
||||||
|
|
||||||
|
Preview de referência:
|
||||||
|
|
||||||
|
`https://amare.preview.hellomanoel.com/`
|
||||||
|
|
||||||
|
Considere os documentos existentes no repositório, especialmente `AGENTS.md`, `README.md`, `SPEC.md`, `DESIGN.md` e equivalentes, como contexto autoritativo do projeto.
|
||||||
|
|
||||||
|
## Direção visual
|
||||||
|
|
||||||
|
Preserve a identidade visual existente quando ela funcionar corretamente:
|
||||||
|
|
||||||
|
* fundos off-white ou bege claro;
|
||||||
|
* verde oliva;
|
||||||
|
* composição editorial;
|
||||||
|
* fotografias em destaque;
|
||||||
|
* espaços em branco generosos;
|
||||||
|
* EB Garamond em títulos e destaques;
|
||||||
|
* elementos minimalistas;
|
||||||
|
* animações discretas;
|
||||||
|
* aparência refinada e atemporal.
|
||||||
|
|
||||||
|
Não preserve decisões que prejudiquem usabilidade, contraste, legibilidade, acessibilidade, responsividade ou performance.
|
||||||
|
|
||||||
|
Avalie especialmente:
|
||||||
|
|
||||||
|
* uso excessivo de Garamond em textos pequenos, menus, botões e formulários;
|
||||||
|
* verdes claros com contraste insuficiente;
|
||||||
|
* CTAs discretos demais;
|
||||||
|
* espaços vazios excessivos no celular;
|
||||||
|
* imagens que reforcem somente o posicionamento de casamento;
|
||||||
|
* falta de equilíbrio entre conteúdo social e corporativo;
|
||||||
|
* inconsistência tipográfica ou de espaçamento;
|
||||||
|
* aparência de template genérico de casamento.
|
||||||
|
|
||||||
|
## Escopo da auditoria
|
||||||
|
|
||||||
|
Analise todas as rotas encontradas no código e corrija problemas relacionados a:
|
||||||
|
|
||||||
|
### Produto e conteúdo
|
||||||
|
|
||||||
|
* clareza da proposta de valor;
|
||||||
|
* equilíbrio entre eventos sociais e corporativos;
|
||||||
|
* hierarquia das informações;
|
||||||
|
* conteúdo genérico, redundante ou sem função;
|
||||||
|
* coerência entre títulos, textos, imagens e CTAs;
|
||||||
|
* clareza dos serviços;
|
||||||
|
* confiança e credibilidade;
|
||||||
|
* caminho até contato ou solicitação de proposta.
|
||||||
|
|
||||||
|
Não invente história, números, clientes, prêmios, depoimentos, equipe, serviços, telefone, e-mail ou qualquer informação não confirmada.
|
||||||
|
|
||||||
|
### UI e experiência
|
||||||
|
|
||||||
|
* navegação;
|
||||||
|
* header e footer;
|
||||||
|
* menu mobile;
|
||||||
|
* hierarquia visual;
|
||||||
|
* tipografia;
|
||||||
|
* espaçamento;
|
||||||
|
* grids;
|
||||||
|
* CTAs;
|
||||||
|
* formulários;
|
||||||
|
* estados interativos;
|
||||||
|
* consistência entre páginas;
|
||||||
|
* experiência mobile-first;
|
||||||
|
* ausência de overflow, cortes, sobreposições ou distorções;
|
||||||
|
* adaptação real do layout ao celular, e não apenas redução da versão desktop.
|
||||||
|
|
||||||
|
### Acessibilidade
|
||||||
|
|
||||||
|
Use WCAG 2.2 AA como referência prática.
|
||||||
|
|
||||||
|
Corrija problemas de:
|
||||||
|
|
||||||
|
* HTML semântico;
|
||||||
|
* hierarquia de headings;
|
||||||
|
* navegação por teclado;
|
||||||
|
* foco visível;
|
||||||
|
* contraste;
|
||||||
|
* nomes acessíveis;
|
||||||
|
* labels;
|
||||||
|
* mensagens de erro;
|
||||||
|
* áreas de toque;
|
||||||
|
* textos alternativos;
|
||||||
|
* menu mobile;
|
||||||
|
* overlays;
|
||||||
|
* preferência por redução de movimento;
|
||||||
|
* uso correto de links e botões.
|
||||||
|
|
||||||
|
Prefira HTML semântico a ARIA desnecessária.
|
||||||
|
|
||||||
|
### Formulários e conversão
|
||||||
|
|
||||||
|
Garanta, quando aplicável:
|
||||||
|
|
||||||
|
* campos e obrigatoriedade claros;
|
||||||
|
* validação adequada;
|
||||||
|
* mensagens de erro específicas;
|
||||||
|
* estado de envio;
|
||||||
|
* prevenção de envio duplicado;
|
||||||
|
* estados de sucesso e falha;
|
||||||
|
* tratamento de erro de rede;
|
||||||
|
* preservação dos dados após erros recuperáveis;
|
||||||
|
* proteção antispam simples;
|
||||||
|
* privacidade e LGPD;
|
||||||
|
* links e mensagens de WhatsApp corretos;
|
||||||
|
* ausência de segredos expostos no cliente.
|
||||||
|
|
||||||
|
Quando algum dado real não estiver disponível, use configuração ou variável de ambiente e documente a pendência.
|
||||||
|
|
||||||
|
### SEO
|
||||||
|
|
||||||
|
Corrija, quando aplicável:
|
||||||
|
|
||||||
|
* títulos exclusivos por rota;
|
||||||
|
* meta descriptions;
|
||||||
|
* canonical;
|
||||||
|
* Open Graph;
|
||||||
|
* Twitter cards;
|
||||||
|
* sitemap;
|
||||||
|
* robots;
|
||||||
|
* favicon;
|
||||||
|
* idioma;
|
||||||
|
* headings;
|
||||||
|
* URLs;
|
||||||
|
* links internos;
|
||||||
|
* página 404;
|
||||||
|
* metadados sociais;
|
||||||
|
* indexação distinta entre preview e produção.
|
||||||
|
|
||||||
|
Não crie dados estruturados com informações não confirmadas.
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
|
||||||
|
Corrija problemas relevantes relacionados a:
|
||||||
|
|
||||||
|
* imagens;
|
||||||
|
* tamanhos responsivos;
|
||||||
|
* formatos modernos;
|
||||||
|
* lazy loading;
|
||||||
|
* LCP;
|
||||||
|
* CLS;
|
||||||
|
* INP;
|
||||||
|
* fontes;
|
||||||
|
* scripts desnecessários;
|
||||||
|
* hidratação excessiva;
|
||||||
|
* JavaScript evitável;
|
||||||
|
* componentes client-side sem necessidade;
|
||||||
|
* animações custosas;
|
||||||
|
* dependências pesadas;
|
||||||
|
* carregamento de conteúdo abaixo da dobra.
|
||||||
|
|
||||||
|
Preserve a qualidade visual das fotografias.
|
||||||
|
|
||||||
|
### Qualidade técnica
|
||||||
|
|
||||||
|
Corrija:
|
||||||
|
|
||||||
|
* erros de TypeScript;
|
||||||
|
* erros de lint;
|
||||||
|
* erros de build;
|
||||||
|
* warnings de hidratação;
|
||||||
|
* erros de console;
|
||||||
|
* requisições quebradas;
|
||||||
|
* links inválidos;
|
||||||
|
* rotas órfãs;
|
||||||
|
* componentes duplicados quando a consolidação simplificar o projeto;
|
||||||
|
* tratamento de erros ausente;
|
||||||
|
* tipagem insegura;
|
||||||
|
* complexidade desnecessária diretamente relacionada ao escopo.
|
||||||
|
|
||||||
|
## Prioridades
|
||||||
|
|
||||||
|
Considere:
|
||||||
|
|
||||||
|
* **P0:** bloqueia funcionamento, segurança, uso ou lançamento;
|
||||||
|
* **P1:** prejudica significativamente experiência, conversão, acessibilidade, SEO, performance ou credibilidade;
|
||||||
|
* **P2:** refinamento não essencial para o lançamento.
|
||||||
|
|
||||||
|
Implemente todos os itens P0 e P1 que possam ser resolvidos com as informações existentes.
|
||||||
|
|
||||||
|
Itens dependentes de dados reais da cliente devem permanecer como pendências explícitas, sem conteúdo fictício.
|
||||||
|
|
||||||
|
## Restrições
|
||||||
|
|
||||||
|
* Preserve a stack e a arquitetura existentes quando forem adequadas.
|
||||||
|
* Prefira mudanças simples, localizadas e fáceis de manter.
|
||||||
|
* Não reescreva o projeto sem necessidade concreta.
|
||||||
|
* Não adicione bibliotecas quando a solução atual for suficiente.
|
||||||
|
* Não implemente pagamentos, CRM, área do cliente, chat, contratos avançados ou funcionalidades fora do MVP.
|
||||||
|
* Não altere arquivos não relacionados sem justificativa.
|
||||||
|
* Preserve alterações locais preexistentes.
|
||||||
|
* Não silencie problemas com `any`, `eslint-disable`, casts inseguros ou desativação de validações.
|
||||||
|
* Não reduza acessibilidade para preservar estética.
|
||||||
|
* Não deixe mocks ou soluções temporárias como implementação final.
|
||||||
|
|
||||||
|
## Critérios de conclusão
|
||||||
|
|
||||||
|
A tarefa estará concluída quando:
|
||||||
|
|
||||||
|
* todas as rotas públicas existentes tiverem sido auditadas;
|
||||||
|
* todos os problemas P0 e P1 solucionáveis tiverem sido corrigidos;
|
||||||
|
* o site funcionar corretamente em mobile e desktop;
|
||||||
|
* não houver overflow horizontal, sobreposição ou componentes quebrados;
|
||||||
|
* menu, navegação, links, CTAs e formulários funcionarem;
|
||||||
|
* o posicionamento social e corporativo estiver claro;
|
||||||
|
* a acessibilidade essencial estiver atendida;
|
||||||
|
* os metadados e fundamentos de SEO estiverem corretos;
|
||||||
|
* os principais problemas de performance tiverem sido tratados;
|
||||||
|
* erros causados ou revelados pelas alterações tiverem sido resolvidos;
|
||||||
|
* lint, TypeScript, testes e build disponíveis tiverem sido executados com sucesso.
|
||||||
|
|
||||||
|
Não declare uma validação como aprovada sem executá-la.
|
||||||
|
|
||||||
|
## Entrega final
|
||||||
|
|
||||||
|
Ao concluir, apresente:
|
||||||
|
|
||||||
|
1. rotas auditadas;
|
||||||
|
2. problemas principais encontrados;
|
||||||
|
3. alterações implementadas;
|
||||||
|
4. arquivos modificados;
|
||||||
|
5. comandos executados e seus resultados;
|
||||||
|
6. decisões e trade-offs relevantes;
|
||||||
|
7. informações que dependem da cliente;
|
||||||
|
8. itens P2 mantidos fora do escopo.
|
||||||
@@ -1,23 +1,23 @@
|
|||||||
## 1. Auth and strict types parity
|
## 1. Auth and strict types parity
|
||||||
|
|
||||||
- [ ] 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.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.2 Confirm Filament/Laravel password reset is enabled; add feature tests for registered vs unknown email without account enumeration
|
- [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.3 Add `declare(strict_types=1);` to project-owned PHP files missing it (e.g. `AdminPanelProvider`); architecture/unit regression as needed
|
- [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.4 Run `composer pint`, `composer phpstan`, and `composer test:feature` for auth changes
|
- [x] 1.4 Run `composer pint`, `composer phpstan`, and `composer test:feature` for auth changes — all green
|
||||||
|
|
||||||
## 2. Local runtime and PHP 8.4 alignment
|
## 2. Local runtime and PHP 8.4 alignment
|
||||||
|
|
||||||
- [ ] 2.1 Extend `docker-compose.yml` with FrankenPHP `app` service (build Dockerfile, depend on healthy postgres, publish 8000); document in README
|
- [x] 2.1 Extend `docker-compose.yml` with FrankenPHP `app` service (build Dockerfile, depend on healthy postgres, publish 8000); document in README
|
||||||
- [ ] 2.2 Align README/docs to PHP 8.4 canonical (keep Composer `^8.3`); verify Dockerfile/CI already on 8.4
|
- [x] 2.2 Align README/docs to PHP 8.4 canonical (keep Composer `^8.3`); verify Dockerfile/CI already on 8.4
|
||||||
- [ ] 2.3 Smoke local compose: `docker compose up -d` → `GET /up` returns 200
|
- [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.4 Run `composer quality` after compose/docs changes
|
- [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)
|
||||||
|
|
||||||
## 3. Quality gates: npm audit and coverage
|
## 3. Quality gates: npm audit and coverage
|
||||||
|
|
||||||
- [ ] 3.1 Add npm audit step to `composer quality` and CI `static` (policy: production deps; document any allowlist)
|
- [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.2 Enable Domain/Application coverage in CI `unit` with 80% fail threshold; exclude views/migrations/framework
|
- [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.3 Add/adjust unit tests if current Domain/Application coverage is below threshold
|
- [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.4 Verify CI `static` and `unit` fail appropriately on intentional audit/coverage breakage in a branch experiment or equivalent proof
|
- [ ] 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
|
||||||
|
|
||||||
## 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
|
||||||
- [ ] 6.3 Update `SPEC.md` §18 Fase 0 checkboxes only for items with evidence; note remaining deferred items if any
|
- [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.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
|
||||||
|
|||||||
2
openspec/changes/enhance-public-motion/.openspec.yaml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-08-06
|
||||||
51
openspec/changes/enhance-public-motion/design.md
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
O site público usa Blade/Tailwind e já contém um runtime pequeno em `resources/js/motion.js`: abertura focal da home, `page-open`, reveals com `IntersectionObserver` e progresso do índice. A cobertura e a marcação são parciais, os reveals não têm grupos/direções explícitas e a matriz browser se concentra nas quatro páginas visuais originais. A mudança cruza templates, CSS, JavaScript e testes, mas não envolve estado de aplicação, dados ou dependências.
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
|
||||||
|
- Tornar a coreografia Heritage Editorial consistente em toda página pública visual.
|
||||||
|
- Manter texto legível durante toda entrada, animando somente `transform` e recorte de mídia.
|
||||||
|
- Garantir enhancement progressivo: estado final imediato sem JavaScript, observer ou com movimento reduzido.
|
||||||
|
- Preservar interação, foco, validação e ausência de overflow em desktop/mobile.
|
||||||
|
- Cobrir marcação, comportamento real, acessibilidade, console e capturas determinísticas.
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
|
||||||
|
- Não criar transições de rota, parallax, animação contínua ou repetição ao voltar no scroll.
|
||||||
|
- Não mudar copy, dados de clientes, estrutura de layout, CMS, controllers, banco, APIs ou Filament.
|
||||||
|
- Não adicionar biblioteca de animação nem itens de SPEC.md §4.2.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
1. **Contrato declarativo em atributos `data-*`.** Templates usarão `data-motion="page-open"`, `data-motion-beat`, `data-reveal-group`, `data-reveal` e `data-reveal-from="up|left|right"`. CSS define o estado visual e JS apenas ativa classes. Alternativa rejeitada: acoplar seletores a classes de layout, por tornar a coreografia frágil a ajustes visuais.
|
||||||
|
|
||||||
|
2. **Enhancement opt-in pelo elemento raiz.** O HTML inicial permanece no estado final; o runtime adiciona `html[data-motion="enhance"]` somente quando movimento é permitido e os recursos necessários existem. Sem JS, sem `IntersectionObserver`, ou em `reduce`, o atributo não é aplicado/removido e todo conteúdo fica final. Alternativa rejeitada: estado inicial oculto no HTML/CSS, pois pode prender conteúdo fora da tela.
|
||||||
|
|
||||||
|
3. **Transformação sem fade de texto.** Entradas duram cerca de 500 ms com `cubic-bezier(0.16, 1, 0.3, 1)`, deslocamento vertical de 12 px e lateral de 24 px no desktop/16 px no mobile. Grupos recebem stagger de 90 ms limitado a 270 ms. Mídia pode combinar transform com `clip-path`; texto não usa opacidade. Alternativa rejeitada: fade geral, que reduz contraste durante axe e legibilidade percebida.
|
||||||
|
|
||||||
|
4. **Observer de execução única.** Cada reveal intersectado recebe o estado final e é removido do observer. Grupos calculam índice limitado para o delay; depoimentos definem explicitamente `left`/`right` pela coluna, preservando alternância no mobile. Alternativa rejeitada: reanimar em toda rolagem, por criar fadiga e instabilidade.
|
||||||
|
|
||||||
|
5. **Feedback separado das entradas.** CTAs, links, navegação e controles recebem transições curtas (100–200 ms) em cor/transform/borda. `:focus-visible` continua prioritário; alertas e mensagens de erro não entram no contrato de motion. Alternativa rejeitada: feedback via JS, desnecessário para estados CSS.
|
||||||
|
|
||||||
|
6. **Progresso com coalescência por frame.** Eventos de scroll apenas agendam uma atualização por `requestAnimationFrame`; o cálculo existente é preservado. Alternativa rejeitada: calcular em todo evento, que multiplica leituras/escritas durante scroll.
|
||||||
|
|
||||||
|
7. **Testes em camadas.** Feature tests comprovam contratos Blade/CSS/JS e renderização de erros; browser tests comprovam entrada, stagger, direções, reduced motion, fallback, interação, overflow e console. Axe/snapshots passam a incluir sobre, contato, privacidade e 404; demais erros ficam em renderização feature.
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- [Conteúdo pisca entre estado final e início do enhancement] → inicializar no primeiro módulo Vite, limitar transformações a distâncias pequenas e nunca ocultar texto.
|
||||||
|
- [Transforms laterais causam overflow horizontal] → limitar distância por breakpoint, manter recorte no contêiner público e testar `scrollWidth` em ambos viewports.
|
||||||
|
- [Snapshots ficam instáveis] → continuar capturando com `reducedMotion: reduce` e transições desabilitadas.
|
||||||
|
- [Muitos observers/estilos inline] → usar um único observer, custom property de índice limitada e `unobserve` imediato.
|
||||||
|
- [Páginas de erro não carregam o runtime em todos os contextos] → o estado final é o baseline seguro; feature tests cobrem os cinco templates.
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
Publicar CSS, JS e templates no mesmo bundle/commit; não há migração de dados. Rollback consiste em reverter esses assets e atributos, sem compatibilidade de schema ou limpeza operacional.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
Nenhuma; direção, alternância mobile, execução única e limites de escopo foram aprovados no plano.
|
||||||
31
openspec/changes/enhance-public-motion/proposal.md
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
O site público já possui uma abertura focal na home, mas o restante da experiência ainda muda de estado de forma desigual entre páginas e seções. WEB-01, WEB-07 e os requisitos transversais de acessibilidade pedem uma coreografia editorial consistente, progressiva e dispensável, sem ocultar conteúdo nem comprometer interação, performance ou movimento reduzido.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- Evoluir o contrato de motion do site público com abertura de página, sequência editorial inicial e reveals direcionais executados uma única vez.
|
||||||
|
- Aplicar a coreografia à home, serviços, portfólio, detalhe, sobre, contato, privacidade e páginas de erro 404/419/429/500/503.
|
||||||
|
- Alternar depoimentos entre esquerda e direita, filtrando citações vazias antes de renderizar a sequência.
|
||||||
|
- Adicionar feedback curto e não bloqueante a links, CTAs, navegação e controles de formulário, preservando foco e mensagens de validação.
|
||||||
|
- Garantir estado final imediato sem JavaScript, sem `IntersectionObserver` e com `prefers-reduced-motion: reduce`.
|
||||||
|
- Limitar o progresso/índice da home a uma atualização por frame e ampliar testes de marcação, browser, acessibilidade, console e regressão visual.
|
||||||
|
- Não objetivos: não criar transições entre rotas, alterar conteúdo ou layout estrutural, adicionar biblioteca de animação, modificar CMS/API/banco/Filament, nem introduzir itens fora do MVP listados em SPEC.md §4.2.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
- `public-site-motion`: Coreografia progressiva, direções, timings, execução única, microfeedback, performance e fallbacks do motion público.
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
- `design-tokens`: Especificar tokens compartilhados de duração, stagger, distância e easing para motion editorial.
|
||||||
|
- `public-site-pages`: Cobrir todas as páginas públicas visuais e templates de erro com o contrato compartilhado de abertura e reveal.
|
||||||
|
- `testimonials`: Filtrar citações vazias e alternar explicitamente a direção de entrada dos depoimentos renderizados.
|
||||||
|
- `visual-regression`: Ampliar a matriz visual para sobre, contato, privacidade e 404, mantendo capturas determinísticas sem animação.
|
||||||
|
- `web-accessibility`: Ampliar axe/console e validar conteúdo imediatamente utilizável com movimento reduzido ou enhancement indisponível.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
Afeta somente templates Blade públicos, `resources/js/motion.js`, tokens/regras CSS e testes Pest/Pest Browser. Não altera APIs, banco, controllers, CMS, dependências, endpoints técnicos (`sitemap.xml`, `robots.txt`) ou Filament.
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Editorial motion tokens are centralized
|
||||||
|
|
||||||
|
The system SHALL define shared public motion tokens for an approximately 500 ms entrance, short interaction feedback, exponential ease-out, 90 ms stagger capped at 270 ms, 12 px vertical distance, and 24 px desktop / 16 px mobile lateral distance. Public motion CSS MUST consume these tokens instead of duplicating arbitrary values.
|
||||||
|
|
||||||
|
#### Scenario: Motion runtime uses shared values
|
||||||
|
- **WHEN** page openings, reveals, or interaction feedback are styled
|
||||||
|
- **THEN** duration, easing, stagger, and distance MUST be derived from centralized tokens
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Public pages use shared progressive motion choreography
|
||||||
|
|
||||||
|
The public site SHALL expose a declarative motion contract using `data-motion="page-open"`, `data-motion-beat`, `data-reveal-group`, `data-reveal`, and `data-reveal-from="up|left|right"`. Home, services, portfolio index/detail, about, contact, privacy, and branded 404/419/429/500/503 pages MUST use the contract without introducing route transitions.
|
||||||
|
|
||||||
|
#### Scenario: Visual public route exposes motion hooks
|
||||||
|
- **WHEN** a visitor loads any visual public route or branded error page
|
||||||
|
- **THEN** the rendered page MUST expose a page opening hook and appropriate reveal hooks
|
||||||
|
|
||||||
|
#### Scenario: Technical endpoints remain outside motion
|
||||||
|
- **WHEN** a visitor requests `sitemap.xml` or `robots.txt`
|
||||||
|
- **THEN** the response MUST NOT depend on the public motion runtime
|
||||||
|
|
||||||
|
### Requirement: Editorial entrance timing and direction are consistent
|
||||||
|
|
||||||
|
Entrances SHALL use shared tokens for an approximately 500 ms duration, 90 ms stagger capped at 270 ms, 12 px vertical distance, and 24 px desktop / 16 px mobile lateral distance. Text MUST remain fully visible; motion MUST use transforms and MAY use media clipping without opacity fades for text.
|
||||||
|
|
||||||
|
#### Scenario: Reveal group computes capped stagger
|
||||||
|
- **WHEN** more than four reveal items are rendered in one group
|
||||||
|
- **THEN** the applied delay MUST NOT exceed 270 ms
|
||||||
|
|
||||||
|
#### Scenario: Text enters without opacity fade
|
||||||
|
- **WHEN** page opening or scroll reveal motion runs
|
||||||
|
- **THEN** text MUST remain visible throughout the transition
|
||||||
|
|
||||||
|
### Requirement: Reveals execute once without blocking interaction
|
||||||
|
|
||||||
|
Each scroll reveal SHALL activate once, reach its final state, and be removed from observation. Visitors MUST be able to activate links and form controls while entrance motion is running.
|
||||||
|
|
||||||
|
#### Scenario: Revealed content leaves observer
|
||||||
|
- **WHEN** a reveal target intersects the configured viewport threshold
|
||||||
|
- **THEN** it MUST receive the final state and be unobserved
|
||||||
|
- **AND** scrolling away and back MUST NOT replay the reveal
|
||||||
|
|
||||||
|
#### Scenario: CTA remains interactive during entrance
|
||||||
|
- **WHEN** a visitor activates a CTA while its entrance is in progress
|
||||||
|
- **THEN** navigation MUST proceed without waiting for animation completion
|
||||||
|
|
||||||
|
### Requirement: Motion enhancement has immediate safe fallbacks
|
||||||
|
|
||||||
|
Without JavaScript, without `IntersectionObserver`, or when `prefers-reduced-motion: reduce` is active, the site SHALL render every motion target immediately in its final usable state. Non-essential animation and transition MUST NOT run under reduced motion.
|
||||||
|
|
||||||
|
#### Scenario: JavaScript is unavailable
|
||||||
|
- **WHEN** a public page renders without executing JavaScript
|
||||||
|
- **THEN** all content MUST be visible and usable in its final state
|
||||||
|
|
||||||
|
#### Scenario: IntersectionObserver is unavailable
|
||||||
|
- **WHEN** JavaScript runs but `IntersectionObserver` is not supported
|
||||||
|
- **THEN** all reveal targets MUST remain in their final state
|
||||||
|
|
||||||
|
#### Scenario: Reduced motion is preferred
|
||||||
|
- **WHEN** the browser reports `prefers-reduced-motion: reduce`
|
||||||
|
- **THEN** page openings, reveals, media clips, and non-essential feedback transitions MUST NOT run
|
||||||
|
|
||||||
|
### Requirement: Public interaction feedback is short and accessible
|
||||||
|
|
||||||
|
CTAs, links, navigation, and form controls SHALL provide short feedback using shared tokens while preserving visible `:focus-visible` indication. Alerts and validation messages MUST NOT be animated by this contract.
|
||||||
|
|
||||||
|
#### Scenario: Keyboard focus remains visible
|
||||||
|
- **WHEN** a visitor focuses an interactive element with the keyboard
|
||||||
|
- **THEN** its focus indicator MUST remain visible and MUST NOT be displaced by motion feedback
|
||||||
|
|
||||||
|
#### Scenario: Validation message appears without motion
|
||||||
|
- **WHEN** server validation renders an error message
|
||||||
|
- **THEN** the message MUST appear in its final position without entrance animation
|
||||||
|
|
||||||
|
### Requirement: Home progress updates at most once per animation frame
|
||||||
|
|
||||||
|
Scroll-driven chapter index and progress updates on the home SHALL be coalesced through `requestAnimationFrame`, with at most one pending update per frame.
|
||||||
|
|
||||||
|
#### Scenario: Multiple scroll events occur in one frame
|
||||||
|
- **WHEN** multiple scroll events fire before the next animation frame
|
||||||
|
- **THEN** the home progress calculation MUST execute only once for that frame
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Visual public and error pages expose the shared motion contract
|
||||||
|
|
||||||
|
The system SHALL apply the shared progressive opening and reveal contract to home, services, portfolio index/detail, about, contact, privacy, and branded 404/419/429/500/503 pages. The contract MUST NOT change page copy, content ordering, route behavior, or structural layout.
|
||||||
|
|
||||||
|
#### Scenario: Institutional page uses shared opening
|
||||||
|
- **WHEN** a visitor loads `/sobre`, `/contato`, or `/privacidade`
|
||||||
|
- **THEN** the page MUST expose the shared page opening hook and section reveal hooks
|
||||||
|
|
||||||
|
#### Scenario: Error page remains branded and progressively enhanced
|
||||||
|
- **WHEN** Laravel renders a branded 404, 419, 429, 500, or 503 response
|
||||||
|
- **THEN** the page MUST expose the shared opening hook
|
||||||
|
- **AND** all error guidance and navigation MUST remain immediately usable without enhancement
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Testimonials are managed with publication control
|
||||||
|
|
||||||
|
The system SHALL allow admins to manage testimonials (SPEC WEB-04) with quote text (including multi-paragraph content), author name, optional context (event type and/or date), optional photo with alt text, sort order, featured flag, and `published_at`. Public rendering MUST preserve paragraph breaks from the stored quote, MUST omit blank/whitespace-only quotes before creating the rendered sequence, and MUST assign alternating `left`/`right` reveal directions according to the remaining rendered column order at desktop and mobile viewports. Testimonials sourced from real clients MUST NOT be published to production without authorization; development seeds MAY include the authorized-pending real quotes marked for review.
|
||||||
|
|
||||||
|
#### Scenario: Unpublished testimonial is excluded
|
||||||
|
|
||||||
|
- **WHEN** a testimonial has `published_at` null
|
||||||
|
- **THEN** the `published()` scope MUST exclude it
|
||||||
|
|
||||||
|
#### Scenario: Published testimonial is queryable
|
||||||
|
|
||||||
|
- **WHEN** an admin sets `published_at` with required quote and author name
|
||||||
|
- **THEN** the testimonial MUST be included in the `published()` scope
|
||||||
|
|
||||||
|
#### Scenario: Assistant cannot manage testimonials
|
||||||
|
|
||||||
|
- **WHEN** an assistant attempts to access the testimonials Resource
|
||||||
|
- **THEN** access MUST be denied with HTTP 403
|
||||||
|
|
||||||
|
#### Scenario: Featured testimonials are filterable
|
||||||
|
|
||||||
|
- **WHEN** content is queried with featured filter
|
||||||
|
- **THEN** records with `is_featured` true MUST be retrievable independently of sort order
|
||||||
|
|
||||||
|
#### Scenario: Multi-paragraph quotes render as paragraphs
|
||||||
|
|
||||||
|
- **GIVEN** a published testimonial whose quote contains blank-line separated paragraphs
|
||||||
|
- **WHEN** the home testimonials section is rendered
|
||||||
|
- **THEN** each paragraph MUST appear as distinct block text rather than a single collapsed line
|
||||||
|
|
||||||
|
#### Scenario: Blank quotes do not affect alternation
|
||||||
|
|
||||||
|
- **GIVEN** the published testimonial collection contains a blank quote between two non-blank quotes
|
||||||
|
- **WHEN** the home testimonials section is rendered
|
||||||
|
- **THEN** the blank testimonial MUST be omitted
|
||||||
|
- **AND** the two rendered testimonials MUST receive alternating `left` and `right` directions based on their rendered order
|
||||||
|
|
||||||
|
#### Scenario: Direction alternation remains on mobile
|
||||||
|
|
||||||
|
- **WHEN** testimonials are viewed at a mobile viewport
|
||||||
|
- **THEN** their explicit `left` and `right` directions MUST be preserved with the reduced mobile distance
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Public screens have desktop and mobile visual baselines
|
||||||
|
|
||||||
|
The system SHALL keep versioned screenshot baselines for the public screens available in this phase (SPEC §13.5): Home, Serviços, Portfólio, Detalhe do portfólio, Sobre, Contato, Privacidade and branded 404, at 1440×1000 desktop and 390×844 mobile, under the Heritage Editorial identity. A rendering change that alters those screens MUST fail the browser suite until the diff is reviewed and baselines are explicitly updated.
|
||||||
|
|
||||||
|
#### Scenario: Unintended visual change fails the suite
|
||||||
|
|
||||||
|
- **GIVEN** approved baselines exist
|
||||||
|
- **WHEN** a code change alters the rendering of a covered screen
|
||||||
|
- **THEN** the visual assertion MUST fail and report the diff
|
||||||
|
|
||||||
|
#### Scenario: Both viewports are covered
|
||||||
|
|
||||||
|
- **WHEN** the visual suite runs
|
||||||
|
- **THEN** each covered screen MUST be asserted at 1440×1000 and 390×844
|
||||||
|
|
||||||
|
#### Scenario: Heritage Editorial identity is captured
|
||||||
|
|
||||||
|
- **WHEN** approved baselines for the home are reviewed after this change
|
||||||
|
- **THEN** they MUST reflect EB Garamond typography, olive/paper palette and sharp-edged editorial layout rather than the previous gold/rounded placeholder look
|
||||||
|
|
||||||
|
#### Scenario: Motion does not destabilize new baselines
|
||||||
|
|
||||||
|
- **WHEN** Sobre, Contato, Privacidade, or 404 is captured
|
||||||
|
- **THEN** the browser MUST use the final reduced-motion state before asserting the screenshot
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Public routes have no critical or serious accessibility issues
|
||||||
|
|
||||||
|
The system SHALL run automated accessibility checks on the public routes covered by the browser suite (SPEC §6.5, §13.8) after the Heritage Editorial motion enhancement. A critical or serious issue MUST fail the suite.
|
||||||
|
|
||||||
|
#### Scenario: Critical issue blocks the suite
|
||||||
|
|
||||||
|
- **WHEN** the automated accessibility check reports a critical or serious issue on a covered route
|
||||||
|
- **THEN** the browser suite MUST fail and report the offending rule and selector
|
||||||
|
|
||||||
|
#### Scenario: Covered routes are checked
|
||||||
|
|
||||||
|
- **WHEN** the accessibility suite runs
|
||||||
|
- **THEN** home, services listing, portfolio listing, case detail, about, contact, privacy and branded 404 MUST each be checked
|
||||||
|
|
||||||
|
### Requirement: Reduced motion preference is honored
|
||||||
|
|
||||||
|
The system SHALL suppress non-essential animation and transition when the user agent reports `prefers-reduced-motion: reduce`, including page openings, editorial reveals, image hover scales, interaction feedback and menu transitions. All affected content MUST render in its final visible and interactive state.
|
||||||
|
|
||||||
|
#### Scenario: Reduced motion disables transitions
|
||||||
|
|
||||||
|
- **GIVEN** the browser reports `prefers-reduced-motion: reduce`
|
||||||
|
- **WHEN** a public page is loaded
|
||||||
|
- **THEN** decorative transitions and animations MUST NOT run
|
||||||
|
- **AND** every motion target MUST be in its final visible and interactive state
|
||||||
|
|
||||||
|
### Requirement: Public pages emit no console errors
|
||||||
|
|
||||||
|
Covered public routes SHALL load without JavaScript console errors in a real browser (SPEC §13.8, §19), including home, services listing, portfolio listing, case detail, about, contact, privacy, branded 404, and pages that load the mobile navigation or motion runtime.
|
||||||
|
|
||||||
|
#### Scenario: Console stays clean on covered routes
|
||||||
|
|
||||||
|
- **WHEN** a covered public route is loaded in the browser suite
|
||||||
|
- **THEN** the console MUST contain no error-level messages
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Motion enhancement failure does not hide or disable content
|
||||||
|
|
||||||
|
The public site SHALL remain readable, keyboard-operable, and interactive when JavaScript is disabled or `IntersectionObserver` is unavailable. Motion targets MUST default to their final state without relying on timeout recovery.
|
||||||
|
|
||||||
|
#### Scenario: JavaScript disabled keeps public page usable
|
||||||
|
|
||||||
|
- **WHEN** a visitor loads a public page with JavaScript disabled
|
||||||
|
- **THEN** headings, copy, navigation, CTAs, and form controls MUST be visible and operable
|
||||||
|
|
||||||
|
#### Scenario: Observer unavailable keeps reveals final
|
||||||
|
|
||||||
|
- **WHEN** the motion runtime executes without `IntersectionObserver`
|
||||||
|
- **THEN** reveal targets MUST remain in the final state
|
||||||
24
openspec/changes/enhance-public-motion/tasks.md
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
## 1. Motion contract and runtime
|
||||||
|
|
||||||
|
- [x] 1.1 Extend feature tests first for motion tokens, declarative hooks, no-opacity text, safe fallbacks, one-shot observer, capped stagger and requestAnimationFrame coalescing
|
||||||
|
- [x] 1.2 Add centralized motion tokens and CSS states for page openings, grouped directional reveals, media clips and accessible interaction feedback
|
||||||
|
- [x] 1.3 Refactor `resources/js/motion.js` to enhance only when supported, initialize page beats/groups, unobserve revealed targets and coalesce progress updates per frame
|
||||||
|
|
||||||
|
## 2. Public template coverage
|
||||||
|
|
||||||
|
- [x] 2.1 Add failing markup coverage for home, services, portfolio index/detail, about, contact, privacy and branded 404/419/429/500/503 pages
|
||||||
|
- [x] 2.2 Apply page-open, beat, reveal-group and directional reveal hooks across all visual public templates without changing content or layout structure
|
||||||
|
- [x] 2.3 Add failing testimonial coverage, filter blank quotes before the rendered sequence and alternate explicit left/right directions on desktop and mobile
|
||||||
|
|
||||||
|
## 3. Browser and accessibility coverage
|
||||||
|
|
||||||
|
- [x] 3.1 Add browser tests for real opening/reveal state, 90 ms capped stagger, one-shot observation and interaction during motion
|
||||||
|
- [x] 3.2 Add desktop/mobile tests for directional distance, reduced motion final state, no-JS/observer fallback, horizontal overflow and clean console
|
||||||
|
- [x] 3.3 Extend axe and console route matrices to about, contact, privacy and branded 404
|
||||||
|
- [x] 3.4 Extend deterministic desktop/mobile visual snapshots to about, contact, privacy and branded 404 and review the generated diffs
|
||||||
|
|
||||||
|
## 4. Verification and delivery
|
||||||
|
|
||||||
|
- [x] 4.1 Run focused feature/browser tests, build, Pint, isolated-cache PHPStan and strict OpenSpec validation
|
||||||
|
- [x] 4.2 Run `composer quality` and visually review desktop/mobile with normal and reduced motion
|
||||||
|
- [ ] 4.3 Stage only scoped files, commit conventionally, open a PR with screenshots and verification evidence, watch CI to green, merge and remove the worktree
|
||||||
@@ -2,8 +2,8 @@ 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/Fortaleza, 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), Livewire 4 + Blade + Alpine + Tailwind (site público),
|
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),
|
||||||
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.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# container-runtime Specification
|
# container-runtime Specification
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
TBD - created by archiving change setup-foundation. Update Purpose after archive.
|
Define the production container image for the application: a reproducible multi-stage FrankenPHP build serving the public directory, run in regular mode as a non-root user, shared by web/queue/scheduler processes, with no secrets in layers and a healthcheck on `/up`.
|
||||||
## Requirements
|
## Requirements
|
||||||
### Requirement: Production image uses multi-stage FrankenPHP build
|
### Requirement: Production image uses multi-stage FrankenPHP build
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# design-tokens Specification
|
# design-tokens Specification
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
TBD - created by archiving change setup-foundation. Update Purpose after archive.
|
Centralize the public site's design tokens so the interface implements the Heritage Editorial system from DESIGN.md (EB Garamond, 8px rhythm, zero radius, 1120px container, paper/olive/sage/ink palette), honoring reduced motion and WCAG AA contrast without card-shadow hierarchy.
|
||||||
## Requirements
|
## Requirements
|
||||||
### Requirement: Design tokens are centralized for the public site
|
### Requirement: Design tokens are centralized for the public site
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# health-check Specification
|
# health-check Specification
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
TBD - created by archiving change setup-foundation. Update Purpose after archive.
|
Expose a public `GET /up` healthcheck that responds quickly and without authentication, exposes no secrets, and fails when the application cannot boot, so orchestrators and CI can verify availability.
|
||||||
## Requirements
|
## Requirements
|
||||||
### Requirement: Public health endpoint responds without authentication
|
### Requirement: Public health endpoint responds without authentication
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# internal-authentication Specification
|
# internal-authentication Specification
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
TBD - created by archiving change setup-foundation. Update Purpose after archive.
|
Provide session-based authentication for the internal Filament panel at `/admin`, limited to two roles (admin and assistant), with unique emails, password reset without enumeration, inactive users denied, and user management restricted to admins.
|
||||||
## Requirements
|
## Requirements
|
||||||
### Requirement: Internal users authenticate via Filament panel
|
### Requirement: Internal users authenticate via Filament panel
|
||||||
|
|
||||||
|
|||||||
@@ -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/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.
|
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.
|
||||||
|
|
||||||
#### Scenario: Repeated run without code change produces no diff
|
#### Scenario: Repeated run without code change produces no diff
|
||||||
|
|
||||||
|
|||||||
57
phpunit.coverage.xml
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
<?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: 0 B After Width: | Height: | Size: 4.2 KiB |
4
public/favicon.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||||
|
<rect width="64" height="64" fill="#556B2F"/>
|
||||||
|
<text x="32" y="45" font-family="Georgia, 'Times New Roman', serif" font-size="38" font-weight="600" fill="#FBF9F4" text-anchor="middle">A</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 264 B |
@@ -18,6 +18,13 @@
|
|||||||
--text-3xl: var(--amare-text-3xl);
|
--text-3xl: var(--amare-text-3xl);
|
||||||
--text-4xl: var(--amare-text-4xl);
|
--text-4xl: var(--amare-text-4xl);
|
||||||
|
|
||||||
|
--text-display: clamp(3rem, 7.7vw, 5.875rem);
|
||||||
|
--text-display--line-height: 0.94;
|
||||||
|
--text-display--letter-spacing: -0.01em;
|
||||||
|
|
||||||
|
--text-headline: clamp(2.375rem, 5.2vw, 4rem);
|
||||||
|
--text-headline--line-height: 1.02;
|
||||||
|
|
||||||
--spacing-1: var(--amare-space-1);
|
--spacing-1: var(--amare-space-1);
|
||||||
--spacing-2: var(--amare-space-2);
|
--spacing-2: var(--amare-space-2);
|
||||||
--spacing-3: var(--amare-space-3);
|
--spacing-3: var(--amare-space-3);
|
||||||
@@ -52,6 +59,27 @@
|
|||||||
|
|
||||||
--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 {
|
||||||
@@ -59,6 +87,7 @@
|
|||||||
background-color: var(--amare-color-bg);
|
background-color: var(--amare-color-bg);
|
||||||
color: var(--amare-color-text);
|
color: var(--amare-color-text);
|
||||||
font-family: var(--amare-font-serif);
|
font-family: var(--amare-font-serif);
|
||||||
|
overflow-wrap: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
[id$='-heading'] {
|
[id$='-heading'] {
|
||||||
@@ -88,11 +117,56 @@
|
|||||||
filter: saturate(0.88) contrast(0.96);
|
filter: saturate(0.88) contrast(0.96);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@utility honeypot {
|
||||||
|
position: absolute;
|
||||||
|
left: -9999px;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
@layer components {
|
@layer components {
|
||||||
.main-nav.is-open {
|
.main-nav.is-open {
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.home-chapters {
|
||||||
|
counter-reset: home-chapter -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-chapter {
|
||||||
|
counter-increment: home-chapter;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-folio {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
color: var(--amare-color-accent);
|
||||||
|
font-size: var(--amare-text-xs);
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
line-height: 1;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-folio--inverse {
|
||||||
|
color: color-mix(in srgb, var(--amare-color-accent-text) 82%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-folio__separator {
|
||||||
|
color: var(--amare-color-sage);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-folio--inverse .home-folio__separator {
|
||||||
|
color: color-mix(in srgb, var(--amare-color-accent-text) 55%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-folio__number::before {
|
||||||
|
content: counter(home-chapter, decimal-leading-zero);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: no-preference) {
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
.main-nav {
|
.main-nav {
|
||||||
transition: opacity var(--amare-duration-normal) var(--amare-ease-standard);
|
transition: opacity var(--amare-duration-normal) var(--amare-ease-standard);
|
||||||
@@ -103,6 +177,10 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
overflow-x: clip;
|
||||||
|
}
|
||||||
|
|
||||||
/* Dossiê vivo — content visible by default; enhance only when opted in */
|
/* Dossiê vivo — content visible by default; enhance only when opted in */
|
||||||
[data-chapter-index] {
|
[data-chapter-index] {
|
||||||
display: none;
|
display: none;
|
||||||
@@ -150,6 +228,9 @@
|
|||||||
|
|
||||||
[data-chapter-index] a {
|
[data-chapter-index] a {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 2.75rem;
|
||||||
padding-left: 0.75rem;
|
padding-left: 0.75rem;
|
||||||
font-size: var(--amare-text-xs);
|
font-size: var(--amare-text-xs);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -157,6 +238,7 @@
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--amare-color-muted);
|
color: var(--amare-color-muted);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
overflow-wrap: break-word;
|
||||||
transition: color var(--amare-duration-fast) var(--amare-ease-standard);
|
transition: color var(--amare-duration-fast) var(--amare-ease-standard);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,60 +261,81 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: no-preference) {
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
:where(a, button, input, textarea, select) {
|
||||||
|
transition-duration: var(--amare-motion-feedback);
|
||||||
|
transition-property: color, background-color, border-color, transform;
|
||||||
|
transition-timing-function: var(--amare-ease-arrival);
|
||||||
|
}
|
||||||
|
|
||||||
|
:where(a, button:not(:disabled)):active {
|
||||||
|
transform: translateY(1px);
|
||||||
|
}
|
||||||
|
|
||||||
/* Transform/clip only — never fade text opacity (axe + WCAG mid-transition). */
|
/* Transform/clip only — never fade text opacity (axe + WCAG mid-transition). */
|
||||||
html[data-motion="enhance"] [data-motion="dossie-hero"] [data-motion-beat="seal"],
|
html[data-motion="enhance"] [data-motion="page-open"]:not(.is-active) [data-motion-beat] {
|
||||||
html[data-motion="enhance"] [data-motion="dossie-hero"] [data-motion-beat="cta"] {
|
transform: translateY(var(--amare-motion-distance-y));
|
||||||
transform: translateY(0.5rem);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
html[data-motion="enhance"] [data-motion="dossie-hero"] [data-motion-beat="title"] {
|
html[data-motion="enhance"] [data-motion="page-open"]:not(.is-active) [data-motion-beat="media"] {
|
||||||
transform: translateY(0.4rem);
|
|
||||||
}
|
|
||||||
|
|
||||||
html[data-motion="enhance"] [data-motion="dossie-hero"] [data-motion-beat="media"] {
|
|
||||||
clip-path: inset(4% 0 0 0);
|
clip-path: inset(4% 0 0 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
html[data-motion="enhance"] [data-motion="dossie-hero"].is-active [data-motion-beat] {
|
html[data-motion="enhance"] [data-motion="page-open"].is-active [data-motion-beat] {
|
||||||
transform: none;
|
transform: none;
|
||||||
clip-path: inset(0 0 0 0);
|
clip-path: inset(0 0 0 0);
|
||||||
transition:
|
transition:
|
||||||
transform var(--amare-duration-slow) var(--amare-ease-arrival),
|
transform var(--amare-motion-enter) var(--amare-ease-arrival),
|
||||||
clip-path var(--amare-duration-focal) var(--amare-ease-arrival);
|
clip-path var(--amare-motion-enter) var(--amare-ease-arrival);
|
||||||
|
transition-delay: min(
|
||||||
|
calc(var(--motion-index, 0) * var(--amare-motion-stagger)),
|
||||||
|
var(--amare-motion-stagger-cap)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
html[data-motion="enhance"] [data-motion="dossie-hero"].is-active [data-motion-beat="seal"] {
|
html[data-motion="enhance"] [data-reveal] {
|
||||||
transition-delay: 0ms;
|
--motion-translate-x: 0;
|
||||||
|
--motion-translate-y: var(--amare-motion-distance-y);
|
||||||
}
|
}
|
||||||
|
|
||||||
html[data-motion="enhance"] [data-motion="dossie-hero"].is-active [data-motion-beat="title"] {
|
html[data-motion="enhance"] [data-reveal-from="left"] {
|
||||||
transition-delay: 80ms;
|
--motion-translate-x: calc(-1 * var(--amare-motion-distance-x-mobile));
|
||||||
|
--motion-translate-y: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
html[data-motion="enhance"] [data-motion="dossie-hero"].is-active [data-motion-beat="media"] {
|
html[data-motion="enhance"] [data-reveal-from="right"] {
|
||||||
transition-delay: 120ms;
|
--motion-translate-x: var(--amare-motion-distance-x-mobile);
|
||||||
|
--motion-translate-y: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
html[data-motion="enhance"] [data-motion="dossie-hero"].is-active [data-motion-beat="cta"] {
|
@media (min-width: 768px) {
|
||||||
transition-delay: 220ms;
|
html[data-motion="enhance"] [data-reveal-from="left"] {
|
||||||
|
--motion-translate-x: calc(-1 * var(--amare-motion-distance-x));
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-motion="enhance"] [data-reveal-from="right"] {
|
||||||
|
--motion-translate-x: var(--amare-motion-distance-x);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
html[data-motion="enhance"] [data-reveal]:not(.is-revealed) {
|
html[data-motion="enhance"] [data-reveal]:not(.is-revealed) {
|
||||||
transform: translateY(0.5rem);
|
transform: translate3d(var(--motion-translate-x), var(--motion-translate-y), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html[data-motion="enhance"] [data-reveal][data-reveal-media]:not(.is-revealed) {
|
||||||
|
clip-path: inset(3% 0 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-motion="enhance"] [data-reveal-group] [data-reveal].is-revealed,
|
||||||
html[data-motion="enhance"] [data-reveal].is-revealed {
|
html[data-motion="enhance"] [data-reveal].is-revealed {
|
||||||
transform: none;
|
transform: none;
|
||||||
transition: transform var(--amare-duration-slow) var(--amare-ease-arrival);
|
clip-path: inset(0 0 0 0);
|
||||||
}
|
transition:
|
||||||
|
transform var(--amare-motion-enter) var(--amare-ease-arrival),
|
||||||
html[data-motion="enhance"] [data-motion="page-open"]:not(.is-active) {
|
clip-path var(--amare-motion-enter) var(--amare-ease-arrival);
|
||||||
transform: translateY(0.4rem);
|
transition-delay: min(
|
||||||
}
|
calc(var(--motion-index, 0) * var(--amare-motion-stagger)),
|
||||||
|
var(--amare-motion-stagger-cap)
|
||||||
html[data-motion="enhance"] [data-motion="page-open"].is-active {
|
);
|
||||||
transform: none;
|
|
||||||
transition: transform var(--amare-duration-normal) var(--amare-ease-arrival);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
62
resources/css/filament/admin/theme.css
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
@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;
|
||||||
|
}
|
||||||
@@ -59,6 +59,15 @@
|
|||||||
--amare-duration-focal: 720ms;
|
--amare-duration-focal: 720ms;
|
||||||
--amare-ease-standard: cubic-bezier(0.4, 0, 0.2, 1);
|
--amare-ease-standard: cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
--amare-ease-arrival: cubic-bezier(0.16, 1, 0.3, 1);
|
--amare-ease-arrival: cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
|
||||||
|
/* Editorial motion */
|
||||||
|
--amare-motion-enter: 500ms;
|
||||||
|
--amare-motion-feedback: 150ms;
|
||||||
|
--amare-motion-stagger: 90ms;
|
||||||
|
--amare-motion-stagger-cap: 270ms;
|
||||||
|
--amare-motion-distance-y: 12px;
|
||||||
|
--amare-motion-distance-x: 24px;
|
||||||
|
--amare-motion-distance-x-mobile: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
@@ -67,6 +76,13 @@
|
|||||||
--amare-duration-normal: 0.01ms;
|
--amare-duration-normal: 0.01ms;
|
||||||
--amare-duration-slow: 0.01ms;
|
--amare-duration-slow: 0.01ms;
|
||||||
--amare-duration-focal: 0.01ms;
|
--amare-duration-focal: 0.01ms;
|
||||||
|
--amare-motion-enter: 0.01ms;
|
||||||
|
--amare-motion-feedback: 0.01ms;
|
||||||
|
--amare-motion-stagger: 0ms;
|
||||||
|
--amare-motion-stagger-cap: 0ms;
|
||||||
|
--amare-motion-distance-y: 0px;
|
||||||
|
--amare-motion-distance-x: 0px;
|
||||||
|
--amare-motion-distance-x-mobile: 0px;
|
||||||
}
|
}
|
||||||
|
|
||||||
*,
|
*,
|
||||||
|
|||||||