Compare commits
31 Commits
fix/seed-f
...
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 |
@@ -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
|
||||||
|
|||||||
46
.github/workflows/ci.yml
vendored
46
.github/workflows/ci.yml
vendored
@@ -14,7 +14,7 @@ env:
|
|||||||
APP_KEY: base64:NXm/6jIyFcDGHoMKGc5QZuSaq0dRZFYPg1Isuy1fNvE=
|
APP_KEY: base64:NXm/6jIyFcDGHoMKGc5QZuSaq0dRZFYPg1Isuy1fNvE=
|
||||||
APP_LOCALE: pt_BR
|
APP_LOCALE: pt_BR
|
||||||
APP_FALLBACK_LOCALE: pt_BR
|
APP_FALLBACK_LOCALE: pt_BR
|
||||||
APP_TIMEZONE: America/Fortaleza
|
APP_TIMEZONE: America/Sao_Paulo
|
||||||
BCRYPT_ROUNDS: 4
|
BCRYPT_ROUNDS: 4
|
||||||
CACHE_STORE: database
|
CACHE_STORE: database
|
||||||
DB_CONNECTION: pgsql
|
DB_CONNECTION: pgsql
|
||||||
@@ -51,10 +51,36 @@ jobs:
|
|||||||
- run: composer pint:check
|
- run: composer pint:check
|
||||||
- run: composer phpstan
|
- run: composer phpstan
|
||||||
- run: composer audit
|
- run: composer audit
|
||||||
|
# audit-level=high: package.json has no runtime `dependencies` today (npm
|
||||||
|
# itself reports prod:1, 0 vulnerabilities), so this is currently a
|
||||||
|
# vacuous forward guard rather than an active protection. `high` is
|
||||||
|
# chosen deliberately over `low`/`moderate` so that once a real runtime
|
||||||
|
# JS dependency is added, the gate flags exploitable issues without
|
||||||
|
# becoming noisy on every transitive dev-only advisory.
|
||||||
|
- run: npm audit --omit=dev --audit-level=high
|
||||||
|
|
||||||
unit:
|
unit:
|
||||||
name: unit
|
name: unit
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
# Postgres is required here (not just in `feature`) because the
|
||||||
|
# Domain/Application coverage gate below runs the Feature suite too: the
|
||||||
|
# App\Application\Queries\Marketing\* classes are only exercised through
|
||||||
|
# Feature (HTTP) tests today, so a Unit-only coverage run would undercount
|
||||||
|
# them well under the 80% threshold.
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:17
|
||||||
|
env:
|
||||||
|
POSTGRES_DB: amare_test
|
||||||
|
POSTGRES_USER: amare
|
||||||
|
POSTGRES_PASSWORD: secret
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
|
options: >-
|
||||||
|
--health-cmd "pg_isready -U amare -d amare_test"
|
||||||
|
--health-interval 5s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 10
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v5
|
||||||
- run: cp .env.example .env
|
- run: cp .env.example .env
|
||||||
@@ -63,7 +89,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
php-version: "8.4"
|
php-version: "8.4"
|
||||||
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd
|
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd
|
||||||
coverage: none
|
coverage: pcov
|
||||||
|
|
||||||
- uses: actions/cache@v5
|
- uses: actions/cache@v5
|
||||||
with:
|
with:
|
||||||
@@ -71,10 +97,24 @@ jobs:
|
|||||||
key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }}
|
key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }}
|
||||||
restore-keys: composer-${{ runner.os }}-
|
restore-keys: composer-${{ runner.os }}-
|
||||||
|
|
||||||
|
- uses: actions/cache@v5
|
||||||
|
with:
|
||||||
|
path: ~/.npm
|
||||||
|
key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
|
||||||
|
restore-keys: npm-${{ runner.os }}-
|
||||||
|
|
||||||
- run: composer install --no-interaction --prefer-dist
|
- run: composer install --no-interaction --prefer-dist
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
- run: npm run build
|
- run: npm run build
|
||||||
- run: composer test:unit
|
- run: composer test:unit
|
||||||
|
- run: php artisan migrate --force
|
||||||
|
# Domain/Application coverage gate (SPEC.md L2351, 80% minimum). Scoped
|
||||||
|
# via phpunit.coverage.xml rather than editing the project-wide
|
||||||
|
# phpunit.xml <source> block, which stays covering all of app/ for
|
||||||
|
# every other test/coverage invocation. app/Domain currently holds only
|
||||||
|
# the DomainModule placeholder (zero executable lines), so in practice
|
||||||
|
# this gates app/Application until Domain gains real logic.
|
||||||
|
- run: composer test:coverage
|
||||||
|
|
||||||
feature:
|
feature:
|
||||||
name: feature
|
name: feature
|
||||||
@@ -181,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 \
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
27
AGENTS.md
27
AGENTS.md
@@ -7,11 +7,12 @@ 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`.
|
||||||
|
|
||||||
@@ -24,7 +25,7 @@ Always work in a git worktree created from the `main` ref — never modify `main
|
|||||||
Hooks live in `.husky/` and auto-install on any plain `npm install` via the `prepare` script. Note `composer setup` runs `npm install --ignore-scripts`, which skips hook installation — after setup, run `npm install` once (or `npx husky`) to activate hooks.
|
Hooks live in `.husky/` and auto-install on any plain `npm install` via the `prepare` script. Note `composer setup` runs `npm install --ignore-scripts`, which skips hook installation — after setup, run `npm install` once (or `npx husky`) to activate hooks.
|
||||||
|
|
||||||
- `pre-commit`: runs `composer pint:check` and `composer phpstan`.
|
- `pre-commit`: runs `composer pint:check` and `composer phpstan`.
|
||||||
- `pre-push`: gates on the `amare_test` database (settings parsed from `phpunit.xml`), blocks the push with a `docker compose up -d` 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
|
||||||
|
|
||||||
@@ -41,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
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
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)
|
||||||
|
|||||||
112
SPEC.md
112
SPEC.md
@@ -20,7 +20,7 @@
|
|||||||
| Princípio principal | YAGNI — implementar somente o necessário para validar o produto |
|
| Princípio principal | YAGNI — implementar somente o necessário para validar o produto |
|
||||||
| Arquitetura | Monólito modular Laravel |
|
| Arquitetura | Monólito modular Laravel |
|
||||||
| Área interna | Filament |
|
| Área interna | Filament |
|
||||||
| Área pública | 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 |
|
||||||
@@ -194,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;
|
||||||
@@ -208,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
|
||||||
|
|
||||||
@@ -315,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
|
||||||
|
|
||||||
@@ -1553,7 +1564,7 @@ Constraints de banco devem proteger:
|
|||||||
| Runtime | PHP com versão minor fixada no Docker |
|
| Runtime | PHP com versão minor fixada no Docker |
|
||||||
| Framework | Laravel 13 |
|
| Framework | Laravel 13 |
|
||||||
| Admin | Filament 5 |
|
| Admin | Filament 5 |
|
||||||
| UI pública | 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 |
|
||||||
@@ -1774,21 +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
|
||||||
|
|
||||||
> **Estado atual (Fases 0–1):** o formulário é implementado em Blade + Controller (`POST /contato`, `ContactBriefingRequest`), conforme WEB-05. Se a Fase 2 mantiver Blade + Controller, os requisitos abaixo valem para o formulário e seus testes independentemente da tecnologia; a criação de Lead segue para a Fase 2.
|
> **Estado atual:** o formulário é implementado em Blade + Controller (`POST /contato`, `ContactBriefingRequest`), conforme WEB-05, e essa é a abordagem aceita — não um estágio provisório. Os requisitos abaixo valem independentemente da tecnologia; a criação de Lead segue para a Fase 2.
|
||||||
|
|
||||||
O componente deve:
|
O formulário deve:
|
||||||
|
|
||||||
- ter estado tipado ou Form Object quando útil;
|
- ter estado tipado ou Form Object quando útil;
|
||||||
- validar no servidor;
|
- validar no servidor;
|
||||||
@@ -1797,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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -2324,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
|
||||||
@@ -2335,7 +2349,7 @@ O agente deve implementar na sequência, salvo instrução explícita.
|
|||||||
- [x] Filament instalado e autenticado;
|
- [x] Filament instalado e autenticado;
|
||||||
- [x] Livewire configurado;
|
- [x] Livewire configurado;
|
||||||
- [x] Tailwind/Vite;
|
- [x] Tailwind/Vite;
|
||||||
- [~] FrankenPHP e Docker Compose local (imagem pronta; serviço de aplicação local pendente);
|
- [x] FrankenPHP e Docker Compose local;
|
||||||
- [x] papéis admin/assistant;
|
- [x] papéis admin/assistant;
|
||||||
- [x] Pint;
|
- [x] Pint;
|
||||||
- [x] PHPStan/Larastan;
|
- [x] PHPStan/Larastan;
|
||||||
@@ -2346,11 +2360,11 @@ O agente deve implementar na sequência, salvo instrução explícita.
|
|||||||
- [x] design tokens mínimos;
|
- [x] design tokens mínimos;
|
||||||
- [x] healthcheck;
|
- [x] healthcheck;
|
||||||
- [x] seed de admin local.
|
- [x] seed de admin local.
|
||||||
- [ ] verificação de e-mail e reset seguro (MustVerifyEmail);
|
- [x] verificação de e-mail e reset seguro (MustVerifyEmail);
|
||||||
- [ ] npm audit no `composer quality` e no job `static`;
|
- [x] npm audit no `composer quality` e no job `static`;
|
||||||
- [ ] gate de cobertura `Domain`/`Application` ≥ 80%;
|
- [x] gate de cobertura `Domain`/`Application` ≥ 80%;
|
||||||
- [ ] serviço de aplicação FrankenPHP no Compose local;
|
- [x] serviço de aplicação FrankenPHP no Compose local;
|
||||||
- [ ] hello-world implantado em staging (critério de saída).
|
- [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.
|
> 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.
|
||||||
|
|
||||||
@@ -2374,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;
|
||||||
@@ -2391,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;
|
||||||
@@ -2406,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;
|
||||||
@@ -2422,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;
|
||||||
@@ -2500,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 |
|
||||||
@@ -2513,6 +2527,8 @@ Toda operação financeira deve:
|
|||||||
| ADR-012 | E-mail transacional via Resend (mailer nativo Laravel) | Aceita |
|
| ADR-012 | E-mail transacional via Resend (mailer nativo Laravel) | Aceita |
|
||||||
| ADR-013 | Design system Heritage Editorial para o site público | Aceita |
|
| ADR-013 | Design system Heritage Editorial para o site público | Aceita |
|
||||||
| ADR-014 | Deploy via Dokploy Compose com imagem imutável por SHA no GHCR | Aceita |
|
| ADR-014 | Deploy via Dokploy Compose com imagem imutável por SHA no GHCR | Aceita |
|
||||||
|
| ADR-015 | Site público permanece Blade + JS vanilla; Livewire e Alpine ficam restritos ao Filament até o gatilho de §22. Emenda o texto da ADR-002 | Aceita |
|
||||||
|
| ADR-016 | Lançamento de 31/08/2026 entrega apenas o site institucional (Fases 0–1); Fases 2–5 seguem especificadas e adiadas, sem data | Aceita |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -2529,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 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -2536,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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
85
app/Domain/Contact/BrazilianPhoneNumber.php
Normal file
85
app/Domain/Contact/BrazilianPhoneNumber.php
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Domain\Contact;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizes a raw phone/WhatsApp number typed into the public briefing
|
||||||
|
* form into a canonical, human-readable Brazilian format.
|
||||||
|
*
|
||||||
|
* The briefing e-mail simply prints the field value in a table, so the
|
||||||
|
* canonical form must stay legible for the staff member reading it —
|
||||||
|
* "(11) 98888-7777" rather than an opaque "11988887777" digit string.
|
||||||
|
*/
|
||||||
|
final class BrazilianPhoneNumber
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Formats to "(DD) 9XXXX-XXXX" for an 11-digit mobile number or
|
||||||
|
* "(DD) XXXX-XXXX" for a 10-digit landline number, stripping a
|
||||||
|
* leading "+55"/"55" country code when present.
|
||||||
|
*
|
||||||
|
* A 10- or 11-digit string is only formatted when it is structurally
|
||||||
|
* plausible as a Brazilian number: the first two digits must be a
|
||||||
|
* possible DDD (`[1-9][1-9]`, since Brazilian area codes run 11-99
|
||||||
|
* and never carry a '0' in either position), and an 11-digit number
|
||||||
|
* must additionally have a '9' as its third digit (mandatory on all
|
||||||
|
* Brazilian mobile numbers since 2012). An explicit "+55" prefix that
|
||||||
|
* leaves no digits for a DDD (e.g. "+55 98888-7777") is treated as a
|
||||||
|
* number missing its area code, not as DDD 55.
|
||||||
|
*
|
||||||
|
* When the digit count does not match either shape, or the shape
|
||||||
|
* fails the checks above (foreign numbers, partial input, extensions,
|
||||||
|
* etc.), the original text is preserved — only whitespace is
|
||||||
|
* collapsed — so no information the recipient might need is
|
||||||
|
* discarded or silently fabricated.
|
||||||
|
*/
|
||||||
|
public static function normalize(string $raw): string
|
||||||
|
{
|
||||||
|
$trimmed = trim($raw);
|
||||||
|
$collapsed = preg_replace('/\s+/', ' ', $trimmed) ?? $trimmed;
|
||||||
|
|
||||||
|
// Only reformat when the text is made exclusively of phone
|
||||||
|
// characters. Anything else — "(WhatsApp)", "falar com João",
|
||||||
|
// a ramal — is information the recipient needs, so it is left
|
||||||
|
// untouched rather than stripped away by the digit extraction.
|
||||||
|
if (preg_match('/^[0-9()+\-.\/ ]+$/', $collapsed) !== 1) {
|
||||||
|
return $collapsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
$digits = preg_replace('/\D+/', '', $collapsed) ?? '';
|
||||||
|
|
||||||
|
if (in_array(strlen($digits), [12, 13], true) && str_starts_with($digits, '55')) {
|
||||||
|
$digits = substr($digits, 2);
|
||||||
|
} elseif (str_starts_with($digits, '55') && preg_match('/^\+\s*55\b/', $collapsed) === 1) {
|
||||||
|
// An explicit "+55" was written, but the total digit count
|
||||||
|
// never reached 12/13, meaning nothing precedes it that could
|
||||||
|
// be a DDD — e.g. "+55 98888-7777" is a mobile number missing
|
||||||
|
// its area code, not DDD 55 with a coincidentally-matching
|
||||||
|
// subscriber number. Guessing a DDD here would fabricate one.
|
||||||
|
return $collapsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
return match (strlen($digits)) {
|
||||||
|
11 => self::isPlausibleDdd($digits) && $digits[2] === '9'
|
||||||
|
? sprintf('(%s) %s-%s', substr($digits, 0, 2), substr($digits, 2, 5), substr($digits, 7))
|
||||||
|
: $collapsed,
|
||||||
|
10 => self::isPlausibleDdd($digits)
|
||||||
|
? sprintf('(%s) %s-%s', substr($digits, 0, 2), substr($digits, 2, 4), substr($digits, 6))
|
||||||
|
: $collapsed,
|
||||||
|
default => $collapsed,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Brazilian DDD (area code) runs 11-99: the first digit is never
|
||||||
|
* '0' (not a valid leading digit) and the second is never '0' either
|
||||||
|
* (no DDD like "10", "20", "30" exists). This does not check that the
|
||||||
|
* DDD is one of the officially assigned codes — only that its shape
|
||||||
|
* is plausible enough to distinguish it from a foreign number.
|
||||||
|
*/
|
||||||
|
private static function isPlausibleDdd(string $digits): bool
|
||||||
|
{
|
||||||
|
return preg_match('/^[1-9][1-9]/', $digits) === 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
181
app/Domain/Contact/MarketingOrigin.php
Normal file
181
app/Domain/Contact/MarketingOrigin.php
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Domain\Contact;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Captures the marketing origin of a visit (SPEC.md WEB-05, "origem de
|
||||||
|
* marketing capturada quando disponível") from raw, untrusted request
|
||||||
|
* data and turns it into a short, human-readable pt-BR label for the
|
||||||
|
* internal briefing e-mail.
|
||||||
|
*
|
||||||
|
* Deliberately framework-free (no Illuminate\Http\Request dependency) so
|
||||||
|
* it stays a pure transformation, mirroring BrazilianPhoneNumber: callers
|
||||||
|
* in the HTTP layer extract the query string / referrer and hand them in
|
||||||
|
* as primitives.
|
||||||
|
*/
|
||||||
|
final class MarketingOrigin
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Session key both the capturing middleware and the controller read
|
||||||
|
* from — kept here so there is exactly one name for the concept.
|
||||||
|
*/
|
||||||
|
public const string SESSION_KEY = 'marketing_origin';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Caps every captured value. This is marketing metadata, not user
|
||||||
|
* content — a crafted query string must not be able to bloat the
|
||||||
|
* session (SPEC.md §12.5 LGPD).
|
||||||
|
*/
|
||||||
|
private const int MAX_LENGTH = 100;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
private const array UTM_KEYS = [
|
||||||
|
'utm_source',
|
||||||
|
'utm_medium',
|
||||||
|
'utm_campaign',
|
||||||
|
'utm_term',
|
||||||
|
'utm_content',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* pt-BR labels for the UTM keys, in the order they should be
|
||||||
|
* displayed when composing the briefing e-mail row.
|
||||||
|
*
|
||||||
|
* @var array<string, string>
|
||||||
|
*/
|
||||||
|
private const array LABELS = [
|
||||||
|
'utm_source' => 'origem',
|
||||||
|
'utm_medium' => 'mídia',
|
||||||
|
'utm_campaign' => 'campanha',
|
||||||
|
'utm_term' => 'termo',
|
||||||
|
'utm_content' => 'conteúdo',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads UTM parameters from the query string, falling back to the
|
||||||
|
* HTTP referrer when none are present. Returns an empty array when
|
||||||
|
* neither is available — capturing nothing is a valid outcome
|
||||||
|
* ("capturada quando disponível").
|
||||||
|
*
|
||||||
|
* A referrer pointing back at this same host is not a marketing
|
||||||
|
* origin — it is the visitor clicking from one internal page to
|
||||||
|
* another (common once the original session has expired and a
|
||||||
|
* fresh one starts mid-visit) — so it is discarded rather than
|
||||||
|
* stored as noise (SPEC.md §12.5, "coletar apenas dados
|
||||||
|
* necessários").
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $queryParams
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public static function capture(array $queryParams, ?string $referrer, ?string $requestHost): array
|
||||||
|
{
|
||||||
|
$utm = [];
|
||||||
|
|
||||||
|
foreach (self::UTM_KEYS as $key) {
|
||||||
|
$value = $queryParams[$key] ?? null;
|
||||||
|
|
||||||
|
if (is_string($value) && trim($value) !== '') {
|
||||||
|
$utm[$key] = self::sanitize($value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($utm !== []) {
|
||||||
|
return $utm;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_string($referrer) && trim($referrer) !== '' && ! self::isSameHost($referrer, $requestHost)) {
|
||||||
|
$origin = self::originOf($referrer);
|
||||||
|
|
||||||
|
if ($origin !== null) {
|
||||||
|
return ['referrer' => self::sanitize($origin)];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reduces a referrer URL to its scheme+host identity, dropping the
|
||||||
|
* path, query string, fragment, and any userinfo. The referrer is
|
||||||
|
* only ever used as a marketing-origin *site* label (SPEC.md WEB-05)
|
||||||
|
* — the query string can carry data that identifies an individual
|
||||||
|
* (e.g. a personalized campaign link's `?email=...`), which would
|
||||||
|
* exceed "coletar apenas dados necessários" (SPEC.md §12.5) once it
|
||||||
|
* lands in the session and the internal briefing e-mail.
|
||||||
|
*/
|
||||||
|
private static function originOf(string $referrer): ?string
|
||||||
|
{
|
||||||
|
$host = parse_url($referrer, PHP_URL_HOST);
|
||||||
|
|
||||||
|
if (! is_string($host) || $host === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$scheme = parse_url($referrer, PHP_URL_SCHEME);
|
||||||
|
$prefix = is_string($scheme) && $scheme !== '' ? "{$scheme}://" : '';
|
||||||
|
|
||||||
|
return "{$prefix}{$host}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function isSameHost(string $referrer, ?string $requestHost): bool
|
||||||
|
{
|
||||||
|
if ($requestHost === null || $requestHost === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$referrerHost = parse_url($referrer, PHP_URL_HOST);
|
||||||
|
|
||||||
|
return is_string($referrerHost) && strcasecmp($referrerHost, $requestHost) === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composes the single-row, pt-BR display value for the internal
|
||||||
|
* briefing e-mail. Returns null when nothing was captured, letting
|
||||||
|
* the caller fall back to the same "—" convention already used for
|
||||||
|
* other optional briefing fields.
|
||||||
|
*
|
||||||
|
* Accepts loosely-typed input on purpose: this reads back whatever
|
||||||
|
* was put in the session, so it is treated as untrusted rather than
|
||||||
|
* assumed to still match the shape `capture()` produced.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $origin
|
||||||
|
*/
|
||||||
|
public static function describe(array $origin): ?string
|
||||||
|
{
|
||||||
|
if (isset($origin['referrer']) && is_string($origin['referrer']) && $origin['referrer'] !== '') {
|
||||||
|
return $origin['referrer'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$parts = [];
|
||||||
|
|
||||||
|
foreach (self::LABELS as $key => $label) {
|
||||||
|
if (isset($origin[$key]) && is_string($origin[$key]) && $origin[$key] !== '') {
|
||||||
|
$parts[] = "{$label}: {$origin[$key]}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $parts === [] ? null : implode(' | ', $parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Untrusted input (query string, HTTP referrer) that ends up in an
|
||||||
|
* HTML e-mail: strip any markup, drop control characters (also
|
||||||
|
* closes off header-injection-style newline tricks), trim, and cap
|
||||||
|
* the length before it ever reaches storage.
|
||||||
|
*/
|
||||||
|
private static function sanitize(string $value): string
|
||||||
|
{
|
||||||
|
$withoutTags = strip_tags($value);
|
||||||
|
// No /u flag: this is a byte-wise scrub of ASCII control bytes, so
|
||||||
|
// it cannot land mid-sequence in valid UTF-8 (continuation bytes
|
||||||
|
// are all >= 0x80) — unlike the Unicode-mode regex, it never
|
||||||
|
// blanks the whole string just because one byte is malformed.
|
||||||
|
$withoutControlChars = preg_replace('/[\x00-\x1F\x7F]/', '', $withoutTags) ?? '';
|
||||||
|
|
||||||
|
return mb_substr(trim($withoutControlChars), 0, self::MAX_LENGTH);
|
||||||
|
}
|
||||||
|
}
|
||||||
43
app/Filament/Pages/Auth/RequestPasswordReset.php
Normal file
43
app/Filament/Pages/Auth/RequestPasswordReset.php
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Pages\Auth;
|
||||||
|
|
||||||
|
use Filament\Auth\Pages\PasswordReset\RequestPasswordReset as BaseRequestPasswordReset;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Illuminate\Support\Facades\Password;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Overrides Filament's stock request-reset page to close an account
|
||||||
|
* enumeration leak (SPEC 12.1 / ADM-01 "reset seguro"). The vendor page
|
||||||
|
* shows a distinguishable danger notification for two statuses that only
|
||||||
|
* ever occur for existing users, letting an attacker tell registered emails
|
||||||
|
* apart from unregistered ones:
|
||||||
|
*
|
||||||
|
* - Password::INVALID_USER — no such user at all.
|
||||||
|
* - Password::RESET_THROTTLED — Illuminate\Auth\Passwords\PasswordBroker
|
||||||
|
* only returns this when a token was already recently created for that
|
||||||
|
* user, which requires the user to exist. Two requests for the same
|
||||||
|
* registered email (allowed by this page's own rate limit of 2) would
|
||||||
|
* otherwise flip from "sent" to "throttled" while an unknown email stays
|
||||||
|
* "sent" both times — the same leak, reached a different way. The
|
||||||
|
* tradeoff: a legitimate user requesting twice sees "sent" again instead
|
||||||
|
* of "please wait", which is an acceptable UX cost on an admin-only panel.
|
||||||
|
*
|
||||||
|
* Existing-but-ineligible users (inactive, or since this panel now requires
|
||||||
|
* a verified email) already resolve to Password::RESET_LINK_SENT with no
|
||||||
|
* mail sent — see the vendor callback in the base class — so only these two
|
||||||
|
* branches need normalizing here.
|
||||||
|
*/
|
||||||
|
class RequestPasswordReset extends BaseRequestPasswordReset
|
||||||
|
{
|
||||||
|
protected function getFailureNotification(string $status): ?Notification
|
||||||
|
{
|
||||||
|
if (in_array($status, [Password::INVALID_USER, Password::RESET_THROTTLED], true)) {
|
||||||
|
return $this->getSentNotification(Password::RESET_LINK_SENT);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parent::getFailureNotification($status);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Http\Controllers\PublicSite;
|
namespace App\Http\Controllers\PublicSite;
|
||||||
|
|
||||||
|
use App\Domain\Contact\MarketingOrigin;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Http\Requests\PublicSite\ContactBriefingRequest;
|
use App\Http\Requests\PublicSite\ContactBriefingRequest;
|
||||||
use App\Mail\ContactBriefing;
|
use App\Mail\ContactBriefing;
|
||||||
@@ -34,7 +35,7 @@ final class ContactController extends Controller
|
|||||||
$this->rememberSubmission($validated);
|
$this->rememberSubmission($validated);
|
||||||
|
|
||||||
$settings = SiteSetting::instance();
|
$settings = SiteSetting::instance();
|
||||||
$fields = $this->buildFields($validated);
|
$fields = $this->buildFields($validated, $this->resolveMarketingOrigin($request));
|
||||||
|
|
||||||
$this->dispatchEmails($settings, $validated['nome'], $validated['email'], $fields);
|
$this->dispatchEmails($settings, $validated['nome'], $validated['email'], $fields);
|
||||||
|
|
||||||
@@ -44,7 +45,7 @@ final class ContactController extends Controller
|
|||||||
/**
|
/**
|
||||||
* @param array<string, mixed> $validated
|
* @param array<string, mixed> $validated
|
||||||
*/
|
*/
|
||||||
private function buildFields(array $validated): array
|
private function buildFields(array $validated, ?string $marketingOrigin): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'Nome' => (string) $validated['nome'],
|
'Nome' => (string) $validated['nome'],
|
||||||
@@ -56,9 +57,32 @@ final class ContactController extends Controller
|
|||||||
'Número estimado de convidados' => isset($validated['convidados']) ? (string) $validated['convidados'] : null,
|
'Número estimado de convidados' => isset($validated['convidados']) ? (string) $validated['convidados'] : null,
|
||||||
'Serviço de interesse' => isset($validated['servico_interesse']) ? (string) $validated['servico_interesse'] : null,
|
'Serviço de interesse' => isset($validated['servico_interesse']) ? (string) $validated['servico_interesse'] : null,
|
||||||
'Mensagem' => (string) $validated['mensagem'],
|
'Mensagem' => (string) $validated['mensagem'],
|
||||||
|
'Origem de marketing' => $marketingOrigin,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the origin captured on arrival (SPEC.md WEB-05) so it can
|
||||||
|
* ride along with the internal briefing e-mail only — never the
|
||||||
|
* confirmation sent to the visitor. Fail-open: any problem reading
|
||||||
|
* or interpreting the session value must never block the
|
||||||
|
* submission, so it degrades to "not captured" instead.
|
||||||
|
*/
|
||||||
|
private function resolveMarketingOrigin(ContactBriefingRequest $request): ?string
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$origin = $request->session()->get(MarketingOrigin::SESSION_KEY, []);
|
||||||
|
|
||||||
|
return is_array($origin) ? MarketingOrigin::describe($origin) : null;
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
Log::warning('Falha ao ler origem de marketing armazenada (ignorada; fail-open)', [
|
||||||
|
'exception' => $exception::class,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, mixed> $validated
|
* @param array<string, mixed> $validated
|
||||||
*/
|
*/
|
||||||
|
|||||||
85
app/Http/Middleware/CaptureMarketingOrigin.php
Normal file
85
app/Http/Middleware/CaptureMarketingOrigin.php
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use App\Domain\Contact\MarketingOrigin;
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Captures the marketing origin (UTM parameters, or the HTTP referrer as
|
||||||
|
* a fallback) on the first public page load of a visit and stores it in
|
||||||
|
* the session, so it can later travel with a contact briefing submission
|
||||||
|
* (SPEC.md WEB-05).
|
||||||
|
*
|
||||||
|
* First *informative* touch wins: nothing is written until a page load
|
||||||
|
* actually carries a UTM parameter or an external referrer, and once
|
||||||
|
* that happens later page views never overwrite it — navigating to
|
||||||
|
* another page without UTM parameters must not erase what arrived with
|
||||||
|
* the visitor. A page load with no signal at all is left unrecorded so a
|
||||||
|
* later, informative page load in the same session can still be
|
||||||
|
* captured.
|
||||||
|
*
|
||||||
|
* Fail-open by construction: any failure here is caught and logged
|
||||||
|
* without request data, and the request proceeds untouched. This path
|
||||||
|
* must never be able to block a page load or, downstream, a conversion.
|
||||||
|
*/
|
||||||
|
final class CaptureMarketingOrigin
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Technical routes that share the `web` middleware group but are never a
|
||||||
|
* human arriving at the site. A crawler fetching `/sitemap.xml?utm_source=…`
|
||||||
|
* would otherwise consume the first-touch slot with traffic that will never
|
||||||
|
* submit a briefing.
|
||||||
|
*
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
private const IGNORED_ROUTES = ['sitemap', 'robots'];
|
||||||
|
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
if (in_array($request->route()?->getName(), self::IGNORED_ROUTES, true)) {
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->captureFirstTouch($request);
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
Log::warning('Falha ao capturar origem de marketing (ignorada; fail-open)', [
|
||||||
|
'exception' => $exception::class,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function captureFirstTouch(Request $request): void
|
||||||
|
{
|
||||||
|
if (! $request->hasSession()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$session = $request->session();
|
||||||
|
|
||||||
|
if ($session->has(MarketingOrigin::SESSION_KEY)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$origin = MarketingOrigin::capture(
|
||||||
|
$request->query(),
|
||||||
|
$request->headers->get('referer'),
|
||||||
|
$request->getHost(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($origin === []) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$session->put(MarketingOrigin::SESSION_KEY, $origin);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,10 +4,26 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Http\Requests\PublicSite;
|
namespace App\Http\Requests\PublicSite;
|
||||||
|
|
||||||
|
use App\Domain\Contact\BrazilianPhoneNumber;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
final class ContactBriefingRequest extends FormRequest
|
final class ContactBriefingRequest extends FormRequest
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* Normaliza e-mail e telefone antes da validação (SPEC.md §12.3),
|
||||||
|
* mantendo o valor legível para quem recebe o briefing por e-mail.
|
||||||
|
*/
|
||||||
|
protected function prepareForValidation(): void
|
||||||
|
{
|
||||||
|
$email = $this->input('email');
|
||||||
|
$telefone = $this->input('telefone');
|
||||||
|
|
||||||
|
$this->merge([
|
||||||
|
'email' => is_string($email) ? mb_strtolower(trim($email)) : $email,
|
||||||
|
'telefone' => is_string($telefone) ? BrazilianPhoneNumber::normalize($telefone) : $telefone,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, array<int, string>>
|
* @return array<string, array<int, string>>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -8,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
|
||||||
|
|||||||
@@ -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
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'),
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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.
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -11,6 +11,7 @@ use App\Models\SiteSetting;
|
|||||||
use App\Support\PublicImageUploadRules;
|
use App\Support\PublicImageUploadRules;
|
||||||
use Illuminate\Database\Seeder;
|
use Illuminate\Database\Seeder;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Facades\App;
|
||||||
use Illuminate\Support\Facades\File;
|
use Illuminate\Support\Facades\File;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
@@ -18,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();
|
||||||
|
|||||||
@@ -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(),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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
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
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
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
BIN
docs/evidence/public-site-motion/about-desktop.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 170 KiB |
BIN
docs/evidence/public-site-motion/contact-mobile.png
Normal file
BIN
docs/evidence/public-site-motion/contact-mobile.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 155 KiB |
417
docs/operations/atualizacao-de-conteudo.md
Normal file
417
docs/operations/atualizacao-de-conteudo.md
Normal file
@@ -0,0 +1,417 @@
|
|||||||
|
# Atualização de conteúdo do site
|
||||||
|
|
||||||
|
Guia para quem cuida do conteúdo do site (textos, fotos, casos de portfólio,
|
||||||
|
depoimentos, serviços e dados de contato) sem precisar mexer em código.
|
||||||
|
|
||||||
|
O site é editado por um painel interno chamado **"admin"** — é uma tela web,
|
||||||
|
parecida com um formulário, onde cada bloco do site (textos da home, casos de
|
||||||
|
portfólio, depoimentos, etc.) vira uma "página" que você edita e salva.
|
||||||
|
|
||||||
|
> Antes de qualquer coisa, leia a seção **"A regra mais importante: o que
|
||||||
|
> torna algo visível no site"** — ela explica um comportamento que engana
|
||||||
|
> quase todo mundo na primeira vez.
|
||||||
|
|
||||||
|
## Sumário
|
||||||
|
|
||||||
|
- [Como entrar no painel](#como-entrar-no-painel)
|
||||||
|
- [A regra mais importante: o que torna algo visível no site](#a-regra-mais-importante-o-que-torna-algo-visível-no-site)
|
||||||
|
- [Editar os textos da home e das páginas institucionais](#editar-os-textos-da-home-e-das-páginas-institucionais)
|
||||||
|
- [Casos de portfólio: criar, editar, publicar e despublicar](#casos-de-portfólio-criar-editar-publicar-e-despublicar)
|
||||||
|
- [Depoimentos: adicionar e publicar](#depoimentos-adicionar-e-publicar)
|
||||||
|
- [Serviços: editar](#serviços-editar)
|
||||||
|
- [Contato: telefone, e-mail e redes sociais](#contato-telefone-e-mail-e-redes-sociais)
|
||||||
|
- [Imagens: formatos aceitos, tamanho e texto alternativo](#imagens-formatos-aceitos-tamanho-e-texto-alternativo)
|
||||||
|
- [Usuários: papéis, ativar e desativar contas](#usuários-papéis-ativar-e-desativar-contas)
|
||||||
|
- [O que NÃO mexer — e com quem falar](#o-que-não-mexer--e-com-quem-falar)
|
||||||
|
- [Seções da home que somem por completo se ficarem vazias](#seções-da-home-que-somem-por-completo-se-ficarem-vazias)
|
||||||
|
- [Sobre o arquivo depoimentos.md na raiz do repositório](#sobre-o-arquivo-depoimentosmd-na-raiz-do-repositório)
|
||||||
|
|
||||||
|
## Como entrar no painel
|
||||||
|
|
||||||
|
1. Acesse `/admin` no domínio do site (ex.: `https://seusite.com.br/admin`).
|
||||||
|
2. Se você não estiver logada, o painel mostra uma tela de login pedindo
|
||||||
|
e-mail e senha.
|
||||||
|
3. Faça login com o e-mail e senha da sua conta.
|
||||||
|
|
||||||
|
**Se você não conseguir entrar:**
|
||||||
|
|
||||||
|
- **Esqueceu a senha:** hoje o painel **não tem** um link de "esqueci minha
|
||||||
|
senha" self-service. Só uma pessoa com perfil **Administrador** consegue
|
||||||
|
trocar a senha de outra conta (em Usuários → editar a conta → campo
|
||||||
|
"Senha"). Se você é a única Administradora e está travada fora do painel,
|
||||||
|
vai precisar pedir para quem desenvolve o site trocar a senha diretamente
|
||||||
|
no banco de dados.
|
||||||
|
- **Mensagem de acesso negado / conta inativa:** sua conta pode estar com o
|
||||||
|
campo "Ativo" desligado. Só uma Administradora pode reativar isso em
|
||||||
|
Usuários (veja [O que NÃO mexer](#o-que-não-mexer--e-com-quem-falar) sobre
|
||||||
|
quem pode mexer em contas de usuário).
|
||||||
|
- **Perfil "Assistente" sem acesso a nada:** hoje, contas com o papel
|
||||||
|
"Assistente" conseguem fazer login, mas não conseguem ver nem editar
|
||||||
|
**nenhum** conteúdo — nem Configurações, nem Portfólio, nem Depoimentos,
|
||||||
|
nada. Isso é uma limitação atual do sistema, não um erro seu. Se você
|
||||||
|
precisa dar acesso de edição a alguém que não seja Administrador, avise
|
||||||
|
quem desenvolve o site — hoje não existe um meio-termo (“pode editar mas
|
||||||
|
não pode tudo”), só "Administrador com acesso total" ou "Assistente sem
|
||||||
|
acesso a nada".
|
||||||
|
|
||||||
|
## A regra mais importante: o que torna algo visível no site
|
||||||
|
|
||||||
|
Duas coisas controlam se algo aparece no site. Entender essas duas evita
|
||||||
|
99% das dúvidas de "publiquei e não apareceu" ou "não publiquei e apareceu".
|
||||||
|
|
||||||
|
### 1. O campo "Publicado em" não é um agendamento
|
||||||
|
|
||||||
|
Em Serviços, Portfólio e Depoimentos existe um campo chamado **"Publicado
|
||||||
|
em"**. A intuição normal seria pensar "se eu colocar uma data futura, ele só
|
||||||
|
aparece nessa data" — **isso está errado**. Assim que você preenche esse
|
||||||
|
campo com **qualquer** data/hora (passada, presente ou futura) e salva, o
|
||||||
|
item fica visível no site **imediatamente**. Não existe agendamento.
|
||||||
|
|
||||||
|
- **Para publicar:** preencha "Publicado em" com uma data qualquer e salve.
|
||||||
|
- **Para despublicar:** apague o conteúdo desse campo (deixe em branco) e
|
||||||
|
salve. Colocar uma data no futuro **não** esconde o item — ele continua
|
||||||
|
visível.
|
||||||
|
|
||||||
|
### 2. Home precisa de DOIS interruptores; páginas de listagem só de um
|
||||||
|
|
||||||
|
- Nas páginas de listagem completas — `/servicos` (todos os serviços) e
|
||||||
|
`/portfolio` (todos os casos) — basta o item estar **publicado** (campo
|
||||||
|
"Publicado em" preenchido) para aparecer.
|
||||||
|
- Já nos blocos de **Serviços** e **Portfólio dentro da home** (a página
|
||||||
|
inicial), o item só aparece se **as duas coisas** forem verdadeiras ao
|
||||||
|
mesmo tempo:
|
||||||
|
1. Estiver **publicado** ("Publicado em" preenchido), **e**
|
||||||
|
2. Tiver o interruptor **"Destaque"** ligado.
|
||||||
|
|
||||||
|
Publicar sozinho não é suficiente para aparecer na home — só coloca o item
|
||||||
|
na listagem completa. Você precisa também ligar "Destaque" se quiser que
|
||||||
|
apareça na home.
|
||||||
|
|
||||||
|
- **Depoimentos são a exceção**: o bloco de Depoimentos na home mostra
|
||||||
|
**todo** depoimento publicado, independente do interruptor "Destaque". Em
|
||||||
|
Depoimentos, "Destaque" só serve para filtrar a tabela dentro do painel —
|
||||||
|
não muda nada no que aparece para quem visita o site.
|
||||||
|
|
||||||
|
## Editar os textos da home e das páginas institucionais
|
||||||
|
|
||||||
|
No menu lateral, vá em **Configurações** (dentro do grupo "Conteúdo do
|
||||||
|
site"). É uma página só, dividida em seções. Edite o que quiser e clique em
|
||||||
|
salvar no final.
|
||||||
|
|
||||||
|
**Diferente de Portfólio, Depoimentos e Serviços, aqui não existe "Publicado
|
||||||
|
em".** Tudo o que você salva em Configurações entra no ar imediatamente —
|
||||||
|
não há passo extra de publicação nem como "rascunhar" uma mudança antes de
|
||||||
|
ela aparecer no site.
|
||||||
|
|
||||||
|
- **Marca e hero** — nome da marca, logo (+ texto alternativo, obrigatório
|
||||||
|
se houver logo), textos do topo da home ("hero"): eyebrow (frase pequena
|
||||||
|
acima do título), título, subtítulo, texto dos dois botões de chamada
|
||||||
|
(CTA), nota de rodapé do hero, e o **resumo institucional**
|
||||||
|
(`about_summary`). **Atenção:** nome da marca, título do hero, subtítulo
|
||||||
|
e texto do botão principal são **obrigatórios** — o formulário não deixa
|
||||||
|
salvar em branco. Já o eyebrow, o botão secundário e a nota do hero podem
|
||||||
|
ficar em branco (o site simplesmente não mostra essa parte se estiver
|
||||||
|
vazia).
|
||||||
|
- O **resumo institucional** é usado em **dois lugares** e se comporta
|
||||||
|
diferente em cada um: na seção "A Amare" da home, se ficar em branco o
|
||||||
|
site mostra uma frase padrão fixa no lugar; já na página **Sobre**
|
||||||
|
(`/sobre`), esse mesmo texto aparece como o parágrafo principal e, se
|
||||||
|
ficar em branco, **fica realmente vazio** ali (sem frase padrão). O
|
||||||
|
formulário **deixa salvar em branco sem avisar** — ou seja, não dá erro
|
||||||
|
nenhum, o parágrafo só fica vazio silenciosamente na página Sobre até
|
||||||
|
alguém notar. Por isso, nunca deixe o resumo institucional em branco;
|
||||||
|
depois de editar, confira a página `/sobre` para garantir que o texto
|
||||||
|
apareceu.
|
||||||
|
- A página **Sobre** também tem uma frase fixa no código
|
||||||
|
("A [nome da marca] atua em [cidade] com foco em planejamento
|
||||||
|
completo...") que **não é editável pelo painel** — ela só muda se você
|
||||||
|
editar Nome da marca ou Cidade (que entram nessa frase), o resto do
|
||||||
|
texto é fixo. Para mudar essa frase, é preciso pedir para quem
|
||||||
|
desenvolve o site.
|
||||||
|
- **Manifesto editorial** — título, texto de abertura ("lead") e corpo do
|
||||||
|
bloco "Manifesto" da home. Diferente de outros textos, se você deixar
|
||||||
|
esses campos em branco o site **não** esconde a seção — ele mostra um
|
||||||
|
texto padrão fixo no lugar. Ou seja: em branco aqui não é "some", é
|
||||||
|
"volta ao texto genérico".
|
||||||
|
- **Método** — introdução do bloco "Método" e até 4 passos (título +
|
||||||
|
descrição cada), reordenáveis arrastando. Se você apagar todos os passos,
|
||||||
|
o site mostra passos padrão pré-definidos em vez de ficar vazio.
|
||||||
|
- **Princípios** — lista de princípios (tags) mostrada na seção
|
||||||
|
institucional. Se ficar vazia, o site mostra uma lista padrão.
|
||||||
|
- **Página Sobre** — imagem da página "Sobre" (+ texto alternativo,
|
||||||
|
obrigatório se houver imagem).
|
||||||
|
- **SEO padrão** — título e descrição que aparecem quando o site é
|
||||||
|
compartilhado ou aparece no Google (título e descrição são obrigatórios),
|
||||||
|
e a imagem padrão de compartilhamento (que também é reaproveitada como a
|
||||||
|
imagem à direita no hero da home — ao trocar essa imagem, ela muda em
|
||||||
|
dois lugares ao mesmo tempo).
|
||||||
|
- **Analytics** — **não mexa aqui**, veja [O que NÃO mexer](#o-que-não-mexer--e-com-quem-falar).
|
||||||
|
|
||||||
|
## Casos de portfólio: criar, editar, publicar e despublicar
|
||||||
|
|
||||||
|
No menu, vá em **Portfólio**.
|
||||||
|
|
||||||
|
### Criar ou editar um caso
|
||||||
|
|
||||||
|
Clique em "Novo" (ou abra um caso existente) e preencha:
|
||||||
|
|
||||||
|
- **Título** e **Slug** — o slug é o pedacinho do endereço na internet
|
||||||
|
(ex.: `casamento-joana-pedro`). Se você deixar o slug em branco ao criar,
|
||||||
|
ele é gerado automaticamente a partir do título. **Depois de publicado,
|
||||||
|
evite mudar o slug** — isso quebra qualquer link já compartilhado para
|
||||||
|
aquele caso (redes sociais, WhatsApp, Google). Se precisar mesmo mudar,
|
||||||
|
veja [O que NÃO mexer](#o-que-não-mexer--e-com-quem-falar).
|
||||||
|
- **Resumo** — texto curto usado nas listagens.
|
||||||
|
- **Tipo de evento**, **Cidade**, **Local**, **Data do evento**.
|
||||||
|
- **Desafio**, **Solução**, **Resultado** — o texto do "case" propriamente
|
||||||
|
dito (Desafio e Solução são obrigatórios; Resultado é opcional).
|
||||||
|
- **Imagem de capa** (+ texto alternativo, obrigatório se houver imagem).
|
||||||
|
**Atenção:** diferente de quase toda outra imagem do painel, a capa de um
|
||||||
|
caso de portfólio é **sempre obrigatória** por trás dos panos — mas o
|
||||||
|
formulário **não bloqueia** o salvamento se você esquecer de anexá-la.
|
||||||
|
Se você tentar salvar um caso novo sem imagem de capa, o painel não avisa
|
||||||
|
"campo obrigatório": o salvamento simplesmente falha com um erro técnico
|
||||||
|
feio (erro de banco de dados), não a mensagem amigável que você vê para
|
||||||
|
título, resumo etc. Sempre anexe a imagem de capa antes de salvar um caso
|
||||||
|
novo.
|
||||||
|
- **Ordem** — número usado para ordenar os casos nas listagens.
|
||||||
|
- **Destaque** — liga/desliga a aparição desse caso na home (ver regra das
|
||||||
|
[duas chaves](#a-regra-mais-importante-o-que-torna-algo-visível-no-site)).
|
||||||
|
- **Publicado em** — data de publicação (ver
|
||||||
|
[regra de publicação](#a-regra-mais-importante-o-que-torna-algo-visível-no-site)).
|
||||||
|
- **Meta title** / **Meta description** — título e descrição específicos
|
||||||
|
desse caso para compartilhamento e Google (opcionais; se em branco, usa
|
||||||
|
o padrão configurado em Configurações).
|
||||||
|
|
||||||
|
### Galeria de fotos do caso
|
||||||
|
|
||||||
|
Depois de salvar o caso, abra-o novamente e procure a aba **"Galeria"**.
|
||||||
|
Ali você adiciona quantas fotos quiser, cada uma com:
|
||||||
|
|
||||||
|
- **Imagem** (obrigatória para criar o item da galeria). **Atenção:** assim
|
||||||
|
como a capa do caso, o formulário **não bloqueia** o salvamento se você
|
||||||
|
esquecer a imagem — ele deixa parecer que deu certo até você clicar em
|
||||||
|
salvar, e aí falha com um erro técnico de banco de dados em vez de avisar
|
||||||
|
"campo obrigatório". Sempre anexe a imagem antes de salvar um item da
|
||||||
|
galeria.
|
||||||
|
- **Texto alternativo** — **obrigatório assim que você anexa uma imagem**.
|
||||||
|
O sistema não deixa salvar uma foto sem essa descrição.
|
||||||
|
- **Legenda** (opcional) — texto que aparece junto da foto.
|
||||||
|
- **Ordem** — dá para arrastar as fotos na tabela para reordenar.
|
||||||
|
|
||||||
|
### Publicar e despublicar um caso
|
||||||
|
|
||||||
|
- **Publicar:** preencha "Publicado em" com qualquer data e salve. Aparece
|
||||||
|
imediatamente em `/portfolio` e na página própria do caso. Para também
|
||||||
|
aparecer na home, ligue "Destaque".
|
||||||
|
- **Despublicar:** apague o conteúdo de "Publicado em" e salve. Colocar
|
||||||
|
uma data futura **não** esconde o caso.
|
||||||
|
|
||||||
|
**"Excluir" não é a mesma coisa que despublicar.** Cada linha da tabela de
|
||||||
|
Portfólio (e também cada foto dentro da aba "Galeria") tem um botão
|
||||||
|
**"Excluir"**, além do botão de editar. Diferente de despublicar, Excluir
|
||||||
|
**apaga o caso para sempre**: não existe lixeira, não existe desfazer, e ao
|
||||||
|
excluir um caso todas as fotos da galeria dele são apagadas junto
|
||||||
|
automaticamente. Selecionar várias linhas na tabela também libera uma ação
|
||||||
|
de exclusão em massa, que apaga todas de uma vez com uma única confirmação.
|
||||||
|
Para esconder um caso do site, **sempre** use "apagar o Publicado em" — só
|
||||||
|
use Excluir quando tiver certeza de que quer destruir o registro
|
||||||
|
definitivamente.
|
||||||
|
|
||||||
|
## Depoimentos: adicionar e publicar
|
||||||
|
|
||||||
|
No menu, vá em **Depoimentos**.
|
||||||
|
|
||||||
|
- **Depoimento** — o texto do casal. Se você separar o texto em parágrafos
|
||||||
|
com uma linha em branco entre eles, o site respeita essa quebra e mostra
|
||||||
|
cada parágrafo separadamente.
|
||||||
|
- **Nome do autor** — ex.: "Jeniffer e Maick".
|
||||||
|
- **Contexto** — texto livre mostrado como "— contexto", ex.:
|
||||||
|
`Casamento · 06/12/2025`.
|
||||||
|
- **Foto** (+ texto alternativo, obrigatório se houver foto) — atenção: a
|
||||||
|
foto **existe no formulário mas hoje não aparece** na home; o campo é
|
||||||
|
preenchido para o futuro, mas visualmente ainda não é exibido.
|
||||||
|
- **Ordem** — ordena os depoimentos no bloco da home.
|
||||||
|
- **Destaque** — **não afeta o site público**. Serve só para filtrar a
|
||||||
|
tabela de depoimentos dentro do painel.
|
||||||
|
- **Publicado em** — este é o único interruptor que importa para
|
||||||
|
depoimentos aparecerem na home. Preencha e salve para publicar; apague e
|
||||||
|
salve para despublicar. Não existe listagem própria de depoimentos fora
|
||||||
|
da home.
|
||||||
|
|
||||||
|
**Atenção ao botão "Excluir"** em cada linha da tabela (e à exclusão em
|
||||||
|
massa ao selecionar várias linhas): ele é diferente de despublicar e apaga
|
||||||
|
o depoimento para sempre, sem lixeira e sem desfazer. Para tirar um
|
||||||
|
depoimento do ar, apague o "Publicado em" — não use Excluir a menos que
|
||||||
|
queira apagar o registro definitivamente.
|
||||||
|
|
||||||
|
## Serviços: editar
|
||||||
|
|
||||||
|
No menu, vá em **Serviços**.
|
||||||
|
|
||||||
|
- **Título** e **Slug** (mesmo comportamento de auto-geração e mesmo
|
||||||
|
cuidado ao mudar depois de publicado que o portfólio).
|
||||||
|
- **Resumo** e **Descrição**.
|
||||||
|
- **Imagem de capa** (+ texto alternativo, obrigatório se houver imagem).
|
||||||
|
- **Ordem**, **Destaque** e **Publicado em** funcionam exatamente como em
|
||||||
|
portfólio: publicado aparece em `/servicos`; publicado **e** destaque
|
||||||
|
aparece também na home.
|
||||||
|
|
||||||
|
**Atenção ao botão "Excluir"** em cada linha da tabela (e à exclusão em
|
||||||
|
massa ao selecionar várias linhas): ele é diferente de despublicar e apaga
|
||||||
|
o serviço para sempre, sem lixeira e sem desfazer. Para tirar um serviço do
|
||||||
|
ar, apague o "Publicado em" — não use Excluir a menos que queira apagar o
|
||||||
|
registro definitivamente.
|
||||||
|
|
||||||
|
## Contato: telefone, e-mail e redes sociais
|
||||||
|
|
||||||
|
Esses campos ficam em **Configurações → seção "Contato"**.
|
||||||
|
|
||||||
|
- **E-mail** e **Telefone** são obrigatórios — o formulário não deixa
|
||||||
|
salvá-los em branco. Eles aparecem no rodapé do site e na página
|
||||||
|
"Contato" como links clicáveis (e-mail abre o programa de e-mail;
|
||||||
|
telefone abre o discador do celular). **Importante:** esse mesmo e-mail
|
||||||
|
é também o endereço para onde o site envia toda mensagem enviada pelo
|
||||||
|
formulário de contato em `/contato` (o "briefing" que um visitante
|
||||||
|
preenche e envia). Ou seja, esse campo não é só um link de exibição —
|
||||||
|
ele precisa ser uma caixa de entrada de verdade, monitorada, porque é
|
||||||
|
para lá que vão os pedidos de orçamento e contato de clientes em
|
||||||
|
potencial. Trocar esse e-mail por um endereço que ninguém acompanha faz
|
||||||
|
o site parar de avisar sobre novos contatos, sem nenhum erro aparecer em
|
||||||
|
lugar nenhum do painel.
|
||||||
|
- **Importante sobre WhatsApp:** hoje existe **um único campo de
|
||||||
|
telefone**, e ele vira apenas um link `tel:` (ligação), **não** um botão
|
||||||
|
de WhatsApp. Se o número cadastrado for um número de WhatsApp, quem
|
||||||
|
clicar vai abrir o discador, não o WhatsApp. Se você precisa de um botão
|
||||||
|
específico de WhatsApp no site, isso é um pedido para quem desenvolve o
|
||||||
|
site — hoje o campo não faz isso sozinho.
|
||||||
|
- **Cidade** — opcional, aparece na página de Contato.
|
||||||
|
- **Redes sociais** — lista de "Rede" + "URL" (ex.: `instagram` →
|
||||||
|
`https://instagram.com/suaempresa`). Adicione quantas quiser pelo botão
|
||||||
|
"Adicionar rede"; para remover uma, apague a linha inteira.
|
||||||
|
|
||||||
|
## Imagens: formatos aceitos, tamanho e texto alternativo
|
||||||
|
|
||||||
|
Vale para **toda** imagem enviada em qualquer tela do painel (logo, capas,
|
||||||
|
galeria de portfólio, fotos de depoimento, imagem da página Sobre, imagem
|
||||||
|
de SEO):
|
||||||
|
|
||||||
|
- **Formatos aceitos:** JPG, JPEG, PNG ou WEBP. Qualquer outro formato
|
||||||
|
(ex.: HEIC direto do iPhone, PDF, GIF) é recusado com uma mensagem de
|
||||||
|
erro — converta a imagem antes de enviar. **HEIC é o caso mais comum**,
|
||||||
|
porque é o formato padrão das fotos tiradas no iPhone. Duas formas
|
||||||
|
simples de resolver:
|
||||||
|
- **Fotos novas:** no iPhone, vá em Ajustes → Câmera → Formatos e
|
||||||
|
escolha "Mais Compatível" — a partir daí, novas fotos já são salvas em
|
||||||
|
JPEG em vez de HEIC.
|
||||||
|
- **Fotos que já estão em HEIC:** envie a foto para você mesma por
|
||||||
|
WhatsApp (ex.: em "Mensagens salvas" ou num grupo/conversa qualquer) e
|
||||||
|
baixe a versão recebida — o WhatsApp converte a imagem para JPEG
|
||||||
|
automaticamente ao enviar.
|
||||||
|
- **Tamanho máximo:** 10 MB por arquivo. Acima disso, o envio é recusado.
|
||||||
|
- **Texto alternativo é obrigatório sempre que houver imagem.** Isso não é
|
||||||
|
burocracia: é o texto que leitores de tela usam para descrever a imagem
|
||||||
|
para pessoas com deficiência visual, e também ajuda o Google a entender
|
||||||
|
do que se trata a foto. Sem uma imagem, o campo de texto alternativo
|
||||||
|
pode ficar em branco; assim que você anexa uma imagem, o sistema passa a
|
||||||
|
exigir o texto.
|
||||||
|
- **Exceção: a imagem de capa de um caso de portfólio e a imagem de um item
|
||||||
|
da galeria não são realmente opcionais**, ao contrário de toda outra
|
||||||
|
imagem do painel (logo, imagem da página Sobre, foto de depoimento,
|
||||||
|
imagem de SEO). Nesses dois casos específicos, o formulário não impede
|
||||||
|
você de salvar sem imagem, mas o salvamento falha de qualquer forma com
|
||||||
|
um erro técnico em vez de uma mensagem amigável de "campo obrigatório"
|
||||||
|
(ver detalhes nas seções [Casos de
|
||||||
|
portfólio](#casos-de-portfólio-criar-editar-publicar-e-despublicar) e
|
||||||
|
[Galeria de fotos do caso](#galeria-de-fotos-do-caso)). Sempre anexe uma
|
||||||
|
imagem antes de salvar um caso de portfólio ou um item de galeria.
|
||||||
|
- Você **não** precisa fazer nada além de enviar a imagem normalmente — o
|
||||||
|
sistema gera sozinho as versões menores usadas em celulares e tablets.
|
||||||
|
Não existe um botão ou comando manual que você precise rodar depois de
|
||||||
|
subir uma foto.
|
||||||
|
|
||||||
|
## Usuários: papéis, ativar e desativar contas
|
||||||
|
|
||||||
|
No menu, vá em **Usuários**. Esta tela só aparece, e só pode ser editada,
|
||||||
|
por contas com perfil **Administrador** — se você consegue ver este menu e
|
||||||
|
seguir o resto deste guia, você é Administradora.
|
||||||
|
|
||||||
|
Ao criar ou editar uma conta, os campos são:
|
||||||
|
|
||||||
|
- **Nome** e **E-mail** — identificação da pessoa; o e-mail também é o
|
||||||
|
usado para fazer login.
|
||||||
|
- **Papel** — **Administrador** ou **Assistente**. Como já visto em [Como
|
||||||
|
entrar no painel](#como-entrar-no-painel), hoje não existe meio-termo:
|
||||||
|
Administrador tem acesso total, e Assistente consegue fazer login mas
|
||||||
|
não vê nem edita **nenhum** conteúdo (nem Portfólio, nem Configurações,
|
||||||
|
nada). Trocar o Papel de alguém para Assistente remove todo o acesso
|
||||||
|
dela imediatamente.
|
||||||
|
- **Ativo** — desligar este interruptor bloqueia o login dessa conta por
|
||||||
|
completo, mesmo com e-mail e senha corretos.
|
||||||
|
- **Senha** — só é preciso preencher ao criar uma conta nova ou quando
|
||||||
|
você realmente quer trocar a senha de alguém; deixando em branco ao
|
||||||
|
editar uma conta existente, a senha atual não muda.
|
||||||
|
|
||||||
|
**Cuidado ao editar a sua própria conta.** O painel não impede uma
|
||||||
|
Administradora de trocar o próprio Papel para Assistente ou de desligar o
|
||||||
|
próprio "Ativo". Se isso acontecer, você perde o acesso imediatamente e,
|
||||||
|
como visto em [Como entrar no painel](#como-entrar-no-painel), hoje não
|
||||||
|
existe "esqueci minha senha" nem qualquer forma de uma Administradora
|
||||||
|
reverter isso sozinha — só sobra pedir para quem desenvolve o site mexer
|
||||||
|
diretamente no banco de dados. Ao mexer no seu próprio usuário, confira
|
||||||
|
duas vezes o que está mudando antes de salvar.
|
||||||
|
|
||||||
|
## O que NÃO mexer — e com quem falar
|
||||||
|
|
||||||
|
- **Seção "Analytics" em Configurações** (o interruptor e o campo de
|
||||||
|
script). É um campo técnico que injeta código de rastreamento no site.
|
||||||
|
Mexer errado aqui pode quebrar o carregamento do site inteiro. Peça para
|
||||||
|
quem desenvolve o site fazer essa alteração.
|
||||||
|
- **Slug de serviços e casos de portfólio**, depois de publicados. Mudar
|
||||||
|
quebra links já compartilhados (redes sociais, WhatsApp, resultados de
|
||||||
|
busca do Google). Se for realmente necessário mudar, avise quem
|
||||||
|
desenvolve o site para avaliar redirecionamento.
|
||||||
|
- **Usuários e permissões** (menu "Usuários") — só para quem **não** é
|
||||||
|
Administradora: essa tela só existe para contas com perfil
|
||||||
|
**Administrador**, então se você não consegue nem ver o menu "Usuários",
|
||||||
|
precisa de uma conta nova, de desativar alguém, ou de trocar uma senha,
|
||||||
|
peça a uma Administradora — ou, na ausência de uma, a quem desenvolve o
|
||||||
|
site. Se você **é** Administradora, essa tela é sua e está descrita em
|
||||||
|
[Usuários: papéis, ativar e desativar
|
||||||
|
contas](#usuários-papéis-ativar-e-desativar-contas).
|
||||||
|
- **Qualquer coisa fora do painel `/admin`** — arquivos de código, banco
|
||||||
|
de dados, comandos de terminal. Nada disso deve ser mexido para uma
|
||||||
|
atualização de conteúdo do dia a dia; se alguém pedir para você rodar um
|
||||||
|
comando técnico para "gerar imagens" ou algo do tipo, isso é tarefa de
|
||||||
|
engenharia, não de edição de conteúdo — fale com quem desenvolve o site.
|
||||||
|
|
||||||
|
## Seções da home que somem por completo se ficarem vazias
|
||||||
|
|
||||||
|
Estas três seções da home **desaparecem inteiramente** (sem nenhum aviso ou
|
||||||
|
espaço reservado) se não houver nenhum item que atenda aos critérios
|
||||||
|
abaixo:
|
||||||
|
|
||||||
|
| Seção da home | Desaparece quando... |
|
||||||
|
|---|---|
|
||||||
|
| **Serviços** | zero serviços estiverem, ao mesmo tempo, publicados **e** com "Destaque" ligado |
|
||||||
|
| **Portfólio** | zero casos estiverem, ao mesmo tempo, publicados **e** com "Destaque" ligado |
|
||||||
|
| **Depoimentos** | zero depoimentos estiverem publicados (o "Destaque" não importa aqui) |
|
||||||
|
|
||||||
|
Se você despublicar o último item de uma dessas categorias — ou esquecer de
|
||||||
|
ligar "Destaque" em qualquer serviço/caso — a home simplesmente fica sem
|
||||||
|
aquele bloco, sem mensagem de erro em lugar nenhum. Se um bloco "sumiu" da
|
||||||
|
home, o primeiro lugar para checar é exatamente essa combinação de
|
||||||
|
publicado + destaque.
|
||||||
|
|
||||||
|
## Sobre o arquivo `depoimentos.md` na raiz do repositório
|
||||||
|
|
||||||
|
Existe um arquivo chamado `depoimentos.md` na raiz do projeto com os cinco
|
||||||
|
depoimentos reais originais, copiados manualmente de onde vieram
|
||||||
|
(WhatsApp/redes sociais). Ele foi o material bruto usado, uma única vez,
|
||||||
|
para digitar os depoimentos dentro do sistema (e é citado em documentos
|
||||||
|
técnicos antigos como a origem desse conteúdo) — mas **hoje nenhum código
|
||||||
|
do site lê esse arquivo**. Editar, corrigir ou apagar `depoimentos.md` **não
|
||||||
|
muda nada** no site: o texto que aparece de verdade para quem visita o site
|
||||||
|
vive no banco de dados, e é editado exclusivamente pela tela **Depoimentos**
|
||||||
|
descrita [acima](#depoimentos-adicionar-e-publicar). Trate esse arquivo como
|
||||||
|
material histórico de referência, não como fonte de conteúdo.
|
||||||
BIN
docs/screenshots/home-editorial-cadence-desktop.webp
Normal file
BIN
docs/screenshots/home-editorial-cadence-desktop.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 137 KiB |
BIN
docs/screenshots/home-editorial-cadence-mobile.webp
Normal file
BIN
docs/screenshots/home-editorial-cadence-mobile.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 192 KiB |
@@ -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
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
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
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
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
|
||||||
@@ -3,7 +3,7 @@ schema: spec-driven
|
|||||||
context: |
|
context: |
|
||||||
Fonte de verdade: SPEC.md na raiz. Precedência: instrução do dono do produto > SPEC.md > ADRs > testes > convenções.
|
Fonte de verdade: SPEC.md na raiz. Precedência: instrução do dono do produto > SPEC.md > ADRs > testes > convenções.
|
||||||
Produto: plataforma de assessoria de eventos, single-tenant, MVP. UI em pt-BR, timezone America/Sao_Paulo, atuação em São Paulo (capital), BRL.
|
Produto: plataforma de assessoria de eventos, single-tenant, MVP. UI em pt-BR, timezone America/Sao_Paulo, atuação em São Paulo (capital), BRL.
|
||||||
Stack: Laravel 13, Filament 5 (/admin), 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.
|
||||||
|
|||||||
@@ -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
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>
|
||||||
@@ -59,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 {
|
||||||
@@ -109,6 +130,43 @@
|
|||||||
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);
|
||||||
@@ -119,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;
|
||||||
@@ -199,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
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ function prefersReducedMotion() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function enableEnhancement() {
|
function enableEnhancement() {
|
||||||
if (prefersReducedMotion()) {
|
if (prefersReducedMotion() || typeof IntersectionObserver !== 'function') {
|
||||||
document.documentElement.removeAttribute('data-motion');
|
document.documentElement.removeAttribute('data-motion');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -14,24 +14,6 @@ function enableEnhancement() {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function activateHero(enhance) {
|
|
||||||
const hero = document.querySelector('[data-motion="dossie-hero"]');
|
|
||||||
if (!hero) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const activate = () => hero.classList.add('is-active');
|
|
||||||
|
|
||||||
if (!enhance) {
|
|
||||||
activate();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
requestAnimationFrame(activate);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function activatePageOpen(enhance) {
|
function activatePageOpen(enhance) {
|
||||||
document.querySelectorAll('[data-motion="page-open"]').forEach((node) => {
|
document.querySelectorAll('[data-motion="page-open"]').forEach((node) => {
|
||||||
if (!enhance) {
|
if (!enhance) {
|
||||||
@@ -45,6 +27,32 @@ function activatePageOpen(enhance) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function initializeMotionDelays() {
|
||||||
|
const beatOrder = new Map([
|
||||||
|
['seal', 0],
|
||||||
|
['heading', 0],
|
||||||
|
['title', 1],
|
||||||
|
['media', 2],
|
||||||
|
['cta', 3],
|
||||||
|
]);
|
||||||
|
|
||||||
|
document.querySelectorAll('[data-motion="page-open"]').forEach((page) => {
|
||||||
|
page.querySelectorAll('[data-motion-beat]').forEach((beat, index) => {
|
||||||
|
const namedIndex = beatOrder.get(beat.dataset.motionBeat);
|
||||||
|
beat.style.setProperty('--motion-index', String(Math.min(namedIndex ?? index, 3)));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('[data-reveal-group]').forEach((group) => {
|
||||||
|
const nodes = Array.from(group.querySelectorAll('[data-reveal]'))
|
||||||
|
.filter((node) => node.closest('[data-reveal-group]') === group);
|
||||||
|
|
||||||
|
nodes.forEach((node, index) => {
|
||||||
|
node.style.setProperty('--motion-index', String(Math.min(index, 3)));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function observeReveals(enhance) {
|
function observeReveals(enhance) {
|
||||||
const nodes = Array.from(document.querySelectorAll('[data-reveal]'));
|
const nodes = Array.from(document.querySelectorAll('[data-reveal]'));
|
||||||
if (nodes.length === 0) {
|
if (nodes.length === 0) {
|
||||||
@@ -86,6 +94,7 @@ function setupChapterIndex() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const links = Array.from(index.querySelectorAll('a[href^="#"]'));
|
const links = Array.from(index.querySelectorAll('a[href^="#"]'));
|
||||||
|
let framePending = false;
|
||||||
|
|
||||||
const setActive = (id) => {
|
const setActive = (id) => {
|
||||||
links.forEach((link) => {
|
links.forEach((link) => {
|
||||||
@@ -129,16 +138,28 @@ function setupChapterIndex() {
|
|||||||
updateProgress(window.scrollY / max);
|
updateProgress(window.scrollY / max);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const scheduleSync = () => {
|
||||||
|
if (framePending) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
framePending = true;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
framePending = false;
|
||||||
|
sync();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
setActive(chapters[0].getAttribute('aria-labelledby') || 'hero-heading');
|
setActive(chapters[0].getAttribute('aria-labelledby') || 'hero-heading');
|
||||||
sync();
|
sync();
|
||||||
|
|
||||||
window.addEventListener('scroll', sync, { passive: true });
|
window.addEventListener('scroll', scheduleSync, { passive: true });
|
||||||
window.addEventListener('resize', sync);
|
window.addEventListener('resize', scheduleSync);
|
||||||
}
|
}
|
||||||
|
|
||||||
function bootMotion() {
|
function bootMotion() {
|
||||||
|
initializeMotionDelays();
|
||||||
const enhance = enableEnhancement();
|
const enhance = enableEnhancement();
|
||||||
activateHero(enhance);
|
|
||||||
activatePageOpen(enhance);
|
activatePageOpen(enhance);
|
||||||
observeReveals(enhance);
|
observeReveals(enhance);
|
||||||
setupChapterIndex();
|
setupChapterIndex();
|
||||||
@@ -146,7 +167,7 @@ function bootMotion() {
|
|||||||
window.matchMedia(MOTION_QUERY).addEventListener('change', (event) => {
|
window.matchMedia(MOTION_QUERY).addEventListener('change', (event) => {
|
||||||
if (event.matches) {
|
if (event.matches) {
|
||||||
document.documentElement.removeAttribute('data-motion');
|
document.documentElement.removeAttribute('data-motion');
|
||||||
document.querySelectorAll('[data-motion="dossie-hero"], [data-motion="page-open"]').forEach((node) => {
|
document.querySelectorAll('[data-motion="page-open"]').forEach((node) => {
|
||||||
node.classList.add('is-active');
|
node.classList.add('is-active');
|
||||||
});
|
});
|
||||||
document.querySelectorAll('[data-reveal]').forEach((node) => {
|
document.querySelectorAll('[data-reveal]').forEach((node) => {
|
||||||
|
|||||||
@@ -20,9 +20,20 @@
|
|||||||
$kind = $mark ? 'mark' : 'lockup';
|
$kind = $mark ? 'mark' : 'lockup';
|
||||||
$staticSrc = asset("brand/{$kind}-{$variant}.webp");
|
$staticSrc = asset("brand/{$kind}-{$variant}.webp");
|
||||||
|
|
||||||
$src = filled($uploadedPath)
|
$usesUploadedLogo = filled($uploadedPath);
|
||||||
|
|
||||||
|
$src = $usesUploadedLogo
|
||||||
? \Illuminate\Support\Facades\Storage::disk('public')->url($uploadedPath)
|
? \Illuminate\Support\Facades\Storage::disk('public')->url($uploadedPath)
|
||||||
: $staticSrc;
|
: $staticSrc;
|
||||||
|
|
||||||
|
// Reserve the box before the image arrives (SPEC §6.4). The intrinsic size
|
||||||
|
// of the shipped assets is known and fixed; an uploaded logo has arbitrary
|
||||||
|
// dimensions, so it gets no attributes rather than wrong ones. The CSS
|
||||||
|
// classes still govern the rendered size in both cases — width/height only
|
||||||
|
// give the browser the aspect ratio to reserve.
|
||||||
|
$intrinsic = $usesUploadedLogo
|
||||||
|
? []
|
||||||
|
: ($mark ? ['width' => 512, 'height' => 257] : ['width' => 512, 'height' => 495]);
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<img
|
<img
|
||||||
@@ -32,5 +43,5 @@
|
|||||||
'class' => trim('brand-logo '.$class),
|
'class' => trim('brand-logo '.$class),
|
||||||
'decoding' => 'async',
|
'decoding' => 'async',
|
||||||
'loading' => 'eager',
|
'loading' => 'eager',
|
||||||
]) }}
|
] + $intrinsic) }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,10 +1,18 @@
|
|||||||
@props([
|
@props([
|
||||||
'settings',
|
'settings',
|
||||||
|
'editorial' => false,
|
||||||
])
|
])
|
||||||
|
|
||||||
<section aria-labelledby="final-cta-heading" class="border-t border-amare-border bg-amare-bg-deep py-20" data-chapter="final-cta">
|
<section aria-labelledby="final-cta-heading" @class([
|
||||||
<div class="container-amare space-y-6 text-center" data-reveal>
|
'border-t border-amare-border bg-amare-bg-deep py-20',
|
||||||
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Próximo passo</p>
|
'home-chapter' => $editorial,
|
||||||
|
]) data-chapter="final-cta" data-reveal-group>
|
||||||
|
<div class="container-amare space-y-6 text-center" data-reveal data-reveal-from="up">
|
||||||
|
@if ($editorial)
|
||||||
|
<x-home.folio label="Próximo passo" class="justify-center" />
|
||||||
|
@else
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Próximo passo</p>
|
||||||
|
@endif
|
||||||
<h2 id="final-cta-heading" class="text-headline font-medium text-amare-text">Do casamento ao evento corporativo, tudo começa com uma boa conversa.</h2>
|
<h2 id="final-cta-heading" class="text-headline font-medium text-amare-text">Do casamento ao evento corporativo, tudo começa com uma boa conversa.</h2>
|
||||||
<p class="mx-auto max-w-2xl text-amare-muted">
|
<p class="mx-auto max-w-2xl text-amare-muted">
|
||||||
Compartilhe as primeiras informações do seu evento. A Amare retorna para entender o contexto e orientar os próximos passos.
|
Compartilhe as primeiras informações do seu evento. A Amare retorna para entender o contexto e orientar os próximos passos.
|
||||||
|
|||||||
20
resources/views/components/home/folio.blade.php
Normal file
20
resources/views/components/home/folio.blade.php
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
@props([
|
||||||
|
'label',
|
||||||
|
'tone' => 'default',
|
||||||
|
])
|
||||||
|
|
||||||
|
@php
|
||||||
|
$tone = $tone === 'inverse' ? 'inverse' : 'default';
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<p
|
||||||
|
{{ $attributes->class([
|
||||||
|
'home-folio',
|
||||||
|
'home-folio--inverse' => $tone === 'inverse',
|
||||||
|
]) }}
|
||||||
|
data-home-folio
|
||||||
|
>
|
||||||
|
<span data-home-folio-label>{{ $label }}</span>
|
||||||
|
<span class="home-folio__separator" aria-hidden="true">·</span>
|
||||||
|
<span class="home-folio__number" data-home-folio-number aria-hidden="true"></span>
|
||||||
|
</p>
|
||||||
@@ -4,11 +4,15 @@
|
|||||||
|
|
||||||
<section
|
<section
|
||||||
aria-labelledby="hero-heading"
|
aria-labelledby="hero-heading"
|
||||||
class="border-b border-amare-border bg-amare-bg"
|
class="home-chapter border-b border-amare-border bg-amare-bg"
|
||||||
data-chapter="hero"
|
data-chapter="hero"
|
||||||
data-motion="dossie-hero"
|
data-motion="page-open"
|
||||||
>
|
>
|
||||||
<div class="container-amare grid gap-12 py-20 md:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)] md:items-center md:py-28">
|
<div class="container-amare pt-8 md:pt-10">
|
||||||
|
<x-home.folio label="Capa" data-motion-beat="folio" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="container-amare grid gap-12 pb-20 pt-10 md:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)] md:items-center md:pb-28 md:pt-14" data-reveal-group>
|
||||||
<div class="space-y-8">
|
<div class="space-y-8">
|
||||||
<div data-motion-beat="seal" class="flex items-center gap-4">
|
<div data-motion-beat="seal" class="flex items-center gap-4">
|
||||||
<x-brand.logo mark variant="on-light" class="h-8 w-auto" alt="" />
|
<x-brand.logo mark variant="on-light" class="h-8 w-auto" alt="" />
|
||||||
|
|||||||
@@ -8,10 +8,14 @@
|
|||||||
$body = $settings->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.';
|
$body = $settings->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.';
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<section aria-labelledby="manifesto-heading" class="border-b border-amare-border bg-amare-bg-deep py-20" data-chapter="manifesto">
|
<section aria-labelledby="manifesto-heading" class="home-chapter border-b border-amare-border bg-amare-bg-deep py-20" data-chapter="manifesto">
|
||||||
<div class="container-amare grid gap-8 md:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]" data-reveal>
|
<div class="container-amare grid gap-8 md:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]" data-reveal-group>
|
||||||
<p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent">Manifesto</p>
|
<div class="space-y-5 md:pr-8" data-reveal data-reveal-from="up">
|
||||||
<div class="space-y-6">
|
<x-home.folio label="Manifesto" />
|
||||||
|
<span class="block h-px w-16 bg-amare-border" aria-hidden="true"></span>
|
||||||
|
<p class="max-w-52 text-lg leading-snug text-amare-text">Humana no cuidado. Precisa na entrega.</p>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-6" data-reveal data-reveal-from="up">
|
||||||
<h2 id="manifesto-heading" class="max-w-3xl text-headline font-medium text-amare-text">{{ $title }}</h2>
|
<h2 id="manifesto-heading" class="max-w-3xl text-headline font-medium text-amare-text">{{ $title }}</h2>
|
||||||
<p class="max-w-2xl text-xl leading-relaxed text-amare-text">{{ $lead }}</p>
|
<p class="max-w-2xl text-xl leading-relaxed text-amare-text">{{ $lead }}</p>
|
||||||
<p class="max-w-2xl text-amare-text-muted">{{ $body }}</p>
|
<p class="max-w-2xl text-amare-text-muted">{{ $body }}</p>
|
||||||
|
|||||||
@@ -7,17 +7,24 @@
|
|||||||
$intro = $settings->method_intro ?: 'Clareza em cada etapa. Tranquilidade durante todo o processo.';
|
$intro = $settings->method_intro ?: 'Clareza em cada etapa. Tranquilidade durante todo o processo.';
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<section aria-labelledby="method-heading" class="border-b border-amare-border bg-amare-bg-archive py-20" data-chapter="method">
|
<section aria-labelledby="method-heading" class="home-chapter border-b border-amare-border bg-amare-bg-archive py-20" data-chapter="method">
|
||||||
<div class="container-amare grid gap-10 md:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)] md:items-start">
|
<div class="container-amare grid gap-10 md:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)] md:items-start" data-reveal-group>
|
||||||
<div class="space-y-3" data-reveal>
|
<div class="space-y-4" data-reveal data-reveal-from="up">
|
||||||
<p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent">Método</p>
|
<x-home.folio label="Método" />
|
||||||
<h2 id="method-heading" class="text-headline font-medium text-amare-text">Cuidado orientado por processo.</h2>
|
<h2 id="method-heading" class="text-headline font-medium text-amare-text">Cuidado orientado por processo.</h2>
|
||||||
<p class="text-amare-text-muted">{{ $intro }}</p>
|
<p class="text-amare-text-muted">{{ $intro }}</p>
|
||||||
|
<x-brand.logo
|
||||||
|
mark
|
||||||
|
variant="on-light"
|
||||||
|
class="mt-8 h-8 w-auto opacity-60"
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ol class="grid gap-5 border-t border-amare-border">
|
<ol class="grid gap-5 border-t border-amare-border">
|
||||||
@foreach ($steps as $index => $step)
|
@foreach ($steps as $index => $step)
|
||||||
<li class="grid gap-2 border-b border-amare-border py-5 md:grid-cols-[4rem_minmax(0,1fr)]" data-reveal>
|
<li class="grid gap-2 border-b border-amare-border py-5 md:grid-cols-[4rem_minmax(0,1fr)]" data-reveal data-reveal-from="up">
|
||||||
<span class="text-sm text-amare-accent">{{ str_pad((string) ($index + 1), 2, '0', STR_PAD_LEFT) }}</span>
|
<span class="text-sm text-amare-accent">{{ str_pad((string) ($index + 1), 2, '0', STR_PAD_LEFT) }}</span>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<h3 class="text-2xl font-medium text-amare-text">{{ $step['title'] ?? '' }}</h3>
|
<h3 class="text-2xl font-medium text-amare-text">{{ $step['title'] ?? '' }}</h3>
|
||||||
|
|||||||
@@ -3,10 +3,10 @@
|
|||||||
])
|
])
|
||||||
|
|
||||||
@if ($cases->isNotEmpty())
|
@if ($cases->isNotEmpty())
|
||||||
<section aria-labelledby="portfolio-heading" class="border-b border-amare-accent-deep bg-amare-accent-deep py-20 text-amare-accent-text" data-chapter="portfolio">
|
<section aria-labelledby="portfolio-heading" class="home-chapter border-b border-amare-accent-deep bg-amare-accent-deep py-20 text-amare-accent-text" data-chapter="portfolio">
|
||||||
<div class="container-amare space-y-10">
|
<div class="container-amare space-y-10" data-reveal-group>
|
||||||
<div class="grid gap-4 md:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]" data-reveal>
|
<div class="grid gap-4 md:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]" data-reveal data-reveal-from="up">
|
||||||
<p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent-text/80">Portfólio</p>
|
<x-home.folio label="Portfólio" tone="inverse" />
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<h2 id="portfolio-heading" class="text-headline font-medium">Celebrações que ganham forma com intenção.</h2>
|
<h2 id="portfolio-heading" class="text-headline font-medium">Celebrações que ganham forma com intenção.</h2>
|
||||||
<p class="max-w-2xl text-amare-accent-text/80">Recortes de eventos conduzidos com escuta, direção e presença em cada etapa.</p>
|
<p class="max-w-2xl text-amare-accent-text/80">Recortes de eventos conduzidos com escuta, direção e presença em cada etapa.</p>
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
|
|
||||||
<div class="grid gap-10 md:grid-cols-2">
|
<div class="grid gap-10 md:grid-cols-2">
|
||||||
@foreach ($cases as $case)
|
@foreach ($cases as $case)
|
||||||
<article class="space-y-4 border-t border-amare-accent-text/30 pt-4" data-reveal>
|
<article class="space-y-4 border-t border-amare-accent-text/30 pt-4" data-reveal data-reveal-from="up">
|
||||||
@if (filled($case->cover_image_path))
|
@if (filled($case->cover_image_path))
|
||||||
<x-media.image
|
<x-media.image
|
||||||
:path="$case->cover_image_path"
|
:path="$case->cover_image_path"
|
||||||
|
|||||||
@@ -7,13 +7,17 @@
|
|||||||
$principles = filled($settings->principles) ? $settings->principles : \App\Models\SiteSetting::defaultPrinciples();
|
$principles = filled($settings->principles) ? $settings->principles : \App\Models\SiteSetting::defaultPrinciples();
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<section aria-labelledby="positioning-heading" class="border-b border-amare-border py-20" data-chapter="positioning">
|
<section aria-labelledby="positioning-heading" class="home-chapter border-b border-amare-border py-20" data-chapter="positioning">
|
||||||
<div class="container-amare grid gap-10 md:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]">
|
<div class="container-amare grid gap-10 md:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]" data-reveal-group>
|
||||||
<div class="space-y-3" data-reveal>
|
<div class="space-y-4" data-reveal data-reveal-from="up">
|
||||||
<p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent">A Amare</p>
|
<x-home.folio label="A Amare" />
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<span class="h-px w-10 bg-amare-border" aria-hidden="true"></span>
|
||||||
|
<p class="text-sm uppercase tracking-[0.12em] text-amare-text-muted">Assessoria boutique · São Paulo</p>
|
||||||
|
</div>
|
||||||
<h2 id="positioning-heading" class="text-headline font-medium text-amare-text">Presença que organiza o essencial.</h2>
|
<h2 id="positioning-heading" class="text-headline font-medium text-amare-text">Presença que organiza o essencial.</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-8" data-reveal>
|
<div class="space-y-8" data-reveal data-reveal-from="up">
|
||||||
<p class="max-w-2xl text-xl leading-relaxed text-amare-text">{{ $summary }}</p>
|
<p class="max-w-2xl text-xl leading-relaxed text-amare-text">{{ $summary }}</p>
|
||||||
<ul class="grid gap-3 border-t border-amare-border">
|
<ul class="grid gap-3 border-t border-amare-border">
|
||||||
@foreach ($principles as $principle)
|
@foreach ($principles as $principle)
|
||||||
|
|||||||
@@ -3,17 +3,22 @@
|
|||||||
])
|
])
|
||||||
|
|
||||||
@if ($services->isNotEmpty())
|
@if ($services->isNotEmpty())
|
||||||
<section aria-labelledby="services-heading" class="border-b border-amare-border py-20" data-chapter="services">
|
<section aria-labelledby="services-heading" class="home-chapter border-b border-amare-border py-20" data-chapter="services">
|
||||||
<div class="container-amare space-y-10">
|
<div class="container-amare space-y-10" data-reveal-group>
|
||||||
<div class="max-w-2xl space-y-3" data-reveal>
|
<div class="grid gap-6 md:grid-cols-12 md:items-end" data-reveal data-reveal-from="up">
|
||||||
<p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent">Atuação</p>
|
<div class="space-y-3 md:col-span-4">
|
||||||
<h2 id="services-heading" class="text-3xl font-medium text-amare-text">Serviços</h2>
|
<x-home.folio label="Serviços" />
|
||||||
<p class="text-amare-text-muted">Assessoria sob medida para decisões importantes e celebrações bem conduzidas.</p>
|
<h2 id="services-heading" class="text-3xl font-medium text-amare-text">Serviços</h2>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-4 md:col-span-6 md:col-start-7">
|
||||||
|
<p class="max-w-xl text-amare-text-muted">Assessoria sob medida para decisões importantes e celebrações bem conduzidas.</p>
|
||||||
|
<p class="border-l border-amare-border pl-4 text-sm uppercase tracking-[0.12em] text-amare-accent">Do íntimo ao corporativo</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ol class="border-t border-amare-border">
|
<ol class="border-t border-amare-border">
|
||||||
@foreach ($services as $index => $service)
|
@foreach ($services as $index => $service)
|
||||||
<li class="grid gap-3 border-b border-amare-border py-5 md:grid-cols-[4rem_minmax(0,0.8fr)_minmax(0,1.2fr)] md:gap-6" data-reveal>
|
<li class="grid gap-3 border-b border-amare-border py-5 md:grid-cols-[4rem_minmax(0,0.8fr)_minmax(0,1.2fr)] md:gap-6" data-reveal data-reveal-from="up">
|
||||||
<span class="text-sm text-amare-accent">{{ str_pad((string) ($index + 1), 2, '0', STR_PAD_LEFT) }}</span>
|
<span class="text-sm text-amare-accent">{{ str_pad((string) ($index + 1), 2, '0', STR_PAD_LEFT) }}</span>
|
||||||
<h3 class="text-2xl font-medium text-amare-text">{{ $service->title }}</h3>
|
<h3 class="text-2xl font-medium text-amare-text">{{ $service->title }}</h3>
|
||||||
<p class="text-amare-text-muted">{{ $service->summary }}</p>
|
<p class="text-amare-text-muted">{{ $service->summary }}</p>
|
||||||
|
|||||||
@@ -2,28 +2,37 @@
|
|||||||
'testimonials',
|
'testimonials',
|
||||||
])
|
])
|
||||||
|
|
||||||
|
@php
|
||||||
|
$testimonials = $testimonials
|
||||||
|
->filter(fn ($testimonial): bool => filled(trim((string) $testimonial->quote)))
|
||||||
|
->values();
|
||||||
|
@endphp
|
||||||
|
|
||||||
@if ($testimonials->isNotEmpty())
|
@if ($testimonials->isNotEmpty())
|
||||||
<section aria-labelledby="testimonials-heading" class="border-b border-amare-border bg-amare-bg py-16" data-chapter="testimonials">
|
<section aria-labelledby="testimonials-heading" class="home-chapter border-b border-amare-border bg-amare-bg py-16" data-chapter="testimonials">
|
||||||
<div class="container-amare space-y-8">
|
<div class="container-amare space-y-8" data-reveal-group>
|
||||||
<div class="max-w-2xl space-y-3" data-reveal>
|
<div class="max-w-2xl space-y-3" data-reveal data-reveal-from="up">
|
||||||
|
<x-home.folio label="Depoimentos" />
|
||||||
<h2 id="testimonials-heading" class="text-3xl font-medium text-amare-text">Depoimentos</h2>
|
<h2 id="testimonials-heading" class="text-3xl font-medium text-amare-text">Depoimentos</h2>
|
||||||
<p class="text-amare-text-muted">Quem celebrou com a Amare conta como foi a experiência.</p>
|
<p class="text-amare-text-muted">Quem celebrou com a Amare conta como foi a experiência.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid gap-6 md:grid-cols-2">
|
<div class="grid gap-6 md:grid-cols-2">
|
||||||
@foreach ($testimonials as $testimonial)
|
@foreach ($testimonials as $testimonial)
|
||||||
@continue(blank(trim((string) $testimonial->quote)))
|
|
||||||
@php
|
@php
|
||||||
$paragraphs = preg_split('/\n\s*\n/', trim((string) $testimonial->quote)) ?: [];
|
$paragraphs = preg_split('/\n\s*\n/', trim((string) $testimonial->quote)) ?: [];
|
||||||
$paragraphs = array_values(array_filter(array_map('trim', $paragraphs), fn (string $p): bool => $p !== ''));
|
$paragraphs = array_values(array_filter(array_map('trim', $paragraphs), fn (string $p): bool => $p !== ''));
|
||||||
@endphp
|
@endphp
|
||||||
<blockquote class="space-y-4 border-t border-amare-border pt-4" data-reveal>
|
<blockquote class="space-y-4 border-t border-amare-border pt-4" data-reveal data-reveal-from="{{ $loop->odd ? 'left' : 'right' }}">
|
||||||
<div class="space-y-3 text-lg text-amare-text">
|
<div class="grid grid-cols-[1.5rem_minmax(0,1fr)] gap-2">
|
||||||
@foreach ($paragraphs as $index => $paragraph)
|
<span class="text-3xl leading-none text-amare-sage" aria-hidden="true">“</span>
|
||||||
<p>@if ($index === 0)“@endif{{ $paragraph }}@if ($index === count($paragraphs) - 1)”@endif</p>
|
<div class="space-y-3 text-lg text-amare-text">
|
||||||
@endforeach
|
@foreach ($paragraphs as $paragraph)
|
||||||
|
<p>{{ $paragraph }}</p>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<footer class="text-sm text-amare-text-muted">
|
<footer class="pl-8 text-sm text-amare-text-muted">
|
||||||
<cite class="not-italic font-semibold text-amare-text">{{ $testimonial->author_name }}</cite>
|
<cite class="not-italic font-semibold text-amare-text">{{ $testimonial->author_name }}</cite>
|
||||||
@if (filled($testimonial->context))
|
@if (filled($testimonial->context))
|
||||||
<span> — {{ $testimonial->context }}</span>
|
<span> — {{ $testimonial->context }}</span>
|
||||||
|
|||||||
@@ -15,11 +15,11 @@
|
|||||||
@extends('layouts.public')
|
@extends('layouts.public')
|
||||||
|
|
||||||
@section('content')
|
@section('content')
|
||||||
<div class="container-amare max-w-2xl space-y-6 py-20 text-center">
|
<div class="container-amare max-w-2xl space-y-6 py-20 text-center" data-motion="page-open" data-reveal-group>
|
||||||
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Erro 404</p>
|
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent" data-motion-beat="seal">Erro 404</p>
|
||||||
<h1 class="text-4xl font-medium tracking-tight text-amare-text">Página não encontrada</h1>
|
<h1 class="text-4xl font-medium tracking-tight text-amare-text" data-motion-beat="title">Página não encontrada</h1>
|
||||||
<p class="text-amare-muted">O endereço que você tentou abrir não existe ou foi movido.</p>
|
<p class="text-amare-muted" data-reveal data-reveal-from="up">O endereço que você tentou abrir não existe ou foi movido.</p>
|
||||||
<p>
|
<p data-reveal data-reveal-from="up">
|
||||||
<a href="{{ route('home') }}" class="inline-flex min-h-11 items-center bg-amare-accent px-5 py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-accent-text transition-colors hover:bg-amare-accent-deep">
|
<a href="{{ route('home') }}" class="inline-flex min-h-11 items-center bg-amare-accent px-5 py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-accent-text transition-colors hover:bg-amare-accent-deep">
|
||||||
Voltar para a home
|
Voltar para a home
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -15,11 +15,11 @@
|
|||||||
@extends('layouts.public')
|
@extends('layouts.public')
|
||||||
|
|
||||||
@section('content')
|
@section('content')
|
||||||
<div class="container-amare max-w-2xl space-y-6 py-20 text-center">
|
<div class="container-amare max-w-2xl space-y-6 py-20 text-center" data-motion="page-open" data-reveal-group>
|
||||||
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Erro 419</p>
|
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent" data-motion-beat="seal">Erro 419</p>
|
||||||
<h1 class="text-4xl font-medium tracking-tight text-amare-text">Sessão expirada</h1>
|
<h1 class="text-4xl font-medium tracking-tight text-amare-text" data-motion-beat="title">Sessão expirada</h1>
|
||||||
<p class="text-amare-muted">Sua sessão expirou. Volte e tente enviar novamente.</p>
|
<p class="text-amare-muted" data-reveal data-reveal-from="up">Sua sessão expirou. Volte e tente enviar novamente.</p>
|
||||||
<p>
|
<p data-reveal data-reveal-from="up">
|
||||||
<a href="{{ route('home') }}" class="inline-flex min-h-11 items-center bg-amare-accent px-5 py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-accent-text transition-colors hover:bg-amare-accent-deep">
|
<a href="{{ route('home') }}" class="inline-flex min-h-11 items-center bg-amare-accent px-5 py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-accent-text transition-colors hover:bg-amare-accent-deep">
|
||||||
Voltar para a home
|
Voltar para a home
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -15,11 +15,11 @@
|
|||||||
@extends('layouts.public')
|
@extends('layouts.public')
|
||||||
|
|
||||||
@section('content')
|
@section('content')
|
||||||
<div class="container-amare max-w-2xl space-y-6 py-20 text-center">
|
<div class="container-amare max-w-2xl space-y-6 py-20 text-center" data-motion="page-open" data-reveal-group>
|
||||||
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Erro 429</p>
|
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent" data-motion-beat="seal">Erro 429</p>
|
||||||
<h1 class="text-4xl font-medium tracking-tight text-amare-text">Muitas solicitações</h1>
|
<h1 class="text-4xl font-medium tracking-tight text-amare-text" data-motion-beat="title">Muitas solicitações</h1>
|
||||||
<p class="text-amare-muted">Você enviou muitas solicitações em pouco tempo. Aguarde um instante e tente novamente.</p>
|
<p class="text-amare-muted" data-reveal data-reveal-from="up">Você enviou muitas solicitações em pouco tempo. Aguarde um instante e tente novamente.</p>
|
||||||
<p>
|
<p data-reveal data-reveal-from="up">
|
||||||
<a href="{{ route('home') }}" class="inline-flex min-h-11 items-center bg-amare-accent px-5 py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-accent-text transition-colors hover:bg-amare-accent-deep">
|
<a href="{{ route('home') }}" class="inline-flex min-h-11 items-center bg-amare-accent px-5 py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-accent-text transition-colors hover:bg-amare-accent-deep">
|
||||||
Voltar para a home
|
Voltar para a home
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -15,11 +15,11 @@
|
|||||||
@extends('layouts.public')
|
@extends('layouts.public')
|
||||||
|
|
||||||
@section('content')
|
@section('content')
|
||||||
<div class="container-amare max-w-2xl space-y-6 py-20 text-center">
|
<div class="container-amare max-w-2xl space-y-6 py-20 text-center" data-motion="page-open" data-reveal-group>
|
||||||
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Erro 500</p>
|
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent" data-motion-beat="seal">Erro 500</p>
|
||||||
<h1 class="text-4xl font-medium tracking-tight text-amare-text">Algo deu errado</h1>
|
<h1 class="text-4xl font-medium tracking-tight text-amare-text" data-motion-beat="title">Algo deu errado</h1>
|
||||||
<p class="text-amare-muted">Não foi possível concluir o pedido agora. Tente novamente em instantes.</p>
|
<p class="text-amare-muted" data-reveal data-reveal-from="up">Não foi possível concluir o pedido agora. Tente novamente em instantes.</p>
|
||||||
<p>
|
<p data-reveal data-reveal-from="up">
|
||||||
<a href="{{ route('home') }}" class="inline-flex min-h-11 items-center bg-amare-accent px-5 py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-accent-text transition-colors hover:bg-amare-accent-deep">
|
<a href="{{ route('home') }}" class="inline-flex min-h-11 items-center bg-amare-accent px-5 py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-accent-text transition-colors hover:bg-amare-accent-deep">
|
||||||
Voltar para a home
|
Voltar para a home
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -15,11 +15,11 @@
|
|||||||
@extends('layouts.public')
|
@extends('layouts.public')
|
||||||
|
|
||||||
@section('content')
|
@section('content')
|
||||||
<div class="container-amare max-w-2xl space-y-6 py-20 text-center">
|
<div class="container-amare max-w-2xl space-y-6 py-20 text-center" data-motion="page-open" data-reveal-group>
|
||||||
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Erro 503</p>
|
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent" data-motion-beat="seal">Erro 503</p>
|
||||||
<h1 class="text-4xl font-medium tracking-tight text-amare-text">Em manutenção</h1>
|
<h1 class="text-4xl font-medium tracking-tight text-amare-text" data-motion-beat="title">Em manutenção</h1>
|
||||||
<p class="text-amare-muted">Estamos realizando uma breve manutenção. Tente novamente em instantes.</p>
|
<p class="text-amare-muted" data-reveal data-reveal-from="up">Estamos realizando uma breve manutenção. Tente novamente em instantes.</p>
|
||||||
<p>
|
<p data-reveal data-reveal-from="up">
|
||||||
<a href="{{ route('home') }}" class="inline-flex min-h-11 items-center bg-amare-accent px-5 py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-accent-text transition-colors hover:bg-amare-accent-deep">
|
<a href="{{ route('home') }}" class="inline-flex min-h-11 items-center bg-amare-accent px-5 py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-accent-text transition-colors hover:bg-amare-accent-deep">
|
||||||
Voltar para a home
|
Voltar para a home
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
|
|
||||||
<div class="flex min-h-[calc(100dvh-14rem)] flex-col border-b border-amare-border bg-amare-bg" data-motion="page-open">
|
<div class="flex min-h-[calc(100dvh-14rem)] flex-col border-b border-amare-border bg-amare-bg" data-motion="page-open">
|
||||||
<div class="container-amare grid flex-1 content-start gap-12 py-16 md:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)] md:py-24">
|
<div class="container-amare grid flex-1 content-start gap-12 py-16 md:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)] md:py-24">
|
||||||
<div class="space-y-6">
|
<div class="space-y-6" data-motion-beat="heading">
|
||||||
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">A Amare</p>
|
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">A Amare</p>
|
||||||
<h1 class="text-headline font-medium tracking-tight text-amare-text">Humana no cuidado. Precisa na entrega.</h1>
|
<h1 class="text-headline font-medium tracking-tight text-amare-text">Humana no cuidado. Precisa na entrega.</h1>
|
||||||
<p class="text-lg text-amare-muted">{{ $siteSettings->about_summary }}</p>
|
<p class="text-lg text-amare-muted">{{ $siteSettings->about_summary }}</p>
|
||||||
@@ -32,9 +32,9 @@
|
|||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul class="space-y-4 border-t border-amare-border pt-6" aria-label="Princípios da Amare">
|
<ul class="space-y-4 border-t border-amare-border pt-6" aria-label="Princípios da Amare" data-reveal-group>
|
||||||
@foreach ($principles as $index => $principle)
|
@foreach ($principles as $index => $principle)
|
||||||
<li class="grid grid-cols-[3rem_minmax(0,1fr)] gap-4 border-b border-amare-border pb-4 text-amare-text">
|
<li class="grid grid-cols-[3rem_minmax(0,1fr)] gap-4 border-b border-amare-border pb-4 text-amare-text" data-reveal data-reveal-from="up">
|
||||||
<span class="text-sm font-semibold uppercase tracking-[0.14em] text-amare-accent">{{ str_pad((string) ($index + 1), 2, '0', STR_PAD_LEFT) }}</span>
|
<span class="text-sm font-semibold uppercase tracking-[0.14em] text-amare-accent">{{ str_pad((string) ($index + 1), 2, '0', STR_PAD_LEFT) }}</span>
|
||||||
<span>{{ $principle }}</span>
|
<span>{{ $principle }}</span>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
@extends('layouts.public')
|
@extends('layouts.public')
|
||||||
|
|
||||||
@section('content')
|
@section('content')
|
||||||
<section class="flex min-h-[calc(100dvh-14rem)] flex-col border-b border-amare-border bg-amare-bg">
|
<section class="flex min-h-[calc(100dvh-14rem)] flex-col border-b border-amare-border bg-amare-bg" data-motion="page-open">
|
||||||
<div class="container-amare grid flex-1 content-start gap-12 py-16 md:grid-cols-[minmax(0,1.2fr)_minmax(0,0.8fr)] md:py-24">
|
<div class="container-amare grid flex-1 content-start gap-12 py-16 md:grid-cols-[minmax(0,1.2fr)_minmax(0,0.8fr)] md:py-24">
|
||||||
<div class="space-y-6">
|
<div class="space-y-6" data-motion-beat="heading">
|
||||||
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Vamos conversar</p>
|
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Vamos conversar</p>
|
||||||
<h1 class="text-headline font-medium tracking-tight text-amare-text">Todo grande encontro começa com uma boa conversa.</h1>
|
<h1 class="text-headline font-medium tracking-tight text-amare-text">Todo grande encontro começa com uma boa conversa.</h1>
|
||||||
<p class="max-w-2xl text-lg text-amare-muted">
|
<p class="max-w-2xl text-lg text-amare-muted">
|
||||||
@@ -11,21 +11,21 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-4 border-t border-amare-border pt-6 text-amare-muted md:border-t-0 md:border-l md:pt-0 md:pl-10">
|
<div class="space-y-4 border-t border-amare-border pt-6 text-amare-muted md:border-t-0 md:border-l md:pt-0 md:pl-10" data-reveal-group>
|
||||||
<p>{{ $siteSettings->city ?: 'São Paulo - SP' }}</p>
|
<p data-reveal data-reveal-from="up">{{ $siteSettings->city ?: 'São Paulo - SP' }}</p>
|
||||||
@if ($siteSettings->email)
|
@if ($siteSettings->email)
|
||||||
<p>
|
<p data-reveal data-reveal-from="up">
|
||||||
<a href="mailto:{{ $siteSettings->email }}" class="inline-flex max-w-full min-h-11 items-center text-amare-accent transition-colors hover:text-amare-accent-deep"><span class="min-w-0 break-words">{{ $siteSettings->email }}</span></a>
|
<a href="mailto:{{ $siteSettings->email }}" class="inline-flex max-w-full min-h-11 items-center text-amare-accent transition-colors hover:text-amare-accent-deep"><span class="min-w-0 break-words">{{ $siteSettings->email }}</span></a>
|
||||||
</p>
|
</p>
|
||||||
@endif
|
@endif
|
||||||
@if ($siteSettings->phone)
|
@if ($siteSettings->phone)
|
||||||
<p>
|
<p data-reveal data-reveal-from="up">
|
||||||
<a href="tel:{{ preg_replace('/\D/', '', (string) $siteSettings->phone) }}" class="inline-flex max-w-full min-h-11 items-center text-amare-accent transition-colors hover:text-amare-accent-deep"><span class="min-w-0 break-words">{{ $siteSettings->phone }}</span></a>
|
<a href="tel:{{ preg_replace('/\D/', '', (string) $siteSettings->phone) }}" class="inline-flex max-w-full min-h-11 items-center text-amare-accent transition-colors hover:text-amare-accent-deep"><span class="min-w-0 break-words">{{ $siteSettings->phone }}</span></a>
|
||||||
</p>
|
</p>
|
||||||
@endif
|
@endif
|
||||||
@foreach ($siteSettings->social_links ?? [] as $network => $url)
|
@foreach ($siteSettings->social_links ?? [] as $network => $url)
|
||||||
@if (filled($url))
|
@if (filled($url))
|
||||||
<p>
|
<p data-reveal data-reveal-from="up">
|
||||||
<a href="{{ $url }}" class="inline-flex max-w-full min-h-11 items-center text-amare-accent transition-colors hover:text-amare-accent-deep" rel="noopener noreferrer" target="_blank"><span class="min-w-0 break-words">{{ ucfirst((string) $network) }}</span></a>
|
<a href="{{ $url }}" class="inline-flex max-w-full min-h-11 items-center text-amare-accent transition-colors hover:text-amare-accent-deep" rel="noopener noreferrer" target="_blank"><span class="min-w-0 break-words">{{ ucfirst((string) $network) }}</span></a>
|
||||||
</p>
|
</p>
|
||||||
@endif
|
@endif
|
||||||
@@ -35,9 +35,9 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="border-b border-amare-border bg-amare-bg" aria-labelledby="briefing-heading">
|
<section class="border-b border-amare-border bg-amare-bg" aria-labelledby="briefing-heading">
|
||||||
<div class="container-amare py-16 md:py-24">
|
<div class="container-amare py-16 md:py-24" data-reveal-group>
|
||||||
<h2 id="briefing-heading" class="text-2xl font-medium tracking-tight text-amare-text md:text-3xl">Briefing de contato</h2>
|
<h2 id="briefing-heading" class="text-2xl font-medium tracking-tight text-amare-text md:text-3xl" data-reveal data-reveal-from="up">Briefing de contato</h2>
|
||||||
<p class="mt-2 max-w-2xl text-amare-muted">
|
<p class="mt-2 max-w-2xl text-amare-muted" data-reveal data-reveal-from="up">
|
||||||
Preencha os campos abaixo. As informações obrigatórias estão marcadas com asterisco (*).
|
Preencha os campos abaixo. As informações obrigatórias estão marcadas com asterisco (*).
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
@section('content')
|
@section('content')
|
||||||
@php
|
@php
|
||||||
|
$testimonials = $content->testimonials
|
||||||
|
->filter(fn ($testimonial): bool => filled(trim((string) $testimonial->quote)))
|
||||||
|
->values();
|
||||||
|
|
||||||
$chapters = [
|
$chapters = [
|
||||||
['id' => 'hero-heading', 'label' => 'Capa'],
|
['id' => 'hero-heading', 'label' => 'Capa'],
|
||||||
['id' => 'manifesto-heading', 'label' => 'Manifesto'],
|
['id' => 'manifesto-heading', 'label' => 'Manifesto'],
|
||||||
@@ -17,7 +21,7 @@
|
|||||||
|
|
||||||
$chapters[] = ['id' => 'method-heading', 'label' => 'Método'];
|
$chapters[] = ['id' => 'method-heading', 'label' => 'Método'];
|
||||||
|
|
||||||
if ($content->testimonials->isNotEmpty()) {
|
if ($testimonials->isNotEmpty()) {
|
||||||
$chapters[] = ['id' => 'testimonials-heading', 'label' => 'Depoimentos'];
|
$chapters[] = ['id' => 'testimonials-heading', 'label' => 'Depoimentos'];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,12 +31,14 @@
|
|||||||
|
|
||||||
<x-home.chapter-index :chapters="$chapters" />
|
<x-home.chapter-index :chapters="$chapters" />
|
||||||
|
|
||||||
<x-home.hero :settings="$content->settings" />
|
<div class="home-chapters">
|
||||||
<x-home.manifesto :settings="$content->settings" />
|
<x-home.hero :settings="$content->settings" />
|
||||||
<x-home.services :services="$content->featuredServices" />
|
<x-home.manifesto :settings="$content->settings" />
|
||||||
<x-home.portfolio :cases="$content->featuredCases" />
|
<x-home.services :services="$content->featuredServices" />
|
||||||
<x-home.method :settings="$content->settings" />
|
<x-home.portfolio :cases="$content->featuredCases" />
|
||||||
<x-home.testimonials :testimonials="$content->testimonials" />
|
<x-home.method :settings="$content->settings" />
|
||||||
<x-home.positioning :settings="$content->settings" />
|
<x-home.testimonials :testimonials="$content->testimonials" />
|
||||||
<x-home.final-cta :settings="$content->settings" />
|
<x-home.positioning :settings="$content->settings" />
|
||||||
|
<x-home.final-cta :settings="$content->settings" editorial />
|
||||||
|
</div>
|
||||||
@endsection
|
@endsection
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
@section('content')
|
@section('content')
|
||||||
<div class="border-b border-amare-border bg-amare-bg-deep" data-motion="page-open">
|
<div class="border-b border-amare-border bg-amare-bg-deep" data-motion="page-open">
|
||||||
<div class="container-amare space-y-10 py-16 md:py-24">
|
<div class="container-amare space-y-10 py-16 md:py-24">
|
||||||
<div class="max-w-2xl space-y-4">
|
<div class="max-w-2xl space-y-4" data-motion-beat="heading">
|
||||||
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Portfólio</p>
|
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Portfólio</p>
|
||||||
<h1 class="text-headline font-medium tracking-tight text-amare-text">Atmosferas que contam histórias.</h1>
|
<h1 class="text-headline font-medium tracking-tight text-amare-text">Atmosferas que contam histórias.</h1>
|
||||||
<p class="text-lg text-amare-muted">Casos conduzidos com atenção a ritmo, composição e cada detalhe da experiência.</p>
|
<p class="text-lg text-amare-muted">Casos conduzidos com atenção a ritmo, composição e cada detalhe da experiência.</p>
|
||||||
@@ -12,9 +12,9 @@
|
|||||||
@if ($cases->isEmpty())
|
@if ($cases->isEmpty())
|
||||||
<p class="text-amare-muted">Novos casos serão publicados assim que o acervo estiver organizado. Enquanto isso, fale conosco para conhecer o nosso trabalho.</p>
|
<p class="text-amare-muted">Novos casos serão publicados assim que o acervo estiver organizado. Enquanto isso, fale conosco para conhecer o nosso trabalho.</p>
|
||||||
@else
|
@else
|
||||||
<div class="grid gap-10 md:grid-cols-2">
|
<div class="grid gap-10 md:grid-cols-2" data-reveal-group>
|
||||||
@foreach ($cases as $case)
|
@foreach ($cases as $case)
|
||||||
<article class="space-y-4">
|
<article class="space-y-4" data-reveal data-reveal-from="up">
|
||||||
@if (filled($case->cover_image_path))
|
@if (filled($case->cover_image_path))
|
||||||
<a href="{{ route('portfolio.show', $case->slug) }}" class="block overflow-hidden">
|
<a href="{{ route('portfolio.show', $case->slug) }}" class="block overflow-hidden">
|
||||||
<x-media.image
|
<x-media.image
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<article data-motion="page-open">
|
<article data-motion="page-open">
|
||||||
<div class="border-b border-amare-border bg-amare-bg">
|
<div class="border-b border-amare-border bg-amare-bg">
|
||||||
<div class="container-amare space-y-8 py-16 md:py-24">
|
<div class="container-amare space-y-8 py-16 md:py-24">
|
||||||
<header class="max-w-3xl space-y-4">
|
<header class="max-w-3xl space-y-4" data-motion-beat="heading">
|
||||||
@if (filled($case->event_type))
|
@if (filled($case->event_type))
|
||||||
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">{{ $case->event_type }}</p>
|
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">{{ $case->event_type }}</p>
|
||||||
@endif
|
@endif
|
||||||
@@ -24,23 +24,24 @@
|
|||||||
loading="eager"
|
loading="eager"
|
||||||
sizes="(max-width: 1024px) 100vw, 1120px"
|
sizes="(max-width: 1024px) 100vw, 1120px"
|
||||||
class="img-editorial aspect-[16/9] w-full object-cover"
|
class="img-editorial aspect-[16/9] w-full object-cover"
|
||||||
|
data-motion-beat="media"
|
||||||
/>
|
/>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="border-b border-amare-border bg-amare-bg-deep">
|
<div class="border-b border-amare-border bg-amare-bg-deep">
|
||||||
<div class="container-amare grid gap-10 py-16 md:grid-cols-3">
|
<div class="container-amare grid gap-10 py-16 md:grid-cols-3" data-reveal-group>
|
||||||
<section class="space-y-3">
|
<section class="space-y-3" data-reveal data-reveal-from="up">
|
||||||
<h2 class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Desafio</h2>
|
<h2 class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Desafio</h2>
|
||||||
<p class="text-amare-muted">{!! nl2br(e($case->challenge)) !!}</p>
|
<p class="text-amare-muted">{!! nl2br(e($case->challenge)) !!}</p>
|
||||||
</section>
|
</section>
|
||||||
<section class="space-y-3">
|
<section class="space-y-3" data-reveal data-reveal-from="up">
|
||||||
<h2 class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Solução</h2>
|
<h2 class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Solução</h2>
|
||||||
<p class="text-amare-muted">{!! nl2br(e($case->solution)) !!}</p>
|
<p class="text-amare-muted">{!! nl2br(e($case->solution)) !!}</p>
|
||||||
</section>
|
</section>
|
||||||
@if (filled($case->result))
|
@if (filled($case->result))
|
||||||
<section class="space-y-3">
|
<section class="space-y-3" data-reveal data-reveal-from="up">
|
||||||
<h2 class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Resultado</h2>
|
<h2 class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Resultado</h2>
|
||||||
<p class="text-amare-muted">{!! nl2br(e($case->result)) !!}</p>
|
<p class="text-amare-muted">{!! nl2br(e($case->result)) !!}</p>
|
||||||
</section>
|
</section>
|
||||||
@@ -52,9 +53,9 @@
|
|||||||
<section aria-labelledby="gallery-heading" class="bg-amare-bg">
|
<section aria-labelledby="gallery-heading" class="bg-amare-bg">
|
||||||
<div class="container-amare space-y-8 py-16">
|
<div class="container-amare space-y-8 py-16">
|
||||||
<h2 id="gallery-heading" class="text-3xl font-medium text-amare-text">Galeria</h2>
|
<h2 id="gallery-heading" class="text-3xl font-medium text-amare-text">Galeria</h2>
|
||||||
<div class="grid gap-6 md:grid-cols-2">
|
<div class="grid gap-6 md:grid-cols-2" data-reveal-group>
|
||||||
@foreach ($case->images as $image)
|
@foreach ($case->images as $image)
|
||||||
<figure class="space-y-2">
|
<figure class="space-y-2" data-reveal data-reveal-from="up" data-reveal-media>
|
||||||
<x-media.image
|
<x-media.image
|
||||||
:path="$image->path"
|
:path="$image->path"
|
||||||
:alt="$image->alt_text"
|
:alt="$image->alt_text"
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
@extends('layouts.public')
|
@extends('layouts.public')
|
||||||
|
|
||||||
@section('content')
|
@section('content')
|
||||||
<div class="border-b border-amare-border bg-amare-bg">
|
<div class="border-b border-amare-border bg-amare-bg" data-motion="page-open">
|
||||||
<div class="container-amare max-w-3xl space-y-6 py-16 md:py-24">
|
<div class="container-amare max-w-3xl space-y-6 py-16 md:py-24" data-reveal-group>
|
||||||
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Privacidade</p>
|
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent" data-motion-beat="seal">Privacidade</p>
|
||||||
<h1 class="text-4xl font-medium tracking-tight text-amare-text">Política de privacidade</h1>
|
<h1 class="text-4xl font-medium tracking-tight text-amare-text" data-motion-beat="title">Política de privacidade</h1>
|
||||||
<p class="text-amare-muted">
|
<p class="text-amare-muted" data-reveal data-reveal-from="up">
|
||||||
A {{ $siteSettings->brand_name }} trata dados pessoais com responsabilidade e somente para finalidades
|
A {{ $siteSettings->brand_name }} trata dados pessoais com responsabilidade e somente para finalidades
|
||||||
relacionadas ao atendimento de interessados e à operação do site.
|
relacionadas ao atendimento de interessados e à operação do site.
|
||||||
</p>
|
</p>
|
||||||
<p class="text-amare-muted">
|
<p class="text-amare-muted" data-reveal data-reveal-from="up">
|
||||||
Para dúvidas sobre privacidade, escreva para
|
Para dúvidas sobre privacidade, escreva para
|
||||||
<a href="mailto:{{ $siteSettings->email }}" class="text-amare-accent transition-colors hover:text-amare-accent-deep">{{ $siteSettings->email }}</a>.
|
<a href="mailto:{{ $siteSettings->email }}" class="text-amare-accent underline underline-offset-2 transition-colors hover:text-amare-accent-deep">{{ $siteSettings->email }}</a>.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
@section('content')
|
@section('content')
|
||||||
<div class="border-b border-amare-border bg-amare-bg" data-motion="page-open">
|
<div class="border-b border-amare-border bg-amare-bg" data-motion="page-open">
|
||||||
<div class="container-amare space-y-10 py-16 md:py-24">
|
<div class="container-amare space-y-10 py-16 md:py-24">
|
||||||
<div class="max-w-2xl space-y-4">
|
<div class="max-w-2xl space-y-4" data-motion-beat="heading">
|
||||||
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Serviços</p>
|
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Serviços</p>
|
||||||
<h1 class="text-headline font-medium tracking-tight text-amare-text">Uma mesma excelência, diferentes ocasiões.</h1>
|
<h1 class="text-headline font-medium tracking-tight text-amare-text">Uma mesma excelência, diferentes ocasiões.</h1>
|
||||||
<p class="text-lg text-amare-muted">O escopo é construído de acordo com o momento do projeto, o nível de apoio necessário e a complexidade de cada evento.</p>
|
<p class="text-lg text-amare-muted">O escopo é construído de acordo com o momento do projeto, o nível de apoio necessário e a complexidade de cada evento.</p>
|
||||||
@@ -12,9 +12,9 @@
|
|||||||
@if ($services->isEmpty())
|
@if ($services->isEmpty())
|
||||||
<p class="text-amare-muted">O catálogo de serviços está em organização. Enquanto isso, fale conosco para uma primeira conversa.</p>
|
<p class="text-amare-muted">O catálogo de serviços está em organização. Enquanto isso, fale conosco para uma primeira conversa.</p>
|
||||||
@else
|
@else
|
||||||
<div class="divide-y divide-amare-border border-y border-amare-border">
|
<div class="divide-y divide-amare-border border-y border-amare-border" data-reveal-group>
|
||||||
@foreach ($services as $index => $service)
|
@foreach ($services as $index => $service)
|
||||||
<article class="grid gap-4 py-8 md:grid-cols-[5rem_minmax(0,1fr)_minmax(0,1.2fr)] md:items-start">
|
<article class="grid gap-4 py-8 md:grid-cols-[5rem_minmax(0,1fr)_minmax(0,1.2fr)] md:items-start" data-reveal data-reveal-from="up">
|
||||||
<span class="text-sm font-semibold uppercase tracking-[0.14em] text-amare-accent">{{ str_pad((string) ($index + 1), 2, '0', STR_PAD_LEFT) }}</span>
|
<span class="text-sm font-semibold uppercase tracking-[0.14em] text-amare-accent">{{ str_pad((string) ($index + 1), 2, '0', STR_PAD_LEFT) }}</span>
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<h2 class="text-2xl font-medium text-amare-text md:text-3xl">{{ $service->title }}</h2>
|
<h2 class="text-2xl font-medium text-amare-text md:text-3xl">{{ $service->title }}</h2>
|
||||||
|
|||||||
67
scripts/perf/lighthouse.sh
Executable file
67
scripts/perf/lighthouse.sh
Executable file
@@ -0,0 +1,67 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Lighthouse run against the production image, reproducing the measurement
|
||||||
|
# behind MAN-109. Not a CI gate — SPEC.md §14.1 pins the five blocking jobs and
|
||||||
|
# §22 governs when new capability is added. This is the recipe so the numbers
|
||||||
|
# can be reproduced instead of remembered.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# scripts/perf/lighthouse.sh [output-dir]
|
||||||
|
#
|
||||||
|
# Expects a site already answering on $BASE_URL. To raise one from scratch:
|
||||||
|
#
|
||||||
|
# docker build -t amare-app:ci .
|
||||||
|
# docker run -d --name amare-web -p 8000:8000 \
|
||||||
|
# -e APP_ENV=production -e APP_DEBUG=false -e APP_KEY="$APP_KEY" \
|
||||||
|
# -e DB_CONNECTION=pgsql -e DB_HOST=host.docker.internal -e DB_PORT=5432 \
|
||||||
|
# -e DB_DATABASE=amare -e DB_USERNAME=amare -e DB_PASSWORD=secret \
|
||||||
|
# -e SESSION_DRIVER=database -e CACHE_STORE=database -e QUEUE_CONNECTION=database \
|
||||||
|
# --add-host=host.docker.internal:host-gateway \
|
||||||
|
# -v "$(pwd)/storage/app/public:/app/storage/app/public" \
|
||||||
|
# amare-app:ci
|
||||||
|
#
|
||||||
|
# Seed content and generate the responsive variants first. Skipping
|
||||||
|
# `media:generate-variants` inflates LCP by roughly 2.5 s on the home page,
|
||||||
|
# because the originals are served at full size:
|
||||||
|
#
|
||||||
|
# php artisan db:seed --class=ContentSeeder --force
|
||||||
|
# php artisan media:generate-variants
|
||||||
|
#
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BASE_URL="${BASE_URL:-http://127.0.0.1:8000}"
|
||||||
|
OUT_DIR="${1:-storage/app/lighthouse}"
|
||||||
|
|
||||||
|
# Lighthouse needs a Chrome binary. Playwright's is already on disk after
|
||||||
|
# `npx playwright install chromium`; fall back to a system Chrome.
|
||||||
|
if [[ -z "${CHROME_PATH:-}" ]]; then
|
||||||
|
PLAYWRIGHT_CHROME=$(find "${HOME}/Library/Caches/ms-playwright" "${HOME}/.cache/ms-playwright" \
|
||||||
|
-maxdepth 3 -name 'Google Chrome for Testing' -type f 2>/dev/null | head -1 || true)
|
||||||
|
|
||||||
|
if [[ -n "${PLAYWRIGHT_CHROME}" ]]; then
|
||||||
|
export CHROME_PATH="${PLAYWRIGHT_CHROME}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "${OUT_DIR}"
|
||||||
|
|
||||||
|
PAGES=("/:home" "/servicos:servicos" "/portfolio:portfolio" "/sobre:sobre" "/contato:contato")
|
||||||
|
|
||||||
|
for entry in "${PAGES[@]}"; do
|
||||||
|
path="${entry%%:*}"
|
||||||
|
name="${entry##*:}"
|
||||||
|
|
||||||
|
echo "auditing ${name} (${BASE_URL}${path})"
|
||||||
|
|
||||||
|
# Default preset: simulated mobile throttling, 150 ms RTT, ~1.6 Mbps,
|
||||||
|
# 4x CPU slowdown. Add --preset=desktop for the desktop numbers.
|
||||||
|
npx --yes lighthouse@12 "${BASE_URL}${path}" \
|
||||||
|
--quiet \
|
||||||
|
--output=json --output=html \
|
||||||
|
--output-path="${OUT_DIR}/${name}-mobile" \
|
||||||
|
--chrome-flags="--headless=new --no-sandbox"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "reports written to ${OUT_DIR}"
|
||||||
|
echo "SPEC.md §6.6 targets: LCP <= 2.5s, CLS <= 0.1, INP <= 200ms, 0 console errors"
|
||||||
92
tasks.md
92
tasks.md
@@ -1,79 +1,19 @@
|
|||||||
# Frontend Audit — Amare site
|
# Fix visual regression baselines (CI-parity)
|
||||||
|
|
||||||
## T0 — Baseline
|
- [x] 1. Worktree `fix/visual-baselines-ci` a partir de `origin/main` (.worktrees/visual-baselines-ci @ 0aee15e)
|
||||||
- [x] Rodar `composer quality` p/ registrar estado verde atual (110 passed após composer install)
|
- [x] 2. Postgres `amare_test` up (amare-postgres reutilizado, DB criado)
|
||||||
- [x] Registrado estado baseline; Lighthouse final consolidado no T6
|
- [x] 3. Build `amare-app:ci` + container amare-web com env idêntica ao job browser (APP_FROZEN_NOW etc.) — /up ok
|
||||||
- [x] Registrar achados baseline (audit completo em frontend-audit.md + relatório subagent)
|
- [x] 4. Build imagem amare-ci-runner (ubuntu:24.04 + PHP 8.4 + node 22 + playwright deps)
|
||||||
|
- [x] 5. Regenerar 16 snapshots via `php artisan test --testsuite=Browser --update-snapshots` dentro do ci-runner (network host)
|
||||||
|
- [x] 6. Verificar: re-run Browser suite verde (35/35)
|
||||||
|
- [x] 7. Commit .snap, push, PR #24, CI verde; bump commonmark 2.9.0 p/ auditoria; merged
|
||||||
|
|
||||||
## T1 — Formulário de contato (e-mail, sem CRM)
|
# PR #23 — cadência editorial à home (sync + merge)
|
||||||
- [x] Form em /contato (campos, LGPD obrigatório)
|
|
||||||
- [x] Rota POST /contato + validação + honeypot + throttle
|
|
||||||
- [x] Anti duplo-envio, estados sucesso/erro/rede, preservação de dados
|
|
||||||
- [x] E-mail via MAIL_MAILER (Resend prod / log local)
|
|
||||||
- [x] A11y do form (labels, erros ligados)
|
|
||||||
- [x] Testes feature + atualizar PublicPagesTest — Feature suite: 97 passed
|
|
||||||
|
|
||||||
## T2 — A11y/navegação
|
- [x] 1. Reset worktree home-editorial-cadence p/ origin/feat/home-editorial-cadence (8ae5824, incl. reconcile)
|
||||||
- [x] Menu mobile: Escape, retorno de foco, fallback sem-JS (app.js + noscript)
|
- [x] 2. Merge origin/main (PR #24): resolver conflitos home snaps (theirs); 14 snaps não-home via auto-merge
|
||||||
- [x] Touch targets ≥44px (header, footer, CTAs, capítulos)
|
- [x] 3. Rebuild amare-app:ci com código PR #23; container amare-web recriado
|
||||||
- [x] Footer: ul/li + address
|
- [x] 4. Regenerar home desktop/mobile snaps no ci-runner (--update-snapshots); commit d9751b1
|
||||||
|
- [x] 5. Verificação: Browser suite 37 passed (156 assertions) — inclui HomeEditorialCadenceTest/MotionTest
|
||||||
## T3 — SEO/metadados
|
- [x] 6. Push; CI 5/5 verde (run 31230461189)
|
||||||
- [x] Títulos "Página · Amare" (PageMeta::withBrandSuffix)
|
- [x] 7. Merge squash PR #23 (commit 42b282c1); worktree/branch/remota limpos
|
||||||
- [x] og:locale, og:site_name, Twitter cards
|
|
||||||
- [x] Favicon real + link, theme-color
|
|
||||||
- [x] Metadados corretos em 404/500 (PageMeta::forErrorPage)
|
|
||||||
- [x] noindex fora de produção (robots meta condicional)
|
|
||||||
|
|
||||||
## T4 — Conteúdo/posicionamento
|
|
||||||
- [x] Remover welcome.blade.php, home/cases, home/proof
|
|
||||||
- [x] Dedup heading final-CTA vs h1 contato; dedup disclaimers
|
|
||||||
- [x] CTA consistente mobile/desktop
|
|
||||||
- [x] Corrigir copy "Em breve..." nas listas
|
|
||||||
- [x] Rebalancear copy corporativo/social
|
|
||||||
|
|
||||||
## T5 — Performance
|
|
||||||
- [x] fetchpriority + dimensões hero (LCP)
|
|
||||||
- [x] Guarda CLS no media/image
|
|
||||||
- [x] Cache manifest de fontes
|
|
||||||
- [x] Escape JSON-LD
|
|
||||||
|
|
||||||
## T6 — Validação final
|
|
||||||
- [x] pint, phpstan, testes unit/feature/architecture — `composer quality` verde (135 testes)
|
|
||||||
- [x] Browser tests + visual regression (18 passaram; 8 baselines `.snap` atualizadas com `--update-snapshots`)
|
|
||||||
- [x] Lighthouse final (home mobile: A11y 100, Best Practices 100, SEO 69 só por noindex intencional fora de prod; contato desktop: A11y 100, BP 100)
|
|
||||||
- [x] E2E form: submit → sucesso role=status; 2 e-mails (novo briefing + confirmação) logados via MAIL_MAILER=log; fila drenada
|
|
||||||
- [x] Mobile: sem overflow horizontal nas 7 rotas (390px); menu mobile abre/fecha + Escape + retorno de foco
|
|
||||||
- [x] Relatório de entrega
|
|
||||||
|
|
||||||
## T7 — Hardening Public Blades
|
|
||||||
|
|
||||||
- [x] Chunk 1: CSS overflow resilience (`overflow-wrap` body + `[data-chapter-index]` wrap safety, browser test)
|
|
||||||
- [x] Chunk 2: Hero + final-CTA fallbacks + `leading-tight` (TDD)
|
|
||||||
- [x] Chunk 3a: Skip blank-quote testimonials (TDD)
|
|
||||||
- [x] Chunk 3b: Guard empty `event_type` + `break-words` meta line (TDD)
|
|
||||||
- [x] Chunk 4: Extend `PageMeta::forErrorPage` + branded 419/429/503 error views (TDD)
|
|
||||||
- [x] Chunk 5: `app.js` defensive hardening — bfcache submit reset, "Enviando…" label swap, aria-busy, mobile-menu focus trap + Escape
|
|
||||||
- [x] Chunk 6: Quality gates — Pint passed, PHPStan clean, 104 feature tests passed (587 assertions), Vite build succeeded
|
|
||||||
|
|
||||||
### Deviations from plan
|
|
||||||
|
|
||||||
- `PageMeta::forErrorPage` landed on `main` during implementation; rebased and extended it for 419/429/503.
|
|
||||||
- Contact form landed on `main`; submit-state browser coverage was restored.
|
|
||||||
- `event_type: null` violates NOT NULL column — test uses `''` instead (guard covers both).
|
|
||||||
|
|
||||||
# Browser CI Gate — estabilidade e diagnosticabilidade
|
|
||||||
|
|
||||||
## BG — Causa raiz
|
|
||||||
- [x] Diagnosticar 3 runs: mismatch visual (8 testes), acessibilidade (1), timeout networkidle 5s + overflow real
|
|
||||||
- [x] Reproduzir local: `waitForLoadState('networkidle')` fragil (conexões longas), default 5s
|
|
||||||
- [x] Reproduzir warning `file_get_contents(.env)` em TODO teste (CI sem .env)
|
|
||||||
|
|
||||||
## BG — Fixes
|
|
||||||
- [x] Helper `StableScreenshot` (tests/Support/): drop networkidle → readyState + fonts + settle
|
|
||||||
- [x] Exportar PNGs standalone (diff/expected/actual) no mismatch — visíveis nos artifacts
|
|
||||||
- [x] CI: `cp .env.example .env` nos jobs de teste; checkout/cache v5
|
|
||||||
- [x] Gitignore tests/Browser/Screenshots
|
|
||||||
- [x] Unit test MismatchScreenshotExporter; pint/phpstan/unit/feature verdes
|
|
||||||
- [x] Push + PR + CI browser verde (PR #15 merge f09ea83, run 31098941038)
|
|
||||||
- [x] Refresh baselines de CI se necessário (não necessário — browser passou com baselines atuais)
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user