Compare commits
7 Commits
feat/man-1
...
feat/home-
| Author | SHA1 | Date | |
|---|---|---|---|
| 8009f1f8f9 | |||
| 8311f89850 | |||
| 887df37fae | |||
| 4859fc5a8b | |||
| dfa798552f | |||
| 2573a4aa73 | |||
| d0e508b237 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -26,3 +26,6 @@ Homestead.json
|
||||
Homestead.yaml
|
||||
Thumbs.db
|
||||
.worktrees/
|
||||
|
||||
# Pest Browser visual diffs (local/CI artifacts)
|
||||
/tests/Browser/Screenshots/
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Models\PortfolioCase;
|
||||
use App\Models\Service;
|
||||
use App\Models\SiteSetting;
|
||||
use App\Models\Testimonial;
|
||||
use App\Models\WeddingPackage;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
|
||||
final readonly class HomeContent
|
||||
@@ -16,11 +17,13 @@ final readonly class HomeContent
|
||||
* @param Collection<int, Service> $featuredServices
|
||||
* @param Collection<int, PortfolioCase> $featuredCases
|
||||
* @param Collection<int, Testimonial> $testimonials
|
||||
* @param Collection<int, WeddingPackage> $packages
|
||||
*/
|
||||
public function __construct(
|
||||
public SiteSetting $settings,
|
||||
public Collection $featuredServices,
|
||||
public Collection $featuredCases,
|
||||
public Collection $testimonials,
|
||||
public Collection $packages,
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Models\PortfolioCase;
|
||||
use App\Models\Service;
|
||||
use App\Models\SiteSetting;
|
||||
use App\Models\Testimonial;
|
||||
use App\Models\WeddingPackage;
|
||||
|
||||
final class GetHomeContent
|
||||
{
|
||||
@@ -31,6 +32,10 @@ final class GetHomeContent
|
||||
->published()
|
||||
->orderBy('sort_order')
|
||||
->get(),
|
||||
packages: WeddingPackage::query()
|
||||
->published()
|
||||
->orderBy('sort_order')
|
||||
->get(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,6 +212,26 @@ class ManageSiteSettings extends Page
|
||||
->reorderable()
|
||||
->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')
|
||||
->schema([
|
||||
TagsInput::make('principles')
|
||||
@@ -231,6 +251,11 @@ class ManageSiteSettings extends Page
|
||||
->label('Telefone')
|
||||
->required()
|
||||
->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')
|
||||
->label('Cidade')
|
||||
->maxLength(255),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -135,6 +135,6 @@ final class ContactController extends Controller
|
||||
|
||||
private function success(): RedirectResponse
|
||||
{
|
||||
return redirect()->route('contact')->with('status', 'briefing-sent');
|
||||
return redirect()->route('briefing')->with('status', 'briefing-sent');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ final class PageController extends Controller
|
||||
{
|
||||
$settings = SiteSetting::instance();
|
||||
|
||||
return view('pages.contact', [
|
||||
return view('pages.partners', [
|
||||
'siteSettings' => $settings,
|
||||
'pageMeta' => PageMeta::forPage(
|
||||
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.'.'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
66
app/Http/Controllers/PublicSite/PartnerInquiryController.php
Normal file
66
app/Http/Controllers/PublicSite/PartnerInquiryController.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ use App\Application\Data\PageMeta;
|
||||
use App\Application\Queries\Marketing\GetPublishedServices;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\SiteSetting;
|
||||
use App\Models\WeddingPackage;
|
||||
use Illuminate\Contracts\View\View;
|
||||
|
||||
final class ServiceController extends Controller
|
||||
@@ -19,6 +20,7 @@ final class ServiceController extends Controller
|
||||
|
||||
return view('pages.services.index', [
|
||||
'services' => $services,
|
||||
'weddingPackages' => WeddingPackage::query()->published()->orderBy('sort_order')->get(),
|
||||
'siteSettings' => $settings,
|
||||
'pageMeta' => PageMeta::forPage(
|
||||
canonical: route('services.index'),
|
||||
|
||||
32
app/Http/Requests/PublicSite/PartnerInquiryRequest.php
Normal file
32
app/Http/Requests/PublicSite/PartnerInquiryRequest.php
Normal 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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
31
app/Mail/PartnerInquiry.php
Normal file
31
app/Mail/PartnerInquiry.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ use Illuminate\Database\Eloquent\Model;
|
||||
/**
|
||||
* @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 $corporate_steps
|
||||
* @property list<string>|null $principles
|
||||
* @property bool $analytics_enabled
|
||||
* @property string|null $default_og_image_path
|
||||
@@ -51,9 +52,11 @@ use Illuminate\Database\Eloquent\Model;
|
||||
'manifesto_body',
|
||||
'method_intro',
|
||||
'method_steps',
|
||||
'corporate_steps',
|
||||
'principles',
|
||||
'email',
|
||||
'phone',
|
||||
'whatsapp_number',
|
||||
'city',
|
||||
'social_links',
|
||||
'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.',
|
||||
'method_intro' => 'Clareza em cada etapa. Tranquilidade durante todo o processo.',
|
||||
'method_steps' => self::defaultMethodSteps(),
|
||||
'corporate_steps' => self::defaultCorporateSteps(),
|
||||
'principles' => self::defaultPrinciples(),
|
||||
'email' => 'amareassessoriaeventos@gmail.com',
|
||||
'phone' => '(11) 99999-9999',
|
||||
'whatsapp_number' => null,
|
||||
'city' => 'São Paulo - SP',
|
||||
'social_links' => [],
|
||||
'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>
|
||||
*/
|
||||
@@ -139,6 +165,7 @@ class SiteSetting extends Model
|
||||
return [
|
||||
'social_links' => 'array',
|
||||
'method_steps' => 'array',
|
||||
'corporate_steps' => 'array',
|
||||
'principles' => 'array',
|
||||
'analytics_enabled' => 'boolean',
|
||||
];
|
||||
|
||||
39
app/Models/WeddingPackage.php
Normal file
39
app/Models/WeddingPackage.php
Normal 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'];
|
||||
}
|
||||
}
|
||||
36
app/Policies/WeddingPackagePolicy.php
Normal file
36
app/Policies/WeddingPackagePolicy.php
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,10 @@ class AppServiceProvider extends ServiceProvider
|
||||
RateLimiter::for('contact-briefing', function (Request $request): Limit {
|
||||
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
|
||||
|
||||
33
database/factories/WeddingPackageFactory.php
Normal file
33
database/factories/WeddingPackageFactory.php
Normal 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()]);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -49,6 +49,7 @@ class ContentSeeder extends Seeder
|
||||
$this->seedServices();
|
||||
$this->seedPortfolioCases();
|
||||
$this->call(TestimonialsSeeder::class);
|
||||
$this->call(WeddingPackagesSeeder::class);
|
||||
}
|
||||
|
||||
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.',
|
||||
'method_intro' => 'Clareza em cada etapa. Tranquilidade durante todo o processo.',
|
||||
'method_steps' => SiteSetting::defaultMethodSteps(),
|
||||
'corporate_steps' => SiteSetting::defaultCorporateSteps(),
|
||||
'principles' => SiteSetting::defaultPrinciples(),
|
||||
'email' => 'amareassessoriaeventos@gmail.com',
|
||||
'phone' => '(11) 99999-9999',
|
||||
|
||||
@@ -30,6 +30,7 @@ class VisualContentSeeder extends Seeder
|
||||
$this->seedServices();
|
||||
$this->seedPortfolioCases();
|
||||
$this->seedTestimonials();
|
||||
$this->call(WeddingPackagesSeeder::class);
|
||||
}
|
||||
|
||||
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.',
|
||||
'method_intro' => 'Clareza em cada etapa. Tranquilidade durante todo o processo.',
|
||||
'method_steps' => SiteSetting::defaultMethodSteps(),
|
||||
'corporate_steps' => SiteSetting::defaultCorporateSteps(),
|
||||
'principles' => SiteSetting::defaultPrinciples(),
|
||||
'email' => 'amareassessoriaeventos@gmail.com',
|
||||
'phone' => '(11) 99999-9999',
|
||||
@@ -198,16 +200,22 @@ class VisualContentSeeder extends Seeder
|
||||
*/
|
||||
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);
|
||||
$color = imagecolorallocate($image, $rgb[0], $rgb[1], $rgb[2]);
|
||||
imagefilledrectangle($image, 0, 0, $width, $height, $color);
|
||||
|
||||
ob_start();
|
||||
imagejpeg($image, null, 90);
|
||||
imagejpeg($image, null, 80);
|
||||
$binary = (string) ob_get_clean();
|
||||
imagedestroy($image);
|
||||
|
||||
Storage::disk('public')->put($destination, $binary);
|
||||
$disk->put($destination, $binary);
|
||||
|
||||
return $destination;
|
||||
}
|
||||
|
||||
71
database/seeders/WeddingPackagesSeeder.php
Normal file
71
database/seeders/WeddingPackagesSeeder.php
Normal 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)],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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-*`.
|
||||
@@ -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.
|
||||
18
openspec/changes/2026-08-11-home-editorial-redesign/tasks.md
Normal file
18
openspec/changes/2026-08-11-home-editorial-redesign/tasks.md
Normal 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.").
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-08-11
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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`
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -24,6 +24,7 @@
|
||||
</include>
|
||||
</source>
|
||||
<php>
|
||||
<env name="APP_ENV" value="testing"/>
|
||||
<env name="APP_KEY" value="base64:NXm/6jIyFcDGHoMKGc5QZuSaq0dRZFYPg1Isuy1fNvE="/>
|
||||
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
|
||||
<env name="BCRYPT_ROUNDS" value="4"/>
|
||||
|
||||
250
resources/views/components/home/briefing.blade.php
Normal file
250
resources/views/components/home/briefing.blade.php
Normal 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 já 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>
|
||||
45
resources/views/components/home/corporate.blade.php
Normal file
45
resources/views/components/home/corporate.blade.php
Normal 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>
|
||||
@@ -17,7 +17,7 @@
|
||||
</p>
|
||||
<div>
|
||||
<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"
|
||||
>
|
||||
{{ $settings->hero_cta_label ?: 'Solicitar proposta' }}
|
||||
|
||||
@@ -2,33 +2,48 @@
|
||||
'settings',
|
||||
])
|
||||
|
||||
<x-public.photo-hero
|
||||
:image-path="$settings->hero_image_path"
|
||||
:image-alt="$settings->hero_image_alt"
|
||||
:eyebrow="$settings->hero_eyebrow"
|
||||
:title="$settings->hero_title ?: 'Celebrações com propósito'"
|
||||
:summary="$settings->hero_subtitle"
|
||||
heading-id="hero-heading"
|
||||
brand-mark
|
||||
full-height-fallback
|
||||
class="home-chapter"
|
||||
<section
|
||||
aria-labelledby="hero-heading"
|
||||
class="home-chapter border-b border-amare-border bg-amare-bg"
|
||||
data-chapter="hero"
|
||||
data-motion="page-open"
|
||||
>
|
||||
<div class="space-y-5">
|
||||
<div 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>
|
||||
<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 data-hero-content data-reveal-group class="flex flex-col gap-8">
|
||||
<div data-motion-beat="eyebrow">
|
||||
<p class="text-xs font-bold uppercase tracking-[0.16em] text-amare-accent-deep">Assessoria & produção de eventos • São Paulo</p>
|
||||
</div>
|
||||
|
||||
@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>
|
||||
<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">
|
||||
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>
|
||||
@endif
|
||||
<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>
|
||||
|
||||
@if (filled($settings->hero_note))
|
||||
<p class="max-w-[470px] text-sm text-amare-text-muted">{{ $settings->hero_note }}</p>
|
||||
@endif
|
||||
<div
|
||||
data-split-hero
|
||||
data-motion-beat="media"
|
||||
class="min-h-[320px] overflow-hidden bg-amare-bg-deep md:min-h-[520px]"
|
||||
>
|
||||
@if (filled($settings->hero_image_path))
|
||||
<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>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</x-public.photo-hero>
|
||||
</section>
|
||||
|
||||
@@ -1,22 +1,24 @@
|
||||
@props([
|
||||
'settings',
|
||||
])
|
||||
@props([])
|
||||
|
||||
@php
|
||||
$title = $settings->manifesto_title ?: 'Sofisticação que também se traduz em organização.';
|
||||
$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.';
|
||||
$body = $settings->manifesto_body ?: 'A Amare combina sensibilidade e precisão para criar encontros coerentes com cada cliente, marca e ocasião — sem fórmulas prontas, excessos ou ruído.';
|
||||
@endphp
|
||||
|
||||
<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 md:grid-cols-12" data-reveal-group>
|
||||
<div class="space-y-5 md:col-span-4 md:pt-20" data-reveal data-reveal-from="left">
|
||||
<p class="max-w-52 text-lg leading-snug text-amare-text">Humana no cuidado. Precisa na entrega.</p>
|
||||
<section
|
||||
aria-labelledby="manifesto-heading"
|
||||
class="home-chapter border-b border-amare-border bg-amare-bg"
|
||||
data-chapter="sobre"
|
||||
id="sobre"
|
||||
>
|
||||
<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 data-reveal data-reveal-from="up">
|
||||
<p class="text-xs font-bold uppercase tracking-[0.16em] text-amare-accent-deep">01 — A Amare</p>
|
||||
</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>
|
||||
<p class="max-w-2xl text-xl leading-relaxed text-amare-text">{{ $lead }}</p>
|
||||
<p class="max-w-2xl text-amare-text-muted">{{ $body }}</p>
|
||||
|
||||
<div class="flex flex-col gap-8" data-reveal data-reveal-from="up">
|
||||
<h2 id="manifesto-heading" class="text-[clamp(1.875rem,4vw,3.25rem)] font-medium leading-[1.15] tracking-[-0.02em] text-amare-text">
|
||||
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>
|
||||
</section>
|
||||
|
||||
69
resources/views/components/home/packages.blade.php
Normal file
69
resources/views/components/home/packages.blade.php
Normal 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">•</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>
|
||||
@@ -2,65 +2,55 @@
|
||||
'cases',
|
||||
])
|
||||
|
||||
@if ($cases->isNotEmpty())
|
||||
<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">
|
||||
<div class="container-amare space-y-10" data-reveal-group>
|
||||
<div class="grid gap-4 md:grid-cols-12" data-reveal data-reveal-from="up">
|
||||
<div class="md:col-span-7 md:col-start-5 space-y-3">
|
||||
<h2 id="portfolio-heading" class="text-headline font-medium">Celebrações que ganham forma com intenção.</h2>
|
||||
<p class="max-w-2xl text-amare-accent-text/80">Recortes de eventos conduzidos com escuta, direção e presença em cada etapa.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<section
|
||||
aria-labelledby="portfolio-heading"
|
||||
class="home-chapter border-b border-amare-border bg-amare-bg"
|
||||
data-chapter="portfolio"
|
||||
id="portfolio"
|
||||
>
|
||||
<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">
|
||||
<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>
|
||||
</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>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
])
|
||||
|
||||
@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="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">
|
||||
|
||||
@@ -3,46 +3,52 @@
|
||||
])
|
||||
|
||||
@php
|
||||
$testimonials = $testimonials
|
||||
->filter(fn ($testimonial): bool => filled(trim((string) $testimonial->quote)))
|
||||
$quotes = collect($testimonials)
|
||||
->filter(fn ($testimonial) => filled($testimonial->quote) && filled(trim((string) $testimonial->quote)))
|
||||
->take(3)
|
||||
->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
|
||||
|
||||
@if ($testimonials->isNotEmpty())
|
||||
<section aria-labelledby="testimonials-heading" class="home-chapter border-b border-amare-border bg-amare-bg py-24" data-chapter="testimonials">
|
||||
<div class="container-amare space-y-10" data-reveal-group>
|
||||
<div class="max-w-2xl space-y-3 md:ml-[16.666667%]" data-reveal data-reveal-from="up">
|
||||
<h2 id="testimonials-heading" class="text-3xl font-medium text-amare-text">Depoimentos</h2>
|
||||
<p class="text-amare-text-muted">Quem celebrou com a Amare conta como foi a experiência.</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 md:grid-cols-12">
|
||||
@foreach ($testimonials as $testimonial)
|
||||
@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>
|
||||
<section
|
||||
aria-labelledby="testimonials-heading"
|
||||
class="home-chapter border-b border-amare-border bg-amare-bg"
|
||||
data-chapter="depoimentos"
|
||||
id="depoimentos"
|
||||
>
|
||||
<div class="container-amare py-16 md:py-24" data-reveal-group>
|
||||
<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">06 — Depoimentos</p>
|
||||
<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>
|
||||
</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>
|
||||
|
||||
41
resources/views/components/home/vertentes.blade.php
Normal file
41
resources/views/components/home/vertentes.blade.php
Normal 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>
|
||||
18
resources/views/components/home/wedding-packages.blade.php
Normal file
18
resources/views/components/home/wedding-packages.blade.php
Normal 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>
|
||||
5
resources/views/emails/partner-inquiry-text.blade.php
Normal file
5
resources/views/emails/partner-inquiry-text.blade.php
Normal file
@@ -0,0 +1,5 @@
|
||||
Nova consulta de fornecedor ou parceria
|
||||
|
||||
@foreach ($fields as $label => $value)
|
||||
{{ $label }}: {{ $value ?: '—' }}
|
||||
@endforeach
|
||||
10
resources/views/emails/partner-inquiry.blade.php
Normal file
10
resources/views/emails/partner-inquiry.blade.php
Normal 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>
|
||||
@@ -55,23 +55,23 @@
|
||||
Ir para o conteúdo
|
||||
</a>
|
||||
|
||||
<header class="site-header sticky top-0 z-40 border-b border-amare-border/80 bg-amare-bg/90 backdrop-blur-sm">
|
||||
<div class="container-amare grid grid-cols-[auto_1fr_auto] items-center gap-4 py-4 md:grid-cols-[1fr_auto_1fr]">
|
||||
<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] 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>
|
||||
<a href="{{ route('home') }}" class="inline-flex items-center py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-muted transition-colors hover:text-amare-accent md:px-2">Início</a>
|
||||
<a href="{{ route('services.index') }}" class="inline-flex items-center py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-muted transition-colors hover:text-amare-accent md:px-2">Serviços</a>
|
||||
<a href="{{ route('portfolio.index') }}" class="inline-flex items-center py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-muted transition-colors hover:text-amare-accent md:px-2">Portfólio</a>
|
||||
<a href="{{ route('about') }}" class="inline-flex items-center py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-muted transition-colors hover:text-amare-accent md:px-2">Amare</a>
|
||||
<a href="{{ route('contact') }}" class="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('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-[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-[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-[13px] font-semibold uppercase tracking-[0.08em] text-amare-muted transition-colors hover:text-amare-accent md:px-3">Amare</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>
|
||||
|
||||
<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>
|
||||
|
||||
<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">
|
||||
Solicitar proposta
|
||||
<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]">
|
||||
Conte seu evento
|
||||
</a>
|
||||
|
||||
<button
|
||||
@@ -97,50 +97,34 @@
|
||||
@yield('content')
|
||||
</main>
|
||||
|
||||
<footer class="border-t border-amare-border bg-amare-bg-deep">
|
||||
<div class="container-amare grid gap-10 py-12 md:grid-cols-[minmax(0,1.4fr)_repeat(2,minmax(0,1fr))]">
|
||||
<div class="space-y-4">
|
||||
<x-brand.logo variant="on-light" class="h-12 w-auto" />
|
||||
<p class="max-w-md text-amare-muted">
|
||||
{{ $siteSettings->about_summary ?: 'Assessoria boutique em São Paulo - SP.' }}
|
||||
</p>
|
||||
<footer class="border-t border-amare-border bg-amare-bg">
|
||||
@php
|
||||
$footerSocialLabel = static function (string $network): string {
|
||||
return match ($network) {
|
||||
'instagram' => 'Instagram',
|
||||
'whatsapp' => 'WhatsApp',
|
||||
'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 & produção de eventos • São Paulo</p>
|
||||
</div>
|
||||
|
||||
<div class="text-sm">
|
||||
<h2 class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-text">Navegação</h2>
|
||||
<ul class="mt-3 space-y-3">
|
||||
<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>
|
||||
<li><a href="{{ route('portfolio.index') }}" class="inline-flex min-h-11 items-center text-amare-muted transition-colors hover:text-amare-accent">Portfólio</a></li>
|
||||
<li><a href="{{ route('about') }}" class="inline-flex min-h-11 items-center text-amare-muted transition-colors hover:text-amare-accent">A Amare</a></li>
|
||||
<li><a href="{{ route('contact') }}" class="inline-flex min-h-11 items-center text-amare-muted transition-colors hover:text-amare-accent">Contato</a></li>
|
||||
</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
|
||||
<p class="text-sm text-amare-muted md:text-right">
|
||||
@if ($footerSocials->isEmpty())
|
||||
Instagram · WhatsApp · E-mail · LinkedIn (quando confirmado)
|
||||
@else
|
||||
@foreach ($footerSocials as $network => $url)
|
||||
<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) · @endunless
|
||||
@endforeach
|
||||
</address>
|
||||
</div>
|
||||
@endif
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-amare-border">
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
@else
|
||||
<form
|
||||
method="POST"
|
||||
action="{{ route('contact.store') }}"
|
||||
action="{{ route('briefing.store') }}"
|
||||
class="mt-10 max-w-3xl space-y-10"
|
||||
data-contact-form
|
||||
>
|
||||
@@ -135,8 +135,8 @@
|
||||
@error('tipo_evento') aria-invalid="true" aria-describedby="tipo_evento-error" @enderror
|
||||
>
|
||||
<option value="" selected disabled>Selecione...</option>
|
||||
<option value="Casamento" @selected(old('tipo_evento') === 'Casamento')>Casamento</option>
|
||||
<option value="Evento corporativo" @selected(old('tipo_evento') === 'Evento corporativo')>Evento corporativo</option>
|
||||
<option value="Casamento" @selected(old('tipo_evento', request('tipo_evento')) === 'Casamento')>Casamento</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="Outro" @selected(old('tipo_evento') === 'Outro')>Outro tipo de evento</option>
|
||||
</select>
|
||||
@@ -206,7 +206,7 @@
|
||||
type="text"
|
||||
id="servico_interesse"
|
||||
name="servico_interesse"
|
||||
value="{{ old('servico_interesse') }}"
|
||||
value="{{ old('servico_interesse', request('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"
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
@section('content')
|
||||
<div class="home-chapters">
|
||||
<x-home.hero :settings="$content->settings" />
|
||||
<x-home.manifesto :settings="$content->settings" />
|
||||
<x-home.services :services="$content->featuredServices" />
|
||||
<x-home.manifesto />
|
||||
<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.method :settings="$content->settings" />
|
||||
<x-home.testimonials :testimonials="$content->testimonials" />
|
||||
<x-home.positioning :settings="$content->settings" />
|
||||
<x-home.final-cta :settings="$content->settings" editorial />
|
||||
<x-home.briefing />
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
41
resources/views/pages/partners.blade.php
Normal file
41
resources/views/pages/partners.blade.php
Normal 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
|
||||
@@ -47,5 +47,9 @@
|
||||
</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" />
|
||||
@endsection
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
use App\Http\Controllers\PublicSite\ContactController;
|
||||
use App\Http\Controllers\PublicSite\HomeController;
|
||||
use App\Http\Controllers\PublicSite\PageController;
|
||||
use App\Http\Controllers\PublicSite\PartnerInquiryController;
|
||||
use App\Http\Controllers\PublicSite\PortfolioController;
|
||||
use App\Http\Controllers\PublicSite\RobotsController;
|
||||
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('/privacidade', [PageController::class, 'privacy'])->name('privacy');
|
||||
Route::get('/contato', [PageController::class, 'contact'])->name('contact');
|
||||
Route::post('/contato', [ContactController::class, 'store'])
|
||||
->middleware('throttle:contact-briefing')
|
||||
Route::post('/contato', [PartnerInquiryController::class, 'store'])
|
||||
->middleware('throttle:partner-inquiry')
|
||||
->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('/robots.txt', RobotsController::class)->name('robots');
|
||||
|
||||
@@ -10,28 +10,36 @@ uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
SiteSetting::instance();
|
||||
});
|
||||
|
||||
it('has no critical or serious accessibility issues on covered public routes', function (): void {
|
||||
$case = PortfolioCase::factory()->published()->create([
|
||||
$this->portfolioCase = PortfolioCase::factory()->published()->create([
|
||||
'slug' => 'casamento-jardim',
|
||||
'title' => 'Casamento Jardim',
|
||||
]);
|
||||
});
|
||||
|
||||
it('has no critical accessibility or console issues on covered public routes', function (): void {
|
||||
$routes = [
|
||||
'/',
|
||||
'/servicos',
|
||||
'/portfolio',
|
||||
'/portfolio/'.$case->slug,
|
||||
'/portfolio/'.$this->portfolioCase->slug,
|
||||
'/sobre',
|
||||
'/contato',
|
||||
'/briefing',
|
||||
'/privacidade',
|
||||
'/__missing-accessibility-page',
|
||||
];
|
||||
|
||||
foreach ($routes as $route) {
|
||||
$this->visit($route)
|
||||
->assertNoAccessibilityIssues(1);
|
||||
$page = $this->visit($routes[0]);
|
||||
|
||||
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');
|
||||
expect($outlineStyle)->not->toBe('none');
|
||||
|
||||
$page->keys('[data-testid="home-primary-cta"]', 'Enter')
|
||||
->assertPathIs('/contato');
|
||||
});
|
||||
$page->keys('[data-testid="home-primary-cta"]', 'Enter');
|
||||
|
||||
it('loads covered public routes without console errors', function (): void {
|
||||
$case = PortfolioCase::factory()->published()->create([
|
||||
'slug' => 'casamento-jardim',
|
||||
]);
|
||||
|
||||
foreach ([
|
||||
'/',
|
||||
'/servicos',
|
||||
'/portfolio',
|
||||
'/portfolio/'.$case->slug,
|
||||
'/sobre',
|
||||
'/contato',
|
||||
'/privacidade',
|
||||
'/__missing-console-page',
|
||||
] as $route) {
|
||||
$this->visit($route)
|
||||
->assertNoJavaScriptErrors();
|
||||
}
|
||||
$hash = $page->script('() => window.location.hash');
|
||||
expect($hash)->toBe('#sobre');
|
||||
});
|
||||
|
||||
it('disables transitions when prefers-reduced-motion is reduce', function (): void {
|
||||
$case = PortfolioCase::factory()->published()->create([
|
||||
'slug' => 'casamento-jardim',
|
||||
]);
|
||||
|
||||
$routes = [
|
||||
'/',
|
||||
'/servicos',
|
||||
'/portfolio',
|
||||
'/portfolio/'.$case->slug,
|
||||
'/portfolio/'.$this->portfolioCase->slug,
|
||||
'/sobre',
|
||||
'/contato',
|
||||
'/briefing',
|
||||
'/privacidade',
|
||||
];
|
||||
|
||||
foreach ($routes as $route) {
|
||||
$page = $this->visit($route, [
|
||||
'reducedMotion' => 'reduce',
|
||||
]);
|
||||
$page = $this->visit($routes[0], [
|
||||
'reducedMotion' => 'reduce',
|
||||
]);
|
||||
|
||||
foreach ($routes as $index => $route) {
|
||||
if ($index > 0) {
|
||||
$page->navigate($route);
|
||||
}
|
||||
|
||||
$duration = $page->script(<<<'JS'
|
||||
() => {
|
||||
|
||||
@@ -11,13 +11,13 @@ beforeEach(function (): void {
|
||||
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([
|
||||
'email' => str_repeat('a', 50).'@email.'.str_repeat('b', 40),
|
||||
'phone' => '(11) 99999-9999',
|
||||
]);
|
||||
|
||||
$page = $this->visit('/contato');
|
||||
$page = $this->visit('/briefing');
|
||||
|
||||
$overflow = $page->script(
|
||||
'() => 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 {
|
||||
$page = $this->visit('/contato');
|
||||
$page = $this->visit('/briefing');
|
||||
|
||||
$state = $page->script(<<<'JS'
|
||||
() => {
|
||||
|
||||
@@ -12,22 +12,22 @@ beforeEach(function (): void {
|
||||
Artisan::call('db:seed', ['--class' => VisualContentSeeder::class, '--force' => true]);
|
||||
});
|
||||
|
||||
it('uses a five-seven desktop spread with a bounded reading column and full-bleed media', function (): void {
|
||||
it('keeps the hero asymmetric on desktop and stacked through md and mobile', function (): void {
|
||||
$page = $this->visit('/', [
|
||||
'reducedMotion' => 'reduce',
|
||||
])->resize(1440, 1000);
|
||||
]);
|
||||
|
||||
$page->script('() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))');
|
||||
$page->script('() => document.fonts.ready');
|
||||
$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 hero = document.querySelector('[data-chapter="hero"]');
|
||||
const content = hero?.querySelector('[data-hero-content]');
|
||||
const media = hero?.querySelector('[data-split-hero]');
|
||||
const heading = hero?.querySelector('h1');
|
||||
const image = media?.querySelector('img');
|
||||
const headerRect = header?.getBoundingClientRect();
|
||||
const heroRect = hero?.getBoundingClientRect();
|
||||
const contentRect = content?.getBoundingClientRect();
|
||||
@@ -36,51 +36,27 @@ it('uses a five-seven desktop spread with a bounded reading column and full-blee
|
||||
return {
|
||||
hasContentColumn: Boolean(content),
|
||||
startsBelowHeader: Math.abs((heroRect?.top ?? -1) - (headerRect?.bottom ?? -2)) <= 1,
|
||||
fillsRemainingViewport: (heroRect?.bottom ?? 0) >= window.innerHeight - 1,
|
||||
mediaTouchesHeroTop: Math.abs((mediaRect?.top ?? -1) - (heroRect?.top ?? -2)) <= 1,
|
||||
mediaTouchesHeroBottom: Math.abs((mediaRect?.bottom ?? -1) - (heroRect?.bottom ?? -2)) <= 1,
|
||||
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)),
|
||||
readingColumnWithinLimit: (heading?.getBoundingClientRect().width ?? Number.POSITIVE_INFINITY) <= 470,
|
||||
titleClipsHorizontally: (() => {
|
||||
if (!heading) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(heading);
|
||||
|
||||
return Array.from(range.getClientRects()).some((line) => line.right > heading.getBoundingClientRect().right + 1);
|
||||
})(),
|
||||
mediaBesideContent: (mediaRect?.left ?? 0) >= (contentRect?.right ?? Number.POSITIVE_INFINITY) - 1,
|
||||
mediaMinHeight: Math.round(mediaRect?.height ?? 0) >= 519,
|
||||
mediaInsideContainer: (mediaRect?.right ?? 0) < window.innerWidth - 100,
|
||||
horizontalOverflow: document.documentElement.scrollWidth > window.innerWidth,
|
||||
};
|
||||
}
|
||||
JS);
|
||||
|
||||
expect($layout)->toBe([
|
||||
expect($desktop)->toBe([
|
||||
'hasContentColumn' => true,
|
||||
'startsBelowHeader' => true,
|
||||
'fillsRemainingViewport' => true,
|
||||
'mediaTouchesHeroTop' => true,
|
||||
'mediaTouchesHeroBottom' => true,
|
||||
'mediaTouchesViewportRight' => true,
|
||||
'splitImageObjectFit' => 'cover',
|
||||
'columnRatio' => 0.714,
|
||||
'readingColumnWithinLimit' => true,
|
||||
'titleClipsHorizontally' => false,
|
||||
'mediaBesideContent' => true,
|
||||
'mediaMinHeight' => true,
|
||||
'mediaInsideContainer' => true,
|
||||
'horizontalOverflow' => false,
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps the home hero linear through tablet with a full-width four-by-five crop', function (): void {
|
||||
$page = $this->visit('/', [
|
||||
'reducedMotion' => 'reduce',
|
||||
])->resize(1023, 1000);
|
||||
$page->resize(767, 1000);
|
||||
$page->script($waitFrames);
|
||||
|
||||
$page->script('() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))');
|
||||
|
||||
$layout = $page->script(<<<'JS'
|
||||
$md = $page->script(<<<'JS'
|
||||
() => {
|
||||
const hero = document.querySelector('[data-chapter="hero"]');
|
||||
const content = hero?.querySelector('[data-hero-content]');
|
||||
@@ -91,30 +67,25 @@ it('keeps the home hero linear through tablet with a full-width four-by-five cro
|
||||
return {
|
||||
hasContentColumn: Boolean(content),
|
||||
mediaFollowsContent: (mediaRect?.top ?? 0) >= (contentRect?.bottom ?? Number.POSITIVE_INFINITY) - 1,
|
||||
mediaFillsViewportWidth: Math.abs((mediaRect?.width ?? 0) - window.innerWidth) <= 1,
|
||||
mediaAspectRatio: Number(((mediaRect?.width ?? 0) / (mediaRect?.height ?? 1)).toFixed(3)),
|
||||
mediaFillsContainerWidth: (mediaRect?.width ?? 0) > window.innerWidth - 100,
|
||||
mediaMinHeight: Math.round(mediaRect?.height ?? 0) >= 319,
|
||||
horizontalOverflow: document.documentElement.scrollWidth > window.innerWidth,
|
||||
};
|
||||
}
|
||||
JS);
|
||||
|
||||
expect($layout)->toBe([
|
||||
expect($md)->toBe([
|
||||
'hasContentColumn' => true,
|
||||
'mediaFollowsContent' => true,
|
||||
'mediaFillsViewportWidth' => true,
|
||||
'mediaAspectRatio' => 0.8,
|
||||
'mediaFillsContainerWidth' => true,
|
||||
'mediaMinHeight' => true,
|
||||
'horizontalOverflow' => false,
|
||||
]);
|
||||
});
|
||||
|
||||
it('stacks the home hero media after its content as a full-width four-by-five crop on mobile', function (): void {
|
||||
$page = $this->visit('/', [
|
||||
'reducedMotion' => 'reduce',
|
||||
])->resize(390, 844);
|
||||
$page->resize(390, 844);
|
||||
$page->script($waitFrames);
|
||||
|
||||
$page->script('() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))');
|
||||
|
||||
$layout = $page->script(<<<'JS'
|
||||
$mobile = $page->script(<<<'JS'
|
||||
() => {
|
||||
const hero = document.querySelector('[data-chapter="hero"]');
|
||||
const content = hero?.querySelector('[data-hero-content]');
|
||||
@@ -125,27 +96,27 @@ it('stacks the home hero media after its content as a full-width four-by-five cr
|
||||
return {
|
||||
hasContentColumn: Boolean(content),
|
||||
mediaFollowsContent: (mediaRect?.top ?? 0) >= (contentRect?.bottom ?? Number.POSITIVE_INFINITY) - 1,
|
||||
mediaFillsViewportWidth: Math.abs((mediaRect?.width ?? 0) - window.innerWidth) <= 1,
|
||||
mediaAspectRatio: Number(((mediaRect?.width ?? 0) / (mediaRect?.height ?? 1)).toFixed(3)),
|
||||
mediaFillsContainerWidth: (mediaRect?.width ?? 0) > window.innerWidth - 100,
|
||||
horizontalOverflow: document.documentElement.scrollWidth > window.innerWidth,
|
||||
};
|
||||
}
|
||||
JS);
|
||||
|
||||
expect($layout)->toBe([
|
||||
expect($mobile)->toBe([
|
||||
'hasContentColumn' => true,
|
||||
'mediaFollowsContent' => true,
|
||||
'mediaFillsViewportWidth' => true,
|
||||
'mediaAspectRatio' => 0.8,
|
||||
'mediaFillsContainerWidth' => true,
|
||||
'horizontalOverflow' => false,
|
||||
]);
|
||||
});
|
||||
|
||||
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]) {
|
||||
$page = $this->visit('/', [
|
||||
'reducedMotion' => 'reduce',
|
||||
])->resize($width, $height);
|
||||
$page->resize($width, $height);
|
||||
|
||||
$page->assertNoAccessibilityIssues(1);
|
||||
|
||||
|
||||
@@ -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('/', [
|
||||
'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 finalReveals = [...document.querySelectorAll('[data-reveal]')]
|
||||
.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 {
|
||||
heroActive: hero?.classList.contains('is-active') ?? false,
|
||||
titleOpacity: titleStyle ? Number.parseFloat(titleStyle.opacity) : 0,
|
||||
hasIndex: Boolean(index),
|
||||
hasActiveChapter: Boolean(active),
|
||||
hasIndex: Boolean(index),
|
||||
hasActiveChapter: Boolean(active),
|
||||
finalReveals,
|
||||
enhancementEnabled: document.documentElement.hasAttribute('data-motion'),
|
||||
reset: flow ? getComputedStyle(flow).counterReset : '',
|
||||
increments: chapters.map((chapter) => getComputedStyle(chapter).counterIncrement),
|
||||
folioCount: folios.length,
|
||||
};
|
||||
}
|
||||
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['finalReveals'])->toBeTrue();
|
||||
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('/', [
|
||||
'reducedMotion' => 'no-preference',
|
||||
]);
|
||||
@@ -77,45 +93,39 @@ it('activates the home opening with 500ms beats and capped 90ms stagger', functi
|
||||
})
|
||||
JS);
|
||||
|
||||
$state = $page->script(<<<'JS'
|
||||
$opening = $page->script(<<<'JS'
|
||||
() => {
|
||||
const hero = document.querySelector('[data-motion="page-open"]');
|
||||
const beatDurations = [...hero.querySelectorAll('[data-motion-beat]')]
|
||||
.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 {
|
||||
active: hero?.classList.contains('is-active') ?? false,
|
||||
beatDurations,
|
||||
methodIndexes: methodItems.map((item) => item.style.getPropertyValue('--motion-index')),
|
||||
vertentesIndexes: vertentesItems.map((item) => item.style.getPropertyValue('--motion-index')),
|
||||
};
|
||||
}
|
||||
JS);
|
||||
|
||||
expect($state['active'])->toBeTrue();
|
||||
expect($state['beatDurations'])->each->toBe(0.5);
|
||||
expect($state['methodIndexes'])->toBe(['0', '1', '2', '3', '3']);
|
||||
});
|
||||
expect($opening['active'])->toBeTrue();
|
||||
expect($opening['beatDurations'])->each->toBe(0.5);
|
||||
expect($opening['vertentesIndexes'])->toBe(['0', '1', '2']);
|
||||
|
||||
it('reveals a section once and keeps it final after returning in the scroll', function (): void {
|
||||
$page = $this->visit('/', [
|
||||
'reducedMotion' => 'no-preference',
|
||||
]);
|
||||
|
||||
$before = $page->script(<<<'JS'
|
||||
() => document.querySelector('#final-cta-heading')?.closest('[data-reveal]')?.classList.contains('is-revealed') ?? false
|
||||
$beforeReveal = $page->script(<<<'JS'
|
||||
() => document.querySelector('#briefing-heading')?.closest('[data-reveal]')?.classList.contains('is-revealed') ?? false
|
||||
JS);
|
||||
|
||||
$page->script(<<<'JS'
|
||||
() => {
|
||||
document.querySelector('#final-cta-heading')?.scrollIntoView({ block: 'center' });
|
||||
document.querySelector('#briefing-heading')?.scrollIntoView({ block: 'center' });
|
||||
return true;
|
||||
}
|
||||
JS);
|
||||
|
||||
$page->script(<<<'JS'
|
||||
() => new Promise((resolve) => {
|
||||
const target = document.querySelector('#final-cta-heading')?.closest('[data-reveal]');
|
||||
const target = document.querySelector('#briefing-heading')?.closest('[data-reveal]');
|
||||
const wait = () => {
|
||||
if (target?.classList.contains('is-revealed')) {
|
||||
resolve(true);
|
||||
@@ -124,67 +134,44 @@ it('reveals a section once and keeps it final after returning in the scroll', fu
|
||||
requestAnimationFrame(wait);
|
||||
};
|
||||
wait();
|
||||
setTimeout(() => resolve(false), 2000);
|
||||
})
|
||||
JS);
|
||||
|
||||
$page->script('() => window.scrollTo({ top: 0, behavior: "instant" })');
|
||||
|
||||
$after = $page->script(<<<'JS'
|
||||
() => document.querySelector('#final-cta-heading')?.closest('[data-reveal]')?.classList.contains('is-revealed') ?? false
|
||||
$afterReveal = $page->script(<<<'JS'
|
||||
() => document.querySelector('#briefing-heading')?.closest('[data-reveal]')?.classList.contains('is-revealed') ?? false
|
||||
JS);
|
||||
|
||||
expect($before)->toBeFalse();
|
||||
expect($after)->toBeTrue();
|
||||
});
|
||||
expect($beforeReveal)->toBeFalse();
|
||||
expect($afterReveal)->toBeTrue();
|
||||
|
||||
it('preserves testimonial directions with reduced lateral distance on mobile', function (): void {
|
||||
Testimonial::factory()->published()->create([
|
||||
'quote' => 'Primeiro relato.',
|
||||
'sort_order' => 10,
|
||||
]);
|
||||
Testimonial::factory()->published()->create([
|
||||
'quote' => 'Segundo relato.',
|
||||
'sort_order' => 20,
|
||||
]);
|
||||
$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 = $this->visit('/', [
|
||||
'reducedMotion' => 'no-preference',
|
||||
]);
|
||||
$page->click('[data-testid="home-primary-cta"]');
|
||||
|
||||
$readDistances = <<<'JS'
|
||||
() => [...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'],
|
||||
]);
|
||||
expect($page->script('() => window.location.hash'))->toBe('#sobre');
|
||||
});
|
||||
|
||||
it('keeps content and navigation usable without javascript', function (): void {
|
||||
$this->visit('/', [
|
||||
$page = $this->visit('/', [
|
||||
'javaScriptEnabled' => false,
|
||||
])
|
||||
]);
|
||||
|
||||
$page
|
||||
->assertVisible('#hero-heading')
|
||||
->assertVisible('[data-testid="home-primary-cta"]')
|
||||
->click('[data-testid="home-primary-cta"]')
|
||||
->assertPathIs('/contato');
|
||||
->click('[data-testid="home-primary-cta"]');
|
||||
|
||||
expect($page->script('() => window.location.hash'))->toBe('#sobre');
|
||||
});
|
||||
|
||||
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 {
|
||||
$case = PortfolioCase::factory()->published()->create([
|
||||
'slug' => 'casamento-jardim',
|
||||
@@ -242,15 +211,24 @@ it('avoids horizontal overflow on visual public routes at desktop and mobile', f
|
||||
'/portfolio/'.$case->slug,
|
||||
'/sobre',
|
||||
'/contato',
|
||||
'/briefing',
|
||||
'/privacidade',
|
||||
'/__missing-public-motion',
|
||||
];
|
||||
|
||||
foreach ([[1440, 1000], [390, 844]] as [$width, $height]) {
|
||||
foreach ($routes as $route) {
|
||||
$page = $this->visit($route, [
|
||||
'reducedMotion' => 'no-preference',
|
||||
])->resize($width, $height);
|
||||
$viewports = [[1440, 1000], [390, 844]];
|
||||
|
||||
$page = $this->visit($routes[0], [
|
||||
'reducedMotion' => 'no-preference',
|
||||
]);
|
||||
|
||||
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');
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -8,11 +8,11 @@ it('renders the public home page', function (): void {
|
||||
$settings = SiteSetting::instance();
|
||||
|
||||
$this->visit('/')
|
||||
->assertSee($settings->hero_title)
|
||||
->assertSee('Eventos com intenção, cuidado e presença.')
|
||||
->assertSee($settings->brand_name);
|
||||
});
|
||||
|
||||
it('renders the admin login page', function (): void {
|
||||
$this->visit('/admin/login')
|
||||
->assertSee('E-mail');
|
||||
->assertVisible('input[type="email"]');
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Models\PortfolioCase;
|
||||
use App\Models\Service;
|
||||
use App\Models\SiteSetting;
|
||||
use App\Models\Testimonial;
|
||||
use App\Models\WeddingPackage;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
@@ -33,6 +34,10 @@ class GetHomeContentTest extends TestCase
|
||||
$testimonialB = Testimonial::factory()->published()->create(['author_name' => 'Author B', 'sort_order' => 20]);
|
||||
$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)();
|
||||
|
||||
$this->assertTrue($content->settings->is($settings));
|
||||
@@ -46,5 +51,8 @@ class GetHomeContentTest extends TestCase
|
||||
$this->assertCount(2, $content->testimonials);
|
||||
$this->assertTrue($content->testimonials->get(0)?->is($testimonialA));
|
||||
$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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,11 +75,11 @@ class PasswordResetRequestTest extends TestCase
|
||||
// `request()` resets the form (see the base page's `$this->form->fill()`).
|
||||
$page->set('data.email', $user->email)
|
||||
->call('request')
|
||||
->assertNotified(Password::RESET_LINK_SENT);
|
||||
->assertNotified($this->expectedSentNotification());
|
||||
|
||||
$page->set('data.email', $user->email)
|
||||
->call('request')
|
||||
->assertNotified(Password::RESET_LINK_SENT);
|
||||
->assertNotified($this->expectedSentNotification());
|
||||
}
|
||||
|
||||
private function expectedSentNotification(): FilamentNotification
|
||||
|
||||
@@ -20,7 +20,7 @@ class HomePageTest extends TestCase
|
||||
|
||||
$response
|
||||
->assertOk()
|
||||
->assertSee($settings->hero_title)
|
||||
->assertSee('Eventos com intenção, cuidado e presença.')
|
||||
->assertSee($settings->brand_name);
|
||||
}
|
||||
}
|
||||
|
||||
40
tests/Feature/Marketing/WeddingPackageTest.php
Normal file
40
tests/Feature/Marketing/WeddingPackageTest.php
Normal 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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -56,11 +56,11 @@ class ContactBriefingOriginTest extends TestCase
|
||||
SiteSetting::instance();
|
||||
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();
|
||||
|
||||
$this->post(route('contact.store'), $this->validPayload())
|
||||
->assertRedirect(route('contact'))
|
||||
$this->post(route('briefing.store'), $this->validPayload())
|
||||
->assertRedirect(route('briefing'))
|
||||
->assertSessionHas('status', 'briefing-sent');
|
||||
|
||||
Mail::assertQueued(ContactBriefing::class, function (ContactBriefing $mail): bool {
|
||||
@@ -76,10 +76,10 @@ class ContactBriefingOriginTest extends TestCase
|
||||
Mail::fake();
|
||||
|
||||
// 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
|
||||
->assertRedirect(route('contact'))
|
||||
->assertRedirect(route('briefing'))
|
||||
->assertSessionHas('status', 'briefing-sent');
|
||||
|
||||
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
|
||||
// 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')
|
||||
->get('/contato')
|
||||
->get('/briefing')
|
||||
->assertOk();
|
||||
|
||||
$this->post(route('contact.store'), $this->validPayload())
|
||||
->assertRedirect(route('contact'))
|
||||
$this->post(route('briefing.store'), $this->validPayload())
|
||||
->assertRedirect(route('briefing'))
|
||||
->assertSessionHas('status', 'briefing-sent');
|
||||
|
||||
Mail::assertQueued(ContactBriefing::class, function (ContactBriefing $mail): bool {
|
||||
@@ -114,15 +114,15 @@ class ContactBriefingOriginTest extends TestCase
|
||||
SiteSetting::instance();
|
||||
Mail::fake();
|
||||
|
||||
$this->get('/contato?utm_source=google&utm_medium=cpc')
|
||||
$this->get('/briefing?utm_source=google&utm_medium=cpc')
|
||||
->assertOk();
|
||||
|
||||
// Navigate elsewhere with no UTM parameters at all before submitting.
|
||||
$this->get('/servicos')->assertOk();
|
||||
$this->get('/sobre')->assertOk();
|
||||
|
||||
$this->post(route('contact.store'), $this->validPayload())
|
||||
->assertRedirect(route('contact'))
|
||||
$this->post(route('briefing.store'), $this->validPayload())
|
||||
->assertRedirect(route('briefing'))
|
||||
->assertSessionHas('status', 'briefing-sent');
|
||||
|
||||
Mail::assertQueued(ContactBriefing::class, function (ContactBriefing $mail): bool {
|
||||
@@ -135,16 +135,16 @@ class ContactBriefingOriginTest extends TestCase
|
||||
SiteSetting::instance();
|
||||
Mail::fake();
|
||||
|
||||
$this->get('/contato?utm_source=google&utm_medium=cpc')
|
||||
$this->get('/briefing?utm_source=google&utm_medium=cpc')
|
||||
->assertOk();
|
||||
|
||||
// A second page load with its own, different UTM parameters must
|
||||
// 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();
|
||||
|
||||
$this->post(route('contact.store'), $this->validPayload())
|
||||
->assertRedirect(route('contact'))
|
||||
$this->post(route('briefing.store'), $this->validPayload())
|
||||
->assertRedirect(route('briefing'))
|
||||
->assertSessionHas('status', 'briefing-sent');
|
||||
|
||||
Mail::assertQueued(ContactBriefing::class, function (ContactBriefing $mail): bool {
|
||||
@@ -157,11 +157,11 @@ class ContactBriefingOriginTest extends TestCase
|
||||
$settings = SiteSetting::instance();
|
||||
Mail::fake();
|
||||
|
||||
$this->get('/contato?utm_source=instagram&utm_campaign=segredo-interno')
|
||||
$this->get('/briefing?utm_source=instagram&utm_campaign=segredo-interno')
|
||||
->assertOk();
|
||||
|
||||
$this->post(route('contact.store'), $this->validPayload())
|
||||
->assertRedirect(route('contact'))
|
||||
$this->post(route('briefing.store'), $this->validPayload())
|
||||
->assertRedirect(route('briefing'))
|
||||
->assertSessionHas('status', 'briefing-sent');
|
||||
|
||||
Mail::assertQueued(ContactBriefingConfirmation::class, function (ContactBriefingConfirmation $mail): bool {
|
||||
@@ -187,10 +187,10 @@ class ContactBriefingOriginTest extends TestCase
|
||||
// the array shape the controller expects. The acceptance
|
||||
// criterion is that this never blocks the submission.
|
||||
$response = $this->withSession([MarketingOrigin::SESSION_KEY => 'lixo'])
|
||||
->post(route('contact.store'), $this->validPayload());
|
||||
->post(route('briefing.store'), $this->validPayload());
|
||||
|
||||
$response
|
||||
->assertRedirect(route('contact'))
|
||||
->assertRedirect(route('briefing'))
|
||||
->assertSessionHas('status', 'briefing-sent');
|
||||
|
||||
Mail::assertQueued(ContactBriefing::class, function (ContactBriefing $mail): bool {
|
||||
@@ -206,13 +206,13 @@ class ContactBriefingOriginTest extends TestCase
|
||||
|
||||
$overlongCampaign = str_repeat('a', 300);
|
||||
|
||||
$this->get('/contato?'.http_build_query([
|
||||
$this->get('/briefing?'.http_build_query([
|
||||
'utm_source' => '<script>alert(1)</script>',
|
||||
'utm_campaign' => $overlongCampaign,
|
||||
]))->assertOk();
|
||||
|
||||
$this->post(route('contact.store'), $this->validPayload())
|
||||
->assertRedirect(route('contact'))
|
||||
$this->post(route('briefing.store'), $this->validPayload())
|
||||
->assertRedirect(route('briefing'))
|
||||
->assertSessionHas('status', 'briefing-sent');
|
||||
|
||||
$capturedOrigin = null;
|
||||
|
||||
@@ -45,16 +45,16 @@ class ContactBriefingTest extends TestCase
|
||||
], $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();
|
||||
|
||||
$response = $this->get(route('contact'));
|
||||
$response = $this->get(route('briefing'));
|
||||
|
||||
$response
|
||||
->assertOk()
|
||||
->assertSee('<form', false)
|
||||
->assertSee('action="'.route('contact.store').'"', false)
|
||||
->assertSee('action="'.route('briefing.store').'"', false)
|
||||
->assertSee('name="nome"', false)
|
||||
->assertSee('name="email"', false)
|
||||
->assertSee('name="telefone"', false)
|
||||
@@ -74,10 +74,10 @@ class ContactBriefingTest extends TestCase
|
||||
$settings = SiteSetting::instance();
|
||||
Mail::fake();
|
||||
|
||||
$response = $this->post(route('contact.store'), $this->validPayload());
|
||||
$response = $this->post(route('briefing.store'), $this->validPayload());
|
||||
|
||||
$response
|
||||
->assertRedirect(route('contact'))
|
||||
->assertRedirect(route('briefing'))
|
||||
->assertSessionHas('status', 'briefing-sent');
|
||||
|
||||
Mail::assertQueued(ContactBriefing::class, function (ContactBriefing $mail) use ($settings): bool {
|
||||
@@ -94,13 +94,13 @@ class ContactBriefingTest extends TestCase
|
||||
$settings = SiteSetting::instance();
|
||||
Mail::fake();
|
||||
|
||||
$response = $this->post(route('contact.store'), $this->validPayload([
|
||||
$response = $this->post(route('briefing.store'), $this->validPayload([
|
||||
'email' => ' Maria.Silva@EXAMPLE.com ',
|
||||
'telefone' => '+55 (11) 98888-7777',
|
||||
]));
|
||||
|
||||
$response
|
||||
->assertRedirect(route('contact'))
|
||||
->assertRedirect(route('briefing'))
|
||||
->assertSessionHas('status', 'briefing-sent');
|
||||
|
||||
Mail::assertQueued(ContactBriefing::class, function (ContactBriefing $mail) use ($settings): bool {
|
||||
@@ -119,12 +119,12 @@ class ContactBriefingTest extends TestCase
|
||||
SiteSetting::instance();
|
||||
Mail::fake();
|
||||
|
||||
$response = $this->post(route('contact.store'), $this->validPayload([
|
||||
$response = $this->post(route('briefing.store'), $this->validPayload([
|
||||
'empresa' => 'http://spam.example',
|
||||
]));
|
||||
|
||||
$response
|
||||
->assertRedirect(route('contact'))
|
||||
->assertRedirect(route('briefing'))
|
||||
->assertSessionHas('status', 'briefing-sent');
|
||||
|
||||
Mail::assertNothingSent();
|
||||
@@ -135,11 +135,11 @@ class ContactBriefingTest extends TestCase
|
||||
SiteSetting::instance();
|
||||
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' => '',
|
||||
]));
|
||||
|
||||
$response->assertRedirect(route('contact'));
|
||||
$response->assertRedirect(route('briefing'));
|
||||
$response->assertSessionHasErrors('privacidade');
|
||||
|
||||
Mail::assertNothingSent();
|
||||
@@ -150,9 +150,9 @@ class ContactBriefingTest extends TestCase
|
||||
SiteSetting::instance();
|
||||
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) {
|
||||
$response->assertSessionHasErrors($field);
|
||||
@@ -168,8 +168,8 @@ class ContactBriefingTest extends TestCase
|
||||
|
||||
$payload = $this->validPayload();
|
||||
|
||||
$this->post(route('contact.store'), $payload)->assertRedirect(route('contact'));
|
||||
$this->post(route('contact.store'), $payload)->assertRedirect(route('contact'));
|
||||
$this->post(route('briefing.store'), $payload)->assertRedirect(route('briefing'));
|
||||
$this->post(route('briefing.store'), $payload)->assertRedirect(route('briefing'));
|
||||
|
||||
Mail::assertQueued(ContactBriefing::class, 1);
|
||||
Mail::assertQueued(ContactBriefingConfirmation::class, 1);
|
||||
@@ -180,10 +180,10 @@ class ContactBriefingTest extends TestCase
|
||||
SiteSetting::instance();
|
||||
config(['mail.default' => 'mailer-inexistente']);
|
||||
|
||||
$response = $this->post(route('contact.store'), $this->validPayload());
|
||||
$response = $this->post(route('briefing.store'), $this->validPayload());
|
||||
|
||||
$response
|
||||
->assertRedirect(route('contact'))
|
||||
->assertRedirect(route('briefing'))
|
||||
->assertSessionHas('status', 'briefing-sent');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,9 +5,9 @@ declare(strict_types=1);
|
||||
namespace Tests\Feature\PublicSite;
|
||||
|
||||
use App\Models\PortfolioCase;
|
||||
use App\Models\Service;
|
||||
use App\Models\SiteSetting;
|
||||
use App\Models\Testimonial;
|
||||
use App\Models\WeddingPackage;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
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
|
||||
{
|
||||
$settings = SiteSetting::instance();
|
||||
$settings->update([
|
||||
'hero_title' => 'Celebrações com propósito',
|
||||
'hero_cta_label' => 'Solicitar orçamento',
|
||||
'manifesto_title' => 'Manifesto Amare',
|
||||
]);
|
||||
SiteSetting::instance();
|
||||
|
||||
Service::factory()->create([
|
||||
'title' => 'Serviço Rascunho',
|
||||
'is_featured' => true,
|
||||
WeddingPackage::factory()->create([
|
||||
'name' => 'Modalidade Rascunho',
|
||||
'published_at' => null,
|
||||
]);
|
||||
Service::factory()->published()->featured()->create([
|
||||
'title' => 'Serviço B',
|
||||
WeddingPackage::factory()->published()->create([
|
||||
'name' => 'Modalidade B',
|
||||
'sort_order' => 20,
|
||||
]);
|
||||
Service::factory()->published()->featured()->create([
|
||||
'title' => 'Serviço A',
|
||||
WeddingPackage::factory()->published()->create([
|
||||
'name' => 'Modalidade A',
|
||||
'sort_order' => 10,
|
||||
]);
|
||||
|
||||
@@ -73,60 +67,68 @@ class HomePageContentTest extends TestCase
|
||||
|
||||
$response
|
||||
->assertOk()
|
||||
->assertSee('Celebrações com propósito')
|
||||
->assertSeeInOrder([
|
||||
'id="hero-heading"',
|
||||
'id="manifesto-heading"',
|
||||
'id="services-heading"',
|
||||
'id="vertentes-heading"',
|
||||
'id="packages-heading"',
|
||||
'id="corporate-heading"',
|
||||
'id="portfolio-heading"',
|
||||
'id="method-heading"',
|
||||
'id="testimonials-heading"',
|
||||
'id="positioning-heading"',
|
||||
'id="final-cta-heading"',
|
||||
'id="briefing-heading"',
|
||||
], false)
|
||||
->assertSeeInOrder(['Serviço A', 'Serviço B'])
|
||||
->assertSeeInOrder(['Modalidade A', 'Modalidade B'])
|
||||
->assertSeeInOrder(['Caso A', 'Caso B'])
|
||||
->assertSeeInOrder(['Autor A', 'Autor B'])
|
||||
->assertDontSee('Serviço Rascunho')
|
||||
->assertDontSee('Modalidade Rascunho')
|
||||
->assertDontSee('Caso Rascunho')
|
||||
->assertDontSee('Autor Rascunho')
|
||||
->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
|
||||
->assertOk()
|
||||
->assertDontSee('id="services-heading"', false)
|
||||
->assertDontSee('id="portfolio-heading"', false)
|
||||
->assertDontSee('id="testimonials-heading"', false)
|
||||
->assertSee('id="manifesto-heading"', false)
|
||||
->assertSee('id="method-heading"', false)
|
||||
->assertSee('id="positioning-heading"', false)
|
||||
->assertSee('id="final-cta-heading"', false);
|
||||
->assertSeeInOrder([
|
||||
'id="hero-heading"',
|
||||
'id="manifesto-heading"',
|
||||
'id="vertentes-heading"',
|
||||
'id="packages-heading"',
|
||||
'id="corporate-heading"',
|
||||
'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([
|
||||
'hero_title' => '',
|
||||
'hero_cta_label' => '',
|
||||
'hero_secondary_cta_label' => '',
|
||||
'hero_subtitle' => '',
|
||||
'hero_note' => '',
|
||||
'hero_image_path' => null,
|
||||
]);
|
||||
|
||||
$response = $this->get(route('home'));
|
||||
|
||||
$response
|
||||
->assertOk()
|
||||
->assertSeeInOrder(['id="hero-heading"', 'Celebrações com propósito'])
|
||||
->assertSeeInOrder(['data-testid="home-primary-cta"', 'Solicitar proposta']);
|
||||
->assertSee('Eventos com intenção, cuidado e presença.')
|
||||
->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
|
||||
@@ -158,13 +160,115 @@ class HomePageContentTest extends TestCase
|
||||
|
||||
$this->get(route('home'))
|
||||
->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('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([
|
||||
'author_name' => 'Autor Mantido',
|
||||
@@ -182,44 +286,34 @@ class HomePageContentTest extends TestCase
|
||||
->assertSee('Autor Mantido')
|
||||
->assertSee('Experiência impecável do início ao fim.')
|
||||
->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([
|
||||
'author_name' => 'Primeira autora',
|
||||
'quote' => 'Primeiro relato.',
|
||||
'sort_order' => 10,
|
||||
]);
|
||||
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,
|
||||
]);
|
||||
foreach ([10, 20, 30, 40] as $index => $sortOrder) {
|
||||
Testimonial::factory()->published()->create([
|
||||
'author_name' => 'Autora '.($index + 1),
|
||||
'quote' => 'Relato '.($index + 1).'.',
|
||||
'sort_order' => $sortOrder,
|
||||
]);
|
||||
}
|
||||
|
||||
$response = $this->get(route('home'));
|
||||
|
||||
$response
|
||||
->assertOk()
|
||||
->assertDontSee('Relato vazio')
|
||||
->assertSeeInOrder([
|
||||
'data-reveal-from="left"',
|
||||
'Primeira autora',
|
||||
'data-reveal-from="right"',
|
||||
'Segunda autora',
|
||||
], false);
|
||||
->assertSeeInOrder(['Relato 1.', 'Relato 2.', 'Relato 3.'])
|
||||
->assertDontSee('Relato 4.');
|
||||
|
||||
$this->assertSame(1, preg_match_all('/<blockquote\b[^>]*data-reveal-from="left"/i', $response->getContent()));
|
||||
$this->assertSame(1, preg_match_all('/<blockquote\b[^>]*data-reveal-from="right"/i', $response->getContent()));
|
||||
$this->assertSame(3, preg_match_all('/<figure\b/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([
|
||||
'author_name' => 'Relato vazio',
|
||||
@@ -228,8 +322,8 @@ class HomePageContentTest extends TestCase
|
||||
|
||||
$this->get(route('home'))
|
||||
->assertOk()
|
||||
->assertDontSee('id="testimonials-heading"', false)
|
||||
->assertDontSee('href="#testimonials-heading"', false)
|
||||
->assertSee('id="testimonials-heading"', false)
|
||||
->assertSee('Depoimento real aprovado pela cliente entra aqui.')
|
||||
->assertDontSee('Relato vazio');
|
||||
}
|
||||
|
||||
@@ -237,7 +331,7 @@ class HomePageContentTest extends TestCase
|
||||
{
|
||||
SiteSetting::instance();
|
||||
|
||||
Service::factory()->published()->featured()->create();
|
||||
WeddingPackage::factory()->published()->create();
|
||||
PortfolioCase::factory()->published()->create(['is_featured' => true]);
|
||||
Testimonial::factory()->published()->create(['quote' => 'Relato publicado.']);
|
||||
|
||||
|
||||
@@ -37,20 +37,16 @@ class ImmersivePhotoHeroTest extends TestCase
|
||||
->assertOk()
|
||||
->assertSee('data-chapter="hero"', false)
|
||||
->assertSee('data-motion="page-open"', false)
|
||||
->assertSee('data-photo-hero', false)
|
||||
->assertSee('md:min-h-[520px]', false)
|
||||
->assertSee('data-hero-content', false)
|
||||
->assertSee('data-split-hero', false)
|
||||
->assertSee('data-motion-beat="media"', false)
|
||||
->assertSee('lg:grid-cols-12', false)
|
||||
->assertSee('lg:col-span-5', false)
|
||||
->assertSee('lg:col-span-7', false)
|
||||
->assertSee('max-w-[470px]', false)
|
||||
->assertSee('break-normal', false)
|
||||
->assertSee('max-lg:aspect-[4/5]', false)
|
||||
->assertSee('md:grid-cols-[1.1fr_0.9fr]', false)
|
||||
->assertSee('content/heroes/home.jpg', false)
|
||||
->assertSee('loading="eager"', false)
|
||||
->assertSee('fetchpriority="high"', false)
|
||||
->assertSee('sizes="(max-width: 1023px) 100vw, 58vw"', false)
|
||||
->assertSee('sizes="(max-width: 1023px) 100vw, 45vw"', false)
|
||||
->assertDontSee('data-tonal-hero', false)
|
||||
->assertSee('content="http://localhost/storage/content/og/social.jpg"', false);
|
||||
}
|
||||
|
||||
@@ -79,7 +75,9 @@ class ImmersivePhotoHeroTest extends TestCase
|
||||
->assertSee('data-motion="page-open"', false)
|
||||
->assertSee('id="hero-heading"', 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);
|
||||
}
|
||||
}
|
||||
@@ -105,25 +103,12 @@ class ImmersivePhotoHeroTest extends TestCase
|
||||
$this->get($route)
|
||||
->assertOk()
|
||||
->assertSee('data-photo-hero', false)
|
||||
->assertSee('lg:grid-cols-12', false)
|
||||
->assertSee('lg:col-span-5', false)
|
||||
->assertSee('lg:col-span-7', false)
|
||||
->assertSee('loading="eager"', false)
|
||||
->assertSee('fetchpriority="high"', false)
|
||||
->assertSee('sizes="(max-width: 1023px) 100vw, 58vw"', false);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_contact_and_privacy_remain_sober_tonal_surfaces(): void
|
||||
{
|
||||
foreach ([route('contact'), route('privacy')] as $route) {
|
||||
$this->get($route)
|
||||
->assertOk()
|
||||
->assertDontSee('data-photo-hero', false)
|
||||
->assertDontSee('data-split-hero', false);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_hero_upload_requires_alt_text_only_when_an_image_is_uploaded(): void
|
||||
{
|
||||
Storage::fake('public');
|
||||
|
||||
@@ -36,7 +36,7 @@ class MotionMarkupTest extends TestCase
|
||||
$response
|
||||
->assertOk()
|
||||
->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="media"', false)
|
||||
->assertSee('data-motion-beat="cta"', false)
|
||||
@@ -45,12 +45,12 @@ class MotionMarkupTest extends TestCase
|
||||
->assertDontSee('data-chapter-index', false)
|
||||
->assertDontSee('data-chapter-progress', false)
|
||||
->assertSee('data-chapter="hero"', false)
|
||||
->assertSee('data-chapter="manifesto"', false)
|
||||
->assertSee('data-chapter="sobre"', false)
|
||||
->assertSee('data-reveal', 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();
|
||||
|
||||
@@ -60,9 +60,16 @@ class MotionMarkupTest extends TestCase
|
||||
->assertOk()
|
||||
->assertDontSee('data-chapter-index', false)
|
||||
->assertDontSee('data-chapter-progress', false)
|
||||
->assertDontSee('id="services-heading"', false)
|
||||
->assertDontSee('id="portfolio-heading"', false)
|
||||
->assertDontSee('id="testimonials-heading"', false);
|
||||
->assertSeeInOrder([
|
||||
'id="hero-heading"',
|
||||
'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
|
||||
|
||||
80
tests/Feature/PublicSite/PartnerInquiryTest.php
Normal file
80
tests/Feature/PublicSite/PartnerInquiryTest.php
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -180,15 +180,21 @@ class PublicPagesTest extends TestCase
|
||||
$this->get(route('contact'))
|
||||
->assertOk()
|
||||
->assertSee('amareassessoriaeventos@gmail.com')
|
||||
->assertSee('(11) 90000-0000')
|
||||
->assertSee('São Paulo - SP')
|
||||
->assertDontSee('Fortaleza')
|
||||
->assertSee('https://instagram.com/amare', false)
|
||||
->assertSee('min-h-[calc(100dvh-14rem)]', false)
|
||||
->assertSee('Fornecedores e parcerias')
|
||||
->assertSee('<form', false)
|
||||
->assertSee('name="nome"', false)
|
||||
->assertSee('name="privacidade"', false)
|
||||
->assertSee('name="atuacao"', false)
|
||||
->assertDontSee('name="tipo_evento"', 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
|
||||
|
||||
Reference in New Issue
Block a user