Compare commits
22 Commits
feat/remov
...
f4c2e4ed2f
| Author | SHA1 | Date | |
|---|---|---|---|
| f4c2e4ed2f | |||
| e2af1f6a23 | |||
| 68460e061b | |||
| fc1c6177d6 | |||
| 26a68e1823 | |||
| ac08b5f064 | |||
| 91c0d412fa | |||
| dd82ca2dca | |||
| 87a019109d | |||
| 1468a23145 | |||
| f730e766f5 | |||
| 1e215ac3d2 | |||
| 716196dbe0 | |||
| 84f6d7c31a | |||
| 08c6d62498 | |||
| 75cce50bd5 | |||
| a0963dd57a | |||
| 77dc592963 | |||
| 623e45cbea | |||
| 455bc4b7d8 | |||
| 3f55180b73 | |||
| ea630328c9 |
@@ -6,7 +6,7 @@ on:
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||
group: ci-${{ gitea.workflow }}-${{ gitea.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
@@ -18,7 +18,9 @@ env:
|
||||
BCRYPT_ROUNDS: 4
|
||||
CACHE_STORE: database
|
||||
DB_CONNECTION: pgsql
|
||||
DB_HOST: 127.0.0.1
|
||||
# Service hostname on the per-job network (act_runner with empty
|
||||
# container.network). Do not publish host :5432/:8000 — parallel jobs collide.
|
||||
DB_HOST: postgres
|
||||
DB_PORT: 5432
|
||||
DB_DATABASE: amare_test
|
||||
DB_USERNAME: amare
|
||||
@@ -40,12 +42,6 @@ jobs:
|
||||
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd
|
||||
coverage: none
|
||||
|
||||
- uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.composer/cache/files
|
||||
key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }}
|
||||
restore-keys: composer-${{ runner.os }}-
|
||||
|
||||
- run: composer validate --strict
|
||||
- run: composer install --no-interaction --prefer-dist
|
||||
- run: composer pint:check
|
||||
@@ -74,8 +70,6 @@ jobs:
|
||||
POSTGRES_DB: amare_test
|
||||
POSTGRES_USER: amare
|
||||
POSTGRES_PASSWORD: secret
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U amare -d amare_test"
|
||||
--health-interval 5s
|
||||
@@ -91,18 +85,6 @@ jobs:
|
||||
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd
|
||||
coverage: pcov
|
||||
|
||||
- uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.composer/cache/files
|
||||
key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }}
|
||||
restore-keys: composer-${{ runner.os }}-
|
||||
|
||||
- uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
|
||||
restore-keys: npm-${{ runner.os }}-
|
||||
|
||||
- run: composer install --no-interaction --prefer-dist
|
||||
- run: npm ci
|
||||
- run: npm run build
|
||||
@@ -126,8 +108,6 @@ jobs:
|
||||
POSTGRES_DB: amare_test
|
||||
POSTGRES_USER: amare
|
||||
POSTGRES_PASSWORD: secret
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U amare -d amare_test"
|
||||
--health-interval 5s
|
||||
@@ -143,18 +123,6 @@ jobs:
|
||||
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd
|
||||
coverage: none
|
||||
|
||||
- uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.composer/cache/files
|
||||
key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }}
|
||||
restore-keys: composer-${{ runner.os }}-
|
||||
|
||||
- uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
|
||||
restore-keys: npm-${{ runner.os }}-
|
||||
|
||||
- run: composer install --no-interaction --prefer-dist
|
||||
- run: npm ci
|
||||
- run: npm run build
|
||||
@@ -171,8 +139,6 @@ jobs:
|
||||
POSTGRES_DB: amare_test
|
||||
POSTGRES_USER: amare
|
||||
POSTGRES_PASSWORD: secret
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U amare -d amare_test"
|
||||
--health-interval 5s
|
||||
@@ -188,18 +154,6 @@ jobs:
|
||||
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd
|
||||
coverage: none
|
||||
|
||||
- uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.composer/cache/files
|
||||
key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }}
|
||||
restore-keys: composer-${{ runner.os }}-
|
||||
|
||||
- uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
|
||||
restore-keys: npm-${{ runner.os }}-
|
||||
|
||||
- run: composer install --no-interaction --prefer-dist
|
||||
- run: npm ci
|
||||
- run: npm run build
|
||||
@@ -212,15 +166,28 @@ jobs:
|
||||
|
||||
- name: Run browser tests against FrankenPHP container
|
||||
run: |
|
||||
docker run -d --name amare-web \
|
||||
# Join the per-job network (act_runner creates one when
|
||||
# container.network is empty). No host -p: parallel jobs would
|
||||
# collide on :8000/:5432; DNS names work on the job network.
|
||||
# Container --name is global on the shared docker.sock host —
|
||||
# include run id or leftovers from cancelled jobs Conflict.
|
||||
JOB_CID="$(hostname)"
|
||||
JOB_NET="$(docker inspect -f '{{range $k, $_ := .NetworkSettings.Networks}}{{println $k}}{{end}}' "$JOB_CID" | head -n1)"
|
||||
test -n "$JOB_NET"
|
||||
WEB_NAME="amare-web-${GITHUB_RUN_ID:-$$}"
|
||||
docker rm -f "$WEB_NAME" 2>/dev/null || true
|
||||
|
||||
docker run -d --name "$WEB_NAME" \
|
||||
--network "$JOB_NET" \
|
||||
--network-alias amare-web \
|
||||
-e APP_ENV=testing \
|
||||
-e APP_KEY="${APP_KEY}" \
|
||||
-e APP_URL=http://127.0.0.1:8000 \
|
||||
-e APP_URL=http://amare-web:8000 \
|
||||
-e APP_LOCALE=pt_BR \
|
||||
-e APP_FALLBACK_LOCALE=pt_BR \
|
||||
-e APP_TIMEZONE=America/Sao_Paulo \
|
||||
-e DB_CONNECTION=pgsql \
|
||||
-e DB_HOST=host.docker.internal \
|
||||
-e DB_HOST=postgres \
|
||||
-e DB_PORT=5432 \
|
||||
-e DB_DATABASE=amare_test \
|
||||
-e DB_USERNAME=amare \
|
||||
@@ -228,26 +195,28 @@ jobs:
|
||||
-e SESSION_DRIVER=database \
|
||||
-e CACHE_STORE=database \
|
||||
-e QUEUE_CONNECTION=database \
|
||||
--add-host=host.docker.internal:host-gateway \
|
||||
-v "${GITHUB_WORKSPACE}/storage/app/public:/app/storage/app/public" \
|
||||
-p 8000:8000 \
|
||||
amare-app:ci
|
||||
|
||||
cleanup() { docker rm -f "$WEB_NAME" >/dev/null 2>&1 || true; }
|
||||
trap cleanup EXIT
|
||||
|
||||
for i in $(seq 1 30); do
|
||||
if curl -fsS http://127.0.0.1:8000/up; then
|
||||
if curl -fsS http://amare-web:8000/up; then
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
curl -fsS http://127.0.0.1:8000/up
|
||||
./vendor/bin/pest --testsuite=Browser
|
||||
curl -fsS http://amare-web:8000/up
|
||||
APP_URL=http://amare-web:8000 ./vendor/bin/pest --testsuite=Browser
|
||||
|
||||
- name: Collect failure diagnostics
|
||||
if: failure()
|
||||
run: |
|
||||
mkdir -p artifacts/browser
|
||||
docker logs amare-web > artifacts/browser/container.log 2>&1 || true
|
||||
WEB_NAME="amare-web-${GITHUB_RUN_ID:-$$}"
|
||||
docker logs "$WEB_NAME" > artifacts/browser/container.log 2>&1 || true
|
||||
cp -R storage/logs artifacts/browser/app-logs 2>/dev/null || true
|
||||
|
||||
- name: Upload browser failure artifacts
|
||||
@@ -269,10 +238,21 @@ jobs:
|
||||
|
||||
- name: Verify container healthcheck and storage link
|
||||
run: |
|
||||
docker run -d --name amare-health \
|
||||
# Same per-job network as the step container — no host :8000
|
||||
# publish (collides when capacity > 1). Unique --name: docker.sock
|
||||
# is shared across jobs; leftovers from cancelled runs Conflict.
|
||||
JOB_CID="$(hostname)"
|
||||
JOB_NET="$(docker inspect -f '{{range $k, $_ := .NetworkSettings.Networks}}{{println $k}}{{end}}' "$JOB_CID" | head -n1)"
|
||||
test -n "$JOB_NET"
|
||||
HEALTH_NAME="amare-health-${GITHUB_RUN_ID:-$$}"
|
||||
docker rm -f "$HEALTH_NAME" 2>/dev/null || true
|
||||
|
||||
docker run -d --name "$HEALTH_NAME" \
|
||||
--network "$JOB_NET" \
|
||||
--network-alias amare-health \
|
||||
-e APP_ENV=production \
|
||||
-e APP_KEY="${{ env.APP_KEY }}" \
|
||||
-e APP_URL=http://127.0.0.1:8000 \
|
||||
-e APP_URL=http://amare-health:8000 \
|
||||
-e APP_DEBUG=false \
|
||||
-e DB_CONNECTION=pgsql \
|
||||
-e DB_HOST=127.0.0.1 \
|
||||
@@ -280,16 +260,18 @@ jobs:
|
||||
-e DB_DATABASE=amare \
|
||||
-e DB_USERNAME=amare \
|
||||
-e DB_PASSWORD=secret \
|
||||
-p 8000:8000 \
|
||||
amare-app:ci
|
||||
|
||||
cleanup() { docker rm -f "$HEALTH_NAME" >/dev/null 2>&1 || true; }
|
||||
trap cleanup EXIT
|
||||
|
||||
for i in $(seq 1 30); do
|
||||
if curl -fsS http://127.0.0.1:8000/up; then
|
||||
docker exec amare-health test -L /app/public/storage
|
||||
if curl -fsS http://amare-health:8000/up; then
|
||||
docker exec "$HEALTH_NAME" test -L /app/public/storage
|
||||
exit 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
docker logs amare-health
|
||||
docker logs "$HEALTH_NAME"
|
||||
exit 1
|
||||
@@ -1,10 +1,16 @@
|
||||
name: Deploy staging
|
||||
|
||||
# Requires Gitea >= 1.25 for workflow_run (1.24.x has no trigger match).
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: [CI]
|
||||
types: [completed]
|
||||
branches: [main]
|
||||
workflows:
|
||||
- CI
|
||||
- ci.yml
|
||||
types:
|
||||
- completed
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -15,27 +21,28 @@ concurrency:
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
REGISTRY: git.hellomanoel.com
|
||||
IMAGE_NAME: ${{ gitea.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'
|
||||
gitea.event_name == 'workflow_dispatch' ||
|
||||
(gitea.event.workflow_run.conclusion == 'success' &&
|
||||
gitea.event.workflow_run.event == 'push' &&
|
||||
gitea.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 }}
|
||||
ref: ${{ gitea.event.workflow_run.head_sha || gitea.sha }}
|
||||
|
||||
- name: Set image metadata
|
||||
id: meta
|
||||
run: |
|
||||
SHA="${{ github.event.workflow_run.head_sha }}"
|
||||
SHA="${{ gitea.event.workflow_run.head_sha || gitea.sha }}"
|
||||
SHORT_SHA="${SHA:0:7}"
|
||||
IMAGE="${REGISTRY}/${IMAGE_NAME}"
|
||||
IMAGE="$(echo "$IMAGE" | tr '[:upper:]' '[:lower:]')"
|
||||
@@ -43,12 +50,14 @@ jobs:
|
||||
echo "short_sha=${SHORT_SHA}" >> "$GITHUB_OUTPUT"
|
||||
echo "image=${IMAGE}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Log in to GHCR
|
||||
- name: Log in to Gitea registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Gitea's GITEA_TOKEN cannot push OCI packages (gitea#23642); a PAT
|
||||
# with read:package/write:package scopes is required instead.
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.REGISTRY_PAT }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
@@ -61,8 +70,6 @@ jobs:
|
||||
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:
|
||||
@@ -4,7 +4,7 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
sha:
|
||||
description: Full git SHA already published to GHCR (same digest used by staging)
|
||||
description: Full git SHA already published to the Gitea registry (same digest used by staging)
|
||||
required: true
|
||||
type: string
|
||||
confirm:
|
||||
@@ -21,8 +21,8 @@ concurrency:
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
REGISTRY: git.hellomanoel.com
|
||||
IMAGE_NAME: ${{ gitea.repository }}
|
||||
|
||||
jobs:
|
||||
promote:
|
||||
@@ -50,12 +50,14 @@ jobs:
|
||||
echo "short_sha=${SHORT_SHA}" >> "$GITHUB_OUTPUT"
|
||||
echo "image=${IMAGE}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Log in to GHCR
|
||||
- name: Log in to Gitea registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Gitea's GITEA_TOKEN cannot push OCI packages (gitea#23642); a PAT
|
||||
# with read:package/write:package scopes is required instead.
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.REGISTRY_PAT }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
11
CLAUDE.md
11
CLAUDE.md
@@ -22,13 +22,14 @@ Write for the problem at hand, not an imagined future. **DRY**: extract and reus
|
||||
|
||||
PHP and Composer are **not on PATH** in this environment, and `vendor/` and `node_modules/` are absent. Every `composer …` / `php artisan …` command in `AGENTS.md` and `README.md` assumes a PHP 8.4+ runtime with Composer 2 installed. Verify the toolchain before promising a command ran.
|
||||
|
||||
## Git remote auth — two GitHub accounts
|
||||
## Git remotes — Gitea origin, GitHub legacy
|
||||
|
||||
`origin` is `git@github.com:manoel-freitas/amore-site.git`, owned by the **`manoel-freitas`** account. The machine's default SSH identity is a different account (`manoel-freitas-neto`) that cannot see this repo, so pushes fail with `ERROR: Repository not found.` — an access error that reads like a missing repo.
|
||||
`origin` is `git@git.hellomanoel.com:manoel-freitas/amare.git` (self-hosted Gitea). CI/CD and the container registry live there (`git.hellomanoel.com`). Verify SSH with `ssh -T git@git.hellomanoel.com` → should greet `Hi there, manoel-freitas!`.
|
||||
|
||||
- Correct key: `~/.ssh/id_github_pessoal`. Verify with `ssh -i ~/.ssh/id_github_pessoal -o IdentitiesOnly=yes -T git@github.com` → should greet `Hi manoel-freitas!`.
|
||||
- The repo has `core.sshCommand = ssh -i ~/.ssh/id_github_pessoal -o IdentitiesOnly=yes` set locally, so plain `git push` works. If that config is lost, restore it instead of editing the remote URL.
|
||||
- **`gh` authenticates separately**, by token rather than SSH key. As of 2026-08-10 it is logged in as `manoel-freitas`, so `gh pr create` / `gh repo view` work. Confirm with `gh auth status` before assuming: if it reports `manoel-freitas-neto`, that account cannot see this repo and every `gh` call fails on it. Recovering needs an interactive `gh auth login` (or `gh auth switch` with both accounts added), so ask the user to run it.
|
||||
The remote named `github` is the legacy mirror `git@github.com:manoel-freitas/amore-site.git`. Push there only when intentionally syncing the backup. That GitHub account still needs `~/.ssh/id_github_pessoal` (or an equivalent key) when the machine's default identity is a different GitHub user (`manoel-freitas-neto`) that cannot see the repo.
|
||||
|
||||
- Prefer plain `git push` / `git push origin <branch>` against Gitea.
|
||||
- **`gh`** talks to GitHub only. Use the Gitea web UI or API for PRs on `amare`. If you still need `gh` against the legacy remote, confirm `gh auth status` shows `manoel-freitas`.
|
||||
|
||||
## Request spine for the public site
|
||||
|
||||
|
||||
@@ -138,10 +138,10 @@ Após `php artisan db:seed`:
|
||||
- [docs/adr/](docs/adr/) — ADRs aceitas
|
||||
- [docs/conventions/php-strict-types.md](docs/conventions/php-strict-types.md) — convenção de strict types
|
||||
- [docs/operations/atualizacao-de-conteudo.md](docs/operations/atualizacao-de-conteudo.md) — runbook de atualização de conteúdo do site pelo painel admin
|
||||
- [docs/deployment/dokploy.md](docs/deployment/dokploy.md) — deploy staging/produção no Dokploy + GHCR
|
||||
- [docs/deployment/dokploy.md](docs/deployment/dokploy.md) — deploy staging/produção no Dokploy + registry Gitea
|
||||
|
||||
## 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).
|
||||
Staging publica automaticamente após CI verde em `main` (imagem no registry Gitea 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).
|
||||
|
||||
4
SPEC.md
4
SPEC.md
@@ -2073,7 +2073,7 @@ quality → Pint check + PHPStan/Larastan + audits + testes
|
||||
### 14.3 Branches e ambientes
|
||||
|
||||
- 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 publicado no registry Gitea (`git.hellomanoel.com`) e deploy automático em staging via Dokploy;
|
||||
- staging: Dokploy Compose executa migração, healthcheck `/up` e smoke pós-deploy (`/up`, `/`, `/admin/login`);
|
||||
- produção: promoção da mesma imagem aprovada, sem rebuild (retag do digest em `:production`);
|
||||
- produção requer aprovação humana explícita no MVP (`workflow_dispatch` com confirmação);
|
||||
@@ -2487,7 +2487,7 @@ Toda operação financeira deve:
|
||||
| ADR-011 | Cloudflare R2 (S3-compatible) como storage de objetos em produção | Aceita |
|
||||
| ADR-012 | E-mail transacional via Resend (mailer nativo Laravel) | Aceita |
|
||||
| ADR-013 | Design system Heritage Editorial para o site público | Aceita |
|
||||
| ADR-014 | Deploy via Dokploy Compose com imagem imutável por SHA no GHCR | Aceita |
|
||||
| ADR-014 | Deploy via Dokploy Compose com imagem imutável por SHA no registry Gitea | Aceita |
|
||||
| ADR-015 | Site público permanece Blade + JS vanilla; Livewire e Alpine ficam restritos ao Filament até o gatilho de §22. Emenda o texto da ADR-002 | Aceita |
|
||||
| ADR-016 | Lançamento de 31/08/2026 entrega apenas o site institucional (Fases 0–1); Fases 2–5 seguem especificadas e adiadas, sem data | Aceita |
|
||||
|
||||
|
||||
20
app/Application/Data/AboutContent.php
Normal file
20
app/Application/Data/AboutContent.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Application\Data;
|
||||
|
||||
use App\Models\PortfolioCase;
|
||||
use App\Models\SiteSetting;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
|
||||
final readonly class AboutContent
|
||||
{
|
||||
/**
|
||||
* @param Collection<int, PortfolioCase> $featuredCases
|
||||
*/
|
||||
public function __construct(
|
||||
public SiteSetting $settings,
|
||||
public Collection $featuredCases,
|
||||
) {}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ namespace App\Application\Data;
|
||||
|
||||
use App\Models\PortfolioCase;
|
||||
use App\Models\SiteSetting;
|
||||
use App\Models\WeddingPackage;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
final readonly class PageMeta
|
||||
@@ -84,6 +85,43 @@ final readonly class PageMeta
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $jsonLd
|
||||
*/
|
||||
public static function forPackage(
|
||||
WeddingPackage $package,
|
||||
string $canonical,
|
||||
SiteSetting $settings,
|
||||
?array $jsonLd = null,
|
||||
): self {
|
||||
$title = filled($package->meta_title)
|
||||
? (string) $package->meta_title
|
||||
: (filled($package->title_line) ? (string) $package->title_line : (string) $package->name);
|
||||
|
||||
$description = filled($package->meta_description)
|
||||
? (string) $package->meta_description
|
||||
: (filled($package->summary) ? (string) $package->summary : self::defaultDescription($settings));
|
||||
|
||||
$ogImageUrl = filled($package->hero_image_path)
|
||||
? url(Storage::disk('public')->url((string) $package->hero_image_path))
|
||||
: self::defaultOgImageUrl($settings);
|
||||
|
||||
$ogImageAlt = filled($package->hero_image_alt)
|
||||
? (string) $package->hero_image_alt
|
||||
: $settings->default_og_image_alt;
|
||||
|
||||
return new self(
|
||||
title: $title,
|
||||
description: $description,
|
||||
canonical: $canonical,
|
||||
ogType: 'article',
|
||||
ogImageUrl: $ogImageUrl,
|
||||
ogImageAlt: $ogImageAlt,
|
||||
jsonLd: $jsonLd,
|
||||
siteName: (string) $settings->brand_name,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the metadata for branded error pages.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Application\Queries\Marketing;
|
||||
|
||||
use App\Models\WeddingPackage;
|
||||
|
||||
final class FindPublishedWeddingPackageBySlug
|
||||
{
|
||||
public function __invoke(string $slug): ?WeddingPackage
|
||||
{
|
||||
return WeddingPackage::query()
|
||||
->published()
|
||||
->where('slug', $slug)
|
||||
->first();
|
||||
}
|
||||
}
|
||||
25
app/Application/Queries/Marketing/GetAboutContent.php
Normal file
25
app/Application/Queries/Marketing/GetAboutContent.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Application\Queries\Marketing;
|
||||
|
||||
use App\Application\Data\AboutContent;
|
||||
use App\Models\PortfolioCase;
|
||||
use App\Models\SiteSetting;
|
||||
|
||||
final class GetAboutContent
|
||||
{
|
||||
public function __invoke(): AboutContent
|
||||
{
|
||||
return new AboutContent(
|
||||
settings: SiteSetting::instance(),
|
||||
featuredCases: PortfolioCase::query()
|
||||
->published()
|
||||
->where('is_featured', true)
|
||||
->orderBy('sort_order')
|
||||
->limit(6)
|
||||
->get(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Application\Queries\Marketing;
|
||||
|
||||
use App\Models\PortfolioCase;
|
||||
use App\Models\WeddingPackage;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
final class GetSitemapEntries
|
||||
@@ -38,6 +39,21 @@ final class GetSitemapEntries
|
||||
];
|
||||
}
|
||||
|
||||
$packages = WeddingPackage::query()
|
||||
->published()
|
||||
->orderBy('sort_order')
|
||||
->get(['slug', 'updated_at']);
|
||||
|
||||
foreach ($packages as $package) {
|
||||
/** @var Carbon|null $updatedAt */
|
||||
$updatedAt = $package->updated_at;
|
||||
|
||||
$entries[] = [
|
||||
'loc' => route('packages.show', $package->slug),
|
||||
'lastmod' => $updatedAt?->toAtomString(),
|
||||
];
|
||||
}
|
||||
|
||||
return $entries;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,6 +63,9 @@ final class MediaGenerateVariantsCommand extends Command
|
||||
if ($settings && filled($settings->about_image_path)) {
|
||||
$paths[] = (string) $settings->about_image_path;
|
||||
}
|
||||
if ($settings && filled($settings->founder_image_path)) {
|
||||
$paths[] = (string) $settings->founder_image_path;
|
||||
}
|
||||
if ($settings && filled($settings->hero_image_path)) {
|
||||
$paths[] = (string) $settings->hero_image_path;
|
||||
}
|
||||
|
||||
49
app/Domain/Marketing/PackageIconCatalog.php
Normal file
49
app/Domain/Marketing/PackageIconCatalog.php
Normal file
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Marketing;
|
||||
|
||||
/**
|
||||
* Closed catalog of icon keys available for wedding modality detail
|
||||
* sections (benefits strip and included items).
|
||||
*
|
||||
* Keys are stable kebab-case identifiers rendered as inline SVGs by the
|
||||
* public Blade components. Admin selection is constrained to this catalog
|
||||
* so arbitrary SVG markup can never reach the public page.
|
||||
*/
|
||||
final class PackageIconCatalog
|
||||
{
|
||||
/**
|
||||
* @return array<string, string> map of icon_key => pt-BR label
|
||||
*/
|
||||
public static function labels(): array
|
||||
{
|
||||
return [
|
||||
'calendar' => 'Calendário',
|
||||
'checklist' => 'Checklist',
|
||||
'users' => 'Casais',
|
||||
'map' => 'Mapa e fornecedores',
|
||||
'heart' => 'Cuidado',
|
||||
'spark' => 'Detalhes',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function keys(): array
|
||||
{
|
||||
return array_keys(self::labels());
|
||||
}
|
||||
|
||||
public static function isValid(string $iconKey): bool
|
||||
{
|
||||
return isset(self::labels()[$iconKey]);
|
||||
}
|
||||
|
||||
public static function labelFor(string $iconKey): ?string
|
||||
{
|
||||
return self::labels()[$iconKey] ?? null;
|
||||
}
|
||||
}
|
||||
@@ -268,8 +268,10 @@ class ManageSiteSettings extends Page
|
||||
->columns(2),
|
||||
Section::make('Página Sobre')
|
||||
->schema([
|
||||
PublicImageUploadRules::fileUpload('about_image_path', 'Imagem da página Sobre', 'content/about'),
|
||||
PublicImageUploadRules::fileUpload('about_image_path', 'Imagem do hero / Sobre', 'content/about'),
|
||||
PublicImageUploadRules::altTextField('about_image_alt', 'about_image_path'),
|
||||
PublicImageUploadRules::fileUpload('founder_image_path', 'Foto da Michele', 'content/about/founder'),
|
||||
PublicImageUploadRules::altTextField('founder_image_alt', 'founder_image_path'),
|
||||
])
|
||||
->columns(2),
|
||||
Section::make('SEO padrão')
|
||||
|
||||
@@ -4,10 +4,14 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\WeddingPackages\Schemas;
|
||||
|
||||
use App\Domain\Marketing\PackageIconCatalog;
|
||||
use App\Support\PublicImageUploadRules;
|
||||
use Filament\Forms\Components\DateTimePicker;
|
||||
use Filament\Forms\Components\Repeater;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
|
||||
class WeddingPackageForm
|
||||
@@ -16,42 +20,174 @@ class WeddingPackageForm
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
TextInput::make('name')
|
||||
->label('Nome da modalidade')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
TextInput::make('level')
|
||||
->label('Número / tipo (ex.: 01 / COMPLETA)')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
Textarea::make('summary')
|
||||
->label('Resumo')
|
||||
->required()
|
||||
->rows(4),
|
||||
Repeater::make('scope_items')
|
||||
->label('O que está incluído')
|
||||
Section::make('Identificação')
|
||||
->schema([
|
||||
TextInput::make('item')
|
||||
->label('Item')
|
||||
TextInput::make('name')
|
||||
->label('Nome da modalidade')
|
||||
->required()
|
||||
->maxLength(255)
|
||||
->live(onBlur: true)
|
||||
->afterStateUpdated(function (?string $state, callable $set, callable $get): void {
|
||||
if (blank($get('slug'))) {
|
||||
$set('slug', str($state)->slug()->toString());
|
||||
}
|
||||
}),
|
||||
TextInput::make('slug')
|
||||
->label('Slug')
|
||||
->required()
|
||||
->maxLength(255)
|
||||
->unique(ignoreRecord: true),
|
||||
TextInput::make('level')
|
||||
->label('Número / tipo (ex.: 01 / COMPLETA)')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
TextInput::make('tag')
|
||||
->label('Etiqueta curta (ex.: Assessoria completa)')
|
||||
->maxLength(255),
|
||||
TextInput::make('subtitle')
|
||||
->label('Frase de apoio')
|
||||
->maxLength(255),
|
||||
Textarea::make('summary')
|
||||
->label('Resumo')
|
||||
->required()
|
||||
->rows(4),
|
||||
Repeater::make('scope_items')
|
||||
->label('O que está incluído')
|
||||
->schema([
|
||||
TextInput::make('item')
|
||||
->label('Item')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
])
|
||||
->defaultItems(4)
|
||||
->minItems(1)
|
||||
->addActionLabel('Adicionar item')
|
||||
->required(),
|
||||
TextInput::make('cta_label')
|
||||
->label('Texto do botão')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
TextInput::make('compare_heading')
|
||||
->label('Comparação — título (leitura rápida)')
|
||||
->maxLength(255),
|
||||
TextInput::make('compare_summary')
|
||||
->label('Comparação — resumo (leitura rápida)')
|
||||
->maxLength(255),
|
||||
TextInput::make('sort_order')
|
||||
->label('Ordem')
|
||||
->numeric()
|
||||
->default(0)
|
||||
->required(),
|
||||
DateTimePicker::make('published_at')
|
||||
->label('Publicado em')
|
||||
->seconds(false),
|
||||
])
|
||||
->defaultItems(4)
|
||||
->minItems(1)
|
||||
->addActionLabel('Adicionar item')
|
||||
->required(),
|
||||
TextInput::make('cta_label')
|
||||
->label('Texto do botão')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
TextInput::make('sort_order')
|
||||
->label('Ordem')
|
||||
->numeric()
|
||||
->default(0)
|
||||
->required(),
|
||||
DateTimePicker::make('published_at')
|
||||
->label('Publicado em')
|
||||
->seconds(false),
|
||||
->columns(2),
|
||||
Section::make('Hero da página')
|
||||
->description('Dados de abertura da página da modalidade.')
|
||||
->schema([
|
||||
TextInput::make('eyebrow')
|
||||
->label('Eyebrow (ex.: Assessoria)')
|
||||
->maxLength(255),
|
||||
TextInput::make('title_line')
|
||||
->label('Título (linha principal)')
|
||||
->maxLength(255),
|
||||
TextInput::make('title_emphasis')
|
||||
->label('Título (ênfase em itálico)')
|
||||
->maxLength(255),
|
||||
Textarea::make('hero_lead')
|
||||
->label('Texto de abertura')
|
||||
->rows(3),
|
||||
PublicImageUploadRules::fileUpload('hero_image_path', 'Imagem do hero'),
|
||||
PublicImageUploadRules::altTextField('hero_image_alt', 'hero_image_path'),
|
||||
])
|
||||
->columns(2),
|
||||
Section::make('Diferenciais')
|
||||
->description('Faixa escura com os valores da modalidade.')
|
||||
->schema([
|
||||
Repeater::make('benefits')
|
||||
->label('Diferenciais')
|
||||
->schema([
|
||||
Select::make('icon_key')
|
||||
->label('Ícone')
|
||||
->options(PackageIconCatalog::labels())
|
||||
->required(),
|
||||
TextInput::make('label')
|
||||
->label('Rótulo')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
])
|
||||
->defaultItems(4)
|
||||
->minItems(1)
|
||||
->addActionLabel('Adicionar diferencial'),
|
||||
])
|
||||
->columns(1),
|
||||
Section::make('O que está incluso')
|
||||
->schema([
|
||||
Repeater::make('included_items')
|
||||
->label('Itens inclusos')
|
||||
->schema([
|
||||
Select::make('icon_key')
|
||||
->label('Ícone')
|
||||
->options(PackageIconCatalog::labels())
|
||||
->required(),
|
||||
TextInput::make('title')
|
||||
->label('Título')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
Textarea::make('description')
|
||||
->label('Descrição')
|
||||
->rows(2)
|
||||
->maxLength(500),
|
||||
])
|
||||
->defaultItems(6)
|
||||
->minItems(1)
|
||||
->addActionLabel('Adicionar item'),
|
||||
])
|
||||
->columns(1),
|
||||
Section::make('Para quem é este pacote')
|
||||
->schema([
|
||||
TextInput::make('audience_heading')
|
||||
->label('Título da seção')
|
||||
->maxLength(255),
|
||||
Textarea::make('audience_intro')
|
||||
->label('Texto de introdução')
|
||||
->rows(3),
|
||||
Repeater::make('audience_points')
|
||||
->label('Público')
|
||||
->schema([
|
||||
TextInput::make('point')
|
||||
->label('Ponto')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
])
|
||||
->defaultItems(4)
|
||||
->minItems(1)
|
||||
->addActionLabel('Adicionar ponto'),
|
||||
PublicImageUploadRules::fileUpload('audience_image_path', 'Imagem da seção'),
|
||||
PublicImageUploadRules::altTextField('audience_image_alt', 'audience_image_path'),
|
||||
])
|
||||
->columns(2),
|
||||
Section::make('CTA final')
|
||||
->schema([
|
||||
TextInput::make('final_cta_heading')
|
||||
->label('Título do CTA final')
|
||||
->maxLength(255),
|
||||
Textarea::make('final_cta_body')
|
||||
->label('Texto do CTA final')
|
||||
->rows(3),
|
||||
])
|
||||
->columns(2),
|
||||
Section::make('SEO')
|
||||
->schema([
|
||||
TextInput::make('meta_title')
|
||||
->label('Meta title')
|
||||
->maxLength(255),
|
||||
Textarea::make('meta_description')
|
||||
->label('Meta description')
|
||||
->rows(3),
|
||||
])
|
||||
->columns(2),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,9 @@ class WeddingPackagesTable
|
||||
TextColumn::make('level')
|
||||
->label('Número / tipo')
|
||||
->searchable(),
|
||||
TextColumn::make('slug')
|
||||
->label('Slug')
|
||||
->searchable(),
|
||||
TextColumn::make('published_at')
|
||||
->label('Publicado em')
|
||||
->dateTime()
|
||||
|
||||
46
app/Http/Controllers/PublicSite/PackageController.php
Normal file
46
app/Http/Controllers/PublicSite/PackageController.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\PublicSite;
|
||||
|
||||
use App\Application\Data\PageMeta;
|
||||
use App\Application\Queries\Marketing\FindPublishedWeddingPackageBySlug;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\SiteSetting;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Http\Response;
|
||||
|
||||
final class PackageController extends Controller
|
||||
{
|
||||
public function show(string $slug, FindPublishedWeddingPackageBySlug $findPublishedWeddingPackageBySlug): View|Response
|
||||
{
|
||||
$package = $findPublishedWeddingPackageBySlug($slug);
|
||||
|
||||
if ($package === null) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$settings = SiteSetting::instance();
|
||||
$canonical = route('packages.show', $package->slug);
|
||||
|
||||
return view('pages.packages.show', [
|
||||
'package' => $package,
|
||||
'siteSettings' => $settings,
|
||||
'pageMeta' => PageMeta::forPackage(
|
||||
package: $package,
|
||||
canonical: $canonical,
|
||||
settings: $settings,
|
||||
jsonLd: [
|
||||
'@context' => 'https://schema.org',
|
||||
'@type' => 'Article',
|
||||
'headline' => $package->name,
|
||||
'description' => $package->summary,
|
||||
'url' => $canonical,
|
||||
'datePublished' => $package->published_at?->toAtomString(),
|
||||
'dateModified' => $package->updated_at?->toAtomString(),
|
||||
],
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -5,17 +5,20 @@ declare(strict_types=1);
|
||||
namespace App\Http\Controllers\PublicSite;
|
||||
|
||||
use App\Application\Data\PageMeta;
|
||||
use App\Application\Queries\Marketing\GetAboutContent;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\SiteSetting;
|
||||
use Illuminate\Contracts\View\View;
|
||||
|
||||
final class PageController extends Controller
|
||||
{
|
||||
public function about(): View
|
||||
public function about(GetAboutContent $getAboutContent): View
|
||||
{
|
||||
$settings = SiteSetting::instance();
|
||||
$content = $getAboutContent();
|
||||
$settings = $content->settings;
|
||||
|
||||
return view('pages.about', [
|
||||
'content' => $content,
|
||||
'siteSettings' => $settings,
|
||||
'pageMeta' => PageMeta::forPage(
|
||||
canonical: route('about'),
|
||||
|
||||
@@ -19,6 +19,8 @@ use Illuminate\Database\Eloquent\Model;
|
||||
* @property string|null $default_og_image_alt
|
||||
* @property string|null $about_image_path
|
||||
* @property string|null $about_image_alt
|
||||
* @property string|null $founder_image_path
|
||||
* @property string|null $founder_image_alt
|
||||
* @property string|null $hero_image_path
|
||||
* @property string|null $hero_image_alt
|
||||
* @property string|null $services_hero_image_path
|
||||
@@ -47,6 +49,8 @@ use Illuminate\Database\Eloquent\Model;
|
||||
'about_summary',
|
||||
'about_image_path',
|
||||
'about_image_alt',
|
||||
'founder_image_path',
|
||||
'founder_image_alt',
|
||||
'manifesto_title',
|
||||
'manifesto_lead',
|
||||
'manifesto_body',
|
||||
|
||||
@@ -12,17 +12,70 @@ use Illuminate\Database\Eloquent\Attributes\UsePolicy;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* @property string $name
|
||||
* @property string|null $slug
|
||||
* @property string $level
|
||||
* @property string|null $tag
|
||||
* @property string|null $subtitle
|
||||
* @property string $summary
|
||||
* @property list<string> $scope_items
|
||||
* @property string $cta_label
|
||||
* @property string|null $compare_heading
|
||||
* @property string|null $compare_summary
|
||||
* @property string|null $eyebrow
|
||||
* @property string|null $title_line
|
||||
* @property string|null $title_emphasis
|
||||
* @property string|null $hero_lead
|
||||
* @property string|null $hero_image_path
|
||||
* @property string|null $hero_image_alt
|
||||
* @property list<array{icon_key: string, label: string}>|null $benefits
|
||||
* @property list<array{icon_key: string, title: string, description: string}>|null $included_items
|
||||
* @property string|null $audience_heading
|
||||
* @property string|null $audience_intro
|
||||
* @property list<string>|null $audience_points
|
||||
* @property string|null $audience_image_path
|
||||
* @property string|null $audience_image_alt
|
||||
* @property string|null $final_cta_heading
|
||||
* @property string|null $final_cta_body
|
||||
* @property string|null $meta_title
|
||||
* @property string|null $meta_description
|
||||
* @property int $sort_order
|
||||
* @property Carbon|null $published_at
|
||||
*/
|
||||
#[Fillable(['name', 'level', 'summary', 'scope_items', 'cta_label', 'sort_order', 'published_at'])]
|
||||
#[Fillable([
|
||||
'name',
|
||||
'slug',
|
||||
'level',
|
||||
'tag',
|
||||
'subtitle',
|
||||
'summary',
|
||||
'scope_items',
|
||||
'cta_label',
|
||||
'compare_heading',
|
||||
'compare_summary',
|
||||
'sort_order',
|
||||
'published_at',
|
||||
'eyebrow',
|
||||
'title_line',
|
||||
'title_emphasis',
|
||||
'hero_lead',
|
||||
'hero_image_path',
|
||||
'hero_image_alt',
|
||||
'benefits',
|
||||
'included_items',
|
||||
'audience_heading',
|
||||
'audience_intro',
|
||||
'audience_points',
|
||||
'audience_image_path',
|
||||
'audience_image_alt',
|
||||
'final_cta_heading',
|
||||
'final_cta_body',
|
||||
'meta_title',
|
||||
'meta_description',
|
||||
])]
|
||||
#[UsePolicy(WeddingPackagePolicy::class)]
|
||||
class WeddingPackage extends Model
|
||||
{
|
||||
@@ -31,9 +84,25 @@ class WeddingPackage extends Model
|
||||
|
||||
use HasPublication;
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::saving(function (WeddingPackage $weddingPackage): void {
|
||||
if (blank($weddingPackage->slug) && filled($weddingPackage->name)) {
|
||||
$weddingPackage->slug = Str::slug($weddingPackage->name);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** @return array<string, string|class-string> */
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['scope_items' => 'array', 'sort_order' => 'integer', 'published_at' => 'datetime'];
|
||||
return [
|
||||
'scope_items' => 'array',
|
||||
'benefits' => 'array',
|
||||
'included_items' => 'array',
|
||||
'audience_points' => 'array',
|
||||
'sort_order' => 'integer',
|
||||
'published_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
28
app/Support/PackageContactLink.php
Normal file
28
app/Support/PackageContactLink.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Models\SiteSetting;
|
||||
|
||||
final class PackageContactLink
|
||||
{
|
||||
/**
|
||||
* Resolve the contextual contact link for a wedding package modality.
|
||||
*
|
||||
* @return array{href: string, isWhatsapp: bool}
|
||||
*/
|
||||
public static function for(SiteSetting $settings, string $packageName): array
|
||||
{
|
||||
$digits = preg_replace('/\D/', '', (string) $settings->whatsapp_number);
|
||||
$isWhatsapp = strlen($digits) >= 10;
|
||||
|
||||
return [
|
||||
'href' => $isWhatsapp
|
||||
? 'https://wa.me/'.$digits.'?text='.rawurlencode('Olá, gostaria de conversar sobre a modalidade '.$packageName.' para meu casamento.')
|
||||
: route('briefing', ['servico_interesse' => $packageName]),
|
||||
'isWhatsapp' => $isWhatsapp,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ namespace Database\Factories;
|
||||
|
||||
use App\Models\WeddingPackage;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/** @extends Factory<WeddingPackage> */
|
||||
class WeddingPackageFactory extends Factory
|
||||
@@ -15,14 +16,36 @@ class WeddingPackageFactory extends Factory
|
||||
/** @return array<string, mixed> */
|
||||
public function definition(): array
|
||||
{
|
||||
$name = fake()->unique()->words(2, true);
|
||||
|
||||
return [
|
||||
'name' => fake()->unique()->words(2, true),
|
||||
'name' => $name,
|
||||
'slug' => Str::slug($name),
|
||||
'level' => 'Assessoria',
|
||||
'tag' => fake()->words(2, true),
|
||||
'subtitle' => fake()->sentence(),
|
||||
'summary' => fake()->sentence(),
|
||||
'scope_items' => [fake()->sentence()],
|
||||
'cta_label' => 'Conversar sobre esta modalidade',
|
||||
'compare_heading' => fake()->words(3, true),
|
||||
'compare_summary' => fake()->sentence(),
|
||||
'sort_order' => 0,
|
||||
'published_at' => null,
|
||||
'eyebrow' => 'Assessoria',
|
||||
'title_line' => fake()->words(2, true),
|
||||
'title_emphasis' => fake()->word(),
|
||||
'hero_lead' => fake()->paragraph(),
|
||||
'benefits' => [
|
||||
['icon_key' => 'checklist', 'label' => fake()->word()],
|
||||
],
|
||||
'included_items' => [
|
||||
['icon_key' => 'checklist', 'title' => fake()->word(), 'description' => fake()->sentence()],
|
||||
],
|
||||
'audience_heading' => 'Para quem é este pacote',
|
||||
'audience_intro' => fake()->paragraph(),
|
||||
'audience_points' => [fake()->sentence()],
|
||||
'final_cta_heading' => 'Vamos conversar?',
|
||||
'final_cta_body' => fake()->paragraph(),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('site_settings', function (Blueprint $table): void {
|
||||
$table->string('founder_image_path')->nullable()->after('about_image_alt');
|
||||
$table->string('founder_image_alt')->nullable()->after('founder_image_path');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('site_settings', function (Blueprint $table): void {
|
||||
$table->dropColumn(['founder_image_path', 'founder_image_alt']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
<?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('wedding_packages', function (Blueprint $table): void {
|
||||
$table->string('tag')->nullable();
|
||||
$table->string('subtitle')->nullable();
|
||||
$table->string('compare_heading')->nullable();
|
||||
$table->string('compare_summary')->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('wedding_packages', function (Blueprint $table): void {
|
||||
$table->dropColumn(['tag', 'subtitle', 'compare_heading', 'compare_summary']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('wedding_packages', function (Blueprint $table): void {
|
||||
$table->string('slug')->nullable()->unique()->after('name');
|
||||
$table->string('eyebrow')->nullable()->after('level');
|
||||
$table->string('title_line')->nullable()->after('eyebrow');
|
||||
$table->string('title_emphasis')->nullable()->after('title_line');
|
||||
$table->text('hero_lead')->nullable()->after('title_emphasis');
|
||||
$table->string('hero_image_path')->nullable()->after('hero_lead');
|
||||
$table->string('hero_image_alt')->nullable()->after('hero_image_path');
|
||||
$table->jsonb('benefits')->nullable()->after('hero_image_alt');
|
||||
$table->jsonb('included_items')->nullable()->after('benefits');
|
||||
$table->string('audience_heading')->nullable()->after('included_items');
|
||||
$table->text('audience_intro')->nullable()->after('audience_heading');
|
||||
$table->jsonb('audience_points')->nullable()->after('audience_intro');
|
||||
$table->string('audience_image_path')->nullable()->after('audience_points');
|
||||
$table->string('audience_image_alt')->nullable()->after('audience_image_path');
|
||||
$table->string('final_cta_heading')->nullable()->after('audience_image_alt');
|
||||
$table->text('final_cta_body')->nullable()->after('final_cta_heading');
|
||||
$table->string('meta_title')->nullable()->after('final_cta_body');
|
||||
$table->text('meta_description')->nullable()->after('meta_title');
|
||||
});
|
||||
|
||||
$this->backfillSlugs();
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('wedding_packages', function (Blueprint $table): void {
|
||||
$table->dropUnique(['slug']);
|
||||
$table->dropColumn([
|
||||
'slug',
|
||||
'eyebrow',
|
||||
'title_line',
|
||||
'title_emphasis',
|
||||
'hero_lead',
|
||||
'hero_image_path',
|
||||
'hero_image_alt',
|
||||
'benefits',
|
||||
'included_items',
|
||||
'audience_heading',
|
||||
'audience_intro',
|
||||
'audience_points',
|
||||
'audience_image_path',
|
||||
'audience_image_alt',
|
||||
'final_cta_heading',
|
||||
'final_cta_body',
|
||||
'meta_title',
|
||||
'meta_description',
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
private function backfillSlugs(): void
|
||||
{
|
||||
$rows = DB::table('wedding_packages')->whereNull('slug')->get(['id', 'name']);
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$slug = Str::slug($row->name);
|
||||
$candidate = $slug;
|
||||
$suffix = 2;
|
||||
|
||||
while (DB::table('wedding_packages')->where('slug', $candidate)->exists()) {
|
||||
$candidate = "{$slug}-{$suffix}";
|
||||
$suffix++;
|
||||
}
|
||||
|
||||
DB::table('wedding_packages')->where('id', $row->id)->update(['slug' => $candidate]);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -71,6 +71,8 @@ class ContentSeeder extends Seeder
|
||||
'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',
|
||||
'founder_image_path' => $this->copyFixture('about-image.jpg', 'content/about/founder/michele.jpg'),
|
||||
'founder_image_alt' => 'Michele, da Amare',
|
||||
'manifesto_title' => 'Sofisticação que também se traduz em organização.',
|
||||
'manifesto_lead' => 'Um evento memorável não nasce apenas de uma boa estética. Ele depende de decisões bem conduzidas, fornecedores alinhados e atenção constante ao que realmente importa.',
|
||||
'manifesto_body' => 'A Amare combina sensibilidade e precisão para criar encontros coerentes com cada cliente, marca e ocasião — sem fórmulas prontas, excessos ou ruído.',
|
||||
|
||||
@@ -52,6 +52,8 @@ class VisualContentSeeder extends Seeder
|
||||
'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',
|
||||
'founder_image_path' => $this->writeSolidJpeg('visual/about/founder-michele.jpg', 900, 1200, [210, 205, 192]),
|
||||
'founder_image_alt' => 'Michele, da Amare',
|
||||
'manifesto_title' => 'Sofisticação que também se traduz em organização.',
|
||||
'manifesto_lead' => 'Um evento memorável não nasce apenas de uma boa estética. Ele depende de decisões bem conduzidas, fornecedores alinhados e atenção constante ao que realmente importa.',
|
||||
'manifesto_body' => 'A Amare combina sensibilidade e precisão para criar encontros coerentes com cada cliente, marca e ocasião — sem fórmulas prontas, excessos ou ruído.',
|
||||
|
||||
@@ -9,9 +9,11 @@ use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* Wedding package modalities shown on the home page (03 — Amare Casamentos).
|
||||
* Copy is approved from the structural model preview (preview(1).html) and
|
||||
* remains editable via the Filament resource.
|
||||
* Wedding package modalities shown on the home page (03 — Amare Casamentos)
|
||||
* and the services page. Copy is approved from the structural model preview
|
||||
* (preview(2).html) and remains editable via the Filament resource. Detail
|
||||
* copy follows the complete-assessoria preview
|
||||
* (amare-assessoria-completa.html) adapted to each modality.
|
||||
*/
|
||||
class WeddingPackagesSeeder extends Seeder
|
||||
{
|
||||
@@ -22,42 +24,144 @@ class WeddingPackagesSeeder extends Seeder
|
||||
$packages = [
|
||||
[
|
||||
'name' => 'Essenza',
|
||||
'slug' => 'essenza',
|
||||
'level' => '01 / COMPLETA',
|
||||
'summary' => 'Para casais que desejam contar com a Amare desde o planejamento até a realização do casamento.',
|
||||
'tag' => 'Assessoria completa',
|
||||
'subtitle' => 'Do planejamento ao grande dia.',
|
||||
'summary' => 'Para casais que desejam contar com a Amare desde o início ou ainda estão estruturando etapas importantes.',
|
||||
'scope_items' => [
|
||||
'Planejamento e organização',
|
||||
'Gestão de etapas e prioridades',
|
||||
'Acompanhamento de fornecedores',
|
||||
'Coordenação do grande dia',
|
||||
'Estruturação do planejamento',
|
||||
'Cronogramas, prazos e prioridades',
|
||||
'Orientação e gestão de fornecedores',
|
||||
'Alinhamento entre os envolvidos',
|
||||
'Coordenação da execução',
|
||||
],
|
||||
'cta_label' => 'Quero conhecer a Essenza',
|
||||
'compare_heading' => 'Começar com a Amare',
|
||||
'compare_summary' => 'Acompanhamento mais amplo ao longo do planejamento.',
|
||||
'sort_order' => 1,
|
||||
'eyebrow' => 'Assessoria',
|
||||
'title_line' => 'Assessoria',
|
||||
'title_emphasis' => 'Completa',
|
||||
'hero_lead' => 'Do planejamento ao grande dia, com calma, precisão e cuidado.',
|
||||
'benefits' => [
|
||||
['icon_key' => 'calendar', 'label' => 'Organização integral'],
|
||||
['icon_key' => 'checklist', 'label' => 'Planejamento personalizado'],
|
||||
['icon_key' => 'users', 'label' => 'Fornecedores selecionados'],
|
||||
['icon_key' => 'heart', 'label' => 'Experiência leve e inesquecível'],
|
||||
],
|
||||
'included_items' => [
|
||||
['icon_key' => 'heart', 'title' => 'Escuta e briefing', 'description' => 'Cada casal é único; começamos por ouvir.'],
|
||||
['icon_key' => 'checklist', 'title' => 'Planejamento completo', 'description' => 'Cada etapa mapeada com clareza.'],
|
||||
['icon_key' => 'map', 'title' => 'Gestão de fornecedores', 'description' => 'Contatos e prazos sob cuidado.'],
|
||||
['icon_key' => 'calendar', 'title' => 'Acompanhamento contínuo', 'description' => 'Perto do casal em cada decisão.'],
|
||||
['icon_key' => 'spark', 'title' => 'Produção e logística', 'description' => 'Dia a dia do evento organizado nos detalhes.'],
|
||||
['icon_key' => 'users', 'title' => 'Dia do evento', 'description' => 'Coordenação total para o casal viver o momento.'],
|
||||
],
|
||||
'audience_heading' => 'Para quem é este pacote',
|
||||
'audience_intro' => 'Para quem quer uma assessoria completa, do início ao fim.',
|
||||
'audience_points' => [
|
||||
'Casamentos',
|
||||
'Aniversários',
|
||||
'Eventos sociais e celebrações',
|
||||
'Eventos corporativos',
|
||||
],
|
||||
'final_cta_heading' => 'Pronta para começar?',
|
||||
'final_cta_body' => 'Conte seu evento para a Amare e receba uma proposta.',
|
||||
],
|
||||
[
|
||||
'name' => 'Conduzione',
|
||||
'slug' => 'conduzione',
|
||||
'level' => '02 / PARCIAL',
|
||||
'summary' => 'Para quem já começou e deseja estruturar o que falta, organizar fornecedores e seguir com acompanhamento profissional.',
|
||||
'tag' => 'Assessoria parcial',
|
||||
'subtitle' => 'Para quem já começou e quer seguir acompanhado.',
|
||||
'summary' => 'Para casais que já tomaram decisões e contrataram parte dos fornecedores, mas precisam estruturar o que falta.',
|
||||
'scope_items' => [
|
||||
'Diagnóstico do planejamento',
|
||||
'Diagnóstico do planejamento existente',
|
||||
'Organização das pendências',
|
||||
'Gestão dos próximos passos',
|
||||
'Coordenação do grande dia',
|
||||
'Gestão dos fornecedores contratados',
|
||||
'Orientação para próximas decisões',
|
||||
'Coordenação da execução',
|
||||
],
|
||||
'cta_label' => 'Quero conhecer a Conduzione',
|
||||
'compare_heading' => 'Trazer a Amare para o caminho',
|
||||
'compare_summary' => 'O planejamento existe, mas ainda há decisões e gestão pela frente.',
|
||||
'sort_order' => 2,
|
||||
'eyebrow' => 'Assessoria',
|
||||
'title_line' => 'Assessoria',
|
||||
'title_emphasis' => 'Parcial',
|
||||
'hero_lead' => 'Para retomar o planejamento, organizar o que falta e seguir com acompanhamento profissional.',
|
||||
'benefits' => [
|
||||
['icon_key' => 'checklist', 'label' => 'Diagnóstico completo'],
|
||||
['icon_key' => 'calendar', 'label' => 'Organização integral'],
|
||||
['icon_key' => 'users', 'label' => 'Fornecedores selecionados'],
|
||||
['icon_key' => 'heart', 'label' => 'Experiência leve e inesquecível'],
|
||||
],
|
||||
'included_items' => [
|
||||
['icon_key' => 'checklist', 'title' => 'Diagnóstico do planejamento', 'description' => 'Um olhar profissional sobre o que já existe.'],
|
||||
['icon_key' => 'calendar', 'title' => 'Organização das pendências', 'description' => 'Prioridades claras, etapa a etapa.'],
|
||||
['icon_key' => 'map', 'title' => 'Gestão de fornecedores', 'description' => 'Contatos e prazos sob cuidado.'],
|
||||
['icon_key' => 'spark', 'title' => 'Gestão dos próximos passos', 'description' => 'Um plano para chegar tranquilo ao grande dia.'],
|
||||
['icon_key' => 'users', 'title' => 'Acompanhamento contínuo', 'description' => 'Perto do casal em cada decisão.'],
|
||||
['icon_key' => 'heart', 'title' => 'Dia do evento', 'description' => 'Coordenação total para o casal viver o momento.'],
|
||||
],
|
||||
'audience_heading' => 'Para quem é este pacote',
|
||||
'audience_intro' => 'Para quem já começou a planejar e quer retomar as rédeas com acompanhamento profissional.',
|
||||
'audience_points' => [
|
||||
'Casamentos em andamento',
|
||||
'Casais com pouco tempo disponível',
|
||||
'Quem precisa priorizar pendências',
|
||||
'Quem busca fornecedores confiáveis',
|
||||
],
|
||||
'final_cta_heading' => 'Pronta para retomar?',
|
||||
'final_cta_body' => 'Conte onde seu planejamento está e receba uma proposta.',
|
||||
],
|
||||
[
|
||||
'name' => 'Grand Jour',
|
||||
'slug' => 'grand-jour',
|
||||
'level' => '03 / FINAL',
|
||||
'summary' => 'Para casais que já planejaram o casamento e precisam de uma equipe para assumir alinhamentos e operação na reta final.',
|
||||
'tag' => 'Assessoria final',
|
||||
'subtitle' => 'O planejamento está pronto. Agora é hora de viver.',
|
||||
'summary' => 'Para casais na reta final que precisam de uma equipe para assumir alinhamentos, cronograma e operação.',
|
||||
'scope_items' => [
|
||||
'Imersão no planejamento existente',
|
||||
'Alinhamento de fornecedores',
|
||||
'Cronograma final',
|
||||
'Conferência das informações',
|
||||
'Alinhamento dos fornecedores',
|
||||
'Organização do cronograma final',
|
||||
'Gestão da operação do evento',
|
||||
],
|
||||
'cta_label' => 'Quero conhecer a Grand Jour',
|
||||
'compare_heading' => 'Entregar a operação para a Amare',
|
||||
'compare_summary' => 'O projeto está pronto e o foco passa a ser alinhamento e execução.',
|
||||
'sort_order' => 3,
|
||||
'eyebrow' => 'Assessoria',
|
||||
'title_line' => 'Assessoria',
|
||||
'title_emphasis' => 'Final',
|
||||
'hero_lead' => 'Para assumir os alinhamentos e a operação quando o grande dia está próximo.',
|
||||
'benefits' => [
|
||||
['icon_key' => 'checklist', 'label' => 'Imersão no que já existe'],
|
||||
['icon_key' => 'users', 'label' => 'Alinhamento de fornecedores'],
|
||||
['icon_key' => 'calendar', 'label' => 'Cronograma final'],
|
||||
['icon_key' => 'heart', 'label' => 'Operação com leveza'],
|
||||
],
|
||||
'included_items' => [
|
||||
['icon_key' => 'checklist', 'title' => 'Imersão no planejamento', 'description' => 'Entendemos cada decisão já tomada.'],
|
||||
['icon_key' => 'map', 'title' => 'Alinhamento de fornecedores', 'description' => 'Contatos e prazos sob cuidado.'],
|
||||
['icon_key' => 'calendar', 'title' => 'Cronograma final', 'description' => 'Cada etapa posicionada na reta final.'],
|
||||
['icon_key' => 'spark', 'title' => 'Ensaios e detalhes', 'description' => 'Produção e logística nos detalhes.'],
|
||||
['icon_key' => 'users', 'title' => 'Operação do evento', 'description' => 'Gestão da equipe e do dia a dia.'],
|
||||
['icon_key' => 'heart', 'title' => 'Dia do evento', 'description' => 'Coordenação total para o casal viver o momento.'],
|
||||
],
|
||||
'audience_heading' => 'Para quem é este pacote',
|
||||
'audience_intro' => 'Para casais já planejados que querem chegar ao grande dia com equipe e operação no lugar.',
|
||||
'audience_points' => [
|
||||
'Casamentos já planejados',
|
||||
'Casais na reta final',
|
||||
'Quem precisa de operação no dia',
|
||||
'Quem quer viver o momento com leveza',
|
||||
],
|
||||
'final_cta_heading' => 'Pronta para o grande dia?',
|
||||
'final_cta_body' => 'Conte a data do seu evento e receba uma proposta.',
|
||||
],
|
||||
];
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Shared Compose for Dokploy staging and production.
|
||||
# Both stacks use the same file with different env:
|
||||
# APP_IMAGE=ghcr.io/<owner>/<repo>
|
||||
# APP_IMAGE=git.hellomanoel.com/manoel-freitas/amare
|
||||
# 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.
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
# Deploy Dokploy (staging → production)
|
||||
|
||||
Runbook for operating Amare on a VPS with Dokploy connected to GitHub, publishing immutable images to GHCR.
|
||||
Runbook for operating Amare on a VPS with Dokploy connected to Gitea (git.hellomanoel.com), publishing immutable images to Gitea's container registry.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
CI (main) → build FrankenPHP image → GHCR :<sha> + :staging
|
||||
CI (main) → build FrankenPHP image → git.hellomanoel.com registry :<sha> + :staging
|
||||
→ Dokploy staging compose.deploy
|
||||
→ smoke /up / /admin/login
|
||||
|
||||
@@ -18,7 +18,7 @@ Promote (manual) → retag same digest as :production (no rebuild)
|
||||
|---|---|
|
||||
| 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`) |
|
||||
| Image | `git.hellomanoel.com/manoel-freitas/amare:<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`) |
|
||||
@@ -26,11 +26,14 @@ Promote (manual) → retag same digest as :production (no rebuild)
|
||||
|
||||
## 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.
|
||||
1. Gitea repository `manoel-freitas/amare` at `https://git.hellomanoel.com`; **Repository Actions enabled** in repo settings; a registered **Gitea Actions runner** (see [docs.gitea.com usage/actions/quickstart](https://docs.gitea.com/usage/actions/quickstart)) with an `ubuntu-latest` label.
|
||||
2. Dokploy installed on the VPS.
|
||||
3. Container registry in Dokploy (`git.hellomanoel.com`) with a PAT that can **read** packages (`read:package`). Prefer a dedicated bot/token; do not store write tokens on the VPS. The **write** PAT (`write:package`) lives only in Gitea repo secrets for the pipeline.
|
||||
4. Two PostgreSQL services in Dokploy (staging + production), private (no public port).
|
||||
5. Two R2 buckets (or prefixes) and Resend credentials for each environment as needed.
|
||||
6. Domains (or temporary Dokploy/traefik.me hosts) pointing at the VPS with TLS.
|
||||
|
||||
Note: Gitea's `GITEA_TOKEN` cannot push OCI packages ([gitea#23642](https://github.com/go-gitea/gitea/issues/23642)); registry authentication in the workflows uses a PAT (`REGISTRY_PAT`), not the token. Secret names must not use the reserved `GITEA_` prefix (Gitea rejects them as invalid).
|
||||
|
||||
## Create Compose stacks
|
||||
|
||||
@@ -44,7 +47,7 @@ Create **two** Dokploy Compose services (same repo, same compose path):
|
||||
Dokploy Environment for each stack must set:
|
||||
|
||||
```bash
|
||||
APP_IMAGE=ghcr.io/<owner>/<repo>
|
||||
APP_IMAGE=git.hellomanoel.com/manoel-freitas/amare
|
||||
IMAGE_TAG=staging # or production
|
||||
```
|
||||
|
||||
@@ -52,11 +55,11 @@ Point Dokploy domain(s) at service **`web`**, port **`8000`**. Do not publish Po
|
||||
|
||||
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`.
|
||||
Source can be Gitea (so Dokploy clones the compose file) or Raw paste of `docker-compose.deploy.yml`. Prefer Gitea + 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.
|
||||
Set these in Dokploy Environment UI (written to `.env` next to the compose file). **Never** put them in Gitea Actions secrets or image layers.
|
||||
|
||||
```env
|
||||
APP_NAME=Amare
|
||||
@@ -120,9 +123,9 @@ Upload path does not need R2 CORS with the local temp-disk default. Still useful
|
||||
|
||||
Also enable public access / custom domain for `R2_URL` so `<img>` URLs work after save.
|
||||
|
||||
## GitHub Actions secrets
|
||||
## Gitea Actions secrets
|
||||
|
||||
Repository secrets used by workflows:
|
||||
Repository secrets (Gitea → Settings → Actions → Secrets) used by workflows:
|
||||
|
||||
| Secret | Purpose |
|
||||
|---|---|
|
||||
@@ -132,32 +135,48 @@ Repository secrets used by workflows:
|
||||
| `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 |
|
||||
| `REGISTRY_PAT` | Personal Access Token with `read:package` + `write:package` scopes — used to push images to `git.hellomanoel.com` (do not name secrets `GITEA_*`; that prefix is reserved) |
|
||||
| `REGISTRY_USER` | Gitea username that owns `REGISTRY_PAT` (`manoel-freitas`) |
|
||||
|
||||
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.
|
||||
The automatic `GITEA_TOKEN` runs workflows but **cannot push OCI packages** ([gitea#23642](https://github.com/go-gitea/gitea/issues/23642)); registry authentication therefore uses `REGISTRY_PAT` + `REGISTRY_USER`. No Laravel/`APP_KEY`/DB/R2/Resend secrets belong in Gitea for this pipeline.
|
||||
|
||||
## Workflows
|
||||
|
||||
Workflows live in [`.gitea/workflows/`](../../.gitea/workflows/).
|
||||
|
||||
`actions/cache` and Docker Buildx `type=gha` are **not** used: the act_runner cache server is not reachable from job containers by default (`getCacheEntry` ETIMEDOUT). Re-enable only after configuring a reachable `cache.host`/`external_server` on the runner.
|
||||
|
||||
CI Postgres services must **not** publish host port `5432` (use service hostname `postgres` on the job network). Publishing `5432:5432` on a shared VPS runner fails with `Bind for 0.0.0.0:5432 failed: port is already allocated` when another job/orphan still holds the port.
|
||||
|
||||
Parallel CI jobs need the act_runner `config.yaml` to keep `container.network` **empty** (per-job Docker network + service DNS) and `runner.capacity` ≥ 2. Setting `network: bridge` puts every job on the default bridge and makes parallel Postgres collide. Nested app containers (browser/container jobs) must join that job network by name and must **not** publish host `:8000`. On the current 1 vCPU / ~4 GiB VPS, `capacity: 2` is the safe ceiling.
|
||||
|
||||
### Staging (automatic)
|
||||
|
||||
[`.github/workflows/deploy-staging.yml`](../../.github/workflows/deploy-staging.yml)
|
||||
[`.gitea/workflows/deploy-staging.yml`](../../.gitea/workflows/deploy-staging.yml) — separate workflow, triggered by `workflow_run` when **CI** completes on `main`.
|
||||
|
||||
1. Waits for workflow `CI` success on push to `main`.
|
||||
2. Builds once; pushes `:<full-sha>` and `:staging`.
|
||||
Requires **Gitea ≥ 1.25** (`workflow_run` is not implemented as an Actions trigger in 1.24.x). Match both workflow display name `CI` and file id `ci.yml`.
|
||||
|
||||
1. Waits for workflow `CI` success (`workflow_run`) on push to `main`.
|
||||
2. Builds once; pushes `:<full-sha>` and `:staging` to the Gitea registry.
|
||||
3. Calls Dokploy `compose.deploy` and polls until done.
|
||||
4. Runs [`scripts/deploy/smoke.sh`](../../scripts/deploy/smoke.sh) against `STAGING_URL`.
|
||||
|
||||
Manual re-deploy: **Actions → Deploy staging → Run workflow** (`workflow_dispatch`).
|
||||
|
||||
`DOKPLOY_API_KEY` must be the **plaintext** key from Dokploy → Profile → API (starts like `amare…`). Do not paste the hashed `apikey.key` column from Postgres — that yields HTTP 401.
|
||||
|
||||
### Production (manual)
|
||||
|
||||
[`.github/workflows/promote-production.yml`](../../.github/workflows/promote-production.yml)
|
||||
[`.gitea/workflows/promote-production.yml`](../../.gitea/workflows/promote-production.yml)
|
||||
|
||||
1. Operator runs **Actions → Promote production**.
|
||||
2. Inputs: full `sha` already on GHCR; `confirm` must be exactly `PRODUCTION`.
|
||||
1. Operator runs **Gitea → Actions → Promote production**.
|
||||
2. Inputs: full `sha` already in the Gitea registry; `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.
|
||||
Human approval is the explicit `workflow_dispatch` + confirmation string (Gitea does not support GitHub Environment required reviewers).
|
||||
|
||||
## First admin and authorized production seeding
|
||||
|
||||
@@ -256,10 +275,10 @@ 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
|
||||
# Locally or in a one-off Actions shell with Gitea registry login
|
||||
docker buildx imagetools create \
|
||||
--tag ghcr.io/<owner>/<repo>:staging \
|
||||
ghcr.io/<owner>/<repo>:<previous-sha>
|
||||
--tag git.hellomanoel.com/manoel-freitas/amare:staging \
|
||||
git.hellomanoel.com/manoel-freitas/amare:<previous-sha>
|
||||
|
||||
# Then trigger Dokploy deploy (UI Deploy, or):
|
||||
DOKPLOY_URL=... DOKPLOY_API_KEY=... DOKPLOY_COMPOSE_ID=... \
|
||||
@@ -297,7 +316,7 @@ Expects HTTP 200 for `/up`, `/`, and `/admin/login`.
|
||||
## Local validation of Compose
|
||||
|
||||
```bash
|
||||
APP_IMAGE=ghcr.io/<owner>/<repo> IMAGE_TAG=staging \
|
||||
APP_IMAGE=git.hellomanoel.com/manoel-freitas/amare IMAGE_TAG=staging \
|
||||
docker compose -f docker-compose.deploy.yml config
|
||||
```
|
||||
|
||||
|
||||
2
openspec/changes/package-detail-pages/.openspec.yaml
Normal file
2
openspec/changes/package-detail-pages/.openspec.yaml
Normal file
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-08-12
|
||||
96
openspec/changes/package-detail-pages/design.md
Normal file
96
openspec/changes/package-detail-pages/design.md
Normal file
@@ -0,0 +1,96 @@
|
||||
## Context
|
||||
|
||||
`WeddingPackage` já cobre cards ordenados + CTA WhatsApp/briefing (change `restructure-home-weddings-corporate`, capability ainda só no delta completo). Campos atuais: `name`, `level`, `summary`, `scope_items` (json), `cta_label`, `sort_order`, `published_at`. Sem slug, sem corpo de detalhe, sem imagens. Portfólio (`portfolio.show`, `FindPublishedPortfolioCaseBySlug`, PageMeta, sitemap) é o padrão de detalhe a espelhar. Mock visual: `/home/manoelfreitas/Downloads/amare-assessoria-completa.html`. Tokens: `DESIGN.md` / `resources/css/tokens.css` (Heritage Editorial) — não Cormorant/Inter do HTML estático.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- Detalhe CMS-driven `/pacotes/{slug}` para as 3 modalidades, publish-gated.
|
||||
- Composição mock → tokens do projeto (olive accent, EB Garamond via stack existente, bg cream).
|
||||
- CTA final = mesmo canal contextual dos cards (`wa.me` ou `/briefing?servico_interesse=`).
|
||||
- SEO mínimo: meta por registro, canonical, OG image (hero ou default), entrada no sitemap.
|
||||
- Admin Filament editável sem deploy de copy.
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- Rota índice `/pacotes`; substituir cards da home; WhatsApp Business API; preços; multi-idioma; Livewire público; gallery multi-imagem além de hero + audience.
|
||||
|
||||
## Decisions
|
||||
|
||||
### Estender `WeddingPackage`, não criar modelo novo
|
||||
|
||||
Uma entidade comercial já existe e é o que home/serviços já consomem. Detalhe é projeção do mesmo agregado. Modelo paralelo geraria sync de publicação/slug e viola YAGNI.
|
||||
|
||||
### Slug único + publicação por `published_at`
|
||||
|
||||
Igual portfólio: URL estável, scope `published()`, query Application `FindPublishedWeddingPackageBySlug`. Draft/inexistente → 404 HTTP, sem vazar campos internos.
|
||||
|
||||
### Campos de detalhe (additive)
|
||||
|
||||
| Campo | Uso |
|
||||
|-------|-----|
|
||||
| `slug` | string unique |
|
||||
| `eyebrow` | ex. "Assessoria" |
|
||||
| `title_line` | parte romana do H1 |
|
||||
| `title_emphasis` | parte itálica do H1 |
|
||||
| `hero_lead` | parágrafo do hero |
|
||||
| `hero_image_path` / `hero_image_alt` | foto hero |
|
||||
| `benefits` | jsonb `[{icon_key,label}]` — faixa escura |
|
||||
| `included_items` | jsonb `[{icon_key,title,description}]` — grid 3 col |
|
||||
| `audience_heading` | default editorial se vazio ok |
|
||||
| `audience_intro` | texto |
|
||||
| `audience_points` | jsonb `string[]` checklist |
|
||||
| `audience_image_path` / `audience_image_alt` | foto 50/50 |
|
||||
| `final_cta_heading` / `final_cta_body` | bloco final |
|
||||
| `meta_title` / `meta_description` | SEO opcional |
|
||||
|
||||
Manter `summary` + `scope_items` para cards. Não migrar cards para `included_items` neste ciclo.
|
||||
|
||||
### Catálogo fechado de ícones (`PackageIconCatalog`)
|
||||
|
||||
Select Filament → `icon_key` string. Map PHP/Blade para SVGs inline (ou partials). Sem upload de SVG arbitrário (XSS/ops). Chaves mínimas cobrem mock (ex.: calendar, checklist, users, map, heart, spark — nomes estáveis kebab).
|
||||
|
||||
### Hero de pacote vs `photo-hero` genérico
|
||||
|
||||
Mock exige H1 bipartido (linha + ênfase itálica), divisor vertical, CTA outline no hero e foto. Preferir componente dedicado `x-public.package-hero` (ou estender `photo-hero` só se API ficar genérica sem branching feio). Seções: `package-benefits`, `package-included`, `package-audience`, reutilizar `final-cta` se encaixar.
|
||||
|
||||
### CTA WhatsApp
|
||||
|
||||
Reutilizar VO/helper já usado nos cards (digits de `SiteSetting.whatsapp_number`, mensagem com nome da modalidade). Mesma regra de fallback briefing. Não duplicar lógica de normalização.
|
||||
|
||||
### Links a partir dos cards
|
||||
|
||||
Card: título/área → `route('packages.show', $package)` quando slug presente; botão CTA continua WhatsApp/briefing. Sem quebrar testes de CTA existentes.
|
||||
|
||||
### Imagens
|
||||
|
||||
`PublicImageUploadRules` + disk público existente + `x-media.image`. Paths nullable; se hero ausente, layout degrada (omitir slot de imagem).
|
||||
|
||||
### Camada Application
|
||||
|
||||
- `FindPublishedWeddingPackageBySlug`
|
||||
- Estender `GetSitemapEntries` com slugs de pacotes publicados
|
||||
- PageMeta no controller (espelhar PortfolioCaseController)
|
||||
|
||||
Sem repository genérico / BaseAction.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [Capability `wedding-packages` ainda não está em `openspec/specs/`] → delta ADDED nesta change; ao arquivar, consolidar com requirements de cards do change completo ou arquivar ambos em ordem.
|
||||
- [Copy das 3 modalidades incompleta] → seeder: Essenza = mock; Conduzione/Grand Jour estrutura paralela adaptada; admin pode editar.
|
||||
- [Ícones insuficientes no catálogo] → adicionar chave no map + opção Filament; sem free-text SVG.
|
||||
- [Cards sem slug durante migrate] → migration backfill slug a partir do name; seeder garante os 3 oficiais.
|
||||
- [Duplicação visual mock vs tokens] → testes de tokens existentes + asserts de classes/roles; não copiar hex do HTML.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. Deploy migration additive + código + assets Blade juntos.
|
||||
2. Rodar seeder/update das 3 modalidades (slug + detalhe).
|
||||
3. Verificar `/pacotes/essenza` (etc.), 404 draft, sitemap, CTA WA.
|
||||
4. Rollback: remover rota/views; colunas additive podem permanecer; unpublish pacotes se necessário.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- Nenhuma bloqueante (decisões de produto já confirmadas: detalhe-only, extend model, 3 modalidades, WA CTA).
|
||||
- WEB-ID dedicado a package detail: não existe no SPEC; rastrear via WEB-02 + padrão WEB-03 até SPEC ganhar ID explícito.
|
||||
32
openspec/changes/package-detail-pages/proposal.md
Normal file
32
openspec/changes/package-detail-pages/proposal.md
Normal file
@@ -0,0 +1,32 @@
|
||||
## Why
|
||||
|
||||
Modalidades de casamento (Essenza, Conduzione, Grand Jour) existem no CMS e na home/serviços só como cards com CTA WhatsApp. Não há página de detalhe: o visitante não consegue ler o que está incluso, para quem é o pacote ou prova visual editorial antes de converter. O mock aprovado (`amare-assessoria-completa.html`) define a jornada; falta rota CMS-driven `/pacotes/{slug}` alinhada a WEB-02/WEB-03 e ao padrão de detalhe do portfólio.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Estender `WeddingPackage` com slug estável, campos editoriais de detalhe (hero, benefícios, itens inclusos, audiência, imagens, SEO opcional) sem remover `summary`/`scope_items` dos cards.
|
||||
- Expor rota pública `packages.show` em `/pacotes/{slug}`: só publicados; rascunho → 404 (mesmo contrato de `portfolio.show`).
|
||||
- Renderizar página de detalhe Heritage Editorial espelhando o mock (hero split, faixa de benefícios, grid “O que está incluso”, bloco audiência, CTA final WhatsApp contextual com fallback briefing).
|
||||
- Incluir slugs publicados no sitemap; meta title/description/canonical/OG por pacote.
|
||||
- Filament: seções de detalhe + catálogo fechado de ícones SVG; seeder preenche as 3 modalidades.
|
||||
- Cards na home e em `/servicos` passam a linkar para o detalhe (navegação secundária); CTA primário permanece WhatsApp/briefing.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `wedding-packages`: modalidades publicáveis com detalhe por slug (baseline ainda não arquivada em `openspec/specs/`; esta change define o contrato de detalhe e reafirma cards/CTA já entregues).
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `public-site-pages`: incluir rota `packages.show` (`/pacotes/{slug}`) e regra de 404 para modalidade não publicada; cards de modalidade podem apontar ao detalhe.
|
||||
- `public-seo`: sitemap e metadados de página para modalidades publicadas.
|
||||
- `service-catalog`: listagem de serviços/casamentos referencia o detalhe da modalidade quando existir slug publicado.
|
||||
|
||||
## Impact
|
||||
|
||||
Migration/model/factory/seeder `WeddingPackage`; query `FindPublishedWeddingPackageBySlug`; controller + rota + view Blade; componentes de seção; `GetSitemapEntries` / PageMeta; Filament `WeddingPackageResource`; links em partials de packages (home/serviços); testes feature (200/404, sitemap, CTA, conteúdo). Atende extensão de WEB-01/WEB-02 e o padrão de detalhe de WEB-03. Sem page builder, sem índice `/pacotes`, sem integração oficial WhatsApp, sem CRM (SPEC.md §4.2).
|
||||
|
||||
## Não objetivos
|
||||
|
||||
- Índice de pacotes, substituir `/servicos`, builder genérico, preços dinâmicos, checkout, multi-idioma, Livewire no site público, inventar prova ou cases Corporate (SPEC.md §4.2).
|
||||
@@ -0,0 +1,84 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Every public page emits title, description and canonical
|
||||
|
||||
The system SHALL render a unique `<title>`, a `<meta name="description">` and a `<link rel="canonical">` on every public route (SPEC §6.6, §19). Portfolio cases and wedding modalities MUST use `meta_title`/`meta_description` when filled and fall back to title/name and summary/hero lead otherwise. Pages without page-level metadata MUST fall back to `default_meta_title` and `default_meta_description` from `site_settings`.
|
||||
|
||||
#### Scenario: Page-level metadata overrides defaults
|
||||
|
||||
- **GIVEN** a published case with `meta_title` and `meta_description` filled
|
||||
- **WHEN** a visitor loads the case detail
|
||||
- **THEN** the rendered title and description MUST use the case values
|
||||
|
||||
#### Scenario: Wedding modality metadata overrides defaults
|
||||
|
||||
- **GIVEN** a published wedding modality with `meta_title` and `meta_description` filled
|
||||
- **WHEN** a visitor loads `/pacotes/{slug}`
|
||||
- **THEN** the rendered title and description MUST use the modality values
|
||||
|
||||
#### Scenario: Missing metadata falls back to site defaults
|
||||
|
||||
- **GIVEN** a published case without `meta_title`
|
||||
- **WHEN** a visitor loads the case detail
|
||||
- **THEN** the rendered title MUST be derived from the case title
|
||||
- **AND** the description MUST fall back to the case summary or the site default
|
||||
|
||||
#### Scenario: Canonical points to the absolute route URL
|
||||
|
||||
- **WHEN** any public page is rendered
|
||||
- **THEN** the canonical URL MUST be the absolute URL of that route without query parameters
|
||||
|
||||
### Requirement: Open Graph metadata is emitted for sharing
|
||||
|
||||
The system SHALL emit Open Graph tags (`og:title`, `og:description`, `og:type`, `og:url`, `og:image`) on public pages. The image MUST use the page cover/hero image when available and `default_og_image_path` from `site_settings` otherwise.
|
||||
|
||||
#### Scenario: Case detail uses its cover as OG image
|
||||
|
||||
- **GIVEN** a published case with a cover image
|
||||
- **WHEN** the case detail is rendered
|
||||
- **THEN** `og:image` MUST reference the case cover image URL
|
||||
|
||||
#### Scenario: Wedding modality detail uses hero as OG image
|
||||
|
||||
- **GIVEN** a published wedding modality with a hero image
|
||||
- **WHEN** `/pacotes/{slug}` is rendered
|
||||
- **THEN** `og:image` MUST reference the modality hero image URL
|
||||
|
||||
#### Scenario: Pages without cover use the default OG image
|
||||
|
||||
- **WHEN** a page without its own image is rendered
|
||||
- **THEN** `og:image` MUST reference `default_og_image_path`
|
||||
|
||||
### Requirement: Sitemap and robots are served by the application
|
||||
|
||||
The system SHALL serve `/sitemap.xml` listing the home, institutional routes, the services listing, the portfolio listing, every published case slug, and every published wedding modality slug with its last modification date. `/robots.txt` MUST be served by an application route referencing the sitemap URL.
|
||||
|
||||
#### Scenario: Sitemap contains only published slugs
|
||||
|
||||
- **GIVEN** one published case and one draft case
|
||||
- **WHEN** `/sitemap.xml` is requested
|
||||
- **THEN** the response MUST include the published slug
|
||||
- **AND** MUST NOT include the draft slug
|
||||
|
||||
#### Scenario: Sitemap contains published wedding modality slugs
|
||||
|
||||
- **GIVEN** one published wedding modality and one draft wedding modality
|
||||
- **WHEN** `/sitemap.xml` is requested
|
||||
- **THEN** the response MUST include `/pacotes/{published-slug}`
|
||||
- **AND** MUST NOT include the draft modality slug
|
||||
|
||||
#### Scenario: Newly published case enters the sitemap
|
||||
|
||||
- **WHEN** an admin publishes a case
|
||||
- **THEN** the case slug MUST appear in `/sitemap.xml` on the next request
|
||||
|
||||
#### Scenario: Newly published wedding modality enters the sitemap
|
||||
|
||||
- **WHEN** an admin publishes a wedding modality
|
||||
- **THEN** the modality package URL MUST appear in `/sitemap.xml` on the next request
|
||||
|
||||
#### Scenario: Robots references the sitemap
|
||||
|
||||
- **WHEN** `/robots.txt` is requested
|
||||
- **THEN** the response MUST be `text/plain`
|
||||
- **AND** MUST contain the absolute `/sitemap.xml` URL
|
||||
@@ -0,0 +1,57 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Public routes serve published content without authentication
|
||||
|
||||
The system SHALL expose the public routes of SPEC §5.1 plus the additive package detail route: `home` (`/`), `services.index` (`/servicos`), `packages.show` (`/pacotes/{slug}`), `portfolio.index` (`/portfolio`), `portfolio.show` (`/portfolio/{slug}`), `about` (`/sobre`), `contact` (`/contato`) and `privacy` (`/privacidade`). Every public route MUST respond without authentication and MUST NOT expose unpublished content, internal fields, or internal notes (SPEC §19).
|
||||
|
||||
#### Scenario: Guest reaches every public route
|
||||
|
||||
- **WHEN** an unauthenticated visitor requests any public route listed above that has published content where required
|
||||
- **THEN** the response status MUST be 200
|
||||
- **AND** no redirect to `/admin/login` MUST occur
|
||||
|
||||
#### Scenario: Unpublished content is invisible
|
||||
|
||||
- **GIVEN** a service, portfolio case, wedding modality, or testimonial with `published_at` null
|
||||
- **WHEN** a visitor loads the corresponding public listing or home section
|
||||
- **THEN** the record MUST NOT appear in the rendered output
|
||||
|
||||
#### Scenario: Unpublished case detail returns 404
|
||||
|
||||
- **GIVEN** a portfolio case saved as draft
|
||||
- **WHEN** a visitor requests `/portfolio/{slug}` for that case
|
||||
- **THEN** the response status MUST be 404
|
||||
|
||||
#### Scenario: Unpublished wedding modality detail returns 404
|
||||
|
||||
- **GIVEN** a wedding modality saved as draft
|
||||
- **WHEN** a visitor requests `/pacotes/{slug}` for that modality
|
||||
- **THEN** the response status MUST be 404
|
||||
|
||||
#### Scenario: Published case detail becomes reachable
|
||||
|
||||
- **GIVEN** a portfolio case saved as draft
|
||||
- **WHEN** an admin fills the required fields and publishes the case
|
||||
- **THEN** `/portfolio/{slug}` MUST respond 200
|
||||
- **AND** the case MUST appear in the `/portfolio` listing
|
||||
|
||||
#### Scenario: Published wedding modality detail becomes reachable
|
||||
|
||||
- **GIVEN** a wedding modality saved as draft with required detail fields
|
||||
- **WHEN** an admin publishes the modality
|
||||
- **THEN** `/pacotes/{slug}` MUST respond 200
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Wedding modality detail uses Heritage Editorial public layout
|
||||
The system SHALL render `/pacotes/{slug}` with the public layout (header/footer shared), Heritage Editorial tokens, and section order: package hero (eyebrow, bipartite title, lead, outline secondary actions as designed, hero media), dark benefit strip, included-items grid, audience split (media + checklist), final conversion CTA. The page MUST be server-rendered Blade without a public Livewire dependency. Missing optional images MUST NOT break the page (omit or degrade the media slot).
|
||||
|
||||
#### Scenario: Detail page section order
|
||||
- **GIVEN** a published modality with complete detail content
|
||||
- **WHEN** a visitor loads `/pacotes/{slug}`
|
||||
- **THEN** the document MUST include the hero, benefits, included items, audience and final CTA regions in that order
|
||||
- **AND** MUST use the public layout chrome
|
||||
|
||||
#### Scenario: Detail page has no console errors
|
||||
- **WHEN** the modality detail is loaded in a real browser at desktop and mobile viewports
|
||||
- **THEN** the browser console MUST contain no JavaScript errors
|
||||
@@ -0,0 +1,9 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Services wedding section links to modality detail
|
||||
When the services page renders published wedding modalities, each modality card SHALL expose a link to `/pacotes/{slug}` for published records that have a slug, while keeping the existing WhatsApp/briefing conversion CTA.
|
||||
|
||||
#### Scenario: Services card navigates to package detail
|
||||
- **GIVEN** at least one published wedding modality with a slug
|
||||
- **WHEN** a visitor loads `/servicos`
|
||||
- **THEN** the wedding modalities region MUST include a link to that modality's `/pacotes/{slug}` URL
|
||||
@@ -0,0 +1,58 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Wedding modalities are managed as publishable ordered content
|
||||
The system SHALL let an admin manage `WeddingPackage` records with name, unique slug, level, summary, ordered scope items, CTA label, sort order and `published_at`. Only published records SHALL appear in public wedding card sections, in ascending `sort_order`.
|
||||
|
||||
#### Scenario: Published modalities follow CMS order
|
||||
- **GIVEN** published and draft wedding modalities with different sort orders
|
||||
- **WHEN** a visitor loads the home or services page
|
||||
- **THEN** only published modalities appear in ascending `sort_order`
|
||||
|
||||
#### Scenario: Official initial modalities are available
|
||||
- **WHEN** the content seeder runs
|
||||
- **THEN** Essenza, Conduzione and Grand Jour are created as the official initial modalities with stable unique slugs
|
||||
|
||||
### Requirement: Wedding modality CTA has a contextual channel and conversion fallback
|
||||
The system SHALL build a WhatsApp deeplink containing the selected modality when `whatsapp_number` is valid. When it is missing or invalid, the CTA SHALL point to `/briefing` with the modality prefilled as `servico_interesse`. The same rule MUST apply on modality cards and on the modality detail page final CTA.
|
||||
|
||||
#### Scenario: Valid WhatsApp setting creates contextual link
|
||||
- **GIVEN** a valid official WhatsApp number
|
||||
- **WHEN** a visitor activates a wedding modality CTA on a card or detail page
|
||||
- **THEN** the link targets `wa.me` with a URL-encoded message naming that modality
|
||||
|
||||
#### Scenario: Missing WhatsApp setting preserves briefing conversion
|
||||
- **GIVEN** no valid official WhatsApp number
|
||||
- **WHEN** a visitor activates a wedding modality CTA on a card or detail page
|
||||
- **THEN** the visitor reaches `/briefing` with `servico_interesse` prefilled
|
||||
|
||||
### Requirement: Wedding modality detail content is CMS-managed
|
||||
The system SHALL store per-modality detail fields: eyebrow, bipartite title (`title_line`, `title_emphasis`), hero lead, optional hero image with alt, ordered benefits (`icon_key`, label), ordered included items (`icon_key`, title, description), audience heading/intro/points and optional audience image with alt, final CTA heading/body, and optional `meta_title` / `meta_description`. Icon keys MUST come from a closed catalog enforced in admin validation. `summary` and `scope_items` MUST remain available for card surfaces.
|
||||
|
||||
#### Scenario: Admin can edit detail sections
|
||||
- **WHEN** an admin opens a WeddingPackage in Filament
|
||||
- **THEN** the form MUST expose detail sections for hero, benefits, included items, audience, final CTA and SEO
|
||||
- **AND** icon fields MUST offer only catalog keys
|
||||
|
||||
#### Scenario: Seeder fills detail for official modalities
|
||||
- **WHEN** the content seeder runs
|
||||
- **THEN** each official modality MUST have slug and non-empty hero lead plus at least one benefit and one included item suitable for public render
|
||||
|
||||
### Requirement: Published modality detail page is publicly reachable by slug
|
||||
The system SHALL expose `packages.show` at `/pacotes/{slug}` without authentication. Only modalities with non-null `published_at` in the past or present MUST resolve. Draft or unknown slugs MUST return HTTP 404 without exposing internal fields.
|
||||
|
||||
#### Scenario: Published modality detail returns 200
|
||||
- **GIVEN** a published wedding modality with slug `essenza`
|
||||
- **WHEN** a visitor requests `/pacotes/essenza`
|
||||
- **THEN** the response status MUST be 200
|
||||
- **AND** the page MUST render hero title parts, benefits, included items, audience block and final CTA using CMS values
|
||||
|
||||
#### Scenario: Draft modality detail returns 404
|
||||
- **GIVEN** a wedding modality saved as draft
|
||||
- **WHEN** a visitor requests `/pacotes/{slug}` for that modality
|
||||
- **THEN** the response status MUST be 404
|
||||
|
||||
#### Scenario: Cards link to the detail page
|
||||
- **GIVEN** a published modality with a slug
|
||||
- **WHEN** a visitor views the home or services wedding cards
|
||||
- **THEN** a navigation control MUST link to `/pacotes/{slug}`
|
||||
- **AND** the primary conversion CTA MUST remain the WhatsApp or briefing channel
|
||||
29
openspec/changes/package-detail-pages/tasks.md
Normal file
29
openspec/changes/package-detail-pages/tasks.md
Normal file
@@ -0,0 +1,29 @@
|
||||
## 1. Data model and domain catalog
|
||||
|
||||
- [x] 1.1 Migration additive em `wedding_packages`: `slug` unique (backfill a partir de `name`), campos de detalhe (eyebrow, title_line, title_emphasis, hero_lead, hero_image_*, benefits jsonb, included_items jsonb, audience_*, final_cta_*, meta_*), atualizar model `$fillable`/casts e factory. Evidência: `database/migrations/2026_08_12_120000_extend_wedding_packages_for_detail_pages.php` (roda limpo), `app/Models/WeddingPackage.php` (fillable+casts+booted slug), `database/factories/WeddingPackageFactory.php` (slug + campos de detalhe).
|
||||
- [x] 1.2 `PackageIconCatalog` (chaves fechadas + labels pt-BR) e validação de `icon_key` em benefits/included_items. Evidência: `app/Domain/Marketing/PackageIconCatalog.php` (6 chaves: calendar/checklist/users/map/heart/spark), Select icon_key em `WeddingPackageForm` usa `PackageIconCatalog::labels()`.
|
||||
- [x] 1.3 Feature/unit: factory published com slug; scope published inalterado; icon_key inválido rejeitado na validação admin/request path usado pelo Filament. Evidência: `WeddingPackageTest` (4 testes: scope published + seeder, factory slug autofill, icon catalog aceita/rejeita `paw`).
|
||||
|
||||
## 2. Application + public route
|
||||
|
||||
- [x] 2.1 Query `FindPublishedWeddingPackageBySlug` + teste 200/null para draft. Evidência: `app/Application/Queries/Marketing/FindPublishedWeddingPackageBySlug.php`; `FindPublishedWeddingPackageBySlugTest` (retorna published, null para draft/inexistente).
|
||||
- [x] 2.2 `PackageController@show`, rota nomeada `packages.show` `/pacotes/{slug}`, PageMeta (meta_title/description fallback name/summary|hero_lead, OG hero). Evidência: `app/Http/Controllers/PublicSite/PackageController.php`, `routes/web.php` (`packages.show`), `PageMeta::forPackage` (title=meta_title??title_line??name, description=meta_description??summary, ogImageUrl=hero_image_path).
|
||||
- [x] 2.3 Estender `GetSitemapEntries` com pacotes publicados; teste sitemap inclui/exclui draft. Evidência: `GetSitemapEntries` appends pacotes published; `SitemapTest::test_sitemap_includes_only_published_packages` (inclui `essenza`, exclui `rascunho-pacote`).
|
||||
|
||||
## 3. Blade detail (Heritage Editorial)
|
||||
|
||||
- [x] 3.1 View `pages/packages/show` + componentes `package-hero`, `package-benefits`, `package-included`, `package-audience`; layout `layouts.public`; tokens DESIGN.md (sem hex do mock). Evidência: `resources/views/pages/packages/show.blade.php` + `resources/views/components/public/package-{hero,benefits,included,audience}.blade.php` + `resources/views/components/package/icon.blade.php` (SVG de catálogo); classes `amare-*` e `text-hero-spread` (tokens), sem hex do mock.
|
||||
- [x] 3.2 CTA final reutiliza builder WhatsApp/briefing dos cards; teste feature assert `wa.me` / `briefing?servico_interesse`. Evidência: `app/Support/PackageContactLink::for()` (helper compartilhado), `package-final-cta.blade.php` usa builder; `PackageDetailTest` (tests 4-5: wa.me com número, briefing fallback).
|
||||
- [x] 3.3 Feature: render de seções a partir do CMS; 404 draft; guest sem auth. Evidência: `PackageDetailTest` (5 testes: render de seções CMS assertSee, 404 draft, 404 slug inexistente, CTA wa.me/briefing).
|
||||
|
||||
## 4. Filament + seeder + card links
|
||||
|
||||
- [x] 4.1 `WeddingPackageResource`: seções Hero, Benefícios, Inclusos, Audiência, CTA final, SEO; FileUpload imagens; Select icon_key do catálogo. Evidência: `app/Filament/Resources/WeddingPackages/Schemas/WeddingPackageForm.php` (sections Identificação/Hero/Diferenciais/Inclusos/Audiência/CTA final/SEO; `PublicImageUploadRules::fileUpload/altTextField`; `Select icon_key` options `PackageIconCatalog::labels()`); `WeddingPackagesTable` ganhou coluna `slug`.
|
||||
- [x] 4.2 Seeder/update Essenza (copy mock), Conduzione e Grand Jour (estrutura paralela) com slugs `essenza`, `conduzione`, `grand-jour`. Evidência: `database/seeders/WeddingPackagesSeeder.php` (3 pacotes com slug + campos de detalhe completos); `WeddingPackageTest::test_content_seeder_creates_official_wedding_modalities` verde.
|
||||
- [x] 4.3 Home + `/servicos` cards: link secundário para `packages.show`; CTA primário WA intacto; testes de regressão de CTA. Evidência: `home/packages.blade.php` + `home/wedding-packages.blade.php` + `services/index.blade.php` (link 'Conhecer esta modalidade' + CTA primário refatorado via `PackageContactLink`); `HomePageContentTest` (22 testes: 143 assertions, incl. href packages.show).
|
||||
|
||||
## 5. Verification gate
|
||||
|
||||
- [x] 5.1 `composer pint:check` + `composer phpstan` no escopo alterado. Evidência: `vendor/bin/pint --test` passou (fix `ordered_imports` em `routes/web.php`); `composer phpstan` (level 5, `--memory-limit=1G --debug`) = No errors.
|
||||
- [x] 5.2 `composer test:unit` + `composer test:feature` verdes (incl. novos testes de pacote). Evidência: `test:unit` 42 passed (186 assertions); `test:feature` 193 passed (1182 assertions); `PackageDetailTest` (5), `FindPublishedWeddingPackageBySlugTest`, `SitemapTest` (3) verdes.
|
||||
- [x] 5.3 `openspec validate package-detail-pages` e marcar tasks só com evidência. Evidência: `openspec validate package-detail-pages` = "Change 'package-detail-pages' is valid"; tasks 1.1-5.3 marcadas acima com referências.
|
||||
2
openspec/changes/remodel-about-page/.openspec.yaml
Normal file
2
openspec/changes/remodel-about-page/.openspec.yaml
Normal file
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-08-12
|
||||
27
openspec/changes/remodel-about-page/design.md
Normal file
27
openspec/changes/remodel-about-page/design.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# Design: remodel About page
|
||||
|
||||
## Context
|
||||
|
||||
Mock `amare-sobre(1).html` define composição e copy. O site já tem Heritage Editorial (`tokens.css`, `DESIGN.md`), `about_image_*` no CMS, e `PortfolioCase` featured na home.
|
||||
|
||||
## Decisions
|
||||
|
||||
1. **Hero** reusa `about_image_path` com layout split próprio (`x-about.hero`), não o full-viewport `x-public.photo-hero`. Fallback tonal quando sem imagem.
|
||||
2. **Michele** usa `founder_image_*` novos; sem path → coluna tonal, sem request de imagem inventada.
|
||||
3. **Portfólio** lê até 6 casos `published` + `is_featured` via `GetAboutContent` (mesma regra da home).
|
||||
4. **Copy** de pilares/Michele/processo/CTA fixa do mock; `about_summary` alimenta lead/SEO.
|
||||
5. **Visual**: estrutura do mock; radius 0, EB Garamond, botões `btn` existentes — desvios tipográficos/pill do HTML são descartados.
|
||||
|
||||
## Spine
|
||||
|
||||
```
|
||||
PageController::about
|
||||
→ GetAboutContent
|
||||
→ AboutContent
|
||||
→ pages/about.blade.php + x-about.*
|
||||
```
|
||||
|
||||
## Risks
|
||||
|
||||
- Testes que acoplam `/sobre` a `data-photo-hero` full-vh precisam atualizar o contrato.
|
||||
- Home positioning continua usando `about_image_path` — não reaproveitar esse campo como foto da Michele.
|
||||
26
openspec/changes/remodel-about-page/proposal.md
Normal file
26
openspec/changes/remodel-about-page/proposal.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# Remodel página Sobre conforme mock aprovado
|
||||
|
||||
## Why
|
||||
|
||||
A rota `/sobre` ainda usa abertura `photo-hero` full-viewport + lista de princípios. O cliente validou o mock `amare-sobre(1).html` com jornada editorial própria: hero split, pilares, bloco Michele, processo, portfólio em destaque e CTA. Sem essa remodelação, a página institucional fica desalinhada da home e do material aprovado.
|
||||
|
||||
## What Changes
|
||||
|
||||
- `/sobre` recomposta em seções `x-about.*` espelhando a ordem do mock (hero, pilares, Michele, processo, portfólio masonry, CTA).
|
||||
- Novo campo CMS `founder_image_path` / `founder_image_alt` no singleton `site_settings` para a foto da Michele; hero continua em `about_image_path`.
|
||||
- Query `GetAboutContent` + DTO `AboutContent` (featured cases ≤6) no spine Application.
|
||||
- Tokens Heritage Editorial do repo (sem Inter/Cormorant, sem pills do HTML estático).
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- CMS para copy dos pilares/Michele/processo.
|
||||
- Replicar header/footer do HTML estático.
|
||||
- Alterar tokens globais assertados por `HeritageEditorialTokensTest`.
|
||||
- Gerar fotografia da Michele (apenas upload).
|
||||
|
||||
## Capabilities
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `public-site-pages`: `/sobre` com composição do mock; motion contract preservado.
|
||||
- `site-settings`: campos `founder_image_path` e `founder_image_alt` administráveis no Filament.
|
||||
@@ -0,0 +1,32 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: About page follows approved editorial composition
|
||||
|
||||
The `/sobre` route SHALL render the Heritage Editorial public layout with this section order: split hero (eyebrow “Sobre nós”, title “Sobre a Amare”, lead from `about_summary` or editorial default, CTA to `#michele`), three pillars, founder block `#michele` (Michele), process (“Como trabalhamos”), featured portfolio masonry (up to six published featured cases), and a final proposal CTA. The page MUST use shared motion markers (`data-motion="page-open"`, `data-reveal*`) without route transitions. Principles list MUST NOT appear on `/sobre` (home may still show them). Contact, privacy and error surfaces remain unchanged by this requirement.
|
||||
|
||||
#### Scenario: Visitor sees the mock section order
|
||||
|
||||
- **WHEN** a visitor loads `/sobre`
|
||||
- **THEN** the response MUST include the headings for Sobre a Amare, the three pillars, Conheça Michele, Como trabalhamos, Portfólio em destaque, and the proposal CTA copy
|
||||
- **AND** MUST NOT render the numbered principles list formerly used on About
|
||||
|
||||
#### Scenario: Founder photo comes from CMS when configured
|
||||
|
||||
- **GIVEN** `site_settings.founder_image_path` is set with alt text
|
||||
- **WHEN** a visitor loads `/sobre`
|
||||
- **THEN** the Michele section MUST render that image with the configured alt
|
||||
- **AND** MUST use eager loading only for the about hero image, not invent a founder asset path when unset
|
||||
|
||||
#### Scenario: About hero uses about_image with tonal fallback
|
||||
|
||||
- **WHEN** `about_image_path` is configured
|
||||
- **THEN** `/sobre` MUST render a split editorial hero with that media (`loading="eager"` and `fetchpriority="high"`)
|
||||
- **WHEN** `about_image_path` is empty
|
||||
- **THEN** `/sobre` MUST render a tonal hero fallback without an empty image request
|
||||
|
||||
#### Scenario: Featured portfolio tiles use real cases
|
||||
|
||||
- **GIVEN** published featured portfolio cases exist
|
||||
- **WHEN** a visitor loads `/sobre`
|
||||
- **THEN** the masonry MUST link to those cases (or the portfolio index)
|
||||
- **AND** MUST NOT invent decorative photography when no cases exist (tonal slots allowed)
|
||||
@@ -0,0 +1,47 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Site settings singleton is manageable by admin only
|
||||
|
||||
The system SHALL persist site-wide settings in a `site_settings` table as a typed singleton (SPEC WEB-06, §8.2). Fields MUST include brand name, optional logo path and logo alt text, hero copy (eyebrow, title, subtitle, primary CTA label, optional secondary CTA label, optional hero note), manifesto copy (title, lead, body), method steps (structured typed data for four editorial steps), principles (structured typed list), about summary, optional about hero image path and alt text, optional founder image path and alt text (Michele portrait for `/sobre`), contact email/phone/city, social links (jsonb), default meta title/description, default OG image path and alt text, and optional analytics fields disabled by default.
|
||||
|
||||
#### Scenario: Admin updates site settings
|
||||
|
||||
- **WHEN** an admin saves the site settings form in Filament
|
||||
- **THEN** the singleton record is updated
|
||||
- **AND** labels and validation messages are in pt-BR
|
||||
|
||||
#### Scenario: Founder image upload requires alt text
|
||||
|
||||
- **WHEN** an admin uploads a founder image without alt text
|
||||
- **THEN** validation MUST fail with a pt-BR error message
|
||||
- **AND** alt text MUST remain optional when no founder image is present
|
||||
|
||||
#### Scenario: Assistant cannot access site settings
|
||||
|
||||
- **WHEN** an assistant navigates to site settings in Filament
|
||||
- **THEN** access MUST be denied with HTTP 403
|
||||
|
||||
#### Scenario: Default OG image requires alt text
|
||||
|
||||
- **WHEN** an admin uploads a default OG image without alt text
|
||||
- **THEN** validation MUST fail with a pt-BR error message
|
||||
- **AND** alt text MUST remain optional when no default OG image is present
|
||||
|
||||
#### Scenario: Logo upload requires alt text
|
||||
|
||||
- **WHEN** an admin uploads a brand logo without alt text
|
||||
- **THEN** validation MUST fail with a pt-BR error message
|
||||
- **AND** alt text MUST remain optional when no logo is uploaded
|
||||
|
||||
#### Scenario: Singleton avoids generic key-value store
|
||||
|
||||
- **WHEN** site settings are stored
|
||||
- **THEN** the system MUST use typed columns on `site_settings`
|
||||
- **AND** MUST NOT introduce a generic key/value configuration table
|
||||
|
||||
#### Scenario: Editorial defaults remain available when optional fields are empty
|
||||
|
||||
- **GIVEN** manifesto, method steps or principles fields are empty
|
||||
- **WHEN** the home is rendered
|
||||
- **THEN** the page MUST still render those sections using safe editorial defaults
|
||||
- **AND** MUST NOT error
|
||||
8
openspec/changes/remodel-about-page/tasks.md
Normal file
8
openspec/changes/remodel-about-page/tasks.md
Normal file
@@ -0,0 +1,8 @@
|
||||
# Tasks: remodel-about-page
|
||||
|
||||
- [x] 1. Migration + SiteSetting fillable/PHPDoc para `founder_image_path` / `founder_image_alt`
|
||||
- [x] 2. Filament ManageSiteSettings: labels hero Sobre + upload Michele; media variants + seeders
|
||||
- [x] 3. AboutContent DTO + GetAboutContent; PageController injeta query
|
||||
- [x] 4. Componentes `x-about.*` + reescrever `pages/about.blade.php`
|
||||
- [x] 5. Feature tests About + atualizar ImmersivePhotoHero / Media / Motion / PublicPages / Filament alt
|
||||
- [ ] 6. PR verde
|
||||
@@ -67,10 +67,13 @@ The home page SHALL render, in order: header/navigation, hero, manifesto, featur
|
||||
### Requirement: Listing and detail pages exist for catalog content
|
||||
The system SHALL render a services listing (WEB-02) and a portfolio listing plus case detail (WEB-03) using the Heritage Editorial visual language. The case detail MUST present summary, event type, optional city/venue/date, challenge, solution, optional result, cover image and the ordered gallery. Portfolio listings and galleries MUST use alternating editorial image proportions on desktop while retaining DOM order and a single-column readable sequence on mobile.
|
||||
|
||||
#### Scenario: Services listing shows published services
|
||||
#### Scenario: Services listing renders editorial sections with published modalities
|
||||
|
||||
- **WHEN** a visitor loads `/servicos`
|
||||
- **THEN** every published service MUST be listed with title and summary in `sort_order`
|
||||
- **THEN** the page MUST present the two vertentes (Amare Casamentos and Amare Corporate) with CTAs to their sections
|
||||
- **AND** every published wedding modality MUST be rendered in `sort_order` with tag, name, subtitle, summary and scope
|
||||
- **AND** a quick-read comparison of the modalities MUST use each modality's `compare_heading` and `compare_summary`
|
||||
- **AND** unpublished modalities MUST NOT be rendered
|
||||
- **AND** the listing MUST use the public editorial layout (not an unrelated visual system)
|
||||
|
||||
#### Scenario: Gallery respects stored order
|
||||
|
||||
@@ -2,18 +2,18 @@
|
||||
|
||||
## Purpose
|
||||
|
||||
Define immutable staging and production promotion through GHCR and Dokploy Compose, including migration, health, smoke, rollback, and backup evidence.
|
||||
Define immutable staging and production promotion through the Gitea container registry (`git.hellomanoel.com`) and Dokploy Compose, including migration, health, smoke, rollback, and backup evidence.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Staging deploys an immutable application image by commit SHA
|
||||
|
||||
The system SHALL deploy staging from a single FrankenPHP application image tagged with the Git commit SHA and published to GHCR (SPEC §14.3, §15.2). Web, queue worker, and scheduler processes MUST use that same image digest. Rebuilds per process on the staging host MUST NOT be the promotion path.
|
||||
The system SHALL deploy staging from a single FrankenPHP application image tagged with the Git commit SHA and published to the Gitea container registry (SPEC §14.3, §15.2). Web, queue worker, and scheduler processes MUST use that same image digest. Rebuilds per process on the staging host MUST NOT be the promotion path.
|
||||
|
||||
#### Scenario: Same image serves all application processes
|
||||
|
||||
- **WHEN** a staging deployment is promoted for commit SHA `abc123`
|
||||
- **THEN** web, queue, and scheduler MUST run from `ghcr.io/<owner>/<repo>:abc123` (or equivalent digest)
|
||||
- **THEN** web, queue, and scheduler MUST run from `git.hellomanoel.com/<owner>/<repo>:abc123` (or equivalent digest)
|
||||
- **AND** MUST NOT rebuild distinct images per process
|
||||
|
||||
#### Scenario: CI publishes the image before Dokploy deploy
|
||||
@@ -69,7 +69,7 @@ Production SHALL be promoted from an already-published SHA-tagged image without
|
||||
#### 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`
|
||||
- **THEN** production web, queue, and scheduler MUST run the same digest previously published as `git.hellomanoel.com/<owner>/<repo>:abc123`
|
||||
- **AND** MUST NOT rebuild from source for that promotion
|
||||
|
||||
### Requirement: Database backups exist before production cutover
|
||||
|
||||
16
resources/views/components/about/cta.blade.php
Normal file
16
resources/views/components/about/cta.blade.php
Normal file
@@ -0,0 +1,16 @@
|
||||
@props([
|
||||
'settings',
|
||||
])
|
||||
|
||||
<section aria-labelledby="about-cta-heading" class="border-b border-amare-border bg-amare-bg-deep py-20 md:py-24" data-about-cta data-reveal-group>
|
||||
<div class="container-amare max-w-4xl space-y-6" data-reveal data-reveal-from="up">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Próximo passo</p>
|
||||
<h2 id="about-cta-heading" class="text-headline font-medium text-amare-text">Vamos criar algo inesquecível juntos?</h2>
|
||||
<p class="max-w-2xl text-amare-muted">Conte com a Amare para transformar seu evento em uma experiência organizada, cuidadosa e memorável.</p>
|
||||
<div>
|
||||
<a href="{{ route('briefing') }}" class="btn btn-primary text-sm font-semibold uppercase tracking-[0.12em]">
|
||||
{{ $settings->hero_cta_label ?: 'Solicitar proposta' }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
40
resources/views/components/about/founder.blade.php
Normal file
40
resources/views/components/about/founder.blade.php
Normal file
@@ -0,0 +1,40 @@
|
||||
@props([
|
||||
'settings',
|
||||
])
|
||||
|
||||
@php
|
||||
$hasImage = filled($settings->founder_image_path);
|
||||
@endphp
|
||||
|
||||
<section id="michele" aria-labelledby="founder-heading" class="border-b border-amare-border bg-amare-bg" data-about-founder>
|
||||
<div class="grid min-h-[500px] lg:grid-cols-12" data-reveal-group>
|
||||
@if ($hasImage)
|
||||
<div class="min-h-[420px] overflow-hidden bg-amare-bg-deep max-lg:aspect-[4/5] lg:col-span-6 lg:min-h-full" data-reveal-media>
|
||||
<x-media.image
|
||||
:path="$settings->founder_image_path"
|
||||
:alt="$settings->founder_image_alt ?: 'Michele, da Amare'"
|
||||
sizes="(max-width: 1023px) 100vw, 50vw"
|
||||
class="img-editorial h-full min-h-[420px] w-full object-cover object-[center_35%] lg:min-h-full"
|
||||
/>
|
||||
</div>
|
||||
@else
|
||||
<div class="flex min-h-[420px] items-center justify-center bg-amare-bg-deep lg:col-span-6 lg:min-h-full" data-founder-tonal>
|
||||
<p class="px-4 text-center text-xs font-semibold uppercase tracking-[0.16em] text-amare-muted">Retrato da Michele</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="flex flex-col justify-center px-6 py-16 lg:col-span-6 lg:px-[clamp(3rem,6vw,7rem)]" data-reveal data-reveal-from="up">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">A pessoa por trás da experiência</p>
|
||||
<h2 id="founder-heading" class="mt-3 text-headline font-medium text-amare-text">Conheça Michele</h2>
|
||||
<p class="mt-5 max-w-[520px] text-lg leading-relaxed text-amare-text">Michele representa o olhar humano da Amare: escuta, organização e presença durante todo o processo de construção do evento.</p>
|
||||
<p class="mt-3.5 max-w-[520px] text-lg leading-relaxed text-amare-text-muted">Seu papel é transformar necessidades e escolhas em um planejamento claro, cuidando dos detalhes sem perder de vista a experiência de quem contrata e de quem participa.</p>
|
||||
<div class="mt-8 flex max-w-[500px] flex-col items-start justify-between gap-4 border border-amare-border bg-amare-bg-deep px-5 py-4 sm:flex-row sm:items-center">
|
||||
<div>
|
||||
<strong class="block text-[2rem] font-normal italic text-amare-text">Michele</strong>
|
||||
<small class="text-xs font-semibold uppercase tracking-[0.08em] text-amare-muted">Assessoria e produção de eventos</small>
|
||||
</div>
|
||||
<a href="{{ route('briefing') }}" class="btn btn-outline shrink-0 text-xs font-bold uppercase tracking-[0.11em]">Falar com a Michele</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
50
resources/views/components/about/hero.blade.php
Normal file
50
resources/views/components/about/hero.blade.php
Normal file
@@ -0,0 +1,50 @@
|
||||
@props([
|
||||
'settings',
|
||||
])
|
||||
|
||||
@php
|
||||
$lead = $settings->about_summary ?: 'Eventos que refletem propósito, conectam pessoas e criam memórias.';
|
||||
$hasImage = filled($settings->about_image_path);
|
||||
@endphp
|
||||
|
||||
<section aria-labelledby="about-hero-heading" class="border-b border-amare-border bg-amare-bg" data-about-hero data-motion="page-open">
|
||||
@if ($hasImage)
|
||||
<div class="grid min-h-[440px] lg:grid-cols-12">
|
||||
<div class="flex min-w-0 items-center bg-amare-bg px-6 py-16 lg:col-span-5 lg:px-[clamp(3rem,6vw,7rem)]" data-reveal-group>
|
||||
<div class="w-full max-w-[470px] space-y-6">
|
||||
<p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent" data-motion-beat="seal">Sobre nós</p>
|
||||
<h1 id="about-hero-heading" class="max-w-[470px] whitespace-normal break-normal text-hero-spread font-medium text-amare-text" data-motion-beat="title">Sobre a Amare</h1>
|
||||
<p class="max-w-[450px] text-[clamp(1.25rem,2vw,1.55rem)] italic leading-snug text-amare-text-muted" data-motion-beat="lede">{{ $lead }}</p>
|
||||
<span class="block h-9 w-px bg-amare-accent" aria-hidden="true"></span>
|
||||
<p class="max-w-[430px] text-lg leading-relaxed text-amare-text">A Amare une sensibilidade, organização e atenção aos detalhes para criar experiências bem planejadas, do primeiro encontro à execução do evento.</p>
|
||||
<div data-motion-beat="cta">
|
||||
<a href="#michele" class="btn btn-outline text-xs font-bold uppercase tracking-[0.11em]">Conhecer mais</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-h-[360px] overflow-hidden bg-amare-bg-deep max-lg:aspect-[4/5] lg:col-span-7 lg:min-h-0" data-motion-beat="media" data-reveal-media>
|
||||
<x-media.image
|
||||
:path="$settings->about_image_path"
|
||||
:alt="$settings->about_image_alt ?: 'Sobre a Amare'"
|
||||
loading="eager"
|
||||
fetchpriority="high"
|
||||
sizes="(max-width: 1023px) 100vw, 58vw"
|
||||
class="img-editorial h-full min-h-[360px] w-full object-cover lg:min-h-[440px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<div class="container-amare py-16 md:py-24" data-tonal-hero>
|
||||
<div class="max-w-[470px] space-y-5" data-motion-beat="heading">
|
||||
<p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent" data-motion-beat="seal">Sobre nós</p>
|
||||
<h1 id="about-hero-heading" class="text-headline font-medium tracking-tight text-amare-text" data-motion-beat="title">Sobre a Amare</h1>
|
||||
<p class="text-lg italic leading-snug text-amare-text-muted" data-motion-beat="lede">{{ $lead }}</p>
|
||||
<span class="block h-9 w-px bg-amare-accent" aria-hidden="true"></span>
|
||||
<p class="text-lg leading-relaxed text-amare-text">A Amare une sensibilidade, organização e atenção aos detalhes para criar experiências bem planejadas, do primeiro encontro à execução do evento.</p>
|
||||
<div data-motion-beat="cta">
|
||||
<a href="#michele" class="btn btn-outline text-xs font-bold uppercase tracking-[0.11em]">Conhecer mais</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</section>
|
||||
31
resources/views/components/about/pillars.blade.php
Normal file
31
resources/views/components/about/pillars.blade.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<section aria-label="Pilares da Amare" class="border-b border-amare-border bg-amare-bg" data-about-pillars>
|
||||
<div class="container-amare py-16 md:py-24" data-reveal-group>
|
||||
<div class="grid border-l border-t border-amare-border md:grid-cols-3">
|
||||
<article class="flex flex-col gap-4 border-b border-r border-amare-border p-8 text-center md:p-10" data-reveal data-reveal-from="up">
|
||||
<svg class="mx-auto h-[34px] w-[34px] text-amare-muted" viewBox="0 0 48 48" fill="none" stroke="currentColor" aria-hidden="true">
|
||||
<circle cx="18" cy="16" r="6"/>
|
||||
<circle cx="30" cy="16" r="6"/>
|
||||
<path d="M7 37c1-8 5-12 11-12s10 4 11 12M21 37c1-8 5-12 11-12 5 0 9 4 10 12"/>
|
||||
</svg>
|
||||
<h3 class="text-base font-medium uppercase tracking-[0.05em] text-amare-text">Atendimento próximo</h3>
|
||||
<p class="mx-auto max-w-[280px] text-amare-text-muted">Escuta atenta para compreender prioridades, contexto e expectativas de cada evento.</p>
|
||||
</article>
|
||||
<article class="flex flex-col gap-4 border-b border-r border-amare-border p-8 text-center md:p-10" data-reveal data-reveal-from="up">
|
||||
<svg class="mx-auto h-[34px] w-[34px] text-amare-muted" viewBox="0 0 48 48" fill="none" stroke="currentColor" aria-hidden="true">
|
||||
<rect x="10" y="7" width="28" height="34" rx="2"/>
|
||||
<path d="M17 15h14M17 22h14M17 29h8M29 29h2"/>
|
||||
</svg>
|
||||
<h3 class="text-base font-medium uppercase tracking-[0.05em] text-amare-text">Planejamento minucioso</h3>
|
||||
<p class="mx-auto max-w-[280px] text-amare-text-muted">Organização clara das etapas para transformar decisões em uma execução consistente.</p>
|
||||
</article>
|
||||
<article class="flex flex-col gap-4 border-b border-r border-amare-border p-8 text-center md:p-10" data-reveal data-reveal-from="up">
|
||||
<svg class="mx-auto h-[34px] w-[34px] text-amare-muted" viewBox="0 0 48 48" fill="none" stroke="currentColor" aria-hidden="true">
|
||||
<path d="M24 40C10 32 8 20 8 12c8 0 14 4 16 11 2-7 8-11 16-11 0 8-2 20-16 28Z"/>
|
||||
<path d="M24 23v17"/>
|
||||
</svg>
|
||||
<h3 class="text-base font-medium uppercase tracking-[0.05em] text-amare-text">Execução tranquila</h3>
|
||||
<p class="mx-auto max-w-[280px] text-amare-text-muted">Cuidado com a operação para que anfitriões e convidados possam viver o momento.</p>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
64
resources/views/components/about/portfolio.blade.php
Normal file
64
resources/views/components/about/portfolio.blade.php
Normal file
@@ -0,0 +1,64 @@
|
||||
@props([
|
||||
'cases',
|
||||
])
|
||||
|
||||
@php
|
||||
$slots = collect([
|
||||
['label' => 'Eventos sociais', 'tall' => true],
|
||||
['label' => 'Celebrações', 'tall' => false],
|
||||
['label' => 'Detalhes', 'tall' => false],
|
||||
['label' => 'Sociais', 'tall' => false],
|
||||
['label' => 'Corporativo', 'tall' => false],
|
||||
['label' => 'Produção', 'tall' => false],
|
||||
]);
|
||||
$cases = $cases->take(6)->values();
|
||||
@endphp
|
||||
|
||||
<section id="portfolio" aria-labelledby="about-portfolio-heading" class="border-b border-amare-border bg-amare-bg" data-about-portfolio>
|
||||
<div class="container-amare py-16 md:py-24" data-reveal-group>
|
||||
<div class="grid gap-10 lg:grid-cols-12 lg:items-end" data-reveal data-reveal-from="up">
|
||||
<div class="lg:col-span-5">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Seleção de trabalhos</p>
|
||||
<h2 id="about-portfolio-heading" class="mt-3 text-headline font-medium text-amare-text">Portfólio em destaque</h2>
|
||||
</div>
|
||||
<div class="lg:col-span-6 lg:col-start-7">
|
||||
<p class="max-w-xl text-amare-text-muted">Uma composição de eventos sociais e corporativos para apresentar a versatilidade da Amare.</p>
|
||||
<a href="{{ route('portfolio.index') }}" class="btn btn-ghost mt-5 min-h-0 px-0 text-xs font-bold uppercase tracking-[0.09em] text-amare-accent hover:bg-transparent hover:text-amare-accent-deep">
|
||||
<span class="border-b border-amare-accent pb-1">Ver portfólio completo</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-12 grid grid-cols-1 gap-[14px] sm:grid-cols-2 lg:grid-cols-[1.2fr_0.8fr_0.8fr]" aria-label="Projetos em destaque" data-editorial-portfolio data-reveal-group>
|
||||
@foreach ($slots as $index => $slot)
|
||||
@php
|
||||
$case = $cases->get($index);
|
||||
@endphp
|
||||
<div
|
||||
@class([
|
||||
'overflow-hidden bg-amare-bg-deep',
|
||||
'sm:col-span-2 sm:min-h-[420px] lg:col-span-1 lg:row-span-2 lg:min-h-[634px]' => $slot['tall'],
|
||||
'min-h-[220px] sm:min-h-[310px]' => ! $slot['tall'],
|
||||
])
|
||||
data-reveal
|
||||
data-reveal-from="up"
|
||||
>
|
||||
@if ($case && filled($case->cover_image_path))
|
||||
<a href="{{ route('portfolio.show', $case) }}" class="block h-full w-full" aria-label="{{ $case->title }}">
|
||||
<x-media.image
|
||||
:path="$case->cover_image_path"
|
||||
:alt="$case->cover_image_alt ?: $case->title"
|
||||
sizes="(max-width: 1023px) 100vw, 33vw"
|
||||
class="img-editorial h-full w-full object-cover transition-opacity hover:opacity-90"
|
||||
/>
|
||||
</a>
|
||||
@else
|
||||
<div class="flex h-full w-full items-center justify-center">
|
||||
<p class="px-4 text-center text-xs font-semibold uppercase tracking-[0.16em] text-amare-muted">{{ $slot['label'] }}</p>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
30
resources/views/components/about/process.blade.php
Normal file
30
resources/views/components/about/process.blade.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<section aria-labelledby="process-heading" class="border-b border-amare-border bg-amare-bg" data-about-process>
|
||||
<div class="container-amare py-16 md:py-24" data-reveal-group>
|
||||
<h2 id="process-heading" class="mb-10 text-center text-headline font-medium text-amare-text" data-reveal data-reveal-from="up">Como trabalhamos</h2>
|
||||
<div class="grid border-l border-t border-amare-border md:grid-cols-3">
|
||||
<article class="flex flex-col gap-3 border-b border-r border-amare-border p-8 text-center md:p-10" data-reveal data-reveal-from="up">
|
||||
<svg class="mx-auto h-[34px] w-[34px] text-amare-muted" viewBox="0 0 48 48" fill="none" stroke="currentColor" aria-hidden="true">
|
||||
<path d="M15 29c-5-4-6-12-2-17 5-7 17-7 22 0 4 6 2 14-4 18-2 1-3 4-3 7H20c0-4-2-6-5-8Z"/>
|
||||
<path d="M20 41h8"/>
|
||||
</svg>
|
||||
<h3 class="text-base font-medium uppercase tracking-[0.04em] text-amare-text">Escuta e entendimento</h3>
|
||||
<p class="mx-auto max-w-[280px] text-amare-text-muted">Começamos pelo contexto do evento, suas prioridades, estilo e expectativas.</p>
|
||||
</article>
|
||||
<article class="flex flex-col gap-3 border-b border-r border-amare-border p-8 text-center md:p-10" data-reveal data-reveal-from="up">
|
||||
<svg class="mx-auto h-[34px] w-[34px] text-amare-muted" viewBox="0 0 48 48" fill="none" stroke="currentColor" aria-hidden="true">
|
||||
<rect x="8" y="11" width="32" height="29" rx="2"/>
|
||||
<path d="M15 7v8M33 7v8M8 20h32M15 26h4M23 26h4M31 26h3M15 33h4M23 33h4"/>
|
||||
</svg>
|
||||
<h3 class="text-base font-medium uppercase tracking-[0.04em] text-amare-text">Planejamento e curadoria</h3>
|
||||
<p class="mx-auto max-w-[280px] text-amare-text-muted">Organizamos etapas, fornecedores e decisões para manter cada frente alinhada.</p>
|
||||
</article>
|
||||
<article class="flex flex-col gap-3 border-b border-r border-amare-border p-8 text-center md:p-10" data-reveal data-reveal-from="up">
|
||||
<svg class="mx-auto h-[34px] w-[34px] text-amare-muted" viewBox="0 0 48 48" fill="none" stroke="currentColor" aria-hidden="true">
|
||||
<path d="M24 39S8 30 8 18c0-6 4-10 10-10 4 0 7 2 9 6 2-4 5-6 9-6 6 0 10 4 10 10 0 12-22 21-22 21Z"/>
|
||||
</svg>
|
||||
<h3 class="text-base font-medium uppercase tracking-[0.04em] text-amare-text">Execução e experiência</h3>
|
||||
<p class="mx-auto max-w-[280px] text-amare-text-muted">Coordenamos a operação para que o evento aconteça com fluidez e presença.</p>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -241,10 +241,6 @@
|
||||
* Campos obrigatórios. Seus dados são usados apenas para responder à sua solicitação.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="border border-dashed border-amare-sage px-6 py-5">
|
||||
<p class="text-sm text-amare-muted">Fornecedores e parcerias — pós-MVP. Não misturar este público ao briefing comercial. Quando validado, criar um canal/formulário institucional próprio.</p>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,26 +1,40 @@
|
||||
@props([
|
||||
'settings',
|
||||
'eyebrow' => '04 — Amare Corporate',
|
||||
'heading' => 'Projetos corporativos tratados como experiência, não apenas operação.',
|
||||
'meta' => 'No MVP, esta frente pode apresentar claramente o que a Amare oferece mesmo sem inventar um portfólio corporativo que ainda não existe.',
|
||||
'steps' => null,
|
||||
'showMeta' => true,
|
||||
'ctaLabel' => 'Falar sobre um evento corporativo',
|
||||
'ctaHref' => '#contato',
|
||||
'showAside' => true,
|
||||
])
|
||||
|
||||
@php
|
||||
$steps = $steps ?? ($settings->corporate_steps ?? []);
|
||||
@endphp
|
||||
|
||||
<section
|
||||
aria-labelledby="corporate-heading"
|
||||
class="home-chapter border-b border-amare-border bg-amare-bg"
|
||||
data-chapter="corporate"
|
||||
id="corporate"
|
||||
>
|
||||
<div class="container-amare grid gap-12 py-16 md:py-24 lg:grid-cols-2 lg:gap-[70px]" data-reveal-group>
|
||||
<div class="container-amare grid gap-12 py-16 md:py-24 @if ($showAside) lg:grid-cols-2 lg:gap-[70px] @endif" data-reveal-group>
|
||||
<div class="flex flex-col gap-8">
|
||||
<div class="space-y-4" data-reveal data-reveal-from="up">
|
||||
<p class="text-xs font-bold uppercase tracking-[0.16em] text-amare-accent-deep">04 — Amare Corporate</p>
|
||||
<h2 id="corporate-heading" class="text-[clamp(2.375rem,5vw,4rem)] font-medium leading-[1.05] tracking-[-0.02em] text-amare-text">Projetos corporativos tratados como experiência, não apenas operação.</h2>
|
||||
<p class="text-xs font-bold uppercase tracking-[0.16em] text-amare-accent-deep">{{ $eyebrow }}</p>
|
||||
<h2 id="corporate-heading" class="text-[clamp(2.375rem,5vw,4rem)] font-medium leading-[1.05] tracking-[-0.02em] text-amare-text">{{ $heading }}</h2>
|
||||
</div>
|
||||
|
||||
<p class="max-w-[760px] text-[clamp(1.1875rem,2vw,1.5rem)] leading-[1.55] text-amare-text-muted" data-reveal data-reveal-from="up">
|
||||
No MVP, esta frente pode apresentar claramente o que a Amare oferece mesmo sem inventar um portfólio corporativo que ainda não existe.
|
||||
</p>
|
||||
@if ($showMeta && filled($meta))
|
||||
<p class="max-w-[760px] text-[clamp(1.1875rem,2vw,1.5rem)] leading-[1.55] text-amare-text-muted" data-reveal data-reveal-from="up">
|
||||
{{ $meta }}
|
||||
</p>
|
||||
@endif
|
||||
|
||||
<div class="border-t border-amare-border" data-reveal data-reveal-from="up">
|
||||
@foreach ($settings->corporate_steps ?? [] as $index => $step)
|
||||
@foreach ($steps as $index => $step)
|
||||
<div class="flex gap-8 border-b border-amare-border py-[23px]">
|
||||
<p class="w-[44px] shrink-0 text-xs font-bold uppercase tracking-[0.16em] text-amare-accent">{{ str_pad((string) ($index + 1), 2, '0', STR_PAD_LEFT) }}</p>
|
||||
<div class="space-y-1">
|
||||
@@ -31,15 +45,17 @@
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<a href="#contato" class="btn btn-primary mt-7 self-start text-xs font-bold uppercase tracking-[0.09em]" data-reveal data-reveal-from="up">
|
||||
Falar sobre um evento corporativo
|
||||
<a href="{{ $ctaHref }}" class="btn btn-primary mt-7 self-start text-xs font-bold uppercase tracking-[0.09em]" data-reveal data-reveal-from="up">
|
||||
{{ $ctaLabel }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="flex min-h-[420px] flex-col items-center justify-center gap-4 bg-amare-bg-deep p-10 text-center" data-reveal data-reveal-from="up">
|
||||
<p class="text-xs font-bold uppercase tracking-[0.16em] text-amare-accent-deep">Portfólio Corporate</p>
|
||||
<h3 class="text-[clamp(1.5625rem,2.7vw,2.125rem)] font-medium leading-tight text-amare-text">Conteúdo em construção</h3>
|
||||
<p class="max-w-sm text-amare-text-muted">Em vez de usar fotografias genéricas como se fossem cases, a página assume com elegância que o portfólio será construído com projetos reais.</p>
|
||||
</div>
|
||||
@if ($showAside)
|
||||
<div class="flex min-h-[420px] flex-col items-center justify-center gap-4 bg-amare-bg-deep p-10 text-center" data-reveal data-reveal-from="up">
|
||||
<p class="text-xs font-bold uppercase tracking-[0.16em] text-amare-accent-deep">Portfólio Corporate</p>
|
||||
<h3 class="text-[clamp(1.5625rem,2.7vw,2.125rem)] font-medium leading-tight text-amare-text">Conteúdo em construção</h3>
|
||||
<p class="max-w-sm text-amare-text-muted">Em vez de usar fotografias genéricas como se fossem cases, a página assume com elegância que o portfólio será construído com projetos reais.</p>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
@props([
|
||||
'settings',
|
||||
'editorial' => false,
|
||||
'ctaHref' => null,
|
||||
])
|
||||
|
||||
<section aria-labelledby="final-cta-heading" @class([
|
||||
@@ -17,7 +18,7 @@
|
||||
</p>
|
||||
<div>
|
||||
<a
|
||||
href="{{ route('briefing') }}"
|
||||
href="{{ $ctaHref ?? route('briefing') }}"
|
||||
class="btn btn-primary text-sm font-semibold uppercase tracking-[0.12em]"
|
||||
>
|
||||
{{ $settings->hero_cta_label ?: 'Solicitar proposta' }}
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
@props([
|
||||
'packages',
|
||||
'settings',
|
||||
'eyebrow' => '03 — Amare Casamentos',
|
||||
'heading' => 'Um acompanhamento para cada momento.',
|
||||
'intro' => 'As modalidades são pontos de partida. O site apresenta o conceito e ajuda o casal a reconhecer seu momento; detalhes operacionais e comerciais ficam para a conversa e proposta.',
|
||||
'kicker' => 'level',
|
||||
'showSubtitle' => false,
|
||||
'showIntro' => true,
|
||||
'showNote' => true,
|
||||
'ctaRoute' => null,
|
||||
'bandCtaHref' => null,
|
||||
'bandBody' => 'Conte um pouco sobre o seu casamento. A Amare entende o momento de vocês e orienta o melhor formato de acompanhamento sem depender de um quiz automático.',
|
||||
'note' => 'Nomenclaturas exibidas conforme materiais/reunião; confirmar versão final antes da publicação.',
|
||||
])
|
||||
|
||||
@php
|
||||
$whatsappDigits = preg_replace('/\D/', '', (string) $settings->whatsapp_number);
|
||||
$hasWhatsapp = strlen($whatsappDigits) >= 10;
|
||||
$bandHref = $bandCtaHref ?? route('briefing');
|
||||
@endphp
|
||||
|
||||
<section
|
||||
aria-labelledby="packages-heading"
|
||||
class="home-chapter border-b border-amare-border bg-amare-bg"
|
||||
@@ -17,10 +26,12 @@
|
||||
<div class="container-amare py-16 md:py-24" data-reveal-group>
|
||||
<div class="flex flex-col gap-6 md:flex-row md:items-end md:justify-between" data-reveal data-reveal-from="up">
|
||||
<div class="max-w-2xl space-y-4">
|
||||
<p class="text-xs font-bold uppercase tracking-[0.16em] text-amare-accent-deep">03 — Amare Casamentos</p>
|
||||
<h2 id="packages-heading" class="text-[clamp(2.375rem,5vw,4rem)] font-medium leading-[1.05] tracking-[-0.02em] text-amare-text">Um acompanhamento para cada momento.</h2>
|
||||
<p class="text-xs font-bold uppercase tracking-[0.16em] text-amare-accent-deep">{{ $eyebrow }}</p>
|
||||
<h2 id="packages-heading" class="text-[clamp(2.375rem,5vw,4rem)] font-medium leading-[1.05] tracking-[-0.02em] text-amare-text">{{ $heading }}</h2>
|
||||
</div>
|
||||
<p class="max-w-[600px] text-amare-text-muted">As modalidades são pontos de partida. O site apresenta o conceito e ajuda o casal a reconhecer seu momento; detalhes operacionais e comerciais ficam para a conversa e proposta.</p>
|
||||
@if ($showIntro && filled($intro))
|
||||
<p class="max-w-[600px] text-amare-text-muted">{{ $intro }}</p>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if ($packages->isNotEmpty())
|
||||
@@ -28,8 +39,11 @@
|
||||
@foreach ($packages as $package)
|
||||
<article class="flex min-h-[480px] flex-col justify-between gap-8 border-b border-r border-amare-border p-8 md:p-10" data-reveal data-reveal-from="up">
|
||||
<div class="space-y-5">
|
||||
<p class="text-xs font-bold uppercase tracking-[0.16em] text-amare-accent">{{ $package->level }}</p>
|
||||
<p class="text-xs font-bold uppercase tracking-[0.16em] text-amare-accent">{{ $kicker === 'tag' ? $package->tag : $package->level }}</p>
|
||||
<h3 class="text-[clamp(1.5625rem,2.7vw,2.125rem)] font-medium leading-tight text-amare-text">{{ $package->name }}</h3>
|
||||
@if ($showSubtitle && filled($package->subtitle))
|
||||
<p class="text-[clamp(1.0625rem,1.7vw,1.25rem)] font-medium leading-snug text-amare-text">{{ $package->subtitle }}</p>
|
||||
@endif
|
||||
<p class="text-amare-text-muted">{{ $package->summary }}</p>
|
||||
<ul class="space-y-3 text-amare-text-muted">
|
||||
@foreach ($package->scope_items as $item)
|
||||
@@ -37,18 +51,22 @@
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@php
|
||||
$waLink = $hasWhatsapp
|
||||
? 'https://wa.me/'.$whatsappDigits.'?text='.rawurlencode('Olá, gostaria de conversar sobre a modalidade '.$package->name.' para meu casamento.')
|
||||
: route('briefing', ['servico_interesse' => $package->name]);
|
||||
@endphp
|
||||
<a
|
||||
href="{{ $waLink }}"
|
||||
@if ($hasWhatsapp) target="_blank" rel="noopener noreferrer" @endif
|
||||
class="btn btn-primary text-xs font-bold uppercase tracking-[0.09em]"
|
||||
>
|
||||
{{ $package->cta_label }}
|
||||
</a>
|
||||
<div class="space-y-5">
|
||||
@php($contactLink = $ctaRoute ? null : \App\Support\PackageContactLink::for($settings, $package->name))
|
||||
<a
|
||||
href="{{ $ctaRoute ? route($ctaRoute) : $contactLink['href'] }}"
|
||||
@if ($ctaRoute === null && $contactLink['isWhatsapp']) target="_blank" rel="noopener noreferrer" @endif
|
||||
class="btn btn-primary text-xs font-bold uppercase tracking-[0.09em]"
|
||||
>
|
||||
{{ $package->cta_label }}
|
||||
</a>
|
||||
<a
|
||||
href="{{ route('packages.show', $package->slug) }}"
|
||||
class="btn btn-ghost min-h-0 px-0 text-xs font-semibold uppercase tracking-[0.09em] text-amare-accent hover:bg-transparent hover:text-amare-accent-deep"
|
||||
>
|
||||
<span class="border-b border-amare-accent pb-1">Conhecer esta modalidade</span>
|
||||
</a>
|
||||
</div>
|
||||
</article>
|
||||
@endforeach
|
||||
</div>
|
||||
@@ -57,13 +75,15 @@
|
||||
<div class="mt-6 flex flex-col justify-between gap-8 bg-amare-accent-deep p-8 text-amare-accent-text md:flex-row md:items-center md:p-12" data-reveal data-reveal-from="up">
|
||||
<div class="max-w-xl space-y-3">
|
||||
<h3 class="text-[clamp(1.5625rem,2.7vw,2.125rem)] font-medium leading-tight">Ainda não sabe qual modalidade é ideal?</h3>
|
||||
<p class="text-amare-accent-text/80">Conte um pouco sobre o seu casamento. A Amare entende o momento de vocês e orienta o melhor formato de acompanhamento sem depender de um quiz automático.</p>
|
||||
<p class="text-amare-accent-text/80">{{ $bandBody }}</p>
|
||||
</div>
|
||||
<a href="{{ route('briefing') }}" class="inline-flex min-h-[48px] items-center justify-center self-start border border-amare-accent-text px-6 text-xs font-bold uppercase tracking-[0.09em] text-amare-accent-text transition-colors hover:bg-amare-accent-text hover:text-amare-accent-deep md:self-auto">
|
||||
<a href="{{ $bandHref }}" class="inline-flex min-h-[48px] items-center justify-center self-start border border-amare-accent-text px-6 text-xs font-bold uppercase tracking-[0.09em] text-amare-accent-text transition-colors hover:bg-amare-accent-text hover:text-amare-accent-deep md:self-auto">
|
||||
Conversar com a Amare
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<p class="mt-6 text-sm text-amare-muted">Nomenclaturas exibidas conforme materiais/reunião; confirmar versão final antes da publicação.</p>
|
||||
@if ($showNote && filled($note))
|
||||
<p class="mt-6 text-sm text-amare-muted">{{ $note }}</p>
|
||||
@endif
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,4 +1,23 @@
|
||||
@props([])
|
||||
@props([
|
||||
'eyebrow' => '02 — Duas vertentes, uma mesma forma de cuidar',
|
||||
'heading' => 'Como podemos acompanhar você?',
|
||||
'cards' => [
|
||||
[
|
||||
'label' => 'Amare Casamentos',
|
||||
'title' => 'Planejamento e condução para viver o processo com mais leveza.',
|
||||
'body' => 'Do início da organização à reta final, modalidades pensadas para diferentes momentos do casal.',
|
||||
'href' => '#casamentos',
|
||||
'cta' => 'Conhecer casamentos',
|
||||
],
|
||||
[
|
||||
'label' => 'Amare Corporate',
|
||||
'title' => 'Eventos empresariais com organização, intenção e execução segura.',
|
||||
'body' => 'Uma frente própria para projetos corporativos, com linguagem e necessidades diferentes das celebrações sociais.',
|
||||
'href' => '#corporate',
|
||||
'cta' => 'Conhecer Corporate',
|
||||
],
|
||||
],
|
||||
])
|
||||
|
||||
<section
|
||||
aria-labelledby="vertentes-heading"
|
||||
@@ -8,34 +27,24 @@
|
||||
>
|
||||
<div class="container-amare py-16 md:py-24" data-reveal-group>
|
||||
<div class="max-w-3xl space-y-4" data-reveal data-reveal-from="up">
|
||||
<p class="text-xs font-bold uppercase tracking-[0.16em] text-amare-accent-deep">02 — Duas vertentes, uma mesma forma de cuidar</p>
|
||||
<h2 id="vertentes-heading" class="text-[clamp(2.375rem,5vw,4rem)] font-medium leading-[1.05] tracking-[-0.02em] text-amare-text">Como podemos acompanhar você?</h2>
|
||||
<p class="text-xs font-bold uppercase tracking-[0.16em] text-amare-accent-deep">{{ $eyebrow }}</p>
|
||||
<h2 id="vertentes-heading" class="text-[clamp(2.375rem,5vw,4rem)] font-medium leading-[1.05] tracking-[-0.02em] text-amare-text">{{ $heading }}</h2>
|
||||
</div>
|
||||
|
||||
<div class="mt-12 grid gap-6 md:mt-16 md:grid-cols-2 md:gap-[24px]">
|
||||
<article class="relative flex min-h-[420px] flex-col justify-end bg-amare-bg-deep p-8 md:p-10" data-reveal data-reveal-from="up">
|
||||
<div aria-hidden="true" class="pointer-events-none absolute inset-x-0 top-0 h-[43%] bg-gradient-to-b from-amare-bg to-transparent"></div>
|
||||
<div class="relative flex flex-col gap-5">
|
||||
<p class="text-xs font-bold uppercase tracking-[0.16em] text-amare-accent-deep">Amare Casamentos</p>
|
||||
<h3 class="text-[clamp(1.5625rem,2.7vw,2.125rem)] font-medium leading-tight text-amare-text">Planejamento e condução para viver o processo com mais leveza.</h3>
|
||||
<p class="text-amare-text-muted">Do início da organização à reta final, modalidades pensadas para diferentes momentos do casal.</p>
|
||||
<a href="#casamentos" class="btn btn-outline mt-2 self-start text-xs font-bold uppercase tracking-[0.09em]">
|
||||
Conhecer casamentos
|
||||
</a>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="relative flex min-h-[420px] flex-col justify-end bg-amare-bg-deep p-8 md:p-10" data-reveal data-reveal-from="up">
|
||||
<div aria-hidden="true" class="pointer-events-none absolute inset-x-0 top-0 h-[43%] bg-gradient-to-b from-amare-bg to-transparent"></div>
|
||||
<div class="relative flex flex-col gap-5">
|
||||
<p class="text-xs font-bold uppercase tracking-[0.16em] text-amare-accent-deep">Amare Corporate</p>
|
||||
<h3 class="text-[clamp(1.5625rem,2.7vw,2.125rem)] font-medium leading-tight text-amare-text">Eventos empresariais com organização, intenção e execução segura.</h3>
|
||||
<p class="text-amare-text-muted">Uma frente própria para projetos corporativos, com linguagem e necessidades diferentes das celebrações sociais.</p>
|
||||
<a href="#corporate" class="btn btn-outline mt-2 self-start text-xs font-bold uppercase tracking-[0.09em]">
|
||||
Conhecer Corporate
|
||||
</a>
|
||||
</div>
|
||||
</article>
|
||||
@foreach ($cards as $card)
|
||||
<article class="relative flex min-h-[420px] flex-col justify-end bg-amare-bg-deep p-8 md:p-10" data-reveal data-reveal-from="up">
|
||||
<div aria-hidden="true" class="pointer-events-none absolute inset-x-0 top-0 h-[43%] bg-gradient-to-b from-amare-bg to-transparent"></div>
|
||||
<div class="relative flex flex-col gap-5">
|
||||
<p class="text-xs font-bold uppercase tracking-[0.16em] text-amare-accent-deep">{{ $card['label'] }}</p>
|
||||
<h3 class="text-[clamp(1.5625rem,2.7vw,2.125rem)] font-medium leading-tight text-amare-text">{{ $card['title'] }}</h3>
|
||||
<p class="text-amare-text-muted">{{ $card['body'] }}</p>
|
||||
<a href="{{ $card['href'] }}" class="btn btn-outline mt-2 self-start text-xs font-bold uppercase tracking-[0.09em]">
|
||||
{{ $card['cta'] }}
|
||||
</a>
|
||||
</div>
|
||||
</article>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -8,9 +8,8 @@
|
||||
@else
|
||||
<ol class="grid gap-8 border-t border-amare-border pt-6 md:grid-cols-3">
|
||||
@foreach ($packages as $package)
|
||||
@php($digits = preg_replace('/\D/', '', (string) $settings->whatsapp_number))
|
||||
@php($href = strlen((string) $digits) >= 10 ? 'https://wa.me/'.$digits.'?text='.rawurlencode('Olá, gostaria de conversar sobre a modalidade '.$package->name.' para meu casamento.') : route('briefing', ['servico_interesse' => $package->name]))
|
||||
<li class="space-y-5 border-t border-amare-border pt-4" data-reveal data-reveal-from="up"><p class="text-xs font-semibold uppercase tracking-[.14em] text-amare-accent">{{ $package->level }}</p><h3 class="text-3xl font-medium">{{ $package->name }}</h3><p class="text-amare-muted">{{ $package->summary }}</p><ul class="space-y-2 text-sm text-amare-text-muted">@foreach ($package->scope_items as $item)<li class="border-l border-amare-border pl-3">{{ $item }}</li>@endforeach</ul><a href="{{ $href }}" @if (str_starts_with($href, 'https://wa.me/')) target="_blank" rel="noopener" @endif class="btn btn-ghost min-h-0 px-0 text-sm font-semibold text-amare-accent hover:bg-transparent hover:text-amare-accent-deep"><span class="border-b border-amare-accent pb-1">{{ $package->cta_label }}</span></a></li>
|
||||
@php($contactLink = \App\Support\PackageContactLink::for($settings, $package->name))
|
||||
<li class="space-y-5 border-t border-amare-border pt-4" data-reveal data-reveal-from="up"><p class="text-xs font-semibold uppercase tracking-[.14em] text-amare-accent">{{ $package->level }}</p><h3 class="text-3xl font-medium">{{ $package->name }}</h3><p class="text-amare-muted">{{ $package->summary }}</p><ul class="space-y-2 text-sm text-amare-text-muted">@foreach ($package->scope_items as $item)<li class="border-l border-amare-border pl-3">{{ $item }}</li>@endforeach</ul><div class="space-y-2"><a href="{{ $contactLink['href'] }}" @if ($contactLink['isWhatsapp']) target="_blank" rel="noopener" @endif class="btn btn-ghost min-h-0 px-0 text-sm font-semibold text-amare-accent hover:bg-transparent hover:text-amare-accent-deep"><span class="border-b border-amare-accent pb-1">{{ $package->cta_label }}</span></a><a href="{{ route('packages.show', $package->slug) }}" class="btn btn-ghost min-h-0 px-0 text-sm font-semibold text-amare-accent hover:bg-transparent hover:text-amare-accent-deep"><span class="border-b border-amare-accent pb-1">Conhecer esta modalidade</span></a></div></li>
|
||||
@endforeach
|
||||
</ol>
|
||||
@endif
|
||||
|
||||
32
resources/views/components/package/icon.blade.php
Normal file
32
resources/views/components/package/icon.blade.php
Normal file
@@ -0,0 +1,32 @@
|
||||
@props(['icon' => null])
|
||||
|
||||
<svg {{ $attributes->merge(['class' => 'h-6 w-6']) }} viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="1.2" aria-hidden="true">
|
||||
@switch($icon)
|
||||
@case('calendar')
|
||||
<rect x="6" y="9" width="36" height="32" />
|
||||
<path d="M6 17h36M16 4v8M32 4v8" />
|
||||
<path d="M14 25h6M14 33h6M28 25h6M28 33h6" />
|
||||
@break
|
||||
@case('checklist')
|
||||
<path d="M8 14l4 4 8-9M8 25l4 4 8-9M8 36l4 4 8-9" />
|
||||
<path d="M28 14h12M28 25h12M28 36h12" />
|
||||
@break
|
||||
@case('users')
|
||||
<circle cx="17" cy="14" r="6" />
|
||||
<path d="M5 40c0-7 5.4-12 12-12s12 5 12 12" />
|
||||
<circle cx="33" cy="16" r="5" />
|
||||
<path d="M31 29c4.5 1.5 8 5.5 8.7 11" />
|
||||
@break
|
||||
@case('map')
|
||||
<path d="M24 6c-6.5 0-12 5.4-12 12.2 0 9 12 21.8 12 21.8s12-12.8 12-21.8C36 11.4 30.5 6 24 6z" />
|
||||
<circle cx="24" cy="18" r="4" />
|
||||
@break
|
||||
@case('heart')
|
||||
<path d="M24 40C13 32 5 25 5 16.4 5 10.7 9.5 6 15.2 6c3.4 0 6.4 1.9 8.8 4.7C26.4 7.9 29.4 6 32.8 6 38.5 6 43 10.7 43 16.4c0 8.6-8 15.6-19 23.6z" />
|
||||
@break
|
||||
@case('spark')
|
||||
<path d="M24 6l3.4 11.6L39 21l-11.6 3.4L24 36l-3.4-11.6L9 21l11.6-3.4z" />
|
||||
<path d="M37 34l1.8 6.2L45 42l-6.2 1.8L37 50l-1.8-6.2L29 42l6.2-1.8z" />
|
||||
@break
|
||||
@endswitch
|
||||
</svg>
|
||||
40
resources/views/components/public/package-audience.blade.php
Normal file
40
resources/views/components/public/package-audience.blade.php
Normal file
@@ -0,0 +1,40 @@
|
||||
@props([
|
||||
'imagePath' => null,
|
||||
'imageAlt' => null,
|
||||
'heading' => null,
|
||||
'intro' => null,
|
||||
'points' => [],
|
||||
])
|
||||
|
||||
@if ($heading || count($points) > 0)
|
||||
<section aria-labelledby="audience-heading" class="border-b border-amare-border bg-amare-bg-deep" data-reveal-group>
|
||||
<div class="grid min-h-[390px] lg:grid-cols-2">
|
||||
<div class="min-h-[300px] bg-amare-bg lg:min-h-0" data-reveal-media data-reveal-from="left">
|
||||
@if ($imagePath)
|
||||
<x-media.image :path="$imagePath" :alt="$imageAlt ?: $heading" loading="lazy" sizes="(max-width: 1023px) 100vw, 50vw" class="img-editorial h-full w-full object-cover" />
|
||||
@endif
|
||||
</div>
|
||||
<div class="flex items-center bg-amare-bg-deep px-6 py-16 lg:px-[clamp(3rem,6vw,6rem)]">
|
||||
<div class="w-full max-w-[430px]" data-reveal data-reveal-from="right">
|
||||
@if ($heading)
|
||||
<h2 id="audience-heading" class="text-3xl font-medium uppercase tracking-[0.05em]">{{ $heading }}</h2>
|
||||
<div class="mt-4 h-px w-12 bg-amare-border" aria-hidden="true"></div>
|
||||
@endif
|
||||
@if ($intro)
|
||||
<p class="mt-6 text-lg text-amare-text-muted">{{ $intro }}</p>
|
||||
@endif
|
||||
@if (count($points) > 0)
|
||||
<ul class="mt-8 space-y-3">
|
||||
@foreach ($points as $point)
|
||||
<li class="flex items-start gap-3">
|
||||
<span aria-hidden="true" class="text-amare-accent">✓</span>
|
||||
<span>{{ $point }}</span>
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@endif
|
||||
14
resources/views/components/public/package-benefits.blade.php
Normal file
14
resources/views/components/public/package-benefits.blade.php
Normal file
@@ -0,0 +1,14 @@
|
||||
@props(['benefits' => []])
|
||||
|
||||
@if (count($benefits) > 0)
|
||||
<section aria-label="Diferenciais da assessoria" class="border-b border-amare-border bg-amare-accent-deep text-amare-accent-text" data-reveal-group>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4">
|
||||
@foreach ($benefits as $benefit)
|
||||
<div class="flex min-h-[132px] flex-col items-center justify-center gap-3 px-4 py-8 text-center" data-reveal data-reveal-from="up">
|
||||
<x-package.icon :icon="$benefit['icon_key']" class="h-9 w-9" />
|
||||
<span class="max-w-[180px] text-sm uppercase tracking-[0.06em]">{{ $benefit['label'] }}</span>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</section>
|
||||
@endif
|
||||
@@ -0,0 +1,24 @@
|
||||
@props([
|
||||
'settings',
|
||||
'heading' => null,
|
||||
'body' => null,
|
||||
'packageName' => null,
|
||||
])
|
||||
|
||||
@php
|
||||
$link = \App\Support\PackageContactLink::for($settings, $packageName ?? '');
|
||||
@endphp
|
||||
|
||||
<section aria-labelledby="final-cta-heading" class="border-b border-amare-border bg-amare-bg py-20 md:py-24" data-chapter="final-cta" data-reveal-group>
|
||||
<div class="container-amare max-w-3xl space-y-6 py-4 text-center" data-reveal data-reveal-from="up">
|
||||
@if ($heading)
|
||||
<h2 id="final-cta-heading" class="text-headline font-medium text-amare-text">{{ $heading }}</h2>
|
||||
@endif
|
||||
@if ($body)
|
||||
<p class="mx-auto max-w-2xl text-amare-muted">{{ $body }}</p>
|
||||
@endif
|
||||
<div class="pt-4">
|
||||
<a href="{{ $link['href'] }}" @if ($link['isWhatsapp']) target="_blank" rel="noopener noreferrer" @endif class="btn btn-primary text-sm font-semibold uppercase tracking-[0.12em]">Solicitar proposta</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
42
resources/views/components/public/package-hero.blade.php
Normal file
42
resources/views/components/public/package-hero.blade.php
Normal file
@@ -0,0 +1,42 @@
|
||||
@props([
|
||||
'imagePath' => null,
|
||||
'imageAlt' => null,
|
||||
'eyebrow' => null,
|
||||
'titleLine' => null,
|
||||
'titleEmphasis' => null,
|
||||
'lead' => null,
|
||||
'headingId' => 'package-hero-heading',
|
||||
])
|
||||
|
||||
<section aria-labelledby="{{ $headingId }}" {{ $attributes->class(['border-b border-amare-border bg-amare-bg']) }} data-motion="page-open">
|
||||
<div class="grid min-h-[calc(100dvh-5rem)] lg:h-[calc(100dvh-5rem)] lg:min-h-0 lg:grid-cols-12" data-photo-hero>
|
||||
<div class="flex min-w-0 items-center bg-amare-bg px-6 py-16 lg:col-span-5 lg:px-[clamp(3rem,6vw,7rem)]" data-hero-content data-reveal-group>
|
||||
<div class="w-full max-w-[470px] space-y-7">
|
||||
@if ($eyebrow)
|
||||
<div class="flex items-center gap-4" data-motion-beat="seal">
|
||||
<x-brand.logo mark variant="on-light" class="h-8 w-auto" />
|
||||
<p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent">{{ $eyebrow }}</p>
|
||||
</div>
|
||||
@endif
|
||||
<h1 id="{{ $headingId }}" class="max-w-[470px] whitespace-normal break-normal text-hero-spread font-medium text-amare-text" data-motion-beat="title">
|
||||
{{ $titleLine }}
|
||||
@if ($titleEmphasis)
|
||||
<em class="mt-4 block text-[0.7em] italic text-amare-accent-deep">{{ $titleEmphasis }}</em>
|
||||
@endif
|
||||
</h1>
|
||||
<div class="h-[42px] w-px bg-amare-accent" aria-hidden="true"></div>
|
||||
@if ($lead)
|
||||
<p class="max-w-[470px] text-lg text-amare-text-muted">{{ $lead }}</p>
|
||||
@endif
|
||||
<div data-motion-beat="cta">
|
||||
{{ $slot }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div data-split-hero data-motion-beat="media" class="min-h-[56dvh] overflow-hidden bg-amare-bg-deep max-lg:aspect-[4/5] lg:col-span-7 lg:min-h-0" data-reveal-media>
|
||||
@if ($imagePath)
|
||||
<x-media.image :path="$imagePath" :alt="$imageAlt ?: $titleLine" loading="eager" fetchpriority="high" sizes="(max-width: 1023px) 100vw, 58vw" class="img-editorial h-full w-full object-cover" />
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
20
resources/views/components/public/package-included.blade.php
Normal file
20
resources/views/components/public/package-included.blade.php
Normal file
@@ -0,0 +1,20 @@
|
||||
@props(['items' => []])
|
||||
|
||||
@if (count($items) > 0)
|
||||
<section aria-labelledby="included-heading" class="border-b border-amare-border bg-amare-bg" data-reveal-group>
|
||||
<div class="container-amare py-16 md:py-24">
|
||||
<p class="text-center text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Modalidade</p>
|
||||
<h2 id="included-heading" class="mt-3 text-center text-3xl font-medium md:text-4xl">O que está incluso</h2>
|
||||
<div class="mx-auto mt-4 h-px w-14 bg-amare-border" aria-hidden="true"></div>
|
||||
<div class="mt-12 grid gap-y-10 md:grid-cols-3 md:gap-x-6 md:gap-y-0">
|
||||
@foreach ($items as $item)
|
||||
<div class="text-center md:border-l md:border-amare-border md:first:border-l-0 md:px-10" data-reveal data-reveal-from="up">
|
||||
<x-package.icon :icon="$item['icon_key']" class="mx-auto h-10 w-10 text-amare-accent-deep" />
|
||||
<h3 class="mt-4 text-lg font-medium uppercase tracking-[0.05em]">{{ $item['title'] }}</h3>
|
||||
<p class="mx-auto mt-3 max-w-[310px] text-amare-text-muted">{{ $item['description'] }}</p>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@endif
|
||||
@@ -1,29 +1,10 @@
|
||||
@extends('layouts.public')
|
||||
|
||||
@section('content')
|
||||
@php
|
||||
$principles = filled($siteSettings->principles)
|
||||
? $siteSettings->principles
|
||||
: \App\Models\SiteSetting::defaultPrinciples();
|
||||
$city = $siteSettings->city ?: 'São Paulo - SP';
|
||||
@endphp
|
||||
|
||||
<x-public.photo-hero :image-path="$siteSettings->about_image_path" :image-alt="$siteSettings->about_image_alt" eyebrow="A Amare" title="Humana no cuidado. Precisa na entrega." :summary="$siteSettings->about_summary">
|
||||
<p class="max-w-xl text-amare-text-muted">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.</p>
|
||||
</x-public.photo-hero>
|
||||
|
||||
<div class="border-b border-amare-border bg-amare-bg">
|
||||
<div class="container-amare py-16 md:py-24">
|
||||
<ul class="space-y-4 border-t border-amare-border pt-6" aria-label="Princípios da Amare" data-reveal-group>
|
||||
@foreach ($principles as $index => $principle)
|
||||
<li class="grid grid-cols-[3rem_minmax(0,1fr)] gap-4 border-b border-amare-border pb-4 text-amare-text" data-reveal data-reveal-from="up">
|
||||
<span class="text-sm font-semibold uppercase tracking-[0.14em] text-amare-accent">{{ str_pad((string) ($index + 1), 2, '0', STR_PAD_LEFT) }}</span>
|
||||
<span>{{ $principle }}</span>
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<x-home.final-cta :settings="$siteSettings" />
|
||||
<x-about.hero :settings="$content->settings" />
|
||||
<x-about.pillars />
|
||||
<x-about.founder :settings="$content->settings" />
|
||||
<x-about.process />
|
||||
<x-about.portfolio :cases="$content->featuredCases" />
|
||||
<x-about.cta :settings="$content->settings" />
|
||||
@endsection
|
||||
|
||||
35
resources/views/pages/packages/show.blade.php
Normal file
35
resources/views/pages/packages/show.blade.php
Normal file
@@ -0,0 +1,35 @@
|
||||
@extends('layouts.public')
|
||||
|
||||
@section('content')
|
||||
<article>
|
||||
<x-public.package-hero
|
||||
:image-path="$package->hero_image_path"
|
||||
:image-alt="$package->hero_image_alt"
|
||||
:eyebrow="$package->eyebrow"
|
||||
:title-line="$package->title_line"
|
||||
:title-emphasis="$package->title_emphasis"
|
||||
:lead="$package->hero_lead"
|
||||
>
|
||||
<a href="#final-cta-heading" class="btn btn-outline text-sm font-semibold uppercase tracking-[0.12em]">Solicitar proposta</a>
|
||||
</x-public.package-hero>
|
||||
|
||||
<x-public.package-benefits :benefits="$package->benefits ?? []" />
|
||||
|
||||
<x-public.package-included :items="$package->included_items ?? []" />
|
||||
|
||||
<x-public.package-audience
|
||||
:image-path="$package->audience_image_path"
|
||||
:image-alt="$package->audience_image_alt"
|
||||
:heading="$package->audience_heading"
|
||||
:intro="$package->audience_intro"
|
||||
:points="$package->audience_points ?? []"
|
||||
/>
|
||||
</article>
|
||||
|
||||
<x-public.package-final-cta
|
||||
:settings="$siteSettings"
|
||||
:heading="$package->final_cta_heading"
|
||||
:body="$package->final_cta_body"
|
||||
:package-name="$package->name"
|
||||
/>
|
||||
@endsection
|
||||
@@ -1,55 +1,94 @@
|
||||
@extends('layouts.public')
|
||||
|
||||
@section('content')
|
||||
<x-public.photo-hero :image-path="$siteSettings->services_hero_image_path" :image-alt="$siteSettings->services_hero_image_alt" eyebrow="Serviços" title="Uma mesma excelência, diferentes ocasiões." summary="O escopo é construído de acordo com o momento do projeto, o nível de apoio necessário e a complexidade de cada evento." />
|
||||
|
||||
<div class="border-b border-amare-border bg-amare-bg">
|
||||
<div class="container-amare space-y-10 py-16 md:py-24">
|
||||
|
||||
@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>
|
||||
@else
|
||||
@php($hasPrioritizedImage = false)
|
||||
<div class="divide-y divide-amare-border border-y border-amare-border" data-editorial-service-list data-reveal-group>
|
||||
@foreach ($services as $index => $service)
|
||||
<article @class([
|
||||
'grid gap-4 py-8 md:grid-cols-[5rem_minmax(0,1fr)_minmax(0,1.2fr)] md:items-start',
|
||||
'md:translate-x-8' => $loop->even,
|
||||
]) data-reveal data-reveal-from="up">
|
||||
<span class="text-sm font-semibold uppercase tracking-[0.14em] text-amare-accent">{{ str_pad((string) ($index + 1), 2, '0', STR_PAD_LEFT) }}</span>
|
||||
<div class="space-y-3">
|
||||
<h2 class="text-2xl font-medium text-amare-text md:text-3xl">{{ $service->title }}</h2>
|
||||
<p class="text-amare-muted">{{ $service->summary }}</p>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
@if (filled($service->cover_image_path))
|
||||
@php($prioritizeImage = ! $hasPrioritizedImage)
|
||||
<x-media.image
|
||||
:path="$service->cover_image_path"
|
||||
:alt="$service->cover_image_alt ?: $service->title"
|
||||
:loading="$prioritizeImage ? 'eager' : 'lazy'"
|
||||
:fetchpriority="$prioritizeImage ? 'high' : null"
|
||||
sizes="(max-width: 768px) calc(100vw - 3rem), 40vw"
|
||||
class="img-editorial aspect-[16/10] w-full object-cover"
|
||||
/>
|
||||
@php($hasPrioritizedImage = true)
|
||||
@endif
|
||||
@if (filled($service->description))
|
||||
<div class="text-amare-muted">
|
||||
{!! nl2br(e($service->description)) !!}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</article>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
<x-public.photo-hero
|
||||
:image-path="$siteSettings->services_hero_image_path"
|
||||
:image-alt="$siteSettings->services_hero_image_alt"
|
||||
eyebrow="Serviços"
|
||||
title="O cuidado certo para cada tipo de evento."
|
||||
summary="A Amare acompanha projetos sociais e corporativos com planejamento, organização e condução profissional. O formato muda conforme o momento, o evento e a necessidade de cada cliente."
|
||||
>
|
||||
<div class="flex flex-wrap items-center gap-4">
|
||||
<a href="#vertentes" class="btn btn-primary text-xs font-bold uppercase tracking-[0.09em]">
|
||||
Conhecer os serviços
|
||||
</a>
|
||||
<a href="{{ route('contact') }}" class="btn btn-ghost min-h-0 px-0 text-xs font-bold uppercase tracking-[0.09em] text-amare-accent hover:bg-transparent hover:text-amare-accent-deep">
|
||||
<span class="border-b border-amare-accent pb-1">Tenho um evento em mente</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</x-public.photo-hero>
|
||||
|
||||
<x-home.vertentes
|
||||
eyebrow="Duas vertentes"
|
||||
heading="Escolha por contexto, não por uma lista de pacotes."
|
||||
:cards="[
|
||||
[
|
||||
'label' => 'Amare Casamentos',
|
||||
'title' => 'Acompanhamento para diferentes momentos do planejamento.',
|
||||
'body' => 'Três modalidades para casais que estão começando, já avançaram ou precisam de apoio na reta final.',
|
||||
'href' => '#casamentos',
|
||||
'cta' => 'Ver modalidades',
|
||||
],
|
||||
[
|
||||
'label' => 'Amare Corporate',
|
||||
'title' => 'Planejamento e execução para eventos empresariais.',
|
||||
'body' => 'Uma frente própria, com linguagem profissional e estrutura adequada a projetos corporativos.',
|
||||
'href' => '#corporate',
|
||||
'cta' => 'Ver Corporate',
|
||||
],
|
||||
]"
|
||||
/>
|
||||
|
||||
<x-home.packages
|
||||
:packages="$weddingPackages"
|
||||
:settings="$siteSettings"
|
||||
eyebrow="Amare Casamentos"
|
||||
heading="Três formas de ter a Amare ao lado."
|
||||
:show-intro="false"
|
||||
kicker="tag"
|
||||
:show-subtitle="true"
|
||||
:band-cta-href="route('briefing')"
|
||||
band-body="Conte um pouco sobre o casamento. A Amare entende o momento de vocês e orienta o melhor formato de acompanhamento sem depender de um quiz automático."
|
||||
:show-note="false"
|
||||
/>
|
||||
|
||||
@if ($weddingPackages->isNotEmpty())
|
||||
<section class="border-b border-amare-border bg-amare-bg-deep py-16 md:py-24" aria-labelledby="wedding-packages-heading"><div class="container-amare"><p class="text-xs font-semibold uppercase tracking-[.14em] text-amare-accent">Casamentos</p><h2 id="wedding-packages-heading" class="mt-3 text-3xl font-medium">Modalidades de assessoria</h2><div class="mt-8 grid gap-6 md:grid-cols-3">@foreach ($weddingPackages as $package)<article class="border-t border-amare-border pt-4"><p class="text-sm text-amare-accent">{{ $package->level }}</p><h3 class="mt-2 text-2xl font-medium">{{ $package->name }}</h3><p class="mt-2 text-amare-muted">{{ $package->summary }}</p><ul class="mt-4 space-y-2 text-sm text-amare-text-muted">@foreach ($package->scope_items as $item)<li>{{ $item }}</li>@endforeach</ul></article>@endforeach</div></div></section>
|
||||
<section aria-label="Comparação rápida entre as modalidades" class="border-b border-amare-border bg-amare-bg">
|
||||
<div class="container-amare py-16 md:py-24" data-reveal-group>
|
||||
<div class="grid border-l border-t border-amare-border md:grid-cols-3">
|
||||
@foreach ($weddingPackages as $package)
|
||||
@if (filled($package->compare_heading))
|
||||
<article class="flex min-h-[300px] flex-col justify-between gap-6 border-b border-r border-amare-border p-8 md:p-10" data-reveal data-reveal-from="up">
|
||||
<div class="space-y-4">
|
||||
<p class="text-xs font-bold uppercase tracking-[0.16em] text-amare-accent">{{ $package->name }}</p>
|
||||
<h3 class="text-[clamp(1.25rem,2vw,1.5rem)] font-medium leading-tight text-amare-text">{{ $package->compare_heading }}</h3>
|
||||
<p class="text-amare-text-muted">{{ $package->compare_summary }}</p>
|
||||
</div>
|
||||
<a href="{{ route('packages.show', $package->slug) }}" class="btn btn-ghost min-h-0 self-start px-0 text-xs font-semibold uppercase tracking-[0.09em] text-amare-accent hover:bg-transparent hover:text-amare-accent-deep">
|
||||
<span class="border-b border-amare-accent pb-1">Conhecer esta modalidade</span>
|
||||
</a>
|
||||
</article>
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@endif
|
||||
|
||||
<x-home.final-cta :settings="$siteSettings" />
|
||||
<x-home.corporate
|
||||
:settings="$siteSettings"
|
||||
eyebrow="Amare Corporate"
|
||||
heading="Organização, produção e condução com linguagem empresarial."
|
||||
:show-meta="false"
|
||||
:steps="[
|
||||
['title' => 'Planejamento', 'body' => 'Estruturação do evento, escopo, prioridades e cronograma.'],
|
||||
['title' => 'Produção', 'body' => 'Coordenação dos elementos e fornecedores necessários ao projeto.'],
|
||||
['title' => 'Execução', 'body' => 'Acompanhamento e condução do evento conforme o planejamento aprovado.'],
|
||||
]"
|
||||
cta-label="Falar sobre um evento Corporate"
|
||||
:cta-href="route('contact')"
|
||||
:show-aside="false"
|
||||
/>
|
||||
|
||||
<x-home.final-cta :settings="$siteSettings" :cta-href="route('contact')" />
|
||||
@endsection
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
use App\Http\Controllers\PublicSite\ContactController;
|
||||
use App\Http\Controllers\PublicSite\HomeController;
|
||||
use App\Http\Controllers\PublicSite\PackageController;
|
||||
use App\Http\Controllers\PublicSite\PageController;
|
||||
use App\Http\Controllers\PublicSite\PartnerInquiryController;
|
||||
use App\Http\Controllers\PublicSite\PortfolioController;
|
||||
@@ -17,6 +18,7 @@ Route::get('/', HomeController::class)->name('home');
|
||||
Route::get('/servicos', [ServiceController::class, 'index'])->name('services.index');
|
||||
Route::get('/portfolio', [PortfolioController::class, 'index'])->name('portfolio.index');
|
||||
Route::get('/portfolio/{slug}', [PortfolioController::class, 'show'])->name('portfolio.show');
|
||||
Route::get('/pacotes/{slug}', [PackageController::class, 'show'])->name('packages.show');
|
||||
Route::get('/sobre', [PageController::class, 'about'])->name('about');
|
||||
Route::get('/privacidade', [PageController::class, 'privacy'])->name('privacy');
|
||||
Route::get('/contato', [PageController::class, 'contact'])->name('contact');
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
# DOKPLOY_API_KEY x-api-key value
|
||||
# DOKPLOY_COMPOSE_ID target compose id
|
||||
# Optional env:
|
||||
# DEPLOY_TITLE deployment title (default: GitHub deploy)
|
||||
# DEPLOY_TITLE deployment title (default: Gitea deploy)
|
||||
# DEPLOY_TIMEOUT_SEC total wait seconds (default: 900)
|
||||
# DEPLOY_POLL_SEC poll interval (default: 10)
|
||||
|
||||
@@ -20,7 +20,7 @@ DOKPLOY_URL="${DOKPLOY_URL%/}"
|
||||
DOKPLOY_URL="${DOKPLOY_URL%/api}"
|
||||
DOKPLOY_URL="${DOKPLOY_URL%/}"
|
||||
|
||||
DEPLOY_TITLE="${DEPLOY_TITLE:-GitHub deploy}"
|
||||
DEPLOY_TITLE="${DEPLOY_TITLE:-Gitea deploy}"
|
||||
DEPLOY_TIMEOUT_SEC="${DEPLOY_TIMEOUT_SEC:-900}"
|
||||
DEPLOY_POLL_SEC="${DEPLOY_POLL_SEC:-10}"
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Application\Queries\Marketing;
|
||||
|
||||
use App\Application\Queries\Marketing\FindPublishedWeddingPackageBySlug;
|
||||
use App\Models\WeddingPackage;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class FindPublishedWeddingPackageBySlugTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_returns_published_package_and_null_for_draft_or_unknown(): void
|
||||
{
|
||||
$published = WeddingPackage::factory()->published()->create([
|
||||
'slug' => 'essenza',
|
||||
]);
|
||||
|
||||
WeddingPackage::factory()->create([
|
||||
'slug' => 'rascunho-interno',
|
||||
'published_at' => null,
|
||||
]);
|
||||
|
||||
$found = (new FindPublishedWeddingPackageBySlug)('essenza');
|
||||
$draft = (new FindPublishedWeddingPackageBySlug)('rascunho-interno');
|
||||
$missing = (new FindPublishedWeddingPackageBySlug)('inexistente');
|
||||
|
||||
$this->assertNotNull($found);
|
||||
$this->assertTrue($found->is($published));
|
||||
$this->assertNull($draft);
|
||||
$this->assertNull($missing);
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,11 @@ declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Marketing;
|
||||
|
||||
use App\Domain\Marketing\PackageIconCatalog;
|
||||
use App\Models\WeddingPackage;
|
||||
use Database\Seeders\ContentSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Str;
|
||||
use Tests\TestCase;
|
||||
|
||||
class WeddingPackageTest extends TestCase
|
||||
@@ -37,4 +39,27 @@ class WeddingPackageTest extends TestCase
|
||||
WeddingPackage::query()->published()->orderBy('sort_order')->pluck('name')->all(),
|
||||
);
|
||||
}
|
||||
|
||||
public function test_factory_generates_a_slug_and_autofills_blank_slug_from_name(): void
|
||||
{
|
||||
$package = WeddingPackage::factory()->published()->create();
|
||||
|
||||
$this->assertNotEmpty($package->slug);
|
||||
$this->assertSame(Str::slug($package->name), $package->slug);
|
||||
|
||||
$explicit = WeddingPackage::factory()->create(['name' => 'Pacote com nome', 'slug' => null]);
|
||||
|
||||
$this->assertSame('pacote-com-nome', $explicit->slug);
|
||||
}
|
||||
|
||||
public function test_package_icon_catalog_accepts_known_keys_and_rejects_unknown(): void
|
||||
{
|
||||
foreach (PackageIconCatalog::keys() as $key) {
|
||||
$this->assertTrue(PackageIconCatalog::isValid($key));
|
||||
$this->assertNotNull(PackageIconCatalog::labelFor($key));
|
||||
}
|
||||
|
||||
$this->assertFalse(PackageIconCatalog::isValid('paw'));
|
||||
$this->assertNull(PackageIconCatalog::labelFor('paw'));
|
||||
}
|
||||
}
|
||||
|
||||
145
tests/Feature/PublicSite/AboutPageContentTest.php
Normal file
145
tests/Feature/PublicSite/AboutPageContentTest.php
Normal file
@@ -0,0 +1,145 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\PublicSite;
|
||||
|
||||
use App\Filament\Pages\ManageSiteSettings;
|
||||
use App\Models\PortfolioCase;
|
||||
use App\Models\SiteSetting;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Livewire\Livewire;
|
||||
use Tests\TestCase;
|
||||
|
||||
class AboutPageContentTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_about_page_renders_mock_section_order_without_principles_list(): void
|
||||
{
|
||||
SiteSetting::instance()->update([
|
||||
'about_summary' => 'Eventos que refletem propósito, conectam pessoas e criam memórias.',
|
||||
'principles' => ['Princípio que não deve aparecer no sobre'],
|
||||
]);
|
||||
|
||||
$this->get(route('about'))
|
||||
->assertOk()
|
||||
->assertSee('data-motion="page-open"', false)
|
||||
->assertSee('Sobre nós')
|
||||
->assertSee('Sobre a Amare')
|
||||
->assertSee('Eventos que refletem propósito, conectam pessoas e criam memórias.')
|
||||
->assertSee('Atendimento próximo')
|
||||
->assertSee('Planejamento minucioso')
|
||||
->assertSee('Execução tranquila')
|
||||
->assertSee('id="michele"', false)
|
||||
->assertSee('Conheça Michele')
|
||||
->assertSee('Como trabalhamos')
|
||||
->assertSee('Escuta e entendimento')
|
||||
->assertSee('Portfólio em destaque')
|
||||
->assertSee('Vamos criar algo inesquecível juntos?')
|
||||
->assertDontSee('Princípio que não deve aparecer no sobre')
|
||||
->assertDontSee('Personalização sem complicação desnecessária');
|
||||
}
|
||||
|
||||
public function test_about_layout_matches_site_editorial_rhythm(): void
|
||||
{
|
||||
SiteSetting::instance()->update([
|
||||
'about_image_path' => 'content/heroes/about.jpg',
|
||||
'about_image_alt' => 'Estúdio Amare',
|
||||
]);
|
||||
|
||||
$html = $this->get(route('about'))->assertOk()->getContent();
|
||||
|
||||
$this->assertMatchesRegularExpression(
|
||||
'/data-about-hero[\s\S]*?lg:grid-cols-12[\s\S]*?lg:col-span-5[\s\S]*?text-hero-spread[\s\S]*?lg:col-span-7/',
|
||||
$html,
|
||||
);
|
||||
$this->assertStringNotContainsString('data-photo-hero', $html);
|
||||
$this->assertDoesNotMatchRegularExpression(
|
||||
'/data-about-hero[\s\S]*?min-h-\[calc\(100dvh-5rem\)\]/',
|
||||
$html,
|
||||
);
|
||||
|
||||
$this->assertMatchesRegularExpression('/data-about-pillars[\s\S]*?py-16 md:py-24/', $html);
|
||||
$this->assertMatchesRegularExpression('/data-about-process[\s\S]*?py-16 md:py-24/', $html);
|
||||
$this->assertMatchesRegularExpression('/data-about-portfolio[\s\S]*?data-editorial-portfolio/', $html);
|
||||
$this->assertDoesNotMatchRegularExpression('/data-about-portfolio[\s\S]*?rounded-full/', $html);
|
||||
$this->assertMatchesRegularExpression('/<(?:section)[^>]*(?:data-about-cta[^>]*bg-amare-bg-deep|bg-amare-bg-deep[^>]*data-about-cta)/', $html);
|
||||
$this->assertStringContainsString('Próximo passo', $html);
|
||||
}
|
||||
|
||||
public function test_about_renders_founder_image_from_cms_when_configured(): void
|
||||
{
|
||||
SiteSetting::instance()->update([
|
||||
'founder_image_path' => 'content/about/founder/michele.jpg',
|
||||
'founder_image_alt' => 'Michele, da Amare',
|
||||
]);
|
||||
|
||||
$this->get(route('about'))
|
||||
->assertOk()
|
||||
->assertSee('content/about/founder/michele.jpg', false)
|
||||
->assertSee('Michele, da Amare')
|
||||
->assertSee('data-about-founder', false);
|
||||
}
|
||||
|
||||
public function test_about_omits_founder_image_request_when_unset(): void
|
||||
{
|
||||
SiteSetting::instance()->update([
|
||||
'founder_image_path' => null,
|
||||
'founder_image_alt' => null,
|
||||
]);
|
||||
|
||||
$html = $this->get(route('about'))->assertOk()->getContent();
|
||||
|
||||
$this->assertStringContainsString('data-about-founder', $html);
|
||||
$this->assertStringNotContainsString('content/about/founder/', $html);
|
||||
$this->assertStringContainsString('data-founder-tonal', $html);
|
||||
}
|
||||
|
||||
public function test_about_portfolio_links_featured_cases(): void
|
||||
{
|
||||
$case = PortfolioCase::factory()->published()->create([
|
||||
'title' => 'Casamento Ana e Lucas',
|
||||
'slug' => 'casamento-ana-lucas',
|
||||
'is_featured' => true,
|
||||
'cover_image_path' => 'content/cases/ana-lucas.jpg',
|
||||
'cover_image_alt' => 'Cerimônia ao ar livre',
|
||||
'sort_order' => 1,
|
||||
]);
|
||||
|
||||
PortfolioCase::factory()->published()->create([
|
||||
'title' => 'Caso não destacado',
|
||||
'slug' => 'nao-destacado',
|
||||
'is_featured' => false,
|
||||
'cover_image_path' => 'content/cases/other.jpg',
|
||||
'sort_order' => 2,
|
||||
]);
|
||||
|
||||
$this->get(route('about'))
|
||||
->assertOk()
|
||||
->assertSee(route('portfolio.show', $case), false)
|
||||
->assertSee('Cerimônia ao ar livre')
|
||||
->assertDontSee(route('portfolio.show', 'nao-destacado'), false);
|
||||
}
|
||||
|
||||
public function test_founder_image_upload_requires_alt_text(): void
|
||||
{
|
||||
Storage::fake('public');
|
||||
$this->actingAs(User::factory()->admin()->create());
|
||||
|
||||
Livewire::test(ManageSiteSettings::class)
|
||||
->set('data.founder_image_path', [UploadedFile::fake()->create('michele.jpg', 100, 'image/jpeg')])
|
||||
->set('data.founder_image_alt', null)
|
||||
->call('save')
|
||||
->assertHasFormErrors(['founder_image_alt' => 'required']);
|
||||
|
||||
Livewire::test(ManageSiteSettings::class)
|
||||
->set('data.founder_image_path', null)
|
||||
->set('data.founder_image_alt', null)
|
||||
->call('save')
|
||||
->assertHasNoFormErrors();
|
||||
}
|
||||
}
|
||||
@@ -107,8 +107,7 @@ class HomePageContentTest extends TestCase
|
||||
], false)
|
||||
->assertSee('FOTO DE CASAMENTO 01')
|
||||
->assertSee('Nome e data reais — aguardando autorização')
|
||||
->assertSee('Conteúdo em construção')
|
||||
->assertSee('Fornecedores e parcerias — pós-MVP');
|
||||
->assertSee('Conteúdo em construção');
|
||||
}
|
||||
|
||||
public function test_hero_falls_back_to_default_copy_when_fields_are_empty(): void
|
||||
@@ -170,6 +169,7 @@ class HomePageContentTest extends TestCase
|
||||
WeddingPackage::factory()->published()->create([
|
||||
'level' => '01 / COMPLETA',
|
||||
'name' => 'Essenza',
|
||||
'slug' => 'essenza',
|
||||
'summary' => 'Para casais que desejam contar com a Amare desde o planejamento até a realização do casamento.',
|
||||
'scope_items' => ['Planejamento e organização', 'Gestão de etapas e prioridades'],
|
||||
'cta_label' => 'Quero conhecer a Essenza',
|
||||
@@ -188,6 +188,8 @@ class HomePageContentTest extends TestCase
|
||||
->assertSee('Gestão de etapas e prioridades')
|
||||
->assertSee('Quero conhecer a Essenza')
|
||||
->assertSee('href="'.route('briefing', ['servico_interesse' => 'Essenza']).'"', false)
|
||||
->assertSee('href="'.route('packages.show', 'essenza').'"', false)
|
||||
->assertSee('Conhecer esta modalidade')
|
||||
->assertSee('Ainda não sabe qual modalidade é ideal?')
|
||||
->assertSee('Conversar com a Amare')
|
||||
->assertSee('Nomenclaturas exibidas conforme materiais/reunião; confirmar versão final antes da publicação.');
|
||||
|
||||
@@ -80,6 +80,14 @@ class ImmersivePhotoHeroTest extends TestCase
|
||||
->assertDontSee('data-split-hero', false)
|
||||
->assertDontSee('content/heroes/home.jpg', false);
|
||||
}
|
||||
|
||||
if ($route === route('about')) {
|
||||
$response
|
||||
->assertSee('data-motion="page-open"', false)
|
||||
->assertSee('data-about-hero', false)
|
||||
->assertDontSee('data-photo-hero', false)
|
||||
->assertDontSee('min-h-[calc(100dvh-5rem)]', false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,7 +107,7 @@ class ImmersivePhotoHeroTest extends TestCase
|
||||
'cover_image_alt' => 'Cerimônia ao ar livre',
|
||||
]);
|
||||
|
||||
foreach ([route('services.index'), route('portfolio.index'), route('portfolio.show', $case->slug), route('about')] as $route) {
|
||||
foreach ([route('services.index'), route('portfolio.index'), route('portfolio.show', $case->slug)] as $route) {
|
||||
$this->get($route)
|
||||
->assertOk()
|
||||
->assertSee('data-photo-hero', false)
|
||||
@@ -107,6 +115,15 @@ class ImmersivePhotoHeroTest extends TestCase
|
||||
->assertSee('fetchpriority="high"', false)
|
||||
->assertSee('sizes="(max-width: 1023px) 100vw, 58vw"', false);
|
||||
}
|
||||
|
||||
$this->get(route('about'))
|
||||
->assertOk()
|
||||
->assertSee('data-about-hero', false)
|
||||
->assertSee('content/heroes/about.jpg', false)
|
||||
->assertSee('loading="eager"', false)
|
||||
->assertSee('fetchpriority="high"', false)
|
||||
->assertDontSee('data-photo-hero', false)
|
||||
->assertDontSee('data-tonal-hero', false);
|
||||
}
|
||||
|
||||
public function test_hero_upload_requires_alt_text_only_when_an_image_is_uploaded(): void
|
||||
|
||||
@@ -42,8 +42,8 @@ class MediaPerformanceTest extends TestCase
|
||||
$about = $this->get(route('about'))->assertOk()->getContent();
|
||||
$detail = $this->get(route('portfolio.show', $firstCase->slug))->assertOk()->getContent();
|
||||
|
||||
$this->assertMatchesRegularExpression('/service-first\.jpg"[^>]*loading="eager"[^>]*fetchpriority="high"/', $services);
|
||||
$this->assertMatchesRegularExpression('/service-later\.jpg"[^>]*loading="lazy"(?![^>]*fetchpriority)/', $services);
|
||||
$this->assertStringNotContainsString('service-first.jpg', $services);
|
||||
$this->assertStringNotContainsString('service-later.jpg', $services);
|
||||
$this->assertMatchesRegularExpression('/case-first\.jpg"[^>]*loading="eager"[^>]*fetchpriority="high"/', $portfolio);
|
||||
$this->assertMatchesRegularExpression('/case-later\.jpg"[^>]*loading="lazy"(?![^>]*fetchpriority)/', $portfolio);
|
||||
$this->assertMatchesRegularExpression('/about\.jpg"[^>]*loading="eager"[^>]*fetchpriority="high"/', $about);
|
||||
@@ -89,7 +89,7 @@ class MediaPerformanceTest extends TestCase
|
||||
$services = $this->get(route('services.index'))->assertOk()->getContent();
|
||||
$portfolio = $this->get(route('portfolio.index'))->assertOk()->getContent();
|
||||
|
||||
$this->assertMatchesRegularExpression('/visible-service\.jpg"[^>]*loading="eager"[^>]*fetchpriority="high"/', $services);
|
||||
$this->assertStringNotContainsString('visible-service.jpg', $services);
|
||||
$this->assertMatchesRegularExpression('/visible-case\.jpg"[^>]*loading="eager"[^>]*fetchpriority="high"/', $portfolio);
|
||||
}
|
||||
}
|
||||
|
||||
118
tests/Feature/PublicSite/PackageDetailTest.php
Normal file
118
tests/Feature/PublicSite/PackageDetailTest.php
Normal file
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\PublicSite;
|
||||
|
||||
use App\Models\SiteSetting;
|
||||
use App\Models\WeddingPackage;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class PackageDetailTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_published_package_renders_all_detail_sections(): void
|
||||
{
|
||||
SiteSetting::instance();
|
||||
|
||||
$package = WeddingPackage::factory()->published()->create([
|
||||
'slug' => 'essenza',
|
||||
'eyebrow' => 'Assessoria',
|
||||
'title_line' => 'Assessoria',
|
||||
'title_emphasis' => 'Completa',
|
||||
'hero_lead' => 'Do planejamento ao grande dia, com calma, precisão e cuidado.',
|
||||
'benefits' => [
|
||||
['icon_key' => 'calendar', 'label' => 'Organização integral'],
|
||||
['icon_key' => 'users', 'label' => 'Fornecedores selecionados'],
|
||||
],
|
||||
'included_items' => [
|
||||
['icon_key' => 'checklist', 'title' => 'Planejamento completo', 'description' => 'Cada etapa mapeada com clareza.'],
|
||||
['icon_key' => 'map', 'title' => 'Gestão de fornecedores', 'description' => 'Contatos e prazos sob cuidado.'],
|
||||
],
|
||||
'audience_heading' => 'Para quem é este pacote',
|
||||
'audience_intro' => 'Para quem quer uma assessoria completa, do início ao fim.',
|
||||
'audience_points' => ['Casamentos', 'Eventos corporativos'],
|
||||
'final_cta_heading' => 'Pronta para começar?',
|
||||
'final_cta_body' => 'Conte seu evento para a Amare e receba uma proposta.',
|
||||
]);
|
||||
|
||||
$response = $this->get(route('packages.show', $package->slug));
|
||||
|
||||
$response
|
||||
->assertOk()
|
||||
->assertSee('Assessoria')
|
||||
->assertSee('Completa')
|
||||
->assertSee('Do planejamento ao grande dia, com calma, precisão e cuidado.')
|
||||
->assertSee('Organização integral')
|
||||
->assertSee('Fornecedores selecionados')
|
||||
->assertSee('O que está incluso')
|
||||
->assertSee('Planejamento completo')
|
||||
->assertSee('Cada etapa mapeada com clareza.')
|
||||
->assertSee('Gestão de fornecedores')
|
||||
->assertSee('Para quem é este pacote')
|
||||
->assertSee('Casamentos')
|
||||
->assertSee('Eventos corporativos')
|
||||
->assertSee('Pronta para começar?')
|
||||
->assertSee('Conte seu evento para a Amare e receba uma proposta.')
|
||||
->assertSee('Solicitar proposta');
|
||||
}
|
||||
|
||||
public function test_draft_package_returns_404(): void
|
||||
{
|
||||
SiteSetting::instance();
|
||||
|
||||
$package = WeddingPackage::factory()->create([
|
||||
'slug' => 'rascunho-interno',
|
||||
'title_line' => 'Rascunho',
|
||||
]);
|
||||
|
||||
$response = $this->get(route('packages.show', $package->slug));
|
||||
|
||||
$response->assertNotFound();
|
||||
}
|
||||
|
||||
public function test_unknown_slug_returns_404(): void
|
||||
{
|
||||
SiteSetting::instance();
|
||||
|
||||
$this->get(route('packages.show', 'inexistente'))->assertNotFound();
|
||||
}
|
||||
|
||||
public function test_package_cta_uses_whatsapp_when_a_number_is_configured(): void
|
||||
{
|
||||
SiteSetting::instance()->update(['whatsapp_number' => '+55 11 98888-7777']);
|
||||
|
||||
$package = WeddingPackage::factory()->published()->create([
|
||||
'name' => 'Essenza',
|
||||
]);
|
||||
|
||||
$response = $this->get(route('packages.show', $package->slug));
|
||||
|
||||
$response
|
||||
->assertOk()
|
||||
->assertSee(
|
||||
'https://wa.me/5511988887777?text='.rawurlencode('Olá, gostaria de conversar sobre a modalidade Essenza para meu casamento.'),
|
||||
false,
|
||||
)
|
||||
->assertSee('target="_blank"', false)
|
||||
->assertDontSee(route('briefing', ['servico_interesse' => 'Essenza']), false);
|
||||
}
|
||||
|
||||
public function test_package_cta_falls_back_to_briefing_without_whatsapp_number(): void
|
||||
{
|
||||
SiteSetting::instance();
|
||||
|
||||
$package = WeddingPackage::factory()->published()->create([
|
||||
'name' => 'Essenza',
|
||||
]);
|
||||
|
||||
$response = $this->get(route('packages.show', $package->slug));
|
||||
|
||||
$response
|
||||
->assertOk()
|
||||
->assertSee(route('briefing', ['servico_interesse' => 'Essenza']), false)
|
||||
->assertDontSee('wa.me/', false);
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,8 @@ namespace Tests\Feature\PublicSite;
|
||||
|
||||
use App\Models\PortfolioCase;
|
||||
use App\Models\PortfolioImage;
|
||||
use App\Models\Service;
|
||||
use App\Models\SiteSetting;
|
||||
use App\Models\WeddingPackage;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -18,17 +18,45 @@ class PublicPagesTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_services_index_lists_published_services_in_sort_order(): void
|
||||
public function test_services_index_renders_editorial_sections_with_published_modalities_only(): void
|
||||
{
|
||||
SiteSetting::instance();
|
||||
|
||||
Service::factory()->create(['title' => 'Rascunho', 'published_at' => null]);
|
||||
Service::factory()->published()->create(['title' => 'Serviço B', 'sort_order' => 20]);
|
||||
Service::factory()->published()->create(['title' => 'Serviço A', 'sort_order' => 10]);
|
||||
WeddingPackage::factory()->create(['name' => 'Rascunho', 'published_at' => null]);
|
||||
WeddingPackage::factory()->published()->create([
|
||||
'name' => 'Essenza',
|
||||
'level' => '01 / COMPLETA',
|
||||
'tag' => 'Assessoria completa',
|
||||
'subtitle' => 'Do planejamento ao grande dia.',
|
||||
'summary' => 'Para casais que desejam contar com a Amare desde o início.',
|
||||
'scope_items' => ['Estruturação do planejamento', 'Coordenação da execução'],
|
||||
'cta_label' => 'Quero conhecer a Essenza',
|
||||
'compare_heading' => 'Começar com a Amare',
|
||||
'compare_summary' => 'Acompanhamento mais amplo ao longo do planejamento.',
|
||||
'sort_order' => 10,
|
||||
]);
|
||||
|
||||
$this->get(route('services.index'))
|
||||
->assertOk()
|
||||
->assertSeeInOrder(['Serviço A', 'Serviço B'])
|
||||
->assertSee('O cuidado certo para cada tipo de evento.')
|
||||
->assertSee('Conhecer os serviços')
|
||||
->assertSee('Tenho um evento em mente')
|
||||
->assertSee('Duas vertentes')
|
||||
->assertSee('Escolha por contexto, não por uma lista de pacotes.')
|
||||
->assertSee('Amare Casamentos')
|
||||
->assertSee('Acompanhamento para diferentes momentos do planejamento.')
|
||||
->assertSee('Amare Corporate')
|
||||
->assertSee('Planejamento e execução para eventos empresariais.')
|
||||
->assertSee('Três formas de ter a Amare ao lado.')
|
||||
->assertSee('Assessoria completa')
|
||||
->assertSee('Essenza')
|
||||
->assertSee('Do planejamento ao grande dia.')
|
||||
->assertSee('Quero conhecer a Essenza')
|
||||
->assertSee('Começar com a Amare')
|
||||
->assertSee('Organização, produção e condução com linguagem empresarial.')
|
||||
->assertSee('Estruturação do evento, escopo, prioridades e cronograma.')
|
||||
->assertSee('Falar sobre um evento Corporate')
|
||||
->assertSee('Do casamento ao evento corporativo, tudo começa com uma boa conversa.')
|
||||
->assertDontSee('Rascunho');
|
||||
}
|
||||
|
||||
@@ -168,9 +196,11 @@ class PublicPagesTest extends TestCase
|
||||
$this->get(route('about'))
|
||||
->assertOk()
|
||||
->assertSee('Sobre a Amare boutique')
|
||||
->assertSee('São Paulo - SP')
|
||||
->assertSee('Sobre a Amare')
|
||||
->assertSee('Conheça Michele')
|
||||
->assertDontSee('Fortaleza')
|
||||
->assertSee('data-tonal-hero', false);
|
||||
->assertSee('data-tonal-hero', false)
|
||||
->assertSee('data-about-hero', false);
|
||||
|
||||
$this->get(route('privacy'))
|
||||
->assertOk()
|
||||
|
||||
53
tests/Feature/PublicSite/ServicesPageCtaTest.php
Normal file
53
tests/Feature/PublicSite/ServicesPageCtaTest.php
Normal file
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\PublicSite;
|
||||
|
||||
use App\Models\SiteSetting;
|
||||
use App\Models\WeddingPackage;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ServicesPageCtaTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_package_cta_uses_whatsapp_when_a_number_is_configured(): void
|
||||
{
|
||||
SiteSetting::instance()->update(['whatsapp_number' => '+55 11 98888-7777']);
|
||||
|
||||
WeddingPackage::factory()->published()->create([
|
||||
'name' => 'Grand Jour',
|
||||
'cta_label' => 'Quero conhecer a Grand Jour',
|
||||
]);
|
||||
|
||||
$response = $this->get(route('services.index'));
|
||||
|
||||
$response
|
||||
->assertOk()
|
||||
->assertSee(
|
||||
'https://wa.me/5511988887777?text='.rawurlencode('Olá, gostaria de conversar sobre a modalidade Grand Jour para meu casamento.'),
|
||||
false,
|
||||
)
|
||||
->assertSee('target="_blank"', false)
|
||||
->assertDontSee(route('briefing', ['servico_interesse' => 'Grand Jour']), false);
|
||||
}
|
||||
|
||||
public function test_package_cta_falls_back_to_briefing_without_whatsapp_number(): void
|
||||
{
|
||||
SiteSetting::instance()->update(['whatsapp_number' => null]);
|
||||
|
||||
WeddingPackage::factory()->published()->create([
|
||||
'name' => 'Essenza',
|
||||
'cta_label' => 'Quero conhecer a Essenza',
|
||||
]);
|
||||
|
||||
$response = $this->get(route('services.index'));
|
||||
|
||||
$response
|
||||
->assertOk()
|
||||
->assertSee('href="'.route('briefing', ['servico_interesse' => 'Essenza']).'"', false)
|
||||
->assertDontSee('wa.me/', false);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ namespace Tests\Feature\PublicSite;
|
||||
|
||||
use App\Models\PortfolioCase;
|
||||
use App\Models\SiteSetting;
|
||||
use App\Models\WeddingPackage;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
@@ -60,4 +61,23 @@ class SitemapTest extends TestCase
|
||||
->assertOk()
|
||||
->assertSee(route('portfolio.show', $case->slug), false);
|
||||
}
|
||||
|
||||
public function test_sitemap_includes_only_published_packages(): void
|
||||
{
|
||||
SiteSetting::instance();
|
||||
|
||||
$published = WeddingPackage::factory()->published()->create([
|
||||
'slug' => 'essenza',
|
||||
]);
|
||||
|
||||
WeddingPackage::factory()->create([
|
||||
'slug' => 'rascunho-pacote',
|
||||
'published_at' => null,
|
||||
]);
|
||||
|
||||
$this->get('/sitemap.xml')
|
||||
->assertOk()
|
||||
->assertSee(route('packages.show', $published->slug), false)
|
||||
->assertDontSee('rascunho-pacote', false);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user