Compare commits
15 Commits
feature/re
...
feat/front
| Author | SHA1 | Date | |
|---|---|---|---|
| 2b4e6a7d1c | |||
| f5830930bc | |||
| 8343867072 | |||
| 8a12b7d82c | |||
| 246410803d | |||
| af8484c74e | |||
| 35bc5a59d9 | |||
| 27711ad0c2 | |||
| af31738021 | |||
| 286522d8a2 | |||
| 3e68192cf3 | |||
| 1a31dd1cf6 | |||
| 9060036024 | |||
| 7f4ea01f4b | |||
| cf1589c916 |
@@ -2,6 +2,7 @@ APP_NAME=Amare
|
|||||||
APP_ENV=local
|
APP_ENV=local
|
||||||
APP_KEY=
|
APP_KEY=
|
||||||
APP_DEBUG=true
|
APP_DEBUG=true
|
||||||
|
# Staging/production: set APP_URL to the public HTTPS origin (e.g. https://staging.example.com).
|
||||||
APP_URL=http://localhost
|
APP_URL=http://localhost
|
||||||
|
|
||||||
APP_LOCALE=pt_BR
|
APP_LOCALE=pt_BR
|
||||||
@@ -38,6 +39,10 @@ SESSION_LIFETIME=120
|
|||||||
SESSION_ENCRYPT=false
|
SESSION_ENCRYPT=false
|
||||||
SESSION_PATH=/
|
SESSION_PATH=/
|
||||||
SESSION_DOMAIN=null
|
SESSION_DOMAIN=null
|
||||||
|
# Staging/production behind HTTPS (Dokploy Traefik): SESSION_SECURE_COOKIE=true
|
||||||
|
SESSION_SECURE_COOKIE=false
|
||||||
|
SESSION_HTTP_ONLY=true
|
||||||
|
SESSION_SAME_SITE=lax
|
||||||
|
|
||||||
BROADCAST_CONNECTION=log
|
BROADCAST_CONNECTION=log
|
||||||
FILESYSTEM_DISK=local
|
FILESYSTEM_DISK=local
|
||||||
|
|||||||
82
.github/workflows/deploy-staging.yml
vendored
Normal file
82
.github/workflows/deploy-staging.yml
vendored
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
name: Deploy staging
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_run:
|
||||||
|
workflows: [CI]
|
||||||
|
types: [completed]
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: deploy-staging
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: ghcr.io
|
||||||
|
IMAGE_NAME: ${{ github.repository }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
name: publish-and-deploy-staging
|
||||||
|
if: >-
|
||||||
|
github.event.workflow_run.conclusion == 'success' &&
|
||||||
|
github.event.workflow_run.event == 'push' &&
|
||||||
|
github.event.workflow_run.head_branch == 'main'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout deployed SHA
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event.workflow_run.head_sha }}
|
||||||
|
|
||||||
|
- name: Set image metadata
|
||||||
|
id: meta
|
||||||
|
run: |
|
||||||
|
SHA="${{ github.event.workflow_run.head_sha }}"
|
||||||
|
SHORT_SHA="${SHA:0:7}"
|
||||||
|
IMAGE="${REGISTRY}/${IMAGE_NAME}"
|
||||||
|
IMAGE="$(echo "$IMAGE" | tr '[:upper:]' '[:lower:]')"
|
||||||
|
echo "sha=${SHA}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "short_sha=${SHORT_SHA}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "image=${IMAGE}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Log in to GHCR
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Build and push SHA + staging tags
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.sha }}
|
||||||
|
${{ steps.meta.outputs.image }}:staging
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
|
- name: Deploy staging on Dokploy
|
||||||
|
env:
|
||||||
|
DOKPLOY_URL: ${{ secrets.DOKPLOY_URL }}
|
||||||
|
DOKPLOY_API_KEY: ${{ secrets.DOKPLOY_API_KEY }}
|
||||||
|
DOKPLOY_COMPOSE_ID: ${{ secrets.DOKPLOY_STAGING_COMPOSE_ID }}
|
||||||
|
DEPLOY_TITLE: "staging ${{ steps.meta.outputs.short_sha }}"
|
||||||
|
run: |
|
||||||
|
chmod +x scripts/deploy/dokploy-deploy.sh
|
||||||
|
./scripts/deploy/dokploy-deploy.sh
|
||||||
|
|
||||||
|
- name: Smoke staging
|
||||||
|
env:
|
||||||
|
SMOKE_BASE_URL: ${{ secrets.STAGING_URL }}
|
||||||
|
run: |
|
||||||
|
chmod +x scripts/deploy/smoke.sh
|
||||||
|
./scripts/deploy/smoke.sh
|
||||||
84
.github/workflows/promote-production.yml
vendored
Normal file
84
.github/workflows/promote-production.yml
vendored
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
name: Promote production
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
sha:
|
||||||
|
description: Full git SHA already published to GHCR (same digest used by staging)
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
confirm:
|
||||||
|
description: Type PRODUCTION to confirm promotion of the given SHA
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: deploy-production
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: ghcr.io
|
||||||
|
IMAGE_NAME: ${{ github.repository }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
promote:
|
||||||
|
name: promote-production
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Guard confirmation
|
||||||
|
run: |
|
||||||
|
if [ "${{ inputs.confirm }}" != "PRODUCTION" ]; then
|
||||||
|
echo "Confirmation must be exactly PRODUCTION" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Checkout repository scripts
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set image metadata
|
||||||
|
id: meta
|
||||||
|
run: |
|
||||||
|
SHA="${{ inputs.sha }}"
|
||||||
|
SHORT_SHA="${SHA:0:7}"
|
||||||
|
IMAGE="${REGISTRY}/${IMAGE_NAME}"
|
||||||
|
IMAGE="$(echo "$IMAGE" | tr '[:upper:]' '[:lower:]')"
|
||||||
|
echo "sha=${SHA}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "short_sha=${SHORT_SHA}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "image=${IMAGE}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Log in to GHCR
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Point :production at existing SHA digest (no rebuild)
|
||||||
|
run: |
|
||||||
|
docker buildx imagetools create \
|
||||||
|
--tag "${{ steps.meta.outputs.image }}:production" \
|
||||||
|
"${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.sha }}"
|
||||||
|
|
||||||
|
- name: Deploy production on Dokploy
|
||||||
|
env:
|
||||||
|
DOKPLOY_URL: ${{ secrets.DOKPLOY_URL }}
|
||||||
|
DOKPLOY_API_KEY: ${{ secrets.DOKPLOY_API_KEY }}
|
||||||
|
DOKPLOY_COMPOSE_ID: ${{ secrets.DOKPLOY_PRODUCTION_COMPOSE_ID }}
|
||||||
|
DEPLOY_TITLE: "production ${{ steps.meta.outputs.short_sha }}"
|
||||||
|
run: |
|
||||||
|
chmod +x scripts/deploy/dokploy-deploy.sh
|
||||||
|
./scripts/deploy/dokploy-deploy.sh
|
||||||
|
|
||||||
|
- name: Smoke production
|
||||||
|
env:
|
||||||
|
SMOKE_BASE_URL: ${{ secrets.PRODUCTION_URL }}
|
||||||
|
run: |
|
||||||
|
chmod +x scripts/deploy/smoke.sh
|
||||||
|
./scripts/deploy/smoke.sh
|
||||||
3
.husky/pre-commit
Executable file
3
.husky/pre-commit
Executable file
@@ -0,0 +1,3 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
|
||||||
|
composer pint:check && composer phpstan
|
||||||
33
.husky/pre-push
Executable file
33
.husky/pre-push
Executable file
@@ -0,0 +1,33 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
|
||||||
|
# Abort push when the test database is unreachable, so broken code never
|
||||||
|
# reaches CI. Test DB settings come from phpunit.xml.
|
||||||
|
php -r '
|
||||||
|
$xml = @simplexml_load_file("phpunit.xml");
|
||||||
|
if ($xml === false) {
|
||||||
|
fwrite(STDERR, "phpunit.xml not found — aborting pre-push.\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
$defaults = ["DB_HOST" => "127.0.0.1", "DB_PORT" => "5432", "DB_DATABASE" => "amare_test", "DB_USERNAME" => "amare", "DB_PASSWORD" => "secret"];
|
||||||
|
$env = [];
|
||||||
|
foreach ($xml->php->env as $node) {
|
||||||
|
$name = (string) $node["name"];
|
||||||
|
if (isset($defaults[$name])) {
|
||||||
|
$env[$name] = (string) $node["value"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$config = array_merge($defaults, $env);
|
||||||
|
|
||||||
|
$dsn = sprintf("pgsql:host=%s;port=%s;dbname=%s", $config["DB_HOST"], $config["DB_PORT"], $config["DB_DATABASE"]);
|
||||||
|
try {
|
||||||
|
new PDO($dsn, $config["DB_USERNAME"], $config["DB_PASSWORD"], [PDO::ATTR_TIMEOUT => 3]);
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
fwrite(STDERR, "\033[31mPostgreSQL is unreachable on {$config["DB_HOST"]}:{$config["DB_PORT"]} (db: {$config["DB_DATABASE"]}).\033[0m\n");
|
||||||
|
fwrite(STDERR, "Start it with: docker compose up -d\n");
|
||||||
|
fwrite(STDERR, "Then retry the push.\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
' || exit 1
|
||||||
|
|
||||||
|
composer test:unit && composer test:feature
|
||||||
@@ -12,5 +12,6 @@ related_targets: ["resources/views/pages/services/index.blade.php","resources/vi
|
|||||||
- **Action, proof, and constraints:** ação principal é enviar briefing inicial. Prova disponível: cinco depoimentos reais de casamentos, com nomes e datas; publicação depende de autorização final. Não inventar cases corporativos, credenciais ou resultados. Fotografias permanecem ilustrativas e marcadas até chegada de acervo autorizado.
|
- **Action, proof, and constraints:** ação principal é enviar briefing inicial. Prova disponível: cinco depoimentos reais de casamentos, com nomes e datas; publicação depende de autorização final. Não inventar cases corporativos, credenciais ou resultados. Fotografias permanecem ilustrativas e marcadas até chegada de acervo autorizado.
|
||||||
- **Chosen direction:** “Dossiê Editorial do Evento”. Home funciona como capa e índice; serviços viram capítulos, portfólio vira cadernos de caso, sobre vira perfil editorial e contato vira ficha de briefing. Sistema visual global segue Heritage Editorial em DESIGN.md.
|
- **Chosen direction:** “Dossiê Editorial do Evento”. Home funciona como capa e índice; serviços viram capítulos, portfólio vira cadernos de caso, sobre vira perfil editorial e contato vira ficha de briefing. Sistema visual global segue Heritage Editorial em DESIGN.md.
|
||||||
- **Memorable moment:** coração facetado atua como selo editorial enquanto índice discreto acompanha capítulos e deixa serviço, método e próximo passo visíveis sem transformar página em dashboard.
|
- **Memorable moment:** coração facetado atua como selo editorial enquanto índice discreto acompanha capítulos e deixa serviço, método e próximo passo visíveis sem transformar página em dashboard.
|
||||||
|
- **Motion thesis (Dossiê vivo):** Home recebe abertura autoral (selo → título → recorte de imagem → CTAs, ≤800ms). Índice lateral no desktop e linha de progresso no mobile acompanham capítulos. Serviços, portfólio e sobre usam abertura curta e revelações discretas; contato, privacidade e erros permanecem quase estáticos. Sem parallax, loops, bounce ou scroll-jacking. `prefers-reduced-motion` entrega estado final imediato.
|
||||||
- **Responsive and interaction:** spreads assimétricos no desktop; sequência linear no mobile. CTAs recorrentes, navegação clara, formulário com loading, erro e sucesso, foco visível e suporte a movimento reduzido.
|
- **Responsive and interaction:** spreads assimétricos no desktop; sequência linear no mobile. CTAs recorrentes, navegação clara, formulário com loading, erro e sucesso, foco visível e suporte a movimento reduzido.
|
||||||
- **Unresolved:** logo transparente ou vetorial; fotografias autorizadas; WhatsApp, e-mail e Instagram oficiais; textos jurídicos; autorização dos depoimentos; provas reais de eventos corporativos.
|
- **Unresolved:** logo transparente ou vetorial; fotografias autorizadas; WhatsApp, e-mail e Instagram oficiais; textos jurídicos; autorização dos depoimentos; provas reais de eventos corporativos.
|
||||||
|
|||||||
11
AGENTS.md
11
AGENTS.md
@@ -15,6 +15,17 @@ This is a Laravel 13 application for an event-planning consultancy. Application
|
|||||||
|
|
||||||
Feature and browser tests require the `amare_test` PostgreSQL database configured in `phpunit.xml`.
|
Feature and browser tests require the `amare_test` PostgreSQL database configured in `phpunit.xml`.
|
||||||
|
|
||||||
|
## Worktrees
|
||||||
|
|
||||||
|
Always work in a git worktree created from the `main` ref — never modify `main` directly and never commit from the primary working tree. Create a dedicated worktree per feature/branch with `git worktree add -b <branch> <path> main`. On finishing work, create a PR, watch CI until green, then merge it. Clean up the worktree with `git worktree remove` after merge.
|
||||||
|
|
||||||
|
## Git Hooks (husky)
|
||||||
|
|
||||||
|
Hooks live in `.husky/` and auto-install on any plain `npm install` via the `prepare` script. Note `composer setup` runs `npm install --ignore-scripts`, which skips hook installation — after setup, run `npm install` once (or `npx husky`) to activate hooks.
|
||||||
|
|
||||||
|
- `pre-commit`: runs `composer pint:check` and `composer phpstan`.
|
||||||
|
- `pre-push`: gates on the `amare_test` database (settings parsed from `phpunit.xml`), blocks the push with a `docker compose up -d` hint when Postgres is unreachable, then runs `composer test:unit` and `composer test:feature`. Browser tests are CI-only (FrankenPHP container).
|
||||||
|
|
||||||
## Coding Style & Naming Conventions
|
## Coding Style & Naming Conventions
|
||||||
|
|
||||||
Follow PSR-4 and Laravel conventions: PascalCase classes, camelCase methods, and snake_case database columns. Use four spaces (two in YAML, except four in Compose files), LF endings, and UTF-8 as defined by `.editorconfig`. Every project-owned PHP file must place `declare(strict_types=1);` immediately after `<?php`. Keep domain code independent of Filament and Livewire. Run `composer pint` to format and `composer phpstan` before review.
|
Follow PSR-4 and Laravel conventions: PascalCase classes, camelCase methods, and snake_case database columns. Use four spaces (two in YAML, except four in Compose files), LF endings, and UTF-8 as defined by `.editorconfig`. Every project-owned PHP file must place `declare(strict_types=1);` immediately after `<?php`. Keep domain code independent of Filament and Livewire. Run `composer pint` to format and `composer phpstan` before review.
|
||||||
|
|||||||
@@ -125,3 +125,10 @@ Após `php artisan db:seed`:
|
|||||||
- [SPEC.md](SPEC.md) — especificação do produto
|
- [SPEC.md](SPEC.md) — especificação do produto
|
||||||
- [docs/adr/](docs/adr/) — ADRs aceitas
|
- [docs/adr/](docs/adr/) — ADRs aceitas
|
||||||
- [docs/conventions/php-strict-types.md](docs/conventions/php-strict-types.md) — convenção de strict types
|
- [docs/conventions/php-strict-types.md](docs/conventions/php-strict-types.md) — convenção de strict types
|
||||||
|
- [docs/deployment/dokploy.md](docs/deployment/dokploy.md) — deploy staging/produção no Dokploy + GHCR
|
||||||
|
|
||||||
|
## Deploy (Dokploy)
|
||||||
|
|
||||||
|
Staging publica automaticamente após CI verde em `main` (imagem GHCR por SHA + alias `:staging`). Produção promove a **mesma digest** com workflow manual `Promote production` (sem rebuild).
|
||||||
|
|
||||||
|
Ver runbook completo: [docs/deployment/dokploy.md](docs/deployment/dokploy.md).
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ final readonly class PageMeta
|
|||||||
public ?string $ogImageUrl = null,
|
public ?string $ogImageUrl = null,
|
||||||
public ?string $ogImageAlt = null,
|
public ?string $ogImageAlt = null,
|
||||||
public ?array $jsonLd = null,
|
public ?array $jsonLd = null,
|
||||||
|
public string $siteName = '',
|
||||||
|
public string $robots = 'index, follow',
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -44,6 +46,7 @@ final readonly class PageMeta
|
|||||||
ogImageUrl: $ogImageUrl ?? self::defaultOgImageUrl($settings),
|
ogImageUrl: $ogImageUrl ?? self::defaultOgImageUrl($settings),
|
||||||
ogImageAlt: $ogImageAlt ?? $settings->default_og_image_alt,
|
ogImageAlt: $ogImageAlt ?? $settings->default_og_image_alt,
|
||||||
jsonLd: $jsonLd,
|
jsonLd: $jsonLd,
|
||||||
|
siteName: (string) $settings->brand_name,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,9 +80,49 @@ final readonly class PageMeta
|
|||||||
ogImageUrl: $ogImageUrl,
|
ogImageUrl: $ogImageUrl,
|
||||||
ogImageAlt: $ogImageAlt,
|
ogImageAlt: $ogImageAlt,
|
||||||
jsonLd: $jsonLd,
|
jsonLd: $jsonLd,
|
||||||
|
siteName: (string) $settings->brand_name,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the metadata for branded error pages (404/500).
|
||||||
|
*
|
||||||
|
* Error pages carry no canonical, are excluded from search indexes and use
|
||||||
|
* the page name suffixed with the brand name as their title.
|
||||||
|
*/
|
||||||
|
public static function forErrorPage(
|
||||||
|
SiteSetting $settings,
|
||||||
|
int $status = 404,
|
||||||
|
): self {
|
||||||
|
[$title, $description] = $status === 500
|
||||||
|
? ['Algo deu errado', 'Não foi possível concluir o pedido. Tente novamente em instantes.']
|
||||||
|
: ['Página não encontrada', 'A página que você procura não existe ou foi movida.'];
|
||||||
|
|
||||||
|
return new self(
|
||||||
|
title: trim($title).' - '.$settings->brand_name,
|
||||||
|
description: $description,
|
||||||
|
canonical: '',
|
||||||
|
robots: 'noindex, nofollow',
|
||||||
|
siteName: (string) $settings->brand_name,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append the brand name to a page title when it is not already present.
|
||||||
|
*/
|
||||||
|
public static function withBrandSuffix(string $title, SiteSetting $settings): string
|
||||||
|
{
|
||||||
|
$brand = filled($settings->default_meta_title)
|
||||||
|
? (string) $settings->default_meta_title
|
||||||
|
: (string) $settings->brand_name;
|
||||||
|
|
||||||
|
if (str_contains($title, $brand)) {
|
||||||
|
return $title;
|
||||||
|
}
|
||||||
|
|
||||||
|
return trim($title).' - '.$brand;
|
||||||
|
}
|
||||||
|
|
||||||
private static function defaultTitle(SiteSetting $settings): string
|
private static function defaultTitle(SiteSetting $settings): string
|
||||||
{
|
{
|
||||||
return filled($settings->default_meta_title)
|
return filled($settings->default_meta_title)
|
||||||
|
|||||||
116
app/Http/Controllers/PublicSite/ContactController.php
Normal file
116
app/Http/Controllers/PublicSite/ContactController.php
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\PublicSite;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\PublicSite\ContactBriefingRequest;
|
||||||
|
use App\Mail\ContactBriefing;
|
||||||
|
use App\Mail\ContactBriefingConfirmation;
|
||||||
|
use App\Models\SiteSetting;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Support\Facades\Mail;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
final class ContactController extends Controller
|
||||||
|
{
|
||||||
|
private const string DUPLICATE_SESSION_KEY = 'contact_briefing_hash';
|
||||||
|
|
||||||
|
public function store(ContactBriefingRequest $request): RedirectResponse
|
||||||
|
{
|
||||||
|
if (filled($request->input('empresa'))) {
|
||||||
|
return $this->success();
|
||||||
|
}
|
||||||
|
|
||||||
|
$validated = $request->validated();
|
||||||
|
unset($validated['privacidade']);
|
||||||
|
|
||||||
|
if ($this->isDuplicate($validated)) {
|
||||||
|
return $this->success();
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->rememberSubmission($validated);
|
||||||
|
|
||||||
|
$settings = SiteSetting::instance();
|
||||||
|
$fields = $this->buildFields($validated);
|
||||||
|
|
||||||
|
$this->dispatchEmails($settings, $validated['nome'], $validated['email'], $fields);
|
||||||
|
|
||||||
|
return $this->success();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $validated
|
||||||
|
*/
|
||||||
|
private function buildFields(array $validated): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'Nome' => (string) $validated['nome'],
|
||||||
|
'E-mail' => (string) $validated['email'],
|
||||||
|
'Telefone/WhatsApp' => (string) $validated['telefone'],
|
||||||
|
'Tipo de evento' => (string) $validated['tipo_evento'],
|
||||||
|
'Data ou período desejado' => isset($validated['data_periodo']) ? (string) $validated['data_periodo'] : null,
|
||||||
|
'Cidade' => (string) $validated['cidade'],
|
||||||
|
'Número estimado de convidados' => isset($validated['convidados']) ? (string) $validated['convidados'] : null,
|
||||||
|
'Serviço de interesse' => isset($validated['servico_interesse']) ? (string) $validated['servico_interesse'] : null,
|
||||||
|
'Mensagem' => (string) $validated['mensagem'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $validated
|
||||||
|
*/
|
||||||
|
private function isDuplicate(array $validated): bool
|
||||||
|
{
|
||||||
|
$hash = $this->hash($validated);
|
||||||
|
|
||||||
|
return session()->get(self::DUPLICATE_SESSION_KEY) === $hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $validated
|
||||||
|
*/
|
||||||
|
private function rememberSubmission(array $validated): void
|
||||||
|
{
|
||||||
|
session()->put(self::DUPLICATE_SESSION_KEY, $this->hash($validated));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $validated
|
||||||
|
*/
|
||||||
|
private function hash(array $validated): string
|
||||||
|
{
|
||||||
|
return hash('sha256', serialize($validated));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $fields
|
||||||
|
*/
|
||||||
|
private function dispatchEmails(SiteSetting $settings, string $name, string $email, array $fields): void
|
||||||
|
{
|
||||||
|
$attempt = function () use ($settings, $name, $email, $fields): void {
|
||||||
|
if (filled($settings->email)) {
|
||||||
|
Mail::to($settings->email)->send(new ContactBriefing($fields));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filled(config('mail.default'))) {
|
||||||
|
Mail::to($email)->send(new ContactBriefingConfirmation($name, (string) $settings->brand_name));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
$attempt();
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
Log::error('Falha ao enviar briefing de contato', [
|
||||||
|
'exception' => $exception,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function success(): RedirectResponse
|
||||||
|
{
|
||||||
|
return redirect()->route('contact')->with('status', 'briefing-sent');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,7 +20,7 @@ final class PageController extends Controller
|
|||||||
'pageMeta' => PageMeta::forPage(
|
'pageMeta' => PageMeta::forPage(
|
||||||
canonical: route('about'),
|
canonical: route('about'),
|
||||||
settings: $settings,
|
settings: $settings,
|
||||||
title: 'Sobre',
|
title: PageMeta::withBrandSuffix('Sobre', $settings),
|
||||||
description: $settings->about_summary ?: ('Conheça a '.$settings->brand_name.'.'),
|
description: $settings->about_summary ?: ('Conheça a '.$settings->brand_name.'.'),
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
@@ -35,7 +35,7 @@ final class PageController extends Controller
|
|||||||
'pageMeta' => PageMeta::forPage(
|
'pageMeta' => PageMeta::forPage(
|
||||||
canonical: route('privacy'),
|
canonical: route('privacy'),
|
||||||
settings: $settings,
|
settings: $settings,
|
||||||
title: 'Política de privacidade',
|
title: PageMeta::withBrandSuffix('Política de privacidade', $settings),
|
||||||
description: 'Política de privacidade da '.$settings->brand_name.'.',
|
description: 'Política de privacidade da '.$settings->brand_name.'.',
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
@@ -50,7 +50,7 @@ final class PageController extends Controller
|
|||||||
'pageMeta' => PageMeta::forPage(
|
'pageMeta' => PageMeta::forPage(
|
||||||
canonical: route('contact'),
|
canonical: route('contact'),
|
||||||
settings: $settings,
|
settings: $settings,
|
||||||
title: 'Contato',
|
title: PageMeta::withBrandSuffix('Contato', $settings),
|
||||||
description: 'Fale com a '.$settings->brand_name.'.',
|
description: 'Fale com a '.$settings->brand_name.'.',
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ final class PortfolioController extends Controller
|
|||||||
'pageMeta' => PageMeta::forPage(
|
'pageMeta' => PageMeta::forPage(
|
||||||
canonical: route('portfolio.index'),
|
canonical: route('portfolio.index'),
|
||||||
settings: $settings,
|
settings: $settings,
|
||||||
title: 'Portfólio',
|
title: PageMeta::withBrandSuffix('Portfólio', $settings),
|
||||||
description: 'Casos reais de eventos conduzidos pela '.$settings->brand_name.'.',
|
description: 'Casos reais de eventos conduzidos pela '.$settings->brand_name.'.',
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ final class ServiceController extends Controller
|
|||||||
'pageMeta' => PageMeta::forPage(
|
'pageMeta' => PageMeta::forPage(
|
||||||
canonical: route('services.index'),
|
canonical: route('services.index'),
|
||||||
settings: $settings,
|
settings: $settings,
|
||||||
title: 'Serviços',
|
title: PageMeta::withBrandSuffix('Serviços', $settings),
|
||||||
description: 'Conheça os serviços de assessoria de eventos da '.$settings->brand_name.'.',
|
description: 'Conheça os serviços de assessoria de eventos da '.$settings->brand_name.'.',
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
|
|||||||
57
app/Http/Requests/PublicSite/ContactBriefingRequest.php
Normal file
57
app/Http/Requests/PublicSite/ContactBriefingRequest.php
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests\PublicSite;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
final class ContactBriefingRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<string, array<int, string>>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'nome' => ['required', 'string', 'max:120'],
|
||||||
|
'email' => ['required', 'email', 'max:254'],
|
||||||
|
'telefone' => ['required', 'string', 'max:40'],
|
||||||
|
'tipo_evento' => ['required', 'string', 'max:80'],
|
||||||
|
'data_periodo' => ['nullable', 'string', 'max:80'],
|
||||||
|
'cidade' => ['required', 'string', 'max:80'],
|
||||||
|
'convidados' => ['nullable', 'integer', 'min:1', 'max:100000'],
|
||||||
|
'servico_interesse' => ['nullable', 'string', 'max:120'],
|
||||||
|
'mensagem' => ['required', 'string', 'max:3000'],
|
||||||
|
'privacidade' => ['accepted'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public function messages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'nome.required' => 'Informe seu nome completo.',
|
||||||
|
'nome.max' => 'O nome deve ter no máximo :max caracteres.',
|
||||||
|
'email.required' => 'Informe seu e-mail.',
|
||||||
|
'email.email' => 'Informe um e-mail válido.',
|
||||||
|
'email.max' => 'O e-mail deve ter no máximo :max caracteres.',
|
||||||
|
'telefone.required' => 'Informe um telefone ou WhatsApp.',
|
||||||
|
'telefone.max' => 'O telefone deve ter no máximo :max caracteres.',
|
||||||
|
'tipo_evento.required' => 'Selecione o tipo de evento.',
|
||||||
|
'tipo_evento.max' => 'O tipo de evento deve ter no máximo :max caracteres.',
|
||||||
|
'data_periodo.max' => 'A data ou período deve ter no máximo :max caracteres.',
|
||||||
|
'cidade.required' => 'Informe a cidade do evento.',
|
||||||
|
'cidade.max' => 'A cidade deve ter no máximo :max caracteres.',
|
||||||
|
'convidados.integer' => 'Informe um número de convidados válido.',
|
||||||
|
'convidados.min' => 'O número de convidados deve ser maior que zero.',
|
||||||
|
'convidados.max' => 'O número de convidados informado é inválido.',
|
||||||
|
'servico_interesse.max' => 'O serviço deve ter no máximo :max caracteres.',
|
||||||
|
'mensagem.required' => 'Conte brevemente o que você precisa.',
|
||||||
|
'mensagem.max' => 'A mensagem deve ter no máximo :max caracteres.',
|
||||||
|
'privacidade.accepted' => 'Você precisa aceitar a política de privacidade.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
40
app/Mail/ContactBriefing.php
Normal file
40
app/Mail/ContactBriefing.php
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Mail;
|
||||||
|
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Mail\Mailable;
|
||||||
|
use Illuminate\Mail\Mailables\Content;
|
||||||
|
use Illuminate\Mail\Mailables\Envelope;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
|
final class ContactBriefing extends Mailable implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Queueable;
|
||||||
|
use SerializesModels;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $fields
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public readonly array $fields,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function envelope(): Envelope
|
||||||
|
{
|
||||||
|
return new Envelope(
|
||||||
|
subject: 'Novo briefing de contato — Amare Assessoria',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function content(): Content
|
||||||
|
{
|
||||||
|
return new Content(
|
||||||
|
html: 'emails.contact-briefing',
|
||||||
|
text: 'emails.contact-briefing-text',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
38
app/Mail/ContactBriefingConfirmation.php
Normal file
38
app/Mail/ContactBriefingConfirmation.php
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Mail;
|
||||||
|
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Mail\Mailable;
|
||||||
|
use Illuminate\Mail\Mailables\Content;
|
||||||
|
use Illuminate\Mail\Mailables\Envelope;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
|
final class ContactBriefingConfirmation extends Mailable implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Queueable;
|
||||||
|
use SerializesModels;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public readonly string $name,
|
||||||
|
public readonly string $brandName,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function envelope(): Envelope
|
||||||
|
{
|
||||||
|
return new Envelope(
|
||||||
|
subject: 'Recebemos sua mensagem — '.$this->brandName,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function content(): Content
|
||||||
|
{
|
||||||
|
return new Content(
|
||||||
|
html: 'emails.contact-briefing-confirmation',
|
||||||
|
text: 'emails.contact-briefing-confirmation-text',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,9 @@ namespace App\Providers;
|
|||||||
use App\Application\Data\PageMeta;
|
use App\Application\Data\PageMeta;
|
||||||
use App\Models\SiteSetting;
|
use App\Models\SiteSetting;
|
||||||
use Carbon\CarbonImmutable;
|
use Carbon\CarbonImmutable;
|
||||||
|
use Illuminate\Cache\RateLimiting\Limit;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
use Illuminate\Support\Facades\View;
|
use Illuminate\Support\Facades\View;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
use Illuminate\View\View as ViewInstance;
|
use Illuminate\View\View as ViewInstance;
|
||||||
@@ -26,7 +29,9 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
*/
|
*/
|
||||||
public function boot(): void
|
public function boot(): void
|
||||||
{
|
{
|
||||||
|
$this->configureLivewireTemporaryUploads();
|
||||||
$this->freezeClockWhenConfigured();
|
$this->freezeClockWhenConfigured();
|
||||||
|
$this->configureRateLimiters();
|
||||||
|
|
||||||
View::composer('layouts.public', function (ViewInstance $view): void {
|
View::composer('layouts.public', function (ViewInstance $view): void {
|
||||||
$settings = $view->offsetExists('siteSettings')
|
$settings = $view->offsetExists('siteSettings')
|
||||||
@@ -46,6 +51,29 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep Livewire/Filament temp uploads on the local disk.
|
||||||
|
*
|
||||||
|
* When FILESYSTEM_DISK=r2, Livewire would otherwise use the S3 driver and
|
||||||
|
* browser-PUT straight to R2 (CORS). Final media still uses the r2 disk via
|
||||||
|
* PublicImageUploadRules. Explicit LIVEWIRE_TEMPORARY_FILE_UPLOAD_DISK wins.
|
||||||
|
*/
|
||||||
|
private function configureLivewireTemporaryUploads(): void
|
||||||
|
{
|
||||||
|
if (filled(config('livewire.temporary_file_upload.disk'))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
config(['livewire.temporary_file_upload.disk' => 'local']);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function configureRateLimiters(): void
|
||||||
|
{
|
||||||
|
RateLimiter::for('contact-briefing', function (Request $request): Limit {
|
||||||
|
return Limit::perMinute(5)->by($request->ip().'|contact-briefing');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private function freezeClockWhenConfigured(): void
|
private function freezeClockWhenConfigured(): void
|
||||||
{
|
{
|
||||||
if ($this->app->environment('production')) {
|
if ($this->app->environment('production')) {
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
health: '/up',
|
health: '/up',
|
||||||
)
|
)
|
||||||
->withMiddleware(function (Middleware $middleware): void {
|
->withMiddleware(function (Middleware $middleware): void {
|
||||||
//
|
// Trust Traefik/Dokploy (and local reverse proxies) for X-Forwarded-* headers.
|
||||||
|
$middleware->trustProxies(at: '*');
|
||||||
})
|
})
|
||||||
->withExceptions(function (Exceptions $exceptions): void {
|
->withExceptions(function (Exceptions $exceptions): void {
|
||||||
$exceptions->shouldRenderJsonWhen(
|
$exceptions->shouldRenderJsonWhen(
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ use App\Models\PortfolioCase;
|
|||||||
use App\Models\PortfolioImage;
|
use App\Models\PortfolioImage;
|
||||||
use App\Models\Service;
|
use App\Models\Service;
|
||||||
use App\Models\SiteSetting;
|
use App\Models\SiteSetting;
|
||||||
use App\Models\Testimonial;
|
|
||||||
use Illuminate\Database\Seeder;
|
use Illuminate\Database\Seeder;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Facades\File;
|
use Illuminate\Support\Facades\File;
|
||||||
@@ -23,7 +22,7 @@ class ContentSeeder extends Seeder
|
|||||||
$this->seedSiteSettings();
|
$this->seedSiteSettings();
|
||||||
$this->seedServices();
|
$this->seedServices();
|
||||||
$this->seedPortfolioCases();
|
$this->seedPortfolioCases();
|
||||||
$this->seedTestimonials();
|
$this->call(TestimonialsSeeder::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function seedSiteSettings(): void
|
private function seedSiteSettings(): void
|
||||||
@@ -170,67 +169,6 @@ class ContentSeeder extends Seeder
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function seedTestimonials(): void
|
|
||||||
{
|
|
||||||
// Real couples from depoimentos.md. Production publication still requires
|
|
||||||
// explicit couple authorization before setting published_at outside local/demo seeds.
|
|
||||||
$testimonials = [
|
|
||||||
[
|
|
||||||
'quote' => "Mi, quero agradecer você e a sua equipe por todo empenho, atenção, vocês são abençoadas.\n\nEra nítida sua preocupação em garantir que todos os detalhes planejados desta comemoração, fossem atendidos.\n\nQue você possa transformar o grande dia das noivinhas sempre com essa sua leveza!!!\n\nMuito obrigada!",
|
|
||||||
'author_name' => 'Jeniffer e Maick',
|
|
||||||
'context' => 'Casamento · 06/12/2025',
|
|
||||||
'sort_order' => 1,
|
|
||||||
'is_featured' => true,
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'quote' => "Mi, eu não tenho palavras pra agradecer você e tudo que você fez por mim e por nós na realização desse sonho. Eu tô ainda extasiada com tudo que aconteceu hoje; mas tenho certeza que sem a sua ajuda, muita coisa não aconteceria.\n\nObrigada por tudo !",
|
|
||||||
'author_name' => 'Quesia e Jhonata',
|
|
||||||
'context' => 'Casamento · 21/12/2025',
|
|
||||||
'sort_order' => 2,
|
|
||||||
'is_featured' => true,
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'quote' => 'Que equipe!! Que equipe maravilhosa!! Obrigado pelo empenho de fazer tudo como eu queria!! Obrigado por se esforçar tanto e vir de tão longe pra realizar meu sonho!! Incríveis!!',
|
|
||||||
'author_name' => 'Milena e Weslley',
|
|
||||||
'context' => 'Casamento · 13/02/2026',
|
|
||||||
'sort_order' => 3,
|
|
||||||
'is_featured' => false,
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'quote' => "Gostaríamos de agradecer por todo o acompanhamento e dedicação durante a realização do nosso casamento. Foi um dia muito especial e inesquecível para nós.\n\nDesde o início, conseguimos conduzir tudo aquilo que estávamos planejando, dentro dos horários que estipulamos, o que foi ótimo, e no grande dia sua equipe nos recebeu e tratou com muito carinho, atenção e cuidado, o que fez toda a diferença para vivermos esse momento com mais tranquilidade.\n\nTambém adoramos as sugestões e ideias para as fotos, que deixaram os registros ainda mais bonitos e espontâneos, porque não iríamos lembrar de quais poses fazer na hora.\n\nObrigada por fazer parte de um momento tão importante das nossas vidas. Desejamos muito sucesso e que muitos outros casais possam viver dias especiais através do trabalho da AMARE.",
|
|
||||||
'author_name' => 'Raquel e Pedro',
|
|
||||||
'context' => 'Casamento · 09/05/2026',
|
|
||||||
'sort_order' => 4,
|
|
||||||
'is_featured' => false,
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'quote' => "Miiii, meu amor… você e sua equipe foram impecáveis.\n\nSuperou todas as nossas expectativas. Somos eternamente gratos por fazer nosso dia acontecer muito melhor do que imaginávamos.\n\nSempre muito atenciosa e paciente.\n\nAdoramos te conhecer e estamos muito felizes em termos escolhido você para assessorar nosso dia.",
|
|
||||||
'author_name' => 'Victoria e Pedro',
|
|
||||||
'context' => 'Casamento · 24/06/2026',
|
|
||||||
'sort_order' => 5,
|
|
||||||
'is_featured' => false,
|
|
||||||
],
|
|
||||||
];
|
|
||||||
|
|
||||||
$keepAuthors = array_column($testimonials, 'author_name');
|
|
||||||
|
|
||||||
Testimonial::query()
|
|
||||||
->whereNotIn('author_name', $keepAuthors)
|
|
||||||
->delete();
|
|
||||||
|
|
||||||
foreach ($testimonials as $testimonial) {
|
|
||||||
Testimonial::query()->updateOrCreate(
|
|
||||||
['author_name' => $testimonial['author_name']],
|
|
||||||
[
|
|
||||||
...$testimonial,
|
|
||||||
'photo_path' => null,
|
|
||||||
'photo_alt' => null,
|
|
||||||
'published_at' => Carbon::parse(self::SEED_TIMESTAMP),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function copyFixture(string $fixtureName, string $destination): string
|
private function copyFixture(string $fixtureName, string $destination): string
|
||||||
{
|
{
|
||||||
$source = base_path('tests/fixtures/images/'.$fixtureName);
|
$source = base_path('tests/fixtures/images/'.$fixtureName);
|
||||||
|
|||||||
70
database/seeders/TestimonialsSeeder.php
Normal file
70
database/seeders/TestimonialsSeeder.php
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use App\Models\Testimonial;
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class TestimonialsSeeder extends Seeder
|
||||||
|
{
|
||||||
|
private const PUBLISHED_AT = '2026-08-05 00:00:00';
|
||||||
|
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
$testimonials = [
|
||||||
|
[
|
||||||
|
'quote' => "Mi, quero agradecer você e a sua equipe por todo empenho, atenção, vocês são abençoadas.\n\nEra nítida sua preocupação em garantir que todos os detalhes planejados desta comemoração, fossem atendidos.\n\nQue você possa transformar o grande dia das noivinhas sempre com essa sua leveza!!!\n\nMuito obrigada!",
|
||||||
|
'author_name' => 'Jeniffer e Maick',
|
||||||
|
'context' => 'Casamento · 06/12/2025',
|
||||||
|
'sort_order' => 1,
|
||||||
|
'is_featured' => true,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'quote' => "Mi, eu não tenho palavras pra agradecer você e tudo que você fez por mim e por nós na realização desse sonho. Eu tô ainda extasiada com tudo que aconteceu hoje; mas tenho certeza que sem a sua ajuda, muita coisa não aconteceria.\n\nObrigada por tudo !",
|
||||||
|
'author_name' => 'Quesia e Jhonata',
|
||||||
|
'context' => 'Casamento · 21/12/2025',
|
||||||
|
'sort_order' => 2,
|
||||||
|
'is_featured' => true,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'quote' => 'Que equipe!! Que equipe maravilhosa!! Obrigado pelo empenho de fazer tudo como eu queria!! Obrigado por se esforçar tanto e vir de tão longe pra realizar meu sonho!! Incríveis!!',
|
||||||
|
'author_name' => 'Milena e Weslley',
|
||||||
|
'context' => 'Casamento · 13/02/2026',
|
||||||
|
'sort_order' => 3,
|
||||||
|
'is_featured' => false,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'quote' => "Gostaríamos de agradecer por todo o acompanhamento e dedicação durante a realização do nosso casamento. Foi um dia muito especial e inesquecível para nós.\n\nDesde o início, conseguimos conduzir tudo aquilo que estávamos planejando, dentro dos horários que estipulamos, o que foi ótimo, e no grande dia sua equipe nos recebeu e tratou com muito carinho, atenção e cuidado, o que fez toda a diferença para vivermos esse momento com mais tranquilidade.\n\nTambém adoramos as sugestões e ideias para as fotos, que deixaram os registros ainda mais bonitos e espontâneos, porque não iríamos lembrar de quais poses fazer na hora.\n\nObrigada por fazer parte de um momento tão importante das nossas vidas. Desejamos muito sucesso e que muitos outros casais possam viver dias especiais através do trabalho da AMARE.",
|
||||||
|
'author_name' => 'Raquel e Pedro',
|
||||||
|
'context' => 'Casamento · 09/05/2026',
|
||||||
|
'sort_order' => 4,
|
||||||
|
'is_featured' => false,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'quote' => "Miiii, meu amor… você e sua equipe foram impecáveis.\n\nSuperou todas as nossas expectativas. Somos eternamente gratos por fazer nosso dia acontecer muito melhor do que imaginávamos.\n\nSempre muito atenciosa e paciente.\n\nAdoramos te conhecer e estamos muito felizes em termos escolhido você para assessorar nosso dia.",
|
||||||
|
'author_name' => 'Victoria e Pedro',
|
||||||
|
'context' => 'Casamento · 24/06/2026',
|
||||||
|
'sort_order' => 5,
|
||||||
|
'is_featured' => false,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
DB::transaction(function () use ($testimonials): void {
|
||||||
|
foreach ($testimonials as $testimonial) {
|
||||||
|
Testimonial::query()->updateOrCreate(
|
||||||
|
['author_name' => $testimonial['author_name']],
|
||||||
|
[
|
||||||
|
'quote' => $testimonial['quote'],
|
||||||
|
'context' => $testimonial['context'],
|
||||||
|
'sort_order' => $testimonial['sort_order'],
|
||||||
|
'is_featured' => $testimonial['is_featured'],
|
||||||
|
'published_at' => self::PUBLISHED_AT,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
73
docker-compose.deploy.yml
Normal file
73
docker-compose.deploy.yml
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
# Shared Compose for Dokploy staging and production.
|
||||||
|
# Both stacks use the same file with different env:
|
||||||
|
# APP_IMAGE=ghcr.io/<owner>/<repo>
|
||||||
|
# IMAGE_TAG=staging|production|<git-sha>
|
||||||
|
# PostgreSQL is a separate Dokploy database service (not defined here).
|
||||||
|
# Traefik/Dokploy domains should target service `web` port 8000.
|
||||||
|
# App services join dokploy-network so they can resolve Dokploy-managed
|
||||||
|
# Postgres internal hosts (e.g. amare-stg-pez43e).
|
||||||
|
|
||||||
|
services:
|
||||||
|
migrate:
|
||||||
|
image: ${APP_IMAGE}:${IMAGE_TAG}
|
||||||
|
pull_policy: always
|
||||||
|
restart: "no"
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
command: ["php", "artisan", "migrate", "--force", "--no-interaction"]
|
||||||
|
networks:
|
||||||
|
- dokploy-network
|
||||||
|
|
||||||
|
web:
|
||||||
|
image: ${APP_IMAGE}:${IMAGE_TAG}
|
||||||
|
pull_policy: always
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
depends_on:
|
||||||
|
migrate:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
expose:
|
||||||
|
- "8000"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8000/up"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
start_period: 40s
|
||||||
|
retries: 3
|
||||||
|
networks:
|
||||||
|
- dokploy-network
|
||||||
|
|
||||||
|
queue:
|
||||||
|
image: ${APP_IMAGE}:${IMAGE_TAG}
|
||||||
|
pull_policy: always
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
depends_on:
|
||||||
|
migrate:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
command: ["php", "artisan", "queue:work", "--sleep=2", "--tries=3", "--max-time=3600"]
|
||||||
|
stop_grace_period: 60s
|
||||||
|
stop_signal: SIGTERM
|
||||||
|
networks:
|
||||||
|
- dokploy-network
|
||||||
|
|
||||||
|
scheduler:
|
||||||
|
image: ${APP_IMAGE}:${IMAGE_TAG}
|
||||||
|
pull_policy: always
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
depends_on:
|
||||||
|
migrate:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
command: ["php", "artisan", "schedule:work"]
|
||||||
|
stop_grace_period: 30s
|
||||||
|
stop_signal: SIGTERM
|
||||||
|
networks:
|
||||||
|
- dokploy-network
|
||||||
|
|
||||||
|
networks:
|
||||||
|
dokploy-network:
|
||||||
|
external: true
|
||||||
@@ -7,5 +7,8 @@ Mesma imagem, comandos distintos:
|
|||||||
| web | `frankenphp run --config /etc/caddy/Caddyfile` |
|
| web | `frankenphp run --config /etc/caddy/Caddyfile` |
|
||||||
| queue | `php artisan queue:work --sleep=2 --tries=3` |
|
| queue | `php artisan queue:work --sleep=2 --tries=3` |
|
||||||
| scheduler | `php artisan schedule:work` |
|
| scheduler | `php artisan schedule:work` |
|
||||||
|
| migrate | `php artisan migrate --force` (one-shot no Compose de deploy) |
|
||||||
|
|
||||||
FrankenPHP em **modo regular** (ADR-006). Worker mode proibido no MVP.
|
FrankenPHP em **modo regular** (ADR-006). Worker mode proibido no MVP.
|
||||||
|
|
||||||
|
Deploy Dokploy (staging/produção): ver [`docker-compose.deploy.yml`](../docker-compose.deploy.yml) e [docs/deployment/dokploy.md](../docs/deployment/dokploy.md).
|
||||||
|
|||||||
300
docs/deployment/dokploy.md
Normal file
300
docs/deployment/dokploy.md
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
# Deploy Dokploy (staging → production)
|
||||||
|
|
||||||
|
Runbook for operating Amare on a VPS with Dokploy connected to GitHub, publishing immutable images to GHCR.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
CI (main) → build FrankenPHP image → GHCR :<sha> + :staging
|
||||||
|
→ Dokploy staging compose.deploy
|
||||||
|
→ smoke /up / /admin/login
|
||||||
|
|
||||||
|
Promote (manual) → retag same digest as :production (no rebuild)
|
||||||
|
→ Dokploy production compose.deploy
|
||||||
|
→ smoke
|
||||||
|
```
|
||||||
|
|
||||||
|
| Piece | Detail |
|
||||||
|
|---|---|
|
||||||
|
| Compose file | [`docker-compose.deploy.yml`](../../docker-compose.deploy.yml) |
|
||||||
|
| Processes | `migrate` (one-shot) → `web` / `queue` / `scheduler` |
|
||||||
|
| Image | `ghcr.io/<owner>/<repo>:<sha>` (+ aliases `:staging`, `:production`) |
|
||||||
|
| Database | Dokploy PostgreSQL **per environment** (not in the app image) |
|
||||||
|
| Media | Cloudflare R2 (`FILESYSTEM_DISK=r2`), separate buckets per environment |
|
||||||
|
| Mail | Resend (`MAIL_MAILER=resend`) |
|
||||||
|
| Proxy | Dokploy Traefik → service `web` port `8000` |
|
||||||
|
|
||||||
|
## Prerequisites (manual)
|
||||||
|
|
||||||
|
1. Dokploy installed on the VPS; GitHub provider connected.
|
||||||
|
2. GHCR registry in Dokploy (`ghcr.io`) with a PAT that can **read** packages (`read:packages`). Prefer a dedicated bot/token; do not store write tokens on the VPS.
|
||||||
|
3. Two PostgreSQL services in Dokploy (staging + production), private (no public port).
|
||||||
|
4. Two R2 buckets (or prefixes) and Resend credentials for each environment as needed.
|
||||||
|
5. Domains (or temporary Dokploy/traefik.me hosts) pointing at the VPS with TLS.
|
||||||
|
|
||||||
|
## Create Compose stacks
|
||||||
|
|
||||||
|
Create **two** Dokploy Compose services (same repo, same compose path):
|
||||||
|
|
||||||
|
| Stack | Compose path | `IMAGE_TAG` | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| staging | `docker-compose.deploy.yml` | `staging` | Auto-deployed after CI on `main` |
|
||||||
|
| production | `docker-compose.deploy.yml` | `production` | Manual promotion only |
|
||||||
|
|
||||||
|
Dokploy Environment for each stack must set:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
APP_IMAGE=ghcr.io/<owner>/<repo>
|
||||||
|
IMAGE_TAG=staging # or production
|
||||||
|
```
|
||||||
|
|
||||||
|
Point Dokploy domain(s) at service **`web`**, port **`8000`**. Do not publish PostgreSQL or host ports for app processes.
|
||||||
|
|
||||||
|
Compose services must join the external Docker network `dokploy-network` (declared in `docker-compose.deploy.yml`) so they can resolve the Dokploy-managed Postgres internal host (e.g. `amare-stg-pez43e`). Set `DB_HOST` to that **Internal Host** from the Dokploy database UI — not a public hostname.
|
||||||
|
|
||||||
|
Source can be GitHub (so Dokploy clones the compose file) or Raw paste of `docker-compose.deploy.yml`. Prefer GitHub + fixed compose path so updates stay in sync with `main`.
|
||||||
|
|
||||||
|
## Required Laravel env (Dokploy only)
|
||||||
|
|
||||||
|
Set these in Dokploy Environment UI (written to `.env` next to the compose file). **Never** put them in GitHub Actions secrets or image layers.
|
||||||
|
|
||||||
|
```env
|
||||||
|
APP_NAME=Amare
|
||||||
|
APP_ENV=staging # or production
|
||||||
|
APP_KEY=base64:... # unique per environment — generate with php artisan key:generate --show
|
||||||
|
APP_DEBUG=false
|
||||||
|
APP_URL=https://staging.example.com
|
||||||
|
|
||||||
|
APP_LOCALE=pt_BR
|
||||||
|
APP_FALLBACK_LOCALE=pt_BR
|
||||||
|
APP_TIMEZONE=America/Fortaleza
|
||||||
|
|
||||||
|
DB_CONNECTION=pgsql
|
||||||
|
DB_HOST=<dokploy-postgres-internal-host> # Internal Host from Dokploy UI (requires dokploy-network)
|
||||||
|
DB_PORT=5432
|
||||||
|
DB_DATABASE=amare_staging
|
||||||
|
DB_USERNAME=...
|
||||||
|
DB_PASSWORD=...
|
||||||
|
|
||||||
|
SESSION_DRIVER=database
|
||||||
|
SESSION_SECURE_COOKIE=true
|
||||||
|
SESSION_HTTP_ONLY=true
|
||||||
|
SESSION_SAME_SITE=lax
|
||||||
|
CACHE_STORE=database
|
||||||
|
QUEUE_CONNECTION=database
|
||||||
|
|
||||||
|
FILESYSTEM_DISK=r2
|
||||||
|
R2_ACCESS_KEY_ID=...
|
||||||
|
R2_SECRET_ACCESS_KEY=...
|
||||||
|
R2_BUCKET=...
|
||||||
|
R2_ENDPOINT=https://<account_id>.r2.cloudflarestorage.com
|
||||||
|
R2_URL=https://media-staging.example.com
|
||||||
|
|
||||||
|
MAIL_MAILER=resend
|
||||||
|
RESEND_API_KEY=...
|
||||||
|
MAIL_FROM_ADDRESS=noreply@example.com
|
||||||
|
MAIL_FROM_NAME=Amare
|
||||||
|
|
||||||
|
LOG_LEVEL=warning
|
||||||
|
```
|
||||||
|
|
||||||
|
Trusted proxies are configured in `bootstrap/app.php` so Traefik `X-Forwarded-*` headers work for HTTPS cookies and URLs.
|
||||||
|
|
||||||
|
Livewire temporary uploads default to the **local** disk in `AppServiceProvider` (even when `FILESYSTEM_DISK=r2`), so Filament does not browser-PUT to R2. Final media still lands on R2 via `PublicImageUploadRules`. Optional override: `LIVEWIRE_TEMPORARY_FILE_UPLOAD_DISK` in Dokploy `.env` (`env_file` accepts any key — does not need a compose `environment:` entry).
|
||||||
|
|
||||||
|
### R2 CORS (public/media reads from JS)
|
||||||
|
|
||||||
|
Upload path does not need R2 CORS with the local temp-disk default. Still useful if the browser fetches R2 URLs cross-origin from JS. Origin must match `APP_URL` exactly; include `AllowedHeaders`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"AllowedOrigins": ["https://hellomanoel.com"],
|
||||||
|
"AllowedMethods": ["GET", "PUT", "POST", "HEAD"],
|
||||||
|
"AllowedHeaders": ["*"],
|
||||||
|
"ExposeHeaders": ["ETag", "Content-Type"],
|
||||||
|
"MaxAgeSeconds": 3600
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
Also enable public access / custom domain for `R2_URL` so `<img>` URLs work after save.
|
||||||
|
|
||||||
|
## GitHub Actions secrets
|
||||||
|
|
||||||
|
Repository secrets used by workflows:
|
||||||
|
|
||||||
|
| Secret | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `DOKPLOY_URL` | Panel origin **without** `/api` (e.g. `https://panel.example.com`). Do not use the OpenAPI base URL that ends in `/api` — that yields `/api/api/...` and 404s. |
|
||||||
|
| `DOKPLOY_API_KEY` | API key from Dokploy profile → API/CLI |
|
||||||
|
| `DOKPLOY_STAGING_COMPOSE_ID` | Staging **Compose** service id (not an Application id) |
|
||||||
|
| `DOKPLOY_PRODUCTION_COMPOSE_ID` | Production **Compose** service id (not an Application id) |
|
||||||
|
| `STAGING_URL` | Public origin for staging smoke (e.g. `https://staging.example.com`) |
|
||||||
|
| `PRODUCTION_URL` | Public origin for production smoke |
|
||||||
|
|
||||||
|
HTTP 404 from `compose.deploy` usually means the compose id is wrong (Application id instead of Compose) or `DOKPLOY_URL` still includes `/api`.
|
||||||
|
|
||||||
|
`GITHUB_TOKEN` (automatic) publishes to GHCR with `packages:write`. No Laravel/`APP_KEY`/DB/R2/Resend secrets belong in GitHub for this pipeline.
|
||||||
|
|
||||||
|
## Workflows
|
||||||
|
|
||||||
|
### Staging (automatic)
|
||||||
|
|
||||||
|
[`.github/workflows/deploy-staging.yml`](../../.github/workflows/deploy-staging.yml)
|
||||||
|
|
||||||
|
1. Waits for workflow `CI` success on push to `main`.
|
||||||
|
2. Builds once; pushes `:<full-sha>` and `:staging`.
|
||||||
|
3. Calls Dokploy `compose.deploy` and polls until done.
|
||||||
|
4. Runs [`scripts/deploy/smoke.sh`](../../scripts/deploy/smoke.sh) against `STAGING_URL`.
|
||||||
|
|
||||||
|
### Production (manual)
|
||||||
|
|
||||||
|
[`.github/workflows/promote-production.yml`](../../.github/workflows/promote-production.yml)
|
||||||
|
|
||||||
|
1. Operator runs **Actions → Promote production**.
|
||||||
|
2. Inputs: full `sha` already on GHCR; `confirm` must be exactly `PRODUCTION`.
|
||||||
|
3. Retags the **same digest** as `:production` (no rebuild).
|
||||||
|
4. Deploys production compose + smoke.
|
||||||
|
|
||||||
|
Private repos on GitHub Free do not get Environment required reviewers; human approval is the explicit `workflow_dispatch` + confirmation string. GitHub Pro Environment reviewers are optional later.
|
||||||
|
|
||||||
|
## First admin and authorized production seeding
|
||||||
|
|
||||||
|
Seed credentials are local-only. For staging/production:
|
||||||
|
|
||||||
|
FrankenPHP sets `XDG_CONFIG_HOME=/config` (Caddy). PsySH/tinker then tries `/config/psysh`, which `appuser` cannot write — you get `Writing to directory /config/psysh is not allowed.` Override that env for the one-shot command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# From Dokploy → staging/production → Open terminal on `web` (or one-off run)
|
||||||
|
XDG_CONFIG_HOME=/tmp php artisan tinker --execute="
|
||||||
|
\$user = \\App\\Models\\User::query()->updateOrCreate(
|
||||||
|
['email' => 'admin@example.com'],
|
||||||
|
[
|
||||||
|
'name' => 'Admin',
|
||||||
|
'password' => 'use-a-strong-password',
|
||||||
|
'role' => 'admin',
|
||||||
|
'is_active' => true,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
\$user->forceFill(['email_verified_at' => now()])->save();
|
||||||
|
echo \$user->email.PHP_EOL;
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
Plain password is enough: `User` casts `password` to `hashed` (and skips re-hash when value already hashed). `email_verified_at` is not mass-assignable — use `forceFill` as above.
|
||||||
|
|
||||||
|
Confirm:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
XDG_CONFIG_HOME=/tmp php artisan tinker --execute="echo \\App\\Models\\User::query()->where('email', 'admin@example.com')->exists() ? 'ok' : 'missing';"
|
||||||
|
```
|
||||||
|
|
||||||
|
Never reuse `admin@amare.local` / `password`.
|
||||||
|
|
||||||
|
### Load authorized testimonials after migrations
|
||||||
|
|
||||||
|
After migrations, manually load the five authorized testimonials in **staging**, then repeat in **production**. From Dokploy, open a terminal on the environment's `web` service (or run an equivalent one-off process):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php artisan db:seed --class='Database\Seeders\TestimonialsSeeder' --force --no-interaction
|
||||||
|
```
|
||||||
|
|
||||||
|
This seeder is safe to rerun: it overwrites canonical source-owned fields, preserves curated photo fields, and leaves unrelated testimonials unchanged. The five records are published with the approved deterministic timestamp. Upsert keys on `author_name` (no unique DB constraint); keep one row per couple before/after running.
|
||||||
|
|
||||||
|
Optional verification:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
XDG_CONFIG_HOME=/tmp php artisan tinker --execute="
|
||||||
|
\$expected = collect(['Jeniffer e Maick', 'Quesia e Jhonata', 'Milena e Weslley', 'Raquel e Pedro', 'Victoria e Pedro']);
|
||||||
|
\$rows = \\App\\Models\\Testimonial::query()->whereIn('author_name', \$expected)->get(['author_name', 'published_at'])->groupBy('author_name');
|
||||||
|
\$valid = \$expected->every(function (string \$author) use (\$rows): bool {
|
||||||
|
\$matches = \$rows->get(\$author, collect());
|
||||||
|
return \$matches->count() === 1
|
||||||
|
&& \$matches->first()->published_at?->format('Y-m-d H:i:s') === '2026-08-05 00:00:00';
|
||||||
|
});
|
||||||
|
echo (\$valid ? 'ok' : 'invalid').PHP_EOL;
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected output: `ok`.
|
||||||
|
|
||||||
|
Do not run `DatabaseSeeder` or `ContentSeeder` in staging or production: they include local credentials and/or broad demo-content effects. Deployment workflows intentionally remain migrate-only; loading these testimonials is a deliberate manual operation in each environment.
|
||||||
|
|
||||||
|
## Backup and restore
|
||||||
|
|
||||||
|
Policy (SPEC §16.3): daily PostgreSQL backup, retention ≥ 14 days, RPO ≤ 24h, RTO ≤ 4h.
|
||||||
|
|
||||||
|
### Configure (Dokploy)
|
||||||
|
|
||||||
|
1. Settings → Destinations: add S3-compatible destination (AWS S3, R2, etc.).
|
||||||
|
2. Open each PostgreSQL service → Backup:
|
||||||
|
- Destination: the S3 destination
|
||||||
|
- Schedule: cron e.g. `0 3 * * *`
|
||||||
|
- Prefix: `amare/staging` or `amare/production`
|
||||||
|
- Enabled: on
|
||||||
|
3. Click **Test** and verify the object appears in the bucket.
|
||||||
|
4. Prefer Dokploy alerts/webhooks for backup failure if configured.
|
||||||
|
|
||||||
|
### Restore (staging rehearsal before first production promote)
|
||||||
|
|
||||||
|
1. Create a scratch database or restore into a disposable Postgres service.
|
||||||
|
2. Database → Backup → **Restore**: pick destination + backup file + target database name.
|
||||||
|
3. Point a temporary compose env at the restored DB and confirm `/up` + `/admin/login`.
|
||||||
|
4. Document the timestamp of the successful rehearsal.
|
||||||
|
|
||||||
|
Do **not** promote to production until staging restore has been proven once.
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
No rebuild. Move the environment alias to a previous SHA digest and redeploy.
|
||||||
|
|
||||||
|
### Staging
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Locally or in a one-off Actions shell with GHCR login
|
||||||
|
docker buildx imagetools create \
|
||||||
|
--tag ghcr.io/<owner>/<repo>:staging \
|
||||||
|
ghcr.io/<owner>/<repo>:<previous-sha>
|
||||||
|
|
||||||
|
# Then trigger Dokploy deploy (UI Deploy, or):
|
||||||
|
DOKPLOY_URL=... DOKPLOY_API_KEY=... DOKPLOY_COMPOSE_ID=... \
|
||||||
|
./scripts/deploy/dokploy-deploy.sh
|
||||||
|
|
||||||
|
SMOKE_BASE_URL=https://staging.example.com ./scripts/deploy/smoke.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### Production
|
||||||
|
|
||||||
|
Same pattern with `:production` tag and production compose id / URL. Prefer re-running **Promote production** with the previous SHA and confirmation `PRODUCTION`.
|
||||||
|
|
||||||
|
If a migration is not backward-compatible, fix forward with a new SHA; keep migrations reversible when possible.
|
||||||
|
|
||||||
|
## Smoke checks
|
||||||
|
|
||||||
|
```bash
|
||||||
|
SMOKE_BASE_URL=https://staging.example.com ./scripts/deploy/smoke.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Expects HTTP 200 for `/up`, `/`, and `/admin/login`.
|
||||||
|
|
||||||
|
## Domain checklist before production promote
|
||||||
|
|
||||||
|
- [ ] Final hostname DNS → VPS
|
||||||
|
- [ ] Dokploy TLS certificate issued
|
||||||
|
- [ ] `APP_URL` matches public HTTPS origin
|
||||||
|
- [ ] `SESSION_SECURE_COOKIE=true`
|
||||||
|
- [ ] Staging smoke green on the SHA to promote
|
||||||
|
- [ ] Staging backup + restore rehearsed
|
||||||
|
- [ ] Production Postgres backup schedule enabled
|
||||||
|
- [ ] Production R2 bucket + Resend domain ready
|
||||||
|
- [ ] First admin created without seed
|
||||||
|
|
||||||
|
## Local validation of Compose
|
||||||
|
|
||||||
|
```bash
|
||||||
|
APP_IMAGE=ghcr.io/<owner>/<repo> IMAGE_TAG=staging \
|
||||||
|
docker compose -f docker-compose.deploy.yml config
|
||||||
|
```
|
||||||
|
|
||||||
|
Requires a `.env` file present (Dokploy creates it from Environment UI). For local config checks, an empty `.env` is enough.
|
||||||
262
frontend-audit.md
Normal file
262
frontend-audit.md
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
Audite e melhore integralmente este projeto do site da **Amare**, incluindo todas as rotas públicas, componentes compartilhados, formulários, navegação, conteúdo, metadados e comportamento responsivo.
|
||||||
|
|
||||||
|
Você está autorizado a executar o projeto, inspecionar o repositório e modificar diretamente o código. Não entregue somente recomendações: implemente as correções necessárias e valide o resultado.
|
||||||
|
|
||||||
|
## Objetivo
|
||||||
|
|
||||||
|
Deixar o site tecnicamente sólido, visualmente refinado e pronto para apresentação à cliente e posterior lançamento, corrigindo problemas de:
|
||||||
|
|
||||||
|
* UI e UX;
|
||||||
|
* responsividade;
|
||||||
|
* posicionamento e conteúdo;
|
||||||
|
* acessibilidade;
|
||||||
|
* conversão;
|
||||||
|
* SEO;
|
||||||
|
* performance;
|
||||||
|
* robustez;
|
||||||
|
* qualidade e manutenção do código.
|
||||||
|
|
||||||
|
## Contexto do produto
|
||||||
|
|
||||||
|
A Amare é uma empresa de assessoria, produção e organização de eventos em São Paulo.
|
||||||
|
|
||||||
|
Ela atende:
|
||||||
|
|
||||||
|
* casamentos;
|
||||||
|
* eventos sociais e celebrações particulares;
|
||||||
|
* eventos corporativos.
|
||||||
|
|
||||||
|
O site não deve transmitir que a empresa trabalha exclusivamente com casamentos.
|
||||||
|
|
||||||
|
A experiência precisa equilibrar:
|
||||||
|
|
||||||
|
* emoção, proximidade, sensibilidade e sofisticação para eventos sociais;
|
||||||
|
* organização, segurança, método, clareza e credibilidade para eventos corporativos.
|
||||||
|
|
||||||
|
O resultado deve ser editorial, contemporâneo, humano, elegante e profissional, sem parecer excessivamente romântico nem corporativo demais.
|
||||||
|
|
||||||
|
Preview de referência:
|
||||||
|
|
||||||
|
`https://amare.preview.hellomanoel.com/`
|
||||||
|
|
||||||
|
Considere os documentos existentes no repositório, especialmente `AGENTS.md`, `README.md`, `SPEC.md`, `DESIGN.md` e equivalentes, como contexto autoritativo do projeto.
|
||||||
|
|
||||||
|
## Direção visual
|
||||||
|
|
||||||
|
Preserve a identidade visual existente quando ela funcionar corretamente:
|
||||||
|
|
||||||
|
* fundos off-white ou bege claro;
|
||||||
|
* verde oliva;
|
||||||
|
* composição editorial;
|
||||||
|
* fotografias em destaque;
|
||||||
|
* espaços em branco generosos;
|
||||||
|
* EB Garamond em títulos e destaques;
|
||||||
|
* elementos minimalistas;
|
||||||
|
* animações discretas;
|
||||||
|
* aparência refinada e atemporal.
|
||||||
|
|
||||||
|
Não preserve decisões que prejudiquem usabilidade, contraste, legibilidade, acessibilidade, responsividade ou performance.
|
||||||
|
|
||||||
|
Avalie especialmente:
|
||||||
|
|
||||||
|
* uso excessivo de Garamond em textos pequenos, menus, botões e formulários;
|
||||||
|
* verdes claros com contraste insuficiente;
|
||||||
|
* CTAs discretos demais;
|
||||||
|
* espaços vazios excessivos no celular;
|
||||||
|
* imagens que reforcem somente o posicionamento de casamento;
|
||||||
|
* falta de equilíbrio entre conteúdo social e corporativo;
|
||||||
|
* inconsistência tipográfica ou de espaçamento;
|
||||||
|
* aparência de template genérico de casamento.
|
||||||
|
|
||||||
|
## Escopo da auditoria
|
||||||
|
|
||||||
|
Analise todas as rotas encontradas no código e corrija problemas relacionados a:
|
||||||
|
|
||||||
|
### Produto e conteúdo
|
||||||
|
|
||||||
|
* clareza da proposta de valor;
|
||||||
|
* equilíbrio entre eventos sociais e corporativos;
|
||||||
|
* hierarquia das informações;
|
||||||
|
* conteúdo genérico, redundante ou sem função;
|
||||||
|
* coerência entre títulos, textos, imagens e CTAs;
|
||||||
|
* clareza dos serviços;
|
||||||
|
* confiança e credibilidade;
|
||||||
|
* caminho até contato ou solicitação de proposta.
|
||||||
|
|
||||||
|
Não invente história, números, clientes, prêmios, depoimentos, equipe, serviços, telefone, e-mail ou qualquer informação não confirmada.
|
||||||
|
|
||||||
|
### UI e experiência
|
||||||
|
|
||||||
|
* navegação;
|
||||||
|
* header e footer;
|
||||||
|
* menu mobile;
|
||||||
|
* hierarquia visual;
|
||||||
|
* tipografia;
|
||||||
|
* espaçamento;
|
||||||
|
* grids;
|
||||||
|
* CTAs;
|
||||||
|
* formulários;
|
||||||
|
* estados interativos;
|
||||||
|
* consistência entre páginas;
|
||||||
|
* experiência mobile-first;
|
||||||
|
* ausência de overflow, cortes, sobreposições ou distorções;
|
||||||
|
* adaptação real do layout ao celular, e não apenas redução da versão desktop.
|
||||||
|
|
||||||
|
### Acessibilidade
|
||||||
|
|
||||||
|
Use WCAG 2.2 AA como referência prática.
|
||||||
|
|
||||||
|
Corrija problemas de:
|
||||||
|
|
||||||
|
* HTML semântico;
|
||||||
|
* hierarquia de headings;
|
||||||
|
* navegação por teclado;
|
||||||
|
* foco visível;
|
||||||
|
* contraste;
|
||||||
|
* nomes acessíveis;
|
||||||
|
* labels;
|
||||||
|
* mensagens de erro;
|
||||||
|
* áreas de toque;
|
||||||
|
* textos alternativos;
|
||||||
|
* menu mobile;
|
||||||
|
* overlays;
|
||||||
|
* preferência por redução de movimento;
|
||||||
|
* uso correto de links e botões.
|
||||||
|
|
||||||
|
Prefira HTML semântico a ARIA desnecessária.
|
||||||
|
|
||||||
|
### Formulários e conversão
|
||||||
|
|
||||||
|
Garanta, quando aplicável:
|
||||||
|
|
||||||
|
* campos e obrigatoriedade claros;
|
||||||
|
* validação adequada;
|
||||||
|
* mensagens de erro específicas;
|
||||||
|
* estado de envio;
|
||||||
|
* prevenção de envio duplicado;
|
||||||
|
* estados de sucesso e falha;
|
||||||
|
* tratamento de erro de rede;
|
||||||
|
* preservação dos dados após erros recuperáveis;
|
||||||
|
* proteção antispam simples;
|
||||||
|
* privacidade e LGPD;
|
||||||
|
* links e mensagens de WhatsApp corretos;
|
||||||
|
* ausência de segredos expostos no cliente.
|
||||||
|
|
||||||
|
Quando algum dado real não estiver disponível, use configuração ou variável de ambiente e documente a pendência.
|
||||||
|
|
||||||
|
### SEO
|
||||||
|
|
||||||
|
Corrija, quando aplicável:
|
||||||
|
|
||||||
|
* títulos exclusivos por rota;
|
||||||
|
* meta descriptions;
|
||||||
|
* canonical;
|
||||||
|
* Open Graph;
|
||||||
|
* Twitter cards;
|
||||||
|
* sitemap;
|
||||||
|
* robots;
|
||||||
|
* favicon;
|
||||||
|
* idioma;
|
||||||
|
* headings;
|
||||||
|
* URLs;
|
||||||
|
* links internos;
|
||||||
|
* página 404;
|
||||||
|
* metadados sociais;
|
||||||
|
* indexação distinta entre preview e produção.
|
||||||
|
|
||||||
|
Não crie dados estruturados com informações não confirmadas.
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
|
||||||
|
Corrija problemas relevantes relacionados a:
|
||||||
|
|
||||||
|
* imagens;
|
||||||
|
* tamanhos responsivos;
|
||||||
|
* formatos modernos;
|
||||||
|
* lazy loading;
|
||||||
|
* LCP;
|
||||||
|
* CLS;
|
||||||
|
* INP;
|
||||||
|
* fontes;
|
||||||
|
* scripts desnecessários;
|
||||||
|
* hidratação excessiva;
|
||||||
|
* JavaScript evitável;
|
||||||
|
* componentes client-side sem necessidade;
|
||||||
|
* animações custosas;
|
||||||
|
* dependências pesadas;
|
||||||
|
* carregamento de conteúdo abaixo da dobra.
|
||||||
|
|
||||||
|
Preserve a qualidade visual das fotografias.
|
||||||
|
|
||||||
|
### Qualidade técnica
|
||||||
|
|
||||||
|
Corrija:
|
||||||
|
|
||||||
|
* erros de TypeScript;
|
||||||
|
* erros de lint;
|
||||||
|
* erros de build;
|
||||||
|
* warnings de hidratação;
|
||||||
|
* erros de console;
|
||||||
|
* requisições quebradas;
|
||||||
|
* links inválidos;
|
||||||
|
* rotas órfãs;
|
||||||
|
* componentes duplicados quando a consolidação simplificar o projeto;
|
||||||
|
* tratamento de erros ausente;
|
||||||
|
* tipagem insegura;
|
||||||
|
* complexidade desnecessária diretamente relacionada ao escopo.
|
||||||
|
|
||||||
|
## Prioridades
|
||||||
|
|
||||||
|
Considere:
|
||||||
|
|
||||||
|
* **P0:** bloqueia funcionamento, segurança, uso ou lançamento;
|
||||||
|
* **P1:** prejudica significativamente experiência, conversão, acessibilidade, SEO, performance ou credibilidade;
|
||||||
|
* **P2:** refinamento não essencial para o lançamento.
|
||||||
|
|
||||||
|
Implemente todos os itens P0 e P1 que possam ser resolvidos com as informações existentes.
|
||||||
|
|
||||||
|
Itens dependentes de dados reais da cliente devem permanecer como pendências explícitas, sem conteúdo fictício.
|
||||||
|
|
||||||
|
## Restrições
|
||||||
|
|
||||||
|
* Preserve a stack e a arquitetura existentes quando forem adequadas.
|
||||||
|
* Prefira mudanças simples, localizadas e fáceis de manter.
|
||||||
|
* Não reescreva o projeto sem necessidade concreta.
|
||||||
|
* Não adicione bibliotecas quando a solução atual for suficiente.
|
||||||
|
* Não implemente pagamentos, CRM, área do cliente, chat, contratos avançados ou funcionalidades fora do MVP.
|
||||||
|
* Não altere arquivos não relacionados sem justificativa.
|
||||||
|
* Preserve alterações locais preexistentes.
|
||||||
|
* Não silencie problemas com `any`, `eslint-disable`, casts inseguros ou desativação de validações.
|
||||||
|
* Não reduza acessibilidade para preservar estética.
|
||||||
|
* Não deixe mocks ou soluções temporárias como implementação final.
|
||||||
|
|
||||||
|
## Critérios de conclusão
|
||||||
|
|
||||||
|
A tarefa estará concluída quando:
|
||||||
|
|
||||||
|
* todas as rotas públicas existentes tiverem sido auditadas;
|
||||||
|
* todos os problemas P0 e P1 solucionáveis tiverem sido corrigidos;
|
||||||
|
* o site funcionar corretamente em mobile e desktop;
|
||||||
|
* não houver overflow horizontal, sobreposição ou componentes quebrados;
|
||||||
|
* menu, navegação, links, CTAs e formulários funcionarem;
|
||||||
|
* o posicionamento social e corporativo estiver claro;
|
||||||
|
* a acessibilidade essencial estiver atendida;
|
||||||
|
* os metadados e fundamentos de SEO estiverem corretos;
|
||||||
|
* os principais problemas de performance tiverem sido tratados;
|
||||||
|
* erros causados ou revelados pelas alterações tiverem sido resolvidos;
|
||||||
|
* lint, TypeScript, testes e build disponíveis tiverem sido executados com sucesso.
|
||||||
|
|
||||||
|
Não declare uma validação como aprovada sem executá-la.
|
||||||
|
|
||||||
|
## Entrega final
|
||||||
|
|
||||||
|
Ao concluir, apresente:
|
||||||
|
|
||||||
|
1. rotas auditadas;
|
||||||
|
2. problemas principais encontrados;
|
||||||
|
3. alterações implementadas;
|
||||||
|
4. arquivos modificados;
|
||||||
|
5. comandos executados e seus resultados;
|
||||||
|
6. decisões e trade-offs relevantes;
|
||||||
|
7. informações que dependem da cliente;
|
||||||
|
8. itens P2 mantidos fora do escopo.
|
||||||
@@ -15,15 +15,17 @@ Fases 0–1 e provedores de produção (Resend + R2) estão em `main` com CI ver
|
|||||||
|
|
||||||
- Staging em Dokploy com a mesma imagem FrankenPHP por SHA para web, queue e scheduler.
|
- Staging em Dokploy com a mesma imagem FrankenPHP por SHA para web, queue e scheduler.
|
||||||
- Deploy automático em `main` após CI: build → GHCR → Dokploy API → migrate → health → smoke.
|
- Deploy automático em `main` após CI: build → GHCR → Dokploy API → migrate → health → smoke.
|
||||||
- Compose local com app FrankenPHP + Postgres.
|
- Produção via promoção manual da mesma digest SHA (alias `:production`), sem rebuild.
|
||||||
- PHP 8.4 canônico em docs/Docker/CI.
|
- Backup PostgreSQL diário (retenção ≥14 dias), restore documentado e rollback por SHA.
|
||||||
- Gates com `npm audit` e cobertura Domain/Application ≥ 80%.
|
- Compose local com app FrankenPHP + Postgres (pendente fora da fatia deploy).
|
||||||
- E-mail verificado + reset de senha seguros no painel (ADM-01 / SPEC §12.1).
|
- PHP 8.4 canônico em docs/Docker/CI (pendente).
|
||||||
- Strict types em PHP próprio faltante.
|
- Gates com `npm audit` e cobertura Domain/Application ≥ 80% (pendente).
|
||||||
|
- E-mail verificado + reset de senha seguros no painel (ADM-01 / SPEC §12.1) (pendente).
|
||||||
|
- Strict types em PHP próprio faltante (pendente).
|
||||||
|
|
||||||
**Non-Goals:**
|
**Non-Goals:**
|
||||||
|
|
||||||
- Produção, promoção humana, provisionamento de VPS para clientes.
|
- Deploy automático em produção; provisionamento de VPS para clientes.
|
||||||
- Fase 2 (briefing/CRM/E2E-01/02).
|
- Fase 2 (briefing/CRM/E2E-01/02).
|
||||||
- Redis, worker mode, CDN automation, signed private media.
|
- Redis, worker mode, CDN automation, signed private media.
|
||||||
|
|
||||||
@@ -31,11 +33,13 @@ Fases 0–1 e provedores de produção (Resend + R2) estão em `main` com CI ver
|
|||||||
|
|
||||||
### D1 — Dokploy Compose na VPS, imagem imutável no GHCR
|
### D1 — Dokploy Compose na VPS, imagem imutável no GHCR
|
||||||
|
|
||||||
GitHub Actions (após CI verde em `main`) constrói **uma** imagem `ghcr.io/<owner>/<repo>:<git-sha>` (+ alias `:staging`), faz push privado e chama `POST /api/compose.deploy` (ou `compose.update` + `compose.deploy`) no Dokploy com `x-api-key`.
|
GitHub Actions (após CI verde em `main`) constrói **uma** imagem `ghcr.io/<owner>/<repo>:<git-sha>` (+ alias `:staging`), faz push privado e chama `POST /api/compose.deploy` no Dokploy com `x-api-key`.
|
||||||
|
|
||||||
Compose de staging referencia `${APP_IMAGE}` / `IMAGE_TAG` para `web`, `queue`, `scheduler` e job `migrate` one-shot (`php artisan migrate --force`). PostgreSQL é serviço Dokploy separado (não na imagem da app).
|
Compose compartilhado (`docker-compose.deploy.yml`) referencia `${APP_IMAGE}` / `IMAGE_TAG` para `web`, `queue`, `scheduler` e job `migrate` one-shot (`php artisan migrate --force`). Dokploy mantém **duas** stacks Compose (staging e production) com `IMAGE_TAG` distinto. PostgreSQL é serviço Dokploy separado por ambiente (não na imagem da app).
|
||||||
|
|
||||||
*Alternativas rejeitadas:* Railway (Pro para GHCR privado + desvio de plataforma); rebuild por serviço no Dokploy (quebra “mesma imagem” SPEC §14.3/§15.2); tag só `:latest` (rollback frágil).
|
Produção: `workflow_dispatch` com SHA + confirmação `PRODUCTION` move alias `:production` para o **mesmo digest** já publicado e dispara `compose.deploy` na stack de produção. Repo privado no GitHub Free não tem required reviewers de Environment; aprovação humana = disparo manual explícito (GitHub Pro opcional depois).
|
||||||
|
|
||||||
|
*Alternativas rejeitadas:* Railway (Pro para GHCR privado + desvio de plataforma); rebuild por serviço no Dokploy (quebra “mesma imagem” SPEC §14.3/§15.2); tag só `:latest` (rollback frágil); deploy automático direto em produção.
|
||||||
|
|
||||||
### D2 — Processos e health
|
### D2 — Processos e health
|
||||||
|
|
||||||
@@ -50,7 +54,11 @@ Healthcheck Docker/Dokploy e smoke pós-deploy usam `GET /up` (sem auth, sem sec
|
|||||||
|
|
||||||
### D3 — Secrets e providers
|
### D3 — Secrets e providers
|
||||||
|
|
||||||
Secrets em GitHub Actions + env Dokploy: `APP_KEY`, DB, `MAIL_MAILER=resend` / `RESEND_API_KEY`, `FILESYSTEM_DISK=r2` / `R2_*`, tokens Dokploy/GHCR. Nunca embeds em layer. Local continua `MAIL_MAILER=log` e disco `public`.
|
GitHub Actions guarda só orquestração: `DOKPLOY_URL`, API key, compose IDs, URLs públicas de smoke. Env Laravel (`APP_KEY`, DB, Resend, R2) vive **somente** no Dokploy, isolado por ambiente. Nunca embeds em layer. Local continua `MAIL_MAILER=log` e disco `public`.
|
||||||
|
|
||||||
|
### D3b — Backup e restore
|
||||||
|
|
||||||
|
PostgreSQL staging/produção: backup diário via Dokploy → destino S3-compatible, retenção mínima 14 dias (SPEC §16.3). Restore documentado e testado em staging antes da primeira promoção a produção.
|
||||||
|
|
||||||
### D4 — Compose local com app
|
### D4 — Compose local com app
|
||||||
|
|
||||||
@@ -74,26 +82,32 @@ Alinhar README, CI (`setup-php` 8.4) e `Dockerfile` `ARG PHP_VERSION=8.4`. Mante
|
|||||||
|
|
||||||
### D8 — Rollback
|
### D8 — Rollback
|
||||||
|
|
||||||
Rollback = apontar Compose staging para tag SHA anterior no GHCR e `compose.deploy`/`redeploy`. Falha de healthcheck impede promoção. Sem rebuild.
|
Rollback = mover alias do ambiente (`:staging` ou `:production`) para tag SHA anterior no GHCR e `compose.deploy`. Falha de healthcheck/smoke impede promoção. Sem rebuild.
|
||||||
|
|
||||||
|
### D9 — Trusted proxies atrás do Traefik
|
||||||
|
|
||||||
|
`bootstrap/app.php` confia em proxies (`trustProxies(at: '*')`) para honrar `X-Forwarded-*` do Traefik/Dokploy. Staging/produção usam `SESSION_SECURE_COOKIE=true` com HTTPS.
|
||||||
|
|
||||||
## Risks / Trade-offs
|
## Risks / Trade-offs
|
||||||
|
|
||||||
- **[GHCR privado + pull na VPS]** → configurar registry no Dokploy com PAT `read:packages`; documentar checklist.
|
- **[GHCR privado + pull na VPS]** → configurar registry no Dokploy com PAT `read:packages`; documentar checklist.
|
||||||
- **[Migrate one-shot falha]** → deploy não promove web; manter migrations backward-compatible.
|
- **[Migrate one-shot falha]** → web/queue/scheduler dependem de migrate exit 0; manter migrations backward-compatible.
|
||||||
- **[Cobertura 80% com Domain quase vazio]** → medir só namespaces existentes; baseline sobe conforme Fase 2 adiciona Domain.
|
- **[GitHub Free sem required reviewers]** → promoção humana via `workflow_dispatch` + input `PRODUCTION`; Pro opcional.
|
||||||
- **[npm audit ruido]** → `--omit=dev` + allowlist documentada se necessário; sem silenciar sem justificativa.
|
- **[Cobertura 80% com Domain quase vazio]** → medir só namespaces existentes; baseline sobe conforme Fase 2 adiciona Domain (pendente).
|
||||||
- **[E-mail verification em staging]** → seed/users de staging com `email_verified_at`; Resend para reset real quando configurado.
|
- **[npm audit ruido]** → `--omit=dev` + allowlist documentada se necessário (pendente).
|
||||||
- **[Compose local rebuild lento]** → documentar serve opcional; CI permanece fonte FrankenPHP.
|
- **[E-mail verification em staging]** → seed/users de staging com `email_verified_at`; Resend para reset real quando configurado (pendente).
|
||||||
|
- **[Compose local rebuild lento]** → documentar serve opcional; CI permanece fonte FrankenPHP (pendente).
|
||||||
|
|
||||||
## Migration Plan
|
## Migration Plan
|
||||||
|
|
||||||
1. Implementar auth/coverage/npm/strict_types/docs/Compose local; CI verde.
|
1. Fatia deploy: Compose deploy, workflows, smoke, trusted proxies, docs Dokploy/backup/rollback; CI verde.
|
||||||
2. Criar projeto Dokploy + Postgres + Compose app; registrar GHCR.
|
2. Criar projeto Dokploy + Postgres (staging + produção) + Compose apps; registrar GHCR.
|
||||||
3. Adicionar workflow deploy; primeiro push de imagem SHA; smoke `/up` + home + login.
|
3. Primeiro push de imagem SHA → staging; smoke `/up` + home + login; testar rollback e backup/restore.
|
||||||
4. Atualizar SPEC §18 Fase 0 apenas com itens comprovados; evidência no PR.
|
4. Promoção manual para produção após domínio/TLS/`APP_URL` confirmados.
|
||||||
5. Rollback: redeploy tag SHA anterior.
|
5. Fatias restantes da change (auth/coverage/npm/Compose local/PHP docs) em PRs seguintes.
|
||||||
|
6. Atualizar SPEC §18 Fase 0 apenas com itens comprovados; evidência no PR.
|
||||||
|
|
||||||
## Open Questions
|
## Open Questions
|
||||||
|
|
||||||
- Domínio público exato do staging (DNS) — preencher na implementação com valor do operador.
|
- Domínio público exato do staging/produção (DNS) — preencher na implementação com valor do operador.
|
||||||
- Se Dokploy Compose API exigir `compose.saveEnvironment` para `IMAGE_TAG` a cada deploy: confirmar payload na primeira fatia de integração.
|
- Se Dokploy Compose API exigir `compose.update` env para `IMAGE_TAG` a cada deploy: preferir aliases `:staging`/`:production` estáveis no Compose Dokploy para evitar rewrite de env.
|
||||||
|
|||||||
@@ -6,17 +6,20 @@ Fases 0 e 1 estão implementadas e mescladas, mas o critério de saída da Fase
|
|||||||
|
|
||||||
- Implantar staging na VPS própria via Dokploy (Docker Compose): imagem única por SHA no GHCR, serviços `web`/`queue`/`scheduler`/migrate, PostgreSQL gerenciado, healthcheck `/up`, smoke pós-deploy e rollback por tag SHA anterior.
|
- Implantar staging na VPS própria via Dokploy (Docker Compose): imagem única por SHA no GHCR, serviços `web`/`queue`/`scheduler`/migrate, PostgreSQL gerenciado, healthcheck `/up`, smoke pós-deploy e rollback por tag SHA anterior.
|
||||||
- Adicionar workflow GitHub Actions de deploy em `main` após CI verde (build → push GHCR → acionar API Dokploy).
|
- Adicionar workflow GitHub Actions de deploy em `main` após CI verde (build → push GHCR → acionar API Dokploy).
|
||||||
- Estender `docker-compose.yml` local com serviço de aplicação FrankenPHP (além do PostgreSQL).
|
- Adicionar promoção manual de produção: mesma digest SHA já publicada, alias `:production`, sem rebuild (`workflow_dispatch` + confirmação explícita).
|
||||||
- Fixar PHP **8.4** como versão canônica em Docker, CI e documentação.
|
- Documentar backup PostgreSQL diário (retenção ≥14d), restore e runbook operacional Dokploy/GHCR.
|
||||||
- Incluir `npm audit` e cobertura mínima de 80% para `Domain` e `Application` nos gates de qualidade (SPEC §12.6, §13.7, §13.9).
|
- Estender `docker-compose.yml` local com serviço de aplicação FrankenPHP (além do PostgreSQL) — **ainda pendente** nesta fatia de deploy.
|
||||||
- Exigir e-mail verificado no painel Filament e entregar reset de senha seguro (SPEC §12.1; ADM-01).
|
- Fixar PHP **8.4** como versão canônica em Docker, CI e documentação — **ainda pendente**.
|
||||||
- Corrigir `declare(strict_types=1);` em PHP próprio que ainda falte e cobrir regressões.
|
- Incluir `npm audit` e cobertura mínima de 80% para `Domain` e `Application` nos gates de qualidade (SPEC §12.6, §13.7, §13.9) — **ainda pendente**.
|
||||||
|
- Exigir e-mail verificado no painel Filament e entregar reset de senha seguro (SPEC §12.1; ADM-01) — **ainda pendente**.
|
||||||
|
- Corrigir `declare(strict_types=1);` em PHP próprio que ainda falte e cobrir regressões — **ainda pendente**.
|
||||||
|
|
||||||
## Non-Goals
|
## Non-Goals
|
||||||
|
|
||||||
Conforme [SPEC.md §4.2](../../SPEC.md):
|
Conforme [SPEC.md §4.2](../../SPEC.md):
|
||||||
|
|
||||||
- Produção com promoção humana, portal do cliente, multi-tenancy, Redis, FrankenPHP worker mode.
|
- Deploy automático direto em produção (produção exige promoção humana da mesma imagem).
|
||||||
|
- Portal do cliente, multi-tenancy, Redis, FrankenPHP worker mode.
|
||||||
- Fase 2 (WEB-05 briefing, CRM, E2E-01/E2E-02) — change futura `build-leads-crm` após esta fechar.
|
- Fase 2 (WEB-05 briefing, CRM, E2E-01/E2E-02) — change futura `build-leads-crm` após esta fechar.
|
||||||
- Provisionamento genérico de VPS/Dokploy para clientes finais.
|
- Provisionamento genérico de VPS/Dokploy para clientes finais.
|
||||||
- Templates de e-mail de lead, auditoria completa (ADM-02), documentos privados.
|
- Templates de e-mail de lead, auditoria completa (ADM-02), documentos privados.
|
||||||
@@ -25,19 +28,20 @@ Conforme [SPEC.md §4.2](../../SPEC.md):
|
|||||||
|
|
||||||
### New Capabilities
|
### New Capabilities
|
||||||
|
|
||||||
- `staging-deployment`: deploy automático de staging na VPS via Dokploy com imagem imutável por SHA, processos web/queue/scheduler, migração, healthcheck, smoke e rollback (SPEC §14.3, §15.2, §18 Fase 0).
|
- `staging-deployment`: deploy automático de staging na VPS via Dokploy com imagem imutável por SHA, processos web/queue/scheduler, migração, healthcheck, smoke e rollback; promoção manual da mesma digest para produção (SPEC §14.3, §15.2, §16.3, §18 Fase 0).
|
||||||
|
|
||||||
### Modified Capabilities
|
### Modified Capabilities
|
||||||
|
|
||||||
- `container-runtime`: Compose local com app FrankenPHP; PHP 8.4 canônico; alinhamento da mesma imagem a processos de staging.
|
- `container-runtime`: Compose local com app FrankenPHP; PHP 8.4 canônico; alinhamento da mesma imagem a processos de staging/produção.
|
||||||
- `quality-gates`: `npm audit` no gate; cobertura mínima 80% para Domain/Application; job de deploy staging após CI.
|
- `quality-gates`: `npm audit` no gate; cobertura mínima 80% para Domain/Application; job de deploy staging após CI.
|
||||||
- `health-check`: healthcheck e smoke pós-deploy de staging usam `/up` sem autenticação.
|
- `health-check`: healthcheck e smoke pós-deploy de staging usam `/up` sem autenticação.
|
||||||
- `internal-authentication`: e-mail verificado obrigatório para acesso ao painel; reset de senha seguro disponível (SPEC §12.1, ADM-01).
|
- `internal-authentication`: e-mail verificado obrigatório para acesso ao painel; reset de senha seguro disponível (SPEC §12.1, ADM-01).
|
||||||
|
|
||||||
## Impact
|
## Impact
|
||||||
|
|
||||||
- **Cria**: `docker-compose` de staging (ou extensão), workflow `.github/workflows/deploy-staging.yml`, docs operacionais de Dokploy/GHCR, testes de auth verification/reset e cobertura.
|
- **Cria (fatia deploy)**: `docker-compose.deploy.yml`, workflows `deploy-staging.yml` / `promote-production.yml`, scripts smoke/Dokploy, docs operacionais Dokploy/GHCR/backup/rollback.
|
||||||
- **Altera**: `docker-compose.yml`, `Dockerfile`/docs PHP, `composer.json`/`package.json` scripts, `.github/workflows/ci.yml`, `User`/`AdminPanelProvider`, README, `.env.example`.
|
- **Altera (fatia deploy)**: `bootstrap/app.php` (trusted proxies), `.env.example`, README.
|
||||||
- **Infra (manual)**: projeto Dokploy na VPS, registry GHCR, secrets (`DOKPLOY_*`, `GHCR_*`, DB, `APP_KEY`, Resend/R2).
|
- **Ainda pendente nesta change**: Compose local FrankenPHP, PHP 8.4 docs, npm audit/coverage, auth verification/reset, strict_types.
|
||||||
|
- **Infra (manual)**: projeto Dokploy na VPS (duas stacks), registry GHCR, secrets (`DOKPLOY_*`, DB, `APP_KEY`, Resend/R2), backup S3.
|
||||||
- **Depende de**: specs já arquivadas (`container-runtime`, `quality-gates`, `health-check`, `internal-authentication`, `transactional-email`, `object-storage`).
|
- **Depende de**: specs já arquivadas (`container-runtime`, `quality-gates`, `health-check`, `internal-authentication`, `transactional-email`, `object-storage`).
|
||||||
- **Risco**: secrets e registry privados; mitigações: tokens com escopo mínimo, imagem por SHA, healthcheck antes de promover, rollback por tag anterior.
|
- **Risco**: secrets e registry privados; mitigações: tokens com escopo mínimo, imagem por SHA, healthcheck antes de promover, rollback por tag anterior.
|
||||||
|
|||||||
@@ -55,3 +55,23 @@ Rollback SHALL redeploy a previously published SHA-tagged image without rebuildi
|
|||||||
- **WHEN** the operator points staging Compose at a previous SHA tag and redeploys
|
- **WHEN** the operator points staging Compose at a previous SHA tag and redeploys
|
||||||
- **THEN** web, queue, and scheduler MUST run that previous image
|
- **THEN** web, queue, and scheduler MUST run that previous image
|
||||||
- **AND** no source rebuild MUST be required
|
- **AND** no source rebuild MUST be required
|
||||||
|
|
||||||
|
### Requirement: Production promotion reuses the same immutable digest
|
||||||
|
|
||||||
|
Production SHALL be promoted from an already-published SHA-tagged image without rebuilding from source. Promotion MUST require explicit human action (SPEC §14.3).
|
||||||
|
|
||||||
|
#### Scenario: Operator promotes a staging-approved SHA to production
|
||||||
|
|
||||||
|
- **WHEN** the operator confirms promotion of commit SHA `abc123`
|
||||||
|
- **THEN** production web, queue, and scheduler MUST run the same digest previously published as `ghcr.io/<owner>/<repo>:abc123`
|
||||||
|
- **AND** MUST NOT rebuild from source for that promotion
|
||||||
|
|
||||||
|
### Requirement: Database backups exist before production cutover
|
||||||
|
|
||||||
|
Staging and production PostgreSQL services SHALL have automated daily backups with retention of at least 14 days, and a documented restore procedure MUST be verified on staging before the first production promotion (SPEC §16.3).
|
||||||
|
|
||||||
|
#### Scenario: Staging restore is proven before production promotion
|
||||||
|
|
||||||
|
- **WHEN** the operator prepares the first production promotion
|
||||||
|
- **THEN** a restore from a staging backup MUST have been documented and successfully tested
|
||||||
|
- **AND** production MUST have daily backup configured with retention of at least 14 days
|
||||||
|
|||||||
@@ -19,23 +19,25 @@
|
|||||||
- [ ] 3.3 Add/adjust unit tests if current Domain/Application coverage is below threshold
|
- [ ] 3.3 Add/adjust unit tests if current Domain/Application coverage is below threshold
|
||||||
- [ ] 3.4 Verify CI `static` and `unit` fail appropriately on intentional audit/coverage breakage in a branch experiment or equivalent proof
|
- [ ] 3.4 Verify CI `static` and `unit` fail appropriately on intentional audit/coverage breakage in a branch experiment or equivalent proof
|
||||||
|
|
||||||
## 4. Staging Compose and Dokploy prep
|
## 4. Staging/production Compose and Dokploy prep
|
||||||
|
|
||||||
- [ ] 4.1 Add versioned staging Compose template (web, queue, scheduler, migrate one-shot) parameterized by `APP_IMAGE`/`IMAGE_TAG`
|
- [x] 4.1 Add versioned Compose template (`docker-compose.deploy.yml`: web, queue, scheduler, migrate one-shot) parameterized by `APP_IMAGE`/`IMAGE_TAG` for staging and production stacks
|
||||||
- [ ] 4.2 Document Dokploy project setup: GHCR registry credentials, Postgres service, Compose import, required env vars (APP_KEY, DB, Resend, R2)
|
- [x] 4.2 Document Dokploy project setup: GHCR registry credentials, Postgres per environment, Compose import, required env vars (APP_KEY, DB, Resend, R2), trusted proxies/session cookies
|
||||||
- [ ] 4.3 Document rollback procedure: redeploy previous SHA tag without rebuild
|
- [x] 4.3 Document rollback procedure: move environment alias to previous SHA and redeploy without rebuild
|
||||||
|
- [x] 4.4 Document PostgreSQL daily backup (≥14d retention), restore procedure, and test restore on staging before first production promotion
|
||||||
|
|
||||||
## 5. Deploy workflow and smoke
|
## 5. Deploy workflow and smoke
|
||||||
|
|
||||||
- [ ] 5.1 Create `.github/workflows/deploy-staging.yml` gated on successful CI on `main`: build image, push `ghcr.io/...:<sha>` + `:staging`, trigger Dokploy `compose.deploy`
|
- [x] 5.1 Create `.github/workflows/deploy-staging.yml` gated on successful CI on `main`: build image, push `ghcr.io/...:<sha>` + `:staging`, trigger Dokploy `compose.deploy`
|
||||||
- [ ] 5.2 Wire migrate-before-serve (Compose migrate service or Dokploy deploy command) and healthcheck on `/up`
|
- [x] 5.2 Create `.github/workflows/promote-production.yml` (`workflow_dispatch` + confirmation): retag same digest as `:production`, deploy production stack, smoke
|
||||||
- [ ] 5.3 Add post-deploy smoke script/job for `/up`, `/`, `/admin/login` returning 200
|
- [x] 5.3 Wire migrate-before-serve (Compose migrate service) and healthcheck on `/up`
|
||||||
- [ ] 5.4 Store secrets only in GitHub/Dokploy; ensure no secrets in image layers (reuse container CI check)
|
- [x] 5.4 Add post-deploy smoke script/job for `/up`, `/`, `/admin/login` returning 200
|
||||||
|
- [x] 5.5 Store orchestration secrets only in GitHub; Laravel/DB/R2/Resend only in Dokploy; ensure no secrets in image layers
|
||||||
|
|
||||||
## 6. Phase 0 exit evidence
|
## 6. Phase 0 exit evidence
|
||||||
|
|
||||||
- [ ] 6.1 Perform first successful staging deploy of a `main` SHA and capture evidence (workflow URL, smoke output)
|
- [ ] 6.1 Perform first successful staging deploy of a `main` SHA and capture evidence (workflow URL, smoke output)
|
||||||
- [ ] 6.2 Verify rollback to previous SHA works once
|
- [ ] 6.2 Verify rollback to previous SHA works once on staging
|
||||||
- [ ] 6.3 Update `SPEC.md` §18 Fase 0 checkboxes only for items with evidence; note remaining deferred items if any
|
- [ ] 6.3 Update `SPEC.md` §18 Fase 0 checkboxes only for items with evidence; note remaining deferred items if any
|
||||||
- [ ] 6.4 Run full `composer quality` and confirm all five CI jobs + staging deploy path green
|
- [ ] 6.4 Run full `composer quality` and confirm all five CI jobs + staging deploy path green
|
||||||
- [ ] 6.5 Report in SPEC §24 format and mark this change ready to archive after merge
|
- [ ] 6.5 Report in SPEC §24 format; archive this change only after remaining parity tasks (1–3) also complete
|
||||||
|
|||||||
17
package-lock.json
generated
17
package-lock.json
generated
@@ -7,6 +7,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.0.0",
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
"concurrently": "^9.0.1",
|
"concurrently": "^9.0.1",
|
||||||
|
"husky": "^9.1.7",
|
||||||
"laravel-vite-plugin": "^3.1",
|
"laravel-vite-plugin": "^3.1",
|
||||||
"playwright": "^1.62.0",
|
"playwright": "^1.62.0",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
@@ -890,6 +891,22 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/husky": {
|
||||||
|
"version": "9.1.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz",
|
||||||
|
"integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"husky": "bin.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/typicode"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-fullwidth-code-point": {
|
"node_modules/is-fullwidth-code-point": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||||
|
|||||||
@@ -4,11 +4,13 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"dev": "vite"
|
"dev": "vite",
|
||||||
|
"prepare": "husky"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.0.0",
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
"concurrently": "^9.0.1",
|
"concurrently": "^9.0.1",
|
||||||
|
"husky": "^9.1.7",
|
||||||
"laravel-vite-plugin": "^3.1",
|
"laravel-vite-plugin": "^3.1",
|
||||||
"playwright": "^1.62.0",
|
"playwright": "^1.62.0",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 0 B After Width: | Height: | Size: 4.2 KiB |
4
public/favicon.svg
Normal file
4
public/favicon.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||||
|
<rect width="64" height="64" fill="#556B2F"/>
|
||||||
|
<text x="32" y="45" font-family="Georgia, 'Times New Roman', serif" font-size="38" font-weight="600" fill="#FBF9F4" text-anchor="middle">A</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 264 B |
@@ -61,6 +61,10 @@
|
|||||||
font-family: var(--amare-font-serif);
|
font-family: var(--amare-font-serif);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[id$='-heading'] {
|
||||||
|
scroll-margin-top: 5.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
a:focus-visible,
|
a:focus-visible,
|
||||||
button:focus-visible,
|
button:focus-visible,
|
||||||
summary:focus-visible,
|
summary:focus-visible,
|
||||||
@@ -84,6 +88,14 @@
|
|||||||
filter: saturate(0.88) contrast(0.96);
|
filter: saturate(0.88) contrast(0.96);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@utility honeypot {
|
||||||
|
position: absolute;
|
||||||
|
left: -9999px;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
@layer components {
|
@layer components {
|
||||||
.main-nav.is-open {
|
.main-nav.is-open {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -98,4 +110,140 @@
|
|||||||
body.menu-open {
|
body.menu-open {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Dossiê vivo — content visible by default; enhance only when opted in */
|
||||||
|
[data-chapter-index] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-chapter-progress] {
|
||||||
|
display: none;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-chapter-index] a[aria-current="true"] {
|
||||||
|
color: var(--amare-color-accent-deep);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-chapter-index] a[aria-current="true"]::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0.35em;
|
||||||
|
bottom: 0.35em;
|
||||||
|
width: 1px;
|
||||||
|
background: var(--amare-color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-chapter-progress] > span {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
width: var(--chapter-progress, 0%);
|
||||||
|
background: var(--amare-color-accent);
|
||||||
|
transform-origin: left center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1280px) {
|
||||||
|
[data-chapter-index] {
|
||||||
|
display: flex;
|
||||||
|
position: fixed;
|
||||||
|
top: 50%;
|
||||||
|
right: max(1rem, calc((100vw - var(--amare-container-max)) / 2 - 7.5rem));
|
||||||
|
z-index: 30;
|
||||||
|
max-width: 6.5rem;
|
||||||
|
translate: 0 -50%;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-chapter-index] a {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 2.75rem;
|
||||||
|
padding-left: 0.75rem;
|
||||||
|
font-size: var(--amare-text-xs);
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--amare-color-muted);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color var(--amare-duration-fast) var(--amare-ease-standard);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-chapter-index] a:hover {
|
||||||
|
color: var(--amare-color-accent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1279px) {
|
||||||
|
[data-chapter-progress] {
|
||||||
|
display: block;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
z-index: 45;
|
||||||
|
width: 100%;
|
||||||
|
height: 2px;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
/* Transform/clip only — never fade text opacity (axe + WCAG mid-transition). */
|
||||||
|
html[data-motion="enhance"] [data-motion="dossie-hero"] [data-motion-beat="seal"],
|
||||||
|
html[data-motion="enhance"] [data-motion="dossie-hero"] [data-motion-beat="cta"] {
|
||||||
|
transform: translateY(0.5rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-motion="enhance"] [data-motion="dossie-hero"] [data-motion-beat="title"] {
|
||||||
|
transform: translateY(0.4rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-motion="enhance"] [data-motion="dossie-hero"] [data-motion-beat="media"] {
|
||||||
|
clip-path: inset(4% 0 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-motion="enhance"] [data-motion="dossie-hero"].is-active [data-motion-beat] {
|
||||||
|
transform: none;
|
||||||
|
clip-path: inset(0 0 0 0);
|
||||||
|
transition:
|
||||||
|
transform var(--amare-duration-slow) var(--amare-ease-arrival),
|
||||||
|
clip-path var(--amare-duration-focal) var(--amare-ease-arrival);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-motion="enhance"] [data-motion="dossie-hero"].is-active [data-motion-beat="seal"] {
|
||||||
|
transition-delay: 0ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-motion="enhance"] [data-motion="dossie-hero"].is-active [data-motion-beat="title"] {
|
||||||
|
transition-delay: 80ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-motion="enhance"] [data-motion="dossie-hero"].is-active [data-motion-beat="media"] {
|
||||||
|
transition-delay: 120ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-motion="enhance"] [data-motion="dossie-hero"].is-active [data-motion-beat="cta"] {
|
||||||
|
transition-delay: 220ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-motion="enhance"] [data-reveal]:not(.is-revealed) {
|
||||||
|
transform: translateY(0.5rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-motion="enhance"] [data-reveal].is-revealed {
|
||||||
|
transform: none;
|
||||||
|
transition: transform var(--amare-duration-slow) var(--amare-ease-arrival);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-motion="enhance"] [data-motion="page-open"]:not(.is-active) {
|
||||||
|
transform: translateY(0.4rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-motion="enhance"] [data-motion="page-open"].is-active {
|
||||||
|
transform: none;
|
||||||
|
transition: transform var(--amare-duration-normal) var(--amare-ease-arrival);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,7 +56,9 @@
|
|||||||
--amare-duration-fast: 150ms;
|
--amare-duration-fast: 150ms;
|
||||||
--amare-duration-normal: 250ms;
|
--amare-duration-normal: 250ms;
|
||||||
--amare-duration-slow: 400ms;
|
--amare-duration-slow: 400ms;
|
||||||
|
--amare-duration-focal: 720ms;
|
||||||
--amare-ease-standard: cubic-bezier(0.4, 0, 0.2, 1);
|
--amare-ease-standard: cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
--amare-ease-arrival: cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
@@ -64,6 +66,7 @@
|
|||||||
--amare-duration-fast: 0.01ms;
|
--amare-duration-fast: 0.01ms;
|
||||||
--amare-duration-normal: 0.01ms;
|
--amare-duration-normal: 0.01ms;
|
||||||
--amare-duration-slow: 0.01ms;
|
--amare-duration-slow: 0.01ms;
|
||||||
|
--amare-duration-focal: 0.01ms;
|
||||||
}
|
}
|
||||||
|
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -1,40 +1,65 @@
|
|||||||
|
import './motion.js';
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
const menuButton = document.querySelector('[data-menu-button]');
|
const menuButton = document.querySelector('[data-menu-button]');
|
||||||
const navigation = document.querySelector('[data-main-nav]');
|
const navigation = document.querySelector('[data-main-nav]');
|
||||||
|
|
||||||
if (!menuButton || !navigation) {
|
if (menuButton && navigation) {
|
||||||
return;
|
const isDesktop = () => window.matchMedia('(min-width: 768px)').matches;
|
||||||
}
|
|
||||||
|
|
||||||
const setOpen = (isOpen) => {
|
const setOpen = (isOpen, { returnFocus = false } = {}) => {
|
||||||
navigation.classList.toggle('is-open', isOpen);
|
navigation.classList.toggle('is-open', isOpen);
|
||||||
navigation.classList.toggle('hidden', !isOpen && !window.matchMedia('(min-width: 768px)').matches);
|
navigation.classList.toggle('hidden', !isOpen && !isDesktop());
|
||||||
navigation.classList.toggle('flex', isOpen || window.matchMedia('(min-width: 768px)').matches);
|
navigation.classList.toggle('flex', isOpen || isDesktop());
|
||||||
document.body.classList.toggle('menu-open', isOpen);
|
document.body.classList.toggle('menu-open', isOpen);
|
||||||
menuButton.setAttribute('aria-expanded', String(isOpen));
|
menuButton.setAttribute('aria-expanded', String(isOpen));
|
||||||
menuButton.setAttribute('aria-label', isOpen ? 'Fechar menu' : 'Abrir menu');
|
menuButton.setAttribute('aria-label', isOpen ? 'Fechar menu' : 'Abrir menu');
|
||||||
|
|
||||||
|
if (!isOpen && returnFocus) {
|
||||||
|
menuButton.focus();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
menuButton.addEventListener('click', () => {
|
menuButton.addEventListener('click', () => {
|
||||||
const isOpen = menuButton.getAttribute('aria-expanded') !== 'true';
|
const isOpen = menuButton.getAttribute('aria-expanded') !== 'true';
|
||||||
setOpen(isOpen);
|
setOpen(isOpen);
|
||||||
|
|
||||||
|
if (isOpen) {
|
||||||
|
const firstLink = navigation.querySelector('a');
|
||||||
|
if (firstLink) {
|
||||||
|
firstLink.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
navigation.querySelectorAll('a').forEach((link) => {
|
navigation.querySelectorAll('a').forEach((link) => {
|
||||||
link.addEventListener('click', () => setOpen(false));
|
link.addEventListener('click', () => setOpen(false));
|
||||||
});
|
});
|
||||||
|
|
||||||
window.matchMedia('(min-width: 768px)').addEventListener('change', (event) => {
|
navigation.addEventListener('keydown', (event) => {
|
||||||
if (event.matches) {
|
if (event.key === 'Escape') {
|
||||||
setOpen(false);
|
setOpen(false, { returnFocus: true });
|
||||||
navigation.classList.remove('hidden');
|
|
||||||
navigation.classList.add('flex');
|
|
||||||
} else {
|
|
||||||
navigation.classList.remove('is-open', 'flex');
|
|
||||||
navigation.classList.add('hidden');
|
|
||||||
document.body.classList.remove('menu-open');
|
|
||||||
menuButton.setAttribute('aria-expanded', 'false');
|
|
||||||
menuButton.setAttribute('aria-label', 'Abrir menu');
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
window.matchMedia('(min-width: 768px)').addEventListener('change', (event) => {
|
||||||
|
setOpen(false);
|
||||||
|
|
||||||
|
if (event.matches) {
|
||||||
|
navigation.classList.remove('hidden');
|
||||||
|
navigation.classList.add('flex');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('form[data-contact-form]').forEach((form) => {
|
||||||
|
form.addEventListener('submit', () => {
|
||||||
|
const button = form.querySelector('[data-submit-button]');
|
||||||
|
|
||||||
|
if (button) {
|
||||||
|
button.disabled = true;
|
||||||
|
button.setAttribute('aria-busy', 'true');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
166
resources/js/motion.js
Normal file
166
resources/js/motion.js
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
const MOTION_QUERY = '(prefers-reduced-motion: reduce)';
|
||||||
|
|
||||||
|
function prefersReducedMotion() {
|
||||||
|
return window.matchMedia(MOTION_QUERY).matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
function enableEnhancement() {
|
||||||
|
if (prefersReducedMotion()) {
|
||||||
|
document.documentElement.removeAttribute('data-motion');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.documentElement.dataset.motion = 'enhance';
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function activateHero(enhance) {
|
||||||
|
const hero = document.querySelector('[data-motion="dossie-hero"]');
|
||||||
|
if (!hero) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activate = () => hero.classList.add('is-active');
|
||||||
|
|
||||||
|
if (!enhance) {
|
||||||
|
activate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
requestAnimationFrame(activate);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function activatePageOpen(enhance) {
|
||||||
|
document.querySelectorAll('[data-motion="page-open"]').forEach((node) => {
|
||||||
|
if (!enhance) {
|
||||||
|
node.classList.add('is-active');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
requestAnimationFrame(() => node.classList.add('is-active'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function observeReveals(enhance) {
|
||||||
|
const nodes = Array.from(document.querySelectorAll('[data-reveal]'));
|
||||||
|
if (nodes.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!enhance || typeof IntersectionObserver !== 'function') {
|
||||||
|
nodes.forEach((node) => node.classList.add('is-revealed'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
if (!entry.isIntersecting) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.target.classList.add('is-revealed');
|
||||||
|
observer.unobserve(entry.target);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rootMargin: '0px 0px -12% 0px',
|
||||||
|
threshold: 0.2,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
nodes.forEach((node) => observer.observe(node));
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupChapterIndex() {
|
||||||
|
const index = document.querySelector('[data-chapter-index]');
|
||||||
|
const progress = document.querySelector('[data-chapter-progress] span');
|
||||||
|
const chapters = Array.from(document.querySelectorAll('[data-chapter]'));
|
||||||
|
|
||||||
|
if (!index || chapters.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const links = Array.from(index.querySelectorAll('a[href^="#"]'));
|
||||||
|
|
||||||
|
const setActive = (id) => {
|
||||||
|
links.forEach((link) => {
|
||||||
|
const isCurrent = link.getAttribute('href') === `#${id}`;
|
||||||
|
if (isCurrent) {
|
||||||
|
link.setAttribute('aria-current', 'true');
|
||||||
|
} else {
|
||||||
|
link.removeAttribute('aria-current');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateProgress = (ratio) => {
|
||||||
|
if (!progress) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clamped = Math.min(1, Math.max(0, ratio));
|
||||||
|
progress.style.setProperty('--chapter-progress', `${(clamped * 100).toFixed(2)}%`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const sync = () => {
|
||||||
|
const marker = window.scrollY + Math.min(window.innerHeight * 0.35, 280);
|
||||||
|
let current = chapters[0];
|
||||||
|
|
||||||
|
chapters.forEach((chapter) => {
|
||||||
|
if (chapter.offsetTop <= marker) {
|
||||||
|
current = chapter;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const heading = current.querySelector('[id$="-heading"]') || document.getElementById(`${current.dataset.chapter}-heading`);
|
||||||
|
const headingId = heading?.id
|
||||||
|
|| current.getAttribute('aria-labelledby')
|
||||||
|
|| `${current.dataset.chapter}-heading`;
|
||||||
|
|
||||||
|
setActive(headingId);
|
||||||
|
|
||||||
|
const doc = document.documentElement;
|
||||||
|
const max = Math.max(1, doc.scrollHeight - window.innerHeight);
|
||||||
|
updateProgress(window.scrollY / max);
|
||||||
|
};
|
||||||
|
|
||||||
|
setActive(chapters[0].getAttribute('aria-labelledby') || 'hero-heading');
|
||||||
|
sync();
|
||||||
|
|
||||||
|
window.addEventListener('scroll', sync, { passive: true });
|
||||||
|
window.addEventListener('resize', sync);
|
||||||
|
}
|
||||||
|
|
||||||
|
function bootMotion() {
|
||||||
|
const enhance = enableEnhancement();
|
||||||
|
activateHero(enhance);
|
||||||
|
activatePageOpen(enhance);
|
||||||
|
observeReveals(enhance);
|
||||||
|
setupChapterIndex();
|
||||||
|
|
||||||
|
window.matchMedia(MOTION_QUERY).addEventListener('change', (event) => {
|
||||||
|
if (event.matches) {
|
||||||
|
document.documentElement.removeAttribute('data-motion');
|
||||||
|
document.querySelectorAll('[data-motion="dossie-hero"], [data-motion="page-open"]').forEach((node) => {
|
||||||
|
node.classList.add('is-active');
|
||||||
|
});
|
||||||
|
document.querySelectorAll('[data-reveal]').forEach((node) => {
|
||||||
|
node.classList.add('is-revealed');
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.documentElement.dataset.motion = 'enhance';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', bootMotion, { once: true });
|
||||||
|
} else {
|
||||||
|
bootMotion();
|
||||||
|
}
|
||||||
@@ -19,7 +19,6 @@
|
|||||||
$variant = $variant === 'on-dark' ? 'on-dark' : 'on-light';
|
$variant = $variant === 'on-dark' ? 'on-dark' : 'on-light';
|
||||||
$kind = $mark ? 'mark' : 'lockup';
|
$kind = $mark ? 'mark' : 'lockup';
|
||||||
$staticSrc = asset("brand/{$kind}-{$variant}.webp");
|
$staticSrc = asset("brand/{$kind}-{$variant}.webp");
|
||||||
$staticFallback = asset("brand/{$kind}-{$variant}.png");
|
|
||||||
|
|
||||||
$src = filled($uploadedPath)
|
$src = filled($uploadedPath)
|
||||||
? \Illuminate\Support\Facades\Storage::disk('public')->url($uploadedPath)
|
? \Illuminate\Support\Facades\Storage::disk('public')->url($uploadedPath)
|
||||||
@@ -34,8 +33,4 @@
|
|||||||
'decoding' => 'async',
|
'decoding' => 'async',
|
||||||
'loading' => 'eager',
|
'loading' => 'eager',
|
||||||
]) }}
|
]) }}
|
||||||
@if (! filled($uploadedPath))
|
|
||||||
data-brand-fallback="{{ $staticFallback }}"
|
|
||||||
@endif
|
|
||||||
data-brand-variant="{{ $variant }}"
|
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -6,7 +6,11 @@
|
|||||||
|
|
||||||
$manifestFile = is_file($manifestPath) ? $manifestPath : $hotManifestPath;
|
$manifestFile = is_file($manifestPath) ? $manifestPath : $hotManifestPath;
|
||||||
$manifest = is_string($manifestFile) && is_file($manifestFile)
|
$manifest = is_string($manifestFile) && is_file($manifestFile)
|
||||||
? json_decode((string) file_get_contents($manifestFile), true)
|
? \Illuminate\Support\Facades\Cache::remember(
|
||||||
|
'fonts-manifest:'.md5($manifestFile.':'.(string) filemtime($manifestFile)),
|
||||||
|
3600,
|
||||||
|
static fn () => json_decode((string) file_get_contents($manifestFile), true) ?: null,
|
||||||
|
)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
$cssFile = is_array($manifest) ? ($manifest['style']['file'] ?? null) : null;
|
$cssFile = is_array($manifest) ? ($manifest['style']['file'] ?? null) : null;
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
@props([
|
|
||||||
'cases',
|
|
||||||
])
|
|
||||||
|
|
||||||
@if ($cases->isNotEmpty())
|
|
||||||
<section aria-labelledby="cases-heading" class="border-b border-amare-border py-16">
|
|
||||||
<div class="container-amare space-y-8">
|
|
||||||
<div class="max-w-2xl space-y-3">
|
|
||||||
<h2 id="cases-heading" class="text-3xl font-semibold text-amare-text">Casos selecionados</h2>
|
|
||||||
<p class="text-amare-text-muted">Histórias recentes de celebrações conduzidas pela Amare.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid gap-8">
|
|
||||||
@foreach ($cases as $case)
|
|
||||||
<article class="grid gap-4 border-t border-amare-border pt-6 md:grid-cols-[200px_minmax(0,1fr)]">
|
|
||||||
@if (filled($case->cover_image_path))
|
|
||||||
<x-media.image
|
|
||||||
:path="$case->cover_image_path"
|
|
||||||
:alt="$case->cover_image_alt ?: $case->title"
|
|
||||||
sizes="200px"
|
|
||||||
class="aspect-square w-full object-cover"
|
|
||||||
/>
|
|
||||||
@endif
|
|
||||||
<div class="space-y-2">
|
|
||||||
<h3 class="text-2xl font-semibold text-amare-text">{{ $case->title }}</h3>
|
|
||||||
<p class="text-sm text-amare-text-muted">{{ $case->event_type }}@if($case->city) · {{ $case->city }}@endif</p>
|
|
||||||
<p class="text-amare-text-muted">{{ $case->summary }}</p>
|
|
||||||
<a href="{{ url('/portfolio/'.$case->slug) }}" class="inline-flex text-sm font-semibold text-amare-accent hover:text-amare-accent-hover">
|
|
||||||
Ver caso
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
@endforeach
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
@endif
|
|
||||||
15
resources/views/components/home/chapter-index.blade.php
Normal file
15
resources/views/components/home/chapter-index.blade.php
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
@props([
|
||||||
|
'chapters' => [],
|
||||||
|
])
|
||||||
|
|
||||||
|
@if (count($chapters) > 0)
|
||||||
|
<nav data-chapter-index aria-label="Índice de capítulos">
|
||||||
|
@foreach ($chapters as $chapter)
|
||||||
|
<a href="#{{ $chapter['id'] }}">{{ $chapter['label'] }}</a>
|
||||||
|
@endforeach
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div data-chapter-progress aria-hidden="true">
|
||||||
|
<span></span>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
@@ -2,17 +2,17 @@
|
|||||||
'settings',
|
'settings',
|
||||||
])
|
])
|
||||||
|
|
||||||
<section aria-labelledby="final-cta-heading" class="border-t border-amare-border bg-amare-bg-deep py-20">
|
<section aria-labelledby="final-cta-heading" class="border-t border-amare-border bg-amare-bg-deep py-20" data-chapter="final-cta">
|
||||||
<div class="container-amare space-y-6 text-center">
|
<div class="container-amare space-y-6 text-center" data-reveal>
|
||||||
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Próximo passo</p>
|
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Próximo passo</p>
|
||||||
<h2 id="final-cta-heading" class="text-3xl font-medium text-amare-text md:text-4xl">Todo grande encontro começa com uma boa conversa.</h2>
|
<h2 id="final-cta-heading" class="text-3xl font-medium text-amare-text md:text-4xl">Do casamento ao evento corporativo, tudo começa com uma boa conversa.</h2>
|
||||||
<p class="mx-auto max-w-2xl text-amare-muted">
|
<p class="mx-auto max-w-2xl text-amare-muted">
|
||||||
Compartilhe as primeiras informações do seu evento. A Amare retorna para entender o contexto e orientar os próximos passos.
|
Compartilhe as primeiras informações do seu evento. A Amare retorna para entender o contexto e orientar os próximos passos.
|
||||||
</p>
|
</p>
|
||||||
<div>
|
<div>
|
||||||
<a
|
<a
|
||||||
href="{{ route('contact') }}"
|
href="{{ route('contact') }}"
|
||||||
class="inline-flex items-center bg-amare-accent px-5 py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-accent-text transition-colors duration-(--amare-duration-normal) ease-(--amare-ease-standard) hover:bg-amare-accent-deep"
|
class="inline-flex min-h-11 items-center bg-amare-accent px-5 py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-accent-text transition-colors duration-(--amare-duration-normal) ease-(--amare-ease-standard) hover:bg-amare-accent-deep"
|
||||||
>
|
>
|
||||||
{{ $settings->hero_cta_label }}
|
{{ $settings->hero_cta_label }}
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -2,14 +2,22 @@
|
|||||||
'settings',
|
'settings',
|
||||||
])
|
])
|
||||||
|
|
||||||
<section aria-labelledby="hero-heading" class="border-b border-amare-border bg-amare-bg">
|
<section
|
||||||
|
aria-labelledby="hero-heading"
|
||||||
|
class="border-b border-amare-border bg-amare-bg"
|
||||||
|
data-chapter="hero"
|
||||||
|
data-motion="dossie-hero"
|
||||||
|
>
|
||||||
<div class="container-amare grid gap-12 py-20 md:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)] md:items-center md:py-28">
|
<div class="container-amare grid gap-12 py-20 md:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)] md:items-center md:py-28">
|
||||||
<div class="space-y-8">
|
<div class="space-y-8">
|
||||||
|
<div data-motion-beat="seal" class="flex items-center gap-4">
|
||||||
|
<x-brand.logo mark variant="on-light" class="h-8 w-auto" alt="" />
|
||||||
@if (filled($settings->hero_eyebrow))
|
@if (filled($settings->hero_eyebrow))
|
||||||
<p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent">{{ $settings->hero_eyebrow }}</p>
|
<p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent">{{ $settings->hero_eyebrow }}</p>
|
||||||
@endif
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
<h1 id="hero-heading" class="max-w-3xl text-4xl font-medium leading-none text-amare-text md:text-5xl">
|
<h1 id="hero-heading" data-motion-beat="title" class="max-w-3xl text-4xl font-medium leading-none text-amare-text md:text-5xl">
|
||||||
{{ $settings->hero_title }}
|
{{ $settings->hero_title }}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
@@ -17,11 +25,11 @@
|
|||||||
<p class="max-w-2xl text-lg text-amare-text-muted">{{ $settings->hero_subtitle }}</p>
|
<p class="max-w-2xl text-lg text-amare-text-muted">{{ $settings->hero_subtitle }}</p>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center gap-4">
|
<div data-motion-beat="cta" class="flex flex-wrap items-center gap-4">
|
||||||
<a
|
<a
|
||||||
href="{{ route('contact') }}"
|
href="{{ route('contact') }}"
|
||||||
data-testid="home-primary-cta"
|
data-testid="home-primary-cta"
|
||||||
class="inline-flex items-center bg-amare-accent px-5 py-3 text-sm font-semibold text-amare-accent-text transition-colors duration-(--amare-duration-normal) ease-(--amare-ease-standard) hover:bg-amare-accent-hover"
|
class="inline-flex min-h-11 items-center bg-amare-accent px-5 py-3 text-sm font-semibold text-amare-accent-text transition-colors duration-(--amare-duration-normal) ease-(--amare-ease-standard) hover:bg-amare-accent-hover"
|
||||||
>
|
>
|
||||||
{{ $settings->hero_cta_label }}
|
{{ $settings->hero_cta_label }}
|
||||||
</a>
|
</a>
|
||||||
@@ -29,9 +37,9 @@
|
|||||||
@if (filled($settings->hero_secondary_cta_label))
|
@if (filled($settings->hero_secondary_cta_label))
|
||||||
<a
|
<a
|
||||||
href="{{ route('portfolio.index') }}"
|
href="{{ route('portfolio.index') }}"
|
||||||
class="inline-flex items-center border-b border-amare-accent pb-1 text-sm font-semibold text-amare-accent transition-colors hover:text-amare-accent-deep"
|
class="inline-flex min-h-11 items-center text-sm font-semibold text-amare-accent transition-colors hover:text-amare-accent-deep"
|
||||||
>
|
>
|
||||||
{{ $settings->hero_secondary_cta_label }}
|
<span class="border-b border-amare-accent pb-1">{{ $settings->hero_secondary_cta_label }}</span>
|
||||||
</a>
|
</a>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
@@ -42,11 +50,12 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (filled($settings->default_og_image_path))
|
@if (filled($settings->default_og_image_path))
|
||||||
<div class="min-h-72 bg-amare-bg-deep">
|
<div data-motion-beat="media" class="min-h-72 overflow-hidden bg-amare-bg-deep">
|
||||||
<x-media.image
|
<x-media.image
|
||||||
:path="$settings->default_og_image_path"
|
:path="$settings->default_og_image_path"
|
||||||
:alt="$settings->default_og_image_alt ?: $settings->brand_name"
|
:alt="$settings->default_og_image_alt ?: $settings->brand_name"
|
||||||
loading="eager"
|
loading="eager"
|
||||||
|
fetchpriority="high"
|
||||||
sizes="(max-width: 768px) 100vw, 40vw"
|
sizes="(max-width: 768px) 100vw, 40vw"
|
||||||
class="img-editorial h-full w-full object-cover"
|
class="img-editorial h-full w-full object-cover"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -8,8 +8,8 @@
|
|||||||
$body = $settings->manifesto_body ?: 'A Amare combina sensibilidade e precisão para criar encontros coerentes com cada cliente, marca e ocasião — sem fórmulas prontas, excessos ou ruído.';
|
$body = $settings->manifesto_body ?: 'A Amare combina sensibilidade e precisão para criar encontros coerentes com cada cliente, marca e ocasião — sem fórmulas prontas, excessos ou ruído.';
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<section aria-labelledby="manifesto-heading" class="border-b border-amare-border bg-amare-bg-deep py-20">
|
<section aria-labelledby="manifesto-heading" class="border-b border-amare-border bg-amare-bg-deep py-20" data-chapter="manifesto">
|
||||||
<div class="container-amare grid gap-8 md:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]">
|
<div class="container-amare grid gap-8 md:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]" data-reveal>
|
||||||
<p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent">Manifesto</p>
|
<p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent">Manifesto</p>
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<h2 id="manifesto-heading" class="max-w-3xl text-3xl font-medium leading-tight text-amare-text md:text-4xl">{{ $title }}</h2>
|
<h2 id="manifesto-heading" class="max-w-3xl text-3xl font-medium leading-tight text-amare-text md:text-4xl">{{ $title }}</h2>
|
||||||
|
|||||||
@@ -7,9 +7,9 @@
|
|||||||
$intro = $settings->method_intro ?: 'Clareza em cada etapa. Tranquilidade durante todo o processo.';
|
$intro = $settings->method_intro ?: 'Clareza em cada etapa. Tranquilidade durante todo o processo.';
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<section aria-labelledby="method-heading" class="border-b border-amare-border bg-amare-bg-archive py-20">
|
<section aria-labelledby="method-heading" class="border-b border-amare-border bg-amare-bg-archive py-20" data-chapter="method">
|
||||||
<div class="container-amare grid gap-10 md:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)] md:items-start">
|
<div class="container-amare grid gap-10 md:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)] md:items-start">
|
||||||
<div class="space-y-3">
|
<div class="space-y-3" data-reveal>
|
||||||
<p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent">Método</p>
|
<p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent">Método</p>
|
||||||
<h2 id="method-heading" class="text-3xl font-medium text-amare-text">Cuidado orientado por processo.</h2>
|
<h2 id="method-heading" class="text-3xl font-medium text-amare-text">Cuidado orientado por processo.</h2>
|
||||||
<p class="text-amare-text-muted">{{ $intro }}</p>
|
<p class="text-amare-text-muted">{{ $intro }}</p>
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
|
|
||||||
<ol class="grid gap-5 border-t border-amare-border">
|
<ol class="grid gap-5 border-t border-amare-border">
|
||||||
@foreach ($steps as $index => $step)
|
@foreach ($steps as $index => $step)
|
||||||
<li class="grid gap-2 border-b border-amare-border py-5 md:grid-cols-[4rem_minmax(0,1fr)]">
|
<li class="grid gap-2 border-b border-amare-border py-5 md:grid-cols-[4rem_minmax(0,1fr)]" data-reveal>
|
||||||
<span class="text-sm text-amare-accent">{{ str_pad((string) ($index + 1), 2, '0', STR_PAD_LEFT) }}</span>
|
<span class="text-sm text-amare-accent">{{ str_pad((string) ($index + 1), 2, '0', STR_PAD_LEFT) }}</span>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<h3 class="text-2xl font-medium text-amare-text">{{ $step['title'] ?? '' }}</h3>
|
<h3 class="text-2xl font-medium text-amare-text">{{ $step['title'] ?? '' }}</h3>
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
])
|
])
|
||||||
|
|
||||||
@if ($cases->isNotEmpty())
|
@if ($cases->isNotEmpty())
|
||||||
<section aria-labelledby="portfolio-heading" class="border-b border-amare-accent-deep bg-amare-accent-deep py-20 text-amare-accent-text">
|
<section aria-labelledby="portfolio-heading" class="border-b border-amare-accent-deep bg-amare-accent-deep py-20 text-amare-accent-text" data-chapter="portfolio">
|
||||||
<div class="container-amare space-y-10">
|
<div class="container-amare space-y-10">
|
||||||
<div class="grid gap-4 md:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]">
|
<div class="grid gap-4 md:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]" data-reveal>
|
||||||
<p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent-text/80">Portfólio</p>
|
<p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent-text/80">Portfólio</p>
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<h2 id="portfolio-heading" class="text-3xl font-medium md:text-4xl">Celebrações que ganham forma com intenção.</h2>
|
<h2 id="portfolio-heading" class="text-3xl font-medium md:text-4xl">Celebrações que ganham forma com intenção.</h2>
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
|
|
||||||
<div class="grid gap-10 md:grid-cols-2">
|
<div class="grid gap-10 md:grid-cols-2">
|
||||||
@foreach ($cases as $case)
|
@foreach ($cases as $case)
|
||||||
<article class="space-y-4 border-t border-amare-accent-text/30 pt-4">
|
<article class="space-y-4 border-t border-amare-accent-text/30 pt-4" data-reveal>
|
||||||
@if (filled($case->cover_image_path))
|
@if (filled($case->cover_image_path))
|
||||||
<x-media.image
|
<x-media.image
|
||||||
:path="$case->cover_image_path"
|
:path="$case->cover_image_path"
|
||||||
@@ -27,8 +27,8 @@
|
|||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<h3 class="text-2xl font-medium">{{ $case->title }}</h3>
|
<h3 class="text-2xl font-medium">{{ $case->title }}</h3>
|
||||||
<p class="text-amare-accent-text/80">{{ $case->summary }}</p>
|
<p class="text-amare-accent-text/80">{{ $case->summary }}</p>
|
||||||
<a href="{{ route('portfolio.show', $case->slug) }}" class="inline-flex border-b border-amare-accent-text pb-1 text-sm font-semibold transition-colors hover:text-amare-accent-text">
|
<a href="{{ route('portfolio.show', $case->slug) }}" class="inline-flex min-h-11 items-center text-sm font-semibold transition-colors hover:text-amare-accent-text">
|
||||||
Ver caso
|
<span class="border-b border-amare-accent-text pb-1">Ver caso</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
@@ -39,8 +39,8 @@
|
|||||||
<p class="max-w-2xl text-sm text-amare-accent-text/75">
|
<p class="max-w-2xl text-sm text-amare-accent-text/75">
|
||||||
Imagens demonstrativas enquanto o acervo autorizado da Amare está em organização.
|
Imagens demonstrativas enquanto o acervo autorizado da Amare está em organização.
|
||||||
</p>
|
</p>
|
||||||
<a href="{{ route('portfolio.index') }}" class="border-b border-amare-accent-text pb-1 text-sm font-semibold transition-colors hover:text-amare-accent-text">
|
<a href="{{ route('portfolio.index') }}" class="inline-flex min-h-11 items-center text-sm font-semibold transition-colors hover:text-amare-accent-text">
|
||||||
Conhecer o portfólio
|
<span class="border-b border-amare-accent-text pb-1">Conhecer o portfólio</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,13 +7,13 @@
|
|||||||
$principles = filled($settings->principles) ? $settings->principles : \App\Models\SiteSetting::defaultPrinciples();
|
$principles = filled($settings->principles) ? $settings->principles : \App\Models\SiteSetting::defaultPrinciples();
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<section aria-labelledby="positioning-heading" class="border-b border-amare-border py-20">
|
<section aria-labelledby="positioning-heading" class="border-b border-amare-border py-20" data-chapter="positioning">
|
||||||
<div class="container-amare grid gap-10 md:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]">
|
<div class="container-amare grid gap-10 md:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]">
|
||||||
<div class="space-y-3">
|
<div class="space-y-3" data-reveal>
|
||||||
<p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent">A Amare</p>
|
<p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent">A Amare</p>
|
||||||
<h2 id="positioning-heading" class="text-3xl font-medium text-amare-text">Presença que organiza o essencial.</h2>
|
<h2 id="positioning-heading" class="text-3xl font-medium text-amare-text">Presença que organiza o essencial.</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-8">
|
<div class="space-y-8" data-reveal>
|
||||||
<p class="max-w-2xl text-xl leading-relaxed text-amare-text">{{ $summary }}</p>
|
<p class="max-w-2xl text-xl leading-relaxed text-amare-text">{{ $summary }}</p>
|
||||||
<ul class="grid gap-3 border-t border-amare-border">
|
<ul class="grid gap-3 border-t border-amare-border">
|
||||||
@foreach ($principles as $principle)
|
@foreach ($principles as $principle)
|
||||||
@@ -21,8 +21,8 @@
|
|||||||
@endforeach
|
@endforeach
|
||||||
</ul>
|
</ul>
|
||||||
<p>
|
<p>
|
||||||
<a href="{{ route('about') }}" class="border-b border-amare-accent pb-1 text-sm font-semibold text-amare-accent transition-colors hover:text-amare-accent-deep">
|
<a href="{{ route('about') }}" class="inline-flex min-h-11 items-center text-sm font-semibold text-amare-accent transition-colors hover:text-amare-accent-deep">
|
||||||
Conhecer a Amare
|
<span class="border-b border-amare-accent pb-1">Conhecer a Amare</span>
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
@props([
|
|
||||||
'cases',
|
|
||||||
])
|
|
||||||
|
|
||||||
@if ($cases->isNotEmpty())
|
|
||||||
<section aria-labelledby="proof-heading" class="border-b border-amare-border bg-amare-bg-muted py-16">
|
|
||||||
<div class="container-amare space-y-8">
|
|
||||||
<div class="max-w-2xl space-y-3">
|
|
||||||
<h2 id="proof-heading" class="text-3xl font-semibold text-amare-text">Prova visual</h2>
|
|
||||||
<p class="text-amare-text-muted">Eventos em destaque que mostram o cuidado com cada celebração.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
|
||||||
@foreach ($cases as $case)
|
|
||||||
<article class="space-y-3">
|
|
||||||
@if (filled($case->cover_image_path))
|
|
||||||
<x-media.image
|
|
||||||
:path="$case->cover_image_path"
|
|
||||||
:alt="$case->cover_image_alt ?: $case->title"
|
|
||||||
sizes="(max-width: 768px) 100vw, 33vw"
|
|
||||||
class="aspect-[4/3] w-full object-cover"
|
|
||||||
/>
|
|
||||||
@endif
|
|
||||||
<h3 class="text-xl font-semibold text-amare-text">{{ $case->title }}</h3>
|
|
||||||
<p class="text-sm text-amare-text-muted">{{ $case->summary }}</p>
|
|
||||||
</article>
|
|
||||||
@endforeach
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
@endif
|
|
||||||
@@ -3,9 +3,9 @@
|
|||||||
])
|
])
|
||||||
|
|
||||||
@if ($services->isNotEmpty())
|
@if ($services->isNotEmpty())
|
||||||
<section aria-labelledby="services-heading" class="border-b border-amare-border py-20">
|
<section aria-labelledby="services-heading" class="border-b border-amare-border py-20" data-chapter="services">
|
||||||
<div class="container-amare space-y-10">
|
<div class="container-amare space-y-10">
|
||||||
<div class="max-w-2xl space-y-3">
|
<div class="max-w-2xl space-y-3" data-reveal>
|
||||||
<p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent">Atuação</p>
|
<p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent">Atuação</p>
|
||||||
<h2 id="services-heading" class="text-3xl font-medium text-amare-text">Serviços</h2>
|
<h2 id="services-heading" class="text-3xl font-medium text-amare-text">Serviços</h2>
|
||||||
<p class="text-amare-text-muted">Assessoria sob medida para decisões importantes e celebrações bem conduzidas.</p>
|
<p class="text-amare-text-muted">Assessoria sob medida para decisões importantes e celebrações bem conduzidas.</p>
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
|
|
||||||
<ol class="border-t border-amare-border">
|
<ol class="border-t border-amare-border">
|
||||||
@foreach ($services as $index => $service)
|
@foreach ($services as $index => $service)
|
||||||
<li class="grid gap-3 border-b border-amare-border py-5 md:grid-cols-[4rem_minmax(0,0.8fr)_minmax(0,1.2fr)] md:gap-6">
|
<li class="grid gap-3 border-b border-amare-border py-5 md:grid-cols-[4rem_minmax(0,0.8fr)_minmax(0,1.2fr)] md:gap-6" data-reveal>
|
||||||
<span class="text-sm text-amare-accent">{{ str_pad((string) ($index + 1), 2, '0', STR_PAD_LEFT) }}</span>
|
<span class="text-sm text-amare-accent">{{ str_pad((string) ($index + 1), 2, '0', STR_PAD_LEFT) }}</span>
|
||||||
<h3 class="text-2xl font-medium text-amare-text">{{ $service->title }}</h3>
|
<h3 class="text-2xl font-medium text-amare-text">{{ $service->title }}</h3>
|
||||||
<p class="text-amare-text-muted">{{ $service->summary }}</p>
|
<p class="text-amare-text-muted">{{ $service->summary }}</p>
|
||||||
@@ -22,8 +22,8 @@
|
|||||||
</ol>
|
</ol>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
<a href="{{ route('services.index') }}" class="border-b border-amare-accent pb-1 text-sm font-semibold text-amare-accent transition-colors hover:text-amare-accent-deep">
|
<a href="{{ route('services.index') }}" class="inline-flex min-h-11 items-center text-sm font-semibold text-amare-accent transition-colors hover:text-amare-accent-deep">
|
||||||
Ver todos os serviços
|
<span class="border-b border-amare-accent pb-1">Ver todos os serviços</span>
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
])
|
])
|
||||||
|
|
||||||
@if ($testimonials->isNotEmpty())
|
@if ($testimonials->isNotEmpty())
|
||||||
<section aria-labelledby="testimonials-heading" class="border-b border-amare-border bg-amare-bg-muted py-16">
|
<section aria-labelledby="testimonials-heading" class="border-b border-amare-border bg-amare-bg-muted py-16" data-chapter="testimonials">
|
||||||
<div class="container-amare space-y-8">
|
<div class="container-amare space-y-8">
|
||||||
<div class="max-w-2xl space-y-3">
|
<div class="max-w-2xl space-y-3" data-reveal>
|
||||||
<h2 id="testimonials-heading" class="text-3xl font-semibold text-amare-text">Depoimentos</h2>
|
<h2 id="testimonials-heading" class="text-3xl font-semibold text-amare-text">Depoimentos</h2>
|
||||||
<p class="text-amare-text-muted">Quem celebrou com a Amare conta como foi a experiência.</p>
|
<p class="text-amare-text-muted">Quem celebrou com a Amare conta como foi a experiência.</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
$paragraphs = preg_split('/\n\s*\n/', trim((string) $testimonial->quote)) ?: [];
|
$paragraphs = preg_split('/\n\s*\n/', trim((string) $testimonial->quote)) ?: [];
|
||||||
$paragraphs = array_values(array_filter(array_map('trim', $paragraphs), fn (string $p): bool => $p !== ''));
|
$paragraphs = array_values(array_filter(array_map('trim', $paragraphs), fn (string $p): bool => $p !== ''));
|
||||||
@endphp
|
@endphp
|
||||||
<blockquote class="space-y-4 border-t border-amare-border pt-4">
|
<blockquote class="space-y-4 border-t border-amare-border pt-4" data-reveal>
|
||||||
<div class="space-y-3 text-lg text-amare-text">
|
<div class="space-y-3 text-lg text-amare-text">
|
||||||
@foreach ($paragraphs as $index => $paragraph)
|
@foreach ($paragraphs as $index => $paragraph)
|
||||||
<p>@if ($index === 0)“@endif{{ $paragraph }}@if ($index === count($paragraphs) - 1)”@endif</p>
|
<p>@if ($index === 0)“@endif{{ $paragraph }}@if ($index === count($paragraphs) - 1)”@endif</p>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
'alt',
|
'alt',
|
||||||
'sizes' => '(max-width: 768px) 100vw, 960px',
|
'sizes' => '(max-width: 768px) 100vw, 960px',
|
||||||
'loading' => 'lazy',
|
'loading' => 'lazy',
|
||||||
|
'fetchpriority' => null,
|
||||||
'width' => null,
|
'width' => null,
|
||||||
'height' => null,
|
'height' => null,
|
||||||
'disk' => null,
|
'disk' => null,
|
||||||
@@ -42,6 +43,7 @@
|
|||||||
@if ($resolvedWidth) width="{{ $resolvedWidth }}" @endif
|
@if ($resolvedWidth) width="{{ $resolvedWidth }}" @endif
|
||||||
@if ($resolvedHeight) height="{{ $resolvedHeight }}" @endif
|
@if ($resolvedHeight) height="{{ $resolvedHeight }}" @endif
|
||||||
loading="{{ $loadingValue }}"
|
loading="{{ $loadingValue }}"
|
||||||
|
@if ($fetchpriority) fetchpriority="{{ $fetchpriority }}" @endif
|
||||||
@if ($class) class="{{ $class }}" @endif
|
@if ($class) class="{{ $class }}" @endif
|
||||||
{{ $attributes->except(['path', 'alt', 'sizes', 'loading', 'width', 'height', 'disk', 'class']) }}
|
{{ $attributes->except(['path', 'alt', 'sizes', 'loading', 'width', 'height', 'disk', 'class']) }}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -3,12 +3,18 @@
|
|||||||
])
|
])
|
||||||
|
|
||||||
<meta name="description" content="{{ $pageMeta->description }}">
|
<meta name="description" content="{{ $pageMeta->description }}">
|
||||||
<link rel="canonical" href="{{ $pageMeta->canonical }}">
|
@if (filled($pageMeta->canonical))
|
||||||
|
<link rel="canonical" href="{{ $pageMeta->canonical }}">
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<meta property="og:locale" content="pt_BR">
|
||||||
|
<meta property="og:site_name" content="{{ $pageMeta->siteName }}">
|
||||||
<meta property="og:title" content="{{ $pageMeta->title }}">
|
<meta property="og:title" content="{{ $pageMeta->title }}">
|
||||||
<meta property="og:description" content="{{ $pageMeta->description }}">
|
<meta property="og:description" content="{{ $pageMeta->description }}">
|
||||||
<meta property="og:type" content="{{ $pageMeta->ogType }}">
|
<meta property="og:type" content="{{ $pageMeta->ogType }}">
|
||||||
<meta property="og:url" content="{{ $pageMeta->canonical }}">
|
@if (filled($pageMeta->canonical))
|
||||||
|
<meta property="og:url" content="{{ $pageMeta->canonical }}">
|
||||||
|
@endif
|
||||||
@if ($pageMeta->ogImageUrl)
|
@if ($pageMeta->ogImageUrl)
|
||||||
<meta property="og:image" content="{{ $pageMeta->ogImageUrl }}">
|
<meta property="og:image" content="{{ $pageMeta->ogImageUrl }}">
|
||||||
@if ($pageMeta->ogImageAlt)
|
@if ($pageMeta->ogImageAlt)
|
||||||
@@ -16,6 +22,19 @@
|
|||||||
@endif
|
@endif
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
@if ($pageMeta->jsonLd)
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
<script type="application/ld+json">{!! json_encode($pageMeta->jsonLd, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) !!}</script>
|
<meta name="twitter:title" content="{{ $pageMeta->title }}">
|
||||||
|
<meta name="twitter:description" content="{{ $pageMeta->description }}">
|
||||||
|
@if ($pageMeta->ogImageUrl)
|
||||||
|
<meta name="twitter:image" content="{{ $pageMeta->ogImageUrl }}">
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if (app()->environment('production'))
|
||||||
|
<meta name="robots" content="{{ $pageMeta->robots }}">
|
||||||
|
@else
|
||||||
|
<meta name="robots" content="noindex, nofollow">
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if ($pageMeta->jsonLd)
|
||||||
|
<script type="application/ld+json">{!! json_encode($pageMeta->jsonLd, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) !!}</script>
|
||||||
@endif
|
@endif
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
Olá, {{ $name }}.
|
||||||
|
|
||||||
|
Recebemos sua mensagem e retornaremos em breve pelo canal informado.
|
||||||
|
|
||||||
|
{{ $brandName }}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="pt-BR">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Recebemos sua mensagem</title>
|
||||||
|
<style>
|
||||||
|
body { margin: 0; padding: 24px; background: #FBF9F4; font-family: Georgia, 'Times New Roman', serif; color: #1B1C19; }
|
||||||
|
.wrap { max-width: 600px; margin: 0 auto; }
|
||||||
|
p { font-size: 15px; line-height: 1.6; }
|
||||||
|
.signature { margin-top: 24px; color: #5D6155; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
<p>Olá, {{ $name }}.</p>
|
||||||
|
<p>
|
||||||
|
Recebemos sua mensagem e retornaremos em breve pelo canal informado.
|
||||||
|
</p>
|
||||||
|
<p class="signature">
|
||||||
|
{{ $brandName }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
7
resources/views/emails/contact-briefing-text.blade.php
Normal file
7
resources/views/emails/contact-briefing-text.blade.php
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
Novo briefing de contato
|
||||||
|
|
||||||
|
@foreach ($fields as $label => $value)
|
||||||
|
{{ $label }}: {{ $value ?: '—' }}
|
||||||
|
@endforeach
|
||||||
|
|
||||||
|
Enviado pelo site {{ config('app.name') }}.
|
||||||
33
resources/views/emails/contact-briefing.blade.php
Normal file
33
resources/views/emails/contact-briefing.blade.php
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="pt-BR">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Novo briefing de contato</title>
|
||||||
|
<style>
|
||||||
|
body { margin: 0; padding: 24px; background: #FBF9F4; font-family: Georgia, 'Times New Roman', serif; color: #1B1C19; }
|
||||||
|
.wrap { max-width: 600px; margin: 0 auto; }
|
||||||
|
h1 { font-size: 20px; font-weight: 600; margin: 0 0 16px; }
|
||||||
|
table { width: 100%; border-collapse: collapse; }
|
||||||
|
th, td { text-align: left; vertical-align: top; padding: 10px 12px; border-bottom: 1px solid #C5C8B8; font-size: 14px; }
|
||||||
|
th { width: 40%; font-weight: 600; color: #556B2F; }
|
||||||
|
.footer { margin-top: 16px; font-size: 12px; color: #5D6155; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
<h1>Novo briefing de contato</h1>
|
||||||
|
<table>
|
||||||
|
@foreach ($fields as $label => $value)
|
||||||
|
<tr>
|
||||||
|
<th scope="row">{{ $label }}</th>
|
||||||
|
<td>{{ $value ?: '—' }}</td>
|
||||||
|
</tr>
|
||||||
|
@endforeach
|
||||||
|
</table>
|
||||||
|
<p class="footer">
|
||||||
|
Enviado pelo site {{ config('app.name') }}.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,3 +1,17 @@
|
|||||||
|
@php
|
||||||
|
try {
|
||||||
|
$errorSettings = \App\Models\SiteSetting::instance();
|
||||||
|
$pageMeta = \App\Application\Data\PageMeta::forErrorPage($errorSettings, 404);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$pageMeta = new \App\Application\Data\PageMeta(
|
||||||
|
title: 'Página não encontrada - Amare Assessoria',
|
||||||
|
description: 'A página que você procura não existe ou foi movida.',
|
||||||
|
canonical: '',
|
||||||
|
robots: 'noindex, nofollow',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
@endphp
|
||||||
|
|
||||||
@extends('layouts.public')
|
@extends('layouts.public')
|
||||||
|
|
||||||
@section('content')
|
@section('content')
|
||||||
@@ -6,7 +20,7 @@
|
|||||||
<h1 class="text-4xl font-medium tracking-tight text-amare-text">Página não encontrada</h1>
|
<h1 class="text-4xl font-medium tracking-tight text-amare-text">Página não encontrada</h1>
|
||||||
<p class="text-amare-muted">O endereço que você tentou abrir não existe ou foi movido.</p>
|
<p class="text-amare-muted">O endereço que você tentou abrir não existe ou foi movido.</p>
|
||||||
<p>
|
<p>
|
||||||
<a href="{{ route('home') }}" class="inline-flex bg-amare-accent px-5 py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-accent-text transition-colors hover:bg-amare-accent-deep">
|
<a href="{{ route('home') }}" class="inline-flex min-h-11 items-center bg-amare-accent px-5 py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-accent-text transition-colors hover:bg-amare-accent-deep">
|
||||||
Voltar para a home
|
Voltar para a home
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -1,3 +1,17 @@
|
|||||||
|
@php
|
||||||
|
try {
|
||||||
|
$errorSettings = \App\Models\SiteSetting::instance();
|
||||||
|
$pageMeta = \App\Application\Data\PageMeta::forErrorPage($errorSettings, 500);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$pageMeta = new \App\Application\Data\PageMeta(
|
||||||
|
title: 'Algo deu errado - Amare Assessoria',
|
||||||
|
description: 'Não foi possível concluir o pedido. Tente novamente em instantes.',
|
||||||
|
canonical: '',
|
||||||
|
robots: 'noindex, nofollow',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
@endphp
|
||||||
|
|
||||||
@extends('layouts.public')
|
@extends('layouts.public')
|
||||||
|
|
||||||
@section('content')
|
@section('content')
|
||||||
@@ -6,7 +20,7 @@
|
|||||||
<h1 class="text-4xl font-medium tracking-tight text-amare-text">Algo deu errado</h1>
|
<h1 class="text-4xl font-medium tracking-tight text-amare-text">Algo deu errado</h1>
|
||||||
<p class="text-amare-muted">Não foi possível concluir o pedido agora. Tente novamente em instantes.</p>
|
<p class="text-amare-muted">Não foi possível concluir o pedido agora. Tente novamente em instantes.</p>
|
||||||
<p>
|
<p>
|
||||||
<a href="{{ route('home') }}" class="inline-flex bg-amare-accent px-5 py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-accent-text transition-colors hover:bg-amare-accent-deep">
|
<a href="{{ route('home') }}" class="inline-flex min-h-11 items-center bg-amare-accent px-5 py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-accent-text transition-colors hover:bg-amare-accent-deep">
|
||||||
Voltar para a home
|
Voltar para a home
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -4,12 +4,33 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<meta name="color-scheme" content="light">
|
<meta name="color-scheme" content="light">
|
||||||
|
<meta name="theme-color" content="#FBF9F4">
|
||||||
|
<link rel="icon" href="{{ asset('favicon.svg') }}" type="image/svg+xml">
|
||||||
|
|
||||||
<title>{{ $pageMeta->title }}</title>
|
<title>{{ $pageMeta->title }}</title>
|
||||||
<x-seo.meta :page-meta="$pageMeta" />
|
<x-seo.meta :page-meta="$pageMeta" />
|
||||||
|
|
||||||
<x-fonts />
|
<x-fonts />
|
||||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||||
|
|
||||||
|
<noscript>
|
||||||
|
<style>
|
||||||
|
.main-nav {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.main-nav {
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-button {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</noscript>
|
||||||
</head>
|
</head>
|
||||||
<body class="min-h-screen bg-amare-bg font-serif text-amare-text antialiased">
|
<body class="min-h-screen bg-amare-bg font-serif text-amare-text antialiased">
|
||||||
<a href="#conteudo" class="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:z-50 focus:bg-amare-accent focus:px-4 focus:py-2 focus:text-amare-accent-text">
|
<a href="#conteudo" class="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:z-50 focus:bg-amare-accent focus:px-4 focus:py-2 focus:text-amare-accent-text">
|
||||||
@@ -18,12 +39,12 @@
|
|||||||
|
|
||||||
<header class="site-header sticky top-0 z-40 border-b border-amare-border/80 bg-amare-bg/90 backdrop-blur-sm">
|
<header class="site-header sticky top-0 z-40 border-b border-amare-border/80 bg-amare-bg/90 backdrop-blur-sm">
|
||||||
<div class="container-amare grid grid-cols-[auto_1fr_auto] items-center gap-4 py-4 md:grid-cols-[1fr_auto_1fr]">
|
<div class="container-amare grid grid-cols-[auto_1fr_auto] items-center gap-4 py-4 md:grid-cols-[1fr_auto_1fr]">
|
||||||
<nav id="main-nav" class="main-nav order-3 col-span-3 hidden flex-col gap-4 border-t border-amare-border pt-4 md:order-1 md:col-span-1 md:flex md:flex-row md:items-center md:border-0 md:pt-0" aria-label="Principal" data-main-nav>
|
<nav id="main-nav" class="main-nav order-3 col-span-3 hidden flex-col gap-4 border-t border-amare-border pt-4 md:order-1 md:col-span-1 md:flex md:flex-row md:items-center md:gap-1 md:border-0 md:pt-0" aria-label="Principal" data-main-nav>
|
||||||
<a href="{{ route('home') }}" class="text-sm font-semibold uppercase tracking-[0.12em] text-amare-muted transition-colors hover:text-amare-accent">Início</a>
|
<a href="{{ route('home') }}" class="inline-flex items-center py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-muted transition-colors hover:text-amare-accent md:px-2">Início</a>
|
||||||
<a href="{{ route('services.index') }}" class="text-sm font-semibold uppercase tracking-[0.12em] text-amare-muted transition-colors hover:text-amare-accent">Serviços</a>
|
<a href="{{ route('services.index') }}" class="inline-flex items-center py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-muted transition-colors hover:text-amare-accent md:px-2">Serviços</a>
|
||||||
<a href="{{ route('portfolio.index') }}" class="text-sm font-semibold uppercase tracking-[0.12em] text-amare-muted transition-colors hover:text-amare-accent">Portfólio</a>
|
<a href="{{ route('portfolio.index') }}" class="inline-flex items-center py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-muted transition-colors hover:text-amare-accent md:px-2">Portfólio</a>
|
||||||
<a href="{{ route('about') }}" class="text-sm font-semibold uppercase tracking-[0.12em] text-amare-muted transition-colors hover:text-amare-accent">Amare</a>
|
<a href="{{ route('about') }}" class="inline-flex items-center py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-muted transition-colors hover:text-amare-accent md:px-2">Amare</a>
|
||||||
<a href="{{ route('contact') }}" class="text-sm font-semibold uppercase tracking-[0.12em] text-amare-muted transition-colors hover:text-amare-accent md:hidden">Contato</a>
|
<a href="{{ route('contact') }}" class="inline-flex items-center py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-muted transition-colors hover:text-amare-accent md:hidden">Solicitar proposta</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<a href="{{ route('home') }}" class="order-1 justify-self-start md:order-2 md:justify-self-center" aria-label="{{ $siteSettings->brand_name }} — página inicial">
|
<a href="{{ route('home') }}" class="order-1 justify-self-start md:order-2 md:justify-self-center" aria-label="{{ $siteSettings->brand_name }} — página inicial">
|
||||||
@@ -31,13 +52,13 @@
|
|||||||
</a>
|
</a>
|
||||||
|
|
||||||
<div class="order-2 flex items-center justify-end gap-3 md:order-3">
|
<div class="order-2 flex items-center justify-end gap-3 md:order-3">
|
||||||
<a href="{{ route('contact') }}" class="hidden text-sm font-semibold uppercase tracking-[0.12em] text-amare-accent transition-colors hover:text-amare-accent-deep md:inline-flex">
|
<a href="{{ route('contact') }}" class="hidden items-center py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-accent transition-colors hover:text-amare-accent-deep md:inline-flex">
|
||||||
Solicitar proposta
|
Solicitar proposta
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="menu-button inline-flex h-10 w-10 items-center justify-center border border-amare-border text-amare-text md:hidden"
|
class="menu-button inline-flex h-11 w-11 items-center justify-center border border-amare-border text-amare-text md:hidden"
|
||||||
aria-label="Abrir menu"
|
aria-label="Abrir menu"
|
||||||
aria-controls="main-nav"
|
aria-controls="main-nav"
|
||||||
aria-expanded="false"
|
aria-expanded="false"
|
||||||
@@ -63,39 +84,45 @@
|
|||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<x-brand.logo variant="on-light" class="h-12 w-auto" />
|
<x-brand.logo variant="on-light" class="h-12 w-auto" />
|
||||||
<p class="max-w-md text-amare-muted">
|
<p class="max-w-md text-amare-muted">
|
||||||
{{ $siteSettings->about_summary ?: 'Assessoria, produção e organização de eventos sociais e corporativos em São Paulo - SP.' }}
|
{{ $siteSettings->about_summary ?: 'Assessoria boutique em São Paulo - SP.' }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-3 text-sm">
|
<div class="text-sm">
|
||||||
<h2 class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-text">Navegação</h2>
|
<h2 class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-text">Navegação</h2>
|
||||||
<p><a href="{{ route('services.index') }}" class="text-amare-muted transition-colors hover:text-amare-accent">Serviços</a></p>
|
<ul class="mt-3 space-y-3">
|
||||||
<p><a href="{{ route('portfolio.index') }}" class="text-amare-muted transition-colors hover:text-amare-accent">Portfólio</a></p>
|
<li><a href="{{ route('services.index') }}" class="inline-flex min-h-11 items-center text-amare-muted transition-colors hover:text-amare-accent">Serviços</a></li>
|
||||||
<p><a href="{{ route('about') }}" class="text-amare-muted transition-colors hover:text-amare-accent">A Amare</a></p>
|
<li><a href="{{ route('portfolio.index') }}" class="inline-flex min-h-11 items-center text-amare-muted transition-colors hover:text-amare-accent">Portfólio</a></li>
|
||||||
<p><a href="{{ route('contact') }}" class="text-amare-muted transition-colors hover:text-amare-accent">Contato</a></p>
|
<li><a href="{{ route('about') }}" class="inline-flex min-h-11 items-center text-amare-muted transition-colors hover:text-amare-accent">A Amare</a></li>
|
||||||
<p><a href="{{ route('privacy') }}" class="text-amare-muted transition-colors hover:text-amare-accent">Política de privacidade</a></p>
|
<li><a href="{{ route('contact') }}" class="inline-flex min-h-11 items-center text-amare-muted transition-colors hover:text-amare-accent">Contato</a></li>
|
||||||
|
<li><a href="{{ route('privacy') }}" class="inline-flex min-h-11 items-center text-amare-muted transition-colors hover:text-amare-accent">Política de privacidade</a></li>
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-3 text-sm">
|
<div class="text-sm">
|
||||||
<h2 class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-text">Contato</h2>
|
<h2 class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-text">Contato</h2>
|
||||||
|
<address class="mt-3 space-y-3 not-italic">
|
||||||
@if ($siteSettings->city)
|
@if ($siteSettings->city)
|
||||||
<p class="text-amare-muted">{{ $siteSettings->city }}</p>
|
<p class="text-amare-muted">{{ $siteSettings->city }}</p>
|
||||||
@endif
|
@endif
|
||||||
@if ($siteSettings->email)
|
@if ($siteSettings->email)
|
||||||
<p>
|
<p>
|
||||||
<a href="mailto:{{ $siteSettings->email }}" class="text-amare-muted transition-colors hover:text-amare-accent">{{ $siteSettings->email }}</a>
|
<a href="mailto:{{ $siteSettings->email }}" class="inline-flex min-h-11 items-center text-amare-muted transition-colors hover:text-amare-accent">{{ $siteSettings->email }}</a>
|
||||||
</p>
|
</p>
|
||||||
@endif
|
@endif
|
||||||
@if ($siteSettings->phone)
|
@if ($siteSettings->phone)
|
||||||
<p class="text-amare-muted">{{ $siteSettings->phone }}</p>
|
<p>
|
||||||
|
<a href="tel:{{ preg_replace('/\D/', '', (string) $siteSettings->phone) }}" class="inline-flex min-h-11 items-center text-amare-muted transition-colors hover:text-amare-accent">{{ $siteSettings->phone }}</a>
|
||||||
|
</p>
|
||||||
@endif
|
@endif
|
||||||
@foreach ($siteSettings->social_links ?? [] as $network => $url)
|
@foreach ($siteSettings->social_links ?? [] as $network => $url)
|
||||||
@if (filled($url))
|
@if (filled($url))
|
||||||
<p>
|
<p>
|
||||||
<a href="{{ $url }}" class="text-amare-muted transition-colors hover:text-amare-accent" rel="noopener noreferrer" target="_blank">{{ ucfirst((string) $network) }}</a>
|
<a href="{{ $url }}" class="inline-flex min-h-11 items-center text-amare-muted transition-colors hover:text-amare-accent" rel="noopener noreferrer" target="_blank">{{ ucfirst((string) $network) }}</a>
|
||||||
</p>
|
</p>
|
||||||
@endif
|
@endif
|
||||||
@endforeach
|
@endforeach
|
||||||
|
</address>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -103,7 +130,7 @@
|
|||||||
<div class="container-amare flex flex-col gap-2 py-6 text-sm text-amare-muted md:flex-row md:items-center md:justify-between">
|
<div class="container-amare flex flex-col gap-2 py-6 text-sm text-amare-muted md:flex-row md:items-center md:justify-between">
|
||||||
<p>© {{ now()->year }} {{ $siteSettings->brand_name }}. Todos os direitos reservados.</p>
|
<p>© {{ now()->year }} {{ $siteSettings->brand_name }}. Todos os direitos reservados.</p>
|
||||||
<p>
|
<p>
|
||||||
<a href="{{ route('privacy') }}" class="transition-colors hover:text-amare-accent">Política de privacidade</a>
|
<a href="{{ route('privacy') }}" class="inline-flex min-h-11 items-center transition-colors hover:text-amare-accent">Política de privacidade</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
$city = $siteSettings->city ?: 'São Paulo - SP';
|
$city = $siteSettings->city ?: 'São Paulo - SP';
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<div class="flex min-h-[calc(100dvh-14rem)] flex-col border-b border-amare-border bg-amare-bg">
|
<div class="flex min-h-[calc(100dvh-14rem)] flex-col border-b border-amare-border bg-amare-bg" data-motion="page-open">
|
||||||
<div class="container-amare grid flex-1 content-start gap-12 py-16 md:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)] md:py-24">
|
<div class="container-amare grid flex-1 content-start gap-12 py-16 md:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)] md:py-24">
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">A Amare</p>
|
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">A Amare</p>
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
@extends('layouts.public')
|
@extends('layouts.public')
|
||||||
|
|
||||||
@section('content')
|
@section('content')
|
||||||
<div class="flex min-h-[calc(100dvh-14rem)] flex-col border-b border-amare-border bg-amare-bg">
|
<section class="flex min-h-[calc(100dvh-14rem)] flex-col border-b border-amare-border bg-amare-bg">
|
||||||
<div class="container-amare grid flex-1 content-start gap-12 py-16 md:grid-cols-[minmax(0,1.2fr)_minmax(0,0.8fr)] md:py-24">
|
<div class="container-amare grid flex-1 content-start gap-12 py-16 md:grid-cols-[minmax(0,1.2fr)_minmax(0,0.8fr)] md:py-24">
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Vamos conversar</p>
|
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Vamos conversar</p>
|
||||||
<h1 class="text-4xl font-medium tracking-tight text-amare-text md:text-5xl">Todo grande encontro começa com uma boa conversa.</h1>
|
<h1 class="text-4xl font-medium tracking-tight text-amare-text md:text-5xl">Todo grande encontro começa com uma boa conversa.</h1>
|
||||||
<p class="max-w-2xl text-lg text-amare-muted">
|
<p class="max-w-2xl text-lg text-amare-muted">
|
||||||
Em breve você poderá enviar um briefing por aqui. Enquanto isso, fale conosco pelos canais abaixo.
|
Conte sobre o seu evento no briefing abaixo. Retornaremos com uma proposta sob medida e sem compromisso.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -15,20 +15,263 @@
|
|||||||
<p>{{ $siteSettings->city ?: 'São Paulo - SP' }}</p>
|
<p>{{ $siteSettings->city ?: 'São Paulo - SP' }}</p>
|
||||||
@if ($siteSettings->email)
|
@if ($siteSettings->email)
|
||||||
<p>
|
<p>
|
||||||
<a href="mailto:{{ $siteSettings->email }}" class="text-amare-accent transition-colors hover:text-amare-accent-deep">{{ $siteSettings->email }}</a>
|
<a href="mailto:{{ $siteSettings->email }}" class="inline-flex min-h-11 items-center text-amare-accent transition-colors hover:text-amare-accent-deep">{{ $siteSettings->email }}</a>
|
||||||
</p>
|
</p>
|
||||||
@endif
|
@endif
|
||||||
@if ($siteSettings->phone)
|
@if ($siteSettings->phone)
|
||||||
<p>{{ $siteSettings->phone }}</p>
|
<p>
|
||||||
|
<a href="tel:{{ preg_replace('/\D/', '', (string) $siteSettings->phone) }}" class="inline-flex min-h-11 items-center text-amare-accent transition-colors hover:text-amare-accent-deep">{{ $siteSettings->phone }}</a>
|
||||||
|
</p>
|
||||||
@endif
|
@endif
|
||||||
@foreach ($siteSettings->social_links ?? [] as $network => $url)
|
@foreach ($siteSettings->social_links ?? [] as $network => $url)
|
||||||
@if (filled($url))
|
@if (filled($url))
|
||||||
<p>
|
<p>
|
||||||
<a href="{{ $url }}" class="text-amare-accent transition-colors hover:text-amare-accent-deep" rel="noopener noreferrer" target="_blank">{{ ucfirst((string) $network) }}</a>
|
<a href="{{ $url }}" class="inline-flex min-h-11 items-center text-amare-accent transition-colors hover:text-amare-accent-deep" rel="noopener noreferrer" target="_blank">{{ ucfirst((string) $network) }}</a>
|
||||||
</p>
|
</p>
|
||||||
@endif
|
@endif
|
||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="border-b border-amare-border bg-amare-bg" aria-labelledby="briefing-heading">
|
||||||
|
<div class="container-amare py-16 md:py-24">
|
||||||
|
<h2 id="briefing-heading" class="text-2xl font-medium tracking-tight text-amare-text md:text-3xl">Briefing de contato</h2>
|
||||||
|
<p class="mt-2 max-w-2xl text-amare-muted">
|
||||||
|
Preencha os campos abaixo. As informações obrigatórias estão marcadas com asterisco (*).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
@if (session('status') === 'briefing-sent')
|
||||||
|
<div role="status" class="mt-8 border border-amare-border bg-amare-bg-deep px-6 py-5">
|
||||||
|
<p class="font-semibold text-amare-text">Mensagem enviada.</p>
|
||||||
|
<p class="mt-1 text-amare-muted">Recebemos seu briefing e retornaremos em breve pelo canal informado.</p>
|
||||||
</div>
|
</div>
|
||||||
|
@else
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="{{ route('contact.store') }}"
|
||||||
|
class="mt-10 max-w-3xl space-y-10"
|
||||||
|
data-contact-form
|
||||||
|
>
|
||||||
|
@csrf
|
||||||
|
|
||||||
|
<div class="honeypot" aria-hidden="true">
|
||||||
|
<label for="empresa">Não preencha este campo</label>
|
||||||
|
<input type="text" id="empresa" name="empresa" tabindex="-1" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if ($errors->any())
|
||||||
|
<div role="alert" class="border border-amare-error/40 bg-amare-error/5 px-6 py-5">
|
||||||
|
<p class="font-semibold text-amare-error">Não foi possível enviar.</p>
|
||||||
|
<p class="mt-1 text-sm text-amare-muted">Revise os campos destacados abaixo e tente novamente.</p>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div class="grid gap-8 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label for="nome" class="mb-2 block text-xs font-semibold uppercase tracking-[0.14em] text-amare-text">Nome completo *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="nome"
|
||||||
|
name="nome"
|
||||||
|
value="{{ old('nome') }}"
|
||||||
|
required
|
||||||
|
autocomplete="name"
|
||||||
|
maxlength="120"
|
||||||
|
placeholder="Seu nome"
|
||||||
|
class="w-full border-0 border-b border-amare-border bg-transparent py-3 text-amare-text transition-colors placeholder:text-amare-muted/60 focus:border-amare-accent focus:outline-none @error('nome') border-amare-error @enderror"
|
||||||
|
@error('nome') aria-invalid="true" aria-describedby="nome-error" @enderror
|
||||||
|
>
|
||||||
|
@error('nome')
|
||||||
|
<p id="nome-error" class="mt-2 text-sm text-amare-error">{{ $message }}</p>
|
||||||
|
@enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="email" class="mb-2 block text-xs font-semibold uppercase tracking-[0.14em] text-amare-text">E-mail *</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
value="{{ old('email') }}"
|
||||||
|
required
|
||||||
|
autocomplete="email"
|
||||||
|
maxlength="254"
|
||||||
|
placeholder="voce@email.com"
|
||||||
|
class="w-full border-0 border-b border-amare-border bg-transparent py-3 text-amare-text transition-colors placeholder:text-amare-muted/60 focus:border-amare-accent focus:outline-none @error('email') border-amare-error @enderror"
|
||||||
|
@error('email') aria-invalid="true" aria-describedby="email-error" @enderror
|
||||||
|
>
|
||||||
|
@error('email')
|
||||||
|
<p id="email-error" class="mt-2 text-sm text-amare-error">{{ $message }}</p>
|
||||||
|
@enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="telefone" class="mb-2 block text-xs font-semibold uppercase tracking-[0.14em] text-amare-text">Telefone/WhatsApp *</label>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
id="telefone"
|
||||||
|
name="telefone"
|
||||||
|
value="{{ old('telefone') }}"
|
||||||
|
required
|
||||||
|
autocomplete="tel"
|
||||||
|
maxlength="40"
|
||||||
|
placeholder="(11) 90000-0000"
|
||||||
|
class="w-full border-0 border-b border-amare-border bg-transparent py-3 text-amare-text transition-colors placeholder:text-amare-muted/60 focus:border-amare-accent focus:outline-none @error('telefone') border-amare-error @enderror"
|
||||||
|
@error('telefone') aria-invalid="true" aria-describedby="telefone-error" @enderror
|
||||||
|
>
|
||||||
|
@error('telefone')
|
||||||
|
<p id="telefone-error" class="mt-2 text-sm text-amare-error">{{ $message }}</p>
|
||||||
|
@enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="tipo_evento" class="mb-2 block text-xs font-semibold uppercase tracking-[0.14em] text-amare-text">Tipo de evento *</label>
|
||||||
|
<select
|
||||||
|
id="tipo_evento"
|
||||||
|
name="tipo_evento"
|
||||||
|
required
|
||||||
|
class="w-full border-0 border-b border-amare-border bg-transparent py-3 text-amare-text transition-colors focus:border-amare-accent focus:outline-none @error('tipo_evento') border-amare-error @enderror"
|
||||||
|
@error('tipo_evento') aria-invalid="true" aria-describedby="tipo_evento-error" @enderror
|
||||||
|
>
|
||||||
|
<option value="" selected disabled>Selecione...</option>
|
||||||
|
<option value="Casamento" @selected(old('tipo_evento') === 'Casamento')>Casamento</option>
|
||||||
|
<option value="Evento corporativo" @selected(old('tipo_evento') === 'Evento corporativo')>Evento corporativo</option>
|
||||||
|
<option value="Celebração intimista" @selected(old('tipo_evento') === 'Celebração intimista')>Celebração intimista</option>
|
||||||
|
<option value="Outro" @selected(old('tipo_evento') === 'Outro')>Outro tipo de evento</option>
|
||||||
|
</select>
|
||||||
|
@error('tipo_evento')
|
||||||
|
<p id="tipo_evento-error" class="mt-2 text-sm text-amare-error">{{ $message }}</p>
|
||||||
|
@enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="data_periodo" class="mb-2 block text-xs font-semibold uppercase tracking-[0.14em] text-amare-text">Data ou período desejado</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="data_periodo"
|
||||||
|
name="data_periodo"
|
||||||
|
value="{{ old('data_periodo') }}"
|
||||||
|
maxlength="80"
|
||||||
|
placeholder="Ex.: novembro de 2027"
|
||||||
|
class="w-full border-0 border-b border-amare-border bg-transparent py-3 text-amare-text transition-colors placeholder:text-amare-muted/60 focus:border-amare-accent focus:outline-none @error('data_periodo') border-amare-error @enderror"
|
||||||
|
@error('data_periodo') aria-invalid="true" aria-describedby="data_periodo-error" @enderror
|
||||||
|
>
|
||||||
|
@error('data_periodo')
|
||||||
|
<p id="data_periodo-error" class="mt-2 text-sm text-amare-error">{{ $message }}</p>
|
||||||
|
@enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="cidade" class="mb-2 block text-xs font-semibold uppercase tracking-[0.14em] text-amare-text">Cidade do evento *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="cidade"
|
||||||
|
name="cidade"
|
||||||
|
value="{{ old('cidade') }}"
|
||||||
|
required
|
||||||
|
maxlength="80"
|
||||||
|
placeholder="Ex.: São Paulo"
|
||||||
|
class="w-full border-0 border-b border-amare-border bg-transparent py-3 text-amare-text transition-colors placeholder:text-amare-muted/60 focus:border-amare-accent focus:outline-none @error('cidade') border-amare-error @enderror"
|
||||||
|
@error('cidade') aria-invalid="true" aria-describedby="cidade-error" @enderror
|
||||||
|
>
|
||||||
|
@error('cidade')
|
||||||
|
<p id="cidade-error" class="mt-2 text-sm text-amare-error">{{ $message }}</p>
|
||||||
|
@enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="convidados" class="mb-2 block text-xs font-semibold uppercase tracking-[0.14em] text-amare-text">Número estimado de convidados</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="convidados"
|
||||||
|
name="convidados"
|
||||||
|
value="{{ old('convidados') }}"
|
||||||
|
min="1"
|
||||||
|
max="100000"
|
||||||
|
inputmode="numeric"
|
||||||
|
autocomplete="off"
|
||||||
|
placeholder="Ex.: 120"
|
||||||
|
class="w-full border-0 border-b border-amare-border bg-transparent py-3 text-amare-text transition-colors placeholder:text-amare-muted/60 focus:border-amare-accent focus:outline-none @error('convidados') border-amare-error @enderror"
|
||||||
|
@error('convidados') aria-invalid="true" aria-describedby="convidados-error" @enderror
|
||||||
|
>
|
||||||
|
@error('convidados')
|
||||||
|
<p id="convidados-error" class="mt-2 text-sm text-amare-error">{{ $message }}</p>
|
||||||
|
@enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="servico_interesse" class="mb-2 block text-xs font-semibold uppercase tracking-[0.14em] text-amare-text">Serviço de interesse</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="servico_interesse"
|
||||||
|
name="servico_interesse"
|
||||||
|
value="{{ old('servico_interesse') }}"
|
||||||
|
maxlength="120"
|
||||||
|
placeholder="Ex.: planejamento completo"
|
||||||
|
class="w-full border-0 border-b border-amare-border bg-transparent py-3 text-amare-text transition-colors placeholder:text-amare-muted/60 focus:border-amare-accent focus:outline-none @error('servico_interesse') border-amare-error @enderror"
|
||||||
|
@error('servico_interesse') aria-invalid="true" aria-describedby="servico_interesse-error" @enderror
|
||||||
|
>
|
||||||
|
@error('servico_interesse')
|
||||||
|
<p id="servico_interesse-error" class="mt-2 text-sm text-amare-error">{{ $message }}</p>
|
||||||
|
@enderror
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="mensagem" class="mb-2 block text-xs font-semibold uppercase tracking-[0.14em] text-amare-text">Mensagem / principal preocupação *</label>
|
||||||
|
<textarea
|
||||||
|
id="mensagem"
|
||||||
|
name="mensagem"
|
||||||
|
required
|
||||||
|
rows="6"
|
||||||
|
maxlength="3000"
|
||||||
|
placeholder="Conte sobre o seu evento, expectativas e principais preocupações."
|
||||||
|
class="w-full border-0 border-b border-amare-border bg-transparent py-3 text-amare-text transition-colors placeholder:text-amare-muted/60 focus:border-amare-accent focus:outline-none @error('mensagem') border-amare-error @enderror"
|
||||||
|
@error('mensagem') aria-invalid="true" aria-describedby="mensagem-error" @enderror
|
||||||
|
>{{ old('mensagem') }}</textarea>
|
||||||
|
@error('mensagem')
|
||||||
|
<p id="mensagem-error" class="mt-2 text-sm text-amare-error">{{ $message }}</p>
|
||||||
|
@enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="privacidade" class="flex items-start gap-3 py-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="privacidade"
|
||||||
|
name="privacidade"
|
||||||
|
value="1"
|
||||||
|
required
|
||||||
|
@checked(old('privacidade'))
|
||||||
|
class="mt-1 h-5 w-5 shrink-0 accent-amare-accent"
|
||||||
|
@error('privacidade') aria-invalid="true" aria-describedby="privacidade-error" @enderror
|
||||||
|
>
|
||||||
|
<span class="text-sm text-amare-muted">
|
||||||
|
Li e aceito a
|
||||||
|
<a href="{{ route('privacy') }}" class="text-amare-accent underline underline-offset-2 transition-colors hover:text-amare-accent-deep">política de privacidade</a>
|
||||||
|
e autorizo o tratamento dos meus dados para fins de atendimento.*
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
@error('privacidade')
|
||||||
|
<p id="privacidade-error" class="mt-2 text-sm text-amare-error">{{ $message }}</p>
|
||||||
|
@enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col items-start gap-4">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
data-submit-button
|
||||||
|
class="inline-flex min-h-[44px] items-center justify-center bg-amare-accent px-8 py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-accent-text transition-colors hover:bg-amare-accent-deep"
|
||||||
|
>
|
||||||
|
Enviar briefing
|
||||||
|
</button>
|
||||||
|
<p class="text-sm text-amare-muted">
|
||||||
|
* Campos obrigatórios. Seus dados são usados apenas para responder à sua solicitação.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
@endsection
|
@endsection
|
||||||
|
|||||||
@@ -1,6 +1,32 @@
|
|||||||
@extends('layouts.public')
|
@extends('layouts.public')
|
||||||
|
|
||||||
@section('content')
|
@section('content')
|
||||||
|
@php
|
||||||
|
$chapters = [
|
||||||
|
['id' => 'hero-heading', 'label' => 'Capa'],
|
||||||
|
['id' => 'manifesto-heading', 'label' => 'Manifesto'],
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($content->featuredServices->isNotEmpty()) {
|
||||||
|
$chapters[] = ['id' => 'services-heading', 'label' => 'Serviços'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($content->featuredCases->isNotEmpty()) {
|
||||||
|
$chapters[] = ['id' => 'portfolio-heading', 'label' => 'Portfólio'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$chapters[] = ['id' => 'method-heading', 'label' => 'Método'];
|
||||||
|
|
||||||
|
if ($content->testimonials->isNotEmpty()) {
|
||||||
|
$chapters[] = ['id' => 'testimonials-heading', 'label' => 'Depoimentos'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$chapters[] = ['id' => 'positioning-heading', 'label' => 'A Amare'];
|
||||||
|
$chapters[] = ['id' => 'final-cta-heading', 'label' => 'Próximo passo'];
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<x-home.chapter-index :chapters="$chapters" />
|
||||||
|
|
||||||
<x-home.hero :settings="$content->settings" />
|
<x-home.hero :settings="$content->settings" />
|
||||||
<x-home.manifesto :settings="$content->settings" />
|
<x-home.manifesto :settings="$content->settings" />
|
||||||
<x-home.services :services="$content->featuredServices" />
|
<x-home.services :services="$content->featuredServices" />
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
@extends('layouts.public')
|
@extends('layouts.public')
|
||||||
|
|
||||||
@section('content')
|
@section('content')
|
||||||
<div class="border-b border-amare-border bg-amare-bg-deep">
|
<div class="border-b border-amare-border bg-amare-bg-deep" data-motion="page-open">
|
||||||
<div class="container-amare space-y-10 py-16 md:py-24">
|
<div class="container-amare space-y-10 py-16 md:py-24">
|
||||||
<div class="max-w-2xl space-y-4">
|
<div class="max-w-2xl space-y-4">
|
||||||
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Portfólio</p>
|
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Portfólio</p>
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if ($cases->isEmpty())
|
@if ($cases->isEmpty())
|
||||||
<p class="text-amare-muted">Em breve publicaremos novos casos.</p>
|
<p class="text-amare-muted">Novos casos serão publicados assim que o acervo estiver organizado. Enquanto isso, fale conosco para conhecer o nosso trabalho.</p>
|
||||||
@else
|
@else
|
||||||
<div class="grid gap-10 md:grid-cols-2">
|
<div class="grid gap-10 md:grid-cols-2">
|
||||||
@foreach ($cases as $case)
|
@foreach ($cases as $case)
|
||||||
@@ -40,7 +40,7 @@
|
|||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
<p class="text-sm text-amare-accent">Imagens demonstrativas até existir acervo autorizado da Amare.</p>
|
<p class="text-sm text-amare-accent">Imagens demonstrativas enquanto o acervo autorizado da Amare está em organização.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@endsection
|
@endsection
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
@extends('layouts.public')
|
@extends('layouts.public')
|
||||||
|
|
||||||
@section('content')
|
@section('content')
|
||||||
<article>
|
<article data-motion="page-open">
|
||||||
<div class="border-b border-amare-border bg-amare-bg">
|
<div class="border-b border-amare-border bg-amare-bg">
|
||||||
<div class="container-amare space-y-8 py-16 md:py-24">
|
<div class="container-amare space-y-8 py-16 md:py-24">
|
||||||
<header class="max-w-3xl space-y-4">
|
<header class="max-w-3xl space-y-4">
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
@extends('layouts.public')
|
@extends('layouts.public')
|
||||||
|
|
||||||
@section('content')
|
@section('content')
|
||||||
<div class="border-b border-amare-border bg-amare-bg">
|
<div class="border-b border-amare-border bg-amare-bg" data-motion="page-open">
|
||||||
<div class="container-amare space-y-10 py-16 md:py-24">
|
<div class="container-amare space-y-10 py-16 md:py-24">
|
||||||
<div class="max-w-2xl space-y-4">
|
<div class="max-w-2xl space-y-4">
|
||||||
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Serviços</p>
|
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Serviços</p>
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if ($services->isEmpty())
|
@if ($services->isEmpty())
|
||||||
<p class="text-amare-muted">Em breve publicaremos o catálogo de serviços.</p>
|
<p class="text-amare-muted">O catálogo de serviços está em organização. Enquanto isso, fale conosco para uma primeira conversa.</p>
|
||||||
@else
|
@else
|
||||||
<div class="divide-y divide-amare-border border-y border-amare-border">
|
<div class="divide-y divide-amare-border border-y border-amare-border">
|
||||||
@foreach ($services as $index => $service)
|
@foreach ($services as $index => $service)
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Http\Controllers\PublicSite\ContactController;
|
||||||
use App\Http\Controllers\PublicSite\HomeController;
|
use App\Http\Controllers\PublicSite\HomeController;
|
||||||
use App\Http\Controllers\PublicSite\PageController;
|
use App\Http\Controllers\PublicSite\PageController;
|
||||||
use App\Http\Controllers\PublicSite\PortfolioController;
|
use App\Http\Controllers\PublicSite\PortfolioController;
|
||||||
@@ -18,5 +19,8 @@ Route::get('/portfolio/{slug}', [PortfolioController::class, 'show'])->name('por
|
|||||||
Route::get('/sobre', [PageController::class, 'about'])->name('about');
|
Route::get('/sobre', [PageController::class, 'about'])->name('about');
|
||||||
Route::get('/privacidade', [PageController::class, 'privacy'])->name('privacy');
|
Route::get('/privacidade', [PageController::class, 'privacy'])->name('privacy');
|
||||||
Route::get('/contato', [PageController::class, 'contact'])->name('contact');
|
Route::get('/contato', [PageController::class, 'contact'])->name('contact');
|
||||||
|
Route::post('/contato', [ContactController::class, 'store'])
|
||||||
|
->middleware('throttle:contact-briefing')
|
||||||
|
->name('contact.store');
|
||||||
Route::get('/sitemap.xml', SitemapController::class)->name('sitemap');
|
Route::get('/sitemap.xml', SitemapController::class)->name('sitemap');
|
||||||
Route::get('/robots.txt', RobotsController::class)->name('robots');
|
Route::get('/robots.txt', RobotsController::class)->name('robots');
|
||||||
|
|||||||
114
scripts/deploy/dokploy-deploy.sh
Executable file
114
scripts/deploy/dokploy-deploy.sh
Executable file
@@ -0,0 +1,114 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Trigger a Dokploy Compose deploy and wait until it finishes.
|
||||||
|
# Required env:
|
||||||
|
# DOKPLOY_URL e.g. https://panel.example.com (panel origin, no /api)
|
||||||
|
# DOKPLOY_API_KEY x-api-key value
|
||||||
|
# DOKPLOY_COMPOSE_ID target compose id
|
||||||
|
# Optional env:
|
||||||
|
# DEPLOY_TITLE deployment title (default: GitHub deploy)
|
||||||
|
# DEPLOY_TIMEOUT_SEC total wait seconds (default: 900)
|
||||||
|
# DEPLOY_POLL_SEC poll interval (default: 10)
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
: "${DOKPLOY_URL:?DOKPLOY_URL is required}"
|
||||||
|
: "${DOKPLOY_API_KEY:?DOKPLOY_API_KEY is required}"
|
||||||
|
: "${DOKPLOY_COMPOSE_ID:?DOKPLOY_COMPOSE_ID is required}"
|
||||||
|
|
||||||
|
# Panel origin only: strip trailing slash and accidental OpenAPI /api suffix.
|
||||||
|
DOKPLOY_URL="${DOKPLOY_URL%/}"
|
||||||
|
DOKPLOY_URL="${DOKPLOY_URL%/api}"
|
||||||
|
DOKPLOY_URL="${DOKPLOY_URL%/}"
|
||||||
|
|
||||||
|
DEPLOY_TITLE="${DEPLOY_TITLE:-GitHub deploy}"
|
||||||
|
DEPLOY_TIMEOUT_SEC="${DEPLOY_TIMEOUT_SEC:-900}"
|
||||||
|
DEPLOY_POLL_SEC="${DEPLOY_POLL_SEC:-10}"
|
||||||
|
|
||||||
|
echo "Dokploy API base: ${DOKPLOY_URL}/api"
|
||||||
|
|
||||||
|
api() {
|
||||||
|
local method="$1"
|
||||||
|
local path="$2"
|
||||||
|
shift 2
|
||||||
|
local url="${DOKPLOY_URL}/api${path}"
|
||||||
|
local response http_code body
|
||||||
|
|
||||||
|
# Body then HTTP status on the last line (no -f so 4xx/5xx still return a body).
|
||||||
|
response="$(
|
||||||
|
curl -sS -w $'\n%{http_code}' -X "$method" \
|
||||||
|
-H "accept: application/json" \
|
||||||
|
-H "content-type: application/json" \
|
||||||
|
-H "x-api-key: ${DOKPLOY_API_KEY}" \
|
||||||
|
"$url" \
|
||||||
|
"$@"
|
||||||
|
)" || {
|
||||||
|
echo "Dokploy API request failed (curl transport error)." >&2
|
||||||
|
echo " method: ${method}" >&2
|
||||||
|
echo " url: ${url}" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
http_code="$(printf '%s' "$response" | tail -n1)"
|
||||||
|
body="$(printf '%s' "$response" | sed '$d')"
|
||||||
|
|
||||||
|
if [[ ! "$http_code" =~ ^2[0-9][0-9]$ ]]; then
|
||||||
|
echo "Dokploy API error." >&2
|
||||||
|
echo " method: ${method}" >&2
|
||||||
|
echo " url: ${url}" >&2
|
||||||
|
echo " status: ${http_code}" >&2
|
||||||
|
echo " body:" >&2
|
||||||
|
printf '%s\n' "$body" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '%s' "$body"
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "Triggering Dokploy compose.deploy for ${DOKPLOY_COMPOSE_ID}..."
|
||||||
|
api POST "/compose.deploy" \
|
||||||
|
-d "$(jq -n \
|
||||||
|
--arg composeId "$DOKPLOY_COMPOSE_ID" \
|
||||||
|
--arg title "$DEPLOY_TITLE" \
|
||||||
|
'{composeId: $composeId, title: $title}')" >/dev/null
|
||||||
|
|
||||||
|
echo "Waiting for deployment to finish (timeout ${DEPLOY_TIMEOUT_SEC}s)..."
|
||||||
|
deadline=$((SECONDS + DEPLOY_TIMEOUT_SEC))
|
||||||
|
last_status=""
|
||||||
|
|
||||||
|
while (( SECONDS < deadline )); do
|
||||||
|
payload="$(api GET "/deployment.allByCompose?composeId=${DOKPLOY_COMPOSE_ID}")"
|
||||||
|
latest="$(echo "$payload" | jq -c 'if type=="array" then .[0] else . end')"
|
||||||
|
|
||||||
|
if [[ -z "$latest" || "$latest" == "null" ]]; then
|
||||||
|
echo "No deployment records yet; retrying..."
|
||||||
|
sleep "$DEPLOY_POLL_SEC"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
status="$(echo "$latest" | jq -r '.status // .deploymentStatus // empty')"
|
||||||
|
created_at="$(echo "$latest" | jq -r '.createdAt // .created_at // empty')"
|
||||||
|
title="$(echo "$latest" | jq -r '.title // .titleLog // empty')"
|
||||||
|
|
||||||
|
if [[ "$status" != "$last_status" ]]; then
|
||||||
|
echo "Deployment status=${status:-unknown} title=${title:-n/a} createdAt=${created_at:-n/a}"
|
||||||
|
last_status="$status"
|
||||||
|
fi
|
||||||
|
|
||||||
|
case "${status,,}" in
|
||||||
|
done|success|successful|finished)
|
||||||
|
echo "Dokploy deployment succeeded."
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
error|failed|failure)
|
||||||
|
echo "Dokploy deployment failed." >&2
|
||||||
|
echo "$latest" | jq . >&2 || true
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
sleep "$DEPLOY_POLL_SEC"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Timed out waiting for Dokploy deployment after ${DEPLOY_TIMEOUT_SEC}s." >&2
|
||||||
|
api GET "/deployment.allByCompose?composeId=${DOKPLOY_COMPOSE_ID}" | jq . >&2 || true
|
||||||
|
exit 1
|
||||||
56
scripts/deploy/smoke.sh
Executable file
56
scripts/deploy/smoke.sh
Executable file
@@ -0,0 +1,56 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Post-deploy HTTP smoke checks for Amare.
|
||||||
|
# Required env:
|
||||||
|
# SMOKE_BASE_URL e.g. https://staging.example.com
|
||||||
|
# Optional env:
|
||||||
|
# SMOKE_RETRIES attempts per path (default: 30)
|
||||||
|
# SMOKE_SLEEP_SEC sleep between attempts (default: 5)
|
||||||
|
# SMOKE_PATHS space-separated paths (default: /up / /admin/login)
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
: "${SMOKE_BASE_URL:?SMOKE_BASE_URL is required}"
|
||||||
|
|
||||||
|
SMOKE_BASE_URL="${SMOKE_BASE_URL%/}"
|
||||||
|
SMOKE_RETRIES="${SMOKE_RETRIES:-30}"
|
||||||
|
SMOKE_SLEEP_SEC="${SMOKE_SLEEP_SEC:-5}"
|
||||||
|
SMOKE_PATHS="${SMOKE_PATHS:-/up / /admin/login}"
|
||||||
|
|
||||||
|
check_path() {
|
||||||
|
local path="$1"
|
||||||
|
local url="${SMOKE_BASE_URL}${path}"
|
||||||
|
local attempt
|
||||||
|
local code
|
||||||
|
|
||||||
|
for ((attempt = 1; attempt <= SMOKE_RETRIES; attempt++)); do
|
||||||
|
code="$(curl -sS -o /tmp/amare-smoke-body -w '%{http_code}' --max-time 20 "$url" 2>/dev/null || true)"
|
||||||
|
if [[ "$code" == "200" ]]; then
|
||||||
|
echo "OK ${path} (HTTP ${code}) attempt=${attempt}"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
echo "WAIT ${path} (HTTP ${code:-000}) attempt=${attempt}/${SMOKE_RETRIES}"
|
||||||
|
sleep "$SMOKE_SLEEP_SEC"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "FAIL ${path} after ${SMOKE_RETRIES} attempts" >&2
|
||||||
|
if [[ -f /tmp/amare-smoke-body ]]; then
|
||||||
|
head -c 500 /tmp/amare-smoke-body >&2 || true
|
||||||
|
echo >&2
|
||||||
|
fi
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "Smoke against ${SMOKE_BASE_URL}"
|
||||||
|
failed=0
|
||||||
|
for path in $SMOKE_PATHS; do
|
||||||
|
if ! check_path "$path"; then
|
||||||
|
failed=1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if (( failed != 0 )); then
|
||||||
|
echo "Smoke checks failed." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Smoke checks passed."
|
||||||
47
tasks.md
Normal file
47
tasks.md
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# Frontend Audit — Amare site
|
||||||
|
|
||||||
|
## T0 — Baseline
|
||||||
|
- [x] Rodar `composer quality` p/ registrar estado verde atual (110 passed após composer install)
|
||||||
|
- [x] Registrado estado baseline; Lighthouse final consolidado no T6
|
||||||
|
- [x] Registrar achados baseline (audit completo em frontend-audit.md + relatório subagent)
|
||||||
|
|
||||||
|
## T1 — Formulário de contato (e-mail, sem CRM)
|
||||||
|
- [x] Form em /contato (campos, LGPD obrigatório)
|
||||||
|
- [x] Rota POST /contato + validação + honeypot + throttle
|
||||||
|
- [x] Anti duplo-envio, estados sucesso/erro/rede, preservação de dados
|
||||||
|
- [x] E-mail via MAIL_MAILER (Resend prod / log local)
|
||||||
|
- [x] A11y do form (labels, erros ligados)
|
||||||
|
- [x] Testes feature + atualizar PublicPagesTest — Feature suite: 97 passed
|
||||||
|
|
||||||
|
## T2 — A11y/navegação
|
||||||
|
- [x] Menu mobile: Escape, retorno de foco, fallback sem-JS (app.js + noscript)
|
||||||
|
- [x] Touch targets ≥44px (header, footer, CTAs, capítulos)
|
||||||
|
- [x] Footer: ul/li + address
|
||||||
|
|
||||||
|
## T3 — SEO/metadados
|
||||||
|
- [x] Títulos "Página · Amare" (PageMeta::withBrandSuffix)
|
||||||
|
- [x] og:locale, og:site_name, Twitter cards
|
||||||
|
- [x] Favicon real + link, theme-color
|
||||||
|
- [x] Metadados corretos em 404/500 (PageMeta::forErrorPage)
|
||||||
|
- [x] noindex fora de produção (robots meta condicional)
|
||||||
|
|
||||||
|
## T4 — Conteúdo/posicionamento
|
||||||
|
- [x] Remover welcome.blade.php, home/cases, home/proof
|
||||||
|
- [x] Dedup heading final-CTA vs h1 contato; dedup disclaimers
|
||||||
|
- [x] CTA consistente mobile/desktop
|
||||||
|
- [x] Corrigir copy "Em breve..." nas listas
|
||||||
|
- [x] Rebalancear copy corporativo/social
|
||||||
|
|
||||||
|
## T5 — Performance
|
||||||
|
- [x] fetchpriority + dimensões hero (LCP)
|
||||||
|
- [x] Guarda CLS no media/image
|
||||||
|
- [x] Cache manifest de fontes
|
||||||
|
- [x] Escape JSON-LD
|
||||||
|
|
||||||
|
## T6 — Validação final
|
||||||
|
- [x] pint, phpstan, testes unit/feature/architecture — `composer quality` verde (135 testes)
|
||||||
|
- [x] Browser tests + visual regression (18 passaram; 8 baselines `.snap` atualizadas com `--update-snapshots`)
|
||||||
|
- [x] Lighthouse final (home mobile: A11y 100, Best Practices 100, SEO 69 só por noindex intencional fora de prod; contato desktop: A11y 100, BP 100)
|
||||||
|
- [x] E2E form: submit → sucesso role=status; 2 e-mails (novo briefing + confirmação) logados via MAIL_MAILER=log; fila drenada
|
||||||
|
- [x] Mobile: sem overflow horizontal nas 7 rotas (390px); menu mobile abre/fecha + Escape + retorno de foco
|
||||||
|
- [x] Relatório de entrega
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -95,7 +95,22 @@ it('loads covered public routes without console errors', function (): void {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('disables transitions when prefers-reduced-motion is reduce', function (): void {
|
it('disables transitions when prefers-reduced-motion is reduce', function (): void {
|
||||||
$page = $this->visit('/', [
|
$case = PortfolioCase::factory()->published()->create([
|
||||||
|
'slug' => 'casamento-jardim',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$routes = [
|
||||||
|
'/',
|
||||||
|
'/servicos',
|
||||||
|
'/portfolio',
|
||||||
|
'/portfolio/'.$case->slug,
|
||||||
|
'/sobre',
|
||||||
|
'/contato',
|
||||||
|
'/privacidade',
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($routes as $route) {
|
||||||
|
$page = $this->visit($route, [
|
||||||
'reducedMotion' => 'reduce',
|
'reducedMotion' => 'reduce',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -111,4 +126,5 @@ it('disables transitions when prefers-reduced-motion is reduce', function (): vo
|
|||||||
JS);
|
JS);
|
||||||
|
|
||||||
expect((float) $duration)->toBeLessThan(0.02);
|
expect((float) $duration)->toBeLessThan(0.02);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
121
tests/Browser/MotionTest.php
Normal file
121
tests/Browser/MotionTest.php
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\Service;
|
||||||
|
use App\Models\SiteSetting;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
beforeEach(function (): void {
|
||||||
|
SiteSetting::instance()->update([
|
||||||
|
'hero_title' => 'Celebrações com propósito',
|
||||||
|
'default_og_image_path' => 'media/hero.jpg',
|
||||||
|
'default_og_image_alt' => 'Capa editorial',
|
||||||
|
]);
|
||||||
|
|
||||||
|
Service::factory()->published()->featured()->create([
|
||||||
|
'title' => 'Casamentos',
|
||||||
|
'summary' => 'Assessoria completa',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps home motion in final visible state when reduced motion is preferred', function (): void {
|
||||||
|
$page = $this->visit('/', [
|
||||||
|
'reducedMotion' => 'reduce',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$state = $page->script(<<<'JS'
|
||||||
|
() => {
|
||||||
|
const hero = document.querySelector('[data-motion="dossie-hero"]');
|
||||||
|
const title = document.querySelector('#hero-heading');
|
||||||
|
const index = document.querySelector('[data-chapter-index]');
|
||||||
|
const active = document.querySelector('[data-chapter-index] [aria-current="true"]');
|
||||||
|
const titleStyle = title ? getComputedStyle(title) : null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
heroActive: hero?.classList.contains('is-active') ?? false,
|
||||||
|
titleOpacity: titleStyle ? Number.parseFloat(titleStyle.opacity) : 0,
|
||||||
|
hasIndex: Boolean(index),
|
||||||
|
hasActiveChapter: Boolean(active),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
JS);
|
||||||
|
|
||||||
|
expect($state['heroActive'])->toBeTrue();
|
||||||
|
expect($state['titleOpacity'])->toBeGreaterThan(0.9);
|
||||||
|
expect($state['hasIndex'])->toBeTrue();
|
||||||
|
expect($state['hasActiveChapter'])->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('activates the home focal opening once when motion is allowed', function (): void {
|
||||||
|
$page = $this->visit('/', [
|
||||||
|
'reducedMotion' => 'no-preference',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$page->script(<<<'JS'
|
||||||
|
() => new Promise((resolve) => {
|
||||||
|
const wait = () => {
|
||||||
|
const hero = document.querySelector('[data-motion="dossie-hero"]');
|
||||||
|
if (hero?.classList.contains('is-active')) {
|
||||||
|
resolve(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
requestAnimationFrame(wait);
|
||||||
|
};
|
||||||
|
wait();
|
||||||
|
setTimeout(() => resolve(false), 2000);
|
||||||
|
})
|
||||||
|
JS);
|
||||||
|
|
||||||
|
$active = $page->script(<<<'JS'
|
||||||
|
() => document.querySelector('[data-motion="dossie-hero"]')?.classList.contains('is-active') ?? false
|
||||||
|
JS);
|
||||||
|
|
||||||
|
expect($active)->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates the active chapter while scrolling the home dossier', function (): void {
|
||||||
|
$page = $this->visit('/', [
|
||||||
|
'reducedMotion' => 'no-preference',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$page->resize(1440, 1000);
|
||||||
|
|
||||||
|
$before = $page->script(<<<'JS'
|
||||||
|
() => document.querySelector('[data-chapter-index] [aria-current="true"]')?.getAttribute('href') ?? null
|
||||||
|
JS);
|
||||||
|
|
||||||
|
$page->script(<<<'JS'
|
||||||
|
() => {
|
||||||
|
const target = document.querySelector('#method-heading');
|
||||||
|
target?.scrollIntoView({ block: 'start' });
|
||||||
|
window.dispatchEvent(new Event('scroll'));
|
||||||
|
return Boolean(target);
|
||||||
|
}
|
||||||
|
JS);
|
||||||
|
|
||||||
|
$page->script(<<<'JS'
|
||||||
|
() => new Promise((resolve) => {
|
||||||
|
let frames = 0;
|
||||||
|
const check = () => {
|
||||||
|
const href = document.querySelector('[data-chapter-index] [aria-current="true"]')?.getAttribute('href');
|
||||||
|
if (href === '#method-heading' || frames > 60) {
|
||||||
|
resolve(href);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
frames += 1;
|
||||||
|
requestAnimationFrame(check);
|
||||||
|
};
|
||||||
|
check();
|
||||||
|
})
|
||||||
|
JS);
|
||||||
|
|
||||||
|
$after = $page->script(<<<'JS'
|
||||||
|
() => document.querySelector('[data-chapter-index] [aria-current="true"]')?.getAttribute('href') ?? null
|
||||||
|
JS);
|
||||||
|
|
||||||
|
expect($before)->not->toBeNull();
|
||||||
|
expect($after)->toBe('#method-heading');
|
||||||
|
});
|
||||||
14
tests/Feature/LivewireTemporaryUploadDiskTest.php
Normal file
14
tests/Feature/LivewireTemporaryUploadDiskTest.php
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Livewire\Features\SupportFileUploads\FileUploadConfiguration;
|
||||||
|
|
||||||
|
it('keeps livewire temporary uploads on the local disk when the default filesystem is r2', function (): void {
|
||||||
|
config(['filesystems.default' => 'r2']);
|
||||||
|
|
||||||
|
// Livewire swaps FileUploadConfiguration::disk() to tmp-for-tests under PHPUnit;
|
||||||
|
// isUsingS3() still reads the configured temp disk and drives signed R2 uploads.
|
||||||
|
expect(config('livewire.temporary_file_upload.disk'))->toBe('local')
|
||||||
|
->and(FileUploadConfiguration::isUsingS3())->toBeFalse();
|
||||||
|
});
|
||||||
@@ -9,6 +9,7 @@ use App\Models\SiteSetting;
|
|||||||
use App\Models\Testimonial;
|
use App\Models\Testimonial;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Database\Seeders\ContentSeeder;
|
use Database\Seeders\ContentSeeder;
|
||||||
|
use Database\Seeders\TestimonialsSeeder;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Livewire\Livewire;
|
use Livewire\Livewire;
|
||||||
@@ -18,6 +19,59 @@ class TestimonialsTest extends TestCase
|
|||||||
{
|
{
|
||||||
use RefreshDatabase;
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var list<array{
|
||||||
|
* quote: string,
|
||||||
|
* author_name: string,
|
||||||
|
* context: string,
|
||||||
|
* sort_order: int,
|
||||||
|
* is_featured: bool,
|
||||||
|
* published_at: string
|
||||||
|
* }>
|
||||||
|
*/
|
||||||
|
private const EXPECTED_TESTIMONIALS = [
|
||||||
|
[
|
||||||
|
'quote' => "Mi, quero agradecer você e a sua equipe por todo empenho, atenção, vocês são abençoadas.\n\nEra nítida sua preocupação em garantir que todos os detalhes planejados desta comemoração, fossem atendidos.\n\nQue você possa transformar o grande dia das noivinhas sempre com essa sua leveza!!!\n\nMuito obrigada!",
|
||||||
|
'author_name' => 'Jeniffer e Maick',
|
||||||
|
'context' => 'Casamento · 06/12/2025',
|
||||||
|
'sort_order' => 1,
|
||||||
|
'is_featured' => true,
|
||||||
|
'published_at' => '2026-08-05 00:00:00',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'quote' => "Mi, eu não tenho palavras pra agradecer você e tudo que você fez por mim e por nós na realização desse sonho. Eu tô ainda extasiada com tudo que aconteceu hoje; mas tenho certeza que sem a sua ajuda, muita coisa não aconteceria.\n\nObrigada por tudo !",
|
||||||
|
'author_name' => 'Quesia e Jhonata',
|
||||||
|
'context' => 'Casamento · 21/12/2025',
|
||||||
|
'sort_order' => 2,
|
||||||
|
'is_featured' => true,
|
||||||
|
'published_at' => '2026-08-05 00:00:00',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'quote' => 'Que equipe!! Que equipe maravilhosa!! Obrigado pelo empenho de fazer tudo como eu queria!! Obrigado por se esforçar tanto e vir de tão longe pra realizar meu sonho!! Incríveis!!',
|
||||||
|
'author_name' => 'Milena e Weslley',
|
||||||
|
'context' => 'Casamento · 13/02/2026',
|
||||||
|
'sort_order' => 3,
|
||||||
|
'is_featured' => false,
|
||||||
|
'published_at' => '2026-08-05 00:00:00',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'quote' => "Gostaríamos de agradecer por todo o acompanhamento e dedicação durante a realização do nosso casamento. Foi um dia muito especial e inesquecível para nós.\n\nDesde o início, conseguimos conduzir tudo aquilo que estávamos planejando, dentro dos horários que estipulamos, o que foi ótimo, e no grande dia sua equipe nos recebeu e tratou com muito carinho, atenção e cuidado, o que fez toda a diferença para vivermos esse momento com mais tranquilidade.\n\nTambém adoramos as sugestões e ideias para as fotos, que deixaram os registros ainda mais bonitos e espontâneos, porque não iríamos lembrar de quais poses fazer na hora.\n\nObrigada por fazer parte de um momento tão importante das nossas vidas. Desejamos muito sucesso e que muitos outros casais possam viver dias especiais através do trabalho da AMARE.",
|
||||||
|
'author_name' => 'Raquel e Pedro',
|
||||||
|
'context' => 'Casamento · 09/05/2026',
|
||||||
|
'sort_order' => 4,
|
||||||
|
'is_featured' => false,
|
||||||
|
'published_at' => '2026-08-05 00:00:00',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'quote' => "Miiii, meu amor… você e sua equipe foram impecáveis.\n\nSuperou todas as nossas expectativas. Somos eternamente gratos por fazer nosso dia acontecer muito melhor do que imaginávamos.\n\nSempre muito atenciosa e paciente.\n\nAdoramos te conhecer e estamos muito felizes em termos escolhido você para assessorar nosso dia.",
|
||||||
|
'author_name' => 'Victoria e Pedro',
|
||||||
|
'context' => 'Casamento · 24/06/2026',
|
||||||
|
'sort_order' => 5,
|
||||||
|
'is_featured' => false,
|
||||||
|
'published_at' => '2026-08-05 00:00:00',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
public function test_published_scope_excludes_unpublished_testimonials(): void
|
public function test_published_scope_excludes_unpublished_testimonials(): void
|
||||||
{
|
{
|
||||||
Testimonial::factory()->create(['published_at' => null]);
|
Testimonial::factory()->create(['published_at' => null]);
|
||||||
@@ -48,11 +102,27 @@ class TestimonialsTest extends TestCase
|
|||||||
->assertForbidden();
|
->assertForbidden();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_testimonials_seeder_maps_all_five_real_couples_from_depoimentos(): void
|
||||||
|
{
|
||||||
|
$this->runTestimonialsSeeder();
|
||||||
|
|
||||||
|
$testimonials = Testimonial::query()
|
||||||
|
->orderBy('sort_order')
|
||||||
|
->get()
|
||||||
|
->map(fn (Testimonial $testimonial): array => $this->sourceOwnedState($testimonial))
|
||||||
|
->all();
|
||||||
|
|
||||||
|
$this->assertSame(self::EXPECTED_TESTIMONIALS, $testimonials);
|
||||||
|
}
|
||||||
|
|
||||||
public function test_content_seeder_loads_five_real_couples_from_depoimentos(): void
|
public function test_content_seeder_loads_five_real_couples_from_depoimentos(): void
|
||||||
{
|
{
|
||||||
Storage::fake('public');
|
Storage::fake('public');
|
||||||
|
|
||||||
$this->seed(ContentSeeder::class);
|
$this->artisan('db:seed', [
|
||||||
|
'--class' => ContentSeeder::class,
|
||||||
|
'--no-interaction' => true,
|
||||||
|
])->assertExitCode(0);
|
||||||
|
|
||||||
$authors = Testimonial::query()->orderBy('sort_order')->pluck('author_name')->all();
|
$authors = Testimonial::query()->orderBy('sort_order')->pluck('author_name')->all();
|
||||||
|
|
||||||
@@ -64,11 +134,6 @@ class TestimonialsTest extends TestCase
|
|||||||
'Victoria e Pedro',
|
'Victoria e Pedro',
|
||||||
], $authors);
|
], $authors);
|
||||||
|
|
||||||
$this->assertNull(
|
|
||||||
Testimonial::query()->where('author_name', 'Ana Souza')->first(),
|
|
||||||
'Fictional demo authors must not remain after seeding real testimonials.',
|
|
||||||
);
|
|
||||||
|
|
||||||
$jeniffer = Testimonial::query()->where('author_name', 'Jeniffer e Maick')->first();
|
$jeniffer = Testimonial::query()->where('author_name', 'Jeniffer e Maick')->first();
|
||||||
|
|
||||||
$this->assertNotNull($jeniffer);
|
$this->assertNotNull($jeniffer);
|
||||||
@@ -77,6 +142,87 @@ class TestimonialsTest extends TestCase
|
|||||||
$this->assertSame('Casamento · 06/12/2025', $jeniffer->context);
|
$this->assertSame('Casamento · 06/12/2025', $jeniffer->context);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_testimonials_seeder_authoritatively_overwrites_source_owned_fields(): void
|
||||||
|
{
|
||||||
|
$testimonial = Testimonial::factory()->create([
|
||||||
|
'quote' => 'Depoimento desatualizado.',
|
||||||
|
'author_name' => 'Jeniffer e Maick',
|
||||||
|
'context' => 'Contexto desatualizado',
|
||||||
|
'sort_order' => 99,
|
||||||
|
'is_featured' => false,
|
||||||
|
'published_at' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->runTestimonialsSeeder();
|
||||||
|
|
||||||
|
$this->assertSame(
|
||||||
|
self::EXPECTED_TESTIMONIALS[0],
|
||||||
|
$this->sourceOwnedState($testimonial->refresh()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_testimonials_seeder_preserves_curated_photo_fields_for_known_author(): void
|
||||||
|
{
|
||||||
|
$testimonial = Testimonial::factory()->create([
|
||||||
|
'author_name' => 'Jeniffer e Maick',
|
||||||
|
'photo_path' => 'testimonials/jeniffer-e-maick.jpg',
|
||||||
|
'photo_alt' => 'Jeniffer e Maick durante o casamento',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->runTestimonialsSeeder();
|
||||||
|
|
||||||
|
$testimonial->refresh();
|
||||||
|
|
||||||
|
$this->assertSame('testimonials/jeniffer-e-maick.jpg', $testimonial->photo_path);
|
||||||
|
$this->assertSame('Jeniffer e Maick durante o casamento', $testimonial->photo_alt);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_testimonials_seeder_leaves_unrelated_testimonials_unchanged(): void
|
||||||
|
{
|
||||||
|
$unrelated = Testimonial::factory()->published()->featured()->create([
|
||||||
|
'quote' => 'Depoimento cadastrado pela equipe.',
|
||||||
|
'author_name' => 'Casal não presente na fonte',
|
||||||
|
'context' => 'Bodas · 02/08/2026',
|
||||||
|
'photo_path' => 'testimonials/casal-equipe.jpg',
|
||||||
|
'photo_alt' => 'Casal cadastrado pela equipe',
|
||||||
|
'sort_order' => 42,
|
||||||
|
]);
|
||||||
|
$originalState = $unrelated->getAttributes();
|
||||||
|
ksort($originalState);
|
||||||
|
|
||||||
|
$this->runTestimonialsSeeder();
|
||||||
|
|
||||||
|
$persisted = $unrelated->fresh();
|
||||||
|
|
||||||
|
$this->assertNotNull($persisted);
|
||||||
|
$persistedState = $persisted->getAttributes();
|
||||||
|
ksort($persistedState);
|
||||||
|
|
||||||
|
$this->assertSame($originalState, $persistedState);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_testimonials_seeder_is_idempotent_without_duplicate_authors(): void
|
||||||
|
{
|
||||||
|
$this->runTestimonialsSeeder();
|
||||||
|
$firstRunState = Testimonial::query()
|
||||||
|
->orderBy('id')
|
||||||
|
->get()
|
||||||
|
->map(fn (Testimonial $testimonial): array => $testimonial->getAttributes())
|
||||||
|
->all();
|
||||||
|
|
||||||
|
$this->runTestimonialsSeeder();
|
||||||
|
|
||||||
|
$secondRunState = Testimonial::query()
|
||||||
|
->orderBy('id')
|
||||||
|
->get()
|
||||||
|
->map(fn (Testimonial $testimonial): array => $testimonial->getAttributes())
|
||||||
|
->all();
|
||||||
|
|
||||||
|
$this->assertCount(5, $secondRunState);
|
||||||
|
$this->assertSame($firstRunState, $secondRunState);
|
||||||
|
$this->assertSame(5, Testimonial::query()->distinct()->count('author_name'));
|
||||||
|
}
|
||||||
|
|
||||||
public function test_home_renders_multi_paragraph_testimonial_quotes(): void
|
public function test_home_renders_multi_paragraph_testimonial_quotes(): void
|
||||||
{
|
{
|
||||||
SiteSetting::instance();
|
SiteSetting::instance();
|
||||||
@@ -97,4 +243,34 @@ class TestimonialsTest extends TestCase
|
|||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function runTestimonialsSeeder(): void
|
||||||
|
{
|
||||||
|
$this->artisan('db:seed', [
|
||||||
|
'--class' => TestimonialsSeeder::class,
|
||||||
|
'--no-interaction' => true,
|
||||||
|
])->assertExitCode(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* quote: string,
|
||||||
|
* author_name: string,
|
||||||
|
* context: string,
|
||||||
|
* sort_order: int,
|
||||||
|
* is_featured: bool,
|
||||||
|
* published_at: string
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
private function sourceOwnedState(Testimonial $testimonial): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'quote' => (string) $testimonial->quote,
|
||||||
|
'author_name' => (string) $testimonial->author_name,
|
||||||
|
'context' => (string) $testimonial->context,
|
||||||
|
'sort_order' => (int) $testimonial->sort_order,
|
||||||
|
'is_featured' => (bool) $testimonial->is_featured,
|
||||||
|
'published_at' => $testimonial->published_at?->format('Y-m-d H:i:s') ?? '',
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
164
tests/Feature/PublicSite/ContactBriefingTest.php
Normal file
164
tests/Feature/PublicSite/ContactBriefingTest.php
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tests\Feature\PublicSite;
|
||||||
|
|
||||||
|
use App\Mail\ContactBriefing;
|
||||||
|
use App\Mail\ContactBriefingConfirmation;
|
||||||
|
use App\Models\SiteSetting;
|
||||||
|
use Illuminate\Foundation\Http\Middleware\PreventRequestForgery;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Routing\Middleware\ThrottleRequests;
|
||||||
|
use Illuminate\Support\Facades\Mail;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class ContactBriefingTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
$this->withoutMiddleware([ThrottleRequests::class, PreventRequestForgery::class]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $overrides
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function validPayload(array $overrides = []): array
|
||||||
|
{
|
||||||
|
return array_merge([
|
||||||
|
'nome' => 'Maria Silva',
|
||||||
|
'email' => 'maria@example.com',
|
||||||
|
'telefone' => '(11) 98888-7777',
|
||||||
|
'tipo_evento' => 'Casamento',
|
||||||
|
'data_periodo' => 'novembro de 2027',
|
||||||
|
'cidade' => 'São Paulo',
|
||||||
|
'convidados' => '120',
|
||||||
|
'servico_interesse' => 'Planejamento completo',
|
||||||
|
'mensagem' => 'Queremos um casamento ao ar livre para 120 convidados.',
|
||||||
|
'privacidade' => '1',
|
||||||
|
'empresa' => '',
|
||||||
|
], $overrides);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_contact_page_renders_briefing_form_with_all_fields(): void
|
||||||
|
{
|
||||||
|
SiteSetting::instance();
|
||||||
|
|
||||||
|
$response = $this->get(route('contact'));
|
||||||
|
|
||||||
|
$response
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('<form', false)
|
||||||
|
->assertSee('action="'.route('contact.store').'"', false)
|
||||||
|
->assertSee('name="nome"', false)
|
||||||
|
->assertSee('name="email"', false)
|
||||||
|
->assertSee('name="telefone"', false)
|
||||||
|
->assertSee('name="tipo_evento"', false)
|
||||||
|
->assertSee('name="data_periodo"', false)
|
||||||
|
->assertSee('name="cidade"', false)
|
||||||
|
->assertSee('name="convidados"', false)
|
||||||
|
->assertSee('name="servico_interesse"', false)
|
||||||
|
->assertSee('name="mensagem"', false)
|
||||||
|
->assertSee('name="privacidade"', false)
|
||||||
|
->assertSee('name="empresa"', false)
|
||||||
|
->assertSee(route('privacy'), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_valid_submission_sends_briefing_and_confirmation_emails(): void
|
||||||
|
{
|
||||||
|
$settings = SiteSetting::instance();
|
||||||
|
Mail::fake();
|
||||||
|
|
||||||
|
$response = $this->post(route('contact.store'), $this->validPayload());
|
||||||
|
|
||||||
|
$response
|
||||||
|
->assertRedirect(route('contact'))
|
||||||
|
->assertSessionHas('status', 'briefing-sent');
|
||||||
|
|
||||||
|
Mail::assertQueued(ContactBriefing::class, function (ContactBriefing $mail) use ($settings): bool {
|
||||||
|
return $mail->hasTo($settings->email);
|
||||||
|
});
|
||||||
|
|
||||||
|
Mail::assertQueued(ContactBriefingConfirmation::class, function (ContactBriefingConfirmation $mail): bool {
|
||||||
|
return $mail->hasTo('maria@example.com');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_honeypot_submission_is_dropped_without_sending_emails(): void
|
||||||
|
{
|
||||||
|
SiteSetting::instance();
|
||||||
|
Mail::fake();
|
||||||
|
|
||||||
|
$response = $this->post(route('contact.store'), $this->validPayload([
|
||||||
|
'empresa' => 'http://spam.example',
|
||||||
|
]));
|
||||||
|
|
||||||
|
$response
|
||||||
|
->assertRedirect(route('contact'))
|
||||||
|
->assertSessionHas('status', 'briefing-sent');
|
||||||
|
|
||||||
|
Mail::assertNothingSent();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_submission_requires_privacy_acceptance(): void
|
||||||
|
{
|
||||||
|
SiteSetting::instance();
|
||||||
|
Mail::fake();
|
||||||
|
|
||||||
|
$response = $this->from(route('contact'))->post(route('contact.store'), $this->validPayload([
|
||||||
|
'privacidade' => '',
|
||||||
|
]));
|
||||||
|
|
||||||
|
$response->assertRedirect(route('contact'));
|
||||||
|
$response->assertSessionHasErrors('privacidade');
|
||||||
|
|
||||||
|
Mail::assertNothingSent();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_invalid_submission_returns_validation_errors(): void
|
||||||
|
{
|
||||||
|
SiteSetting::instance();
|
||||||
|
Mail::fake();
|
||||||
|
|
||||||
|
$response = $this->from(route('contact'))->post(route('contact.store'), []);
|
||||||
|
|
||||||
|
$response->assertRedirect(route('contact'));
|
||||||
|
|
||||||
|
foreach (['nome', 'email', 'telefone', 'tipo_evento', 'cidade', 'mensagem', 'privacidade'] as $field) {
|
||||||
|
$response->assertSessionHasErrors($field);
|
||||||
|
}
|
||||||
|
|
||||||
|
Mail::assertNothingSent();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_duplicate_submission_is_not_sent_twice(): void
|
||||||
|
{
|
||||||
|
SiteSetting::instance();
|
||||||
|
Mail::fake();
|
||||||
|
|
||||||
|
$payload = $this->validPayload();
|
||||||
|
|
||||||
|
$this->post(route('contact.store'), $payload)->assertRedirect(route('contact'));
|
||||||
|
$this->post(route('contact.store'), $payload)->assertRedirect(route('contact'));
|
||||||
|
|
||||||
|
Mail::assertQueued(ContactBriefing::class, 1);
|
||||||
|
Mail::assertQueued(ContactBriefingConfirmation::class, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_email_failure_does_not_block_submission(): void
|
||||||
|
{
|
||||||
|
SiteSetting::instance();
|
||||||
|
config(['mail.default' => 'mailer-inexistente']);
|
||||||
|
|
||||||
|
$response = $this->post(route('contact.store'), $this->validPayload());
|
||||||
|
|
||||||
|
$response
|
||||||
|
->assertRedirect(route('contact'))
|
||||||
|
->assertSessionHas('status', 'briefing-sent');
|
||||||
|
}
|
||||||
|
}
|
||||||
146
tests/Feature/PublicSite/MotionMarkupTest.php
Normal file
146
tests/Feature/PublicSite/MotionMarkupTest.php
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tests\Feature\PublicSite;
|
||||||
|
|
||||||
|
use App\Models\PortfolioCase;
|
||||||
|
use App\Models\Service;
|
||||||
|
use App\Models\SiteSetting;
|
||||||
|
use App\Models\Testimonial;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class MotionMarkupTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_home_exposes_focal_hero_hooks_and_chapter_index_links(): void
|
||||||
|
{
|
||||||
|
$settings = SiteSetting::instance();
|
||||||
|
$settings->update([
|
||||||
|
'hero_title' => 'Celebrações com propósito',
|
||||||
|
'default_og_image_path' => 'media/hero.jpg',
|
||||||
|
'default_og_image_alt' => 'Capa editorial',
|
||||||
|
]);
|
||||||
|
|
||||||
|
Service::factory()->published()->featured()->create(['title' => 'Casamentos']);
|
||||||
|
PortfolioCase::factory()->published()->create([
|
||||||
|
'title' => 'Caso A',
|
||||||
|
'is_featured' => true,
|
||||||
|
]);
|
||||||
|
Testimonial::factory()->published()->create(['author_name' => 'Ana']);
|
||||||
|
|
||||||
|
$response = $this->get(route('home'));
|
||||||
|
|
||||||
|
$response
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('data-motion="dossie-hero"', false)
|
||||||
|
->assertSee('data-motion-beat="seal"', false)
|
||||||
|
->assertSee('data-motion-beat="title"', false)
|
||||||
|
->assertSee('data-motion-beat="media"', false)
|
||||||
|
->assertSee('data-motion-beat="cta"', false)
|
||||||
|
->assertSee('data-chapter-index', false)
|
||||||
|
->assertSee('data-chapter-progress', false)
|
||||||
|
->assertSee('href="#hero-heading"', false)
|
||||||
|
->assertSee('href="#manifesto-heading"', false)
|
||||||
|
->assertSee('href="#services-heading"', false)
|
||||||
|
->assertSee('href="#portfolio-heading"', false)
|
||||||
|
->assertSee('href="#method-heading"', false)
|
||||||
|
->assertSee('href="#testimonials-heading"', false)
|
||||||
|
->assertSee('href="#positioning-heading"', false)
|
||||||
|
->assertSee('href="#final-cta-heading"', false)
|
||||||
|
->assertSee('data-chapter="hero"', false)
|
||||||
|
->assertSee('data-chapter="manifesto"', false)
|
||||||
|
->assertSee('data-reveal', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_chapter_index_omits_empty_cms_sections(): void
|
||||||
|
{
|
||||||
|
SiteSetting::instance();
|
||||||
|
|
||||||
|
$response = $this->get(route('home'));
|
||||||
|
|
||||||
|
$response
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('data-chapter-index', false)
|
||||||
|
->assertSee('href="#hero-heading"', false)
|
||||||
|
->assertSee('href="#manifesto-heading"', false)
|
||||||
|
->assertSee('href="#method-heading"', false)
|
||||||
|
->assertSee('href="#positioning-heading"', false)
|
||||||
|
->assertSee('href="#final-cta-heading"', false)
|
||||||
|
->assertDontSee('href="#services-heading"', false)
|
||||||
|
->assertDontSee('href="#portfolio-heading"', false)
|
||||||
|
->assertDontSee('href="#testimonials-heading"', false)
|
||||||
|
->assertDontSee('id="services-heading"', false)
|
||||||
|
->assertDontSee('id="portfolio-heading"', false)
|
||||||
|
->assertDontSee('id="testimonials-heading"', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_motion_tokens_and_runtime_exist_with_reduced_motion_guards(): void
|
||||||
|
{
|
||||||
|
$tokens = (string) file_get_contents(resource_path('css/tokens.css'));
|
||||||
|
$appCss = (string) file_get_contents(resource_path('css/app.css'));
|
||||||
|
$appJs = (string) file_get_contents(resource_path('js/app.js'));
|
||||||
|
|
||||||
|
$this->assertStringContainsString('--amare-duration-focal:', $tokens);
|
||||||
|
$this->assertStringContainsString('--amare-ease-arrival:', $tokens);
|
||||||
|
$this->assertStringContainsString('--amare-duration-focal: 0.01ms', $tokens);
|
||||||
|
|
||||||
|
$this->assertStringContainsString('[data-motion="dossie-hero"]', $appCss);
|
||||||
|
$this->assertStringContainsString('[data-reveal]', $appCss);
|
||||||
|
$this->assertStringContainsString('[data-chapter-index]', $appCss);
|
||||||
|
$this->assertStringContainsString('prefers-reduced-motion: no-preference', $appCss);
|
||||||
|
|
||||||
|
// Entrance motion must not fade text opacity — mid-fade fails WCAG contrast (axe).
|
||||||
|
$this->assertDoesNotMatchRegularExpression(
|
||||||
|
'/\[data-motion-beat="seal"\][^{]*\{[^}]*opacity:\s*0/s',
|
||||||
|
$appCss,
|
||||||
|
);
|
||||||
|
$this->assertDoesNotMatchRegularExpression(
|
||||||
|
'/\[data-motion="page-open"\]:not\(\.is-active\)[^{]*\{[^}]*opacity:\s*0/s',
|
||||||
|
$appCss,
|
||||||
|
);
|
||||||
|
$this->assertDoesNotMatchRegularExpression(
|
||||||
|
'/\[data-reveal\]:not\(\.is-revealed\)[^{]*\{[^}]*opacity:\s*0/s',
|
||||||
|
$appCss,
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertFileExists(resource_path('js/motion.js'));
|
||||||
|
$this->assertStringContainsString("import './motion.js'", $appJs);
|
||||||
|
$this->assertStringContainsString('prefers-reduced-motion', (string) file_get_contents(resource_path('js/motion.js')));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_internal_pages_expose_shared_page_open_hook(): void
|
||||||
|
{
|
||||||
|
SiteSetting::instance();
|
||||||
|
$case = PortfolioCase::factory()->published()->create([
|
||||||
|
'slug' => 'casamento-jardim',
|
||||||
|
'title' => 'Casamento Jardim',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->get(route('services.index'))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('data-motion="page-open"', false);
|
||||||
|
|
||||||
|
$this->get(route('portfolio.index'))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('data-motion="page-open"', false);
|
||||||
|
|
||||||
|
$this->get(route('portfolio.show', $case->slug))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('data-motion="page-open"', false);
|
||||||
|
|
||||||
|
$this->get(route('about'))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('data-motion="page-open"', false);
|
||||||
|
|
||||||
|
$this->get(route('contact'))
|
||||||
|
->assertOk()
|
||||||
|
->assertDontSee('data-motion="page-open"', false);
|
||||||
|
|
||||||
|
$this->get(route('privacy'))
|
||||||
|
->assertOk()
|
||||||
|
->assertDontSee('data-motion="page-open"', false);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -134,8 +134,10 @@ class PublicPagesTest extends TestCase
|
|||||||
->assertDontSee('Fortaleza')
|
->assertDontSee('Fortaleza')
|
||||||
->assertSee('https://instagram.com/amare', false)
|
->assertSee('https://instagram.com/amare', false)
|
||||||
->assertSee('min-h-[calc(100dvh-14rem)]', false)
|
->assertSee('min-h-[calc(100dvh-14rem)]', false)
|
||||||
->assertDontSee('<form', false)
|
->assertSee('<form', false)
|
||||||
->assertDontSee('</form>', false);
|
->assertSee('name="nome"', false)
|
||||||
|
->assertSee('name="privacidade"', false)
|
||||||
|
->assertSee(route('privacy'), false);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_site_setting_defaults_use_sao_paulo_and_official_email(): void
|
public function test_site_setting_defaults_use_sao_paulo_and_official_email(): void
|
||||||
|
|||||||
22
tests/Feature/TrustedProxyTest.php
Normal file
22
tests/Feature/TrustedProxyTest.php
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class TrustedProxyTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_forwarded_proto_https_is_recognized_behind_proxy(): void
|
||||||
|
{
|
||||||
|
$response = $this->get('/up', [
|
||||||
|
'HTTP_X_FORWARDED_PROTO' => 'https',
|
||||||
|
'HTTP_X_FORWARDED_FOR' => '203.0.113.10',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
$this->assertTrue(request()->secure());
|
||||||
|
$this->assertSame('203.0.113.10', request()->ip());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user