Compare commits

...

8 Commits

Author SHA1 Message Date
8009f1f8f9 test: speed browser suite via visit reuse
Collapse route×viewport cartesian products, merge home motion
asserts, skip existing VisualContentSeeder JPEGs, ignore Screenshots.
2026-08-11 18:09:30 -03:00
8311f89850 test: CTA hero agora ancora #sobre conforme preview 2026-08-11 17:24:57 -03:00
887df37fae merge: integrar main (#51 spread editorial, #52 jornada de casamentos) 2026-08-11 16:37:59 -03:00
4859fc5a8b docs: proposta openspec home editorial 2026-08-11 16:24:12 -03:00
dfa798552f feat: home editorial e header/footer conforme preview 2026-08-11 16:22:28 -03:00
2573a4aa73 feat: modalidades de casamento e etapas corporativas 2026-08-11 16:22:25 -03:00
d0e508b237 feat: reestruturar jornada pública de casamentos (#52) 2026-08-11 15:47:43 -03:00
70cc65227a feat: aplicar spread editorial 5/7 aos heros (#51) 2026-08-11 13:36:32 -03:00
87 changed files with 2376 additions and 624 deletions

3
.gitignore vendored
View File

@@ -26,3 +26,6 @@ Homestead.json
Homestead.yaml Homestead.yaml
Thumbs.db Thumbs.db
.worktrees/ .worktrees/
# Pest Browser visual diffs (local/CI artifacts)
/tests/Browser/Screenshots/

View File

@@ -73,7 +73,7 @@ Estrutura base usa grade central de 12 colunas no desktop e 4 no mobile, com lar
Composições editoriais alternam colunas assimétricas, imagens em diferentes proporções e blocos de leitura curtos. Formulários, listas e navegação mantêm alinhamento estrito. Ritmo estrutural usa múltiplos de `8px`; ajustes tipográficos usam `4px`. Composições editoriais alternam colunas assimétricas, imagens em diferentes proporções e blocos de leitura curtos. Formulários, listas e navegação mantêm alinhamento estrito. Ritmo estrutural usa múltiplos de `8px`; ajustes tipográficos usam `4px`.
Desktop aceita spreads, conteúdo deslocado e relações 5/7 ou 4/5. Mobile lineariza ordem sem esconder conteúdo essencial, mantém CTA acessível e troca spreads por sequências verticais. Fotografia pode ocupar viewport amplo, desde que texto e ação permaneçam legíveis. Desktop aceita spreads, conteúdo deslocado e relações 5/7 ou 4/5. Nas aberturas narrativas com fotografia, o spread padrão reserva 5/12 para o texto e 7/12 para a mídia; a coluna de leitura não passa de 470px, e o título mantém quebra normal. Mobile lineariza a ordem para texto seguido da fotografia sem esconder conteúdo essencial e mantém CTA acessível. Fotografia pode ocupar viewport amplo, desde que texto e ação permaneçam legíveis.
**The Editorial Rhythm Rule.** Seções alternam imagem, texto, densidade e silêncio; repetição de grades idênticas por toda página é proibida. **The Editorial Rhythm Rule.** Seções alternam imagem, texto, densidade e silêncio; repetição de grades idênticas por toda página é proibida.

View File

@@ -8,6 +8,7 @@ use App\Models\PortfolioCase;
use App\Models\Service; use App\Models\Service;
use App\Models\SiteSetting; use App\Models\SiteSetting;
use App\Models\Testimonial; use App\Models\Testimonial;
use App\Models\WeddingPackage;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
final readonly class HomeContent final readonly class HomeContent
@@ -16,11 +17,13 @@ final readonly class HomeContent
* @param Collection<int, Service> $featuredServices * @param Collection<int, Service> $featuredServices
* @param Collection<int, PortfolioCase> $featuredCases * @param Collection<int, PortfolioCase> $featuredCases
* @param Collection<int, Testimonial> $testimonials * @param Collection<int, Testimonial> $testimonials
* @param Collection<int, WeddingPackage> $packages
*/ */
public function __construct( public function __construct(
public SiteSetting $settings, public SiteSetting $settings,
public Collection $featuredServices, public Collection $featuredServices,
public Collection $featuredCases, public Collection $featuredCases,
public Collection $testimonials, public Collection $testimonials,
public Collection $packages,
) {} ) {}
} }

View File

@@ -9,6 +9,7 @@ use App\Models\PortfolioCase;
use App\Models\Service; use App\Models\Service;
use App\Models\SiteSetting; use App\Models\SiteSetting;
use App\Models\Testimonial; use App\Models\Testimonial;
use App\Models\WeddingPackage;
final class GetHomeContent final class GetHomeContent
{ {
@@ -31,6 +32,10 @@ final class GetHomeContent
->published() ->published()
->orderBy('sort_order') ->orderBy('sort_order')
->get(), ->get(),
packages: WeddingPackage::query()
->published()
->orderBy('sort_order')
->get(),
); );
} }
} }

View File

@@ -212,6 +212,26 @@ class ManageSiteSettings extends Page
->reorderable() ->reorderable()
->columnSpanFull(), ->columnSpanFull(),
]), ]),
Section::make('Corporate')
->description('Etapas apresentadas na frente Amare Corporate da home.')
->schema([
Repeater::make('corporate_steps')
->label('Etapas do serviço corporativo')
->schema([
TextInput::make('title')
->label('Título')
->required()
->maxLength(255),
Textarea::make('body')
->label('Descrição')
->required()
->rows(2),
])
->defaultItems(3)
->maxItems(6)
->reorderable()
->columnSpanFull(),
]),
Section::make('Princípios') Section::make('Princípios')
->schema([ ->schema([
TagsInput::make('principles') TagsInput::make('principles')
@@ -231,6 +251,11 @@ class ManageSiteSettings extends Page
->label('Telefone') ->label('Telefone')
->required() ->required()
->maxLength(255), ->maxLength(255),
TextInput::make('whatsapp_number')
->label('WhatsApp oficial para as modalidades')
->tel()
->maxLength(40)
->helperText('Use o número com DDD. Sem um número válido, as CTAs levam ao briefing.'),
TextInput::make('city') TextInput::make('city')
->label('Cidade') ->label('Cidade')
->maxLength(255), ->maxLength(255),

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\WeddingPackages\Pages;
use App\Filament\Resources\WeddingPackages\WeddingPackageResource;
use Filament\Resources\Pages\CreateRecord;
class CreateWeddingPackage extends CreateRecord
{
protected static string $resource = WeddingPackageResource::class;
}

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\WeddingPackages\Pages;
use App\Filament\Resources\WeddingPackages\WeddingPackageResource;
use Filament\Resources\Pages\EditRecord;
class EditWeddingPackage extends EditRecord
{
protected static string $resource = WeddingPackageResource::class;
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\WeddingPackages\Pages;
use App\Filament\Resources\WeddingPackages\WeddingPackageResource;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
class ListWeddingPackages extends ListRecords
{
protected static string $resource = WeddingPackageResource::class;
protected function getHeaderActions(): array
{
return [
CreateAction::make(),
];
}
}

View File

@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\WeddingPackages\Schemas;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Schemas\Schema;
class WeddingPackageForm
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
TextInput::make('name')
->label('Nome da modalidade')
->required()
->maxLength(255),
TextInput::make('level')
->label('Número / tipo (ex.: 01 / COMPLETA)')
->required()
->maxLength(255),
Textarea::make('summary')
->label('Resumo')
->required()
->rows(4),
Repeater::make('scope_items')
->label('O que está incluído')
->schema([
TextInput::make('item')
->label('Item')
->required()
->maxLength(255),
])
->defaultItems(4)
->minItems(1)
->addActionLabel('Adicionar item')
->required(),
TextInput::make('cta_label')
->label('Texto do botão')
->required()
->maxLength(255),
TextInput::make('sort_order')
->label('Ordem')
->numeric()
->default(0)
->required(),
DateTimePicker::make('published_at')
->label('Publicado em')
->seconds(false),
]);
}
}

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\WeddingPackages\Tables;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteAction;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
class WeddingPackagesTable
{
public static function configure(Table $table): Table
{
return $table
->columns([
TextColumn::make('name')
->label('Nome')
->searchable()
->sortable(),
TextColumn::make('level')
->label('Número / tipo')
->searchable(),
TextColumn::make('published_at')
->label('Publicado em')
->dateTime()
->sortable(),
TextColumn::make('sort_order')
->label('Ordem')
->sortable(),
])
->defaultSort('sort_order')
->filters([
//
])
->recordActions([
EditAction::make(),
DeleteAction::make()
->requiresConfirmation(),
])
->toolbarActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
]),
]);
}
}

View File

@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\WeddingPackages;
use App\Filament\Resources\WeddingPackages\Pages\CreateWeddingPackage;
use App\Filament\Resources\WeddingPackages\Pages\EditWeddingPackage;
use App\Filament\Resources\WeddingPackages\Pages\ListWeddingPackages;
use App\Filament\Resources\WeddingPackages\Schemas\WeddingPackageForm;
use App\Filament\Resources\WeddingPackages\Tables\WeddingPackagesTable;
use App\Models\WeddingPackage;
use App\Policies\WeddingPackagePolicy;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
use UnitEnum;
class WeddingPackageResource extends Resource
{
protected static ?string $model = WeddingPackage::class;
protected static ?string $policy = WeddingPackagePolicy::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedRectangleStack;
protected static ?string $navigationLabel = 'Modalidades';
protected static ?string $modelLabel = 'modalidade';
protected static ?string $pluralModelLabel = 'modalidades';
protected static string|UnitEnum|null $navigationGroup = 'Conteúdo do site';
protected static ?int $navigationSort = 3;
public static function form(Schema $schema): Schema
{
return WeddingPackageForm::configure($schema);
}
public static function table(Table $table): Table
{
return WeddingPackagesTable::configure($table);
}
public static function getRelations(): array
{
return [];
}
public static function getPages(): array
{
return [
'index' => ListWeddingPackages::route('/'),
'create' => CreateWeddingPackage::route('/create'),
'edit' => EditWeddingPackage::route('/{record}/edit'),
];
}
}

View File

@@ -135,6 +135,6 @@ final class ContactController extends Controller
private function success(): RedirectResponse private function success(): RedirectResponse
{ {
return redirect()->route('contact')->with('status', 'briefing-sent'); return redirect()->route('briefing')->with('status', 'briefing-sent');
} }
} }

View File

@@ -45,7 +45,7 @@ final class PageController extends Controller
{ {
$settings = SiteSetting::instance(); $settings = SiteSetting::instance();
return view('pages.contact', [ return view('pages.partners', [
'siteSettings' => $settings, 'siteSettings' => $settings,
'pageMeta' => PageMeta::forPage( 'pageMeta' => PageMeta::forPage(
canonical: route('contact'), canonical: route('contact'),
@@ -55,4 +55,14 @@ final class PageController extends Controller
), ),
]); ]);
} }
public function briefing(): View
{
$settings = SiteSetting::instance();
return view('pages.contact', [
'siteSettings' => $settings,
'pageMeta' => PageMeta::forPage(canonical: route('briefing'), settings: $settings, title: PageMeta::withBrandSuffix('Briefing', $settings), description: 'Solicite uma proposta à '.$settings->brand_name.'.'),
]);
}
} }

View File

@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\PublicSite;
use App\Http\Controllers\Controller;
use App\Http\Requests\PublicSite\PartnerInquiryRequest;
use App\Mail\PartnerInquiry;
use App\Models\SiteSetting;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Throwable;
final class PartnerInquiryController extends Controller
{
private const string DUPLICATE_SESSION_KEY = 'partner_inquiry_hash';
public function store(PartnerInquiryRequest $request): RedirectResponse
{
if (filled($request->input('website'))) {
return $this->success();
}
$validated = $request->validated();
if (session()->get(self::DUPLICATE_SESSION_KEY) === $this->hash($validated)) {
return $this->success();
}
session()->put(self::DUPLICATE_SESSION_KEY, $this->hash($validated));
try {
$settings = SiteSetting::instance();
if (filled($settings->email)) {
Mail::to($settings->email)->send(new PartnerInquiry([
'Nome' => $validated['nome'],
'Nome profissional ou empresa' => $validated['empresa'] ?? null,
'E-mail' => $validated['email'],
'Atuação ou serviço' => $validated['atuacao'],
'Área de atendimento' => $validated['area_atendimento'],
'Mensagem' => $validated['mensagem'],
'Portfólio ou redes' => $validated['portfolio_redes'] ?? null,
'Telefone' => $validated['telefone'] ?? null,
]));
}
} catch (Throwable $exception) {
Log::error('Falha ao enviar consulta de parceiro', ['exception' => $exception::class]);
}
return $this->success();
}
/** @param array<string, mixed> $validated */
private function hash(array $validated): string
{
return hash('sha256', serialize($validated));
}
private function success(): RedirectResponse
{
return redirect()->route('contact')->with('status', 'partner-inquiry-sent');
}
}

View File

@@ -8,6 +8,7 @@ use App\Application\Data\PageMeta;
use App\Application\Queries\Marketing\GetPublishedServices; use App\Application\Queries\Marketing\GetPublishedServices;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\SiteSetting; use App\Models\SiteSetting;
use App\Models\WeddingPackage;
use Illuminate\Contracts\View\View; use Illuminate\Contracts\View\View;
final class ServiceController extends Controller final class ServiceController extends Controller
@@ -19,6 +20,7 @@ final class ServiceController extends Controller
return view('pages.services.index', [ return view('pages.services.index', [
'services' => $services, 'services' => $services,
'weddingPackages' => WeddingPackage::query()->published()->orderBy('sort_order')->get(),
'siteSettings' => $settings, 'siteSettings' => $settings,
'pageMeta' => PageMeta::forPage( 'pageMeta' => PageMeta::forPage(
canonical: route('services.index'), canonical: route('services.index'),

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\PublicSite;
use Illuminate\Foundation\Http\FormRequest;
final class PartnerInquiryRequest extends FormRequest
{
protected function prepareForValidation(): void
{
$email = $this->input('email');
$this->merge(['email' => is_string($email) ? mb_strtolower(trim($email)) : $email]);
}
/** @return array<string, array<int, string>> */
public function rules(): array
{
return [
'nome' => ['required', 'string', 'max:120'],
'empresa' => ['nullable', 'string', 'max:160'],
'email' => ['required', 'email', 'max:254'],
'atuacao' => ['required', 'string', 'max:120'],
'area_atendimento' => ['required', 'string', 'max:160'],
'mensagem' => ['required', 'string', 'max:3000'],
'portfolio_redes' => ['nullable', 'string', 'max:500'],
'telefone' => ['nullable', 'string', 'max:40'],
];
}
}

View File

@@ -0,0 +1,31 @@
<?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 PartnerInquiry 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: 'Nova consulta de fornecedor ou parceria — Amare Assessoria');
}
public function content(): Content
{
return new Content(html: 'emails.partner-inquiry', text: 'emails.partner-inquiry-text');
}
}

View File

@@ -12,6 +12,7 @@ use Illuminate\Database\Eloquent\Model;
/** /**
* @property array<string, string|null> $social_links * @property array<string, string|null> $social_links
* @property list<array{title?: string, body?: string}>|null $method_steps * @property list<array{title?: string, body?: string}>|null $method_steps
* @property list<array{title?: string, body?: string}>|null $corporate_steps
* @property list<string>|null $principles * @property list<string>|null $principles
* @property bool $analytics_enabled * @property bool $analytics_enabled
* @property string|null $default_og_image_path * @property string|null $default_og_image_path
@@ -51,9 +52,11 @@ use Illuminate\Database\Eloquent\Model;
'manifesto_body', 'manifesto_body',
'method_intro', 'method_intro',
'method_steps', 'method_steps',
'corporate_steps',
'principles', 'principles',
'email', 'email',
'phone', 'phone',
'whatsapp_number',
'city', 'city',
'social_links', 'social_links',
'default_meta_title', 'default_meta_title',
@@ -82,9 +85,11 @@ class SiteSetting extends Model
'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.',
'method_intro' => 'Clareza em cada etapa. Tranquilidade durante todo o processo.', 'method_intro' => 'Clareza em cada etapa. Tranquilidade durante todo o processo.',
'method_steps' => self::defaultMethodSteps(), 'method_steps' => self::defaultMethodSteps(),
'corporate_steps' => self::defaultCorporateSteps(),
'principles' => self::defaultPrinciples(), 'principles' => self::defaultPrinciples(),
'email' => 'amareassessoriaeventos@gmail.com', 'email' => 'amareassessoriaeventos@gmail.com',
'phone' => '(11) 99999-9999', 'phone' => '(11) 99999-9999',
'whatsapp_number' => null,
'city' => 'São Paulo - SP', 'city' => 'São Paulo - SP',
'social_links' => [], 'social_links' => [],
'default_meta_title' => 'Amare Assessoria de Eventos', 'default_meta_title' => 'Amare Assessoria de Eventos',
@@ -118,6 +123,27 @@ class SiteSetting extends Model
]; ];
} }
/**
* @return list<array{title: string, body: string}>
*/
public static function defaultCorporateSteps(): array
{
return [
[
'title' => 'Planejamento',
'body' => 'Estruturação de escopo, cronograma e prioridades.',
],
[
'title' => 'Produção',
'body' => 'Coordenação dos elementos necessários para colocar o evento de pé.',
],
[
'title' => 'Execução',
'body' => 'Condução e acompanhamento do evento conforme o projeto aprovado.',
],
];
}
/** /**
* @return list<string> * @return list<string>
*/ */
@@ -139,6 +165,7 @@ class SiteSetting extends Model
return [ return [
'social_links' => 'array', 'social_links' => 'array',
'method_steps' => 'array', 'method_steps' => 'array',
'corporate_steps' => 'array',
'principles' => 'array', 'principles' => 'array',
'analytics_enabled' => 'boolean', 'analytics_enabled' => 'boolean',
]; ];

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Models;
use App\Models\Concerns\HasPublication;
use App\Policies\WeddingPackagePolicy;
use Database\Factories\WeddingPackageFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\UsePolicy;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon;
/**
* @property string $name
* @property string $level
* @property string $summary
* @property list<string> $scope_items
* @property string $cta_label
* @property int $sort_order
* @property Carbon|null $published_at
*/
#[Fillable(['name', 'level', 'summary', 'scope_items', 'cta_label', 'sort_order', 'published_at'])]
#[UsePolicy(WeddingPackagePolicy::class)]
class WeddingPackage extends Model
{
/** @use HasFactory<WeddingPackageFactory> */
use HasFactory;
use HasPublication;
/** @return array<string, string|class-string> */
protected function casts(): array
{
return ['scope_items' => 'array', 'sort_order' => 'integer', 'published_at' => 'datetime'];
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Policies;
use App\Models\User;
use App\Models\WeddingPackage;
class WeddingPackagePolicy
{
public function viewAny(User $user): bool
{
return $user->isAdmin();
}
public function view(User $user, WeddingPackage $package): bool
{
return $user->isAdmin();
}
public function create(User $user): bool
{
return $user->isAdmin();
}
public function update(User $user, WeddingPackage $package): bool
{
return $user->isAdmin();
}
public function delete(User $user, WeddingPackage $package): bool
{
return $user->isAdmin();
}
}

View File

@@ -72,6 +72,10 @@ class AppServiceProvider extends ServiceProvider
RateLimiter::for('contact-briefing', function (Request $request): Limit { RateLimiter::for('contact-briefing', function (Request $request): Limit {
return Limit::perMinute(5)->by($request->ip().'|contact-briefing'); return Limit::perMinute(5)->by($request->ip().'|contact-briefing');
}); });
RateLimiter::for('partner-inquiry', function (Request $request): Limit {
return Limit::perMinute(5)->by($request->ip().'|partner-inquiry');
});
} }
private function freezeClockWhenConfigured(): void private function freezeClockWhenConfigured(): void

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Database\Factories;
use App\Models\WeddingPackage;
use Illuminate\Database\Eloquent\Factories\Factory;
/** @extends Factory<WeddingPackage> */
class WeddingPackageFactory extends Factory
{
protected $model = WeddingPackage::class;
/** @return array<string, mixed> */
public function definition(): array
{
return [
'name' => fake()->unique()->words(2, true),
'level' => 'Assessoria',
'summary' => fake()->sentence(),
'scope_items' => [fake()->sentence()],
'cta_label' => 'Conversar sobre esta modalidade',
'sort_order' => 0,
'published_at' => null,
];
}
public function published(): static
{
return $this->state(fn (): array => ['published_at' => now()]);
}
}

View File

@@ -0,0 +1,24 @@
<?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->jsonb('corporate_steps')->nullable()->after('method_steps');
});
}
public function down(): void
{
Schema::table('site_settings', function (Blueprint $table): void {
$table->dropColumn('corporate_steps');
});
}
};

View File

@@ -0,0 +1,24 @@
<?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('whatsapp_number')->nullable()->after('phone');
});
}
public function down(): void
{
Schema::table('site_settings', function (Blueprint $table): void {
$table->dropColumn('whatsapp_number');
});
}
};

View File

@@ -0,0 +1,30 @@
<?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::create('wedding_packages', function (Blueprint $table): void {
$table->id();
$table->string('name');
$table->string('level');
$table->string('summary');
$table->jsonb('scope_items');
$table->string('cta_label');
$table->integer('sort_order')->default(0)->index();
$table->timestamp('published_at')->nullable()->index();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('wedding_packages');
}
};

View File

@@ -49,6 +49,7 @@ class ContentSeeder extends Seeder
$this->seedServices(); $this->seedServices();
$this->seedPortfolioCases(); $this->seedPortfolioCases();
$this->call(TestimonialsSeeder::class); $this->call(TestimonialsSeeder::class);
$this->call(WeddingPackagesSeeder::class);
} }
private function seedSiteSettings(): void private function seedSiteSettings(): void
@@ -75,6 +76,7 @@ class ContentSeeder extends Seeder
'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.',
'method_intro' => 'Clareza em cada etapa. Tranquilidade durante todo o processo.', 'method_intro' => 'Clareza em cada etapa. Tranquilidade durante todo o processo.',
'method_steps' => SiteSetting::defaultMethodSteps(), 'method_steps' => SiteSetting::defaultMethodSteps(),
'corporate_steps' => SiteSetting::defaultCorporateSteps(),
'principles' => SiteSetting::defaultPrinciples(), 'principles' => SiteSetting::defaultPrinciples(),
'email' => 'amareassessoriaeventos@gmail.com', 'email' => 'amareassessoriaeventos@gmail.com',
'phone' => '(11) 99999-9999', 'phone' => '(11) 99999-9999',

View File

