Compare commits

..

17 Commits

Author SHA1 Message Date
f36ee534af fix: make staging initialization failures fatal 2026-08-06 11:41:58 -03:00
4b440b7e5b Merge pull request #21 from manoel-freitas/fix/seed-resilient
fix(deploy): array command form + resilient seed
2026-08-06 11:27:48 -03:00
5949fad9b1 fix(deploy): array command form for dokploy + resilient seed
sh -c via folded scalar broke dokoy compose deploy; use array command.
Seed+media best-effort so web starts even if R2 seed errors.
2026-08-06 11:23:13 -03:00
d054d04ab9 Merge pull request #20 from manoel-freitas/fix/seed-resilient
fix(deploy): keep staging web up when content seed is best-effort
2026-08-06 11:16:20 -03:00
db4453b165 fix(deploy): keep staging web up when content seed is best-effort
Seed+media step made non-fatal so a transient R2 write issue
doesn't block web/queue/scheduler from starting. Seed output
preserved in dokploy deployment logs for diagnosis.
2026-08-06 11:11:45 -03:00
946939ba94 Merge pull request #19 from manoel-freitas/fix/seed-to-active-disk
fix(deploy): seed content on staging deploy
2026-08-06 11:00:37 -03:00
10a1f0dc7c fix(deploy): run seed + media:generate-variants after migrate
staging R2 was empty — migrate ran but no seeder, so ContentSeeder
never placed fixtures on the configured disk (r2) and media:generate-variants
had nothing for image paths. Staging deploy now seeds content.
2026-08-06 10:55:58 -03:00
c04790ee1f Merge pull request #18 from manoel-freitas/fix/seed-to-active-disk
fix: seed content images onto active filesystem disk
2026-08-06 10:21:14 -03:00
edf1c48050 fix: seed content images onto active filesystem disk
Preview/prod use FILESYSTEM_DISK=r2; seeder wrote only to public,
so media.hellomanoel.com URLs 404/403. Put fixtures on active disk.
2026-08-06 10:16:23 -03:00
3c711a74f3 Merge pull request #17 from manoel-freitas/fix/seed-fixtures-in-image
fix: ship seed image fixtures inside Docker image
2026-08-06 10:05:22 -03:00
88d63f349d fix: ship seed image fixtures inside Docker image
Move demo JPEGs from tests/fixtures (dockerignored) to
database/fixtures so ContentSeeder works in the container.
2026-08-06 10:03:26 -03:00
e0390ff333 Merge pull request #16 from manoel-freitas/feat/visual-placeholders
feat: visual placeholders, type scale, CTAs and about image
2026-08-06 09:54:52 -03:00
321f0cee11 fix: restore lazy loading and CI visual baselines
Drop test-wide eager override that broke MediaImageComponentTest.
Replace local Chromium baselines with CI FrankenPHP host captures.
2026-08-06 09:50:48 -03:00
1d23e16625 merge: origin/main into feat/visual-placeholders
Resolve conflicts keeping DESIGN.md type scale, main blade fallbacks,
and StableScreenshot visual helper. Regenerate browser baselines.
2026-08-06 09:42:37 -03:00
33788af172 feat: visual placeholders, type scale, CTAs and about image
Replace tiny JPEG stubs with per-slot Unsplash editorial placeholders,
align public type scale to DESIGN.md display/headline tokens, add
final-cta on all subpages, about_image_path on site settings, and harden
visual regression with solid-color VisualContentSeeder fixtures.
2026-08-06 09:38:13 -03:00
119711d6f0 chore: mark browser CI gate tasks complete in tasks.md 2026-08-06 09:09:08 -03:00
f09ea83071 fix: stabilize and diagnose browser CI gate (#15)
* fix: stabilize and diagnose browser CI gate

- Replace networkidle screenshot wait (5s client timeout, flaky with
  long-lived connections) with readyState + fonts-loaded wait and fixed
  settle in a StableScreenshot helper.
- Export standalone diff/expected/actual PNGs on visual mismatch so CI
  artifacts are directly viewable (vendor only writes an HTML diff view).
- Create .env in CI test jobs; without it Laravel's env bootstrap emits a
  file_get_contents warning on every test.
- Bump checkout/cache actions to v5 (Node 20 deprecation).
- Gitignore tests/Browser/Screenshots.

* docs: track browser CI gate task status in tasks.md
2026-08-06 08:59:03 -03:00
55 changed files with 277 additions and 50 deletions

View File

@@ -56,6 +56,9 @@ final class MediaGenerateVariantsCommand extends Command
if ($settings && filled($settings->default_og_image_path)) {
$paths[] = (string) $settings->default_og_image_path;
}
if ($settings && filled($settings->about_image_path)) {
$paths[] = (string) $settings->about_image_path;
}
foreach (Service::query()->whereNotNull('cover_image_path')->pluck('cover_image_path') as $path) {
$paths[] = (string) $path;

View File

@@ -228,6 +228,12 @@ class ManageSiteSettings extends Page
->addActionLabel('Adicionar rede'),
])
->columns(2),
Section::make('Página Sobre')
->schema([
PublicImageUploadRules::fileUpload('about_image_path', 'Imagem da página Sobre', 'content/about'),
PublicImageUploadRules::altTextField('about_image_alt', 'about_image_path'),
])
->columns(2),
Section::make('SEO padrão')
->schema([
TextInput::make('default_meta_title')

View File

@@ -16,6 +16,8 @@ use Illuminate\Database\Eloquent\Model;
* @property bool $analytics_enabled
* @property string|null $default_og_image_path
* @property string|null $default_og_image_alt
* @property string|null $about_image_path
* @property string|null $about_image_alt
* @property string|null $logo_path
* @property string|null $logo_alt
*/
@@ -30,6 +32,8 @@ use Illuminate\Database\Eloquent\Model;
'hero_secondary_cta_label',
'hero_note',
'about_summary',
'about_image_path',
'about_image_alt',
'manifesto_title',
'manifesto_lead',
'manifesto_body',

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 436 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 622 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 499 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 235 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 360 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 330 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 302 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 337 KiB

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('site_settings', function (Blueprint $table): void {
$table->string('about_image_path')->nullable()->after('about_summary');
$table->string('about_image_alt')->nullable()->after('about_image_path');
});
}
public function down(): void
{
Schema::table('site_settings', function (Blueprint $table): void {
$table->dropColumn(['about_image_path', 'about_image_alt']);
});
}
};

