Compare commits
5 Commits
fix/browse
...
feat/visua
| Author | SHA1 | Date | |
|---|---|---|---|
| 321f0cee11 | |||
| 1d23e16625 | |||
| 33788af172 | |||
| 119711d6f0 | |||
| f09ea83071 |
26
.github/workflows/ci.yml
vendored
@@ -31,7 +31,8 @@ jobs:
|
|||||||
name: static
|
name: static
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
|
- run: cp .env.example .env
|
||||||
|
|
||||||
- uses: shivammathur/setup-php@v2
|
- uses: shivammathur/setup-php@v2
|
||||||
with:
|
with:
|
||||||
@@ -39,7 +40,7 @@ jobs:
|
|||||||
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd
|
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd
|
||||||
coverage: none
|
coverage: none
|
||||||
|
|
||||||
- uses: actions/cache@v4
|
- uses: actions/cache@v5
|
||||||
with:
|
with:
|
||||||
path: ~/.composer/cache/files
|
path: ~/.composer/cache/files
|
||||||
key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }}
|
key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }}
|
||||||
@@ -55,7 +56,8 @@ jobs:
|
|||||||
name: unit
|
name: unit
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
|
- run: cp .env.example .env
|
||||||
|
|
||||||
- uses: shivammathur/setup-php@v2
|
- uses: shivammathur/setup-php@v2
|
||||||
with:
|
with:
|
||||||
@@ -63,7 +65,7 @@ jobs:
|
|||||||
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd
|
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd
|
||||||
coverage: none
|
coverage: none
|
||||||
|
|
||||||
- uses: actions/cache@v4
|
- uses: actions/cache@v5
|
||||||
with:
|
with:
|
||||||
path: ~/.composer/cache/files
|
path: ~/.composer/cache/files
|
||||||
key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }}
|
key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }}
|
||||||
@@ -92,7 +94,8 @@ jobs:
|
|||||||
--health-timeout 5s
|
--health-timeout 5s
|
||||||
--health-retries 10
|
--health-retries 10
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
|
- run: cp .env.example .env
|
||||||
|
|
||||||
- uses: shivammathur/setup-php@v2
|
- uses: shivammathur/setup-php@v2
|
||||||
with:
|
with:
|
||||||
@@ -100,13 +103,13 @@ jobs:
|
|||||||
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd
|
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd
|
||||||
coverage: none
|
coverage: none
|
||||||
|
|
||||||
- uses: actions/cache@v4
|
- uses: actions/cache@v5
|
||||||
with:
|
with:
|
||||||
path: ~/.composer/cache/files
|
path: ~/.composer/cache/files
|
||||||
key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }}
|
key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }}
|
||||||
restore-keys: composer-${{ runner.os }}-
|
restore-keys: composer-${{ runner.os }}-
|
||||||
|
|
||||||
- uses: actions/cache@v4
|
- uses: actions/cache@v5
|
||||||
with:
|
with:
|
||||||
path: ~/.npm
|
path: ~/.npm
|
||||||
key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
|
key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
|
||||||
@@ -136,7 +139,8 @@ jobs:
|
|||||||
--health-timeout 5s
|
--health-timeout 5s
|
||||||
--health-retries 10
|
--health-retries 10
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
|
- run: cp .env.example .env
|
||||||
|
|
||||||
- uses: shivammathur/setup-php@v2
|
- uses: shivammathur/setup-php@v2
|
||||||
with:
|
with:
|
||||||
@@ -144,13 +148,13 @@ jobs:
|
|||||||
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd
|
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd
|
||||||
coverage: none
|
coverage: none
|
||||||
|
|
||||||
- uses: actions/cache@v4
|
- uses: actions/cache@v5
|
||||||
with:
|
with:
|
||||||
path: ~/.composer/cache/files
|
path: ~/.composer/cache/files
|
||||||
key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }}
|
key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }}
|
||||||
restore-keys: composer-${{ runner.os }}-
|
restore-keys: composer-${{ runner.os }}-
|
||||||
|
|
||||||
- uses: actions/cache@v4
|
- uses: actions/cache@v5
|
||||||
with:
|
with:
|
||||||
path: ~/.npm
|
path: ~/.npm
|
||||||
key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
|
key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
|
||||||
@@ -224,7 +228,7 @@ jobs:
|
|||||||
name: container
|
name: container
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Build production image
|
- name: Build production image
|
||||||
run: docker build -t amare-app:ci .
|
run: docker build -t amare-app:ci .
|
||||||
|
|||||||
1
.gitignore
vendored
@@ -18,6 +18,7 @@
|
|||||||
/public/fonts-manifest.dev.json
|
/public/fonts-manifest.dev.json
|
||||||
/public/hot
|
/public/hot
|
||||||
/public/storage
|
/public/storage
|
||||||
|
/tests/Browser/Screenshots
|
||||||
/storage/*.key
|
/storage/*.key
|
||||||
/storage/pail
|
/storage/pail
|
||||||
/vendor
|
/vendor
|
||||||
|
|||||||
@@ -56,6 +56,9 @@ final class MediaGenerateVariantsCommand extends Command
|
|||||||
if ($settings && filled($settings->default_og_image_path)) {
|
if ($settings && filled($settings->default_og_image_path)) {
|
||||||
$paths[] = (string) $settings->default_og_image_path;
|
$paths[] = (string) $settings->default_og_image_path;
|
||||||
}
|
}
|
||||||
|
if ($settings && filled($settings->about_image_path)) {
|
||||||
|
$paths[] = (string) $settings->about_image_path;
|
||||||
|
}
|
||||||
|
|
||||||
foreach (Service::query()->whereNotNull('cover_image_path')->pluck('cover_image_path') as $path) {
|
foreach (Service::query()->whereNotNull('cover_image_path')->pluck('cover_image_path') as $path) {
|
||||||
$paths[] = (string) $path;
|
$paths[] = (string) $path;
|
||||||
|
|||||||
@@ -228,6 +228,12 @@ class ManageSiteSettings extends Page
|
|||||||
->addActionLabel('Adicionar rede'),
|
->addActionLabel('Adicionar rede'),
|
||||||
])
|
])
|
||||||
->columns(2),
|
->columns(2),
|
||||||
|
Section::make('Página Sobre')
|
||||||
|
->schema([
|
||||||
|
PublicImageUploadRules::fileUpload('about_image_path', 'Imagem da página Sobre', 'content/about'),
|
||||||
|
PublicImageUploadRules::altTextField('about_image_alt', 'about_image_path'),
|
||||||
|
])
|
||||||
|
->columns(2),
|
||||||
Section::make('SEO padrão')
|
Section::make('SEO padrão')
|
||||||
->schema([
|
->schema([
|
||||||
TextInput::make('default_meta_title')
|
TextInput::make('default_meta_title')
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ use Illuminate\Database\Eloquent\Model;
|
|||||||
* @property bool $analytics_enabled
|
* @property bool $analytics_enabled
|
||||||
* @property string|null $default_og_image_path
|
* @property string|null $default_og_image_path
|
||||||
* @property string|null $default_og_image_alt
|
* @property string|null $default_og_image_alt
|
||||||
|
* @property string|null $about_image_path
|
||||||
|
* @property string|null $about_image_alt
|
||||||
* @property string|null $logo_path
|
* @property string|null $logo_path
|
||||||
* @property string|null $logo_alt
|
* @property string|null $logo_alt
|
||||||
*/
|
*/
|
||||||
@@ -30,6 +32,8 @@ use Illuminate\Database\Eloquent\Model;
|
|||||||
'hero_secondary_cta_label',
|
'hero_secondary_cta_label',
|
||||||
'hero_note',
|
'hero_note',
|
||||||
'about_summary',
|
'about_summary',
|
||||||
|
'about_image_path',
|
||||||
|
'about_image_alt',
|
||||||
'manifesto_title',
|
'manifesto_title',
|
||||||
'manifesto_lead',
|
'manifesto_lead',
|
||||||
'manifesto_body',
|
'manifesto_body',
|
||||||
|
|||||||
@@ -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']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -36,6 +36,8 @@ class ContentSeeder extends Seeder
|
|||||||
'hero_secondary_cta_label' => 'Conheça nosso olhar',
|
'hero_secondary_cta_label' => 'Conheça nosso olhar',
|
||||||
'hero_note' => 'Planejamento cuidadoso, comunicação clara e execução segura — do primeiro encontro ao último detalhe.',
|
'hero_note' => 'Planejamento cuidadoso, comunicação clara e execução segura — do primeiro encontro ao último detalhe.',
|
||||||
'about_summary' => 'Assessoria boutique especializada em experiências memoráveis em São Paulo.',
|
'about_summary' => 'Assessoria boutique especializada em experiências memoráveis em São Paulo.',
|
||||||
|
'about_image_path' => $this->copyFixture('about-image.jpg', 'content/about/about-image.jpg'),
|
||||||
|
'about_image_alt' => 'Mesa de planejamento com caderno, café e guardanapos de pano',
|
||||||
'manifesto_title' => 'Sofisticação que também se traduz em organização.',
|
'manifesto_title' => 'Sofisticação que também se traduz em organização.',
|
||||||
'manifesto_lead' => 'Um evento memorável não nasce apenas de uma boa estética. Ele depende de decisões bem conduzidas, fornecedores alinhados e atenção constante ao que realmente importa.',
|
'manifesto_lead' => 'Um evento memorável não nasce apenas de uma boa estética. Ele depende de decisões bem conduzidas, fornecedores alinhados e atenção constante ao que realmente importa.',
|
||||||
'manifesto_body' => 'A Amare combina sensibilidade e precisão para criar encontros coerentes com cada cliente, marca e ocasião — sem fórmulas prontas, excessos ou ruído.',
|
'manifesto_body' => 'A Amare combina sensibilidade e precisão para criar encontros coerentes com cada cliente, marca e ocasião — sem fórmulas prontas, excessos ou ruído.',
|
||||||
@@ -91,7 +93,7 @@ class ContentSeeder extends Seeder
|
|||||||
['slug' => $service['slug']],
|
['slug' => $service['slug']],
|
||||||
[
|
[
|
||||||
...$service,
|
...$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'],
|
'cover_image_alt' => 'Capa do serviço '.$service['title'],
|
||||||
'published_at' => Carbon::parse(self::SEED_TIMESTAMP),
|
'published_at' => Carbon::parse(self::SEED_TIMESTAMP),
|
||||||
],
|
],
|
||||||
@@ -148,7 +150,7 @@ class ContentSeeder extends Seeder
|
|||||||
['slug' => $caseData['slug']],
|
['slug' => $caseData['slug']],
|
||||||
[
|
[
|
||||||
...$caseData,
|
...$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'],
|
'cover_image_alt' => 'Capa do caso '.$caseData['title'],
|
||||||
'is_featured' => true,
|
'is_featured' => true,
|
||||||
'published_at' => Carbon::parse(self::SEED_TIMESTAMP),
|
'published_at' => Carbon::parse(self::SEED_TIMESTAMP),
|
||||||
@@ -160,7 +162,7 @@ class ContentSeeder extends Seeder
|
|||||||
foreach ([1, 2] as $index) {
|
foreach ([1, 2] as $index) {
|
||||||
PortfolioImage::query()->create([
|
PortfolioImage::query()->create([
|
||||||
'portfolio_case_id' => $case->id,
|
'portfolio_case_id' => $case->id,
|
||||||
'path' => $this->copyFixture('gallery.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,
|
'alt_text' => 'Galeria '.$caseData['title'].' '.$index,
|
||||||
'caption' => $index === 1 ? 'Detalhe da decoração' : null,
|
'caption' => $index === 1 ? 'Detalhe da decoração' : null,
|
||||||
'sort_order' => $index,
|
'sort_order' => $index,
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ use App\Models\SiteSetting;
|
|||||||
use App\Models\Testimonial;
|
use App\Models\Testimonial;
|
||||||
use Illuminate\Database\Seeder;
|
use Illuminate\Database\Seeder;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Facades\File;
|
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -44,6 +43,8 @@ class VisualContentSeeder extends Seeder
|
|||||||
'hero_secondary_cta_label' => 'Conheça nosso olhar',
|
'hero_secondary_cta_label' => 'Conheça nosso olhar',
|
||||||
'hero_note' => 'Planejamento cuidadoso, comunicação clara e execução segura — do primeiro encontro ao último detalhe.',
|
'hero_note' => 'Planejamento cuidadoso, comunicação clara e execução segura — do primeiro encontro ao último detalhe.',
|
||||||
'about_summary' => 'Assessoria boutique especializada em experiências memoráveis em São Paulo.',
|
'about_summary' => 'Assessoria boutique especializada em experiências memoráveis em São Paulo.',
|
||||||
|
'about_image_path' => $this->writeSolidJpeg('visual/about/about-image.jpg', 1200, 900, [232, 228, 218]),
|
||||||
|
'about_image_alt' => 'Imagem editorial da página Sobre',
|
||||||
'manifesto_title' => 'Sofisticação que também se traduz em organização.',
|
'manifesto_title' => 'Sofisticação que também se traduz em organização.',
|
||||||
'manifesto_lead' => 'Um evento memorável não nasce apenas de uma boa estética. Ele depende de decisões bem conduzidas, fornecedores alinhados e atenção constante ao que realmente importa.',
|
'manifesto_lead' => 'Um evento memorável não nasce apenas de uma boa estética. Ele depende de decisões bem conduzidas, fornecedores alinhados e atenção constante ao que realmente importa.',
|
||||||
'manifesto_body' => 'A Amare combina sensibilidade e precisão para criar encontros coerentes com cada cliente, marca e ocasião — sem fórmulas prontas, excessos ou ruído.',
|
'manifesto_body' => 'A Amare combina sensibilidade e precisão para criar encontros coerentes com cada cliente, marca e ocasião — sem fórmulas prontas, excessos ou ruído.',
|
||||||
@@ -58,7 +59,7 @@ class VisualContentSeeder extends Seeder
|
|||||||
],
|
],
|
||||||
'default_meta_title' => 'Amare Assessoria de Eventos',
|
'default_meta_title' => 'Amare Assessoria de Eventos',
|
||||||
'default_meta_description' => 'Assessoria premium para casamentos e eventos corporativos em São Paulo.',
|
'default_meta_description' => 'Assessoria premium para casamentos e eventos corporativos em São Paulo.',
|
||||||
'default_og_image_path' => $this->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',
|
'default_og_image_alt' => 'Identidade visual da Amare Assessoria de Eventos',
|
||||||
'analytics_enabled' => false,
|
'analytics_enabled' => false,
|
||||||
'analytics_script' => null,
|
'analytics_script' => null,
|
||||||
@@ -89,7 +90,7 @@ class VisualContentSeeder extends Seeder
|
|||||||
['slug' => $service['slug']],
|
['slug' => $service['slug']],
|
||||||
[
|
[
|
||||||
...$service,
|
...$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'],
|
'cover_image_alt' => 'Capa do serviço '.$service['title'],
|
||||||
'published_at' => Carbon::parse(self::SEED_TIMESTAMP),
|
'published_at' => Carbon::parse(self::SEED_TIMESTAMP),
|
||||||
],
|
],
|
||||||
@@ -133,7 +134,7 @@ class VisualContentSeeder extends Seeder
|
|||||||
['slug' => $caseData['slug']],
|
['slug' => $caseData['slug']],
|
||||||
[
|
[
|
||||||
...$caseData,
|
...$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'],
|
'cover_image_alt' => 'Capa do caso '.$caseData['title'],
|
||||||
'is_featured' => true,
|
'is_featured' => true,
|
||||||
'published_at' => Carbon::parse(self::SEED_TIMESTAMP),
|
'published_at' => Carbon::parse(self::SEED_TIMESTAMP),
|
||||||
@@ -144,7 +145,7 @@ class VisualContentSeeder extends Seeder
|
|||||||
|
|
||||||
PortfolioImage::query()->create([
|
PortfolioImage::query()->create([
|
||||||
'portfolio_case_id' => $case->id,
|
'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',
|
'alt_text' => 'Galeria '.$caseData['title'].' 1',
|
||||||
'caption' => 'Detalhe da decoração',
|
'caption' => 'Detalhe da decoração',
|
||||||
'sort_order' => 1,
|
'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);
|
$image = imagecreatetruecolor($width, $height);
|
||||||
Storage::disk('public')->put($destination, File::get($source));
|
$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;
|
return $destination;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,13 @@
|
|||||||
--text-3xl: var(--amare-text-3xl);
|
--text-3xl: var(--amare-text-3xl);
|
||||||
--text-4xl: var(--amare-text-4xl);
|
--text-4xl: var(--amare-text-4xl);
|
||||||
|
|
||||||
|
--text-display: clamp(3rem, 7.7vw, 5.875rem);
|
||||||
|
--text-display--line-height: 0.94;
|
||||||
|
--text-display--letter-spacing: -0.01em;
|
||||||
|
|
||||||
|
--text-headline: clamp(2.375rem, 5.2vw, 4rem);
|
||||||
|
--text-headline--line-height: 1.02;
|
||||||
|
|
||||||
--spacing-1: var(--amare-space-1);
|
--spacing-1: var(--amare-space-1);
|
||||||
--spacing-2: var(--amare-space-2);
|
--spacing-2: var(--amare-space-2);
|
||||||
--spacing-3: var(--amare-space-3);
|
--spacing-3: var(--amare-space-3);
|
||||||
|
|||||||
@@ -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">
|
<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>
|
<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">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">
|
<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>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
@endif
|
@endif
|
||||||
</div>
|
</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' }}
|
{{ $settings->hero_title ?: 'Celebrações com propósito' }}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
<div class="container-amare grid gap-8 md:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]" data-reveal>
|
<div class="container-amare grid gap-8 md:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]" 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-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-xl leading-relaxed text-amare-text">{{ $lead }}</p>
|
||||||
<p class="max-w-2xl text-amare-text-muted">{{ $body }}</p>
|
<p class="max-w-2xl text-amare-text-muted">{{ $body }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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="container-amare grid gap-10 md:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)] md:items-start">
|
||||||
<div class="space-y-3" data-reveal>
|
<div class="space-y-3" 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-headline font-medium text-amare-text">Cuidado orientado por processo.</h2>
|
||||||
<p class="text-amare-text-muted">{{ $intro }}</p>
|
<p class="text-amare-text-muted">{{ $intro }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
<div class="grid gap-4 md:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]" data-reveal>
|
<div class="grid gap-4 md:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]" 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-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>
|
<p class="max-w-2xl text-amare-accent-text/80">Recortes de eventos conduzidos com escuta, direção e presença em cada etapa.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
<div class="container-amare grid gap-10 md:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]">
|
<div class="container-amare grid gap-10 md:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]">
|
||||||
<div class="space-y-3" data-reveal>
|
<div class="space-y-3" 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-headline font-medium text-amare-text">Presença que organiza o essencial.</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-8" data-reveal>
|
<div class="space-y-8" 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>
|
||||||
|
|||||||
@@ -3,10 +3,10 @@
|
|||||||
])
|
])
|
||||||
|
|
||||||
@if ($testimonials->isNotEmpty())
|
@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="container-amare space-y-8">
|
||||||
<div class="max-w-2xl space-y-3" data-reveal>
|
<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>
|
<p class="text-amare-text-muted">Quem celebrou com a Amare conta como foi a experiência.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
|
|
||||||
$resolvedWidth = $width ?? $dimensions['width'] ?? null;
|
$resolvedWidth = $width ?? $dimensions['width'] ?? null;
|
||||||
$resolvedHeight = $height ?? $dimensions['height'] ?? null;
|
$resolvedHeight = $height ?? $dimensions['height'] ?? null;
|
||||||
$loadingValue = $loading === 'eager' ? 'eager' : 'lazy';
|
$loadingValue = $loading;
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<img
|
<img
|
||||||
|
|||||||
@@ -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('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('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('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>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -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="container-amare grid flex-1 content-start gap-12 py-16 md:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)] md:py-24">
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">A Amare</p>
|
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">A Amare</p>
|
||||||
<h1 class="text-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-lg text-amare-muted">{{ $siteSettings->about_summary }}</p>
|
||||||
<p class="text-amare-muted">
|
<p class="text-amare-muted">
|
||||||
A {{ $siteSettings->brand_name }} atua em {{ $city }} com foco em planejamento completo,
|
A {{ $siteSettings->brand_name }} atua em {{ $city }} com foco em planejamento completo,
|
||||||
presença no dia do evento e uma condução serena do início ao fim.
|
presença no dia do evento e uma condução serena do início ao fim.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
@if (filled($siteSettings->about_image_path))
|
||||||
|
<div class="overflow-hidden bg-amare-bg-deep">
|
||||||
|
<x-media.image
|
||||||
|
:path="$siteSettings->about_image_path"
|
||||||
|
:alt="$siteSettings->about_image_alt ?: $siteSettings->brand_name"
|
||||||
|
loading="lazy"
|
||||||
|
sizes="(max-width: 768px) 100vw, 50vw"
|
||||||
|
class="img-editorial aspect-[4/3] w-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul class="space-y-4 border-t border-amare-border pt-6" aria-label="Princípios da Amare">
|
<ul class="space-y-4 border-t border-amare-border pt-6" aria-label="Princípios da Amare">
|
||||||
@@ -30,4 +42,6 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<x-home.final-cta :settings="$siteSettings" />
|
||||||
@endsection
|
@endsection
|
||||||
|
|||||||
@@ -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="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-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">
|
<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.
|
Conte sobre o seu evento no briefing abaixo. Retornaremos com uma proposta sob medida e sem compromisso.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<div class="container-amare space-y-10 py-16 md:py-24">
|
<div class="container-amare space-y-10 py-16 md:py-24">
|
||||||
<div class="max-w-2xl space-y-4">
|
<div class="max-w-2xl space-y-4">
|
||||||
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Portfólio</p>
|
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Portfólio</p>
|
||||||
<h1 class="text-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>
|
<p class="text-lg text-amare-muted">Casos conduzidos com atenção a ritmo, composição e cada detalhe da experiência.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -43,4 +43,6 @@
|
|||||||
<p class="text-sm text-amare-accent">Imagens demonstrativas enquanto o acervo autorizado da Amare está em organização.</p>
|
<p class="text-sm text-amare-accent">Imagens demonstrativas enquanto o acervo autorizado da Amare está em organização.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<x-home.final-cta :settings="$siteSettings" />
|
||||||
@endsection
|
@endsection
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
@if (filled($case->event_type))
|
@if (filled($case->event_type))
|
||||||
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">{{ $case->event_type }}</p>
|
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">{{ $case->event_type }}</p>
|
||||||
@endif
|
@endif
|
||||||
<h1 class="text-4xl font-medium tracking-tight text-amare-text md:text-5xl">{{ $case->title }}</h1>
|
<h1 class="text-headline font-medium tracking-tight text-amare-text">{{ $case->title }}</h1>
|
||||||
<p class="text-lg text-amare-muted">{{ $case->summary }}</p>
|
<p class="text-lg text-amare-muted">{{ $case->summary }}</p>
|
||||||
<p class="text-sm break-words text-amare-muted">
|
<p class="text-sm break-words text-amare-muted">
|
||||||
@if ($case->city){{ $case->city }}@endif
|
@if ($case->city){{ $case->city }}@endif
|
||||||
@@ -71,4 +71,6 @@
|
|||||||
</section>
|
</section>
|
||||||
@endif
|
@endif
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
|
<x-home.final-cta :settings="$siteSettings" />
|
||||||
@endsection
|
@endsection
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<div class="container-amare space-y-10 py-16 md:py-24">
|
<div class="container-amare space-y-10 py-16 md:py-24">
|
||||||
<div class="max-w-2xl space-y-4">
|
<div class="max-w-2xl space-y-4">
|
||||||
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Serviços</p>
|
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Serviços</p>
|
||||||
<h1 class="text-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>
|
<p class="text-lg text-amare-muted">O escopo é construído de acordo com o momento do projeto, o nível de apoio necessário e a complexidade de cada evento.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -41,4 +41,6 @@
|
|||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<x-home.final-cta :settings="$siteSettings" />
|
||||||
@endsection
|
@endsection
|
||||||
|
|||||||
16
tasks.md
@@ -61,3 +61,19 @@
|
|||||||
- `PageMeta::forErrorPage` landed on `main` during implementation; rebased and extended it for 419/429/503.
|
- `PageMeta::forErrorPage` landed on `main` during implementation; rebased and extended it for 419/429/503.
|
||||||
- Contact form landed on `main`; submit-state browser coverage was restored.
|
- Contact form landed on `main`; submit-state browser coverage was restored.
|
||||||
- `event_type: null` violates NOT NULL column — test uses `''` instead (guard covers both).
|
- `event_type: null` violates NOT NULL column — test uses `''` instead (guard covers both).
|
||||||
|
|
||||||
|
# Browser CI Gate — estabilidade e diagnosticabilidade
|
||||||
|
|
||||||
|
## BG — Causa raiz
|
||||||
|
- [x] Diagnosticar 3 runs: mismatch visual (8 testes), acessibilidade (1), timeout networkidle 5s + overflow real
|
||||||
|
- [x] Reproduzir local: `waitForLoadState('networkidle')` fragil (conexões longas), default 5s
|
||||||
|
- [x] Reproduzir warning `file_get_contents(.env)` em TODO teste (CI sem .env)
|
||||||
|
|
||||||
|
## BG — Fixes
|
||||||
|
- [x] Helper `StableScreenshot` (tests/Support/): drop networkidle → readyState + fonts + settle
|
||||||
|
- [x] Exportar PNGs standalone (diff/expected/actual) no mismatch — visíveis nos artifacts
|
||||||
|
- [x] CI: `cp .env.example .env` nos jobs de teste; checkout/cache v5
|
||||||
|
- [x] Gitignore tests/Browser/Screenshots
|
||||||
|
- [x] Unit test MismatchScreenshotExporter; pint/phpstan/unit/feature verdes
|
||||||
|
- [x] Push + PR + CI browser verde (PR #15 merge f09ea83, run 31098941038)
|
||||||
|
- [x] Refresh baselines de CI se necessário (não necessário — browser passou com baselines atuais)
|
||||||
|
|||||||
@@ -41,13 +41,14 @@ $viewports = [
|
|||||||
foreach ($screens as $screen => $path) {
|
foreach ($screens as $screen => $path) {
|
||||||
foreach ($viewports as $viewport => [$width, $height]) {
|
foreach ($viewports as $viewport => [$width, $height]) {
|
||||||
it("matches {$screen} {$viewport} visual baseline", function () use ($path, $width, $height): void {
|
it("matches {$screen} {$viewport} visual baseline", function () use ($path, $width, $height): void {
|
||||||
|
$this->assertStableScreenshotMatches(
|
||||||
$this->visit($path, [
|
$this->visit($path, [
|
||||||
'reducedMotion' => 'reduce',
|
'reducedMotion' => 'reduce',
|
||||||
])
|
])
|
||||||
->withLocale('pt-BR')
|
->withLocale('pt-BR')
|
||||||
->withTimezone('America/Fortaleza')
|
->withTimezone('America/Fortaleza')
|
||||||
->resize($width, $height)
|
->resize($width, $height)
|
||||||
->assertScreenshotMatches();
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
40
tests/Support/MismatchScreenshotExporter.php
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tests\Support;
|
||||||
|
|
||||||
|
final class MismatchScreenshotExporter
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Extracts the diff, expected, and actual images embedded in an ImageDiffView
|
||||||
|
* HTML file and writes them as standalone PNG files so they can be inspected
|
||||||
|
* directly in CI artifacts.
|
||||||
|
*
|
||||||
|
* @return list<string> paths of the written PNG files
|
||||||
|
*/
|
||||||
|
public static function export(string $htmlPath, string $targetDir): array
|
||||||
|
{
|
||||||
|
$html = (string) file_get_contents($htmlPath);
|
||||||
|
|
||||||
|
if (preg_match_all('/src="data:image\/png;base64,([^"]+)"/', $html, $matches) === false || ! isset($matches[1][2])) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$base = pathinfo($htmlPath, PATHINFO_FILENAME);
|
||||||
|
$labels = ['diff', 'expected', 'actual'];
|
||||||
|
$written = [];
|
||||||
|
|
||||||
|
foreach (array_slice($matches[1], 0, 3) as $index => $base64) {
|
||||||
|
$path = $targetDir.'/'.$base.'-'.($labels[$index] ?? 'image-'.$index).'.png';
|
||||||
|
|
||||||
|
if (file_put_contents($path, (string) base64_decode($base64, true)) === false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$written[] = $path;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $written;
|
||||||
|
}
|
||||||
|
}
|
||||||
68
tests/Support/StableScreenshot.php
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tests\Support;
|
||||||
|
|
||||||
|
use Pest\Browser\Api\AwaitableWebpage;
|
||||||
|
use Pest\Browser\Exceptions\BrowserExpectationFailedException;
|
||||||
|
use Pest\Browser\Support\Screenshot;
|
||||||
|
use Pest\TestSuite;
|
||||||
|
use PHPUnit\Framework\ExpectationFailedException;
|
||||||
|
|
||||||
|
trait StableScreenshot
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Asserts the page matches its visual baseline without relying on the
|
||||||
|
* networkidle load state, which is inherently flaky (long-lived connections
|
||||||
|
* like fonts, polling, or keep-alive can keep it from ever firing and cause
|
||||||
|
* spurious timeouts at the 5s client default).
|
||||||
|
*
|
||||||
|
* On mismatch the vendor only writes an HTML diff view with base64-embedded
|
||||||
|
* images, so this helper additionally exports standalone diff/expected/actual
|
||||||
|
* PNG files that are directly viewable in CI artifacts.
|
||||||
|
*/
|
||||||
|
public function assertStableScreenshotMatches(AwaitableWebpage $awaitable): void
|
||||||
|
{
|
||||||
|
$page = $awaitable->page();
|
||||||
|
|
||||||
|
$page->addStyleTag('* {
|
||||||
|
transition: none !important;
|
||||||
|
animation: none !important;
|
||||||
|
font-family: Arial, sans-serif !important;
|
||||||
|
body {
|
||||||
|
-webkit-font-smoothing: antialiased !important;
|
||||||
|
-moz-osx-font-smoothing: grayscale !important;
|
||||||
|
}
|
||||||
|
}');
|
||||||
|
|
||||||
|
$page->waitForFunction(
|
||||||
|
'document.readyState === "complete"'
|
||||||
|
.' && document.fonts.status === "loaded"'
|
||||||
|
);
|
||||||
|
|
||||||
|
usleep(300_000);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$page->expectScreenshot(true, false);
|
||||||
|
} catch (ExpectationFailedException $exception) {
|
||||||
|
$this->exportMismatchScreenshots();
|
||||||
|
|
||||||
|
throw BrowserExpectationFailedException::from($page, $exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function exportMismatchScreenshots(): void
|
||||||
|
{
|
||||||
|
[$snapshotName] = TestSuite::getInstance()->snapshots->get();
|
||||||
|
|
||||||
|
$base = pathinfo($snapshotName, PATHINFO_FILENAME);
|
||||||
|
$htmlPath = Screenshot::dir().'/ImageDiffView/'.$base.'.html';
|
||||||
|
|
||||||
|
if (! is_file($htmlPath)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
MismatchScreenshotExporter::export($htmlPath, Screenshot::dir());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,8 +3,9 @@
|
|||||||
namespace Tests;
|
namespace Tests;
|
||||||
|
|
||||||
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
|
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
|
||||||
|
use Tests\Support\StableScreenshot;
|
||||||
|
|
||||||
abstract class TestCase extends BaseTestCase
|
abstract class TestCase extends BaseTestCase
|
||||||
{
|
{
|
||||||
//
|
use StableScreenshot;
|
||||||
}
|
}
|
||||||
|
|||||||
47
tests/Unit/Support/MismatchScreenshotExporterTest.php
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Tests\Support\MismatchScreenshotExporter;
|
||||||
|
|
||||||
|
it('exports diff, expected, and actual PNGs from an ImageDiffView html', function (): void {
|
||||||
|
$targetDir = sys_get_temp_dir().'/mismatch-exporter-'.uniqid();
|
||||||
|
mkdir($targetDir, 0755, true);
|
||||||
|
|
||||||
|
$expected = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgAAACAAFWoS02AAAAAElFTkSuQmCC';
|
||||||
|
$actual = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgAAACAAFWoS02AAAAAElFTkSuQmCC';
|
||||||
|
$diff = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgAAACAAFWoS02AAAAAElFTkSuQmCC';
|
||||||
|
|
||||||
|
$html = <<<HTML
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html><body>
|
||||||
|
<img alt="Diff" src="data:image/png;base64,{$diff}"/>
|
||||||
|
<img slot="image-1" alt="Expected" src="data:image/png;base64,{$expected}"/>
|
||||||
|
<img slot="image-2" alt="Actual" src="data:image/png;base64,{$actual}"/>
|
||||||
|
</body></html>
|
||||||
|
HTML;
|
||||||
|
|
||||||
|
$htmlPath = $targetDir.'/it_matches_home_desktop.html';
|
||||||
|
file_put_contents($htmlPath, $html);
|
||||||
|
|
||||||
|
$written = MismatchScreenshotExporter::export($htmlPath, $targetDir);
|
||||||
|
|
||||||
|
expect($written)->toHaveCount(3);
|
||||||
|
expect($written)->toContain($targetDir.'/it_matches_home_desktop-diff.png');
|
||||||
|
expect($written)->toContain($targetDir.'/it_matches_home_desktop-expected.png');
|
||||||
|
expect($written)->toContain($targetDir.'/it_matches_home_desktop-actual.png');
|
||||||
|
|
||||||
|
expect(base64_decode($diff, true))->toBe((string) file_get_contents($targetDir.'/it_matches_home_desktop-diff.png'));
|
||||||
|
expect(base64_decode($expected, true))->toBe((string) file_get_contents($targetDir.'/it_matches_home_desktop-expected.png'));
|
||||||
|
expect(base64_decode($actual, true))->toBe((string) file_get_contents($targetDir.'/it_matches_home_desktop-actual.png'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an empty list when the html has no embedded images', function (): void {
|
||||||
|
$targetDir = sys_get_temp_dir().'/mismatch-exporter-'.uniqid();
|
||||||
|
mkdir($targetDir, 0755, true);
|
||||||
|
|
||||||
|
$htmlPath = $targetDir.'/plain.html';
|
||||||
|
file_put_contents($htmlPath, '<html><body>no images</body></html>');
|
||||||
|
|
||||||
|
expect(MismatchScreenshotExporter::export($htmlPath, $targetDir))->toBe([]);
|
||||||
|
});
|
||||||
@@ -5,10 +5,19 @@ declare(strict_types=1);
|
|||||||
it('ships visual content fixtures as valid jpeg images', function (): void {
|
it('ships visual content fixtures as valid jpeg images', function (): void {
|
||||||
$fixtures = [
|
$fixtures = [
|
||||||
'og-default.jpg',
|
'og-default.jpg',
|
||||||
'portfolio-cover.jpg',
|
'service-casamentos.jpg',
|
||||||
'service-cover.jpg',
|
'service-eventos-corporativos.jpg',
|
||||||
'gallery.jpg',
|
'service-celebracoes-intimistas.jpg',
|
||||||
'testimonial.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) {
|
foreach ($fixtures as $fixture) {
|
||||||
|
|||||||
BIN
tests/fixtures/images/about-image.jpg
vendored
Normal file
|
After Width: | Height: | Size: 151 KiB |
BIN
tests/fixtures/images/case-casamento-ana-lucas.jpg
vendored
Normal file
|
After Width: | Height: | Size: 436 KiB |
BIN
tests/fixtures/images/case-lancamento-verano.jpg
vendored
Normal file
|
After Width: | Height: | Size: 254 KiB |
BIN
tests/fixtures/images/case-mini-wedding-marina.jpg
vendored
Normal file
|
After Width: | Height: | Size: 258 KiB |
BIN
tests/fixtures/images/gallery-casamento-ana-lucas-1.jpg
vendored
Normal file
|
After Width: | Height: | Size: 622 KiB |
BIN
tests/fixtures/images/gallery-casamento-ana-lucas-2.jpg
vendored
Normal file
|
After Width: | Height: | Size: 499 KiB |
BIN
tests/fixtures/images/gallery-lancamento-verano-1.jpg
vendored
Normal file
|
After Width: | Height: | Size: 244 KiB |
BIN
tests/fixtures/images/gallery-lancamento-verano-2.jpg
vendored
Normal file
|
After Width: | Height: | Size: 235 KiB |
BIN
tests/fixtures/images/gallery-mini-wedding-marina-1.jpg
vendored
Normal file
|
After Width: | Height: | Size: 239 KiB |
BIN
tests/fixtures/images/gallery-mini-wedding-marina-2.jpg
vendored
Normal file
|
After Width: | Height: | Size: 236 KiB |
BIN
tests/fixtures/images/gallery.jpg
vendored
|
Before Width: | Height: | Size: 310 B |
BIN
tests/fixtures/images/og-default.jpg
vendored
|
Before Width: | Height: | Size: 310 B After Width: | Height: | Size: 360 KiB |
BIN
tests/fixtures/images/portfolio-cover.jpg
vendored
|
Before Width: | Height: | Size: 310 B |
BIN
tests/fixtures/images/service-casamentos.jpg
vendored
Normal file
|
After Width: | Height: | Size: 330 KiB |
BIN
tests/fixtures/images/service-celebracoes-intimistas.jpg
vendored
Normal file
|
After Width: | Height: | Size: 302 KiB |
BIN
tests/fixtures/images/service-cover.jpg
vendored
|
Before Width: | Height: | Size: 311 B |
BIN
tests/fixtures/images/service-eventos-corporativos.jpg
vendored
Normal file
|
After Width: | Height: | Size: 337 KiB |
BIN
tests/fixtures/images/testimonial.jpg
vendored
|
Before Width: | Height: | Size: 310 B |