@@ -30,6 +30,7 @@ class VisualContentSeeder extends Seeder
$this->seedServices(); $this->seedServices();
$this->seedPortfolioCases(); $this->seedPortfolioCases();
$this->seedTestimonials(); $this->seedTestimonials();
$this->call(WeddingPackagesSeeder::class);
} }
private function seedSiteSettings(): void private function seedSiteSettings(): void
@@ -56,6 +57,7 @@ class VisualContentSeeder extends Seeder
'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.',
'method_intro' => 'Clareza em cada etapa. Tranquilidade durante todo o processo.', 'method_intro' => 'Clareza em cada etapa. Tranquilidade durante todo o processo.',
'method_steps' => SiteSetting::defaultMethodSteps(), 'method_steps' => SiteSetting::defaultMethodSteps(),
'corporate_steps' => SiteSetting::defaultCorporateSteps(),
'principles' => SiteSetting::defaultPrinciples(), 'principles' => SiteSetting::defaultPrinciples(),
'email' => 'amareassessoriaeventos@gmail.com', 'email' => 'amareassessoriaeventos@gmail.com',
'phone' => '(11) 99999-9999', 'phone' => '(11) 99999-9999',
@@ -198,16 +200,22 @@ class VisualContentSeeder extends Seeder
*/ */
private function writeSolidJpeg(string $destination, int $width, int $height, array $rgb): string private function writeSolidJpeg(string $destination, int $width, int $height, array $rgb): string
{ {
$disk = Storage::disk('public');
if ($disk->exists($destination)) {
return $destination;
}
$image = imagecreatetruecolor($width, $height); $image = imagecreatetruecolor($width, $height);
$color = imagecolorallocate($image, $rgb[0], $rgb[1], $rgb[2]); $color = imagecolorallocate($image, $rgb[0], $rgb[1], $rgb[2]);
imagefilledrectangle($image, 0, 0, $width, $height, $color); imagefilledrectangle($image, 0, 0, $width, $height, $color);
ob_start(); ob_start();
imagejpeg($image, null, 90); imagejpeg($image, null, 80);
$binary = (string) ob_get_clean(); $binary = (string) ob_get_clean();
imagedestroy($image); imagedestroy($image);
Storage::disk('public')->put($destination, $binary); $disk->put($destination, $binary);
return $destination; return $destination;
} }

View File

@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace Database\Seeders;
use App\Models\WeddingPackage;
use Illuminate\Database\Seeder;
use Illuminate\Support\Carbon;
/**
* Wedding package modalities shown on the home page (03 Amare Casamentos).
* Copy is approved from the structural model preview (preview(1).html) and
* remains editable via the Filament resource.
*/
class WeddingPackagesSeeder extends Seeder
{
private const PUBLISHED_AT = '2026-08-11 00:00:00';
public function run(): void
{
$packages = [
[
'name' => 'Essenza',
'level' => '01 / COMPLETA',
'summary' => 'Para casais que desejam contar com a Amare desde o planejamento até a realização do casamento.',
'scope_items' => [
'Planejamento e organização',
'Gestão de etapas e prioridades',
'Acompanhamento de fornecedores',
'Coordenação do grande dia',
],
'cta_label' => 'Quero conhecer a Essenza',
'sort_order' => 1,
],
[
'name' => 'Conduzione',
'level' => '02 / PARCIAL',
'summary' => 'Para quem já começou e deseja estruturar o que falta, organizar fornecedores e seguir com acompanhamento profissional.',
'scope_items' => [
'Diagnóstico do planejamento',
'Organização das pendências',
'Gestão dos próximos passos',
'Coordenação do grande dia',
],
'cta_label' => 'Quero conhecer a Conduzione',
'sort_order' => 2,
],
[
'name' => 'Grand Jour',
'level' => '03 / FINAL',
'summary' => 'Para casais que já planejaram o casamento e precisam de uma equipe para assumir alinhamentos e operação na reta final.',
'scope_items' => [
'Imersão no planejamento existente',
'Alinhamento de fornecedores',
'Cronograma final',
'Gestão da operação do evento',
],
'cta_label' => 'Quero conhecer a Grand Jour',
'sort_order' => 3,
],
];
foreach ($packages as $package) {
WeddingPackage::query()->updateOrCreate(
['name' => $package['name']],
[...$package, 'published_at' => Carbon::parse(self::PUBLISHED_AT)],
);
}
}
}

View File

@@ -0,0 +1,36 @@
# Design — Home editorial conforme preview
Fonte de verdade visual: modelo estrutural validado (`preview(1).html`) + tokens `resources/css/tokens.css` (inalterados; assertados por `HeritageEditorialTokensTest`). Paleta do preview já mapeada para os utilitários `amare-*`.
## Estrutura da home (8 capítulos)
| Capítulo | Id/âncora | Componente | Conteúdo |
|---|---|---|---|
| Hero | — | `x-home.hero` | Eyebrow fixo, h1 fixo, lede fixo, CTAs "Conhecer a Amare" (`#sobre`) + "Enviar briefing" (`#contato`), arte `hero_image_path` ou placeholder rotulado "Fotografia hero" |
| 01 — A Amare | `#sobre` | `x-home.manifesto` | Editorial-split 0.8fr/1.2fr; statement fixo + placeholder institucional |
| 02 — Duas vertentes | `#vertentes` | `x-home.vertentes` | 2 cards (Casamentos/Corporate) com overlay gradiente 43%, CTAs para âncoras |
| 03 — Amare Casamentos | `#casamentos` | `x-home.packages` | Grid 3 colunas com bordas (Eyebrow/título/descrição/items/CTA primário → `contact`), bloco de orientação accent-deep + nota de nomenclatura |
| 04 — Amare Corporate | `#corporate` | `x-home.corporate` | Etapas `corporate_steps` (número 44px, bordas) + placeholder "Portfólio Corporate / Conteúdo em construção" |
| 05 — Portfólio | `#portfolio` | `x-home.portfolio` | Grid 1.2fr/0.8fr/0.8fr, 1 foto alta (row-span-2, 634px) + 4; cases reais ou labels "FOTO 0X" |
| 06 — Depoimentos | `#depoimentos` | `x-home.testimonials` | 3 cards brancos com borda; sem carrossel; placeholders até completar 3 |
| 07 — Briefing | `#contato` | `x-home.briefing` | Formulário completo reusando `contact.store` (underline inputs, labels uppercase 12px, honeypot `#empresa`, checkbox LGPD) |
## Padrões visuais
- Seções: `home-chapter border-b border-amare-border bg-amare-bg`, padding `py-16 md:py-24`, `container-amare`, `data-reveal-group` + blocos `data-reveal data-reveal-from="up"`.
- Eyebrow: `text-xs font-bold uppercase tracking-[0.16em] text-amare-accent-deep`.
- h2 `clamp(2.375rem,5vw,4rem)`, h3 `clamp(1.5625rem,2.7vw,2.125rem)`, lede `clamp(1.1875rem,2vw,1.5rem)` max-w 760px, `leading-[1.55]`.
- Botão primário: `min-h-[48px] bg-amare-accent text-amare-accent-text uppercase text-xs font-bold tracking-[0.09em]`, hover accent-deep; contorno: `border-amare-accent text-amare-accent-deep`; em bloco escuro: `border-amare-accent-text text-amare-accent-text`.
- Header: sticky 74px, `bg-amare-bg/95 backdrop-blur-sm`, nav 13px uppercase `tracking-[0.08em]`, botão "Conte seu evento" → `contact`.
- Footer: `py-14 md:py-[58px]`, marca + "Assessoria & produção de eventos • São Paulo", social links do settings ou placeholder "Instagram · WhatsApp · E-mail · LinkedIn (quando confirmado)".
## Acessibilidade
- Âncoras `aria-labelledby` por seção; foco/ordem tab preservados; skip-link intacto.
- `prefers-reduced-motion`: blocos globais em tokens.css (0.01ms) — nenhum motion extra adicionado.
- Erros de validação do briefing com `role="alert"` e `aria-describedby`; honeypot oculto.
- Contrastes: accent `#556B2F` sobre bg `#FBF9F4` ≈ 4.6:1; accent-text branco sobre accent ≈ 6.9:1; muted `#5D6155` sobre bg ≈ 5.4:1 (dentro do AA).
## Fora de escopo visual
Copy final institucional (placeholders), acervo fotográfico autorizado, canal de fornecedores (pós-MVP), folios ornamentais, carrossel, parallax.

View File

@@ -0,0 +1,33 @@
# Home editorial conforme preview estrutural
## Why
A home atual não reflete a direção editorial aprovada para o site (Dossiê Editorial do Evento). O cliente validou um modelo estrutural (`preview(1).html`) que demonstra hierarquia, jornada e diferenciação de fluxos: hero editorial, editorial-split institucional, duas vertentes (Casamentos/Corporate), modalidades de casamento com orientação humana, frente corporativa honesta sobre portfólio em construção, portfólio curado, depoimentos sem carrossel e briefing comercial único. A home também é a porta de conversão primária (briefing), então a apresentação deve construir confiança antes de pedir conversão.
## What Changes
- **Home em 8 seções** (componentes `x-home.*`): hero assimétrico (1.1fr/0.9fr, arte rotulada até acervo autorizado), `01 — A Amare` (editorial-split), `02 — Duas vertentes` (2 cards), `03 — Amare Casamentos` (grid de modalidades com bordas + bloco de orientação em accent-deep), `04 — Amare Corporate` (etapas + placeholder "Conteúdo em construção"), `05 — Portfólio` (1 foto alta + 4), `06 — Depoimentos` (3 cards, sem carrossel), `07 — Briefing comercial` (formulário completo reaproveitando `contact.store`).
- **Novo modelo `WeddingPackage`** (Modalidades): migration, model com publication, factory, policy, seeder com copy exata do preview (Essenza/Conduzione/Grand Jour) e recurso Filament "Modalidades" com repeater de itens.
- **`SiteSetting.corporate_steps`**: JSON de 3 etapas (Planejamento/Produção/Execução), editável via repeater no `ManageSiteSettings`, semeadas nos dois seeders.
- **Header/footer globais** restilizados conforme preview (barra 74px, nav uppercase 13px, botão "Conte seu evento", footer editorial), preservando menu mobile, skip-link, logo e links legais.
- Motion mantém convenções existentes (`data-motion="page-open"`, `data-motion-beat`, `data-reveal`), respeitando `prefers-reduced-motion` global.
## Non-Goals
- Copy final institucional onde o preview declara placeholder (ex.: "Aqui entra a apresentação institucional da marca e da Michele…") — permanece placeholder.
- Fotografias autorizadas da Amare (acervo ainda em organização) — imagens permanecem rotuladas.
- Canal/forma de fornecedores e parcerias (pós-MVP, caixa tracejada) — não misturar ao briefing comercial.
- Carrossel de depoimentos, parallax, scroll-jacking ou folios ornamentais.
- Alteração de tokens do design system (assertados por `HeritageEditorialTokensTest`).
## Capabilities
### New Capabilities
- `wedding-packages`: cadastro de modalidades de casamento (Essenza/Conduzione/Grand Jour) com itens, ordenação e publicação via Filament; exibidas na home na seção `03 — Amare Casamentos`.
### Modified Capabilities
- `public-site-pages`: home recomposta em 8 capítulos com âncoras (`#sobre`, `#vertentes`, `#casamentos`, `#corporate`, `#portfolio`, `#depoimentos`, `#contato`); header/footer globais restilizados; briefing da home reutiliza o fluxo `contact.store` existente.
- `content-media`: heróis continuam usando `hero_image_path` com fallback tipográfico rotulado; portfólio usa `featuredCases` reais ou labels provisórios.
- `design-tokens`: nenhum valor alterado; novos componentes usam os tokens existentes via utilitários `amare-*`.

View File

@@ -0,0 +1,22 @@
# Wedding Packages
## Purpose
Cadastro editorial das modalidades de acompanhamento de casamento oferecidas pela Amare (Essenza, Conduzione, Grand Jour). Fonte única de verdade para a seção `03 — Amare Casamentos` da home.
## Functional Requirements
- FR-1: Um pacote contém `eyebrow` (ex.: "01 / COMPLETA"), `title` (ex.: "Essenza"), `description`, lista ordenada de `items` (o que está incluído) e `sort_order`.
- FR-2: Publicação via `published_at` (modelo usa `HasPublication`); pacotes rascunho nunca aparecem na home.
- FR-3: A home renderiza pacotes publicados ordenados por `sort_order`, cada um com CTA primário "Quero conhecer a {title}" apontando para o briefing (`route('contact')`).
- FR-4: O recurso Filament "Modalidades" (grupo "Conteúdo do site") permite criar/editar/excluir pacotes e ajustar itens via repeater; acesso restrito a admins (`WeddingPackagePolicy`).
## Non-Requirements
- Nenhum vínculo com checkout/pagamento nesta capacidade.
- Nomenclaturas exibidas são provisórias até confirmação com a cliente (nota visível na home).
## Data
- Tabela `wedding_packages` (migration `2026_08_11_000000`): `eyebrow string`, `title string`, `description text`, `items json`, `sort_order int`, `published_at timestamp nullable`, timestamps.
- Seed padrão (ambos os seeders): Essenza `01 / COMPLETA`, Conduzione `02 / PARCIAL`, Grand Jour `03 / FINAL`, com copy exata do preview estrutural.

View File

@@ -0,0 +1,18 @@
# Tasks — Home editorial conforme preview
## Concluído
- [x] **T0** Worktree `.worktrees/home-redesign` (branch `feat/home-redesign`) a partir de `main`.
- [x] **T1** `WeddingPackage` (model + migration `2026_08_11_000000` + factory + policy + seeder + recurso Filament "Modalidades"): 3 modalidades semeadas com copy exata do preview; seeders `ContentSeeder`/`VisualContentSeeder` passam a chamar `WeddingPackagesSeeder`.
- [x] **T2** `SiteSetting.corporate_steps` (migration `2026_08_11_010000`, `defaultCorporateSteps()`, casts/fillable/docblock, repeater em `ManageSiteSettings`, seeders atualizados).
- [x] **T3** Header/footer globais (`layouts/public.blade.php`) restilizados conforme preview; menu mobile, skip-link, logo e links legais preservados.
- [x] **T4** Home recomposta em 8 seções (`x-home.*`: hero, manifesto/sobre, vertentes, packages, corporate, portfolio, testimonials, briefing); `HomeContent` ganha `packages`; `GetHomeContent` busca pacotes publicados por `sort_order`.
- [x] **T5** Motion via convenções existentes (`data-reveal`, `data-motion-beat`, `data-motion="page-open"`); `prefers-reduced-motion` global respeitado; sem carrossel/parallax.
- [x] **T6** Testes: `HomePageContentTest` reescrito (8 seções, packages, corporate steps, briefing form, placeholders), `HomePageTest`, `GetHomeContentTest` (packages), `ImmersivePhotoHeroTest`/`MotionMarkupTest` atualizados para o novo contrato, `HomeEditorialCadenceTest` reescrito para o layout assimétrico. **178 feature + 42 unit verdes**.
- [x] **T7** `composer pint` + `phpstan` limpos; 2 commits convencionais; PR #53 aberto.
## Pendente
- [ ] **T8** Proposta openspec (este change) revisada e arquivada após merge.
- [ ] Merge do PR #53 após CI verde; limpeza do worktree (`git worktree remove`).
- [ ] Follow-up pós-merge: confirmar nomenclaturas das modalidades com a cliente (nota na home: "Nomenclaturas exibidas conforme materiais/reunião; confirmar versão final antes da publicação.").

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-11

View File

@@ -0,0 +1,48 @@
## Context
As imagens de abertura por rota, `ResponsiveImage`, texto alternativo obrigatório e fallback tonal já existem. O componente público atual, porém, põe o texto sobre a fotografia e a home mantém duas colunas iguais. A referência aprovada pede uma abertura editorial legível, reutilizável e linear sem JavaScript.
## Goals / Non-Goals
**Goals:**
- Reunir as cinco aberturas narrativas em um único componente Blade de spread 5/7.
- Limitar a coluna de leitura a 470 px e manter títulos com quebra normal, sem clipping ou overflow.
- Preservar a ordem DOM texto → mídia em mobile, mídia responsiva, foco, motion progressivo e fallback tonal.
**Non-Goals:**
- Alterar campos CMS, uploads, dados, rotas, cópia, SEO/Open Graph, contato, privacidade, erros ou dependências.
- Criar ou atualizar snapshots, introduzir imagens externas ou transformar a rota de protótipo em funcionalidade de produção.
## Decisions
### Um componente compartilhado recebe todo o conteúdo de abertura
`x-public.photo-hero` passa a renderizar texto, slot opcional e mídia tanto para as páginas institucionais quanto para a home. A home delega o seu conteúdo ao componente para impedir que os contratos de proporção, fallback e carregamento se separem. Duplicar uma segunda implementação da home foi descartado por já ter produzido divergência 50/50.
### Grade CSS 5/7 acima de `lg`; fluxo normal abaixo dela
No desktop, uma grade de doze colunas entrega 5/12 ao campo de texto e 7/12 à mídia. No mobile e tablet, a ordem do DOM produz texto seguido pela imagem de proporção 4/5. Posicionamento absoluto e painel sobre a foto foram descartados porque reduzem a largura de leitura previsível e dificultam a contenção do texto.
### Mídia de hero continua prioritária e dimensionada pela área real
A única imagem de abertura mantém `loading=eager`, `fetchpriority=high`, `object-cover` e o componente responsivo existente. Seu `sizes` informa `100vw` abaixo de `lg` e aproximadamente `58vw` acima dele, em vez dos antigos `50vw`/`100vw` indiscriminados. O fallback não emite imagem quando o campo está vazio.
### Motion preserva o conteúdo legível desde o HTML
Os atributos existentes de `page-open` e beats de mídia/título/CTA permanecem. Nenhum texto fica escondido por opacidade; movimento reduzido e ausência de JavaScript exibem o estado final.
## Risks / Trade-offs
- [Uma imagem de origem estreita pode recortar conteúdo importante em 7/12] → `object-cover` preserva o contrato atual; curadoria/crop da mídia continua sendo responsabilidade editorial.
- [Conteúdo de título excepcionalmente longo pode pressionar a coluna] → limite de leitura, quebra normal e testes de ausência de overflow evitam palavras cortadas.
- [Breakpoint pode atualizar depois de resize em browser test] → os testes aguardam dois frames de animação antes de medir a mídia.
## Migration Plan
Não há migração de dados. O deploy troca apenas markup e classes; o rollback do commit restaura a abertura anterior. Rotas sem imagem continuam no fallback tonal já previsto.
## Open Questions
Nenhuma. Fotografias autorizadas de produção continuam uma dependência de conteúdo, não desta mudança.

View File

@@ -0,0 +1,25 @@
## Why
As aberturas fotográficas já contam com mídia própria, mas a composição atual alterna uma imagem de fundo ou uma divisão 50/50 que achata a leitura editorial. Um spread consistente de texto 5/12 e imagem 7/12 reforça a hierarquia de WEB-01, WEB-02, WEB-03 e WEB-07 sem inventar conteúdo nem ampliar o CMS.
## What Changes
- Define um componente único de abertura fotográfica em spread: conteúdo à esquerda em 5/12, fotografia à direita em 7/12, coluna de leitura limitada e imagem com recorte editorial.
- Aplica o mesmo contrato às aberturas narrativas da home, serviços, portfólio, detalhe de case e Sobre; telas menores linearizam texto antes da mídia.
- Mantém os campos de mídia existentes, carregamento prioritário apenas do hero, alternativa tonal sem imagem e a separação entre imagens editoriais e Open Graph.
- Mantém contato, privacidade e páginas de erro como superfícies tonais sóbrias.
## Capabilities
### New Capabilities
- Nenhuma.
### Modified Capabilities
- `public-site-pages`: as cinco aberturas narrativas passam a exigir o spread editorial 5/7 em desktop e uma sequência texto-para-mídia em telas menores.
- `content-media`: imagens de abertura passam a declarar tamanhos compatíveis com 100% da largura em mobile e 7/12 do viewport em desktop.
## Impact
Afeta os componentes Blade de hero público e da home, a documentação de design, os testes de renderização e browser das rotas públicas e as delta specs. Não altera rotas de produção, controladores, CMS, uploads, SEO/OG, dependências ou os itens fora do MVP em SPEC.md §4.2.

View File

@@ -0,0 +1,14 @@
## ADDED Requirements
### Requirement: Editorial hero media declares its responsive spread width
Configured narrative hero media SHALL load eagerly with high priority and declare responsive sizes matching the rendered spread: full viewport width below the desktop breakpoint and seven twelfths of the viewport at desktop. Each configured hero image MUST retain meaningful alternative text; a missing image MUST use the tonal fallback without emitting media markup (SPEC §6.4, §6.6).
#### Scenario: A configured hero reports its responsive width
- **WHEN** a narrative route renders configured hero media
- **THEN** the image MUST declare `100vw` for smaller viewports and approximately `58vw` for the desktop spread
- **AND** it MUST use eager loading and high fetch priority
#### Scenario: An unconfigured hero emits no media
- **WHEN** a narrative route has no hero image path
- **THEN** the opening MUST not render an image element
- **AND** it MUST retain its readable tonal content

View File

@@ -0,0 +1,23 @@
## ADDED Requirements
### Requirement: Narrative openings use a readable editorial spread
The home, services, portfolio listing, portfolio detail, and About routes SHALL render configured hero media as a Heritage Editorial spread: a 5/12 text field and 7/12 media field on desktop, with a text-first linear sequence on smaller viewports. The text field MUST limit reading width to 470 px, preserve normal word wrapping, and avoid horizontal overflow or clipped text. Contact, privacy, and error surfaces MUST remain sober tonal layouts (WEB-01, WEB-02, WEB-03, WEB-07).
#### Scenario: A configured narrative opening renders the desktop spread
- **WHEN** a visitor loads a narrative route with its configured hero image at a desktop viewport
- **THEN** its text field MUST occupy five of twelve grid columns and its media field seven of twelve grid columns
- **AND** the media MUST use an editorial `object-cover` crop
#### Scenario: A configured narrative opening stacks without losing reading content
- **WHEN** a visitor loads a narrative route with its configured hero image below the desktop breakpoint
- **THEN** the text content MUST precede the media in DOM and visual order
- **AND** the route MUST not horizontally overflow or clip text
#### Scenario: A narrative opening has no configured image
- **WHEN** a visitor loads a narrative route without configured hero media
- **THEN** the route MUST render its intentional tonal fallback without an empty image request
#### Scenario: Functional routes remain sober
- **WHEN** a visitor loads contact or privacy
- **THEN** the route MUST use its sober tonal opening
- **AND** it MUST not render the narrative photo spread

View File

@@ -0,0 +1,14 @@
## 1. Editorial contract
- [x] 1.1 Record the approved 5/7 spread rule in the Heritage Editorial design guidance.
- [x] 1.2 Add failing feature and browser coverage for the 5/7 desktop grid, 470 px reading column, responsive image sizes, text-first mobile order, and contained text.
## 2. Shared opening implementation
- [x] 2.1 Rebuild the public photo hero as the single responsive spread component, retaining fallback, accessible media, motion attributes, optional content, and no-image behavior.
- [x] 2.2 Delegate the home opening to the shared component while retaining its CTAs, note, chapter marker, and motion hooks.
## 3. Verification
- [x] 3.1 Run focused feature and browser/accessibility/motion checks without creating or updating screenshots.
- [x] 3.2 Run Pint, PHPStan, frontend build, and strict OpenSpec validation.

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-11

View File