View File

@@ -8,6 +8,7 @@ use App\Models\PortfolioCase;
use App\Models\PortfolioImage;
use App\Models\Service;
use App\Models\SiteSetting;
use App\Support\PublicImageUploadRules;
use Illuminate\Database\Seeder;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\File;
@@ -36,6 +37,8 @@ class ContentSeeder extends Seeder
'hero_secondary_cta_label' => 'Conheça nosso olhar',
'hero_note' => 'Planejamento cuidadoso, comunicação clara e execução segura — do primeiro encontro ao último detalhe.',
'about_summary' => 'Assessoria boutique especializada em experiências memoráveis em São Paulo.',
'about_image_path' => $this->copyFixture('about-image.jpg', 'content/about/about-image.jpg'),
'about_image_alt' => 'Mesa de planejamento com caderno, café e guardanapos de pano',
'manifesto_title' => 'Sofisticação que também se traduz em organização.',
'manifesto_lead' => 'Um evento memorável não nasce apenas de uma boa estética. Ele depende de decisões bem conduzidas, fornecedores alinhados e atenção constante ao que realmente importa.',
'manifesto_body' => 'A Amare combina sensibilidade e precisão para criar encontros coerentes com cada cliente, marca e ocasião — sem fórmulas prontas, excessos ou ruído.',
@@ -91,7 +94,7 @@ class ContentSeeder extends Seeder
['slug' => $service['slug']],
[
...$service,
'cover_image_path' => $this->copyFixture('service-cover.jpg', 'content/services/'.$service['slug'].'.jpg'),
'cover_image_path' => $this->copyFixture('service-'.$service['slug'].'.jpg', 'content/services/'.$service['slug'].'.jpg'),
'cover_image_alt' => 'Capa do serviço '.$service['title'],
'published_at' => Carbon::parse(self::SEED_TIMESTAMP),
],
@@ -148,7 +151,7 @@ class ContentSeeder extends Seeder
['slug' => $caseData['slug']],
[
...$caseData,
'cover_image_path' => $this->copyFixture('portfolio-cover.jpg', 'content/portfolio/'.$caseData['slug'].'-cover.jpg'),
'cover_image_path' => $this->copyFixture('case-'.$caseData['slug'].'.jpg', 'content/portfolio/'.$caseData['slug'].'-cover.jpg'),
'cover_image_alt' => 'Capa do caso '.$caseData['title'],
'is_featured' => true,
'published_at' => Carbon::parse(self::SEED_TIMESTAMP),
@@ -160,7 +163,7 @@ class ContentSeeder extends Seeder
foreach ([1, 2] as $index) {
PortfolioImage::query()->create([
'portfolio_case_id' => $case->id,
'path' => $this->copyFixture('gallery.jpg', 'content/portfolio/'.$caseData['slug'].'-gallery-'.$index.'.jpg'),
'path' => $this->copyFixture('gallery-'.$caseData['slug'].'-'.$index.'.jpg', 'content/portfolio/'.$caseData['slug'].'-gallery-'.$index.'.jpg'),
'alt_text' => 'Galeria '.$caseData['title'].' '.$index,
'caption' => $index === 1 ? 'Detalhe da decoração' : null,
'sort_order' => $index,
@@ -171,8 +174,10 @@ class ContentSeeder extends Seeder
private function copyFixture(string $fixtureName, string $destination): string
{
$source = base_path('tests/fixtures/images/'.$fixtureName);
Storage::disk('public')->put($destination, File::get($source));
$source = base_path('database/fixtures/images/'.$fixtureName);
$disk = PublicImageUploadRules::disk();
Storage::disk($disk)->put($destination, File::get($source), 'public');
return $destination;
}

View File

@@ -11,7 +11,6 @@ use App\Models\SiteSetting;
use App\Models\Testimonial;
use Illuminate\Database\Seeder;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Storage;
/**
@@ -44,6 +43,8 @@ class VisualContentSeeder extends Seeder
'hero_secondary_cta_label' => 'Conheça nosso olhar',
'hero_note' => 'Planejamento cuidadoso, comunicação clara e execução segura — do primeiro encontro ao último detalhe.',
'about_summary' => 'Assessoria boutique especializada em experiências memoráveis em São Paulo.',
'about_image_path' => $this->writeSolidJpeg('visual/about/about-image.jpg', 1200, 900, [232, 228, 218]),
'about_image_alt' => 'Imagem editorial da página Sobre',
'manifesto_title' => 'Sofisticação que também se traduz em organização.',
'manifesto_lead' => 'Um evento memorável não nasce apenas de uma boa estética. Ele depende de decisões bem conduzidas, fornecedores alinhados e atenção constante ao que realmente importa.',
'manifesto_body' => 'A Amare combina sensibilidade e precisão para criar encontros coerentes com cada cliente, marca e ocasião — sem fórmulas prontas, excessos ou ruído.',
@@ -58,7 +59,7 @@ class VisualContentSeeder extends Seeder
],
'default_meta_title' => 'Amare Assessoria de Eventos',
'default_meta_description' => 'Assessoria premium para casamentos e eventos corporativos em São Paulo.',
'default_og_image_path' => $this->copyFixture('og-default.jpg', 'visual/og/og-default.jpg'),
'default_og_image_path' => $this->writeSolidJpeg('visual/og/og-default.jpg', 1600, 1067, [85, 107, 47]),
'default_og_image_alt' => 'Identidade visual da Amare Assessoria de Eventos',
'analytics_enabled' => false,
'analytics_script' => null,
@@ -89,7 +90,7 @@ class VisualContentSeeder extends Seeder
['slug' => $service['slug']],
[
...$service,
'cover_image_path' => $this->copyFixture('service-cover.jpg', 'visual/services/'.$service['slug'].'.jpg'),
'cover_image_path' => $this->writeSolidJpeg('visual/services/'.$service['slug'].'.jpg', 1600, 1000, [196, 200, 184]),
'cover_image_alt' => 'Capa do serviço '.$service['title'],
'published_at' => Carbon::parse(self::SEED_TIMESTAMP),
],
@@ -133,7 +134,7 @@ class VisualContentSeeder extends Seeder
['slug' => $caseData['slug']],
[
...$caseData,
'cover_image_path' => $this->copyFixture('portfolio-cover.jpg', 'visual/portfolio/'.$caseData['slug'].'-cover.jpg'),
'cover_image_path' => $this->writeSolidJpeg('visual/portfolio/'.$caseData['slug'].'-cover.jpg', 1600, 1200, [240, 238, 233]),
'cover_image_alt' => 'Capa do caso '.$caseData['title'],
'is_featured' => true,
'published_at' => Carbon::parse(self::SEED_TIMESTAMP),
@@ -144,7 +145,7 @@ class VisualContentSeeder extends Seeder
PortfolioImage::query()->create([
'portfolio_case_id' => $case->id,
'path' => $this->copyFixture('gallery.jpg', 'visual/portfolio/'.$caseData['slug'].'-gallery-1.jpg'),
'path' => $this->writeSolidJpeg('visual/portfolio/'.$caseData['slug'].'-gallery-1.jpg', 1600, 1200, [228, 226, 221]),
'alt_text' => 'Galeria '.$caseData['title'].' 1',
'caption' => 'Detalhe da decoração',
'sort_order' => 1,
@@ -186,10 +187,21 @@ class VisualContentSeeder extends Seeder
}
}
private function copyFixture(string $fixtureName, string $destination): string
/**
* @param array{0: int, 1: int, 2: int} $rgb
*/
private function writeSolidJpeg(string $destination, int $width, int $height, array $rgb): string
{
$source = base_path('tests/fixtures/images/'.$fixtureName);
Storage::disk('public')->put($destination, File::get($source));
$image = imagecreatetruecolor($width, $height);
$color = imagecolorallocate($image, $rgb[0], $rgb[1], $rgb[2]);
imagefilledrectangle($image, 0, 0, $width, $height, $color);
ob_start();
imagejpeg($image, null, 90);
$binary = (string) ob_get_clean();
imagedestroy($image);
Storage::disk('public')->put($destination, $binary);
return $destination;
}

View File

@@ -14,7 +14,7 @@ services:
restart: "no"
env_file:
- .env
command: ["php", "artisan", "migrate", "--force", "--no-interaction"]
command: ["/app/scripts/deploy/initialize-application.sh"]
networks:
- dokploy-network

View File

@@ -17,7 +17,7 @@ Promote (manual) → retag same digest as :production (no rebuild)
| Piece | Detail |
|---|---|
| Compose file | [`docker-compose.deploy.yml`](../../docker-compose.deploy.yml) |
| Processes | `migrate` (one-shot) → `web` / `queue` / `scheduler` |
| Processes | `migrate` initialization (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 |
@@ -145,7 +145,7 @@ HTTP 404 from `compose.deploy` usually means the compose id is wrong (Applicatio
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.
3. Calls Dokploy `compose.deploy`; initialization migrates, runs `ContentSeeder`, and generates media variants before the app services start.
4. Runs [`scripts/deploy/smoke.sh`](../../scripts/deploy/smoke.sh) against `STAGING_URL`.
### Production (manual)
@@ -155,7 +155,11 @@ HTTP 404 from `compose.deploy` usually means the compose id is wrong (Applicatio
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.
4. Deploys production compose; initialization runs migrations only, then smoke.
The one-shot initializer is a dependency of `web`, `queue`, and `scheduler`. Migration failure is fatal in every environment. In staging, `ContentSeeder` and `media:generate-variants` also run automatically and are fatal: if any initialization command fails, application services do not start. Production and every non-staging environment remain migrate-only.
When Dokploy reports a failed deployment, [`scripts/deploy/dokploy-deploy.sh`](../../scripts/deploy/dokploy-deploy.sh) prints the latest deployment metadata and fetches the last 1,000 log lines through `deployment.readLogs`. Log retrieval is best-effort and cannot hide the original deployment failure.
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.
@@ -192,9 +196,9 @@ XDG_CONFIG_HOME=/tmp php artisan tinker --execute="echo \\App\\Models\\User::que
Never reuse `admin@amare.local` / `password`.
### Load authorized testimonials after migrations
### Load authorized testimonials in production
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):
Staging receives the five authorized testimonials through the automatic `ContentSeeder`. Production is migrate-only, so manually load them there after migrations. From Dokploy, open a terminal on the production `web` service (or run an equivalent one-off process):
```bash
php artisan db:seed --class='Database\Seeders\TestimonialsSeeder' --force --no-interaction
@@ -219,7 +223,7 @@ 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.
Never run `DatabaseSeeder` in staging or production because it creates local credentials. Do not run `ContentSeeder` in production: its broad content fixtures are intended for staging initialization only. Production testimonial loading remains the deliberate, narrowly scoped manual operation above.
## Backup and restore

View File

@@ -18,6 +18,13 @@
--text-3xl: var(--amare-text-3xl);
--text-4xl: var(--amare-text-4xl);
--text-display: clamp(3rem, 7.7vw, 5.875rem);
--text-display--line-height: 0.94;
--text-display--letter-spacing: -0.01em;
--text-headline: clamp(2.375rem, 5.2vw, 4rem);
--text-headline--line-height: 1.02;
--spacing-1: var(--amare-space-1);
--spacing-2: var(--amare-space-2);
--spacing-3: var(--amare-space-3);

View File

@@ -5,7 +5,7 @@
<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" data-reveal>
<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">Do casamento ao evento corporativo, tudo começa com uma boa conversa.</h2>
<h2 id="final-cta-heading" class="text-headline font-medium text-amare-text">Do casamento ao evento corporativo, tudo começa com uma boa conversa.</h2>
<p class="mx-auto max-w-2xl text-amare-muted">
Compartilhe as primeiras informações do seu evento. A Amare retorna para entender o contexto e orientar os próximos passos.
</p>

View File

@@ -17,7 +17,7 @@
@endif
</div>
<h1 id="hero-heading" data-motion-beat="title" class="max-w-3xl text-4xl font-medium leading-tight text-amare-text md:text-5xl">
<h1 id="hero-heading" data-motion-beat="title" class="max-w-3xl text-display font-medium text-amare-text">
{{ $settings->hero_title ?: 'Celebrações com propósito' }}
</h1>

View File

@@ -12,7 +12,7 @@
<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>
<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-headline font-medium text-amare-text">{{ $title }}</h2>
<p class="max-w-2xl text-xl leading-relaxed text-amare-text">{{ $lead }}</p>
<p class="max-w-2xl text-amare-text-muted">{{ $body }}</p>
</div>

View File

@@ -11,7 +11,7 @@
<div class="container-amare grid gap-10 md:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)] md:items-start">
<div class="space-y-3" data-reveal>
<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-headline font-medium text-amare-text">Cuidado orientado por processo.</h2>
<p class="text-amare-text-muted">{{ $intro }}</p>
</div>

View File

@@ -8,7 +8,7 @@
<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>
<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-headline font-medium">Celebrações que ganham forma com intenção.</h2>
<p class="max-w-2xl text-amare-accent-text/80">Recortes de eventos conduzidos com escuta, direção e presença em cada etapa.</p>
</div>
</div>

View File

@@ -11,7 +11,7 @@
<div class="container-amare grid gap-10 md:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]">
<div class="space-y-3" data-reveal>
<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-headline font-medium text-amare-text">Presença que organiza o essencial.</h2>
</div>
<div class="space-y-8" data-reveal>
<p class="max-w-2xl text-xl leading-relaxed text-amare-text">{{ $summary }}</p>

View File

@@ -3,10 +3,10 @@
])
@if ($testimonials->isNotEmpty())
<section aria-labelledby="testimonials-heading" class="border-b border-amare-border bg-amare-bg-muted py-16" data-chapter="testimonials">
<section aria-labelledby="testimonials-heading" class="border-b border-amare-border bg-amare-bg py-16" data-chapter="testimonials">
<div class="container-amare space-y-8">
<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-medium text-amare-text">Depoimentos</h2>
<p class="text-amare-text-muted">Quem celebrou com a Amare conta como foi a experiência.</p>
</div>

View File

@@ -33,7 +33,7 @@
$resolvedWidth = $width ?? $dimensions['width'] ?? null;
$resolvedHeight = $height ?? $dimensions['height'] ?? null;
$loadingValue = $loading === 'eager' ? 'eager' : 'lazy';
$loadingValue = $loading;
@endphp
<img

View File

@@ -95,7 +95,6 @@
<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>
<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>
<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>

View File

@@ -12,12 +12,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">
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">A Amare</p>
<h1 class="text-4xl font-medium tracking-tight text-amare-text md:text-5xl">Humana no cuidado. Precisa na entrega.</h1>
<h1 class="text-headline font-medium tracking-tight text-amare-text">Humana no cuidado. Precisa na entrega.</h1>
<p class="text-lg text-amare-muted">{{ $siteSettings->about_summary }}</p>
<p class="text-amare-muted">
A {{ $siteSettings->brand_name }} atua em {{ $city }} com foco em planejamento completo,
presença no dia do evento e uma condução serena do início ao fim.
</p>
@if (filled($siteSettings->about_image_path))
<div class="overflow-hidden bg-amare-bg-deep">
<x-media.image
:path="$siteSettings->about_image_path"
:alt="$siteSettings->about_image_alt ?: $siteSettings->brand_name"
loading="lazy"
sizes="(max-width: 768px) 100vw, 50vw"
class="img-editorial aspect-[4/3] w-full object-cover"
/>
</div>
@endif
</div>
<ul class="space-y-4 border-t border-amare-border pt-6" aria-label="Princípios da Amare">
@@ -30,4 +42,6 @@
</ul>
</div>
</div>
<x-home.final-cta :settings="$siteSettings" />
@endsection

View File

@@ -5,7 +5,7 @@
<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">
<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-headline font-medium tracking-tight text-amare-text">Todo grande encontro começa com uma boa conversa.</h1>
<p class="max-w-2xl text-lg text-amare-muted">
Conte sobre o seu evento no briefing abaixo. Retornaremos com uma proposta sob medida e sem compromisso.
</p>

View File

@@ -5,7 +5,7 @@
<div class="container-amare space-y-10 py-16 md:py-24">
<div class="max-w-2xl space-y-4">
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Portfólio</p>
<h1 class="text-4xl font-medium tracking-tight text-amare-text md:text-5xl">Atmosferas que contam histórias.</h1>
<h1 class="text-headline font-medium tracking-tight text-amare-text">Atmosferas que contam histórias.</h1>
<p class="text-lg text-amare-muted">Casos conduzidos com atenção a ritmo, composição e cada detalhe da experiência.</p>
</div>
@@ -43,4 +43,6 @@
<p class="text-sm text-amare-accent">Imagens demonstrativas enquanto o acervo autorizado da Amare está em organização.</p>
</div>
</div>
<x-home.final-cta :settings="$siteSettings" />
@endsection

View File

@@ -8,7 +8,7 @@
@if (filled($case->event_type))
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">{{ $case->event_type }}</p>
@endif
<h1 class="text-4xl font-medium tracking-tight text-amare-text md:text-5xl">{{ $case->title }}</h1>
<h1 class="text-headline font-medium tracking-tight text-amare-text">{{ $case->title }}</h1>
<p class="text-lg text-amare-muted">{{ $case->summary }}</p>
<p class="text-sm break-words text-amare-muted">
@if ($case->city){{ $case->city }}@endif
@@ -71,4 +71,6 @@
</section>
@endif
</article>
<x-home.final-cta :settings="$siteSettings" />
@endsection

View File

@@ -5,7 +5,7 @@
<div class="container-amare space-y-10 py-16 md:py-24">
<div class="max-w-2xl space-y-4">
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Serviços</p>
<h1 class="text-4xl font-medium tracking-tight text-amare-text md:text-5xl">Uma mesma excelência, diferentes ocasiões.</h1>
<h1 class="text-headline font-medium tracking-tight text-amare-text">Uma mesma excelência, diferentes ocasiões.</h1>
<p class="text-lg text-amare-muted">O escopo é construído de acordo com o momento do projeto, o nível de apoio necessário e a complexidade de cada evento.</p>
</div>
@@ -41,4 +41,6 @@
@endif
</div>
</div>
<x-home.final-cta :settings="$siteSettings" />
@endsection

View File

@@ -102,6 +102,17 @@ while (( SECONDS < deadline )); do
error|failed|failure)
echo "Dokploy deployment failed." >&2
echo "$latest" | jq . >&2 || true
deployment_id="$(echo "$latest" | jq -r '.deploymentId // .id // empty')"
if [[ -z "$deployment_id" ]]; then
echo "Dokploy deployment logs could not be retrieved: deployment metadata has no id." >&2
elif deployment_logs="$(api GET "/deployment.readLogs?deploymentId=${deployment_id}&tail=1000")"; then
echo "Dokploy deployment logs:" >&2
printf '%s\n' "$deployment_logs" >&2
else
echo "Dokploy deployment logs could not be retrieved; the original deployment failure remains authoritative." >&2
fi
exit 1
;;
esac

View File

@@ -0,0 +1,10 @@
#!/bin/sh
set -eu
php artisan migrate --force --no-interaction
if [ "${APP_ENV:-}" = "staging" ]; then
php artisan db:seed --class=ContentSeeder --force --no-interaction
php artisan media:generate-variants
fi

View File

@@ -75,5 +75,5 @@
- [x] CI: `cp .env.example .env` nos jobs de teste; checkout/cache v5
- [x] Gitignore tests/Browser/Screenshots
- [x] Unit test MismatchScreenshotExporter; pint/phpstan/unit/feature verdes
- [ ] Push + PR + CI browser verde
- [ ] Refresh baselines de CI se necessário (timing mudou ligeiramente)
- [x] Push + PR + CI browser verde (PR #15 merge f09ea83, run 31098941038)
- [x] Refresh baselines de CI se necessário (não necessário — browser passou com baselines atuais)

View File

@@ -0,0 +1,112 @@
<?php
declare(strict_types=1);
use Symfony\Component\Process\Process;
function runDeployInitializationScript(string $environment, ?string $failingCommand = null, int $failureCode = 1): array
{
$temporaryDirectory = sys_get_temp_dir().'/amare-deploy-initialization-'.bin2hex(random_bytes(8));
$fakeBinDirectory = $temporaryDirectory.'/bin';
$commandLog = $temporaryDirectory.'/commands.log';
mkdir($fakeBinDirectory, 0777, true);
$fakePhp = <<<'SH'
#!/bin/sh
set -eu
printf '%s\n' "$*" >> "$FAKE_PHP_LOG"
if [ -n "${FAKE_PHP_FAIL_ON:-}" ] && [ "$*" = "$FAKE_PHP_FAIL_ON" ]; then
exit "${FAKE_PHP_FAIL_CODE:-1}"
fi
SH;
file_put_contents($fakeBinDirectory.'/php', $fakePhp);
chmod($fakeBinDirectory.'/php', 0755);
$process = new Process(
['/bin/sh', dirname(__DIR__, 2).'/scripts/deploy/initialize-application.sh'],
dirname(__DIR__, 2),
[
'APP_ENV' => $environment,
'FAKE_PHP_FAIL_CODE' => (string) $failureCode,
'FAKE_PHP_FAIL_ON' => $failingCommand ?? '',
'FAKE_PHP_LOG' => $commandLog,
'PATH' => $fakeBinDirectory.PATH_SEPARATOR.getenv('PATH'),
],
);
$process->run();
$commands = file_exists($commandLog)
? file($commandLog, FILE_IGNORE_NEW_LINES)
: [];
unlink($fakeBinDirectory.'/php');
if (file_exists($commandLog)) {
unlink($commandLog);
}
rmdir($fakeBinDirectory);
rmdir($temporaryDirectory);
return [$process->getExitCode(), $commands];
}
it('runs migrations, content seeding, and media generation in staging', function (): void {
[$exitCode, $commands] = runDeployInitializationScript('staging');
expect($exitCode)->toBe(0)
->and($commands)->toBe([
'artisan migrate --force --no-interaction',
'artisan db:seed --class=ContentSeeder --force --no-interaction',
'artisan media:generate-variants',
]);
});
it('runs only migrations outside staging', function (string $environment): void {
[$exitCode, $commands] = runDeployInitializationScript($environment);
expect($exitCode)->toBe(0)
->and($commands)->toBe([
'artisan migrate --force --no-interaction',
]);
})->with([
'production' => 'production',
'another environment' => 'preview',
]);
it('propagates initialization failures and stops later commands', function (
string $failingCommand,
int $failureCode,
array $expectedCommands,
): void {
[$exitCode, $commands] = runDeployInitializationScript('staging', $failingCommand, $failureCode);
expect($exitCode)->toBe($failureCode)
->and($commands)->toBe($expectedCommands);
})->with([
'migration failure' => [
'artisan migrate --force --no-interaction',
41,
['artisan migrate --force --no-interaction'],
],
'content seeding failure' => [
'artisan db:seed --class=ContentSeeder --force --no-interaction',
42,
[
'artisan migrate --force --no-interaction',
'artisan db:seed --class=ContentSeeder --force --no-interaction',
],
],
'media generation failure' => [
'artisan media:generate-variants',
43,
[
'artisan migrate --force --no-interaction',
'artisan db:seed --class=ContentSeeder --force --no-interaction',
'artisan media:generate-variants',
],
],
]);

View File

@@ -5,14 +5,23 @@ declare(strict_types=1);
it('ships visual content fixtures as valid jpeg images', function (): void {
$fixtures = [
'og-default.jpg',
'portfolio-cover.jpg',
'service-cover.jpg',
'gallery.jpg',
'testimonial.jpg',
'service-casamentos.jpg',
'service-eventos-corporativos.jpg',
'service-celebracoes-intimistas.jpg',
'case-casamento-ana-lucas.jpg',
'gallery-casamento-ana-lucas-1.jpg',
'gallery-casamento-ana-lucas-2.jpg',
'case-lancamento-verano.jpg',
'gallery-lancamento-verano-1.jpg',
'gallery-lancamento-verano-2.jpg',
'case-mini-wedding-marina.jpg',
'gallery-mini-wedding-marina-1.jpg',
'gallery-mini-wedding-marina-2.jpg',
'about-image.jpg',
];
foreach ($fixtures as $fixture) {
$path = dirname(__DIR__).'/fixtures/images/'.$fixture;
$path = dirname(__DIR__, 2).'/database/fixtures/images/'.$fixture;
expect(is_file($path))->toBeTrue("Missing fixture: {$fixture}");

Binary file not shown.

Before

Width:  |  Height:  |  Size: 310 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 310 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 310 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 311 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 310 B