Compare commits
2 Commits
fix/seed-t
...
feature/re
| Author | SHA1 | Date | |
|---|---|---|---|
| 2a5c55d90f | |||
| 9c43ad1d65 |
@@ -2,7 +2,6 @@ APP_NAME=Amare
|
|||||||
APP_ENV=local
|
APP_ENV=local
|
||||||
APP_KEY=
|
APP_KEY=
|
||||||
APP_DEBUG=true
|
APP_DEBUG=true
|
||||||
# Staging/production: set APP_URL to the public HTTPS origin (e.g. https://staging.example.com).
|
|
||||||
APP_URL=http://localhost
|
APP_URL=http://localhost
|
||||||
|
|
||||||
APP_LOCALE=pt_BR
|
APP_LOCALE=pt_BR
|
||||||
@@ -39,10 +38,6 @@ SESSION_LIFETIME=120
|
|||||||
SESSION_ENCRYPT=false
|
SESSION_ENCRYPT=false
|
||||||
SESSION_PATH=/
|
SESSION_PATH=/
|
||||||
SESSION_DOMAIN=null
|
SESSION_DOMAIN=null
|
||||||
# Staging/production behind HTTPS (Dokploy Traefik): SESSION_SECURE_COOKIE=true
|
|
||||||
SESSION_SECURE_COOKIE=false
|
|
||||||
SESSION_HTTP_ONLY=true
|
|
||||||
SESSION_SAME_SITE=lax
|
|
||||||
|
|
||||||
BROADCAST_CONNECTION=log
|
BROADCAST_CONNECTION=log
|
||||||
FILESYSTEM_DISK=local
|
FILESYSTEM_DISK=local
|
||||||
|
|||||||
26
.github/workflows/ci.yml
vendored
@@ -31,8 +31,7 @@ jobs:
|
|||||||
name: static
|
name: static
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v4
|
||||||
- run: cp .env.example .env
|
|
||||||
|
|
||||||
- uses: shivammathur/setup-php@v2
|
- uses: shivammathur/setup-php@v2
|
||||||
with:
|
with:
|
||||||
@@ -40,7 +39,7 @@ jobs:
|
|||||||
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd
|
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd
|
||||||
coverage: none
|
coverage: none
|
||||||
|
|
||||||
- uses: actions/cache@v5
|
- uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: ~/.composer/cache/files
|
path: ~/.composer/cache/files
|
||||||
key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }}
|
key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }}
|
||||||
@@ -56,8 +55,7 @@ jobs:
|
|||||||
name: unit
|
name: unit
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v4
|
||||||
- run: cp .env.example .env
|
|
||||||
|
|
||||||
- uses: shivammathur/setup-php@v2
|
- uses: shivammathur/setup-php@v2
|
||||||
with:
|
with:
|
||||||
@@ -65,7 +63,7 @@ jobs:
|
|||||||
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd
|
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd
|
||||||
coverage: none
|
coverage: none
|
||||||
|
|
||||||
- uses: actions/cache@v5
|
- uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: ~/.composer/cache/files
|
path: ~/.composer/cache/files
|
||||||
key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }}
|
key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }}
|
||||||
@@ -94,8 +92,7 @@ jobs:
|
|||||||
--health-timeout 5s
|
--health-timeout 5s
|
||||||
--health-retries 10
|
--health-retries 10
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v4
|
||||||
- run: cp .env.example .env
|
|
||||||
|
|
||||||
- uses: shivammathur/setup-php@v2
|
- uses: shivammathur/setup-php@v2
|
||||||
with:
|
with:
|
||||||
@@ -103,13 +100,13 @@ jobs:
|
|||||||
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd
|
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd
|
||||||
coverage: none
|
coverage: none
|
||||||
|
|
||||||
- uses: actions/cache@v5
|
- uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: ~/.composer/cache/files
|
path: ~/.composer/cache/files
|
||||||
key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }}
|
key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }}
|
||||||
restore-keys: composer-${{ runner.os }}-
|
restore-keys: composer-${{ runner.os }}-
|
||||||
|
|
||||||
- uses: actions/cache@v5
|
- uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: ~/.npm
|
path: ~/.npm
|
||||||
key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
|
key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
|
||||||
@@ -139,8 +136,7 @@ jobs:
|
|||||||
--health-timeout 5s
|
--health-timeout 5s
|
||||||
--health-retries 10
|
--health-retries 10
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v4
|
||||||
- run: cp .env.example .env
|
|
||||||
|
|
||||||
- uses: shivammathur/setup-php@v2
|
- uses: shivammathur/setup-php@v2
|
||||||
with:
|
with:
|
||||||
@@ -148,13 +144,13 @@ jobs:
|
|||||||
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd
|
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd
|
||||||
coverage: none
|
coverage: none
|
||||||
|
|
||||||
- uses: actions/cache@v5
|
- uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: ~/.composer/cache/files
|
path: ~/.composer/cache/files
|
||||||
key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }}
|
key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }}
|
||||||
restore-keys: composer-${{ runner.os }}-
|
restore-keys: composer-${{ runner.os }}-
|
||||||
|
|
||||||
- uses: actions/cache@v5
|
- uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: ~/.npm
|
path: ~/.npm
|
||||||
key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
|
key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
|
||||||
@@ -228,7 +224,7 @@ jobs:
|
|||||||
name: container
|
name: container
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Build production image
|
- name: Build production image
|
||||||
run: docker build -t amare-app:ci .
|
run: docker build -t amare-app:ci .
|
||||||
|
|||||||
82
.github/workflows/deploy-staging.yml
vendored
@@ -1,82 +0,0 @@
|
|||||||
name: Deploy staging
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_run:
|
|
||||||
workflows: [CI]
|
|
||||||
types: [completed]
|
|
||||||
branches: [main]
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
packages: write
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: deploy-staging
|
|
||||||
cancel-in-progress: false
|
|
||||||
|
|
||||||
env:
|
|
||||||
REGISTRY: ghcr.io
|
|
||||||
IMAGE_NAME: ${{ github.repository }}
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
deploy:
|
|
||||||
name: publish-and-deploy-staging
|
|
||||||
if: >-
|
|
||||||
github.event.workflow_run.conclusion == 'success' &&
|
|
||||||
github.event.workflow_run.event == 'push' &&
|
|
||||||
github.event.workflow_run.head_branch == 'main'
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout deployed SHA
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
ref: ${{ github.event.workflow_run.head_sha }}
|
|
||||||
|
|
||||||
- name: Set image metadata
|
|
||||||
id: meta
|
|
||||||
run: |
|
|
||||||
SHA="${{ github.event.workflow_run.head_sha }}"
|
|
||||||
SHORT_SHA="${SHA:0:7}"
|
|
||||||
IMAGE="${REGISTRY}/${IMAGE_NAME}"
|
|
||||||
IMAGE="$(echo "$IMAGE" | tr '[:upper:]' '[:lower:]')"
|
|
||||||
echo "sha=${SHA}" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "short_sha=${SHORT_SHA}" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "image=${IMAGE}" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- name: Log in to GHCR
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ${{ env.REGISTRY }}
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: Build and push SHA + staging tags
|
|
||||||
uses: docker/build-push-action@v6
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
push: true
|
|
||||||
tags: |
|
|
||||||
${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.sha }}
|
|
||||||
${{ steps.meta.outputs.image }}:staging
|
|
||||||
cache-from: type=gha
|
|
||||||
cache-to: type=gha,mode=max
|
|
||||||
|
|
||||||
- name: Deploy staging on Dokploy
|
|
||||||
env:
|
|
||||||
DOKPLOY_URL: ${{ secrets.DOKPLOY_URL }}
|
|
||||||
DOKPLOY_API_KEY: ${{ secrets.DOKPLOY_API_KEY }}
|
|
||||||
DOKPLOY_COMPOSE_ID: ${{ secrets.DOKPLOY_STAGING_COMPOSE_ID }}
|
|
||||||
DEPLOY_TITLE: "staging ${{ steps.meta.outputs.short_sha }}"
|
|
||||||
run: |
|
|
||||||
chmod +x scripts/deploy/dokploy-deploy.sh
|
|
||||||
./scripts/deploy/dokploy-deploy.sh
|
|
||||||
|
|
||||||
- name: Smoke staging
|
|
||||||
env:
|
|
||||||
SMOKE_BASE_URL: ${{ secrets.STAGING_URL }}
|
|
||||||
run: |
|
|
||||||
chmod +x scripts/deploy/smoke.sh
|
|
||||||
./scripts/deploy/smoke.sh
|
|
||||||
84
.github/workflows/promote-production.yml
vendored
@@ -1,84 +0,0 @@
|
|||||||
name: Promote production
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
sha:
|
|
||||||
description: Full git SHA already published to GHCR (same digest used by staging)
|
|
||||||
required: true
|
|
||||||
type: string
|
|
||||||
confirm:
|
|
||||||
description: Type PRODUCTION to confirm promotion of the given SHA
|
|
||||||
required: true
|
|
||||||
type: string
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
packages: write
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: deploy-production
|
|
||||||
cancel-in-progress: false
|
|
||||||
|
|
||||||
env:
|
|
||||||
REGISTRY: ghcr.io
|
|
||||||
IMAGE_NAME: ${{ github.repository }}
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
promote:
|
|
||||||
name: promote-production
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Guard confirmation
|
|
||||||
run: |
|
|
||||||
if [ "${{ inputs.confirm }}" != "PRODUCTION" ]; then
|
|
||||||
echo "Confirmation must be exactly PRODUCTION" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Checkout repository scripts
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Set image metadata
|
|
||||||
id: meta
|
|
||||||
run: |
|
|
||||||
SHA="${{ inputs.sha }}"
|
|
||||||
SHORT_SHA="${SHA:0:7}"
|
|
||||||
IMAGE="${REGISTRY}/${IMAGE_NAME}"
|
|
||||||
IMAGE="$(echo "$IMAGE" | tr '[:upper:]' '[:lower:]')"
|
|
||||||
echo "sha=${SHA}" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "short_sha=${SHORT_SHA}" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "image=${IMAGE}" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- name: Log in to GHCR
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ${{ env.REGISTRY }}
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: Point :production at existing SHA digest (no rebuild)
|
|
||||||
run: |
|
|
||||||
docker buildx imagetools create \
|
|
||||||
--tag "${{ steps.meta.outputs.image }}:production" \
|
|
||||||
"${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.sha }}"
|
|
||||||
|
|
||||||
- name: Deploy production on Dokploy
|
|
||||||
env:
|
|
||||||
DOKPLOY_URL: ${{ secrets.DOKPLOY_URL }}
|
|
||||||
DOKPLOY_API_KEY: ${{ secrets.DOKPLOY_API_KEY }}
|
|
||||||
DOKPLOY_COMPOSE_ID: ${{ secrets.DOKPLOY_PRODUCTION_COMPOSE_ID }}
|
|
||||||
DEPLOY_TITLE: "production ${{ steps.meta.outputs.short_sha }}"
|
|
||||||
run: |
|
|
||||||
chmod +x scripts/deploy/dokploy-deploy.sh
|
|
||||||
./scripts/deploy/dokploy-deploy.sh
|
|
||||||
|
|
||||||
- name: Smoke production
|
|
||||||
env:
|
|
||||||
SMOKE_BASE_URL: ${{ secrets.PRODUCTION_URL }}
|
|
||||||
run: |
|
|
||||||
chmod +x scripts/deploy/smoke.sh
|
|
||||||
./scripts/deploy/smoke.sh
|
|
||||||
1
.gitignore
vendored
@@ -18,7 +18,6 @@
|
|||||||
/public/fonts-manifest.dev.json
|
/public/fonts-manifest.dev.json
|
||||||
/public/hot
|
/public/hot
|
||||||
/public/storage
|
/public/storage
|
||||||
/tests/Browser/Screenshots
|
|
||||||
/storage/*.key
|
/storage/*.key
|
||||||
/storage/pail
|
/storage/pail
|
||||||
/vendor
|
/vendor
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
#!/usr/bin/env sh
|
|
||||||
|
|
||||||
composer pint:check && composer phpstan
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
#!/usr/bin/env sh
|
|
||||||
|
|
||||||
# Abort push when the test database is unreachable, so broken code never
|
|
||||||
# reaches CI. Test DB settings come from phpunit.xml.
|
|
||||||
php -r '
|
|
||||||
$xml = @simplexml_load_file("phpunit.xml");
|
|
||||||
if ($xml === false) {
|
|
||||||
fwrite(STDERR, "phpunit.xml not found — aborting pre-push.\n");
|
|
||||||
exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
$defaults = ["DB_HOST" => "127.0.0.1", "DB_PORT" => "5432", "DB_DATABASE" => "amare_test", "DB_USERNAME" => "amare", "DB_PASSWORD" => "secret"];
|
|
||||||
$env = [];
|
|
||||||
foreach ($xml->php->env as $node) {
|
|
||||||
$name = (string) $node["name"];
|
|
||||||
if (isset($defaults[$name])) {
|
|
||||||
$env[$name] = (string) $node["value"];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$config = array_merge($defaults, $env);
|
|
||||||
|
|
||||||
$dsn = sprintf("pgsql:host=%s;port=%s;dbname=%s", $config["DB_HOST"], $config["DB_PORT"], $config["DB_DATABASE"]);
|
|
||||||
try {
|
|
||||||
new PDO($dsn, $config["DB_USERNAME"], $config["DB_PASSWORD"], [PDO::ATTR_TIMEOUT => 3]);
|
|
||||||
} catch (PDOException $e) {
|
|
||||||
fwrite(STDERR, "\033[31mPostgreSQL is unreachable on {$config["DB_HOST"]}:{$config["DB_PORT"]} (db: {$config["DB_DATABASE"]}).\033[0m\n");
|
|
||||||
fwrite(STDERR, "Start it with: docker compose up -d\n");
|
|
||||||
fwrite(STDERR, "Then retry the push.\n");
|
|
||||||
exit(1);
|
|
||||||
}
|
|
||||||
' || exit 1
|
|
||||||
|
|
||||||
composer test:unit && composer test:feature
|
|
||||||
@@ -12,6 +12,5 @@ related_targets: ["resources/views/pages/services/index.blade.php","resources/vi
|
|||||||
- **Action, proof, and constraints:** ação principal é enviar briefing inicial. Prova disponível: cinco depoimentos reais de casamentos, com nomes e datas; publicação depende de autorização final. Não inventar cases corporativos, credenciais ou resultados. Fotografias permanecem ilustrativas e marcadas até chegada de acervo autorizado.
|
- **Action, proof, and constraints:** ação principal é enviar briefing inicial. Prova disponível: cinco depoimentos reais de casamentos, com nomes e datas; publicação depende de autorização final. Não inventar cases corporativos, credenciais ou resultados. Fotografias permanecem ilustrativas e marcadas até chegada de acervo autorizado.
|
||||||
- **Chosen direction:** “Dossiê Editorial do Evento”. Home funciona como capa e índice; serviços viram capítulos, portfólio vira cadernos de caso, sobre vira perfil editorial e contato vira ficha de briefing. Sistema visual global segue Heritage Editorial em DESIGN.md.
|
- **Chosen direction:** “Dossiê Editorial do Evento”. Home funciona como capa e índice; serviços viram capítulos, portfólio vira cadernos de caso, sobre vira perfil editorial e contato vira ficha de briefing. Sistema visual global segue Heritage Editorial em DESIGN.md.
|
||||||
- **Memorable moment:** coração facetado atua como selo editorial enquanto índice discreto acompanha capítulos e deixa serviço, método e próximo passo visíveis sem transformar página em dashboard.
|
- **Memorable moment:** coração facetado atua como selo editorial enquanto índice discreto acompanha capítulos e deixa serviço, método e próximo passo visíveis sem transformar página em dashboard.
|
||||||
- **Motion thesis (Dossiê vivo):** Home recebe abertura autoral (selo → título → recorte de imagem → CTAs, ≤800ms). Índice lateral no desktop e linha de progresso no mobile acompanham capítulos. Serviços, portfólio e sobre usam abertura curta e revelações discretas; contato, privacidade e erros permanecem quase estáticos. Sem parallax, loops, bounce ou scroll-jacking. `prefers-reduced-motion` entrega estado final imediato.
|
|
||||||
- **Responsive and interaction:** spreads assimétricos no desktop; sequência linear no mobile. CTAs recorrentes, navegação clara, formulário com loading, erro e sucesso, foco visível e suporte a movimento reduzido.
|
- **Responsive and interaction:** spreads assimétricos no desktop; sequência linear no mobile. CTAs recorrentes, navegação clara, formulário com loading, erro e sucesso, foco visível e suporte a movimento reduzido.
|
||||||
- **Unresolved:** logo transparente ou vetorial; fotografias autorizadas; WhatsApp, e-mail e Instagram oficiais; textos jurídicos; autorização dos depoimentos; provas reais de eventos corporativos.
|
- **Unresolved:** logo transparente ou vetorial; fotografias autorizadas; WhatsApp, e-mail e Instagram oficiais; textos jurídicos; autorização dos depoimentos; provas reais de eventos corporativos.
|
||||||
|
|||||||
11
AGENTS.md
@@ -15,17 +15,6 @@ This is a Laravel 13 application for an event-planning consultancy. Application
|
|||||||
|
|
||||||
Feature and browser tests require the `amare_test` PostgreSQL database configured in `phpunit.xml`.
|
Feature and browser tests require the `amare_test` PostgreSQL database configured in `phpunit.xml`.
|
||||||
|
|
||||||
## Worktrees
|
|
||||||
|
|
||||||
Always work in a git worktree created from the `main` ref — never modify `main` directly and never commit from the primary working tree. Create a dedicated worktree per feature/branch with `git worktree add -b <branch> <path> main`. On finishing work, create a PR, watch CI until green, then merge it. Clean up the worktree with `git worktree remove` after merge.
|
|
||||||
|
|
||||||
## Git Hooks (husky)
|
|
||||||
|
|
||||||
Hooks live in `.husky/` and auto-install on any plain `npm install` via the `prepare` script. Note `composer setup` runs `npm install --ignore-scripts`, which skips hook installation — after setup, run `npm install` once (or `npx husky`) to activate hooks.
|
|
||||||
|
|
||||||
- `pre-commit`: runs `composer pint:check` and `composer phpstan`.
|
|
||||||
- `pre-push`: gates on the `amare_test` database (settings parsed from `phpunit.xml`), blocks the push with a `docker compose up -d` hint when Postgres is unreachable, then runs `composer test:unit` and `composer test:feature`. Browser tests are CI-only (FrankenPHP container).
|
|
||||||
|
|
||||||
## Coding Style & Naming Conventions
|
## Coding Style & Naming Conventions
|
||||||
|
|
||||||
Follow PSR-4 and Laravel conventions: PascalCase classes, camelCase methods, and snake_case database columns. Use four spaces (two in YAML, except four in Compose files), LF endings, and UTF-8 as defined by `.editorconfig`. Every project-owned PHP file must place `declare(strict_types=1);` immediately after `<?php`. Keep domain code independent of Filament and Livewire. Run `composer pint` to format and `composer phpstan` before review.
|
Follow PSR-4 and Laravel conventions: PascalCase classes, camelCase methods, and snake_case database columns. Use four spaces (two in YAML, except four in Compose files), LF endings, and UTF-8 as defined by `.editorconfig`. Every project-owned PHP file must place `declare(strict_types=1);` immediately after `<?php`. Keep domain code independent of Filament and Livewire. Run `composer pint` to format and `composer phpstan` before review.
|
||||||
|
|||||||
@@ -125,10 +125,3 @@ 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/deployment/dokploy.md](docs/deployment/dokploy.md) — deploy staging/produção no Dokploy + GHCR
|
|
||||||
|
|
||||||
## Deploy (Dokploy)
|
|
||||||
|
|
||||||
Staging publica automaticamente após CI verde em `main` (imagem GHCR por SHA + alias `:staging`). Produção promove a **mesma digest** com workflow manual `Promote production` (sem rebuild).
|
|
||||||
|
|
||||||
Ver runbook completo: [docs/deployment/dokploy.md](docs/deployment/dokploy.md).
|
|
||||||
|
|||||||
104
SPEC.md
@@ -14,8 +14,7 @@
|
|||||||
| Estágio | MVP |
|
| Estágio | MVP |
|
||||||
| Status da especificação | Aprovada para implementação |
|
| Status da especificação | Aprovada para implementação |
|
||||||
| Idioma da interface | Português do Brasil (`pt-BR`) |
|
| Idioma da interface | Português do Brasil (`pt-BR`) |
|
||||||
| Timezone padrão | `America/Sao_Paulo` |
|
| Timezone padrão | `America/Fortaleza` |
|
||||||
| Cidade de atuação | São Paulo (capital) |
|
|
||||||
| Moeda | BRL, sem conversão entre moedas |
|
| Moeda | BRL, sem conversão entre moedas |
|
||||||
| Princípio principal | YAGNI — implementar somente o necessário para validar o produto |
|
| Princípio principal | YAGNI — implementar somente o necessário para validar o produto |
|
||||||
| Arquitetura | Monólito modular Laravel |
|
| Arquitetura | Monólito modular Laravel |
|
||||||
@@ -45,8 +44,6 @@ Em caso de conflito, seguir esta ordem:
|
|||||||
|
|
||||||
O agente **NÃO DEVE** alterar silenciosamente uma decisão deste documento. Uma alteração de escopo ou arquitetura deve atualizar esta especificação ou criar uma ADR.
|
O agente **NÃO DEVE** alterar silenciosamente uma decisão deste documento. Uma alteração de escopo ou arquitetura deve atualizar esta especificação ou criar uma ADR.
|
||||||
|
|
||||||
Mudanças incrementais são planejadas em `openspec/changes/` e, após arquivadas, este documento DEVE ser revalidado para incorporar decisões ratificadas (back-sync). Este arquivo permanece a fonte de verdade do produto.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. Contrato de operação para agentes
|
## 1. Contrato de operação para agentes
|
||||||
@@ -352,14 +349,13 @@ A home DEVE conter, nesta ordem aproximada:
|
|||||||
|
|
||||||
1. header e navegação;
|
1. header e navegação;
|
||||||
2. hero com proposta de valor e CTA;
|
2. hero com proposta de valor e CTA;
|
||||||
3. manifesto da marca;
|
3. prova visual por eventos em destaque;
|
||||||
4. resumo dos serviços em destaque;
|
4. resumo dos serviços;
|
||||||
5. casos selecionados do portfólio;
|
5. método de trabalho;
|
||||||
6. método de trabalho (4 passos);
|
6. casos selecionados;
|
||||||
7. depoimentos;
|
7. depoimentos;
|
||||||
8. posicionamento/perfil;
|
8. CTA final para briefing;
|
||||||
9. CTA final para briefing;
|
9. footer com contato, redes e links legais.
|
||||||
10. footer com contato, redes e links legais.
|
|
||||||
|
|
||||||
A ordem pode variar apenas se a revisão de UX justificar a mudança.
|
A ordem pode variar apenas se a revisão de UX justificar a mudança.
|
||||||
|
|
||||||
@@ -378,15 +374,6 @@ Centralizar tokens de:
|
|||||||
|
|
||||||
Não espalhar valores visuais arbitrários por componentes.
|
Não espalhar valores visuais arbitrários por componentes.
|
||||||
|
|
||||||
O MVP adota o sistema de design **Heritage Editorial** (ver DESIGN.md e ADR-013):
|
|
||||||
|
|
||||||
- tipografia serifada auto-hospedada (EB Garamond) com escala de display a label;
|
|
||||||
- paleta papel/oliva/sálvia/tinta com superfícies tonais; hierarquia sem sombras de card;
|
|
||||||
- raio de borda zero para superfícies interativas e de conteúdo;
|
|
||||||
- largura de container 1120px e ritmo de espaçamento de 8px;
|
|
||||||
- `prefers-reduced-motion` respeitado;
|
|
||||||
- contraste WCAG AA (tinta sobre papel e oliva sobre papel).
|
|
||||||
|
|
||||||
### 6.4 Requisitos de mídia
|
### 6.4 Requisitos de mídia
|
||||||
|
|
||||||
- Imagens públicas DEVERÃO possuir texto alternativo.
|
- Imagens públicas DEVERÃO possuir texto alternativo.
|
||||||
@@ -396,7 +383,6 @@ O MVP adota o sistema de design **Heritage Editorial** (ver DESIGN.md e ADR-013)
|
|||||||
- Dimensões DEVERÃO ser reservadas para evitar layout shift.
|
- Dimensões DEVERÃO ser reservadas para evitar layout shift.
|
||||||
- Não armazenar blobs de imagem no PostgreSQL.
|
- Não armazenar blobs de imagem no PostgreSQL.
|
||||||
- Não depender do disco efêmero do contêiner em produção.
|
- Não depender do disco efêmero do contêiner em produção.
|
||||||
- O logotipo da marca DEVE possuir texto alternativo e variantes claro/escuro; `site_settings.logo_path` sobrescreve o asset padrão quando preenchido.
|
|
||||||
|
|
||||||
### 6.5 Acessibilidade
|
### 6.5 Acessibilidade
|
||||||
|
|
||||||
@@ -548,10 +534,12 @@ Campos públicos:
|
|||||||
- validar no servidor;
|
- validar no servidor;
|
||||||
- usar honeypot e rate limiting;
|
- usar honeypot e rate limiting;
|
||||||
- impedir duplo envio acidental;
|
- impedir duplo envio acidental;
|
||||||
|
- criar Lead com status `new`;
|
||||||
|
- registrar origem `website`;
|
||||||
|
- notificar administradores;
|
||||||
|
- exibir sucesso sem revelar dados internos;
|
||||||
- enviar e-mail de confirmação quando o serviço de e-mail estiver configurado;
|
- enviar e-mail de confirmação quando o serviço de e-mail estiver configurado;
|
||||||
- exibir sucesso sem revelar dados internos.
|
- falha no e-mail não pode apagar o lead já criado.
|
||||||
|
|
||||||
> **Estado atual (Fases 0–1):** o formulário usa Blade + Controller e envia apenas e-mails informativos (para a assessoria e confirmação ao visitante), sem criar Lead. A criação de Lead (status `new`, origem `website`), a notificação aos administradores e o registro de aceite de privacidade entram na **Fase 2**, quando o formulário passa a criar o Lead via `CaptureWebsiteLead`. A falha de e-mail não pode apagar dados já criados.
|
|
||||||
|
|
||||||
**Aceite:**
|
**Aceite:**
|
||||||
|
|
||||||
@@ -1265,19 +1253,10 @@ Singleton:
|
|||||||
|
|
||||||
- `id` bigint PK;
|
- `id` bigint PK;
|
||||||
- `brand_name`;
|
- `brand_name`;
|
||||||
- `logo_path` nullable;
|
|
||||||
- `logo_alt` nullable;
|
|
||||||
- `hero_eyebrow` nullable;
|
- `hero_eyebrow` nullable;
|
||||||
- `hero_title`;
|
- `hero_title`;
|
||||||
- `hero_subtitle`;
|
- `hero_subtitle`;
|
||||||
- `hero_cta_label`;
|
- `hero_cta_label`;
|
||||||
- `hero_cta_secondary_label` nullable;
|
|
||||||
- `hero_note` nullable;
|
|
||||||
- `manifesto_title`;
|
|
||||||
- `manifesto_lead`;
|
|
||||||
- `manifesto_body`;
|
|
||||||
- `method_steps` jsonb (4 passos tipados);
|
|
||||||
- `principles` jsonb (lista tipada);
|
|
||||||
- `about_summary` nullable;
|
- `about_summary` nullable;
|
||||||
- `email`;
|
- `email`;
|
||||||
- `phone`;
|
- `phone`;
|
||||||
@@ -1286,8 +1265,7 @@ Singleton:
|
|||||||
- `default_meta_title`;
|
- `default_meta_title`;
|
||||||
- `default_meta_description`;
|
- `default_meta_description`;
|
||||||
- `default_og_image_path` nullable;
|
- `default_og_image_path` nullable;
|
||||||
- `default_og_image_alt` nullable;
|
- analytics fields nullable;
|
||||||
- `analytics_enabled` boolean default false;
|
|
||||||
- timestamps.
|
- timestamps.
|
||||||
|
|
||||||
#### `services`
|
#### `services`
|
||||||
@@ -1558,7 +1536,7 @@ Constraints de banco devem proteger:
|
|||||||
| Servidor | FrankenPHP + Caddy |
|
| Servidor | FrankenPHP + Caddy |
|
||||||
| Assets | Vite |
|
| Assets | Vite |
|
||||||
| Testes | Pest 4 + Pest Browser/Playwright |
|
| Testes | Pest 4 + Pest Browser/Playwright |
|
||||||
| Arquivos | Laravel Filesystem + Cloudflare R2 (S3-compatible) em produção |
|
| Arquivos | Laravel Filesystem + storage S3-compatible em produção |
|
||||||
| Fila | Database queue |
|
| Fila | Database queue |
|
||||||
| Scheduler | Laravel Scheduler em processo separado |
|
| Scheduler | Laravel Scheduler em processo separado |
|
||||||
|
|
||||||
@@ -1786,8 +1764,6 @@ Não transformar todas as seções estáticas em componentes Livewire. Usar Blad
|
|||||||
|
|
||||||
### 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.
|
|
||||||
|
|
||||||
O componente deve:
|
O componente deve:
|
||||||
|
|
||||||
- ter estado tipado ou Form Object quando útil;
|
- ter estado tipado ou Form Object quando útil;
|
||||||
@@ -1994,7 +1970,7 @@ Determinismo obrigatório:
|
|||||||
|
|
||||||
- Chromium e imagem Linux fixos;
|
- Chromium e imagem Linux fixos;
|
||||||
- viewport fixo;
|
- viewport fixo;
|
||||||
- timezone `America/Sao_Paulo`;
|
- timezone `America/Fortaleza`;
|
||||||
- locale `pt-BR`;
|
- locale `pt-BR`;
|
||||||
- fontes instaladas na imagem;
|
- fontes instaladas na imagem;
|
||||||
- relógio congelado;
|
- relógio congelado;
|
||||||
@@ -2076,7 +2052,7 @@ quality → Pint check + PHPStan/Larastan + audits + testes
|
|||||||
|
|
||||||
| Job | Responsabilidade | Bloqueia merge |
|
| Job | Responsabilidade | Bloqueia merge |
|
||||||
|---|---|---:|
|
|---|---|---:|
|
||||||
| `static` | Pint, PHPStan/Larastan, Composer validate, Composer e npm audit | Sim |
|
| `static` | Pint, PHPStan/Larastan, Composer validate e audits | Sim |
|
||||||
| `unit` | Unitários, arquitetura e cobertura | Sim |
|
| `unit` | Unitários, arquitetura e cobertura | Sim |
|
||||||
| `feature` | PostgreSQL, migrations, Livewire, Filament e integração | Sim |
|
| `feature` | PostgreSQL, migrations, Livewire, Filament e integração | Sim |
|
||||||
| `browser` | Vite, FrankenPHP, E2E, smoke, acessibilidade e visual | Sim |
|
| `browser` | Vite, FrankenPHP, E2E, smoke, acessibilidade e visual | Sim |
|
||||||
@@ -2097,11 +2073,11 @@ quality → Pint check + PHPStan/Larastan + audits + testes
|
|||||||
### 14.3 Branches e ambientes
|
### 14.3 Branches e ambientes
|
||||||
|
|
||||||
- PR: testes e preview opcional;
|
- PR: testes e preview opcional;
|
||||||
- `main`: build imutável por SHA publicado no GHCR e deploy automático em staging via Dokploy;
|
- `main`: build imutável por SHA e deploy automático em staging;
|
||||||
- staging: Dokploy Compose executa migração, healthcheck `/up` e smoke pós-deploy (`/up`, `/`, `/admin/login`);
|
- staging: migration, cache warmup e smoke pós-deploy;
|
||||||
- produção: promoção da mesma imagem aprovada, sem rebuild (retag do digest em `:production`);
|
- produção: promoção da mesma imagem aprovada, sem rebuild;
|
||||||
- produção requer aprovação humana explícita no MVP (`workflow_dispatch` com confirmação);
|
- produção requer aprovação humana explícita no MVP;
|
||||||
- rollback usa imagem anterior (SHA anterior, sem rebuild);
|
- rollback usa imagem anterior;
|
||||||
- migrations devem ser backward-compatible quando possível.
|
- migrations devem ser backward-compatible quando possível.
|
||||||
|
|
||||||
### 14.4 Definition of Done
|
### 14.4 Definition of Done
|
||||||
@@ -2183,7 +2159,7 @@ APP_DEBUG=false
|
|||||||
APP_URL
|
APP_URL
|
||||||
APP_LOCALE=pt_BR
|
APP_LOCALE=pt_BR
|
||||||
APP_FALLBACK_LOCALE=pt_BR
|
APP_FALLBACK_LOCALE=pt_BR
|
||||||
APP_TIMEZONE=America/Sao_Paulo
|
APP_TIMEZONE=America/Fortaleza
|
||||||
|
|
||||||
DB_CONNECTION=pgsql
|
DB_CONNECTION=pgsql
|
||||||
DB_HOST
|
DB_HOST
|
||||||
@@ -2196,16 +2172,19 @@ CACHE_STORE=database ou file conforme ambiente
|
|||||||
QUEUE_CONNECTION=database
|
QUEUE_CONNECTION=database
|
||||||
SESSION_DRIVER=database ou cookie conforme decisão
|
SESSION_DRIVER=database ou cookie conforme decisão
|
||||||
|
|
||||||
FILESYSTEM_DISK=r2 em produção
|
FILESYSTEM_DISK=s3 em produção
|
||||||
R2_ACCESS_KEY_ID
|
AWS_ACCESS_KEY_ID
|
||||||
R2_SECRET_ACCESS_KEY
|
AWS_SECRET_ACCESS_KEY
|
||||||
R2_BUCKET
|
AWS_DEFAULT_REGION
|
||||||
R2_ENDPOINT
|
AWS_BUCKET
|
||||||
R2_URL (domínio próprio opcional)
|
AWS_ENDPOINT opcional
|
||||||
AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_DEFAULT_REGION, AWS_BUCKET, AWS_ENDPOINT, AWS_USE_PATH_STYLE_ENDPOINT — opcionais, apenas se o disk s3 for usado
|
AWS_USE_PATH_STYLE_ENDPOINT opcional
|
||||||
|
|
||||||
MAIL_MAILER=resend em produção (mailer nativo Laravel)
|
MAIL_MAILER
|
||||||
RESEND_API_KEY
|
MAIL_HOST
|
||||||
|
MAIL_PORT
|
||||||
|
MAIL_USERNAME
|
||||||
|
MAIL_PASSWORD
|
||||||
MAIL_FROM_ADDRESS
|
MAIL_FROM_ADDRESS
|
||||||
MAIL_FROM_NAME
|
MAIL_FROM_NAME
|
||||||
```
|
```
|
||||||
@@ -2300,7 +2279,7 @@ O seed deve criar:
|
|||||||
- configurações do site;
|
- configurações do site;
|
||||||
- 3 serviços;
|
- 3 serviços;
|
||||||
- 3 casos de portfólio;
|
- 3 casos de portfólio;
|
||||||
- depoimentos reais: os 5 casais de `depoimentos.md` (Jeniffer e Maick, Quesia e Jhonata, Milena e Weslley, Raquel e Pedro, Victoria e Pedro), preservando texto e datas; autores fictícios de demonstração removidos; em produção permanecem não publicados até autorização explícita de publicação;
|
- 3 depoimentos;
|
||||||
- leads em estados variados;
|
- leads em estados variados;
|
||||||
- 2 eventos futuros e 1 concluído;
|
- 2 eventos futuros e 1 concluído;
|
||||||
- tarefas vencidas e futuras;
|
- tarefas vencidas e futuras;
|
||||||
@@ -2335,7 +2314,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,13 +2325,6 @@ 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);
|
|
||||||
- [ ] npm audit no `composer quality` e no job `static`;
|
|
||||||
- [ ] gate de cobertura `Domain`/`Application` ≥ 80%;
|
|
||||||
- [ ] serviço de aplicação FrankenPHP no Compose local;
|
|
||||||
- [ ] hello-world implantado em staging (critério de saída).
|
|
||||||
|
|
||||||
> Os itens pendentes acima são tratados pela mudança OpenSpec `complete-foundation-parity`; o critério de saída da fase só é atingido com staging implantado.
|
|
||||||
|
|
||||||
**Critério de saída:** pipeline verde e hello-world implantado em staging.
|
**Critério de saída:** pipeline verde e hello-world implantado em staging.
|
||||||
|
|
||||||
@@ -2509,10 +2481,6 @@ Toda operação financeira deve:
|
|||||||
| ADR-008 | Database queue; Redis adiado | Aceita |
|
| ADR-008 | Database queue; Redis adiado | Aceita |
|
||||||
| ADR-009 | Dinheiro em BRL armazenado como centavos inteiros | Aceita |
|
| ADR-009 | Dinheiro em BRL armazenado como centavos inteiros | Aceita |
|
||||||
| ADR-010 | Home com estrutura fixa e CMS tipado, sem page builder | Aceita |
|
| ADR-010 | Home com estrutura fixa e CMS tipado, sem page builder | Aceita |
|
||||||
| ADR-011 | Cloudflare R2 (S3-compatible) como storage de objetos em produção | Aceita |
|
|
||||||
| ADR-012 | E-mail transacional via Resend (mailer nativo Laravel) | Aceita |
|
|
||||||
| ADR-013 | Design system Heritage Editorial para o site público | Aceita |
|
|
||||||
| ADR-014 | Deploy via Dokploy Compose com imagem imutável por SHA no GHCR | Aceita |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -21,8 +21,6 @@ final readonly class PageMeta
|
|||||||
public ?string $ogImageUrl = null,
|
public ?string $ogImageUrl = null,
|
||||||
public ?string $ogImageAlt = null,
|
public ?string $ogImageAlt = null,
|
||||||
public ?array $jsonLd = null,
|
public ?array $jsonLd = null,
|
||||||
public string $siteName = '',
|
|
||||||
public string $robots = 'index, follow',
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -46,7 +44,6 @@ final readonly class PageMeta
|
|||||||
ogImageUrl: $ogImageUrl ?? self::defaultOgImageUrl($settings),
|
ogImageUrl: $ogImageUrl ?? self::defaultOgImageUrl($settings),
|
||||||
ogImageAlt: $ogImageAlt ?? $settings->default_og_image_alt,
|
ogImageAlt: $ogImageAlt ?? $settings->default_og_image_alt,
|
||||||
jsonLd: $jsonLd,
|
jsonLd: $jsonLd,
|
||||||
siteName: (string) $settings->brand_name,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,53 +77,9 @@ final readonly class PageMeta
|
|||||||
ogImageUrl: $ogImageUrl,
|
ogImageUrl: $ogImageUrl,
|
||||||
ogImageAlt: $ogImageAlt,
|
ogImageAlt: $ogImageAlt,
|
||||||
jsonLd: $jsonLd,
|
jsonLd: $jsonLd,
|
||||||
siteName: (string) $settings->brand_name,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the metadata for branded error pages.
|
|
||||||
*
|
|
||||||
* Error pages carry no canonical, are excluded from search indexes and use
|
|
||||||
* the page name suffixed with the brand name as their title.
|
|
||||||
*/
|
|
||||||
public static function forErrorPage(
|
|
||||||
SiteSetting $settings,
|
|
||||||
int $status = 404,
|
|
||||||
): self {
|
|
||||||
[$title, $description] = match ($status) {
|
|
||||||
500 => ['Algo deu errado', 'Não foi possível concluir o pedido. Tente novamente em instantes.'],
|
|
||||||
419 => ['Sessão expirada', 'Sua sessão expirou. Volte e tente enviar novamente.'],
|
|
||||||
429 => ['Muitas solicitações', 'Você enviou muitas solicitações em pouco tempo. Aguarde um instante e tente novamente.'],
|
|
||||||
503 => ['Em manutenção', 'Estamos realizando uma breve manutenção. Tente novamente em instantes.'],
|
|
||||||
default => ['Página não encontrada', 'A página que você procura não existe ou foi movida.'],
|
|
||||||
};
|
|
||||||
|
|
||||||
return new self(
|
|
||||||
title: trim($title).' - '.$settings->brand_name,
|
|
||||||
description: $description,
|
|
||||||
canonical: '',
|
|
||||||
robots: 'noindex, nofollow',
|
|
||||||
siteName: (string) $settings->brand_name,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Append the brand name to a page title when it is not already present.
|
|
||||||
*/
|
|
||||||
public static function withBrandSuffix(string $title, SiteSetting $settings): string
|
|
||||||
{
|
|
||||||
$brand = filled($settings->default_meta_title)
|
|
||||||
? (string) $settings->default_meta_title
|
|
||||||
: (string) $settings->brand_name;
|
|
||||||
|
|
||||||
if (str_contains($title, $brand)) {
|
|
||||||
return $title;
|
|
||||||
}
|
|
||||||
|
|
||||||
return trim($title).' - '.$brand;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function defaultTitle(SiteSetting $settings): string
|
private static function defaultTitle(SiteSetting $settings): string
|
||||||
{
|
{
|
||||||
return filled($settings->default_meta_title)
|
return filled($settings->default_meta_title)
|
||||||
|
|||||||
@@ -56,9 +56,6 @@ final class MediaGenerateVariantsCommand extends Command
|
|||||||
if ($settings && filled($settings->default_og_image_path)) {
|
if ($settings && filled($settings->default_og_image_path)) {
|
||||||
$paths[] = (string) $settings->default_og_image_path;
|
$paths[] = (string) $settings->default_og_image_path;
|
||||||
}
|
}
|
||||||
if ($settings && filled($settings->about_image_path)) {
|
|
||||||
$paths[] = (string) $settings->about_image_path;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (Service::query()->whereNotNull('cover_image_path')->pluck('cover_image_path') as $path) {
|
foreach (Service::query()->whereNotNull('cover_image_path')->pluck('cover_image_path') as $path) {
|
||||||
$paths[] = (string) $path;
|
$paths[] = (string) $path;
|
||||||
|
|||||||
@@ -228,12 +228,6 @@ class ManageSiteSettings extends Page
|
|||||||
->addActionLabel('Adicionar rede'),
|
->addActionLabel('Adicionar rede'),
|
||||||
])
|
])
|
||||||
->columns(2),
|
->columns(2),
|
||||||
Section::make('Página Sobre')
|
|
||||||
->schema([
|
|
||||||
PublicImageUploadRules::fileUpload('about_image_path', 'Imagem da página Sobre', 'content/about'),
|
|
||||||
PublicImageUploadRules::altTextField('about_image_alt', 'about_image_path'),
|
|
||||||
])
|
|
||||||
->columns(2),
|
|
||||||
Section::make('SEO padrão')
|
Section::make('SEO padrão')
|
||||||
->schema([
|
->schema([
|
||||||
TextInput::make('default_meta_title')
|
TextInput::make('default_meta_title')
|
||||||
|
|||||||
@@ -1,116 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Http\Controllers\PublicSite;
|
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
|
||||||
use App\Http\Requests\PublicSite\ContactBriefingRequest;
|
|
||||||
use App\Mail\ContactBriefing;
|
|
||||||
use App\Mail\ContactBriefingConfirmation;
|
|
||||||
use App\Models\SiteSetting;
|
|
||||||
use Illuminate\Http\RedirectResponse;
|
|
||||||
use Illuminate\Support\Facades\Log;
|
|
||||||
use Illuminate\Support\Facades\Mail;
|
|
||||||
use Throwable;
|
|
||||||
|
|
||||||
final class ContactController extends Controller
|
|
||||||
{
|
|
||||||
private const string DUPLICATE_SESSION_KEY = 'contact_briefing_hash';
|
|
||||||
|
|
||||||
public function store(ContactBriefingRequest $request): RedirectResponse
|
|
||||||
{
|
|
||||||
if (filled($request->input('empresa'))) {
|
|
||||||
return $this->success();
|
|
||||||
}
|
|
||||||
|
|
||||||
$validated = $request->validated();
|
|
||||||
unset($validated['privacidade']);
|
|
||||||
|
|
||||||
if ($this->isDuplicate($validated)) {
|
|
||||||
return $this->success();
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->rememberSubmission($validated);
|
|
||||||
|
|
||||||
$settings = SiteSetting::instance();
|
|
||||||
$fields = $this->buildFields($validated);
|
|
||||||
|
|
||||||
$this->dispatchEmails($settings, $validated['nome'], $validated['email'], $fields);
|
|
||||||
|
|
||||||
return $this->success();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $validated
|
|
||||||
*/
|
|
||||||
private function buildFields(array $validated): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'Nome' => (string) $validated['nome'],
|
|
||||||
'E-mail' => (string) $validated['email'],
|
|
||||||
'Telefone/WhatsApp' => (string) $validated['telefone'],
|
|
||||||
'Tipo de evento' => (string) $validated['tipo_evento'],
|
|
||||||
'Data ou período desejado' => isset($validated['data_periodo']) ? (string) $validated['data_periodo'] : null,
|
|
||||||
'Cidade' => (string) $validated['cidade'],
|
|
||||||
'Número estimado de convidados' => isset($validated['convidados']) ? (string) $validated['convidados'] : null,
|
|
||||||
'Serviço de interesse' => isset($validated['servico_interesse']) ? (string) $validated['servico_interesse'] : null,
|
|
||||||
'Mensagem' => (string) $validated['mensagem'],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $validated
|
|
||||||
*/
|
|
||||||
private function isDuplicate(array $validated): bool
|
|
||||||
{
|
|
||||||
$hash = $this->hash($validated);
|
|
||||||
|
|
||||||
return session()->get(self::DUPLICATE_SESSION_KEY) === $hash;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $validated
|
|
||||||
*/
|
|
||||||
private function rememberSubmission(array $validated): void
|
|
||||||
{
|
|
||||||
session()->put(self::DUPLICATE_SESSION_KEY, $this->hash($validated));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $validated
|
|
||||||
*/
|
|
||||||
private function hash(array $validated): string
|
|
||||||
{
|
|
||||||
return hash('sha256', serialize($validated));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $fields
|
|
||||||
*/
|
|
||||||
private function dispatchEmails(SiteSetting $settings, string $name, string $email, array $fields): void
|
|
||||||
{
|
|
||||||
$attempt = function () use ($settings, $name, $email, $fields): void {
|
|
||||||
if (filled($settings->email)) {
|
|
||||||
Mail::to($settings->email)->send(new ContactBriefing($fields));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filled(config('mail.default'))) {
|
|
||||||
Mail::to($email)->send(new ContactBriefingConfirmation($name, (string) $settings->brand_name));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
$attempt();
|
|
||||||
} catch (Throwable $exception) {
|
|
||||||
Log::error('Falha ao enviar briefing de contato', [
|
|
||||||
'exception' => $exception,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function success(): RedirectResponse
|
|
||||||
{
|
|
||||||
return redirect()->route('contact')->with('status', 'briefing-sent');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -20,7 +20,7 @@ final class PageController extends Controller
|
|||||||
'pageMeta' => PageMeta::forPage(
|
'pageMeta' => PageMeta::forPage(
|
||||||
canonical: route('about'),
|
canonical: route('about'),
|
||||||
settings: $settings,
|
settings: $settings,
|
||||||
title: PageMeta::withBrandSuffix('Sobre', $settings),
|
title: 'Sobre',
|
||||||
description: $settings->about_summary ?: ('Conheça a '.$settings->brand_name.'.'),
|
description: $settings->about_summary ?: ('Conheça a '.$settings->brand_name.'.'),
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
@@ -35,7 +35,7 @@ final class PageController extends Controller
|
|||||||
'pageMeta' => PageMeta::forPage(
|
'pageMeta' => PageMeta::forPage(
|
||||||
canonical: route('privacy'),
|
canonical: route('privacy'),
|
||||||
settings: $settings,
|
settings: $settings,
|
||||||
title: PageMeta::withBrandSuffix('Política de privacidade', $settings),
|
title: 'Política de privacidade',
|
||||||
description: 'Política de privacidade da '.$settings->brand_name.'.',
|
description: 'Política de privacidade da '.$settings->brand_name.'.',
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
@@ -50,7 +50,7 @@ final class PageController extends Controller
|
|||||||
'pageMeta' => PageMeta::forPage(
|
'pageMeta' => PageMeta::forPage(
|
||||||
canonical: route('contact'),
|
canonical: route('contact'),
|
||||||
settings: $settings,
|
settings: $settings,
|
||||||
title: PageMeta::withBrandSuffix('Contato', $settings),
|
title: 'Contato',
|
||||||
description: 'Fale com a '.$settings->brand_name.'.',
|
description: 'Fale com a '.$settings->brand_name.'.',
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ final class PortfolioController extends Controller
|
|||||||
'pageMeta' => PageMeta::forPage(
|
'pageMeta' => PageMeta::forPage(
|
||||||
canonical: route('portfolio.index'),
|
canonical: route('portfolio.index'),
|
||||||
settings: $settings,
|
settings: $settings,
|
||||||
title: PageMeta::withBrandSuffix('Portfólio', $settings),
|
title: 'Portfólio',
|
||||||
description: 'Casos reais de eventos conduzidos pela '.$settings->brand_name.'.',
|
description: 'Casos reais de eventos conduzidos pela '.$settings->brand_name.'.',
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ final class ServiceController extends Controller
|
|||||||
'pageMeta' => PageMeta::forPage(
|
'pageMeta' => PageMeta::forPage(
|
||||||
canonical: route('services.index'),
|
canonical: route('services.index'),
|
||||||
settings: $settings,
|
settings: $settings,
|
||||||
title: PageMeta::withBrandSuffix('Serviços', $settings),
|
title: 'Serviços',
|
||||||
description: 'Conheça os serviços de assessoria de eventos da '.$settings->brand_name.'.',
|
description: 'Conheça os serviços de assessoria de eventos da '.$settings->brand_name.'.',
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Http\Requests\PublicSite;
|
|
||||||
|
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
|
||||||
|
|
||||||
final class ContactBriefingRequest extends FormRequest
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @return array<string, array<int, string>>
|
|
||||||
*/
|
|
||||||
public function rules(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'nome' => ['required', 'string', 'max:120'],
|
|
||||||
'email' => ['required', 'email', 'max:254'],
|
|
||||||
'telefone' => ['required', 'string', 'max:40'],
|
|
||||||
'tipo_evento' => ['required', 'string', 'max:80'],
|
|
||||||
'data_periodo' => ['nullable', 'string', 'max:80'],
|
|
||||||
'cidade' => ['required', 'string', 'max:80'],
|
|
||||||
'convidados' => ['nullable', 'integer', 'min:1', 'max:100000'],
|
|
||||||
'servico_interesse' => ['nullable', 'string', 'max:120'],
|
|
||||||
'mensagem' => ['required', 'string', 'max:3000'],
|
|
||||||
'privacidade' => ['accepted'],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, string>
|
|
||||||
*/
|
|
||||||
public function messages(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'nome.required' => 'Informe seu nome completo.',
|
|
||||||
'nome.max' => 'O nome deve ter no máximo :max caracteres.',
|
|
||||||
'email.required' => 'Informe seu e-mail.',
|
|
||||||
'email.email' => 'Informe um e-mail válido.',
|
|
||||||
'email.max' => 'O e-mail deve ter no máximo :max caracteres.',
|
|
||||||
'telefone.required' => 'Informe um telefone ou WhatsApp.',
|
|
||||||
'telefone.max' => 'O telefone deve ter no máximo :max caracteres.',
|
|
||||||
'tipo_evento.required' => 'Selecione o tipo de evento.',
|
|
||||||
'tipo_evento.max' => 'O tipo de evento deve ter no máximo :max caracteres.',
|
|
||||||
'data_periodo.max' => 'A data ou período deve ter no máximo :max caracteres.',
|
|
||||||
'cidade.required' => 'Informe a cidade do evento.',
|
|
||||||
'cidade.max' => 'A cidade deve ter no máximo :max caracteres.',
|
|
||||||
'convidados.integer' => 'Informe um número de convidados válido.',
|
|
||||||
'convidados.min' => 'O número de convidados deve ser maior que zero.',
|
|
||||||
'convidados.max' => 'O número de convidados informado é inválido.',
|
|
||||||
'servico_interesse.max' => 'O serviço deve ter no máximo :max caracteres.',
|
|
||||||
'mensagem.required' => 'Conte brevemente o que você precisa.',
|
|
||||||
'mensagem.max' => 'A mensagem deve ter no máximo :max caracteres.',
|
|
||||||
'privacidade.accepted' => 'Você precisa aceitar a política de privacidade.',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Mail;
|
|
||||||
|
|
||||||
use Illuminate\Bus\Queueable;
|
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
|
||||||
use Illuminate\Mail\Mailable;
|
|
||||||
use Illuminate\Mail\Mailables\Content;
|
|
||||||
use Illuminate\Mail\Mailables\Envelope;
|
|
||||||
use Illuminate\Queue\SerializesModels;
|
|
||||||
|
|
||||||
final class ContactBriefing extends Mailable implements ShouldQueue
|
|
||||||
{
|
|
||||||
use Queueable;
|
|
||||||
use SerializesModels;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $fields
|
|
||||||
*/
|
|
||||||
public function __construct(
|
|
||||||
public readonly array $fields,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function envelope(): Envelope
|
|
||||||
{
|
|
||||||
return new Envelope(
|
|
||||||
subject: 'Novo briefing de contato — Amare Assessoria',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function content(): Content
|
|
||||||
{
|
|
||||||
return new Content(
|
|
||||||
html: 'emails.contact-briefing',
|
|
||||||
text: 'emails.contact-briefing-text',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Mail;
|
|
||||||
|
|
||||||
use Illuminate\Bus\Queueable;
|
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
|
||||||
use Illuminate\Mail\Mailable;
|
|
||||||
use Illuminate\Mail\Mailables\Content;
|
|
||||||
use Illuminate\Mail\Mailables\Envelope;
|
|
||||||
use Illuminate\Queue\SerializesModels;
|
|
||||||
|
|
||||||
final class ContactBriefingConfirmation extends Mailable implements ShouldQueue
|
|
||||||
{
|
|
||||||
use Queueable;
|
|
||||||
use SerializesModels;
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
public readonly string $name,
|
|
||||||
public readonly string $brandName,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function envelope(): Envelope
|
|
||||||
{
|
|
||||||
return new Envelope(
|
|
||||||
subject: 'Recebemos sua mensagem — '.$this->brandName,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function content(): Content
|
|
||||||
{
|
|
||||||
return new Content(
|
|
||||||
html: 'emails.contact-briefing-confirmation',
|
|
||||||
text: 'emails.contact-briefing-confirmation-text',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -16,8 +16,6 @@ use Illuminate\Database\Eloquent\Model;
|
|||||||
* @property bool $analytics_enabled
|
* @property bool $analytics_enabled
|
||||||
* @property string|null $default_og_image_path
|
* @property string|null $default_og_image_path
|
||||||
* @property string|null $default_og_image_alt
|
* @property string|null $default_og_image_alt
|
||||||
* @property string|null $about_image_path
|
|
||||||
* @property string|null $about_image_alt
|
|
||||||
* @property string|null $logo_path
|
* @property string|null $logo_path
|
||||||
* @property string|null $logo_alt
|
* @property string|null $logo_alt
|
||||||
*/
|
*/
|
||||||
@@ -32,8 +30,6 @@ use Illuminate\Database\Eloquent\Model;
|
|||||||
'hero_secondary_cta_label',
|
'hero_secondary_cta_label',
|
||||||
'hero_note',
|
'hero_note',
|
||||||
'about_summary',
|
'about_summary',
|
||||||
'about_image_path',
|
|
||||||
'about_image_alt',
|
|
||||||
'manifesto_title',
|
'manifesto_title',
|
||||||
'manifesto_lead',
|
'manifesto_lead',
|
||||||
'manifesto_body',
|
'manifesto_body',
|
||||||
|
|||||||
@@ -7,9 +7,6 @@ namespace App\Providers;
|
|||||||
use App\Application\Data\PageMeta;
|
use App\Application\Data\PageMeta;
|
||||||
use App\Models\SiteSetting;
|
use App\Models\SiteSetting;
|
||||||
use Carbon\CarbonImmutable;
|
use Carbon\CarbonImmutable;
|
||||||
use Illuminate\Cache\RateLimiting\Limit;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Support\Facades\RateLimiter;
|
|
||||||
use Illuminate\Support\Facades\View;
|
use Illuminate\Support\Facades\View;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
use Illuminate\View\View as ViewInstance;
|
use Illuminate\View\View as ViewInstance;
|
||||||
@@ -29,9 +26,7 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
*/
|
*/
|
||||||
public function boot(): void
|
public function boot(): void
|
||||||
{
|
{
|
||||||
$this->configureLivewireTemporaryUploads();
|
|
||||||
$this->freezeClockWhenConfigured();
|
$this->freezeClockWhenConfigured();
|
||||||
$this->configureRateLimiters();
|
|
||||||
|
|
||||||
View::composer('layouts.public', function (ViewInstance $view): void {
|
View::composer('layouts.public', function (ViewInstance $view): void {
|
||||||
$settings = $view->offsetExists('siteSettings')
|
$settings = $view->offsetExists('siteSettings')
|
||||||
@@ -51,29 +46,6 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Keep Livewire/Filament temp uploads on the local disk.
|
|
||||||
*
|
|
||||||
* When FILESYSTEM_DISK=r2, Livewire would otherwise use the S3 driver and
|
|
||||||
* browser-PUT straight to R2 (CORS). Final media still uses the r2 disk via
|
|
||||||
* PublicImageUploadRules. Explicit LIVEWIRE_TEMPORARY_FILE_UPLOAD_DISK wins.
|
|
||||||
*/
|
|
||||||
private function configureLivewireTemporaryUploads(): void
|
|
||||||
{
|
|
||||||
if (filled(config('livewire.temporary_file_upload.disk'))) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
config(['livewire.temporary_file_upload.disk' => 'local']);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function configureRateLimiters(): void
|
|
||||||
{
|
|
||||||
RateLimiter::for('contact-briefing', function (Request $request): Limit {
|
|
||||||
return Limit::perMinute(5)->by($request->ip().'|contact-briefing');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private function freezeClockWhenConfigured(): void
|
private function freezeClockWhenConfigured(): void
|
||||||
{
|
{
|
||||||
if ($this->app->environment('production')) {
|
if ($this->app->environment('production')) {
|
||||||
|
|||||||
@@ -12,8 +12,7 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
health: '/up',
|
health: '/up',
|
||||||
)
|
)
|
||||||
->withMiddleware(function (Middleware $middleware): void {
|
->withMiddleware(function (Middleware $middleware): void {
|
||||||
// Trust Traefik/Dokploy (and local reverse proxies) for X-Forwarded-* headers.
|
//
|
||||||
$middleware->trustProxies(at: '*');
|
|
||||||
})
|
})
|
||||||
->withExceptions(function (Exceptions $exceptions): void {
|
->withExceptions(function (Exceptions $exceptions): void {
|
||||||
$exceptions->shouldRenderJsonWhen(
|
$exceptions->shouldRenderJsonWhen(
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 151 KiB |
|
Before Width: | Height: | Size: 436 KiB |
|
Before Width: | Height: | Size: 254 KiB |
|
Before Width: | Height: | Size: 258 KiB |
|
Before Width: | Height: | Size: 622 KiB |
|
Before Width: | Height: | Size: 499 KiB |
|
Before Width: | Height: | Size: 244 KiB |
|
Before Width: | Height: | Size: 235 KiB |
|
Before Width: | Height: | Size: 239 KiB |
|
Before Width: | Height: | Size: 236 KiB |
|
Before Width: | Height: | Size: 360 KiB |
|
Before Width: | Height: | Size: 330 KiB |
|
Before Width: | Height: | Size: 302 KiB |
|
Before Width: | Height: | Size: 337 KiB |
@@ -1,25 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use Illuminate\Database\Migrations\Migration;
|
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
|
|
||||||
return new class extends Migration
|
|
||||||
{
|
|
||||||
public function up(): void
|
|
||||||
{
|
|
||||||
Schema::table('site_settings', function (Blueprint $table): void {
|
|
||||||
$table->string('about_image_path')->nullable()->after('about_summary');
|
|
||||||
$table->string('about_image_alt')->nullable()->after('about_image_path');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public function down(): void
|
|
||||||
{
|
|
||||||
Schema::table('site_settings', function (Blueprint $table): void {
|
|
||||||
$table->dropColumn(['about_image_path', 'about_image_alt']);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -8,7 +8,7 @@ use App\Models\PortfolioCase;
|
|||||||
use App\Models\PortfolioImage;
|
use App\Models\PortfolioImage;
|
||||||
use App\Models\Service;
|
use App\Models\Service;
|
||||||
use App\Models\SiteSetting;
|
use App\Models\SiteSetting;
|
||||||
use App\Support\PublicImageUploadRules;
|
use App\Models\Testimonial;
|
||||||
use Illuminate\Database\Seeder;
|
use Illuminate\Database\Seeder;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Facades\File;
|
use Illuminate\Support\Facades\File;
|
||||||
@@ -23,7 +23,7 @@ class ContentSeeder extends Seeder
|
|||||||
$this->seedSiteSettings();
|
$this->seedSiteSettings();
|
||||||
$this->seedServices();
|
$this->seedServices();
|
||||||
$this->seedPortfolioCases();
|
$this->seedPortfolioCases();
|
||||||
$this->call(TestimonialsSeeder::class);
|
$this->seedTestimonials();
|
||||||
}
|
}
|
||||||
|
|
||||||
private function seedSiteSettings(): void
|
private function seedSiteSettings(): void
|
||||||
@@ -37,8 +37,6 @@ class ContentSeeder extends Seeder
|
|||||||
'hero_secondary_cta_label' => 'Conheça nosso olhar',
|
'hero_secondary_cta_label' => 'Conheça nosso olhar',
|
||||||
'hero_note' => 'Planejamento cuidadoso, comunicação clara e execução segura — do primeiro encontro ao último detalhe.',
|
'hero_note' => 'Planejamento cuidadoso, comunicação clara e execução segura — do primeiro encontro ao último detalhe.',
|
||||||
'about_summary' => 'Assessoria boutique especializada em experiências memoráveis em São Paulo.',
|
'about_summary' => 'Assessoria boutique especializada em experiências memoráveis em São Paulo.',
|
||||||
'about_image_path' => $this->copyFixture('about-image.jpg', 'content/about/about-image.jpg'),
|
|
||||||
'about_image_alt' => 'Mesa de planejamento com caderno, café e guardanapos de pano',
|
|
||||||
'manifesto_title' => 'Sofisticação que também se traduz em organização.',
|
'manifesto_title' => 'Sofisticação que também se traduz em organização.',
|
||||||
'manifesto_lead' => 'Um evento memorável não nasce apenas de uma boa estética. Ele depende de decisões bem conduzidas, fornecedores alinhados e atenção constante ao que realmente importa.',
|
'manifesto_lead' => 'Um evento memorável não nasce apenas de uma boa estética. Ele depende de decisões bem conduzidas, fornecedores alinhados e atenção constante ao que realmente importa.',
|
||||||
'manifesto_body' => 'A Amare combina sensibilidade e precisão para criar encontros coerentes com cada cliente, marca e ocasião — sem fórmulas prontas, excessos ou ruído.',
|
'manifesto_body' => 'A Amare combina sensibilidade e precisão para criar encontros coerentes com cada cliente, marca e ocasião — sem fórmulas prontas, excessos ou ruído.',
|
||||||
@@ -94,7 +92,7 @@ class ContentSeeder extends Seeder
|
|||||||
['slug' => $service['slug']],
|
['slug' => $service['slug']],
|
||||||
[
|
[
|
||||||
...$service,
|
...$service,
|
||||||
'cover_image_path' => $this->copyFixture('service-'.$service['slug'].'.jpg', 'content/services/'.$service['slug'].'.jpg'),
|
'cover_image_path' => $this->copyFixture('service-cover.jpg', 'content/services/'.$service['slug'].'.jpg'),
|
||||||
'cover_image_alt' => 'Capa do serviço '.$service['title'],
|
'cover_image_alt' => 'Capa do serviço '.$service['title'],
|
||||||
'published_at' => Carbon::parse(self::SEED_TIMESTAMP),
|
'published_at' => Carbon::parse(self::SEED_TIMESTAMP),
|
||||||
],
|
],
|
||||||
@@ -151,7 +149,7 @@ class ContentSeeder extends Seeder
|
|||||||
['slug' => $caseData['slug']],
|
['slug' => $caseData['slug']],
|
||||||
[
|
[
|
||||||
...$caseData,
|
...$caseData,
|
||||||
'cover_image_path' => $this->copyFixture('case-'.$caseData['slug'].'.jpg', 'content/portfolio/'.$caseData['slug'].'-cover.jpg'),
|
'cover_image_path' => $this->copyFixture('portfolio-cover.jpg', 'content/portfolio/'.$caseData['slug'].'-cover.jpg'),
|
||||||
'cover_image_alt' => 'Capa do caso '.$caseData['title'],
|
'cover_image_alt' => 'Capa do caso '.$caseData['title'],
|
||||||
'is_featured' => true,
|
'is_featured' => true,
|
||||||
'published_at' => Carbon::parse(self::SEED_TIMESTAMP),
|
'published_at' => Carbon::parse(self::SEED_TIMESTAMP),
|
||||||
@@ -163,7 +161,7 @@ class ContentSeeder extends Seeder
|
|||||||
foreach ([1, 2] as $index) {
|
foreach ([1, 2] as $index) {
|
||||||
PortfolioImage::query()->create([
|
PortfolioImage::query()->create([
|
||||||
'portfolio_case_id' => $case->id,
|
'portfolio_case_id' => $case->id,
|
||||||
'path' => $this->copyFixture('gallery-'.$caseData['slug'].'-'.$index.'.jpg', 'content/portfolio/'.$caseData['slug'].'-gallery-'.$index.'.jpg'),
|
'path' => $this->copyFixture('gallery.jpg', 'content/portfolio/'.$caseData['slug'].'-gallery-'.$index.'.jpg'),
|
||||||
'alt_text' => 'Galeria '.$caseData['title'].' '.$index,
|
'alt_text' => 'Galeria '.$caseData['title'].' '.$index,
|
||||||
'caption' => $index === 1 ? 'Detalhe da decoração' : null,
|
'caption' => $index === 1 ? 'Detalhe da decoração' : null,
|
||||||
'sort_order' => $index,
|
'sort_order' => $index,
|
||||||
@@ -172,12 +170,71 @@ class ContentSeeder extends Seeder
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function seedTestimonials(): void
|
||||||
|
{
|
||||||
|
// Real couples from depoimentos.md. Production publication still requires
|
||||||
|
// explicit couple authorization before setting published_at outside local/demo seeds.
|
||||||
|
$testimonials = [
|
||||||
|
[
|
||||||
|
'quote' => "Mi, quero agradecer você e a sua equipe por todo empenho, atenção, vocês são abençoadas.\n\nEra nítida sua preocupação em garantir que todos os detalhes planejados desta comemoração, fossem atendidos.\n\nQue você possa transformar o grande dia das noivinhas sempre com essa sua leveza!!!\n\nMuito obrigada!",
|
||||||
|
'author_name' => 'Jeniffer e Maick',
|
||||||
|
'context' => 'Casamento · 06/12/2025',
|
||||||
|
'sort_order' => 1,
|
||||||
|
'is_featured' => true,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'quote' => "Mi, eu não tenho palavras pra agradecer você e tudo que você fez por mim e por nós na realização desse sonho. Eu tô ainda extasiada com tudo que aconteceu hoje; mas tenho certeza que sem a sua ajuda, muita coisa não aconteceria.\n\nObrigada por tudo !",
|
||||||
|
'author_name' => 'Quesia e Jhonata',
|
||||||
|
'context' => 'Casamento · 21/12/2025',
|
||||||
|
'sort_order' => 2,
|
||||||
|
'is_featured' => true,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'quote' => 'Que equipe!! Que equipe maravilhosa!! Obrigado pelo empenho de fazer tudo como eu queria!! Obrigado por se esforçar tanto e vir de tão longe pra realizar meu sonho!! Incríveis!!',
|
||||||
|
'author_name' => 'Milena e Weslley',
|
||||||
|
'context' => 'Casamento · 13/02/2026',
|
||||||
|
'sort_order' => 3,
|
||||||
|
'is_featured' => false,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'quote' => "Gostaríamos de agradecer por todo o acompanhamento e dedicação durante a realização do nosso casamento. Foi um dia muito especial e inesquecível para nós.\n\nDesde o início, conseguimos conduzir tudo aquilo que estávamos planejando, dentro dos horários que estipulamos, o que foi ótimo, e no grande dia sua equipe nos recebeu e tratou com muito carinho, atenção e cuidado, o que fez toda a diferença para vivermos esse momento com mais tranquilidade.\n\nTambém adoramos as sugestões e ideias para as fotos, que deixaram os registros ainda mais bonitos e espontâneos, porque não iríamos lembrar de quais poses fazer na hora.\n\nObrigada por fazer parte de um momento tão importante das nossas vidas. Desejamos muito sucesso e que muitos outros casais possam viver dias especiais através do trabalho da AMARE.",
|
||||||
|
'author_name' => 'Raquel e Pedro',
|
||||||
|
'context' => 'Casamento · 09/05/2026',
|
||||||
|
'sort_order' => 4,
|
||||||
|
'is_featured' => false,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'quote' => "Miiii, meu amor… você e sua equipe foram impecáveis.\n\nSuperou todas as nossas expectativas. Somos eternamente gratos por fazer nosso dia acontecer muito melhor do que imaginávamos.\n\nSempre muito atenciosa e paciente.\n\nAdoramos te conhecer e estamos muito felizes em termos escolhido você para assessorar nosso dia.",
|
||||||
|
'author_name' => 'Victoria e Pedro',
|
||||||
|
'context' => 'Casamento · 24/06/2026',
|
||||||
|
'sort_order' => 5,
|
||||||
|
'is_featured' => false,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$keepAuthors = array_column($testimonials, 'author_name');
|
||||||
|
|
||||||
|
Testimonial::query()
|
||||||
|
->whereNotIn('author_name', $keepAuthors)
|
||||||
|
->delete();
|
||||||
|
|
||||||
|
foreach ($testimonials as $testimonial) {
|
||||||
|
Testimonial::query()->updateOrCreate(
|
||||||
|
['author_name' => $testimonial['author_name']],
|
||||||
|
[
|
||||||
|
...$testimonial,
|
||||||
|
'photo_path' => null,
|
||||||
|
'photo_alt' => null,
|
||||||
|
'published_at' => Carbon::parse(self::SEED_TIMESTAMP),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private function copyFixture(string $fixtureName, string $destination): string
|
private function copyFixture(string $fixtureName, string $destination): string
|
||||||
{
|
{
|
||||||
$source = base_path('database/fixtures/images/'.$fixtureName);
|
$source = base_path('tests/fixtures/images/'.$fixtureName);
|
||||||
$disk = PublicImageUploadRules::disk();
|
Storage::disk('public')->put($destination, File::get($source));
|
||||||
|
|
||||||
Storage::disk($disk)->put($destination, File::get($source), 'public');
|
|
||||||
|
|
||||||
return $destination;
|
return $destination;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,70 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Database\Seeders;
|
|
||||||
|
|
||||||
use App\Models\Testimonial;
|
|
||||||
use Illuminate\Database\Seeder;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
|
|
||||||
class TestimonialsSeeder extends Seeder
|
|
||||||
{
|
|
||||||
private const PUBLISHED_AT = '2026-08-05 00:00:00';
|
|
||||||
|
|
||||||
public function run(): void
|
|
||||||
{
|
|
||||||
$testimonials = [
|
|
||||||
[
|
|
||||||
'quote' => "Mi, quero agradecer você e a sua equipe por todo empenho, atenção, vocês são abençoadas.\n\nEra nítida sua preocupação em garantir que todos os detalhes planejados desta comemoração, fossem atendidos.\n\nQue você possa transformar o grande dia das noivinhas sempre com essa sua leveza!!!\n\nMuito obrigada!",
|
|
||||||
'author_name' => 'Jeniffer e Maick',
|
|
||||||
'context' => 'Casamento · 06/12/2025',
|
|
||||||
'sort_order' => 1,
|
|
||||||
'is_featured' => true,
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'quote' => "Mi, eu não tenho palavras pra agradecer você e tudo que você fez por mim e por nós na realização desse sonho. Eu tô ainda extasiada com tudo que aconteceu hoje; mas tenho certeza que sem a sua ajuda, muita coisa não aconteceria.\n\nObrigada por tudo !",
|
|
||||||
'author_name' => 'Quesia e Jhonata',
|
|
||||||
'context' => 'Casamento · 21/12/2025',
|
|
||||||
'sort_order' => 2,
|
|
||||||
'is_featured' => true,
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'quote' => 'Que equipe!! Que equipe maravilhosa!! Obrigado pelo empenho de fazer tudo como eu queria!! Obrigado por se esforçar tanto e vir de tão longe pra realizar meu sonho!! Incríveis!!',
|
|
||||||
'author_name' => 'Milena e Weslley',
|
|
||||||
'context' => 'Casamento · 13/02/2026',
|
|
||||||
'sort_order' => 3,
|
|
||||||
'is_featured' => false,
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'quote' => "Gostaríamos de agradecer por todo o acompanhamento e dedicação durante a realização do nosso casamento. Foi um dia muito especial e inesquecível para nós.\n\nDesde o início, conseguimos conduzir tudo aquilo que estávamos planejando, dentro dos horários que estipulamos, o que foi ótimo, e no grande dia sua equipe nos recebeu e tratou com muito carinho, atenção e cuidado, o que fez toda a diferença para vivermos esse momento com mais tranquilidade.\n\nTambém adoramos as sugestões e ideias para as fotos, que deixaram os registros ainda mais bonitos e espontâneos, porque não iríamos lembrar de quais poses fazer na hora.\n\nObrigada por fazer parte de um momento tão importante das nossas vidas. Desejamos muito sucesso e que muitos outros casais possam viver dias especiais através do trabalho da AMARE.",
|
|
||||||
'author_name' => 'Raquel e Pedro',
|
|
||||||
'context' => 'Casamento · 09/05/2026',
|
|
||||||
'sort_order' => 4,
|
|
||||||
'is_featured' => false,
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'quote' => "Miiii, meu amor… você e sua equipe foram impecáveis.\n\nSuperou todas as nossas expectativas. Somos eternamente gratos por fazer nosso dia acontecer muito melhor do que imaginávamos.\n\nSempre muito atenciosa e paciente.\n\nAdoramos te conhecer e estamos muito felizes em termos escolhido você para assessorar nosso dia.",
|
|
||||||
'author_name' => 'Victoria e Pedro',
|
|
||||||
'context' => 'Casamento · 24/06/2026',
|
|
||||||
'sort_order' => 5,
|
|
||||||
'is_featured' => false,
|
|
||||||
],
|
|
||||||
];
|
|
||||||
|
|
||||||
DB::transaction(function () use ($testimonials): void {
|
|
||||||
foreach ($testimonials as $testimonial) {
|
|
||||||
Testimonial::query()->updateOrCreate(
|
|
||||||
['author_name' => $testimonial['author_name']],
|
|
||||||
[
|
|
||||||
'quote' => $testimonial['quote'],
|
|
||||||
'context' => $testimonial['context'],
|
|
||||||
'sort_order' => $testimonial['sort_order'],
|
|
||||||
'is_featured' => $testimonial['is_featured'],
|
|
||||||
'published_at' => self::PUBLISHED_AT,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -11,6 +11,7 @@ use App\Models\SiteSetting;
|
|||||||
use App\Models\Testimonial;
|
use App\Models\Testimonial;
|
||||||
use Illuminate\Database\Seeder;
|
use Illuminate\Database\Seeder;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Facades\File;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -43,8 +44,6 @@ class VisualContentSeeder extends Seeder
|
|||||||
'hero_secondary_cta_label' => 'Conheça nosso olhar',
|
'hero_secondary_cta_label' => 'Conheça nosso olhar',
|
||||||
'hero_note' => 'Planejamento cuidadoso, comunicação clara e execução segura — do primeiro encontro ao último detalhe.',
|
'hero_note' => 'Planejamento cuidadoso, comunicação clara e execução segura — do primeiro encontro ao último detalhe.',
|
||||||
'about_summary' => 'Assessoria boutique especializada em experiências memoráveis em São Paulo.',
|
'about_summary' => 'Assessoria boutique especializada em experiências memoráveis em São Paulo.',
|
||||||
'about_image_path' => $this->writeSolidJpeg('visual/about/about-image.jpg', 1200, 900, [232, 228, 218]),
|
|
||||||
'about_image_alt' => 'Imagem editorial da página Sobre',
|
|
||||||
'manifesto_title' => 'Sofisticação que também se traduz em organização.',
|
'manifesto_title' => 'Sofisticação que também se traduz em organização.',
|
||||||
'manifesto_lead' => 'Um evento memorável não nasce apenas de uma boa estética. Ele depende de decisões bem conduzidas, fornecedores alinhados e atenção constante ao que realmente importa.',
|
'manifesto_lead' => 'Um evento memorável não nasce apenas de uma boa estética. Ele depende de decisões bem conduzidas, fornecedores alinhados e atenção constante ao que realmente importa.',
|
||||||
'manifesto_body' => 'A Amare combina sensibilidade e precisão para criar encontros coerentes com cada cliente, marca e ocasião — sem fórmulas prontas, excessos ou ruído.',
|
'manifesto_body' => 'A Amare combina sensibilidade e precisão para criar encontros coerentes com cada cliente, marca e ocasião — sem fórmulas prontas, excessos ou ruído.',
|
||||||
@@ -59,7 +58,7 @@ class VisualContentSeeder extends Seeder
|
|||||||
],
|
],
|
||||||
'default_meta_title' => 'Amare Assessoria de Eventos',
|
'default_meta_title' => 'Amare Assessoria de Eventos',
|
||||||
'default_meta_description' => 'Assessoria premium para casamentos e eventos corporativos em São Paulo.',
|
'default_meta_description' => 'Assessoria premium para casamentos e eventos corporativos em São Paulo.',
|
||||||
'default_og_image_path' => $this->writeSolidJpeg('visual/og/og-default.jpg', 1600, 1067, [85, 107, 47]),
|
'default_og_image_path' => $this->copyFixture('og-default.jpg', 'visual/og/og-default.jpg'),
|
||||||
'default_og_image_alt' => 'Identidade visual da Amare Assessoria de Eventos',
|
'default_og_image_alt' => 'Identidade visual da Amare Assessoria de Eventos',
|
||||||
'analytics_enabled' => false,
|
'analytics_enabled' => false,
|
||||||
'analytics_script' => null,
|
'analytics_script' => null,
|
||||||
@@ -90,7 +89,7 @@ class VisualContentSeeder extends Seeder
|
|||||||
['slug' => $service['slug']],
|
['slug' => $service['slug']],
|
||||||
[
|
[
|
||||||
...$service,
|
...$service,
|
||||||
'cover_image_path' => $this->writeSolidJpeg('visual/services/'.$service['slug'].'.jpg', 1600, 1000, [196, 200, 184]),
|
'cover_image_path' => $this->copyFixture('service-cover.jpg', 'visual/services/'.$service['slug'].'.jpg'),
|
||||||
'cover_image_alt' => 'Capa do serviço '.$service['title'],
|
'cover_image_alt' => 'Capa do serviço '.$service['title'],
|
||||||
'published_at' => Carbon::parse(self::SEED_TIMESTAMP),
|
'published_at' => Carbon::parse(self::SEED_TIMESTAMP),
|
||||||
],
|
],
|
||||||
@@ -134,7 +133,7 @@ class VisualContentSeeder extends Seeder
|
|||||||
['slug' => $caseData['slug']],
|
['slug' => $caseData['slug']],
|
||||||
[
|
[
|
||||||
...$caseData,
|
...$caseData,
|
||||||
'cover_image_path' => $this->writeSolidJpeg('visual/portfolio/'.$caseData['slug'].'-cover.jpg', 1600, 1200, [240, 238, 233]),
|
'cover_image_path' => $this->copyFixture('portfolio-cover.jpg', 'visual/portfolio/'.$caseData['slug'].'-cover.jpg'),
|
||||||
'cover_image_alt' => 'Capa do caso '.$caseData['title'],
|
'cover_image_alt' => 'Capa do caso '.$caseData['title'],
|
||||||
'is_featured' => true,
|
'is_featured' => true,
|
||||||
'published_at' => Carbon::parse(self::SEED_TIMESTAMP),
|
'published_at' => Carbon::parse(self::SEED_TIMESTAMP),
|
||||||
@@ -145,7 +144,7 @@ class VisualContentSeeder extends Seeder
|
|||||||
|
|
||||||
PortfolioImage::query()->create([
|
PortfolioImage::query()->create([
|
||||||
'portfolio_case_id' => $case->id,
|
'portfolio_case_id' => $case->id,
|
||||||
'path' => $this->writeSolidJpeg('visual/portfolio/'.$caseData['slug'].'-gallery-1.jpg', 1600, 1200, [228, 226, 221]),
|
'path' => $this->copyFixture('gallery.jpg', 'visual/portfolio/'.$caseData['slug'].'-gallery-1.jpg'),
|
||||||
'alt_text' => 'Galeria '.$caseData['title'].' 1',
|
'alt_text' => 'Galeria '.$caseData['title'].' 1',
|
||||||
'caption' => 'Detalhe da decoração',
|
'caption' => 'Detalhe da decoração',
|
||||||
'sort_order' => 1,
|
'sort_order' => 1,
|
||||||
@@ -187,21 +186,10 @@ class VisualContentSeeder extends Seeder
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private function copyFixture(string $fixtureName, string $destination): string
|
||||||
* @param array{0: int, 1: int, 2: int} $rgb
|
|
||||||
*/
|
|
||||||
private function writeSolidJpeg(string $destination, int $width, int $height, array $rgb): string
|
|
||||||
{
|
{
|
||||||
$image = imagecreatetruecolor($width, $height);
|
$source = base_path('tests/fixtures/images/'.$fixtureName);
|
||||||
$color = imagecolorallocate($image, $rgb[0], $rgb[1], $rgb[2]);
|
Storage::disk('public')->put($destination, File::get($source));
|
||||||
imagefilledrectangle($image, 0, 0, $width, $height, $color);
|
|
||||||
|
|
||||||
ob_start();
|
|
||||||
imagejpeg($image, null, 90);
|
|
||||||
$binary = (string) ob_get_clean();
|
|
||||||
imagedestroy($image);
|
|
||||||
|
|
||||||
Storage::disk('public')->put($destination, $binary);
|
|
||||||
|
|
||||||
return $destination;
|
return $destination;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,78 +0,0 @@
|
|||||||
# Shared Compose for Dokploy staging and production.
|
|
||||||
# Both stacks use the same file with different env:
|
|
||||||
# APP_IMAGE=ghcr.io/<owner>/<repo>
|
|
||||||
# IMAGE_TAG=staging|production|<git-sha>
|
|
||||||
# PostgreSQL is a separate Dokploy database service (not defined here).
|
|
||||||
# Traefik/Dokploy domains should target service `web` port 8000.
|
|
||||||
# App services join dokploy-network so they can resolve Dokploy-managed
|
|
||||||
# Postgres internal hosts (e.g. amare-stg-pez43e).
|
|
||||||
|
|
||||||
services:
|
|
||||||
migrate:
|
|
||||||
image: ${APP_IMAGE}:${IMAGE_TAG}
|
|
||||||
pull_policy: always
|
|
||||||
restart: "no"
|
|
||||||
env_file:
|
|
||||||
- .env
|
|
||||||
command: >
|
|
||||||
sh -c
|
|
||||||
"php artisan migrate --force --no-interaction
|
|
||||||
&& (php artisan db:seed --class=ContentSeeder --force --no-interaction 2>&1
|
|
||||||
&& php artisan media:generate-variants --force 2>&1) || true
|
|
||||||
&& echo 'migrate+seed done'"
|
|
||||||
networks:
|
|
||||||
- dokploy-network
|
|
||||||
|
|
||||||
web:
|
|
||||||
image: ${APP_IMAGE}:${IMAGE_TAG}
|
|
||||||
pull_policy: always
|
|
||||||
restart: unless-stopped
|
|
||||||
env_file:
|
|
||||||
- .env
|
|
||||||
depends_on:
|
|
||||||
migrate:
|
|
||||||
condition: service_completed_successfully
|
|
||||||
expose:
|
|
||||||
- "8000"
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8000/up"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 5s
|
|
||||||
start_period: 40s
|
|
||||||
retries: 3
|
|
||||||
networks:
|
|
||||||
- dokploy-network
|
|
||||||
|
|
||||||
queue:
|
|
||||||
image: ${APP_IMAGE}:${IMAGE_TAG}
|
|
||||||
pull_policy: always
|
|
||||||
restart: unless-stopped
|
|
||||||
env_file:
|
|
||||||
- .env
|
|
||||||
depends_on:
|
|
||||||
migrate:
|
|
||||||
condition: service_completed_successfully
|
|
||||||
command: ["php", "artisan", "queue:work", "--sleep=2", "--tries=3", "--max-time=3600"]
|
|
||||||
stop_grace_period: 60s
|
|
||||||
stop_signal: SIGTERM
|
|
||||||
networks:
|
|
||||||
- dokploy-network
|
|
||||||
|
|
||||||
scheduler:
|
|
||||||
image: ${APP_IMAGE}:${IMAGE_TAG}
|
|
||||||
pull_policy: always
|
|
||||||
restart: unless-stopped
|
|
||||||
env_file:
|
|
||||||
- .env
|
|
||||||
depends_on:
|
|
||||||
migrate:
|
|
||||||
condition: service_completed_successfully
|
|
||||||
command: ["php", "artisan", "schedule:work"]
|
|
||||||
stop_grace_period: 30s
|
|
||||||
stop_signal: SIGTERM
|
|
||||||
networks:
|
|
||||||
- dokploy-network
|
|
||||||
|
|
||||||
networks:
|
|
||||||
dokploy-network:
|
|
||||||
external: true
|
|
||||||
@@ -7,8 +7,5 @@ Mesma imagem, comandos distintos:
|
|||||||
| web | `frankenphp run --config /etc/caddy/Caddyfile` |
|
| web | `frankenphp run --config /etc/caddy/Caddyfile` |
|
||||||
| queue | `php artisan queue:work --sleep=2 --tries=3` |
|
| queue | `php artisan queue:work --sleep=2 --tries=3` |
|
||||||
| scheduler | `php artisan schedule:work` |
|
| scheduler | `php artisan schedule:work` |
|
||||||
| migrate | `php artisan migrate --force` (one-shot no Compose de deploy) |
|
|
||||||
|
|
||||||
FrankenPHP em **modo regular** (ADR-006). Worker mode proibido no MVP.
|
FrankenPHP em **modo regular** (ADR-006). Worker mode proibido no MVP.
|
||||||
|
|
||||||
Deploy Dokploy (staging/produção): ver [`docker-compose.deploy.yml`](../docker-compose.deploy.yml) e [docs/deployment/dokploy.md](../docs/deployment/dokploy.md).
|
|
||||||
|
|||||||
@@ -1,300 +0,0 @@
|
|||||||
# Deploy Dokploy (staging → production)
|
|
||||||
|
|
||||||
Runbook for operating Amare on a VPS with Dokploy connected to GitHub, publishing immutable images to GHCR.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
```
|
|
||||||
CI (main) → build FrankenPHP image → GHCR :<sha> + :staging
|
|
||||||
→ Dokploy staging compose.deploy
|
|
||||||
→ smoke /up / /admin/login
|
|
||||||
|
|
||||||
Promote (manual) → retag same digest as :production (no rebuild)
|
|
||||||
→ Dokploy production compose.deploy
|
|
||||||
→ smoke
|
|
||||||
```
|
|
||||||
|
|
||||||
| Piece | Detail |
|
|
||||||
|---|---|
|
|
||||||
| Compose file | [`docker-compose.deploy.yml`](../../docker-compose.deploy.yml) |
|
|
||||||
| Processes | `migrate` (one-shot) → `web` / `queue` / `scheduler` |
|
|
||||||
| Image | `ghcr.io/<owner>/<repo>:<sha>` (+ aliases `:staging`, `:production`) |
|
|
||||||
| Database | Dokploy PostgreSQL **per environment** (not in the app image) |
|
|
||||||
| Media | Cloudflare R2 (`FILESYSTEM_DISK=r2`), separate buckets per environment |
|
|
||||||
| Mail | Resend (`MAIL_MAILER=resend`) |
|
|
||||||
| Proxy | Dokploy Traefik → service `web` port `8000` |
|
|
||||||
|
|
||||||
## Prerequisites (manual)
|
|
||||||
|
|
||||||
1. Dokploy installed on the VPS; GitHub provider connected.
|
|
||||||
2. GHCR registry in Dokploy (`ghcr.io`) with a PAT that can **read** packages (`read:packages`). Prefer a dedicated bot/token; do not store write tokens on the VPS.
|
|
||||||
3. Two PostgreSQL services in Dokploy (staging + production), private (no public port).
|
|
||||||
4. Two R2 buckets (or prefixes) and Resend credentials for each environment as needed.
|
|
||||||
5. Domains (or temporary Dokploy/traefik.me hosts) pointing at the VPS with TLS.
|
|
||||||
|
|
||||||
## Create Compose stacks
|
|
||||||
|
|
||||||
Create **two** Dokploy Compose services (same repo, same compose path):
|
|
||||||
|
|
||||||
| Stack | Compose path | `IMAGE_TAG` | Notes |
|
|
||||||
|---|---|---|---|
|
|
||||||
| staging | `docker-compose.deploy.yml` | `staging` | Auto-deployed after CI on `main` |
|
|
||||||
| production | `docker-compose.deploy.yml` | `production` | Manual promotion only |
|
|
||||||
|
|
||||||
Dokploy Environment for each stack must set:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
APP_IMAGE=ghcr.io/<owner>/<repo>
|
|
||||||
IMAGE_TAG=staging # or production
|
|
||||||
```
|
|
||||||
|
|
||||||
Point Dokploy domain(s) at service **`web`**, port **`8000`**. Do not publish PostgreSQL or host ports for app processes.
|
|
||||||
|
|
||||||
Compose services must join the external Docker network `dokploy-network` (declared in `docker-compose.deploy.yml`) so they can resolve the Dokploy-managed Postgres internal host (e.g. `amare-stg-pez43e`). Set `DB_HOST` to that **Internal Host** from the Dokploy database UI — not a public hostname.
|
|
||||||
|
|
||||||
Source can be GitHub (so Dokploy clones the compose file) or Raw paste of `docker-compose.deploy.yml`. Prefer GitHub + fixed compose path so updates stay in sync with `main`.
|
|
||||||
|
|
||||||
## Required Laravel env (Dokploy only)
|
|
||||||
|
|
||||||
Set these in Dokploy Environment UI (written to `.env` next to the compose file). **Never** put them in GitHub Actions secrets or image layers.
|
|
||||||
|
|
||||||
```env
|
|
||||||
APP_NAME=Amare
|
|
||||||
APP_ENV=staging # or production
|
|
||||||
APP_KEY=base64:... # unique per environment — generate with php artisan key:generate --show
|
|
||||||
APP_DEBUG=false
|
|
||||||
APP_URL=https://staging.example.com
|
|
||||||
|
|
||||||
APP_LOCALE=pt_BR
|
|
||||||
APP_FALLBACK_LOCALE=pt_BR
|
|
||||||
APP_TIMEZONE=America/Fortaleza
|
|
||||||
|
|
||||||
DB_CONNECTION=pgsql
|
|
||||||
DB_HOST=<dokploy-postgres-internal-host> # Internal Host from Dokploy UI (requires dokploy-network)
|
|
||||||
DB_PORT=5432
|
|
||||||
DB_DATABASE=amare_staging
|
|
||||||
DB_USERNAME=...
|
|
||||||
DB_PASSWORD=...
|
|
||||||
|
|
||||||
SESSION_DRIVER=database
|
|
||||||
SESSION_SECURE_COOKIE=true
|
|
||||||
SESSION_HTTP_ONLY=true
|
|
||||||
SESSION_SAME_SITE=lax
|
|
||||||
CACHE_STORE=database
|
|
||||||
QUEUE_CONNECTION=database
|
|
||||||
|
|
||||||
FILESYSTEM_DISK=r2
|
|
||||||
R2_ACCESS_KEY_ID=...
|
|
||||||
R2_SECRET_ACCESS_KEY=...
|
|
||||||
R2_BUCKET=...
|
|
||||||
R2_ENDPOINT=https://<account_id>.r2.cloudflarestorage.com
|
|
||||||
R2_URL=https://media-staging.example.com
|
|
||||||
|
|
||||||
MAIL_MAILER=resend
|
|
||||||
RESEND_API_KEY=...
|
|
||||||
MAIL_FROM_ADDRESS=noreply@example.com
|
|
||||||
MAIL_FROM_NAME=Amare
|
|
||||||
|
|
||||||
LOG_LEVEL=warning
|
|
||||||
```
|
|
||||||
|
|
||||||
Trusted proxies are configured in `bootstrap/app.php` so Traefik `X-Forwarded-*` headers work for HTTPS cookies and URLs.
|
|
||||||
|
|
||||||
Livewire temporary uploads default to the **local** disk in `AppServiceProvider` (even when `FILESYSTEM_DISK=r2`), so Filament does not browser-PUT to R2. Final media still lands on R2 via `PublicImageUploadRules`. Optional override: `LIVEWIRE_TEMPORARY_FILE_UPLOAD_DISK` in Dokploy `.env` (`env_file` accepts any key — does not need a compose `environment:` entry).
|
|
||||||
|
|
||||||
### R2 CORS (public/media reads from JS)
|
|
||||||
|
|
||||||
Upload path does not need R2 CORS with the local temp-disk default. Still useful if the browser fetches R2 URLs cross-origin from JS. Origin must match `APP_URL` exactly; include `AllowedHeaders`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"AllowedOrigins": ["https://hellomanoel.com"],
|
|
||||||
"AllowedMethods": ["GET", "PUT", "POST", "HEAD"],
|
|
||||||
"AllowedHeaders": ["*"],
|
|
||||||
"ExposeHeaders": ["ETag", "Content-Type"],
|
|
||||||
"MaxAgeSeconds": 3600
|
|
||||||
}
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
Also enable public access / custom domain for `R2_URL` so `<img>` URLs work after save.
|
|
||||||
|
|
||||||
## GitHub Actions secrets
|
|
||||||
|
|
||||||
Repository secrets used by workflows:
|
|
||||||
|
|
||||||
| Secret | Purpose |
|
|
||||||
|---|---|
|
|
||||||
| `DOKPLOY_URL` | Panel origin **without** `/api` (e.g. `https://panel.example.com`). Do not use the OpenAPI base URL that ends in `/api` — that yields `/api/api/...` and 404s. |
|
|
||||||
| `DOKPLOY_API_KEY` | API key from Dokploy profile → API/CLI |
|
|
||||||
| `DOKPLOY_STAGING_COMPOSE_ID` | Staging **Compose** service id (not an Application id) |
|
|
||||||
| `DOKPLOY_PRODUCTION_COMPOSE_ID` | Production **Compose** service id (not an Application id) |
|
|
||||||
| `STAGING_URL` | Public origin for staging smoke (e.g. `https://staging.example.com`) |
|
|
||||||
| `PRODUCTION_URL` | Public origin for production smoke |
|
|
||||||
|
|
||||||
HTTP 404 from `compose.deploy` usually means the compose id is wrong (Application id instead of Compose) or `DOKPLOY_URL` still includes `/api`.
|
|
||||||
|
|
||||||
`GITHUB_TOKEN` (automatic) publishes to GHCR with `packages:write`. No Laravel/`APP_KEY`/DB/R2/Resend secrets belong in GitHub for this pipeline.
|
|
||||||
|
|
||||||
## Workflows
|
|
||||||
|
|
||||||
### Staging (automatic)
|
|
||||||
|
|
||||||
[`.github/workflows/deploy-staging.yml`](../../.github/workflows/deploy-staging.yml)
|
|
||||||
|
|
||||||
1. Waits for workflow `CI` success on push to `main`.
|
|
||||||
2. Builds once; pushes `:<full-sha>` and `:staging`.
|
|
||||||
3. Calls Dokploy `compose.deploy` and polls until done.
|
|
||||||
4. Runs [`scripts/deploy/smoke.sh`](../../scripts/deploy/smoke.sh) against `STAGING_URL`.
|
|
||||||
|
|
||||||
### Production (manual)
|
|
||||||
|
|
||||||
[`.github/workflows/promote-production.yml`](../../.github/workflows/promote-production.yml)
|
|
||||||
|
|
||||||
1. Operator runs **Actions → Promote production**.
|
|
||||||
2. Inputs: full `sha` already on GHCR; `confirm` must be exactly `PRODUCTION`.
|
|
||||||
3. Retags the **same digest** as `:production` (no rebuild).
|
|
||||||
4. Deploys production compose + smoke.
|
|
||||||
|
|
||||||
Private repos on GitHub Free do not get Environment required reviewers; human approval is the explicit `workflow_dispatch` + confirmation string. GitHub Pro Environment reviewers are optional later.
|
|
||||||
|
|
||||||
## First admin and authorized production seeding
|
|
||||||
|
|
||||||
Seed credentials are local-only. For staging/production:
|
|
||||||
|
|
||||||
FrankenPHP sets `XDG_CONFIG_HOME=/config` (Caddy). PsySH/tinker then tries `/config/psysh`, which `appuser` cannot write — you get `Writing to directory /config/psysh is not allowed.` Override that env for the one-shot command:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# From Dokploy → staging/production → Open terminal on `web` (or one-off run)
|
|
||||||
XDG_CONFIG_HOME=/tmp php artisan tinker --execute="
|
|
||||||
\$user = \\App\\Models\\User::query()->updateOrCreate(
|
|
||||||
['email' => 'admin@example.com'],
|
|
||||||
[
|
|
||||||
'name' => 'Admin',
|
|
||||||
'password' => 'use-a-strong-password',
|
|
||||||
'role' => 'admin',
|
|
||||||
'is_active' => true,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
\$user->forceFill(['email_verified_at' => now()])->save();
|
|
||||||
echo \$user->email.PHP_EOL;
|
|
||||||
"
|
|
||||||
```
|
|
||||||
|
|
||||||
Plain password is enough: `User` casts `password` to `hashed` (and skips re-hash when value already hashed). `email_verified_at` is not mass-assignable — use `forceFill` as above.
|
|
||||||
|
|
||||||
Confirm:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
XDG_CONFIG_HOME=/tmp php artisan tinker --execute="echo \\App\\Models\\User::query()->where('email', 'admin@example.com')->exists() ? 'ok' : 'missing';"
|
|
||||||
```
|
|
||||||
|
|
||||||
Never reuse `admin@amare.local` / `password`.
|
|
||||||
|
|
||||||
### Load authorized testimonials after migrations
|
|
||||||
|
|
||||||
After migrations, manually load the five authorized testimonials in **staging**, then repeat in **production**. From Dokploy, open a terminal on the environment's `web` service (or run an equivalent one-off process):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
php artisan db:seed --class='Database\Seeders\TestimonialsSeeder' --force --no-interaction
|
|
||||||
```
|
|
||||||
|
|
||||||
This seeder is safe to rerun: it overwrites canonical source-owned fields, preserves curated photo fields, and leaves unrelated testimonials unchanged. The five records are published with the approved deterministic timestamp. Upsert keys on `author_name` (no unique DB constraint); keep one row per couple before/after running.
|
|
||||||
|
|
||||||
Optional verification:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
XDG_CONFIG_HOME=/tmp php artisan tinker --execute="
|
|
||||||
\$expected = collect(['Jeniffer e Maick', 'Quesia e Jhonata', 'Milena e Weslley', 'Raquel e Pedro', 'Victoria e Pedro']);
|
|
||||||
\$rows = \\App\\Models\\Testimonial::query()->whereIn('author_name', \$expected)->get(['author_name', 'published_at'])->groupBy('author_name');
|
|
||||||
\$valid = \$expected->every(function (string \$author) use (\$rows): bool {
|
|
||||||
\$matches = \$rows->get(\$author, collect());
|
|
||||||
return \$matches->count() === 1
|
|
||||||
&& \$matches->first()->published_at?->format('Y-m-d H:i:s') === '2026-08-05 00:00:00';
|
|
||||||
});
|
|
||||||
echo (\$valid ? 'ok' : 'invalid').PHP_EOL;
|
|
||||||
"
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected output: `ok`.
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
## Backup and restore
|
|
||||||
|
|
||||||
Policy (SPEC §16.3): daily PostgreSQL backup, retention ≥ 14 days, RPO ≤ 24h, RTO ≤ 4h.
|
|
||||||
|
|
||||||
### Configure (Dokploy)
|
|
||||||
|
|
||||||
1. Settings → Destinations: add S3-compatible destination (AWS S3, R2, etc.).
|
|
||||||
2. Open each PostgreSQL service → Backup:
|
|
||||||
- Destination: the S3 destination
|
|
||||||
- Schedule: cron e.g. `0 3 * * *`
|
|
||||||
- Prefix: `amare/staging` or `amare/production`
|
|
||||||
- Enabled: on
|
|
||||||
3. Click **Test** and verify the object appears in the bucket.
|
|
||||||
4. Prefer Dokploy alerts/webhooks for backup failure if configured.
|
|
||||||
|
|
||||||
### Restore (staging rehearsal before first production promote)
|
|
||||||
|
|
||||||
1. Create a scratch database or restore into a disposable Postgres service.
|
|
||||||
2. Database → Backup → **Restore**: pick destination + backup file + target database name.
|
|
||||||
3. Point a temporary compose env at the restored DB and confirm `/up` + `/admin/login`.
|
|
||||||
4. Document the timestamp of the successful rehearsal.
|
|
||||||
|
|
||||||
Do **not** promote to production until staging restore has been proven once.
|
|
||||||
|
|
||||||
## Rollback
|
|
||||||
|
|
||||||
No rebuild. Move the environment alias to a previous SHA digest and redeploy.
|
|
||||||
|
|
||||||
### Staging
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Locally or in a one-off Actions shell with GHCR login
|
|
||||||
docker buildx imagetools create \
|
|
||||||
--tag ghcr.io/<owner>/<repo>:staging \
|
|
||||||
ghcr.io/<owner>/<repo>:<previous-sha>
|
|
||||||
|
|
||||||
# Then trigger Dokploy deploy (UI Deploy, or):
|
|
||||||
DOKPLOY_URL=... DOKPLOY_API_KEY=... DOKPLOY_COMPOSE_ID=... \
|
|
||||||
./scripts/deploy/dokploy-deploy.sh
|
|
||||||
|
|
||||||
SMOKE_BASE_URL=https://staging.example.com ./scripts/deploy/smoke.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
### Production
|
|
||||||
|
|
||||||
Same pattern with `:production` tag and production compose id / URL. Prefer re-running **Promote production** with the previous SHA and confirmation `PRODUCTION`.
|
|
||||||
|
|
||||||
If a migration is not backward-compatible, fix forward with a new SHA; keep migrations reversible when possible.
|
|
||||||
|
|
||||||
## Smoke checks
|
|
||||||
|
|
||||||
```bash
|
|
||||||
SMOKE_BASE_URL=https://staging.example.com ./scripts/deploy/smoke.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
Expects HTTP 200 for `/up`, `/`, and `/admin/login`.
|
|
||||||
|
|
||||||
## Domain checklist before production promote
|
|
||||||
|
|
||||||
- [ ] Final hostname DNS → VPS
|
|
||||||
- [ ] Dokploy TLS certificate issued
|
|
||||||
- [ ] `APP_URL` matches public HTTPS origin
|
|
||||||
- [ ] `SESSION_SECURE_COOKIE=true`
|
|
||||||
- [ ] Staging smoke green on the SHA to promote
|
|
||||||
- [ ] Staging backup + restore rehearsed
|
|
||||||
- [ ] Production Postgres backup schedule enabled
|
|
||||||
- [ ] Production R2 bucket + Resend domain ready
|
|
||||||
- [ ] First admin created without seed
|
|
||||||
|
|
||||||
## Local validation of Compose
|
|
||||||
|
|
||||||
```bash
|
|
||||||
APP_IMAGE=ghcr.io/<owner>/<repo> IMAGE_TAG=staging \
|
|
||||||
docker compose -f docker-compose.deploy.yml config
|
|
||||||
```
|
|
||||||
|
|
||||||
Requires a `.env` file present (Dokploy creates it from Environment UI). For local config checks, an empty `.env` is enough.
|
|
||||||
@@ -1,262 +0,0 @@
|
|||||||
Audite e melhore integralmente este projeto do site da **Amare**, incluindo todas as rotas públicas, componentes compartilhados, formulários, navegação, conteúdo, metadados e comportamento responsivo.
|
|
||||||
|
|
||||||
Você está autorizado a executar o projeto, inspecionar o repositório e modificar diretamente o código. Não entregue somente recomendações: implemente as correções necessárias e valide o resultado.
|
|
||||||
|
|
||||||
## Objetivo
|
|
||||||
|
|
||||||
Deixar o site tecnicamente sólido, visualmente refinado e pronto para apresentação à cliente e posterior lançamento, corrigindo problemas de:
|
|
||||||
|
|
||||||
* UI e UX;
|
|
||||||
* responsividade;
|
|
||||||
* posicionamento e conteúdo;
|
|
||||||
* acessibilidade;
|
|
||||||
* conversão;
|
|
||||||
* SEO;
|
|
||||||
* performance;
|
|
||||||
* robustez;
|
|
||||||
* qualidade e manutenção do código.
|
|
||||||
|
|
||||||
## Contexto do produto
|
|
||||||
|
|
||||||
A Amare é uma empresa de assessoria, produção e organização de eventos em São Paulo.
|
|
||||||
|
|
||||||
Ela atende:
|
|
||||||
|
|
||||||
* casamentos;
|
|
||||||
* eventos sociais e celebrações particulares;
|
|
||||||
* eventos corporativos.
|
|
||||||
|
|
||||||
O site não deve transmitir que a empresa trabalha exclusivamente com casamentos.
|
|
||||||
|
|
||||||
A experiência precisa equilibrar:
|
|
||||||
|
|
||||||
* emoção, proximidade, sensibilidade e sofisticação para eventos sociais;
|
|
||||||
* organização, segurança, método, clareza e credibilidade para eventos corporativos.
|
|
||||||
|
|
||||||
O resultado deve ser editorial, contemporâneo, humano, elegante e profissional, sem parecer excessivamente romântico nem corporativo demais.
|
|
||||||
|
|
||||||
Preview de referência:
|
|
||||||
|
|
||||||
`https://amare.preview.hellomanoel.com/`
|
|
||||||
|
|
||||||
Considere os documentos existentes no repositório, especialmente `AGENTS.md`, `README.md`, `SPEC.md`, `DESIGN.md` e equivalentes, como contexto autoritativo do projeto.
|
|
||||||
|
|
||||||
## Direção visual
|
|
||||||
|
|
||||||
Preserve a identidade visual existente quando ela funcionar corretamente:
|
|
||||||
|
|
||||||
* fundos off-white ou bege claro;
|
|
||||||
* verde oliva;
|
|
||||||
* composição editorial;
|
|
||||||
* fotografias em destaque;
|
|
||||||
* espaços em branco generosos;
|
|
||||||
* EB Garamond em títulos e destaques;
|
|
||||||
* elementos minimalistas;
|
|
||||||
* animações discretas;
|
|
||||||
* aparência refinada e atemporal.
|
|
||||||
|
|
||||||
Não preserve decisões que prejudiquem usabilidade, contraste, legibilidade, acessibilidade, responsividade ou performance.
|
|
||||||
|
|
||||||
Avalie especialmente:
|
|
||||||
|
|
||||||
* uso excessivo de Garamond em textos pequenos, menus, botões e formulários;
|
|
||||||
* verdes claros com contraste insuficiente;
|
|
||||||
* CTAs discretos demais;
|
|
||||||
* espaços vazios excessivos no celular;
|
|
||||||
* imagens que reforcem somente o posicionamento de casamento;
|
|
||||||
* falta de equilíbrio entre conteúdo social e corporativo;
|
|
||||||
* inconsistência tipográfica ou de espaçamento;
|
|
||||||
* aparência de template genérico de casamento.
|
|
||||||
|
|
||||||
## Escopo da auditoria
|
|
||||||
|
|
||||||
Analise todas as rotas encontradas no código e corrija problemas relacionados a:
|
|
||||||
|
|
||||||
### Produto e conteúdo
|
|
||||||
|
|
||||||
* clareza da proposta de valor;
|
|
||||||
* equilíbrio entre eventos sociais e corporativos;
|
|
||||||
* hierarquia das informações;
|
|
||||||
* conteúdo genérico, redundante ou sem função;
|
|
||||||
* coerência entre títulos, textos, imagens e CTAs;
|
|
||||||
* clareza dos serviços;
|
|
||||||
* confiança e credibilidade;
|
|
||||||
* caminho até contato ou solicitação de proposta.
|
|
||||||
|
|
||||||
Não invente história, números, clientes, prêmios, depoimentos, equipe, serviços, telefone, e-mail ou qualquer informação não confirmada.
|
|
||||||
|
|
||||||
### UI e experiência
|
|
||||||
|
|
||||||
* navegação;
|
|
||||||
* header e footer;
|
|
||||||
* menu mobile;
|
|
||||||
* hierarquia visual;
|
|
||||||
* tipografia;
|
|
||||||
* espaçamento;
|
|
||||||
* grids;
|
|
||||||
* CTAs;
|
|
||||||
* formulários;
|
|
||||||
* estados interativos;
|
|
||||||
* consistência entre páginas;
|
|
||||||
* experiência mobile-first;
|
|
||||||
* ausência de overflow, cortes, sobreposições ou distorções;
|
|
||||||
* adaptação real do layout ao celular, e não apenas redução da versão desktop.
|
|
||||||
|
|
||||||
### Acessibilidade
|
|
||||||
|
|
||||||
Use WCAG 2.2 AA como referência prática.
|
|
||||||
|
|
||||||
Corrija problemas de:
|
|
||||||
|
|
||||||
* HTML semântico;
|
|
||||||
* hierarquia de headings;
|
|
||||||
* navegação por teclado;
|
|
||||||
* foco visível;
|
|
||||||
* contraste;
|
|
||||||
* nomes acessíveis;
|
|
||||||
* labels;
|
|
||||||
* mensagens de erro;
|
|
||||||
* áreas de toque;
|
|
||||||
* textos alternativos;
|
|
||||||
* menu mobile;
|
|
||||||
* overlays;
|
|
||||||
* preferência por redução de movimento;
|
|
||||||
* uso correto de links e botões.
|
|
||||||
|
|
||||||
Prefira HTML semântico a ARIA desnecessária.
|
|
||||||
|
|
||||||
### Formulários e conversão
|
|
||||||
|
|
||||||
Garanta, quando aplicável:
|
|
||||||
|
|
||||||
* campos e obrigatoriedade claros;
|
|
||||||
* validação adequada;
|
|
||||||
* mensagens de erro específicas;
|
|
||||||
* estado de envio;
|
|
||||||
* prevenção de envio duplicado;
|
|
||||||
* estados de sucesso e falha;
|
|
||||||
* tratamento de erro de rede;
|
|
||||||
* preservação dos dados após erros recuperáveis;
|
|
||||||
* proteção antispam simples;
|
|
||||||
* privacidade e LGPD;
|
|
||||||
* links e mensagens de WhatsApp corretos;
|
|
||||||
* ausência de segredos expostos no cliente.
|
|
||||||
|
|
||||||
Quando algum dado real não estiver disponível, use configuração ou variável de ambiente e documente a pendência.
|
|
||||||
|
|
||||||
### SEO
|
|
||||||
|
|
||||||
Corrija, quando aplicável:
|
|
||||||
|
|
||||||
* títulos exclusivos por rota;
|
|
||||||
* meta descriptions;
|
|
||||||
* canonical;
|
|
||||||
* Open Graph;
|
|
||||||
* Twitter cards;
|
|
||||||
* sitemap;
|
|
||||||
* robots;
|
|
||||||
* favicon;
|
|
||||||
* idioma;
|
|
||||||
* headings;
|
|
||||||
* URLs;
|
|
||||||
* links internos;
|
|
||||||
* página 404;
|
|
||||||
* metadados sociais;
|
|
||||||
* indexação distinta entre preview e produção.
|
|
||||||
|
|
||||||
Não crie dados estruturados com informações não confirmadas.
|
|
||||||
|
|
||||||
### Performance
|
|
||||||
|
|
||||||
Corrija problemas relevantes relacionados a:
|
|
||||||
|
|
||||||
* imagens;
|
|
||||||
* tamanhos responsivos;
|
|
||||||
* formatos modernos;
|
|
||||||
* lazy loading;
|
|
||||||
* LCP;
|
|
||||||
* CLS;
|
|
||||||
* INP;
|
|
||||||
* fontes;
|
|
||||||
* scripts desnecessários;
|
|
||||||
* hidratação excessiva;
|
|
||||||
* JavaScript evitável;
|
|
||||||
* componentes client-side sem necessidade;
|
|
||||||
* animações custosas;
|
|
||||||
* dependências pesadas;
|
|
||||||
* carregamento de conteúdo abaixo da dobra.
|
|
||||||
|
|
||||||
Preserve a qualidade visual das fotografias.
|
|
||||||
|
|
||||||
### Qualidade técnica
|
|
||||||
|
|
||||||
Corrija:
|
|
||||||
|
|
||||||
* erros de TypeScript;
|
|
||||||
* erros de lint;
|
|
||||||
* erros de build;
|
|
||||||
* warnings de hidratação;
|
|
||||||
* erros de console;
|
|
||||||
* requisições quebradas;
|
|
||||||
* links inválidos;
|
|
||||||
* rotas órfãs;
|
|
||||||
* componentes duplicados quando a consolidação simplificar o projeto;
|
|
||||||
* tratamento de erros ausente;
|
|
||||||
* tipagem insegura;
|
|
||||||
* complexidade desnecessária diretamente relacionada ao escopo.
|
|
||||||
|
|
||||||
## Prioridades
|
|
||||||
|
|
||||||
Considere:
|
|
||||||
|
|
||||||
* **P0:** bloqueia funcionamento, segurança, uso ou lançamento;
|
|
||||||
* **P1:** prejudica significativamente experiência, conversão, acessibilidade, SEO, performance ou credibilidade;
|
|
||||||
* **P2:** refinamento não essencial para o lançamento.
|
|
||||||
|
|
||||||
Implemente todos os itens P0 e P1 que possam ser resolvidos com as informações existentes.
|
|
||||||
|
|
||||||
Itens dependentes de dados reais da cliente devem permanecer como pendências explícitas, sem conteúdo fictício.
|
|
||||||
|
|
||||||
## Restrições
|
|
||||||
|
|
||||||
* Preserve a stack e a arquitetura existentes quando forem adequadas.
|
|
||||||
* Prefira mudanças simples, localizadas e fáceis de manter.
|
|
||||||
* Não reescreva o projeto sem necessidade concreta.
|
|
||||||
* Não adicione bibliotecas quando a solução atual for suficiente.
|
|
||||||
* Não implemente pagamentos, CRM, área do cliente, chat, contratos avançados ou funcionalidades fora do MVP.
|
|
||||||
* Não altere arquivos não relacionados sem justificativa.
|
|
||||||
* Preserve alterações locais preexistentes.
|
|
||||||
* Não silencie problemas com `any`, `eslint-disable`, casts inseguros ou desativação de validações.
|
|
||||||
* Não reduza acessibilidade para preservar estética.
|
|
||||||
* Não deixe mocks ou soluções temporárias como implementação final.
|
|
||||||
|
|
||||||
## Critérios de conclusão
|
|
||||||
|
|
||||||
A tarefa estará concluída quando:
|
|
||||||
|
|
||||||
* todas as rotas públicas existentes tiverem sido auditadas;
|
|
||||||
* todos os problemas P0 e P1 solucionáveis tiverem sido corrigidos;
|
|
||||||
* o site funcionar corretamente em mobile e desktop;
|
|
||||||
* não houver overflow horizontal, sobreposição ou componentes quebrados;
|
|
||||||
* menu, navegação, links, CTAs e formulários funcionarem;
|
|
||||||
* o posicionamento social e corporativo estiver claro;
|
|
||||||
* a acessibilidade essencial estiver atendida;
|
|
||||||
* os metadados e fundamentos de SEO estiverem corretos;
|
|
||||||
* os principais problemas de performance tiverem sido tratados;
|
|
||||||
* erros causados ou revelados pelas alterações tiverem sido resolvidos;
|
|
||||||
* lint, TypeScript, testes e build disponíveis tiverem sido executados com sucesso.
|
|
||||||
|
|
||||||
Não declare uma validação como aprovada sem executá-la.
|
|
||||||
|
|
||||||
## Entrega final
|
|
||||||
|
|
||||||
Ao concluir, apresente:
|
|
||||||
|
|
||||||
1. rotas auditadas;
|
|
||||||
2. problemas principais encontrados;
|
|
||||||
3. alterações implementadas;
|
|
||||||
4. arquivos modificados;
|
|
||||||
5. comandos executados e seus resultados;
|
|
||||||
6. decisões e trade-offs relevantes;
|
|
||||||
7. informações que dependem da cliente;
|
|
||||||
8. itens P2 mantidos fora do escopo.
|
|
||||||
@@ -15,17 +15,15 @@ Fases 0–1 e provedores de produção (Resend + R2) estão em `main` com CI ver
|
|||||||
|
|
||||||
- Staging em Dokploy com a mesma imagem FrankenPHP por SHA para web, queue e scheduler.
|
- Staging em Dokploy com a mesma imagem FrankenPHP por SHA para web, queue e scheduler.
|
||||||
- Deploy automático em `main` após CI: build → GHCR → Dokploy API → migrate → health → smoke.
|
- Deploy automático em `main` após CI: build → GHCR → Dokploy API → migrate → health → smoke.
|
||||||
- Produção via promoção manual da mesma digest SHA (alias `:production`), sem rebuild.
|
- Compose local com app FrankenPHP + Postgres.
|
||||||
- Backup PostgreSQL diário (retenção ≥14 dias), restore documentado e rollback por SHA.
|
- PHP 8.4 canônico em docs/Docker/CI.
|
||||||
- Compose local com app FrankenPHP + Postgres (pendente fora da fatia deploy).
|
- Gates com `npm audit` e cobertura Domain/Application ≥ 80%.
|
||||||
- PHP 8.4 canônico em docs/Docker/CI (pendente).
|
- E-mail verificado + reset de senha seguros no painel (ADM-01 / SPEC §12.1).
|
||||||
- Gates com `npm audit` e cobertura Domain/Application ≥ 80% (pendente).
|
- Strict types em PHP próprio faltante.
|
||||||
- E-mail verificado + reset de senha seguros no painel (ADM-01 / SPEC §12.1) (pendente).
|
|
||||||
- Strict types em PHP próprio faltante (pendente).
|
|
||||||
|
|
||||||
**Non-Goals:**
|
**Non-Goals:**
|
||||||
|
|
||||||
- Deploy automático em produção; provisionamento de VPS para clientes.
|
- Produção, promoção humana, provisionamento de VPS para clientes.
|
||||||
- Fase 2 (briefing/CRM/E2E-01/02).
|
- Fase 2 (briefing/CRM/E2E-01/02).
|
||||||
- Redis, worker mode, CDN automation, signed private media.
|
- Redis, worker mode, CDN automation, signed private media.
|
||||||
|
|
||||||
@@ -33,13 +31,11 @@ Fases 0–1 e provedores de produção (Resend + R2) estão em `main` com CI ver
|
|||||||
|
|
||||||
### D1 — Dokploy Compose na VPS, imagem imutável no GHCR
|
### D1 — Dokploy Compose na VPS, imagem imutável no GHCR
|
||||||
|
|
||||||
GitHub Actions (após CI verde em `main`) constrói **uma** imagem `ghcr.io/<owner>/<repo>:<git-sha>` (+ alias `:staging`), faz push privado e chama `POST /api/compose.deploy` no Dokploy com `x-api-key`.
|
GitHub Actions (após CI verde em `main`) constrói **uma** imagem `ghcr.io/<owner>/<repo>:<git-sha>` (+ alias `:staging`), faz push privado e chama `POST /api/compose.deploy` (ou `compose.update` + `compose.deploy`) no Dokploy com `x-api-key`.
|
||||||
|
|
||||||
Compose compartilhado (`docker-compose.deploy.yml`) referencia `${APP_IMAGE}` / `IMAGE_TAG` para `web`, `queue`, `scheduler` e job `migrate` one-shot (`php artisan migrate --force`). Dokploy mantém **duas** stacks Compose (staging e production) com `IMAGE_TAG` distinto. PostgreSQL é serviço Dokploy separado por ambiente (não na imagem da app).
|
Compose de staging referencia `${APP_IMAGE}` / `IMAGE_TAG` para `web`, `queue`, `scheduler` e job `migrate` one-shot (`php artisan migrate --force`). PostgreSQL é serviço Dokploy separado (não na imagem da app).
|
||||||
|
|
||||||
Produção: `workflow_dispatch` com SHA + confirmação `PRODUCTION` move alias `:production` para o **mesmo digest** já publicado e dispara `compose.deploy` na stack de produção. Repo privado no GitHub Free não tem required reviewers de Environment; aprovação humana = disparo manual explícito (GitHub Pro opcional depois).
|
*Alternativas rejeitadas:* Railway (Pro para GHCR privado + desvio de plataforma); rebuild por serviço no Dokploy (quebra “mesma imagem” SPEC §14.3/§15.2); tag só `:latest` (rollback frágil).
|
||||||
|
|
||||||
*Alternativas rejeitadas:* Railway (Pro para GHCR privado + desvio de plataforma); rebuild por serviço no Dokploy (quebra “mesma imagem” SPEC §14.3/§15.2); tag só `:latest` (rollback frágil); deploy automático direto em produção.
|
|
||||||
|
|
||||||
### D2 — Processos e health
|
### D2 — Processos e health
|
||||||
|
|
||||||
@@ -54,11 +50,7 @@ Healthcheck Docker/Dokploy e smoke pós-deploy usam `GET /up` (sem auth, sem sec
|
|||||||
|
|
||||||
### D3 — Secrets e providers
|
### D3 — Secrets e providers
|
||||||
|
|
||||||
GitHub Actions guarda só orquestração: `DOKPLOY_URL`, API key, compose IDs, URLs públicas de smoke. Env Laravel (`APP_KEY`, DB, Resend, R2) vive **somente** no Dokploy, isolado por ambiente. Nunca embeds em layer. Local continua `MAIL_MAILER=log` e disco `public`.
|
Secrets em GitHub Actions + env Dokploy: `APP_KEY`, DB, `MAIL_MAILER=resend` / `RESEND_API_KEY`, `FILESYSTEM_DISK=r2` / `R2_*`, tokens Dokploy/GHCR. Nunca embeds em layer. Local continua `MAIL_MAILER=log` e disco `public`.
|
||||||
|
|
||||||
### D3b — Backup e restore
|
|
||||||
|
|
||||||
PostgreSQL staging/produção: backup diário via Dokploy → destino S3-compatible, retenção mínima 14 dias (SPEC §16.3). Restore documentado e testado em staging antes da primeira promoção a produção.
|
|
||||||
|
|
||||||
### D4 — Compose local com app
|
### D4 — Compose local com app
|
||||||
|
|
||||||
@@ -82,32 +74,26 @@ Alinhar README, CI (`setup-php` 8.4) e `Dockerfile` `ARG PHP_VERSION=8.4`. Mante
|
|||||||
|
|
||||||
### D8 — Rollback
|
### D8 — Rollback
|
||||||
|
|
||||||
Rollback = mover alias do ambiente (`:staging` ou `:production`) para tag SHA anterior no GHCR e `compose.deploy`. Falha de healthcheck/smoke impede promoção. Sem rebuild.
|
Rollback = apontar Compose staging para tag SHA anterior no GHCR e `compose.deploy`/`redeploy`. Falha de healthcheck impede promoção. Sem rebuild.
|
||||||
|
|
||||||
### D9 — Trusted proxies atrás do Traefik
|
|
||||||
|
|
||||||
`bootstrap/app.php` confia em proxies (`trustProxies(at: '*')`) para honrar `X-Forwarded-*` do Traefik/Dokploy. Staging/produção usam `SESSION_SECURE_COOKIE=true` com HTTPS.
|
|
||||||
|
|
||||||
## Risks / Trade-offs
|
## Risks / Trade-offs
|
||||||
|
|
||||||
- **[GHCR privado + pull na VPS]** → configurar registry no Dokploy com PAT `read:packages`; documentar checklist.
|
- **[GHCR privado + pull na VPS]** → configurar registry no Dokploy com PAT `read:packages`; documentar checklist.
|
||||||
- **[Migrate one-shot falha]** → web/queue/scheduler dependem de migrate exit 0; manter migrations backward-compatible.
|
- **[Migrate one-shot falha]** → deploy não promove web; manter migrations backward-compatible.
|
||||||
- **[GitHub Free sem required reviewers]** → promoção humana via `workflow_dispatch` + input `PRODUCTION`; Pro opcional.
|
- **[Cobertura 80% com Domain quase vazio]** → medir só namespaces existentes; baseline sobe conforme Fase 2 adiciona Domain.
|
||||||
- **[Cobertura 80% com Domain quase vazio]** → medir só namespaces existentes; baseline sobe conforme Fase 2 adiciona Domain (pendente).
|
- **[npm audit ruido]** → `--omit=dev` + allowlist documentada se necessário; sem silenciar sem justificativa.
|
||||||
- **[npm audit ruido]** → `--omit=dev` + allowlist documentada se necessário (pendente).
|
- **[E-mail verification em staging]** → seed/users de staging com `email_verified_at`; Resend para reset real quando configurado.
|
||||||
- **[E-mail verification em staging]** → seed/users de staging com `email_verified_at`; Resend para reset real quando configurado (pendente).
|
- **[Compose local rebuild lento]** → documentar serve opcional; CI permanece fonte FrankenPHP.
|
||||||
- **[Compose local rebuild lento]** → documentar serve opcional; CI permanece fonte FrankenPHP (pendente).
|
|
||||||
|
|
||||||
## Migration Plan
|
## Migration Plan
|
||||||
|
|
||||||
1. Fatia deploy: Compose deploy, workflows, smoke, trusted proxies, docs Dokploy/backup/rollback; CI verde.
|
1. Implementar auth/coverage/npm/strict_types/docs/Compose local; CI verde.
|
||||||
2. Criar projeto Dokploy + Postgres (staging + produção) + Compose apps; registrar GHCR.
|
2. Criar projeto Dokploy + Postgres + Compose app; registrar GHCR.
|
||||||
3. Primeiro push de imagem SHA → staging; smoke `/up` + home + login; testar rollback e backup/restore.
|
3. Adicionar workflow deploy; primeiro push de imagem SHA; smoke `/up` + home + login.
|
||||||
4. Promoção manual para produção após domínio/TLS/`APP_URL` confirmados.
|
4. Atualizar SPEC §18 Fase 0 apenas com itens comprovados; evidência no PR.
|
||||||
5. Fatias restantes da change (auth/coverage/npm/Compose local/PHP docs) em PRs seguintes.
|
5. Rollback: redeploy tag SHA anterior.
|
||||||
6. Atualizar SPEC §18 Fase 0 apenas com itens comprovados; evidência no PR.
|
|
||||||
|
|
||||||
## Open Questions
|
## Open Questions
|
||||||
|
|
||||||
- Domínio público exato do staging/produção (DNS) — preencher na implementação com valor do operador.
|
- Domínio público exato do staging (DNS) — preencher na implementação com valor do operador.
|
||||||
- Se Dokploy Compose API exigir `compose.update` env para `IMAGE_TAG` a cada deploy: preferir aliases `:staging`/`:production` estáveis no Compose Dokploy para evitar rewrite de env.
|
- Se Dokploy Compose API exigir `compose.saveEnvironment` para `IMAGE_TAG` a cada deploy: confirmar payload na primeira fatia de integração.
|
||||||
|
|||||||
@@ -6,20 +6,17 @@ Fases 0 e 1 estão implementadas e mescladas, mas o critério de saída da Fase
|
|||||||
|
|
||||||
- Implantar staging na VPS própria via Dokploy (Docker Compose): imagem única por SHA no GHCR, serviços `web`/`queue`/`scheduler`/migrate, PostgreSQL gerenciado, healthcheck `/up`, smoke pós-deploy e rollback por tag SHA anterior.
|
- Implantar staging na VPS própria via Dokploy (Docker Compose): imagem única por SHA no GHCR, serviços `web`/`queue`/`scheduler`/migrate, PostgreSQL gerenciado, healthcheck `/up`, smoke pós-deploy e rollback por tag SHA anterior.
|
||||||
- Adicionar workflow GitHub Actions de deploy em `main` após CI verde (build → push GHCR → acionar API Dokploy).
|
- Adicionar workflow GitHub Actions de deploy em `main` após CI verde (build → push GHCR → acionar API Dokploy).
|
||||||
- Adicionar promoção manual de produção: mesma digest SHA já publicada, alias `:production`, sem rebuild (`workflow_dispatch` + confirmação explícita).
|
- Estender `docker-compose.yml` local com serviço de aplicação FrankenPHP (além do PostgreSQL).
|
||||||
- Documentar backup PostgreSQL diário (retenção ≥14d), restore e runbook operacional Dokploy/GHCR.
|
- Fixar PHP **8.4** como versão canônica em Docker, CI e documentação.
|
||||||
- Estender `docker-compose.yml` local com serviço de aplicação FrankenPHP (além do PostgreSQL) — **ainda pendente** nesta fatia de deploy.
|
- Incluir `npm audit` e cobertura mínima de 80% para `Domain` e `Application` nos gates de qualidade (SPEC §12.6, §13.7, §13.9).
|
||||||
- Fixar PHP **8.4** como versão canônica em Docker, CI e documentação — **ainda pendente**.
|
- Exigir e-mail verificado no painel Filament e entregar reset de senha seguro (SPEC §12.1; ADM-01).
|
||||||
- Incluir `npm audit` e cobertura mínima de 80% para `Domain` e `Application` nos gates de qualidade (SPEC §12.6, §13.7, §13.9) — **ainda pendente**.
|
- Corrigir `declare(strict_types=1);` em PHP próprio que ainda falte e cobrir regressões.
|
||||||
- Exigir e-mail verificado no painel Filament e entregar reset de senha seguro (SPEC §12.1; ADM-01) — **ainda pendente**.
|
|
||||||
- Corrigir `declare(strict_types=1);` em PHP próprio que ainda falte e cobrir regressões — **ainda pendente**.
|
|
||||||
|
|
||||||
## Non-Goals
|
## Non-Goals
|
||||||
|
|
||||||
Conforme [SPEC.md §4.2](../../SPEC.md):
|
Conforme [SPEC.md §4.2](../../SPEC.md):
|
||||||
|
|
||||||
- Deploy automático direto em produção (produção exige promoção humana da mesma imagem).
|
- Produção com promoção humana, portal do cliente, multi-tenancy, Redis, FrankenPHP worker mode.
|
||||||
- Portal do cliente, multi-tenancy, Redis, FrankenPHP worker mode.
|
|
||||||
- Fase 2 (WEB-05 briefing, CRM, E2E-01/E2E-02) — change futura `build-leads-crm` após esta fechar.
|
- Fase 2 (WEB-05 briefing, CRM, E2E-01/E2E-02) — change futura `build-leads-crm` após esta fechar.
|
||||||
- Provisionamento genérico de VPS/Dokploy para clientes finais.
|
- Provisionamento genérico de VPS/Dokploy para clientes finais.
|
||||||
- Templates de e-mail de lead, auditoria completa (ADM-02), documentos privados.
|
- Templates de e-mail de lead, auditoria completa (ADM-02), documentos privados.
|
||||||
@@ -28,20 +25,19 @@ Conforme [SPEC.md §4.2](../../SPEC.md):
|
|||||||
|
|
||||||
### New Capabilities
|
### New Capabilities
|
||||||
|
|
||||||
- `staging-deployment`: deploy automático de staging na VPS via Dokploy com imagem imutável por SHA, processos web/queue/scheduler, migração, healthcheck, smoke e rollback; promoção manual da mesma digest para produção (SPEC §14.3, §15.2, §16.3, §18 Fase 0).
|
- `staging-deployment`: deploy automático de staging na VPS via Dokploy com imagem imutável por SHA, processos web/queue/scheduler, migração, healthcheck, smoke e rollback (SPEC §14.3, §15.2, §18 Fase 0).
|
||||||
|
|
||||||
### Modified Capabilities
|
### Modified Capabilities
|
||||||
|
|
||||||
- `container-runtime`: Compose local com app FrankenPHP; PHP 8.4 canônico; alinhamento da mesma imagem a processos de staging/produção.
|
- `container-runtime`: Compose local com app FrankenPHP; PHP 8.4 canônico; alinhamento da mesma imagem a processos de staging.
|
||||||
- `quality-gates`: `npm audit` no gate; cobertura mínima 80% para Domain/Application; job de deploy staging após CI.
|
- `quality-gates`: `npm audit` no gate; cobertura mínima 80% para Domain/Application; job de deploy staging após CI.
|
||||||
- `health-check`: healthcheck e smoke pós-deploy de staging usam `/up` sem autenticação.
|
- `health-check`: healthcheck e smoke pós-deploy de staging usam `/up` sem autenticação.
|
||||||
- `internal-authentication`: e-mail verificado obrigatório para acesso ao painel; reset de senha seguro disponível (SPEC §12.1, ADM-01).
|
- `internal-authentication`: e-mail verificado obrigatório para acesso ao painel; reset de senha seguro disponível (SPEC §12.1, ADM-01).
|
||||||
|
|
||||||
## Impact
|
## Impact
|
||||||
|
|
||||||
- **Cria (fatia deploy)**: `docker-compose.deploy.yml`, workflows `deploy-staging.yml` / `promote-production.yml`, scripts smoke/Dokploy, docs operacionais Dokploy/GHCR/backup/rollback.
|
- **Cria**: `docker-compose` de staging (ou extensão), workflow `.github/workflows/deploy-staging.yml`, docs operacionais de Dokploy/GHCR, testes de auth verification/reset e cobertura.
|
||||||
- **Altera (fatia deploy)**: `bootstrap/app.php` (trusted proxies), `.env.example`, README.
|
- **Altera**: `docker-compose.yml`, `Dockerfile`/docs PHP, `composer.json`/`package.json` scripts, `.github/workflows/ci.yml`, `User`/`AdminPanelProvider`, README, `.env.example`.
|
||||||
- **Ainda pendente nesta change**: Compose local FrankenPHP, PHP 8.4 docs, npm audit/coverage, auth verification/reset, strict_types.
|
- **Infra (manual)**: projeto Dokploy na VPS, registry GHCR, secrets (`DOKPLOY_*`, `GHCR_*`, DB, `APP_KEY`, Resend/R2).
|
||||||
- **Infra (manual)**: projeto Dokploy na VPS (duas stacks), registry GHCR, secrets (`DOKPLOY_*`, DB, `APP_KEY`, Resend/R2), backup S3.
|
|
||||||
- **Depende de**: specs já arquivadas (`container-runtime`, `quality-gates`, `health-check`, `internal-authentication`, `transactional-email`, `object-storage`).
|
- **Depende de**: specs já arquivadas (`container-runtime`, `quality-gates`, `health-check`, `internal-authentication`, `transactional-email`, `object-storage`).
|
||||||
- **Risco**: secrets e registry privados; mitigações: tokens com escopo mínimo, imagem por SHA, healthcheck antes de promover, rollback por tag anterior.
|
- **Risco**: secrets e registry privados; mitigações: tokens com escopo mínimo, imagem por SHA, healthcheck antes de promover, rollback por tag anterior.
|
||||||
|
|||||||
@@ -55,23 +55,3 @@ Rollback SHALL redeploy a previously published SHA-tagged image without rebuildi
|
|||||||
- **WHEN** the operator points staging Compose at a previous SHA tag and redeploys
|
- **WHEN** the operator points staging Compose at a previous SHA tag and redeploys
|
||||||
- **THEN** web, queue, and scheduler MUST run that previous image
|
- **THEN** web, queue, and scheduler MUST run that previous image
|
||||||
- **AND** no source rebuild MUST be required
|
- **AND** no source rebuild MUST be required
|
||||||
|
|
||||||
### Requirement: Production promotion reuses the same immutable digest
|
|
||||||
|
|
||||||
Production SHALL be promoted from an already-published SHA-tagged image without rebuilding from source. Promotion MUST require explicit human action (SPEC §14.3).
|
|
||||||
|
|
||||||
#### Scenario: Operator promotes a staging-approved SHA to production
|
|
||||||
|
|
||||||
- **WHEN** the operator confirms promotion of commit SHA `abc123`
|
|
||||||
- **THEN** production web, queue, and scheduler MUST run the same digest previously published as `ghcr.io/<owner>/<repo>:abc123`
|
|
||||||
- **AND** MUST NOT rebuild from source for that promotion
|
|
||||||
|
|
||||||
### Requirement: Database backups exist before production cutover
|
|
||||||
|
|
||||||
Staging and production PostgreSQL services SHALL have automated daily backups with retention of at least 14 days, and a documented restore procedure MUST be verified on staging before the first production promotion (SPEC §16.3).
|
|
||||||
|
|
||||||
#### Scenario: Staging restore is proven before production promotion
|
|
||||||
|
|
||||||
- **WHEN** the operator prepares the first production promotion
|
|
||||||
- **THEN** a restore from a staging backup MUST have been documented and successfully tested
|
|
||||||
- **AND** production MUST have daily backup configured with retention of at least 14 days
|
|
||||||
|
|||||||
@@ -19,25 +19,23 @@
|
|||||||
- [ ] 3.3 Add/adjust unit tests if current Domain/Application coverage is below threshold
|
- [ ] 3.3 Add/adjust unit tests if current Domain/Application coverage is below threshold
|
||||||
- [ ] 3.4 Verify CI `static` and `unit` fail appropriately on intentional audit/coverage breakage in a branch experiment or equivalent proof
|
- [ ] 3.4 Verify CI `static` and `unit` fail appropriately on intentional audit/coverage breakage in a branch experiment or equivalent proof
|
||||||
|
|
||||||
## 4. Staging/production Compose and Dokploy prep
|
## 4. Staging Compose and Dokploy prep
|
||||||
|
|
||||||
- [x] 4.1 Add versioned Compose template (`docker-compose.deploy.yml`: web, queue, scheduler, migrate one-shot) parameterized by `APP_IMAGE`/`IMAGE_TAG` for staging and production stacks
|
- [ ] 4.1 Add versioned staging Compose template (web, queue, scheduler, migrate one-shot) parameterized by `APP_IMAGE`/`IMAGE_TAG`
|
||||||
- [x] 4.2 Document Dokploy project setup: GHCR registry credentials, Postgres per environment, Compose import, required env vars (APP_KEY, DB, Resend, R2), trusted proxies/session cookies
|
- [ ] 4.2 Document Dokploy project setup: GHCR registry credentials, Postgres service, Compose import, required env vars (APP_KEY, DB, Resend, R2)
|
||||||
- [x] 4.3 Document rollback procedure: move environment alias to previous SHA and redeploy without rebuild
|
- [ ] 4.3 Document rollback procedure: redeploy previous SHA tag without rebuild
|
||||||
- [x] 4.4 Document PostgreSQL daily backup (≥14d retention), restore procedure, and test restore on staging before first production promotion
|
|
||||||
|
|
||||||
## 5. Deploy workflow and smoke
|
## 5. Deploy workflow and smoke
|
||||||
|
|
||||||
- [x] 5.1 Create `.github/workflows/deploy-staging.yml` gated on successful CI on `main`: build image, push `ghcr.io/...:<sha>` + `:staging`, trigger Dokploy `compose.deploy`
|
- [ ] 5.1 Create `.github/workflows/deploy-staging.yml` gated on successful CI on `main`: build image, push `ghcr.io/...:<sha>` + `:staging`, trigger Dokploy `compose.deploy`
|
||||||
- [x] 5.2 Create `.github/workflows/promote-production.yml` (`workflow_dispatch` + confirmation): retag same digest as `:production`, deploy production stack, smoke
|
- [ ] 5.2 Wire migrate-before-serve (Compose migrate service or Dokploy deploy command) and healthcheck on `/up`
|
||||||
- [x] 5.3 Wire migrate-before-serve (Compose migrate service) and healthcheck on `/up`
|
- [ ] 5.3 Add post-deploy smoke script/job for `/up`, `/`, `/admin/login` returning 200
|
||||||
- [x] 5.4 Add post-deploy smoke script/job for `/up`, `/`, `/admin/login` returning 200
|
- [ ] 5.4 Store secrets only in GitHub/Dokploy; ensure no secrets in image layers (reuse container CI check)
|
||||||
- [x] 5.5 Store orchestration secrets only in GitHub; Laravel/DB/R2/Resend only in Dokploy; ensure no secrets in image layers
|
|
||||||
|
|
||||||
## 6. Phase 0 exit evidence
|
## 6. Phase 0 exit evidence
|
||||||
|
|
||||||
- [ ] 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
|
||||||
- [ ] 6.3 Update `SPEC.md` §18 Fase 0 checkboxes only for items with evidence; note remaining deferred items if any
|
- [ ] 6.3 Update `SPEC.md` §18 Fase 0 checkboxes only for items with evidence; note remaining deferred items if any
|
||||||
- [ ] 6.4 Run full `composer quality` and confirm all five CI jobs + staging deploy path green
|
- [ ] 6.4 Run full `composer quality` and confirm all five CI jobs + staging deploy path green
|
||||||
- [ ] 6.5 Report in SPEC §24 format; archive this change only after remaining parity tasks (1–3) also complete
|
- [ ] 6.5 Report in SPEC §24 format and mark this change ready to archive after merge
|
||||||
|
|||||||
@@ -2,7 +2,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/Fortaleza, BRL.
|
||||||
Stack: Laravel 13, Filament 5 (/admin), Livewire 4 + Blade + Alpine + Tailwind (site público),
|
Stack: Laravel 13, Filament 5 (/admin), Livewire 4 + Blade + Alpine + Tailwind (site público),
|
||||||
PostgreSQL, FrankenPHP regular mode (sem worker mode), Vite, Pest 4 + Pest Browser, database queue.
|
PostgreSQL, FrankenPHP regular mode (sem worker mode), Vite, Pest 4 + Pest Browser, database queue.
|
||||||
Arquitetura: monólito modular. Interface -> Application (Actions/Queries) -> Domain (Enums/VOs) -> Infrastructure.
|
Arquitetura: monólito modular. Interface -> Application (Actions/Queries) -> Domain (Enums/VOs) -> Infrastructure.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# container-runtime Specification
|
# container-runtime Specification
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
Define the production container image for the application: a reproducible multi-stage FrankenPHP build serving the public directory, run in regular mode as a non-root user, shared by web/queue/scheduler processes, with no secrets in layers and a healthcheck on `/up`.
|
TBD - created by archiving change setup-foundation. Update Purpose after archive.
|
||||||
## Requirements
|
## Requirements
|
||||||
### Requirement: Production image uses multi-stage FrankenPHP build
|
### Requirement: Production image uses multi-stage FrankenPHP build
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# design-tokens Specification
|
# design-tokens Specification
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
Centralize the public site's design tokens so the interface implements the Heritage Editorial system from DESIGN.md (EB Garamond, 8px rhythm, zero radius, 1120px container, paper/olive/sage/ink palette), honoring reduced motion and WCAG AA contrast without card-shadow hierarchy.
|
TBD - created by archiving change setup-foundation. Update Purpose after archive.
|
||||||
## Requirements
|
## Requirements
|
||||||
### Requirement: Design tokens are centralized for the public site
|
### Requirement: Design tokens are centralized for the public site
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# health-check Specification
|
# health-check Specification
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
Expose a public `GET /up` healthcheck that responds quickly and without authentication, exposes no secrets, and fails when the application cannot boot, so orchestrators and CI can verify availability.
|
TBD - created by archiving change setup-foundation. Update Purpose after archive.
|
||||||
## Requirements
|
## Requirements
|
||||||
### Requirement: Public health endpoint responds without authentication
|
### Requirement: Public health endpoint responds without authentication
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# internal-authentication Specification
|
# internal-authentication Specification
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
Provide session-based authentication for the internal Filament panel at `/admin`, limited to two roles (admin and assistant), with unique emails, password reset without enumeration, inactive users denied, and user management restricted to admins.
|
TBD - created by archiving change setup-foundation. Update Purpose after archive.
|
||||||
## Requirements
|
## Requirements
|
||||||
### Requirement: Internal users authenticate via Filament panel
|
### Requirement: Internal users authenticate via Filament panel
|
||||||
|
|
||||||
|
|||||||
17
package-lock.json
generated
@@ -7,7 +7,6 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.0.0",
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
"concurrently": "^9.0.1",
|
"concurrently": "^9.0.1",
|
||||||
"husky": "^9.1.7",
|
|
||||||
"laravel-vite-plugin": "^3.1",
|
"laravel-vite-plugin": "^3.1",
|
||||||
"playwright": "^1.62.0",
|
"playwright": "^1.62.0",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
@@ -891,22 +890,6 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/husky": {
|
|
||||||
"version": "9.1.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz",
|
|
||||||
"integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"bin": {
|
|
||||||
"husky": "bin.js"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/typicode"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/is-fullwidth-code-point": {
|
"node_modules/is-fullwidth-code-point": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||||
|
|||||||
@@ -4,13 +4,11 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"dev": "vite",
|
"dev": "vite"
|
||||||
"prepare": "husky"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.0.0",
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
"concurrently": "^9.0.1",
|
"concurrently": "^9.0.1",
|
||||||
"husky": "^9.1.7",
|
|
||||||
"laravel-vite-plugin": "^3.1",
|
"laravel-vite-plugin": "^3.1",
|
||||||
"playwright": "^1.62.0",
|
"playwright": "^1.62.0",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 0 B |
@@ -1,4 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
|
||||||
<rect width="64" height="64" fill="#556B2F"/>
|
|
||||||
<text x="32" y="45" font-family="Georgia, 'Times New Roman', serif" font-size="38" font-weight="600" fill="#FBF9F4" text-anchor="middle">A</text>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 264 B |
@@ -18,13 +18,6 @@
|
|||||||
--text-3xl: var(--amare-text-3xl);
|
--text-3xl: var(--amare-text-3xl);
|
||||||
--text-4xl: var(--amare-text-4xl);
|
--text-4xl: var(--amare-text-4xl);
|
||||||
|
|
||||||
--text-display: clamp(3rem, 7.7vw, 5.875rem);
|
|
||||||
--text-display--line-height: 0.94;
|
|
||||||
--text-display--letter-spacing: -0.01em;
|
|
||||||
|
|
||||||
--text-headline: clamp(2.375rem, 5.2vw, 4rem);
|
|
||||||
--text-headline--line-height: 1.02;
|
|
||||||
|
|
||||||
--spacing-1: var(--amare-space-1);
|
--spacing-1: var(--amare-space-1);
|
||||||
--spacing-2: var(--amare-space-2);
|
--spacing-2: var(--amare-space-2);
|
||||||
--spacing-3: var(--amare-space-3);
|
--spacing-3: var(--amare-space-3);
|
||||||
@@ -66,11 +59,6 @@
|
|||||||
background-color: var(--amare-color-bg);
|
background-color: var(--amare-color-bg);
|
||||||
color: var(--amare-color-text);
|
color: var(--amare-color-text);
|
||||||
font-family: var(--amare-font-serif);
|
font-family: var(--amare-font-serif);
|
||||||
overflow-wrap: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
[id$='-heading'] {
|
|
||||||
scroll-margin-top: 5.5rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
a:focus-visible,
|
a:focus-visible,
|
||||||
@@ -96,14 +84,6 @@
|
|||||||
filter: saturate(0.88) contrast(0.96);
|
filter: saturate(0.88) contrast(0.96);
|
||||||
}
|
}
|
||||||
|
|
||||||
@utility honeypot {
|
|
||||||
position: absolute;
|
|
||||||
left: -9999px;
|
|
||||||
width: 1px;
|
|
||||||
height: 1px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
@layer components {
|
@layer components {
|
||||||
.main-nav.is-open {
|
.main-nav.is-open {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -118,141 +98,4 @@
|
|||||||
body.menu-open {
|
body.menu-open {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dossiê vivo — content visible by default; enhance only when opted in */
|
|
||||||
[data-chapter-index] {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-chapter-progress] {
|
|
||||||
display: none;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-chapter-index] a[aria-current="true"] {
|
|
||||||
color: var(--amare-color-accent-deep);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-chapter-index] a[aria-current="true"]::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
top: 0.35em;
|
|
||||||
bottom: 0.35em;
|
|
||||||
width: 1px;
|
|
||||||
background: var(--amare-color-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-chapter-progress] > span {
|
|
||||||
display: block;
|
|
||||||
height: 100%;
|
|
||||||
width: var(--chapter-progress, 0%);
|
|
||||||
background: var(--amare-color-accent);
|
|
||||||
transform-origin: left center;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 1280px) {
|
|
||||||
[data-chapter-index] {
|
|
||||||
display: flex;
|
|
||||||
position: fixed;
|
|
||||||
top: 50%;
|
|
||||||
right: max(1rem, calc((100vw - var(--amare-container-max)) / 2 - 7.5rem));
|
|
||||||
z-index: 30;
|
|
||||||
max-width: 6.5rem;
|
|
||||||
translate: 0 -50%;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-chapter-index] a {
|
|
||||||
position: relative;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
min-height: 2.75rem;
|
|
||||||
padding-left: 0.75rem;
|
|
||||||
font-size: var(--amare-text-xs);
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: 0.12em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--amare-color-muted);
|
|
||||||
text-decoration: none;
|
|
||||||
overflow-wrap: break-word;
|
|
||||||
transition: color var(--amare-duration-fast) var(--amare-ease-standard);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-chapter-index] a:hover {
|
|
||||||
color: var(--amare-color-accent);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1279px) {
|
|
||||||
[data-chapter-progress] {
|
|
||||||
display: block;
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
z-index: 45;
|
|
||||||
width: 100%;
|
|
||||||
height: 2px;
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: no-preference) {
|
|
||||||
/* 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="dossie-hero"] [data-motion-beat="cta"] {
|
|
||||||
transform: translateY(0.5rem);
|
|
||||||
}
|
|
||||||
|
|
||||||
html[data-motion="enhance"] [data-motion="dossie-hero"] [data-motion-beat="title"] {
|
|
||||||
transform: translateY(0.4rem);
|
|
||||||
}
|
|
||||||
|
|
||||||
html[data-motion="enhance"] [data-motion="dossie-hero"] [data-motion-beat="media"] {
|
|
||||||
clip-path: inset(4% 0 0 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
html[data-motion="enhance"] [data-motion="dossie-hero"].is-active [data-motion-beat] {
|
|
||||||
transform: none;
|
|
||||||
clip-path: inset(0 0 0 0);
|
|
||||||
transition:
|
|
||||||
transform var(--amare-duration-slow) var(--amare-ease-arrival),
|
|
||||||
clip-path var(--amare-duration-focal) var(--amare-ease-arrival);
|
|
||||||
}
|
|
||||||
|
|
||||||
html[data-motion="enhance"] [data-motion="dossie-hero"].is-active [data-motion-beat="seal"] {
|
|
||||||
transition-delay: 0ms;
|
|
||||||
}
|
|
||||||
|
|
||||||
html[data-motion="enhance"] [data-motion="dossie-hero"].is-active [data-motion-beat="title"] {
|
|
||||||
transition-delay: 80ms;
|
|
||||||
}
|
|
||||||
|
|
||||||
html[data-motion="enhance"] [data-motion="dossie-hero"].is-active [data-motion-beat="media"] {
|
|
||||||
transition-delay: 120ms;
|
|
||||||
}
|
|
||||||
|
|
||||||
html[data-motion="enhance"] [data-motion="dossie-hero"].is-active [data-motion-beat="cta"] {
|
|
||||||
transition-delay: 220ms;
|
|
||||||
}
|
|
||||||
|
|
||||||
html[data-motion="enhance"] [data-reveal]:not(.is-revealed) {
|
|
||||||
transform: translateY(0.5rem);
|
|
||||||
}
|
|
||||||
|
|
||||||
html[data-motion="enhance"] [data-reveal].is-revealed {
|
|
||||||
transform: none;
|
|
||||||
transition: transform var(--amare-duration-slow) var(--amare-ease-arrival);
|
|
||||||
}
|
|
||||||
|
|
||||||
html[data-motion="enhance"] [data-motion="page-open"]:not(.is-active) {
|
|
||||||
transform: translateY(0.4rem);
|
|
||||||
}
|
|
||||||
|
|
||||||
html[data-motion="enhance"] [data-motion="page-open"].is-active {
|
|
||||||
transform: none;
|
|
||||||
transition: transform var(--amare-duration-normal) var(--amare-ease-arrival);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,9 +56,7 @@
|
|||||||
--amare-duration-fast: 150ms;
|
--amare-duration-fast: 150ms;
|
||||||
--amare-duration-normal: 250ms;
|
--amare-duration-normal: 250ms;
|
||||||
--amare-duration-slow: 400ms;
|
--amare-duration-slow: 400ms;
|
||||||
--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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
@@ -66,7 +64,6 @@
|
|||||||
--amare-duration-fast: 0.01ms;
|
--amare-duration-fast: 0.01ms;
|
||||||
--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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -1,108 +1,40 @@
|
|||||||
import './motion.js';
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
const menuButton = document.querySelector('[data-menu-button]');
|
const menuButton = document.querySelector('[data-menu-button]');
|
||||||
const navigation = document.querySelector('[data-main-nav]');
|
const navigation = document.querySelector('[data-main-nav]');
|
||||||
|
|
||||||
if (menuButton && navigation) {
|
if (!menuButton || !navigation) {
|
||||||
const isDesktop = () => window.matchMedia('(min-width: 768px)').matches;
|
return;
|
||||||
const isMenuOpen = () => navigation.classList.contains('is-open');
|
}
|
||||||
|
|
||||||
const setOpen = (isOpen, { returnFocus = false } = {}) => {
|
const setOpen = (isOpen) => {
|
||||||
navigation.classList.toggle('is-open', isOpen);
|
navigation.classList.toggle('is-open', isOpen);
|
||||||
navigation.classList.toggle('hidden', !isOpen && !isDesktop());
|
navigation.classList.toggle('hidden', !isOpen && !window.matchMedia('(min-width: 768px)').matches);
|
||||||
navigation.classList.toggle('flex', isOpen || isDesktop());
|
navigation.classList.toggle('flex', isOpen || window.matchMedia('(min-width: 768px)').matches);
|
||||||
document.body.classList.toggle('menu-open', isOpen);
|
document.body.classList.toggle('menu-open', isOpen);
|
||||||
menuButton.setAttribute('aria-expanded', String(isOpen));
|
menuButton.setAttribute('aria-expanded', String(isOpen));
|
||||||
menuButton.setAttribute('aria-label', isOpen ? 'Fechar menu' : 'Abrir menu');
|
menuButton.setAttribute('aria-label', isOpen ? 'Fechar menu' : 'Abrir menu');
|
||||||
|
|
||||||
if (!isOpen && returnFocus) {
|
|
||||||
menuButton.focus();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
menuButton.addEventListener('click', () => {
|
menuButton.addEventListener('click', () => {
|
||||||
const isOpen = menuButton.getAttribute('aria-expanded') !== 'true';
|
const isOpen = menuButton.getAttribute('aria-expanded') !== 'true';
|
||||||
setOpen(isOpen);
|
setOpen(isOpen);
|
||||||
|
|
||||||
if (isOpen) {
|
|
||||||
const firstLink = navigation.querySelector('a');
|
|
||||||
if (firstLink) {
|
|
||||||
firstLink.focus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
navigation.querySelectorAll('a').forEach((link) => {
|
navigation.querySelectorAll('a').forEach((link) => {
|
||||||
link.addEventListener('click', () => setOpen(false));
|
link.addEventListener('click', () => setOpen(false));
|
||||||
});
|
});
|
||||||
|
|
||||||
navigation.addEventListener('keydown', (event) => {
|
|
||||||
if (event.key === 'Escape') {
|
|
||||||
setOpen(false, { returnFocus: true });
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.key === 'Tab' && !isDesktop() && isMenuOpen()) {
|
|
||||||
const focusables = navigation.querySelectorAll('a[href], button:not([disabled])');
|
|
||||||
|
|
||||||
if (focusables.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const first = focusables[0];
|
|
||||||
const last = focusables[focusables.length - 1];
|
|
||||||
|
|
||||||
if (event.shiftKey && document.activeElement === first) {
|
|
||||||
event.preventDefault();
|
|
||||||
last.focus();
|
|
||||||
} else if (!event.shiftKey && document.activeElement === last) {
|
|
||||||
event.preventDefault();
|
|
||||||
first.focus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
window.matchMedia('(min-width: 768px)').addEventListener('change', (event) => {
|
window.matchMedia('(min-width: 768px)').addEventListener('change', (event) => {
|
||||||
setOpen(false);
|
|
||||||
|
|
||||||
if (event.matches) {
|
if (event.matches) {
|
||||||
|
setOpen(false);
|
||||||
navigation.classList.remove('hidden');
|
navigation.classList.remove('hidden');
|
||||||
navigation.classList.add('flex');
|
navigation.classList.add('flex');
|
||||||
}
|
} else {
|
||||||
});
|
navigation.classList.remove('is-open', 'flex');
|
||||||
}
|
navigation.classList.add('hidden');
|
||||||
|
document.body.classList.remove('menu-open');
|
||||||
document.querySelectorAll('form[data-contact-form]').forEach((form) => {
|
menuButton.setAttribute('aria-expanded', 'false');
|
||||||
const submitButton = form.querySelector('[data-submit-button]');
|
menuButton.setAttribute('aria-label', 'Abrir menu');
|
||||||
|
|
||||||
const resetSubmitState = () => {
|
|
||||||
form.removeAttribute('aria-busy');
|
|
||||||
form.removeAttribute('aria-disabled');
|
|
||||||
|
|
||||||
if (submitButton) {
|
|
||||||
submitButton.disabled = false;
|
|
||||||
submitButton.textContent = submitButton.dataset.defaultLabel ?? 'Enviar briefing';
|
|
||||||
submitButton.removeAttribute('aria-busy');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
form.addEventListener('submit', () => {
|
|
||||||
if (submitButton) {
|
|
||||||
submitButton.dataset.defaultLabel ??= submitButton.textContent.trim();
|
|
||||||
submitButton.textContent = 'Enviando…';
|
|
||||||
submitButton.disabled = true;
|
|
||||||
submitButton.setAttribute('aria-busy', 'true');
|
|
||||||
}
|
|
||||||
|
|
||||||
form.setAttribute('aria-busy', 'true');
|
|
||||||
});
|
|
||||||
|
|
||||||
window.addEventListener('pageshow', (event) => {
|
|
||||||
if (event.persisted) {
|
|
||||||
resetSubmitState();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,166 +0,0 @@
|
|||||||
const MOTION_QUERY = '(prefers-reduced-motion: reduce)';
|
|
||||||
|
|
||||||
function prefersReducedMotion() {
|
|
||||||
return window.matchMedia(MOTION_QUERY).matches;
|
|
||||||
}
|
|
||||||
|
|
||||||
function enableEnhancement() {
|
|
||||||
if (prefersReducedMotion()) {
|
|
||||||
document.documentElement.removeAttribute('data-motion');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
document.documentElement.dataset.motion = 'enhance';
|
|
||||||
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) {
|
|
||||||
document.querySelectorAll('[data-motion="page-open"]').forEach((node) => {
|
|
||||||
if (!enhance) {
|
|
||||||
node.classList.add('is-active');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
requestAnimationFrame(() => node.classList.add('is-active'));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function observeReveals(enhance) {
|
|
||||||
const nodes = Array.from(document.querySelectorAll('[data-reveal]'));
|
|
||||||
if (nodes.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!enhance || typeof IntersectionObserver !== 'function') {
|
|
||||||
nodes.forEach((node) => node.classList.add('is-revealed'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const observer = new IntersectionObserver(
|
|
||||||
(entries) => {
|
|
||||||
entries.forEach((entry) => {
|
|
||||||
if (!entry.isIntersecting) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
entry.target.classList.add('is-revealed');
|
|
||||||
observer.unobserve(entry.target);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
{
|
|
||||||
rootMargin: '0px 0px -12% 0px',
|
|
||||||
threshold: 0.2,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
nodes.forEach((node) => observer.observe(node));
|
|
||||||
}
|
|
||||||
|
|
||||||
function setupChapterIndex() {
|
|
||||||
const index = document.querySelector('[data-chapter-index]');
|
|
||||||
const progress = document.querySelector('[data-chapter-progress] span');
|
|
||||||
const chapters = Array.from(document.querySelectorAll('[data-chapter]'));
|
|
||||||
|
|
||||||
if (!index || chapters.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const links = Array.from(index.querySelectorAll('a[href^="#"]'));
|
|
||||||
|
|
||||||
const setActive = (id) => {
|
|
||||||
links.forEach((link) => {
|
|
||||||
const isCurrent = link.getAttribute('href') === `#${id}`;
|
|
||||||
if (isCurrent) {
|
|
||||||
link.setAttribute('aria-current', 'true');
|
|
||||||
} else {
|
|
||||||
link.removeAttribute('aria-current');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateProgress = (ratio) => {
|
|
||||||
if (!progress) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const clamped = Math.min(1, Math.max(0, ratio));
|
|
||||||
progress.style.setProperty('--chapter-progress', `${(clamped * 100).toFixed(2)}%`);
|
|
||||||
};
|
|
||||||
|
|
||||||
const sync = () => {
|
|
||||||
const marker = window.scrollY + Math.min(window.innerHeight * 0.35, 280);
|
|
||||||
let current = chapters[0];
|
|
||||||
|
|
||||||
chapters.forEach((chapter) => {
|
|
||||||
if (chapter.offsetTop <= marker) {
|
|
||||||
current = chapter;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const heading = current.querySelector('[id$="-heading"]') || document.getElementById(`${current.dataset.chapter}-heading`);
|
|
||||||
const headingId = heading?.id
|
|
||||||
|| current.getAttribute('aria-labelledby')
|
|
||||||
|| `${current.dataset.chapter}-heading`;
|
|
||||||
|
|
||||||
setActive(headingId);
|
|
||||||
|
|
||||||
const doc = document.documentElement;
|
|
||||||
const max = Math.max(1, doc.scrollHeight - window.innerHeight);
|
|
||||||
updateProgress(window.scrollY / max);
|
|
||||||
};
|
|
||||||
|
|
||||||
setActive(chapters[0].getAttribute('aria-labelledby') || 'hero-heading');
|
|
||||||
sync();
|
|
||||||
|
|
||||||
window.addEventListener('scroll', sync, { passive: true });
|
|
||||||
window.addEventListener('resize', sync);
|
|
||||||
}
|
|
||||||
|
|
||||||
function bootMotion() {
|
|
||||||
const enhance = enableEnhancement();
|
|
||||||
activateHero(enhance);
|
|
||||||
activatePageOpen(enhance);
|
|
||||||
observeReveals(enhance);
|
|
||||||
setupChapterIndex();
|
|
||||||
|
|
||||||
window.matchMedia(MOTION_QUERY).addEventListener('change', (event) => {
|
|
||||||
if (event.matches) {
|
|
||||||
document.documentElement.removeAttribute('data-motion');
|
|
||||||
document.querySelectorAll('[data-motion="dossie-hero"], [data-motion="page-open"]').forEach((node) => {
|
|
||||||
node.classList.add('is-active');
|
|
||||||
});
|
|
||||||
document.querySelectorAll('[data-reveal]').forEach((node) => {
|
|
||||||
node.classList.add('is-revealed');
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
document.documentElement.dataset.motion = 'enhance';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (document.readyState === 'loading') {
|
|
||||||
document.addEventListener('DOMContentLoaded', bootMotion, { once: true });
|
|
||||||
} else {
|
|
||||||
bootMotion();
|
|
||||||
}
|
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
$variant = $variant === 'on-dark' ? 'on-dark' : 'on-light';
|
$variant = $variant === 'on-dark' ? 'on-dark' : 'on-light';
|
||||||
$kind = $mark ? 'mark' : 'lockup';
|
$kind = $mark ? 'mark' : 'lockup';
|
||||||
$staticSrc = asset("brand/{$kind}-{$variant}.webp");
|
$staticSrc = asset("brand/{$kind}-{$variant}.webp");
|
||||||
|
$staticFallback = asset("brand/{$kind}-{$variant}.png");
|
||||||
|
|
||||||
$src = filled($uploadedPath)
|
$src = filled($uploadedPath)
|
||||||
? \Illuminate\Support\Facades\Storage::disk('public')->url($uploadedPath)
|
? \Illuminate\Support\Facades\Storage::disk('public')->url($uploadedPath)
|
||||||
@@ -33,4 +34,8 @@
|
|||||||
'decoding' => 'async',
|
'decoding' => 'async',
|
||||||
'loading' => 'eager',
|
'loading' => 'eager',
|
||||||
]) }}
|
]) }}
|
||||||
|
@if (! filled($uploadedPath))
|
||||||
|
data-brand-fallback="{{ $staticFallback }}"
|
||||||
|
@endif
|
||||||
|
data-brand-variant="{{ $variant }}"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -6,11 +6,7 @@
|
|||||||
|
|
||||||
$manifestFile = is_file($manifestPath) ? $manifestPath : $hotManifestPath;
|
$manifestFile = is_file($manifestPath) ? $manifestPath : $hotManifestPath;
|
||||||
$manifest = is_string($manifestFile) && is_file($manifestFile)
|
$manifest = is_string($manifestFile) && is_file($manifestFile)
|
||||||
? \Illuminate\Support\Facades\Cache::remember(
|
? json_decode((string) file_get_contents($manifestFile), true)
|
||||||
'fonts-manifest:'.md5($manifestFile.':'.(string) filemtime($manifestFile)),
|
|
||||||
3600,
|
|
||||||
static fn () => json_decode((string) file_get_contents($manifestFile), true) ?: null,
|
|
||||||
)
|
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
$cssFile = is_array($manifest) ? ($manifest['style']['file'] ?? null) : null;
|
$cssFile = is_array($manifest) ? ($manifest['style']['file'] ?? null) : null;
|
||||||
|
|||||||
37
resources/views/components/home/cases.blade.php
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
@props([
|
||||||
|
'cases',
|
||||||
|
])
|
||||||
|
|
||||||
|
@if ($cases->isNotEmpty())
|
||||||
|
<section aria-labelledby="cases-heading" class="border-b border-amare-border py-16">
|
||||||
|
<div class="container-amare space-y-8">
|
||||||
|
<div class="max-w-2xl space-y-3">
|
||||||
|
<h2 id="cases-heading" class="text-3xl font-semibold text-amare-text">Casos selecionados</h2>
|
||||||
|
<p class="text-amare-text-muted">Histórias recentes de celebrações conduzidas pela Amare.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-8">
|
||||||
|
@foreach ($cases as $case)
|
||||||
|
<article class="grid gap-4 border-t border-amare-border pt-6 md:grid-cols-[200px_minmax(0,1fr)]">
|
||||||
|
@if (filled($case->cover_image_path))
|
||||||
|
<x-media.image
|
||||||
|
:path="$case->cover_image_path"
|
||||||
|
:alt="$case->cover_image_alt ?: $case->title"
|
||||||
|
sizes="200px"
|
||||||
|
class="aspect-square w-full object-cover"
|
||||||
|
/>
|
||||||
|
@endif
|
||||||
|
<div class="space-y-2">
|
||||||
|
<h3 class="text-2xl font-semibold text-amare-text">{{ $case->title }}</h3>
|
||||||
|
<p class="text-sm text-amare-text-muted">{{ $case->event_type }}@if($case->city) · {{ $case->city }}@endif</p>
|
||||||
|
<p class="text-amare-text-muted">{{ $case->summary }}</p>
|
||||||
|
<a href="{{ url('/portfolio/'.$case->slug) }}" class="inline-flex text-sm font-semibold text-amare-accent hover:text-amare-accent-hover">
|
||||||
|
Ver caso
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
@endif
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
@props([
|
|
||||||
'chapters' => [],
|
|
||||||
])
|
|
||||||
|
|
||||||
@if (count($chapters) > 0)
|
|
||||||
<nav data-chapter-index aria-label="Índice de capítulos">
|
|
||||||
@foreach ($chapters as $chapter)
|
|
||||||
<a href="#{{ $chapter['id'] }}">{{ $chapter['label'] }}</a>
|
|
||||||
@endforeach
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div data-chapter-progress aria-hidden="true">
|
|
||||||
<span></span>
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
@@ -2,19 +2,19 @@
|
|||||||
'settings',
|
'settings',
|
||||||
])
|
])
|
||||||
|
|
||||||
<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="border-t border-amare-border bg-amare-bg-deep py-20">
|
||||||
<div class="container-amare space-y-6 text-center" data-reveal>
|
<div class="container-amare space-y-6 text-center">
|
||||||
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Próximo passo</p>
|
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Próximo passo</p>
|
||||||
<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-3xl font-medium text-amare-text md:text-4xl">Todo grande encontro 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.
|
||||||
</p>
|
</p>
|
||||||
<div>
|
<div>
|
||||||
<a
|
<a
|
||||||
href="{{ route('contact') }}"
|
href="{{ route('contact') }}"
|
||||||
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 duration-(--amare-duration-normal) ease-(--amare-ease-standard) hover:bg-amare-accent-deep"
|
class="inline-flex items-center bg-amare-accent px-5 py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-accent-text transition-colors duration-(--amare-duration-normal) ease-(--amare-ease-standard) hover:bg-amare-accent-deep"
|
||||||
>
|
>
|
||||||
{{ $settings->hero_cta_label ?: 'Solicitar proposta' }}
|
{{ $settings->hero_cta_label }}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,44 +2,36 @@
|
|||||||
'settings',
|
'settings',
|
||||||
])
|
])
|
||||||
|
|
||||||
<section
|
<section aria-labelledby="hero-heading" class="border-b border-amare-border bg-amare-bg">
|
||||||
aria-labelledby="hero-heading"
|
|
||||||
class="border-b border-amare-border bg-amare-bg"
|
|
||||||
data-chapter="hero"
|
|
||||||
data-motion="dossie-hero"
|
|
||||||
>
|
|
||||||
<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 grid gap-12 py-20 md:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)] md:items-center md:py-28">
|
||||||
<div class="space-y-8">
|
<div class="space-y-8">
|
||||||
<div data-motion-beat="seal" class="flex items-center gap-4">
|
|
||||||
<x-brand.logo mark variant="on-light" class="h-8 w-auto" alt="" />
|
|
||||||
@if (filled($settings->hero_eyebrow))
|
@if (filled($settings->hero_eyebrow))
|
||||||
<p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent">{{ $settings->hero_eyebrow }}</p>
|
<p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent">{{ $settings->hero_eyebrow }}</p>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
|
||||||
|
|
||||||
<h1 id="hero-heading" data-motion-beat="title" class="max-w-3xl text-display font-medium text-amare-text">
|
<h1 id="hero-heading" class="max-w-3xl text-4xl font-medium leading-none text-amare-text md:text-5xl">
|
||||||
{{ $settings->hero_title ?: 'Celebrações com propósito' }}
|
{{ $settings->hero_title }}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
@if (filled($settings->hero_subtitle))
|
@if (filled($settings->hero_subtitle))
|
||||||
<p class="max-w-2xl text-lg text-amare-text-muted">{{ $settings->hero_subtitle }}</p>
|
<p class="max-w-2xl text-lg text-amare-text-muted">{{ $settings->hero_subtitle }}</p>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
<div data-motion-beat="cta" class="flex flex-wrap items-center gap-4">
|
<div class="flex flex-wrap items-center gap-4">
|
||||||
<a
|
<a
|
||||||
href="{{ route('contact') }}"
|
href="{{ route('contact') }}"
|
||||||
data-testid="home-primary-cta"
|
data-testid="home-primary-cta"
|
||||||
class="inline-flex min-h-11 items-center bg-amare-accent px-5 py-3 text-sm font-semibold text-amare-accent-text transition-colors duration-(--amare-duration-normal) ease-(--amare-ease-standard) hover:bg-amare-accent-hover"
|
class="inline-flex items-center bg-amare-accent px-5 py-3 text-sm font-semibold text-amare-accent-text transition-colors duration-(--amare-duration-normal) ease-(--amare-ease-standard) hover:bg-amare-accent-hover"
|
||||||
>
|
>
|
||||||
{{ $settings->hero_cta_label ?: 'Solicitar proposta' }}
|
{{ $settings->hero_cta_label }}
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
@if (filled($settings->hero_secondary_cta_label))
|
@if (filled($settings->hero_secondary_cta_label))
|
||||||
<a
|
<a
|
||||||
href="{{ route('portfolio.index') }}"
|
href="{{ route('portfolio.index') }}"
|
||||||
class="inline-flex min-h-11 items-center text-sm font-semibold text-amare-accent transition-colors hover:text-amare-accent-deep"
|
class="inline-flex items-center border-b border-amare-accent pb-1 text-sm font-semibold text-amare-accent transition-colors hover:text-amare-accent-deep"
|
||||||
>
|
>
|
||||||
<span class="border-b border-amare-accent pb-1">{{ $settings->hero_secondary_cta_label }}</span>
|
{{ $settings->hero_secondary_cta_label }}
|
||||||
</a>
|
</a>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
@@ -50,12 +42,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (filled($settings->default_og_image_path))
|
@if (filled($settings->default_og_image_path))
|
||||||
<div data-motion-beat="media" class="min-h-72 overflow-hidden bg-amare-bg-deep">
|
<div class="min-h-72 bg-amare-bg-deep">
|
||||||
<x-media.image
|
<x-media.image
|
||||||
:path="$settings->default_og_image_path"
|
:path="$settings->default_og_image_path"
|
||||||
:alt="$settings->default_og_image_alt ?: $settings->brand_name"
|
:alt="$settings->default_og_image_alt ?: $settings->brand_name"
|
||||||
loading="eager"
|
loading="eager"
|
||||||
fetchpriority="high"
|
|
||||||
sizes="(max-width: 768px) 100vw, 40vw"
|
sizes="(max-width: 768px) 100vw, 40vw"
|
||||||
class="img-editorial h-full w-full object-cover"
|
class="img-editorial h-full w-full object-cover"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -8,11 +8,11 @@
|
|||||||
$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="border-b border-amare-border bg-amare-bg-deep py-20">
|
||||||
<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)]">
|
||||||
<p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent">Manifesto</p>
|
<p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent">Manifesto</p>
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<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-3xl font-medium leading-tight text-amare-text md:text-4xl">{{ $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>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,17 +7,17 @@
|
|||||||
$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="border-b border-amare-border bg-amare-bg-archive py-20">
|
||||||
<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">
|
||||||
<div class="space-y-3" data-reveal>
|
<div class="space-y-3">
|
||||||
<p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent">Método</p>
|
<p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent">Método</p>
|
||||||
<h2 id="method-heading" class="text-headline font-medium text-amare-text">Cuidado orientado por processo.</h2>
|
<h2 id="method-heading" class="text-3xl 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>
|
||||||
</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)]">
|
||||||
<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,19 +3,19 @@
|
|||||||
])
|
])
|
||||||
|
|
||||||
@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="border-b border-amare-accent-deep bg-amare-accent-deep py-20 text-amare-accent-text">
|
||||||
<div class="container-amare space-y-10">
|
<div class="container-amare space-y-10">
|
||||||
<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)]">
|
||||||
<p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent-text/80">Portfólio</p>
|
<p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent-text/80">Portfólio</p>
|
||||||
<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-3xl font-medium md:text-4xl">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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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">
|
||||||
@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"
|
||||||
@@ -27,8 +27,8 @@
|
|||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<h3 class="text-2xl font-medium">{{ $case->title }}</h3>
|
<h3 class="text-2xl font-medium">{{ $case->title }}</h3>
|
||||||
<p class="text-amare-accent-text/80">{{ $case->summary }}</p>
|
<p class="text-amare-accent-text/80">{{ $case->summary }}</p>
|
||||||
<a href="{{ route('portfolio.show', $case->slug) }}" class="inline-flex min-h-11 items-center text-sm font-semibold transition-colors hover:text-amare-accent-text">
|
<a href="{{ route('portfolio.show', $case->slug) }}" class="inline-flex border-b border-amare-accent-text pb-1 text-sm font-semibold transition-colors hover:text-amare-accent-text">
|
||||||
<span class="border-b border-amare-accent-text pb-1">Ver caso</span>
|
Ver caso
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
@@ -39,8 +39,8 @@
|
|||||||
<p class="max-w-2xl text-sm text-amare-accent-text/75">
|
<p class="max-w-2xl text-sm text-amare-accent-text/75">
|
||||||
Imagens demonstrativas enquanto o acervo autorizado da Amare está em organização.
|
Imagens demonstrativas enquanto o acervo autorizado da Amare está em organização.
|
||||||
</p>
|
</p>
|
||||||
<a href="{{ route('portfolio.index') }}" class="inline-flex min-h-11 items-center text-sm font-semibold transition-colors hover:text-amare-accent-text">
|
<a href="{{ route('portfolio.index') }}" class="border-b border-amare-accent-text pb-1 text-sm font-semibold transition-colors hover:text-amare-accent-text">
|
||||||
<span class="border-b border-amare-accent-text pb-1">Conhecer o portfólio</span>
|
Conhecer o portfólio
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,13 +7,13 @@
|
|||||||
$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="border-b border-amare-border py-20">
|
||||||
<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)]">
|
||||||
<div class="space-y-3" data-reveal>
|
<div class="space-y-3">
|
||||||
<p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent">A Amare</p>
|
<p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent">A Amare</p>
|
||||||
<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-3xl 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">
|
||||||
<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)
|
||||||
@@ -21,8 +21,8 @@
|
|||||||
@endforeach
|
@endforeach
|
||||||
</ul>
|
</ul>
|
||||||
<p>
|
<p>
|
||||||
<a href="{{ route('about') }}" class="inline-flex min-h-11 items-center text-sm font-semibold text-amare-accent transition-colors hover:text-amare-accent-deep">
|
<a href="{{ route('about') }}" class="border-b border-amare-accent pb-1 text-sm font-semibold text-amare-accent transition-colors hover:text-amare-accent-deep">
|
||||||
<span class="border-b border-amare-accent pb-1">Conhecer a Amare</span>
|
Conhecer a Amare
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
31
resources/views/components/home/proof.blade.php
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
@props([
|
||||||
|
'cases',
|
||||||
|
])
|
||||||
|
|
||||||
|
@if ($cases->isNotEmpty())
|
||||||
|
<section aria-labelledby="proof-heading" class="border-b border-amare-border bg-amare-bg-muted py-16">
|
||||||
|
<div class="container-amare space-y-8">
|
||||||
|
<div class="max-w-2xl space-y-3">
|
||||||
|
<h2 id="proof-heading" class="text-3xl font-semibold text-amare-text">Prova visual</h2>
|
||||||
|
<p class="text-amare-text-muted">Eventos em destaque que mostram o cuidado com cada celebração.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
@foreach ($cases as $case)
|
||||||
|
<article class="space-y-3">
|
||||||
|
@if (filled($case->cover_image_path))
|
||||||
|
<x-media.image
|
||||||
|
:path="$case->cover_image_path"
|
||||||
|
:alt="$case->cover_image_alt ?: $case->title"
|
||||||
|
sizes="(max-width: 768px) 100vw, 33vw"
|
||||||
|
class="aspect-[4/3] w-full object-cover"
|
||||||
|
/>
|
||||||
|
@endif
|
||||||
|
<h3 class="text-xl font-semibold text-amare-text">{{ $case->title }}</h3>
|
||||||
|
<p class="text-sm text-amare-text-muted">{{ $case->summary }}</p>
|
||||||
|
</article>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
@endif
|
||||||
@@ -3,9 +3,9 @@
|
|||||||
])
|
])
|
||||||
|
|
||||||
@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="border-b border-amare-border py-20">
|
||||||
<div class="container-amare space-y-10">
|
<div class="container-amare space-y-10">
|
||||||
<div class="max-w-2xl space-y-3" data-reveal>
|
<div class="max-w-2xl space-y-3">
|
||||||
<p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent">Atuação</p>
|
<p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent">Atuação</p>
|
||||||
<h2 id="services-heading" class="text-3xl font-medium text-amare-text">Serviços</h2>
|
<h2 id="services-heading" class="text-3xl font-medium text-amare-text">Serviços</h2>
|
||||||
<p class="text-amare-text-muted">Assessoria sob medida para decisões importantes e celebrações bem conduzidas.</p>
|
<p class="text-amare-text-muted">Assessoria sob medida para decisões importantes e celebrações bem conduzidas.</p>
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
|
|
||||||
<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">
|
||||||
<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>
|
||||||
@@ -22,8 +22,8 @@
|
|||||||
</ol>
|
</ol>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
<a href="{{ route('services.index') }}" class="inline-flex min-h-11 items-center text-sm font-semibold text-amare-accent transition-colors hover:text-amare-accent-deep">
|
<a href="{{ route('services.index') }}" class="border-b border-amare-accent pb-1 text-sm font-semibold text-amare-accent transition-colors hover:text-amare-accent-deep">
|
||||||
<span class="border-b border-amare-accent pb-1">Ver todos os serviços</span>
|
Ver todos os serviços
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,21 +3,20 @@
|
|||||||
])
|
])
|
||||||
|
|
||||||
@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="border-b border-amare-border bg-amare-bg-muted py-16">
|
||||||
<div class="container-amare space-y-8">
|
<div class="container-amare space-y-8">
|
||||||
<div class="max-w-2xl space-y-3" data-reveal>
|
<div class="max-w-2xl space-y-3">
|
||||||
<h2 id="testimonials-heading" class="text-3xl font-medium text-amare-text">Depoimentos</h2>
|
<h2 id="testimonials-heading" class="text-3xl font-semibold 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">
|
||||||
<div class="space-y-3 text-lg text-amare-text">
|
<div class="space-y-3 text-lg text-amare-text">
|
||||||
@foreach ($paragraphs as $index => $paragraph)
|
@foreach ($paragraphs as $index => $paragraph)
|
||||||
<p>@if ($index === 0)“@endif{{ $paragraph }}@if ($index === count($paragraphs) - 1)”@endif</p>
|
<p>@if ($index === 0)“@endif{{ $paragraph }}@if ($index === count($paragraphs) - 1)”@endif</p>
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
'alt',
|
'alt',
|
||||||
'sizes' => '(max-width: 768px) 100vw, 960px',
|
'sizes' => '(max-width: 768px) 100vw, 960px',
|
||||||
'loading' => 'lazy',
|
'loading' => 'lazy',
|
||||||
'fetchpriority' => null,
|
|
||||||
'width' => null,
|
'width' => null,
|
||||||
'height' => null,
|
'height' => null,
|
||||||
'disk' => null,
|
'disk' => null,
|
||||||
@@ -33,7 +32,7 @@
|
|||||||
|
|
||||||
$resolvedWidth = $width ?? $dimensions['width'] ?? null;
|
$resolvedWidth = $width ?? $dimensions['width'] ?? null;
|
||||||
$resolvedHeight = $height ?? $dimensions['height'] ?? null;
|
$resolvedHeight = $height ?? $dimensions['height'] ?? null;
|
||||||
$loadingValue = $loading;
|
$loadingValue = $loading === 'eager' ? 'eager' : 'lazy';
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<img
|
<img
|
||||||
@@ -43,7 +42,6 @@
|
|||||||
@if ($resolvedWidth) width="{{ $resolvedWidth }}" @endif
|
@if ($resolvedWidth) width="{{ $resolvedWidth }}" @endif
|
||||||
@if ($resolvedHeight) height="{{ $resolvedHeight }}" @endif
|
@if ($resolvedHeight) height="{{ $resolvedHeight }}" @endif
|
||||||
loading="{{ $loadingValue }}"
|
loading="{{ $loadingValue }}"
|
||||||
@if ($fetchpriority) fetchpriority="{{ $fetchpriority }}" @endif
|
|
||||||
@if ($class) class="{{ $class }}" @endif
|
@if ($class) class="{{ $class }}" @endif
|
||||||
{{ $attributes->except(['path', 'alt', 'sizes', 'loading', 'width', 'height', 'disk', 'class']) }}
|
{{ $attributes->except(['path', 'alt', 'sizes', 'loading', 'width', 'height', 'disk', 'class']) }}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -3,18 +3,12 @@
|
|||||||
])
|
])
|
||||||
|
|
||||||
<meta name="description" content="{{ $pageMeta->description }}">
|
<meta name="description" content="{{ $pageMeta->description }}">
|
||||||
@if (filled($pageMeta->canonical))
|
|
||||||
<link rel="canonical" href="{{ $pageMeta->canonical }}">
|
<link rel="canonical" href="{{ $pageMeta->canonical }}">
|
||||||
@endif
|
|
||||||
|
|
||||||
<meta property="og:locale" content="pt_BR">
|
|
||||||
<meta property="og:site_name" content="{{ $pageMeta->siteName }}">
|
|
||||||
<meta property="og:title" content="{{ $pageMeta->title }}">
|
<meta property="og:title" content="{{ $pageMeta->title }}">
|
||||||
<meta property="og:description" content="{{ $pageMeta->description }}">
|
<meta property="og:description" content="{{ $pageMeta->description }}">
|
||||||
<meta property="og:type" content="{{ $pageMeta->ogType }}">
|
<meta property="og:type" content="{{ $pageMeta->ogType }}">
|
||||||
@if (filled($pageMeta->canonical))
|
|
||||||
<meta property="og:url" content="{{ $pageMeta->canonical }}">
|
<meta property="og:url" content="{{ $pageMeta->canonical }}">
|
||||||
@endif
|
|
||||||
@if ($pageMeta->ogImageUrl)
|
@if ($pageMeta->ogImageUrl)
|
||||||
<meta property="og:image" content="{{ $pageMeta->ogImageUrl }}">
|
<meta property="og:image" content="{{ $pageMeta->ogImageUrl }}">
|
||||||
@if ($pageMeta->ogImageAlt)
|
@if ($pageMeta->ogImageAlt)
|
||||||
@@ -22,19 +16,6 @@
|
|||||||
@endif
|
@endif
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
<meta name="twitter:card" content="summary_large_image">
|
|
||||||
<meta name="twitter:title" content="{{ $pageMeta->title }}">
|
|
||||||
<meta name="twitter:description" content="{{ $pageMeta->description }}">
|
|
||||||
@if ($pageMeta->ogImageUrl)
|
|
||||||
<meta name="twitter:image" content="{{ $pageMeta->ogImageUrl }}">
|
|
||||||
@endif
|
|
||||||
|
|
||||||
@if (app()->environment('production'))
|
|
||||||
<meta name="robots" content="{{ $pageMeta->robots }}">
|
|
||||||
@else
|
|
||||||
<meta name="robots" content="noindex, nofollow">
|
|
||||||
@endif
|
|
||||||
|
|
||||||
@if ($pageMeta->jsonLd)
|
@if ($pageMeta->jsonLd)
|
||||||
<script type="application/ld+json">{!! json_encode($pageMeta->jsonLd, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) !!}</script>
|
<script type="application/ld+json">{!! json_encode($pageMeta->jsonLd, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) !!}</script>
|
||||||
@endif
|
@endif
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
Olá, {{ $name }}.
|
|
||||||
|
|
||||||
Recebemos sua mensagem e retornaremos em breve pelo canal informado.
|
|
||||||
|
|
||||||
{{ $brandName }}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="pt-BR">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>Recebemos sua mensagem</title>
|
|
||||||
<style>
|
|
||||||
body { margin: 0; padding: 24px; background: #FBF9F4; font-family: Georgia, 'Times New Roman', serif; color: #1B1C19; }
|
|
||||||
.wrap { max-width: 600px; margin: 0 auto; }
|
|
||||||
p { font-size: 15px; line-height: 1.6; }
|
|
||||||
.signature { margin-top: 24px; color: #5D6155; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="wrap">
|
|
||||||
<p>Olá, {{ $name }}.</p>
|
|
||||||
<p>
|
|
||||||
Recebemos sua mensagem e retornaremos em breve pelo canal informado.
|
|
||||||
</p>
|
|
||||||
<p class="signature">
|
|
||||||
{{ $brandName }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
Novo briefing de contato
|
|
||||||
|
|
||||||
@foreach ($fields as $label => $value)
|
|
||||||
{{ $label }}: {{ $value ?: '—' }}
|
|
||||||
@endforeach
|
|
||||||
|
|
||||||
Enviado pelo site {{ config('app.name') }}.
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="pt-BR">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>Novo briefing de contato</title>
|
|
||||||
<style>
|
|
||||||
body { margin: 0; padding: 24px; background: #FBF9F4; font-family: Georgia, 'Times New Roman', serif; color: #1B1C19; }
|
|
||||||
.wrap { max-width: 600px; margin: 0 auto; }
|
|
||||||
h1 { font-size: 20px; font-weight: 600; margin: 0 0 16px; }
|
|
||||||
table { width: 100%; border-collapse: collapse; }
|
|
||||||
th, td { text-align: left; vertical-align: top; padding: 10px 12px; border-bottom: 1px solid #C5C8B8; font-size: 14px; }
|
|
||||||
th { width: 40%; font-weight: 600; color: #556B2F; }
|
|
||||||
.footer { margin-top: 16px; font-size: 12px; color: #5D6155; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="wrap">
|
|
||||||
<h1>Novo briefing de contato</h1>
|
|
||||||
<table>
|
|
||||||
@foreach ($fields as $label => $value)
|
|
||||||
<tr>
|
|
||||||
<th scope="row">{{ $label }}</th>
|
|
||||||
<td>{{ $value ?: '—' }}</td>
|
|
||||||
</tr>
|
|
||||||
@endforeach
|
|
||||||
</table>
|
|
||||||
<p class="footer">
|
|
||||||
Enviado pelo site {{ config('app.name') }}.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,17 +1,3 @@
|
|||||||
@php
|
|
||||||
try {
|
|
||||||
$errorSettings = \App\Models\SiteSetting::instance();
|
|
||||||
$pageMeta = \App\Application\Data\PageMeta::forErrorPage($errorSettings, 404);
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
$pageMeta = new \App\Application\Data\PageMeta(
|
|
||||||
title: 'Página não encontrada - Amare Assessoria',
|
|
||||||
description: 'A página que você procura não existe ou foi movida.',
|
|
||||||
canonical: '',
|
|
||||||
robots: 'noindex, nofollow',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@endphp
|
|
||||||
|
|
||||||
@extends('layouts.public')
|
@extends('layouts.public')
|
||||||
|
|
||||||
@section('content')
|
@section('content')
|
||||||
@@ -20,7 +6,7 @@
|
|||||||
<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">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">O endereço que você tentou abrir não existe ou foi movido.</p>
|
||||||
<p>
|
<p>
|
||||||
<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 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>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
@php
|
|
||||||
try {
|
|
||||||
$errorSettings = \App\Models\SiteSetting::instance();
|
|
||||||
$pageMeta = \App\Application\Data\PageMeta::forErrorPage($errorSettings, 419);
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
$pageMeta = new \App\Application\Data\PageMeta(
|
|
||||||
title: 'Sessão expirada - Amare Assessoria',
|
|
||||||
description: 'Sua sessão expirou. Volte e tente enviar novamente.',
|
|
||||||
canonical: '',
|
|
||||||
robots: 'noindex, nofollow',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@endphp
|
|
||||||
|
|
||||||
@extends('layouts.public')
|
|
||||||
|
|
||||||
@section('content')
|
|
||||||
<div class="container-amare max-w-2xl space-y-6 py-20 text-center">
|
|
||||||
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Erro 419</p>
|
|
||||||
<h1 class="text-4xl font-medium tracking-tight text-amare-text">Sessão expirada</h1>
|
|
||||||
<p class="text-amare-muted">Sua sessão expirou. Volte e tente enviar novamente.</p>
|
|
||||||
<p>
|
|
||||||
<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
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
@endsection
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
@php
|
|
||||||
try {
|
|
||||||
$errorSettings = \App\Models\SiteSetting::instance();
|
|
||||||
$pageMeta = \App\Application\Data\PageMeta::forErrorPage($errorSettings, 429);
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
$pageMeta = new \App\Application\Data\PageMeta(
|
|
||||||
title: 'Muitas solicitações - Amare Assessoria',
|
|
||||||
description: 'Você enviou muitas solicitações em pouco tempo. Aguarde um instante e tente novamente.',
|
|
||||||
canonical: '',
|
|
||||||
robots: 'noindex, nofollow',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@endphp
|
|
||||||
|
|
||||||
@extends('layouts.public')
|
|
||||||
|
|
||||||
@section('content')
|
|
||||||
<div class="container-amare max-w-2xl space-y-6 py-20 text-center">
|
|
||||||
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Erro 429</p>
|
|
||||||
<h1 class="text-4xl font-medium tracking-tight text-amare-text">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>
|
|
||||||
<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
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
@endsection
|
|
||||||
@@ -1,17 +1,3 @@
|
|||||||
@php
|
|
||||||
try {
|
|
||||||
$errorSettings = \App\Models\SiteSetting::instance();
|
|
||||||
$pageMeta = \App\Application\Data\PageMeta::forErrorPage($errorSettings, 500);
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
$pageMeta = new \App\Application\Data\PageMeta(
|
|
||||||
title: 'Algo deu errado - Amare Assessoria',
|
|
||||||
description: 'Não foi possível concluir o pedido. Tente novamente em instantes.',
|
|
||||||
canonical: '',
|
|
||||||
robots: 'noindex, nofollow',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@endphp
|
|
||||||
|
|
||||||
@extends('layouts.public')
|
@extends('layouts.public')
|
||||||
|
|
||||||
@section('content')
|
@section('content')
|
||||||
@@ -20,7 +6,7 @@
|
|||||||
<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">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">Não foi possível concluir o pedido agora. Tente novamente em instantes.</p>
|
||||||
<p>
|
<p>
|
||||||
<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 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>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
@php
|
|
||||||
try {
|
|
||||||
$errorSettings = \App\Models\SiteSetting::instance();
|
|
||||||
$pageMeta = \App\Application\Data\PageMeta::forErrorPage($errorSettings, 503);
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
$pageMeta = new \App\Application\Data\PageMeta(
|
|
||||||
title: 'Em manutenção - Amare Assessoria',
|
|
||||||
description: 'Estamos realizando uma breve manutenção. Tente novamente em instantes.',
|
|
||||||
canonical: '',
|
|
||||||
robots: 'noindex, nofollow',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@endphp
|
|
||||||
|
|
||||||
@extends('layouts.public')
|
|
||||||
|
|
||||||
@section('content')
|
|
||||||
<div class="container-amare max-w-2xl space-y-6 py-20 text-center">
|
|
||||||
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Erro 503</p>
|
|
||||||
<h1 class="text-4xl font-medium tracking-tight text-amare-text">Em manutenção</h1>
|
|
||||||
<p class="text-amare-muted">Estamos realizando uma breve manutenção. Tente novamente em instantes.</p>
|
|
||||||
<p>
|
|
||||||
<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
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
@endsection
|
|
||||||
@@ -4,33 +4,12 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<meta name="color-scheme" content="light">
|
<meta name="color-scheme" content="light">
|
||||||
<meta name="theme-color" content="#FBF9F4">
|
|
||||||
<link rel="icon" href="{{ asset('favicon.svg') }}" type="image/svg+xml">
|
|
||||||
|
|
||||||
<title>{{ $pageMeta->title }}</title>
|
<title>{{ $pageMeta->title }}</title>
|
||||||
<x-seo.meta :page-meta="$pageMeta" />
|
<x-seo.meta :page-meta="$pageMeta" />
|
||||||
|
|
||||||
<x-fonts />
|
<x-fonts />
|
||||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||||
|
|
||||||
<noscript>
|
|
||||||
<style>
|
|
||||||
.main-nav {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
.main-nav {
|
|
||||||
flex-direction: row;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.menu-button {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</noscript>
|
|
||||||
</head>
|
</head>
|
||||||
<body class="min-h-screen bg-amare-bg font-serif text-amare-text antialiased">
|
<body class="min-h-screen bg-amare-bg font-serif text-amare-text antialiased">
|
||||||
<a href="#conteudo" class="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:z-50 focus:bg-amare-accent focus:px-4 focus:py-2 focus:text-amare-accent-text">
|
<a href="#conteudo" class="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:z-50 focus:bg-amare-accent focus:px-4 focus:py-2 focus:text-amare-accent-text">
|
||||||
@@ -39,12 +18,12 @@
|
|||||||
|
|
||||||
<header class="site-header sticky top-0 z-40 border-b border-amare-border/80 bg-amare-bg/90 backdrop-blur-sm">
|
<header class="site-header sticky top-0 z-40 border-b border-amare-border/80 bg-amare-bg/90 backdrop-blur-sm">
|
||||||
<div class="container-amare grid grid-cols-[auto_1fr_auto] items-center gap-4 py-4 md:grid-cols-[1fr_auto_1fr]">
|
<div class="container-amare grid grid-cols-[auto_1fr_auto] items-center gap-4 py-4 md:grid-cols-[1fr_auto_1fr]">
|
||||||
<nav id="main-nav" class="main-nav order-3 col-span-3 hidden flex-col gap-4 border-t border-amare-border pt-4 md:order-1 md:col-span-1 md:flex md:flex-row md:items-center md:gap-1 md:border-0 md:pt-0" aria-label="Principal" data-main-nav>
|
<nav id="main-nav" class="main-nav order-3 col-span-3 hidden flex-col gap-4 border-t border-amare-border pt-4 md:order-1 md:col-span-1 md:flex md:flex-row md:items-center md:border-0 md:pt-0" aria-label="Principal" data-main-nav>
|
||||||
<a href="{{ route('home') }}" class="inline-flex items-center py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-muted transition-colors hover:text-amare-accent md:px-2">Início</a>
|
<a href="{{ route('home') }}" class="text-sm font-semibold uppercase tracking-[0.12em] text-amare-muted transition-colors hover:text-amare-accent">Início</a>
|
||||||
<a href="{{ route('services.index') }}" class="inline-flex items-center py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-muted transition-colors hover:text-amare-accent md:px-2">Serviços</a>
|
<a href="{{ route('services.index') }}" class="text-sm font-semibold uppercase tracking-[0.12em] text-amare-muted transition-colors hover:text-amare-accent">Serviços</a>
|
||||||
<a href="{{ route('portfolio.index') }}" class="inline-flex items-center py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-muted transition-colors hover:text-amare-accent md:px-2">Portfólio</a>
|
<a href="{{ route('portfolio.index') }}" class="text-sm font-semibold uppercase tracking-[0.12em] text-amare-muted transition-colors hover:text-amare-accent">Portfólio</a>
|
||||||
<a href="{{ route('about') }}" class="inline-flex items-center py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-muted transition-colors hover:text-amare-accent md:px-2">Amare</a>
|
<a href="{{ route('about') }}" class="text-sm font-semibold uppercase tracking-[0.12em] text-amare-muted transition-colors hover:text-amare-accent">Amare</a>
|
||||||
<a href="{{ route('contact') }}" class="inline-flex items-center py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-muted transition-colors hover:text-amare-accent md:hidden">Solicitar proposta</a>
|
<a href="{{ route('contact') }}" class="text-sm font-semibold uppercase tracking-[0.12em] text-amare-muted transition-colors hover:text-amare-accent md:hidden">Contato</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<a href="{{ route('home') }}" class="order-1 justify-self-start md:order-2 md:justify-self-center" aria-label="{{ $siteSettings->brand_name }} — página inicial">
|
<a href="{{ route('home') }}" class="order-1 justify-self-start md:order-2 md:justify-self-center" aria-label="{{ $siteSettings->brand_name }} — página inicial">
|
||||||
@@ -52,13 +31,13 @@
|
|||||||
</a>
|
</a>
|
||||||
|
|
||||||
<div class="order-2 flex items-center justify-end gap-3 md:order-3">
|
<div class="order-2 flex items-center justify-end gap-3 md:order-3">
|
||||||
<a href="{{ route('contact') }}" class="hidden items-center py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-accent transition-colors hover:text-amare-accent-deep md:inline-flex">
|
<a href="{{ route('contact') }}" class="hidden text-sm font-semibold uppercase tracking-[0.12em] text-amare-accent transition-colors hover:text-amare-accent-deep md:inline-flex">
|
||||||
Solicitar proposta
|
Solicitar proposta
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="menu-button inline-flex h-11 w-11 items-center justify-center border border-amare-border text-amare-text md:hidden"
|
class="menu-button inline-flex h-10 w-10 items-center justify-center border border-amare-border text-amare-text md:hidden"
|
||||||
aria-label="Abrir menu"
|
aria-label="Abrir menu"
|
||||||
aria-controls="main-nav"
|
aria-controls="main-nav"
|
||||||
aria-expanded="false"
|
aria-expanded="false"
|
||||||
@@ -84,44 +63,39 @@
|
|||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<x-brand.logo variant="on-light" class="h-12 w-auto" />
|
<x-brand.logo variant="on-light" class="h-12 w-auto" />
|
||||||
<p class="max-w-md text-amare-muted">
|
<p class="max-w-md text-amare-muted">
|
||||||
{{ $siteSettings->about_summary ?: 'Assessoria boutique em São Paulo - SP.' }}
|
{{ $siteSettings->about_summary ?: 'Assessoria, produção e organização de eventos sociais e corporativos em São Paulo - SP.' }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text-sm">
|
<div class="space-y-3 text-sm">
|
||||||
<h2 class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-text">Navegação</h2>
|
<h2 class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-text">Navegação</h2>
|
||||||
<ul class="mt-3 space-y-3">
|
<p><a href="{{ route('services.index') }}" class="text-amare-muted transition-colors hover:text-amare-accent">Serviços</a></p>
|
||||||
<li><a href="{{ route('services.index') }}" class="inline-flex min-h-11 items-center text-amare-muted transition-colors hover:text-amare-accent">Serviços</a></li>
|
<p><a href="{{ route('portfolio.index') }}" class="text-amare-muted transition-colors hover:text-amare-accent">Portfólio</a></p>
|
||||||
<li><a href="{{ route('portfolio.index') }}" class="inline-flex min-h-11 items-center text-amare-muted transition-colors hover:text-amare-accent">Portfólio</a></li>
|
<p><a href="{{ route('about') }}" class="text-amare-muted transition-colors hover:text-amare-accent">A Amare</a></p>
|
||||||
<li><a href="{{ route('about') }}" class="inline-flex min-h-11 items-center text-amare-muted transition-colors hover:text-amare-accent">A Amare</a></li>
|
<p><a href="{{ route('contact') }}" class="text-amare-muted transition-colors hover:text-amare-accent">Contato</a></p>
|
||||||
<li><a href="{{ route('contact') }}" class="inline-flex min-h-11 items-center text-amare-muted transition-colors hover:text-amare-accent">Contato</a></li>
|
<p><a href="{{ route('privacy') }}" class="text-amare-muted transition-colors hover:text-amare-accent">Política de privacidade</a></p>
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text-sm">
|
<div class="space-y-3 text-sm">
|
||||||
<h2 class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-text">Contato</h2>
|
<h2 class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-text">Contato</h2>
|
||||||
<address class="mt-3 space-y-3 not-italic">
|
|
||||||
@if ($siteSettings->city)
|
@if ($siteSettings->city)
|
||||||
<p class="text-amare-muted">{{ $siteSettings->city }}</p>
|
<p class="text-amare-muted">{{ $siteSettings->city }}</p>
|
||||||
@endif
|
@endif
|
||||||
@if ($siteSettings->email)
|
@if ($siteSettings->email)
|
||||||
<p>
|
<p>
|
||||||
<a href="mailto:{{ $siteSettings->email }}" class="inline-flex max-w-full min-h-11 items-center text-amare-muted transition-colors hover:text-amare-accent"><span class="min-w-0 break-words">{{ $siteSettings->email }}</span></a>
|
<a href="mailto:{{ $siteSettings->email }}" class="text-amare-muted transition-colors hover:text-amare-accent">{{ $siteSettings->email }}</a>
|
||||||
</p>
|
</p>
|
||||||
@endif
|
@endif
|
||||||
@if ($siteSettings->phone)
|
@if ($siteSettings->phone)
|
||||||
<p>
|
<p class="text-amare-muted">{{ $siteSettings->phone }}</p>
|
||||||
<a href="tel:{{ preg_replace('/\D/', '', (string) $siteSettings->phone) }}" class="inline-flex max-w-full min-h-11 items-center text-amare-muted transition-colors hover:text-amare-accent"><span class="min-w-0 break-words">{{ $siteSettings->phone }}</span></a>
|
|
||||||
</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>
|
||||||
<a href="{{ $url }}" class="inline-flex max-w-full min-h-11 items-center text-amare-muted transition-colors hover:text-amare-accent" rel="noopener noreferrer" target="_blank"><span class="min-w-0 break-words">{{ ucfirst((string) $network) }}</span></a>
|
<a href="{{ $url }}" class="text-amare-muted transition-colors hover:text-amare-accent" rel="noopener noreferrer" target="_blank">{{ ucfirst((string) $network) }}</a>
|
||||||
</p>
|
</p>
|
||||||
@endif
|
@endif
|
||||||
@endforeach
|
@endforeach
|
||||||
</address>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -129,7 +103,7 @@
|
|||||||
<div class="container-amare flex flex-col gap-2 py-6 text-sm text-amare-muted md:flex-row md:items-center md:justify-between">
|
<div class="container-amare flex flex-col gap-2 py-6 text-sm text-amare-muted md:flex-row md:items-center md:justify-between">
|
||||||
<p>© {{ now()->year }} {{ $siteSettings->brand_name }}. Todos os direitos reservados.</p>
|
<p>© {{ now()->year }} {{ $siteSettings->brand_name }}. Todos os direitos reservados.</p>
|
||||||
<p>
|
<p>
|
||||||
<a href="{{ route('privacy') }}" class="inline-flex min-h-11 items-center transition-colors hover:text-amare-accent">Política de privacidade</a>
|
<a href="{{ route('privacy') }}" class="transition-colors hover:text-amare-accent">Política de privacidade</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,28 +8,16 @@
|
|||||||
$city = $siteSettings->city ?: 'São Paulo - SP';
|
$city = $siteSettings->city ?: 'São Paulo - SP';
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<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">
|
||||||
<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">
|
||||||
<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-4xl font-medium tracking-tight text-amare-text md:text-5xl">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>
|
||||||
<p class="text-amare-muted">
|
<p class="text-amare-muted">
|
||||||
A {{ $siteSettings->brand_name }} atua em {{ $city }} com foco em planejamento completo,
|
A {{ $siteSettings->brand_name }} atua em {{ $city }} com foco em planejamento completo,
|
||||||
presença no dia do evento e uma condução serena do início ao fim.
|
presença no dia do evento e uma condução serena do início ao fim.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@if (filled($siteSettings->about_image_path))
|
|
||||||
<div class="overflow-hidden bg-amare-bg-deep">
|
|
||||||
<x-media.image
|
|
||||||
:path="$siteSettings->about_image_path"
|
|
||||||
:alt="$siteSettings->about_image_alt ?: $siteSettings->brand_name"
|
|
||||||
loading="lazy"
|
|
||||||
sizes="(max-width: 768px) 100vw, 50vw"
|
|
||||||
class="img-editorial aspect-[4/3] w-full object-cover"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
@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">
|
||||||
@@ -42,6 +30,4 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<x-home.final-cta :settings="$siteSettings" />
|
|
||||||
@endsection
|
@endsection
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
@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">
|
<div class="flex min-h-[calc(100dvh-14rem)] flex-col border-b border-amare-border bg-amare-bg">
|
||||||
<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">
|
||||||
<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-4xl font-medium tracking-tight text-amare-text md:text-5xl">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">
|
||||||
Conte sobre o seu evento no briefing abaixo. Retornaremos com uma proposta sob medida e sem compromisso.
|
Em breve você poderá enviar um briefing por aqui. Enquanto isso, fale conosco pelos canais abaixo.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -15,263 +15,20 @@
|
|||||||
<p>{{ $siteSettings->city ?: 'São Paulo - SP' }}</p>
|
<p>{{ $siteSettings->city ?: 'São Paulo - SP' }}</p>
|
||||||
@if ($siteSettings->email)
|
@if ($siteSettings->email)
|
||||||
<p>
|
<p>
|
||||||
<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="text-amare-accent transition-colors hover:text-amare-accent-deep">{{ $siteSettings->email }}</a>
|
||||||
</p>
|
</p>
|
||||||
@endif
|
@endif
|
||||||
@if ($siteSettings->phone)
|
@if ($siteSettings->phone)
|
||||||
<p>
|
<p>{{ $siteSettings->phone }}</p>
|
||||||
<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>
|
|
||||||
@endif
|
@endif
|
||||||
@foreach ($siteSettings->social_links ?? [] as $network => $url)
|
@foreach ($siteSettings->social_links ?? [] as $network => $url)
|
||||||
@if (filled($url))
|
@if (filled($url))
|
||||||
<p>
|
<p>
|
||||||
<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="text-amare-accent transition-colors hover:text-amare-accent-deep" rel="noopener noreferrer" target="_blank">{{ ucfirst((string) $network) }}</a>
|
||||||
</p>
|
</p>
|
||||||
@endif
|
@endif
|
||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="border-b border-amare-border bg-amare-bg" aria-labelledby="briefing-heading">
|
|
||||||
<div class="container-amare py-16 md:py-24">
|
|
||||||
<h2 id="briefing-heading" class="text-2xl font-medium tracking-tight text-amare-text md:text-3xl">Briefing de contato</h2>
|
|
||||||
<p class="mt-2 max-w-2xl text-amare-muted">
|
|
||||||
Preencha os campos abaixo. As informações obrigatórias estão marcadas com asterisco (*).
|
|
||||||
</p>
|
|
||||||
|
|
||||||
@if (session('status') === 'briefing-sent')
|
|
||||||
<div role="status" class="mt-8 border border-amare-border bg-amare-bg-deep px-6 py-5">
|
|
||||||
<p class="font-semibold text-amare-text">Mensagem enviada.</p>
|
|
||||||
<p class="mt-1 text-amare-muted">Recebemos seu briefing e retornaremos em breve pelo canal informado.</p>
|
|
||||||
</div>
|
</div>
|
||||||
@else
|
|
||||||
<form
|
|
||||||
method="POST"
|
|
||||||
action="{{ route('contact.store') }}"
|
|
||||||
class="mt-10 max-w-3xl space-y-10"
|
|
||||||
data-contact-form
|
|
||||||
>
|
|
||||||
@csrf
|
|
||||||
|
|
||||||
<div class="honeypot" aria-hidden="true">
|
|
||||||
<label for="empresa">Não preencha este campo</label>
|
|
||||||
<input type="text" id="empresa" name="empresa" tabindex="-1" autocomplete="off">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if ($errors->any())
|
|
||||||
<div role="alert" class="border border-amare-error/40 bg-amare-error/5 px-6 py-5">
|
|
||||||
<p class="font-semibold text-amare-error">Não foi possível enviar.</p>
|
|
||||||
<p class="mt-1 text-sm text-amare-muted">Revise os campos destacados abaixo e tente novamente.</p>
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
<div class="grid gap-8 sm:grid-cols-2">
|
|
||||||
<div>
|
|
||||||
<label for="nome" class="mb-2 block text-xs font-semibold uppercase tracking-[0.14em] text-amare-text">Nome completo *</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="nome"
|
|
||||||
name="nome"
|
|
||||||
value="{{ old('nome') }}"
|
|
||||||
required
|
|
||||||
autocomplete="name"
|
|
||||||
maxlength="120"
|
|
||||||
placeholder="Seu nome"
|
|
||||||
class="w-full border-0 border-b border-amare-border bg-transparent py-3 text-amare-text transition-colors placeholder:text-amare-muted/60 focus:border-amare-accent focus:outline-none @error('nome') border-amare-error @enderror"
|
|
||||||
@error('nome') aria-invalid="true" aria-describedby="nome-error" @enderror
|
|
||||||
>
|
|
||||||
@error('nome')
|
|
||||||
<p id="nome-error" class="mt-2 text-sm text-amare-error">{{ $message }}</p>
|
|
||||||
@enderror
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label for="email" class="mb-2 block text-xs font-semibold uppercase tracking-[0.14em] text-amare-text">E-mail *</label>
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
id="email"
|
|
||||||
name="email"
|
|
||||||
value="{{ old('email') }}"
|
|
||||||
required
|
|
||||||
autocomplete="email"
|
|
||||||
maxlength="254"
|
|
||||||
placeholder="voce@email.com"
|
|
||||||
class="w-full border-0 border-b border-amare-border bg-transparent py-3 text-amare-text transition-colors placeholder:text-amare-muted/60 focus:border-amare-accent focus:outline-none @error('email') border-amare-error @enderror"
|
|
||||||
@error('email') aria-invalid="true" aria-describedby="email-error" @enderror
|
|
||||||
>
|
|
||||||
@error('email')
|
|
||||||
<p id="email-error" class="mt-2 text-sm text-amare-error">{{ $message }}</p>
|
|
||||||
@enderror
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label for="telefone" class="mb-2 block text-xs font-semibold uppercase tracking-[0.14em] text-amare-text">Telefone/WhatsApp *</label>
|
|
||||||
<input
|
|
||||||
type="tel"
|
|
||||||
id="telefone"
|
|
||||||
name="telefone"
|
|
||||||
value="{{ old('telefone') }}"
|
|
||||||
required
|
|
||||||
autocomplete="tel"
|
|
||||||
maxlength="40"
|
|
||||||
placeholder="(11) 90000-0000"
|
|
||||||
class="w-full border-0 border-b border-amare-border bg-transparent py-3 text-amare-text transition-colors placeholder:text-amare-muted/60 focus:border-amare-accent focus:outline-none @error('telefone') border-amare-error @enderror"
|
|
||||||
@error('telefone') aria-invalid="true" aria-describedby="telefone-error" @enderror
|
|
||||||
>
|
|
||||||
@error('telefone')
|
|
||||||
<p id="telefone-error" class="mt-2 text-sm text-amare-error">{{ $message }}</p>
|
|
||||||
@enderror
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label for="tipo_evento" class="mb-2 block text-xs font-semibold uppercase tracking-[0.14em] text-amare-text">Tipo de evento *</label>
|
|
||||||
<select
|
|
||||||
id="tipo_evento"
|
|
||||||
name="tipo_evento"
|
|
||||||
required
|
|
||||||
class="w-full border-0 border-b border-amare-border bg-transparent py-3 text-amare-text transition-colors focus:border-amare-accent focus:outline-none @error('tipo_evento') border-amare-error @enderror"
|
|
||||||
@error('tipo_evento') aria-invalid="true" aria-describedby="tipo_evento-error" @enderror
|
|
||||||
>
|
|
||||||
<option value="" selected disabled>Selecione...</option>
|
|
||||||
<option value="Casamento" @selected(old('tipo_evento') === 'Casamento')>Casamento</option>
|
|
||||||
<option value="Evento corporativo" @selected(old('tipo_evento') === 'Evento corporativo')>Evento corporativo</option>
|
|
||||||
<option value="Celebração intimista" @selected(old('tipo_evento') === 'Celebração intimista')>Celebração intimista</option>
|
|
||||||
<option value="Outro" @selected(old('tipo_evento') === 'Outro')>Outro tipo de evento</option>
|
|
||||||
</select>
|
|
||||||
@error('tipo_evento')
|
|
||||||
<p id="tipo_evento-error" class="mt-2 text-sm text-amare-error">{{ $message }}</p>
|
|
||||||
@enderror
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label for="data_periodo" class="mb-2 block text-xs font-semibold uppercase tracking-[0.14em] text-amare-text">Data ou período desejado</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="data_periodo"
|
|
||||||
name="data_periodo"
|
|
||||||
value="{{ old('data_periodo') }}"
|
|
||||||
maxlength="80"
|
|
||||||
placeholder="Ex.: novembro de 2027"
|
|
||||||
class="w-full border-0 border-b border-amare-border bg-transparent py-3 text-amare-text transition-colors placeholder:text-amare-muted/60 focus:border-amare-accent focus:outline-none @error('data_periodo') border-amare-error @enderror"
|
|
||||||
@error('data_periodo') aria-invalid="true" aria-describedby="data_periodo-error" @enderror
|
|
||||||
>
|
|
||||||
@error('data_periodo')
|
|
||||||
<p id="data_periodo-error" class="mt-2 text-sm text-amare-error">{{ $message }}</p>
|
|
||||||
@enderror
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label for="cidade" class="mb-2 block text-xs font-semibold uppercase tracking-[0.14em] text-amare-text">Cidade do evento *</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="cidade"
|
|
||||||
name="cidade"
|
|
||||||
value="{{ old('cidade') }}"
|
|
||||||
required
|
|
||||||
maxlength="80"
|
|
||||||
placeholder="Ex.: São Paulo"
|
|
||||||
class="w-full border-0 border-b border-amare-border bg-transparent py-3 text-amare-text transition-colors placeholder:text-amare-muted/60 focus:border-amare-accent focus:outline-none @error('cidade') border-amare-error @enderror"
|
|
||||||
@error('cidade') aria-invalid="true" aria-describedby="cidade-error" @enderror
|
|
||||||
>
|
|
||||||
@error('cidade')
|
|
||||||
<p id="cidade-error" class="mt-2 text-sm text-amare-error">{{ $message }}</p>
|
|
||||||
@enderror
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label for="convidados" class="mb-2 block text-xs font-semibold uppercase tracking-[0.14em] text-amare-text">Número estimado de convidados</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
id="convidados"
|
|
||||||
name="convidados"
|
|
||||||
value="{{ old('convidados') }}"
|
|
||||||
min="1"
|
|
||||||
max="100000"
|
|
||||||
inputmode="numeric"
|
|
||||||
autocomplete="off"
|
|
||||||
placeholder="Ex.: 120"
|
|
||||||
class="w-full border-0 border-b border-amare-border bg-transparent py-3 text-amare-text transition-colors placeholder:text-amare-muted/60 focus:border-amare-accent focus:outline-none @error('convidados') border-amare-error @enderror"
|
|
||||||
@error('convidados') aria-invalid="true" aria-describedby="convidados-error" @enderror
|
|
||||||
>
|
|
||||||
@error('convidados')
|
|
||||||
<p id="convidados-error" class="mt-2 text-sm text-amare-error">{{ $message }}</p>
|
|
||||||
@enderror
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label for="servico_interesse" class="mb-2 block text-xs font-semibold uppercase tracking-[0.14em] text-amare-text">Serviço de interesse</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="servico_interesse"
|
|
||||||
name="servico_interesse"
|
|
||||||
value="{{ old('servico_interesse') }}"
|
|
||||||
maxlength="120"
|
|
||||||
placeholder="Ex.: planejamento completo"
|
|
||||||
class="w-full border-0 border-b border-amare-border bg-transparent py-3 text-amare-text transition-colors placeholder:text-amare-muted/60 focus:border-amare-accent focus:outline-none @error('servico_interesse') border-amare-error @enderror"
|
|
||||||
@error('servico_interesse') aria-invalid="true" aria-describedby="servico_interesse-error" @enderror
|
|
||||||
>
|
|
||||||
@error('servico_interesse')
|
|
||||||
<p id="servico_interesse-error" class="mt-2 text-sm text-amare-error">{{ $message }}</p>
|
|
||||||
@enderror
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label for="mensagem" class="mb-2 block text-xs font-semibold uppercase tracking-[0.14em] text-amare-text">Mensagem / principal preocupação *</label>
|
|
||||||
<textarea
|
|
||||||
id="mensagem"
|
|
||||||
name="mensagem"
|
|
||||||
required
|
|
||||||
rows="6"
|
|
||||||
maxlength="3000"
|
|
||||||
placeholder="Conte sobre o seu evento, expectativas e principais preocupações."
|
|
||||||
class="w-full border-0 border-b border-amare-border bg-transparent py-3 text-amare-text transition-colors placeholder:text-amare-muted/60 focus:border-amare-accent focus:outline-none @error('mensagem') border-amare-error @enderror"
|
|
||||||
@error('mensagem') aria-invalid="true" aria-describedby="mensagem-error" @enderror
|
|
||||||
>{{ old('mensagem') }}</textarea>
|
|
||||||
@error('mensagem')
|
|
||||||
<p id="mensagem-error" class="mt-2 text-sm text-amare-error">{{ $message }}</p>
|
|
||||||
@enderror
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label for="privacidade" class="flex items-start gap-3 py-2">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
id="privacidade"
|
|
||||||
name="privacidade"
|
|
||||||
value="1"
|
|
||||||
required
|
|
||||||
@checked(old('privacidade'))
|
|
||||||
class="mt-1 h-5 w-5 shrink-0 accent-amare-accent"
|
|
||||||
@error('privacidade') aria-invalid="true" aria-describedby="privacidade-error" @enderror
|
|
||||||
>
|
|
||||||
<span class="text-sm text-amare-muted">
|
|
||||||
Li e aceito a
|
|
||||||
<a href="{{ route('privacy') }}" class="text-amare-accent underline underline-offset-2 transition-colors hover:text-amare-accent-deep">política de privacidade</a>
|
|
||||||
e autorizo o tratamento dos meus dados para fins de atendimento.*
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
@error('privacidade')
|
|
||||||
<p id="privacidade-error" class="mt-2 text-sm text-amare-error">{{ $message }}</p>
|
|
||||||
@enderror
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-col items-start gap-4">
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
data-submit-button
|
|
||||||
class="inline-flex min-h-[44px] items-center justify-center bg-amare-accent px-8 py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-accent-text transition-colors hover:bg-amare-accent-deep"
|
|
||||||
>
|
|
||||||
Enviar briefing
|
|
||||||
</button>
|
|
||||||
<p class="text-sm text-amare-muted">
|
|
||||||
* Campos obrigatórios. Seus dados são usados apenas para responder à sua solicitação.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
@endsection
|
@endsection
|
||||||
|
|||||||
@@ -1,32 +1,6 @@
|
|||||||
@extends('layouts.public')
|
@extends('layouts.public')
|
||||||
|
|
||||||
@section('content')
|
@section('content')
|
||||||
@php
|
|
||||||
$chapters = [
|
|
||||||
['id' => 'hero-heading', 'label' => 'Capa'],
|
|
||||||
['id' => 'manifesto-heading', 'label' => 'Manifesto'],
|
|
||||||
];
|
|
||||||
|
|
||||||
if ($content->featuredServices->isNotEmpty()) {
|
|
||||||
$chapters[] = ['id' => 'services-heading', 'label' => 'Serviços'];
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($content->featuredCases->isNotEmpty()) {
|
|
||||||
$chapters[] = ['id' => 'portfolio-heading', 'label' => 'Portfólio'];
|
|
||||||
}
|
|
||||||
|
|
||||||
$chapters[] = ['id' => 'method-heading', 'label' => 'Método'];
|
|
||||||
|
|
||||||
if ($content->testimonials->isNotEmpty()) {
|
|
||||||
$chapters[] = ['id' => 'testimonials-heading', 'label' => 'Depoimentos'];
|
|
||||||
}
|
|
||||||
|
|
||||||
$chapters[] = ['id' => 'positioning-heading', 'label' => 'A Amare'];
|
|
||||||
$chapters[] = ['id' => 'final-cta-heading', 'label' => 'Próximo passo'];
|
|
||||||
@endphp
|
|
||||||
|
|
||||||
<x-home.chapter-index :chapters="$chapters" />
|
|
||||||
|
|
||||||
<x-home.hero :settings="$content->settings" />
|
<x-home.hero :settings="$content->settings" />
|
||||||
<x-home.manifesto :settings="$content->settings" />
|
<x-home.manifesto :settings="$content->settings" />
|
||||||
<x-home.services :services="$content->featuredServices" />
|
<x-home.services :services="$content->featuredServices" />
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
@extends('layouts.public')
|
@extends('layouts.public')
|
||||||
|
|
||||||
@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">
|
||||||
<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">
|
||||||
<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-4xl font-medium tracking-tight text-amare-text md:text-5xl">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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@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">Em breve publicaremos novos casos.</p>
|
||||||
@else
|
@else
|
||||||
<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)
|
||||||
@@ -40,9 +40,7 @@
|
|||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
<p class="text-sm text-amare-accent">Imagens demonstrativas enquanto o acervo autorizado da Amare está em organização.</p>
|
<p class="text-sm text-amare-accent">Imagens demonstrativas até existir acervo autorizado da Amare.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<x-home.final-cta :settings="$siteSettings" />
|
|
||||||
@endsection
|
@endsection
|
||||||
|
|||||||
@@ -1,16 +1,14 @@
|
|||||||
@extends('layouts.public')
|
@extends('layouts.public')
|
||||||
|
|
||||||
@section('content')
|
@section('content')
|
||||||
<article data-motion="page-open">
|
<article>
|
||||||
<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">
|
||||||
@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
|
<h1 class="text-4xl font-medium tracking-tight text-amare-text md:text-5xl">{{ $case->title }}</h1>
|
||||||
<h1 class="text-headline font-medium tracking-tight text-amare-text">{{ $case->title }}</h1>
|
|
||||||
<p class="text-lg text-amare-muted">{{ $case->summary }}</p>
|
<p class="text-lg text-amare-muted">{{ $case->summary }}</p>
|
||||||
<p class="text-sm break-words text-amare-muted">
|
<p class="text-sm text-amare-muted">
|
||||||
@if ($case->city){{ $case->city }}@endif
|
@if ($case->city){{ $case->city }}@endif
|
||||||
@if ($case->venue) · {{ $case->venue }}@endif
|
@if ($case->venue) · {{ $case->venue }}@endif
|
||||||
@if ($case->event_date) · {{ $case->event_date->format('d/m/Y') }}@endif
|
@if ($case->event_date) · {{ $case->event_date->format('d/m/Y') }}@endif
|
||||||
@@ -71,6 +69,4 @@
|
|||||||
</section>
|
</section>
|
||||||
@endif
|
@endif
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<x-home.final-cta :settings="$siteSettings" />
|
|
||||||
@endsection
|
@endsection
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
@extends('layouts.public')
|
@extends('layouts.public')
|
||||||
|
|
||||||
@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">
|
||||||
<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">
|
||||||
<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-4xl font-medium tracking-tight text-amare-text md:text-5xl">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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@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">Em breve publicaremos o catálogo de serviços.</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">
|
||||||
@foreach ($services as $index => $service)
|
@foreach ($services as $index => $service)
|
||||||
@@ -41,6 +41,4 @@
|
|||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<x-home.final-cta :settings="$siteSettings" />
|
|
||||||
@endsection
|
@endsection
|
||||||
|
|||||||
223
resources/views/welcome.blade.php
Normal file
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
use App\Http\Controllers\PublicSite\ContactController;
|
|
||||||
use App\Http\Controllers\PublicSite\HomeController;
|
use App\Http\Controllers\PublicSite\HomeController;
|
||||||
use App\Http\Controllers\PublicSite\PageController;
|
use App\Http\Controllers\PublicSite\PageController;
|
||||||
use App\Http\Controllers\PublicSite\PortfolioController;
|
use App\Http\Controllers\PublicSite\PortfolioController;
|
||||||
@@ -19,8 +18,5 @@ Route::get('/portfolio/{slug}', [PortfolioController::class, 'show'])->name('por
|
|||||||
Route::get('/sobre', [PageController::class, 'about'])->name('about');
|
Route::get('/sobre', [PageController::class, 'about'])->name('about');
|
||||||
Route::get('/privacidade', [PageController::class, 'privacy'])->name('privacy');
|
Route::get('/privacidade', [PageController::class, 'privacy'])->name('privacy');
|
||||||
Route::get('/contato', [PageController::class, 'contact'])->name('contact');
|
Route::get('/contato', [PageController::class, 'contact'])->name('contact');
|
||||||
Route::post('/contato', [ContactController::class, 'store'])
|
|
||||||
->middleware('throttle:contact-briefing')
|
|
||||||
->name('contact.store');
|
|
||||||
Route::get('/sitemap.xml', SitemapController::class)->name('sitemap');
|
Route::get('/sitemap.xml', SitemapController::class)->name('sitemap');
|
||||||
Route::get('/robots.txt', RobotsController::class)->name('robots');
|
Route::get('/robots.txt', RobotsController::class)->name('robots');
|
||||||
|
|||||||
@@ -1,114 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Trigger a Dokploy Compose deploy and wait until it finishes.
|
|
||||||
# Required env:
|
|
||||||
# DOKPLOY_URL e.g. https://panel.example.com (panel origin, no /api)
|
|
||||||
# DOKPLOY_API_KEY x-api-key value
|
|
||||||
# DOKPLOY_COMPOSE_ID target compose id
|
|
||||||
# Optional env:
|
|
||||||
# DEPLOY_TITLE deployment title (default: GitHub deploy)
|
|
||||||
# DEPLOY_TIMEOUT_SEC total wait seconds (default: 900)
|
|
||||||
# DEPLOY_POLL_SEC poll interval (default: 10)
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
: "${DOKPLOY_URL:?DOKPLOY_URL is required}"
|
|
||||||
: "${DOKPLOY_API_KEY:?DOKPLOY_API_KEY is required}"
|
|
||||||
: "${DOKPLOY_COMPOSE_ID:?DOKPLOY_COMPOSE_ID is required}"
|
|
||||||
|
|
||||||
# Panel origin only: strip trailing slash and accidental OpenAPI /api suffix.
|
|
||||||
DOKPLOY_URL="${DOKPLOY_URL%/}"
|
|
||||||
DOKPLOY_URL="${DOKPLOY_URL%/api}"
|
|
||||||
DOKPLOY_URL="${DOKPLOY_URL%/}"
|
|
||||||
|
|
||||||
DEPLOY_TITLE="${DEPLOY_TITLE:-GitHub deploy}"
|
|
||||||
DEPLOY_TIMEOUT_SEC="${DEPLOY_TIMEOUT_SEC:-900}"
|
|
||||||
DEPLOY_POLL_SEC="${DEPLOY_POLL_SEC:-10}"
|
|
||||||
|
|
||||||
echo "Dokploy API base: ${DOKPLOY_URL}/api"
|
|
||||||
|
|
||||||
api() {
|
|
||||||
local method="$1"
|
|
||||||
local path="$2"
|
|
||||||
shift 2
|
|
||||||
local url="${DOKPLOY_URL}/api${path}"
|
|
||||||
local response http_code body
|
|
||||||
|
|
||||||
# Body then HTTP status on the last line (no -f so 4xx/5xx still return a body).
|
|
||||||
response="$(
|
|
||||||
curl -sS -w $'\n%{http_code}' -X "$method" \
|
|
||||||
-H "accept: application/json" \
|
|
||||||
-H "content-type: application/json" \
|
|
||||||
-H "x-api-key: ${DOKPLOY_API_KEY}" \
|
|
||||||
"$url" \
|
|
||||||
"$@"
|
|
||||||
)" || {
|
|
||||||
echo "Dokploy API request failed (curl transport error)." >&2
|
|
||||||
echo " method: ${method}" >&2
|
|
||||||
echo " url: ${url}" >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
http_code="$(printf '%s' "$response" | tail -n1)"
|
|
||||||
body="$(printf '%s' "$response" | sed '$d')"
|
|
||||||
|
|
||||||
if [[ ! "$http_code" =~ ^2[0-9][0-9]$ ]]; then
|
|
||||||
echo "Dokploy API error." >&2
|
|
||||||
echo " method: ${method}" >&2
|
|
||||||
echo " url: ${url}" >&2
|
|
||||||
echo " status: ${http_code}" >&2
|
|
||||||
echo " body:" >&2
|
|
||||||
printf '%s\n' "$body" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
printf '%s' "$body"
|
|
||||||
}
|
|
||||||
|
|
||||||
echo "Triggering Dokploy compose.deploy for ${DOKPLOY_COMPOSE_ID}..."
|
|
||||||
api POST "/compose.deploy" \
|
|
||||||
-d "$(jq -n \
|
|
||||||
--arg composeId "$DOKPLOY_COMPOSE_ID" \
|
|
||||||
--arg title "$DEPLOY_TITLE" \
|
|
||||||
'{composeId: $composeId, title: $title}')" >/dev/null
|
|
||||||
|
|
||||||
echo "Waiting for deployment to finish (timeout ${DEPLOY_TIMEOUT_SEC}s)..."
|
|
||||||
deadline=$((SECONDS + DEPLOY_TIMEOUT_SEC))
|
|
||||||
last_status=""
|
|
||||||
|
|
||||||
while (( SECONDS < deadline )); do
|
|
||||||
payload="$(api GET "/deployment.allByCompose?composeId=${DOKPLOY_COMPOSE_ID}")"
|
|
||||||
latest="$(echo "$payload" | jq -c 'if type=="array" then .[0] else . end')"
|
|
||||||
|
|
||||||
if [[ -z "$latest" || "$latest" == "null" ]]; then
|
|
||||||
echo "No deployment records yet; retrying..."
|
|
||||||
sleep "$DEPLOY_POLL_SEC"
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
|
|
||||||
status="$(echo "$latest" | jq -r '.status // .deploymentStatus // empty')"
|
|
||||||
created_at="$(echo "$latest" | jq -r '.createdAt // .created_at // empty')"
|
|
||||||
title="$(echo "$latest" | jq -r '.title // .titleLog // empty')"
|
|
||||||
|
|
||||||
if [[ "$status" != "$last_status" ]]; then
|
|
||||||
echo "Deployment status=${status:-unknown} title=${title:-n/a} createdAt=${created_at:-n/a}"
|
|
||||||
last_status="$status"
|
|
||||||
fi
|
|
||||||
|
|
||||||
case "${status,,}" in
|
|
||||||
done|success|successful|finished)
|
|
||||||
echo "Dokploy deployment succeeded."
|
|
||||||
exit 0
|
|
||||||
;;
|
|
||||||
error|failed|failure)
|
|
||||||
echo "Dokploy deployment failed." >&2
|
|
||||||
echo "$latest" | jq . >&2 || true
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
sleep "$DEPLOY_POLL_SEC"
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "Timed out waiting for Dokploy deployment after ${DEPLOY_TIMEOUT_SEC}s." >&2
|
|
||||||
api GET "/deployment.allByCompose?composeId=${DOKPLOY_COMPOSE_ID}" | jq . >&2 || true
|
|
||||||
exit 1
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Post-deploy HTTP smoke checks for Amare.
|
|
||||||
# Required env:
|
|
||||||
# SMOKE_BASE_URL e.g. https://staging.example.com
|
|
||||||
# Optional env:
|
|
||||||
# SMOKE_RETRIES attempts per path (default: 30)
|
|
||||||
# SMOKE_SLEEP_SEC sleep between attempts (default: 5)
|
|
||||||
# SMOKE_PATHS space-separated paths (default: /up / /admin/login)
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
: "${SMOKE_BASE_URL:?SMOKE_BASE_URL is required}"
|
|
||||||
|
|
||||||
SMOKE_BASE_URL="${SMOKE_BASE_URL%/}"
|
|
||||||
SMOKE_RETRIES="${SMOKE_RETRIES:-30}"
|
|
||||||
SMOKE_SLEEP_SEC="${SMOKE_SLEEP_SEC:-5}"
|
|
||||||
SMOKE_PATHS="${SMOKE_PATHS:-/up / /admin/login}"
|
|
||||||
|
|
||||||
check_path() {
|
|
||||||
local path="$1"
|
|
||||||
local url="${SMOKE_BASE_URL}${path}"
|
|
||||||
local attempt
|
|
||||||
local code
|
|
||||||
|
|
||||||
for ((attempt = 1; attempt <= SMOKE_RETRIES; attempt++)); do
|
|
||||||
code="$(curl -sS -o /tmp/amare-smoke-body -w '%{http_code}' --max-time 20 "$url" 2>/dev/null || true)"
|
|
||||||
if [[ "$code" == "200" ]]; then
|
|
||||||
echo "OK ${path} (HTTP ${code}) attempt=${attempt}"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
echo "WAIT ${path} (HTTP ${code:-000}) attempt=${attempt}/${SMOKE_RETRIES}"
|
|
||||||
sleep "$SMOKE_SLEEP_SEC"
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "FAIL ${path} after ${SMOKE_RETRIES} attempts" >&2
|
|
||||||
if [[ -f /tmp/amare-smoke-body ]]; then
|
|
||||||
head -c 500 /tmp/amare-smoke-body >&2 || true
|
|
||||||
echo >&2
|
|
||||||
fi
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
echo "Smoke against ${SMOKE_BASE_URL}"
|
|
||||||
failed=0
|
|
||||||
for path in $SMOKE_PATHS; do
|
|
||||||
if ! check_path "$path"; then
|
|
||||||
failed=1
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
if (( failed != 0 )); then
|
|
||||||
echo "Smoke checks failed." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Smoke checks passed."
|
|
||||||
79
tasks.md
@@ -1,79 +0,0 @@
|
|||||||
# Frontend Audit — Amare site
|
|
||||||
|
|
||||||
## T0 — Baseline
|
|
||||||
- [x] Rodar `composer quality` p/ registrar estado verde atual (110 passed após composer install)
|
|
||||||
- [x] Registrado estado baseline; Lighthouse final consolidado no T6
|
|
||||||
- [x] Registrar achados baseline (audit completo em frontend-audit.md + relatório subagent)
|
|
||||||
|
|
||||||
## T1 — Formulário de contato (e-mail, sem CRM)
|
|
||||||
- [x] Form em /contato (campos, LGPD obrigatório)
|
|
||||||
- [x] Rota POST /contato + validação + honeypot + throttle
|
|
||||||
- [x] Anti duplo-envio, estados sucesso/erro/rede, preservação de dados
|
|
||||||
- [x] E-mail via MAIL_MAILER (Resend prod / log local)
|
|
||||||
- [x] A11y do form (labels, erros ligados)
|
|
||||||
- [x] Testes feature + atualizar PublicPagesTest — Feature suite: 97 passed
|
|
||||||
|
|
||||||
## T2 — A11y/navegação
|
|
||||||
- [x] Menu mobile: Escape, retorno de foco, fallback sem-JS (app.js + noscript)
|
|
||||||
- [x] Touch targets ≥44px (header, footer, CTAs, capítulos)
|
|
||||||
- [x] Footer: ul/li + address
|
|
||||||
|
|
||||||
## T3 — SEO/metadados
|
|
||||||
- [x] Títulos "Página · Amare" (PageMeta::withBrandSuffix)
|
|
||||||
- [x] og:locale, og:site_name, Twitter cards
|
|
||||||
- [x] Favicon real + link, theme-color
|
|
||||||
- [x] Metadados corretos em 404/500 (PageMeta::forErrorPage)
|
|
||||||
- [x] noindex fora de produção (robots meta condicional)
|
|
||||||
|
|
||||||
## T4 — Conteúdo/posicionamento
|
|
||||||
- [x] Remover welcome.blade.php, home/cases, home/proof
|
|
||||||
- [x] Dedup heading final-CTA vs h1 contato; dedup disclaimers
|
|
||||||
- [x] CTA consistente mobile/desktop
|
|
||||||
- [x] Corrigir copy "Em breve..." nas listas
|
|
||||||
- [x] Rebalancear copy corporativo/social
|
|
||||||
|
|
||||||
## T5 — Performance
|
|
||||||
- [x] fetchpriority + dimensões hero (LCP)
|
|
||||||
- [x] Guarda CLS no media/image
|
|
||||||
- [x] Cache manifest de fontes
|
|
||||||
- [x] Escape JSON-LD
|
|
||||||
|
|
||||||
## T6 — Validação final
|
|
||||||
- [x] pint, phpstan, testes unit/feature/architecture — `composer quality` verde (135 testes)
|
|
||||||
- [x] Browser tests + visual regression (18 passaram; 8 baselines `.snap` atualizadas com `--update-snapshots`)
|
|
||||||
- [x] Lighthouse final (home mobile: A11y 100, Best Practices 100, SEO 69 só por noindex intencional fora de prod; contato desktop: A11y 100, BP 100)
|
|
||||||
- [x] E2E form: submit → sucesso role=status; 2 e-mails (novo briefing + confirmação) logados via MAIL_MAILER=log; fila drenada
|
|
||||||
- [x] Mobile: sem overflow horizontal nas 7 rotas (390px); menu mobile abre/fecha + Escape + retorno de foco
|
|
||||||
- [x] Relatório de entrega
|
|
||||||
|
|
||||||
## T7 — Hardening Public Blades
|
|
||||||
|
|
||||||
- [x] Chunk 1: CSS overflow resilience (`overflow-wrap` body + `[data-chapter-index]` wrap safety, browser test)
|
|
||||||
- [x] Chunk 2: Hero + final-CTA fallbacks + `leading-tight` (TDD)
|
|
||||||
- [x] Chunk 3a: Skip blank-quote testimonials (TDD)
|
|
||||||
- [x] Chunk 3b: Guard empty `event_type` + `break-words` meta line (TDD)
|
|
||||||
- [x] Chunk 4: Extend `PageMeta::forErrorPage` + branded 419/429/503 error views (TDD)
|
|
||||||
- [x] Chunk 5: `app.js` defensive hardening — bfcache submit reset, "Enviando…" label swap, aria-busy, mobile-menu focus trap + Escape
|
|
||||||
- [x] Chunk 6: Quality gates — Pint passed, PHPStan clean, 104 feature tests passed (587 assertions), Vite build succeeded
|
|
||||||
|
|
||||||
### Deviations from plan
|
|
||||||
|
|
||||||
- `PageMeta::forErrorPage` landed on `main` during implementation; rebased and extended it for 419/429/503.
|
|
||||||
- Contact form landed on `main`; submit-state browser coverage was restored.
|
|
||||||
- `event_type: null` violates NOT NULL column — test uses `''` instead (guard covers both).
|
|
||||||
|
|
||||||
# Browser CI Gate — estabilidade e diagnosticabilidade
|
|
||||||
|
|
||||||
## BG — Causa raiz
|
|
||||||
- [x] Diagnosticar 3 runs: mismatch visual (8 testes), acessibilidade (1), timeout networkidle 5s + overflow real
|
|
||||||
- [x] Reproduzir local: `waitForLoadState('networkidle')` fragil (conexões longas), default 5s
|
|
||||||
- [x] Reproduzir warning `file_get_contents(.env)` em TODO teste (CI sem .env)
|
|
||||||
|
|
||||||
## BG — Fixes
|
|
||||||
- [x] Helper `StableScreenshot` (tests/Support/): drop networkidle → readyState + fonts + settle
|
|
||||||
- [x] Exportar PNGs standalone (diff/expected/actual) no mismatch — visíveis nos artifacts
|
|
||||||
- [x] CI: `cp .env.example .env` nos jobs de teste; checkout/cache v5
|
|
||||||
- [x] Gitignore tests/Browser/Screenshots
|
|
||||||
- [x] Unit test MismatchScreenshotExporter; pint/phpstan/unit/feature verdes
|
|
||||||
- [x] Push + PR + CI browser verde (PR #15 merge f09ea83, run 31098941038)
|
|
||||||
- [x] Refresh baselines de CI se necessário (não necessário — browser passou com baselines atuais)
|
|
||||||