@@ -0,0 +1,58 @@
## Context
The public site currently sends every commercial CTA to `/contato`, where the detailed event briefing lives. Its home chapters reflect the original editorial sequence, while the CMS only has generic `Service` records. Production seed gating already distinguishes environmental content but the public portfolio needs an explicit empty-acervo state so no demonstrative case can be mistaken for proof.
## Goals / Non-Goals
**Goals:**
- Keep the public surface server-rendered Blade with progressive vanilla JS and a single Heritage Editorial visual language.
- Make wedding modalities typed, publishable, ordered CMS content; derive their WhatsApp CTA server-side with an explicit briefing fallback.
- Split prospect and partner submissions at route, request, controller, mail and success-state boundaries.
- Keep public data minimised: partner submissions are mailed, never persisted.
**Non-Goals:**
- No WhatsApp API/integration, CRM Lead creation, partner directory, Corporate service inventory, Corporate case, invented evidence or page builder.
- No production migration of fixture cases into real proof; the authorized client content remains an operational prerequisite.
## Decisions
### Typed `WeddingPackage` content instead of a JSON setting
`wedding_packages` has a dedicated model, migration, factory, policy, Filament resource and public query. This matches the existing publication/order model for Services and keeps content validation, ordering and authorization visible. A JSON array in `site_settings` would be faster initially but would weaken publication control and future editing.
### WhatsApp is a deeplink, not an integration
The setting stores digits-normalized `whatsapp_number`; a small value object builds `https://wa.me/<digits>?text=<encoded contextual message>`. If the setting is absent or invalid, the same CTA points to `/briefing?servico_interesse=<package>` so conversion still works. This offers the authorized official channel without violating SPEC §4.2's prohibition on an official integration.
### Separate partner boundary, not a mode on briefing
`PartnerInquiryRequest`, `PartnerInquiryController` and `PartnerInquiry` mailable have their own payload, honeypot key, throttle and session duplicate hash. It prevents event fields, marketing origin and confirmations from crossing into the partnership flow. A single controller with a discriminated input was rejected because it increases the chance of accidental routing or data disclosure.
### Honest public availability states
The home consumes `GetHomeContent`, which returns only actual published cases in production. The portfolio chapter itself remains present and shows an acervo-em-preparação note on empty data. Non-production visual fixtures continue to seed only for development/staging. Corporate remains copy plus a briefing CTA, deliberately without data-driven claims, media or a service list.
### Navigation links use canonical home fragments
Header/footer link to `/#amare`, `/#casamentos`, `/#corporate`, `/#portfolio` and `/#depoimentos` from other routes, and native fragments on the home. This preserves deep links and no-JS behavior; CSS scroll margin compensates for the sticky header.
## Risks / Trade-offs
- [Official WhatsApp number is not configured at deploy] → every package CTA has a deterministic briefing fallback and settings label calls out the operational requirement.
- [Client proof is still unavailable] → production renders no fixture cases, a visible acervo note and no Corporate cases.
- [New contact route changes incoming links] → `/contato` remains available for partners and header/footer continue to expose it; event CTA links move deliberately to `/briefing`.
- [Detailed forms duplicate markup] → shared field styles and narrow controllers are preferred over generic, condition-heavy form components.
## Migration Plan
1. Deploy the additive table/column migrations and code together.
2. Run the content seeder to install official wedding modalities; set the official WhatsApp number in Filament before promoting CTAs.
3. In production, leave portfolio cases unpublished until real authorized material is loaded; verify the empty-acervo state.
4. Rollback is code-safe because routes remain additive and the legacy setting data is untouched; if necessary, unset the new WhatsApp column and retain the package rows without public publication.
## Open Questions
- Corporate institutional copy and authorized proof (MAN-98/MAN-99) remain client dependencies; no placeholder claims will be added.
- The official WhatsApp number must be supplied before the campaign links are promoted.

View File

@@ -0,0 +1,31 @@
## Why
A home atual apresenta os conteúdos do CMS, mas não conduz com clareza as duas frentes da Amare nem separa o pedido de proposta da relação com fornecedores. A estrutura de lançamento precisa tornar Casamentos e Corporate compreensíveis sem inventar prova, preservar o acervo real como requisito de publicação e abrir um canal mínimo, privado e específico para parcerias.
## What Changes
- Reorganizar a home em capítulos editoriais ancoráveis: apresentação, Amare, vertentes, Casamentos, Corporate, acervo, depoimentos e fechamento.
- Adicionar modalidades de casamento publicáveis e ordenáveis no CMS, com conteúdo inicial oficial para Essenza, Conduzione e Grand Jour.
- Adicionar número oficial de WhatsApp às configurações e tornar as CTAs das modalidades contextuais, com fallback para o briefing.
- Mover o formulário de pedido de proposta para `/briefing`; transformar `/contato` em uma jornada independente para fornecedores e parcerias.
- Enviar a consulta de parceiro por e-mail próprio, sem criar Lead ou persistir dados; manter CSRF, honeypot, rate limit, validação e aviso de privacidade proporcional à finalidade.
- Manter Corporate como apresentação institucional e CTA até haver copy, fotos e cases autorizados; em produção, não publicar fixtures de portfólio e informar que o acervo está em preparação.
## Capabilities
### New Capabilities
- `wedding-packages`: modalidades de casamento gerenciadas no CMS e CTAs contextuais de WhatsApp/briefing.
- `partner-inquiries`: formulário público mínimo, privado e não persistente para fornecedores e parcerias.
### Modified Capabilities
- `public-site-pages`: nova estrutura editorial da home, âncoras e separação entre briefing e contato de parceiros.
- `service-catalog`: aprofundamento de serviços passa a apresentar as modalidades de casamento publicadas.
- `site-settings`: configurações tipadas passam a incluir o número oficial de WhatsApp.
- `transactional-email`: e-mail operacional separado para consultas de parceiros.
- `content-media`: fixtures de casos ficam limitadas a desenvolvimento e staging; produção só mostra acervo real publicado.
## Impact
Afeta rotas, controllers, requests, mailables, modelos/migrations/seeders, Filament, consultas de marketing, componentes Blade, navegação, estilos e testes feature/browser. A mudança atende WEB-01, WEB-02, WEB-03, WEB-05, WEB-06 e WEB-07, sem introduzir integração oficial com WhatsApp, CRM, persistência de leads, marketplace ou outros itens fora do MVP (SPEC.md §4.2).

View File

@@ -0,0 +1,8 @@
## ADDED Requirements
### Requirement: Production never promotes visual fixture cases as Amare proof
The content seeder SHALL restrict visual fixture portfolio cases to non-production environments. Production public output SHALL only render cases that were explicitly loaded and published as authorized content.
#### Scenario: Production seed does not create fixture cases
- **WHEN** the content seeder runs with the production environment
- **THEN** no fixture portfolio case or fixture portfolio image is published

View File

@@ -0,0 +1,16 @@
## ADDED Requirements
### Requirement: Partners have a separate, non-persistent contact journey
The system SHALL expose `/contato` as a supplier and partnership form. It MUST collect name, optional professional/company name, e-mail, service/activity, coverage area, message, optional portfolio/social link and optional telephone. It SHALL send a dedicated internal e-mail and MUST NOT persist a Lead or partner record.
#### Scenario: Valid partner inquiry is delivered separately
- **WHEN** a visitor submits valid partner data on `/contato`
- **THEN** the configured internal recipient receives the dedicated partner inquiry e-mail
- **AND** no briefing confirmation or Lead is created
### Requirement: Partner inquiry is protected and transparent
The partner form SHALL enforce CSRF, server-side limits, honeypot, rate limiting and duplicate-click protection. It MUST show a purpose-specific privacy notice linking to the privacy policy.
#### Scenario: Honeypot blocks automated partner form submissions
- **WHEN** the partner honeypot is filled
- **THEN** the system reports generic success without sending an e-mail

View File

@@ -0,0 +1,38 @@
## ADDED Requirements
### Requirement: Home presents the weddings and corporate editorial journey
The home SHALL expose anchored chapters for presentation, Amare, service directions, weddings, Corporate, portfolio/acervo, testimonials and final contact paths. It MUST retain the photographic hero, tonal fields, fine rules, EB Garamond and progressive/reduced-motion behavior. Corporate SHALL present only institutional copy and a briefing CTA until authorized copy and proof exist.
#### Scenario: Canonical anchor journey is reachable
- **WHEN** a visitor loads the home or follows a `/#chapter` link from another public page
- **THEN** the relevant chapter exists with a stable id and is reachable without JavaScript
#### Scenario: Corporate does not imply unauthorized proof
- **WHEN** a visitor loads the Corporate chapter before client proof is published
- **THEN** no Corporate service list, image, metric or case is rendered
### Requirement: Production portfolio remains truthful when no cases are published
The public portfolio chapter SHALL render a visible acervo-em-preparação note when no real published cases are available. Production seeders MUST NOT publish development fixture cases.
#### Scenario: Empty production acervo is explicit
- **GIVEN** production has no published portfolio cases
- **WHEN** a visitor loads the home
- **THEN** the portfolio chapter displays the acervo preparation note and no case card
## MODIFIED Requirements
### Requirement: Contact page presents contact data as briefing placeholder
The `contact` route SHALL render the partner and supplier inquiry page using `site_settings` contact data. The detailed event proposal form SHALL be served by the `briefing` route at `/briefing`; public proposal CTAs MUST target that route. Neither public form MUST create a Lead in the launch scope.
#### Scenario: Contact page shows partner journey
- **WHEN** a visitor loads `/contato`
- **THEN** partner-specific copy, fields and privacy notice MUST be displayed
- **AND** the detailed event briefing MUST NOT be rendered
#### Scenario: Briefing route remains reachable for prospects
- **WHEN** a visitor loads `/briefing`
- **THEN** the detailed event proposal form MUST be displayed
- **AND** no Lead record MUST be created

View File

@@ -0,0 +1,9 @@
## ADDED Requirements
### Requirement: Services explain published wedding modalities
The public services experience SHALL present published wedding modalities as the available Casamentos scope, including level, summary and ordered scope items. It MUST not treat unpublished modalities as public services.
#### Scenario: Draft modality remains private
- **GIVEN** a wedding modality with `published_at` null
- **WHEN** a visitor loads `/servicos`
- **THEN** that modality MUST NOT be rendered

View File

@@ -0,0 +1,8 @@
## ADDED Requirements
### Requirement: Official WhatsApp number is a typed site setting
The typed site settings singleton SHALL include an optional `whatsapp_number`. The admin form MUST validate and label it as the official number used by public modality CTAs; it MUST NOT create an external WhatsApp integration.
#### Scenario: Admin stores official WhatsApp number
- **WHEN** an admin saves a valid number in site settings
- **THEN** public wedding modality CTAs can derive the contextual deeplink

View File

@@ -0,0 +1,9 @@
## ADDED Requirements
### Requirement: Partner inquiries use a dedicated transactional message
The system SHALL send supplier and partnership submissions in a dedicated `PartnerInquiry` mailable to the configured internal site recipient. It MUST NOT send the visitor briefing confirmation for this form.
#### Scenario: Partner email does not cross into briefing mail
- **WHEN** a valid partner inquiry is submitted
- **THEN** the recipient receives the partner field set
- **AND** the visitor does not receive `ContactBriefingConfirmation`

View File

@@ -0,0 +1,26 @@
## ADDED Requirements
### Requirement: Wedding modalities are managed as publishable ordered content
The system SHALL let an admin manage `WeddingPackage` records with name, level, summary, ordered scope items, CTA label, sort order and `published_at`. Only published records SHALL appear in public wedding sections, in ascending `sort_order`.
#### Scenario: Published modalities follow CMS order
- **GIVEN** published and draft wedding modalities with different sort orders
- **WHEN** a visitor loads the home or services page
- **THEN** only published modalities appear in ascending `sort_order`
#### Scenario: Official initial modalities are available
- **WHEN** the content seeder runs
- **THEN** Essenza, Conduzione and Grand Jour are created as the official initial modalities
### Requirement: Wedding modality CTA has a contextual channel and conversion fallback
The system SHALL build a WhatsApp deeplink containing the selected modality when `whatsapp_number` is valid. When it is missing or invalid, the CTA SHALL point to `/briefing` with the modality prefilled as `servico_interesse`.
#### Scenario: Valid WhatsApp setting creates contextual link
- **GIVEN** a valid official WhatsApp number
- **WHEN** a visitor activates a wedding modality CTA
- **THEN** the link targets `wa.me` with a URL-encoded message naming that modality
#### Scenario: Missing WhatsApp setting preserves briefing conversion
- **GIVEN** no valid official WhatsApp number
- **WHEN** a visitor activates a wedding modality CTA
- **THEN** the visitor reaches `/briefing` with `servico_interesse` prefilled

View File

@@ -0,0 +1,25 @@
## 1. CMS and public content model
- [x] 1.1 Add the typed official WhatsApp setting with migration, model/form validation and regression coverage.
- [x] 1.2 Add the publishable, ordered WeddingPackage model, migration, factory, policy and Filament resource with failing-first coverage.
- [x] 1.3 Add official Essenza, Conduzione and Grand Jour seed content and production-safe visual fixture gating.
- [x] 1.4 Extend the public marketing query/data object to load wedding modalities and truthful acervo state.
## 2. Proposal and partner boundaries
- [x] 2.1 Move the existing event proposal request/form/mail route to `/briefing`, including prefilled modality interest.
- [x] 2.2 Add the isolated partner inquiry request, controller, rate limit, honeypot, duplicate guard and dedicated mailable.
- [x] 2.3 Cover each public form's validation, delivery, privacy copy, anti-spam path and absence of cross-flow mail/persistence.
## 3. Editorial public journey
- [x] 3.1 Recompose home chapters and stable anchors for Amare, directions, weddings, Corporate, acervo and testimonials.
- [x] 3.2 Render published wedding modalities with contextual WhatsApp/fallback CTAs and keep Corporate proof-free.
- [x] 3.3 Update navigation, footer and deep-route `/#chapter` links; preserve the services, portfolio, about and privacy deep dives.
- [x] 3.4 Update `/servicos`, `/briefing` and `/contato` with the approved factual copy and Heritage Editorial form/layout treatment.
## 4. Verification and handoff
- [x] 4.1 Run the focused feature suite and static/formatting gates; fix all scoped regressions.
- [x] 4.2 Run browser coverage at desktop and mobile, keyboard/axe and reduced-motion checks without visual snapshots.
- [x] 4.3 Validate the OpenSpec change and update every completed task only with recorded verification evidence.

View File

@@ -24,6 +24,7 @@
</include> </include>
</source> </source>
<php> <php>
<env name="APP_ENV" value="testing"/>
<env name="APP_KEY" value="base64:NXm/6jIyFcDGHoMKGc5QZuSaq0dRZFYPg1Isuy1fNvE="/> <env name="APP_KEY" value="base64:NXm/6jIyFcDGHoMKGc5QZuSaq0dRZFYPg1Isuy1fNvE="/>
<env name="APP_MAINTENANCE_DRIVER" value="file"/> <env name="APP_MAINTENANCE_DRIVER" value="file"/>
<env name="BCRYPT_ROUNDS" value="4"/> <env name="BCRYPT_ROUNDS" value="4"/>

View File

@@ -22,6 +22,10 @@
--text-display--line-height: 0.94; --text-display--line-height: 0.94;
--text-display--letter-spacing: -0.01em; --text-display--letter-spacing: -0.01em;
--text-hero-spread: clamp(3rem, 5.5vw, 5rem);
--text-hero-spread--line-height: 0.94;
--text-hero-spread--letter-spacing: -0.01em;
--text-headline: clamp(2.375rem, 5.2vw, 4rem); --text-headline: clamp(2.375rem, 5.2vw, 4rem);
--text-headline--line-height: 1.02; --text-headline--line-height: 1.02;

View File

@@ -0,0 +1,250 @@
@props([])
<section
aria-labelledby="briefing-heading"
class="home-chapter border-b border-amare-border bg-amare-bg"
data-chapter="contato"
id="contato"
>
<div class="container-amare grid gap-12 py-16 md:py-24 lg:grid-cols-[0.8fr_1.2fr] lg:gap-[80px]" data-reveal-group>
<div class="flex flex-col gap-6" data-reveal data-reveal-from="up">
<div class="space-y-4">
<p class="text-xs font-bold uppercase tracking-[0.16em] text-amare-accent-deep">07 Briefing comercial</p>
<h2 id="briefing-heading" class="text-[clamp(2.375rem,5vw,4rem)] font-medium leading-[1.05] tracking-[-0.02em] text-amare-text">Conte sobre o evento que você está imaginando.</h2>
</div>
<p class="max-w-[760px] text-[clamp(1.1875rem,2vw,1.5rem)] leading-[1.55] text-amare-text-muted">Este formulário é para potenciais clientes. Ele continua existindo mesmo com os CTAs dos pacotes: são portas de entrada complementares.</p>
<p class="max-w-xl text-sm text-amare-muted">Para quem sabe a modalidade, WhatsApp contextual. Para quem quer contextualizar o projeto, briefing. Para fornecedores/parcerias, outro fluxo.</p>
</div>
<form
method="POST"
action="{{ route('briefing.store') }}"
class="flex flex-col gap-[18px]"
data-contact-form
data-reveal
data-reveal-from="up"
>
@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-[18px] sm:grid-cols-2">
<div>
<label for="briefing-nome" class="mb-2 block text-xs font-bold uppercase tracking-[0.1em] text-amare-accent-deep">Nome *</label>
<input
type="text"
id="briefing-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="briefing-nome-error" @enderror
>
@error('nome')
<p id="briefing-nome-error" class="mt-2 text-sm text-amare-error">{{ $message }}</p>
@enderror
</div>
<div>
<label for="briefing-email" class="mb-2 block text-xs font-bold uppercase tracking-[0.1em] text-amare-accent-deep">E-mail *</label>
<input
type="email"
id="briefing-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="briefing-email-error" @enderror
>
@error('email')
<p id="briefing-email-error" class="mt-2 text-sm text-amare-error">{{ $message }}</p>
@enderror
</div>
<div>
<label for="briefing-telefone" class="mb-2 block text-xs font-bold uppercase tracking-[0.1em] text-amare-accent-deep">Telefone/WhatsApp *</label>
<input
type="tel"
id="briefing-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="briefing-telefone-error" @enderror
>
@error('telefone')
<p id="briefing-telefone-error" class="mt-2 text-sm text-amare-error">{{ $message }}</p>
@enderror
</div>
<div>
<label for="briefing-tipo_evento" class="mb-2 block text-xs font-bold uppercase tracking-[0.1em] text-amare-accent-deep">Tipo de evento *</label>
<select
id="briefing-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="briefing-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="briefing-tipo_evento-error" class="mt-2 text-sm text-amare-error">{{ $message }}</p>
@enderror
</div>
<div>
<label for="briefing-data_periodo" class="mb-2 block text-xs font-bold uppercase tracking-[0.1em] text-amare-accent-deep">Data ou período desejado</label>
<input
type="text"
id="briefing-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="briefing-data_periodo-error" @enderror
>
@error('data_periodo')
<p id="briefing-data_periodo-error" class="mt-2 text-sm text-amare-error">{{ $message }}</p>
@enderror
</div>
<div>
<label for="briefing-cidade" class="mb-2 block text-xs font-bold uppercase tracking-[0.1em] text-amare-accent-deep">Cidade do evento *</label>
<input
type="text"
id="briefing-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="briefing-cidade-error" @enderror
>
@error('cidade')
<p id="briefing-cidade-error" class="mt-2 text-sm text-amare-error">{{ $message }}</p>
@enderror
</div>
<div>
<label for="briefing-convidados" class="mb-2 block text-xs font-bold uppercase tracking-[0.1em] text-amare-accent-deep">Número estimado de convidados</label>
<input
type="number"
id="briefing-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="briefing-convidados-error" @enderror
>
@error('convidados')
<p id="briefing-convidados-error" class="mt-2 text-sm text-amare-error">{{ $message }}</p>
@enderror
</div>
<div>
<label for="briefing-servico_interesse" class="mb-2 block text-xs font-bold uppercase tracking-[0.1em] text-amare-accent-deep">Serviço de interesse</label>
<input
type="text"
id="briefing-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="briefing-servico_interesse-error" @enderror
>
@error('servico_interesse')
<p id="briefing-servico_interesse-error" class="mt-2 text-sm text-amare-error">{{ $message }}</p>
@enderror
</div>
</div>
<div>
<label for="briefing-mensagem" class="mb-2 block text-xs font-bold uppercase tracking-[0.1em] text-amare-accent-deep">Mensagem / principal preocupação *</label>
<textarea
id="briefing-mensagem"
name="mensagem"
required
rows="5"
maxlength="3000"
placeholder="O que já está definido? Em que ponto você precisa de ajuda?"
class="w-full min-h-[110px] 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="briefing-mensagem-error" @enderror
>{{ old('mensagem') }}</textarea>
@error('mensagem')
<p id="briefing-mensagem-error" class="mt-2 text-sm text-amare-error">{{ $message }}</p>
@enderror
</div>
<div>
<label for="briefing-privacidade" class="flex items-start gap-3 py-2">
<input
type="checkbox"
id="briefing-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="briefing-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="briefing-privacidade-error" class="mt-2 text-sm text-amare-error">{{ $message }}</p>
@enderror
</div>
<div class="flex flex-col items-start gap-4 pt-2">
<button
type="submit"
data-submit-button
class="inline-flex min-h-[48px] items-center justify-center border border-transparent bg-amare-accent px-6 text-xs font-bold uppercase tracking-[0.09em] 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>
<div class="border border-dashed border-amare-sage px-6 py-5">
<p class="text-sm text-amare-muted">Fornecedores e parcerias pós-MVP. Não misturar este público ao briefing comercial. Quando validado, criar um canal/formulário institucional próprio.</p>
</div>
</form>
</div>
</section>

View File

@@ -0,0 +1,45 @@
@props([
'settings',
])
<section
aria-labelledby="corporate-heading"
class="home-chapter border-b border-amare-border bg-amare-bg"
data-chapter="corporate"
id="corporate"
>
<div class="container-amare grid gap-12 py-16 md:py-24 lg:grid-cols-2 lg:gap-[70px]" data-reveal-group>
<div class="flex flex-col gap-8">
<div class="space-y-4" data-reveal data-reveal-from="up">
<p class="text-xs font-bold uppercase tracking-[0.16em] text-amare-accent-deep">04 Amare Corporate</p>
<h2 id="corporate-heading" class="text-[clamp(2.375rem,5vw,4rem)] font-medium leading-[1.05] tracking-[-0.02em] text-amare-text">Projetos corporativos tratados como experiência, não apenas operação.</h2>
</div>
<p class="max-w-[760px] text-[clamp(1.1875rem,2vw,1.5rem)] leading-[1.55] text-amare-text-muted" data-reveal data-reveal-from="up">
No MVP, esta frente pode apresentar claramente o que a Amare oferece mesmo sem inventar um portfólio corporativo que ainda não existe.
</p>
<div class="border-t border-amare-border" data-reveal data-reveal-from="up">
@foreach ($settings->corporate_steps ?? [] as $index => $step)
<div class="flex gap-8 border-b border-amare-border py-[23px]">
<p class="w-[44px] shrink-0 text-xs font-bold uppercase tracking-[0.16em] text-amare-accent">{{ str_pad((string) ($index + 1), 2, '0', STR_PAD_LEFT) }}</p>
<div class="space-y-1">
<h3 class="text-xl font-medium text-amare-text">{{ $step['title'] }}</h3>
<p class="text-amare-text-muted">{{ $step['body'] }}</p>
</div>
</div>
@endforeach
</div>
<a href="#contato" class="mt-7 inline-flex min-h-[48px] items-center justify-center self-start border border-transparent bg-amare-accent px-6 text-xs font-bold uppercase tracking-[0.09em] text-amare-accent-text transition-colors hover:bg-amare-accent-deep" data-reveal data-reveal-from="up">
Falar sobre um evento corporativo
</a>
</div>
<div class="flex min-h-[420px] flex-col items-center justify-center gap-4 bg-amare-bg-deep p-10 text-center" data-reveal data-reveal-from="up">
<p class="text-xs font-bold uppercase tracking-[0.16em] text-amare-accent-deep">Portfólio Corporate</p>
<h3 class="text-[clamp(1.5625rem,2.7vw,2.125rem)] font-medium leading-tight text-amare-text">Conteúdo em construção</h3>
<p class="max-w-sm text-amare-text-muted">Em vez de usar fotografias genéricas como se fossem cases, a página assume com elegância que o portfólio será construído com projetos reais.</p>
</div>
</div>
</section>

