Compare commits

..

15 Commits

Author SHA1 Message Date
2b4e6a7d1c test: regenerate visual baselines from CI environment 2026-08-05 23:21:54 -03:00
f5830930bc Merge branch '_base-main' into feat/frontend-audit
# Conflicts:
#	AGENTS.md
#	tasks.md
2026-08-05 23:10:37 -03:00
8343867072 feat: frontend audit — formulário de contato, a11y, SEO, performance e conteúdo 2026-08-05 23:06:23 -03:00
8a12b7d82c chore: add husky pre-commit and pre-push hooks (#12) 2026-08-05 23:02:46 -03:00
246410803d chore: add husky pre-commit and pre-push hooks 2026-08-05 22:58:00 -03:00
af8484c74e fix: keep motion entrance at full opacity for contrast
Axe sampled hero/page-open text mid opacity fade (~0.58), reporting
false WCAG failures. Animate transform/clip-path only.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 20:28:40 -03:00
35bc5a59d9 feat: Dossiê vivo motion for the public site (#10)
* feat: add Dossiê vivo motion to the public site

Introduce a focal Home opening, chapter index, and restrained continuity
reveals while keeping reduced-motion and visual baselines intact.

Co-authored-by: Cursor <cursoragent@cursor.com>

* test: refresh visual baselines from CI Ubuntu screenshots

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 20:16:51 -03:00
27711ad0c2 feat: add production testimonials seeder (#11)
Canonical upsert for five authorized couples so staging/prod
can load depoimentos without DatabaseSeeder/ContentSeeder.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 20:11:33 -03:00
af31738021 test: align Livewire temp-disk assertion with PHPUnit disk swap
FileUploadConfiguration: :disk() always returns tmp-for-tests under PHPUnit; assert config + isUsingS3() instead.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 19:53:19 -03:00
286522d8a2 fix: keep Livewire temp uploads local when using R2
Avoid browser PUT to R2 (CORS) by defaulting Filament/Livewire temp disk to local, and document Dokploy tinker + R2 CORS gotchas.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 00:11:32 -03:00
3e68192cf3 fix: join Dokploy network so Compose reaches managed Postgres (#9)
Migrate could not resolve the database internal hostname because the
Compose stack used an isolated project network. Attach services to the
external dokploy-network used by Dokploy Postgres.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 23:15:52 -03:00
1a31dd1cf6 fix: surface Dokploy API errors and normalize panel URL (#8)
curl -f hid 404 bodies; strip accidental /api on DOKPLOY_URL so compose.deploy diagnostics are actionable.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 23:14:12 -03:00
9060036024 test: refresh visual baselines from CI Ubuntu screenshots (#7)
Heritage Editorial redesign snaps were generated outside CI and fail
assertScreenshotMatches on FrankenPHP/Ubuntu Chromium. Promote the
browser-failure-artifacts PNGs from run 30729267028 into Pest snaps.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 22:23:58 -03:00
7f4ea01f4b feat: prepare Dokploy staging and production deploy pipeline (#6)
Publish immutable FrankenPHP images to GHCR, auto-deploy staging after CI,
and promote the same digest to production with smoke, backup, and rollback docs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 23:40:13 -03:00
cf1589c916 feat: recreate public frontend with Heritage Editorial identity (#5)
* feat: recreate public frontend with Heritage Editorial identity

Replace placeholder visual system with EB Garamond/olive tokens, brand assets, editorial home narrative, São Paulo settings, real testimonials, and regenerated visual baselines.

Co-authored-by: Cursor <cursoragent@cursor.com>

* docs: archive recreate-public-frontend and sync Heritage Editorial specs

Merge delta requirements into main OpenSpec capabilities and move the completed change into the dated archive.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 23:39:00 -03:00
85 changed files with 3190 additions and 561 deletions

View File

@@ -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
View 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

View 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
View File

@@ -0,0 +1,3 @@
#!/usr/bin/env sh
composer pint:check && composer phpstan

33
.husky/pre-push Executable file
View 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

View File

@@ -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.

View File

@@ -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.

View File

@@ -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).

View File

@@ -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)

View 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');
}
}

View File

@@ -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.'.',
), ),
]); ]);

View File

@@ -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.'.',
), ),
]); ]);

View File

@@ -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.'.',
), ),
]); ]);

View 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.',
];
}
}

View 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',
);
}
}

View 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',
);
}
}

View File

@@ -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')) {

View File

@@ -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(

View File

@@ -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);

View 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
View 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

View File

@@ -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
View 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
View 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.

View File

@@ -15,15 +15,17 @@ Fases 01 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 01 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.

View File

@@ -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.

View File

@@ -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

View File

@@ -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 (13) also complete

17
package-lock.json generated
View File

@@ -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",

View File

@@ -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
View 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

View File

@@ -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);
}
}
} }

View File

@@ -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;
} }
*, *,

View File

@@ -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
View 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();
}

View File

@@ -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 }}"
/> />

View File

@@ -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;

View File

@@ -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

View 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

View File

@@ -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>

View File

@@ -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"
/> />

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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']) }}
> >

View File

@@ -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

View File

@@ -0,0 +1,5 @@
Olá, {{ $name }}.
Recebemos sua mensagem e retornaremos em breve pelo canal informado.
{{ $brandName }}

View File

@@ -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>

View File

@@ -0,0 +1,7 @@
Novo briefing de contato
@foreach ($fields as $label => $value)
{{ $label }}: {{ $value ?: '—' }}
@endforeach
Enviado pelo site {{ config('app.name') }}.

View 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>&copy; {{ now()->year }} {{ $siteSettings->brand_name }}. Todos os direitos reservados.</p> <p>&copy; {{ 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>

View File

@@ -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>

View File

@@ -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

View File

@@ -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" />

View File

@@ -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

View File

@@ -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">

View File

@@ -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

View File

@@ -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
View 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
View 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
View 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

View File

@@ -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);
}
}); });

View 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');
});

View 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();
});

View File

@@ -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') ?? '',
];
}
} }

View 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');
}
}

View 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);
}
}

View File

@@ -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

View 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());
}
}