View File

@@ -17,7 +17,7 @@
</p> </p>
<div> <div>
<a <a
href="{{ route('contact') }}" href="{{ route('briefing') }}"
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" 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 ?: 'Solicitar proposta' }} {{ $settings->hero_cta_label ?: 'Solicitar proposta' }}

View File

@@ -4,91 +4,46 @@
<section <section
aria-labelledby="hero-heading" aria-labelledby="hero-heading"
@class([ class="home-chapter border-b border-amare-border bg-amare-bg"
'home-chapter border-b border-amare-border bg-amare-bg',
'min-h-[100dvh]' => blank($settings->hero_image_path),
'lg:h-[calc(100dvh-5rem)]' => filled($settings->hero_image_path),
])
data-chapter="hero" data-chapter="hero"
data-motion="page-open" data-motion="page-open"
> >
@if (filled($settings->hero_image_path)) <div class="container-amare grid items-end gap-12 pb-12 pt-16 md:grid-cols-[1.1fr_0.9fr] md:gap-[clamp(3rem,6vw,7rem)] md:pb-[72px] md:pt-24">
<div class="grid lg:h-full lg:grid-cols-2"> <div data-hero-content data-reveal-group class="flex flex-col gap-8">
<div <div data-motion-beat="eyebrow">
data-hero-content <p class="text-xs font-bold uppercase tracking-[0.16em] text-amare-accent-deep">Assessoria &amp; produção de eventos &bull; São Paulo</p>
data-reveal-group
class="flex flex-col justify-center space-y-8 px-6 py-20 lg:py-12 lg:pr-[clamp(3rem,6vw,7rem)] lg:pl-[max(var(--amare-container-padding),calc((100vw-var(--amare-container-max))/2+var(--amare-container-padding)))]"
>
<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))
<p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent">{{ $settings->hero_eyebrow }}</p>
@endif
</div>
<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>
@if (filled($settings->hero_subtitle))
<p class="max-w-2xl text-lg text-amare-text-muted">{{ $settings->hero_subtitle }}</p>
@endif
<div data-motion-beat="cta" class="flex flex-wrap items-center gap-4">
<a href="{{ route('contact') }}" data-testid="home-primary-cta" 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 ?: 'Solicitar proposta' }}
</a>
@if (filled($settings->hero_secondary_cta_label))
<a href="{{ route('portfolio.index') }}" class="inline-flex min-h-11 items-center text-sm font-semibold text-amare-accent transition-colors hover:text-amare-accent-deep">
<span class="border-b border-amare-accent pb-1">{{ $settings->hero_secondary_cta_label }}</span>
</a>
@endif
</div>
@if (filled($settings->hero_note))
<p class="max-w-xl text-sm text-amare-text-muted">{{ $settings->hero_note }}</p>
@endif
</div> </div>
<div data-split-hero data-motion-beat="media" class="max-lg:aspect-[4/5] overflow-hidden bg-amare-bg-deep lg:h-full" data-reveal-media> <h1 id="hero-heading" data-motion-beat="title" class="text-[clamp(3rem,8vw,5.75rem)] font-medium leading-[0.95] tracking-[-0.04em] text-amare-text">
<x-media.image :path="$settings->hero_image_path" :alt="$settings->hero_image_alt ?: $settings->brand_name" loading="eager" fetchpriority="high" sizes="(max-width: 1023px) 100vw, 50vw" class="img-editorial h-full w-full object-cover" /> Eventos com intenção, cuidado e presença.
</h1>
<p class="max-w-[760px] text-[clamp(1.1875rem,2vw,1.5rem)] leading-[1.55] text-amare-text-muted">
Uma boutique de assessoria que une sensibilidade, organização e condução profissional para transformar cada projeto em uma experiência bem cuidada.
</p>
<div data-motion-beat="cta" class="flex flex-wrap items-center gap-4">
<a href="#sobre" data-testid="home-primary-cta" class="inline-flex min-h-[48px] items-center justify-center border border-transparent bg-amare-accent px-6 text-xs font-bold uppercase tracking-[0.09em] text-amare-accent-text transition-colors hover:bg-amare-accent-deep">
Conhecer a Amare
</a>
<a href="#contato" class="inline-flex min-h-[48px] items-center justify-center border border-amare-accent px-6 text-xs font-bold uppercase tracking-[0.09em] text-amare-accent-deep transition-colors hover:bg-amare-accent hover:text-amare-accent-text">
Enviar briefing
</a>
</div> </div>
</div> </div>
@else
<div class="container-amare grid min-h-[100dvh] gap-12 py-20 md:max-w-4xl md:items-center md:py-10" data-reveal-group data-tonal-hero> <div
<div class="flex flex-col justify-center space-y-8 md:py-12"> data-split-hero
<div data-motion-beat="seal" class="flex items-center gap-4"> data-motion-beat="media"
<x-brand.logo mark variant="on-light" class="h-8 w-auto" alt="" /> class="min-h-[320px] overflow-hidden bg-amare-bg-deep md:min-h-[520px]"
@if (filled($settings->hero_eyebrow)) >
<p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent">{{ $settings->hero_eyebrow }}</p> @if (filled($settings->hero_image_path))
@endif <x-media.image :path="$settings->hero_image_path" :alt="$settings->hero_image_alt ?: $settings->brand_name" loading="eager" fetchpriority="high" sizes="(max-width: 1023px) 100vw, 45vw" class="img-editorial h-full w-full object-cover" />
@else
<div data-tonal-hero class="flex h-full min-h-[320px] items-center justify-center bg-gradient-to-br from-amare-bg-deep to-amare-bg-archive md:min-h-[520px]">
<p class="text-xs font-semibold uppercase tracking-[0.16em] text-amare-muted">Fotografia hero</p>
</div> </div>
@endif
<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>
@if (filled($settings->hero_subtitle))
<p class="max-w-2xl text-lg text-amare-text-muted">{{ $settings->hero_subtitle }}</p>
@endif
<div data-motion-beat="cta" class="flex flex-wrap items-center gap-4">
<a href="{{ route('contact') }}" data-testid="home-primary-cta" 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 ?: 'Solicitar proposta' }}
</a>
@if (filled($settings->hero_secondary_cta_label))
<a href="{{ route('portfolio.index') }}" class="inline-flex min-h-11 items-center text-sm font-semibold text-amare-accent transition-colors hover:text-amare-accent-deep">
<span class="border-b border-amare-accent pb-1">{{ $settings->hero_secondary_cta_label }}</span>
</a>
@endif
</div>
@if (filled($settings->hero_note))
<p class="max-w-xl text-sm text-amare-text-muted">{{ $settings->hero_note }}</p>
@endif
</div>
</div> </div>
@endif </div>
</section> </section>

View File

@@ -1,22 +1,24 @@
@props([ @props([])
'settings',
])
@php <section
$title = $settings->manifesto_title ?: 'Sofisticação que também se traduz em organização.'; aria-labelledby="manifesto-heading"
$lead = $settings->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.'; class="home-chapter border-b border-amare-border bg-amare-bg"
$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.'; data-chapter="sobre"
@endphp id="sobre"
>
<section aria-labelledby="manifesto-heading" class="home-chapter border-b border-amare-border bg-amare-bg-deep py-24 md:py-32" data-chapter="manifesto"> <div class="container-amare grid gap-10 py-16 md:grid-cols-[0.8fr_1.2fr] md:gap-[90px] md:py-24" data-reveal-group>
<div class="container-amare grid gap-10 md:grid-cols-12" data-reveal-group> <div data-reveal data-reveal-from="up">
<div class="space-y-5 md:col-span-4 md:pt-20" data-reveal data-reveal-from="left"> <p class="text-xs font-bold uppercase tracking-[0.16em] text-amare-accent-deep">01 A Amare</p>
<p class="max-w-52 text-lg leading-snug text-amare-text">Humana no cuidado. Precisa na entrega.</p>
</div> </div>
<div class="space-y-6 md:col-span-7 md:col-start-6" data-reveal data-reveal-from="up">
<h2 id="manifesto-heading" class="max-w-3xl text-headline font-medium text-amare-text">{{ $title }}</h2> <div class="flex flex-col gap-8" data-reveal data-reveal-from="up">
<p class="max-w-2xl text-xl leading-relaxed text-amare-text">{{ $lead }}</p> <h2 id="manifesto-heading" class="text-[clamp(1.875rem,4vw,3.25rem)] font-medium leading-[1.15] tracking-[-0.02em] text-amare-text">
<p class="max-w-2xl text-amare-text-muted">{{ $body }}</p> Mais do que organizar um evento, a Amare conduz pessoas, decisões e detalhes para que cada experiência aconteça com clareza e tranquilidade.
</h2>
<p class="max-w-[760px] text-[clamp(1.1875rem,2vw,1.5rem)] leading-[1.55] text-amare-text-muted">
Aqui entra a apresentação institucional da marca e da Michele, com texto real aprovado pela cliente. A home deve construir confiança antes de pedir uma conversão.
</p>
</div> </div>
</div> </div>
</section> </section>

View File

@@ -0,0 +1,69 @@
@props([
'packages',
'settings',
])
@php
$whatsappDigits = preg_replace('/\D/', '', (string) $settings->whatsapp_number);
$hasWhatsapp = strlen($whatsappDigits) >= 10;
@endphp
<section
aria-labelledby="packages-heading"
class="home-chapter border-b border-amare-border bg-amare-bg"
data-chapter="casamentos"
id="casamentos"
>
<div class="container-amare py-16 md:py-24" data-reveal-group>
<div class="flex flex-col gap-6 md:flex-row md:items-end md:justify-between" data-reveal data-reveal-from="up">
<div class="max-w-2xl space-y-4">
<p class="text-xs font-bold uppercase tracking-[0.16em] text-amare-accent-deep">03 Amare Casamentos</p>
<h2 id="packages-heading" class="text-[clamp(2.375rem,5vw,4rem)] font-medium leading-[1.05] tracking-[-0.02em] text-amare-text">Um acompanhamento para cada momento.</h2>
</div>
<p class="max-w-[600px] text-amare-text-muted">As modalidades são pontos de partida. O site apresenta o conceito e ajuda o casal a reconhecer seu momento; detalhes operacionais e comerciais ficam para a conversa e proposta.</p>
</div>
@if ($packages->isNotEmpty())
<div class="mt-12 grid border-l border-t border-amare-border md:mt-16 md:grid-cols-3">
@foreach ($packages as $package)
<article class="flex min-h-[480px] flex-col justify-between gap-8 border-b border-r border-amare-border p-8 md:p-10" data-reveal data-reveal-from="up">
<div class="space-y-5">
<p class="text-xs font-bold uppercase tracking-[0.16em] text-amare-accent">{{ $package->level }}</p>
<h3 class="text-[clamp(1.5625rem,2.7vw,2.125rem)] font-medium leading-tight text-amare-text">{{ $package->name }}</h3>
<p class="text-amare-text-muted">{{ $package->summary }}</p>
<ul class="space-y-3 text-amare-text-muted">
@foreach ($package->scope_items as $item)
<li class="flex gap-3"><span aria-hidden="true" class="text-amare-accent">&bull;</span><span>{{ $item }}</span></li>
@endforeach
</ul>
</div>
@php
$waLink = $hasWhatsapp
? 'https://wa.me/'.$whatsappDigits.'?text='.rawurlencode('Olá, gostaria de conversar sobre a modalidade '.$package->name.' para meu casamento.')
: route('briefing', ['servico_interesse' => $package->name]);
@endphp
<a
href="{{ $waLink }}"
@if ($hasWhatsapp) target="_blank" rel="noopener noreferrer" @endif
class="inline-flex min-h-[48px] items-center justify-center border border-transparent bg-amare-accent px-6 text-xs font-bold uppercase tracking-[0.09em] text-amare-accent-text transition-colors hover:bg-amare-accent-deep"
>
{{ $package->cta_label }}
</a>
</article>
@endforeach
</div>
@endif
<div class="mt-6 flex flex-col justify-between gap-8 bg-amare-accent-deep p-8 text-amare-accent-text md:flex-row md:items-center md:p-12" data-reveal data-reveal-from="up">
<div class="max-w-xl space-y-3">
<h3 class="text-[clamp(1.5625rem,2.7vw,2.125rem)] font-medium leading-tight">Ainda não sabe qual modalidade é ideal?</h3>
<p class="text-amare-accent-text/80">Conte um pouco sobre o seu casamento. A Amare entende o momento de vocês e orienta o melhor formato de acompanhamento sem depender de um quiz automático.</p>
</div>
<a href="{{ route('briefing') }}" class="inline-flex min-h-[48px] items-center justify-center self-start border border-amare-accent-text px-6 text-xs font-bold uppercase tracking-[0.09em] text-amare-accent-text transition-colors hover:bg-amare-accent-text hover:text-amare-accent-deep md:self-auto">
Conversar com a Amare
</a>
</div>
<p class="mt-6 text-sm text-amare-muted">Nomenclaturas exibidas conforme materiais/reunião; confirmar versão final antes da publicação.</p>
</div>
</section>

View File

@@ -2,65 +2,55 @@
'cases', 'cases',
]) ])
@if ($cases->isNotEmpty()) <section
<section aria-labelledby="portfolio-heading" class="home-chapter border-b border-amare-accent-deep bg-amare-accent-deep py-24 text-amare-accent-text" data-chapter="portfolio"> aria-labelledby="portfolio-heading"
<div class="container-amare space-y-10" data-reveal-group> class="home-chapter border-b border-amare-border bg-amare-bg"
<div class="grid gap-4 md:grid-cols-12" data-reveal data-reveal-from="up"> data-chapter="portfolio"
<div class="md:col-span-7 md:col-start-5 space-y-3"> id="portfolio"
<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 class="container-amare py-16 md:py-24" data-reveal-group>
</div> <div class="flex flex-col gap-6 md:flex-row md:items-end md:justify-between" data-reveal data-reveal-from="up">
</div> <h2 id="portfolio-heading" class="max-w-2xl text-[clamp(2.375rem,5vw,4rem)] font-medium leading-[1.05] tracking-[-0.02em] text-amare-text">Trabalhos que carregam a assinatura Amare.</h2>
<p class="max-w-[600px] text-amare-text-muted">Priorizar fotografias reais e uma curadoria pequena. O portfólio não precisa explicar tudo: deve provar cuidado, execução e consistência visual.</p>
<div class="grid gap-8 md:grid-cols-12 md:gap-x-8 md:gap-y-14" data-editorial-portfolio>
@foreach ($cases as $case)
@php
$isFeature = $loop->first;
$isOffset = ! $isFeature && $loop->even;
@endphp
<article
@class([
'space-y-4 border-t border-amare-accent-text/30 pt-4',
'md:col-span-7' => $isFeature,
'md:col-span-5 md:col-start-8 md:pt-12' => $isOffset,
'md:col-span-5' => ! $isFeature && ! $isOffset,
])
data-editorial-portfolio-item="{{ $isFeature ? 'feature' : ($isOffset ? 'offset' : 'standard') }}"
data-reveal
data-reveal-from="up"
>
@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) calc(100vw - 3rem), 50vw"
@class([
'img-editorial w-full object-cover',
'aspect-[5/4]' => $isFeature,
'aspect-[4/5]' => $isOffset,
'aspect-[4/3]' => ! $isFeature && ! $isOffset,
])
/>
@endif
<div class="space-y-2">
<h3 class="text-2xl font-medium">{{ $case->title }}</h3>
<p class="text-amare-accent-text/80">{{ $case->summary }}</p>
<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">
<span class="border-b border-amare-accent-text pb-1">Ver caso</span>
</a>
</div>
</article>
@endforeach
</div>
<div class="flex flex-col gap-4 border-t border-amare-accent-text/30 pt-5 md:flex-row md:items-end md:justify-between">
<p class="max-w-2xl text-sm text-amare-accent-text/75">
Imagens demonstrativas enquanto o acervo autorizado da Amare está em organização.
</p>
<a href="{{ route('portfolio.index') }}" class="inline-flex min-h-11 items-center text-sm font-semibold transition-colors hover:text-amare-accent-text">
<span class="border-b border-amare-accent-text pb-1">Conhecer o portfólio</span>
</a>
</div>
</div> </div>
</section>
@endif @php
$slots = collect([
['label' => 'FOTO DE CASAMENTO 01', 'tall' => true],
['label' => 'FOTO 02', 'tall' => false],
['label' => 'FOTO 03', 'tall' => false],
['label' => 'FOTO 04', 'tall' => false],
['label' => 'FOTO 05', 'tall' => false],
]);
$cases = $cases->take(5)->values();
@endphp
<div class="mt-12 grid grid-cols-1 gap-[14px] sm:grid-cols-2 lg:grid-cols-[1.2fr_0.8fr_0.8fr]" data-editorial-portfolio data-reveal-group>
@foreach ($slots as $index => $slot)
@php
$case = $cases->get($index);
@endphp
<div
@class([
'overflow-hidden bg-amare-bg-deep',
'sm:col-span-2 sm:min-h-[420px] lg:col-span-1 lg:row-span-2 lg:min-h-[634px]' => $slot['tall'],
'min-h-[220px] sm:min-h-[310px]' => ! $slot['tall'],
])
@if ($slot['tall']) data-editorial-portfolio-item="feature" @endif
data-reveal
data-reveal-from="up"
>
@if ($case && filled($case->cover_image_path))
<a href="{{ route('portfolio.show', $case) }}" class="block h-full w-full" aria-label="{{ $case->title }}">
<x-media.image :path="$case->cover_image_path" :alt="$case->cover_image_alt ?: $case->title" sizes="(max-width: 1023px) 100vw, 33vw" class="img-editorial h-full w-full object-cover transition-opacity hover:opacity-90" />
</a>
@else
<div class="flex h-full w-full items-center justify-center">
<p class="px-4 text-center text-xs font-semibold uppercase tracking-[0.16em] text-amare-muted">{{ $slot['label'] }}</p>
</div>
@endif
</div>
@endforeach
</div>
</div>
</section>

View File

@@ -3,7 +3,7 @@
]) ])
@if ($services->isNotEmpty()) @if ($services->isNotEmpty())
<section aria-labelledby="services-heading" class="home-chapter border-b border-amare-border py-24" data-chapter="services"> <section id="vertentes" aria-labelledby="services-heading" class="home-chapter border-b border-amare-border py-24" data-chapter="services">
<div class="container-amare space-y-10" data-reveal-group> <div class="container-amare space-y-10" data-reveal-group>
<div class="grid gap-6 md:grid-cols-12 md:items-end" data-reveal data-reveal-from="up"> <div class="grid gap-6 md:grid-cols-12 md:items-end" data-reveal data-reveal-from="up">
<div class="space-y-3 md:col-span-4"> <div class="space-y-3 md:col-span-4">

View File

@@ -3,46 +3,52 @@
]) ])
@php @php
$testimonials = $testimonials $quotes = collect($testimonials)
->filter(fn ($testimonial): bool => filled(trim((string) $testimonial->quote))) ->filter(fn ($testimonial) => filled($testimonial->quote) && filled(trim((string) $testimonial->quote)))
->take(3)
->values(); ->values();
$cards = $quotes->map(fn ($testimonial) => [
'quote' => $testimonial->quote,
'author' => $testimonial->author_name,
'context' => $testimonial->context,
])->all();
while (count($cards) < 3) {
$cards[] = [
'quote' => 'Depoimento real aprovado pela cliente entra aqui.',
'author' => 'Nome e data reais — aguardando autorização',
'context' => null,
];
}
@endphp @endphp
@if ($testimonials->isNotEmpty()) <section
<section aria-labelledby="testimonials-heading" class="home-chapter border-b border-amare-border bg-amare-bg py-24" data-chapter="testimonials"> aria-labelledby="testimonials-heading"
<div class="container-amare space-y-10" data-reveal-group> class="home-chapter border-b border-amare-border bg-amare-bg"
<div class="max-w-2xl space-y-3 md:ml-[16.666667%]" data-reveal data-reveal-from="up"> data-chapter="depoimentos"
<h2 id="testimonials-heading" class="text-3xl font-medium text-amare-text">Depoimentos</h2> id="depoimentos"
<p class="text-amare-text-muted">Quem celebrou com a Amare conta como foi a experiência.</p> >
</div> <div class="container-amare py-16 md:py-24" data-reveal-group>
<div class="space-y-4" data-reveal data-reveal-from="up">
<div class="grid gap-6 md:grid-cols-12"> <p class="text-xs font-bold uppercase tracking-[0.16em] text-amare-accent-deep">06 Depoimentos</p>
@foreach ($testimonials as $testimonial) <h2 id="testimonials-heading" class="text-[clamp(2.375rem,5vw,4rem)] font-medium leading-[1.05] tracking-[-0.02em] text-amare-text">Confiança construída na experiência.</h2>
@php
$paragraphs = preg_split('/\n\s*\n/', trim((string) $testimonial->quote)) ?: [];
$paragraphs = array_values(array_filter(array_map('trim', $paragraphs), fn (string $p): bool => $p !== ''));
@endphp
<blockquote @class([
'space-y-4 border-t border-amare-border pt-4 md:col-span-5' => $loop->odd,
'space-y-4 border-t border-amare-border pt-4 md:col-span-5 md:col-start-7 md:mt-16' => $loop->even,
]) data-reveal data-reveal-from="{{ $loop->odd ? 'left' : 'right' }}">
<div class="grid grid-cols-[1.5rem_minmax(0,1fr)] gap-2">
<span class="text-3xl leading-none text-amare-sage" aria-hidden="true"></span>
<div class="space-y-3 text-lg text-amare-text">
@foreach ($paragraphs as $paragraph)
<p>{{ $paragraph }}</p>
@endforeach
</div>
</div>
<footer class="pl-8 text-sm text-amare-text-muted">
<cite class="not-italic font-semibold text-amare-text">{{ $testimonial->author_name }}</cite>
@if (filled($testimonial->context))
<span> {{ $testimonial->context }}</span>
@endif
</footer>
</blockquote>
@endforeach
</div>
</div> </div>
</section>
@endif <div class="mt-12 grid gap-6 md:mt-16 md:grid-cols-3">
@foreach ($cards as $card)
<figure class="flex flex-col justify-between gap-6 border border-amare-border bg-white p-8" data-reveal data-reveal-from="up">
<blockquote class="text-lg leading-[1.55] text-amare-text">
<p>{{ $card['quote'] }}</p>
</blockquote>
<figcaption class="text-sm text-amare-muted">
@if (filled($card['context']))
<p class="font-semibold text-amare-text">{{ $card['author'] }}</p>
<p>{{ $card['context'] }}</p>
@else
<p>{{ $card['author'] }}</p>
@endif
</figcaption>
</figure>
@endforeach
</div>
</div>
</section>

View File

@@ -0,0 +1,41 @@
@props([])
<section
aria-labelledby="vertentes-heading"
class="home-chapter border-b border-amare-border bg-amare-bg"
data-chapter="vertentes"
id="vertentes"
>
<div class="container-amare py-16 md:py-24" data-reveal-group>
<div class="max-w-3xl space-y-4" data-reveal data-reveal-from="up">
<p class="text-xs font-bold uppercase tracking-[0.16em] text-amare-accent-deep">02 Duas vertentes, uma mesma forma de cuidar</p>
<h2 id="vertentes-heading" class="text-[clamp(2.375rem,5vw,4rem)] font-medium leading-[1.05] tracking-[-0.02em] text-amare-text">Como podemos acompanhar você?</h2>
</div>
<div class="mt-12 grid gap-6 md:mt-16 md:grid-cols-2 md:gap-[24px]">
<article class="relative flex min-h-[420px] flex-col justify-end bg-amare-bg-deep p-8 md:p-10" data-reveal data-reveal-from="up">
<div aria-hidden="true" class="pointer-events-none absolute inset-x-0 top-0 h-[43%] bg-gradient-to-b from-amare-bg to-transparent"></div>
<div class="relative flex flex-col gap-5">
<p class="text-xs font-bold uppercase tracking-[0.16em] text-amare-accent-deep">Amare Casamentos</p>
<h3 class="text-[clamp(1.5625rem,2.7vw,2.125rem)] font-medium leading-tight text-amare-text">Planejamento e condução para viver o processo com mais leveza.</h3>
<p class="text-amare-text-muted">Do início da organização à reta final, modalidades pensadas para diferentes momentos do casal.</p>
<a href="#casamentos" class="mt-2 inline-flex min-h-[48px] items-center justify-center self-start border border-amare-accent px-6 text-xs font-bold uppercase tracking-[0.09em] text-amare-accent-deep transition-colors hover:bg-amare-accent hover:text-amare-accent-text">
Conhecer casamentos
</a>
</div>
</article>
<article class="relative flex min-h-[420px] flex-col justify-end bg-amare-bg-deep p-8 md:p-10" data-reveal data-reveal-from="up">
<div aria-hidden="true" class="pointer-events-none absolute inset-x-0 top-0 h-[43%] bg-gradient-to-b from-amare-bg to-transparent"></div>
<div class="relative flex flex-col gap-5">
<p class="text-xs font-bold uppercase tracking-[0.16em] text-amare-accent-deep">Amare Corporate</p>
<h3 class="text-[clamp(1.5625rem,2.7vw,2.125rem)] font-medium leading-tight text-amare-text">Eventos empresariais com organização, intenção e execução segura.</h3>
<p class="text-amare-text-muted">Uma frente própria para projetos corporativos, com linguagem e necessidades diferentes das celebrações sociais.</p>
<a href="#corporate" class="mt-2 inline-flex min-h-[48px] items-center justify-center self-start border border-amare-accent px-6 text-xs font-bold uppercase tracking-[0.09em] text-amare-accent-deep transition-colors hover:bg-amare-accent hover:text-amare-accent-text">
Conhecer Corporate
</a>
</div>
</article>
</div>
</div>
</section>

View File

@@ -0,0 +1,18 @@
@props(['packages', 'settings'])
<section id="casamentos" aria-labelledby="weddings-heading" class="home-chapter border-b border-amare-border bg-amare-bg-deep py-24" data-chapter="weddings">
<div class="container-amare space-y-10" data-reveal-group>
<div class="grid gap-6 md:grid-cols-12" data-reveal data-reveal-from="up"><div class="md:col-span-5"><p class="text-xs font-semibold uppercase tracking-[.14em] text-amare-accent">Casamentos</p><h2 id="weddings-heading" class="mt-3 text-headline font-medium">Uma condução à altura do que importa.</h2></div><p class="max-w-xl text-amare-muted md:col-span-5 md:col-start-7">Modalidades para diferentes momentos de planejamento, sempre construídas a partir do contexto de cada celebração.</p></div>
@if ($packages->isEmpty())
<p class="border-t border-amare-border pt-5 text-amare-muted">As modalidades de casamento estão em atualização. Conte-nos sobre o seu momento no briefing.</p>
@else
<ol class="grid gap-8 border-t border-amare-border pt-6 md:grid-cols-3">
@foreach ($packages as $package)
@php($digits = preg_replace('/\D/', '', (string) $settings->whatsapp_number))
@php($href = strlen((string) $digits) >= 10 ? 'https://wa.me/'.$digits.'?text='.rawurlencode('Olá, gostaria de conversar sobre a modalidade '.$package->name.' para meu casamento.') : route('briefing', ['servico_interesse' => $package->name]))
<li class="space-y-5 border-t border-amare-border pt-4" data-reveal data-reveal-from="up"><p class="text-xs font-semibold uppercase tracking-[.14em] text-amare-accent">{{ $package->level }}</p><h3 class="text-3xl font-medium">{{ $package->name }}</h3><p class="text-amare-muted">{{ $package->summary }}</p><ul class="space-y-2 text-sm text-amare-text-muted">@foreach ($package->scope_items as $item)<li class="border-l border-amare-border pl-3">{{ $item }}</li>@endforeach</ul><a href="{{ $href }}" @if (str_starts_with($href, 'https://wa.me/')) target="_blank" rel="noopener" @endif class="inline-flex min-h-11 items-center text-sm font-semibold text-amare-accent hover:text-amare-accent-deep"><span class="border-b border-amare-accent pb-1">{{ $package->cta_label }}</span></a></li>
@endforeach
</ol>
@endif
</div>
</section>

View File

@@ -7,25 +7,16 @@
'metadata' => null, 'metadata' => null,
'headingId' => 'hero-heading', 'headingId' => 'hero-heading',
'brandMark' => false, 'brandMark' => false,
'fullHeightFallback' => false,
]) ])
<section aria-labelledby="{{ $headingId }}" {{ $attributes->class(['border-b border-amare-border bg-amare-bg']) }} data-motion="page-open"> <section aria-labelledby="{{ $headingId }}" {{ $attributes->class(['border-b border-amare-border bg-amare-bg']) }} data-motion="page-open">
@if (filled($imagePath)) @if (filled($imagePath))
<div class="relative flex min-h-[calc(100dvh-5.5rem)] items-end overflow-hidden" data-photo-hero> <div class="grid min-h-[calc(100dvh-5rem)] lg:h-[calc(100dvh-5rem)] lg:min-h-0 lg:grid-cols-12" data-photo-hero>
<x-media.image <div class="flex min-w-0 items-center bg-amare-bg px-6 py-16 lg:col-span-5 lg:px-[clamp(3rem,6vw,7rem)]" data-hero-content data-reveal-group>
:path="$imagePath" <div class="w-full max-w-[470px] space-y-7">
:alt="$imageAlt ?: $title"
loading="eager"
fetchpriority="high"
sizes="100vw"
class="img-editorial absolute inset-0 h-full w-full object-cover"
data-motion-beat="media"
/>
<div class="container-amare relative z-10 w-full py-8 md:py-12">
<div class="max-w-3xl border border-amare-border bg-amare-bg p-6 text-amare-text md:p-10" data-motion-beat="heading">
@if ($brandMark || filled($eyebrow)) @if ($brandMark || filled($eyebrow))
<div class="mb-5 flex items-center gap-4" data-motion-beat="seal"> <div class="flex items-center gap-4" data-motion-beat="seal">
@if ($brandMark) @if ($brandMark)
<x-brand.logo mark variant="on-light" class="h-8 w-auto" alt="" /> <x-brand.logo mark variant="on-light" class="h-8 w-auto" alt="" />
@endif @endif
@@ -34,22 +25,36 @@
@endif @endif
</div> </div>
@endif @endif
<h1 id="{{ $headingId }}" data-motion-beat="title" class="max-w-3xl text-display font-medium text-amare-text">{{ $title }}</h1> <h1 id="{{ $headingId }}" data-motion-beat="title" class="max-w-[470px] whitespace-normal break-normal text-hero-spread font-medium text-amare-text">{{ $title }}</h1>
@if (filled($summary)) @if (filled($summary))
<p class="mt-5 max-w-2xl text-lg text-amare-text-muted">{{ $summary }}</p> <p class="max-w-[470px] text-lg text-amare-text-muted">{{ $summary }}</p>
@endif @endif
@if (filled($metadata)) @if (filled($metadata))
<p class="mt-4 text-sm break-words text-amare-text-muted">{{ $metadata }}</p> <p class="max-w-[470px] text-sm break-words text-amare-text-muted">{{ $metadata }}</p>
@endif @endif
@if (trim((string) $slot) !== '') @if (trim((string) $slot) !== '')
<div class="mt-7" data-motion-beat="cta">{{ $slot }}</div> <div data-motion-beat="cta">{{ $slot }}</div>
@endif @endif
</div> </div>
</div> </div>
<div data-split-hero data-motion-beat="media" class="min-h-[56dvh] overflow-hidden bg-amare-bg-deep max-lg:aspect-[4/5] lg:col-span-7 lg:min-h-0" data-reveal-media>
<x-media.image
:path="$imagePath"
:alt="$imageAlt ?: $title"
loading="eager"
fetchpriority="high"
sizes="(max-width: 1023px) 100vw, 58vw"
class="img-editorial h-full w-full object-cover"
/>
</div>
</div> </div>
@else @else
<div class="container-amare py-16 md:py-24" data-tonal-hero> <div @class([
<div class="max-w-3xl space-y-4" data-motion-beat="heading"> 'container-amare py-16 md:py-24',
'min-h-[100dvh]' => $fullHeightFallback,
]) data-tonal-hero>
<div class="max-w-[470px] space-y-4" data-motion-beat="heading">
@if ($brandMark || filled($eyebrow)) @if ($brandMark || filled($eyebrow))
<div class="flex items-center gap-4" data-motion-beat="seal"> <div class="flex items-center gap-4" data-motion-beat="seal">
@if ($brandMark) @if ($brandMark)
@@ -60,12 +65,12 @@
@endif @endif
</div> </div>
@endif @endif
<h1 id="{{ $headingId }}" data-motion-beat="title" class="text-headline font-medium tracking-tight text-amare-text">{{ $title }}</h1> <h1 id="{{ $headingId }}" data-motion-beat="title" class="max-w-[470px] whitespace-normal break-normal text-headline font-medium tracking-tight text-amare-text">{{ $title }}</h1>
@if (filled($summary)) @if (filled($summary))
<p class="text-lg text-amare-muted">{{ $summary }}</p> <p class="max-w-[470px] text-lg text-amare-muted">{{ $summary }}</p>
@endif @endif
@if (filled($metadata)) @if (filled($metadata))
<p class="text-sm break-words text-amare-muted">{{ $metadata }}</p> <p class="max-w-[470px] text-sm break-words text-amare-muted">{{ $metadata }}</p>
@endif @endif
@if (trim((string) $slot) !== '') @if (trim((string) $slot) !== '')
<div data-motion-beat="cta">{{ $slot }}</div> <div data-motion-beat="cta">{{ $slot }}</div>

View File

@@ -0,0 +1,5 @@
Nova consulta de fornecedor ou parceria
@foreach ($fields as $label => $value)
{{ $label }}: {{ $value ?: '—' }}
@endforeach

View File

@@ -0,0 +1,10 @@
<!DOCTYPE html>
<html lang="pt-BR"><head><meta charset="utf-8"><title>Nova consulta de parceria</title></head>
<body>
<h1>Nova consulta de fornecedor ou parceria</h1>
<table>
@foreach ($fields as $label => $value)
<tr><th scope="row">{{ $label }}</th><td>{{ $value ?: '—' }}</td></tr>
@endforeach
</table>
</body></html>

View File

@@ -55,23 +55,23 @@
Ir para o conteúdo Ir para o conteúdo
</a> </a>
<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 bg-amare-bg/95 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] md:h-[74px] md:py-0">
<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> <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="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('home') }}" class="inline-flex items-center py-3 text-[13px] font-semibold uppercase tracking-[0.08em] text-amare-muted transition-colors hover:text-amare-accent md:px-3">Início</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('services.index') }}" class="inline-flex items-center py-3 text-[13px] font-semibold uppercase tracking-[0.08em] text-amare-muted transition-colors hover:text-amare-accent md:px-3">Serviços</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('portfolio.index') }}" class="inline-flex items-center py-3 text-[13px] font-semibold uppercase tracking-[0.08em] text-amare-muted transition-colors hover:text-amare-accent md:px-3">Portfólio</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('about') }}" class="inline-flex items-center py-3 text-[13px] font-semibold uppercase tracking-[0.08em] text-amare-muted transition-colors hover:text-amare-accent md:px-3">Amare</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> <a href="{{ route('briefing') }}" class="inline-flex items-center py-3 text-[13px] font-semibold uppercase tracking-[0.08em] 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">
<x-brand.logo variant="on-light" class="h-10 w-auto md:h-12" /> <x-brand.logo variant="on-light" class="h-10 w-auto md:h-11" />
</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 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"> <a href="{{ route('briefing') }}" class="hidden items-center justify-center border border-amare-accent px-6 text-xs font-bold uppercase tracking-[0.09em] text-amare-accent-deep transition-colors hover:bg-amare-accent hover:text-amare-accent-text md:inline-flex min-h-[48px]">
Solicitar proposta Conte seu evento
</a> </a>
<button <button
@@ -97,50 +97,34 @@
@yield('content') @yield('content')
</main> </main>
<footer class="border-t border-amare-border bg-amare-bg-deep"> <footer class="border-t border-amare-border bg-amare-bg">
<div class="container-amare grid gap-10 py-12 md:grid-cols-[minmax(0,1.4fr)_repeat(2,minmax(0,1fr))]"> @php
<div class="space-y-4"> $footerSocialLabel = static function (string $network): string {
<x-brand.logo variant="on-light" class="h-12 w-auto" /> return match ($network) {
<p class="max-w-md text-amare-muted"> 'instagram' => 'Instagram',
{{ $siteSettings->about_summary ?: 'Assessoria boutique em São Paulo - SP.' }} 'whatsapp' => 'WhatsApp',
</p> 'linkedin' => 'LinkedIn',
default => ucfirst($network),
};
};
$footerSocials = collect($siteSettings->social_links ?? [])->filter(static fn ($url) => filled($url));
@endphp
<div class="container-amare flex flex-col gap-8 py-14 md:flex-row md:items-end md:justify-between md:py-[58px]">
<div class="space-y-3">
<x-brand.logo variant="on-light" class="h-10 w-auto" />
<p class="text-sm text-amare-muted">Assessoria &amp; produção de eventos &bull; São Paulo</p>
</div> </div>
<div class="text-sm"> <p class="text-sm text-amare-muted md:text-right">
<h2 class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-text">Navegação</h2> @if ($footerSocials->isEmpty())
<ul class="mt-3 space-y-3"> Instagram &middot; WhatsApp &middot; E-mail &middot; LinkedIn (quando confirmado)
<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> @else
<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> @foreach ($footerSocials as $network => $url)
<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> <a href="{{ $url }}" class="inline-flex min-h-11 items-center transition-colors hover:text-amare-accent" rel="noopener noreferrer" target="_blank">{{ $footerSocialLabel((string) $network) }}</a>@unless ($loop->last) &nbsp;&middot;&nbsp; @endunless
<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>
</ul>
</div>
<div class="text-sm">
<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)
<p class="text-amare-muted">{{ $siteSettings->city }}</p>
@endif
@if ($siteSettings->email)
<p>
<a href="mailto:{{ $siteSettings->email }}" class="inline-flex max-w-full min-h-11 items-center text-amare-muted transition-colors hover:text-amare-accent"><span class="min-w-0 break-words">{{ $siteSettings->email }}</span></a>
</p>
@endif
@if ($siteSettings->phone)
<p>
<a href="tel:{{ preg_replace('/\D/', '', (string) $siteSettings->phone) }}" class="inline-flex max-w-full min-h-11 items-center text-amare-muted transition-colors hover:text-amare-accent"><span class="min-w-0 break-words">{{ $siteSettings->phone }}</span></a>
</p>
@endif
@foreach ($siteSettings->social_links ?? [] as $network => $url)
@if (filled($url))
<p>
<a href="{{ $url }}" class="inline-flex max-w-full min-h-11 items-center text-amare-muted transition-colors hover:text-amare-accent" rel="noopener noreferrer" target="_blank"><span class="min-w-0 break-words">{{ ucfirst((string) $network) }}</span></a>
</p>
@endif
@endforeach @endforeach
</address> @endif
</div> </p>
</div> </div>
<div class="border-t border-amare-border"> <div class="border-t border-amare-border">

View File

@@ -49,7 +49,7 @@
@else @else
<form <form
method="POST" method="POST"
action="{{ route('contact.store') }}" action="{{ route('briefing.store') }}"
class="mt-10 max-w-3xl space-y-10" class="mt-10 max-w-3xl space-y-10"
data-contact-form data-contact-form
> >
@@ -135,8 +135,8 @@
@error('tipo_evento') aria-invalid="true" aria-describedby="tipo_evento-error" @enderror @error('tipo_evento') aria-invalid="true" aria-describedby="tipo_evento-error" @enderror
> >
<option value="" selected disabled>Selecione...</option> <option value="" selected disabled>Selecione...</option>
<option value="Casamento" @selected(old('tipo_evento') === 'Casamento')>Casamento</option> <option value="Casamento" @selected(old('tipo_evento', request('tipo_evento')) === 'Casamento')>Casamento</option>
<option value="Evento corporativo" @selected(old('tipo_evento') === 'Evento corporativo')>Evento corporativo</option> <option value="Evento corporativo" @selected(old('tipo_evento', request('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="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> <option value="Outro" @selected(old('tipo_evento') === 'Outro')>Outro tipo de evento</option>
</select> </select>
@@ -206,7 +206,7 @@
type="text" type="text"
id="servico_interesse" id="servico_interesse"
name="servico_interesse" name="servico_interesse"
value="{{ old('servico_interesse') }}" value="{{ old('servico_interesse', request('servico_interesse')) }}"
maxlength="120" maxlength="120"
placeholder="Ex.: planejamento completo" 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" 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"

View File

@@ -3,12 +3,12 @@
@section('content') @section('content')
<div class="home-chapters"> <div class="home-chapters">
<x-home.hero :settings="$content->settings" /> <x-home.hero :settings="$content->settings" />
<x-home.manifesto :settings="$content->settings" /> <x-home.manifesto />
<x-home.services :services="$content->featuredServices" /> <x-home.vertentes />
<x-home.packages :packages="$content->packages" :settings="$content->settings" />
<x-home.corporate :settings="$content->settings" />
<x-home.portfolio :cases="$content->featuredCases" /> <x-home.portfolio :cases="$content->featuredCases" />
<x-home.method :settings="$content->settings" />
<x-home.testimonials :testimonials="$content->testimonials" /> <x-home.testimonials :testimonials="$content->testimonials" />
<x-home.positioning :settings="$content->settings" /> <x-home.briefing />
<x-home.final-cta :settings="$content->settings" editorial />
</div> </div>
@endsection @endsection

View File

@@ -0,0 +1,41 @@
@extends('layouts.public')
@section('content')
<section class="border-b border-amare-border bg-amare-bg" data-motion="page-open">
<div class="container-amare grid gap-12 py-16 md:grid-cols-[1.15fr_.85fr] md:py-24" data-reveal-group>
<div class="space-y-6" data-motion-beat="heading" data-reveal data-reveal-from="up">
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Contato profissional</p>
<h1 class="text-headline font-medium tracking-tight text-amare-text">Fornecedores e parcerias</h1>
<p class="max-w-2xl text-lg text-amare-muted">Se o seu trabalho conversa com a forma de atuar da Amare, apresente-se. Usaremos suas informações somente para avaliar uma possível relação profissional.</p>
</div>
<div class="border-t border-amare-border pt-6 text-amare-muted md:border-l md:border-t-0 md:pl-10 md:pt-0" data-reveal data-reveal-from="up">
<p>{{ $siteSettings->city ?: 'São Paulo - SP' }}</p>
@if ($siteSettings->email)<a class="mt-3 inline-flex min-h-11 items-center text-amare-accent underline hover:text-amare-accent-deep" href="mailto:{{ $siteSettings->email }}">{{ $siteSettings->email }}</a>@endif
<p class="mt-5 text-sm">Para solicitar uma proposta para um evento, use o <a class="text-amare-accent underline" href="{{ route('briefing') }}">briefing</a>.</p>
</div>
</div>
</section>
<section class="border-b border-amare-border bg-amare-bg-deep" aria-labelledby="partner-form-heading">
<div class="container-amare py-16 md:py-24" data-reveal-group>
<h2 id="partner-form-heading" class="text-3xl font-medium text-amare-text" data-reveal data-reveal-from="up">Apresente seu trabalho</h2>
@if (session('status') === 'partner-inquiry-sent')
<div role="status" class="mt-8 border border-amare-border bg-amare-bg px-6 py-5"><p class="font-semibold">Mensagem enviada.</p><p class="mt-1 text-amare-muted">Recebemos sua apresentação e entraremos em contato se houver aderência.</p></div>
@else
<form method="POST" action="{{ route('contact.store') }}" class="mt-10 max-w-3xl space-y-8" data-contact-form data-reveal data-reveal-from="up">
@csrf
<div class="honeypot" aria-hidden="true"><label for="website">Não preencha este campo</label><input id="website" name="website" tabindex="-1" autocomplete="off"></div>
@if ($errors->any())<div role="alert" class="border border-amare-error/40 px-6 py-5">Revise os campos destacados abaixo.</div>@endif
<div class="grid gap-8 sm:grid-cols-2">
@foreach (['nome' => 'Nome *', 'empresa' => 'Nome profissional ou empresa', 'email' => 'E-mail *', 'atuacao' => 'Atuação ou serviço *', 'area_atendimento' => 'Área de atendimento *', 'portfolio_redes' => 'Portfólio ou redes', 'telefone' => 'Telefone'] as $field => $label)
<div><label class="mb-2 block text-xs font-semibold uppercase tracking-[.14em]" for="{{ $field }}">{{ $label }}</label><input class="w-full border-0 border-b border-amare-border bg-transparent py-3 focus:border-amare-accent focus:outline-none @error($field) border-amare-error @enderror" id="{{ $field }}" name="{{ $field }}" value="{{ old($field) }}" maxlength="{{ $field === 'portfolio_redes' ? 500 : ($field === 'email' ? 254 : 160) }}" @required(in_array($field, ['nome','email','atuacao','area_atendimento'], true)) @error($field) aria-invalid="true" @enderror>@error($field)<p class="mt-2 text-sm text-amare-error">{{ $message }}</p>@enderror</div>
@endforeach
</div>
<div><label class="mb-2 block text-xs font-semibold uppercase tracking-[.14em]" for="mensagem">Mensagem *</label><textarea class="w-full border border-amare-border bg-transparent p-3 focus:border-amare-accent focus:outline-none" id="mensagem" name="mensagem" rows="6" required maxlength="3000">{{ old('mensagem') }}</textarea>@error('mensagem')<p class="mt-2 text-sm text-amare-error">{{ $message }}</p>@enderror</div>
<p class="max-w-2xl text-sm text-amare-muted">Ao enviar, você reconhece o uso destes dados exclusivamente para analisar uma possível parceria, conforme a <a class="text-amare-accent underline" href="{{ route('privacy') }}">política de privacidade</a>.</p>
<button type="submit" data-submit-button class="inline-flex min-h-11 bg-amare-accent px-6 py-3 text-sm font-semibold uppercase tracking-[.12em] text-amare-accent-text hover:bg-amare-accent-deep">Enviar apresentação</button>
</form>
@endif
</div>
</section>
@endsection

View File

@@ -47,5 +47,9 @@
</div> </div>
</div> </div>
@if ($weddingPackages->isNotEmpty())
<section class="border-b border-amare-border bg-amare-bg-deep py-16 md:py-24" aria-labelledby="wedding-packages-heading"><div class="container-amare"><p class="text-xs font-semibold uppercase tracking-[.14em] text-amare-accent">Casamentos</p><h2 id="wedding-packages-heading" class="mt-3 text-3xl font-medium">Modalidades de assessoria</h2><div class="mt-8 grid gap-6 md:grid-cols-3">@foreach ($weddingPackages as $package)<article class="border-t border-amare-border pt-4"><p class="text-sm text-amare-accent">{{ $package->level }}</p><h3 class="mt-2 text-2xl font-medium">{{ $package->name }}</h3><p class="mt-2 text-amare-muted">{{ $package->summary }}</p><ul class="mt-4 space-y-2 text-sm text-amare-text-muted">@foreach ($package->scope_items as $item)<li>{{ $item }}</li>@endforeach</ul></article>@endforeach</div></div></section>
@endif
<x-home.final-cta :settings="$siteSettings" /> <x-home.final-cta :settings="$siteSettings" />
@endsection @endsection

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
use App\Http\Controllers\PublicSite\ContactController; 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\PartnerInquiryController;
use App\Http\Controllers\PublicSite\PortfolioController; use App\Http\Controllers\PublicSite\PortfolioController;
use App\Http\Controllers\PublicSite\RobotsController; use App\Http\Controllers\PublicSite\RobotsController;
use App\Http\Controllers\PublicSite\ServiceController; use App\Http\Controllers\PublicSite\ServiceController;
@@ -19,8 +20,12 @@ 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']) Route::post('/contato', [PartnerInquiryController::class, 'store'])
->middleware('throttle:contact-briefing') ->middleware('throttle:partner-inquiry')
->name('contact.store'); ->name('contact.store');
Route::get('/briefing', [PageController::class, 'briefing'])->name('briefing');
Route::post('/briefing', [ContactController::class, 'store'])
->middleware('throttle:contact-briefing')
->name('briefing.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');

View File

@@ -10,28 +10,36 @@ uses(RefreshDatabase::class);
beforeEach(function (): void { beforeEach(function (): void {
SiteSetting::instance(); SiteSetting::instance();
});
it('has no critical or serious accessibility issues on covered public routes', function (): void { $this->portfolioCase = PortfolioCase::factory()->published()->create([
$case = PortfolioCase::factory()->published()->create([
'slug' => 'casamento-jardim', 'slug' => 'casamento-jardim',
'title' => 'Casamento Jardim', 'title' => 'Casamento Jardim',
]); ]);
});
it('has no critical accessibility or console issues on covered public routes', function (): void {
$routes = [ $routes = [
'/', '/',
'/servicos', '/servicos',
'/portfolio', '/portfolio',
'/portfolio/'.$case->slug, '/portfolio/'.$this->portfolioCase->slug,
'/sobre', '/sobre',
'/contato', '/contato',
'/briefing',
'/privacidade', '/privacidade',
'/__missing-accessibility-page', '/__missing-accessibility-page',
]; ];
foreach ($routes as $route) { $page = $this->visit($routes[0]);
$this->visit($route)
->assertNoAccessibilityIssues(1); foreach ($routes as $index => $route) {
if ($index > 0) {
$page->navigate($route);
}
$page
->assertNoAccessibilityIssues(1)
->assertNoJavaScriptErrors();
} }
}); });
@@ -83,49 +91,32 @@ it('reaches the primary CTA by keyboard and activates it', function (): void {
$outlineStyle = $page->script('() => getComputedStyle(document.activeElement).outlineStyle'); $outlineStyle = $page->script('() => getComputedStyle(document.activeElement).outlineStyle');
expect($outlineStyle)->not->toBe('none'); expect($outlineStyle)->not->toBe('none');
$page->keys('[data-testid="home-primary-cta"]', 'Enter') $page->keys('[data-testid="home-primary-cta"]', 'Enter');
->assertPathIs('/contato');
});
it('loads covered public routes without console errors', function (): void { $hash = $page->script('() => window.location.hash');
$case = PortfolioCase::factory()->published()->create([ expect($hash)->toBe('#sobre');
'slug' => 'casamento-jardim',
]);
foreach ([
'/',
'/servicos',
'/portfolio',
'/portfolio/'.$case->slug,
'/sobre',
'/contato',
'/privacidade',
'/__missing-console-page',
] as $route) {
$this->visit($route)
->assertNoJavaScriptErrors();
}
}); });
it('disables transitions when prefers-reduced-motion is reduce', function (): void { it('disables transitions when prefers-reduced-motion is reduce', function (): void {
$case = PortfolioCase::factory()->published()->create([
'slug' => 'casamento-jardim',
]);
$routes = [ $routes = [
'/', '/',
'/servicos', '/servicos',
'/portfolio', '/portfolio',
'/portfolio/'.$case->slug, '/portfolio/'.$this->portfolioCase->slug,
'/sobre', '/sobre',
'/contato', '/contato',
'/briefing',
'/privacidade', '/privacidade',
]; ];
foreach ($routes as $route) { $page = $this->visit($routes[0], [
$page = $this->visit($route, [ 'reducedMotion' => 'reduce',
'reducedMotion' => 'reduce', ]);
]);
foreach ($routes as $index => $route) {
if ($index > 0) {
$page->navigate($route);
}
$duration = $page->script(<<<'JS' $duration = $page->script(<<<'JS'
() => { () => {

View File

@@ -11,13 +11,13 @@ beforeEach(function (): void {
SiteSetting::instance(); SiteSetting::instance();
}); });
it('does not overflow horizontally when contact info contains long unbroken strings', function (): void { it('does not overflow horizontally when briefing contact info contains long unbroken strings', function (): void {
SiteSetting::instance()->update([ SiteSetting::instance()->update([
'email' => str_repeat('a', 50).'@email.'.str_repeat('b', 40), 'email' => str_repeat('a', 50).'@email.'.str_repeat('b', 40),
'phone' => '(11) 99999-9999', 'phone' => '(11) 99999-9999',
]); ]);
$page = $this->visit('/contato'); $page = $this->visit('/briefing');
$overflow = $page->script( $overflow = $page->script(
'() => document.documentElement.scrollWidth - document.documentElement.clientWidth', '() => document.documentElement.scrollWidth - document.documentElement.clientWidth',
@@ -27,7 +27,7 @@ it('does not overflow horizontally when contact info contains long unbroken stri
}); });
it('shows sending state on submit and resets on bfcache restore', function (): void { it('shows sending state on submit and resets on bfcache restore', function (): void {
$page = $this->visit('/contato'); $page = $this->visit('/briefing');
$state = $page->script(<<<'JS' $state = $page->script(<<<'JS'
() => { () => {

View File

@@ -12,20 +12,22 @@ beforeEach(function (): void {
Artisan::call('db:seed', ['--class' => VisualContentSeeder::class, '--force' => true]); Artisan::call('db:seed', ['--class' => VisualContentSeeder::class, '--force' => true]);
}); });
it('uses equal desktop columns while filling the opening below the header with full-bleed media', function (): void { it('keeps the hero asymmetric on desktop and stacked through md and mobile', function (): void {
$page = $this->visit('/', [ $page = $this->visit('/', [
'reducedMotion' => 'reduce', 'reducedMotion' => 'reduce',
])->resize(1440, 1000); ]);
$page->script('() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))'); $waitFrames = '() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))';
$layout = $page->script(<<<'JS' $page->resize(1440, 1000);
$page->script($waitFrames);
$desktop = $page->script(<<<'JS'
() => { () => {
const header = document.querySelector('.site-header'); const header = document.querySelector('.site-header');
const hero = document.querySelector('[data-chapter="hero"]'); const hero = document.querySelector('[data-chapter="hero"]');
const content = hero?.querySelector('[data-hero-content]'); const content = hero?.querySelector('[data-hero-content]');
const media = hero?.querySelector('[data-split-hero]'); const media = hero?.querySelector('[data-split-hero]');
const image = media?.querySelector('img');
const headerRect = header?.getBoundingClientRect(); const headerRect = header?.getBoundingClientRect();
const heroRect = hero?.getBoundingClientRect(); const heroRect = hero?.getBoundingClientRect();
const contentRect = content?.getBoundingClientRect(); const contentRect = content?.getBoundingClientRect();
@@ -34,38 +36,27 @@ it('uses equal desktop columns while filling the opening below the header with f
return { return {
hasContentColumn: Boolean(content), hasContentColumn: Boolean(content),
startsBelowHeader: Math.abs((heroRect?.top ?? -1) - (headerRect?.bottom ?? -2)) <= 1, startsBelowHeader: Math.abs((heroRect?.top ?? -1) - (headerRect?.bottom ?? -2)) <= 1,
fillsRemainingViewport: Math.abs((heroRect?.bottom ?? -1) - window.innerHeight) <= 1, mediaBesideContent: (mediaRect?.left ?? 0) >= (contentRect?.right ?? Number.POSITIVE_INFINITY) - 1,
mediaTouchesHeroTop: Math.abs((mediaRect?.top ?? -1) - (heroRect?.top ?? -2)) <= 1, mediaMinHeight: Math.round(mediaRect?.height ?? 0) >= 519,
mediaTouchesHeroBottom: Math.abs((mediaRect?.bottom ?? -1) - (heroRect?.bottom ?? -2)) <= 1, mediaInsideContainer: (mediaRect?.right ?? 0) < window.innerWidth - 100,
mediaTouchesViewportRight: Math.abs((mediaRect?.right ?? -1) - window.innerWidth) <= 1,
splitImageObjectFit: image ? getComputedStyle(image).objectFit : null,
columnRatio: Number(((contentRect?.width ?? 0) / (mediaRect?.width ?? 1)).toFixed(3)),
horizontalOverflow: document.documentElement.scrollWidth > window.innerWidth, horizontalOverflow: document.documentElement.scrollWidth > window.innerWidth,
}; };
} }
JS); JS);
expect($layout)->toBe([ expect($desktop)->toBe([
'hasContentColumn' => true, 'hasContentColumn' => true,
'startsBelowHeader' => true, 'startsBelowHeader' => true,
'fillsRemainingViewport' => true, 'mediaBesideContent' => true,
'mediaTouchesHeroTop' => true, 'mediaMinHeight' => true,
'mediaTouchesHeroBottom' => true, 'mediaInsideContainer' => true,
'mediaTouchesViewportRight' => true,
'splitImageObjectFit' => 'cover',
'columnRatio' => 1,
'horizontalOverflow' => false, 'horizontalOverflow' => false,
]); ]);
});
it('keeps the home hero linear through tablet with a full-width four-by-five crop', function (): void { $page->resize(767, 1000);
$page = $this->visit('/', [ $page->script($waitFrames);
'reducedMotion' => 'reduce',
])->resize(1023, 1000);
$page->script('() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))'); $md = $page->script(<<<'JS'
$layout = $page->script(<<<'JS'
() => { () => {
const hero = document.querySelector('[data-chapter="hero"]'); const hero = document.querySelector('[data-chapter="hero"]');
const content = hero?.querySelector('[data-hero-content]'); const content = hero?.querySelector('[data-hero-content]');
@@ -76,30 +67,25 @@ it('keeps the home hero linear through tablet with a full-width four-by-five cro
return { return {
hasContentColumn: Boolean(content), hasContentColumn: Boolean(content),
mediaFollowsContent: (mediaRect?.top ?? 0) >= (contentRect?.bottom ?? Number.POSITIVE_INFINITY) - 1, mediaFollowsContent: (mediaRect?.top ?? 0) >= (contentRect?.bottom ?? Number.POSITIVE_INFINITY) - 1,
mediaFillsViewportWidth: Math.abs((mediaRect?.width ?? 0) - window.innerWidth) <= 1, mediaFillsContainerWidth: (mediaRect?.width ?? 0) > window.innerWidth - 100,
mediaAspectRatio: Number(((mediaRect?.width ?? 0) / (mediaRect?.height ?? 1)).toFixed(3)), mediaMinHeight: Math.round(mediaRect?.height ?? 0) >= 319,
horizontalOverflow: document.documentElement.scrollWidth > window.innerWidth, horizontalOverflow: document.documentElement.scrollWidth > window.innerWidth,
}; };
} }
JS); JS);
expect($layout)->toBe([ expect($md)->toBe([
'hasContentColumn' => true, 'hasContentColumn' => true,
'mediaFollowsContent' => true, 'mediaFollowsContent' => true,
'mediaFillsViewportWidth' => true, 'mediaFillsContainerWidth' => true,
'mediaAspectRatio' => 0.8, 'mediaMinHeight' => true,
'horizontalOverflow' => false, 'horizontalOverflow' => false,
]); ]);
});
it('stacks the home hero media after its content as a full-width four-by-five crop on mobile', function (): void { $page->resize(390, 844);
$page = $this->visit('/', [ $page->script($waitFrames);
'reducedMotion' => 'reduce',
])->resize(390, 844);
$page->script('() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))'); $mobile = $page->script(<<<'JS'
$layout = $page->script(<<<'JS'
() => { () => {
const hero = document.querySelector('[data-chapter="hero"]'); const hero = document.querySelector('[data-chapter="hero"]');
const content = hero?.querySelector('[data-hero-content]'); const content = hero?.querySelector('[data-hero-content]');
@@ -110,27 +96,27 @@ it('stacks the home hero media after its content as a full-width four-by-five cr
return { return {
hasContentColumn: Boolean(content), hasContentColumn: Boolean(content),
mediaFollowsContent: (mediaRect?.top ?? 0) >= (contentRect?.bottom ?? Number.POSITIVE_INFINITY) - 1, mediaFollowsContent: (mediaRect?.top ?? 0) >= (contentRect?.bottom ?? Number.POSITIVE_INFINITY) - 1,
mediaFillsViewportWidth: Math.abs((mediaRect?.width ?? 0) - window.innerWidth) <= 1, mediaFillsContainerWidth: (mediaRect?.width ?? 0) > window.innerWidth - 100,
mediaAspectRatio: Number(((mediaRect?.width ?? 0) / (mediaRect?.height ?? 1)).toFixed(3)),
horizontalOverflow: document.documentElement.scrollWidth > window.innerWidth, horizontalOverflow: document.documentElement.scrollWidth > window.innerWidth,
}; };
} }
JS); JS);
expect($layout)->toBe([ expect($mobile)->toBe([
'hasContentColumn' => true, 'hasContentColumn' => true,
'mediaFollowsContent' => true, 'mediaFollowsContent' => true,
'mediaFillsViewportWidth' => true, 'mediaFillsContainerWidth' => true,
'mediaAspectRatio' => 0.8,
'horizontalOverflow' => false, 'horizontalOverflow' => false,
]); ]);
}); });
it('keeps the full home cadence accessible and contained at both viewports', function (): void { it('keeps the full home cadence accessible and contained at both viewports', function (): void {
$page = $this->visit('/', [
'reducedMotion' => 'reduce',
]);
foreach ([[1440, 1000], [390, 844]] as [$width, $height]) { foreach ([[1440, 1000], [390, 844]] as [$width, $height]) {
$page = $this->visit('/', [ $page->resize($width, $height);
'reducedMotion' => 'reduce',
])->resize($width, $height);
$page->assertNoAccessibilityIssues(1); $page->assertNoAccessibilityIssues(1);

View File

@@ -23,7 +23,14 @@ beforeEach(function (): void {
]); ]);
}); });
it('keeps every home motion target in final visible state when reduced motion is preferred', function (): void { it('keeps reduced-motion home final and free of ornamental chapter counters', function (): void {
PortfolioCase::factory()->published()->create([
'is_featured' => true,
]);
Testimonial::factory()->published()->create([
'quote' => 'Relato publicado.',
]);
$page = $this->visit('/', [ $page = $this->visit('/', [
'reducedMotion' => 'reduce', 'reducedMotion' => 'reduce',
]); ]);
@@ -37,14 +44,20 @@ it('keeps every home motion target in final visible state when reduced motion is
const titleStyle = title ? getComputedStyle(title) : null; const titleStyle = title ? getComputedStyle(title) : null;
const finalReveals = [...document.querySelectorAll('[data-reveal]')] const finalReveals = [...document.querySelectorAll('[data-reveal]')]
.every((node) => node.classList.contains('is-revealed')); .every((node) => node.classList.contains('is-revealed'));
const flow = document.querySelector('.home-chapters');
const chapters = Array.from(document.querySelectorAll('.home-chapter'));
const folios = Array.from(document.querySelectorAll('[data-home-folio]'));
return { return {
heroActive: hero?.classList.contains('is-active') ?? false, heroActive: hero?.classList.contains('is-active') ?? false,
titleOpacity: titleStyle ? Number.parseFloat(titleStyle.opacity) : 0, titleOpacity: titleStyle ? Number.parseFloat(titleStyle.opacity) : 0,
hasIndex: Boolean(index), hasIndex: Boolean(index),
hasActiveChapter: Boolean(active), hasActiveChapter: Boolean(active),
finalReveals, finalReveals,
enhancementEnabled: document.documentElement.hasAttribute('data-motion'), enhancementEnabled: document.documentElement.hasAttribute('data-motion'),
reset: flow ? getComputedStyle(flow).counterReset : '',
increments: chapters.map((chapter) => getComputedStyle(chapter).counterIncrement),
folioCount: folios.length,
}; };
} }
JS); JS);
@@ -55,9 +68,12 @@ it('keeps every home motion target in final visible state when reduced motion is
expect($state['hasActiveChapter'])->toBeFalse(); expect($state['hasActiveChapter'])->toBeFalse();
expect($state['finalReveals'])->toBeTrue(); expect($state['finalReveals'])->toBeTrue();
expect($state['enhancementEnabled'])->toBeFalse(); expect($state['enhancementEnabled'])->toBeFalse();
expect($state['reset'])->toBe('none');
expect($state['increments'])->each->toBe('none');
expect($state['folioCount'])->toBe(0);
}); });
it('activates the home opening with 500ms beats and capped 90ms stagger', function (): void { it('opens home motion, reveals once, and allows CTA during open', function (): void {
$page = $this->visit('/', [ $page = $this->visit('/', [
'reducedMotion' => 'no-preference', 'reducedMotion' => 'no-preference',
]); ]);
@@ -77,45 +93,39 @@ it('activates the home opening with 500ms beats and capped 90ms stagger', functi
}) })
JS); JS);
$state = $page->script(<<<'JS' $opening = $page->script(<<<'JS'
() => { () => {
const hero = document.querySelector('[data-motion="page-open"]'); const hero = document.querySelector('[data-motion="page-open"]');
const beatDurations = [...hero.querySelectorAll('[data-motion-beat]')] const beatDurations = [...hero.querySelectorAll('[data-motion-beat]')]
.map((beat) => Number.parseFloat(getComputedStyle(beat).transitionDuration)); .map((beat) => Number.parseFloat(getComputedStyle(beat).transitionDuration));
const methodItems = [...document.querySelectorAll('[data-chapter="method"] [data-reveal]')]; const vertentesItems = [...document.querySelectorAll('[data-chapter="vertentes"] [data-reveal]')];
return { return {
active: hero?.classList.contains('is-active') ?? false, active: hero?.classList.contains('is-active') ?? false,
beatDurations, beatDurations,
methodIndexes: methodItems.map((item) => item.style.getPropertyValue('--motion-index')), vertentesIndexes: vertentesItems.map((item) => item.style.getPropertyValue('--motion-index')),
}; };
} }
JS); JS);
expect($state['active'])->toBeTrue(); expect($opening['active'])->toBeTrue();
expect($state['beatDurations'])->each->toBe(0.5); expect($opening['beatDurations'])->each->toBe(0.5);
expect($state['methodIndexes'])->toBe(['0', '1', '2', '3', '3']); expect($opening['vertentesIndexes'])->toBe(['0', '1', '2']);
});
it('reveals a section once and keeps it final after returning in the scroll', function (): void { $beforeReveal = $page->script(<<<'JS'
$page = $this->visit('/', [ () => document.querySelector('#briefing-heading')?.closest('[data-reveal]')?.classList.contains('is-revealed') ?? false
'reducedMotion' => 'no-preference',
]);
$before = $page->script(<<<'JS'
() => document.querySelector('#final-cta-heading')?.closest('[data-reveal]')?.classList.contains('is-revealed') ?? false
JS); JS);
$page->script(<<<'JS' $page->script(<<<'JS'
() => { () => {
document.querySelector('#final-cta-heading')?.scrollIntoView({ block: 'center' }); document.querySelector('#briefing-heading')?.scrollIntoView({ block: 'center' });
return true; return true;
} }
JS); JS);
$page->script(<<<'JS' $page->script(<<<'JS'
() => new Promise((resolve) => { () => new Promise((resolve) => {
const target = document.querySelector('#final-cta-heading')?.closest('[data-reveal]'); const target = document.querySelector('#briefing-heading')?.closest('[data-reveal]');
const wait = () => { const wait = () => {
if (target?.classList.contains('is-revealed')) { if (target?.classList.contains('is-revealed')) {
resolve(true); resolve(true);
@@ -124,67 +134,44 @@ it('reveals a section once and keeps it final after returning in the scroll', fu
requestAnimationFrame(wait); requestAnimationFrame(wait);
}; };
wait(); wait();
setTimeout(() => resolve(false), 2000);
}) })
JS); JS);
$page->script('() => window.scrollTo({ top: 0, behavior: "instant" })'); $page->script('() => window.scrollTo({ top: 0, behavior: "instant" })');
$after = $page->script(<<<'JS' $afterReveal = $page->script(<<<'JS'
() => document.querySelector('#final-cta-heading')?.closest('[data-reveal]')?.classList.contains('is-revealed') ?? false () => document.querySelector('#briefing-heading')?.closest('[data-reveal]')?.classList.contains('is-revealed') ?? false
JS); JS);
expect($before)->toBeFalse(); expect($beforeReveal)->toBeFalse();
expect($after)->toBeTrue(); expect($afterReveal)->toBeTrue();
});
it('preserves testimonial directions with reduced lateral distance on mobile', function (): void { $page->script(<<<'JS'
Testimonial::factory()->published()->create([ () => {
'quote' => 'Primeiro relato.', const opening = document.querySelector('[data-motion="page-open"]');
'sort_order' => 10, opening?.classList.remove('is-active');
]); requestAnimationFrame(() => opening?.classList.add('is-active'));
Testimonial::factory()->published()->create([ return true;
'quote' => 'Segundo relato.', }
'sort_order' => 20, JS);
]);
$page = $this->visit('/', [ $page->click('[data-testid="home-primary-cta"]');
'reducedMotion' => 'no-preference',
]);
$readDistances = <<<'JS' expect($page->script('() => window.location.hash'))->toBe('#sobre');
() => [...document.querySelectorAll('[data-chapter="testimonials"] blockquote')]
.map((item) => ({
direction: item.dataset.revealFrom,
distance: getComputedStyle(item).getPropertyValue('--motion-translate-x').trim(),
}))
JS;
$page->resize(1440, 1000);
$page->script('() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))');
$desktop = $page->script($readDistances);
$page->resize(390, 844);
$page->script('() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))');
$mobile = $page->script($readDistances);
expect($desktop)->toBe([
['direction' => 'left', 'distance' => 'calc(-1 * 24px)'],
['direction' => 'right', 'distance' => '24px'],
]);
expect($mobile)->toBe([
['direction' => 'left', 'distance' => 'calc(-1 * 16px)'],
['direction' => 'right', 'distance' => '16px'],
]);
}); });
it('keeps content and navigation usable without javascript', function (): void { it('keeps content and navigation usable without javascript', function (): void {
$this->visit('/', [ $page = $this->visit('/', [
'javaScriptEnabled' => false, 'javaScriptEnabled' => false,
]) ]);
$page
->assertVisible('#hero-heading') ->assertVisible('#hero-heading')
->assertVisible('[data-testid="home-primary-cta"]') ->assertVisible('[data-testid="home-primary-cta"]')
->click('[data-testid="home-primary-cta"]') ->click('[data-testid="home-primary-cta"]');
->assertPathIs('/contato');
expect($page->script('() => window.location.hash'))->toBe('#sobre');
}); });
it('keeps targets final when intersection observer is unavailable', function (): void { it('keeps targets final when intersection observer is unavailable', function (): void {
@@ -212,24 +199,6 @@ it('keeps targets final when intersection observer is unavailable', function ():
]); ]);
}); });
it('allows cta activation while its opening is in progress', function (): void {
$page = $this->visit('/', [
'reducedMotion' => 'no-preference',
]);
$page->script(<<<'JS'
() => {
const opening = document.querySelector('[data-motion="page-open"]');
opening?.classList.remove('is-active');
requestAnimationFrame(() => opening?.classList.add('is-active'));
return true;
}
JS);
$page->click('[data-testid="home-primary-cta"]')
->assertPathIs('/contato');
});
it('avoids horizontal overflow on visual public routes at desktop and mobile', function (): void { it('avoids horizontal overflow on visual public routes at desktop and mobile', function (): void {
$case = PortfolioCase::factory()->published()->create([ $case = PortfolioCase::factory()->published()->create([
'slug' => 'casamento-jardim', 'slug' => 'casamento-jardim',
@@ -242,15 +211,24 @@ it('avoids horizontal overflow on visual public routes at desktop and mobile', f
'/portfolio/'.$case->slug, '/portfolio/'.$case->slug,
'/sobre', '/sobre',
'/contato', '/contato',
'/briefing',
'/privacidade', '/privacidade',
'/__missing-public-motion', '/__missing-public-motion',
]; ];
foreach ([[1440, 1000], [390, 844]] as [$width, $height]) { $viewports = [[1440, 1000], [390, 844]];
foreach ($routes as $route) {
$page = $this->visit($route, [ $page = $this->visit($routes[0], [
'reducedMotion' => 'no-preference', 'reducedMotion' => 'no-preference',
])->resize($width, $height); ]);
foreach ($routes as $index => $route) {
if ($index > 0) {
$page->navigate($route);
}
foreach ($viewports as [$width, $height]) {
$page->resize($width, $height);
$overflow = $page->script('() => document.documentElement.scrollWidth - document.documentElement.clientWidth'); $overflow = $page->script('() => document.documentElement.scrollWidth - document.documentElement.clientWidth');
@@ -258,34 +236,3 @@ it('avoids horizontal overflow on visual public routes at desktop and mobile', f
} }
} }
}); });
it('keeps rendered home chapters free of ornamental folios and counters', function (): void {
PortfolioCase::factory()->published()->create([
'is_featured' => true,
]);
Testimonial::factory()->published()->create([
'quote' => 'Relato publicado.',
]);
$page = $this->visit('/', [
'reducedMotion' => 'reduce',
]);
$editorial = $page->script(<<<'JS'
() => {
const flow = document.querySelector('.home-chapters');
const chapters = Array.from(document.querySelectorAll('.home-chapter'));
const folios = Array.from(document.querySelectorAll('[data-home-folio]'));
return {
reset: flow ? getComputedStyle(flow).counterReset : '',
increments: chapters.map((chapter) => getComputedStyle(chapter).counterIncrement),
folioCount: folios.length,
};
}
JS);
expect($editorial['reset'])->toBe('none');
expect($editorial['increments'])->each->toBe('none');
expect($editorial['folioCount'])->toBe(0);
});

View File

@@ -8,11 +8,11 @@ it('renders the public home page', function (): void {
$settings = SiteSetting::instance(); $settings = SiteSetting::instance();
$this->visit('/') $this->visit('/')
->assertSee($settings->hero_title) ->assertSee('Eventos com intenção, cuidado e presença.')
->assertSee($settings->brand_name); ->assertSee($settings->brand_name);
}); });
it('renders the admin login page', function (): void { it('renders the admin login page', function (): void {
$this->visit('/admin/login') $this->visit('/admin/login')
->assertSee('E-mail'); ->assertVisible('input[type="email"]');
}); });

View File

@@ -9,6 +9,7 @@ use App\Models\PortfolioCase;
use App\Models\Service; use App\Models\Service;
use App\Models\SiteSetting; use App\Models\SiteSetting;
use App\Models\Testimonial; use App\Models\Testimonial;
use App\Models\WeddingPackage;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase; use Tests\TestCase;
@@ -33,6 +34,10 @@ class GetHomeContentTest extends TestCase
$testimonialB = Testimonial::factory()->published()->create(['author_name' => 'Author B', 'sort_order' => 20]); $testimonialB = Testimonial::factory()->published()->create(['author_name' => 'Author B', 'sort_order' => 20]);
$testimonialA = Testimonial::factory()->published()->create(['author_name' => 'Author A', 'sort_order' => 10]); $testimonialA = Testimonial::factory()->published()->create(['author_name' => 'Author A', 'sort_order' => 10]);
WeddingPackage::factory()->create(['name' => 'Draft Package', 'published_at' => null]);
$packageB = WeddingPackage::factory()->published()->create(['name' => 'Package B', 'sort_order' => 20]);
$packageA = WeddingPackage::factory()->published()->create(['name' => 'Package A', 'sort_order' => 10]);
$content = (new GetHomeContent)(); $content = (new GetHomeContent)();
$this->assertTrue($content->settings->is($settings)); $this->assertTrue($content->settings->is($settings));
@@ -46,5 +51,8 @@ class GetHomeContentTest extends TestCase
$this->assertCount(2, $content->testimonials); $this->assertCount(2, $content->testimonials);
$this->assertTrue($content->testimonials->get(0)?->is($testimonialA)); $this->assertTrue($content->testimonials->get(0)?->is($testimonialA));
$this->assertTrue($content->testimonials->get(1)?->is($testimonialB)); $this->assertTrue($content->testimonials->get(1)?->is($testimonialB));
$this->assertCount(2, $content->packages);
$this->assertTrue($content->packages->get(0)?->is($packageA));
$this->assertTrue($content->packages->get(1)?->is($packageB));
} }
} }

View File

@@ -75,11 +75,11 @@ class PasswordResetRequestTest extends TestCase
// `request()` resets the form (see the base page's `$this->form->fill()`). // `request()` resets the form (see the base page's `$this->form->fill()`).
$page->set('data.email', $user->email) $page->set('data.email', $user->email)
->call('request') ->call('request')
->assertNotified(Password::RESET_LINK_SENT); ->assertNotified($this->expectedSentNotification());
$page->set('data.email', $user->email) $page->set('data.email', $user->email)
->call('request') ->call('request')
->assertNotified(Password::RESET_LINK_SENT); ->assertNotified($this->expectedSentNotification());
} }
private function expectedSentNotification(): FilamentNotification private function expectedSentNotification(): FilamentNotification

View File

@@ -20,7 +20,7 @@ class HomePageTest extends TestCase
$response $response
->assertOk() ->assertOk()
->assertSee($settings->hero_title) ->assertSee('Eventos com intenção, cuidado e presença.')
->assertSee($settings->brand_name); ->assertSee($settings->brand_name);
} }
} }

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Marketing;
use App\Models\WeddingPackage;
use Database\Seeders\ContentSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class WeddingPackageTest extends TestCase
{
use RefreshDatabase;
public function test_published_scope_excludes_drafts_and_keeps_cms_order(): void
{
WeddingPackage::factory()->create(['name' => 'Rascunho', 'published_at' => null]);
$second = WeddingPackage::factory()->published()->create(['name' => 'Segundo', 'sort_order' => 20]);
$first = WeddingPackage::factory()->published()->create(['name' => 'Primeiro', 'sort_order' => 10]);
$packages = WeddingPackage::query()->published()->orderBy('sort_order')->get();
$this->assertCount(2, $packages);
$this->assertTrue($packages->first()?->is($first));
$this->assertTrue($packages->last()?->is($second));
}
public function test_content_seeder_creates_official_wedding_modalities(): void
{
$this->app->detectEnvironment(fn (): string => 'testing');
$this->seed(ContentSeeder::class);
$this->assertSame(
['Essenza', 'Conduzione', 'Grand Jour'],
WeddingPackage::query()->published()->orderBy('sort_order')->pluck('name')->all(),
);
}
}

View File

@@ -56,11 +56,11 @@ class ContactBriefingOriginTest extends TestCase
SiteSetting::instance(); SiteSetting::instance();
Mail::fake(); Mail::fake();
$this->get('/contato?utm_source=instagram&utm_medium=social&utm_campaign=lancamento-2026') $this->get('/briefing?utm_source=instagram&utm_medium=social&utm_campaign=lancamento-2026')
->assertOk(); ->assertOk();
$this->post(route('contact.store'), $this->validPayload()) $this->post(route('briefing.store'), $this->validPayload())
->assertRedirect(route('contact')) ->assertRedirect(route('briefing'))
->assertSessionHas('status', 'briefing-sent'); ->assertSessionHas('status', 'briefing-sent');
Mail::assertQueued(ContactBriefing::class, function (ContactBriefing $mail): bool { Mail::assertQueued(ContactBriefing::class, function (ContactBriefing $mail): bool {
@@ -76,10 +76,10 @@ class ContactBriefingOriginTest extends TestCase
Mail::fake(); Mail::fake();
// No GET beforehand: posting cold, exactly like a direct/no-JS submission. // No GET beforehand: posting cold, exactly like a direct/no-JS submission.
$response = $this->post(route('contact.store'), $this->validPayload()); $response = $this->post(route('briefing.store'), $this->validPayload());
$response $response
->assertRedirect(route('contact')) ->assertRedirect(route('briefing'))
->assertSessionHas('status', 'briefing-sent'); ->assertSessionHas('status', 'briefing-sent');
Mail::assertQueued(ContactBriefing::class, function (ContactBriefing $mail): bool { Mail::assertQueued(ContactBriefing::class, function (ContactBriefing $mail): bool {
@@ -97,11 +97,11 @@ class ContactBriefingOriginTest extends TestCase
// identifiable e-mail/subscriber id) — only the site identity may // identifiable e-mail/subscriber id) — only the site identity may
// be captured as marketing origin, never the query string itself. // be captured as marketing origin, never the query string itself.
$this->withHeader('referer', 'https://mail.example.com/click?email=maria%40example.com&subscriber_id=42') $this->withHeader('referer', 'https://mail.example.com/click?email=maria%40example.com&subscriber_id=42')
->get('/contato') ->get('/briefing')
->assertOk(); ->assertOk();
$this->post(route('contact.store'), $this->validPayload()) $this->post(route('briefing.store'), $this->validPayload())
->assertRedirect(route('contact')) ->assertRedirect(route('briefing'))
->assertSessionHas('status', 'briefing-sent'); ->assertSessionHas('status', 'briefing-sent');
Mail::assertQueued(ContactBriefing::class, function (ContactBriefing $mail): bool { Mail::assertQueued(ContactBriefing::class, function (ContactBriefing $mail): bool {
@@ -114,15 +114,15 @@ class ContactBriefingOriginTest extends TestCase
SiteSetting::instance(); SiteSetting::instance();
Mail::fake(); Mail::fake();
$this->get('/contato?utm_source=google&utm_medium=cpc') $this->get('/briefing?utm_source=google&utm_medium=cpc')
->assertOk(); ->assertOk();
// Navigate elsewhere with no UTM parameters at all before submitting. // Navigate elsewhere with no UTM parameters at all before submitting.
$this->get('/servicos')->assertOk(); $this->get('/servicos')->assertOk();
$this->get('/sobre')->assertOk(); $this->get('/sobre')->assertOk();
$this->post(route('contact.store'), $this->validPayload()) $this->post(route('briefing.store'), $this->validPayload())
->assertRedirect(route('contact')) ->assertRedirect(route('briefing'))
->assertSessionHas('status', 'briefing-sent'); ->assertSessionHas('status', 'briefing-sent');
Mail::assertQueued(ContactBriefing::class, function (ContactBriefing $mail): bool { Mail::assertQueued(ContactBriefing::class, function (ContactBriefing $mail): bool {
@@ -135,16 +135,16 @@ class ContactBriefingOriginTest extends TestCase
SiteSetting::instance(); SiteSetting::instance();
Mail::fake(); Mail::fake();
$this->get('/contato?utm_source=google&utm_medium=cpc') $this->get('/briefing?utm_source=google&utm_medium=cpc')
->assertOk(); ->assertOk();
// A second page load with its own, different UTM parameters must // A second page load with its own, different UTM parameters must
// not overwrite what was captured on first touch. // not overwrite what was captured on first touch.
$this->get('/contato?utm_source=facebook&utm_medium=social') $this->get('/briefing?utm_source=facebook&utm_medium=social')
->assertOk(); ->assertOk();
$this->post(route('contact.store'), $this->validPayload()) $this->post(route('briefing.store'), $this->validPayload())
->assertRedirect(route('contact')) ->assertRedirect(route('briefing'))
->assertSessionHas('status', 'briefing-sent'); ->assertSessionHas('status', 'briefing-sent');
Mail::assertQueued(ContactBriefing::class, function (ContactBriefing $mail): bool { Mail::assertQueued(ContactBriefing::class, function (ContactBriefing $mail): bool {
@@ -157,11 +157,11 @@ class ContactBriefingOriginTest extends TestCase
$settings = SiteSetting::instance(); $settings = SiteSetting::instance();
Mail::fake(); Mail::fake();
$this->get('/contato?utm_source=instagram&utm_campaign=segredo-interno') $this->get('/briefing?utm_source=instagram&utm_campaign=segredo-interno')
->assertOk(); ->assertOk();
$this->post(route('contact.store'), $this->validPayload()) $this->post(route('briefing.store'), $this->validPayload())
->assertRedirect(route('contact')) ->assertRedirect(route('briefing'))
->assertSessionHas('status', 'briefing-sent'); ->assertSessionHas('status', 'briefing-sent');
Mail::assertQueued(ContactBriefingConfirmation::class, function (ContactBriefingConfirmation $mail): bool { Mail::assertQueued(ContactBriefingConfirmation::class, function (ContactBriefingConfirmation $mail): bool {
@@ -187,10 +187,10 @@ class ContactBriefingOriginTest extends TestCase
// the array shape the controller expects. The acceptance // the array shape the controller expects. The acceptance
// criterion is that this never blocks the submission. // criterion is that this never blocks the submission.
$response = $this->withSession([MarketingOrigin::SESSION_KEY => 'lixo']) $response = $this->withSession([MarketingOrigin::SESSION_KEY => 'lixo'])
->post(route('contact.store'), $this->validPayload()); ->post(route('briefing.store'), $this->validPayload());
$response $response
->assertRedirect(route('contact')) ->assertRedirect(route('briefing'))
->assertSessionHas('status', 'briefing-sent'); ->assertSessionHas('status', 'briefing-sent');
Mail::assertQueued(ContactBriefing::class, function (ContactBriefing $mail): bool { Mail::assertQueued(ContactBriefing::class, function (ContactBriefing $mail): bool {
@@ -206,13 +206,13 @@ class ContactBriefingOriginTest extends TestCase
$overlongCampaign = str_repeat('a', 300); $overlongCampaign = str_repeat('a', 300);
$this->get('/contato?'.http_build_query([ $this->get('/briefing?'.http_build_query([
'utm_source' => '<script>alert(1)</script>', 'utm_source' => '<script>alert(1)</script>',
'utm_campaign' => $overlongCampaign, 'utm_campaign' => $overlongCampaign,
]))->assertOk(); ]))->assertOk();
$this->post(route('contact.store'), $this->validPayload()) $this->post(route('briefing.store'), $this->validPayload())
->assertRedirect(route('contact')) ->assertRedirect(route('briefing'))
->assertSessionHas('status', 'briefing-sent'); ->assertSessionHas('status', 'briefing-sent');
$capturedOrigin = null; $capturedOrigin = null;

View File

@@ -45,16 +45,16 @@ class ContactBriefingTest extends TestCase
], $overrides); ], $overrides);
} }
public function test_contact_page_renders_briefing_form_with_all_fields(): void public function test_briefing_page_renders_event_form_with_all_fields(): void
{ {
SiteSetting::instance(); SiteSetting::instance();
$response = $this->get(route('contact')); $response = $this->get(route('briefing'));
$response $response
->assertOk() ->assertOk()
->assertSee('<form', false) ->assertSee('<form', false)
->assertSee('action="'.route('contact.store').'"', false) ->assertSee('action="'.route('briefing.store').'"', false)
->assertSee('name="nome"', false) ->assertSee('name="nome"', false)
->assertSee('name="email"', false) ->assertSee('name="email"', false)
->assertSee('name="telefone"', false) ->assertSee('name="telefone"', false)
@@ -74,10 +74,10 @@ class ContactBriefingTest extends TestCase
$settings = SiteSetting::instance(); $settings = SiteSetting::instance();
Mail::fake(); Mail::fake();
$response = $this->post(route('contact.store'), $this->validPayload()); $response = $this->post(route('briefing.store'), $this->validPayload());
$response $response
->assertRedirect(route('contact')) ->assertRedirect(route('briefing'))
->assertSessionHas('status', 'briefing-sent'); ->assertSessionHas('status', 'briefing-sent');
Mail::assertQueued(ContactBriefing::class, function (ContactBriefing $mail) use ($settings): bool { Mail::assertQueued(ContactBriefing::class, function (ContactBriefing $mail) use ($settings): bool {
@@ -94,13 +94,13 @@ class ContactBriefingTest extends TestCase
$settings = SiteSetting::instance(); $settings = SiteSetting::instance();
Mail::fake(); Mail::fake();
$response = $this->post(route('contact.store'), $this->validPayload([ $response = $this->post(route('briefing.store'), $this->validPayload([
'email' => ' Maria.Silva@EXAMPLE.com ', 'email' => ' Maria.Silva@EXAMPLE.com ',
'telefone' => '+55 (11) 98888-7777', 'telefone' => '+55 (11) 98888-7777',
])); ]));
$response $response
->assertRedirect(route('contact')) ->assertRedirect(route('briefing'))
->assertSessionHas('status', 'briefing-sent'); ->assertSessionHas('status', 'briefing-sent');
Mail::assertQueued(ContactBriefing::class, function (ContactBriefing $mail) use ($settings): bool { Mail::assertQueued(ContactBriefing::class, function (ContactBriefing $mail) use ($settings): bool {
@@ -119,12 +119,12 @@ class ContactBriefingTest extends TestCase
SiteSetting::instance(); SiteSetting::instance();
Mail::fake(); Mail::fake();
$response = $this->post(route('contact.store'), $this->validPayload([ $response = $this->post(route('briefing.store'), $this->validPayload([
'empresa' => 'http://spam.example', 'empresa' => 'http://spam.example',
])); ]));
$response $response
->assertRedirect(route('contact')) ->assertRedirect(route('briefing'))
->assertSessionHas('status', 'briefing-sent'); ->assertSessionHas('status', 'briefing-sent');
Mail::assertNothingSent(); Mail::assertNothingSent();
@@ -135,11 +135,11 @@ class ContactBriefingTest extends TestCase
SiteSetting::instance(); SiteSetting::instance();
Mail::fake(); Mail::fake();
$response = $this->from(route('contact'))->post(route('contact.store'), $this->validPayload([ $response = $this->from(route('briefing'))->post(route('briefing.store'), $this->validPayload([
'privacidade' => '', 'privacidade' => '',
])); ]));
$response->assertRedirect(route('contact')); $response->assertRedirect(route('briefing'));
$response->assertSessionHasErrors('privacidade'); $response->assertSessionHasErrors('privacidade');
Mail::assertNothingSent(); Mail::assertNothingSent();
@@ -150,9 +150,9 @@ class ContactBriefingTest extends TestCase
SiteSetting::instance(); SiteSetting::instance();
Mail::fake(); Mail::fake();
$response = $this->from(route('contact'))->post(route('contact.store'), []); $response = $this->from(route('briefing'))->post(route('briefing.store'), []);
$response->assertRedirect(route('contact')); $response->assertRedirect(route('briefing'));
foreach (['nome', 'email', 'telefone', 'tipo_evento', 'cidade', 'mensagem', 'privacidade'] as $field) { foreach (['nome', 'email', 'telefone', 'tipo_evento', 'cidade', 'mensagem', 'privacidade'] as $field) {
$response->assertSessionHasErrors($field); $response->assertSessionHasErrors($field);
@@ -168,8 +168,8 @@ class ContactBriefingTest extends TestCase
$payload = $this->validPayload(); $payload = $this->validPayload();
$this->post(route('contact.store'), $payload)->assertRedirect(route('contact')); $this->post(route('briefing.store'), $payload)->assertRedirect(route('briefing'));
$this->post(route('contact.store'), $payload)->assertRedirect(route('contact')); $this->post(route('briefing.store'), $payload)->assertRedirect(route('briefing'));
Mail::assertQueued(ContactBriefing::class, 1); Mail::assertQueued(ContactBriefing::class, 1);
Mail::assertQueued(ContactBriefingConfirmation::class, 1); Mail::assertQueued(ContactBriefingConfirmation::class, 1);
@@ -180,10 +180,10 @@ class ContactBriefingTest extends TestCase
SiteSetting::instance(); SiteSetting::instance();
config(['mail.default' => 'mailer-inexistente']); config(['mail.default' => 'mailer-inexistente']);
$response = $this->post(route('contact.store'), $this->validPayload()); $response = $this->post(route('briefing.store'), $this->validPayload());
$response $response
->assertRedirect(route('contact')) ->assertRedirect(route('briefing'))
->assertSessionHas('status', 'briefing-sent'); ->assertSessionHas('status', 'briefing-sent');
} }
} }

View File

@@ -5,9 +5,9 @@ declare(strict_types=1);
namespace Tests\Feature\PublicSite; namespace Tests\Feature\PublicSite;
use App\Models\PortfolioCase; use App\Models\PortfolioCase;
use App\Models\Service;
use App\Models\SiteSetting; use App\Models\SiteSetting;
use App\Models\Testimonial; use App\Models\Testimonial;
use App\Models\WeddingPackage;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase; use Tests\TestCase;
@@ -17,24 +17,18 @@ class HomePageContentTest extends TestCase
public function test_published_featured_content_appears_in_order_and_drafts_are_hidden(): void public function test_published_featured_content_appears_in_order_and_drafts_are_hidden(): void
{ {
$settings = SiteSetting::instance(); SiteSetting::instance();
$settings->update([
'hero_title' => 'Celebrações com propósito',
'hero_cta_label' => 'Solicitar orçamento',
'manifesto_title' => 'Manifesto Amare',
]);
Service::factory()->create([ WeddingPackage::factory()->create([
'title' => 'Serviço Rascunho', 'name' => 'Modalidade Rascunho',
'is_featured' => true,
'published_at' => null, 'published_at' => null,
]); ]);
Service::factory()->published()->featured()->create([ WeddingPackage::factory()->published()->create([
'title' => 'Serviço B', 'name' => 'Modalidade B',
'sort_order' => 20, 'sort_order' => 20,
]); ]);
Service::factory()->published()->featured()->create([ WeddingPackage::factory()->published()->create([
'title' => 'Serviço A', 'name' => 'Modalidade A',
'sort_order' => 10, 'sort_order' => 10,
]); ]);
@@ -73,60 +67,68 @@ class HomePageContentTest extends TestCase
$response $response
->assertOk() ->assertOk()
->assertSee('Celebrações com propósito')
->assertSeeInOrder([ ->assertSeeInOrder([
'id="hero-heading"', 'id="hero-heading"',
'id="manifesto-heading"', 'id="manifesto-heading"',
'id="services-heading"', 'id="vertentes-heading"',
'id="packages-heading"',
'id="corporate-heading"',
'id="portfolio-heading"', 'id="portfolio-heading"',
'id="method-heading"',
'id="testimonials-heading"', 'id="testimonials-heading"',
'id="positioning-heading"', 'id="briefing-heading"',
'id="final-cta-heading"',
], false) ], false)
->assertSeeInOrder(['Serviço A', 'Serviço B']) ->assertSeeInOrder(['Modalidade A', 'Modalidade B'])
->assertSeeInOrder(['Caso A', 'Caso B']) ->assertSeeInOrder(['Caso A', 'Caso B'])
->assertSeeInOrder(['Autor A', 'Autor B']) ->assertSeeInOrder(['Autor A', 'Autor B'])
->assertDontSee('Serviço Rascunho') ->assertDontSee('Modalidade Rascunho')
->assertDontSee('Caso Rascunho') ->assertDontSee('Caso Rascunho')
->assertDontSee('Autor Rascunho') ->assertDontSee('Autor Rascunho')
->assertSee('data-testid="home-primary-cta"', false) ->assertSee('data-testid="home-primary-cta"', false)
->assertSee('href="'.route('contact').'"', false); ->assertSee('href="'.route('briefing').'"', false);
} }
public function test_empty_sections_are_omitted_when_no_published_content(): void public function test_all_sections_render_with_placeholders_when_no_published_content(): void
{ {
SiteSetting::instance(); SiteSetting::instance()->update(['hero_image_path' => null]);
$response = $this->get(route('home')); $response = $this->get(route('home'));
$response $response
->assertOk() ->assertOk()
->assertDontSee('id="services-heading"', false) ->assertSeeInOrder([
->assertDontSee('id="portfolio-heading"', false) 'id="hero-heading"',
->assertDontSee('id="testimonials-heading"', false) 'id="manifesto-heading"',
->assertSee('id="manifesto-heading"', false) 'id="vertentes-heading"',
->assertSee('id="method-heading"', false) 'id="packages-heading"',
->assertSee('id="positioning-heading"', false) 'id="corporate-heading"',
->assertSee('id="final-cta-heading"', false); 'id="portfolio-heading"',
'id="testimonials-heading"',
'id="briefing-heading"',
], false)
->assertSee('Fotografia hero')
->assertSee('FOTO DE CASAMENTO 01')
->assertSee('Nome e data reais — aguardando autorização')
->assertSee('Conteúdo em construção')
->assertSee('Fornecedores e parcerias — pós-MVP');
} }
public function test_hero_falls_back_to_default_copy_when_fields_are_empty(): void public function test_hero_uses_fixed_editorial_copy_regardless_of_settings(): void
{ {
SiteSetting::instance()->update([ SiteSetting::instance()->update([
'hero_title' => '', 'hero_title' => '',
'hero_cta_label' => '',
'hero_secondary_cta_label' => '',
'hero_subtitle' => '', 'hero_subtitle' => '',
'hero_note' => '', 'hero_image_path' => null,
]); ]);
$response = $this->get(route('home')); $response = $this->get(route('home'));
$response $response
->assertOk() ->assertOk()
->assertSeeInOrder(['id="hero-heading"', 'Celebrações com propósito']) ->assertSee('Eventos com intenção, cuidado e presença.')
->assertSeeInOrder(['data-testid="home-primary-cta"', 'Solicitar proposta']); ->assertSee('Uma boutique de assessoria que une sensibilidade, organização e condução profissional para transformar cada projeto em uma experiência bem cuidada.')
->assertSee('Conhecer a Amare')
->assertSee('Enviar briefing')
->assertSeeInOrder(['href="#sobre"', 'data-testid="home-primary-cta"'], false);
} }
public function test_home_hero_uses_its_own_cms_image_instead_of_the_social_og_image(): void public function test_home_hero_uses_its_own_cms_image_instead_of_the_social_og_image(): void
@@ -158,13 +160,115 @@ class HomePageContentTest extends TestCase
$this->get(route('home')) $this->get(route('home'))
->assertOk() ->assertOk()
->assertSee('min-h-[100dvh]', false) ->assertSee('min-h-[320px]', false)
->assertSee('md:min-h-[520px]', false)
->assertSee('Fotografia hero')
->assertSee('id="hero-heading"', false) ->assertSee('id="hero-heading"', false)
->assertSee('data-testid="home-primary-cta"', false) ->assertSee('data-testid="home-primary-cta"', false)
->assertDontSee('data-motion-beat="media"', false); ->assertSee('data-motion-beat="media"', false)
->assertDontSee('content/home/hero.jpg', false);
} }
public function test_blank_quote_testimonials_are_skipped(): void public function test_wedding_packages_render_from_database_with_guidance_block(): void
{
WeddingPackage::factory()->published()->create([
'level' => '01 / COMPLETA',
'name' => 'Essenza',
'summary' => 'Para casais que desejam contar com a Amare desde o planejamento até a realização do casamento.',
'scope_items' => ['Planejamento e organização', 'Gestão de etapas e prioridades'],
'cta_label' => 'Quero conhecer a Essenza',
'sort_order' => 1,
]);
$response = $this->get(route('home'));
$response
->assertOk()
->assertSee('Um acompanhamento para cada momento.')
->assertSee('01 / COMPLETA')
->assertSee('Essenza')
->assertSee('Para casais que desejam contar com a Amare desde o planejamento até a realização do casamento.')
->assertSee('Planejamento e organização')
->assertSee('Gestão de etapas e prioridades')
->assertSee('Quero conhecer a Essenza')
->assertSee('href="'.route('briefing', ['servico_interesse' => 'Essenza']).'"', false)
->assertSee('Ainda não sabe qual modalidade é ideal?')
->assertSee('Conversar com a Amare')
->assertSee('Nomenclaturas exibidas conforme materiais/reunião; confirmar versão final antes da publicação.');
}
public function test_package_cta_uses_whatsapp_when_a_number_is_configured(): void
{
SiteSetting::instance()->update(['whatsapp_number' => '+55 11 98888-7777']);
WeddingPackage::factory()->published()->create([
'name' => 'Grand Jour',
'cta_label' => 'Quero conhecer a Grand Jour',
]);
$response = $this->get(route('home'));
$response
->assertOk()
->assertSee(
'https://wa.me/5511988887777?text='.rawurlencode('Olá, gostaria de conversar sobre a modalidade Grand Jour para meu casamento.'),
false,
)
->assertSee('target="_blank"', false)
->assertDontSee(route('briefing', ['servico_interesse' => 'Grand Jour']), false);
}
public function test_corporate_steps_and_placeholder_render_from_settings(): void
{
SiteSetting::instance()->update([
'corporate_steps' => [
['title' => 'Planejamento', 'body' => 'Estruturação de escopo, cronograma e prioridades.'],
['title' => 'Produção', 'body' => 'Coordenação dos elementos necessários para colocar o evento de pé.'],
['title' => 'Execução', 'body' => 'Condução e acompanhamento do evento conforme o projeto aprovado.'],
],
]);
$response = $this->get(route('home'));
$response
->assertOk()
->assertSee('Projetos corporativos tratados como experiência, não apenas operação.')
->assertSeeInOrder(['Planejamento', 'Estruturação de escopo, cronograma e prioridades.'])
->assertSeeInOrder(['Produção', 'Coordenação dos elementos necessários para colocar o evento de pé.'])
->assertSeeInOrder(['Execução', 'Condução e acompanhamento do evento conforme o projeto aprovado.'])
->assertSee('Falar sobre um evento corporativo')
->assertSee('Portfólio Corporate')
->assertSee('Conteúdo em construção')
->assertSee('Em vez de usar fotografias genéricas como se fossem cases');
}
public function test_briefing_form_renders_all_fields_and_posts_to_briefing_store(): void
{
SiteSetting::instance();
$response = $this->get(route('home'));
$response
->assertOk()
->assertSee('Conte sobre o evento que você está imaginando.')
->assertSee('method="POST"', false)
->assertSee('action="'.route('briefing.store').'"', false)
->assertSee('name="empresa"', false)
->assertSee('id="briefing-nome"', false)
->assertSee('id="briefing-email"', false)
->assertSee('id="briefing-telefone"', false)
->assertSee('id="briefing-tipo_evento"', false)
->assertSee('id="briefing-data_periodo"', false)
->assertSee('id="briefing-cidade"', false)
->assertSee('id="briefing-convidados"', false)
->assertSee('id="briefing-servico_interesse"', false)
->assertSee('id="briefing-mensagem"', false)
->assertSee('id="briefing-privacidade"', false)
->assertSee('Enviar briefing')
->assertSee('href="'.route('privacy').'"', false);
}
public function test_blank_quote_testimonials_are_skipped_and_padded_with_placeholders(): void
{ {
Testimonial::factory()->published()->create([ Testimonial::factory()->published()->create([
'author_name' => 'Autor Mantido', 'author_name' => 'Autor Mantido',
@@ -182,44 +286,34 @@ class HomePageContentTest extends TestCase
->assertSee('Autor Mantido') ->assertSee('Autor Mantido')
->assertSee('Experiência impecável do início ao fim.') ->assertSee('Experiência impecável do início ao fim.')
->assertDontSee('Autor Oculto') ->assertDontSee('Autor Oculto')
->assertSee('data-reveal-from="left"', false); ->assertSee('Nome e data reais — aguardando autorização');
$this->assertSame(3, preg_match_all('/<figure\b/i', $response->getContent()));
} }
public function test_blank_testimonials_are_filtered_before_directional_sequence(): void public function test_testimonials_render_up_to_three_cards_without_a_carousel(): void
{ {
Testimonial::factory()->published()->create([ foreach ([10, 20, 30, 40] as $index => $sortOrder) {
'author_name' => 'Primeira autora', Testimonial::factory()->published()->create([
'quote' => 'Primeiro relato.', 'author_name' => 'Autora '.($index + 1),
'sort_order' => 10, 'quote' => 'Relato '.($index + 1).'.',
]); 'sort_order' => $sortOrder,
Testimonial::factory()->published()->create([ ]);
'author_name' => 'Relato vazio', }
'quote' => " \n ",
'sort_order' => 20,
]);
Testimonial::factory()->published()->create([
'author_name' => 'Segunda autora',
'quote' => 'Segundo relato.',
'sort_order' => 30,
]);
$response = $this->get(route('home')); $response = $this->get(route('home'));
$response $response
->assertOk() ->assertOk()
->assertDontSee('Relato vazio') ->assertSeeInOrder(['Relato 1.', 'Relato 2.', 'Relato 3.'])
->assertSeeInOrder([ ->assertDontSee('Relato 4.');
'data-reveal-from="left"',
'Primeira autora',
'data-reveal-from="right"',
'Segunda autora',
], false);
$this->assertSame(1, preg_match_all('/<blockquote\b[^>]*data-reveal-from="left"/i', $response->getContent())); $this->assertSame(3, preg_match_all('/<figure\b/i', $response->getContent()));
$this->assertSame(1, preg_match_all('/<blockquote\b[^>]*data-reveal-from="right"/i', $response->getContent())); $this->assertStringNotContainsString('data-carousel', $response->getContent());
$this->assertStringNotContainsString('aria-roledescription="carousel"', $response->getContent());
} }
public function test_testimonials_section_is_omitted_when_every_published_quote_is_blank(): void public function test_testimonials_section_renders_placeholders_when_every_published_quote_is_blank(): void
{ {
Testimonial::factory()->published()->create([ Testimonial::factory()->published()->create([
'author_name' => 'Relato vazio', 'author_name' => 'Relato vazio',
@@ -228,8 +322,8 @@ class HomePageContentTest extends TestCase
$this->get(route('home')) $this->get(route('home'))
->assertOk() ->assertOk()
->assertDontSee('id="testimonials-heading"', false) ->assertSee('id="testimonials-heading"', false)
->assertDontSee('href="#testimonials-heading"', false) ->assertSee('Depoimento real aprovado pela cliente entra aqui.')
->assertDontSee('Relato vazio'); ->assertDontSee('Relato vazio');
} }
@@ -237,7 +331,7 @@ class HomePageContentTest extends TestCase
{ {
SiteSetting::instance(); SiteSetting::instance();
Service::factory()->published()->featured()->create(); WeddingPackage::factory()->published()->create();
PortfolioCase::factory()->published()->create(['is_featured' => true]); PortfolioCase::factory()->published()->create(['is_featured' => true]);
Testimonial::factory()->published()->create(['quote' => 'Relato publicado.']); Testimonial::factory()->published()->create(['quote' => 'Relato publicado.']);

View File

@@ -37,16 +37,16 @@ class ImmersivePhotoHeroTest extends TestCase
->assertOk() ->assertOk()
->assertSee('data-chapter="hero"', false) ->assertSee('data-chapter="hero"', false)
->assertSee('data-motion="page-open"', false) ->assertSee('data-motion="page-open"', false)
->assertSee('lg:h-[calc(100dvh-5rem)]', false) ->assertSee('md:min-h-[520px]', false)
->assertSee('data-hero-content', false) ->assertSee('data-hero-content', false)
->assertSee('data-split-hero', false) ->assertSee('data-split-hero', false)
->assertSee('data-motion-beat="media"', false) ->assertSee('data-motion-beat="media"', false)
->assertSee('lg:grid-cols-2', false) ->assertSee('md:grid-cols-[1.1fr_0.9fr]', false)
->assertSee('max-lg:aspect-[4/5]', false)
->assertSee('content/heroes/home.jpg', false) ->assertSee('content/heroes/home.jpg', false)
->assertSee('loading="eager"', false) ->assertSee('loading="eager"', false)
->assertSee('fetchpriority="high"', false) ->assertSee('fetchpriority="high"', false)
->assertSee('sizes="(max-width: 1023px) 100vw, 50vw"', false) ->assertSee('sizes="(max-width: 1023px) 100vw, 45vw"', false)
->assertDontSee('data-tonal-hero', false)
->assertSee('content="http://localhost/storage/content/og/social.jpg"', false); ->assertSee('content="http://localhost/storage/content/og/social.jpg"', false);
} }
@@ -75,7 +75,9 @@ class ImmersivePhotoHeroTest extends TestCase
->assertSee('data-motion="page-open"', false) ->assertSee('data-motion="page-open"', false)
->assertSee('id="hero-heading"', false) ->assertSee('id="hero-heading"', false)
->assertSee('data-testid="home-primary-cta"', false) ->assertSee('data-testid="home-primary-cta"', false)
->assertDontSee('data-split-hero', false) ->assertSee('data-split-hero', false)
->assertSee('data-tonal-hero', false)
->assertSee('Fotografia hero')
->assertDontSee('content/heroes/home.jpg', false); ->assertDontSee('content/heroes/home.jpg', false);
} }
} }
@@ -103,7 +105,7 @@ class ImmersivePhotoHeroTest extends TestCase
->assertSee('data-photo-hero', false) ->assertSee('data-photo-hero', false)
->assertSee('loading="eager"', false) ->assertSee('loading="eager"', false)
->assertSee('fetchpriority="high"', false) ->assertSee('fetchpriority="high"', false)
->assertSee('sizes="100vw"', false); ->assertSee('sizes="(max-width: 1023px) 100vw, 58vw"', false);
} }
} }

View File

@@ -36,7 +36,7 @@ class MotionMarkupTest extends TestCase
$response $response
->assertOk() ->assertOk()
->assertSee('data-motion="page-open"', false) ->assertSee('data-motion="page-open"', false)
->assertSee('data-motion-beat="seal"', false) ->assertSee('data-motion-beat="eyebrow"', false)
->assertSee('data-motion-beat="title"', false) ->assertSee('data-motion-beat="title"', false)
->assertSee('data-motion-beat="media"', false) ->assertSee('data-motion-beat="media"', false)
->assertSee('data-motion-beat="cta"', false) ->assertSee('data-motion-beat="cta"', false)
@@ -45,12 +45,12 @@ class MotionMarkupTest extends TestCase
->assertDontSee('data-chapter-index', false) ->assertDontSee('data-chapter-index', false)
->assertDontSee('data-chapter-progress', false) ->assertDontSee('data-chapter-progress', false)
->assertSee('data-chapter="hero"', false) ->assertSee('data-chapter="hero"', false)
->assertSee('data-chapter="manifesto"', false) ->assertSee('data-chapter="sobre"', false)
->assertSee('data-reveal', false) ->assertSee('data-reveal', false)
->assertDontSee('data-home-folio', false); ->assertDontSee('data-home-folio', false);
} }
public function test_home_has_no_chapter_navigation_when_cms_sections_are_empty(): void public function test_home_has_no_chapter_navigation_and_keeps_all_sections_when_cms_sections_are_empty(): void
{ {
SiteSetting::instance(); SiteSetting::instance();
@@ -60,9 +60,16 @@ class MotionMarkupTest extends TestCase
->assertOk() ->assertOk()
->assertDontSee('data-chapter-index', false) ->assertDontSee('data-chapter-index', false)
->assertDontSee('data-chapter-progress', false) ->assertDontSee('data-chapter-progress', false)
->assertDontSee('id="services-heading"', false) ->assertSeeInOrder([
->assertDontSee('id="portfolio-heading"', false) 'id="hero-heading"',
->assertDontSee('id="testimonials-heading"', false); 'id="manifesto-heading"',
'id="vertentes-heading"',
'id="packages-heading"',
'id="corporate-heading"',
'id="portfolio-heading"',
'id="testimonials-heading"',
'id="briefing-heading"',
], false);
} }
public function test_motion_tokens_and_runtime_exist_with_reduced_motion_guards(): void public function test_motion_tokens_and_runtime_exist_with_reduced_motion_guards(): void

View File

@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\PublicSite;
use App\Mail\PartnerInquiry;
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 PartnerInquiryTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->withoutMiddleware([ThrottleRequests::class, PreventRequestForgery::class]);
}
/** @return array<string, string> */
private function validPayload(array $overrides = []): array
{
return array_merge([
'nome' => 'Ana Costa',
'empresa' => 'Flores da Ana',
'email' => 'ana@example.com',
'atuacao' => 'Florista',
'area_atendimento' => 'São Paulo capital',
'mensagem' => 'Gostaria de apresentar nosso portfólio para futuras parcerias.',
'portfolio_redes' => 'https://instagram.com/floresdaana',
'telefone' => '(11) 98888-7777',
'website' => '',
], $overrides);
}
public function test_contact_page_is_a_partner_journey_not_the_event_briefing(): void
{
SiteSetting::instance();
$this->get(route('contact'))
->assertOk()
->assertSee('Fornecedores e parcerias')
->assertSee('name="atuacao"', false)
->assertSee('name="area_atendimento"', false)
->assertSee('name="website"', false)
->assertDontSee('name="tipo_evento"', false)
->assertSee(route('privacy'), false);
}
public function test_valid_partner_inquiry_sends_only_the_dedicated_internal_email(): void
{
$settings = SiteSetting::instance();
Mail::fake();
$this->post(route('contact.store'), $this->validPayload())
->assertRedirect(route('contact'))
->assertSessionHas('status', 'partner-inquiry-sent');
Mail::assertQueued(PartnerInquiry::class, fn (PartnerInquiry $mail): bool => $mail->hasTo($settings->email));
Mail::assertQueued(PartnerInquiry::class, 1);
}
public function test_partner_honeypot_reports_success_without_sending_mail(): void
{
SiteSetting::instance();
Mail::fake();
$this->post(route('contact.store'), $this->validPayload(['website' => 'https://spam.invalid']))
->assertRedirect(route('contact'))
->assertSessionHas('status', 'partner-inquiry-sent');
Mail::assertNothingSent();
}
}

View File

@@ -180,15 +180,21 @@ class PublicPagesTest extends TestCase
$this->get(route('contact')) $this->get(route('contact'))
->assertOk() ->assertOk()
->assertSee('amareassessoriaeventos@gmail.com') ->assertSee('amareassessoriaeventos@gmail.com')
->assertSee('(11) 90000-0000')
->assertSee('São Paulo - SP') ->assertSee('São Paulo - SP')
->assertDontSee('Fortaleza') ->assertDontSee('Fortaleza')
->assertSee('https://instagram.com/amare', false) ->assertSee('https://instagram.com/amare', false)
->assertSee('min-h-[calc(100dvh-14rem)]', false) ->assertSee('Fornecedores e parcerias')
->assertSee('<form', false) ->assertSee('<form', false)
->assertSee('name="nome"', false) ->assertSee('name="nome"', false)
->assertSee('name="privacidade"', false) ->assertSee('name="atuacao"', false)
->assertDontSee('name="tipo_evento"', false)
->assertSee(route('privacy'), false); ->assertSee(route('privacy'), false);
$this->get(route('briefing'))
->assertOk()
->assertSee('min-h-[calc(100dvh-14rem)]', false)
->assertSee('name="tipo_evento"', false)
->assertSee('name="privacidade"', 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